@nklisch/pi-enhanced 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,23 +1,19 @@
1
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
3
  import { homedir } from "node:os";
3
4
  import { join } from "node:path";
4
5
 
5
- /** Store layout under the pocket root:
6
- * config.json — toggles + distiller settings (config.ts)
7
- * SUMMARY.md — injected into astra's system prompt; mechanical render
8
- * POCKET.md — searchable registry, one line per note
9
- * notes/<ts>-<slug>.md — append-only note files
10
- * distilled.json — distiller bookkeeping (which sessions are processed)
11
- */
6
+ import { resolveProjectIdentity } from "./scope.js";
12
7
 
13
8
  const PINNED_START = "<!-- pocket:pinned:start -->";
14
9
  const PINNED_END = "<!-- pocket:pinned:end -->";
15
10
  const DIGEST_START = "<!-- pocket:digest:start -->";
16
11
  const DIGEST_END = "<!-- pocket:digest:end -->";
17
-
18
12
  const RECENT_NOTES_CAP = 20;
19
- const REGISTRY_LINE_CAP = 500;
20
- const BODY_SCAN_CAP = 200;
13
+ const NOTE_EXCERPT = 320;
14
+ const FULL_NOTE_EXCERPT = 4_000;
15
+ const DIGEST_NOTE_CAP = 200;
16
+ const DIGEST_NOTE_BYTES = 2_000;
21
17
 
22
18
  export function defaultAgentDir(): string {
23
19
  return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
@@ -31,37 +27,59 @@ export function notesDir(root: string): string {
31
27
  return join(root, "notes");
32
28
  }
33
29
 
30
+ function atomicWrite(path: string, contents: string): void {
31
+ const temporary = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
32
+ writeFileSync(temporary, contents, "utf8");
33
+ renameSync(temporary, path);
34
+ }
35
+
34
36
  export function ensureLayout(root: string): void {
35
37
  mkdirSync(notesDir(root), { recursive: true });
36
38
  const registry = join(root, "POCKET.md");
37
- if (!existsSync(registry)) {
38
- writeFileSync(registry, "# Astral Pocket Registry\n\nOne line per note. Search this first.\n\n", "utf8");
39
- }
40
- if (!existsSync(join(root, "SUMMARY.md"))) {
41
- writeFileSync(join(root, "SUMMARY.md"), renderSummary(root, []), "utf8");
42
- }
39
+ if (!existsSync(registry)) atomicWrite(registry, renderRegistry([]));
40
+ if (!existsSync(join(root, "SUMMARY.md"))) atomicWrite(join(root, "SUMMARY.md"), renderSummary(root, []));
43
41
  }
44
42
 
43
+ export type NoteScope = "project" | "global";
44
+
45
45
  export interface NoteInput {
46
46
  title: string;
47
47
  body: string;
48
48
  keywords?: string[];
49
- /** cwd of the session taking the note; recorded for project-aware ranking. */
50
49
  project?: string;
50
+ projectId?: string;
51
+ scope?: NoteScope;
51
52
  source?: "agent" | "distilled";
52
53
  }
53
54
 
55
+ export interface GeneratedNoteInput extends NoteInput {
56
+ sessionId: string;
57
+ sourcePath: string;
58
+ sourceUpdatedAt: string;
59
+ sourceSize: number;
60
+ sourceRevision: string;
61
+ }
62
+
63
+ interface StoredNote {
64
+ fileName: string;
65
+ title: string;
66
+ text: string;
67
+ body: string;
68
+ project: string;
69
+ projectId: string;
70
+ scope: NoteScope | "unknown";
71
+ source: string;
72
+ created: string;
73
+ updated: string;
74
+ }
75
+
54
76
  function slugify(text: string): string {
55
- const slug = text
56
- .toLowerCase()
57
- .replace(/[^a-z0-9]+/g, "-")
58
- .replace(/^-+|-+$/g, "")
59
- .slice(0, 48);
77
+ const slug = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48);
60
78
  return slug || "note";
61
79
  }
62
80
 
63
81
  function stamp(date: Date): string {
64
- return date.toISOString().replace(/[:.]/g, "-").replace("Z", "Z");
82
+ return date.toISOString().replace(/[:.]/g, "-");
65
83
  }
66
84
 
