@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.4.1 — 2026-09-04
4
+
5
+ ### Added
6
+
7
+ - Include `@nklisch/pi-astral-pocket` 0.1.0, the Astra-only persistent note and recall extension.
8
+
9
+ ### Fixed
10
+
11
+ - Rebundle `@nklisch/pi-plugins` 0.8.2 and `@nklisch/pi-conveniences` 0.1.3 with accurate plugin versions, a framed manager, safer updates, and workspace-correct project instructions.
12
+
3
13
  ## v0.3.1
4
14
 
5
15
  ### Fixed
package/README.md CHANGED
@@ -25,6 +25,7 @@ install commands, or versions to coordinate.
25
25
  | pi-background-tasks | Background jobs with polling, status, cancellation, and wakeups. |
26
26
  | pi-model-modes | System-prompt adaptation per model and mode. |
27
27
  | pi-conveniences | `/exit`, `.agents/AGENTS.md` context loading, context-window footer, subagent model listing. |
28
+ | pi-astral-pocket | Persistent note pocket and past-session recall, active only for gpt-6-astra. |
28
29
  | pi-fff-compat | FFF-indexed file search through Pi-native find/grep semantics (no fuzzy fallback). |
29
30
 
30
31
  (`@nklisch/pi-zai-research` remains published separately if you prefer its
@@ -0,0 +1,3 @@
1
+ MIT License
2
+
3
+ Copyright (c) Nathan Klisch
@@ -0,0 +1,82 @@
1
+ # @nklisch/pi-astral-pocket
2
+
3
+ A persistent note pocket that activates only for `openai-codex/gpt-6-astra`
4
+ sessions. Astra gets a dedicated, cross-session note store with recall —
5
+ modeled on OpenAI Codex's memories system, minus the always-on cost.
6
+
7
+ ## What it does
8
+
9
+ When the active model is astra (including mid-session `/model` switches), the
10
+ extension:
11
+
12
+ - **Injects pocket guidance** into the system prompt: when to consult the
13
+ pocket, a budgeted "quick pass" lookup protocol, drift/verification policy,
14
+ and note-taking judgment (durable facts only, never secrets).
15
+ - **Activates two tools**:
16
+ - `pocket_note` — write a durable note (title, body, keywords). The store's
17
+ registry and summary are updated mechanically on every write.
18
+ - `pocket_recall` — keyword-search pocket notes and past **astra** sessions.
19
+ Summarized by default (tool names + truncated args/results); `full: true`
20
+ for larger excerpts.
21
+ - **Runs a bounded distiller pass** on activation: astra sessions idle past a
22
+ threshold (default 6h, max 16 per pass, 30-day window) are distilled into
23
+ pocket notes by a cheap configurable model, then consolidated into the
24
+ injected summary's durable digest. There is no daemon; the pass only runs
25
+ when astra activates. If the distiller is disabled or its model is
26
+ unavailable, the mechanical floor (registry + recent-notes index) keeps
27
+ working.
28
+
29
+ For any other model, the extension is inert: no tools, no injection, no
30
+ distiller.
31
+
32
+ ## Commands
33
+
34
+ - `/pocket on` / `/pocket off` — enable/disable the pocket (persisted).
35
+ - `/pocket status` — show enablement, activation, note count, distiller config.
36
+
37
+ ## Storage
38
+
39
+ Everything lives in `~/.pi/agent/astral-pocket/` (or
40
+ `$PI_CODING_AGENT_DIR/astral-pocket/`):
41
+
42
+ ```
43
+ config.json # enabled flag + distiller settings
44
+ SUMMARY.md # injected into astra's prompt; pinned block + durable digest + recent notes
45
+ POCKET.md # searchable registry, one line per note
46
+ notes/ # append-only note files (<timestamp>-<slug>.md)
47
+ distilled.json # distiller bookkeeping
48
+ ```
49
+
50
+ ### Configuration
51
+
52
+ `config.json` (all optional, defaults shown):
53
+
54
+ ```json
55
+ {
56
+ "enabled": true,
57
+ "distiller": {
58
+ "enabled": true,
59
+ "model": null,
60
+ "minIdleHours": 6,
61
+ "maxSessionsPerPass": 16,
62
+ "maxSessionAgeDays": 30
63
+ }
64
+ }
65
+ ```
66
+
67
+ `distiller.model` is a `"provider/modelId"` override; when null the first
68
+ resolvable entry of a cheap-model preference list is used. If no model
69
+ resolves, the distiller skips with a notice.
70
+
71
+ ## Privacy note
72
+
73
+ Pi session files record tool calls and results verbatim. `pocket_recall`
74
+ searches only astra sessions and summarizes by default, but full excerpts can
75
+ re-surface anything that appeared in past output. Keep the default summarized
76
+ mode unless you need exact commands or error text.
77
+
78
+ ## Install
79
+
80
+ ```sh
81
+ pi install npm:@nklisch/pi-astral-pocket
82
+ ```
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@nklisch/pi-astral-pocket",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "Model-gated note pocket and session recall for gpt-6-astra",
6
+ "type": "module",
7
+ "author": {
8
+ "name": "Nathan Klisch"
9
+ },
10
+ "license": "MIT",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/nklisch/pi-extensions.git",
14
+ "directory": "packages/pi-astral-pocket"
15
+ },
16
+ "bugs": {
17
+ "url": "https://github.com/nklisch/pi-extensions/issues"
18
+ },
19
+ "homepage": "https://github.com/nklisch/pi-extensions/tree/main/packages/pi-astral-pocket#readme",
20
+ "publishConfig": {
21
+ "access": "public",
22
+ "provenance": true
23
+ },
24
+ "keywords": [
25
+ "pi-package",
26
+ "pi-extension"
27
+ ],
28
+ "pi": {
29
+ "extensions": [
30
+ "./src/index.ts"
31
+ ]
32
+ },
33
+ "files": [
34
+ "src",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
38
+ "scripts": {
39
+ "test": "vitest run",
40
+ "typecheck": "tsc --noEmit"
41
+ },
42
+ "peerDependencies": {
43
+ "@earendil-works/pi-coding-agent": "*",
44
+ "typebox": "*"
45
+ },
46
+ "devDependencies": {
47
+ "@earendil-works/pi-coding-agent": "*",
48
+ "@types/node": "^24.0.0",
49
+ "typescript": ">=7.0.0 <8",
50
+ "vitest": "^4.0.0"
51
+ }
52
+ }
@@ -0,0 +1,44 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import type { PocketConfig } from "./config.js";
4
+
5
+ export const ASTRA_PROVIDER = "openai-codex";
6
+ export const ASTRA_MODEL_ID = "gpt-6-astra";
7
+ export const POCKET_TOOLS = ["pocket_note", "pocket_recall"] as const;
8
+
9
+ /** Mutable activation flag shared by tools, the /pocket command, and the
10
+ * before_agent_start injector. Module-level because the extension factory has
11
+ * no ctx — the first reliable model read is in session_start. */
12
+ export interface ActivationState {
13
+ active: boolean;
14
+ }
15
+
16
+ export function isAstraModel(model: { id?: string; provider?: string } | undefined | null): boolean {
17
+ return model?.provider === ASTRA_PROVIDER && model?.id === ASTRA_MODEL_ID;
18
+ }
19
+
20
+ export function isActive(state: ActivationState, ctx: ExtensionContext, config: PocketConfig): boolean {
21
+ return config.enabled && isAstraModel(ctx.model);
22
+ }
23
+
24
+ /** Recompute activation from the current model + config and sync pi's active
25
+ * tool set. Call from session_start and model_select handlers — never from
26
+ * the factory body (pi forbids active-set mutation during load). */
27
+ export function recomputeActivation(
28
+ pi: ExtensionAPI,
29
+ ctx: ExtensionContext,
30
+ state: ActivationState,
31
+ config: PocketConfig,
32
+ ): boolean {
33
+ const active = isActive(state, ctx, config);
34
+ const current = pi.getActiveTools();
35
+ const hasPocket = POCKET_TOOLS.every((t) => current.includes(t));
36
+ if (active && !hasPocket) {
37
+ pi.setActiveTools([...new Set([...current, ...POCKET_TOOLS])]);
38
+ } else if (!active && hasPocket) {
39
+ pi.setActiveTools(current.filter((t) => !(POCKET_TOOLS as readonly string[]).includes(t)));
40
+ }
41
+ const becameActive = active && !state.active;
42
+ state.active = active;
43
+ return becameActive;
44
+ }
@@ -0,0 +1,78 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ export interface DistillerConfig {
5
+ /** Master switch for the startup-pass distiller. */
6
+ enabled: boolean;
7
+ /** "provider/modelId" override; null walks the cheap-model preference list. */
8
+ model: string | null;
9
+ /** Sessions younger than this are still in progress; leave them alone. */
10
+ minIdleHours: number;
11
+ /** Bound on LLM extraction calls per activation pass (Codex parity: 16). */
12
+ maxSessionsPerPass: number;
13
+ /** Sessions older than this are not worth distilling (Codex parity: 30). */
14
+ maxSessionAgeDays: number;
15
+ }
16
+
17
+ export interface PocketConfig {
18
+ /** Master switch for the whole pocket, toggled by /pocket on|off. */
19
+ enabled: boolean;
20
+ distiller: DistillerConfig;
21
+ }
22
+
23
+ export const DEFAULT_CONFIG: PocketConfig = {
24
+ enabled: true,
25
+ distiller: {
26
+ enabled: true,
27
+ model: null,
28
+ minIdleHours: 6,
29
+ maxSessionsPerPass: 16,
30
+ maxSessionAgeDays: 30,
31
+ },
32
+ };
33
+
34
+ /** Cheap-model preference order when config.distiller.model is null. The first
35
+ * entry resolvable in the user's model registry wins; if none resolve, the
36
+ * distiller degrades to "mechanical floor only" rather than failing. */
37
+ export const DISTILLER_MODEL_PREFERENCE: readonly string[] = [
38
+ "zai/glm-5.3-flash",
39
+ "openrouter/deepseek-v4-flash-latest",
40
+ "ollama-cloud/glm-5.3-flash",
41
+ ];
42
+
43
+ export function configPath(root: string): string {
44
+ return join(root, "config.json");
45
+ }
46
+
47
+ function clampNumber(value: unknown, fallback: number, min: number, max: number): number {
48
+ const n = typeof value === "number" && Number.isFinite(value) ? value : fallback;
49
+ return Math.min(max, Math.max(min, n));
50
+ }
51
+
52
+ /** Load config, tolerating a missing or corrupt file by falling back to
53
+ * defaults. The pocket is a convenience feature: a broken config must never
54
+ * block extension load, so every read failure degrades to defaults. */
55
+ export function loadConfig(root: string): PocketConfig {
56
+ let raw: Record<string, unknown> = {};
57
+ try {
58
+ raw = JSON.parse(readFileSync(configPath(root), "utf8")) as Record<string, unknown>;
59
+ } catch {
60
+ return structuredClone(DEFAULT_CONFIG);
61
+ }
62
+ const d = (raw.distiller ?? {}) as Record<string, unknown>;
63
+ return {
64
+ enabled: typeof raw.enabled === "boolean" ? raw.enabled : DEFAULT_CONFIG.enabled,
65
+ distiller: {
66
+ enabled: typeof d.enabled === "boolean" ? d.enabled : DEFAULT_CONFIG.distiller.enabled,
67
+ model: typeof d.model === "string" && d.model.includes("/") ? d.model : null,
68
+ minIdleHours: clampNumber(d.minIdleHours, DEFAULT_CONFIG.distiller.minIdleHours, 1, 48),
69
+ maxSessionsPerPass: clampNumber(d.maxSessionsPerPass, DEFAULT_CONFIG.distiller.maxSessionsPerPass, 1, 128),
70
+ maxSessionAgeDays: clampNumber(d.maxSessionAgeDays, DEFAULT_CONFIG.distiller.maxSessionAgeDays, 0, 90),
71
+ },
72
+ };
73
+ }
74
+
75
+ export function saveConfig(root: string, config: PocketConfig): void {
76
+ mkdirSync(root, { recursive: true });
77
+ writeFileSync(configPath(root), `${JSON.stringify(config, null, 2)}\n`, "utf8");
78
+ }
@@ -0,0 +1,162 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
5
+
6
+ import type { DistillerConfig } from "./config.js";
7
+ import { identifySession, listSessionFiles, readSessionDigest, type SessionFileInfo } from "./sessions.js";
8
+ import { ensureLayout, updateDigest, writeNote } from "./store.js";
9
+
10
+ /** Bounded startup-pass distiller, modeled on Codex's memories pipeline: when
11
+ * astra activates, find astra sessions that have been idle long enough, run
12
+ * one cheap-model extraction per session into distilled pocket notes, then one
13
+ * consolidation call that refreshes the durable digest. There is no daemon —
14
+ * the pass runs at activation time only, and every failure degrades to the
15
+ * mechanical floor (registry + recent-notes index stay correct without it). */
16
+
17
+ export interface DistillerDeps {
18
+ /** One cheap-model completion. Injected so tests never touch the network;
19
+ * null when no distiller model resolves (distiller skips with a notice). */
20
+ callModel: ((prompt: string) => Promise<string>) | null;
21
+ log: (message: string) => void;
22
+ now?: () => number;
23
+ }
24
+
25
+ export interface DistillerResult {
26
+ processed: number;
27
+ skippedReason?: string;
28
+ errors: string[];
29
+ }
30
+
31
+ interface DistilledState {
32
+ sessions: Record<string, string>; // sessionId -> ISO date distilled
33
+ }
34
+
35
+ function statePath(root: string): string {
36
+ return join(root, "distilled.json");
37
+ }
38
+
39
+ function loadState(root: string): DistilledState {
40
+ try {
41
+ return JSON.parse(readFileSync(statePath(root), "utf8")) as DistilledState;
42
+ } catch {
43
+ return { sessions: {} };
44
+ }
45
+ }
46
+
47
+ function saveState(root: string, state: DistilledState): void {
48
+ writeFileSync(statePath(root), `${JSON.stringify(state, null, 2)}\n`, "utf8");
49
+ }
50
+
51
+ /** Sessions eligible for distillation: astra sessions idle past the threshold,
52
+ * within the age cap, not already distilled, oldest first, bounded. */
53
+ export async function selectDistillationCandidates(
54
+ sessionsDir: string,
55
+ config: DistillerConfig,
56
+ state: DistilledState,
57
+ nowMs: number,
58
+ ): Promise<SessionFileInfo[]> {
59
+ const idleCutoff = nowMs - config.minIdleHours * 3_600_000;
60
+ const candidates: SessionFileInfo[] = [];
61
+ for (const { path, mtimeMs } of listSessionFiles(sessionsDir, config.maxSessionAgeDays)) {
62
+ if (mtimeMs > idleCutoff) continue;
63
+ const info = await identifySession(path, mtimeMs);
64
+ if (!info.astra || !info.id || state.sessions[info.id]) continue;
65
+ candidates.push(info);
66
+ }
67
+ return candidates.sort((a, b) => a.mtimeMs - b.mtimeMs).slice(0, config.maxSessionsPerPass);
68
+ }
69
+
70
+ const EXTRACTION_PROMPT = `You are distilling a past coding-agent session into durable pocket notes for future sessions.
71
+
72
+ From the transcript below, extract ONLY durable knowledge worth carrying forward:
73
+ - decisions and their rationale
74
+ - project conventions and constraints
75
+ - recurring pitfalls and their fixes
76
+ - user preferences and working style
77
+ - non-obvious facts that cost effort to discover
78
+
79
+ Do NOT include: ephemeral task status, things already recorded in the repo, anything re-derivable in seconds, or secrets/credentials/personal data (redact anything that looks like one).
80
+
81
+ If nothing in the session is durable, reply with exactly: NONE
82
+ Otherwise reply with at most 5 short markdown bullets, each starting with a bolded title.
83
+
84
+ TRANSCRIPT:
85
+ `;
86
+
87
+ const CONSOLIDATION_PROMPT = `You maintain the "durable digest" of an agent's cross-session pocket notes. Merge the new distilled notes below into the existing digest: dedupe, drop contradicted or stale entries, keep it under 40 short bullets. Reply with the refreshed digest markdown only, no commentary.
88
+
89
+ EXISTING DIGEST:
90
+ `;
91
+
92
+ export async function runDistillerPass(
93
+ root: string,
94
+ sessionsDir: string,
95
+ config: DistillerConfig,
96
+ deps: DistillerDeps,
97
+ ): Promise<DistillerResult> {
98
+ const nowMs = (deps.now ?? Date.now)();
99
+ if (!config.enabled) return { processed: 0, skippedReason: "distiller disabled", errors: [] };
100
+ if (!deps.callModel) {
101
+ deps.log("astral-pocket: no distiller model resolved; mechanical floor only");
102
+ return { processed: 0, skippedReason: "no distiller model", errors: [] };
103
+ }
104
+
105
+ ensureLayout(root);
106
+ const state = loadState(root);
107
+ const candidates = await selectDistillationCandidates(sessionsDir, config, state, nowMs);
108
+ const errors: string[] = [];
109
+ const extracted: string[] = [];
110
+
111
+ for (const session of candidates) {
112
+ try {
113
+ const transcript = await readSessionDigest(session.path);
114
+ if (transcript.length < 200) {
115
+ state.sessions[session.id] = new Date(nowMs).toISOString(); // too short to hold durable signal
116
+ continue;
117
+ }
118
+ const output = await deps.callModel(`${EXTRACTION_PROMPT}\n${transcript}`);
119
+ if (output.trim() && output.trim() !== "NONE") {
120
+ const project = session.cwd.split("/").filter(Boolean).pop() ?? "unknown";
121
+ // Queue on POCKET.md like pocket_note does: the distiller runs
122
+ // concurrently with live note writes, and registry+summary updates
123
+ // are read-modify-write cycles that would otherwise race.
124
+ await withFileMutationQueue(join(root, "POCKET.md"), async () =>
125
+ writeNote(root, {
126
+ title: `Distilled session — ${project} — ${new Date(session.mtimeMs).toISOString().slice(0, 10)}`,
127
+ body: output.trim(),
128
+ keywords: ["distilled", project],
129
+ project: session.cwd,
130
+ source: "distilled",
131
+ }),
132
+ );
133
+ extracted.push(output.trim());
134
+ }
135
+ state.sessions[session.id] = new Date(nowMs).toISOString();
136
+ } catch (error) {
137
+ errors.push(`${session.id}: ${error instanceof Error ? error.message : String(error)}`);
138
+ }
139
+ }
140
+
141
+ if (extracted.length > 0) {
142
+ try {
143
+ const summaryPath = join(root, "SUMMARY.md");
144
+ const existing = existsSync(summaryPath) ? readFileSync(summaryPath, "utf8") : "";
145
+ const digestMatch = existing.match(/<!-- pocket:digest:start -->([\s\S]*?)<!-- pocket:digest:end -->/);
146
+ const existingDigest = digestMatch?.[1]?.trim() ?? "";
147
+ const refreshed = await deps.callModel(
148
+ `${CONSOLIDATION_PROMPT}\n${existingDigest}\n\nNEW NOTES:\n${extracted.join("\n")}`,
149
+ );
150
+ if (refreshed.trim()) updateDigest(root, refreshed.trim());
151
+ } catch (error) {
152
+ errors.push(`consolidation: ${error instanceof Error ? error.message : String(error)}`);
153
+ }
154
+ }
155
+
156
+ try {
157
+ saveState(root, state);
158
+ } catch (error) {
159
+ errors.push(`state: ${error instanceof Error ? error.message : String(error)}`);
160
+ }
161
+ return { processed: extracted.length, errors };
162
+ }
@@ -0,0 +1,59 @@
1
+ /** Guidance injected into astra's system prompt while the pocket is active.
2
+ * Adapted from OpenAI Codex's shipped memories template
3
+ * (codex-rs/ext/memories/templates/memories/read_path.md): same decision
4
+ * boundary, same budgeted quick pass, same drift policy. Differences from
5
+ * Codex are deliberate: notes are written autonomously with judgment (this is
6
+ * a personal pocket, not a shared memory product), and there is no citation
7
+ * block (pi has no UI surface that would render it). */
8
+ export function buildPocketGuidance(summary: string): string {
9
+ return `## Astral Pocket
10
+
11
+ You have a persistent note pocket that survives across sessions. It carries
12
+ decisions, conventions, pitfalls, and preferences you judged worth keeping.
13
+ The pocket summary is appended below; the full store is searchable with the
14
+ pocket_recall tool.
15
+
16
+ Decision boundary — when to consult the pocket:
17
+
18
+ - Skip the pocket ONLY when the request is clearly self-contained and needs no
19
+ project history, conventions, or prior decisions (current time, one-line
20
+ shell commands, trivial rewrites).
21
+ - Consult it by default when the task mentions a project, repo, or topic that
22
+ appears in the summary below, when the user asks about prior context or
23
+ previous decisions, or when the task is ambiguous in a way earlier choices
24
+ could resolve.
25
+ - If unsure, do a quick pocket pass.
26
+
27
+ Quick pocket pass (keep it cheap — at most 4-6 lookup steps before main work):
28
+
29
+ 1. Skim the summary below for task-relevant keywords.
30
+ 2. Search with pocket_recall using those keywords.
31
+ 3. Open at most 1-2 of the most relevant hits (full: true only when you need
32
+ exact commands, error text, or precise evidence).
33
+ 4. If nothing relevant surfaces, stop and continue normally.
34
+
35
+ During execution: if you hit repeated errors or confusing behavior that prior
36
+ context might explain, redo the quick pass.
37
+
38
+ Trust and drift:
39
+
40
+ - Pocket notes are point-in-time. If a noted fact is likely to have drifted
41
+ and is cheap to verify, verify before relying on it. When you answer from an
42
+ unverified note, say it is pocket-derived and may be stale.
43
+ - Do not present unverified pocket facts as confirmed-current.
44
+
45
+ Taking notes with pocket_note:
46
+
47
+ - Write a note when you learn something durable: a decision and its rationale,
48
+ a project convention, a recurring pitfall, a user preference, a non-obvious
49
+ fact that cost effort to discover.
50
+ - Do not note ephemeral task state, things already recorded in the repo
51
+ (AGENTS.md, docs, code), or anything you could re-derive in seconds.
52
+ - Never note secrets, credentials, tokens, or personal data.
53
+ - One topic per note; a few sentences is enough. Give it 2-5 keywords so
54
+ future recall can find it.
55
+
56
+ ========= POCKET SUMMARY BEGINS =========
57
+ ${summary.trim() || "(empty — no notes yet)"}
58
+ ========= POCKET SUMMARY ENDS =========`;
59
+ }
@@ -0,0 +1,127 @@
1
+ import { join } from "node:path";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+
4
+ import { recomputeActivation, type ActivationState } from "./activation.js";
5
+ import { DISTILLER_MODEL_PREFERENCE, loadConfig, saveConfig, type PocketConfig } from "./config.js";
6
+ import { runDistillerPass } from "./distiller.js";
7
+ import { buildPocketGuidance } from "./guidance.js";
8
+ import { readRegistryLines, readSummaryCapped, ensureLayout, pocketRoot, defaultAgentDir } from "./store.js";
9
+ import { registerPocketTools } from "./tools.js";
10
+
11
+ /** Extract text from a completed AssistantMessage, failing on error stops. */
12
+ function messageText(message: { stopReason?: string; errorMessage?: string; content: unknown }): string {
13
+ if (message.stopReason === "error") throw new Error(message.errorMessage ?? "model error");
14
+ if (!Array.isArray(message.content)) return "";
15
+ return (message.content as { type?: string; text?: string }[])
16
+ .filter((b) => b.type === "text" && typeof b.text === "string")
17
+ .map((b) => b.text)
18
+ .join("");
19
+ }
20
+
21
+ /** Resolve the distiller's cheap model: config override first, then the
22
+ * preference list, first entry the registry can resolve. Returns null when
23
+ * nothing resolves — the distiller then degrades to the mechanical floor. */
24
+ function makeCallModel(ctx: ExtensionContext, config: PocketConfig): ((prompt: string) => Promise<string>) | null {
25
+ const candidates = config.distiller.model
26
+ ? [config.distiller.model]
27
+ : DISTILLER_MODEL_PREFERENCE;
28
+ for (const ref of candidates) {
29
+ const slash = ref.indexOf("/");
30
+ const providerId = ref.slice(0, slash);
31
+ const modelId = ref.slice(slash + 1);
32
+ const model = ctx.modelRegistry.find(providerId, modelId);
33
+ if (!model) continue;
34
+ const provider = ctx.modelRegistry.getProvider(providerId) as {
35
+ streamSimple?: (m: unknown, c: unknown, o?: unknown) => { result(): Promise<unknown> };
36
+ } | null;
37
+ if (!provider?.streamSimple) continue;
38
+ return async (prompt: string) => {
39
+ const stream = provider.streamSimple!(model, {
40
+ messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
41
+ });
42
+ return messageText((await stream.result()) as { stopReason?: string; errorMessage?: string; content: unknown });
43
+ };
44
+ }
45
+ return null;
46
+ }
47
+
48
+ export default function extension(pi: ExtensionAPI): void {
49
+ const agentDir = defaultAgentDir();
50
+ const root = pocketRoot(agentDir);
51
+ const sessionsDir = join(agentDir, "sessions");
52
+ const state: ActivationState = { active: false };
53
+ let config = loadConfig(root);
54
+
55
+ registerPocketTools(pi, {
56
+ state,
57
+ root,
58
+ sessionsDir,
59
+ maxSessionAgeDays: () => config.distiller.maxSessionAgeDays,
60
+ });
61
+
62
+ /** Sync activation with the current model; on activation, ensure the store
63
+ * exists and kick the bounded distiller pass (fire-and-forget — it must
64
+ * never delay or fail the user's session start). */
65
+ function activate(ctx: ExtensionContext): void {
66
+ const becameActive = recomputeActivation(pi, ctx, state, config);
67
+ if (!state.active) return;
68
+ ensureLayout(root);
69
+ if (!becameActive) return;
70
+ const callModel = makeCallModel(ctx, config);
71
+ runDistillerPass(root, sessionsDir, config.distiller, {
72
+ callModel,
73
+ log: (msg) => ctx.ui.notify(msg, "info"),
74
+ })
75
+ .then((result) => {
76
+ if (result.errors.length > 0) {
77
+ ctx.ui.notify(`astral-pocket distiller: ${result.errors.length} session(s) failed`, "warning");
78
+ }
79
+ })
80
+ .catch(() => ctx.ui.notify("astral-pocket distiller pass failed; mechanical floor intact", "warning"));
81
+ }
82
+
83
+ pi.on("session_start", (_event, ctx) => {
84
+ activate(ctx);
85
+ });
86
+ pi.on("model_select", (_event, ctx) => {
87
+ activate(ctx);
88
+ });
89
+
90
+ pi.on("before_agent_start", (event, _ctx) => {
91
+ if (!state.active) return undefined;
92
+ return { systemPrompt: `${event.systemPrompt}\n\n${buildPocketGuidance(readSummaryCapped(root))}` };
93
+ });
94
+
95
+ pi.registerCommand("pocket", {
96
+ description: "Toggle the astral pocket: /pocket on|off|status",
97
+ getArgumentCompletions: (prefix) => {
98
+ const items = ["on", "off", "status"].map((v) => ({ value: v, label: v }));
99
+ const filtered = items.filter((i) => i.value.startsWith(prefix));
100
+ return filtered.length > 0 ? filtered : null;
101
+ },
102
+ handler: async (args, ctx) => {
103
+ const verb = args.trim().toLowerCase();
104
+ if (verb === "on" || verb === "off") {
105
+ config = { ...config, enabled: verb === "on" };
106
+ saveConfig(root, config);
107
+ activate(ctx);
108
+ ctx.ui.notify(
109
+ config.enabled
110
+ ? "Astral pocket enabled (active for gpt-6-astra sessions)."
111
+ : "Astral pocket disabled.",
112
+ "info",
113
+ );
114
+ return;
115
+ }
116
+ const notes = readRegistryLines(root).length;
117
+ ctx.ui.notify(
118
+ [
119
+ `Pocket: ${config.enabled ? "enabled" : "disabled"}; ${state.active ? "active this session" : "inactive (not an astra session)"}`,
120
+ `Notes: ${notes}. Distiller: ${config.distiller.enabled ? `on (model: ${config.distiller.model ?? DISTILLER_MODEL_PREFERENCE[0]})` : "off"}`,
121
+ `Store: ${root}`,
122
+ ].join("\n"),
123
+ "info",
124
+ );
125
+ },
126
+ });
127
+ }