@in-the-loop-labs/pair-review 3.5.2 → 3.7.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.
Files changed (93) hide show
  1. package/README.md +4 -0
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  5. package/public/css/analysis-config.css +1807 -0
  6. package/public/css/pr.css +1029 -2169
  7. package/public/index.html +11 -0
  8. package/public/js/components/AIPanel.js +39 -23
  9. package/public/js/components/AdvancedConfigTab.js +56 -4
  10. package/public/js/components/AnalysisConfigModal.js +41 -25
  11. package/public/js/components/ChatPanel.js +163 -3
  12. package/public/js/components/KeyboardShortcuts.js +10 -26
  13. package/public/js/components/ReviewModal.js +135 -13
  14. package/public/js/components/TourBar.js +248 -0
  15. package/public/js/components/VoiceCentricConfigTab.js +36 -0
  16. package/public/js/index.js +175 -16
  17. package/public/js/local.js +64 -8
  18. package/public/js/modules/cancel-background-job.js +183 -0
  19. package/public/js/modules/hunk-summary-renderer.js +116 -0
  20. package/public/js/modules/storage-cleanup.js +16 -0
  21. package/public/js/modules/suggestion-manager.js +25 -1
  22. package/public/js/modules/tour-renderer.js +755 -0
  23. package/public/js/pr.js +1826 -56
  24. package/public/js/repo-links.js +328 -0
  25. package/public/js/utils/modal-detection.js +77 -0
  26. package/public/js/utils/provider-model.js +88 -0
  27. package/public/js/utils/storage-keys.js +50 -0
  28. package/public/local.html +24 -0
  29. package/public/pr.html +24 -0
  30. package/public/repo-settings.html +1 -0
  31. package/public/setup.html +2 -0
  32. package/src/ai/abort-signal-wiring.js +130 -0
  33. package/src/ai/analyzer.js +125 -18
  34. package/src/ai/background-queue.js +290 -0
  35. package/src/ai/claude-cli.js +1 -1
  36. package/src/ai/claude-provider.js +50 -7
  37. package/src/ai/codex-provider.js +28 -5
  38. package/src/ai/copilot-provider.js +22 -3
  39. package/src/ai/cursor-agent-provider.js +22 -6
  40. package/src/ai/executable-provider.js +4 -19
  41. package/src/ai/gemini-provider.js +22 -5
  42. package/src/ai/hunk-hashing.js +161 -0
  43. package/src/ai/index.js +2 -0
  44. package/src/ai/opencode-provider.js +21 -5
  45. package/src/ai/pi-provider.js +21 -5
  46. package/src/ai/prompts/hunk-summary.js +199 -0
  47. package/src/ai/prompts/tour.js +232 -0
  48. package/src/ai/provider.js +21 -1
  49. package/src/ai/summary-generator.js +469 -0
  50. package/src/ai/tour-generator.js +568 -0
  51. package/src/config.js +778 -10
  52. package/src/database.js +282 -1
  53. package/src/external/github-adapter.js +114 -25
  54. package/src/git/base-branch.js +11 -4
  55. package/src/github/client.js +482 -588
  56. package/src/github/errors.js +55 -0
  57. package/src/github/impl/graphql/pending-review-comments.js +230 -0
  58. package/src/github/impl/graphql/pending-review.js +153 -0
  59. package/src/github/impl/graphql/review-lifecycle.js +161 -0
  60. package/src/github/impl/graphql/stack-walker.js +210 -0
  61. package/src/github/impl/host/pending-review-comments.js +338 -0
  62. package/src/github/impl/rest/pending-review.js +251 -0
  63. package/src/github/impl/rest/review-lifecycle.js +226 -0
  64. package/src/github/impl/rest/stack-walker.js +309 -0
  65. package/src/github/operations/pending-review-comments.js +79 -0
  66. package/src/github/operations/pending-review.js +89 -0
  67. package/src/github/operations/review-lifecycle.js +126 -0
  68. package/src/github/operations/stack-walker.js +87 -0
  69. package/src/github/parser.js +230 -4
  70. package/src/github/stack-walker.js +14 -189
  71. package/src/links/repo-links.js +230 -0
  72. package/src/local-review.js +201 -172
  73. package/src/main.js +133 -30
  74. package/src/routes/analyses.js +30 -7
  75. package/src/routes/bulk-analysis-configs.js +295 -0
  76. package/src/routes/config.js +118 -3
  77. package/src/routes/context-files.js +2 -29
  78. package/src/routes/external-comments.js +20 -10
  79. package/src/routes/github-collections.js +3 -1
  80. package/src/routes/local.js +410 -13
  81. package/src/routes/mcp.js +47 -4
  82. package/src/routes/middleware/validate-review-id.js +53 -0
  83. package/src/routes/pr.js +556 -71
  84. package/src/routes/reviews.js +145 -29
  85. package/src/routes/setup.js +8 -3
  86. package/src/routes/stack-analysis.js +33 -9
  87. package/src/routes/worktrees.js +3 -2
  88. package/src/server.js +2 -0
  89. package/src/setup/pr-setup.js +37 -11
  90. package/src/setup/stack-setup.js +13 -3
  91. package/src/single-port.js +6 -3
  92. package/src/utils/diff-hunks.js +65 -0
  93. package/src/utils/json-extractor.js +5 -2
