@mrclrchtr/supi-prompt-suggestions 2.1.0 → 2.2.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.
@@ -19,8 +19,8 @@ import {
19
19
  GENERATION_TIMEOUT_MS,
20
20
  type SuggestionClientOutput,
21
21
  } from "./client.ts";
22
- import { resolveSuggestionAuth } from "./model-resolution.ts";
23
- import { normalizeSuggestion } from "./normalize.ts";
22
+ import { type ResolvedAuth, resolveSuggestionAuth } from "./model-resolution.ts";
23
+ import { normalizeSuggestionDetailed } from "./normalize.ts";
24
24
 
25
25
  // ── Types ──────────────────────────────────────────────────────────────────
26
26
 
@@ -141,7 +141,7 @@ export class SuggestionGenerator {
141
141
  message: authResult.message,
142
142
  cwd: ctx.cwd,
143
143
  });
144
- callbacks.onStatus({ kind: "error", message: authResult.message });
144
+ callbacks.onStatus({ kind: "idle" });
145
145
  return;
146
146
  }
147
147
 
@@ -149,23 +149,10 @@ export class SuggestionGenerator {
149
149
 
150
150
  if (id !== this.generationId || abort.signal.aborted) return;
151
151
 
152
- const { signal: combinedSignal, cleanup: cleanupTimeout } = combineAbortSignals(
153
- abort,
154
- GENERATION_TIMEOUT_MS,
155
- );
156
-
157
- try {
158
- const response = await callSuggestionModel({
159
- model: auth.model,
160
- auth: { apiKey: auth.apiKey, headers: auth.headers },
161
- tail,
162
- signal: combinedSignal,
163
- });
152
+ const response = await this.#callModelWithTimeout(opts, auth);
153
+ if (!response) return;
164
154
 
165
- this.#handleResponse(response, id, ctx.cwd, callbacks, opts.modelId);
166
- } finally {
167
- cleanupTimeout();
168
- }
155
+ this.#handleResponse(response, id, ctx.cwd, callbacks, opts.modelId);
169
156
  } catch (err) {
170
157
  if (id !== this.generationId) return;
171
158
  const message = err instanceof Error ? err.message : String(err);
@@ -185,6 +172,62 @@ export class SuggestionGenerator {
185
172
  }
186
173
  }
187
174
 
175
+ async #callModelWithTimeout(
176
+ opts: RunOptions,
177
+ auth: ResolvedAuth,
178
+ ): Promise<SuggestionClientOutput | null> {
179
+ const { signal: combinedSignal, cleanup: cleanupTimeout } = combineAbortSignals(
180
+ opts.abort,
181
+ GENERATION_TIMEOUT_MS,
182
+ );
183
+
184
+ try {
185
+ return await callSuggestionModel({
186
+ model: auth.model,
187
+ auth: { apiKey: auth.apiKey, headers: auth.headers, env: auth.env },
188
+ tail: opts.tail,
189
+ signal: combinedSignal,
190
+ });
191
+ } catch (err) {
192
+ if (opts.id !== this.generationId) return null;
193
+ if (opts.abort.signal.aborted) {
194
+ this.#recordGenerationAbort(opts);
195
+ opts.callbacks.onStatus({ kind: "idle" });
196
+ return null;
197
+ }
198
+ if (combinedSignal.aborted) {
199
+ this.#recordGenerationTimeout(opts);
200
+ opts.callbacks.onStatus({ kind: "idle" });
201
+ return null;
202
+ }
203
+ throw err;
204
+ } finally {
205
+ cleanupTimeout();
206
+ }
207
+ }
208
+
209
+ #recordGenerationAbort(opts: RunOptions): void {
210
+ recordDebugEvent({
211
+ source: "prompt-suggestions",
212
+ level: "debug",
213
+ category: "generation.aborted",
214
+ message: "Prompt suggestion generation aborted",
215
+ cwd: opts.ctx.cwd,
216
+ data: { modelId: opts.modelId },
217
+ });
218
+ }
219
+
220
+ #recordGenerationTimeout(opts: RunOptions): void {
221
+ recordDebugEvent({
222
+ source: "prompt-suggestions",
223
+ level: "debug",
224
+ category: "generation.timeout",
225
+ message: "Prompt suggestion generation timed out",
226
+ cwd: opts.ctx.cwd,
227
+ data: { modelId: opts.modelId, timeoutMs: GENERATION_TIMEOUT_MS },
228
+ });
229
+ }
230
+
188
231
  // biome-ignore lint/complexity/useMaxParams: private method, params are orthogonal
