@mrclrchtr/supi-insights 1.10.0 → 1.11.0

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-core",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "description": "SuPi core — shared infrastructure for SuPi extensions (XML context tags, config system)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -19,11 +19,15 @@
19
19
  "!__tests__"
20
20
  ],
21
21
  "peerDependencies": {
22
+ "@earendil-works/pi-ai": "*",
22
23
  "@earendil-works/pi-coding-agent": "*",
23
24
  "@earendil-works/pi-tui": "*",
24
25
  "typebox": "*"
25
26
  },
26
27
  "peerDependenciesMeta": {
28
+ "@earendil-works/pi-ai": {
29
+ "optional": true
30
+ },
27
31
  "@earendil-works/pi-coding-agent": {
28
32
  "optional": true
29
33
  },
@@ -40,8 +44,10 @@
40
44
  "./config": "./src/config.ts",
41
45
  "./context": "./src/context.ts",
42
46
  "./debug": "./src/debug-registry.ts",
47
+ "./llm": "./src/llm.ts",
43
48
  "./package.json": "./package.json",
44
49
  "./path": "./src/path.ts",
50
+ "./progress-widget": "./src/progress-widget.ts",
45
51
  "./project": "./src/project.ts",
46
52
  "./session": "./src/session.ts",
47
53
  "./settings": "./src/settings.ts",
@@ -13,6 +13,8 @@ export * from "./context.ts";
13
13
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
14
14
  export * from "./debug-registry.ts";
15
15
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
16
+ export * from "./llm.ts";
17
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
16
18
  export * from "./path.ts";
17
19
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
18
20
  export * from "./project.ts";
@@ -1,11 +1,42 @@
1
1
  // Config-aware settings helper for SuPi config-backed settings sections.
2
2
  // Wraps registerSettings() and centralizes selected-scope loading + scoped persistence.
3
+ //
4
+ // Setting items can declare a `configType` ("boolean" | "number" | "stringList")
5
+ // to enable auto-generated persistChange. When all items have a configType,
6
+ // the persistChange callback can be omitted.
3
7
 
4
8
  import type { SettingItem } from "@earendil-works/pi-tui";
5
9
  import type { SettingsScope } from "../settings/settings-registry.ts";
6
10
  import { registerSettings } from "../settings/settings-registry.ts";
7
11
  import { loadSupiConfigForScope, removeSupiConfigKey, writeSupiConfig } from "./config.ts";
8
12
 
13
+ // ── Types ──────────────────────────────────────────────────────────────────
14
+
15
+ /**
16
+ * Supported config value types for declarative persistChange.
17
+ *
18
+ * - `"boolean"`: maps "on" → true, "off" → false
19
+ * - `"number"`: parses integer via Number.parseInt, falls back to unset on invalid
20
+ * - `"stringList"`: splits on comma, trims whitespace, unsets on empty
21
+ */
22
+ export type ConfigSettingType = "boolean" | "number" | "stringList";
23
+
24
+ /**
25
+ * Extended setting item that can declare its config type for auto-generated
26
+ * persistence handling.
27
+ */
28
+ export interface ConfigSettingItem extends SettingItem {
29
+ /**
30
+ * When set, persistChange for this item is auto-generated.
31
+ * All items must declare a configType for auto-generation to activate.
32
+ */
33
+ configType?: ConfigSettingType;
34
+ }
35
+
36
+ /**
37
+ * Helpers provided to the persistChange callback for writing or removing
38
+ * scoped config values.
39
+ */
9
40
  export interface ConfigSettingsHelpers {
10
41
  /** Write a key to the selected scope's config section. */
11
42
  set(key: string, value: unknown): void;
@@ -22,10 +53,21 @@ export interface ConfigSettingsOptions<T> {
22
53
  section: string;
23
54
  /** Default config values */
24
55
  defaults: T;
25
- /** Build SettingItem[] from scoped config. Called by loadValues. */
26
- buildItems: (settings: T, scope: SettingsScope, cwd: string) => SettingItem[];
27
- /** Handle a settings change with scoped persistence helpers. */
28
- persistChange: (
56
+ /**
57
+ * Build SettingItem[] from scoped config. Called by loadValues.
58
+ *
59
+ * Items can include a `configType` property for auto-generated
60
+ * persistChange handling. When ALL items declare a configType,
61
+ * the `persistChange` callback can be omitted.
62
+ */
63
+ buildItems: (settings: T, scope: SettingsScope, cwd: string) => ConfigSettingItem[];
64
+ /**
65
+ * Handle a settings change with scoped persistence helpers.
66
+ *
67
+ * Optional when all items returned by `buildItems` declare a `configType`.
68
+ * Required when any item lacks a `configType`.
69
+ */
70
+ persistChange?: (
29
71
  scope: SettingsScope,
30
72
  cwd: string,
31
73
  settingId: string,
@@ -36,6 +78,52 @@ export interface ConfigSettingsOptions<T> {
36
78
  homeDir?: string;
37
79
  }
38
80
 
81
+ // ── Auto-generated persistChange ───────────────────────────────────────────
82
+
83
+ function autoPersistChange(
84
+ settingId: string,
85
+ value: string,
86
+ helpers: ConfigSettingsHelpers,
87
+ items: ConfigSettingItem[],
88
+ ): void {
89
+ const item = items.find((i) => i.id === settingId);
90
+ if (!item?.configType) return;
91
+
92
+ switch (item.configType) {
93
+ case "boolean": {
94
+ helpers.set(settingId, value === "on");
95
+ break;
96
+ }
97
+ case "number": {
98
+ const num = Number.parseInt(value, 10);
99
+ if (Number.isFinite(num) && num > 0) {
100
+ helpers.set(settingId, num);
101
+ } else {
102
+ helpers.unset(settingId);
103
+ }
104
+ break;
105
+ }
106
+ case "stringList": {
107
+ const names = value
108
+ .split(",")
109
+ .map((s) => s.trim())
110
+ .filter((s) => s.length > 0);
111
+ if (names.length > 0) {
112
+ helpers.set(settingId, names);
113
+ } else {
114
+ helpers.unset(settingId);
115
+ }
116
+ break;
117
+ }
118
+ }
119
+ }
120
+
121
+ function areAllItemsDeclarative(items: ConfigSettingItem[]): boolean {
122
+ return items.length > 0 && items.every((i) => i.configType !== undefined);
123
+ }
124
+
125
+ // ── Registration ───────────────────────────────────────────────────────────
126
+
39
127
  /**
40
128
  * Register a config-backed settings section.
41
129
  *
@@ -43,8 +131,13 @@ export interface ConfigSettingsOptions<T> {
43
131
  * instead of merged effective runtime config. Provides scoped `set` / `unset`
44
132
  * persistence helpers so extensions don't need to wire `writeSupiConfig` /
45
133
  * `removeSupiConfigKey` by hand.
134
+ *
135
+ * When every item returned by `buildItems` declares a `configType`, the
136
+ * `persistChange` callback is optional and will be auto-generated.
46
137
  */
47
138
  export function registerConfigSettings<T>(options: ConfigSettingsOptions<T>): void {
139
+ let cachedItems: ConfigSettingItem[] | undefined;
140
+
48
141
  registerSettings({
49
142
  id: options.id,
50
143
  label: options.label,
@@ -53,7 +146,9 @@ export function registerConfigSettings<T>(options: ConfigSettingsOptions<T>): vo
53
146
  scope,
54
147
  homeDir: options.homeDir,
55
148
  });
56
- return options.buildItems(settings, scope, cwd);
149
+ const items = options.buildItems(settings, scope, cwd);
150
+ cachedItems = items;
151
+ return items;
57
152
  },
58
153
  persistChange: (scope, cwd, settingId, value) => {
59
154
  const helpers: ConfigSettingsHelpers = {
@@ -70,7 +165,18 @@ export function registerConfigSettings<T>(options: ConfigSettingsOptions<T>): vo
70
165
  });
71
166
  },
72
167
  };
73
- options.persistChange(scope, cwd, settingId, value, helpers);
168
+
169
+ // Use manual persistChange when provided
170
+ if (options.persistChange) {
171
+ options.persistChange(scope, cwd, settingId, value, helpers);
172
+ return;
173
+ }
174
+
175
+ // Auto-generate when all items are declarative
176
+ const items = cachedItems ?? options.buildItems(options.defaults, scope, cwd);
177
+ if (areAllItemsDeclarative(items)) {
178
+ autoPersistChange(settingId, value, helpers, items);
179
+ }
74
180
  },
75
181
  });
