@nklisch/pi-enhanced 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +1 -0
  3. package/node_modules/@nklisch/pi-astral-pocket/LICENSE +3 -0
  4. package/node_modules/@nklisch/pi-astral-pocket/README.md +82 -0
  5. package/node_modules/@nklisch/pi-astral-pocket/package.json +52 -0
  6. package/node_modules/@nklisch/pi-astral-pocket/src/activation.ts +44 -0
  7. package/node_modules/@nklisch/pi-astral-pocket/src/config.ts +78 -0
  8. package/node_modules/@nklisch/pi-astral-pocket/src/distiller.ts +162 -0
  9. package/node_modules/@nklisch/pi-astral-pocket/src/guidance.ts +59 -0
  10. package/node_modules/@nklisch/pi-astral-pocket/src/index.ts +127 -0
  11. package/node_modules/@nklisch/pi-astral-pocket/src/sessions.ts +225 -0
  12. package/node_modules/@nklisch/pi-astral-pocket/src/store.ts +227 -0
  13. package/node_modules/@nklisch/pi-astral-pocket/src/tools.ts +112 -0
  14. package/node_modules/@nklisch/pi-clearance/native/clearance-core.darwin-arm64.node +0 -0
  15. package/node_modules/@nklisch/pi-clearance/native/clearance-core.darwin-x64.node +0 -0
  16. package/node_modules/@nklisch/pi-clearance/native/clearance-core.linux-arm64-gnu.node +0 -0
  17. package/node_modules/@nklisch/pi-clearance/native/clearance-core.win32-x64-msvc.node +0 -0
  18. package/node_modules/@nklisch/pi-conveniences/extensions/agents-context.ts +4 -6
  19. package/node_modules/@nklisch/pi-conveniences/extensions/context-window-footer.ts +38 -22
  20. package/node_modules/@nklisch/pi-conveniences/package.json +1 -1
  21. package/node_modules/@nklisch/pi-plugins/README.md +12 -6
  22. package/node_modules/@nklisch/pi-plugins/dist/catalog.js +11 -0
  23. package/node_modules/@nklisch/pi-plugins/dist/catalog.js.map +1 -1
  24. package/node_modules/@nklisch/pi-plugins/dist/host.js +85 -29
  25. package/node_modules/@nklisch/pi-plugins/dist/host.js.map +1 -1
  26. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager-layout.d.ts +4 -0
  27. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager-layout.js +24 -0
  28. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager-layout.js.map +1 -0
  29. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.d.ts +6 -0
  30. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.js +130 -47
  31. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.js.map +1 -1
  32. package/node_modules/@nklisch/pi-plugins/dist/plugin-metadata.d.ts +9 -0
  33. package/node_modules/@nklisch/pi-plugins/dist/plugin-metadata.js +37 -0
  34. package/node_modules/@nklisch/pi-plugins/dist/plugin-metadata.js.map +1 -0
  35. package/node_modules/@nklisch/pi-plugins/dist/runtime-discovery.js +2 -0
  36. package/node_modules/@nklisch/pi-plugins/dist/runtime-discovery.js.map +1 -1
  37. package/node_modules/@nklisch/pi-plugins/dist/types.d.ts +4 -1
  38. package/node_modules/@nklisch/pi-plugins/dist/types.js.map +1 -1
  39. package/node_modules/@nklisch/pi-plugins/package.json +1 -1
  40. package/package.json +4 -1
