@nklisch/pi-enhanced 0.4.4 → 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.
- package/CHANGELOG.md +12 -1
- package/README.md +1 -1
- package/node_modules/@nklisch/pi-astral-pocket/README.md +127 -93
- package/node_modules/@nklisch/pi-astral-pocket/package.json +2 -2
- package/node_modules/@nklisch/pi-astral-pocket/src/distiller.ts +196 -183
- package/node_modules/@nklisch/pi-astral-pocket/src/guidance.ts +26 -62
- package/node_modules/@nklisch/pi-astral-pocket/src/index.ts +8 -5
- package/node_modules/@nklisch/pi-astral-pocket/src/sessions.ts +10 -3
- package/node_modules/@nklisch/pi-astral-pocket/src/store.ts +439 -321
- package/node_modules/@nklisch/pi-astral-pocket/src/tools.ts +172 -66
- package/node_modules/@nklisch/pi-clearance/docs/CONFIGURATION.md +3 -3
- package/node_modules/@nklisch/pi-clearance/docs/DEVELOPER_GUIDE.md +11 -12
- package/node_modules/@nklisch/pi-clearance/docs/PACK_AUTHORING.md +5 -5
- package/node_modules/@nklisch/pi-clearance/docs/PRINCIPLES.md +1 -2
- package/node_modules/@nklisch/pi-clearance/docs/REFERENCE_PATTERNS.md +13 -19
- package/node_modules/@nklisch/pi-clearance/docs/SPEC.md +4 -4
- package/node_modules/@nklisch/pi-clearance/docs/USER_GUIDE.md +2 -3
- package/node_modules/@nklisch/pi-clearance/docs/VISION.md +1 -1
- package/node_modules/@nklisch/pi-clearance/native/clearance-core.win32-x64-msvc.node +0 -0
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/README.md +2 -2
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/VISION.md +2 -3
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/architecture.md +105 -886
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/decisions/0002-extensions-on-a-minimal-core.md +44 -78
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/decisions/0003-publish-bundled-type-declarations.md +28 -57
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/decisions/0004-reconsider-ui-direction.md +49 -261
- package/package.json +1 -1
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/client-server-opportunities.md +0 -127
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/history/phase-1-api-boundary.md +0 -8
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/history/phase-10-structural-decomposition.md +0 -141
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/history/phase-11-closure-to-class.md +0 -100
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/history/phase-12-complexity-test-fixtures.md +0 -55
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/history/phase-13-remaining-smells.md +0 -88
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/history/phase-14-strip-policy.md +0 -49
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/history/phase-15-domain-model-evolution.md +0 -73
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/history/phase-16-invert-dependencies.md +0 -144
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/history/phase-17-core-consolidation.md +0 -214
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/history/phase-18-reconsider-ui.md +0 -166
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/history/phase-19-implement-ui-decisions.md +0 -282
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/history/phase-2-remove-scheduling.md +0 -9
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/history/phase-3-remove-rpc-groupjoin.md +0 -11
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/history/phase-4-implement-service.md +0 -8
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/history/phase-5-decompose-index.md +0 -42
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/history/phase-7-encapsulation.md +0 -173
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/history/phase-8-testability.md +0 -103
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/history/phase-9-observation-ctx.md +0 -122
- 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 {
|
|
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
|
-
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
|
|
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
|
|
29
|
+
project: string;
|
|
50
30
|
projectId?: string;
|
|
51
|
-
scope?:
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
return date.toISOString().replace(/[:.]/g, "-");
|
|
63
|
+
export function pocketRoot(agentDir = defaultAgentDir()): string {
|
|
64
|
+
return join(agentDir, "astral-pocket");
|
|
83
65
|
}
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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) =>
|
|
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
|
|
119
|
-
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
108
|
+
title: content.match(/^# (.+)$/m)?.[1] ?? fileName,
|
|
109
|
+
body: content.replace(/^# .+\r?\n+/, "").trim(),
|
|
132
110
|
project,
|
|
133
|
-
projectId:
|
|
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, "
|
|
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
|
-
|
|
129
|
+
/* One unreadable note must not hide the remaining canonical files. */
|
|
141
130
|
}
|
|
142
131
|
}
|
|
143
|
-
return notes.sort(
|
|
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
|
-
|
|
203
|
+
function noteMarkdown(
|
|
204
|
+
input: NoteInput,
|
|
205
|
+
metadata: string[],
|
|
206
|
+
created: string,
|
|
207
|
+
updated: string,
|
|
208
|
+
): string {
|
|
147
209
|
return [
|
|
148
210
|
"---",
|
|
149
|
-
`
|
|
150
|
-
`
|
|
151
|
-
|
|
152
|
-
`
|
|
153
|
-
`
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
return `session-${identity}.md`;
|
|
278
|
+
export interface NoteMutation {
|
|
279
|
+
replace?: string;
|
|
280
|
+
supersedes?: string[];
|
|
186
281
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|
380
|
+
return files;
|
|
215
381
|
}
|
|
216
|
-
|
|
217
382
|
function registryLine(note: StoredNote): string {
|
|
218
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
|
309
|
-
const cap = full ?
|
|
310
|
-
|
|
311
|
-
.filter(
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
}
|