@nklisch/pi-enhanced 0.3.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/CHANGELOG.md +10 -0
- package/README.md +1 -0
- package/node_modules/@nklisch/pi-astral-pocket/LICENSE +3 -0
- package/node_modules/@nklisch/pi-astral-pocket/README.md +138 -0
- package/node_modules/@nklisch/pi-astral-pocket/package.json +54 -0
- package/node_modules/@nklisch/pi-astral-pocket/src/activation.ts +44 -0
- package/node_modules/@nklisch/pi-astral-pocket/src/config.ts +95 -0
- package/node_modules/@nklisch/pi-astral-pocket/src/controller.ts +78 -0
- package/node_modules/@nklisch/pi-astral-pocket/src/distiller.ts +271 -0
- package/node_modules/@nklisch/pi-astral-pocket/src/guidance.ts +66 -0
- package/node_modules/@nklisch/pi-astral-pocket/src/index.ts +222 -0
- 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 +237 -0
- package/node_modules/@nklisch/pi-astral-pocket/src/store.ts +427 -0
- package/node_modules/@nklisch/pi-astral-pocket/src/tools.ts +142 -0
- package/node_modules/@nklisch/pi-clearance/native/clearance-core.darwin-arm64.node +0 -0
- package/node_modules/@nklisch/pi-clearance/native/clearance-core.darwin-x64.node +0 -0
- package/node_modules/@nklisch/pi-clearance/native/clearance-core.linux-arm64-gnu.node +0 -0
- package/node_modules/@nklisch/pi-clearance/native/clearance-core.win32-x64-msvc.node +0 -0
- package/node_modules/@nklisch/pi-conveniences/extensions/agents-context.ts +4 -6
- package/node_modules/@nklisch/pi-conveniences/extensions/context-window-footer.ts +38 -22
- package/node_modules/@nklisch/pi-conveniences/package.json +1 -1
- package/node_modules/@nklisch/pi-plugins/README.md +12 -6
- package/node_modules/@nklisch/pi-plugins/dist/catalog.js +11 -0
- package/node_modules/@nklisch/pi-plugins/dist/catalog.js.map +1 -1
- package/node_modules/@nklisch/pi-plugins/dist/host.js +85 -29
- package/node_modules/@nklisch/pi-plugins/dist/host.js.map +1 -1
- package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager-layout.d.ts +4 -0
- package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager-layout.js +24 -0
- package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager-layout.js.map +1 -0
- package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.d.ts +6 -0
- package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.js +130 -47
- package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.js.map +1 -1
- package/node_modules/@nklisch/pi-plugins/dist/plugin-metadata.d.ts +9 -0
- package/node_modules/@nklisch/pi-plugins/dist/plugin-metadata.js +37 -0
- package/node_modules/@nklisch/pi-plugins/dist/plugin-metadata.js.map +1 -0
- package/node_modules/@nklisch/pi-plugins/dist/runtime-discovery.js +2 -0
- package/node_modules/@nklisch/pi-plugins/dist/runtime-discovery.js.map +1 -1
- package/node_modules/@nklisch/pi-plugins/dist/types.d.ts +4 -1
- package/node_modules/@nklisch/pi-plugins/dist/types.js.map +1 -1
- package/node_modules/@nklisch/pi-plugins/package.json +1 -1
- package/package.json +4 -1
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
const cache = new Map<string, string>();
|
|
6
|
+
|
|
7
|
+
function canonicalPath(path: string): string {
|
|
8
|
+
try { return realpathSync(path); } catch { return resolve(path); }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Identify a local repository by Git's common directory, which is shared by
|
|
13
|
+
* subdirectories and linked worktrees. Non-Git directories use their resolved
|
|
14
|
+
* cwd. Remote URLs and basenames are deliberately not identity inputs.
|
|
15
|
+
*/
|
|
16
|
+
export function resolveProjectIdentity(cwd: string): string {
|
|
17
|
+
const canonicalCwd = canonicalPath(cwd);
|
|
18
|
+
const cached = cache.get(canonicalCwd);
|
|
19
|
+
if (cached) return cached;
|
|
20
|
+
let identity = canonicalCwd;
|
|
21
|
+
try {
|
|
22
|
+
const commonDir = execFileSync(
|
|
23
|
+
"git",
|
|
24
|
+
["-C", canonicalCwd, "rev-parse", "--path-format=absolute", "--git-common-dir"],
|
|
25
|
+
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 2_000 },
|
|
26
|
+
).trim();
|
|
27
|
+
if (commonDir) identity = canonicalPath(commonDir);
|
|
28
|
+
} catch {
|
|
29
|
+
// Not being a Git repository is a supported mode, not an activation error.
|
|
30
|
+
}
|
|
31
|
+
cache.set(canonicalCwd, identity);
|
|
32
|
+
return identity;
|
|
33
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { createReadStream, existsSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { createInterface } from "node:readline";
|
|
4
|
+
|
|
5
|
+
import { ASTRA_MODEL_ID, ASTRA_PROVIDER } from "./activation.js";
|
|
6
|
+
import { resolveProjectIdentity } from "./scope.js";
|
|
7
|
+
|
|
8
|
+
export interface SessionRevision {
|
|
9
|
+
mtimeMs: number;
|
|
10
|
+
size: number;
|
|
11
|
+
key: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SessionFileInfo extends SessionRevision {
|
|
15
|
+
path: string;
|
|
16
|
+
id: string;
|
|
17
|
+
cwd: string;
|
|
18
|
+
astra: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SessionSearchHit {
|
|
22
|
+
sessionId: string;
|
|
23
|
+
project: string;
|
|
24
|
+
timestamp: string;
|
|
25
|
+
kind: "user" | "assistant" | "toolCall" | "toolResult";
|
|
26
|
+
excerpt: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const HITS_PER_FILE_CAP = 50;
|
|
30
|
+
const SUMMARIZED_EXCERPT = 200;
|
|
31
|
+
const FULL_EXCERPT = 2_000;
|
|
32
|
+
|
|
33
|
+
async function* iterLines(path: string): AsyncGenerator<string> {
|
|
34
|
+
const rl = createInterface({ input: createReadStream(path, "utf8"), crlfDelay: Infinity });
|
|
35
|
+
for await (const line of rl) yield line;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function revisionFor(path: string): SessionRevision | undefined {
|
|
39
|
+
try {
|
|
40
|
+
const stat = statSync(path);
|
|
41
|
+
const mtimeMs = stat.mtimeMs;
|
|
42
|
+
const size = stat.size;
|
|
43
|
+
return { mtimeMs, size, key: `${mtimeMs}:${size}` };
|
|
44
|
+
} catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Enumerate top-level session files across all project dirs, newest first. */
|
|
50
|
+
export function listSessionFiles(sessionsDir: string, maxAgeDays?: number, nowMs = Date.now()): Array<{ path: string } & SessionRevision> {
|
|
51
|
+
if (!existsSync(sessionsDir)) return [];
|
|
52
|
+
const cutoff = maxAgeDays === undefined ? null : nowMs - maxAgeDays * 86_400_000;
|
|
53
|
+
const files: Array<{ path: string } & SessionRevision> = [];
|
|
54
|
+
for (const dir of readdirSync(sessionsDir)) {
|
|
55
|
+
const dirPath = join(sessionsDir, dir);
|
|
56
|
+
try {
|
|
57
|
+
if (!statSync(dirPath).isDirectory()) continue;
|
|
58
|
+
for (const file of readdirSync(dirPath)) {
|
|
59
|
+
if (!file.endsWith(".jsonl")) continue;
|
|
60
|
+
const path = join(dirPath, file);
|
|
61
|
+
const revision = revisionFor(path);
|
|
62
|
+
if (!revision || (cutoff !== null && revision.mtimeMs < cutoff)) continue;
|
|
63
|
+
files.push({ path, ...revision });
|
|
64
|
+
}
|
|
65
|
+
} catch {
|
|
66
|
+
// Recall remains available when one session directory is unreadable.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return files.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isAstraMarker(entry: Record<string, unknown>): boolean {
|
|
73
|
+
if (entry.type === "model_change" && entry.provider === ASTRA_PROVIDER && entry.modelId === ASTRA_MODEL_ID) return true;
|
|
74
|
+
if (entry.type === "message") {
|
|
75
|
+
const msg = entry.message as Record<string, unknown> | undefined;
|
|
76
|
+
if (msg?.role === "assistant" && msg.provider === ASTRA_PROVIDER && msg.model === ASTRA_MODEL_ID) return true;
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function identifySession(path: string, revisionOrMtime: SessionRevision | number): Promise<SessionFileInfo> {
|
|
82
|
+
const revision = typeof revisionOrMtime === "number"
|
|
83
|
+
? (revisionFor(path) ?? { mtimeMs: revisionOrMtime, size: 0, key: `${revisionOrMtime}:0` })
|
|
84
|
+
: revisionOrMtime;
|
|
85
|
+
let id = "";
|
|
86
|
+
let cwd = "";
|
|
87
|
+
let astra = false;
|
|
88
|
+
for await (const line of iterLines(path)) {
|
|
89
|
+
if (id && cwd && astra) break;
|
|
90
|
+
let entry: Record<string, unknown>;
|
|
91
|
+
try { entry = JSON.parse(line) as Record<string, unknown>; } catch { continue; }
|
|
92
|
+
if (entry.type === "session") {
|
|
93
|
+
id = String(entry.id ?? "");
|
|
94
|
+
cwd = String(entry.cwd ?? "");
|
|
95
|
+
} else if (isAstraMarker(entry)) astra = true;
|
|
96
|
+
}
|
|
97
|
+
return { path, id, cwd, astra, ...revision };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function textOf(content: unknown): string {
|
|
101
|
+
if (typeof content === "string") return content;
|
|
102
|
+
if (!Array.isArray(content)) return "";
|
|
103
|
+
return content.filter((block): block is Record<string, unknown> => typeof block === "object" && block !== null)
|
|
104
|
+
.map((block) => typeof block.text === "string" ? block.text : "").filter(Boolean).join(" ");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function truncate(text: string, cap: number): string {
|
|
108
|
+
const clean = text.replace(/\s+/g, " ").trim();
|
|
109
|
+
return clean.length <= cap ? clean : `${clean.slice(0, cap)}…`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function searchAstraSessions(
|
|
113
|
+
sessionsDir: string,
|
|
114
|
+
query: string,
|
|
115
|
+
options: { full?: boolean; limit?: number; maxAgeDays?: number; projectId?: string; recallScope?: "current" | "all" } = {},
|
|
116
|
+
): Promise<SessionSearchHit[]> {
|
|
117
|
+
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
118
|
+
if (terms.length === 0) return [];
|
|
119
|
+
const limit = Math.max(1, options.limit ?? 10);
|
|
120
|
+
const cap = options.full ? FULL_EXCERPT : SUMMARIZED_EXCERPT;
|
|
121
|
+
const hits: SessionSearchHit[] = [];
|
|
122
|
+
const sessions: SessionFileInfo[] = [];
|
|
123
|
+
for (const file of listSessionFiles(sessionsDir, options.maxAgeDays)) {
|
|
124
|
+
const info = await identifySession(file.path, file);
|
|
125
|
+
if (!info.astra) continue;
|
|
126
|
+
if ((options.recallScope ?? "current") !== "all" &&
|
|
127
|
+
(!info.cwd || options.projectId === undefined || resolveProjectIdentity(info.cwd) !== options.projectId)) continue;
|
|
128
|
+
sessions.push(info);
|
|
129
|
+
}
|
|
130
|
+
sessions.sort((a, b) => {
|
|
131
|
+
const projectRank = Number(Boolean(options.projectId && b.cwd) && resolveProjectIdentity(b.cwd) === options.projectId) -
|
|
132
|
+
Number(Boolean(options.projectId && a.cwd) && resolveProjectIdentity(a.cwd) === options.projectId);
|
|
133
|
+
return projectRank || b.mtimeMs - a.mtimeMs;
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
for (const info of sessions) {
|
|
137
|
+
if (hits.length >= limit) break;
|
|
138
|
+
const fileHits: SessionSearchHit[] = [];
|
|
139
|
+
for await (const line of iterLines(info.path)) {
|
|
140
|
+
if (fileHits.length >= HITS_PER_FILE_CAP) break;
|
|
141
|
+
if (!terms.every((term) => line.toLowerCase().includes(term))) continue;
|
|
142
|
+
let entry: Record<string, unknown>;
|
|
143
|
+
try { entry = JSON.parse(line) as Record<string, unknown>; } catch { continue; }
|
|
144
|
+
if (entry.type !== "message") continue;
|
|
145
|
+
const msg = entry.message as Record<string, unknown>;
|
|
146
|
+
const role = msg.role as string;
|
|
147
|
+
const timestamp = String(entry.timestamp ?? "");
|
|
148
|
+
const base = { sessionId: info.id, project: info.cwd, timestamp };
|
|
149
|
+
if (role === "user" || role === "assistant") {
|
|
150
|
+
if (Array.isArray(msg.content)) {
|
|
151
|
+
for (const block of msg.content as Record<string, unknown>[]) {
|
|
152
|
+
if (block.type === "toolCall") {
|
|
153
|
+
const args = truncate(JSON.stringify(block.arguments ?? {}), cap);
|
|
154
|
+
if (terms.every((term) => `${String(block.name)} ${args}`.toLowerCase().includes(term))) {
|
|
155
|
+
fileHits.push({ ...base, kind: "toolCall", excerpt: `${String(block.name)}(${args})` });
|
|
156
|
+
}
|
|
157
|
+
} else if (block.type === "text" && typeof block.text === "string") {
|
|
158
|
+
const text = block.text;
|
|
159
|
+
if (terms.every((term) => text.toLowerCase().includes(term))) {
|
|
160
|
+
fileHits.push({ ...base, kind: role as "user" | "assistant", excerpt: truncate(text, cap) });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} else {
|
|
165
|
+
const text = textOf(msg.content);
|
|
166
|
+
if (terms.every((term) => text.toLowerCase().includes(term))) {
|
|
167
|
+
fileHits.push({ ...base, kind: role as "user" | "assistant", excerpt: truncate(text, cap) });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
} else if (role === "toolResult") {
|
|
171
|
+
const text = textOf(msg.content);
|
|
172
|
+
if (terms.every((term) => `${String(msg.toolName ?? "")} ${text}`.toLowerCase().includes(term))) {
|
|
173
|
+
fileHits.push({ ...base, kind: "toolResult", excerpt: `${String(msg.toolName ?? "tool")} → ${truncate(text, cap)}` });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
hits.push(...fileHits);
|
|
178
|
+
}
|
|
179
|
+
return hits.slice(0, limit);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function compactEntry(entry: Record<string, unknown>): string {
|
|
183
|
+
if (entry.type !== "message") return "";
|
|
184
|
+
const msg = entry.message as Record<string, unknown>;
|
|
185
|
+
if (msg.role === "user") return `USER: ${truncate(textOf(msg.content), 1_500)}`;
|
|
186
|
+
if (msg.role === "assistant") {
|
|
187
|
+
const blocks = Array.isArray(msg.content) ? msg.content as Record<string, unknown>[] : [];
|
|
188
|
+
const content = blocks.map((block) => {
|
|
189
|
+
if (block.type === "text") return truncate(String(block.text ?? ""), 1_500);
|
|
190
|
+
if (block.type === "toolCall") return `[tool: ${String(block.name)} ${truncate(JSON.stringify(block.arguments ?? {}), 200)}]`;
|
|
191
|
+
return "";
|
|
192
|
+
}).filter(Boolean).join(" ");
|
|
193
|
+
return content ? `ASSISTANT: ${content}` : "";
|
|
194
|
+
}
|
|
195
|
+
if (msg.role === "toolResult") {
|
|
196
|
+
return `[result: ${String(msg.toolName ?? "tool")}${msg.isError ? " (error)" : ""}] ${truncate(textOf(msg.content), 200)}`;
|
|
197
|
+
}
|
|
198
|
+
return "";
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Keep a small opening for problem context and devote the rest of the budget
|
|
203
|
+
* to the latest conversation, where final decisions and corrections live.
|
|
204
|
+
*/
|
|
205
|
+
export async function readSessionDigest(path: string, capBytes = 60_000): Promise<string> {
|
|
206
|
+
const openingCap = Math.max(1_000, Math.floor(capBytes * 0.2));
|
|
207
|
+
const tailCap = Math.max(1_000, capBytes - openingCap - 80);
|
|
208
|
+
const opening: string[] = [];
|
|
209
|
+
const tail: string[] = [];
|
|
210
|
+
let openingSize = 0;
|
|
211
|
+
let tailSize = 0;
|
|
212
|
+
let openingClosed = false;
|
|
213
|
+
let omitted = false;
|
|
214
|
+
|
|
215
|
+
for await (const line of iterLines(path)) {
|
|
216
|
+
let entry: Record<string, unknown>;
|
|
217
|
+
try { entry = JSON.parse(line) as Record<string, unknown>; } catch { continue; }
|
|
218
|
+
const chunk = compactEntry(entry);
|
|
219
|
+
if (!chunk) continue;
|
|
220
|
+
if (!openingClosed && openingSize + chunk.length + 1 <= openingCap) {
|
|
221
|
+
opening.push(chunk);
|
|
222
|
+
openingSize += chunk.length + 1;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
// Once a chunk overflows the opening budget, all later entries belong to
|
|
226
|
+
// the tail so their chronology cannot jump ahead of an older tail entry.
|
|
227
|
+
openingClosed = true;
|
|
228
|
+
tail.push(chunk);
|
|
229
|
+
tailSize += chunk.length + 1;
|
|
230
|
+
while (tailSize > tailCap && tail.length > 1) {
|
|
231
|
+
tailSize -= (tail.shift()?.length ?? 0) + 1;
|
|
232
|
+
omitted = true;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (tail.length === 0) return opening.join("\n");
|
|
236
|
+
return [...opening, ...(omitted ? ["… [earlier transcript omitted; latest decisions retained] …"] : []), ...tail].join("\n");
|
|
237
|
+
}
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
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
|
+
|
|
43
|
+
export type NoteScope = "project" | "global";
|
|
44
|
+
|
|
45
|
+
export interface NoteInput {
|
|
46
|
+
title: string;
|
|
47
|
+
body: string;
|
|
48
|
+
keywords?: string[];
|
|
49
|
+
project?: string;
|
|
50
|
+
projectId?: string;
|
|
51
|
+
scope?: NoteScope;
|
|
52
|
+
source?: "agent" | "distilled";
|
|
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
|
+
|
|
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";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function stamp(date: Date): string {
|
|
82
|
+
return date.toISOString().replace(/[:.]/g, "-");
|
|
83
|
+
}
|
|
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();
|
|
90
|
+
}
|
|
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() ?? "";
|
|
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 [
|
|
148
|
+
"---",
|
|
149
|
+
`created: ${created.toISOString()}`,
|
|
150
|
+
`updated: ${updated.toISOString()}`,
|
|
151
|
+
`project: ${input.project ?? "unknown"}`,
|
|
152
|
+
`project_id: ${input.projectId ?? ""}`,
|
|
153
|
+
`scope: ${input.scope ?? "project"}`,
|
|
154
|
+
`keywords: [${(input.keywords ?? []).join(", ")}]`,
|
|
155
|
+
`source: ${input.source ?? "agent"}`,
|
|
156
|
+
...metadata,
|
|
157
|
+
"---",
|
|
158
|
+
"",
|
|
159
|
+
`# ${input.title}`,
|
|
160
|
+
"",
|
|
161
|
+
input.body.trim(),
|
|
162
|
+
"",
|
|
163
|
+
].join("\n");
|
|
164
|
+
}
|
|
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));
|
|
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;
|
|
238
|
+
}
|
|
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);
|
|
262
|
+
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._"]), "",
|
|
266
|
+
].join("\n");
|
|
267
|
+
}
|
|
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)_`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export interface PocketSearchHit {
|
|
288
|
+
noteFile: string;
|
|
289
|
+
title: string;
|
|
290
|
+
excerpt: string;
|
|
291
|
+
project: string;
|
|
292
|
+
projectId: string;
|
|
293
|
+
source: string;
|
|
294
|
+
scope: NoteScope | "unknown";
|
|
295
|
+
date: string;
|
|
296
|
+
}
|
|
297
|
+
|
|
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[] {
|
|
307
|
+
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
|
+
};
|
|
427
|
+
}
|