@mrclrchtr/supi-prompt-suggestions 1.16.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.
- package/README.md +62 -0
- package/node_modules/@mrclrchtr/supi-core/README.md +113 -0
- package/node_modules/@mrclrchtr/supi-core/package.json +79 -0
- package/node_modules/@mrclrchtr/supi-core/src/abort-utils.ts +31 -0
- package/node_modules/@mrclrchtr/supi-core/src/api.ts +44 -0
- package/node_modules/@mrclrchtr/supi-core/src/config/config-settings.ts +188 -0
- package/node_modules/@mrclrchtr/supi-core/src/config/config.ts +207 -0
- package/node_modules/@mrclrchtr/supi-core/src/config.ts +12 -0
- package/node_modules/@mrclrchtr/supi-core/src/context/context-messages.ts +119 -0
- package/node_modules/@mrclrchtr/supi-core/src/context/context-provider-registry.ts +36 -0
- package/node_modules/@mrclrchtr/supi-core/src/context/context-tag.ts +31 -0
- package/node_modules/@mrclrchtr/supi-core/src/context.ts +16 -0
- package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +255 -0
- package/node_modules/@mrclrchtr/supi-core/src/footer-registry.ts +57 -0
- package/node_modules/@mrclrchtr/supi-core/src/index.ts +40 -0
- package/node_modules/@mrclrchtr/supi-core/src/llm.ts +211 -0
- package/node_modules/@mrclrchtr/supi-core/src/model-selection.ts +134 -0
- package/node_modules/@mrclrchtr/supi-core/src/path-utils.ts +40 -0
- package/node_modules/@mrclrchtr/supi-core/src/path.ts +2 -0
- package/node_modules/@mrclrchtr/supi-core/src/progress-widget.ts +189 -0
- package/node_modules/@mrclrchtr/supi-core/src/project-roots.ts +170 -0
- package/node_modules/@mrclrchtr/supi-core/src/project.ts +15 -0
- package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +93 -0
- package/node_modules/@mrclrchtr/supi-core/src/report.ts +121 -0
- package/node_modules/@mrclrchtr/supi-core/src/session-utils.ts +29 -0
- package/node_modules/@mrclrchtr/supi-core/src/session.ts +4 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings/settings-command.ts +15 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings/settings-registry.ts +48 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings/settings-ui.ts +331 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings-ui.ts +6 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings.ts +9 -0
- package/node_modules/@mrclrchtr/supi-core/src/spinner-frames.ts +11 -0
- package/node_modules/@mrclrchtr/supi-core/src/status-spinner.ts +68 -0
- package/node_modules/@mrclrchtr/supi-core/src/substrate-types.ts +11 -0
- package/node_modules/@mrclrchtr/supi-core/src/terminal.ts +60 -0
- package/node_modules/@mrclrchtr/supi-core/src/tool-framework.ts +182 -0
- package/node_modules/@mrclrchtr/supi-core/src/types.ts +2 -0
- package/package.json +39 -0
- package/src/api.ts +8 -0
- package/src/config/config.ts +16 -0
- package/src/config/settings.ts +44 -0
- package/src/editor/editor.ts +138 -0
- package/src/extension.ts +26 -0
- package/src/generation/client.ts +122 -0
- package/src/generation/generator.ts +225 -0
- package/src/generation/model-resolution.ts +72 -0
- package/src/generation/normalize.ts +50 -0
- package/src/session.ts +168 -0
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mrclrchtr/supi-prompt-suggestions",
|
|
3
|
+
"version": "1.16.1",
|
|
4
|
+
"description": "Advisory ghost-text prompt suggestions for the pi coding agent",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"files": [
|
|
8
|
+
"src/**/*.ts",
|
|
9
|
+
"!__tests__"
|
|
10
|
+
],
|
|
11
|
+
"exports": {
|
|
12
|
+
"./extension": {
|
|
13
|
+
"types": "./src/extension.ts",
|
|
14
|
+
"import": "./src/extension.ts"
|
|
15
|
+
},
|
|
16
|
+
"./api": {
|
|
17
|
+
"types": "./src/api.ts",
|
|
18
|
+
"import": "./src/api.ts"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"pi": {
|
|
22
|
+
"extensions": [
|
|
23
|
+
"./src/extension.ts"
|
|
24
|
+
]
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@earendil-works/pi-ai": "*",
|
|
28
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
29
|
+
"@earendil-works/pi-tui": "*",
|
|
30
|
+
"@mrclrchtr/supi-core": "workspace:*"
|
|
31
|
+
},
|
|
32
|
+
"bundledDependencies": [
|
|
33
|
+
"@mrclrchtr/supi-core"
|
|
34
|
+
],
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/node": "25.9.4",
|
|
37
|
+
"vitest": "4.1.9"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config types and defaults for supi-prompt-suggestions.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface PromptSuggestionsConfig extends Record<string, unknown> {
|
|
8
|
+
/** The suggestion model in `provider/model-id` format, or `"disabled"`. */
|
|
9
|
+
model: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const DEFAULTS: PromptSuggestionsConfig = {
|
|
13
|
+
model: "disabled",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const CONFIG_SECTION = "promptSuggestions";
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings registration for supi-prompt-suggestions.
|
|
3
|
+
*
|
|
4
|
+
* Registers a `promptSuggestions` section with a single `model` picker
|
|
5
|
+
* that shows `disabled` first, then the scoped model set.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { registerConfigSettings } from "@mrclrchtr/supi-core/config";
|
|
11
|
+
import { createModelPickerSubmenu } from "@mrclrchtr/supi-core/settings-ui";
|
|
12
|
+
import { CONFIG_SECTION, DEFAULTS } from "./config.ts";
|
|
13
|
+
|
|
14
|
+
const MODEL_ITEM_ID = "model";
|
|
15
|
+
|
|
16
|
+
/** Register the prompt-suggestions settings section. */
|
|
17
|
+
export function registerPromptSuggestionsSettings(): void {
|
|
18
|
+
registerConfigSettings({
|
|
19
|
+
id: "promptSuggestions",
|
|
20
|
+
label: "Prompt suggestions",
|
|
21
|
+
section: CONFIG_SECTION,
|
|
22
|
+
defaults: DEFAULTS,
|
|
23
|
+
buildItems: (settings, _scope, _cwd, ctx) => [
|
|
24
|
+
{
|
|
25
|
+
id: MODEL_ITEM_ID,
|
|
26
|
+
label: "Suggestion model",
|
|
27
|
+
description: "Model used for ghost-text suggestions. Select 'disabled' to turn off.",
|
|
28
|
+
currentValue: settings.model,
|
|
29
|
+
submenu: (currentValue: string, done: (selectedValue?: string) => void) =>
|
|
30
|
+
createModelPickerSubmenu(currentValue, done, ctx),
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
// biome-ignore lint/complexity/useMaxParams: callback shape mandated by registerConfigSettings API
|
|
34
|
+
persistChange: (_scope, _cwd, settingId, value, helpers) => {
|
|
35
|
+
if (settingId === MODEL_ITEM_ID) {
|
|
36
|
+
if (value === "disabled") {
|
|
37
|
+
helpers.unset(MODEL_ITEM_ID);
|
|
38
|
+
} else {
|
|
39
|
+
helpers.set(MODEL_ITEM_ID, value);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ghost-text editor component.
|
|
3
|
+
*
|
|
4
|
+
* Extends PI's CustomEditor and renders prompt suggestions as dim ghost text
|
|
5
|
+
* after the cursor in an empty editor.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { CustomEditor, type KeybindingsManager } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import {
|
|
12
|
+
CURSOR_MARKER,
|
|
13
|
+
type EditorOptions,
|
|
14
|
+
type EditorTheme,
|
|
15
|
+
matchesKey,
|
|
16
|
+
type TUI,
|
|
17
|
+
truncateToWidth,
|
|
18
|
+
visibleWidth,
|
|
19
|
+
} from "@earendil-works/pi-tui";
|
|
20
|
+
|
|
21
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
export interface GhostTextCallbacks {
|
|
24
|
+
onAccept: (suggestion: string) => void;
|
|
25
|
+
onDismiss: () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface GhostTextEditorOptions extends EditorOptions {
|
|
29
|
+
callbacks: GhostTextCallbacks;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── Component ──────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
export class GhostTextEditor extends CustomEditor {
|
|
35
|
+
private suggestion: string | null = null;
|
|
36
|
+
private callbacks: GhostTextCallbacks;
|
|
37
|
+
|
|
38
|
+
constructor(
|
|
39
|
+
tui: TUI,
|
|
40
|
+
theme: EditorTheme,
|
|
41
|
+
keybindings: KeybindingsManager,
|
|
42
|
+
options: GhostTextEditorOptions,
|
|
43
|
+
) {
|
|
44
|
+
const { callbacks, ...editorOptions } = options;
|
|
45
|
+
super(tui, theme, keybindings, editorOptions);
|
|
46
|
+
this.callbacks = callbacks;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Ghost text API ──────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
setSuggestion(suggestion: string | null): void {
|
|
52
|
+
this.suggestion = suggestion;
|
|
53
|
+
this.tui.requestRender();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
clearGhost(): void {
|
|
57
|
+
this.suggestion = null;
|
|
58
|
+
this.tui.requestRender();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Input handling ──────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
override handleInput(data: string): void {
|
|
64
|
+
if (this.suggestion) {
|
|
65
|
+
// Use PI's matchesKey (not raw escape sequences) — it handles
|
|
66
|
+
// CSI (\x1b[C), SS3 (\x1bOC), and Kitty keyboard protocol correctly.
|
|
67
|
+
if (matchesKey(data, "right")) {
|
|
68
|
+
this.insertTextAtCursor(this.suggestion);
|
|
69
|
+
this.callbacks.onAccept(this.suggestion);
|
|
70
|
+
this.clearGhost();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
this.clearGhost();
|
|
74
|
+
this.callbacks.onDismiss();
|
|
75
|
+
}
|
|
76
|
+
super.handleInput(data);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Rendering ───────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
override render(width: number): string[] {
|
|
82
|
+
const lines = super.render(width);
|
|
83
|
+
if (!this.suggestion || !this.focused) return lines;
|
|
84
|
+
|
|
85
|
+
const markerIndex = findCursorMarkerLine(lines);
|
|
86
|
+
if (markerIndex === -1) return lines;
|
|
87
|
+
|
|
88
|
+
const markerLine = lines[markerIndex];
|
|
89
|
+
if (!markerLine) return lines;
|
|
90
|
+
|
|
91
|
+
// Cap ghost text to at most half the terminal width.
|
|
92
|
+
const cursorPos = markerLine.indexOf(CURSOR_MARKER);
|
|
93
|
+
const available = width - visibleWidth(markerLine.slice(0, cursorPos));
|
|
94
|
+
const maxGhost = Math.min(available - 1, Math.floor(width / 2));
|
|
95
|
+
if (maxGhost <= 0) return lines;
|
|
96
|
+
const plainSuggestion = truncateToWidth(this.suggestion, maxGhost, "");
|
|
97
|
+
if (!plainSuggestion) return lines;
|
|
98
|
+
const ghost = `\x1b[2m${plainSuggestion}\x1b[0m`;
|
|
99
|
+
const gw = visibleWidth(plainSuggestion);
|
|
100
|
+
|
|
101
|
+
// Shrink the line first so ghost addition stays within terminal width.
|
|
102
|
+
const shrunk = truncateToWidth(markerLine, width - gw, "");
|
|
103
|
+
if (!shrunk.includes(CURSOR_MARKER)) return lines;
|
|
104
|
+
|
|
105
|
+
const insertPos = ghostInsertPosition(shrunk);
|
|
106
|
+
|
|
107
|
+
lines[markerIndex] = shrunk.slice(0, insertPos) + ghost + shrunk.slice(insertPos);
|
|
108
|
+
return lines;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Find the character position where ghost text should be inserted on
|
|
116
|
+
* a line containing {@link CURSOR_MARKER}.
|
|
117
|
+
*
|
|
118
|
+
* The ghost text is inserted after the visual cursor block emitted by
|
|
119
|
+
* `CustomEditor`. Since the cursor is rendered as an inverse-video span
|
|
120
|
+
* bounded by `\x1b[7m` … `\x1b[0m`, we search for the reset escape after
|
|
121
|
+
* the cursor marker to skip the entire block.
|
|
122
|
+
*
|
|
123
|
+
* @remarks
|
|
124
|
+
* This depends on `CustomEditor`'s internal ANSI encoding of the cursor.
|
|
125
|
+
* If the upstream cursor rendering changes, this helper must be updated.
|
|
126
|
+
*/
|
|
127
|
+
function ghostInsertPosition(line: string): number {
|
|
128
|
+
const cursorIdx = line.indexOf(CURSOR_MARKER);
|
|
129
|
+
if (cursorIdx === -1) return 0;
|
|
130
|
+
|
|
131
|
+
const afterMarker = line.slice(cursorIdx + CURSOR_MARKER.length);
|
|
132
|
+
const inverseEnd = afterMarker.indexOf("\x1b[0m");
|
|
133
|
+
return cursorIdx + CURSOR_MARKER.length + (inverseEnd >= 0 ? inverseEnd + 4 : 0);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function findCursorMarkerLine(lines: string[]): number {
|
|
137
|
+
return lines.findIndex((l) => l.includes(CURSOR_MARKER));
|
|
138
|
+
}
|
package/src/extension.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* supi-prompt-suggestions PI extension entrypoint.
|
|
3
|
+
*
|
|
4
|
+
* Registers settings and wires session event handlers to a
|
|
5
|
+
* {@link SessionLifecycle} instance that owns the ghost editor,
|
|
6
|
+
* status spinner, and suggestion generator lifecycle.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { registerPromptSuggestionsSettings } from "./config/settings.ts";
|
|
13
|
+
import { SuggestionGenerator } from "./generation/generator.ts";
|
|
14
|
+
import { SessionLifecycle } from "./session.ts";
|
|
15
|
+
|
|
16
|
+
export default function (pi: ExtensionAPI): void {
|
|
17
|
+
registerPromptSuggestionsSettings();
|
|
18
|
+
|
|
19
|
+
const generator = new SuggestionGenerator();
|
|
20
|
+
const session = new SessionLifecycle(generator);
|
|
21
|
+
|
|
22
|
+
pi.on("session_start", (_event, ctx) => session.onStart(ctx));
|
|
23
|
+
pi.on("agent_end", (event, ctx) => session.onAgentEnd(event, ctx));
|
|
24
|
+
pi.on("agent_start", () => session.onAgentStart());
|
|
25
|
+
pi.on("session_shutdown", () => session.onShutdown());
|
|
26
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Low-level suggestion model client.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions for calling the suggestion model — no module-level state,
|
|
5
|
+
* no orchestration, no debug logging.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { completeSimple } from "@earendil-works/pi-ai";
|
|
11
|
+
|
|
12
|
+
// ── Constants ──────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export const GENERATION_TIMEOUT_MS = 20_000;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* System prompt instructs the model to suggest a follow-up user message
|
|
18
|
+
* (question, answer, or directive) or return the NO_SUGGESTION sentinel
|
|
19
|
+
* when none is useful.
|
|
20
|
+
*
|
|
21
|
+
* The model receives no PI, SuPi, project, or conversation context —
|
|
22
|
+
* only the last assistant message text is sent.
|
|
23
|
+
*/
|
|
24
|
+
const SYSTEM_PROMPT =
|
|
25
|
+
"You suggest follow-up messages for a coding assistant conversation. " +
|
|
26
|
+
"Given the assistant's last message, write a single line the user would type next. " +
|
|
27
|
+
"It must be a direct question, answer, or directive — nothing else. " +
|
|
28
|
+
"Do NOT include greetings, thank-yous, politeness, or conversational filler. " +
|
|
29
|
+
"If there is no useful follow-up, respond with exactly the word NO_SUGGESTION and nothing else. " +
|
|
30
|
+
"Keep suggestions under 240 characters.";
|
|
31
|
+
|
|
32
|
+
// ── Prompt building ────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Format the tail text as a completion prompt.
|
|
36
|
+
*
|
|
37
|
+
* Instructions live in {@link SYSTEM_PROMPT} — the user message
|
|
38
|
+
* only provides the assistant message content to suggest from.
|
|
39
|
+
*/
|
|
40
|
+
export function buildPrompt(tail: string): string {
|
|
41
|
+
return `<assistant_message>\n${tail}\n</assistant_message>\n\nSuggestion:`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Types ──────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
export interface SuggestionClientResult {
|
|
47
|
+
ok: true;
|
|
48
|
+
text: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface SuggestionClientError {
|
|
52
|
+
ok: false;
|
|
53
|
+
message: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type SuggestionClientOutput = SuggestionClientResult | SuggestionClientError;
|
|
57
|
+
|
|
58
|
+
export interface SuggestionClientOptions {
|
|
59
|
+
// biome-ignore lint/suspicious/noExplicitAny: Model<any> is pi's canonical type
|
|
60
|
+
model: any;
|
|
61
|
+
auth: { apiKey: string; headers?: Record<string, string> };
|
|
62
|
+
tail: string;
|
|
63
|
+
signal: AbortSignal;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── API call ───────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Call the suggestion model with a simple completion prompt.
|
|
70
|
+
*
|
|
71
|
+
* Pure function — all side effects (HTTP, abort) are scoped to the call.
|
|
72
|
+
* Returns a structured result; does not log or interact with the extension
|
|
73
|
+
* context.
|
|
74
|
+
*/
|
|
75
|
+
export async function callSuggestionModel(
|
|
76
|
+
opts: SuggestionClientOptions,
|
|
77
|
+
): Promise<SuggestionClientOutput> {
|
|
78
|
+
const { model, auth, tail, signal } = opts;
|
|
79
|
+
|
|
80
|
+
const response = await completeSimple(
|
|
81
|
+
model,
|
|
82
|
+
{
|
|
83
|
+
systemPrompt: SYSTEM_PROMPT,
|
|
84
|
+
messages: [
|
|
85
|
+
{
|
|
86
|
+
role: "user",
|
|
87
|
+
content: [{ type: "text", text: buildPrompt(tail) }],
|
|
88
|
+
timestamp: Date.now(),
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
apiKey: auth.apiKey,
|
|
94
|
+
headers: auth.headers,
|
|
95
|
+
signal,
|
|
96
|
+
},
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
if (!response?.content) {
|
|
100
|
+
const message = `Suggestion model returned no content (stopReason: ${response?.stopReason ?? "undefined"})`;
|
|
101
|
+
return { ok: false, message };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (response.stopReason === "error") {
|
|
105
|
+
const message = `Suggestion model failed: ${response.errorMessage ?? response.stopReason}`;
|
|
106
|
+
return { ok: false, message };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Prefer text blocks; fall back to thinking blocks for reasoning-only models.
|
|
110
|
+
const textContent = response.content
|
|
111
|
+
.filter((c: { type: string }) => c.type === "text")
|
|
112
|
+
.map((c: { type: string; text?: string }) => c.text)
|
|
113
|
+
.join("");
|
|
114
|
+
|
|
115
|
+
if (!textContent) {
|
|
116
|
+
const contentTypes = response.content.map((c: { type: string }) => c.type);
|
|
117
|
+
const message = `Suggestion model returned no text (content types: [${contentTypes.join(", ")}])`;
|
|
118
|
+
return { ok: false, message };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { ok: true, text: textContent };
|
|
122
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Suggestion generator — async orchestration of prompt suggestion
|
|
3
|
+
* generation with concurrency control.
|
|
4
|
+
*
|
|
5
|
+
* Manages the lifecycle: config check, model resolution, model call,
|
|
6
|
+
* normalization, and callback dispatch. Uses an internal abort controller
|
|
7
|
+
* and generation ID to cancel or discard stale results.
|
|
8
|
+
*
|
|
9
|
+
* @module
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { combineAbortSignals } from "@mrclrchtr/supi-core/abort-utils";
|
|
14
|
+
import { loadSectionConfig } from "@mrclrchtr/supi-core/config";
|
|
15
|
+
import { recordDebugEvent } from "@mrclrchtr/supi-core/debug";
|
|
16
|
+
import { CONFIG_SECTION, DEFAULTS } from "../config/config.ts";
|
|
17
|
+
import {
|
|
18
|
+
callSuggestionModel,
|
|
19
|
+
GENERATION_TIMEOUT_MS,
|
|
20
|
+
type SuggestionClientOutput,
|
|
21
|
+
} from "./client.ts";
|
|
22
|
+
import { resolveSuggestionAuth } from "./model-resolution.ts";
|
|
23
|
+
import { normalizeSuggestion } from "./normalize.ts";
|
|
24
|
+
|
|
25
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export type GenerationStatus =
|
|
28
|
+
| { kind: "idle" }
|
|
29
|
+
| { kind: "generating" }
|
|
30
|
+
| { kind: "ready"; suggestion: string }
|
|
31
|
+
| { kind: "error"; message: string };
|
|
32
|
+
|
|
33
|
+
export interface SuggestionCallbacks {
|
|
34
|
+
/** Called when generation status changes. */
|
|
35
|
+
onStatus: (status: GenerationStatus) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface RunOptions {
|
|
39
|
+
ctx: ExtensionContext;
|
|
40
|
+
modelId: string;
|
|
41
|
+
tail: string;
|
|
42
|
+
id: number;
|
|
43
|
+
abort: AbortController;
|
|
44
|
+
callbacks: SuggestionCallbacks;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── Generator ──────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Encapsulates the async suggestion generation lifecycle.
|
|
51
|
+
*
|
|
52
|
+
* Manages concurrency via an internal abort controller and generation ID.
|
|
53
|
+
* Instances are independent — tests can create fresh instances without
|
|
54
|
+
* shared mutable state.
|
|
55
|
+
*/
|
|
56
|
+
export class SuggestionGenerator {
|
|
57
|
+
private currentAbort: AbortController | null = null;
|
|
58
|
+
private generationId = 0;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Start suggestion generation from the last assistant text.
|
|
62
|
+
*
|
|
63
|
+
* Fire-and-forget — the caller does not await the returned promise.
|
|
64
|
+
* Cancels any in-flight generation.
|
|
65
|
+
*/
|
|
66
|
+
start(ctx: ExtensionContext, lastAssistantText: string, callbacks: SuggestionCallbacks): void {
|
|
67
|
+
// Cancel any in-flight generation
|
|
68
|
+
this.dismiss();
|
|
69
|
+
|
|
70
|
+
const config = loadSectionConfig(CONFIG_SECTION, ctx.cwd, DEFAULTS);
|
|
71
|
+
if (config.model === "disabled") {
|
|
72
|
+
recordDebugEvent({
|
|
73
|
+
source: "prompt-suggestions",
|
|
74
|
+
level: "debug",
|
|
75
|
+
category: "generation.skipped",
|
|
76
|
+
message: "Prompt suggestion generation skipped: model is disabled",
|
|
77
|
+
cwd: ctx.cwd,
|
|
78
|
+
});
|
|
79
|
+
callbacks.onStatus({ kind: "idle" });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const text = lastAssistantText.trim();
|
|
84
|
+
if (!text) {
|
|
85
|
+
recordDebugEvent({
|
|
86
|
+
source: "prompt-suggestions",
|
|
87
|
+
level: "debug",
|
|
88
|
+
category: "generation.skipped",
|
|
89
|
+
message: "Prompt suggestion generation skipped: no text in last assistant message",
|
|
90
|
+
cwd: ctx.cwd,
|
|
91
|
+
});
|
|
92
|
+
callbacks.onStatus({ kind: "idle" });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const tail = text.slice(-8_000);
|
|
97
|
+
const id = ++this.generationId;
|
|
98
|
+
const abort = new AbortController();
|
|
99
|
+
this.currentAbort = abort;
|
|
100
|
+
|
|
101
|
+
callbacks.onStatus({ kind: "generating" });
|
|
102
|
+
|
|
103
|
+
// Fire-and-forget
|
|
104
|
+
void this.#run({ ctx, modelId: config.model, tail, id, abort, callbacks });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Cancel in-flight generation and invalidate the current generation ID. */
|
|
108
|
+
dismiss(): void {
|
|
109
|
+
if (this.currentAbort) {
|
|
110
|
+
this.currentAbort.abort();
|
|
111
|
+
this.currentAbort = null;
|
|
112
|
+
}
|
|
113
|
+
this.generationId++;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── Private ──────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
async #run(opts: RunOptions): Promise<void> {
|
|
119
|
+
const { ctx, tail, id, abort, callbacks } = opts;
|
|
120
|
+
|
|
121
|
+
recordDebugEvent({
|
|
122
|
+
source: "prompt-suggestions",
|
|
123
|
+
level: "debug",
|
|
124
|
+
category: "generation.start",
|
|
125
|
+
message: "Prompt suggestion generation started",
|
|
126
|
+
cwd: ctx.cwd,
|
|
127
|
+
data: { tailLength: tail.length },
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const authResult = await resolveSuggestionAuth(ctx, opts.modelId);
|
|
132
|
+
|
|
133
|
+
// Discard if generation was cancelled while resolving auth
|
|
134
|
+
if (id !== this.generationId || abort.signal.aborted) return;
|
|
135
|
+
|
|
136
|
+
if (authResult.kind === "error") {
|
|
137
|
+
recordDebugEvent({
|
|
138
|
+
source: "prompt-suggestions",
|
|
139
|
+
level: "warning",
|
|
140
|
+
category: "generation.auth-failure",
|
|
141
|
+
message: authResult.message,
|
|
142
|
+
cwd: ctx.cwd,
|
|
143
|
+
});
|
|
144
|
+
callbacks.onStatus({ kind: "error", message: authResult.message });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const { auth } = authResult;
|
|
149
|
+
|
|
150
|
+
if (id !== this.generationId || abort.signal.aborted) return;
|
|
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
|
+
});
|
|
164
|
+
|
|
165
|
+
this.#handleResponse(response, id, ctx.cwd, callbacks);
|
|
166
|
+
} finally {
|
|
167
|
+
cleanupTimeout();
|
|
168
|
+
}
|
|
169
|
+
} catch (err) {
|
|
170
|
+
if (id !== this.generationId) return;
|
|
171
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
172
|
+
recordDebugEvent({
|
|
173
|
+
source: "prompt-suggestions",
|
|
174
|
+
level: "warning",
|
|
175
|
+
category: "generation.error",
|
|
176
|
+
message: `Prompt suggestion generation failed: ${message}`,
|
|
177
|
+
cwd: ctx.cwd,
|
|
178
|
+
data: { error: message },
|
|
179
|
+
});
|
|
180
|
+
callbacks.onStatus({ kind: "error", message });
|
|
181
|
+
} finally {
|
|
182
|
+
if (this.currentAbort === abort) {
|
|
183
|
+
this.currentAbort = null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
#handleResponse(
|
|
189
|
+
response: SuggestionClientOutput,
|
|
190
|
+
id: number,
|
|
191
|
+
cwd: string,
|
|
192
|
+
callbacks: SuggestionCallbacks,
|
|
193
|
+
): void {
|
|
194
|
+
if (id !== this.generationId) return;
|
|
195
|
+
|
|
196
|
+
if (!response.ok) {
|
|
197
|
+
callbacks.onStatus({ kind: "error", message: response.message });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const normalized = normalizeSuggestion(response.text);
|
|
202
|
+
if (!normalized) {
|
|
203
|
+
recordDebugEvent({
|
|
204
|
+
source: "prompt-suggestions",
|
|
205
|
+
level: "debug",
|
|
206
|
+
category: "generation.rejected",
|
|
207
|
+
message: `Prompt suggestion rejected: empty after normalization (${response.text.length} raw chars)`,
|
|
208
|
+
cwd,
|
|
209
|
+
data: { rawLength: response.text.length },
|
|
210
|
+
});
|
|
211
|
+
callbacks.onStatus({ kind: "idle" });
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
recordDebugEvent({
|
|
216
|
+
source: "prompt-suggestions",
|
|
217
|
+
level: "debug",
|
|
218
|
+
category: "generation.done",
|
|
219
|
+
message: `Prompt suggestion ready: "${normalized}"`,
|
|
220
|
+
cwd,
|
|
221
|
+
data: { suggestion: normalized, length: normalized.length },
|
|
222
|
+
});
|
|
223
|
+
callbacks.onStatus({ kind: "ready", suggestion: normalized });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Suggestion model resolution.
|
|
3
|
+
*
|
|
4
|
+
* Resolves the configured suggestion model from the scoped model set
|
|
5
|
+
* and obtains its API key. Extracted from {@link SuggestionGenerator}
|
|
6
|
+
* for isolated testing.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { getSelectableModels } from "@mrclrchtr/supi-core/model-selection";
|
|
13
|
+
|
|
14
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export interface ResolvedAuth {
|
|
17
|
+
// biome-ignore lint/suspicious/noExplicitAny: Model<any> is pi's canonical type
|
|
18
|
+
model: any;
|
|
19
|
+
apiKey: string;
|
|
20
|
+
headers?: Record<string, string>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface AuthResolutionError {
|
|
24
|
+
kind: "error";
|
|
25
|
+
message: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type AuthResolutionResult = { kind: "ok"; auth: ResolvedAuth } | AuthResolutionError;
|
|
29
|
+
|
|
30
|
+
// ── Resolution ─────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve model + API key for the configured suggestion model.
|
|
34
|
+
*
|
|
35
|
+
* Finds the model in the scoped set and obtains an API key via
|
|
36
|
+
* `modelRegistry.getApiKeyAndHeaders`.
|
|
37
|
+
*
|
|
38
|
+
* The caller must check for `"disabled"` before invoking — this function
|
|
39
|
+
* assumes a valid, non-disabled model ID.
|
|
40
|
+
*/
|
|
41
|
+
export async function resolveSuggestionAuth(
|
|
42
|
+
ctx: ExtensionContext,
|
|
43
|
+
modelId: string,
|
|
44
|
+
): Promise<AuthResolutionResult> {
|
|
45
|
+
const match = findSuggestionModel(ctx, modelId);
|
|
46
|
+
if (!match) {
|
|
47
|
+
return {
|
|
48
|
+
kind: "error",
|
|
49
|
+
message: `Suggestion model "${modelId}" not in scoped set`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(match.model);
|
|
54
|
+
if (!auth.ok) {
|
|
55
|
+
return {
|
|
56
|
+
kind: "error",
|
|
57
|
+
message: `No API key configured for ${match.canonicalId}`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
kind: "ok",
|
|
63
|
+
auth: { model: match.model, apiKey: auth.apiKey ?? "", headers: auth.headers },
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
function findSuggestionModel(ctx: ExtensionContext, modelId: string) {
|
|
70
|
+
const models = getSelectableModels(ctx);
|
|
71
|
+
return models.find((m) => m.canonicalId === modelId);
|
|
72
|
+
}
|