189
232
  #handleResponse(
190
233
  response: SuggestionClientOutput,
@@ -208,13 +251,13 @@ export class SuggestionGenerator {
208
251
  return;
209
252
  }
210
253
 
211
- const normalized = normalizeSuggestion(response.text);
254
+ const normalized = normalizeSuggestionDetailed(response.text);
212
255
  if (!normalized) {
213
256
  recordDebugEvent({
214
257
  source: "prompt-suggestions",
215
258
  level: "debug",
216
259
  category: "generation.rejected",
217
- message: `Prompt suggestion rejected: empty after normalization (${response.text.length} raw chars)`,
260
+ message: "Prompt suggestion rejected after normalization",
218
261
  cwd,
219
262
  data: { rawLength: response.text.length },
220
263
  });
@@ -226,10 +269,17 @@ export class SuggestionGenerator {
226
269
  source: "prompt-suggestions",
227
270
  level: "debug",
228
271
  category: "generation.done",
229
- message: `Prompt suggestion ready: "${normalized}"`,
272
+ message: "Prompt suggestion ready",
230
273
  cwd,
231
- data: { suggestion: normalized, length: normalized.length },
274
+ data: {
275
+ modelId,
276
+ rawLength: response.text.length,
277
+ length: normalized.text.length,
278
+ graphemeCount: normalized.graphemeCount,
279
+ originalGraphemeCount: normalized.originalGraphemeCount,
280
+ wasSafetyCapped: normalized.wasSafetyCapped,
281
+ },
232
282
  });
233
- callbacks.onStatus({ kind: "ready", suggestion: normalized });
283
+ callbacks.onStatus({ kind: "ready", suggestion: normalized.text });
234
284
  }
235
285
  }
@@ -18,6 +18,7 @@ export interface ResolvedAuth {
18
18
  model: any;
19
19
  apiKey: string;
20
20
  headers?: Record<string, string>;
21
+ env?: Record<string, string>;
21
22
  }
22
23
 
