@nklisch/pi-enhanced 0.4.3 → 0.5.0

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.
Files changed (47) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +1 -1
  3. package/node_modules/@nklisch/pi-astral-pocket/README.md +127 -93
  4. package/node_modules/@nklisch/pi-astral-pocket/package.json +2 -2
  5. package/node_modules/@nklisch/pi-astral-pocket/src/distiller.ts +196 -183
  6. package/node_modules/@nklisch/pi-astral-pocket/src/guidance.ts +26 -62
  7. package/node_modules/@nklisch/pi-astral-pocket/src/index.ts +8 -5
  8. package/node_modules/@nklisch/pi-astral-pocket/src/sessions.ts +10 -3
  9. package/node_modules/@nklisch/pi-astral-pocket/src/store.ts +439 -321
  10. package/node_modules/@nklisch/pi-astral-pocket/src/tools.ts +172 -66
  11. package/node_modules/@nklisch/pi-clearance/docs/CONFIGURATION.md +3 -3
  12. package/node_modules/@nklisch/pi-clearance/docs/DEVELOPER_GUIDE.md +11 -12
  13. package/node_modules/@nklisch/pi-clearance/docs/PACK_AUTHORING.md +5 -5
  14. package/node_modules/@nklisch/pi-clearance/docs/PRINCIPLES.md +1 -2
  15. package/node_modules/@nklisch/pi-clearance/docs/REFERENCE_PATTERNS.md +13 -19
  16. package/node_modules/@nklisch/pi-clearance/docs/SPEC.md +4 -4
  17. package/node_modules/@nklisch/pi-clearance/docs/USER_GUIDE.md +2 -3
  18. package/node_modules/@nklisch/pi-clearance/docs/VISION.md +1 -1
  19. package/node_modules/@nklisch/pi-clearance/native/clearance-core.win32-x64-msvc.node +0 -0
  20. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/README.md +2 -2
  21. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/VISION.md +2 -3
  22. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/architecture.md +105 -886
  23. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/decisions/0002-extensions-on-a-minimal-core.md +44 -78
  24. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/decisions/0003-publish-bundled-type-declarations.md +28 -57
  25. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/decisions/0004-reconsider-ui-direction.md +49 -261
  26. package/node_modules/@nklisch/pi-plugins/package.json +2 -2
  27. package/package.json +2 -2
  28. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/client-server-opportunities.md +0 -127
  29. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/history/phase-1-api-boundary.md +0 -8
  30. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/history/phase-10-structural-decomposition.md +0 -141
  31. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/history/phase-11-closure-to-class.md +0 -100
  32. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/history/phase-12-complexity-test-fixtures.md +0 -55
  33. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/history/phase-13-remaining-smells.md +0 -88
  34. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/history/phase-14-strip-policy.md +0 -49
  35. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/history/phase-15-domain-model-evolution.md +0 -73
  36. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/history/phase-16-invert-dependencies.md +0 -144
  37. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/history/phase-17-core-consolidation.md +0 -214
  38. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/history/phase-18-reconsider-ui.md +0 -166
  39. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/history/phase-19-implement-ui-decisions.md +0 -282
  40. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/history/phase-2-remove-scheduling.md +0 -9
  41. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/history/phase-3-remove-rpc-groupjoin.md +0 -11
  42. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/history/phase-4-implement-service.md +0 -8
  43. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/history/phase-5-decompose-index.md +0 -42
  44. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/history/phase-7-encapsulation.md +0 -173
  45. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/history/phase-8-testability.md +0 -103
  46. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/history/phase-9-observation-ctx.md +0 -122
  47. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/decisions/0001-deferred-patches.md +0 -80
@@ -1,65 +1,43 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ readdirSync,
7
+ renameSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from "node:fs";
3
11
  import { homedir } from "node:os";
4
12
  import { join } from "node:path";
5
-
6
13
  import { resolveProjectIdentity } from "./scope.js";
