diff --git a/CHANGELOG.md b/CHANGELOG.md index 376bb64..6d35e1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,25 @@ follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- Added repository-scoped memory under `.pi/agent/memory/` alongside user-wide + memory under `~/.pi/agent/memory/`. Repository paths resolve from the nearest + Git root, or from the session working directory outside Git. +- Memory tools now accept user/repository scopes. Natural-language clues such as + “remember this for this repo” select repository memory when the tool omits an + explicit scope; neutral requests continue to default to user memory. +- Context injection loads and labels both scopes, with repository memory taking + priority. `memory_search` searches both scopes by default through separate qmd + collections, and `memory_status` reports both inventories and collections. + +### Changed + +- New recovery records include their memory scope so repository deletions restore + to the correct location. Existing recovery records remain readable. +- `PI_MEMORY_DIR` now explicitly overrides only the user-wide memory directory; + repository memory remains at `.pi/agent/memory/`. + ## [0.4.2] — 2026-08-10 ### Added diff --git a/README.md b/README.md index 819156e..43cc32d 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,14 @@ Thanks to https://github.com/skyfallsin/pi-mem for inspiration. -Your coding agent forgets everything between sessions. pi-memory gives it a memory: durable facts and decisions, a running daily log, and a scratchpad of things to come back to — all as plain markdown files you can read, edit, and commit. With optional [qmd](https://github.com/tobi/qmd) it also gets keyword, semantic, and hybrid **search** across everything it has ever remembered. +Your coding agent forgets everything between sessions. pi-memory gives it a memory: durable facts and decisions, a running daily log, and a scratchpad of things to come back to. User-wide memory lives under `~/.pi/agent/memory/`; repository-specific memory lives under `.pi/agent/memory/` in the repository root. Both use plain Markdown you can inspect and edit, and repository memory can be committed with the project. Recovery payloads are always kept in user-private state outside the repository. With optional [qmd](https://github.com/tobi/qmd), pi can search both scopes with keyword, semantic, and hybrid **search**. ## What it feels like ```text # Session 1 you ▸ I always use pnpm in this repo, never npm. Remember that. -pi ▸ Got it — saved to long-term memory. (writes MEMORY.md) +pi ▸ Got it — saved to repository memory. (writes .pi/agent/memory/MEMORY.md) # …days later, brand new session… you ▸ add prettier as a dev dependency @@ -23,12 +23,16 @@ pi ▸ pnpm add -D prettier (recalled your package-manager preference from memory — no reminder needed) ``` -Everything lives in `~/.pi/agent/memory/` as markdown, so you can also just `cat` it: +You can inspect either scope directly: ```bash -$ cat ~/.pi/agent/memory/MEMORY.md +# Repository-specific memory +$ cat .pi/agent/memory/MEMORY.md #preference [[package-manager]] Always use pnpm in this repo, never npm. + +# User-wide memory +$ cat ~/.pi/agent/memory/MEMORY.md ``` ## Installation @@ -54,15 +58,15 @@ npm install -g @tobilu/qmd # no Bun required bun install -g https://github.com/tobi/qmd # ensure ~/.bun/bin is on PATH ``` -When qmd is present, the extension **automatically creates** the `pi-memory` -collection and path contexts on the next session start — no manual step. Run -`memory_status` any time to confirm qmd, the collection, and embeddings are ready. +When qmd is present, the extension **automatically creates** the user-wide +`pi-memory` collection and a uniquely named collection for the current repository +when repository memory exists. Run `memory_status` any time to confirm qmd, both +collections, and embeddings are ready. Semantic/deep modes need vector embeddings; the extension keeps them current automatically (`qmd embed` runs in the background at session start and after writes). The very first embed downloads the embedding model, so semantic search -may take a minute to come online on a fresh install. To set the collection up -by hand: +may take a minute to come online on a fresh install. To set up the user-wide collection by hand: ```bash qmd collection add ~/.pi/agent/memory --name pi-memory @@ -77,13 +81,13 @@ Without qmd, the core tools still work fully — only `memory_search` and select | Tool | Description | |------|-------------| -| `memory_write` | Write to MEMORY.md (long-term) or daily log | -| `memory_forget` | Delete matching entries and create a durable recovery record | +| `memory_write` | Write to user or repository MEMORY.md (long-term) or a daily log | +| `memory_forget` | Delete matching entries in either scope and create a durable recovery record outside repository Git history | | `memory_restore` | Restore a deletion using the recovery ID returned by `memory_forget` | -| `memory_read` | Read any memory file or list daily logs | -| `scratchpad` | Add/done/undo/clear/list checklist items | -| `memory_search` | Search across all memory files (requires qmd) | -| `memory_status` | Health check: where files live, qmd/collection/embeddings state, active config | +| `memory_read` | Read a user or repository memory file, or list daily logs | +| `scratchpad` | Add/done/undo/clear/list user or repository checklist items | +| `memory_search` | Search user memory, repository memory, or both (requires qmd) | +| `memory_status` | Health check for both scopes, qmd collections, embeddings, and active config | ### memory_search modes @@ -95,32 +99,49 @@ Without qmd, the core tools still work fully — only `memory_search` and select If the first search doesn't find what you need, try rephrasing or switching modes. +## Memory scopes + +Most memory tools accept `scope: "user" | "repo"`: + +- **`user`** stores cross-repository preferences and facts in `~/.pi/agent/memory/`. +- **`repo`** stores project-specific decisions and context in `.pi/agent/memory/` at the nearest Git repository root. Outside Git, the session working directory is used. + +When `scope` is omitted, the extension uses clues in the active request. Phrases such as “remember this for this repo,” “current project,” or “repository-specific” select repository memory. “Globally,” “across all repositories,” and similar phrases select user memory. Neutral requests default to user memory for backward compatibility. An explicit tool argument always wins. + +`memory_search` defaults to `scope: "all"`, while `memory_restore` looks for the recovery ID in both scopes. + ## File layout -``` -~/.pi/agent/memory/ - MEMORY.md # Curated long-term memory - SCRATCHPAD.md # Checklist of things to fix/remember +Both scopes use the same layout: + +```text +/ + MEMORY.md # Curated long-term memory + SCRATCHPAD.md # Checklist of things to fix/remember daily/ - 2026-02-15.md # Daily append-only log + 2026-02-15.md # Daily append-only log 2026-02-14.md ... recovery/ - .json # Complete payload and restore state for a memory_forget deletion + .json # Complete payload, scope, and restore state ``` +The user scope root is `~/.pi/agent/memory/`. The repository scope root is +`.pi/agent/memory/` under the nearest Git root. Repository-scope recovery JSON is stored under the user-wide recovery area, keyed by repository identity, so deleted content is never written into the repository. + ## How it works ### Context injection -Before every agent turn, the following are injected into the system prompt (in priority order): +Before every agent turn, memory is injected into the system prompt in this order: -1. **Open scratchpad items** (up to 2K chars) -2. **Today's daily log** (up to 3K chars, tail) -3. **MEMORY.md** (up to 4K chars, middle-truncated) -4. **Yesterday's daily log** (up to 3K chars, tail — lowest priority, trimmed first) +1. Repository then user **open scratchpad items** (up to 2K chars per scope) +2. Repository then user **today's daily log** (up to 3K chars per scope, tail) +3. Relevant qmd results in per-turn mode +4. Repository then user **MEMORY.md** (up to 4K chars per scope, middle-truncated) +5. Repository then user **yesterday's daily log** (up to 3K chars per scope, tail) -Total injection is capped at 16K chars. +Repository sections are labeled separately from user-wide sections. Total injection remains capped at 16K chars. ### KV cache-stable snapshot (default) @@ -176,7 +197,7 @@ This ensures in-progress context survives compaction and is visible in the next - **Persistence**: Memory files are plain markdown on disk — readable, editable, and git-friendly. - **Recoverable deletion**: `memory_forget` stores complete deleted entries under `recovery/` before changing memory and returns a recovery ID that `memory_restore` can use. Recovery JSON is outside qmd's `**/*.md` index. - **Tool response previews**: Write/scratchpad tools return size-capped previews instead of full file contents. -- **qmd auto-setup**: On first session start with qmd available, the extension creates the collection and path contexts automatically. +- **qmd auto-setup**: On session start, the extension creates the user collection and, when repository memory exists, the current repository's uniquely named collection and path contexts. - **qmd re-indexing**: After every write, a debounced `qmd update` runs in the background (fire-and-forget, non-blocking) unless disabled via `PI_MEMORY_QMD_UPDATE`. - **qmd embeddings**: Vector embeddings for semantic/deep search are kept current automatically — `qmd embed` (incremental) runs in the background after each re-index and as a catch-up at session start. Disabled along with re-indexing via `PI_MEMORY_QMD_UPDATE`. - **Graceful degradation**: If qmd is not installed, core tools work fine. `memory_search` returns install instructions. @@ -185,7 +206,7 @@ This ensures in-progress context survives compaction and is visible in the next | Variable | Values | Default | Description | |----------|--------|---------|-------------| -| `PI_MEMORY_DIR` | path | `~/.pi/agent/memory` | Override the memory storage directory | +| `PI_MEMORY_DIR` | path | `~/.pi/agent/memory` | Override the user-wide memory directory; repository memory remains at `.pi/agent/memory/` | | `PI_MEMORY_SNAPSHOT` | `stable`, `per-turn` | `stable` | `stable` snapshots memory at checkpoints for KV cache stability; `per-turn` rebuilds every turn (legacy behavior) | | `PI_MEMORY_QMD_UPDATE` | `background`, `manual`, `off` | `background` | Controls automatic `qmd update` + `qmd embed` after writes | | `PI_MEMORY_QMD_SEARCH_TIMEOUT_MS` | positive integer (milliseconds) | `60000` | Sets the timeout for explicit `memory_search` qmd queries | @@ -204,7 +225,7 @@ Run the `memory_status` tool first — it reports most of these at a glance. | `memory_search` says qmd is required | qmd not installed or not on `PATH` | Install qmd (`npm install -g @tobilu/qmd`); if installed via Bun, ensure `~/.bun/bin` is on `PATH` | | Search returns nothing for terms you know exist | Index is stale | A background `qmd update` runs after writes; if disabled (`PI_MEMORY_QMD_UPDATE=off`), run `qmd update` manually | | “need embeddings” on semantic/deep search | Vectors not built yet | Embedding starts automatically in the background — retry shortly. If `PI_MEMORY_QMD_UPDATE` is `manual`/`off`, run `qmd embed` yourself | -| Collection `pi-memory` missing | Auto-setup didn't run (qmd installed mid-session) | Run any `memory_search` (auto-creates it) or `qmd collection add ~/.pi/agent/memory --name pi-memory` | +| A user or repository collection is missing | Auto-setup did not run, or qmd was installed mid-session | Run `memory_search` (auto-creates the required collection) and confirm with `memory_status` | | qmd works in the shell but not from pi on Windows | Broken `.cmd`/`.ps1` shims | The extension bypasses them by invoking qmd's JS entry with `node`; make sure the npm global `node_modules` dir is on `PATH` | | Memory isn't being injected after a write | Cache-stable snapshot only refreshes at checkpoints | Long-term writes refresh next turn; for daily/scratchpad use `memory_read`, or set `PI_MEMORY_SNAPSHOT=per-turn` | @@ -245,8 +266,8 @@ This is a single-file extension (`index.ts`). No build step required — pi load # Test with pi directly pi -p -e ./index.ts "remember: I prefer dark mode" -# Verify memory was written -cat ~/.pi/agent/memory/MEMORY.md +# Verify repository-specific memory was written +cat .pi/agent/memory/MEMORY.md ``` ## Publishing (maintainers) diff --git a/design.md b/design.md index 48edafb..69b6b4c 100644 --- a/design.md +++ b/design.md @@ -48,9 +48,10 @@ Three principles guided the design: **1. Files are the index.** No separate metadata store, no extraction pipeline, no sync to keep in -agreement. Memory lives in `~/.pi/agent/memory/` as markdown files. qmd -(a full-text + vector search tool) indexes them directly. `git diff` shows -what changed. `cat` shows what's stored. +agreement. User-wide memory lives in `~/.pi/agent/memory/`; repository memory +lives in `.pi/agent/memory/` at the repository root. qmd (a full-text + vector +search tool) indexes both scopes directly. `git diff` shows repository-memory +changes. `cat` shows what's stored in either scope. **2. Injection should be selective, not exhaustive.** The previous design injected ALL of MEMORY.md every turn, truncating from @@ -79,11 +80,10 @@ falls back to the previous behavior. No feature is critical-path. | - format top 3 results | | | 2. buildMemoryContext(searchResults) - | - read scratchpad | - | - read today's daily | + | - read repo + user scratchpads + | - read repo + user daily logs | - include search results - | - read MEMORY.md | - | - read yesterday's daily + | - read repo + user MEMORY.md | - truncate to 16K | | | | 3. Append to system prompt @@ -109,24 +109,21 @@ falls back to the previous behavior. No feature is critical-path. ### Injection Priority -Context budget is 16K chars. Sections are built in priority order; when the total -exceeds the budget, content is trimmed from the end (yesterday goes first): - -``` - Priority Section Budget Truncation - -------- ------- ------ ---------- - 1 (high) Open scratchpad items 2.0K from start - 2 Today's daily log 3.0K from end (tail) - 3 qmd search results 2.5K from start - 4 MEMORY.md (long-term) 4.0K from middle - 5 (low) Yesterday's daily log 3.0K from end (tail) - ------ - 14.5K (individual caps) - 16.0K (total cap) +Context budget is 16K chars. Each file type keeps its existing per-section cap, +but repository sections precede user-wide sections: + +```text + Priority Section Budget Truncation + -------- ------- ------ ---------- + 1 (high) Repository, then user scratchpad 2.0K each from start + 2 Repository, then user today's log 3.0K each from end (tail) + 3 qmd search results 2.5K from start + 4 Repository, then user MEMORY.md 4.0K each from middle + 5 (low) Repository, then user yesterday 3.0K each from end (tail) ``` -The gap between individual caps (14.5K) and total cap (16K) provides headroom -for section headers and separator lines. +The 16K total cap remains authoritative when the per-section caps add up to more +than the available context. ### Why This Order @@ -147,7 +144,8 @@ the oldest context and most likely to be stale. | +-- sanitize: strip control chars, limit to 200 chars +-- check: qmd available? collection exists? - +-- qmd search "what database should we use?" -n 3 -c pi-memory + +-- qmd search "what database should we use?" -n 3 + | -c pi-memory -c pi-memory-repo- +-- timeout: 3 seconds (Promise.race) +-- format: markdown snippets with file paths | @@ -220,21 +218,21 @@ commands. Now: | no --> show install instructions, stop | yes --> continue | - +-- checkCollection("pi-memory") — does collection exist? - | yes --> done - | no --> setupQmdCollection() - | | - | +-- qmd collection add ~/.pi/agent/memory --name pi-memory - | +-- qmd context add /daily "Daily work logs" -c pi-memory - | +-- qmd context add / "Long-term memory" -c pi-memory - | | - | +-- any step fails? log and continue (not critical) + +-- ensure user collection `pi-memory` + | +-- qmd collection add ~/.pi/agent/memory --name pi-memory + | + +-- repository memory exists? + | no --> skip repository setup + | yes --> ensure `pi-memory-repo-` for .pi/agent/memory + | + +-- add /daily and / path contexts to each collection (best effort) | done ``` -The same auto-setup runs inside the `memory_search` tool if the collection is -missing at search time, covering the case where qmd was installed mid-session. +The same auto-setup runs inside `memory_search`. Searches use repeated `-c` +arguments to search user and repository collections together, while explicit +scope arguments can restrict the search. ## What We Chose Not to Build @@ -249,9 +247,10 @@ relationship modeling, and query translation — all failure-prone. Wiki-links l `[[database-choice]]` achieve cross-referencing through content, searchable without any graph infrastructure. -**No multiple collections.** One qmd collection with path contexts (`/daily` vs -`/`) is sufficient. Splitting into per-topic collections would require routing -logic to decide which collection to search. +**No per-topic collections.** qmd uses one user-wide collection and one +collection for each repository that has memory. This split enforces storage +scope without adding topic-routing logic; `/daily` and `/` path contexts still +distinguish daily logs from long-term memory within each collection. **No semantic search for injection.** Keyword search (BM25) runs in ~30ms. Semantic search (vector) takes ~2s. For injection that runs every turn, latency @@ -419,5 +418,5 @@ complex architectures — at least for retrieval tasks. Our testing verifies the mechanics work. The open question is whether selective injection meaningfully improves recall in practice, and the eval infrastructure exists to answer it. -Total implementation: ~1,100 lines of TypeScript in a single file. Zero -dependencies beyond the pi runtime and optional qmd. +The implementation remains a single TypeScript file with no runtime dependencies +beyond pi and optional qmd. diff --git a/index.ts b/index.ts index 3ad496d..4a70c73 100644 --- a/index.ts +++ b/index.ts @@ -5,11 +5,11 @@ * Core memory tools (write/read/scratchpad) work without qmd installed. * The memory_search tool requires qmd for keyword, semantic, and hybrid search. * - * Layout (under ~/.pi/agent/memory/): + * Layout (under user-wide ~/.pi/agent/memory/ and repository .pi/agent/memory/): * MEMORY.md — curated long-term memory (decisions, preferences, durable facts) * SCRATCHPAD.md — checklist of things to keep in mind / fix later * daily/YYYY-MM-DD.md — daily append-only log (today + yesterday loaded at session start) - * recovery/*.json — durable records for restoring memory_forget deletions + * recovery/*.json — user-private durable records for restoring deletions (never stored in repositories) * * Tools: * memory_write — write to MEMORY.md or daily log @@ -24,12 +24,13 @@ */ import { type ExecFileOptions, execFile } from "node:child_process"; -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import * as fs from "node:fs"; import * as path from "node:path"; import { type Message, StringEnum, Type } from "@earendil-works/pi-ai"; import { complete } from "@earendil-works/pi-ai/compat"; import { + CONFIG_DIR_NAME, convertToLlm, type ExtensionAPI, type ExtensionContext, @@ -54,7 +55,76 @@ export function resolveMemoryDir(env: MemoryEnv = process.env): string { env.USERPROFILE ?? (env.HOMEDRIVE && env.HOMEPATH ? `${env.HOMEDRIVE}${env.HOMEPATH}` : undefined) ?? "~"; - return path.join(home, ".pi", "agent", "memory"); + return path.join(home, CONFIG_DIR_NAME, "agent", "memory"); +} + +export type MemoryScope = "user" | "repo"; +type MemorySearchScope = MemoryScope | "all"; + +interface MemoryPaths { + scope: MemoryScope; + dir: string; + memoryFile: string; + scratchpadFile: string; + dailyDir: string; + recoveryDir: string; + collectionName: string; +} + +function memoryPaths(scope: MemoryScope, dir: string, collectionName: string): MemoryPaths { + return { + scope, + dir, + memoryFile: path.join(dir, "MEMORY.md"), + scratchpadFile: path.join(dir, "SCRATCHPAD.md"), + dailyDir: path.join(dir, "daily"), + recoveryDir: path.join(dir, "recovery"), + collectionName, + }; +} + +/** Resolve the nearest git repository root, or use cwd for non-git projects. */ +export function resolveRepositoryRoot(cwd: string): string { + const start = path.resolve(cwd); + let current = start; + while (true) { + if (fs.existsSync(path.join(current, ".git"))) return current; + const parent = path.dirname(current); + if (parent === current) return start; + current = parent; + } +} + +/** Stable qmd collection name for one repository. */ +export function repoCollectionName(repoRoot: string): string { + const digest = createHash("sha256").update(path.resolve(repoRoot)).digest("hex").slice(0, 12); + return `pi-memory-repo-${digest}`; +} + +/** Resolve .pi/agent/memory under the nearest repository root. */ +export function resolveRepoMemoryDir(cwd: string): string { + return path.join(resolveRepositoryRoot(cwd), CONFIG_DIR_NAME, "agent", "memory"); +} + +const USER_SCOPE_PATTERNS = [ + /\bglobally\b/i, + /\buser[- ](?:wide|level)\b/i, + /\buser memory\b/i, + /\bacross\s+(?:all\s+)?(?:repos?|repositories|projects?|codebases)\b/i, + /\b(?:all|every)\s+(?:repos?|repositories|projects?|codebases)\b/i, +]; +const REPO_SCOPE_PATTERNS = [ + /\b(?:this|current|the current)\s+(?:repos?|repository|projects?|codebase)\b/i, + /\b(?:repos?|repository|project|codebase)[ -]specific\b/i, + /\b(?:repo|repository|project) memory\b/i, + /\b(?:only|locally)\s+(?:in|for|to)\s+(?:this|the current)\s+(?:repo|repository|project|codebase)\b/i, +]; + +/** Infer an explicitly requested memory scope from natural-language clues. */ +export function inferMemoryScope(prompt: string): MemoryScope | null { + if (USER_SCOPE_PATTERNS.some((pattern) => pattern.test(prompt))) return "user"; + if (REPO_SCOPE_PATTERNS.some((pattern) => pattern.test(prompt))) return "repo"; + return null; } let MEMORY_DIR = resolveMemoryDir(); @@ -77,14 +147,47 @@ export function _resetBaseDir() { _setBaseDir(resolveMemoryDir()); } +function getUserMemoryPaths(): MemoryPaths { + return { + scope: "user", + dir: MEMORY_DIR, + memoryFile: MEMORY_FILE, + scratchpadFile: SCRATCHPAD_FILE, + dailyDir: DAILY_DIR, + recoveryDir: RECOVERY_DIR, + collectionName: "pi-memory", + }; +} + +function getRepoMemoryPaths(cwd: string): MemoryPaths { + const repoRoot = resolveRepositoryRoot(cwd); + const paths = memoryPaths( + "repo", + path.join(repoRoot, CONFIG_DIR_NAME, "agent", "memory"), + repoCollectionName(repoRoot), + ); + // Repository memory is intentionally git-friendly, but recovery records can + // contain complete deleted content. Keep those private and outside the repo + // so a forgotten secret cannot be committed or preserved in Git history. + const recoveryKey = createHash("sha256").update(path.resolve(repoRoot)).digest("hex").slice(0, 24); + return { + ...paths, + recoveryDir: path.join(MEMORY_DIR, "recovery", "repos", recoveryKey), + }; +} + +function ensureMemoryDirs(paths: MemoryPaths) { + fs.mkdirSync(paths.dir, { recursive: true }); + fs.mkdirSync(paths.dailyDir, { recursive: true }); + fs.mkdirSync(paths.recoveryDir, { recursive: true }); +} + // --------------------------------------------------------------------------- // Utilities // --------------------------------------------------------------------------- export function ensureDirs() { - fs.mkdirSync(MEMORY_DIR, { recursive: true }); - fs.mkdirSync(DAILY_DIR, { recursive: true }); - fs.mkdirSync(RECOVERY_DIR, { recursive: true }); + ensureMemoryDirs(getUserMemoryPaths()); } // Daily logs are keyed by the user's LOCAL calendar day. toISOString() is UTC, @@ -134,11 +237,15 @@ export function isValidDailyDate(date: string): boolean { return parsed.getUTCFullYear() === year && parsed.getUTCMonth() === month - 1 && parsed.getUTCDate() === day; } -export function dailyPath(date: string): string { +function dailyPathFor(paths: MemoryPaths, date: string): string { if (!isValidDailyDate(date)) { throw new Error(`Invalid daily date: ${date}. Expected YYYY-MM-DD.`); } - return path.join(DAILY_DIR, `${date}.md`); + return path.join(paths.dailyDir, `${date}.md`); +} + +export function dailyPath(date: string): string { + return dailyPathFor(getUserMemoryPaths(), date); } // --------------------------------------------------------------------------- @@ -610,6 +717,7 @@ interface RecoveryRecord { version: 1; id: string; createdAt: string; + scope?: MemoryScope; target: MemoryTarget; date?: string; removedContent: string[]; @@ -724,9 +832,9 @@ export function forgetBlocks(content: string, match: string): { content: string; }; } -function recoveryPath(recoveryId: string): string | null { +function recoveryPathFor(paths: MemoryPaths, recoveryId: string): string | null { if (!RECOVERY_ID_REGEX.test(recoveryId)) return null; - return path.join(RECOVERY_DIR, `${recoveryId}.json`); + return path.join(paths.recoveryDir, `${recoveryId}.json`); } function isRecoveryRecord(value: unknown): value is RecoveryRecord { @@ -736,6 +844,7 @@ function isRecoveryRecord(value: unknown): value is RecoveryRecord { record.version === 1 && typeof record.id === "string" && RECOVERY_ID_REGEX.test(record.id) && + (record.scope === undefined || record.scope === "user" || record.scope === "repo") && (record.target === "long_term" || record.target === "daily") && (record.target !== "daily" || (typeof record.date === "string" && isValidDailyDate(record.date))) && Array.isArray(record.removedContent) && @@ -744,67 +853,86 @@ function isRecoveryRecord(value: unknown): value is RecoveryRecord { ); } -function writeRecoveryRecord(target: MemoryTarget, date: string | undefined, removedContent: string[]): RecoveryRecord { +function writeRecoveryRecord( + paths: MemoryPaths, + target: MemoryTarget, + date: string | undefined, + removedContent: string[], +): RecoveryRecord { const record: RecoveryRecord = { version: 1, id: randomUUID(), createdAt: new Date().toISOString(), + scope: paths.scope, target, ...(date ? { date } : {}), removedContent, }; - const filePath = recoveryPath(record.id); + const filePath = recoveryPathFor(paths, record.id); if (!filePath) throw new Error("Failed to create a valid recovery ID."); fs.writeFileSync(filePath, `${JSON.stringify(record, null, 2)}\n`, { encoding: "utf-8", flag: "wx" }); return record; } -function readRecoveryRecord(recoveryId: string): { record: RecoveryRecord; filePath: string } | null { - const filePath = recoveryPath(recoveryId); - if (!filePath) return null; - const content = readFileSafe(filePath); - if (!content) return null; - try { - const record: unknown = JSON.parse(content); - if (!isRecoveryRecord(record) || record.id !== recoveryId) return null; - return { record, filePath }; - } catch { - return null; +function readRecoveryRecord( + recoveryId: string, + candidatePaths: readonly MemoryPaths[], +): { record: RecoveryRecord; filePath: string; paths: MemoryPaths } | null { + for (const paths of candidatePaths) { + const filePath = recoveryPathFor(paths, recoveryId); + if (!filePath) return null; + const content = readFileSafe(filePath); + if (!content) continue; + try { + const record: unknown = JSON.parse(content); + if (!isRecoveryRecord(record) || record.id !== recoveryId) continue; + return { record, filePath, paths }; + } catch {} } + return null; } // --------------------------------------------------------------------------- // Context builder // --------------------------------------------------------------------------- -export function buildMemoryContext(searchResults?: string): string { +export function buildMemoryContext(searchResults?: string, repoMemoryDir?: string): string { ensureDirs(); - // Priority order: scratchpad > today's daily > search results > MEMORY.md > yesterday's daily + const userPaths = getUserMemoryPaths(); + const repoPaths = repoMemoryDir ? memoryPaths("repo", repoMemoryDir, "") : null; + const scoped = repoPaths !== null; + // Priority order favors repository-specific context over user-wide context. const sections: string[] = []; + const pathsInPriorityOrder = repoPaths ? [repoPaths, userPaths] : [userPaths]; + const scopeLabel = (paths: MemoryPaths) => (paths.scope === "repo" ? "Repository" : "User"); - const scratchpad = readFileSafe(SCRATCHPAD_FILE); - if (scratchpad?.trim()) { + for (const paths of pathsInPriorityOrder) { + const scratchpad = readFileSafe(paths.scratchpadFile); + if (!scratchpad?.trim()) continue; const openItems = parseScratchpad(scratchpad).filter((i) => !i.done); - if (openItems.length > 0) { - const serialized = serializeScratchpad(openItems); - const section = formatContextSection( - "## SCRATCHPAD.md (working context)", - serialized, - "start", - CONTEXT_SCRATCHPAD_MAX_LINES, - CONTEXT_SCRATCHPAD_MAX_CHARS, - ); - if (section) sections.push(section); - } + if (openItems.length === 0) continue; + const serialized = serializeScratchpad(openItems); + const label = scoped + ? `## ${scopeLabel(paths)} SCRATCHPAD.md (working context)` + : "## SCRATCHPAD.md (working context)"; + const section = formatContextSection( + label, + serialized, + "start", + CONTEXT_SCRATCHPAD_MAX_LINES, + CONTEXT_SCRATCHPAD_MAX_CHARS, + ); + if (section) sections.push(section); } const today = todayStr(); const yesterday = yesterdayStr(); - - const todayContent = readFileSafe(dailyPath(today)); - if (todayContent?.trim()) { + for (const paths of pathsInPriorityOrder) { + const todayContent = readFileSafe(dailyPathFor(paths, today)); + if (!todayContent?.trim()) continue; + const label = scoped ? `## ${scopeLabel(paths)} daily log: ${today} (today)` : `## Daily log: ${today} (today)`; const section = formatContextSection( - `## Daily log: ${today} (today)`, + label, todayContent, "end", CONTEXT_DAILY_MAX_LINES, @@ -824,10 +952,12 @@ export function buildMemoryContext(searchResults?: string): string { if (section) sections.push(section); } - const longTerm = readFileSafe(MEMORY_FILE); - if (longTerm?.trim()) { + for (const paths of pathsInPriorityOrder) { + const longTerm = readFileSafe(paths.memoryFile); + if (!longTerm?.trim()) continue; + const label = scoped ? `## ${scopeLabel(paths)} MEMORY.md (long-term)` : "## MEMORY.md (long-term)"; const section = formatContextSection( - "## MEMORY.md (long-term)", + label, longTerm, "middle", CONTEXT_LONG_TERM_MAX_LINES, @@ -836,10 +966,14 @@ export function buildMemoryContext(searchResults?: string): string { if (section) sections.push(section); } - const yesterdayContent = readFileSafe(dailyPath(yesterday)); - if (yesterdayContent?.trim()) { + for (const paths of pathsInPriorityOrder) { + const yesterdayContent = readFileSafe(dailyPathFor(paths, yesterday)); + if (!yesterdayContent?.trim()) continue; + const label = scoped + ? `## ${scopeLabel(paths)} daily log: ${yesterday} (yesterday)` + : `## Daily log: ${yesterday} (yesterday)`; const section = formatContextSection( - `## Daily log: ${yesterday} (yesterday)`, + label, yesterdayContent, "end", CONTEXT_DAILY_MAX_LINES, @@ -1019,8 +1153,8 @@ export function qmdInstallInstructions(): string { " npm install -g @tobilu/qmd # no Bun needed", ` bun install -g ${QMD_REPO_URL} # ensure ~/.bun/bin is on PATH`, "", - "The extension auto-creates the collection on next session start.", - "To set it up manually instead:", + "The extension auto-creates the required user and repository collections on the next session start.", + "To set up the user-wide collection manually instead:", ` qmd collection add ${MEMORY_DIR} --name pi-memory`, " qmd embed", ].join("\n"); @@ -1028,7 +1162,7 @@ export function qmdInstallInstructions(): string { export function qmdCollectionInstructions(): string { return [ - "qmd collection pi-memory is not configured.", + "The user-wide qmd collection pi-memory is not configured.", "", "Set up the collection (one-time):", ` qmd collection add ${MEMORY_DIR} --name pi-memory`, @@ -1036,12 +1170,15 @@ export function qmdCollectionInstructions(): string { ].join("\n"); } -/** Auto-create the pi-memory collection and path contexts in qmd. */ -export async function setupQmdCollection(): Promise { +/** Auto-create a memory collection and path contexts in qmd. */ +export async function setupQmdCollection(paths: MemoryPaths = getUserMemoryPaths()): Promise { try { await new Promise((resolve, reject) => { - execFileFn("qmd", ["collection", "add", MEMORY_DIR, "--name", "pi-memory"], { timeout: 10_000 }, (err) => - err ? reject(err) : resolve(), + execFileFn( + "qmd", + ["collection", "add", paths.dir, "--name", paths.collectionName], + { timeout: 10_000 }, + (err) => (err ? reject(err) : resolve()), ); }); } catch { @@ -1050,24 +1187,26 @@ export async function setupQmdCollection(): Promise { } // Add path contexts (best-effort, ignore errors) + const scopeDescription = paths.scope === "repo" ? "Repository-scoped" : "User-wide"; const contexts: [string, string][] = [ - ["/daily", "Daily append-only work logs organized by date"], - ["/", "Curated long-term memory: decisions, preferences, facts, lessons"], + ["/daily", `${scopeDescription} daily append-only work logs organized by date`], + ["/", `${scopeDescription} curated long-term memory: decisions, preferences, facts, lessons`], ]; for (const [ctxPath, desc] of contexts) { try { await new Promise((resolve, reject) => { - execFileFn("qmd", ["context", "add", ctxPath, desc, "-c", "pi-memory"], { timeout: 10_000 }, (err) => - err ? reject(err) : resolve(), + execFileFn( + "qmd", + ["context", "add", ctxPath, desc, "-c", paths.collectionName], + { timeout: 10_000 }, + (err) => (err ? reject(err) : resolve()), ); }); } catch { // Ignore — context may already exist } } - // Seed the cache so checkCollection("pi-memory") doesn't redundantly re-run - // setupQmdCollection during the short negative-cache window. - qmdCollectionStatusCache.set("pi-memory", { checkedAt: Date.now(), exists: true }); + qmdCollectionStatusCache.set(paths.collectionName, { checkedAt: Date.now(), exists: true }); return true; } @@ -1125,6 +1264,16 @@ export function checkCollection(name: string): Promise { }); } +async function ensureQmdCollection(paths: MemoryPaths): Promise { + if (await checkCollection(paths.collectionName)) return true; + return setupQmdCollection(paths); +} + +async function ensureMemoryCollectionForUpdate(paths: MemoryPaths): Promise { + if (!(await ensureQmdAvailableForUpdate())) return false; + return ensureQmdCollection(paths); +} + // `qmd embed` is incremental: it only embeds new/changed chunks and no-ops in // well under a second when everything is current. The first run ever may // download the embedding model, hence the generous timeout. @@ -1190,7 +1339,7 @@ async function runQmdUpdateNow() { } /** Search for memories relevant to the user's prompt. Returns formatted markdown or empty string on error. */ -export async function searchRelevantMemories(prompt: string): Promise { +export async function searchRelevantMemories(prompt: string, cwd?: string): Promise { if (!qmdAvailable || !prompt.trim()) return ""; // Sanitize: strip control chars, limit to 200 chars for the search query @@ -1203,11 +1352,16 @@ export async function searchRelevantMemories(prompt: string): Promise { let timer: ReturnType | undefined; try { - const hasCollection = await checkCollection("pi-memory"); - if (!hasCollection) return ""; + const repoPaths = cwd ? getRepoMemoryPaths(cwd) : activeRepoPaths; + const candidatePaths = [getUserMemoryPaths(), ...(repoPaths && fs.existsSync(repoPaths.dir) ? [repoPaths] : [])]; + const collectionNames: string[] = []; + for (const paths of candidatePaths) { + if (await checkCollection(paths.collectionName)) collectionNames.push(paths.collectionName); + } + if (collectionNames.length === 0) return ""; const results = await Promise.race([ - runQmdSearch("keyword", sanitized, 3), + runQmdSearch("keyword", sanitized, 3, collectionNames), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("timeout")), 3_000); }), @@ -1292,9 +1446,11 @@ export function runQmdSearch( mode: "keyword" | "semantic" | "deep", query: string, limit: number, + collectionNames: readonly string[] = ["pi-memory"], ): Promise<{ results: QmdSearchResult[]; stderr: string }> { const subcommand = mode === "keyword" ? "search" : mode === "semantic" ? "vsearch" : "query"; - const args = [subcommand, "--json", "-c", "pi-memory", "-n", String(limit), query]; + const collectionArgs = collectionNames.flatMap((name) => ["-c", name]); + const args = [subcommand, "--json", ...collectionArgs, "-n", String(limit), query]; const timeoutMs = getQmdSearchTimeoutMs(); return new Promise((resolve, reject) => { @@ -1331,11 +1487,13 @@ export function runQmdSearch( * "ready" means a probe query ran without qmd's "need embeddings" warning — * it does not prove the index has content. */ -export async function probeEmbeddings(): Promise<"ready" | "missing" | "unknown"> { +export async function probeEmbeddings( + collectionNames: readonly string[] = ["pi-memory"], +): Promise<"ready" | "missing" | "unknown"> { let timer: ReturnType | undefined; try { const { stderr } = await Promise.race([ - runQmdSearch("semantic", "memory", 1), + runQmdSearch("semantic", "memory", 1, collectionNames), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("timeout")), 4_000); }), @@ -1350,29 +1508,30 @@ export async function probeEmbeddings(): Promise<"ready" | "missing" | "unknown" } } -/** Collect a fast on-disk inventory of the memory files (no qmd needed). */ -export function getMemoryInventory(): { +interface MemoryInventory { dir: string; longTermChars: number; scratchpadOpen: number; scratchpadTotal: number; dailyCount: number; latestDaily: string | null; -} { - const longTerm = readFileSafe(MEMORY_FILE) ?? ""; - const scratchpad = readFileSafe(SCRATCHPAD_FILE) ?? ""; +} + +function getMemoryInventoryFor(paths: MemoryPaths): MemoryInventory { + const longTerm = readFileSafe(paths.memoryFile) ?? ""; + const scratchpad = readFileSafe(paths.scratchpadFile) ?? ""; const items = parseScratchpad(scratchpad); let dailyFiles: string[] = []; try { dailyFiles = fs - .readdirSync(DAILY_DIR) + .readdirSync(paths.dailyDir) .filter((f) => f.endsWith(".md")) .sort(); } catch { dailyFiles = []; } return { - dir: MEMORY_DIR, + dir: paths.dir, longTermChars: longTerm.trim().length, scratchpadOpen: items.filter((i) => !i.done).length, scratchpadTotal: items.length, @@ -1381,6 +1540,11 @@ export function getMemoryInventory(): { }; } +/** Collect a fast on-disk inventory of user-wide memory files (no qmd needed). */ +export function getMemoryInventory(): MemoryInventory { + return getMemoryInventoryFor(getUserMemoryPaths()); +} + // --------------------------------------------------------------------------- // Memory snapshot (Option P: KV cache-stable context injection) // @@ -1396,9 +1560,29 @@ let snapshotTakenAt: string | null = null; let snapshotTakenOnDate: string | null = null; let snapshotReason: string | null = null; let snapshotDirty = false; +let activePromptMemoryScope: MemoryScope | null = null; +let activeRepoPaths: MemoryPaths | null = null; + +function repoPathsFromContext(ctx: Partial): MemoryPaths | null { + return typeof ctx.cwd === "string" && ctx.cwd.trim() ? getRepoMemoryPaths(ctx.cwd) : activeRepoPaths; +} -function refreshMemorySnapshot(reason: string) { - memorySnapshot = buildMemoryContext(""); +function selectedMemoryScope(explicitScope: MemoryScope | undefined): MemoryScope { + return explicitScope ?? activePromptMemoryScope ?? "user"; +} + +function selectedMemoryPaths(explicitScope: MemoryScope | undefined, ctx: Partial): MemoryPaths { + const scope = selectedMemoryScope(explicitScope); + if (scope === "user") return getUserMemoryPaths(); + const repoPaths = repoPathsFromContext(ctx); + if (!repoPaths) { + throw new Error("Repository-scoped memory requires a session working directory."); + } + return repoPaths; +} + +function refreshMemorySnapshot(reason: string, repoPaths: MemoryPaths | null = activeRepoPaths) { + memorySnapshot = buildMemoryContext("", repoPaths?.dir); snapshotTakenAt = nowTimestamp(); snapshotTakenOnDate = todayStr(); snapshotReason = reason; @@ -1417,6 +1601,8 @@ export function _resetMemorySnapshot() { snapshotTakenOnDate = null; snapshotReason = null; snapshotDirty = false; + activePromptMemoryScope = null; + activeRepoPaths = null; } // --------------------------------------------------------------------------- @@ -1424,9 +1610,14 @@ export function _resetMemorySnapshot() { // --------------------------------------------------------------------------- export default function (pi: ExtensionAPI) { + activePromptMemoryScope = null; + activeRepoPaths = null; + // --- session_start: detect qmd, auto-setup collection --- pi.on("session_start", async (_event, ctx) => { exitSummaryReason = null; + activePromptMemoryScope = null; + activeRepoPaths = typeof ctx.cwd === "string" && ctx.cwd.trim() ? getRepoMemoryPaths(ctx.cwd) : null; if (terminalInputUnsubscribe) { terminalInputUnsubscribe(); terminalInputUnsubscribe = null; @@ -1450,9 +1641,9 @@ export default function (pi: ExtensionAPI) { return; } - const hasCollection = await checkCollection("pi-memory"); - if (!hasCollection) { - await setupQmdCollection(); + await ensureQmdCollection(getUserMemoryPaths()); + if (activeRepoPaths && fs.existsSync(activeRepoPaths.dir)) { + await ensureQmdCollection(activeRepoPaths); } // Catch-up embed: covers writes from previous sessions (shutdown skips // embedding) and fresh installs where the collection exists but was @@ -1537,7 +1728,11 @@ export default function (pi: ExtensionAPI) { }); // --- Inject memory context before every agent turn --- - pi.on("before_agent_start", async (event, _ctx) => { + pi.on("before_agent_start", async (event, ctx) => { + activePromptMemoryScope = inferMemoryScope(event.prompt ?? ""); + if (typeof ctx.cwd === "string" && ctx.cwd.trim()) { + activeRepoPaths = getRepoMemoryPaths(ctx.cwd); + } const mode = getSnapshotMode(); let memoryContext: string; @@ -1545,8 +1740,8 @@ export default function (pi: ExtensionAPI) { if (mode === "per-turn") { const skipSearch = process.env.PI_MEMORY_NO_SEARCH === "1"; - const searchResults = skipSearch ? "" : await searchRelevantMemories(event.prompt ?? ""); - memoryContext = buildMemoryContext(searchResults); + const searchResults = skipSearch ? "" : await searchRelevantMemories(event.prompt ?? "", ctx.cwd); + memoryContext = buildMemoryContext(searchResults, activeRepoPaths?.dir); } else { const today = todayStr(); const needsRefresh = memorySnapshot === null || snapshotDirty || snapshotTakenOnDate !== today; @@ -1567,10 +1762,12 @@ export default function (pi: ExtensionAPI) { const headerLines = ["\n\n## Memory"]; if (snapshotCaveat) headerLines.push(`(${snapshotCaveat})`); headerLines.push( - "The following memory files have been loaded. Use the memory_write tool to persist important information.", + "The following user-wide and repository memory files have been loaded. Use memory_write to persist important information.", "- Decisions, preferences, and durable facts \u2192 MEMORY.md", "- Day-to-day notes and running context \u2192 daily/.md", "- Things to fix later or keep in mind \u2192 scratchpad tool", + "- Requests such as 'remember this for this repo/project/codebase' \u2192 scope='repo' (.pi/agent/memory in the repository root).", + "- Global or cross-repository requests \u2192 scope='user' (~/.pi/agent/memory). Neutral requests default to user scope.", "- Use memory_search to find past context across all memory files (keyword, semantic, or deep search).", "- Use #tags (e.g. #decision, #preference) and [[links]] (e.g. [[auth-strategy]]) in memory content to improve future search recall.", '- If someone says "remember this," write it immediately.', @@ -1636,9 +1833,10 @@ export default function (pi: ExtensionAPI) { name: "memory_write", label: "Memory Write", description: [ - "Write to memory files. Two targets:", + "Write to user-wide or repository-scoped memory files. Two targets:", "- 'long_term': Write to MEMORY.md (curated durable facts, decisions, preferences). Mode: 'append' or 'overwrite'.", "- 'daily': Append to today's daily log (daily/.md). Always appends.", + "Scope defaults to 'user'. Use scope='repo' when the request says 'for this repo/project/codebase' or otherwise makes the memory repository-specific. Use scope='user' for global, cross-repository preferences.", "Use this when the user asks you to remember something, or when you learn important preferences/decisions.", "Use #tags (e.g. #decision, #preference, #lesson, #bug) and [[links]] (e.g. [[auth-strategy]]) in content to improve searchability.", ].join("\n"), @@ -1652,15 +1850,22 @@ export default function (pi: ExtensionAPI) { description: "Write mode for long_term target. Default: 'append'. Daily always appends.", }), ), + scope: Type.Optional( + StringEnum(["user", "repo"] as const, { + description: + "Memory scope. Use 'repo' for this repository/project/codebase; use 'user' for global cross-repository memory. Inferred from the active user request when omitted.", + }), + ), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - ensureDirs(); + const paths = selectedMemoryPaths(params.scope, ctx); + ensureMemoryDirs(paths); const { target, content, mode } = params; const sid = shortSessionId(ctx.sessionManager.getSessionId()); const ts = nowTimestamp(); if (target === "daily") { - const filePath = dailyPath(todayStr()); + const filePath = dailyPathFor(paths, todayStr()); const existing = readFileSafe(filePath) ?? ""; const existingPreview = buildPreview(existing, { maxLines: RESPONSE_PREVIEW_MAX_LINES, @@ -1674,7 +1879,7 @@ export default function (pi: ExtensionAPI) { const separator = existing.trim() ? "\n\n" : ""; const stamped = `\n${content}`; fs.writeFileSync(filePath, existing + separator + stamped, "utf-8"); - await ensureQmdAvailableForUpdate(); + await ensureMemoryCollectionForUpdate(paths); scheduleQmdUpdate(); return { content: [ @@ -1685,6 +1890,7 @@ export default function (pi: ExtensionAPI) { ], details: { path: filePath, + scope: paths.scope, target, mode: "append", sessionId: sid, @@ -1696,7 +1902,7 @@ export default function (pi: ExtensionAPI) { } // long_term - const existing = readFileSafe(MEMORY_FILE) ?? ""; + const existing = readFileSafe(paths.memoryFile) ?? ""; const existingPreview = buildPreview(existing, { maxLines: RESPONSE_PREVIEW_MAX_LINES, maxChars: RESPONSE_PREVIEW_MAX_CHARS, @@ -1714,13 +1920,14 @@ export default function (pi: ExtensionAPI) { if (mode === "overwrite") { const stamped = `\n${content}`; - fs.writeFileSync(MEMORY_FILE, stamped, "utf-8"); - await ensureQmdAvailableForUpdate(); + fs.writeFileSync(paths.memoryFile, stamped, "utf-8"); + await ensureMemoryCollectionForUpdate(paths); scheduleQmdUpdate(); return { content: [{ type: "text", text: `Overwrote MEMORY.md${existingSnippet}` }], details: { - path: MEMORY_FILE, + path: paths.memoryFile, + scope: paths.scope, target, mode: "overwrite", sessionId: sid, @@ -1734,13 +1941,14 @@ export default function (pi: ExtensionAPI) { // append (default) const separator = existing.trim() ? "\n\n" : ""; const stamped = `\n${content}`; - fs.writeFileSync(MEMORY_FILE, existing + separator + stamped, "utf-8"); - await ensureQmdAvailableForUpdate(); + fs.writeFileSync(paths.memoryFile, existing + separator + stamped, "utf-8"); + await ensureMemoryCollectionForUpdate(paths); scheduleQmdUpdate(); return { content: [{ type: "text", text: `Appended to MEMORY.md${existingSnippet}` }], details: { - path: MEMORY_FILE, + path: paths.memoryFile, + scope: paths.scope, target, mode: "append", sessionId: sid, @@ -1757,7 +1965,7 @@ export default function (pi: ExtensionAPI) { name: "scratchpad", label: "Scratchpad", description: [ - "Manage a checklist of things to fix later or keep in mind. Actions:", + "Manage a user-wide or repository-scoped checklist of things to fix later or keep in mind. Scope defaults to user and follows repository clues in the active request. Actions:", "- 'add': Add a new unchecked item (- [ ] text)", "- 'done': Mark an item as done (- [x] text). Match by substring.", "- 'undo': Uncheck a done item back to open. Match by substring.", @@ -1773,14 +1981,20 @@ export default function (pi: ExtensionAPI) { description: "Item text for add, or substring to match for done/undo", }), ), + scope: Type.Optional( + StringEnum(["user", "repo"] as const, { + description: "Checklist scope. Use 'repo' for this repository and 'user' for the user-wide checklist.", + }), + ), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - ensureDirs(); + const paths = selectedMemoryPaths(params.scope, ctx); + ensureMemoryDirs(paths); const { action, text } = params; const sid = shortSessionId(ctx.sessionManager.getSessionId()); const ts = nowTimestamp(); - const existing = readFileSafe(SCRATCHPAD_FILE) ?? ""; + const existing = readFileSafe(paths.scratchpadFile) ?? ""; const items = parseScratchpad(existing); if (action === "list") { @@ -1824,8 +2038,8 @@ export default function (pi: ExtensionAPI) { maxChars: RESPONSE_PREVIEW_MAX_CHARS, mode: "start", }); - fs.writeFileSync(SCRATCHPAD_FILE, serialized, "utf-8"); - await ensureQmdAvailableForUpdate(); + fs.writeFileSync(paths.scratchpadFile, serialized, "utf-8"); + await ensureMemoryCollectionForUpdate(paths); scheduleQmdUpdate(); return { content: [ @@ -1836,6 +2050,8 @@ export default function (pi: ExtensionAPI) { ], details: { action, + scope: paths.scope, + path: paths.scratchpadFile, sessionId: sid, timestamp: ts, qmdUpdateMode: getQmdUpdateMode(), @@ -1875,8 +2091,8 @@ export default function (pi: ExtensionAPI) { maxChars: RESPONSE_PREVIEW_MAX_CHARS, mode: "start", }); - fs.writeFileSync(SCRATCHPAD_FILE, serialized, "utf-8"); - await ensureQmdAvailableForUpdate(); + fs.writeFileSync(paths.scratchpadFile, serialized, "utf-8"); + await ensureMemoryCollectionForUpdate(paths); scheduleQmdUpdate(); return { content: [ @@ -1887,6 +2103,8 @@ export default function (pi: ExtensionAPI) { ], details: { action, + scope: paths.scope, + path: paths.scratchpadFile, sessionId: sid, timestamp: ts, qmdUpdateMode: getQmdUpdateMode(), @@ -1904,8 +2122,8 @@ export default function (pi: ExtensionAPI) { maxChars: RESPONSE_PREVIEW_MAX_CHARS, mode: "start", }); - fs.writeFileSync(SCRATCHPAD_FILE, serialized, "utf-8"); - await ensureQmdAvailableForUpdate(); + fs.writeFileSync(paths.scratchpadFile, serialized, "utf-8"); + await ensureMemoryCollectionForUpdate(paths); scheduleQmdUpdate(); return { content: [ @@ -1916,6 +2134,8 @@ export default function (pi: ExtensionAPI) { ], details: { action, + scope: paths.scope, + path: paths.scratchpadFile, removed, qmdUpdateMode: getQmdUpdateMode(), preview, @@ -1935,7 +2155,7 @@ export default function (pi: ExtensionAPI) { name: "memory_read", label: "Memory Read", description: [ - "Read a memory file. Targets:", + "Read a user-wide or repository-scoped memory file. Scope defaults to user and follows repository clues in the active request. Targets:", "- 'long_term': Read MEMORY.md", "- 'scratchpad': Read SCRATCHPAD.md", "- 'daily': Read a specific day's log (default: today). Pass date as YYYY-MM-DD.", @@ -1950,15 +2170,21 @@ export default function (pi: ExtensionAPI) { description: "Date for daily log (YYYY-MM-DD). Default: today.", }), ), + scope: Type.Optional( + StringEnum(["user", "repo"] as const, { + description: "Memory scope to read: 'user' (default) or 'repo' for the current repository.", + }), + ), }), - async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { - ensureDirs(); + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const paths = selectedMemoryPaths(params.scope, ctx); + ensureMemoryDirs(paths); const { target, date } = params; if (target === "list") { try { const files = fs - .readdirSync(DAILY_DIR) + .readdirSync(paths.dailyDir) .filter((f) => f.endsWith(".md")) .sort() .reverse(); @@ -1975,7 +2201,7 @@ export default function (pi: ExtensionAPI) { text: `Daily logs:\n${files.map((f) => `- ${f}`).join("\n")}`, }, ], - details: { files }, + details: { files, scope: paths.scope, dir: paths.dailyDir }, }; } catch { return { @@ -1994,7 +2220,7 @@ export default function (pi: ExtensionAPI) { details: { date: d }, }; } - const filePath = dailyPath(d); + const filePath = dailyPathFor(paths, d); const content = readFileSafe(filePath); if (!content) { return { @@ -2004,12 +2230,12 @@ export default function (pi: ExtensionAPI) { } return { content: [{ type: "text", text: content }], - details: { path: filePath, date: d }, + details: { path: filePath, date: d, scope: paths.scope }, }; } if (target === "scratchpad") { - const content = readFileSafe(SCRATCHPAD_FILE); + const content = readFileSafe(paths.scratchpadFile); if (!content?.trim()) { return { content: [ @@ -2023,12 +2249,12 @@ export default function (pi: ExtensionAPI) { } return { content: [{ type: "text", text: content }], - details: { path: SCRATCHPAD_FILE }, + details: { path: paths.scratchpadFile, scope: paths.scope }, }; } // long_term - const content = readFileSafe(MEMORY_FILE); + const content = readFileSafe(paths.memoryFile); if (!content) { return { content: [{ type: "text", text: "MEMORY.md is empty or does not exist." }], @@ -2037,7 +2263,7 @@ export default function (pi: ExtensionAPI) { } return { content: [{ type: "text", text: content }], - details: { path: MEMORY_FILE }, + details: { path: paths.memoryFile, scope: paths.scope }, }; }, }); @@ -2047,7 +2273,7 @@ export default function (pi: ExtensionAPI) { name: "memory_forget", label: "Memory Forget", description: [ - "Delete outdated or incorrect facts from memory. Removes every entry/paragraph", + "Delete outdated or incorrect facts from user-wide or repository-scoped memory. Scope defaults to user and follows repository clues in the active request. Removes every entry/paragraph", "containing the match string (case-insensitive substring) from MEMORY.md, or from", "a daily log when target='daily'. Every deletion creates a durable recovery record", "whose visible recovery ID can be passed to memory_restore if the deletion was wrong.", @@ -2066,9 +2292,15 @@ export default function (pi: ExtensionAPI) { date: Type.Optional( Type.String({ description: "Daily log date (YYYY-MM-DD) when target='daily'. Default: today." }), ), + scope: Type.Optional( + StringEnum(["user", "repo"] as const, { + description: "Memory scope to change: 'user' (default) or 'repo' for the current repository.", + }), + ), }), - async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { - ensureDirs(); + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const paths = selectedMemoryPaths(params.scope, ctx); + ensureMemoryDirs(paths); const target: MemoryTarget = params.target ?? "long_term"; if (!params.match.trim()) { return { @@ -2088,10 +2320,10 @@ export default function (pi: ExtensionAPI) { details: { date: d }, }; } - filePath = dailyPath(d); + filePath = dailyPathFor(paths, d); recoveryDate = d; } else { - filePath = MEMORY_FILE; + filePath = paths.memoryFile; } const existing = readFileSafe(filePath); @@ -2112,13 +2344,13 @@ export default function (pi: ExtensionAPI) { // Persist the complete recovery payload before mutating the source file. // If either write fails, we never report a successful unrecoverable deletion. - const recovery = writeRecoveryRecord(target, recoveryDate, result.removed); + const recovery = writeRecoveryRecord(paths, target, recoveryDate, result.removed); fs.writeFileSync(filePath, result.content, "utf-8"); // Deleted facts must leave the injected snapshot too, whichever file // they lived in — a forgotten-but-still-injected memory defeats the // point of forgetting. snapshotDirty = true; - await ensureQmdAvailableForUpdate(); + await ensureMemoryCollectionForUpdate(paths); scheduleQmdUpdate(); const removedPreview = buildPreview(result.removed.join("\n\n"), { @@ -2139,10 +2371,11 @@ export default function (pi: ExtensionAPI) { ], details: { path: filePath, + scope: paths.scope, target, removed: result.removed.length, recoveryId: recovery.id, - recoveryPath: recoveryPath(recovery.id), + recoveryPath: recoveryPathFor(paths, recovery.id), removedPreview, }, }; @@ -2159,10 +2392,19 @@ export default function (pi: ExtensionAPI) { ].join("\n"), parameters: Type.Object({ recoveryId: Type.String({ description: "Recovery ID returned by memory_forget" }), + scope: Type.Optional( + StringEnum(["user", "repo"] as const, { + description: "Optional scope hint. By default recovery records are searched in both scopes.", + }), + ), }), - async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { ensureDirs(); - const loaded = readRecoveryRecord(params.recoveryId); + const repoPaths = repoPathsFromContext(ctx); + const candidatePaths = params.scope + ? [selectedMemoryPaths(params.scope, ctx)] + : [...(repoPaths ? [repoPaths] : []), getUserMemoryPaths()]; + const loaded = readRecoveryRecord(params.recoveryId, candidatePaths); if (!loaded) { return { content: [{ type: "text", text: `No valid recovery record found for ID ${params.recoveryId}.` }], @@ -2171,7 +2413,7 @@ export default function (pi: ExtensionAPI) { }; } - const { record, filePath: recordPath } = loaded; + const { record, filePath: recordPath, paths } = loaded; if (record.restoredAt) { return { content: [{ type: "text", text: `Recovery ${record.id} was already restored at ${record.restoredAt}.` }], @@ -2179,14 +2421,14 @@ export default function (pi: ExtensionAPI) { }; } - const targetPath = record.target === "daily" ? dailyPath(record.date as string) : MEMORY_FILE; + const targetPath = record.target === "daily" ? dailyPathFor(paths, record.date as string) : paths.memoryFile; const existing = readFileSafe(targetPath) ?? ""; const missingEntries = record.removedContent.filter((entry) => !existing.includes(entry)); if (missingEntries.length > 0) { const separator = existing.trim() ? "\n\n" : ""; fs.writeFileSync(targetPath, `${existing}${separator}${missingEntries.join("\n\n")}\n`, "utf-8"); snapshotDirty = true; - await ensureQmdAvailableForUpdate(); + await ensureMemoryCollectionForUpdate(paths); scheduleQmdUpdate(); } @@ -2204,6 +2446,7 @@ export default function (pi: ExtensionAPI) { ], details: { recoveryId: record.id, + scope: record.scope ?? paths.scope, target: record.target, path: targetPath, restored: missingEntries.length, @@ -2217,7 +2460,7 @@ export default function (pi: ExtensionAPI) { name: "memory_search", label: "Memory Search", description: - "Search across all memory files (MEMORY.md, SCRATCHPAD.md, daily logs).\n" + + "Search user and repository memory files (MEMORY.md, SCRATCHPAD.md, daily logs). Scope defaults to 'all'.\n" + "Modes:\n" + "- 'keyword' (default, ~30ms): Fast BM25 search. Best for specific terms, dates, names, #tags, [[links]].\n" + "- 'semantic' (~2s): Meaning-based search. Finds related concepts even with different wording.\n" + @@ -2233,8 +2476,13 @@ export default function (pi: ExtensionAPI) { }), ), limit: Type.Optional(Type.Number({ description: "Max results (default: 5)" })), + scope: Type.Optional( + StringEnum(["all", "user", "repo"] as const, { + description: "Search scope. Default: 'all' (user and current repository memory).", + }), + ), }), - async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { if (!qmdAvailable) { // Re-check on demand in case qmd was installed after session start. qmdAvailable = await detectQmd(); @@ -2253,23 +2501,33 @@ export default function (pi: ExtensionAPI) { }; } - let hasCollection = await checkCollection("pi-memory"); - if (!hasCollection) { - const created = await setupQmdCollection(); - if (created) { - hasCollection = true; - } + const scope: MemorySearchScope = params.scope ?? "all"; + const repoPaths = repoPathsFromContext(ctx); + const pathsToSearch = [ + ...(scope === "all" || scope === "user" ? [getUserMemoryPaths()] : []), + ...(repoPaths && fs.existsSync(repoPaths.dir) && (scope === "all" || scope === "repo") ? [repoPaths] : []), + ]; + if (pathsToSearch.length === 0) { + return { + content: [{ type: "text", text: "No repository memory exists for the current repository." }], + details: { scope, count: 0 }, + }; + } + + const collectionNames: string[] = []; + for (const paths of pathsToSearch) { + if (await ensureQmdCollection(paths)) collectionNames.push(paths.collectionName); } - if (!hasCollection) { + if (collectionNames.length === 0) { return { content: [ { type: "text", - text: "Could not set up qmd pi-memory collection. Check that qmd is working and the memory directory exists.", + text: "Could not set up the requested qmd memory collections. Check that qmd is working and the memory directories exist.", }, ], isError: true, - details: {}, + details: { scope }, }; } @@ -2277,7 +2535,7 @@ export default function (pi: ExtensionAPI) { const limit = clampSearchLimit(params.limit); try { - const { results, stderr } = await runQmdSearch(mode, params.query, limit); + const { results, stderr } = await runQmdSearch(mode, params.query, limit, collectionNames); const needsEmbed = /need embeddings/i.test(stderr ?? ""); // Self-heal: any "need embeddings" warning (even with partial // results) kicks off an incremental background embed. @@ -2302,7 +2560,7 @@ export default function (pi: ExtensionAPI) { ].join("\n"), }, ], - details: { mode, query: params.query, count: 0, needsEmbed: true, embedStarted }, + details: { mode, scope, query: params.query, count: 0, needsEmbed: true, embedStarted }, }; } return { @@ -2312,7 +2570,7 @@ export default function (pi: ExtensionAPI) { text: `No results found for "${params.query}" (mode: ${mode}).`, }, ], - details: { mode, query: params.query, count: 0, needsEmbed }, + details: { mode, scope, query: params.query, count: 0, needsEmbed }, }; } @@ -2330,7 +2588,7 @@ export default function (pi: ExtensionAPI) { return { content: [{ type: "text", text: formatted }], - details: { mode, query: params.query, count: results.length, needsEmbed }, + details: { mode, scope, query: params.query, count: results.length, needsEmbed }, }; } catch (err) { return { @@ -2352,39 +2610,60 @@ export default function (pi: ExtensionAPI) { name: "memory_status", label: "Memory Status", description: - "Report the health of the memory system: where files live, what's stored, " + - "whether qmd search is available, whether the pi-memory collection exists, " + + "Report the health of user-wide and repository memory: where files live, what's stored, " + + "whether qmd search is available, whether the memory collections exist, " + "whether embeddings are ready, and the active configuration. " + "Use this when search behaves unexpectedly or to confirm setup.", parameters: Type.Object({}), - async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) { + async execute(_toolCallId, _params, _signal, _onUpdate, ctx) { ensureDirs(); - const inv = getMemoryInventory(); + const userPaths = getUserMemoryPaths(); + const repoPaths = repoPathsFromContext(ctx); + const user = getMemoryInventoryFor(userPaths); + const repo = repoPaths ? getMemoryInventoryFor(repoPaths) : null; const qmdOk = qmdAvailable || (await detectQmd()); - let collectionOk = false; + let userCollection = false; + let repoCollection = false; let embeddings: "ready" | "missing" | "unknown" | "n/a" = "n/a"; if (qmdOk) { - collectionOk = await checkCollection("pi-memory"); - embeddings = collectionOk ? await probeEmbeddings() : "n/a"; + userCollection = await checkCollection(userPaths.collectionName); + repoCollection = repoPaths ? await checkCollection(repoPaths.collectionName) : false; + const collectionNames = [ + ...(userCollection ? [userPaths.collectionName] : []), + ...(repoCollection && repoPaths ? [repoPaths.collectionName] : []), + ]; + embeddings = collectionNames.length > 0 ? await probeEmbeddings(collectionNames) : "n/a"; } const mark = (ok: boolean) => (ok ? "✓" : "✗"); - const lines: string[] = [ - "# Memory status", - "", - `- Memory dir: ${inv.dir}`, + const inventoryLines = (inv: MemoryInventory) => [ `- MEMORY.md: ${inv.longTermChars} chars`, `- Scratchpad: ${inv.scratchpadOpen} open / ${inv.scratchpadTotal} total`, `- Daily logs: ${inv.dailyCount}${inv.latestDaily ? ` (latest ${inv.latestDaily})` : ""}`, + ]; + const lines: string[] = [ + "# Memory status", + "", + "## User memory", + `- User memory dir: ${user.dir}`, + ...inventoryLines(user), + "", + "## Repository memory", + ...(repo + ? [`- Repository memory dir: ${repo.dir}`, ...inventoryLines(repo)] + : ["- Repository memory unavailable: no session working directory"]), "", "## Search (qmd)", `- qmd available: ${mark(qmdOk)}`, ]; if (qmdOk) { - lines.push(`- Collection \`pi-memory\`: ${mark(collectionOk)}`); - if (collectionOk) { + lines.push(`- User collection \`${userPaths.collectionName}\`: ${mark(userCollection)}`); + if (repoPaths) { + lines.push(`- Repository collection \`${repoPaths.collectionName}\`: ${mark(repoCollection)}`); + } + if (userCollection || repoCollection) { const embMark = embeddings === "ready" ? "✓" : embeddings === "missing" ? "⚠" : "?"; lines.push(`- Embeddings (semantic/deep): ${embMark} ${embeddings}`); if (embeddings === "missing") { @@ -2397,7 +2676,7 @@ export default function (pi: ExtensionAPI) { lines.push(" - Could not verify within the probe timeout; run a semantic search to confirm."); } } else { - lines.push(" - Run a `memory_search` (auto-creates it) or `qmd collection add` manually."); + lines.push(" - Run a `memory_search` to create the requested collection(s)."); } } else { lines.push("", qmdInstallInstructions()); @@ -2418,9 +2697,12 @@ export default function (pi: ExtensionAPI) { return { content: [{ type: "text", text: lines.join("\n") }], details: { - ...inv, + ...user, + user, + repo, qmd: qmdOk, - collection: collectionOk, + collection: userCollection, + collections: { user: userCollection, repo: repoCollection }, embeddings, snapshotMode: getSnapshotMode(), qmdUpdateMode: getQmdUpdateMode(), diff --git a/test/unit.test.ts b/test/unit.test.ts index 79865a1..f0e1925 100644 --- a/test/unit.test.ts +++ b/test/unit.test.ts @@ -13,6 +13,7 @@ import * as path from "node:path"; import { _clearEmbedInFlight, + _clearQmdStatusCaches, _clearUpdateTimer, _getEmbedInFlight, _getUpdateTimer, @@ -33,6 +34,7 @@ import { forgetBlocks, getExitSummaryTimeoutMs, getQmdSearchTimeoutMs, + inferMemoryScope, isExitSummaryEmpty, isExitSummaryEnabled, nowTimestamp, @@ -40,8 +42,11 @@ import { qmdCollectionInstructions, qmdInstallInstructions, readFileSafe, + repoCollectionName, resolveMemoryDir, resolveQmdJsPath, + resolveRepoMemoryDir, + resolveRepositoryRoot, runQmdSearch, type ScratchpadItem, scheduleQmdUpdate, @@ -90,8 +95,9 @@ function createMockPi() { } /** Create a mock tool execution context. */ -function createMockCtx(sessionId = "abcdef1234567890") { +function createMockCtx(sessionId = "abcdef1234567890", cwd?: string) { return { + ...(cwd ? { cwd } : {}), sessionManager: { getSessionId: () => sessionId, }, @@ -267,6 +273,57 @@ describe("resolveMemoryDir", () => { }); }); +describe("repository memory paths and scope inference", () => { + let root: string; + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-memory-repo-path-")); + fs.mkdirSync(path.join(root, ".git")); + }); + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + test("resolves repository memory from the git root", () => { + const nested = path.join(root, "packages", "app"); + fs.mkdirSync(nested, { recursive: true }); + + expect(resolveRepositoryRoot(nested)).toBe(root); + expect(resolveRepoMemoryDir(nested)).toBe(path.join(root, ".pi", "agent", "memory")); + }); + + test("uses cwd as the project root outside git", () => { + const plain = fs.mkdtempSync(path.join(os.tmpdir(), "pi-memory-nonrepo-")); + try { + expect(resolveRepositoryRoot(plain)).toBe(plain); + } finally { + fs.rmSync(plain, { recursive: true, force: true }); + } + }); + + test("creates a stable, repository-specific qmd collection name", () => { + expect(repoCollectionName(root)).toMatch(/^pi-memory-repo-[0-9a-f]{12}$/); + expect(repoCollectionName(root)).toBe(repoCollectionName(path.join(root, "."))); + expect(repoCollectionName(path.join(root, "other"))).not.toBe(repoCollectionName(root)); + }); + + test("recognizes repository-scoped remember requests", () => { + for (const prompt of [ + "Remember that we use pnpm for this repo", + "Save this in the current project memory", + "This is a repository-specific decision", + "Keep this preference for this codebase only", + ]) { + expect(inferMemoryScope(prompt)).toBe("repo"); + } + }); + + test("recognizes explicit user-wide requests and leaves neutral prompts unset", () => { + expect(inferMemoryScope("Remember this across all repositories")).toBe("user"); + expect(inferMemoryScope("Save this globally for every project")).toBe("user"); + expect(inferMemoryScope("Remember that I like concise answers")).toBeNull(); + }); +}); + describe("buildQmdSpawn", () => { const QMD_JS = "C:\\npm\\prefix\\node_modules\\@tobilu\\qmd\\dist\\cli\\qmd.js"; @@ -620,6 +677,21 @@ describe("buildMemoryContext", () => { fs.writeFileSync(path.join(tmpDir, "MEMORY.md"), " \n\n ", "utf-8"); expect(buildMemoryContext()).toBe(""); }); + + test("combines user and repository memories with clear scope labels", () => { + ensureDirs(); + const repoDir = path.join(tmpDir, "repo-memory"); + fs.mkdirSync(repoDir, { recursive: true }); + fs.writeFileSync(path.join(tmpDir, "MEMORY.md"), "Global preference", "utf-8"); + fs.writeFileSync(path.join(repoDir, "MEMORY.md"), "Repository decision", "utf-8"); + + const ctx = buildMemoryContext("", repoDir); + expect(ctx).toContain("## Repository MEMORY.md (long-term)"); + expect(ctx).toContain("Repository decision"); + expect(ctx).toContain("## User MEMORY.md (long-term)"); + expect(ctx).toContain("Global preference"); + expect(ctx.indexOf("Repository decision")).toBeLessThan(ctx.indexOf("Global preference")); + }); }); // ========================================================================== @@ -895,6 +967,74 @@ describe("memory_write tool", () => { expect(content).toContain("New"); expect(result.details.mode).toBe("append"); }); + + test("writes explicit repository memory under the repository root", async () => { + const repoRoot = path.join(tmpDir, "project"); + const nested = path.join(repoRoot, "src", "feature"); + fs.mkdirSync(path.join(repoRoot, ".git"), { recursive: true }); + fs.mkdirSync(nested, { recursive: true }); + + const result = await tools.memory_write.execute( + "call1", + { target: "long_term", content: "Use pnpm here", scope: "repo" }, + null, + null, + createMockCtx("abcdef1234567890", nested), + ); + + const repoFile = path.join(repoRoot, ".pi", "agent", "memory", "MEMORY.md"); + expect(fs.readFileSync(repoFile, "utf-8")).toContain("Use pnpm here"); + expect(fs.existsSync(path.join(tmpDir, "MEMORY.md"))).toBe(false); + expect(result.details.scope).toBe("repo"); + expect(result.details.path).toBe(repoFile); + }); + + test("infers repository scope from the active remember request", async () => { + const repoRoot = path.join(tmpDir, "project"); + fs.mkdirSync(path.join(repoRoot, ".git"), { recursive: true }); + const mockPi = createMockPi(); + registerExtension(mockPi.pi as any); + const ctx = createMockCtx("abcdef1234567890", repoRoot); + + await mockPi.hooks.before_agent_start( + { systemPrompt: "base", prompt: "Remember that tests use fake timers for this repo" }, + ctx, + ); + await mockPi.tools.memory_write.execute( + "call1", + { target: "long_term", content: "Tests use fake timers" }, + null, + null, + ctx, + ); + + const repoFile = path.join(repoRoot, ".pi", "agent", "memory", "MEMORY.md"); + expect(fs.readFileSync(repoFile, "utf-8")).toContain("Tests use fake timers"); + expect(fs.existsSync(path.join(tmpDir, "MEMORY.md"))).toBe(false); + }); + + test("defaults neutral remember requests to user memory", async () => { + const repoRoot = path.join(tmpDir, "project"); + fs.mkdirSync(path.join(repoRoot, ".git"), { recursive: true }); + const mockPi = createMockPi(); + registerExtension(mockPi.pi as any); + const ctx = createMockCtx("abcdef1234567890", repoRoot); + + await mockPi.hooks.before_agent_start( + { systemPrompt: "base", prompt: "Remember that I prefer concise answers" }, + ctx, + ); + const result = await mockPi.tools.memory_write.execute( + "call1", + { target: "long_term", content: "User prefers concise answers" }, + null, + null, + ctx, + ); + + expect(fs.readFileSync(path.join(tmpDir, "MEMORY.md"), "utf-8")).toContain("concise answers"); + expect(result.details.scope).toBe("user"); + }); }); // ========================================================================== @@ -935,6 +1075,21 @@ describe("scratchpad tool", () => { expect(content).toContain("[ ]"); }); + test("manages a repository-scoped scratchpad", async () => { + const repoRoot = path.join(tmpDir, "project"); + fs.mkdirSync(path.join(repoRoot, ".git"), { recursive: true }); + const result = await tools.scratchpad.execute( + "call1", + { action: "add", text: "Fix project build", scope: "repo" }, + null, + null, + createMockCtx("abcdef1234567890", repoRoot), + ); + const repoScratchpad = path.join(repoRoot, ".pi", "agent", "memory", "SCRATCHPAD.md"); + expect(fs.readFileSync(repoScratchpad, "utf-8")).toContain("Fix project build"); + expect(result.details.scope).toBe("repo"); + }); + test("add without text returns error", async () => { const ctx = createMockCtx(); const result = await tools.scratchpad.execute("call1", { action: "add" }, null, null, ctx); @@ -1079,6 +1234,23 @@ describe("memory_read tool", () => { expect(result.content[0].text).toBe("My memories"); }); + test("reads repository-scoped long-term memory", async () => { + const repoRoot = path.join(tmpDir, "project"); + const repoDir = path.join(repoRoot, ".pi", "agent", "memory"); + fs.mkdirSync(path.join(repoRoot, ".git"), { recursive: true }); + fs.mkdirSync(repoDir, { recursive: true }); + fs.writeFileSync(path.join(repoDir, "MEMORY.md"), "Repository memory", "utf-8"); + const result = await tools.memory_read.execute( + "c1", + { target: "long_term", scope: "repo" }, + null, + null, + createMockCtx("abcdef1234567890", repoRoot), + ); + expect(result.content[0].text).toBe("Repository memory"); + expect(result.details.scope).toBe("repo"); + }); + test("read long_term when file does not exist", async () => { const result = await tools.memory_read.execute("c1", { target: "long_term" }, null, null, {}); expect(result.content[0].text).toContain("empty or does not exist"); @@ -1191,6 +1363,28 @@ describe("runQmdSearch qmd diagnostics", () => { _resetExecFileForTest(); }); + test("passes repeated collection filters when searching user and repository memory", async () => { + let observedArgs: string[] = []; + _setExecFileForTest(((_file: string, args: string[], _opts: any, cb: any) => { + observedArgs = args; + cb(null, "[]", ""); + }) as any); + + await runQmdSearch("keyword", "query", 5, ["pi-memory", "pi-memory-repo-123456789abc"]); + + expect(observedArgs).toEqual([ + "search", + "--json", + "-c", + "pi-memory", + "-c", + "pi-memory-repo-123456789abc", + "-n", + "5", + "query", + ]); + }); + test("strips qmd spinner control sequences from stderr failures", async () => { _setExecFileForTest(((_file: string, _args: string[], _opts: any, cb: any) => { cb( @@ -1303,6 +1497,43 @@ describe("memory_search tool", () => { expect(desc).toContain("semantic"); expect(desc).toContain("deep"); }); + + test("searches user and repository qmd collections by default", async () => { + const repoRoot = path.join(tmpDir, "project"); + const repoDir = path.join(repoRoot, ".pi", "agent", "memory"); + fs.mkdirSync(path.join(repoRoot, ".git"), { recursive: true }); + fs.mkdirSync(repoDir, { recursive: true }); + fs.writeFileSync(path.join(repoDir, "MEMORY.md"), "Repository fact", "utf-8"); + const repoCollection = repoCollectionName(repoRoot); + let searchArgs: string[] = []; + _clearQmdStatusCaches(); + _setQmdAvailable(true); + _setExecFileForTest(((_file: string, args: string[], _opts: any, cb: any) => { + if (args[0] === "collection" && args[1] === "list") { + cb(null, JSON.stringify([{ name: "pi-memory" }, { name: repoCollection }]), ""); + return; + } + if (args[0] === "search") { + searchArgs = args; + cb(null, "[]", ""); + return; + } + cb(null, "", ""); + }) as any); + + const result = await tools.memory_search.execute( + "c1", + { query: "fact" }, + null, + null, + createMockCtx("abcdef1234567890", repoRoot), + ); + + expect(result.isError).not.toBe(true); + expect(searchArgs).toContain("pi-memory"); + expect(searchArgs).toContain(repoCollection); + expect(result.details.scope).toBe("all"); + }); }); describe("memory_status tool", () => { @@ -1343,6 +1574,32 @@ describe("memory_status tool", () => { expect(result.details.qmd).toBe(false); expect(result.details.longTermChars).toBeGreaterThan(0); }); + + test("reports separate user and repository inventories", async () => { + _setExecFileForTest(((_file: string, _args: string[], _opts: any, cb: any) => { + cb(new Error("qmd not found"), "", ""); + }) as any); + _setQmdAvailable(false); + fs.writeFileSync(path.join(tmpDir, "MEMORY.md"), "User fact", "utf-8"); + const repoRoot = path.join(tmpDir, "project"); + const repoDir = path.join(repoRoot, ".pi", "agent", "memory"); + fs.mkdirSync(path.join(repoRoot, ".git"), { recursive: true }); + fs.mkdirSync(repoDir, { recursive: true }); + fs.writeFileSync(path.join(repoDir, "MEMORY.md"), "Repo fact", "utf-8"); + + const result = await tools.memory_status.execute( + "c1", + {}, + null, + null, + createMockCtx("abcdef1234567890", repoRoot), + ); + const text = result.content[0].text; + expect(text).toContain(`User memory dir: ${tmpDir}`); + expect(text).toContain(`Repository memory dir: ${repoDir}`); + expect(result.details.user.longTermChars).toBeGreaterThan(0); + expect(result.details.repo.longTermChars).toBeGreaterThan(0); + }); }); // ========================================================================== @@ -1398,6 +1655,23 @@ describe("lifecycle hooks", () => { expect(result.systemPrompt).toContain("scratchpad"); }); + test("before_agent_start injects both repository and user memory", async () => { + const repoRoot = path.join(tmpDir, "project"); + const repoDir = path.join(repoRoot, ".pi", "agent", "memory"); + fs.mkdirSync(path.join(repoRoot, ".git"), { recursive: true }); + fs.mkdirSync(repoDir, { recursive: true }); + fs.writeFileSync(path.join(tmpDir, "MEMORY.md"), "User-wide preference", "utf-8"); + fs.writeFileSync(path.join(repoDir, "MEMORY.md"), "Repository-only decision", "utf-8"); + + const result = await hooks.before_agent_start( + { systemPrompt: "base", prompt: "What should I work on?" }, + createMockCtx("abcdef1234567890", repoRoot), + ); + expect(result.systemPrompt).toContain("Repository-only decision"); + expect(result.systemPrompt).toContain("User-wide preference"); + expect(result.systemPrompt).toContain("scope='repo'"); + }); + // -- session_shutdown -- test("session_shutdown clears update timer", async () => { @@ -2309,6 +2583,38 @@ describe("memory_forget tool", () => { expect(fs.readFileSync(path.join(tmpDir, "MEMORY.md"), "utf-8")).toBe("a fact\n"); }); + test("forgets and restores repository-scoped memory", async () => { + const repoRoot = path.join(tmpDir, "project"); + const repoDir = path.join(repoRoot, ".pi", "agent", "memory"); + fs.mkdirSync(path.join(repoRoot, ".git"), { recursive: true }); + fs.mkdirSync(repoDir, { recursive: true }); + fs.writeFileSync(path.join(repoDir, "MEMORY.md"), "Old repository fact\n", "utf-8"); + const ctx = createMockCtx("abcdef1234567890", repoRoot); + + const forgotten = await tools.memory_forget.execute( + "c1", + { match: "Old repository", scope: "repo" }, + null, + null, + ctx, + ); + expect(fs.readFileSync(path.join(repoDir, "MEMORY.md"), "utf-8")).not.toContain("Old repository fact"); + expect(forgotten.details.scope).toBe("repo"); + expect(forgotten.details.recoveryPath).toContain(path.join(tmpDir, "recovery", "repos")); + expect(forgotten.details.recoveryPath).not.toContain(repoDir); + expect(fs.existsSync(forgotten.details.recoveryPath)).toBe(true); + + const restored = await tools.memory_restore.execute( + "c2", + { recoveryId: forgotten.details.recoveryId }, + null, + null, + ctx, + ); + expect(restored.details.scope).toBe("repo"); + expect(fs.readFileSync(path.join(repoDir, "MEMORY.md"), "utf-8")).toContain("Old repository fact"); + }); + test("targets a specific daily log by date", async () => { fs.mkdirSync(path.join(tmpDir, "daily"), { recursive: true }); fs.writeFileSync(path.join(tmpDir, "daily", "2026-07-01.md"), "old wrong fact\n\nkeep me\n", "utf-8");