Files
flynn/docs/plans/2026-02-25-pi-personal-assistant-memory-plan.md
T
2026-02-25 12:29:44 -08:00

22 KiB

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:

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:

  /**
   * 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

pnpm typecheck

Expected: no errors.

Step 4: Commit

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:

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

pnpm test:run src/memory/workingMemory.test.ts

Expected: FAIL — module not found.

Step 3: Implement src/memory/workingMemory.ts

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

pnpm test:run src/memory/workingMemory.test.ts

Expected: all pass.

Step 5: Typecheck

pnpm typecheck

Expected: no errors.

Step 6: Commit

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:

/**
 * 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

pnpm typecheck

Expected: no errors.

Step 3: Commit

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:

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:

export async function compactHistory(opts: {
  messages: Message[];
  orchestrator: AgentOrchestrator;
  config: CompactionConfig;
  memoryStore?: MemoryStore;
  autoExtract?: boolean;
  usePersonalAssistantPrompt?: boolean;   // ← new
}): Promise<CompactionResult> {

In the body, replace the COMPACTION_SYSTEM_PROMPT reference in the orchestrator.delegate() call:

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:

  return {
    messages: [...preservedMessages, summaryMessage, ...toKeep],
    compactedCount: toSummarize.length,
    tokensBefore: estimateMessageTokens(messages),
    tokensAfter: estimateMessageTokens([...preservedMessages, summaryMessage, ...toKeep]),
    summary: result.content,   // ← new
  };

Step 4: Typecheck

pnpm typecheck

Expected: no errors.

Step 5: Run full test suite

pnpm test:run

Expected: all pass (summary field is additive, existing tests unaffected).

Step 6: Commit

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:

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:

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:

  /** 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):

private _userNamespace?: string;
private _workingMemoryTtlDays: number;
private _workingMemoryMaxTokens: number;
private _proactiveSessionGreeting: boolean;
private _sessionContextInjected = false;

In the constructor, after the existing memory field assignments:

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:

import { writeWorkingMemory } from '../../memory/workingMemory.js';

In the compact() method, after the auditLogger?.sessionCompact(...) call (around line 500), add:

    // 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():

    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):

  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:

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:

    // One-time session-start context injection (user/profile + user/working)
    this._injectSessionContext();
    this._injectMemoryContext(userMessage);

Also reset _sessionContextInjected in reset():

  reset(): void {
    this._agent.reset();
    this._usageByTier.clear();
    this._lastContextAlertLevel = null;
    this._pendingContextAlert = undefined;
    this._lastCheckpointAt = 0;
    this._sessionContextInjected = false;   // ← add
  }

Step 7: Typecheck

pnpm typecheck

Expected: no errors.

Step 8: Run full test suite

pnpm test:run

Expected: all pass.

Step 9: Commit

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:

        memoryDailyLogMaxAssistantChars: deps.config.memory?.daily_log?.max_assistant_chars,

Add:

        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

pnpm typecheck

Expected: no errors.

Step 3: Run full test suite

pnpm test:run

Expected: all pass.

Step 4: Commit

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

pnpm test:run

Expected: all pass.

Step 2: Manual smoke test (optional but recommended)

Add this to your config.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:

"pi_personal_assistant_memory": {
  "status": "complete",
  "commit": "<sha>",
  "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

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

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