88 * 2. Input focus fallback: hookTriggeredSessionId prevents repeat
99 * 3. Daily check-in: needsDailyCheckIn respects onboarding state
1010 * 4. Workspace prompt scoping: only assistant project sessions get prompts
11+ * 5. V2: Daily memory write/load, v1→v2 migration, budget-aware prompt assembly
1112 */
1213
1314import { describe , it , beforeEach , afterEach } from 'node:test' ;
@@ -29,6 +30,11 @@ const {
2930 loadWorkspaceFiles,
3031 assembleWorkspacePrompt,
3132 generateDirectoryDocs,
33+ ensureDailyDir,
34+ writeDailyMemory,
35+ loadDailyMemories,
36+ migrateStateV1ToV2,
37+ generateRootDocs,
3238} = require ( '../../lib/assistant-workspace' ) as typeof import ( '../../lib/assistant-workspace' ) ;
3339
3440const { createSession, getLatestSessionByWorkingDirectory, closeDb } = require ( '../../lib/db' ) as typeof import ( '../../lib/db' ) ;
@@ -53,7 +59,7 @@ describe('Assistant Workspace', () => {
5359 const state = JSON . parse ( fs . readFileSync ( statePath , 'utf-8' ) ) ;
5460 assert . equal ( state . onboardingComplete , false ) ;
5561 assert . equal ( state . lastCheckInDate , null ) ;
56- assert . equal ( state . schemaVersion , 1 ) ;
62+ assert . equal ( state . schemaVersion , 2 ) ;
5763 } ) ;
5864
5965 it ( 'should create all 4 template files' , ( ) => {
@@ -63,6 +69,12 @@ describe('Assistant Workspace', () => {
6369 assert . ok ( fs . existsSync ( path . join ( workDir , 'user.md' ) ) ) ;
6470 assert . ok ( fs . existsSync ( path . join ( workDir , 'memory.md' ) ) ) ;
6571 } ) ;
72+
73+ it ( 'should create V2 directories (memory/daily, Inbox)' , ( ) => {
74+ initializeWorkspace ( workDir ) ;
75+ assert . ok ( fs . existsSync ( path . join ( workDir , 'memory' , 'daily' ) ) ) ;
76+ assert . ok ( fs . existsSync ( path . join ( workDir , 'Inbox' ) ) ) ;
77+ } ) ;
6678 } ) ;
6779
6880 describe ( 'onboarding auto-trigger detection' , ( ) => {
@@ -111,26 +123,24 @@ describe('Assistant Workspace', () => {
111123
112124 describe ( 'daily check-in respects onboarding state' , ( ) => {
113125 it ( 'should not trigger check-in if onboarding not complete' , ( ) => {
114- const state = { onboardingComplete : false , lastCheckInDate : null , schemaVersion : 1 } ;
126+ const state = { onboardingComplete : false , lastCheckInDate : null , schemaVersion : 2 } ;
115127 assert . equal ( needsDailyCheckIn ( state ) , false ) ;
116128 } ) ;
117129
118130 it ( 'should trigger check-in if onboarding done and no check-in today' , ( ) => {
119- const state = { onboardingComplete : true , lastCheckInDate : '2020-01-01' , schemaVersion : 1 } ;
131+ const state = { onboardingComplete : true , lastCheckInDate : '2020-01-01' , schemaVersion : 2 } ;
120132 assert . equal ( needsDailyCheckIn ( state ) , true ) ;
121133 } ) ;
122134
123135 it ( 'should not trigger check-in if already done today' , ( ) => {
124136 const today = new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) ;
125- const state = { onboardingComplete : true , lastCheckInDate : today , schemaVersion : 1 } ;
137+ const state = { onboardingComplete : true , lastCheckInDate : today , schemaVersion : 2 } ;
126138 assert . equal ( needsDailyCheckIn ( state ) , false ) ;
127139 } ) ;
128140
129141 it ( 'onboarding day should skip daily check-in (lastCheckInDate set)' , ( ) => {
130- // Simulates what happens after onboarding completes:
131- // onboardingComplete=true, lastCheckInDate=today
132142 const today = new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) ;
133- const state = { onboardingComplete : true , lastCheckInDate : today , schemaVersion : 1 } ;
143+ const state = { onboardingComplete : true , lastCheckInDate : today , schemaVersion : 2 } ;
134144 assert . equal ( needsDailyCheckIn ( state ) , false ) ;
135145 } ) ;
136146 } ) ;
@@ -201,6 +211,298 @@ describe('Assistant Workspace', () => {
201211 assert . ok ( pathContent . includes ( 'Path Index' ) ) ;
202212 } ) ;
203213 } ) ;
214+
215+ // ===== V2 Tests =====
216+
217+ describe ( 'daily memory write and load' , ( ) => {
218+ it ( 'should write and read daily memory' , ( ) => {
219+ initializeWorkspace ( workDir ) ;
220+ const today = '2024-03-06' ;
221+ const content = '# Work Log\nDid some coding.' ;
222+
223+ writeDailyMemory ( workDir , today , content ) ;
224+
225+ const dailyPath = path . join ( workDir , 'memory' , 'daily' , `${ today } .md` ) ;
226+ assert . ok ( fs . existsSync ( dailyPath ) , 'Daily memory file should exist' ) ;
227+
228+ const read = fs . readFileSync ( dailyPath , 'utf-8' ) ;
229+ assert . equal ( read , content ) ;
230+ } ) ;
231+
232+ it ( 'should load most recent daily memories' , ( ) => {
233+ initializeWorkspace ( workDir ) ;
234+ writeDailyMemory ( workDir , '2024-03-04' , 'Day 1' ) ;
235+ writeDailyMemory ( workDir , '2024-03-05' , 'Day 2' ) ;
236+ writeDailyMemory ( workDir , '2024-03-06' , 'Day 3' ) ;
237+
238+ const memories = loadDailyMemories ( workDir , 2 ) ;
239+ assert . equal ( memories . length , 2 ) ;
240+ assert . equal ( memories [ 0 ] . date , '2024-03-06' ) ;
241+ assert . equal ( memories [ 1 ] . date , '2024-03-05' ) ;
242+ } ) ;
243+
244+ it ( 'should return empty array for no daily memories' , ( ) => {
245+ initializeWorkspace ( workDir ) ;
246+ const memories = loadDailyMemories ( workDir , 2 ) ;
247+ assert . equal ( memories . length , 0 ) ;
248+ } ) ;
249+ } ) ;
250+
251+ describe ( 'v1 to v2 migration' , ( ) => {
252+ it ( 'should migrate v1 state to v2' , ( ) => {
253+ // Create a v1-style workspace
254+ const stateDir = path . join ( workDir , '.assistant' ) ;
255+ fs . mkdirSync ( stateDir , { recursive : true } ) ;
256+ fs . writeFileSync (
257+ path . join ( stateDir , 'state.json' ) ,
258+ JSON . stringify ( { onboardingComplete : true , lastCheckInDate : '2024-01-01' , schemaVersion : 1 } ) ,
259+ 'utf-8'
260+ ) ;
261+
262+ migrateStateV1ToV2 ( workDir ) ;
263+
264+ const state = loadState ( workDir ) ;
265+ assert . equal ( state . schemaVersion , 2 ) ;
266+ assert . ok ( fs . existsSync ( path . join ( workDir , 'memory' , 'daily' ) ) ) ;
267+ assert . ok ( fs . existsSync ( path . join ( workDir , 'Inbox' ) ) ) ;
268+ } ) ;
269+
270+ it ( 'should not re-migrate v2 state' , ( ) => {
271+ initializeWorkspace ( workDir ) ;
272+ const state = loadState ( workDir ) ;
273+ assert . equal ( state . schemaVersion , 2 ) ;
274+
275+ // Should not throw or change anything
276+ migrateStateV1ToV2 ( workDir ) ;
277+ const reloaded = loadState ( workDir ) ;
278+ assert . equal ( reloaded . schemaVersion , 2 ) ;
279+ } ) ;
280+ } ) ;
281+
282+ describe ( 'budget-aware prompt assembly' , ( ) => {
283+ it ( 'should include daily memories in prompt' , ( ) => {
284+ initializeWorkspace ( workDir ) ;
285+ fs . writeFileSync ( path . join ( workDir , 'soul.md' ) , '# Soul\nI am helpful.' , 'utf-8' ) ;
286+ writeDailyMemory ( workDir , '2024-03-06' , '# Today\nDid coding.' ) ;
287+
288+ const files = loadWorkspaceFiles ( workDir ) ;
289+ const prompt = assembleWorkspacePrompt ( files ) ;
290+
291+ assert . ok ( prompt . includes ( '<assistant-workspace>' ) ) ;
292+ assert . ok ( prompt . includes ( 'I am helpful' ) ) ;
293+ assert . ok ( prompt . includes ( 'Did coding' ) ) ;
294+ } ) ;
295+
296+ it ( 'should include retrieval results in prompt' , ( ) => {
297+ initializeWorkspace ( workDir ) ;
298+ fs . writeFileSync ( path . join ( workDir , 'soul.md' ) , '# Soul\nI am helpful.' , 'utf-8' ) ;
299+
300+ const files = loadWorkspaceFiles ( workDir ) ;
301+ const results = [
302+ { path : 'notes/test.md' , heading : 'Test' , snippet : 'Some test content' , score : 15 , source : 'title' as const } ,
303+ ] ;
304+ const prompt = assembleWorkspacePrompt ( files , results ) ;
305+
306+ assert . ok ( prompt . includes ( 'retrieval-result' ) ) ;
307+ assert . ok ( prompt . includes ( 'Some test content' ) ) ;
308+ } ) ;
309+
310+ it ( 'should respect total prompt limit' , ( ) => {
311+ initializeWorkspace ( workDir ) ;
312+ // Write large content to test truncation
313+ const largeContent = '# Soul\n' + 'x' . repeat ( 50000 ) ;
314+ fs . writeFileSync ( path . join ( workDir , 'soul.md' ) , largeContent , 'utf-8' ) ;
315+
316+ const files = loadWorkspaceFiles ( workDir ) ;
317+ const prompt = assembleWorkspacePrompt ( files ) ;
318+
319+ assert . ok ( prompt . length <= 45000 , 'Prompt should not exceed budget (with some overhead)' ) ;
320+ } ) ;
321+ } ) ;
322+
323+ describe ( 'root docs generation' , ( ) => {
324+ it ( 'should generate README.ai.md and PATH.ai.md at root' , ( ) => {
325+ initializeWorkspace ( workDir ) ;
326+ // Create some subdirs
327+ fs . mkdirSync ( path . join ( workDir , 'notes' ) ) ;
328+ fs . mkdirSync ( path . join ( workDir , 'projects' ) ) ;
329+
330+ const generated = generateRootDocs ( workDir ) ;
331+ assert . ok ( generated . length >= 2 ) ;
332+ assert . ok ( fs . existsSync ( path . join ( workDir , 'README.ai.md' ) ) ) ;
333+ assert . ok ( fs . existsSync ( path . join ( workDir , 'PATH.ai.md' ) ) ) ;
334+ } ) ;
335+
336+ it ( 'should include root docs in loaded files' , ( ) => {
337+ initializeWorkspace ( workDir ) ;
338+ fs . mkdirSync ( path . join ( workDir , 'notes' ) ) ;
339+ generateRootDocs ( workDir ) ;
340+
341+ const files = loadWorkspaceFiles ( workDir ) ;
342+ assert . ok ( files . rootReadme , 'Should have rootReadme' ) ;
343+ assert . ok ( files . rootPath , 'Should have rootPath' ) ;
344+ } ) ;
345+ } ) ;
346+ } ) ;
347+
348+ // ---------------------------------------------------------------------------
349+ // Tests for review fixes
350+ // ---------------------------------------------------------------------------
351+
352+ describe ( 'parseQuery CJK support' , ( ) => {
353+ const { parseQuery } = require ( '../../lib/workspace-retrieval' ) as typeof import ( '../../lib/workspace-retrieval' ) ;
354+
355+ it ( 'should tokenize Chinese text into bigrams and unigrams' , ( ) => {
356+ const tokens = parseQuery ( '项目排期' ) ;
357+ // Should contain individual chars and bigrams
358+ assert . ok ( tokens . includes ( '项' ) , 'should include unigram 项' ) ;
359+ assert . ok ( tokens . includes ( '目' ) , 'should include unigram 目' ) ;
360+ assert . ok ( tokens . includes ( '项目' ) , 'should include bigram 项目' ) ;
361+ assert . ok ( tokens . includes ( '排期' ) , 'should include bigram 排期' ) ;
362+ } ) ;
363+
364+ it ( 'should handle mixed Chinese and English' , ( ) => {
365+ const tokens = parseQuery ( '下周project排期' ) ;
366+ assert . ok ( tokens . includes ( '下周' ) , 'should include CJK bigram' ) ;
367+ assert . ok ( tokens . includes ( 'project' ) , 'should include English word' ) ;
368+ assert . ok ( tokens . includes ( '排期' ) , 'should include CJK bigram' ) ;
369+ } ) ;
370+
371+ it ( 'should still filter English stop words' , ( ) => {
372+ const tokens = parseQuery ( 'the project is good' ) ;
373+ assert . ok ( ! tokens . includes ( 'the' ) ) ;
374+ assert . ok ( ! tokens . includes ( 'is' ) ) ;
375+ assert . ok ( tokens . includes ( 'project' ) ) ;
376+ assert . ok ( tokens . includes ( 'good' ) ) ;
377+ } ) ;
378+ } ) ;
379+
380+ describe ( 'incremental indexWorkspace' , ( ) => {
381+ const { indexWorkspace, loadManifest } = require ( '../../lib/workspace-indexer' ) as typeof import ( '../../lib/workspace-indexer' ) ;
382+ let wsDir : string ;
383+
384+ beforeEach ( ( ) => {
385+ wsDir = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , 'idx-test-' ) ) ;
386+ fs . writeFileSync ( path . join ( wsDir , 'note1.md' ) , '# Note 1\nHello' , 'utf-8' ) ;
387+ fs . writeFileSync ( path . join ( wsDir , 'note2.md' ) , '# Note 2\nWorld' , 'utf-8' ) ;
388+ } ) ;
389+
390+ afterEach ( ( ) => {
391+ fs . rmSync ( wsDir , { recursive : true , force : true } ) ;
392+ } ) ;
393+
394+ it ( 'should skip unchanged files on second run' , ( ) => {
395+ const first = indexWorkspace ( wsDir ) ;
396+ assert . equal ( first . fileCount , 2 ) ;
397+
398+ // Second run: nothing changed, should reuse existing
399+ const second = indexWorkspace ( wsDir ) ;
400+ assert . equal ( second . fileCount , 2 ) ;
401+
402+ // Manifest should be identical
403+ const manifest = loadManifest ( wsDir ) ;
404+ assert . equal ( manifest . length , 2 ) ;
405+ } ) ;
406+
407+ it ( 'should re-index only modified files' , ( ) => {
408+ indexWorkspace ( wsDir ) ;
409+ const manifestBefore = loadManifest ( wsDir ) ;
410+ const note1Before = manifestBefore . find ( m => m . path === 'note1.md' ) ! ;
411+
412+ // Modify note1 (ensure mtime changes)
413+ const futureTime = Date . now ( ) + 1000 ;
414+ fs . writeFileSync ( path . join ( wsDir , 'note1.md' ) , '# Note 1 Updated\nChanged content' , 'utf-8' ) ;
415+ fs . utimesSync ( path . join ( wsDir , 'note1.md' ) , new Date ( futureTime ) , new Date ( futureTime ) ) ;
416+
417+ indexWorkspace ( wsDir ) ;
418+ const manifestAfter = loadManifest ( wsDir ) ;
419+
420+ const note1After = manifestAfter . find ( m => m . path === 'note1.md' ) ! ;
421+ const note2After = manifestAfter . find ( m => m . path === 'note2.md' ) ! ;
422+ const note2Before = manifestBefore . find ( m => m . path === 'note2.md' ) ! ;
423+
424+ // note1 should have changed hash
425+ assert . notEqual ( note1After . hash , note1Before . hash ) ;
426+ // note2 should be unchanged
427+ assert . equal ( note2After . hash , note2Before . hash ) ;
428+ } ) ;
429+
430+ it ( 'force mode should re-index all files' , ( ) => {
431+ indexWorkspace ( wsDir ) ;
432+ const result = indexWorkspace ( wsDir , { force : true } ) ;
433+ assert . equal ( result . fileCount , 2 ) ;
434+ } ) ;
435+ } ) ;
436+
437+ describe ( 'memory.md promotion dedup' , ( ) => {
438+ const { promoteDailyToLongTerm } = require ( '../../lib/workspace-organizer' ) as typeof import ( '../../lib/workspace-organizer' ) ;
439+ let wsDir : string ;
440+
441+ beforeEach ( ( ) => {
442+ wsDir = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , 'promo-test-' ) ) ;
443+ fs . mkdirSync ( path . join ( wsDir , 'memory' , 'daily' ) , { recursive : true } ) ;
444+ fs . writeFileSync ( path . join ( wsDir , 'memory.md' ) , '# Memory\n' , 'utf-8' ) ;
445+ } ) ;
446+
447+ afterEach ( ( ) => {
448+ fs . rmSync ( wsDir , { recursive : true , force : true } ) ;
449+ } ) ;
450+
451+ it ( 'should promote content on first call' , ( ) => {
452+ const dailyContent = '## Work Log\nDid stuff\n\n## Candidate Long-Term Memory\nUser prefers dark mode and uses Vim keybindings daily.\n' ;
453+ fs . writeFileSync ( path . join ( wsDir , 'memory' , 'daily' , '2026-02-20.md' ) , dailyContent , 'utf-8' ) ;
454+
455+ const result = promoteDailyToLongTerm ( wsDir , '2026-02-20' ) ;
456+ assert . equal ( result , true ) ;
457+
458+ const memory = fs . readFileSync ( path . join ( wsDir , 'memory.md' ) , 'utf-8' ) ;
459+ assert . ok ( memory . includes ( 'Promoted from 2026-02-20' ) ) ;
460+ } ) ;
461+
462+ it ( 'should NOT promote same date twice (idempotent)' , ( ) => {
463+ const dailyContent = '## Candidate Long-Term Memory\nUser prefers dark mode and uses Vim keybindings daily.\n' ;
464+ fs . writeFileSync ( path . join ( wsDir , 'memory' , 'daily' , '2026-02-20.md' ) , dailyContent , 'utf-8' ) ;
465+
466+ promoteDailyToLongTerm ( wsDir , '2026-02-20' ) ;
467+ const memoryAfterFirst = fs . readFileSync ( path . join ( wsDir , 'memory.md' ) , 'utf-8' ) ;
468+
469+ // Second call should return false
470+ const result = promoteDailyToLongTerm ( wsDir , '2026-02-20' ) ;
471+ assert . equal ( result , false ) ;
472+
473+ const memoryAfterSecond = fs . readFileSync ( path . join ( wsDir , 'memory.md' ) , 'utf-8' ) ;
474+ assert . equal ( memoryAfterFirst , memoryAfterSecond , 'memory.md should not change on second call' ) ;
475+ } ) ;
476+ } ) ;
477+
478+ describe ( 'hotset boosts search results' , ( ) => {
479+ const { indexWorkspace } = require ( '../../lib/workspace-indexer' ) as typeof import ( '../../lib/workspace-indexer' ) ;
480+ const { searchWorkspace, updateHotset } = require ( '../../lib/workspace-retrieval' ) as typeof import ( '../../lib/workspace-retrieval' ) ;
481+ let wsDir : string ;
482+
483+ beforeEach ( ( ) => {
484+ wsDir = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , 'hotset-test-' ) ) ;
485+ // Two notes with similar relevance to "design"
486+ fs . writeFileSync ( path . join ( wsDir , 'alpha.md' ) , '# Design Notes\nSome design thoughts' , 'utf-8' ) ;
487+ fs . writeFileSync ( path . join ( wsDir , 'beta.md' ) , '# Design Patterns\nSome design patterns' , 'utf-8' ) ;
488+ indexWorkspace ( wsDir , { force : true } ) ;
489+ } ) ;
490+
491+ afterEach ( ( ) => {
492+ fs . rmSync ( wsDir , { recursive : true , force : true } ) ;
493+ } ) ;
494+
495+ it ( 'should boost frequently accessed files in search ranking' , ( ) => {
496+ // Access beta many times to build frequency
497+ for ( let i = 0 ; i < 10 ; i ++ ) {
498+ updateHotset ( wsDir , [ 'beta.md' ] ) ;
499+ }
500+
501+ const results = searchWorkspace ( wsDir , 'design' ) ;
502+ assert . ok ( results . length >= 2 , 'Should find both files' ) ;
503+ // beta.md should be boosted to top due to hotset frequency
504+ assert . equal ( results [ 0 ] . path , 'beta.md' , 'Frequently accessed file should rank higher' ) ;
505+ } ) ;
204506} ) ;
205507
206508// Clean up DB
0 commit comments