7
-
8
- const PINNED_START = "<!-- pocket:pinned:start -->";
9
- const PINNED_END = "<!-- pocket:pinned:end -->";
10
- const DIGEST_START = "<!-- pocket:digest:start -->";
11
- const DIGEST_END = "<!-- pocket:digest:end -->";
12
- const RECENT_NOTES_CAP = 20;
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;
17
-
18
- export function defaultAgentDir(): string {
19
- return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
20
- }
21
-
22
- export function pocketRoot(agentDir: string = defaultAgentDir()): string {
23
- return join(agentDir, "astral-pocket");
24
- }
25
-
26
- export function notesDir(root: string): string {
27
- return join(root, "notes");
28
- }
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
-
36
- export function ensureLayout(root: string): void {
37
- mkdirSync(notesDir(root), { recursive: true });
38
- const registry = join(root, "POCKET.md");
39
- if (!existsSync(registry)) atomicWrite(registry, renderRegistry([]));
40
- if (!existsSync(join(root, "SUMMARY.md"))) atomicWrite(join(root, "SUMMARY.md"), renderSummary(root, []));
41
- }
42
-
14
+ import { isExcludedSession, type SessionExclusion } from "./sessions.js";
15
+
16
+ export const LEARNING_VERSION = 1;
17
+ export const EXTRACTION_POLICY_VERSION = "repository-learning-1";
18
+ export const MAX_TITLE_CHARS = 160;
19
+ export const MAX_BODY_CHARS = 1_600;
20
+ export const MAX_EVIDENCE_CHARS = 500;
21
+ export type LearningKind = "navigation" | "pitfall";
43
22
  export type NoteScope = "project" | "global";
44
-
45
23
  export interface NoteInput {
24
+ kind: LearningKind;
46
25
  title: string;
47
26
  body: string;
27
+ evidence?: string;
48
28
  keywords?: string[];
49
- project?: string;
29
+ project: string;
50
30
  projectId?: string;
51
- scope?: NoteScope;
31
+ scope?: "project";
52
32
  source?: "agent" | "distilled";
53
33
  }