76
182
  }
@@ -184,3 +184,23 @@ function extractSection(
184
184
  }
185
185
  return null;
186
186
  }
187
+
188
+ /**
189
+ * Shorthand for {@link loadSupiConfig} that infers the return type from defaults.
190
+ *
191
+ * Reduces boilerplate when a package only needs the merged runtime config.
192
+ *
193
+ * @example
194
+ * ```ts
195
+ * const config = loadSectionConfig("my-ext", cwd, { enabled: true, timeout: 30 });
196
+ * // config is typed as { enabled: boolean; timeout: number }
197
+ * ```
198
+ */
199
+ export function loadSectionConfig<T extends Record<string, unknown>>(
200
+ section: string,
201
+ cwd: string,
202
+ defaults: T,
203
+ options?: SupiConfigOptions,
204
+ ): T {
205
+ return loadSupiConfig(section, cwd, defaults, options);
206
+ }
@@ -0,0 +1,211 @@
1
+ import { complete } from "@earendil-works/pi-ai";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import type { TSchema } from "typebox";
4
+ import { Value } from "typebox/value";
5
+
6
+ // Shared LLM utilities for SuPi extensions.
7
+ //
8
+ // Provides retry logic, structured LLM call helpers, and other
9
+ // common patterns for extensions that interact with AI models.
10
+
11
+ /**
12
+ * Options for {@link withRetry}.
13
+ */
14
+ export interface WithRetryOptions {
15
+ /** Maximum number of retry attempts after the initial call. Default: 2 */
16
+ retries?: number;
17
+ /** Base delay in milliseconds for exponential backoff. Default: 1000 */
18
+ baseDelayMs?: number;
19
+ /** AbortSignal to cancel retry loops. */
20
+ signal?: AbortSignal;
21
+ /** Called with each failed attempt's attempt index and error. */
22
+ logger?: (attempt: number, error: unknown) => void;
23
+ /** Called before each retry delay with attempt index and computed delay. */
24
+ onRetry?: (attempt: number, delayMs: number) => void;
25
+ }
26
+
27
+ /**
28
+ * Attempt an async operation with retries and exponential backoff.
29
+ *
30
+ * If the signal is already aborted on entry, the operation is skipped entirely.
31
+ * If the signal aborts during a delay, the delay is cancelled immediately.
32
+ *
33
+ * @param fn - The async operation to retry.
34
+ * @param options - Optional configuration for retries, backoff, signal, and callbacks.
35
+ * @returns The result on success, or `null` if all attempts fail or the signal aborts.
36
+ */
37
+ /**
38
+ * Create a promise that resolves after `ms` milliseconds, or rejects if
39
+ * the signal fires before the timeout elapses.
40
+ */
41
+ function delay(ms: number, signal?: AbortSignal): Promise<void> {
42
+ return new Promise<void>((resolve, reject) => {
43
+ const timer = setTimeout(resolve, ms);
44
+ if (signal) {
45
+ const onAbort = () => {
46
+ clearTimeout(timer);
47
+ reject(new DOMException("Aborted", "AbortError"));
48
+ };
49
+ signal.addEventListener("abort", onAbort, { once: true });
50
+ }
51
+ });
52
+ }
53
+
54
+ /**
55
+ * Attempt an async operation with retries and exponential backoff.
56
+ *
57
+ * If the signal is already aborted on entry, the operation is skipped entirely.
58
+ * If the signal aborts during a delay, the delay is cancelled immediately.
59
+ *
60
+ * @param fn - The async operation to retry.
61
+ * @param options - Optional configuration for retries, backoff, signal, and callbacks.
62
+ * @returns The result on success, or `null` if all attempts fail or the signal aborts.
63
+ */
64
+ export async function withRetry<T>(
65
+ fn: () => Promise<T>,
66
+ options?: WithRetryOptions,
67
+ ): Promise<T | null> {
68
+ const { retries = 2, baseDelayMs = 1000, signal, logger, onRetry } = options ?? {};
69
+
70
+ if (signal?.aborted) return null;
71
+
72
+ for (let attempt = 0; attempt <= retries; attempt++) {
73
+ try {
74
+ return await fn();
75
+ } catch (err) {
76
+ logger?.(attempt, err);
77
+ if (attempt >= retries || signal?.aborted) continue;
78
+
79
+ const delayMs = baseDelayMs * 2 ** attempt;
80
+ onRetry?.(attempt, delayMs);
81
+
82
+ try {
83
+ await delay(delayMs, signal);
84
+ } catch {
85
+ // delay() only rejects on abort
86
+ return null;
87
+ }
88
+ }
89
+ }
90
+
91
+ return null;
92
+ }
93
+
94
+ /**
95
+ * Extract and validate JSON from LLM response content blocks.
96
+ *
97
+ * Finds the first JSON object `{...}` in the combined text content,
98
+ * parses it, and validates against a TypeBox schema.
99
+ *
100
+ * @param content - The LLM response content blocks.
101
+ * @param schema - TypeBox schema to validate against.
102
+ * @returns The parsed and validated result, or `null` if extraction or validation fails.
103
+ */
104
+ export function extractJsonFromResponse<T extends TSchema>(
105
+ content: ReadonlyArray<{ type: string; text?: string }>,
106
+ schema: T,
107
+ ): { parsed: import("typebox").Static<T> } | null {
108
+ const text = content
109
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
110
+ .map((c) => c.text)
111
+ .join("");
112
+
113
+ const jsonMatch = text.match(/\{[\s\S]*\}/);
114
+ if (!jsonMatch) return null;
115
+
116
+ try {
117
+ const parsed = JSON.parse(jsonMatch[0]);
118
+ if (Value.Check(schema, parsed)) {
119
+ return { parsed } as { parsed: import("typebox").Static<T> };
120
+ }
121
+ return null;
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+
127
+ // ── callWithJsonResponse ───────────────────────────────────────────────────
128
+
129
+ /**
130
+ * Options for {@link callWithJsonResponse}.
131
+ */
132
+ export interface CallWithJsonResponseOptions {
133
+ /** The prompt to send to the LLM. */
134
+ prompt: string;
135
+ /** Optional data context appended to the prompt. */
136
+ dataContext?: string;
137
+ /** Maximum tokens for the response. Default: 4096 */
138
+ maxTokens?: number;
139
+ /** System prompt for the LLM call. Default: "" */
140
+ systemPrompt?: string;
141
+ /** Number of retries for the LLM call. Default: 2 */
142
+ retries?: number;
143
+ }
144
+
145
+ /**
146
+ * Call the LLM with a prompt and validate the JSON response against a TypeBox schema.
147
+ *
148
+ * Handles model resolution, auth, retry via `withRetry`, text extraction,
149
+ * JSON regex matching, and TypeBox validation.
150
+ *
151
+ * Returns `null` when:
152
+ * - No model is available
153
+ * - All retries fail
154
+ * - Response contains no valid JSON
155
+ * - JSON doesn't match the schema
156
+ * - The request is aborted
157
+ *
158
+ * @param ctx - The extension context for model resolution and auth.
159
+ * @param options - Call options including prompt, schema, and retry config.
160
+ * @param schema - TypeBox schema to validate the JSON response against.
161
+ * @returns The parsed and validated result, or `null`.
162
+ */
163
+ export async function callWithJsonResponse<T extends TSchema>(
164
+ ctx: ExtensionContext,
165
+ options: CallWithJsonResponseOptions,
166
+ schema: T,
167
+ ): Promise<{ parsed: import("typebox").Static<T> } | null> {
168
+ const { prompt, dataContext, maxTokens = 4096, systemPrompt = "", retries = 2 } = options;
169
+
170
+ const model = ctx.model ?? ctx.modelRegistry.getAvailable()[0] ?? null;
171
+ if (!model) return null;
172
+
173
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
174
+ if (!auth.ok || !auth.apiKey) return null;
175
+
176
+ const fullPrompt = dataContext
177
+ ? `${prompt}
178
+
179
+ DATA:
180
+ ${dataContext}`
181
+ : prompt;
182
+
183
+ const response = await withRetry(
184
+ async () => {
185
+ return complete(
186
+ model,
187
+ {
188
+ systemPrompt,
189
+ messages: [
190
+ {
191
+ role: "user",
192
+ content: [{ type: "text", text: fullPrompt }],
193
+ timestamp: Date.now(),
194
+ },
195
+ ],
196
+ },
197
+ {
198
+ apiKey: auth.apiKey,
199
+ headers: auth.headers,
200
+ signal: ctx.signal,
201
+ maxTokens,
202
+ },
203
+ );
204
+ },
205
+ { retries, baseDelayMs: 1000, signal: ctx.signal },
206
+ );
207
+
208
+ if (!response) return null;
209
+
210
+ return extractJsonFromResponse(response.content, schema);
211
+ }
@@ -0,0 +1,108 @@
1
+ // Generic progress widget for SuPi long-running operations.
2
+ //
3
+ // Provides a TUI-based progress display with animated loader, turn counts,
4
+ // tool usage, and activity descriptions.
5
+
6
+ import type { Theme } from "@earendil-works/pi-coding-agent";
7
+ import { CancellableLoader, Container, Text } from "@earendil-works/pi-tui";
8
+
9
+ // ── Types ──────────────────────────────────────────────────────────────────
10
+
11
+ /** Progress state for widget display, compatible with child-session updates. */
12
+ export interface WidgetProgress {
13
+ /** Number of agent turns completed. */
14
+ turns: number;
15
+ /** Number of tool executions started. */
16
+ toolUses: number;
17
+ /** Human-readable active tool descriptions. */
18
+ activities: string[];
19
+ /** Token usage stats, if available. */
20
+ tokens?: { input: number; output: number; total: number };
21
+ }
22
+
23
+ // ── Widget ─────────────────────────────────────────────────────────────────
24
+
25
+ /**
26
+ * TUI progress widget for long-running operations.
27
+ *
28
+ * Shows an animated loader, turn count, tool uses, token count, and any active
29
+ * tool descriptions while the child session or operation is running.
30
+ */
31
+ export class ProgressWidget extends Container {
32
+ private message: string;
33
+ private progress: WidgetProgress = { turns: 0, toolUses: 0, activities: [] };
34
+ private loader: CancellableLoader;
35
+ private tui: { requestRender(): void };
36
+ private theme: Theme;
37
+
38
+ constructor(tui: { requestRender(): void }, theme: Theme, message: string) {
39
+ super();
40
+ this.tui = tui;
41
+ this.theme = theme;
42
+ this.message = message;
43
+ this.loader = new CancellableLoader(
44
+ tui as ConstructorParameters<typeof CancellableLoader>[0],
45
+ (text: string) => theme.fg("accent", text),
46
+ (text: string) => theme.fg("muted", text),
47
+ message,
48
+ );
49
+
50
+ this.renderContent();
51
+ }
52
+
53
+ /** AbortSignal that fires when the user presses Escape. */
54
+ get signal(): AbortSignal {
55
+ return this.loader.signal;
56
+ }
57
+
58
+ /** Callback invoked when the user presses Escape. */
59
+ set onAbort(fn: (() => void) | undefined) {
60
+ this.loader.onAbort = fn;
61
+ }
62
+
63
+ /** Delegate keyboard input to the loader. */
64
+ handleInput(data: string): void {
65
+ this.loader.handleInput(data);
66
+ }
67
+
68
+ /** Update progress state and request a re-render. */
69
+ updateProgress(progress: WidgetProgress): void {
70
+ this.progress = progress;
71
+ this.renderContent();
72
+ this.tui.requestRender();
73
+ }
74
+
75
+ /** Clean up the widget. */
76
+ dispose(): void {
77
+ this.loader.dispose();
78
+ }
79
+
80
+ private renderContent(): void {
81
+ this.clear();
82
+
83
+ const stats: string[] = [];
84
+ if (this.progress.turns > 0) stats.push(`⟳${this.progress.turns}`);
85
+ if (this.progress.toolUses > 0) stats.push(`${this.progress.toolUses} tool uses`);
86
+ if (this.progress.tokens) stats.push(`${formatTokens(this.progress.tokens.total)} tokens`);
87
+
88
+ const loaderMessage =
89
+ stats.length > 0 ? `${this.message} · ${stats.join(" · ")}` : this.message;
90
+
91
+ this.loader.setMessage(loaderMessage);
92
+ this.addChild(this.loader);
93
+
94
+ if (this.progress.activities.length > 0) {
95
+ this.addChild(
96
+ new Text(this.theme.fg("dim", ` ⎿ ${this.progress.activities.join(", ")}…`), 1, 0),
97
+ );
98
+ }
99
+ }
100
+ }
101
+
102
+ // ── Helpers ────────────────────────────────────────────────────────────────
103
+
104
+ function formatTokens(count: number): string {
105
+ if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
106
+ if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
107
+ return String(count);
108
+ }
@@ -8,9 +8,11 @@ import type {
8
8
  AgentToolResult,
9
9
  AgentToolUpdateCallback,
10
10
  ExtensionAPI,
11
+ ExtensionCommandContext,
11
12
  ExtensionContext,
12
13
  } from "@earendil-works/pi-coding-agent";
13
14
  import { type TSchema, Type } from "typebox";
15
+ import { ProgressWidget, type WidgetProgress } from "./progress-widget.ts";
14
16
 
15
17
  // ---------------------------------------------------------------------------
16
18
  // Types
@@ -114,3 +116,67 @@ export const SymbolParam = Type.String({
114
116
 
115
117
  /** Maximum results to return. */
116
118
  export const MaxResultsParam = Type.Number({ description: "Maximum results to return" });
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Progress widget runner
122
+ // ---------------------------------------------------------------------------
123
+
124
+ /**
125
+ * Run an async operation with a live TUI progress widget.
126
+ *
127
+ * Automatically manages:
128
+ * - The {@link ProgressWidget} lifecycle
129
+ * - `supi:working:start` / `supi:working:end` events for tab-spinner integration
130
+ * - Abort signal handling
131
+ * - Error catching (returns `null` on failure)
132
+ *
133
+ * Falls back to running without a widget when `ctx.hasUI` is false.
134
+ *
135
+ * @param pi - The extension API (for event emission).
136
+ * @param ctx - The command context (for UI access and hasUI check).
137
+ * @param title - The progress widget title.
138
+ * @param runner - Async function that receives (signal, onProgress).
139
+ * @returns The runner result, or `null` on cancel/error.
140
+ */
141
+ export async function runWithProgressWidget<T>(
142
+ pi: ExtensionAPI,
143
+ ctx: ExtensionCommandContext,
144
+ title: string,
145
+ runner: (signal: AbortSignal, onProgress: (p: WidgetProgress) => void) => Promise<T>,
146
+ ): Promise<T | null> {
147
+ if (!ctx.hasUI) {
148
+ // No UI — run without progress widget but still emit working events
149
+ pi.events.emit("supi:working:start", { source: "supi-core" });
150
+ try {
151
+ return await runner(new AbortController().signal, () => {});
152
+ } catch {
153
+ return null;
154
+ } finally {
155
+ pi.events.emit("supi:working:end", { source: "supi-core" });
156
+ }
157
+ }
158
+
159
+ return ctx.ui.custom<T | null>((tui, theme, _kb, done) => {
160
+ const widget = new ProgressWidget(tui, theme, title);
161
+ let finished = false;
162
+
163
+ const finish = (result: T | null) => {
164
+ if (finished) return;
165
+ finished = true;
166
+ pi.events.emit("supi:working:end", { source: "supi-core" });
167
+ widget.dispose();
168
+ done(result);
169
+ };
170
+
171
+ widget.onAbort = () => {
172
+ // Widget handles abort signal; runner resolves with cancel/error.
173
+ };
174
+
175
+ pi.events.emit("supi:working:start", { source: "supi-core" });
176
+ runner(widget.signal, (progress) => widget.updateProgress(progress))
177
+ .then((result) => finish(result))
178
+ .catch(() => finish(null));
179
+
180
+ return widget;
181
+ });
182
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-insights",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "description": "SuPi Insights extension — generate usage reports analyzing your PI sessions",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -24,14 +24,15 @@
24
24
  ],
25
25
  "dependencies": {
26
26
  "diff": "^9.0.0",
27
- "@mrclrchtr/supi-core": "1.10.0"
27
+ "@mrclrchtr/supi-core": "1.11.0"
28
28
  },
29
29
  "bundledDependencies": [
30
30
  "@mrclrchtr/supi-core"
31
31
  ],
32
32
  "peerDependencies": {
33
33
  "@earendil-works/pi-coding-agent": "*",
34
- "@earendil-works/pi-ai": "*"
34
+ "@earendil-works/pi-ai": "*",
35
+ "typebox": "*"
35
36
  },
36
37
  "peerDependenciesMeta": {
37
38
  "@earendil-works/pi-coding-agent": {
@@ -39,6 +40,9 @@
39
40
  },
40
41
  "@earendil-works/pi-ai": {
41
42
  "optional": true
43
+ },
44
+ "typebox": {
45
+ "optional": true
42
46
  }
43
47
  },
44
48
  "pi": {
package/src/extractor.ts CHANGED
@@ -2,8 +2,26 @@
2
2
 
3
3
  import { complete } from "@earendil-works/pi-ai";
4
4
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import { callWithJsonResponse } from "@mrclrchtr/supi-core/llm";
6
+ import { Type } from "typebox";
5
7
  import type { SessionFacets } from "./types.ts";
6
- import { withRetry } from "./utils.ts";
8
+
9
+ // ── TypeBox schema for facet extraction response ──────────────────────────
10
+
11
+ const FacetSchema = Type.Object({
12
+ underlyingGoal: Type.String(),
13
+ goalCategories: Type.Record(Type.String(), Type.Number()),
14
+ outcome: Type.String(),
15
+ userSatisfactionCounts: Type.Record(Type.String(), Type.Number()),
16
+ claudeHelpfulness: Type.String(),
17
+ sessionType: Type.String(),
18
+ frictionCounts: Type.Record(Type.String(), Type.Number()),
19
+ frictionDetail: Type.String(),
20
+ primarySuccess: Type.String(),
21
+ briefSummary: Type.String(),
22
+ });
23
+
24
+ // ── Prompts ───────────────────────────────────────────────────────────────
7
25
 
8
26
  const FACET_EXTRACTION_PROMPT = `Analyze this PI coding agent session and extract structured facets.
9
27
 
@@ -30,6 +48,20 @@ CRITICAL GUIDELINES:
30
48
 
31
49
  4. If very short or just warmup, use warmup_minimal for goal_category
32
50
 
51
+ RESPOND WITH ONLY A VALID JSON OBJECT matching this schema:
52
+ {
53
+ "underlyingGoal": "What the user fundamentally wanted to achieve",
54
+ "goalCategories": {"category_name": count, ...},
55
+ "outcome": "fully_achieved|mostly_achieved|partially_achieved|not_achieved|unclear_from_transcript",
56
+ "userSatisfactionCounts": {"level": count, ...},
57
+ "claudeHelpfulness": "unhelpful|slightly_helpful|moderately_helpful|very_helpful|essential",
58
+ "sessionType": "single_task|multi_task|iterative_refinement|exploration|quick_question",
59
+ "frictionCounts": {"friction_type": count, ...},
60
+ "frictionDetail": "One sentence describing friction or empty",
61
+ "primarySuccess": "none|fast_accurate_search|correct_code_edits|good_explanations|proactive_help|multi_file_changes|good_debugging",
62
+ "briefSummary": "One sentence: what user wanted and whether they got it"
63
+ }
64
+
33
65
  SESSION:
34
66
  `;
35
67
 
@@ -44,86 +76,34 @@ Keep it concise - 3-5 sentences. Preserve specific details like file names, erro
44
76
  TRANSCRIPT CHUNK:
45
77
  `;
46
78
 
79
+ // ── Public API ─────────────────────────────────────────────────────────────
80
+
47
81
  export async function extractFacets(
48
82
  transcript: string,
49
83
  sessionId: string,
50
84
  ctx: ExtensionContext,
51
85
  ): Promise<SessionFacets | null> {
52
- // Resolve model: prefer active model, fall back to any available configured model
53
- const model = ctx.model ?? ctx.modelRegistry.getAvailable()[0];
54
- if (!model) return null;
55
-
56
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
57
- if (!auth.ok || !auth.apiKey) return null;
58
-
59
86
  // For long transcripts, summarize in chunks first
60
87
  const processedTranscript =
61
88
  transcript.length > 30000 ? await summarizeTranscript(transcript, ctx) : transcript;
62
89
 
63
- const jsonPrompt = `${FACET_EXTRACTION_PROMPT}${processedTranscript}
64
-
65
- RESPOND WITH ONLY A VALID JSON OBJECT matching this schema:
66
- {
67
- "underlyingGoal": "What the user fundamentally wanted to achieve",
68
- "goalCategories": {"category_name": count, ...},
69
- "outcome": "fully_achieved|mostly_achieved|partially_achieved|not_achieved|unclear_from_transcript",
70
- "userSatisfactionCounts": {"level": count, ...},
71
- "claudeHelpfulness": "unhelpful|slightly_helpful|moderately_helpful|very_helpful|essential",
72
- "sessionType": "single_task|multi_task|iterative_refinement|exploration|quick_question",
73
- "frictionCounts": {"friction_type": count, ...},
74
- "frictionDetail": "One sentence describing friction or empty",
75
- "primarySuccess": "none|fast_accurate_search|correct_code_edits|good_explanations|proactive_help|multi_file_changes|good_debugging",
76
- "briefSummary": "One sentence: what user wanted and whether they got it"
77
- }`;
78
-
79
- // Attempt the LLM call with up to 2 retries
80
- const response = await withRetry(
81
- async () => {
82
- const res = await complete(
83
- model,
84
- {
85
- systemPrompt: "",
86
- messages: [
87
- {
88
- role: "user",
89
- content: [{ type: "text", text: jsonPrompt }],
90
- timestamp: Date.now(),
91
- },
92
- ],
93
- },
94
- {
95
- apiKey: auth.apiKey,
96
- headers: auth.headers,
97
- signal: ctx.signal,
98
- maxTokens: 4096,
99
- },
100
- );
101
- return res;
90
+ const result = await callWithJsonResponse(
91
+ ctx,
92
+ {
93
+ prompt: `${FACET_EXTRACTION_PROMPT}${processedTranscript}`,
94
+ maxTokens: 4096,
95
+ retries: 2,
102
96
  },
103
- 2,
104
- 1000,
97
+ FacetSchema,
105
98
  );
106
99
 
107
- if (!response) return null;
108
-
109
- try {
110
- const text = response.content
111
- .filter((c): c is { type: "text"; text: string } => c.type === "text")
112
- .map((c) => c.text)
113
- .join("");
114
-
115
- const jsonMatch = text.match(/\{[\s\S]*\}/);
116
- if (!jsonMatch) return null;
117
-
118
- const parsed = JSON.parse(jsonMatch[0]) as unknown;
119
- if (!isValidSessionFacets(parsed)) return null;
100
+ if (!result) return null;
120
101
 
121
- return { ...parsed, sessionId };
122
- } catch {
123
- return null;
124
- }
102
+ return { ...result.parsed, sessionId } as unknown as SessionFacets;
125
103
  }
126
104
 
105
+ // ── Transcript chunking ───────────────────────────────────────────────────
106
+
127
107
  async function summarizeTranscript(transcript: string, ctx: ExtensionContext): Promise<string> {
128
108
  const model = ctx.model ?? ctx.modelRegistry.getAvailable()[0];
129
109
  if (!model) return transcript.slice(0, 30000);
@@ -171,19 +151,3 @@ async function summarizeTranscript(transcript: string, ctx: ExtensionContext): P
171
151
 
172
152
  return summaries.join("\n\n---\n\n");
173
153
  }
174
-
175
- function isValidSessionFacets(obj: unknown): obj is Omit<SessionFacets, "sessionId"> {
176
- if (!obj || typeof obj !== "object") return false;
177
- const o = obj as Record<string, unknown>;
178
- return (
179
- typeof o.underlyingGoal === "string" &&
180
- typeof o.outcome === "string" &&
181
- typeof o.briefSummary === "string" &&
182
- o.goalCategories !== null &&
183
- typeof o.goalCategories === "object" &&
184
- o.userSatisfactionCounts !== null &&
185
- typeof o.userSatisfactionCounts === "object" &&
186
- o.frictionCounts !== null &&
187
- typeof o.frictionCounts === "object"
188
- );
189
- }
package/src/generator.ts CHANGED
@@ -1,13 +1,105 @@
1
1
  // Insight generator — produce narrative insights from aggregated data via LLM calls.
2
2
 
3
- import { complete } from "@earendil-works/pi-ai";
4
3
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { callWithJsonResponse } from "@mrclrchtr/supi-core/llm";
5
+ import { Type } from "typebox";
5
6
  import type { AggregatedData, InsightResults, SessionFacets } from "./types.ts";
6
- import { withRetry } from "./utils.ts";
7
+
8
+ // ── TypeBox schemas for each insight section ───────────────────────────────
9
+
10
+ const ProjectAreasSchema = Type.Object({
11
+ areas: Type.Array(
12
+ Type.Object({
13
+ name: Type.String(),
14
+ sessionCount: Type.Number(),
15
+ description: Type.String(),
16
+ }),
17
+ ),
18
+ });
19
+
20
+ const InteractionStyleSchema = Type.Object({
21
+ narrative: Type.String(),
22
+ keyPattern: Type.String(),
23
+ });
24
+
25
+ const WhatWorksSchema = Type.Object({
26
+ intro: Type.String(),
27
+ impressiveWorkflows: Type.Array(
28
+ Type.Object({
29
+ title: Type.String(),
30
+ description: Type.String(),
31
+ }),
32
+ ),
33
+ });
34
+
35
+ const FrictionAnalysisSchema = Type.Object({
36
+ intro: Type.String(),
37
+ categories: Type.Array(
38
+ Type.Object({
39
+ category: Type.String(),
40
+ description: Type.String(),
41
+ examples: Type.Array(Type.String()),
42
+ }),
43
+ ),
44
+ });
45
+
46
+ const SuggestionsSchema = Type.Object({
47
+ claudeMdAdditions: Type.Array(
48
+ Type.Object({
49
+ addition: Type.String(),
50
+ why: Type.String(),
51
+ promptScaffold: Type.String(),
52
+ }),
53
+ ),
54
+ featuresToTry: Type.Array(
55
+ Type.Object({
56
+ feature: Type.String(),
57
+ oneLiner: Type.String(),
58
+ whyForYou: Type.String(),
59
+ exampleCode: Type.String(),
60
+ }),
61
+ ),
62
+ usagePatterns: Type.Array(
63
+ Type.Object({
64
+ title: Type.String(),
65
+ suggestion: Type.String(),
66
+ detail: Type.String(),
67
+ copyablePrompt: Type.String(),
68
+ }),
69
+ ),
70
+ });
71
+
72
+ const OnTheHorizonSchema = Type.Object({
73
+ intro: Type.String(),
74
+ opportunities: Type.Array(
75
+ Type.Object({
76
+ title: Type.String(),
77
+ whatsPossible: Type.String(),
78
+ howToTry: Type.String(),
79
+ copyablePrompt: Type.String(),
80
+ }),
81
+ ),
82
+ });
83
+
84
+ const FunEndingSchema = Type.Object({
85
+ headline: Type.String(),
86
+ detail: Type.String(),
87
+ });
88
+
89
+ const AtAGlanceSchema = Type.Object({
90
+ whatsWorking: Type.String(),
91
+ whatsHindering: Type.String(),
92
+ quickWins: Type.String(),
93
+ ambitiousWorkflows: Type.String(),
94
+ });
95
+
96
+ // ── Section definitions ──────────────────────────────────────────────────
7
97
 
8
98
  type InsightSection = {
9
99
  name: keyof InsightResults;
10
100
  prompt: string;
101
+ // biome-ignore lint/suspicious/noExplicitAny: TypeBox schema union
102
+ schema: ReturnType<typeof Type.Object<any>>;
11
103
  maxTokens: number;
12
104
  };
13
105
 
@@ -24,6 +116,7 @@ RESPOND WITH ONLY A VALID JSON OBJECT:
24
116
  }
25
117
 
26
118
  Include 4-5 areas.`,
119
+ schema: ProjectAreasSchema,
27
120
  maxTokens: 4096,
28
121
  },
29
122
  {
@@ -35,6 +128,7 @@ RESPOND WITH ONLY A VALID JSON OBJECT:
35
128
  "narrative": "2-3 paragraphs analyzing HOW the user interacts with PI. Use second person 'you'. Describe patterns: iterate quickly vs detailed upfront specs? Interrupt often or let the agent run? Include specific examples. Use **bold** for key insights.",
36
129
  "keyPattern": "One sentence summary of most distinctive interaction style"
37
130
  }`,
131
+ schema: InteractionStyleSchema,
38
132
  maxTokens: 4096,
39
133
  },
40
134
  {
@@ -50,6 +144,7 @@ RESPOND WITH ONLY A VALID JSON OBJECT:
50
144
  }
51
145
 
52
146
  Include 3 impressive workflows.`,
147
+ schema: WhatWorksSchema,
53
148
  maxTokens: 4096,
54
149
  },
55
150
  {
@@ -65,6 +160,7 @@ RESPOND WITH ONLY A VALID JSON OBJECT:
65
160
  }
66
161
 
67
162
  Include 3 friction categories with 2 examples each.`,
163
+ schema: FrictionAnalysisSchema,
68
164
  maxTokens: 4096,
69
165
  },
70
166
  {
@@ -85,6 +181,7 @@ RESPOND WITH ONLY A VALID JSON OBJECT:
85
181
  }
86
182
 
87
183
  IMPORTANT: PRIORITIZE instructions that appear MULTIPLE TIMES in the user data.`,
184
+ schema: SuggestionsSchema,
88
185
  maxTokens: 4096,
89
186
  },
90
187
  {
@@ -100,6 +197,7 @@ RESPOND WITH ONLY A VALID JSON OBJECT:
100
197
  }
101
198
 
102
199
  Include 3 opportunities. Think BIG - autonomous workflows, parallel agents, iterating against tests.`,
200
+ schema: OnTheHorizonSchema,
103
201
  maxTokens: 4096,
104
202
  },
105
203
  {
@@ -113,10 +211,13 @@ RESPOND WITH ONLY A VALID JSON OBJECT:
113
211
  }
114
212
 
115
213
  Find something genuinely interesting or amusing from the session summaries.`,
214
+ schema: FunEndingSchema,
116
215
  maxTokens: 2048,
117
216
  },
118
217
  ];