67
85
  function extractSection(markdown: string, start: string, end: string): string | null {
@@ -71,82 +89,183 @@ function extractSection(markdown: string, start: string, end: string): string |
71
89
  return markdown.slice(i + start.length, j).trim();
72
90
  }
73
91
 
74
- /** Write one note file and update the registry + summary. Returns the note's
75
- * file name. Callers that need cross-file mutation safety should wrap this in
76
- * `withFileMutationQueue(join(root, "POCKET.md"), ...)`. */
77
- export function writeNote(root: string, input: NoteInput, now: Date = new Date()): string {
78
- ensureLayout(root);
79
- const fileName = `${stamp(now)}-${slugify(input.title)}.md`;
80
- const frontmatter = [
92
+ function field(text: string, name: string): string {
93
+ const frontmatter = text.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1] ?? "";
94
+ return frontmatter.match(new RegExp(`^${name}: (.*)$`, "m"))?.[1]?.trim() ?? "";
95
+ }
96
+
97
+ function noteTimestamp(note: StoredNote): number {
98
+ const parsed = Date.parse(note.updated || note.created);
99
+ return Number.isNaN(parsed) ? Number.NEGATIVE_INFINITY : parsed;
100
+ }
101
+
102
+ function compareNotesByTime(a: StoredNote, b: StoredNote): number {
103
+ const aTime = noteTimestamp(a);
104
+ const bTime = noteTimestamp(b);
105
+ if (aTime !== bTime) return aTime < bTime ? -1 : 1;
106
+ // Prefer deliberate and legacy notes at a time tie so a batch of generated
107
+ // notes cannot crowd them out solely because session hashes sort later.
108
+ const manualRank = Number(a.source !== "distilled") - Number(b.source !== "distilled");
109
+ return manualRank || a.fileName.localeCompare(b.fileName);
110
+ }
111
+
112
+ function readStoredNotes(root: string): StoredNote[] {
113
+ if (!existsSync(notesDir(root))) return [];
114
+ const notes: StoredNote[] = [];
115
+ for (const fileName of readdirSync(notesDir(root)).filter((name) => name.endsWith(".md"))) {
116
+ try {
117
+ const text = readFileSync(join(notesDir(root), fileName), "utf8");
118
+ const heading = text.match(/^# (.+)$/m)?.[1] ?? fileName;
119
+ const headingAt = text.search(/^# .+$/m);
120
+ const project = field(text, "project");
121
+ const declaredScope = field(text, "scope");
122
+ const scope: NoteScope | "unknown" = declaredScope === "global"
123
+ ? "global"
124
+ : declaredScope === "project" || (declaredScope === "" && project !== "" && project !== "unknown")
125
+ ? "project"
126
+ : "unknown";
127
+ notes.push({
128
+ fileName,
129
+ title: heading,
130
+ text,
131
+ body: headingAt >= 0 ? text.slice(headingAt).replace(/^# .+\n+/, "").trim() : text.trim(),
132
+ project,
133
+ projectId: field(text, "project_id") || (scope === "project" && project !== "" && project !== "unknown" ? resolveProjectIdentity(project) : ""),
134
+ scope,
135
+ source: field(text, "source") || "legacy",
136
+ created: field(text, "created"),
137
+ updated: field(text, "updated") || field(text, "source_updated_at") || field(text, "created"),
138
+ });
139
+ } catch {
140
+ // One unreadable note must not hide the remaining canonical note files.
141
+ }
142
+ }
143
+ return notes.sort(compareNotesByTime);
144
+ }
145
+
146
+ function noteMarkdown(input: NoteInput, metadata: string[], created: Date, updated: Date = created): string {
147
+ return [
81
148
  "---",
82
- `created: ${now.toISOString()}`,
149
+ `created: ${created.toISOString()}`,
150
+ `updated: ${updated.toISOString()}`,
83
151
  `project: ${input.project ?? "unknown"}`,
152
+ `project_id: ${input.projectId ?? ""}`,
153
+ `scope: ${input.scope ?? "project"}`,
84
154
  `keywords: [${(input.keywords ?? []).join(", ")}]`,
85
155
  `source: ${input.source ?? "agent"}`,
156
+ ...metadata,
86
157
  "---",
158
+ "",
159
+ `# ${input.title}`,
160
+ "",
161
+ input.body.trim(),
162
+ "",
87
163
  ].join("\n");
88
- writeFileSync(join(notesDir(root), fileName), `${frontmatter}\n\n# ${input.title}\n\n${input.body.trim()}\n`, "utf8");
164
+ }
89
165
 
90
- const projectTag = input.project ? (input.project.split("/").filter(Boolean).pop() ?? input.project) : "unknown";
91
- const keywordTag = (input.keywords ?? []).join(", ");
92
- const line = `- [${input.title}](notes/${fileName})${keywordTag ? ` — ${keywordTag}` : ""} — ${projectTag} — ${now.toISOString().slice(0, 10)}`;
93
- appendRegistryLine(root, line);
94
- rerenderSummary(root);
166
+ function uniqueManualFile(root: string, input: NoteInput, now: Date): string {
167
+ const base = `${stamp(now)}-${slugify(input.title)}`;
168
+ let fileName = `${base}.md`;
169
+ let suffix = 2;
170
+ while (existsSync(join(notesDir(root), fileName))) fileName = `${base}-${suffix++}.md`;
95
171
  return fileName;
96
172
  }
97
173
 
98
- function appendRegistryLine(root: string, line: string): void {
99
- const registry = join(root, "POCKET.md");
100
- const existing = existsSync(registry) ? readFileSync(registry, "utf8") : "";
101
- writeFileSync(registry, `${existing.trimEnd()}\n${line}\n`, "utf8");
174
+ /** Write one deliberate note. Call inside the POCKET.md mutation queue. */
175
+ export function writeNote(root: string, input: NoteInput, now: Date = new Date()): string {
176
+ ensureLayout(root);
177
+ const fileName = uniqueManualFile(root, input, now);
178
+ atomicWrite(join(notesDir(root), fileName), noteMarkdown(input, [], now));
179
+ rebuildDerivedStore(root);
180
+ return fileName;
181
+ }
182
+
183
+ export function generatedNoteFile(sessionId: string): string {
184
+ const identity = createHash("sha256").update(sessionId).digest("hex").slice(0, 24);
185
+ return `session-${identity}.md`;
186
+ }
187
+
188
+ /** Replace the one generated note owned by a session revision. */
189
+ export function writeGeneratedNote(root: string, input: GeneratedNoteInput, now: Date = new Date()): string {
190
+ ensureLayout(root);
191
+ const fileName = generatedNoteFile(input.sessionId);
192
+ const existingPath = join(notesDir(root), fileName);
193
+ let created = now;
194
+ if (existsSync(existingPath)) {
195
+ const previousCreated = field(readFileSync(existingPath, "utf8"), "created");
196
+ if (previousCreated && !Number.isNaN(Date.parse(previousCreated))) created = new Date(previousCreated);
197
+ }
198
+ atomicWrite(existingPath, noteMarkdown(input, [
199
+ `session_id: ${input.sessionId}`,
200
+ `source_path: ${input.sourcePath}`,
201
+ `source_updated_at: ${input.sourceUpdatedAt}`,
202
+ `source_size: ${input.sourceSize}`,
203
+ `source_revision: ${input.sourceRevision}`,
204
+ ], created, now));
205
+ rebuildDerivedStore(root);
206
+ return fileName;
207
+ }
208
+
209
+ export function removeGeneratedNote(root: string, sessionId: string): boolean {
210
+ const path = join(notesDir(root), generatedNoteFile(sessionId));
211
+ if (!existsSync(path)) return false;
212
+ rmSync(path);
213
+ rebuildDerivedStore(root);
214
+ return true;
215
+ }
216
+
217
+ function registryLine(note: StoredNote): string {
218
+ const project = note.scope === "global"
219
+ ? "global"
220
+ : (note.project.split("/").filter(Boolean).pop() ?? note.project) || "unknown";
221
+ const date = (note.updated || note.created).slice(0, 10) || "unknown-date";
222
+ return `- [${note.title}](notes/${note.fileName}) — ${note.scope} — ${project} — ${date}`;
223
+ }
224
+
225
+ function renderRegistry(notes: StoredNote[]): string {
226
+ return [
227
+ "# Astral Pocket Registry",
228
+ "",
229
+ "Derived from the canonical Markdown files in `notes/`. Search this first.",
230
+ "",
231
+ ...notes.map(registryLine),
232
+ "",
233
+ ].join("\n");
234
+ }
235
+
236
+ export function countNotes(root: string): number {
237
+ return readStoredNotes(root).length;
102
238
  }
103
239
 
104
240
  export function readRegistryLines(root: string): string[] {
105
241
  const registry = join(root, "POCKET.md");
106
242
  if (!existsSync(registry)) return [];
107
- return readFileSync(registry, "utf8")
108
- .split("\n")
109
- .filter((l) => l.startsWith("- ["));
243
+ return readFileSync(registry, "utf8").split("\n").filter((line) => line.startsWith("- ["));
110
244
  }
111
245
 
112
- /** Re-render SUMMARY.md mechanically: the pinned block and the
113
- * distiller-maintained digest block carry over verbatim from the existing
114
- * file; only the recent-notes index is regenerated. This is the mechanical
115
- * floor — it runs on every note write with zero LLM involvement. */
116
- export function rerenderSummary(root: string): void {
117
- writeFileSync(join(root, "SUMMARY.md"), renderSummary(root, readRegistryLines(root)), "utf8");
246
+ /** Rebuildable indexes are rendered from note files, never treated as note authority. */
247
+ export function rebuildDerivedStore(root: string): void {
248
+ const notes = readStoredNotes(root);
249
+ atomicWrite(join(root, "POCKET.md"), renderRegistry(notes));
250
+ atomicWrite(join(root, "SUMMARY.md"), renderSummary(root, notes.map(registryLine)));
118
251
  }
119
252
 
253
+ export const rerenderSummary = rebuildDerivedStore;
254
+
120
255
  function renderSummary(root: string, registryLines: string[]): string {
121
256
  const summaryPath = join(root, "SUMMARY.md");
122
257
  const existing = existsSync(summaryPath) ? readFileSync(summaryPath, "utf8") : "";
123
258
  const pinned = extractSection(existing, PINNED_START, PINNED_END) ?? "";
124
- const digest =
125
- extractSection(existing, DIGEST_START, DIGEST_END) ??
259
+ const digest = extractSection(existing, DIGEST_START, DIGEST_END) ??
126
260
  "_No digest yet. It is filled in by the distiller pass; until then, rely on Recent notes and search POCKET.md._";
127
261
  const recent = registryLines.slice(-RECENT_NOTES_CAP);
128
262
  return [
129
- "# Astral Pocket Summary",
130
- "",
131
- PINNED_START,
132
- pinned,
133
- PINNED_END,
134
- "",
135
- "## Durable digest",
136
- "",
137
- DIGEST_START,
138
- digest,
139
- DIGEST_END,
140
- "",
141
- "## Recent notes",
142
- "",
143
- ...(recent.length > 0 ? recent : ["_No notes yet._"]),
144
- "",
263
+ "# Astral Pocket Summary", "", PINNED_START, pinned, PINNED_END, "",
264
+ "## Durable digest", "", DIGEST_START, digest, DIGEST_END, "",
265
+ "## Recent notes", "", ...(recent.length > 0 ? recent : ["_No notes yet._"]), "",
145
266
  ].join("\n");
146
267
  }
147
268
 
148
- /** Replace the distiller-maintained digest block, preserving everything else.
149
- * Returns false when SUMMARY.md is missing the markers (never rendered). */
150
269
  export function updateDigest(root: string, digest: string): boolean {
151
270
  const summaryPath = join(root, "SUMMARY.md");
152
271
  if (!existsSync(summaryPath)) return false;
@@ -154,12 +273,10 @@ export function updateDigest(root: string, digest: string): boolean {
154
273
  const i = existing.indexOf(DIGEST_START);
155
274
  const j = existing.indexOf(DIGEST_END);
156
275
  if (i === -1 || j === -1 || j < i) return false;
157
- writeFileSync(summaryPath, `${existing.slice(0, i + DIGEST_START.length)}\n${digest.trim()}\n${existing.slice(j)}`, "utf8");
276
+ atomicWrite(summaryPath, `${existing.slice(0, i + DIGEST_START.length)}\n${digest.trim()}\n${existing.slice(j)}`);
158
277
  return true;
159
278
  }
160
279
 
161
- /** The text injected into astra's system prompt, capped to keep the per-turn
162
- * cost bounded regardless of how large the summary grows. */
163
280
  export function readSummaryCapped(root: string, capBytes = 12_000): string {
164
281
  const summaryPath = join(root, "SUMMARY.md");
165
282
  if (!existsSync(summaryPath)) return "";
@@ -172,56 +289,139 @@ export interface PocketSearchHit {
172
289
  title: string;
173
290
  excerpt: string;
174
291
  project: string;
292
+ projectId: string;
293
+ source: string;
294
+ scope: NoteScope | "unknown";
295
+ date: string;
175
296
  }
176
297
 
177
- /** Case-insensitive keyword search over the registry, then the note bodies.
178
- * Registry hits rank first; current-project notes rank above others. */
179
- export function searchPocket(root: string, query: string, currentProject: string | undefined, limit: number): PocketSearchHit[] {
298
+ /** Search complete canonical note content, then truncate only the returned excerpt. */
299
+ export function searchPocket(
300
+ root: string,
301
+ query: string,
302
+ currentProject: string | undefined,
303
+ limit: number,
304
+ full = false,
305
+ recallScope: "current" | "all" = "current",
306
+ ): PocketSearchHit[] {
180
307
  const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
181
308
  if (terms.length === 0) return [];
182
- const matches = (haystack: string) => terms.every((t) => haystack.toLowerCase().includes(t));
183
-
184
- const hits: PocketSearchHit[] = [];
185
- for (const line of readRegistryLines(root).slice(-REGISTRY_LINE_CAP)) {
186
- if (!matches(line)) continue;
187
- const fileMatch = line.match(/\]\((notes\/[^)]+)\)/);
188
- const titleMatch = line.match(/- \[([^\]]+)\]/);
189
- if (!fileMatch) continue;
190
- hits.push({
191
- noteFile: fileMatch[1].replace(/^notes\//, ""),
192
- title: titleMatch?.[1] ?? fileMatch[1],
193
- excerpt: line,
194
- project: "",
195
- });
196
- }
309
+ const cap = full ? FULL_NOTE_EXCERPT : NOTE_EXCERPT;
310
+ const hits = readStoredNotes(root)
311
+ .filter((note) => recallScope === "all" || note.scope === "global" || (note.scope === "project" && note.projectId === currentProject))
312
+ .filter((note) => terms.every((term) => note.text.toLowerCase().includes(term))).map((note) => {
313
+ const lowerBody = note.body.toLowerCase();
314
+ const bodyMatches = terms.map((term) => lowerBody.indexOf(term)).filter((at) => at >= 0);
315
+ const at = bodyMatches.length > 0 ? Math.min(...bodyMatches) : 0;
316
+ const start = Math.max(0, at - Math.floor(cap / 4));
317
+ const excerpt = note.body.slice(start, start + cap).trim();
318
+ return {
319
+ noteFile: note.fileName,
320
+ title: note.title,
321
+ excerpt: excerpt.length < note.body.slice(start).trim().length ? `${excerpt}…` : excerpt,
322
+ project: note.project,
323
+ projectId: note.projectId,
324
+ source: note.source,
325
+ scope: note.scope,
326
+ date: note.updated || note.created,
327
+ };
328
+ });
329
+ hits.sort((a, b) => {
330
+ const projectRank = Number(b.projectId === currentProject) - Number(a.projectId === currentProject);
331
+ const globalRank = Number(b.scope === "global") - Number(a.scope === "global");
332
+ return projectRank || globalRank || b.date.localeCompare(a.date) || b.noteFile.localeCompare(a.noteFile);
333
+ });
334
+ return hits.slice(0, Math.max(1, limit));
335
+ }
336
+
337
+ export interface DigestSnapshot {
338
+ fingerprint: string;
339
+ promptSource: string;
340
+ noteCount: number;
341
+ }
342
+
343
+ export type DigestScope = { kind: "project"; projectId: string } | { kind: "global" };
344
+
345
+ export function digestScopeKey(scope: DigestScope): string {
346
+ return scope.kind === "global"
347
+ ? "global"
348
+ : `project:${createHash("sha256").update(scope.projectId).digest("hex").slice(0, 24)}`;
349
+ }
197
350
 
198
- const bodyHits: PocketSearchHit[] = [];
199
- // Timestamp-prefixed names sort chronologically; cap the body scan at the
200
- // newest files so recall stays fast as the store grows.
201
- const files = readdirSync(notesDir(root))
202
- .filter((f) => f.endsWith(".md"))
203
- .sort()
204
- .slice(-BODY_SCAN_CAP);
205
- for (const file of files) {
206
- if (hits.some((h) => h.noteFile === file)) continue;
207
- const text = readFileSync(join(notesDir(root), file), "utf8");
208
- if (!matches(text)) continue;
209
- const project = text.match(/^project: (.+)$/m)?.[1] ?? "";
210
- const title = text.match(/^# (.+)$/m)?.[1] ?? file;
211
- const idx = text.toLowerCase().indexOf(terms[0]);
212
- bodyHits.push({
213
- noteFile: file,
214
- title,
215
- excerpt: text.slice(Math.max(0, idx - 120), idx + 280).trim(),
216
- project,
217
- });
351
+ function digestPath(root: string, scope: DigestScope): string {
352
+ return join(root, "digests", `${digestScopeKey(scope).replace(":", "-")}.md`);
353
+ }
354
+
355
+ export function scopedDigestExists(root: string, scope: DigestScope): boolean {
356
+ return existsSync(digestPath(root, scope));
357
+ }
358
+
359
+ function successfulDigestFingerprint(root: string, scope: DigestScope): string | undefined {
360
+ try {
361
+ const state = JSON.parse(readFileSync(join(root, "distilled.json"), "utf8")) as { digestFingerprints?: Record<string, unknown> };
362
+ const value = state.digestFingerprints?.[digestScopeKey(scope)];
363
+ return typeof value === "string" ? value : undefined;
364
+ } catch {
365
+ return undefined;
218
366
  }
367
+ }
219
368
 
220
- const currentTag = currentProject?.split("/").filter(Boolean).pop();
221
- const ranked = [...hits, ...bodyHits].sort((a, b) => {
222
- const aCur = currentTag && a.project.endsWith(currentTag) ? 1 : 0;
223
- const bCur = currentTag && b.project.endsWith(currentTag) ? 1 : 0;
224
- return bCur - aCur;
225
- });
226
- return ranked.slice(0, limit);
369
+ export function updateScopedDigest(root: string, scope: DigestScope, digest: string): void {
370
+ mkdirSync(join(root, "digests"), { recursive: true });
371
+ atomicWrite(digestPath(root, scope), `${digest.trim()}\n`);
372
+ }
373
+
374
+ /** Build injected context only from the current project and explicit global notes. */
375
+ export function readScopedSummary(root: string, projectId: string, capBytes = 12_000): string {
376
+ const renderLayer = (scope: DigestScope, heading: string, layerCap: number): string => {
377
+ const notes = readStoredNotes(root).filter((note) =>
378
+ scope.kind === "global" ? note.scope === "global" : note.scope === "project" && note.projectId === scope.projectId,
379
+ );
380
+ let digest = "";
381
+ const currentFingerprint = createDigestSnapshot(root, scope).fingerprint;
382
+ if (successfulDigestFingerprint(root, scope) === currentFingerprint) {
383
+ try { digest = readFileSync(digestPath(root, scope), "utf8").trim(); } catch { /* derived cache may lag */ }
384
+ }
385
+ const recent = notes.slice(-RECENT_NOTES_CAP).map(registryLine);
386
+ const layer = [
387
+ `## ${heading}`,
388
+ "",
389
+ digest || "_No digest is available; use the source-linked recent notes below._",
390
+ "",
391
+ "### Recent source notes",
392
+ ...(recent.length > 0 ? recent : ["_None._"]),
393
+ ].join("\n");
394
+ return layer.length <= layerCap
395
+ ? layer
396
+ : `${layer.slice(0, layerCap)}\n_(layer truncated; use pocket_recall for source notes)_`;
397
+ };
398
+ const globalCap = Math.max(1_500, Math.floor(capBytes / 4));
399
+ const projectCap = Math.max(1_500, capBytes - globalCap - 40);
400
+ return [
401
+ "# Astral Pocket Summary",
402
+ "",
403
+ renderLayer({ kind: "project", projectId }, "Current repository memory", projectCap),
404
+ "",
405
+ renderLayer({ kind: "global" }, "Explicit global memory", globalCap),
406
+ ].join("\n");
407
+ }
408
+
409
+ /** Bounded, source-linked digest input built from notes rather than SUMMARY.md. */
410
+ export function createDigestSnapshot(root: string, scope: DigestScope): DigestSnapshot {
411
+ const notes = readStoredNotes(root)
412
+ .filter((note) => scope.kind === "global" ? note.scope === "global" : note.scope === "project" && note.projectId === scope.projectId)
413
+ .slice(-DIGEST_NOTE_CAP);
414
+ const promptSource = notes.map((note) => [
415
+ `NOTE: notes/${note.fileName}`,
416
+ `TITLE: ${note.title}`,
417
+ `PROJECT: ${note.project || "unknown"}`,
418
+ `SOURCE: ${note.source || "legacy"}`,
419
+ `DATE: ${note.updated || note.created || "unknown"}`,
420
+ note.body.slice(0, DIGEST_NOTE_BYTES),
421
+ ].join("\n")).join("\n\n");
422
+ return {
423
+ fingerprint: createHash("sha256").update(promptSource).digest("hex"),
424
+ promptSource,
425
+ noteCount: notes.length,
426
+ };
227
427
  }
@@ -4,11 +4,19 @@ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
4
4
  import { join } from "node:path";
5
5
 
6
6
  import type { ActivationState } from "./activation.js";
7
+ import { resolveProjectIdentity } from "./scope.js";
7
8
  import { searchAstraSessions } from "./sessions.js";
8
9
  import { readSummaryCapped, searchPocket, writeNote } from "./store.js";
9
10
 
10
11
  const INACTIVE_MESSAGE =
11
12
  "Pocket tools are only active in gpt-6-astra sessions with the pocket enabled (/pocket on).";
13
+ const DEFAULT_RECALL_LIMIT = 10;
14
+ export const MAX_RECALL_LIMIT_PER_SOURCE = 20;
15
+
16
+ function normalizeRecallLimit(value: unknown): number {
17
+ if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_RECALL_LIMIT;
18
+ return Math.min(MAX_RECALL_LIMIT_PER_SOURCE, Math.max(1, Math.trunc(value)));
19
+ }
12
20
 
13
21
  export interface ToolDeps {
14
22
  state: ActivationState;
@@ -28,28 +36,39 @@ export function registerPocketTools(pi: ExtensionAPI, deps: ToolDeps): void {
28
36
  name: "pocket_note",
29
37
  label: "Pocket Note",
30
38
  description:
31
- "Write a durable note to your persistent pocket. Notes survive across sessions. Use for decisions, conventions, pitfalls, and preferences worth remembering — never for secrets or ephemeral task state.",
39
+ "Write a durable note to your persistent pocket. Notes default to the current repository. Use global scope only for explicitly portable preferences or observations — never for secrets or ephemeral task state.",
32
40
  promptSnippet: "Save a durable cross-session note to the astral pocket",
33
41
  promptGuidelines: [
34
42
  "Use pocket_note when you learn something durable (a decision and why, a project convention, a pitfall, a user preference) — not for ephemeral task state.",
35
43
  "Never put secrets, credentials, tokens, or personal data in pocket notes.",
44
+ "Keep pocket_note project-scoped by default; use global scope only for a clearly general preference or conditional portable observation.",
36
45
  ],
37
46
  parameters: Type.Object({
38
47
  title: Type.String({ description: "Short note title" }),
39
48
  body: Type.String({ description: "Note content — a few sentences is enough" }),
40
49
  keywords: Type.Optional(Type.Array(Type.String(), { description: "2-5 recall keywords" })),
50
+ scope: Type.Optional(Type.Union([Type.Literal("project"), Type.Literal("global")], {
51
+ description: "Project by default. Use global only for explicitly portable preferences or observations.",
52
+ })),
41
53
  }),
42
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
54
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
43
55
  if (!deps.state.active) throw new Error(INACTIVE_MESSAGE);
44
- const fileName = await withFileMutationQueue(join(deps.root, "POCKET.md"), async () =>
45
- writeNote(deps.root, {
56
+ if (signal?.aborted) throw new Error("Pocket note cancelled");
57
+ const project = ctx.cwd;
58
+ const scope = params.scope ?? "project";
59
+ const projectId = resolveProjectIdentity(project);
60
+ const fileName = await withFileMutationQueue(join(deps.root, "POCKET.md"), async () => {
61
+ if (signal?.aborted || !deps.state.active) throw new Error("Pocket note cancelled");
62
+ return writeNote(deps.root, {
46
63
  title: params.title,
47
64
  body: params.body,
48
65
  keywords: params.keywords,
49
- project: ctx.cwd,
66
+ project,
67
+ projectId: scope === "project" ? projectId : undefined,
68
+ scope,
50
69
  source: "agent",
51
- }),
52
- );
70
+ });
71
+ });
53
72
  return textResult(`Note saved to the pocket: notes/${fileName}`);
54
73
  },
55
74
  });
@@ -58,7 +77,7 @@ export function registerPocketTools(pi: ExtensionAPI, deps: ToolDeps): void {
58
77
  name: "pocket_recall",
59
78
  label: "Pocket Recall",
60
79
  description:
61
- "Search your persistent pocket notes and your past gpt-6-astra sessions. Summarized by default (tool names + truncated args/results); pass full: true for larger excerpts. Past-session output can contain sensitive data from earlier work — prefer summarized results.",
80
+ "Search current-repository and explicit global pocket notes plus current-repository Astra sessions. Summarized by default; pass full: true for larger excerpts or scope: all for intentional cross-repository precedent.",
62
81
  promptSnippet: "Search pocket notes and past astra sessions",
63
82
  promptGuidelines: [
64
83
  "Use pocket_recall for the quick pocket pass: search with keywords from the pocket summary before deep repo exploration.",
@@ -72,21 +91,30 @@ export function registerPocketTools(pi: ExtensionAPI, deps: ToolDeps): void {
72
91
  }),
73
92
  ),
74
93
  full: Type.Optional(Type.Boolean({ description: "Return larger excerpts (default: false)" })),
75
- limit: Type.Optional(Type.Number({ description: "Max hits (default: 10)" })),
94
+ limit: Type.Optional(Type.Integer({
95
+ minimum: 1,
96
+ maximum: MAX_RECALL_LIMIT_PER_SOURCE,
97
+ description: "Max hits from each source (default: 10, maximum: 20)",
98
+ })),
99
+ scope: Type.Optional(Type.Union([Type.Literal("current"), Type.Literal("all")], {
100
+ description: "Current repository plus global notes by default; all includes foreign repositories as precedent.",
101
+ })),
76
102
  }),
77
103
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
78
104
  if (!deps.state.active) throw new Error(INACTIVE_MESSAGE);
79
105
  const source = params.source ?? "both";
80
- const limit = params.limit ?? 10;
106
+ const limit = normalizeRecallLimit(params.limit);
107
+ const recallScope = params.scope ?? "current";
108
+ const projectId = resolveProjectIdentity(ctx.cwd);
81
109
  const sections: string[] = [];
82
110
 
83
111
  if (source === "pocket" || source === "both") {
84
- const hits = searchPocket(deps.root, params.query, ctx.cwd, limit);
112
+ const hits = searchPocket(deps.root, params.query, projectId, limit, params.full, recallScope);
85
113
  sections.push(
86
114
  hits.length === 0
87
115
  ? "Pocket notes: no matches."
88
116
  : `Pocket notes (${hits.length}):\n${hits
89
- .map((h) => `- ${h.title} [notes/${h.noteFile}]${h.project ? ` (${h.project})` : ""}\n ${h.excerpt}`)
117
+ .map((h) => `- ${h.title} [notes/${h.noteFile}]${h.project ? ` (${h.project})` : ""} · scope: ${h.scope}${recallScope === "all" && h.scope === "project" && resolveProjectIdentity(h.project) !== projectId ? " · cross-repository precedent" : ""}${h.source ? ` · ${h.source}` : ""}${h.date ? ` · ${h.date}` : ""}\n ${h.excerpt}`)
90
118
  .join("\n")}`,
91
119
  );
92
120
  }
@@ -96,12 +124,14 @@ export function registerPocketTools(pi: ExtensionAPI, deps: ToolDeps): void {
96
124
  full: params.full,
97
125
  limit,
98
126
  maxAgeDays: deps.maxSessionAgeDays(),
127
+ projectId,
128
+ recallScope,
99
129
  });
100
130
  sections.push(
101
131
  hits.length === 0
102
132
  ? "Past astra sessions: no matches."
103
133
  : `Past astra sessions (${hits.length}):\n${hits
104
- .map((h) => `- [${h.kind}] ${h.timestamp} (${h.project})\n ${h.excerpt}`)
134
+ .map((h) => `- [${h.kind}] ${h.timestamp} (${h.project})${recallScope === "all" && resolveProjectIdentity(h.project) !== projectId ? " · cross-repository precedent" : ""}\n ${h.excerpt}`)
105
135
  .join("\n")}`,
106
136
  );
107
137
  }