@@ -0,0 +1,199 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Hunk summary prompt builder.
4
+ *
5
+ * Pure function that produces the prompt body sent to the background provider
6
+ * for summarizing one file's worth of diff hunks. Output schema and length
7
+ * constraints are defined in plans/semantic-hunk-summaries-and-tours.md
8
+ * ("Prompt Design Notes" -> "Summary prompt contract").
9
+ */
10
+
11
+ const MAX_CHANGED_FILES_LISTED = 100;
12
+
13
+ /**
14
+ * @typedef {Object} HunkInput
15
+ * @property {string} header - "@@ -10,5 +10,7 @@" line
16
+ * @property {string[]} lines - Diff body lines with leading +/-/space markers
17
+ */
18
+
19
+ /**
20
+ * @typedef {Object} SummaryContext
21
+ * @property {string} filePath - Path of the file being summarized
22
+ * @property {HunkInput[]} hunks - Hunks to summarize, in file order
23
+ * @property {string} [prTitle] - Optional PR title or local-review name
24
+ * @property {string} [prDescription] - Optional PR description
25
+ * @property {string[]} [changedFiles] - Optional list of all changed-file paths in this review (light context)
26
+ * @property {string} [cwd] - Optional working directory the agent is running in.
27
+ * When provided, the prompt invites bounded read-only file access; the path
28
+ * itself is NOT embedded in the prompt. Used purely as a signal flag — when
29
+ * omitted, the prompt does not promise read-only access at all.
30
+ */
31
+
32
+ /**
33
+ * Returns true if the value is a non-empty, non-whitespace-only string.
34
+ * @param {unknown} value
35
+ * @returns {boolean}
36
+ */
37
+ function hasText(value) {
38
+ return typeof value === 'string' && value.trim().length > 0;
39
+ }
40
+
41
+ /**
42
+ * Build the prompt body sent to the background provider for summarizing one
43
+ * file's worth of hunks. Returns a single string (the full prompt).
44
+ * @param {SummaryContext} context
45
+ * @returns {string}
46
+ */
47
+ function buildHunkSummaryPrompt({ filePath, hunks, prTitle, prDescription, changedFiles, cwd } = {}) {
48
+ if (!hasText(filePath)) {
49
+ throw new TypeError('filePath is required');
50
+ }
51
+ if (hunks === undefined || hunks === null) {
52
+ throw new TypeError('hunks is required');
53
+ }
54
+ if (!Array.isArray(hunks)) {
55
+ throw new TypeError('hunks is required');
56
+ }
57
+
58
+ const sections = [];
59
+
60
+ sections.push(
61
+ 'You are summarizing changed hunks from a code review. Treat the diff text provided below as the primary source. Do NOT modify files. Do NOT run write commands (rm, mv, git commit, etc.). Produce concise natural-language summaries.'
62
+ );
63
+
64
+ if (hasText(cwd)) {
65
+ sections.push(
66
+ [
67
+ 'You have read-only access to the current working directory. The diff is',
68
+ 'your primary source. You MAY consult adjacent code ONLY when it materially',
69
+ 'improves the description of WHAT changed:',
70
+ '- A symbol introduced/modified in the diff has callers or a definition',
71
+ ' elsewhere whose existence changes the summary (e.g. "extracts a helper',
72
+ ' now used by 4 sites" vs "adds a helper").',
73
+ '- The diff is locally ambiguous about what changed (e.g. a one-line',
74
+ ' signature change whose meaning depends on the function body not in',
75
+ ' the hunk).',
76
+ '',
77
+ 'Budget per file: at most ~5 file reads, ~3 grep calls. Do not browse',
78
+ 'broadly. Do not read tests, fixtures, or generated files unless directly',
79
+ 'relevant. Do not modify any file.',
80
+ '',
81
+ 'The summary still describes what the DIFF changes, not what the',
82
+ 'surrounding code does. Context informs phrasing; it does not become',
83
+ 'the subject.'
84
+ ].join('\n')
85
+ );
86
+ }
87
+
88
+ sections.push(
89
+ [
90
+ 'Style:',
91
+ '- 1–3 sentences. Aim for one; use two only when a second sentence adds',
92
+ ' information the first cannot. Three is rare.',
93
+ '- Target ~200 characters; hard ceiling 400.',
94
+ '- State WHAT changed in the diff. Context informs phrasing; it does not',
95
+ ' become the subject. Do not speculate beyond what code you can see makes',
96
+ ' unambiguous.',
97
+ '- For mechanical changes (formatting, trivial rename), say so in one short',
98
+ ' sentence and stop.',
99
+ '- Lead with a verb (Adds, Removes, Renames, Refactors, Fixes, Moves,',
100
+ ' Inlines, Extracts).'
101
+ ].join('\n')
102
+ );
103
+
104
+ sections.push(
105
+ [
106
+ 'You MAY return summary: null for a hunk only when ALL of these hold:',
107
+ '- The change is purely mechanical (whitespace, import reorder, lint fix,',
108
+ ' trivial rename) AND',
109
+ '- A reader scanning the diff would learn nothing from a summary.',
110
+ '',
111
+ 'Default is to summarize. When in doubt, write the summary.'
112
+ ].join('\n')
113
+ );
114
+
115
+ if (hasText(prTitle) || hasText(prDescription)) {
116
+ const contextLines = ["Author's stated intent (hint only — verify against the diff):"];
117
+ if (hasText(prTitle)) {
118
+ contextLines.push(` Title: ${prTitle.trim()}`);
119
+ }
120
+ if (hasText(prDescription)) {
121
+ contextLines.push(` Description: ${prDescription.trim()}`);
122
+ }
123
+ sections.push(contextLines.join('\n'));
124
+
125
+ sections.push(
126
+ [
127
+ "The author's stated intent above is a HINT — useful for orientation and",
128
+ 'vocabulary. It is NOT verified ground truth. The diff is ground truth.',
129
+ '- Use the description to orient your reading and to choose vocabulary that',
130
+ ' matches the project (e.g. domain terms).',
131
+ '- Do NOT repeat or paraphrase the description as the summary.',
132
+ '- If the diff and the description disagree, describe the diff. Do not',
133
+ ' paper over the disagreement, and do not editorialize about it — just',
134
+ ' state what the diff does.',
135
+ '- If the description is vague, templated, or empty, ignore it entirely.'
136
+ ].join('\n')
137
+ );
138
+ }
139
+
140
+ if (Array.isArray(changedFiles) && changedFiles.length > 0) {
141
+ if (changedFiles.length > MAX_CHANGED_FILES_LISTED) {
142
+ sections.push(
143
+ `Changed files in this review: ${changedFiles.length} total (list omitted for length)`
144
+ );
145
+ } else {
146
+ const fileLines = ['Changed files in this review:'];
147
+ for (const path of changedFiles) {
148
+ fileLines.push(` - ${path}`);
149
+ }
150
+ sections.push(fileLines.join('\n'));
151
+ }
152
+ }
153
+
154
+ const hunkBlockLines = [`File: ${filePath}`, 'Hunks (numbered):'];
155
+ if (hunks.length === 0) {
156
+ hunkBlockLines.push('(no hunks)');
157
+ } else {
158
+ hunks.forEach((hunk, idx) => {
159
+ const header = hunk && typeof hunk.header === 'string' ? hunk.header : '';
160
+ const lines = hunk && Array.isArray(hunk.lines) ? hunk.lines : [];
161
+ hunkBlockLines.push(`[${idx + 1}] ${header}`);
162
+ if (lines.length > 0) {
163
+ hunkBlockLines.push(lines.join('\n'));
164
+ }
165
+ });
166
+ }
167
+ sections.push(hunkBlockLines.join('\n'));
168
+
169
+ if (hunks.length === 0) {
170
+ sections.push(
171
+ [
172
+ 'There are no hunks to summarize.',
173
+ 'Return ONLY this JSON object:',
174
+ '{ "summaries": [] }',
175
+ '',
176
+ 'Do not include any extra fields, explanation, or prose outside the JSON.'
177
+ ].join('\n')
178
+ );
179
+ } else {
180
+ sections.push(
181
+ [
182
+ 'Return ONLY a JSON object with this shape:',
183
+ '{ "summaries": [',
184
+ ' { "index": 1, "summary": "Adds X to do Y." },',
185
+ ' { "index": 2, "summary": null }',
186
+ '] }',
187
+ '',
188
+ 'Rules:',
189
+ '- One entry per hunk above; index matches the [N] label.',
190
+ '- `summary` is `string | null` (null only per the opt-out clause above).',
191
+ '- Do not include any extra fields, explanation, or prose outside the JSON.'
192
+ ].join('\n')
193
+ );
194
+ }
195
+
196
+ return sections.join('\n\n');
197
+ }
198
+
199
+ module.exports = { buildHunkSummaryPrompt };
@@ -0,0 +1,232 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Tour prompt builder.
4
+ *
5
+ * Pure function that produces the prompt body sent to the background provider
6
+ * for generating a guided "tour" of a code review. The agent is expected to
7
+ * actively explore the worktree (read files, run the annotated diff tool,
8
+ * grep) and choose stops grounded in real code it has read directly.
9
+ */
10
+
11
+ const { buildAnalysisLineNumberGuidance } = require('./line-number-guidance');
12
+
13
+ // What the prompt asks the model for. Trivial diffs may legitimately yield
14
+ // only one or two stops; we don't want to coerce padding.
15
+ const PROMPT_MIN_STOPS = 1;
16
+ // Persistence gate: fewer than this and the result is treated as
17
+ // "not tour-worthy" rather than published.
18
+ const PERSIST_MIN_STOPS = 2;
19
+ const MAX_STOPS = 12;
20
+ const TITLE_MAX = 120;
21
+ // Storage cap. The UI clamps the visible description to ~3 lines and
22
+ // reveals the rest behind a "Show more" toggle, so we can afford to give
23
+ // the model more room than the old 280-char hard cap (which produced
24
+ // visibly mid-sentence truncations the user hated).
25
+ const DESCRIPTION_MAX = 800;
26
+
27
+ /**
28
+ * Returns true if the value is a non-empty, non-whitespace-only string.
29
+ * @param {unknown} value
30
+ * @returns {boolean}
31
+ */
32
+ function hasText(value) {
33
+ return typeof value === 'string' && value.trim().length > 0;
34
+ }
35
+
36
+ /**
37
+ * Build the prompt body sent to the background provider for generating a tour.
38
+ *
39
+ * The agent is expected to explore the worktree directly (Read, grep, the
40
+ * annotated diff tool) and ground every stop in real file content.
41
+ *
42
+ * @param {Object} context
43
+ * @param {string} [context.prTitle] Optional PR title or local-review name.
44
+ * @param {string} [context.prDescription] Optional PR description.
45
+ * @param {string} context.scriptCommand Annotated-diff command (e.g. `git-diff-lines --cwd "/abs"`).
46
+ * @param {string[]} context.changedFiles Repo-relative paths of files in the diff.
47
+ * @param {string} [context.worktreePath] Informational; the agent's cwd.
48
+ * @returns {string} The full prompt.
49
+ */
50
+ function buildTourPrompt({
51
+ prTitle,
52
+ prDescription,
53
+ scriptCommand,
54
+ changedFiles,
55
+ worktreePath
56
+ } = {}) {
57
+ if (!hasText(scriptCommand)) {
58
+ throw new TypeError('scriptCommand is required');
59
+ }
60
+ if (!Array.isArray(changedFiles)) {
61
+ throw new TypeError('changedFiles is required');
62
+ }
63
+
64
+ const sections = [];
65
+
66
+ sections.push(
67
+ [
68
+ 'You are building a guided tour of a pull request or local code change.',
69
+ 'The tour exists for ONE reason: accelerated understanding of the change',
70
+ 'for the reader.',
71
+ '',
72
+ 'Audience: a reviewer who needs to build an accurate mental model of',
73
+ 'the change fast and know where to focus scrutiny before deciding',
74
+ 'whether to approve, push back, or dig deeper.',
75
+ '',
76
+ 'Success looks like: after reading the tour in order, the reviewer',
77
+ '(a) understands what changed and why, and (b) knows which parts',
78
+ 'deserve careful attention.',
79
+ '',
80
+ 'This is NOT a changelog or a hunk-by-hunk summary. Skip anything a',
81
+ 'competent reviewer absorbs at a glance. Privilege load-bearing logic,',
82
+ 'contract changes, and subtle risk over breadth of coverage.',
83
+ '',
84
+ 'You have shell tools (Read, the annotated diff tool, cat, grep).',
85
+ 'EXPLORE the worktree directly. Ground every stop in real file content',
86
+ 'you have read. Do NOT modify files. Do NOT run write commands.'
87
+ ].join('\n')
88
+ );
89
+
90
+ sections.push(
91
+ [
92
+ 'Return ONLY a JSON object with this shape:',
93
+ '{',
94
+ ' "stops": [',
95
+ ' {',
96
+ ' "file_path": "src/foo.ts",',
97
+ ' "side": "RIGHT",',
98
+ ' "line_start": 42,',
99
+ ' "line_end": 58,',
100
+ ` "title": "<= ${TITLE_MAX} chars",`,
101
+ ` "description": "1-3 sentences; aim for ~200-300 chars, up to ${DESCRIPTION_MAX}"`,
102
+ ' }',
103
+ ' ]',
104
+ '}',
105
+ '',
106
+ '`side` is "LEFT" or "RIGHT".'
107
+ ].join('\n')
108
+ );
109
+
110
+ sections.push(
111
+ [
112
+ 'Style:',
113
+ `- title: a short noun phrase, <= ${TITLE_MAX} characters.`,
114
+ `- description: 1–3 sentences. Aim for ~200–300 characters; up to ${DESCRIPTION_MAX}`,
115
+ ' if more context is genuinely needed. Explain WHY this stop matters and',
116
+ ' what to look for. Do NOT restate what the code does; say why it is load-bearing.',
117
+ '- Be concrete. No fluff like "this is important code".'
118
+ ].join('\n')
119
+ );
120
+
121
+ sections.push(
122
+ [
123
+ 'Stop selection:',
124
+ '- Order stops as a coherent reading path — start with the most load-bearing',
125
+ ' change, then dependents, then supporting changes. Order does NOT have to',
126
+ ' follow file order.',
127
+ '- Skip mechanical/uninteresting code (formatting, imports, whitespace).',
128
+ ' Include only stops a reviewer benefits from reading explicitly.',
129
+ '- `[line_start, line_end]` should be tight — a function, a block, a few',
130
+ ' related lines — not an entire file.',
131
+ `- Return as many stops as the diff genuinely warrants, up to ${MAX_STOPS}.`,
132
+ ' Trivial diffs may legitimately yield only 1-2 stops; do not pad. Fewer',
133
+ ' well-chosen stops beat a long laundry list.',
134
+ '- Stops must not overlap. Pick the single most informative range per',
135
+ ' region; do not return multiple stops covering the same function or',
136
+ ' block under different framings.'
137
+ ].join('\n')
138
+ );
139
+
140
+ sections.push(
141
+ [
142
+ 'Every stop MUST point at lines that actually changed in the diff:',
143
+ '- The chosen `[line_start, line_end]` range MUST intersect changed',
144
+ ' lines for the chosen `side` in the chosen file.',
145
+ '- Stops on unchanged code or on files outside the diff will be rejected.',
146
+ '- If you want to call out unchanged code that the change interacts with,',
147
+ ' pick a stop on a changed line nearby and reference the unchanged code',
148
+ ' in the description text.'
149
+ ].join('\n')
150
+ );
151
+
152
+ sections.push(
153
+ [
154
+ 'Side semantics:',
155
+ '- `RIGHT` = post-change content (added or context lines, by NEW line numbers).',
156
+ '- `LEFT` = pre-change content (deleted lines, by OLD line numbers). Use only',
157
+ ' when calling out something that was removed.',
158
+ '- The range must intersect a changed-line region on the chosen side. Use the',
159
+ ' annotated diff tool to confirm.'
160
+ ].join('\n')
161
+ );
162
+
163
+ sections.push(
164
+ [
165
+ 'Exploration discipline:',
166
+ '- Use the annotated diff tool to ground every line number. Treat it as the',
167
+ ' authoritative source for which lines changed and on which side.',
168
+ '- Read the relevant files to verify ranges sit on meaningful boundaries',
169
+ ' (full function, full block) before committing to a stop.'
170
+ ].join('\n')
171
+ );
172
+
173
+ sections.push(buildAnalysisLineNumberGuidance({ scriptCommand }).trim());
174
+
175
+ if (hasText(prTitle) || hasText(prDescription)) {
176
+ const intentLines = ["Author's stated intent (HINT only — verify against the code):"];
177
+ if (hasText(prTitle)) {
178
+ intentLines.push(` Title: ${prTitle.trim()}`);
179
+ }
180
+ if (hasText(prDescription)) {
181
+ intentLines.push(` Description: ${prDescription.trim()}`);
182
+ }
183
+ sections.push(intentLines.join('\n'));
184
+
185
+ sections.push(
186
+ [
187
+ "The author's stated intent above is a HINT for orientation and vocabulary.",
188
+ 'It is NOT verified ground truth. The code and diff are ground truth.',
189
+ '- If the description and the code disagree, follow the code.',
190
+ '- If the description is vague, templated, or empty, ignore it entirely.'
191
+ ].join('\n')
192
+ );
193
+ }
194
+
195
+ const changedFilesBlock = ['Changed files in this diff:'];
196
+ if (changedFiles.length === 0) {
197
+ changedFilesBlock.push(' (none)');
198
+ } else {
199
+ for (const path of changedFiles) {
200
+ changedFilesBlock.push(` - ${path}`);
201
+ }
202
+ }
203
+ changedFilesBlock.push('');
204
+ changedFilesBlock.push(
205
+ 'Stops MUST be in one of the files above. Stops on other files will be rejected.'
206
+ );
207
+ sections.push(changedFilesBlock.join('\n'));
208
+
209
+ if (hasText(worktreePath)) {
210
+ sections.push(`Your working directory is: ${worktreePath}`);
211
+ }
212
+
213
+ sections.push(
214
+ [
215
+ 'Final output rules:',
216
+ '- JSON only. No prose, no markdown fences.',
217
+ '- Validate every range against file bounds and (for changed-file stops)',
218
+ ' against the annotated diff before returning.'
219
+ ].join('\n')
220
+ );
221
+
222
+ return sections.join('\n\n');
223
+ }
224
+
225
+ module.exports = {
226
+ buildTourPrompt,
227
+ TOUR_PROMPT_MIN_STOPS: PROMPT_MIN_STOPS,
228
+ TOUR_PERSIST_MIN_STOPS: PERSIST_MIN_STOPS,
229
+ TOUR_MAX_STOPS: MAX_STOPS,
230
+ TOUR_TITLE_MAX: TITLE_MAX,
231
+ TOUR_DESCRIPTION_MAX: DESCRIPTION_MAX
232
+ };
@@ -300,7 +300,7 @@ ${rawResponse}
300
300
  }