23
24
  export interface AuthResolutionError {
@@ -51,7 +52,7 @@ export async function resolveSuggestionAuth(
51
52
  }
52
53
 
53
54
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(match.model);
54
- if (!auth.ok) {
55
+ if (!auth.ok || !hasConfiguredAuth(ctx, match.model, auth.apiKey)) {
55
56
  return {
56
57
  kind: "error",
57
58
  message: `No API key configured for ${match.canonicalId}`,
@@ -60,7 +61,7 @@ export async function resolveSuggestionAuth(
60
61
 
61
62
  return {
62
63
  kind: "ok",
63
- auth: { model: match.model, apiKey: auth.apiKey ?? "", headers: auth.headers },
64
+ auth: { model: match.model, apiKey: auth.apiKey ?? "", headers: auth.headers, env: auth.env },
64
65
  };
65
66
  }
66
67
 
@@ -70,3 +71,13 @@ function findSuggestionModel(ctx: ExtensionContext, modelId: string) {
70
71
  const models = getSelectableModels(ctx);
71
72
  return models.find((m) => m.canonicalId === modelId);
72
73
  }
74
+
75
+ function hasConfiguredAuth(
76
+ ctx: ExtensionContext,
77
+ // biome-ignore lint/suspicious/noExplicitAny: Model<any> is pi's canonical type
78
+ model: any,
79
+ apiKey?: string,
80
+ ): boolean {
81
+ if (apiKey) return true;
82
+ return ctx.modelRegistry.hasConfiguredAuth(model);
83
+ }
@@ -2,23 +2,33 @@
2
2
  * Prompt suggestion normalization.
3
3
  *
4
4
  * Trims, strips matching outer quotes, detects the NO_SUGGESTION sentinel,
5
- * collapses whitespace, rejects empty results, truncates at 240 chars.
5
+ * collapses whitespace, rejects empty results, and applies a high grapheme-safe
6
+ * safety cap to guard against runaway model output.
6
7
  *
7
8
  * @module
8
9
  */
9
10
 
10
- const MAX_SUGGESTION_LENGTH = 240;
11
+ const MAX_SUGGESTION_GRAPHEMES = 2_000;
11
12
 
12
13
  /** Sentinels that indicate the model found no useful suggestion. */
13
14
  const NO_SUGGESTION_SENTINELS = ["NO_SUGGESTION", "NO SUGGESTION"];
14
15
 
16
+ export interface NormalizedSuggestion {
17
+ /** Full normalized suggestion text, possibly safety-capped. */
18
+ text: string;
19
+ /** Whether the high safety cap was applied. */
20
+ wasSafetyCapped: boolean;
21
+ /** Grapheme count after normalization and before safety capping. */
22
+ originalGraphemeCount: number;
23
+ /** Grapheme count of the returned text. */
24
+ graphemeCount: number;
25
+ }
26
+
15
27
  /**
16
- * Normalize a raw suggestion string: trim, strip outer quotes,
17
- * detect NO_SUGGESTION sentinel, collapse whitespace, reject empty,
18
- * truncate at 240 chars.
19
- * Exported for testing.
28
+ * Normalize a raw suggestion string and return metadata about safety capping.
29
+ * Exported for orchestration and testing.
20
30
  */
21
- export function normalizeSuggestion(text: string): string | null {
31
+ export function normalizeSuggestionDetailed(text: string): NormalizedSuggestion | null {
22
32
  let normalized = text.trim();
23
33
 
24
34
  // Strip matching outer quotes
@@ -35,16 +45,42 @@ export function normalizeSuggestion(text: string): string | null {
35
45
  return null;
36
46
  }
37
47
 
38
- // Collapse internal newlines to spaces
48
+ // Collapse all whitespace to spaces.
39
49
  normalized = normalized.replace(/\s+/g, " ").trim();
40
50
 
41
- // Reject empty (also catches pure-sentinel after whitespace collapse)
42
51
  if (!normalized) return null;
43
52
 
44
- // Truncate overlong suggestions
45
- if (normalized.length > MAX_SUGGESTION_LENGTH) {
46
- normalized = normalized.slice(0, MAX_SUGGESTION_LENGTH);
53
+ const graphemes = splitGraphemes(normalized);
54
+ const wasSafetyCapped = graphemes.length > MAX_SUGGESTION_GRAPHEMES;
55
+ if (!wasSafetyCapped) {
56
+ return {
57
+ text: normalized,
58
+ wasSafetyCapped: false,
59
+ originalGraphemeCount: graphemes.length,
60
+ graphemeCount: graphemes.length,
61
+ };
47
62
  }
48
63
 
49
- return normalized;
64
+ const cappedText = graphemes.slice(0, MAX_SUGGESTION_GRAPHEMES).join("").trimEnd();
65
+ const cappedGraphemeCount = splitGraphemes(cappedText).length;
66
+ return {
67
+ text: cappedText,
68
+ wasSafetyCapped: true,
69
+ originalGraphemeCount: graphemes.length,
70
+ graphemeCount: cappedGraphemeCount,
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Normalize a raw suggestion string: trim, strip outer quotes, detect
76
+ * NO_SUGGESTION, collapse whitespace, reject empty, and apply the high safety
77
+ * cap. Exported for existing callers and tests that only need the text.
78
+ */
79
+ export function normalizeSuggestion(text: string): string | null {
80
+ return normalizeSuggestionDetailed(text)?.text ?? null;
81
+ }
82
+
83
+ function splitGraphemes(text: string): string[] {
84
+ const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
85
+ return Array.from(segmenter.segment(text), (segment) => segment.segment);
50
86
  }
package/src/session.ts CHANGED
@@ -14,10 +14,16 @@ import type { GenerationStatus, SuggestionGenerator } from "./generation/generat
14
14
 
15
15
  // ── Types ──────────────────────────────────────────────────────────────────
16
16
 
17
+ type SessionTextContent = string | { type: string; text?: string }[] | undefined;
18
+
17
19
  /** Narrow shape for session message entries used in history seeding. */
18
20
  interface SessionMessageEntry {
19
21
  type: "message";
20
- message?: { role?: string; content?: { type: string; text?: string }[] };
22
+ message?: {
23
+ role?: string;
24
+ stopReason?: string;
25
+ content?: SessionTextContent;
26
+ };
21
27
  }
22
28
 
23
29
  // ── SessionLifecycle ───────────────────────────────────────────────────────
@@ -41,12 +47,18 @@ export class SessionLifecycle {
41
47
  /** Install the ghost editor wrapper, seed UP-arrow history, and recreate the spinner. */
42
48
  onStart(ctx: ExtensionContext): void {
43
49
  this.statusSpinner?.stop();
50
+ this.statusSpinner = null;
51
+ this.ghostEditor = null;
52
+ if (ctx.mode !== "tui") return;
53
+
44
54
  this.statusSpinner = new StatusSpinner(ctx, "supi-prompt-suggestions");
45
55
  this.installEditor(ctx);
46
56
  }
47
57
 
48
58
  /** Extract the last assistant message and fire suggestion generation. */
49
59
  onAgentEnd(event: AgentEndEvent, ctx: ExtensionContext): void {
60
+ if (ctx.mode !== "tui") return;
61
+
50
62
  const lastAssistant = extractLastAssistantText(event);
51
63
  if (!lastAssistant) {
52
64
  this.statusSpinner?.stop();
@@ -98,7 +110,6 @@ export class SessionLifecycle {
98
110
  case "error":
99
111
  this.generationInFlight = false;
100
112
  this.statusSpinner?.stop();
101
- ctx.ui.setStatus("supi-prompt-suggestions", `suggestion error: ${status.message}`);
102
113
  break;
103
114
  case "idle":
104
115
  this.generationInFlight = false;
@@ -136,8 +147,9 @@ export class SessionLifecycle {
136
147
  * The Editor base class stores history in-memory via `addToHistory()` when
137
148
  * messages are submitted. Since `setEditorComponent` replaces the editor
138
149
  * with a fresh instance on every `session_start` (including `/reload`), the
139
- * in-memory history is lost. Reading from `sessionManager.getEntries()`
140
- * repopulates it from the persistent session file so history survives reloads.
150
+ * in-memory history is lost. Reading from `sessionManager.getBranch()`
151
+ * repopulates it from the current conversation path so history survives
152
+ * reloads without pulling prompts from abandoned branches.
141
153
  *
142
154
  * Cross-module save/restore (capturing the old editor's history in
143
155
  * `session_shutdown` and restoring in `session_start`) does not work here
@@ -145,16 +157,12 @@ export class SessionLifecycle {
145
157
  * closure variables are destroyed before the new module can read them.
146
158
  */
147
159
  private seedHistoryFromSession(editor: GhostTextEditor, ctx: ExtensionContext): void {
148
- const entries = ctx.sessionManager.getEntries();
160
+ const entries = ctx.sessionManager.getBranch();
149
161
  for (const entry of entries) {
150
162
  if (entry.type !== "message") continue;
151
163
  const msg = (entry as SessionMessageEntry).message;
152
164
  if (msg?.role !== "user") continue;
153
- const text = msg.content
154
- ?.filter((c) => c.type === "text")
155
- .map((c) => c.text ?? "")
156
- .join("")
157
- .trim();
165
+ const text = extractTextContent(msg.content).trim();
158
166
  if (text) editor.addToHistory(text);
159
167
  }
160
168
  }
@@ -178,13 +186,20 @@ function extractLastAssistantText(event: AgentEndEvent): string | null {
178
186
  for (let i = messages.length - 1; i >= 0; i--) {
179
187
  const msg = messages[i];
180
188
  if (msg?.role === "assistant") {
181
- const textContent = msg.content
182
- ?.filter((c: { type: string; text?: string }) => c.type === "text")
183
- .map((c: { type: string; text?: string }) => c.text)
184
- .join("")
185
- .trim();
189
+ if (msg.stopReason !== "stop") return null;
190
+ const textContent = extractTextContent(msg.content).trim();
186
191
  return textContent || null;
187
192
  }
188
193
  }
189
194
  return null;
190
195
  }
196
+
197
+ function extractTextContent(content: SessionTextContent): string {
198
+ if (typeof content === "string") return content;
199
+ if (!Array.isArray(content)) return "";
200
+
201
+ return content
202
+ .filter((c) => c.type === "text")
203
+ .map((c) => c.text ?? "")
204
+ .join("\n");
205
+ }
@@ -1,207 +0,0 @@
1
- // Config-aware settings contribution helper for SuPi packages.
2
- //
3
- // Registers config-backed settings sections through PI's shared event bus so
4
- // /supi-settings can collect contributions from all loaded extensions without
5
- // relying on a shared supi-core module instance.
6
-
7
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
8
- import type { SettingItem } from "@earendil-works/pi-tui";
9
- import {
10
- isSettingsContributionCollector,
11
- type SettingsScope,
12
- type SettingsSection,
13
- SUPI_SETTINGS_COLLECT_EVENT,
14
- } from "../settings/settings-registry.ts";
15
- import { loadSupiConfigForScope, removeSupiConfigKey, writeSupiConfig } from "./config.ts";
16
-
17
- // ── Types ──────────────────────────────────────────────────────────────────
18
-
19
- /**
20
- * Supported config value types for declarative persistChange.
21
- *
22
- * - `"boolean"`: maps "on" → true, "off" → false
23
- * - `"number"`: parses integer via Number.parseInt, falls back to unset on invalid
24
- * - `"stringList"`: splits on comma, trims whitespace, unsets on empty
25
- */
26
- export type ConfigSettingType = "boolean" | "number" | "stringList";
27
-
28
- /** Extended setting item that can declare its config type for persistence. */
29
- export interface ConfigSettingItem extends SettingItem {
30
- /** Config value type used for auto-generated persistence. */
31
- configType?: ConfigSettingType;
32
- }
33
-
34
- /** Helpers provided to persistChange for scoped SuPi config writes. */
35
- export interface ConfigSettingsHelpers {
36
- /** Write a key to the selected scope's config section. */
37
- set(key: string, value: unknown): void;
38
- /** Remove a key from the selected scope's config section. */
39
- unset(key: string): void;
40
- }
41
-
42
- export interface ConfigSettingsPersistedChange {
43
- scope: SettingsScope;
44
- cwd: string;
45
- settingId: string;
46
- value: string;
47
- }
48
-
49
- export interface ConfigSettingsOptions<T> {
50
- /** Settings contribution identifier — e.g. "lsp", "claude-md". */
51
- id: string;
52
- /** Human-readable label shown in the UI. */
53
- label: string;
54
- /** SuPi config section name — e.g. "lsp", "claude-md". */
55
- section: string;
56
- /** Default config values. */
57
- defaults: T;
58
- /** Build SettingItem[] from scoped config. */
59
- buildItems: (
60
- settings: T,
61
- scope: SettingsScope,
62
- cwd: string,
63
- ctx?: ExtensionContext,
64
- ) => ConfigSettingItem[];
65
- /**
66
- * Convert a UI value into scoped SuPi config writes.
67
- *
68
- * Optional when every item returned by `buildItems` declares `configType`.
69
- * Required when any item lacks `configType`.
70
- */
71
- persistChange?: (
72
- scope: SettingsScope,
73
- cwd: string,
74
- settingId: string,
75
- value: string,
76
- helpers: ConfigSettingsHelpers,
77
- ) => void;
78
- /** Optional live runtime sync after successful persistence. */
79
- afterPersist?: (change: ConfigSettingsPersistedChange) => void;
80
- /** Optional home directory for config resolution (testing). */
81
- homeDir?: string;
82
- }
83
-
84
- // ── Auto-generated persistChange ───────────────────────────────────────────
85
-
86
- function autoPersistChange(
87
- settingId: string,
88
- value: string,
89
- helpers: ConfigSettingsHelpers,
90
- items: ConfigSettingItem[],
91
- ): void {
92
- const item = items.find((i) => i.id === settingId);
93
- if (!item?.configType) return;
94
-
95
- switch (item.configType) {
96
- case "boolean": {
97
- helpers.set(settingId, value === "on");
98
- break;
99
- }
100
- case "number": {
101
- const num = Number.parseInt(value, 10);
102
- if (Number.isFinite(num) && num > 0) {
103
- helpers.set(settingId, num);
104
- } else {
105
- helpers.unset(settingId);
106
- }
107
- break;
108
- }
109
- case "stringList": {
110
- const names = value
111
- .split(",")
112
- .map((s) => s.trim())
113
- .filter((s) => s.length > 0);
114
- if (names.length > 0) {
115
- helpers.set(settingId, names);
116
- } else {
117
- helpers.unset(settingId);
118
- }
119
- break;
120
- }
121
- }
122
- }
123
-
124
- function areAllItemsDeclarative(items: ConfigSettingItem[]): boolean {
125
- return items.length > 0 && items.every((i) => i.configType !== undefined);
126
- }
127
-
128
- function createHelpers<T>(options: ConfigSettingsOptions<T>, scope: SettingsScope, cwd: string) {
129
- return {
130
- set: (key: string, val: unknown) => {
131
- writeSupiConfig(
132
- { section: options.section, scope, cwd },
133
- { [key]: val },
134
- { homeDir: options.homeDir },
135
- );
136
- },
137
- unset: (key: string) => {
138
- removeSupiConfigKey({ section: options.section, scope, cwd }, key, {
139
- homeDir: options.homeDir,
140
- });
141
- },
142
- } satisfies ConfigSettingsHelpers;
143
- }
144
-
145
- function toSettingsSection<T>(options: ConfigSettingsOptions<T>): SettingsSection {
146
- let cachedItems: ConfigSettingItem[] | undefined;
147
-
148
- return {
149
- id: options.id,
150
- label: options.label,
151
- loadValues: (scope, cwd, ctx) => {
152
- const settings = loadSupiConfigForScope(options.section, cwd, options.defaults, {
153
- scope,
154
- homeDir: options.homeDir,
155
- });
156
- const items = options.buildItems(settings, scope, cwd, ctx);
157
- cachedItems = items;
158
- return items;
159
- },
160
- persistChange: (scope, cwd, settingId, value) => {
161
- const helpers = createHelpers(options, scope, cwd);
162
-
163
- if (options.persistChange) {
164
- options.persistChange(scope, cwd, settingId, value, helpers);
165
- } else {
166
- const items = cachedItems ?? options.buildItems(options.defaults, scope, cwd, undefined);
167
- if (!areAllItemsDeclarative(items)) {
168
- throw new Error(
169
- `Settings contribution "${options.id}" needs persistChange or configType on every item.`,
170
- );
171
- }
172
- autoPersistChange(settingId, value, helpers, items);
173
- }
174
-
175
- try {
176
- options.afterPersist?.({ scope, cwd, settingId, value });
177
- } catch (error) {
178
- const message = error instanceof Error ? error.message : String(error);
179
- throw new Error(`Saved setting, but live sync failed: ${message}`, { cause: error });
180
- }
181
- },
182
- };
183
- }
184
-
185
- // ── Registration ───────────────────────────────────────────────────────────
186
-
187
- /**
188
- * Register a config-backed settings contribution for `/supi-settings`.
189
- *
190
- * Contributions are collected through PI's process-local event bus. Call this
191
- * during the extension factory function, not in async session handlers.
192
- */
193
- export function registerConfigSettings<T>(
194
- pi: ExtensionAPI,
195
- options: ConfigSettingsOptions<T>,
196
- ): void {
197
- const section = toSettingsSection(options);
198
- const dispose = pi.events.on(SUPI_SETTINGS_COLLECT_EVENT, (collector) => {
199
- if (isSettingsContributionCollector(collector)) {
200
- collector.add(section);
201
- }
202
- });
203
-
204
- pi.on("session_shutdown", () => {
205
- dispose();
206
- });
207
- }