@@ -0,0 +1,225 @@
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
+
7
+ /** Past-session access. Pi stores sessions as JSONL trees at
8
+ * `<agentDir>/sessions/<cwd-slug>/<timestamp>_<uuid>.jsonl`, with sub-agent
9
+ * task sessions nested under a `tasks/` subdir (excluded here — they are
10
+ * worker noise, and their parent session carries the durable signal). */
11
+
12
+ export interface SessionFileInfo {
13
+ path: string;
14
+ id: string;
15
+ cwd: string;
16
+ mtimeMs: number;
17
+ /** True when any assistant message in the file was produced by astra, or a
18
+ * model_change switched to it. Assistant messages carry provider/model
19
+ * directly, so sessions that were astra from the start are still caught. */
20
+ astra: boolean;
21
+ }
22
+
23
+ export interface SessionSearchHit {
24
+ sessionId: string;
25
+ project: string;
26
+ timestamp: string;
27
+ kind: "user" | "assistant" | "toolCall" | "toolResult";
28
+ excerpt: string;
29
+ }
30
+
31
+ const HITS_PER_FILE_CAP = 50;
32
+ const SUMMARIZED_EXCERPT = 200;
33
+ const FULL_EXCERPT = 2000;
34
+
35
+ async function* iterLines(path: string): AsyncGenerator<string> {
36
+ const rl = createInterface({ input: createReadStream(path, "utf8"), crlfDelay: Infinity });
37
+ for await (const line of rl) yield line;
38
+ }
39
+
40
+ /** Enumerate top-level session files across all project dirs, newest first. */
41
+ export function listSessionFiles(sessionsDir: string, maxAgeDays?: number): { path: string; mtimeMs: number }[] {
42
+ if (!existsSync(sessionsDir)) return [];
43
+ const cutoff = maxAgeDays === undefined ? null : Date.now() - maxAgeDays * 86_400_000;
44
+ const files: { path: string; mtimeMs: number }[] = [];
45
+ for (const dir of readdirSync(sessionsDir)) {
46
+ const dirPath = join(sessionsDir, dir);
47
+ try {
48
+ if (!statSync(dirPath).isDirectory()) continue;
49
+ for (const file of readdirSync(dirPath)) {
50
+ if (!file.endsWith(".jsonl")) continue;
51
+ const path = join(dirPath, file);
52
+ const mtimeMs = statSync(path).mtimeMs;
53
+ if (cutoff !== null && mtimeMs < cutoff) continue;
54
+ files.push({ path, mtimeMs });
55
+ }
56
+ } catch {
57
+ continue; // unreadable dir: skip, never fail recall over it
58
+ }
59
+ }
60
+ return files.sort((a, b) => b.mtimeMs - a.mtimeMs);
61
+ }
62
+
63
+ function isAstraMarker(entry: Record<string, unknown>): boolean {
64
+ if (entry.type === "model_change" && entry.provider === ASTRA_PROVIDER && entry.modelId === ASTRA_MODEL_ID) return true;
65
+ if (entry.type === "message") {
66
+ const msg = entry.message as Record<string, unknown> | undefined;
67
+ if (msg?.role === "assistant" && msg.provider === ASTRA_PROVIDER && msg.model === ASTRA_MODEL_ID) return true;
68
+ }
69
+ return false;
70
+ }
71
+
72
+ /** Identify a session file: header id/cwd plus astra usage, streaming so large
73
+ * files stop early once both identity and astra-ness are known. */
74
+ export async function identifySession(path: string, mtimeMs: number): Promise<SessionFileInfo> {
75
+ let id = "";
76
+ let cwd = "";
77
+ let astra = false;
78
+ for await (const line of iterLines(path)) {
79
+ if (id && cwd && astra) break;
80
+ let entry: Record<string, unknown>;
81
+ try {
82
+ entry = JSON.parse(line) as Record<string, unknown>;
83
+ } catch {
84
+ continue;
85
+ }
86
+ if (entry.type === "session") {
87
+ id = String(entry.id ?? "");
88
+ cwd = String(entry.cwd ?? "");
89
+ } else if (isAstraMarker(entry)) {
90
+ astra = true;
91
+ }
92
+ }
93
+ return { path, id, cwd, mtimeMs, astra };
94
+ }
95
+
96
+ function textOf(content: unknown): string {
97
+ if (typeof content === "string") return content;
98
+ if (!Array.isArray(content)) return "";
99
+ return content
100
+ .filter((b): b is Record<string, unknown> => typeof b === "object" && b !== null)
101
+ .map((b) => (typeof b.text === "string" ? b.text : ""))
102
+ .filter(Boolean)
103
+ .join(" ");
104
+ }
105
+
106
+ function truncate(text: string, cap: number): string {
107
+ const clean = text.replace(/\s+/g, " ").trim();
108
+ return clean.length <= cap ? clean : `${clean.slice(0, cap)}…`;
109
+ }
110
+
111
+ /** Search astra sessions for a query. Summarized by default: tool hits return
112
+ * name + truncated args/results, message hits return short excerpts. `full`
113
+ * raises the excerpt cap. Only astra sessions are searched — other models'
114
+ * sessions are noise for this tool and may carry unrelated secrets. */
115
+ export async function searchAstraSessions(
116
+ sessionsDir: string,
117
+ query: string,
118
+ options: { full?: boolean; limit?: number; maxAgeDays?: number } = {},
119
+ ): Promise<SessionSearchHit[]> {
120
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
121
+ if (terms.length === 0) return [];
122
+ const limit = options.limit ?? 10;
123
+ const cap = options.full ? FULL_EXCERPT : SUMMARIZED_EXCERPT;
124
+ const hits: SessionSearchHit[] = [];
125
+
126
+ for (const { path, mtimeMs } of listSessionFiles(sessionsDir, options.maxAgeDays)) {
127
+ if (hits.length >= limit) break;
128
+ const info = await identifySession(path, mtimeMs);
129
+ if (!info.astra) continue;
130
+
131
+ const fileHits: SessionSearchHit[] = [];
132
+ for await (const line of iterLines(path)) {
133
+ if (fileHits.length >= HITS_PER_FILE_CAP) break;
134
+ if (!terms.every((t) => line.toLowerCase().includes(t))) continue;
135
+ let entry: Record<string, unknown>;
136
+ try {
137
+ entry = JSON.parse(line) as Record<string, unknown>;
138
+ } catch {
139
+ continue;
140
+ }
141
+ if (entry.type !== "message") continue;
142
+ const msg = entry.message as Record<string, unknown>;
143
+ const role = msg.role as string;
144
+ const timestamp = String(entry.timestamp ?? "");
145
+ const base = { sessionId: info.id, project: info.cwd, timestamp };
146
+ if (role === "user" || role === "assistant") {
147
+ const content = msg.content;
148
+ if (Array.isArray(content)) {
149
+ for (const block of content as Record<string, unknown>[]) {
150
+ if (block.type === "toolCall") {
151
+ const args = truncate(JSON.stringify(block.arguments ?? {}), cap);
152
+ if (terms.every((t) => `${String(block.name)} ${args}`.toLowerCase().includes(t))) {
153
+ fileHits.push({ ...base, kind: "toolCall", excerpt: `${String(block.name)}(${args})` });
154
+ }
155
+ } else if (block.type === "text" && typeof block.text === "string") {
156
+ const text = block.text as string;
157
+ if (terms.every((t) => text.toLowerCase().includes(t))) {
158
+ fileHits.push({ ...base, kind: role as "user" | "assistant", excerpt: truncate(text, cap) });
159
+ }
160
+ }
161
+ }
162
+ } else {
163
+ const text = textOf(content);
164
+ if (terms.every((t) => text.toLowerCase().includes(t))) {
165
+ fileHits.push({ ...base, kind: role as "user" | "assistant", excerpt: truncate(text, cap) });
166
+ }
167
+ }
168
+ } else if (role === "toolResult") {
169
+ const text = textOf(msg.content);
170
+ if (terms.every((t) => `${String(msg.toolName ?? "")} ${text}`.toLowerCase().includes(t))) {
171
+ fileHits.push({
172
+ ...base,
173
+ kind: "toolResult",
174
+ excerpt: `${String(msg.toolName ?? "tool")} → ${truncate(text, cap)}`,
175
+ });
176
+ }
177
+ }
178
+ }
179
+ hits.push(...fileHits);
180
+ }
181
+ return hits.slice(0, limit);
182
+ }
183
+
184
+ /** Compact transcript of one session for the distiller: user/assistant text
185
+ * plus tool-call names with truncated args, tool results reduced to a short
186
+ * marker. Full results are deliberately excluded — they are the largest and
187
+ * least durable content, and the biggest secret-resurfacing vector. */
188
+ export async function readSessionDigest(path: string, capBytes = 60_000): Promise<string> {
189
+ const parts: string[] = [];
190
+ let size = 0;
191
+ for await (const line of iterLines(path)) {
192
+ if (size >= capBytes) {
193
+ parts.push("… [transcript truncated]");
194
+ break;
195
+ }
196
+ let entry: Record<string, unknown>;
197
+ try {
198
+ entry = JSON.parse(line) as Record<string, unknown>;
199
+ } catch {
200
+ continue;
201
+ }
202
+ if (entry.type !== "message") continue;
203
+ const msg = entry.message as Record<string, unknown>;
204
+ let chunk = "";
205
+ if (msg.role === "user") chunk = `USER: ${truncate(textOf(msg.content), 1500)}`;
206
+ else if (msg.role === "assistant") {
207
+ const content = Array.isArray(msg.content) ? (msg.content as Record<string, unknown>[]) : [];
208
+ const texts = content
209
+ .map((b) => {
210
+ if (b.type === "text") return truncate(String(b.text ?? ""), 1500);
211
+ if (b.type === "toolCall") return `[tool: ${String(b.name)} ${truncate(JSON.stringify(b.arguments ?? {}), 200)}]`;
212
+ return "";
213
+ })
214
+ .filter(Boolean)
215
+ .join(" ");
216
+ chunk = texts ? `ASSISTANT: ${texts}` : "";
217
+ } else if (msg.role === "toolResult") {
218
+ chunk = `[result: ${String(msg.toolName ?? "tool")}${msg.isError ? " (error)" : ""}] ${truncate(textOf(msg.content), 200)}`;
219
+ }
220
+ if (!chunk) continue;
221
+ size += chunk.length;
222
+ parts.push(chunk);
223
+ }
224
+ return parts.join("\n");
225
+ }
@@ -0,0 +1,227 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ /** Store layout under the pocket root:
6
+ * config.json — toggles + distiller settings (config.ts)
7
+ * SUMMARY.md — injected into astra's system prompt; mechanical render
8
+ * POCKET.md — searchable registry, one line per note
9
+ * notes/<ts>-<slug>.md — append-only note files
10
+ * distilled.json — distiller bookkeeping (which sessions are processed)
11
+ */
12
+
13
+ const PINNED_START = "<!-- pocket:pinned:start -->";
14
+ const PINNED_END = "<!-- pocket:pinned:end -->";
15
+ const DIGEST_START = "<!-- pocket:digest:start -->";
16
+ const DIGEST_END = "<!-- pocket:digest:end -->";
17
+
18
+ const RECENT_NOTES_CAP = 20;
19
+ const REGISTRY_LINE_CAP = 500;
20
+ const BODY_SCAN_CAP = 200;
21
+
22
+ export function defaultAgentDir(): string {
23
+ return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
24
+ }
25
+
26
+ export function pocketRoot(agentDir: string = defaultAgentDir()): string {
27
+ return join(agentDir, "astral-pocket");
28
+ }
29
+
30
+ export function notesDir(root: string): string {
31
+ return join(root, "notes");
32
+ }
33
+
34
+ export function ensureLayout(root: string): void {
35
+ mkdirSync(notesDir(root), { recursive: true });
36
+ const registry = join(root, "POCKET.md");
37
+ if (!existsSync(registry)) {
38
+ writeFileSync(registry, "# Astral Pocket Registry\n\nOne line per note. Search this first.\n\n", "utf8");
39
+ }
40
+ if (!existsSync(join(root, "SUMMARY.md"))) {
41
+ writeFileSync(join(root, "SUMMARY.md"), renderSummary(root, []), "utf8");
42
+ }
43
+ }
44
+
45
+ export interface NoteInput {
46
+ title: string;
47
+ body: string;
48
+ keywords?: string[];
49
+ /** cwd of the session taking the note; recorded for project-aware ranking. */
50
+ project?: string;
51
+ source?: "agent" | "distilled";
52
+ }
53
+
54
+ function slugify(text: string): string {
55
+ const slug = text
56
+ .toLowerCase()
57
+ .replace(/[^a-z0-9]+/g, "-")
58
+ .replace(/^-+|-+$/g, "")
59
+ .slice(0, 48);
60
+ return slug || "note";
61
+ }
62
+
63
+ function stamp(date: Date): string {
64
+ return date.toISOString().replace(/[:.]/g, "-").replace("Z", "Z");
65
+ }
66
+
67
+ function extractSection(markdown: string, start: string, end: string): string | null {
68
+ const i = markdown.indexOf(start);
69
+ const j = markdown.indexOf(end);
70
+ if (i === -1 || j === -1 || j < i) return null;
71
+ return markdown.slice(i + start.length, j).trim();
72
+ }
73
+
74
+ /** Write one note file and update the registry + summary. Returns the note's
75
+ * file name. Callers that need cross-file mutation safety should wrap this in
76
+ * `withFileMutationQueue(join(root, "POCKET.md"), ...)`. */
77
+ export function writeNote(root: string, input: NoteInput, now: Date = new Date()): string {
78
+ ensureLayout(root);
79
+ const fileName = `${stamp(now)}-${slugify(input.title)}.md`;
80
+ const frontmatter = [
81
+ "---",
82
+ `created: ${now.toISOString()}`,
83
+ `project: ${input.project ?? "unknown"}`,
84
+ `keywords: [${(input.keywords ?? []).join(", ")}]`,
85
+ `source: ${input.source ?? "agent"}`,
86
+ "---",
87
+ ].join("\n");
88
+ writeFileSync(join(notesDir(root), fileName), `${frontmatter}\n\n# ${input.title}\n\n${input.body.trim()}\n`, "utf8");
89
+
90
+ const projectTag = input.project ? (input.project.split("/").filter(Boolean).pop() ?? input.project) : "unknown";
91
+ const keywordTag = (input.keywords ?? []).join(", ");
92
+ const line = `- [${input.title}](notes/${fileName})${keywordTag ? ` — ${keywordTag}` : ""} — ${projectTag} — ${now.toISOString().slice(0, 10)}`;
93
+ appendRegistryLine(root, line);
94
+ rerenderSummary(root);
95
+ return fileName;
96
+ }
97
+
98
+ function appendRegistryLine(root: string, line: string): void {
99
+ const registry = join(root, "POCKET.md");
100
+ const existing = existsSync(registry) ? readFileSync(registry, "utf8") : "";
101
+ writeFileSync(registry, `${existing.trimEnd()}\n${line}\n`, "utf8");
102
+ }
103
+
104
+ export function readRegistryLines(root: string): string[] {
105
+ const registry = join(root, "POCKET.md");
106
+ if (!existsSync(registry)) return [];
107
+ return readFileSync(registry, "utf8")
108
+ .split("\n")
109
+ .filter((l) => l.startsWith("- ["));
110
+ }
111
+
112
+ /** Re-render SUMMARY.md mechanically: the pinned block and the
113
+ * distiller-maintained digest block carry over verbatim from the existing
114
+ * file; only the recent-notes index is regenerated. This is the mechanical
115
+ * floor — it runs on every note write with zero LLM involvement. */
116
+ export function rerenderSummary(root: string): void {
117
+ writeFileSync(join(root, "SUMMARY.md"), renderSummary(root, readRegistryLines(root)), "utf8");
118
+ }
119
+
120
+ function renderSummary(root: string, registryLines: string[]): string {
121
+ const summaryPath = join(root, "SUMMARY.md");
122
+ const existing = existsSync(summaryPath) ? readFileSync(summaryPath, "utf8") : "";
123
+ const pinned = extractSection(existing, PINNED_START, PINNED_END) ?? "";
124
+ const digest =
125
+ extractSection(existing, DIGEST_START, DIGEST_END) ??
126
+ "_No digest yet. It is filled in by the distiller pass; until then, rely on Recent notes and search POCKET.md._";
127
+ const recent = registryLines.slice(-RECENT_NOTES_CAP);
128
+ return [
129
+ "# Astral Pocket Summary",
130
+ "",
131
+ PINNED_START,
132
+ pinned,
133
+ PINNED_END,
134
+ "",
135
+ "## Durable digest",
136
+ "",
137
+ DIGEST_START,
138
+ digest,
139
+ DIGEST_END,
140
+ "",
141
+ "## Recent notes",
142
+ "",
143
+ ...(recent.length > 0 ? recent : ["_No notes yet._"]),
144
+ "",
145
+ ].join("\n");
146
+ }
147
+
148
+ /** Replace the distiller-maintained digest block, preserving everything else.
149
+ * Returns false when SUMMARY.md is missing the markers (never rendered). */
150
+ export function updateDigest(root: string, digest: string): boolean {
151
+ const summaryPath = join(root, "SUMMARY.md");
152
+ if (!existsSync(summaryPath)) return false;
153
+ const existing = readFileSync(summaryPath, "utf8");
154
+ const i = existing.indexOf(DIGEST_START);
155
+ const j = existing.indexOf(DIGEST_END);
156
+ if (i === -1 || j === -1 || j < i) return false;
157
+ writeFileSync(summaryPath, `${existing.slice(0, i + DIGEST_START.length)}\n${digest.trim()}\n${existing.slice(j)}`, "utf8");
158
+ return true;
159
+ }
160
+
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
+ export function readSummaryCapped(root: string, capBytes = 12_000): string {
164
+ const summaryPath = join(root, "SUMMARY.md");
165
+ if (!existsSync(summaryPath)) return "";
166
+ const text = readFileSync(summaryPath, "utf8");
167
+ return text.length <= capBytes ? text : `${text.slice(0, capBytes)}\n\n_(summary truncated; search POCKET.md for older material)_`;
168
+ }
169
+
170
+ export interface PocketSearchHit {
171
+ noteFile: string;
172
+ title: string;
173
+ excerpt: string;
174
+ project: string;
175
+ }
176
+
177
+ /** Case-insensitive keyword search over the registry, then the note bodies.
178
+ * Registry hits rank first; current-project notes rank above others. */
179
+ export function searchPocket(root: string, query: string, currentProject: string | undefined, limit: number): PocketSearchHit[] {
180
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
181
+ if (terms.length === 0) return [];
182
+ const matches = (haystack: string) => terms.every((t) => haystack.toLowerCase().includes(t));
183
+
184
+ const hits: PocketSearchHit[] = [];
185
+ for (const line of readRegistryLines(root).slice(-REGISTRY_LINE_CAP)) {
186
+ if (!matches(line)) continue;
187
+ const fileMatch = line.match(/\]\((notes\/[^)]+)\)/);
188
+ const titleMatch = line.match(/- \[([^\]]+)\]/);
189
+ if (!fileMatch) continue;
190
+ hits.push({
191
+ noteFile: fileMatch[1].replace(/^notes\//, ""),
192
+ title: titleMatch?.[1] ?? fileMatch[1],
193
+ excerpt: line,
194
+ project: "",
195
+ });
196
+ }
197
+
198
+ const bodyHits: PocketSearchHit[] = [];
199
+ // Timestamp-prefixed names sort chronologically; cap the body scan at the
200
+ // newest files so recall stays fast as the store grows.
201
+ const files = readdirSync(notesDir(root))
202
+ .filter((f) => f.endsWith(".md"))
203
+ .sort()
204
+ .slice(-BODY_SCAN_CAP);
205
+ for (const file of files) {
206
+ if (hits.some((h) => h.noteFile === file)) continue;
207
+ const text = readFileSync(join(notesDir(root), file), "utf8");
208
+ if (!matches(text)) continue;
209
+ const project = text.match(/^project: (.+)$/m)?.[1] ?? "";
210
+ const title = text.match(/^# (.+)$/m)?.[1] ?? file;
211
+ const idx = text.toLowerCase().indexOf(terms[0]);
212
+ bodyHits.push({
213
+ noteFile: file,
214
+ title,
215
+ excerpt: text.slice(Math.max(0, idx - 120), idx + 280).trim(),
216
+ project,
217
+ });
218
+ }
219
+
220
+ const currentTag = currentProject?.split("/").filter(Boolean).pop();
221
+ const ranked = [...hits, ...bodyHits].sort((a, b) => {
222
+ const aCur = currentTag && a.project.endsWith(currentTag) ? 1 : 0;
223
+ const bCur = currentTag && b.project.endsWith(currentTag) ? 1 : 0;
224
+ return bCur - aCur;
225
+ });
226
+ return ranked.slice(0, limit);
227
+ }
@@ -0,0 +1,112 @@
1
+ import { Type } from "typebox";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
4
+ import { join } from "node:path";
5
+
6
+ import type { ActivationState } from "./activation.js";
7
+ import { searchAstraSessions } from "./sessions.js";
8
+ import { readSummaryCapped, searchPocket, writeNote } from "./store.js";
9
+
10
+ const INACTIVE_MESSAGE =
11
+ "Pocket tools are only active in gpt-6-astra sessions with the pocket enabled (/pocket on).";
12
+
13
+ export interface ToolDeps {
14
+ state: ActivationState;
15
+ /** Pocket root directory (~/.pi/agent/astral-pocket). */
16
+ root: string;
17
+ /** Sessions directory (~/.pi/agent/sessions). */
18
+ sessionsDir: string;
19
+ maxSessionAgeDays: () => number;
20
+ }
21
+
22
+ function textResult(text: string) {
23
+ return { content: [{ type: "text" as const, text }], details: {} };
24
+ }
25
+
26
+ export function registerPocketTools(pi: ExtensionAPI, deps: ToolDeps): void {
27
+ pi.registerTool({
28
+ name: "pocket_note",
29
+ label: "Pocket Note",
30
+ description:
31
+ "Write a durable note to your persistent pocket. Notes survive across sessions. Use for decisions, conventions, pitfalls, and preferences worth remembering — never for secrets or ephemeral task state.",
32
+ promptSnippet: "Save a durable cross-session note to the astral pocket",
33
+ promptGuidelines: [
34
+ "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
+ "Never put secrets, credentials, tokens, or personal data in pocket notes.",
36
+ ],
37
+ parameters: Type.Object({
38
+ title: Type.String({ description: "Short note title" }),
39
+ body: Type.String({ description: "Note content — a few sentences is enough" }),
40
+ keywords: Type.Optional(Type.Array(Type.String(), { description: "2-5 recall keywords" })),
41
+ }),
42
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
43
+ if (!deps.state.active) throw new Error(INACTIVE_MESSAGE);
44
+ const fileName = await withFileMutationQueue(join(deps.root, "POCKET.md"), async () =>
45
+ writeNote(deps.root, {
46
+ title: params.title,
47
+ body: params.body,
48
+ keywords: params.keywords,
49
+ project: ctx.cwd,
50
+ source: "agent",
51
+ }),
52
+ );
53
+ return textResult(`Note saved to the pocket: notes/${fileName}`);
54
+ },
55
+ });
56
+
57
+ pi.registerTool({
58
+ name: "pocket_recall",
59
+ label: "Pocket Recall",
60
+ description:
61
+ "Search your persistent pocket notes and your past gpt-6-astra sessions. Summarized by default (tool names + truncated args/results); pass full: true for larger excerpts. Past-session output can contain sensitive data from earlier work — prefer summarized results.",
62
+ promptSnippet: "Search pocket notes and past astra sessions",
63
+ promptGuidelines: [
64
+ "Use pocket_recall for the quick pocket pass: search with keywords from the pocket summary before deep repo exploration.",
65
+ "Keep recall cheap: at most 4-6 lookup steps, summarized results first, full: true only when you need exact commands or error text.",
66
+ ],
67
+ parameters: Type.Object({
68
+ query: Type.String({ description: "Keywords to search for (all must match)" }),
69
+ source: Type.Optional(
70
+ Type.Union([Type.Literal("pocket"), Type.Literal("sessions"), Type.Literal("both")], {
71
+ description: "Where to search (default: both)",
72
+ }),
73
+ ),
74
+ full: Type.Optional(Type.Boolean({ description: "Return larger excerpts (default: false)" })),
75
+ limit: Type.Optional(Type.Number({ description: "Max hits (default: 10)" })),
76
+ }),
77
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
78
+ if (!deps.state.active) throw new Error(INACTIVE_MESSAGE);
79
+ const source = params.source ?? "both";
80
+ const limit = params.limit ?? 10;
81
+ const sections: string[] = [];
82
+
83
+ if (source === "pocket" || source === "both") {
84
+ const hits = searchPocket(deps.root, params.query, ctx.cwd, limit);
85
+ sections.push(
86
+ hits.length === 0
87
+ ? "Pocket notes: no matches."
88
+ : `Pocket notes (${hits.length}):\n${hits
89
+ .map((h) => `- ${h.title} [notes/${h.noteFile}]${h.project ? ` (${h.project})` : ""}\n ${h.excerpt}`)
90
+ .join("\n")}`,
91
+ );
92
+ }
93
+
94
+ if (source === "sessions" || source === "both") {
95
+ const hits = await searchAstraSessions(deps.sessionsDir, params.query, {
96
+ full: params.full,
97
+ limit,
98
+ maxAgeDays: deps.maxSessionAgeDays(),
99
+ });
100
+ sections.push(
101
+ hits.length === 0
102
+ ? "Past astra sessions: no matches."
103
+ : `Past astra sessions (${hits.length}):\n${hits
104
+ .map((h) => `- [${h.kind}] ${h.timestamp} (${h.project})\n ${h.excerpt}`)
105
+ .join("\n")}`,
106
+ );
107
+ }
108
+
109
+ return textResult(sections.join("\n\n"));
110
+ },
111
+ });
112
+ }
@@ -38,16 +38,15 @@ export const RELATIVE_PATH = ".agents/AGENTS.md";
38
38
  /** The exact opening tag emitted — also the idempotency sentinel. */
