@aion0/forge 0.10.38 → 0.10.40

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 CHANGED
@@ -1,8 +1,11 @@
1
- # Forge v0.10.38
1
+ # Forge v0.10.40
2
2
 
3
3
  Released: 2026-06-05
4
4
 
5
- ## Changes since v0.10.37
5
+ ## Changes since v0.10.39
6
6
 
7
+ ### Other
8
+ - fix(server): portable port-pid lookup so Linux without lsof can stop (#33)
7
9
 
8
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.37...v0.10.38
10
+
11
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.39...v0.10.40
@@ -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
+ }
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} />;
@@ -289,17 +289,59 @@ if (!isStop) {
289
289
  }
290
290
  }
291
291
 
292
+ // ── Find pids listening on a TCP port (portable) ──
293
+ // macOS ships lsof; minimal Linux containers (RHEL/CentOS/Alpine) often
294
+ // don't. Try lsof → ss (iproute2, ~universal on modern Linux) → fuser
295
+ // (psmisc) in order. Empty result means no listener, period.
296
+ //
297
+ // Without this, `forge server stop` on Linux silently leaked next-server
298
+ // because `lsof -ti:8403` threw ENOENT and the whole stop path was
299
+ // wrapped in try/catch — the port lookup just disappeared.
300
+ function findPortPids(port) {
301
+ // 1) lsof
302
+ try {
303
+ const out = execSync(`lsof -ti:${port}`, {
304
+ encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'],
305
+ }).trim();
306
+ if (out) return [...new Set(out.split('\n').map(s => s.trim()).filter(Boolean))];
307
+ } catch { /* fall through */ }
308
+ // 2) ss -tlnp — line looks like:
309
+ // LISTEN 0 511 *:8403 ... users:(("next-server",pid=903438,fd=14))
310
+ try {
311
+ const out = execSync(`ss -tlnp 2>/dev/null`, {
312
+ encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'],
313
+ });
314
+ const pids = new Set();
315
+ for (const line of out.split('\n')) {
316
+ // Match port at end of local-address field, before either whitespace
317
+ // (IPv4 `*:8403 `) or end-of-field (IPv6 `[::]:8403 `).
318
+ if (!new RegExp(`:${port}\\b`).test(line)) continue;
319
+ for (const m of line.matchAll(/pid=(\d+)/g)) pids.add(m[1]);
320
+ }
321
+ if (pids.size) return [...pids];
322
+ } catch { /* fall through */ }
323
+ // 3) fuser
324
+ try {
325
+ const out = execSync(`fuser ${port}/tcp 2>/dev/null`, {
326
+ encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'],
327
+ });
328
+ const pids = out.trim().split(/\s+/).filter(p => /^\d+$/.test(p));
329
+ if (pids.length) return [...new Set(pids)];
330
+ } catch { /* nothing more */ }
331
+ return [];
332
+ }
333
+
292
334
  // ── Reset terminal server (kill port + tmux sessions) ──
