@nklisch/pi-enhanced 0.4.1 → 0.4.3

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.
@@ -2,47 +2,43 @@ import { join } from "node:path";
2
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
 
4
4
  import { recomputeActivation, type ActivationState } from "./activation.js";
5
- import { DISTILLER_MODEL_PREFERENCE, loadConfig, saveConfig, type PocketConfig } from "./config.js";
5
+ import {
6
+ DEFAULT_DISTILLER_MODEL,
7
+ DEFAULT_DISTILLER_REASONING,
8
+ isModelSpec,
9
+ isReasoningLevel,
10
+ loadConfig,
11
+ saveConfig,
12
+ type PocketConfig,
13
+ } from "./config.js";
14
+ import { DistillerController } from "./controller.js";
6
15
  import { runDistillerPass } from "./distiller.js";
7
16
  import { buildPocketGuidance } from "./guidance.js";
8
- import { readRegistryLines, readSummaryCapped, ensureLayout, pocketRoot, defaultAgentDir } from "./store.js";
17
+ import { createDistillerModelClient, type DistillerModelStatus } from "./provider.js";
18
+ import { resolveProjectIdentity } from "./scope.js";
19
+ import { countNotes, defaultAgentDir, ensureLayout, pocketRoot, readScopedSummary } from "./store.js";
9
20
  import { registerPocketTools } from "./tools.js";
10
21
 
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("");
22
+ function safeNotify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info"): void {
23
+ try { ctx.ui.notify(message, level); } catch { /* lifecycle may revoke the command context */ }
19
24
  }
20
25
 
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
- };
26
+ function outcomeText(controller: DistillerController): string {
27
+ const outcome = controller.status();
28
+ if (outcome.state === "completed") {
29
+ if (outcome.result.skippedReason) return `skipped (${outcome.result.skippedReason})`;
30
+ return `completed (${outcome.result.processed} revision(s), digest ${outcome.result.digest}, ${outcome.result.errors.length} error(s))`;
44
31
  }
45
- return null;
32
+ if (outcome.state === "failed") return `failed (${outcome.error})`;
33
+ return outcome.state;
34
+ }
35
+
36
+ function modelStatus(ctx: ExtensionContext, config: PocketConfig): DistillerModelStatus {
37
+ return createDistillerModelClient(
38
+ ctx.modelRegistry,
39
+ config.distiller.model,
40
+ config.distiller.reasoning,
41
+ ).status();
46
42
  }
47
43
 