301
301
 
302
302
  // Use the generic extractJSON for all providers - the LLM should return raw JSON
303
- const extracted = extractJSON(stdout, level);
303
+ const extracted = extractJSON(stdout, level, levelPrefix);
304
304
  if (extracted.success) {
305
305
  logger.success(`${levelPrefix} LLM extraction successful`);
306
306
  settle(extracted);
@@ -604,6 +604,25 @@ function getRegisteredProviderIds() {
604
604
  return Array.from(providerRegistry.keys());
605
605
  }
606
606
 
607
+ /**
608
+ * Resolve a non-executable provider id, preferring `preferredId` if it is
609
+ * non-executable. Falls back to the first registered non-executable provider.
610
+ * Returns null if none are available.
611
+ * @param {string} [preferredId] - Preferred provider id
612
+ * @returns {string|null}
613
+ */
614
+ function resolveNonExecutableProviderId(preferredId) {
615
+ if (preferredId) {
616
+ const cls = getProviderClass(preferredId);
617
+ if (cls && !cls.isExecutable) return preferredId;
618
+ }
619
+ for (const pid of getRegisteredProviderIds()) {
620
+ const cls = getProviderClass(pid);
621
+ if (cls && !cls.isExecutable) return pid;
622
+ }
623
+ return null;
624
+ }
625
+
607
626
  /**
608
627
  * Merge config-override models with a provider's built-in models.
609
628
  * Config models with matching IDs replace built-ins; config models with new IDs
@@ -768,6 +787,7 @@ module.exports = {
768
787
  registerProvider,
769
788
  getProviderClass,
770
789
  getRegisteredProviderIds,
790
+ resolveNonExecutableProviderId,
771
791
  getAllProvidersInfo,
772
792
  createProvider,
773
793
  testProviderAvailability,