293
335
  if (resetTerminal) {
294
336
  console.log(`[forge] Resetting terminal server (port ${terminalPort})...`);
295
- try {
296
- const pids = execSync(`lsof -ti:${terminalPort}`, { encoding: 'utf-8' }).trim();
297
- for (const pid of pids.split('\n').filter(Boolean)) {
298
- try { execSync(`kill ${pid.trim()}`); } catch {}
337
+ const pids = findPortPids(terminalPort);
338
+ if (pids.length === 0) {
339
+ console.log(`[forge] No process on port ${terminalPort}`);
340
+ } else {
341
+ for (const pid of pids) {
342
+ try { process.kill(parseInt(pid), 'SIGTERM'); } catch {}
299
343
  }
300
344
  console.log(`[forge] Killed terminal server on port ${terminalPort}`);
301
- } catch {
302
- console.log(`[forge] No process on port ${terminalPort}`);
303
345
  }
304
346
  }
305
347
 
@@ -338,14 +380,10 @@ function cleanupOrphans() {
338
380
  try {
339
381
  // Kill processes on our ports
340
382
  for (const port of [webPort, terminalPort]) {
341
- try {
342
- const pids = execSync(`lsof -ti:${port}`, { encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
343
- for (const pid of pids.split('\n').filter(Boolean)) {
344
- const p = pid.trim();
345
- if (p === myPid || protectedPids.has(p)) continue;
346
- try { process.kill(parseInt(p), 'SIGTERM'); } catch {}
347
- }
348
- } catch {}
383
+ for (const p of findPortPids(port)) {
384
+ if (p === myPid || protectedPids.has(p)) continue;
385
+ try { process.kill(parseInt(p), 'SIGTERM'); } catch {}
386
+ }
349
387
  }
350
388
  // Kill standalone processes: our instance's + orphans without any tag
351
389
  try {
@@ -365,11 +403,25 @@ function cleanupOrphans() {
365
403
  // imported lib/task-manager (directly or via lib/pipeline) starts its
366
404
  // own setInterval task runner that never exits — those run in parallel
367
405
  // with the real runner and silently steal tasks. Detect via lsof on
368
- // workflow.db, exclude our own next-server + standalones.
406
+ // workflow.db (Mac), with a fuser fallback for lsof-less Linux.
369
407
  try {
370
408
  const dbPath = join(DATA_DIR, 'workflow.db');
371
- const lsofOut = execSync(`lsof -t "${dbPath}"`, { encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
372
- for (const pid of lsofOut.split('\n').map(s => s.trim()).filter(Boolean)) {
409
+ let holders = [];
410
+ try {
411
+ const out = execSync(`lsof -t "${dbPath}"`, {
412
+ encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
413
+ }).trim();
414
+ holders = out.split('\n').map(s => s.trim()).filter(Boolean);
415
+ } catch {
416
+ try {
417
+ // `fuser <file>` writes pids to stderr, not stdout. Merge streams.
418
+ const out = execSync(`fuser "${dbPath}" 2>&1`, {
419
+ encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
420
+ });
421
+ holders = out.replace(/^.*?:/, '').trim().split(/\s+/).filter(p => /^\d+$/.test(p));
422
+ } catch { /* both unavailable — zombie scan disabled */ }
423
+ }
424
+ for (const pid of holders) {
373
425
  if (pid === myPid || protectedPids.has(pid)) continue;
374
426
  let cmd = '';
375
427
  try {
@@ -458,25 +510,35 @@ async function stopServer() {
458
510
  } catch {}
459
511
  try { unlinkSync(PID_FILE); } catch {}
460
512
 
461
- // Also kill by port (in case PID file is stale)
462
- const portPids = [];
463
- try {
464
- const pids = execSync(`lsof -ti:${webPort}`, { encoding: 'utf-8', timeout: 3000 }).trim();
465
- for (const p of pids.split('\n').filter(Boolean)) {
466
- const pid = parseInt(p.trim());
467
- try { process.kill(pid, 'SIGTERM'); stopped = true; portPids.push(pid); } catch {}
468
- }
469
- if (pids) console.log(`[forge] Killed processes on port ${webPort}`);
470
- } catch {}
513
+ // Also kill by port (in case PID file is stale). Use findPortPids
514
+ // so Linux-without-lsof still works.
515
+ const portPids = findPortPids(webPort);
516
+ for (const p of portPids) {
517
+ const pid = parseInt(p);
518
+ try { process.kill(pid, 'SIGTERM'); stopped = true; } catch {}
519
+ }
520
+ if (portPids.length > 0) console.log(`[forge] Killed processes on port ${webPort}`);
471
521
 
472
- // Force kill after 2 seconds if SIGTERM didn't work
522
+ // Force kill survivors after 2 seconds.
473
523
  if (portPids.length > 0) {
474
524
  await new Promise(r => setTimeout(r, 2000));
475
525
  for (const pid of portPids) {
476
- try { process.kill(pid, 'SIGKILL'); } catch {}
526
+ try { process.kill(parseInt(pid), 'SIGKILL'); } catch {}
477
527
  }
478
528
  }
479
529
 
530
+ // Final verify — if anything still listens on the port (e.g. a child
531
+ // re-bound, or our SIGKILL hit EPERM), surface it loudly. Silent leak
532
+ // is exactly the bug we're fixing.
533
+ const survivors = findPortPids(webPort);
534
+ if (survivors.length > 0) {
535
+ console.warn(
536
+ `[forge] WARNING: port ${webPort} still bound by pid(s) ${survivors.join(', ')} after stop. ` +
537
+ `Likely a different user / cron-launched / sudo'd instance. ` +
538
+ `Run: kill ${survivors.join(' ')} (or with sudo) to free it.`,
539
+ );
540
+ }
541
+
480
542
  if (!stopped) {
481
543
  console.log('[forge] No running server found');
482
544
  }
@@ -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={[remarkGfm]}
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
- {content}
126
+ {safeContent}
71
127
  </Markdown>
72
128
  );
73
129
  }
@@ -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 = trimOrphanToolResults(
655
- listMessagesCapped(args.sessionId, HISTORY_MSG_BUDGET, 8_000, estimateTokens),
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 = trimOrphanToolResults(
708
- listMessagesCapped(args.sessionId, HISTORY_MSG_BUDGET, historyBudget, estimateTokens),
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.38",
3
+ "version": "0.10.40",
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"