119
218
 
219
+ // ── Public API ─────────────────────────────────────────────────────────────
220
+
120
221
  export async function generateInsights(
121
222
  data: AggregatedData,
122
223
  facets: Map<string, SessionFacets>,
@@ -145,70 +246,25 @@ export async function generateInsights(
145
246
  return insights;
146
247
  }
147
248
 
148
- async function resolveModel(ctx: ExtensionContext): Promise<{
149
- model: NonNullable<ExtensionContext["model"]>;
150
- apiKey: string;
151
- headers: Record<string, string>;
152
- } | null> {
153
- const model = ctx.model ?? ctx.modelRegistry.getAvailable()[0] ?? null;
154
- if (!model) return null;
155
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
156
- if (!auth.ok || !auth.apiKey) return null;
157
- return { model, apiKey: auth.apiKey, headers: auth.headers ?? {} };
158
- }
249
+ // ── Section generators ────────────────────────────────────────────────────
159
250
 
160
251
  async function generateSectionInsight(
161
252
  section: InsightSection,
162
253
  dataContext: string,
163
254
  ctx: ExtensionContext,
164
255
  ): Promise<{ name: keyof InsightResults; result: unknown }> {
165
- const resolved = await resolveModel(ctx);
166
- if (!resolved) return { name: section.name, result: null };
167
-
168
- const { model, apiKey, headers } = resolved;
169
-
170
- const response = await withRetry(
171
- async () => {
172
- const res = await complete(
173
- model,
174
- {
175
- systemPrompt: "",
176
- messages: [
177
- {
178
- role: "user",
179
- content: [{ type: "text", text: `${section.prompt}\n\nDATA:\n${dataContext}` }],
180
- timestamp: Date.now(),
181
- },
182
- ],
183
- },
184
- {
185
- apiKey,
186
- headers,
187
- signal: ctx.signal,
188
- maxTokens: section.maxTokens,
189
- },
190
- );
191
- return res;
256
+ const result = await callWithJsonResponse(
257
+ ctx,
258
+ {
259
+ prompt: section.prompt,
260
+ dataContext,
261
+ maxTokens: section.maxTokens,
262
+ retries: 2,
192
263
  },
193
- 2,
194
- 1000,
264
+ section.schema,
195
265
  );
196
266
 
197
- if (!response) return { name: section.name, result: null };
198
-
199
- const text = response.content
200
- .filter((c): c is { type: "text"; text: string } => c.type === "text")
201
- .map((c) => c.text)
202
- .join("");
203
-
204
- const jsonMatch = text.match(/\{[\s\S]*\}/);
205
- if (!jsonMatch) return { name: section.name, result: null };
206
-
207
- try {
208
- return { name: section.name, result: JSON.parse(jsonMatch[0]) };
209
- } catch {
210
- return { name: section.name, result: null };
211
- }
267
+ return { name: section.name, result: result?.parsed ?? null };
212
268
  }
213
269
 
214
270
  async function generateAtAGlance(
@@ -302,55 +358,21 @@ ${featuresText}
302
358
  ## On the Horizon
303
359
  ${horizonText}`;
304
360
 
305
- const resolved = await resolveModel(ctx);
306
- if (!resolved) return null;
307
-
308
- const { model, apiKey, headers } = resolved;
309
-
310
- const response = await withRetry(
311
- async () => {
312
- const res = await complete(
313
- model,
314
- {
315
- systemPrompt: "",
316
- messages: [
317
- {
318
- role: "user",
319
- content: [{ type: "text", text: prompt }],
320
- timestamp: Date.now(),
321
- },
322
- ],
323
- },
324
- {
325
- apiKey,
326
- headers,
327
- signal: ctx.signal,
328
- maxTokens: 4096,
329
- },
330
- );
331
- return res;
361
+ const result = await callWithJsonResponse(
362
+ ctx,
363
+ {
364
+ prompt,
365
+ maxTokens: 4096,
366
+ retries: 2,
332
367
  },
333
- 2,
334
- 1000,
368
+ AtAGlanceSchema,
335
369
  );
336
370
 
337
- if (!response) return null;
338
-
339
- const text = response.content
340
- .filter((c): c is { type: "text"; text: string } => c.type === "text")
341
- .map((c) => c.text)
342
- .join("");
343
-
344
- const jsonMatch = text.match(/\{[\s\S]*\}/);
345
- if (!jsonMatch) return null;
346
-
347
- try {
348
- return JSON.parse(jsonMatch[0]);
349
- } catch {
350
- return null;
351
- }
371
+ return result?.parsed ?? null;
352
372
  }
353
373
 
374
+ // ── Data context ──────────────────────────────────────────────────────────
375
+
354
376
  function buildDataContext(data: AggregatedData, facets: Map<string, SessionFacets>): string {
355
377
  const facetSummaries = Array.from(facets.values())
356
378
  .slice(0, 50)
package/src/insights.ts CHANGED
@@ -91,26 +91,23 @@ export default function insightsExtension(pi: ExtensionAPI) {
91
91
  label: "Enable insights",
92
92
  currentValue: settings.enabled ? "on" : "off",
93
93
  values: ["on", "off"],
94
+ configType: "boolean" as const,
94
95
  },
95
96
  {
96
97
  id: "maxSessions",
97
98
  label: "Max sessions to analyze",
98
99
  currentValue: String(settings.maxSessions),
99
100
  values: ["50", "100", "200", "500"],
101
+ configType: "number" as const,
100
102
  },
101
103
  {
102
104
  id: "maxFacets",
103
105
  label: "Max facet extractions",
104
106
  currentValue: String(settings.maxFacets),
105
107
  values: ["20", "50", "100"],
108
+ configType: "number" as const,
106
109
  },
107
110
  ],
108
- // biome-ignore lint/complexity/useMaxParams: registerConfigSettings defines this callback shape.
109
- persistChange: (_scope, _cwd, settingId, value, helpers) => {
110
- const numSettings = new Set(["maxSessions", "maxFacets"]);
111
- const finalValue = numSettings.has(settingId) ? Number(value) : value === "on";
112
- helpers.set(settingId, finalValue);
113
- },
114
111
  });
115
112
 
116
113
  // ── /supi-insights command ──────────────────────────────────
@@ -128,7 +125,9 @@ export default function insightsExtension(pi: ExtensionAPI) {
128
125
  ctx.ui.setWorkingMessage("Analyzing sessions...");
129
126
 
130
127
  try {
128
+ pi.events.emit("supi:working:start", { source: "supi-insights" });
131
129
  const report = await generateReport(ctx, config);
130
+ pi.events.emit("supi:working:end", { source: "supi-insights" });
132
131
  ctx.ui.setWorkingMessage();
133
132
 
134
133
  if (!report) {
@@ -183,6 +182,7 @@ export default function insightsExtension(pi: ExtensionAPI) {
183
182
 
184
183
  ctx.ui.notify(`Insights report saved: ${htmlPath}`, "info");
185
184
  } catch (err) {
185
+ pi.events.emit("supi:working:end", { source: "supi-insights" });
186
186
  ctx.ui.setWorkingMessage();
187
187
  const message = err instanceof Error ? err.message : String(err);
188
188
  ctx.ui.notify(`Insights generation failed: ${message}`, "error");
package/src/utils.ts CHANGED
@@ -2,29 +2,6 @@
2
2
 
3
3
  import { extname } from "node:path";
4
4
 
5
- // ── Retry helper for LLM calls ────────────────────────────
6
-
7
- /**
8
- * Attempt an async operation with retries and exponential backoff.
9
- * Returns the result on success, or null if all attempts fail.
10
- */
11
- export async function withRetry<T>(
12
- fn: () => Promise<T>,
13
- retries = 2,
14
- baseDelayMs = 1000,
15
- ): Promise<T | null> {
16
- for (let attempt = 0; attempt <= retries; attempt++) {
17
- try {
18
- return await fn();
19
- } catch (_err) {
20
- if (attempt < retries) {
21
- await new Promise((r) => setTimeout(r, baseDelayMs * 2 ** attempt));
22
- }
23
- }
24
- }
25
- return null;
26
- }
27
-
28
5
  const EXTENSION_TO_LANGUAGE: Record<string, string> = {
29
6
  ".ts": "TypeScript",
30
7
  ".tsx": "TypeScript",