Merge pull request #253 from Ark0N/feat/readmymind

feat: Read My Mind phase 1, per-case intent profiles (opt-in)
This commit is contained in:
Ark0N
2026-08-09 18:34:11 +02:00
committed by GitHub
18 changed files with 1070 additions and 4 deletions
+187
View File
@@ -0,0 +1,187 @@
/**
* @fileoverview Unit tests for the Read My Mind intent store (src/intent-store.ts).
*
* Pure helpers (key derivation, capturability filter, sanitization, append fold)
* plus the IO layer against a per-test temp data dir (CODEMAN_DATA_DIR) so
* nothing touches the real ~/.codeman. No server, no tmux.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import fs from 'node:fs/promises';
import { statSync, existsSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
appendPrompt,
deriveIntentKey,
IntentStore,
isCapturablePrompt,
MAX_GOALS_CHARS,
MAX_INTENT_PROFILES,
MAX_PROMPT_CHARS,
MAX_RECENT_PROMPTS,
sanitizePromptText,
} from '../src/intent-store.js';
import type { IntentProfile } from '../src/types/index.js';
let tmpDir: string;
let savedDataDir: string | undefined;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codeman-intents-'));
savedDataDir = process.env.CODEMAN_DATA_DIR;
process.env.CODEMAN_DATA_DIR = tmpDir;
});
afterEach(async () => {
if (savedDataDir === undefined) delete process.env.CODEMAN_DATA_DIR;
else process.env.CODEMAN_DATA_DIR = savedDataDir;
await fs.rm(tmpDir, { recursive: true, force: true });
});
const intentsFile = () => path.join(tmpDir, 'intents.json');
function makeProfile(overrides: Partial<IntentProfile> = {}): IntentProfile {
return { key: 'k', workingDir: '/w', updatedAt: 0, goals: '', recentPrompts: [], ...overrides };
}
describe('deriveIntentKey', () => {
it('is stable and 16 lowercase hex chars', () => {
const a = deriveIntentKey('alice', '/home/alice/proj');
expect(a).toMatch(/^[0-9a-f]{16}$/);
expect(deriveIntentKey('alice', '/home/alice/proj')).toBe(a);
});
it('separates owners and directories', () => {
expect(deriveIntentKey('alice', '/p')).not.toBe(deriveIntentKey('bob', '/p'));
expect(deriveIntentKey('alice', '/p')).not.toBe(deriveIntentKey('alice', '/q'));
expect(deriveIntentKey(undefined, '/p')).not.toBe(deriveIntentKey('alice', '/p'));
});
});
describe('isCapturablePrompt', () => {
it('rejects local command echo and system wrappers', () => {
expect(isCapturablePrompt('<command-name>/model</command-name>')).toBe(false);
expect(isCapturablePrompt('before <local-command-stdout>out</local-command-stdout>')).toBe(false);
expect(isCapturablePrompt('<system-reminder>context</system-reminder>')).toBe(false);
expect(isCapturablePrompt('Caveat: The messages below were generated…')).toBe(false);
expect(isCapturablePrompt('[Request interrupted by user]')).toBe(false);
});
it('accepts a normal prompt', () => {
expect(isCapturablePrompt('fix the login bug and add a test')).toBe(true);
});
});
describe('sanitizePromptText', () => {
it('collapses newlines and strips control chars', () => {
expect(sanitizePromptText('line one\nline two\r\nthree')).toBe('line one line two three');
expect(sanitizePromptText('a\x1b[31mred\x1b[0mb end')).toBe('a[31mred[0mb end');
});
it('returns null for menu-digit noise', () => {
expect(sanitizePromptText('1')).toBeNull();
expect(sanitizePromptText(' \n ')).toBeNull();
});
it('truncates to the cap', () => {
const out = sanitizePromptText('x'.repeat(MAX_PROMPT_CHARS + 100));
expect(out).toHaveLength(MAX_PROMPT_CHARS);
});
});
describe('appendPrompt', () => {
it('collapses consecutive duplicates but keeps non-adjacent ones', () => {
let p = makeProfile();
p = appendPrompt(p, { ts: 1, sessionId: 's', text: 'continue' });
p = appendPrompt(p, { ts: 2, sessionId: 's', text: 'continue' });
expect(p.recentPrompts).toHaveLength(1);
expect(p.updatedAt).toBe(2);
p = appendPrompt(p, { ts: 3, sessionId: 's', text: 'run tests' });
p = appendPrompt(p, { ts: 4, sessionId: 's', text: 'continue' });
expect(p.recentPrompts.map((e) => e.text)).toEqual(['continue', 'run tests', 'continue']);
});
it('FIFO-caps at MAX_RECENT_PROMPTS, dropping the oldest', () => {
let p = makeProfile();
for (let i = 0; i < MAX_RECENT_PROMPTS + 5; i++) {
p = appendPrompt(p, { ts: i, sessionId: 's', text: `prompt number ${i}` });
}
expect(p.recentPrompts).toHaveLength(MAX_RECENT_PROMPTS);
expect(p.recentPrompts[0].text).toBe('prompt number 5');
});
});
describe('IntentStore', () => {
it('records a prompt, persists 0600, and reloads from disk', () => {
const store = new IntentStore();
expect(store.recordPrompt('alice', tmpDir, 'sess1', 'ship the release')).toBe(true);
expect(existsSync(intentsFile())).toBe(true);
expect(statSync(intentsFile()).mode & 0o777).toBe(0o600);
const reloaded = new IntentStore();
const profile = reloaded.getProfile('alice', tmpDir);
expect(profile.recentPrompts.map((e) => e.text)).toEqual(['ship the release']);
expect(profile.updatedAt).toBeGreaterThan(0);
});
it('getProfile on an absent case returns an empty transient profile without persisting', () => {
const store = new IntentStore();
const profile = store.getProfile('alice', tmpDir);
expect(profile.updatedAt).toBe(0);
expect(profile.goals).toBe('');
expect(profile.recentPrompts).toEqual([]);
expect(existsSync(intentsFile())).toBe(false);
});
it('filters uncapturable and too-short prompts', () => {
const store = new IntentStore();
expect(store.recordPrompt('a', tmpDir, 's', '<command-name>/clear</command-name>')).toBe(false);
expect(store.recordPrompt('a', tmpDir, 's', '2')).toBe(false);
expect(existsSync(intentsFile())).toBe(false);
});
it('keys by resolved directory so path spellings converge', () => {
const store = new IntentStore();
store.recordPrompt('a', `${tmpDir}${path.sep}.`, 's', 'same case either way');
const profile = store.getProfile('a', tmpDir);
expect(profile.recentPrompts).toHaveLength(1);
});
it('separates owners of the same directory', () => {
const store = new IntentStore();
store.recordPrompt('alice', tmpDir, 's', 'alice private plan');
expect(store.getProfile('bob', tmpDir).recentPrompts).toEqual([]);
});
it('setGoals bounds the text and deleteProfile forgets the case', () => {
const store = new IntentStore();
const updated = store.setGoals('a', tmpDir, 'g'.repeat(MAX_GOALS_CHARS + 50));
expect(updated.goals).toHaveLength(MAX_GOALS_CHARS);
expect(store.deleteProfile('a', tmpDir)).toBe(true);
expect(store.deleteProfile('a', tmpDir)).toBe(false);
expect(store.getProfile('a', tmpDir).goals).toBe('');
});
it('evicts the least-recently-updated profile past the cap', () => {
const store = new IntentStore();
for (let i = 0; i <= MAX_INTENT_PROFILES; i++) {
store.setGoals('a', `${tmpDir}/case-${i}`, `goal ${i}`);
}
const reloaded = new IntentStore();
expect(reloaded.getProfile('a', `${tmpDir}/case-0`).goals).toBe('');
expect(reloaded.getProfile('a', `${tmpDir}/case-${MAX_INTENT_PROFILES}`).goals).toBe(`goal ${MAX_INTENT_PROFILES}`);
});
it('starts empty on a corrupted state file', () => {
const store = new IntentStore();
store.setGoals('a', tmpDir, 'valid');
return fs.writeFile(intentsFile(), '{ not json').then(() => {
const reloaded = new IntentStore();
expect(reloaded.getProfile('a', tmpDir).goals).toBe('');
expect(reloaded.recordPrompt('a', tmpDir, 's', 'recover cleanly')).toBe(true);
});
});
});
+148
View File
@@ -0,0 +1,148 @@
/**
* @fileoverview Read My Mind intent route tests (src/web/routes/readmymind-routes.ts)
* via app.inject(), no live port.
*
* The routes read the process-wide `intentStore` singleton, whose data file
* resolves under this test file's temp HOME (test/setup.ts). The singleton's
* in-memory map lives for the whole file, so each test uses a distinct
* session workingDir to stay isolated.
*
* Port: SessionPort.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { registerReadMyMindRoutes } from '../../src/web/routes/readmymind-routes.js';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
const SESSION_ID = 'test-session-1';
let harness: RouteTestHarness;
let caseCounter = 0;
beforeEach(async () => {
harness = await createRouteTestHarness(registerReadMyMindRoutes);
// Unique (nonexistent) workingDir per test: resolveDir falls back to the raw
// string, so the key is stable and no other test's profile bleeds in.
caseCounter++;
sessionUnderTest().workingDir = `/nonexistent/readmymind-case-${caseCounter}`;
});
afterEach(async () => {
await harness.app.close();
});
function sessionUnderTest(): { workingDir: string; owner?: string } {
return harness.ctx.sessions.get(SESSION_ID) as unknown as { workingDir: string; owner?: string };
}
describe('GET /api/sessions/:id/intent', () => {
it('returns an empty transient profile for a fresh case', async () => {
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/intent` });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data.intent.goals).toBe('');
expect(body.data.intent.recentPrompts).toEqual([]);
expect(body.data.intent.updatedAt).toBe(0);
});
it('404s an unknown session id', async () => {
const res = await harness.app.inject({ method: 'GET', url: '/api/sessions/nope/intent' });
expect(res.statusCode).toBe(404);
expect(res.json().success).toBe(false);
});
});
describe('PUT /api/sessions/:id/intent', () => {
it('round-trips goals through the store', async () => {
const put = await harness.app.inject({
method: 'PUT',
url: `/api/sessions/${SESSION_ID}/intent`,
payload: { goals: 'ship 1.17 with the readmymind phase 1' },
});
expect(put.statusCode).toBe(200);
expect(put.json().data.intent.goals).toBe('ship 1.17 with the readmymind phase 1');
expect(put.json().data.intent.updatedAt).toBeGreaterThan(0);
const get = await harness.app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/intent` });
expect(get.json().data.intent.goals).toBe('ship 1.17 with the readmymind phase 1');
});
it('rejects over-long goals and unknown keys (strict schema)', async () => {
const tooLong = await harness.app.inject({
method: 'PUT',
url: `/api/sessions/${SESSION_ID}/intent`,
payload: { goals: 'x'.repeat(8193) },
});
expect(tooLong.statusCode).toBe(400);
const extraKey = await harness.app.inject({
method: 'PUT',
url: `/api/sessions/${SESSION_ID}/intent`,
payload: { goals: 'ok', recentPrompts: [] },
});
expect(extraKey.statusCode).toBe(400);
});
});
describe('DELETE /api/sessions/:id/intent', () => {
it('forgets the case and reports whether anything existed', async () => {
await harness.app.inject({
method: 'PUT',
url: `/api/sessions/${SESSION_ID}/intent`,
payload: { goals: 'temporary' },
});
const first = await harness.app.inject({ method: 'DELETE', url: `/api/sessions/${SESSION_ID}/intent` });
expect(first.statusCode).toBe(200);
expect(first.json().data.deleted).toBe(true);
const second = await harness.app.inject({ method: 'DELETE', url: `/api/sessions/${SESSION_ID}/intent` });
expect(second.json().data.deleted).toBe(false);
const get = await harness.app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/intent` });
expect(get.json().data.intent.goals).toBe('');
});
});
describe('multi-user scoping', () => {
let savedMultiuser: string | undefined;
beforeEach(() => {
savedMultiuser = process.env.CODEMAN_MULTIUSER;
process.env.CODEMAN_MULTIUSER = '1';
});
afterEach(() => {
if (savedMultiuser === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = savedMultiuser;
});
it("404s (never 403s) another user's session", async () => {
const scoped = await createRouteTestHarness(registerReadMyMindRoutes, {
authUser: { username: 'bob', role: 'user' },
});
try {
(scoped.ctx.sessions.get(SESSION_ID) as unknown as { owner?: string }).owner = 'alice';
const res = await scoped.app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/intent` });
expect(res.statusCode).toBe(404);
} finally {
await scoped.app.close();
}
});
it('serves the owner normally', async () => {
const scoped = await createRouteTestHarness(registerReadMyMindRoutes, {
authUser: { username: 'bob', role: 'user' },
});
try {
const session = scoped.ctx.sessions.get(SESSION_ID) as unknown as { owner?: string; workingDir: string };
session.owner = 'bob';
session.workingDir = `/nonexistent/readmymind-owned-${Date.now()}`;
const res = await scoped.app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/intent` });
expect(res.statusCode).toBe(200);
expect(res.json().success).toBe(true);
} finally {
await scoped.app.close();
}
});
});
+82
View File
@@ -232,6 +232,88 @@ describe('TranscriptWatcher', () => {
});
});
describe('User prompt capture (Read My Mind)', () => {
it('emits transcript:user_prompt with the raw text for string content', async () => {
writeFileSync(testFile, '');
watcher.start(testFile);
const promptHandler = vi.fn();
watcher.on('transcript:user_prompt', promptHandler);
const ts = new Date().toISOString();
appendFileSync(
testFile,
JSON.stringify({ type: 'user', timestamp: ts, message: { role: 'user', content: 'fix the login bug' } }) + '\n'
);
await vi.waitFor(() => {
expect(promptHandler).toHaveBeenCalledWith('fix the login bug', ts);
});
});
it('emits joined text blocks but stays silent for tool_result-only entries', async () => {
writeFileSync(testFile, '');
watcher.start(testFile);
const promptHandler = vi.fn();
watcher.on('transcript:user_prompt', promptHandler);
appendFileSync(
testFile,
JSON.stringify({
type: 'user',
timestamp: new Date().toISOString(),
message: {
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'ok', is_error: false }],
},
}) + '\n'
);
appendFileSync(
testFile,
JSON.stringify({
type: 'user',
timestamp: new Date().toISOString(),
message: {
role: 'user',
content: [
{ type: 'text', text: 'run the tests' },
{ type: 'text', text: 'then push' },
],
},
}) + '\n'
);
await vi.waitFor(() => {
expect(promptHandler).toHaveBeenCalledTimes(1);
});
expect(promptHandler).toHaveBeenCalledWith('run the tests then push', expect.any(String));
});
it('does not emit for whitespace-only string content', async () => {
writeFileSync(testFile, '');
watcher.start(testFile);
const promptHandler = vi.fn();
watcher.on('transcript:user_prompt', promptHandler);
appendFileSync(
testFile,
JSON.stringify({
type: 'user',
timestamp: new Date().toISOString(),
message: { role: 'user', content: ' ' },
}) + '\n'
);
// Wait for the entry to be processed, then assert no emission happened.
await vi.waitFor(() => {
expect(watcher.getState().entryCount).toBeGreaterThanOrEqual(1);
});
expect(promptHandler).not.toHaveBeenCalled();
});
});
describe('State Management', () => {
it('should return a copy of state', () => {
const state1 = watcher.getState();