Skip to content

Commit 94fdd36

Browse files
7418claude
andcommitted
feat: assistant workspace V2 — indexing, retrieval, security hardening & workspace switching
Assistant Workspace V2: - Markdown indexing engine (incremental manifest + chunks JSONL) - Keyword retrieval with CJK bigram support and hotset boosting - Directory taxonomy inference and evolution suggestions - Workspace config management (.assistant/config.json) - Organize API: capture, classify, move, archive, suggest-evolution Workspace switching: - Inspect API (GET /api/workspace/inspect) for pre-switch validation - Two-phase switching with confirmation dialogs (5 workspace statuses) - Atomic save: init/reset runs before setSetting(), rollback on failure - Auto-navigate to new/reused session after switch - Mismatch banner in ChatView (only for former assistant sessions) - Debounced path validation with visual indicators Security hardening: - Completion fence (onboarding-complete/checkin-complete) only processes when session.workingDirectory === assistant_workspace_path (frontend) AND session.working_directory === workspacePath (backend double-check) - assertContained() in workspace-organizer: rejects absolute paths, ~/, ../ traversal, and symlink escapes for captureNote/moveFile - organize API validates all path params at entry (validateRelativePath) - captureDefault sanitized to relative path on onboarding write - R_OK + W_OK validation on GET settings, session creation, and PUT Bug fixes: - hookTriggeredSessionId now read before auto-trigger; allows retry if session has no messages (previous trigger failed silently) - workspace-switched event listener matches oldPath (not just != newPath) so normal project chats never show the mismatch banner - Daily check-in card hidden when onboarding not complete - Refresh docs now calls generateRootDocs() + generateDirectoryDocs() - Banner "Open" button reuses latest session (checkin mode) instead of always creating new onboarding session Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 19aba25 commit 94fdd36

File tree

28 files changed

+3851
-177
lines changed

28 files changed

+3851
-177
lines changed

docs/handover/assistant-workspace.md

Lines changed: 195 additions & 25 deletions
Large diffs are not rendered by default.

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codepilot",
3-
"version": "0.26.0",
3+
"version": "0.27.0",
44
"private": true,
55
"author": {
66
"name": "op7418",

src/__tests__/unit/assistant-workspace.test.ts

Lines changed: 309 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
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

1314
import { 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

3440
const { 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

Comments
 (0)