@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.
- package/node_modules/@nklisch/pi-astral-pocket/README.md +108 -52
- package/node_modules/@nklisch/pi-astral-pocket/package.json +3 -1
- package/node_modules/@nklisch/pi-astral-pocket/src/config.ts +40 -23
- package/node_modules/@nklisch/pi-astral-pocket/src/controller.ts +78 -0
- package/node_modules/@nklisch/pi-astral-pocket/src/distiller.ts +182 -73
- package/node_modules/@nklisch/pi-astral-pocket/src/guidance.ts +12 -5
- package/node_modules/@nklisch/pi-astral-pocket/src/index.ts +172 -77
- package/node_modules/@nklisch/pi-astral-pocket/src/provider.ts +128 -0
- package/node_modules/@nklisch/pi-astral-pocket/src/scope.ts +33 -0
- package/node_modules/@nklisch/pi-astral-pocket/src/sessions.ts +111 -99
- package/node_modules/@nklisch/pi-astral-pocket/src/store.ts +319 -119
- package/node_modules/@nklisch/pi-astral-pocket/src/tools.ts +43 -13
- package/node_modules/@nklisch/pi-clearance/native/clearance-core.win32-x64-msvc.node +0 -0
- package/package.json +1 -1
|
@@ -1,23 +1,19 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
|
20
|
-
const
|
|
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
|
-
|
|
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, "-")
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
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: ${
|
|
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
|
-
|
|
164
|
+
}
|
|
89
165
|
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
/**
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
|
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,
|
|
54
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
43
55
|
if (!deps.state.active) throw new Error(INACTIVE_MESSAGE);
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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,
|
|
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
|
}
|