39
39
  export const OPEN_TAG = `<project_instructions path="${RELATIVE_PATH}">`;
40
40
 
41
- type SystemPromptOptions = { cwd?: string };
42
41
  type BeforeAgentStartEvent = {
43
42
  systemPrompt?: string;
44
- systemPromptOptions?: SystemPromptOptions;
45
43
  };
46
44
  type PiApi = {
47
45
  on?: (
48
46
  event: "before_agent_start",
49
47
  handler: (
50
48
  event: BeforeAgentStartEvent,
49
+ ctx?: { cwd?: string },
51
50
  ) => { systemPrompt: string } | undefined | void,
52
51
  ) => void;
53
52
  };
@@ -80,10 +79,9 @@ function readAgentsContext(cwd: string): string | null {
80
79
  }
81
80
 
82
81
  export default function agentsContextExtension(pi: PiApi): void {
83
- pi.on?.("before_agent_start", (event) => {
84
- const content = readAgentsContext(
85
- event?.systemPromptOptions?.cwd ?? process.cwd(),
86
- );
82
+ pi.on?.("before_agent_start", (event, ctx) => {
83
+ // Pi supplies the active workspace on the event context, not the event.
84
+ const content = readAgentsContext(ctx?.cwd ?? process.cwd());
87
85
  if (!content) return;
88
86
  const base = event?.systemPrompt ?? "";
89
87
  // Idempotency / double-registration guard. The prompt is rebuilt from base