diff --git a/docs/plans/2026-02-25-pi-personal-assistant-memory-plan.md b/docs/plans/2026-02-25-pi-personal-assistant-memory-plan.md new file mode 100644 index 0000000..46fe1c5 --- /dev/null +++ b/docs/plans/2026-02-25-pi-personal-assistant-memory-plan.md @@ -0,0 +1,733 @@ +# Pi-Inspired Personal Assistant Memory — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add two-tier personal-assistant memory: a unified `user/*` namespace shared across channels and a working-memory layer (`user/working`) that survives restarts and is injected at session start. + +**Architecture:** `user/working` is written on every compaction (TTL-based flat file with header metadata). On the first message of a new session, `user/profile` and `user/working` are injected into the base system prompt once. All behavior is behind a `memory.user_namespace` config key; when unset the feature is entirely inert. + +**Tech Stack:** TypeScript, Vitest, existing `MemoryStore` (`src/memory/store.ts`), `AgentOrchestrator` (`src/backends/native/orchestrator.ts`), Zod config schema + +--- + +## Task 1: Config schema — new memory fields + +**Files:** +- Modify: `src/config/schema.ts:592-613` + +Add four new fields to `memorySchema`. They must come before `.default({})` at line 613. + +**Step 1: Write the failing type check** + +Run: +```bash +pnpm typecheck +``` +Expected: passes (baseline). We'll check again after the edit. + +**Step 2: Add fields to memorySchema** + +In `src/config/schema.ts`, inside `memorySchema` (after `qmd: qmdSchema,` on line 612, before `}).default({});`), add: + +```typescript + /** + * When set, all channels share user/* memory (unified identity namespace). + * Absent = current session-scoped behavior, unchanged. + */ + user_namespace: z.string().optional(), + /** How long working memory remains valid after the last compaction (days). */ + working_memory_ttl_days: z.number().min(1).max(365).default(14), + /** Token budget for working memory injection at session start. */ + working_memory_max_tokens: z.number().min(100).max(4000).default(1000), + /** When true, instruct the model to acknowledge prior context on session start. */ + proactive_session_greeting: z.boolean().default(false), +``` + +**Step 3: Run typecheck** + +```bash +pnpm typecheck +``` +Expected: no errors. + +**Step 4: Commit** + +```bash +git add src/config/schema.ts +git commit -m "feat(memory): add user_namespace and working memory config fields" +``` + +--- + +## Task 2: WorkingMemory module + +**Files:** +- Create: `src/memory/workingMemory.ts` +- Create: `src/memory/workingMemory.test.ts` + +Working memory file format (stored at `{ns}/working.md`): + +``` +# Working Memory +Updated: 2026-02-25T11:30:00Z +Expires: 2026-03-10T11:30:00Z + +[content] +``` + +**Step 1: Write the failing tests** + +Create `src/memory/workingMemory.test.ts`: + +```typescript +import { describe, it, expect, beforeEach } from 'vitest'; +import { join } from 'path'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { MemoryStore } from './store.js'; +import { writeWorkingMemory, readWorkingMemory } from './workingMemory.js'; + +function makeStore(): { store: MemoryStore; dir: string } { + const dir = mkdtempSync(join(tmpdir(), 'wm-test-')); + const store = new MemoryStore({ dir, maxContextTokens: 2000 }); + return { store, dir }; +} + +describe('writeWorkingMemory', () => { + it('writes a file with Updated/Expires headers', () => { + const { store, dir } = makeStore(); + writeWorkingMemory(store, 'user/working', 'some content', 14, 1000); + const raw = store.read('user/working'); + expect(raw).toContain('# Working Memory'); + expect(raw).toContain('Updated:'); + expect(raw).toContain('Expires:'); + expect(raw).toContain('some content'); + rmSync(dir, { recursive: true }); + }); + + it('truncates content to token budget', () => { + const { store, dir } = makeStore(); + const longContent = 'x'.repeat(10000); + writeWorkingMemory(store, 'user/working', longContent, 14, 100); + const raw = store.read('user/working'); + // 100 tokens * 4 chars = 400 chars budget for content + const contentPart = raw.split('\n\n').slice(1).join('\n\n'); + expect(contentPart.length).toBeLessThanOrEqual(400 + 10); // small tolerance + rmSync(dir, { recursive: true }); + }); +}); + +describe('readWorkingMemory', () => { + it('returns null when file does not exist', () => { + const { store, dir } = makeStore(); + expect(readWorkingMemory(store, 'user/working')).toBeNull(); + rmSync(dir, { recursive: true }); + }); + + it('returns content when not expired', () => { + const { store, dir } = makeStore(); + writeWorkingMemory(store, 'user/working', 'hello world', 14, 1000); + const result = readWorkingMemory(store, 'user/working'); + expect(result).not.toBeNull(); + expect(result!.content).toBe('hello world'); + rmSync(dir, { recursive: true }); + }); + + it('returns null when expired', () => { + const { store, dir } = makeStore(); + // Write with 0 TTL days (expires immediately) + writeWorkingMemory(store, 'user/working', 'stale content', 0, 1000); + const result = readWorkingMemory(store, 'user/working'); + expect(result).toBeNull(); + rmSync(dir, { recursive: true }); + }); + + it('returns null for malformed file', () => { + const { store, dir } = makeStore(); + store.write('user/working', 'no headers here', 'replace'); + expect(readWorkingMemory(store, 'user/working')).toBeNull(); + rmSync(dir, { recursive: true }); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +```bash +pnpm test:run src/memory/workingMemory.test.ts +``` +Expected: FAIL — module not found. + +**Step 3: Implement `src/memory/workingMemory.ts`** + +```typescript +import type { MemoryStore } from './store.js'; + +export interface WorkingMemoryEntry { + content: string; + updatedAt: Date; + expiresAt: Date; +} + +const HEADER_PREFIX = '# Working Memory\n'; + +/** + * Write a compaction summary to the working memory namespace. + * Content is capped at maxTokens (estimated at 4 chars/token). + * The file format includes Updated/Expires timestamps for lazy expiry checks. + */ +export function writeWorkingMemory( + store: MemoryStore, + namespace: string, + content: string, + ttlDays: number, + maxTokens: number, +): void { + const now = new Date(); + const expiresAt = new Date(now.getTime() + ttlDays * 24 * 60 * 60 * 1000); + const maxChars = maxTokens * 4; + const truncatedContent = content.length > maxChars ? content.slice(0, maxChars) : content; + + const file = [ + '# Working Memory', + `Updated: ${now.toISOString()}`, + `Expires: ${expiresAt.toISOString()}`, + '', + truncatedContent, + ].join('\n'); + + store.write(namespace, file, 'replace'); +} + +/** + * Read working memory. Returns null if the file is absent, malformed, or expired. + * Expiry is checked lazily here — no background cleanup needed. + */ +export function readWorkingMemory( + store: MemoryStore, + namespace: string, +): WorkingMemoryEntry | null { + const raw = store.read(namespace); + if (!raw) { + return null; + } + + if (!raw.startsWith(HEADER_PREFIX)) { + return null; + } + + const lines = raw.split('\n'); + let updatedAt: Date | null = null; + let expiresAt: Date | null = null; + let contentStartLine = 0; + + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + if (line.startsWith('Updated: ')) { + updatedAt = new Date(line.slice('Updated: '.length)); + } else if (line.startsWith('Expires: ')) { + expiresAt = new Date(line.slice('Expires: '.length)); + } else if (line === '' && expiresAt !== null) { + contentStartLine = i + 1; + break; + } + } + + if (!updatedAt || !expiresAt || isNaN(expiresAt.getTime())) { + return null; + } + + if (expiresAt.getTime() <= Date.now()) { + console.debug('[Flynn:working-memory] Working memory expired, skipping injection'); + return null; + } + + const content = lines.slice(contentStartLine).join('\n').trim(); + + return { content, updatedAt, expiresAt }; +} +``` + +**Step 4: Run tests** + +```bash +pnpm test:run src/memory/workingMemory.test.ts +``` +Expected: all pass. + +**Step 5: Typecheck** + +```bash +pnpm typecheck +``` +Expected: no errors. + +**Step 6: Commit** + +```bash +git add src/memory/workingMemory.ts src/memory/workingMemory.test.ts +git commit -m "feat(memory): add working memory read/write with TTL expiry" +``` + +--- + +## Task 3: PA-focused compaction prompt + +**Files:** +- Modify: `src/backends/native/prompts.ts` + +The existing `COMPACTION_SYSTEM_PROMPT` is a generic summarizer. We need a personal-assistant variant that captures what the user was working on, decisions made, expressed preferences, and open threads. + +**Step 1: Add the new prompt constant** + +In `src/backends/native/prompts.ts`, after `COMPACTION_SYSTEM_PROMPT`, add: + +```typescript +/** + * Personal-assistant variant of the compaction prompt. + * Used when `memory.user_namespace` is configured. + * Captures continuity context: what the user was working on, decisions, + * preferences, and open threads — not just a generic recap. + */ +export const PA_COMPACTION_SYSTEM_PROMPT = `You are summarising a conversation for a personal assistant. Your summary will be injected at the start of the next session so the assistant can pick up exactly where things left off. + +Focus on: +- What the user was working on and its current status (be specific: which files, commands, or steps were involved) +- Decisions made and why (include rationale when stated) +- Preferences or constraints the user expressed (tools, styles, approaches to avoid or prefer) +- Open threads, unresolved questions, or explicit follow-up items + +Rules: +- Preserve key facts, file paths, error messages, and specific values verbatim. +- Be concise but complete — aim for roughly 20% of the original length. +- Use bullet points under short descriptive headings. +- Never invent information not present in the conversation. +- Skip purely transient content (one-off commands, status messages with no lasting significance). + +Output format: +Return a markdown summary. No preamble — output only the summary.`; +``` + +**Step 2: Typecheck** + +```bash +pnpm typecheck +``` +Expected: no errors. + +**Step 3: Commit** + +```bash +git add src/backends/native/prompts.ts +git commit -m "feat(memory): add personal-assistant compaction prompt" +``` + +--- + +## Task 4: Thread working memory through compaction + +**Files:** +- Modify: `src/context/compaction.ts` + +Add `summary` to `CompactionResult` so the orchestrator can write it to `user/working` without re-computing it. Also thread the PA prompt option. + +**Step 1: Write the failing test** + +There is no direct unit test for `compactHistory()` currently (it requires a live orchestrator). We'll test via the orchestrator integration in Task 5. For now, just verify the type change works. + +**Step 2: Update `CompactionResult` interface** + +In `src/context/compaction.ts`, add `summary` to `CompactionResult`: + +```typescript +export interface CompactionResult { + /** The compacted messages: [summary, ...recentMessages]. */ + messages: Message[]; + /** Number of messages that were compacted (removed). */ + compactedCount: number; + /** Estimated tokens before compaction. */ + tokensBefore: number; + /** Estimated tokens after compaction. */ + tokensAfter: number; + /** The raw summary text produced by the compaction model (populated when compaction ran). */ + summary?: string; +} +``` + +**Step 3: Add `usePersonalAssistantPrompt` option and populate `summary`** + +Update the `compactHistory` function signature to accept the option and return the summary: + +```typescript +export async function compactHistory(opts: { + messages: Message[]; + orchestrator: AgentOrchestrator; + config: CompactionConfig; + memoryStore?: MemoryStore; + autoExtract?: boolean; + usePersonalAssistantPrompt?: boolean; // ← new +}): Promise { +``` + +In the body, replace the `COMPACTION_SYSTEM_PROMPT` reference in the `orchestrator.delegate()` call: + +```typescript +import { COMPACTION_SYSTEM_PROMPT, MEMORY_EXTRACTION_PROMPT, PA_COMPACTION_SYSTEM_PROMPT } from '../backends/native/prompts.js'; + +// ...inside compactHistory, where orchestrator.delegate is called: +const systemPrompt = opts.usePersonalAssistantPrompt + ? PA_COMPACTION_SYSTEM_PROMPT + : COMPACTION_SYSTEM_PROMPT; + +const result = await orchestrator.delegate({ + task: 'compaction', + tier, + systemPrompt, + message: formattedConversation, + maxTokens: config.summaryMaxTokens, +}); +``` + +And populate `summary` in the return value: + +```typescript + return { + messages: [...preservedMessages, summaryMessage, ...toKeep], + compactedCount: toSummarize.length, + tokensBefore: estimateMessageTokens(messages), + tokensAfter: estimateMessageTokens([...preservedMessages, summaryMessage, ...toKeep]), + summary: result.content, // ← new + }; +``` + +**Step 4: Typecheck** + +```bash +pnpm typecheck +``` +Expected: no errors. + +**Step 5: Run full test suite** + +```bash +pnpm test:run +``` +Expected: all pass (summary field is additive, existing tests unaffected). + +**Step 6: Commit** + +```bash +git add src/context/compaction.ts src/backends/native/prompts.ts +git commit -m "feat(memory): thread summary and PA prompt through compaction result" +``` + +--- + +## Task 5: Orchestrator — session-start injection + post-compaction write + +**Files:** +- Modify: `src/backends/native/orchestrator.ts` + +This task has two parts: +1. Write working memory after every compaction (when `userNamespace` is set) +2. Inject `user/profile` + `user/working` into the base system prompt on first message + +**Step 1: Write the failing test — post-compaction working memory write** + +In `src/backends/native/orchestrator.test.ts` (or the closest existing test file — check with `ls src/backends/native/*.test.ts`), add a test. First, find the test file: + +```bash +ls src/backends/native/*.test.ts +``` + +If `orchestrator.test.ts` exists, add to it. The test should verify that after calling `compact()`, the working memory namespace contains the summary. Because this requires a functioning orchestrator setup, write an integration-style test using vitest mocks rather than a full live model: + +Locate the `describe('compact')` block in `src/backends/native/orchestrator.test.ts`. Add: + +```typescript +it('writes summary to user/working when userNamespace is set', async () => { + // Setup: orchestrator with a mock delegate that returns a fixed summary + // and a real MemoryStore in a temp dir + // (follow the existing orchestrator test setup patterns) + // After orchestrator.compact(), read store.read('user/working') + // Expect it to contain the summary content +}); +``` + +You'll need to examine the existing test setup patterns carefully before writing the full test body. See the file for the existing mock structure. + +**Step 2: Add new fields to `OrchestratorConfig`** + +In `src/backends/native/orchestrator.ts`, in `OrchestratorConfig` (after `attachmentCollector` on line ~160), add: + +```typescript + /** Shared identity namespace for cross-channel memory (e.g. 'user'). Absent = session-scoped. */ + userNamespace?: string; + /** TTL in days for working memory. Defaults to 14. */ + workingMemoryTtlDays?: number; + /** Token budget for working memory injection. Defaults to 1000. */ + workingMemoryMaxTokens?: number; + /** When true, instruct the model to acknowledge prior context on session start. */ + proactiveSessionGreeting?: boolean; +``` + +**Step 3: Store new config fields on the class** + +In the `AgentOrchestrator` class, add private fields (near the other `_memory*` fields): + +```typescript +private _userNamespace?: string; +private _workingMemoryTtlDays: number; +private _workingMemoryMaxTokens: number; +private _proactiveSessionGreeting: boolean; +private _sessionContextInjected = false; +``` + +In the constructor, after the existing memory field assignments: + +```typescript +this._userNamespace = config.userNamespace; +this._workingMemoryTtlDays = config.workingMemoryTtlDays ?? 14; +this._workingMemoryMaxTokens = config.workingMemoryMaxTokens ?? 1000; +this._proactiveSessionGreeting = config.proactiveSessionGreeting ?? false; +``` + +**Step 4: Post-compaction write in `compact()`** + +Import `writeWorkingMemory` at the top of orchestrator.ts: + +```typescript +import { writeWorkingMemory } from '../../memory/workingMemory.js'; +``` + +In the `compact()` method, after the `auditLogger?.sessionCompact(...)` call (around line 500), add: + +```typescript + // Write working memory when user namespace is configured + if (result.summary && this._userNamespace && this._memoryStore) { + const workingNs = `${this._userNamespace}/working`; + writeWorkingMemory( + this._memoryStore, + workingNs, + result.summary, + this._workingMemoryTtlDays, + this._workingMemoryMaxTokens, + ); + console.log(`[Flynn:working-memory] Updated ${workingNs} after compaction`); + } +``` + +Also, pass `usePersonalAssistantPrompt` to `compactHistory()` in `compact()`: + +```typescript + const result = await compactHistory({ + messages, + orchestrator: this, + config, + memoryStore: this._memoryStore, + autoExtract: this._memoryAutoExtract, + usePersonalAssistantPrompt: Boolean(this._userNamespace), // ← new + }); +``` + +**Step 5: Session-start injection** + +Add `_injectSessionContext()` private method to `AgentOrchestrator` (after `_injectMemoryContext`): + +```typescript + private _injectSessionContext(): void { + if (this._sessionContextInjected) { + return; + } + this._sessionContextInjected = true; + + if (!this._memoryStore || !this._userNamespace) { + return; + } + + const sections: string[] = []; + + // User profile block + const profile = this._memoryStore.read(`${this._userNamespace}/profile`); + if (profile.length > 0) { + sections.push(`--- Who you're talking to ---\n${profile}`); + } + + // Working memory block + const working = readWorkingMemory(this._memoryStore, `${this._userNamespace}/working`); + if (working) { + sections.push(`--- Recent context ---\n${working.content}`); + } + + if (sections.length === 0) { + return; + } + + this._systemPromptBase = `${this._systemPromptBase}\n\n${sections.join('\n\n')}`; + + if (this._proactiveSessionGreeting) { + this._systemPromptBase += + '\n\n[If relevant, briefly acknowledge what the user was last working on before responding to their first message.]'; + } + } +``` + +Add the import at the top of orchestrator.ts: + +```typescript +import { writeWorkingMemory, readWorkingMemory } from '../../memory/workingMemory.js'; +``` + +**Step 6: Call `_injectSessionContext()` from `process()`** + +In the `process()` method, find the first place where `_injectMemoryContext()` is called. Add `_injectSessionContext()` just before it: + +```typescript + // One-time session-start context injection (user/profile + user/working) + this._injectSessionContext(); + this._injectMemoryContext(userMessage); +``` + +Also reset `_sessionContextInjected` in `reset()`: + +```typescript + reset(): void { + this._agent.reset(); + this._usageByTier.clear(); + this._lastContextAlertLevel = null; + this._pendingContextAlert = undefined; + this._lastCheckpointAt = 0; + this._sessionContextInjected = false; // ← add + } +``` + +**Step 7: Typecheck** + +```bash +pnpm typecheck +``` +Expected: no errors. + +**Step 8: Run full test suite** + +```bash +pnpm test:run +``` +Expected: all pass. + +**Step 9: Commit** + +```bash +git add src/backends/native/orchestrator.ts src/memory/workingMemory.ts +git commit -m "feat(memory): inject session context and write working memory after compaction" +``` + +--- + +## Task 6: Daemon routing — wire new config fields + +**Files:** +- Modify: `src/daemon/routing.ts` + +**Step 1: Add the new fields after `memoryDailyLogMaxAssistantChars`** + +In `src/daemon/routing.ts` around line 756, after: +```typescript + memoryDailyLogMaxAssistantChars: deps.config.memory?.daily_log?.max_assistant_chars, +``` + +Add: +```typescript + userNamespace: deps.config.memory?.user_namespace, + workingMemoryTtlDays: deps.config.memory?.working_memory_ttl_days, + workingMemoryMaxTokens: deps.config.memory?.working_memory_max_tokens, + proactiveSessionGreeting: deps.config.memory?.proactive_session_greeting, +``` + +**Step 2: Typecheck** + +```bash +pnpm typecheck +``` +Expected: no errors. + +**Step 3: Run full test suite** + +```bash +pnpm test:run +``` +Expected: all pass. + +**Step 4: Commit** + +```bash +git add src/daemon/routing.ts +git commit -m "feat(memory): wire user_namespace and working memory config to orchestrator" +``` + +--- + +## Task 7: Smoke test and state.json update + +**Step 1: Run full test suite one final time** + +```bash +pnpm test:run +``` +Expected: all pass. + +**Step 2: Manual smoke test (optional but recommended)** + +Add this to your `config.yaml`: +```yaml +memory: + user_namespace: "user" + working_memory_ttl_days: 14 + working_memory_max_tokens: 1000 + proactive_session_greeting: false +``` + +Start a session, chat for a while, trigger a compaction (or wait for auto-compaction), then restart and start a new session. Verify `user/working.md` exists in your memory dir and is injected on the new session's first turn. + +**Step 3: Update state.json** + +In `docs/plans/state.json`, add an entry to `completed`: + +```json +"pi_personal_assistant_memory": { + "status": "complete", + "commit": "", + "summary": "Two-tier personal assistant memory: working memory (user/working, TTL-based) written on compaction, injected at session start; unified user/* namespace across channels; PA-focused compaction prompt; proactive session greeting option." +} +``` + +**Step 4: Final commit** + +```bash +git add docs/plans/state.json +git commit -m "docs(state): mark pi personal assistant memory as complete" +``` + +--- + +## Success Criteria Checklist + +- [ ] `user_namespace` absent → zero behavior change (all new code paths guarded by `this._userNamespace` check) +- [ ] After compaction with `user_namespace` set, `user/working.md` exists in memory dir +- [ ] Daemon restart + new session → `user/working` content appears in the first-turn system prompt +- [ ] Telegram and web UI sessions share the same `user/working` file +- [ ] Expired working memory (TTL elapsed) is silently ignored +- [ ] All existing tests pass + +--- + +## Reference: Config + +```yaml +memory: + # Enables shared identity and working memory. Absent = unchanged behavior. + user_namespace: "user" + working_memory_ttl_days: 14 # default + working_memory_max_tokens: 1000 # default + proactive_session_greeting: false # default +```