@aion0/forge 0.10.37 → 0.10.39
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/RELEASE_NOTES.md +8 -4
- package/app/api/chat/link-patterns/route.ts +23 -0
- package/app/api/onboarding/route.ts +41 -2
- package/app/chat/page.tsx +4 -4
- package/components/MarkdownContent.tsx +59 -3
- package/components/OnboardingWizard.tsx +139 -61
- package/lib/chat/agent-loop.ts +100 -4
- package/lib/chat/link-patterns.ts +138 -0
- package/lib/chat/reference-prompt.ts +81 -0
- package/lib/chat/remark-linkify.ts +132 -0
- package/package.json +2 -1
- package/templates/connector-config-template.json +1 -1
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
# Forge v0.10.
|
|
1
|
+
# Forge v0.10.39
|
|
2
2
|
|
|
3
|
-
Released: 2026-06-
|
|
3
|
+
Released: 2026-06-05
|
|
4
4
|
|
|
5
|
-
## Changes since v0.10.
|
|
5
|
+
## Changes since v0.10.38
|
|
6
6
|
|
|
7
|
+
### Other
|
|
8
|
+
- feat(chat): auto-link bug/MR/CVE refs in AI output (#32)
|
|
9
|
+
- fix(chat): heal orphan tool_use blocks in session history
|
|
7
10
|
|
|
8
|
-
|
|
11
|
+
|
|
12
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.38...v0.10.39
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/chat/link-patterns
|
|
3
|
+
*
|
|
4
|
+
* Returns the chat-output linkify rules with all `{base_url}` placeholders
|
|
5
|
+
* expanded against installed connector configs. Patterns whose connector
|
|
6
|
+
* isn't installed/configured are filtered out — chat UI just renders the
|
|
7
|
+
* remaining ones via remarkLinkify (lib/chat/remark-linkify.ts).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { NextResponse } from 'next/server';
|
|
11
|
+
import { getActiveLinkPatterns, serializePatterns } from '@/lib/chat/link-patterns';
|
|
12
|
+
|
|
13
|
+
export async function GET() {
|
|
14
|
+
try {
|
|
15
|
+
const patterns = serializePatterns(getActiveLinkPatterns());
|
|
16
|
+
return NextResponse.json({ ok: true, patterns });
|
|
17
|
+
} catch (e) {
|
|
18
|
+
return NextResponse.json(
|
|
19
|
+
{ ok: false, error: e instanceof Error ? e.message : String(e), patterns: [] },
|
|
20
|
+
{ status: 500 },
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -58,6 +58,11 @@ interface OnboardingPayload {
|
|
|
58
58
|
setAsDefault?: boolean;
|
|
59
59
|
};
|
|
60
60
|
connectorValues?: Record<string, string>; // template ${key} → value
|
|
61
|
+
/** Subset of template connector ids to install. Missing/empty = install
|
|
62
|
+
* everything in the template. Unselected ids are skipped entirely —
|
|
63
|
+
* no installFromRegistry, no setConnectorConfig. Lets users opt out of
|
|
64
|
+
* Jenkins/NAC/FortiNCM etc. on first run. */
|
|
65
|
+
selectedConnectors?: string[];
|
|
61
66
|
pipelines?: string[]; // marketplace names to install
|
|
62
67
|
projectRoots?: string[]; // paths to append
|
|
63
68
|
}
|
|
@@ -154,13 +159,19 @@ function applyCliAgent(a: OnboardingPayload['cliAgent']): string | null {
|
|
|
154
159
|
return null;
|
|
155
160
|
}
|
|
156
161
|
|
|
157
|
-
async function applyConnectors(
|
|
162
|
+
async function applyConnectors(
|
|
163
|
+
values: Record<string, string> | undefined,
|
|
164
|
+
selectedConnectors: string[] | undefined,
|
|
165
|
+
): Promise<{
|
|
158
166
|
applied: string[];
|
|
159
167
|
installed_from_registry: string[];
|
|
160
168
|
skipped_missing_manifest: string[];
|
|
169
|
+
skipped_unselected: string[];
|
|
161
170
|
fields_preserved: Array<{ connector: string; field: string }>;
|
|
162
171
|
}> {
|
|
163
172
|
const template = resolveTemplate();
|
|
173
|
+
// Undefined = install everything; explicit [] = install nothing.
|
|
174
|
+
const selected = selectedConnectors ? new Set(selectedConnectors) : null;
|
|
164
175
|
// Auto-inject user identity from settings so connectors (e.g. tp.username)
|
|
165
176
|
// can reference {user_name} / {user_email} without prompting again.
|
|
166
177
|
// User-supplied values still win over auto-injected ones.
|
|
@@ -173,10 +184,12 @@ async function applyConnectors(values: Record<string, string> | undefined): Prom
|
|
|
173
184
|
const applied: string[] = [];
|
|
174
185
|
const installedFromRegistry: string[] = [];
|
|
175
186
|
const missing: string[] = [];
|
|
187
|
+
const skippedUnselected: string[] = [];
|
|
176
188
|
const preserved: Array<{ connector: string; field: string }> = [];
|
|
177
189
|
|
|
178
190
|
for (const [id, row] of Object.entries(template)) {
|
|
179
191
|
if (id.startsWith('_')) continue; // metadata keys
|
|
192
|
+
if (selected && !selected.has(id)) { skippedUnselected.push(id); continue; }
|
|
180
193
|
let def = getConnector(id);
|
|
181
194
|
if (!def) {
|
|
182
195
|
// Manifest not on disk yet — fetch from forge-connectors registry
|
|
@@ -217,6 +230,7 @@ async function applyConnectors(values: Record<string, string> | undefined): Prom
|
|
|
217
230
|
applied,
|
|
218
231
|
installed_from_registry: installedFromRegistry,
|
|
219
232
|
skipped_missing_manifest: missing,
|
|
233
|
+
skipped_unselected: skippedUnselected,
|
|
220
234
|
fields_preserved: preserved,
|
|
221
235
|
};
|
|
222
236
|
}
|
|
@@ -299,6 +313,30 @@ export async function GET() {
|
|
|
299
313
|
// which froze Dashboard mount + every other API on the same worker.
|
|
300
314
|
const detected: Array<{ name: string; path: string; version: string }> = [];
|
|
301
315
|
|
|
316
|
+
// Connector list from the template (excluding `_*` metadata keys),
|
|
317
|
+
// in template order. UI renders this as checkboxes — default all
|
|
318
|
+
// selected, user can opt out before Apply. `default_enabled` mirrors
|
|
319
|
+
// the template's `enabled:` (e.g. fortincm is opt-in).
|
|
320
|
+
const templateConnectors: Array<{
|
|
321
|
+
id: string;
|
|
322
|
+
default_enabled: boolean;
|
|
323
|
+
has_prompts: boolean;
|
|
324
|
+
already_installed: boolean;
|
|
325
|
+
}> = [];
|
|
326
|
+
for (const [connId, row] of Object.entries(template)) {
|
|
327
|
+
if (connId.startsWith('_')) continue;
|
|
328
|
+
const cfg = (row as any)?.config ?? {};
|
|
329
|
+
const hasPrompts = Object.values(cfg).some(
|
|
330
|
+
v => typeof v === 'string' && /\$\{[a-zA-Z0-9_]+\}/.test(v),
|
|
331
|
+
);
|
|
332
|
+
templateConnectors.push({
|
|
333
|
+
id: connId,
|
|
334
|
+
default_enabled: (row as any)?.enabled !== false,
|
|
335
|
+
has_prompts: hasPrompts,
|
|
336
|
+
already_installed: !!getInstalledConnector(connId),
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
302
340
|
// Per-prompt "already set" probe — for each ${key} the template uses,
|
|
303
341
|
// find which connector field references it and check if that field
|
|
304
342
|
// is already non-empty. Lets the wizard show "•••• (keep existing)"
|
|
@@ -342,6 +380,7 @@ export async function GET() {
|
|
|
342
380
|
projectRoots: settings.projectRoots || [],
|
|
343
381
|
},
|
|
344
382
|
detected_cli: detected,
|
|
383
|
+
template_connectors: templateConnectors,
|
|
345
384
|
template_prompts: template._prompts || {},
|
|
346
385
|
prompt_values_set: promptValuesSet,
|
|
347
386
|
prompt_targets: promptTargets,
|
|
@@ -392,7 +431,7 @@ export async function POST(req: Request) {
|
|
|
392
431
|
}
|
|
393
432
|
|
|
394
433
|
try {
|
|
395
|
-
const r = await applyConnectors(payload.connectorValues);
|
|
434
|
+
const r = await applyConnectors(payload.connectorValues, payload.selectedConnectors);
|
|
396
435
|
phases.push({ phase: 'connectors', ok: true, detail: r });
|
|
397
436
|
} catch (e) {
|
|
398
437
|
phases.push({ phase: 'connectors', ok: false, error: e instanceof Error ? e.message : String(e) });
|
package/app/chat/page.tsx
CHANGED
|
@@ -386,7 +386,7 @@ export default function ChatPage() {
|
|
|
386
386
|
))}
|
|
387
387
|
{partial && (
|
|
388
388
|
<RoleBlock role="assistant">
|
|
389
|
-
<MarkdownContent content={partial} />
|
|
389
|
+
<MarkdownContent content={partial} linkify />
|
|
390
390
|
<span className="inline-block w-2 h-3 ml-0.5 align-text-bottom bg-[var(--accent)] animate-pulse" />
|
|
391
391
|
</RoleBlock>
|
|
392
392
|
)}
|
|
@@ -501,7 +501,7 @@ function MessageView({ m }: { m: Message }) {
|
|
|
501
501
|
return (
|
|
502
502
|
<RoleBlock role={m.role} ts={m.ts}>
|
|
503
503
|
{m.blocks.map((b, i) => (
|
|
504
|
-
<BlockView key={i} b={b} />
|
|
504
|
+
<BlockView key={i} b={b} role={m.role} />
|
|
505
505
|
))}
|
|
506
506
|
{m.error && (
|
|
507
507
|
<div className="text-xs text-red-400 border border-red-500/30 bg-red-500/5 rounded-md p-2 mt-1">
|
|
@@ -512,9 +512,9 @@ function MessageView({ m }: { m: Message }) {
|
|
|
512
512
|
);
|
|
513
513
|
}
|
|
514
514
|
|
|
515
|
-
function BlockView({ b }: { b: ContentBlock }) {
|
|
515
|
+
function BlockView({ b, role }: { b: ContentBlock; role?: 'user' | 'assistant' }) {
|
|
516
516
|
if (b.type === 'text') {
|
|
517
|
-
return <MarkdownContent content={b.text} />;
|
|
517
|
+
return <MarkdownContent content={b.text} linkify={role === 'assistant'} />;
|
|
518
518
|
}
|
|
519
519
|
if (b.type === 'tool_use') {
|
|
520
520
|
return <ToolUseBlockView name={b.name} input={b.input} />;
|
|
@@ -1,12 +1,68 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
3
4
|
import Markdown from 'react-markdown';
|
|
4
5
|
import remarkGfm from 'remark-gfm';
|
|
6
|
+
import { remarkLinkify } from '@/lib/chat/remark-linkify';
|
|
7
|
+
import type { SerializablePattern } from '@/lib/chat/link-patterns';
|
|
8
|
+
|
|
9
|
+
// Module-level cache so every MarkdownContent instance shares one fetch.
|
|
10
|
+
// Patterns rarely change (only when connector base_url is edited) so a
|
|
11
|
+
// per-tab cache is sufficient; refresh requires a reload.
|
|
12
|
+
let _patternsCache: SerializablePattern[] | null = null;
|
|
13
|
+
let _patternsPromise: Promise<SerializablePattern[]> | null = null;
|
|
14
|
+
function loadPatterns(): Promise<SerializablePattern[]> {
|
|
15
|
+
if (_patternsCache) return Promise.resolve(_patternsCache);
|
|
16
|
+
if (_patternsPromise) return _patternsPromise;
|
|
17
|
+
_patternsPromise = fetch('/api/chat/link-patterns')
|
|
18
|
+
.then(r => r.json())
|
|
19
|
+
.then(j => {
|
|
20
|
+
_patternsCache = Array.isArray(j?.patterns) ? j.patterns : [];
|
|
21
|
+
return _patternsCache!;
|
|
22
|
+
})
|
|
23
|
+
.catch(() => {
|
|
24
|
+
_patternsCache = [];
|
|
25
|
+
return _patternsCache;
|
|
26
|
+
});
|
|
27
|
+
return _patternsPromise;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default function MarkdownContent({
|
|
31
|
+
content,
|
|
32
|
+
linkify,
|
|
33
|
+
}: {
|
|
34
|
+
content: string;
|
|
35
|
+
/** Enable auto-link of bug#/!MR/CVE etc. Only set true for AI chat output. */
|
|
36
|
+
linkify?: boolean;
|
|
37
|
+
}) {
|
|
38
|
+
const [patterns, setPatterns] = useState<SerializablePattern[]>(() =>
|
|
39
|
+
linkify ? (_patternsCache || []) : [],
|
|
40
|
+
);
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!linkify) return;
|
|
43
|
+
if (_patternsCache) { setPatterns(_patternsCache); return; }
|
|
44
|
+
let cancelled = false;
|
|
45
|
+
loadPatterns().then(p => { if (!cancelled) setPatterns(p); });
|
|
46
|
+
return () => { cancelled = true; };
|
|
47
|
+
}, [linkify]);
|
|
48
|
+
|
|
49
|
+
// remarkPlugins uses unified's plugin convention:
|
|
50
|
+
// `plugin` alone → unified calls `plugin()` internally
|
|
51
|
+
// `[plugin, opts]` → unified calls `plugin(opts)` internally
|
|
52
|
+
// Passing `remarkLinkify(patterns)` directly was wrong — that's an
|
|
53
|
+
// already-resolved transformer; unified would call it again with no
|
|
54
|
+
// tree, which crashed with "Cannot use 'in' operator to search for
|
|
55
|
+
// 'children' in undefined".
|
|
56
|
+
const plugins: any[] = linkify && patterns.length > 0
|
|
57
|
+
? [remarkGfm, [remarkLinkify, patterns]]
|
|
58
|
+
: [remarkGfm];
|
|
59
|
+
|
|
60
|
+
// Defensive — react-markdown chokes if children is undefined/non-string.
|
|
61
|
+
const safeContent = typeof content === 'string' ? content : '';
|
|
5
62
|
|
|
6
|
-
export default function MarkdownContent({ content }: { content: string }) {
|
|
7
63
|
return (
|
|
8
64
|
<Markdown
|
|
9
|
-
remarkPlugins={
|
|
65
|
+
remarkPlugins={plugins}
|
|
10
66
|
components={{
|
|
11
67
|
h1: ({ children }) => <h1 className="text-base font-bold text-[var(--text-primary)] mt-3 mb-1">{children}</h1>,
|
|
12
68
|
h2: ({ children }) => <h2 className="text-sm font-bold text-[var(--text-primary)] mt-3 mb-1">{children}</h2>,
|
|
@@ -67,7 +123,7 @@ export default function MarkdownContent({ content }: { content: string }) {
|
|
|
67
123
|
td: ({ children }) => <td className="border border-[var(--border)] px-3 py-1.5 text-[11px]">{children}</td>,
|
|
68
124
|
}}
|
|
69
125
|
>
|
|
70
|
-
{
|
|
126
|
+
{safeContent}
|
|
71
127
|
</Markdown>
|
|
72
128
|
);
|
|
73
129
|
}
|
|
@@ -49,6 +49,13 @@ interface OnboardingState {
|
|
|
49
49
|
projectRoots: string[];
|
|
50
50
|
};
|
|
51
51
|
detected_cli: Array<{ name: string; path: string; version: string }>;
|
|
52
|
+
/** All non-meta connector ids in the template, in template order. */
|
|
53
|
+
template_connectors: Array<{
|
|
54
|
+
id: string;
|
|
55
|
+
default_enabled: boolean;
|
|
56
|
+
has_prompts: boolean;
|
|
57
|
+
already_installed: boolean;
|
|
58
|
+
}>;
|
|
52
59
|
template_prompts: Record<string, PromptDef>;
|
|
53
60
|
/** Per ${key} prompt: true = at least one target field already has a real value;
|
|
54
61
|
* leave blank to keep it. Backend's applyConnectors preserves existing on empty. */
|
|
@@ -138,6 +145,11 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
|
|
|
138
145
|
|
|
139
146
|
// Section 3: Connector template values (one per ${key})
|
|
140
147
|
const [connectorValues, setConnectorValues] = useState<Record<string, string>>({});
|
|
148
|
+
// Section 3.5: which template connectors to install. Default = all
|
|
149
|
+
// (populated from template_connectors on state load). User-typed values
|
|
150
|
+
// for un-selected connectors are kept in connectorValues so re-checking
|
|
151
|
+
// restores them — only the payload skips unselected ones.
|
|
152
|
+
const [selectedConnectors, setSelectedConnectors] = useState<Set<string>>(new Set());
|
|
141
153
|
|
|
142
154
|
// Section 4: Pipelines
|
|
143
155
|
const [selectedPipelines, setSelectedPipelines] = useState<Set<string>>(new Set());
|
|
@@ -179,7 +191,7 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
|
|
|
179
191
|
// drawer into a "checks" view that runs glab-sync / login-status /
|
|
180
192
|
// backend monitor probes — so users see what's actually live before
|
|
181
193
|
// we dismiss the wizard.
|
|
182
|
-
type CheckState = { status: 'pending' | 'running' | 'ok' | 'fail'; message?: string; detail?: any };
|
|
194
|
+
type CheckState = { status: 'pending' | 'running' | 'ok' | 'fail' | 'skipped'; message?: string; detail?: any };
|
|
183
195
|
const [phase, setPhase] = useState<'form' | 'checks'>('form');
|
|
184
196
|
const [checks, setChecks] = useState<Record<string, CheckState>>({
|
|
185
197
|
glabCli: { status: 'pending' },
|
|
@@ -188,19 +200,29 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
|
|
|
188
200
|
});
|
|
189
201
|
|
|
190
202
|
async function runChecks() {
|
|
191
|
-
// glab sync — only meaningful
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
})
|
|
203
|
-
|
|
203
|
+
// glab sync — only meaningful when the user opted into gitlab.
|
|
204
|
+
// Avoid hitting the API at all when gitlab isn't selected, otherwise
|
|
205
|
+
// the route returns a hard error ("not installed or not enabled")
|
|
206
|
+
// and the check shows red for something the user explicitly skipped.
|
|
207
|
+
if (!selectedConnectors.has('gitlab')) {
|
|
208
|
+
setChecks(c => ({ ...c, glabCli: {
|
|
209
|
+
status: 'skipped',
|
|
210
|
+
message: 'gitlab not selected — skipped',
|
|
211
|
+
} }));
|
|
212
|
+
} else {
|
|
213
|
+
setChecks(c => ({ ...c, glabCli: { status: 'running' } }));
|
|
214
|
+
fetch('/api/connectors/gitlab/sync-cli', { method: 'POST' })
|
|
215
|
+
.then(r => r.json().then(j => ({ ok: r.ok && j.ok !== false, j })))
|
|
216
|
+
.then(({ ok, j }) => setChecks(c => ({
|
|
217
|
+
...c,
|
|
218
|
+
glabCli: {
|
|
219
|
+
status: ok ? 'ok' : 'fail',
|
|
220
|
+
message: ok ? 'glab CLI synced with GitLab token' : (j.error || j.stderr || 'sync failed'),
|
|
221
|
+
detail: j,
|
|
222
|
+
},
|
|
223
|
+
})))
|
|
224
|
+
.catch(e => setChecks(c => ({ ...c, glabCli: { status: 'fail', message: e.message } })));
|
|
225
|
+
}
|
|
204
226
|
|
|
205
227
|
// Login status — POST triggers fresh check across all sources.
|
|
206
228
|
setChecks(c => ({ ...c, loginStatus: { status: 'running' } }));
|
|
@@ -250,7 +272,7 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
|
|
|
250
272
|
if (!initializedRef.current) { initializedRef.current = true; return; }
|
|
251
273
|
setDirty(true);
|
|
252
274
|
}, [displayName, displayEmail, apiKey, apiBaseUrl, apiModel, apiProfileId, apiProvider,
|
|
253
|
-
connectorValues, selectedPipelines, projectInput, cliAgentId, cliAgentPath]);
|
|
275
|
+
connectorValues, selectedConnectors, selectedPipelines, projectInput, cliAgentId, cliAgentPath]);
|
|
254
276
|
// Inline marketplace sync (so user doesn't have to leave the wizard
|
|
255
277
|
// to populate the pipeline list when it's empty / stale).
|
|
256
278
|
const [syncingMarket, setSyncingMarket] = useState(false);
|
|
@@ -301,6 +323,20 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
|
|
|
301
323
|
setDisplayEmail(s.current.displayEmail);
|
|
302
324
|
setSelectedPipelines(new Set(s.suggested_pipelines));
|
|
303
325
|
|
|
326
|
+
// Default-select rules:
|
|
327
|
+
// - template ships enabled=true → on by default
|
|
328
|
+
// - user already installed it (any prior run / manual setup) →
|
|
329
|
+
// on, even if template ships enabled=false (e.g. fortincm).
|
|
330
|
+
// Otherwise re-running the wizard would silently deselect a
|
|
331
|
+
// working connector and the next Apply would skip writing
|
|
332
|
+
// fresh tokens to it.
|
|
333
|
+
const defaultSelected = new Set(
|
|
334
|
+
(s.template_connectors || [])
|
|
335
|
+
.filter(c => c.default_enabled || c.already_installed)
|
|
336
|
+
.map(c => c.id),
|
|
337
|
+
);
|
|
338
|
+
setSelectedConnectors(defaultSelected);
|
|
339
|
+
|
|
304
340
|
// Pre-fill any prompt that declares a `default` (e.g. jenkins
|
|
305
341
|
// instance name = "default-jenkins"). User-set values from a prior
|
|
306
342
|
// run would shadow these via the "currently set" path on apply.
|
|
@@ -339,7 +375,7 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
|
|
|
339
375
|
}).catch(() => setState({
|
|
340
376
|
ok: false, onboardingCompleted: false,
|
|
341
377
|
current: { displayName: '', displayEmail: '', apiProfileIds: [], apiProfile: null, chatAgent: '', defaultAgent: '', agents: [], projectRoots: [] },
|
|
342
|
-
detected_cli: [], template_prompts: {}, prompt_values_set: {}, prompt_targets: {}, suggested_pipelines: [],
|
|
378
|
+
detected_cli: [], template_connectors: [], template_prompts: {}, prompt_values_set: {}, prompt_targets: {}, suggested_pipelines: [],
|
|
343
379
|
}));
|
|
344
380
|
}, []);
|
|
345
381
|
|
|
@@ -356,9 +392,19 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
|
|
|
356
392
|
if (applying) return;
|
|
357
393
|
setApplying(true); setResult(null);
|
|
358
394
|
try {
|
|
395
|
+
// Strip values whose target connector got unchecked — keeping them
|
|
396
|
+
// in state lets the user re-check and recover the typed value, but
|
|
397
|
+
// we don't want to write tokens into connectors we're not installing.
|
|
398
|
+
const filteredValues: Record<string, string> = {};
|
|
399
|
+
for (const [k, v] of Object.entries(connectorValues)) {
|
|
400
|
+
const targets = state?.prompt_targets?.[k] || [];
|
|
401
|
+
const anyTargetSelected = targets.some(t => selectedConnectors.has(t.connector));
|
|
402
|
+
if (anyTargetSelected || targets.length === 0) filteredValues[k] = v;
|
|
403
|
+
}
|
|
359
404
|
const payload: any = {
|
|
360
405
|
identity: { displayName, displayEmail },
|
|
361
|
-
connectorValues,
|
|
406
|
+
connectorValues: filteredValues,
|
|
407
|
+
selectedConnectors: Array.from(selectedConnectors),
|
|
362
408
|
pipelines: Array.from(selectedPipelines),
|
|
363
409
|
projectRoots: projectInput.split(/[\n,]/).map(s => s.trim()).filter(Boolean),
|
|
364
410
|
};
|
|
@@ -638,54 +684,84 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
|
|
|
638
684
|
</Section>
|
|
639
685
|
|
|
640
686
|
{/* ── 3. Connectors ────────────────────────────────────── */}
|
|
641
|
-
<Section title="3. Connector tokens" hint="
|
|
642
|
-
{
|
|
687
|
+
<Section title="3. Connector tokens" hint="Pick which connectors to install. Default = team-recommended set. Shared keys (e.g. GitLab PAT also used by Jenkins) only asked once.">
|
|
688
|
+
{(state.template_connectors || []).length === 0 && (
|
|
643
689
|
<p className="text-[10px] text-amber-500">
|
|
644
690
|
No template loaded. Make sure the marketplace has been synced (Settings → Connectors → Sync).
|
|
645
691
|
</p>
|
|
646
692
|
)}
|
|
647
|
-
{
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
{p.label}
|
|
662
|
-
{p.required && !isSet && <span className="text-red-500">*</span>}
|
|
663
|
-
{!p.required && !isSet && <span className="text-[9px] text-[var(--text-secondary)]">(optional)</span>}
|
|
664
|
-
{isSet && <span className="text-[9px] text-emerald-500">● currently set</span>}
|
|
665
|
-
{p.url && (
|
|
666
|
-
<a href={p.url} target="_blank" rel="noopener noreferrer" className="ml-auto text-[10px] text-[var(--accent)] hover:underline" title={p.url}>
|
|
667
|
-
↗ {p.url_label || 'Get token'}
|
|
668
|
-
</a>
|
|
669
|
-
)}
|
|
670
|
-
</label>
|
|
671
|
-
{p.hint && <p className="text-[9px] text-[var(--text-secondary)] leading-snug">{p.hint}</p>}
|
|
672
|
-
{sharedWith.length > 0 && (
|
|
673
|
-
<p className="text-[9px] text-[var(--text-secondary)] font-mono">
|
|
674
|
-
also used by: {sharedWith.map(t => `${t.connector}.${t.field}`).join(', ')}
|
|
675
|
-
</p>
|
|
676
|
-
)}
|
|
677
|
-
<input
|
|
678
|
-
type={p.secret ? 'password' : 'text'}
|
|
679
|
-
className={inputCls + ' font-mono'}
|
|
680
|
-
value={v}
|
|
681
|
-
onChange={e => setConnectorValues({ ...connectorValues, [key]: e.target.value })}
|
|
682
|
-
placeholder={isSet ? '•••••••• (leave blank to keep current)' : (p.required ? 'required' : 'leave blank')}
|
|
683
|
-
/>
|
|
684
|
-
</div>
|
|
685
|
-
);
|
|
686
|
-
})}
|
|
693
|
+
{/* Bulk select/deselect */}
|
|
694
|
+
{(state.template_connectors || []).length > 0 && (
|
|
695
|
+
<div className="flex items-center gap-1.5 text-[10px] text-[var(--text-secondary)]">
|
|
696
|
+
<button
|
|
697
|
+
type="button"
|
|
698
|
+
onClick={() => setSelectedConnectors(new Set((state.template_connectors || []).map(c => c.id)))}
|
|
699
|
+
className="px-1.5 py-0.5 border border-[var(--border)] rounded hover:border-[var(--text-primary)]"
|
|
700
|
+
>Select all</button>
|
|
701
|
+
<button
|
|
702
|
+
type="button"
|
|
703
|
+
onClick={() => setSelectedConnectors(new Set())}
|
|
704
|
+
className="px-1.5 py-0.5 border border-[var(--border)] rounded hover:border-[var(--text-primary)]"
|
|
705
|
+
>Deselect all</button>
|
|
706
|
+
<span className="ml-auto">{selectedConnectors.size}/{(state.template_connectors || []).length} selected</span>
|
|
687
707
|
</div>
|
|
688
|
-
)
|
|
708
|
+
)}
|
|
709
|
+
{(state.template_connectors || []).map(({ id: connector, has_prompts, already_installed }) => {
|
|
710
|
+
const checked = selectedConnectors.has(connector);
|
|
711
|
+
const promptKeys = (promptGroups.find(([c]) => c === connector)?.[1]) || [];
|
|
712
|
+
const toggle = () => {
|
|
713
|
+
setSelectedConnectors(prev => {
|
|
714
|
+
const next = new Set(prev);
|
|
715
|
+
if (next.has(connector)) next.delete(connector); else next.add(connector);
|
|
716
|
+
return next;
|
|
717
|
+
});
|
|
718
|
+
};
|
|
719
|
+
return (
|
|
720
|
+
<div key={connector} className={`border-l-2 pl-2.5 space-y-2 ${checked ? 'border-[var(--accent)]/40' : 'border-[var(--border)] opacity-60'}`}>
|
|
721
|
+
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer">
|
|
722
|
+
<input type="checkbox" checked={checked} onChange={toggle} />
|
|
723
|
+
<span className="font-mono uppercase tracking-wider text-[var(--accent)]">{connector}</span>
|
|
724
|
+
{already_installed && <span className="text-[9px] text-emerald-500">● installed</span>}
|
|
725
|
+
{!has_prompts && <span className="text-[9px] text-[var(--text-secondary)]">(no tokens needed)</span>}
|
|
726
|
+
</label>
|
|
727
|
+
{checked && promptKeys.map(key => {
|
|
728
|
+
const p = prompts[key];
|
|
729
|
+
const v = connectorValues[key] ?? '';
|
|
730
|
+
const isSet = !!state.prompt_values_set?.[key];
|
|
731
|
+
const targets = state.prompt_targets?.[key] || [];
|
|
732
|
+
const sharedWith = targets.filter(t => t.connector !== connector);
|
|
733
|
+
return (
|
|
734
|
+
<div key={key} className="space-y-1 ml-5">
|
|
735
|
+
<label className="text-[10px] font-medium text-[var(--text-primary)] flex items-center gap-1.5 flex-wrap">
|
|
736
|
+
{p.label}
|
|
737
|
+
{p.required && !isSet && <span className="text-red-500">*</span>}
|
|
738
|
+
{!p.required && !isSet && <span className="text-[9px] text-[var(--text-secondary)]">(optional)</span>}
|
|
739
|
+
{isSet && <span className="text-[9px] text-emerald-500">● currently set</span>}
|
|
740
|
+
{p.url && (
|
|
741
|
+
<a href={p.url} target="_blank" rel="noopener noreferrer" className="ml-auto text-[10px] text-[var(--accent)] hover:underline" title={p.url}>
|
|
742
|
+
↗ {p.url_label || 'Get token'}
|
|
743
|
+
</a>
|
|
744
|
+
)}
|
|
745
|
+
</label>
|
|
746
|
+
{p.hint && <p className="text-[9px] text-[var(--text-secondary)] leading-snug">{p.hint}</p>}
|
|
747
|
+
{sharedWith.length > 0 && (
|
|
748
|
+
<p className="text-[9px] text-[var(--text-secondary)] font-mono">
|
|
749
|
+
also used by: {sharedWith.map(t => `${t.connector}.${t.field}`).join(', ')}
|
|
750
|
+
</p>
|
|
751
|
+
)}
|
|
752
|
+
<input
|
|
753
|
+
type={p.secret ? 'password' : 'text'}
|
|
754
|
+
className={inputCls + ' font-mono'}
|
|
755
|
+
value={v}
|
|
756
|
+
onChange={e => setConnectorValues({ ...connectorValues, [key]: e.target.value })}
|
|
757
|
+
placeholder={isSet ? '•••••••• (leave blank to keep current)' : (p.required ? 'required' : 'leave blank')}
|
|
758
|
+
/>
|
|
759
|
+
</div>
|
|
760
|
+
);
|
|
761
|
+
})}
|
|
762
|
+
</div>
|
|
763
|
+
);
|
|
764
|
+
})}
|
|
689
765
|
</Section>
|
|
690
766
|
|
|
691
767
|
{/* ── 4. Pipelines ─────────────────────────────────────── */}
|
|
@@ -876,17 +952,19 @@ function CheckRow({
|
|
|
876
952
|
}: {
|
|
877
953
|
title: string;
|
|
878
954
|
hint?: string;
|
|
879
|
-
state: { status: 'pending' | 'running' | 'ok' | 'fail'; message?: string; detail?: any };
|
|
955
|
+
state: { status: 'pending' | 'running' | 'ok' | 'fail' | 'skipped'; message?: string; detail?: any };
|
|
880
956
|
renderDetail?: (d: any) => React.ReactNode;
|
|
881
957
|
}) {
|
|
882
958
|
const [open, setOpen] = useState(false);
|
|
883
959
|
const icon = state.status === 'ok' ? '✓'
|
|
884
960
|
: state.status === 'fail' ? '✗'
|
|
885
961
|
: state.status === 'running' ? '⟳'
|
|
962
|
+
: state.status === 'skipped' ? '–'
|
|
886
963
|
: '○';
|
|
887
964
|
const color = state.status === 'ok' ? 'text-emerald-500'
|
|
888
965
|
: state.status === 'fail' ? 'text-red-400'
|
|
889
966
|
: state.status === 'running' ? 'text-amber-500'
|
|
967
|
+
: state.status === 'skipped' ? 'text-[var(--text-secondary)]'
|
|
890
968
|
: 'text-[var(--text-secondary)]';
|
|
891
969
|
return (
|
|
892
970
|
<div className="border border-[var(--border)] rounded p-2.5 space-y-1">
|
package/lib/chat/agent-loop.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
} from './tool-dispatcher';
|
|
26
26
|
import { getMemoryStore } from './memory-store';
|
|
27
27
|
import { buildMemoryContext } from './build-memory-context';
|
|
28
|
+
import { buildReferencePromptSection } from './reference-prompt';
|
|
28
29
|
import { buildMemoryTools } from './memory-tools';
|
|
29
30
|
import { buildStartWatchTool } from '../watch/start-watch-tool';
|
|
30
31
|
import { estimateTokens } from '../memory/token-estimate';
|
|
@@ -98,6 +99,84 @@ function trimOrphanToolResults(history: Message[]): Message[] {
|
|
|
98
99
|
return i === 0 ? history : history.slice(i);
|
|
99
100
|
}
|
|
100
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Heal sessions where an assistant `tool_use` lacks a matching follow-up
|
|
104
|
+
* `tool_result` (e.g. user hit Stop mid-execution, stream crashed, tool
|
|
105
|
+
* runner threw without persisting). Both Anthropic and OpenAI reject
|
|
106
|
+
* requests with orphan tool calls — AI SDK surfaces this as
|
|
107
|
+
* `AI_MissingToolResultsError`.
|
|
108
|
+
*
|
|
109
|
+
* Strategy: for every assistant message containing tool_use blocks,
|
|
110
|
+
* collect the ids that are NOT covered by the immediately-next message's
|
|
111
|
+
* tool_results, then synthesize stub `tool_result` blocks for the
|
|
112
|
+
* missing ones and prepend them to that next user message (creating one
|
|
113
|
+
* if needed). Synthesized result text says the call was interrupted so
|
|
114
|
+
* the model can recover sensibly on retry.
|
|
115
|
+
*/
|
|
116
|
+
function healOrphanToolUses(history: Message[]): Message[] {
|
|
117
|
+
if (history.length === 0) return history;
|
|
118
|
+
const out: Message[] = [];
|
|
119
|
+
for (let i = 0; i < history.length; i += 1) {
|
|
120
|
+
const m = history[i];
|
|
121
|
+
out.push(m);
|
|
122
|
+
if (m.role !== 'assistant' || !Array.isArray(m.blocks)) continue;
|
|
123
|
+
|
|
124
|
+
const toolUses = m.blocks.filter(
|
|
125
|
+
(b): b is ToolUseBlock => (b as any)?.type === 'tool_use',
|
|
126
|
+
);
|
|
127
|
+
if (toolUses.length === 0) continue;
|
|
128
|
+
|
|
129
|
+
// Examine the next message (if any) for matching tool_results.
|
|
130
|
+
const next = history[i + 1];
|
|
131
|
+
const coveredIds = new Set<string>();
|
|
132
|
+
if (next?.role === 'user' && Array.isArray(next.blocks)) {
|
|
133
|
+
for (const b of next.blocks) {
|
|
134
|
+
if ((b as any).type === 'tool_result' && (b as ToolResultBlock).tool_use_id) {
|
|
135
|
+
coveredIds.add((b as ToolResultBlock).tool_use_id);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const missing = toolUses.filter((tu) => !coveredIds.has(tu.id));
|
|
141
|
+
if (missing.length === 0) continue;
|
|
142
|
+
|
|
143
|
+
console.warn(
|
|
144
|
+
`[agent-loop] healed ${missing.length} orphan tool_use(s) in session history ` +
|
|
145
|
+
`(ids: ${missing.map((t) => t.id).join(', ')}). Likely cause: cancelled / crashed prior turn.`,
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
const stubBlocks: ToolResultBlock[] = missing.map((tu) => ({
|
|
149
|
+
type: 'tool_result',
|
|
150
|
+
tool_use_id: tu.id,
|
|
151
|
+
content: JSON.stringify({
|
|
152
|
+
ok: false,
|
|
153
|
+
error: 'previous tool call did not finish (interrupted or crashed); retry the request',
|
|
154
|
+
}),
|
|
155
|
+
is_error: true,
|
|
156
|
+
}));
|
|
157
|
+
|
|
158
|
+
if (next?.role === 'user' && Array.isArray(next.blocks)
|
|
159
|
+
&& next.blocks.some((b) => (b as any).type === 'tool_result')) {
|
|
160
|
+
// Existing pairing message — splice stubs in front of its blocks
|
|
161
|
+
// so the surviving real results still apply in their original order.
|
|
162
|
+
history[i + 1] = { ...next, blocks: [...stubBlocks, ...next.blocks] };
|
|
163
|
+
} else {
|
|
164
|
+
// No follow-up at all — inject a fresh user message right after this
|
|
165
|
+
// assistant turn. We mutate `history` so the loop sees it next iter,
|
|
166
|
+
// but push to `out` here so order is preserved.
|
|
167
|
+
const injected: Message = {
|
|
168
|
+
id: `synthetic-${m.id}`,
|
|
169
|
+
session_id: m.session_id,
|
|
170
|
+
role: 'user',
|
|
171
|
+
blocks: stubBlocks,
|
|
172
|
+
ts: m.ts + 1,
|
|
173
|
+
};
|
|
174
|
+
out.push(injected);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
101
180
|
export interface AgentEvent {
|
|
102
181
|
type:
|
|
103
182
|
| 'text_delta' // assistant text streaming
|
|
@@ -324,6 +403,19 @@ function buildSystemPrompt(
|
|
|
324
403
|
}
|
|
325
404
|
}
|
|
326
405
|
|
|
406
|
+
// Reference format guide — encourages the LLM to emit markdown links
|
|
407
|
+
// for bug ids / MR ids / CVE etc., using each connector's real base_url.
|
|
408
|
+
// Front-end remark-linkify (lib/chat/remark-linkify.ts) is the safety
|
|
409
|
+
// net when the LLM forgets and writes plain text.
|
|
410
|
+
// Wrap in try/catch — system prompt assembly is hot path; an unread
|
|
411
|
+
// connector-configs.json must not break the entire chat turn.
|
|
412
|
+
try {
|
|
413
|
+
const refSection = buildReferencePromptSection();
|
|
414
|
+
if (refSection) lines.push('', refSection);
|
|
415
|
+
} catch (e) {
|
|
416
|
+
console.warn('[agent-loop] buildReferencePromptSection failed:', (e as Error).message);
|
|
417
|
+
}
|
|
418
|
+
|
|
327
419
|
if (sessionSystemPrompt) {
|
|
328
420
|
lines.push('', sessionSystemPrompt);
|
|
329
421
|
}
|
|
@@ -651,8 +743,10 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
|
|
|
651
743
|
// the LLM calls connector_open, subsequent iterations pick that up.
|
|
652
744
|
// (Computed off a preview slice of history — refined below once
|
|
653
745
|
// we have the real history under budget.)
|
|
654
|
-
const previewHistory =
|
|
655
|
-
|
|
746
|
+
const previewHistory = healOrphanToolUses(
|
|
747
|
+
trimOrphanToolResults(
|
|
748
|
+
listMessagesCapped(args.sessionId, HISTORY_MSG_BUDGET, 8_000, estimateTokens),
|
|
749
|
+
),
|
|
656
750
|
);
|
|
657
751
|
const newOpenSet = computeOpenSet(previewHistory, assistantBlocksAccum);
|
|
658
752
|
const setChanged = newOpenSet.size !== openSet.size ||
|
|
@@ -704,8 +798,10 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
|
|
|
704
798
|
return { ok: false, error: 'profile context budget exhausted' };
|
|
705
799
|
}
|
|
706
800
|
|
|
707
|
-
const history =
|
|
708
|
-
|
|
801
|
+
const history = healOrphanToolUses(
|
|
802
|
+
trimOrphanToolResults(
|
|
803
|
+
listMessagesCapped(args.sessionId, HISTORY_MSG_BUDGET, historyBudget, estimateTokens),
|
|
804
|
+
),
|
|
709
805
|
);
|
|
710
806
|
if (history.length === 0) {
|
|
711
807
|
cb({ type: 'error', data: { error: 'Conversation context is empty after trimming an oversized result. Clear the chat or retry with a narrower query.' } });
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat auto-link patterns — turn natural prose like `Mantis #1226625`,
|
|
3
|
+
* `MR !14860`, `bug 1234567`, `CVE-2024-0001` into clickable links.
|
|
4
|
+
*
|
|
5
|
+
* Disambiguation: every pattern requires a prose anchor word — `Mantis`,
|
|
6
|
+
* `bug`, `MR`, `merge request`, `issue`, `BDSA-`, `CVE-`. We intentionally
|
|
7
|
+
* do NOT match bare `#N` (ambiguous: GitHub issue / GitLab / Mantis).
|
|
8
|
+
*
|
|
9
|
+
* Rules live in this single file (not in connector manifests) so adding
|
|
10
|
+
* a new pattern doesn't require bumping any connector version. Patterns
|
|
11
|
+
* referencing a connector's base_url (`baseUrlFrom: 'mantis'`) are
|
|
12
|
+
* auto-skipped when that connector is not installed.
|
|
13
|
+
*
|
|
14
|
+
* URL template syntax:
|
|
15
|
+
* {0} → the entire regex match
|
|
16
|
+
* {1}, {2}, ... → capture groups
|
|
17
|
+
* {base_url} → connector's installed base_url, trailing slash stripped
|
|
18
|
+
*
|
|
19
|
+
* The chat system prompt also injects a "Reference Format" guide built
|
|
20
|
+
* from the same connector base_urls (see lib/chat/reference-prompt.ts)
|
|
21
|
+
* so the LLM is nudged to emit markdown links directly — these regexes
|
|
22
|
+
* are a safety net for plain-text mentions.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { getInstalledConnector } from '../connectors/registry';
|
|
26
|
+
|
|
27
|
+
export interface LinkPattern {
|
|
28
|
+
id: string;
|
|
29
|
+
/** Regex with the `g` flag — required so we can iterate all matches. */
|
|
30
|
+
regex: RegExp;
|
|
31
|
+
/** Connector id whose `config.base_url` populates `{base_url}` in `url`.
|
|
32
|
+
* Omit when the pattern is self-contained (e.g. CVE → nvd.nist.gov). */
|
|
33
|
+
baseUrlFrom?: string;
|
|
34
|
+
/** URL template — see syntax above. */
|
|
35
|
+
url: string;
|
|
36
|
+
/** Tooltip template (same syntax). Defaults to the matched text. */
|
|
37
|
+
label?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Raw patterns. `baseUrlFrom` is resolved by getActiveLinkPatterns. */
|
|
41
|
+
export const LINK_PATTERNS: LinkPattern[] = [
|
|
42
|
+
{
|
|
43
|
+
// Matches "Mantis #1226625", "mantis bug 1226625", "bug #1234567",
|
|
44
|
+
// "bug 1234567". 4–8 digits caps false positives on small numbers
|
|
45
|
+
// like list indices ("bug 3" etc.).
|
|
46
|
+
id: 'mantis-bug',
|
|
47
|
+
regex: /\b(?:mantis(?:\s+bug)?\s*#?|bug\s*#?)(\d{4,8})\b/gi,
|
|
48
|
+
baseUrlFrom: 'mantis',
|
|
49
|
+
url: '{base_url}/view.php?id={1}',
|
|
50
|
+
label: 'Mantis #{1}',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
// Matches "MR !14860", "MR 14860", "merge request !14860", "!14860".
|
|
54
|
+
// The lookbehind on the bare-`!N` form keeps `not!42` / `1!42` /
|
|
55
|
+
// `foo!42bar` out, and allows CJK-adjacent (`测试!14860`).
|
|
56
|
+
id: 'gitlab-mr',
|
|
57
|
+
regex: /(?:\b(?:MR|merge\s+request)\s+!?|(?<![A-Za-z0-9])!)(\d+)\b/gi,
|
|
58
|
+
baseUrlFrom: 'gitlab',
|
|
59
|
+
url: '{base_url}/-/merge_requests/{1}',
|
|
60
|
+
label: 'MR !{1}',
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
// Matches "issue #789", "issue 789", "gitlab issue #789". 3+ digits
|
|
64
|
+
// minimum keeps "issue 1" / "issue 12" out (too noisy).
|
|
65
|
+
id: 'gitlab-issue',
|
|
66
|
+
regex: /\b(?:gitlab\s+issue\s*#?|gl\s*issue\s*#?|issue\s*#?)(\d{3,})\b/gi,
|
|
67
|
+
baseUrlFrom: 'gitlab',
|
|
68
|
+
url: '{base_url}/-/issues/{1}',
|
|
69
|
+
label: 'Issue #{1}',
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: 'blackduck-bdsa',
|
|
73
|
+
regex: /\b(BDSA-\d{4}-\d+)\b/g,
|
|
74
|
+
baseUrlFrom: 'blackduck',
|
|
75
|
+
url: '{base_url}/api/vulnerabilities/{1}',
|
|
76
|
+
label: '{1}',
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: 'cve',
|
|
80
|
+
regex: /\b(CVE-\d{4}-\d+)\b/g,
|
|
81
|
+
url: 'https://nvd.nist.gov/vuln/detail/{1}',
|
|
82
|
+
label: '{1}',
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
export interface CompiledPattern {
|
|
87
|
+
id: string;
|
|
88
|
+
regex: RegExp;
|
|
89
|
+
/** url template after {base_url} expansion */
|
|
90
|
+
urlTemplate: string;
|
|
91
|
+
labelTemplate: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Resolve `baseUrlFrom` against installed connector configs and drop
|
|
96
|
+
* patterns whose connector isn't configured. Returns the safe-to-render
|
|
97
|
+
* set; UI fetches this once and reuses it per chat session.
|
|
98
|
+
*/
|
|
99
|
+
export function getActiveLinkPatterns(): CompiledPattern[] {
|
|
100
|
+
const out: CompiledPattern[] = [];
|
|
101
|
+
for (const p of LINK_PATTERNS) {
|
|
102
|
+
let urlTemplate = p.url;
|
|
103
|
+
if (p.baseUrlFrom) {
|
|
104
|
+
const cfg = getInstalledConnector(p.baseUrlFrom)?.config as
|
|
105
|
+
| { base_url?: string }
|
|
106
|
+
| undefined;
|
|
107
|
+
const base = (cfg?.base_url || '').trim().replace(/\/+$/, '');
|
|
108
|
+
if (!base) continue; // skip — connector unconfigured
|
|
109
|
+
urlTemplate = urlTemplate.replace('{base_url}', base);
|
|
110
|
+
}
|
|
111
|
+
out.push({
|
|
112
|
+
id: p.id,
|
|
113
|
+
regex: p.regex,
|
|
114
|
+
urlTemplate,
|
|
115
|
+
labelTemplate: p.label ?? '{0}',
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Serializable form for sending to the browser. */
|
|
122
|
+
export interface SerializablePattern {
|
|
123
|
+
id: string;
|
|
124
|
+
source: string; // regex.source — re-hydrated client-side
|
|
125
|
+
flags: string; // regex.flags
|
|
126
|
+
url: string;
|
|
127
|
+
label: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function serializePatterns(patterns: CompiledPattern[]): SerializablePattern[] {
|
|
131
|
+
return patterns.map(p => ({
|
|
132
|
+
id: p.id,
|
|
133
|
+
source: p.regex.source,
|
|
134
|
+
flags: p.regex.flags.includes('g') ? p.regex.flags : p.regex.flags + 'g',
|
|
135
|
+
url: p.urlTemplate,
|
|
136
|
+
label: p.labelTemplate,
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a "Reference Format" block for the chat system prompt that lists
|
|
3
|
+
* each installed connector's URL pattern and asks the LLM to emit
|
|
4
|
+
* markdown links directly when mentioning IDs.
|
|
5
|
+
*
|
|
6
|
+
* Output goes into buildSystemPrompt in agent-loop.ts. Base URLs are read
|
|
7
|
+
* from connector-configs.json so the prompt only mentions connectors the
|
|
8
|
+
* user has actually configured — no broken/placeholder URLs leak.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { getInstalledConnector } from '../connectors/registry';
|
|
12
|
+
|
|
13
|
+
interface RefLine {
|
|
14
|
+
/** Human reference type for the prompt. */
|
|
15
|
+
label: string;
|
|
16
|
+
/** Example template the LLM should follow. */
|
|
17
|
+
example: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Returns a markdown-friendly multiline string section, or empty string
|
|
22
|
+
* if no connector is configured for any of the rules. Caller appends it
|
|
23
|
+
* verbatim to the system prompt.
|
|
24
|
+
*/
|
|
25
|
+
export function buildReferencePromptSection(): string {
|
|
26
|
+
const out: RefLine[] = [];
|
|
27
|
+
|
|
28
|
+
// Helper — read base_url from a connector, trailing slash stripped.
|
|
29
|
+
const baseOf = (id: string): string | null => {
|
|
30
|
+
const cfg = getInstalledConnector(id)?.config as { base_url?: string } | undefined;
|
|
31
|
+
const v = (cfg?.base_url || '').trim().replace(/\/+$/, '');
|
|
32
|
+
return v || null;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const mantis = baseOf('mantis');
|
|
36
|
+
if (mantis) {
|
|
37
|
+
out.push({
|
|
38
|
+
label: 'Mantis bug',
|
|
39
|
+
example: `[Mantis #1226625](${mantis}/view.php?id=1226625)`,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const gitlab = baseOf('gitlab');
|
|
44
|
+
if (gitlab) {
|
|
45
|
+
out.push({
|
|
46
|
+
label: 'GitLab merge request',
|
|
47
|
+
example: `[MR !14860](${gitlab}/-/merge_requests/14860)`,
|
|
48
|
+
});
|
|
49
|
+
out.push({
|
|
50
|
+
label: 'GitLab issue',
|
|
51
|
+
example: `[Issue #789](${gitlab}/-/issues/789)`,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const blackduck = baseOf('blackduck');
|
|
56
|
+
if (blackduck) {
|
|
57
|
+
out.push({
|
|
58
|
+
label: 'Black Duck advisory',
|
|
59
|
+
example: `[BDSA-2024-0001](${blackduck}/api/vulnerabilities/BDSA-2024-0001)`,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Always include — self-contained, doesn't need a connector
|
|
64
|
+
out.push({
|
|
65
|
+
label: 'CVE',
|
|
66
|
+
example: '[CVE-2024-1234](https://nvd.nist.gov/vuln/detail/CVE-2024-1234)',
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
if (out.length === 0) return '';
|
|
70
|
+
|
|
71
|
+
const lines = [
|
|
72
|
+
'Reference format — when mentioning these IDs in your reply, emit them as inline markdown links (substitute the real id):',
|
|
73
|
+
];
|
|
74
|
+
for (const r of out) {
|
|
75
|
+
lines.push(`- ${r.label}: ${r.example}`);
|
|
76
|
+
}
|
|
77
|
+
lines.push(
|
|
78
|
+
'Always use the URLs shown above; do NOT invent hostnames. If a category is not listed, just mention the id as plain text — the UI will auto-link known patterns as a fallback.',
|
|
79
|
+
);
|
|
80
|
+
return lines.join('\n');
|
|
81
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remark plugin: auto-link known references (bug#, !MR, CVE-, …) in
|
|
3
|
+
* markdown text nodes. Skips text inside code/inlineCode/link parents
|
|
4
|
+
* so existing markdown links and code samples render untouched.
|
|
5
|
+
*
|
|
6
|
+
* Multi-pattern: caller passes a pre-compiled list (typically from
|
|
7
|
+
* /api/chat/link-patterns). Patterns whose backing connector isn't
|
|
8
|
+
* configured are already filtered server-side.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { visit, SKIP } from 'unist-util-visit';
|
|
12
|
+
import type { SerializablePattern } from './link-patterns';
|
|
13
|
+
|
|
14
|
+
interface MdastNode { type: string; [k: string]: any }
|
|
15
|
+
interface MdastText extends MdastNode { type: 'text'; value: string }
|
|
16
|
+
interface MdastParent extends MdastNode { children: MdastNode[] }
|
|
17
|
+
|
|
18
|
+
interface ClientPattern {
|
|
19
|
+
id: string;
|
|
20
|
+
regex: RegExp;
|
|
21
|
+
urlTemplate: string;
|
|
22
|
+
labelTemplate: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Hydrate `{ source, flags }` into RegExp objects once per render. */
|
|
26
|
+
export function hydratePatterns(patterns: SerializablePattern[]): ClientPattern[] {
|
|
27
|
+
return patterns.map(p => ({
|
|
28
|
+
id: p.id,
|
|
29
|
+
regex: new RegExp(p.source, p.flags),
|
|
30
|
+
urlTemplate: p.url,
|
|
31
|
+
labelTemplate: p.label,
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface Hit {
|
|
36
|
+
start: number;
|
|
37
|
+
end: number;
|
|
38
|
+
matchText: string;
|
|
39
|
+
url: string;
|
|
40
|
+
label: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Expand {0}, {1}, ... in a template string using a RegExp match array. */
|
|
44
|
+
function expand(tmpl: string, match: RegExpExecArray): string {
|
|
45
|
+
return tmpl.replace(/\{(\d+)\}/g, (_, idx) => match[Number(idx)] ?? '');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Find every non-overlapping match across all patterns in one string.
|
|
49
|
+
* Earlier patterns win in case of overlap. */
|
|
50
|
+
function findHits(text: string, patterns: ClientPattern[]): Hit[] {
|
|
51
|
+
const hits: Hit[] = [];
|
|
52
|
+
for (const p of patterns) {
|
|
53
|
+
// Clone — running .exec mutates lastIndex on a shared regex.
|
|
54
|
+
const re = new RegExp(p.regex.source, p.regex.flags);
|
|
55
|
+
let m: RegExpExecArray | null;
|
|
56
|
+
while ((m = re.exec(text)) !== null) {
|
|
57
|
+
const start = m.index;
|
|
58
|
+
const matchText = m[0];
|
|
59
|
+
const end = start + matchText.length;
|
|
60
|
+
// Overlap check against already-accepted hits (first-wins).
|
|
61
|
+
if (hits.some(h => !(end <= h.start || start >= h.end))) continue;
|
|
62
|
+
hits.push({
|
|
63
|
+
start,
|
|
64
|
+
end,
|
|
65
|
+
matchText,
|
|
66
|
+
url: expand(p.urlTemplate, m),
|
|
67
|
+
label: expand(p.labelTemplate, m),
|
|
68
|
+
});
|
|
69
|
+
if (m.index === re.lastIndex) re.lastIndex++; // zero-width safety
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
hits.sort((a, b) => a.start - b.start);
|
|
73
|
+
return hits;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Splice hits into a text node, returning new mdast children. */
|
|
77
|
+
function spliceText(value: string, hits: Hit[]): MdastNode[] {
|
|
78
|
+
const out: MdastNode[] = [];
|
|
79
|
+
let cursor = 0;
|
|
80
|
+
for (const h of hits) {
|
|
81
|
+
if (h.start > cursor) {
|
|
82
|
+
out.push({ type: 'text', value: value.slice(cursor, h.start) });
|
|
83
|
+
}
|
|
84
|
+
out.push({
|
|
85
|
+
type: 'link',
|
|
86
|
+
url: h.url,
|
|
87
|
+
title: h.label,
|
|
88
|
+
children: [{ type: 'text', value: h.matchText }],
|
|
89
|
+
});
|
|
90
|
+
cursor = h.end;
|
|
91
|
+
}
|
|
92
|
+
if (cursor < value.length) {
|
|
93
|
+
out.push({ type: 'text', value: value.slice(cursor) });
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const SKIP_PARENT_TYPES = new Set(['link', 'linkReference', 'code', 'inlineCode']);
|
|
99
|
+
|
|
100
|
+
export function remarkLinkify(patterns: SerializablePattern[]) {
|
|
101
|
+
if (!patterns.length) return () => {};
|
|
102
|
+
const compiled = hydratePatterns(patterns);
|
|
103
|
+
|
|
104
|
+
return (tree: MdastNode) => {
|
|
105
|
+
visit(tree as any, 'text', (node: any, index: number | undefined, parent: any) => {
|
|
106
|
+
try {
|
|
107
|
+
if (!parent || index == null) return;
|
|
108
|
+
const n = node as MdastText;
|
|
109
|
+
const p = parent as MdastParent;
|
|
110
|
+
if (!n || typeof n.value !== 'string') return;
|
|
111
|
+
if (!Array.isArray(p?.children)) return;
|
|
112
|
+
if (SKIP_PARENT_TYPES.has(p.type)) return;
|
|
113
|
+
|
|
114
|
+
const hits = findHits(n.value, compiled);
|
|
115
|
+
if (!hits.length) return;
|
|
116
|
+
|
|
117
|
+
const replacement = spliceText(n.value, hits);
|
|
118
|
+
// Sanity — every replacement child must be a proper node with a type.
|
|
119
|
+
if (!replacement.every(r => r && typeof (r as any).type === 'string')) return;
|
|
120
|
+
|
|
121
|
+
p.children.splice(index, 1, ...replacement);
|
|
122
|
+
return [SKIP, index + replacement.length];
|
|
123
|
+
} catch (e) {
|
|
124
|
+
// Linkify is a nice-to-have — never let it crash the chat render.
|
|
125
|
+
if (typeof console !== 'undefined') {
|
|
126
|
+
console.warn('[remark-linkify] visit failed, skipping node:', (e as Error)?.message);
|
|
127
|
+
}
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aion0/forge",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.39",
|
|
4
4
|
"description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
"react-markdown": "^10.1.0",
|
|
57
57
|
"remark-gfm": "^4.0.1",
|
|
58
58
|
"undici": "^8.3.0",
|
|
59
|
+
"unist-util-visit": "^5.1.0",
|
|
59
60
|
"ws": "^8.19.0",
|
|
60
61
|
"yaml": "^2.8.2",
|
|
61
62
|
"zod": "^4.3.6"
|