48
44
  export default function extension(pi: ExtensionAPI): void {
@@ -50,7 +46,9 @@ export default function extension(pi: ExtensionAPI): void {
50
46
  const root = pocketRoot(agentDir);
51
47
  const sessionsDir = join(agentDir, "sessions");
52
48
  const state: ActivationState = { active: false };
49
+ const controller = new DistillerController();
53
50
  let config = loadConfig(root);
51
+ let currentProjectId: string | undefined;
54
52
 
55
53
  registerPocketTools(pi, {
56
54
  state,
@@ -59,69 +57,166 @@ export default function extension(pi: ExtensionAPI): void {
59
57
  maxSessionAgeDays: () => config.distiller.maxSessionAgeDays,
60
58
  });
61
59
 
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 {
60
+ function startPass(ctx: ExtensionContext, forceDigest = false): void {
61
+ if (!state.active || !currentProjectId) return;
62
+ const snapshot = structuredClone(config.distiller);
63
+ const projectId = currentProjectId;
64
+ const client = createDistillerModelClient(ctx.modelRegistry, snapshot.model, snapshot.reasoning);
65
+ const available = client.status().error === undefined;
66
+ void controller.start(
67
+ (signal) => runDistillerPass(root, sessionsDir, snapshot, {
68
+ callModel: available ? (prompt, requestSignal, maxTokens) => client.call(prompt, requestSignal, maxTokens) : null,
69
+ log: () => undefined,
70
+ signal,
71
+ forceDigest,
72
+ }, projectId),
73
+ (message, level) => safeNotify(ctx, message, level),
74
+ ).catch(() => undefined);
75
+ }
76
+
77
+ /** Activation and command use reload config so external edits take effect. */
78
+ function activate(ctx: ExtensionContext, startOnActivation = true, replaceSession = false): void {
79
+ const previousConfig = config;
80
+ const previousProjectId = currentProjectId;
81
+ config = loadConfig(root);
82
+ currentProjectId = resolveProjectIdentity(ctx.cwd);
83
+ const effectiveConfigChanged = JSON.stringify(previousConfig) !== JSON.stringify(config);
84
+ const projectChanged = previousProjectId !== undefined && previousProjectId !== currentProjectId;
85
+ if (replaceSession || effectiveConfigChanged || projectChanged) controller.stop();
86
+
66
87
  const becameActive = recomputeActivation(pi, ctx, state, config);
67
- if (!state.active) return;
88
+ if (!state.active) {
89
+ controller.stop();
90
+ return;
91
+ }
68
92
  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"));
93
+ if (startOnActivation && config.distiller.enabled && (replaceSession || becameActive || effectiveConfigChanged || projectChanged)) {
94
+ startPass(ctx);
95
+ }
81
96
  }
82
97
 
83
- pi.on("session_start", (_event, ctx) => {
84
- activate(ctx);
85
- });
86
- pi.on("model_select", (_event, ctx) => {
87
- activate(ctx);
98
+ // A new session invalidates the previous session's reporting context even
99
+ // when the selected model remains Astra.
100
+ pi.on("session_start", (_event, ctx) => activate(ctx, true, true));
101
+ pi.on("model_select", (_event, ctx) => activate(ctx));
102
+ pi.on("session_shutdown", () => {
103
+ state.active = false;
104
+ currentProjectId = undefined;
105
+ controller.stop();
88
106
  });
89
107
 
90
- pi.on("before_agent_start", (event, _ctx) => {
108
+ pi.on("before_agent_start", (event, ctx) => {
91
109
  if (!state.active) return undefined;
92
- return { systemPrompt: `${event.systemPrompt}\n\n${buildPocketGuidance(readSummaryCapped(root))}` };
110
+ const projectId = currentProjectId ?? resolveProjectIdentity(ctx.cwd);
111
+ return { systemPrompt: `${event.systemPrompt}\n\n${buildPocketGuidance(readScopedSummary(root, projectId))}` };
93
112
  });
94
113
 
95
114
  pi.registerCommand("pocket", {
96
- description: "Toggle the astral pocket: /pocket on|off|status",
115
+ description: "Manage Astral Pocket: status, on/off, distiller, model, reasoning, distill, rebuild",
97
116
  getArgumentCompletions: (prefix) => {
98
- const items = ["on", "off", "status"].map((v) => ({ value: v, label: v }));
99
- const filtered = items.filter((i) => i.value.startsWith(prefix));
117
+ const values = ["status", "on", "off", "distiller on", "distiller off", "model reset", "reasoning minimal", "distill", "rebuild"];
118
+ const filtered = values.filter((value) => value.startsWith(prefix)).map((value) => ({ value, label: value }));
100
119
  return filtered.length > 0 ? filtered : null;
101
120
  },
102
121
  handler: async (args, ctx) => {
103
- const verb = args.trim().toLowerCase();
122
+ activate(ctx, false);
123
+ const words = args.trim().split(/\s+/).filter(Boolean);
124
+ const verb = (words[0] ?? "status").toLowerCase();
125
+
104
126
  if (verb === "on" || verb === "off") {
127
+ if (words.length !== 1) {
128
+ safeNotify(ctx, `Usage: /pocket ${verb}`, "warning");
129
+ return;
130
+ }
105
131
  config = { ...config, enabled: verb === "on" };
106
132
  saveConfig(root, config);
107
133
  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
- );
134
+ safeNotify(ctx, verb === "on" ? "Astral Pocket enabled for Astra sessions." : "Astral Pocket disabled.");
135
+ return;
136
+ }
137
+
138
+ if (verb === "distiller") {
139
+ const value = words[1]?.toLowerCase();
140
+ if ((value !== "on" && value !== "off") || words.length !== 2) {
141
+ safeNotify(ctx, "Usage: /pocket distiller on|off", "warning");
142
+ return;
143
+ }
144
+ config = { ...config, distiller: { ...config.distiller, enabled: value === "on" } };
145
+ saveConfig(root, config);
146
+ controller.stop();
147
+ if (value === "on" && state.active) startPass(ctx);
148
+ safeNotify(ctx, `Astral Pocket distiller ${value === "on" ? "enabled" : "disabled"}.`);
149
+ return;
150
+ }
151
+
152
+ if (verb === "model") {
153
+ const value = words.slice(1).join(" ").trim();
154
+ const model = value === "reset" || value === "" ? DEFAULT_DISTILLER_MODEL : value;
155
+ if (words.length > 2 || !isModelSpec(model)) {
156
+ safeNotify(ctx, "Usage: /pocket model provider/modelId (or /pocket model reset)", "warning");
157
+ return;
158
+ }
159
+ config = { ...config, distiller: { ...config.distiller, model } };
160
+ saveConfig(root, config);
161
+ controller.stop();
162
+ if (state.active && config.distiller.enabled) startPass(ctx);
163
+ const status = modelStatus(ctx, config);
164
+ safeNotify(ctx, status.error ? `Distiller model saved but unavailable: ${status.error}` : `Distiller model: ${status.resolvedModel}.` , status.error ? "warning" : "info");
114
165
  return;
115
166
  }
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
- );
167
+
168
+ if (verb === "reasoning") {
169
+ const value = words[1]?.toLowerCase() ?? "reset";
170
+ const reasoning = value === "reset" ? DEFAULT_DISTILLER_REASONING : value;
171
+ if (words.length > 2 || !isReasoningLevel(reasoning)) {
172
+ safeNotify(ctx, "Usage: /pocket reasoning off|minimal|low|medium|high|xhigh|max|reset", "warning");
173
+ return;
174
+ }
175
+ config = { ...config, distiller: { ...config.distiller, reasoning } };
176
+ saveConfig(root, config);
177
+ controller.stop();
178
+ if (state.active && config.distiller.enabled) startPass(ctx);
179
+ const status = modelStatus(ctx, config);
180
+ safeNotify(ctx, `Distiller reasoning requested: ${reasoning}; effective: ${status.effectiveReasoning ?? "unavailable"}.`, status.error ? "warning" : "info");
181
+ return;
182
+ }
183
+
184
+ if (verb === "distill" || verb === "rebuild") {
185
+ if (words.length !== 1) {
186
+ safeNotify(ctx, `Usage: /pocket ${verb}`, "warning");
187
+ return;
188
+ }
189
+ if (!state.active) {
190
+ safeNotify(ctx, "Astral Pocket distillation is available only in an active Astra session.", "warning");
191
+ return;
192
+ }
193
+ if (!config.distiller.enabled) {
194
+ safeNotify(ctx, "The distiller is disabled. Run /pocket distiller on first.", "warning");
195
+ return;
196
+ }
197
+ startPass(ctx, verb === "rebuild");
198
+ safeNotify(ctx, verb === "rebuild" ? "Astral Pocket digest rebuild started." : "Astral Pocket distillation started.");
199
+ return;
200
+ }
201
+
202
+ if (verb !== "status" || words.length > 1) {
203
+ safeNotify(ctx, "Usage: /pocket status|on|off|distiller on|off|model <provider/modelId>|reasoning <level>|distill|rebuild", "warning");
204
+ return;
205
+ }
206
+
207
+ const notes = countNotes(root);
208
+ const status = modelStatus(ctx, config);
209
+ const reasoning = status.effectiveReasoning && status.effectiveReasoning !== status.requestedReasoning
210
+ ? `${status.requestedReasoning} → ${status.effectiveReasoning}`
211
+ : status.requestedReasoning;
212
+ safeNotify(ctx, [
213
+ `Pocket: ${config.enabled ? "enabled" : "disabled"}; ${state.active ? "active" : "inactive (Astra only)"}`,
214
+ `Notes: ${notes}. Distiller: ${config.distiller.enabled ? "on" : "off"}`,
215
+ `Model: requested ${status.requestedModel}; resolved ${status.resolvedModel ?? "unavailable"}`,
216
+ `Reasoning: ${reasoning}${status.error ? `; ${status.error}` : ""}`,
217
+ `Last pass: ${outcomeText(controller)}`,
218
+ `Store: ${root}`,
219
+ ].join("\n"), status.error ? "warning" : "info");
125
220
  },
126
221
  });
127
222
  }
@@ -0,0 +1,128 @@
1
+ import type {
2
+ Api,
3
+ AssistantMessage,
4
+ Context,
5
+ Model,
6
+ ModelThinkingLevel,
7
+ Provider,
8
+ SimpleStreamOptions,
9
+ } from "@earendil-works/pi-ai";
10
+ import { clampThinkingLevel } from "@earendil-works/pi-ai";
11
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
12
+
13
+ export interface DistillerModelStatus {
14
+ requestedModel: string;
15
+ resolvedModel?: string;
16
+ requestedReasoning: ModelThinkingLevel;
17
+ effectiveReasoning?: string;
18
+ error?: string;
19
+ }
20
+
21
+ export interface DistillerModelClient {
22
+ status(): DistillerModelStatus;
23
+ call(prompt: string, signal: AbortSignal, maxTokens: number): Promise<string>;
24
+ }
25
+
26
+ export type ModelInvoker = (
27
+ provider: Provider,
28
+ model: Model<Api>,
29
+ context: Context,
30
+ options: SimpleStreamOptions,
31
+ ) => Promise<AssistantMessage>;
32
+
33
+ function parseModelSpec(spec: string): { provider: string; modelId: string } | undefined {
34
+ const slash = spec.indexOf("/");
35
+ if (slash <= 0 || slash === spec.length - 1) return undefined;
36
+ return { provider: spec.slice(0, slash), modelId: spec.slice(slash + 1) };
37
+ }
38
+
39
+ function resolveReasoning(model: Model<Api>, requested: ModelThinkingLevel): {
40
+ request?: SimpleStreamOptions["reasoning"];
41
+ effective: string;
42
+ } {
43
+ const clamped = clampThinkingLevel(model, requested);
44
+ const mapped = model.thinkingLevelMap?.[clamped];
45
+ return {
46
+ ...(clamped === "off" ? {} : { request: clamped }),
47
+ effective: mapped ?? clamped,
48
+ };
49
+ }
50
+
51
+ function raceWithSignal<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
52
+ if (signal.aborted) return Promise.reject(new Error("distiller request aborted"));
53
+ return new Promise<T>((resolve, reject) => {
54
+ const onAbort = () => reject(new Error("distiller request aborted"));
55
+ signal.addEventListener("abort", onAbort, { once: true });
56
+ promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
57
+ });
58
+ }
59
+
60
+ function extractSuccessfulText(message: AssistantMessage): string {
61
+ if (message.stopReason !== "stop" || message.errorMessage !== undefined) {
62
+ throw new Error(`distiller model ended with ${message.errorMessage ?? message.stopReason}`);
63
+ }
64
+ const text = message.content
65
+ .filter((part): part is { type: "text"; text: string } => part.type === "text")
66
+ .map((part) => part.text)
67
+ .join("\n")
68
+ .trim();
69
+ if (text.length === 0) throw new Error("distiller model returned no text");
70
+ return text;
71
+ }
72
+
73
+ const defaultInvoker: ModelInvoker = async (provider, model, context, options) =>
74
+ provider.streamSimple(model, context, options).result();
75
+
76
+ /**
77
+ * Resolve one exact model and use Pi's registry for fresh authentication on
78
+ * every request. Direct provider calls need these request options; invoking a
79
+ * provider without them breaks OAuth-backed providers such as openai-codex.
80
+ */
81
+ export function createDistillerModelClient(
82
+ registry: Pick<ModelRegistry, "find" | "getProvider" | "getApiKeyAndHeaders">,
83
+ requestedModel: string,
84
+ requestedReasoning: ModelThinkingLevel,
85
+ invoker: ModelInvoker = defaultInvoker,
86
+ ): DistillerModelClient {
87
+ const parsed = parseModelSpec(requestedModel);
88
+ const model = parsed ? registry.find(parsed.provider, parsed.modelId) : undefined;
89
+ const provider = parsed ? registry.getProvider(parsed.provider) : undefined;
90
+ const reasoning = model ? resolveReasoning(model, requestedReasoning) : undefined;
91
+ const unavailable = !parsed
92
+ ? `invalid model "${requestedModel}"; use provider/modelId`
93
+ : !model
94
+ ? `model ${requestedModel} is not in the Pi model registry`
95
+ : !provider
96
+ ? `provider ${parsed.provider} is not available`
97
+ : undefined;
98
+
99
+ return {
100
+ status: () => ({
101
+ requestedModel,
102
+ ...(model ? { resolvedModel: `${model.provider}/${model.id}` } : {}),
103
+ requestedReasoning,
104
+ ...(reasoning ? { effectiveReasoning: reasoning.effective } : {}),
105
+ ...(unavailable ? { error: unavailable } : {}),
106
+ }),
107
+ async call(prompt, signal, maxTokens) {
108
+ if (!model || !provider || !reasoning) throw new Error(unavailable ?? "distiller model unavailable");
109
+ if (signal.aborted) throw new Error("distiller request aborted");
110
+ const auth = await raceWithSignal(registry.getApiKeyAndHeaders(model), signal);
111
+ if (signal.aborted) throw new Error("distiller request aborted");
112
+ if (!auth.ok) throw new Error(`authentication failed for ${requestedModel}: ${auth.error}`);
113
+ const options: SimpleStreamOptions = {
114
+ signal,
115
+ maxTokens,
116
+ ...(reasoning.request ? { reasoning: reasoning.request } : {}),
117
+ ...(auth.apiKey ? { apiKey: auth.apiKey } : {}),
118
+ ...(auth.headers ? { headers: auth.headers } : {}),
119
+ ...(auth.env ? { env: auth.env } : {}),
120
+ };
121
+ const message = await invoker(provider, model, {
122
+ messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
123
+ }, options);
124
+ if (signal.aborted) throw new Error("distiller request aborted");
125
+ return extractSuccessfulText(message);
126
+ },
127
+ };
128
+ }
@@ -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
+ }