54
-
55
- export interface GeneratedNoteInput extends NoteInput {
34
+ export interface GeneratedSource {
56
35
  sessionId: string;
57
36
  sourcePath: string;
58
37
  sourceUpdatedAt: string;
59
38
  sourceSize: number;
60
39
  sourceRevision: string;
61
40
  }
62
-
63
41
  interface StoredNote {
64
42
  fileName: string;
65
43
  title: string;
@@ -71,219 +49,429 @@ interface StoredNote {
71
49
  source: string;
72
50
  created: string;
73
51
  updated: string;
52
+ kind: string;
53
+ version: string;
54
+ retired: boolean;
55
+ evidence: string;
56
+ policy: string;
57
+ sessionId: string;
58
+ sourcePath: string;
74
59
  }
75
-
76
- function slugify(text: string): string {
77
- const slug = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48);
78
- return slug || "note";
60
+ export function defaultAgentDir(): string {
61
+ return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
79
62
  }
80
-
81
- function stamp(date: Date): string {
82
- return date.toISOString().replace(/[:.]/g, "-");
63
+ export function pocketRoot(agentDir = defaultAgentDir()): string {
64
+ return join(agentDir, "astral-pocket");
83
65
  }
84
-
85
- function extractSection(markdown: string, start: string, end: string): string | null {
86
- const i = markdown.indexOf(start);
87
- const j = markdown.indexOf(end);
88
- if (i === -1 || j === -1 || j < i) return null;
89
- return markdown.slice(i + start.length, j).trim();
66
+ export function notesDir(root: string): string {
67
+ return join(root, "notes");
90
68
  }
91
-
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() ?? "";
69
+ function atomicWrite(path: string, contents: string): void {
70
+ const temporary = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
71
+ writeFileSync(temporary, contents, "utf8");
72
+ renameSync(temporary, path);
95
73
  }
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;
74
+ export function ensureLayout(root: string): void {
75
+ mkdirSync(notesDir(root), { recursive: true });
76
+ if (!existsSync(join(root, "POCKET.md"))) rebuildDerivedStore(root);
100
77
  }
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);
78
+ function field(text: string, name: string): string {
79
+ const frontmatter =
80
+ text.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1] ?? "";
81
+ return (
82
+ frontmatter.match(new RegExp(`^${name}: (.*)$`, "m"))?.[1]?.trim() ?? ""
83
+ );
110
84
  }
111
-
112
85
  function readStoredNotes(root: string): StoredNote[] {
113
86
  if (!existsSync(notesDir(root))) return [];
114
87
  const notes: StoredNote[] = [];
115
- for (const fileName of readdirSync(notesDir(root)).filter((name) => name.endsWith(".md"))) {
88
+ for (const fileName of readdirSync(notesDir(root)).filter((name) =>
89
+ name.endsWith(".md"),
90
+ )) {
116
91
  try {
117
92
  const text = readFileSync(join(notesDir(root), fileName), "utf8");
118
- const heading = text.match(/^# (.+)$/m)?.[1] ?? fileName;
119
- const headingAt = text.search(/^# .+$/m);
93
+ const content = text
94
+ .replace(/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/, "")
95
+ .trim();
120
96
  const project = field(text, "project");
121
97
  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";
98
+ const scope =
99
+ declaredScope === "global"
100
+ ? "global"
101
+ : declaredScope === "project" ||
102
+ (!declaredScope && project && project !== "unknown")
103
+ ? "project"
104
+ : "unknown";
127
105
  notes.push({
128
106
  fileName,
129
- title: heading,
130
107
  text,
131
- body: headingAt >= 0 ? text.slice(headingAt).replace(/^# .+\n+/, "").trim() : text.trim(),
108
+ title: content.match(/^# (.+)$/m)?.[1] ?? fileName,
109
+ body: content.replace(/^# .+\r?\n+/, "").trim(),
132
110
  project,
133
- projectId: field(text, "project_id") || (scope === "project" && project !== "" && project !== "unknown" ? resolveProjectIdentity(project) : ""),
111
+ projectId:
112
+ field(text, "project_id") ||
113
+ (scope === "project" && project
114
+ ? resolveProjectIdentity(project)
115
+ : ""),
134
116
  scope,
135
117
  source: field(text, "source") || "legacy",
136
118
  created: field(text, "created"),
137
- updated: field(text, "updated") || field(text, "source_updated_at") || field(text, "created"),
119
+ updated: field(text, "updated") || field(text, "created"),
120
+ kind: field(text, "kind"),
121
+ version: field(text, "learning_version"),
122
+ retired: field(text, "retired") === "true",
123
+ evidence: field(text, "evidence"),
124
+ policy: field(text, "extraction_policy"),
125
+ sessionId: field(text, "session_id"),
126
+ sourcePath: field(text, "source_path"),
138
127
  });
139
128
  } catch {
140
- // One unreadable note must not hide the remaining canonical note files.
129
+ /* One unreadable note must not hide the remaining canonical files. */
141
130
  }
142
131
  }
143
- return notes.sort(compareNotesByTime);
132
+ return notes.sort(
133
+ (a, b) =>
134
+ b.updated.localeCompare(a.updated) ||
135
+ a.fileName.localeCompare(b.fileName),
136
+ );
137
+ }
138
+
139
+ /** A narrow rejection of obvious preference text, not a semantic classifier.
140
+ * The explicit contract and extraction prompt carry the broader exclusions. */
141
+ function clearPreference(text: string): boolean {
142
+ return /\b(?:user|maintainer|author)\s+(?:explicitly\s+)?(?:prefers?|wants?|likes?)\b|\buser preferences?\b|\bprefer (?:concise|terse|verbose)\b/i.test(
143
+ text,
144
+ );
145
+ }
146
+ export function validateLearning(
147
+ input: Pick<NoteInput, "kind" | "title" | "body" | "evidence" | "keywords">,
148
+ ): void {
149
+ if (input.kind !== "navigation" && input.kind !== "pitfall")
150
+ throw new Error(
151
+ "Learning kind must be navigation or pitfall, never preferences or policy",
152
+ );
153
+ for (const [label, value, cap] of [
154
+ ["title", input.title, MAX_TITLE_CHARS],
155
+ ["body", input.body, MAX_BODY_CHARS],
156
+ ] as const) {
157
+ if (typeof value !== "string" || !value.trim() || value.length > cap)
158
+ throw new Error(`${label} must contain 1-${cap} characters`);
159
+ }
160
+ if (/[\r\n]/.test(input.title))
161
+ throw new Error("Title must be a single line");
162
+ if (
163
+ input.evidence !== undefined &&
164
+ (typeof input.evidence !== "string" ||
165
+ input.evidence.length > MAX_EVIDENCE_CHARS ||
166
+ /[\r\n]/.test(input.evidence))
167
+ )
168
+ throw new Error(
169
+ "Evidence must be a single-line pointer of at most 500 characters",
170
+ );
171
+ if (
172
+ input.keywords &&
173
+ (input.keywords.length > 5 ||
174
+ input.keywords.some(
175
+ (v) => typeof v !== "string" || v.length > 64 || /[\r\n]/.test(v),
176
+ ))
177
+ )
178
+ throw new Error(
179
+ "Use at most 5 single-line keywords of at most 64 characters",
180
+ );
181
+ if (clearPreference(`${input.title}\n${input.body}`))
182
+ throw new Error(
183
+ "Pocket stores repository navigation and pitfalls, not user preferences",
184
+ );
185
+ }
186
+ function eligible(note: StoredNote): boolean {
187
+ if (
188
+ note.retired ||
189
+ note.scope !== "project" ||
190
+ !note.projectId ||
191
+ note.version !== String(LEARNING_VERSION)
192
+ )
193
+ return false;
194
+ if (note.source === "distilled" && note.policy !== EXTRACTION_POLICY_VERSION)
195
+ return false;
196
+ try {
197
+ validateLearning({ ...note, kind: note.kind as LearningKind });
198
+ return true;
199
+ } catch {
200
+ return false;
201
+ }
144
202
  }
145
-
146
- function noteMarkdown(input: NoteInput, metadata: string[], created: Date, updated: Date = created): string {
203
+ function noteMarkdown(
204
+ input: NoteInput,
205
+ metadata: string[],
206
+ created: string,
207
+ updated: string,
208
+ ): string {
147
209
  return [
148
210
  "---",
149
- `created: ${created.toISOString()}`,
150
- `updated: ${updated.toISOString()}`,
151
- `project: ${input.project ?? "unknown"}`,
152
- `project_id: ${input.projectId ?? ""}`,
153
- `scope: ${input.scope ?? "project"}`,
211
+ `learning_version: ${LEARNING_VERSION}`,
212
+ `kind: ${input.kind}`,
213
+ "retired: false",
214
+ `created: ${created}`,
215
+ `updated: ${updated}`,
216
+ `project: ${input.project}`,
217
+ `project_id: ${input.projectId ?? resolveProjectIdentity(input.project)}`,
218
+ "scope: project",
154
219
  `keywords: [${(input.keywords ?? []).join(", ")}]`,
155
220
  `source: ${input.source ?? "agent"}`,
221
+ ...(input.evidence ? [`evidence: ${input.evidence}`] : []),
156
222
  ...metadata,
157
223
  "---",
158
224
  "",
159
- `# ${input.title}`,
225
+ `# ${input.title.trim()}`,
160
226
  "",
161
227
  input.body.trim(),
162
228
  "",
163
229
  ].join("\n");
164
230
  }
165
-
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`;
171
- return fileName;
172
- }
173
-
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));
231
+ function validateNote(input: NoteInput): void {
232
+ validateLearning(input);
233
+ if (input.scope !== undefined && input.scope !== "project")
234
+ throw new Error("New notes are project-only");
235
+ if (
236
+ !input.project ||
237
+ /[\r\n]/.test(input.project) ||
238
+ /[\r\n]/.test(input.projectId ?? "")
239
+ )
240
+ throw new Error("A single-line repository identity is required");
241
+ }
242
+ /** Mutations accept canonical filenames, never arbitrary paths supplied by a tool. */
243
+ function ownedNote(
244
+ root: string,
245
+ fileName: string,
246
+ projectId: string,
247
+ ): StoredNote {
248
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*\.md$/.test(fileName))
249
+ throw new Error("Use a note filename, not a path");
250
+ const note = readStoredNotes(root).find((n) => n.fileName === fileName);
251
+ if (!note || note.scope !== "project" || note.projectId !== projectId)
252
+ throw new Error("Note does not belong to the current repository");
253
+ return note;
254
+ }
255
+ export function retireNote(
256
+ root: string,
257
+ fileName: string,
258
+ projectId: string,
259
+ now = new Date(),
260
+ ): void {
261
+ const note = ownedNote(root, fileName, projectId);
262
+ const frontmatter = note.text.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
263
+ if (!frontmatter) throw new Error("Note has no editable metadata");
264
+ const metadata = frontmatter[1]
265
+ .split(/\r?\n/)
266
+ .filter((line) => !/^(retired|updated): /.test(line));
267
+ const text = [
268
+ "---",
269
+ "retired: true",
270
+ `updated: ${now.toISOString()}`,
271
+ ...metadata,
272
+ "---",
273
+ note.text.slice(frontmatter[0].length),
274
+ ].join("\n");
275
+ atomicWrite(join(notesDir(root), fileName), text);
179
276
  rebuildDerivedStore(root);
180
- return fileName;
181
277
  }
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`;
278
+ export interface NoteMutation {
279
+ replace?: string;
280
+ supersedes?: string[];
186
281
  }
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 {
282
+ /** Call inside the POCKET.md mutation queue. Manual replacement never edits session-owned files. */
283
+ export function writeNote(
284
+ root: string,
285
+ input: NoteInput,
286
+ now = new Date(),
287
+ mutation: NoteMutation = {},
288
+ ): string {
289
+ validateNote(input);
190
290
  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));
291
+ const projectId = input.projectId ?? resolveProjectIdentity(input.project);
292
+ const previous = mutation.replace
293
+ ? ownedNote(root, mutation.replace, projectId)
294
+ : undefined;
295
+ if (previous?.source === "distilled")
296
+ throw new Error(
297
+ "Retire generated notes or supersede them with a manual note instead",
298
+ );
299
+ for (const file of mutation.supersedes ?? [])
300
+ ownedNote(root, file, projectId);
301
+ const slug = input.title
302
+ .toLowerCase()
303
+ .replace(/[^a-z0-9]+/g, "-")
304
+ .slice(0, 48);
305
+ const fileName =
306
+ previous?.fileName ??
307
+ `${now.toISOString().replace(/[:.]/g, "-")}-${slug}-${Math.random().toString(16).slice(2, 8)}.md`;
308
+ atomicWrite(
309
+ join(notesDir(root), fileName),
310
+ noteMarkdown(
311
+ { ...input, source: "agent" },
312
+ [],
313
+ previous?.created || now.toISOString(),
314
+ now.toISOString(),
315
+ ),
316
+ );
317
+ for (const file of mutation.supersedes ?? [])
318
+ if (file !== fileName) retireNote(root, file, projectId, now);
205
319
  rebuildDerivedStore(root);
206
320
  return fileName;
207
321
  }
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);
322
+ export function generatedNoteFile(sessionId: string, index?: number): string {
323
+ const identity = createHash("sha256")
324
+ .update(sessionId)
325
+ .digest("hex")
326
+ .slice(0, 24);
327
+ return `session-${identity}${index === undefined ? "" : `-${index + 1}`}.md`;
328
+ }
329
+ /** Replace the bounded set owned by this session; retain retirement tombstones so
330
+ * re-extraction cannot resurrect a learning deliberately removed by the agent. */
331
+ export function replaceGeneratedNotes(
332
+ root: string,
333
+ inputs: NoteInput[],
334
+ source: GeneratedSource,
335
+ now = new Date(),
336
+ ): string[] {
337
+ inputs.forEach(validateNote);
338
+ ensureLayout(root);
339
+ const previous = readStoredNotes(root).filter(
340
+ (n) => n.source === "distilled" && n.sessionId === source.sessionId,
341
+ );
342
+ if (previous.some((note) => note.retired))
343
+ return previous
344
+ .filter((note) => !note.retired)
345
+ .map((note) => note.fileName);
346
+ const files: string[] = [];
347
+ inputs.forEach((input, index) => {
348
+ const fileName = generatedNoteFile(source.sessionId, index);
349
+ const old = previous.find((n) => n.fileName === fileName);
350
+ atomicWrite(
351
+ join(notesDir(root), fileName),
352
+ noteMarkdown(
353
+ { ...input, source: "distilled" },
354
+ [
355
+ `extraction_policy: ${EXTRACTION_POLICY_VERSION}`,
356
+ `session_id: ${source.sessionId}`,
357
+ `source_path: ${source.sourcePath}`,
358
+ `source_updated_at: ${source.sourceUpdatedAt}`,
359
+ `source_size: ${source.sourceSize}`,
360
+ `source_revision: ${source.sourceRevision}`,
361
+ ],
362
+ old?.created || now.toISOString(),
363
+ now.toISOString(),
364
+ ),
365
+ );
366
+ files.push(fileName);
367
+ });
368
+ for (const note of previous)
369
+ if (!note.retired && !files.includes(note.fileName))
370
+ rmSync(join(notesDir(root), note.fileName));
371
+ // Only retire a legacy generated file when its provenance confirms ownership;
372
+ // a matching filename alone must never delete a manually authored note.
373
+ const legacyFile = generatedNoteFile(source.sessionId);
374
+ const legacy = readStoredNotes(root).find(
375
+ (note) => note.fileName === legacyFile,
376
+ );
377
+ if (legacy?.source === "distilled" && !legacy.retired)
378
+ rmSync(join(notesDir(root), legacyFile));
213
379
  rebuildDerivedStore(root);
214
- return true;
380
+ return files;
215
381
  }
216
-
217
382
  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}`;
383
+ return `- [${note.title}](notes/${note.fileName}) — ${note.scope} ${note.retired ? "retired" : eligible(note) ? note.kind : "historical/unclassified"} — ${note.updated || note.created}`;
223
384
  }
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");
385
+ export function rebuildDerivedStore(root: string): void {
386
+ atomicWrite(
387
+ join(root, "POCKET.md"),
388
+ [
389
+ "# Astral Pocket Registry",
390
+ "",
391
+ "Derived from canonical notes; historical entries are not active learning.",
392
+ "",
393
+ ...readStoredNotes(root).map(registryLine),
394
+ "",
395
+ ].join("\n"),
396
+ );
234
397
  }
235
-
236
398
  export function countNotes(root: string): number {
237
399
  return readStoredNotes(root).length;
238
400
  }
239
-
240
- export function readRegistryLines(root: string): string[] {
241
- const registry = join(root, "POCKET.md");
242
- if (!existsSync(registry)) return [];
243
- return readFileSync(registry, "utf8").split("\n").filter((line) => line.startsWith("- ["));
244
- }
245
-
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)));
251
- }
252
-
253
- export const rerenderSummary = rebuildDerivedStore;
254
-
255
- function renderSummary(root: string, registryLines: string[]): string {
256
- const summaryPath = join(root, "SUMMARY.md");
257
- const existing = existsSync(summaryPath) ? readFileSync(summaryPath, "utf8") : "";
258
- const pinned = extractSection(existing, PINNED_START, PINNED_END) ?? "";
259
- const digest = extractSection(existing, DIGEST_START, DIGEST_END) ??
260
- "_No digest yet. It is filled in by the distiller pass; until then, rely on Recent notes and search POCKET.md._";
261
- const recent = registryLines.slice(-RECENT_NOTES_CAP);
401
+ /** Source-level exclusion deliberately trades later automatic discoveries for
402
+ * predictable retirement: a model can paraphrase both a title and its body. */
403
+ export function retiredSessionIds(
404
+ root: string,
405
+ projectId?: string,
406
+ ): Set<string> {
407
+ return new Set(
408
+ readStoredNotes(root)
409
+ .filter(
410
+ (n) =>
411
+ n.retired &&
412
+ n.source === "distilled" &&
413
+ n.sessionId &&
414
+ (projectId === undefined || n.projectId === projectId),
415
+ )
416
+ .map((n) => n.sessionId),
417
+ );
418
+ }
419
+ function card(note: StoredNote): string {
262
420
  return [
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._"]), "",
421
+ `### ${note.title}`,
422
+ `${(note.updated || note.created).slice(0, 10)} · ${note.kind} · [source](notes/${note.fileName})`,
423
+ note.body,
424
+ ...(note.evidence ? [`Evidence: ${note.evidence}`] : []),
266
425
  ].join("\n");
267
426
  }
268
-
269
- export function updateDigest(root: string, digest: string): boolean {
270
- const summaryPath = join(root, "SUMMARY.md");
271
- if (!existsSync(summaryPath)) return false;
272
- const existing = readFileSync(summaryPath, "utf8");
273
- const i = existing.indexOf(DIGEST_START);
274
- const j = existing.indexOf(DIGEST_END);
275
- if (i === -1 || j === -1 || j < i) return false;
276
- atomicWrite(summaryPath, `${existing.slice(0, i + DIGEST_START.length)}\n${digest.trim()}\n${existing.slice(j)}`);
277
- return true;
278
- }
279
-
280
- export function readSummaryCapped(root: string, capBytes = 12_000): string {
281
- const summaryPath = join(root, "SUMMARY.md");
282
- if (!existsSync(summaryPath)) return "";
283
- const text = readFileSync(summaryPath, "utf8");
284
- return text.length <= capBytes ? text : `${text.slice(0, capBytes)}\n\n_(summary truncated; search POCKET.md for older material)_`;
427
+ /** Deterministic complete cards. Consider every eligible source; manual learning
428
+ * wins over automatic extraction, with kind interleaving to retain navigation. */
429
+ export function readScopedSummary(
430
+ root: string,
431
+ projectId: string,
432
+ capChars = 6_000,
433
+ ): string {
434
+ const notes = readStoredNotes(root).filter(
435
+ (n) => eligible(n) && n.projectId === projectId,
436
+ );
437
+ const ranked = notes.sort(
438
+ (a, b) =>
439
+ Number(a.source === "distilled") - Number(b.source === "distilled") ||
440
+ b.updated.localeCompare(a.updated) ||
441
+ a.fileName.localeCompare(b.fileName),
442
+ );
443
+ const ordered: StoredNote[] = [];
444
+ for (const automatic of [false, true]) {
445
+ const navigation = ranked.filter(
446
+ (n) =>
447
+ (n.source === "distilled") === automatic && n.kind === "navigation",
448
+ );
449
+ const pitfalls = ranked.filter(
450
+ (n) => (n.source === "distilled") === automatic && n.kind === "pitfall",
451
+ );
452
+ for (let i = 0; i < Math.max(navigation.length, pitfalls.length); i++) {
453
+ if (navigation[i]) ordered.push(navigation[i]);
454
+ if (pitfalls[i]) ordered.push(pitfalls[i]);
455
+ }
456
+ }
457
+ const heading = "# Current repository learning\n\n";
458
+ const footer = (skipped: number) =>
459
+ `\n\n${skipped} active learning(s) skipped. Use pocket_recall for relevant source excerpts.`;
460
+ if (heading.length + footer(notes.length).length > capChars) return "";
461
+ let text = heading;
462
+ let included = 0;
463
+ for (const note of ordered) {
464
+ const addition = `${included ? "\n\n" : ""}${card(note)}`;
465
+ if (
466
+ text.length + addition.length + footer(notes.length).length <=
467
+ capChars
468
+ ) {
469
+ text += addition;
470
+ included++;
471
+ }
472
+ }
473
+ return text + footer(notes.length - included);
285
474
  }
286
-
287
475
  export interface PocketSearchHit {
288
476
  noteFile: string;
289
477
  title: string;
@@ -293,9 +481,9 @@ export interface PocketSearchHit {
293
481
  source: string;
294
482
  scope: NoteScope | "unknown";
295
483
  date: string;
484
+ historical: boolean;
485
+ retired: boolean;
296
486
  }
297
-
298
- /** Search complete canonical note content, then truncate only the returned excerpt. */
299
487
  export function searchPocket(
300
488
  root: string,
301
489
  query: string,
@@ -303,125 +491,55 @@ export function searchPocket(
303
491
  limit: number,
304
492
  full = false,
305
493
  recallScope: "current" | "all" = "current",
494
+ includeHistory = false,
495
+ excludeSession?: SessionExclusion,
306
496
  ): PocketSearchHit[] {
307
497
  const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
308
- if (terms.length === 0) return [];
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
- }
350
-
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;
366
- }
367
- }
368
-
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
- };
498
+ if (!terms.length) return [];
499
+ const cap = full ? 4_000 : 320;
500
+ return readStoredNotes(root)
501
+ .filter(
502
+ (n) =>
503
+ !isExcludedSession(
504
+ { id: n.sessionId, path: n.sourcePath },
505
+ excludeSession,
506
+ ),
507
+ )
508
+ .filter((n) => includeHistory || eligible(n))
509
+ .filter(
510
+ (n) =>
511
+ recallScope === "all" ||
512
+ n.projectId === currentProject ||
513
+ (includeHistory && n.scope === "global"),
514
+ )
515
+ .filter((n) => terms.every((term) => n.text.toLowerCase().includes(term)))
516
+ .sort(
517
+ (a, b) =>
518
+ Number(b.projectId === currentProject) -
519
+ Number(a.projectId === currentProject) ||
520
+ b.updated.localeCompare(a.updated) ||
521
+ a.fileName.localeCompare(b.fileName),
522
+ )
523
+ .slice(0, Math.max(1, limit))
524
+ .map((n) => {
525
+ const matches = terms
526
+ .map((term) => n.body.toLowerCase().indexOf(term))
527
+ .filter((at) => at >= 0);
528
+ const start = Math.max(
529
+ 0,
530
+ (matches.length ? Math.min(...matches) : 0) - Math.floor(cap / 4),
531
+ );
532
+ return {
533
+ noteFile: n.fileName,
534
+ title: n.title,
535
+ excerpt: `${start ? "…" : ""}${n.body.slice(start, start + cap)}${start + cap < n.body.length ? "…" : ""}`,
536
+ project: n.project,
537
+ projectId: n.projectId,
538
+ scope: n.scope,
539
+ source: n.source,
540
+ date: n.updated || n.created,
541
+ historical: !eligible(n) || n.projectId !== currentProject,
542
+ retired: n.retired,
543
+ };
544
+ });
427
545
  }