@aion0/forge 0.9.16 → 0.9.19

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,14 +1,18 @@
1
- # Forge v0.9.16
1
+ # Forge v0.9.19
2
2
 
3
- Released: 2026-05-28
3
+ Released: 2026-05-29
4
4
 
5
- ## Changes since v0.9.15
6
-
7
- ### Bug Fixes
8
- - fix: convert all @/ aliases in lib/ and src/ to relative paths
5
+ ## Changes since v0.9.18
9
6
 
10
7
  ### Other
11
- - fix(chat): replace @/src/* aliases with relative paths in lib/
8
+ - feat(memory): writeEpisode dual-write + Memory drawer in /chat web
9
+ - feat(monitor): D3 — Memory Worker row in Settings Monitor
10
+ - feat(chat): Phase C — agent-loop reads summarized memory + token-budget history
11
+ - feat(memory): B11 — spawn memory-standalone from forge-server + dev supervisor
12
+ - fix(memory): bypass ai-sdk for summarizer LLM call — raw fetch instead
13
+ - feat(memory): Phase B — memory-standalone process + Temper Summary sub-task
14
+ - feat(memory): Phase A — key conventions + buildMemoryContext + OR-match search
15
+ - fix(chat): cap LLM history at last 40 messages and drop orphan tool_results
12
16
 
13
17
 
14
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.9.15...v0.9.16
18
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.9.18...v0.9.19
@@ -41,17 +41,76 @@ function defaultsFor(id: string): Record<string, any> {
41
41
 
42
42
  const SECRET_MASK = '••••••••';
43
43
 
44
- function isSecretField(schema: ConnectorFieldSchema | undefined): boolean {
45
- if (!schema) return false;
46
- return schema.type === 'secret' || (schema.type as string) === 'password';
44
+ /**
45
+ * Walk `value` against `schema`, applying `mask` (plaintext → ••••) or
46
+ * `restore` (•••• stored plaintext). Recurses into `type: 'instances'`
47
+ * arrays so per-row sub-secrets get the same mask treatment as flat
48
+ * top-level secrets. Mirrors the encrypt/decrypt walker in
49
+ * lib/connectors/registry.ts so the on-disk and over-the-wire
50
+ * representations stay symmetric.
51
+ */
52
+ function transformFieldSecrets(
53
+ value: any,
54
+ schema: ConnectorFieldSchema | undefined,
55
+ existingValue: any,
56
+ op: 'mask' | 'restore',
57
+ ): any {
58
+ if (!schema) return value;
59
+ const t = String((schema as any)?.type || '');
60
+
61
+ if (t === 'secret' || t === 'password') {
62
+ if (op === 'mask') {
63
+ return typeof value === 'string' && value ? SECRET_MASK : value;
64
+ }
65
+ // restore
66
+ if (value === SECRET_MASK) {
67
+ return typeof existingValue === 'string' ? existingValue : undefined;
68
+ }
69
+ return value;
70
+ }
71
+
72
+ if (t === 'instances' && (schema as any).fields) {
73
+ const wasString = typeof value === 'string';
74
+ let rows: any;
75
+ if (wasString) {
76
+ try { rows = JSON.parse(value); } catch { return value; }
77
+ } else {
78
+ rows = value;
79
+ }
80
+ if (!Array.isArray(rows)) return value;
81
+
82
+ // Build name→row map of the existing stored value so per-instance
83
+ // mask restoration can find the corresponding row by name.
84
+ let existingRows: any[] = [];
85
+ if (typeof existingValue === 'string') {
86
+ try { existingRows = JSON.parse(existingValue); } catch { existingRows = []; }
87
+ } else if (Array.isArray(existingValue)) {
88
+ existingRows = existingValue;
89
+ }
90
+ const existingByName = new Map<string, any>();
91
+ for (const r of existingRows) {
92
+ if (r && typeof r === 'object' && typeof r.name === 'string') existingByName.set(r.name, r);
93
+ }
94
+
95
+ const transformed = rows.map((row: any) => {
96
+ if (!row || typeof row !== 'object') return row;
97
+ const existingRow = (typeof row.name === 'string' && existingByName.get(row.name)) || {};
98
+ const out: any = { ...row };
99
+ for (const [k, sub] of Object.entries((schema as any).fields)) {
100
+ out[k] = transformFieldSecrets(out[k], sub as ConnectorFieldSchema, existingRow[k], op);
101
+ }
102
+ return out;
103
+ });
104
+ return wasString ? JSON.stringify(transformed) : transformed;
105
+ }
106
+
107
+ return value;
47
108
  }
48
109
 
49
110
  function maskSecrets(settings: Record<string, any>, schema: Record<string, ConnectorFieldSchema>): Record<string, any> {
50
111
  const out: Record<string, any> = { ...settings };
51
112
  for (const [k, v] of Object.entries(schema)) {
52
- if (isSecretField(v) && typeof out[k] === 'string' && out[k]) {
53
- out[k] = SECRET_MASK;
54
- }
113
+ out[k] = transformFieldSecrets(out[k], v, undefined, 'mask');
55
114
  }
56
115
  return out;
57
116
  }
@@ -63,10 +122,9 @@ function restoreSecrets(
63
122
  ): Record<string, any> {
64
123
  const out: Record<string, any> = { ...incoming };
65
124
  for (const [k, v] of Object.entries(schema)) {
66
- if (isSecretField(v) && out[k] === SECRET_MASK) {
67
- if (typeof existing[k] === 'string') out[k] = existing[k];
68
- else delete out[k];
69
- }
125
+ const restored = transformFieldSecrets(out[k], v, existing[k], 'restore');
126
+ if (restored === undefined) delete out[k];
127
+ else out[k] = restored;
70
128
  }
71
129
  return out;
72
130
  }
@@ -34,6 +34,7 @@ import {
34
34
  } from '@/lib/connectors/registry';
35
35
  import { expandSettingsTokens, expandAllTokens } from '@/lib/plugins/templates';
36
36
  import { bridgeRpc } from '@/lib/chat/bridge-client';
37
+ import { applyAuth } from '@/lib/chat/protocols/http';
37
38
  import type { ConnectorDefinition, ConnectorTest, HttpRequestSpec } from '@/lib/connectors/types';
38
39
 
39
40
  const DEFAULT_TIMEOUT_MS = 15_000;
@@ -106,17 +107,39 @@ interface TestResult {
106
107
  body_preview?: string;
107
108
  }
108
109
 
109
- async function runHttpProbe(test: ConnectorTest, settings: Record<string, unknown>): Promise<TestResult> {
110
+ async function runHttpProbe(test: ConnectorTest, settings: Record<string, unknown>, def: ConnectorDefinition): Promise<TestResult> {
110
111
  const spec = test.request;
111
112
  if (!spec?.url) return { ok: false, error: 'test.request.url is required for http probe' };
112
113
 
114
+ // Multi-instance overlay: test probe always uses the first instance
115
+ // (same guard as tool-dispatcher — only kicks in when instances is a
116
+ // well-formed array, so single-instance connectors are unaffected).
117
+ let effectiveSettings = settings as Record<string, any>;
118
+ let instances = effectiveSettings?.instances;
119
+ // type: json fields persist as strings — parse before checking.
120
+ if (typeof instances === 'string') {
121
+ try { instances = JSON.parse(instances); } catch { instances = null; }
122
+ }
123
+ if (
124
+ Array.isArray(instances) &&
125
+ instances.length > 0 &&
126
+ instances.every((i: any) => i && typeof i === 'object' && typeof i.name === 'string')
127
+ ) {
128
+ effectiveSettings = { ...effectiveSettings, ...instances[0] };
129
+ }
130
+
113
131
  const method = (spec.method || 'GET').toUpperCase();
114
- const url = buildUrl(spec, settings);
115
- const headers = buildHeaders(spec, settings);
116
- const { body, contentType } = buildBody(spec, settings);
132
+ let url = buildUrl(spec, effectiveSettings);
133
+ const headers = buildHeaders(spec, effectiveSettings);
134
+ const { body, contentType } = buildBody(spec, effectiveSettings);
117
135
  if (body != null && contentType && !headers.has('content-type')) {
118
136
  headers.set('content-type', contentType);
119
137
  }
138
+ // Apply connector-level auth so the test probe uses the same scheme
139
+ // as live tool calls (e.g. Basic auth for Jenkins). Manifests that
140
+ // hand-craft Authorization in test.request.headers still work — the
141
+ // auth scheme would just overwrite the header.
142
+ url = applyAuth(url, headers, def.auth, effectiveSettings);
120
143
 
121
144
  const timeoutMs = test.timeout_ms || DEFAULT_TIMEOUT_MS;
122
145
  const okStatus = test.ok_status?.length ? test.ok_status : [200];
@@ -255,6 +278,6 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
255
278
  const probe = def.test.probe || 'http';
256
279
  const r = probe === 'browser'
257
280
  ? await runBrowserProbe(def, inst.config)
258
- : await runHttpProbe(def.test, inst.config);
281
+ : await runHttpProbe(def.test, inst.config, def);
259
282
  return NextResponse.json(r);
260
283
  }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * GET /api/memory/blocks?q=&limit=&scope=
3
+ *
4
+ * Lists memory blocks from the active backend (Temper or Local).
5
+ * Powers the Memory drawer in /chat — no editing, just inspection.
6
+ *
7
+ * Query params:
8
+ * q — optional search query; routed to store.search() so the
9
+ * active backend's relevance ranking applies (Temper KG,
10
+ * LocalMemoryStore LIKE). Omit to list everything.
11
+ * limit — cap on rows returned (default 200, max 500)
12
+ * scope — 'own' | 'global' | 'both' (default 'both'), forwarded
13
+ * to listBlocks. Ignored when q is set.
14
+ */
15
+
16
+ import { NextResponse } from 'next/server';
17
+ import { getMemoryStore } from '@/lib/chat/memory-store';
18
+
19
+ export async function GET(req: Request) {
20
+ const url = new URL(req.url);
21
+ const q = (url.searchParams.get('q') || '').trim();
22
+ const limit = Math.min(500, Math.max(1, Number(url.searchParams.get('limit') || 200)));
23
+ const scopeParam = url.searchParams.get('scope');
24
+ const scope: 'own' | 'global' | 'both' =
25
+ scopeParam === 'own' || scopeParam === 'global' || scopeParam === 'both' ? scopeParam : 'both';
26
+
27
+ const store = getMemoryStore();
28
+ if (!store.enabled) {
29
+ return NextResponse.json({ backend: store.kind, enabled: false, blocks: [], hits: [] });
30
+ }
31
+
32
+ if (q) {
33
+ // Search path — backend chooses ranking (Temper KG vs Local LIKE).
34
+ // listBlocks is also fetched so the UI can show "all blocks" once
35
+ // the user clears the query without a second roundtrip; capped.
36
+ const [hits, all] = await Promise.allSettled([
37
+ store.search(q, limit),
38
+ store.listBlocks({ scope }),
39
+ ]);
40
+ return NextResponse.json({
41
+ backend: store.kind,
42
+ enabled: true,
43
+ query: q,
44
+ hits: hits.status === 'fulfilled' ? hits.value : [],
45
+ blocks: all.status === 'fulfilled' ? all.value.slice(0, limit) : [],
46
+ });
47
+ }
48
+
49
+ const blocks = await store.listBlocks({ scope });
50
+ return NextResponse.json({
51
+ backend: store.kind,
52
+ enabled: true,
53
+ blocks: blocks.slice(0, limit),
54
+ hits: [],
55
+ });
56
+ }
@@ -24,6 +24,7 @@ export async function GET() {
24
24
  const workspace = countProcess('workspace-standalone');
25
25
  const chat = countProcess('chat-standalone');
26
26
  const browserBridge = countProcess('browser-bridge-standalone');
27
+ const memoryWorker = countProcess('memory-standalone');
27
28
  const tunnel = countProcess('cloudflared tunnel');
28
29
 
29
30
  // Chat backend health (port 8408 — process can be alive but crashed)
@@ -83,6 +84,7 @@ export async function GET() {
83
84
  telegram: { running: telegram.count > 0, pid: telegram.pid, startedAt: telegram.startedAt },
84
85
  workspace: { running: workspace.count > 0, pid: workspace.pid, startedAt: workspace.startedAt },
85
86
  browserBridge: { running: browserBridge.count > 0, pid: browserBridge.pid, startedAt: browserBridge.startedAt },
87
+ memory: { running: memoryWorker.count > 0, pid: memoryWorker.pid, startedAt: memoryWorker.startedAt },
86
88
  chat: {
87
89
  running: chatStatus.running,
88
90
  pid: chat.pid,
package/app/chat/page.tsx CHANGED
@@ -50,6 +50,7 @@ export default function ChatPage() {
50
50
  const [streaming, setStreaming] = useState(false);
51
51
  const [partial, setPartial] = useState('');
52
52
  const [memory, setMemory] = useState<MemoryStatus | null>(null);
53
+ const [memoryOpen, setMemoryOpen] = useState(false);
53
54
  const [error, setError] = useState('');
54
55
 
55
56
  const eventSrcRef = useRef<EventSource | null>(null);
@@ -282,7 +283,11 @@ export default function ChatPage() {
282
283
  </div>
283
284
 
284
285
  <div className="px-4 py-3 border-t border-[var(--border)] text-xs text-[var(--text-secondary)] space-y-1">
285
- <div className="flex items-center gap-2">
286
+ <button
287
+ type="button"
288
+ onClick={() => setMemoryOpen(true)}
289
+ className="flex items-center gap-2 w-full text-left hover:text-[var(--text-primary)] transition-colors"
290
+ >
286
291
  <span>Memory</span>
287
292
  <span
288
293
  className={`px-1.5 py-[1px] rounded text-[10px] uppercase tracking-wide border ${
@@ -295,7 +300,8 @@ export default function ChatPage() {
295
300
  >
296
301
  {memory?.backend ?? '…'}
297
302
  </span>
298
- </div>
303
+ <span className="ml-auto text-[10px] opacity-60">view →</span>
304
+ </button>
299
305
  {memory && (
300
306
  <div className="text-[11px]">
301
307
  {memory.pinnedCount ?? 0} pinned · {memory.blocksCount ?? 0} blocks
@@ -307,6 +313,8 @@ export default function ChatPage() {
307
313
  </div>
308
314
  </aside>
309
315
 
316
+ {memoryOpen && <MemoryDrawer onClose={() => setMemoryOpen(false)} />}
317
+
310
318
  {/* ─── Main pane ───────────────────────────────────── */}
311
319
  <main className="flex-1 flex flex-col min-w-0">
312
320
  <header className="border-b border-[var(--border)] px-6 py-3 flex items-center justify-between">
@@ -529,3 +537,182 @@ function tryPrettyJson(s: string): string {
529
537
  return s;
530
538
  }
531
539
  }
540
+
541
+ // ─── Memory drawer ───────────────────────────────────────────
542
+ //
543
+ // Inspector for whatever the active memory backend (Temper / Local)
544
+ // has. Read-only: search + list + click-to-expand JSON. The internal
545
+ // summarizer bookkeeping (cursor / health) is hidden by default to
546
+ // reduce noise; toggle reveals it.
547
+
548
+ const INTERNAL_PREFIXES = ['forge.summarizer.cursor:', 'forge.summarizer.health:'];
549
+
550
+ interface MemoryBlockRow {
551
+ key: string;
552
+ value: unknown;
553
+ pinned?: boolean;
554
+ description?: string;
555
+ scope?: string;
556
+ }
557
+
558
+ interface MemoryHitRow {
559
+ id: string;
560
+ kind: string;
561
+ fact?: string;
562
+ score?: number;
563
+ valid_at?: string | null;
564
+ }
565
+
566
+ interface MemoryBlocksResponse {
567
+ backend: 'temper' | 'local';
568
+ enabled: boolean;
569
+ blocks: MemoryBlockRow[];
570
+ hits: MemoryHitRow[];
571
+ query?: string;
572
+ }
573
+
574
+ function MemoryDrawer({ onClose }: { onClose: () => void }) {
575
+ const [data, setData] = useState<MemoryBlocksResponse | null>(null);
576
+ const [loading, setLoading] = useState(false);
577
+ const [err, setErr] = useState('');
578
+ const [q, setQ] = useState('');
579
+ const [showInternal, setShowInternal] = useState(false);
580
+ const [expanded, setExpanded] = useState<Record<string, boolean>>({});
581
+
582
+ const fetchBlocks = useCallback(async (query: string) => {
583
+ setLoading(true);
584
+ setErr('');
585
+ try {
586
+ const url = query
587
+ ? `/api/memory/blocks?q=${encodeURIComponent(query)}&limit=300`
588
+ : `/api/memory/blocks?limit=300`;
589
+ const r = await fetch(url);
590
+ if (!r.ok) throw new Error(`${r.status}`);
591
+ const j = (await r.json()) as MemoryBlocksResponse;
592
+ setData(j);
593
+ } catch (e) {
594
+ setErr(e instanceof Error ? e.message : String(e));
595
+ } finally {
596
+ setLoading(false);
597
+ }
598
+ }, []);
599
+
600
+ useEffect(() => { fetchBlocks(''); }, [fetchBlocks]);
601
+
602
+ // Debounce search input → API
603
+ useEffect(() => {
604
+ const t = setTimeout(() => fetchBlocks(q.trim()), 250);
605
+ return () => clearTimeout(t);
606
+ }, [q, fetchBlocks]);
607
+
608
+ const visibleBlocks = useMemo(() => {
609
+ const all = data?.blocks ?? [];
610
+ if (showInternal) return all;
611
+ return all.filter((b) => !INTERNAL_PREFIXES.some((p) => b.key.startsWith(p)));
612
+ }, [data, showInternal]);
613
+
614
+ const visibleHits = data?.hits ?? [];
615
+
616
+ return (
617
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
618
+ <div
619
+ className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg w-[720px] max-w-[95vw] max-h-[85vh] flex flex-col shadow-xl"
620
+ onClick={(e) => e.stopPropagation()}
621
+ style={{ fontFamily: SANS_FONT }}
622
+ >
623
+ <div className="px-4 py-3 border-b border-[var(--border)] flex items-center gap-3">
624
+ <h2 className="text-sm font-bold text-[var(--text-primary)]">Memory</h2>
625
+ {data && (
626
+ <span
627
+ className={`px-1.5 py-[1px] rounded text-[10px] uppercase tracking-wide border ${
628
+ data.backend === 'temper'
629
+ ? 'border-green-500/60 text-green-400'
630
+ : 'border-[var(--accent)] text-[var(--accent)]'
631
+ }`}
632
+ >
633
+ {data.backend}
634
+ </span>
635
+ )}
636
+ <input
637
+ value={q}
638
+ onChange={(e) => setQ(e.target.value)}
639
+ placeholder="search…"
640
+ className="flex-1 bg-[var(--bg-primary)] border border-[var(--border)] rounded px-2 py-1 text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
641
+ />
642
+ <label className="flex items-center gap-1 text-[10px] text-[var(--text-secondary)]">
643
+ <input
644
+ type="checkbox"
645
+ checked={showInternal}
646
+ onChange={(e) => setShowInternal(e.target.checked)}
647
+ className="accent-[var(--accent)]"
648
+ />
649
+ show internal
650
+ </label>
651
+ <button onClick={onClose} className="text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]">Close</button>
652
+ </div>
653
+
654
+ <div className="flex-1 overflow-y-auto">
655
+ {loading && <div className="px-4 py-6 text-xs text-[var(--text-secondary)]">Loading…</div>}
656
+ {err && <div className="px-4 py-6 text-xs text-red-400">Error: {err}</div>}
657
+
658
+ {visibleHits.length > 0 && (
659
+ <div className="px-4 py-2 border-b border-[var(--border)]">
660
+ <div className="text-[10px] uppercase tracking-wide text-[var(--text-secondary)] mb-1">
661
+ Search hits ({visibleHits.length})
662
+ </div>
663
+ <div className="space-y-1">
664
+ {visibleHits.slice(0, 20).map((h) => (
665
+ <div key={h.id} className="text-[11px] text-[var(--text-primary)]">
666
+ <span className="text-[var(--text-secondary)] font-mono mr-2">{h.id}</span>
667
+ {h.fact || '(no fact)'}
668
+ </div>
669
+ ))}
670
+ </div>
671
+ </div>
672
+ )}
673
+
674
+ <div className="px-2 py-1 text-[10px] uppercase tracking-wide text-[var(--text-secondary)] sticky top-0 bg-[var(--bg-secondary)]">
675
+ Blocks ({visibleBlocks.length}{data && data.blocks.length !== visibleBlocks.length ? ` of ${data.blocks.length}` : ''})
676
+ </div>
677
+ {visibleBlocks.length === 0 && !loading && (
678
+ <div className="px-4 py-6 text-xs text-[var(--text-secondary)] italic">No blocks{q ? ' match' : ''}.</div>
679
+ )}
680
+ {visibleBlocks.map((b) => {
681
+ const isOpen = !!expanded[b.key];
682
+ const valStr = typeof b.value === 'string' ? b.value : JSON.stringify(b.value);
683
+ const preview = valStr.length > 140 ? valStr.slice(0, 140) + '…' : valStr;
684
+ return (
685
+ <div key={b.key} className="border-b border-[var(--border)]">
686
+ <button
687
+ type="button"
688
+ onClick={() => setExpanded((s) => ({ ...s, [b.key]: !isOpen }))}
689
+ className="w-full text-left px-3 py-2 hover:bg-[var(--bg-primary)] transition-colors"
690
+ >
691
+ <div className="flex items-baseline gap-2">
692
+ <span className="text-[11px] font-mono text-[var(--accent)] truncate flex-1">{b.key}</span>
693
+ {b.pinned && <span className="text-[9px] text-yellow-400">📌</span>}
694
+ </div>
695
+ <div className="text-[11px] text-[var(--text-secondary)] mt-0.5 truncate">
696
+ {preview}
697
+ </div>
698
+ {b.description && (
699
+ <div className="text-[10px] text-gray-500 italic mt-0.5 truncate">{b.description}</div>
700
+ )}
701
+ </button>
702
+ {isOpen && (
703
+ <pre className="px-3 pb-3 text-[10px] font-mono whitespace-pre-wrap break-words text-[var(--text-secondary)]">
704
+ {tryPrettyJson(valStr)}
705
+ </pre>
706
+ )}
707
+ </div>
708
+ );
709
+ })}
710
+ </div>
711
+
712
+ <div className="px-4 py-2 border-t border-[var(--border)] text-[10px] text-[var(--text-secondary)]">
713
+ Read-only. Edit / delete via Settings → Memory (Temper UI for KG).
714
+ </div>
715
+ </div>
716
+ </div>
717
+ );
718
+ }
@@ -349,7 +349,7 @@ function cleanupOrphans() {
349
349
  }
350
350
  // Kill standalone processes: our instance's + orphans without any tag
351
351
  try {
352
- const out = execSync(`ps aux | grep -E 'telegram-standalone|terminal-standalone|workspace-standalone|browser-bridge-standalone|chat-standalone' | grep -v grep`, {
352
+ const out = execSync(`ps aux | grep -E 'telegram-standalone|terminal-standalone|workspace-standalone|browser-bridge-standalone|chat-standalone|memory-standalone' | grep -v grep`, {
353
353
  encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
354
354
  }).trim();
355
355
  for (const line of out.split('\n').filter(Boolean)) {
@@ -376,7 +376,7 @@ function cleanupOrphans() {
376
376
  cmd = execSync(`ps -p ${pid} -o command=`, { encoding: 'utf-8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
377
377
  } catch { continue; }
378
378
  // Skip legit holders: next-server + our standalones (handled above)
379
- if (/next-server|next start|telegram-standalone|terminal-standalone|workspace-standalone|browser-bridge-standalone|chat-standalone/.test(cmd)) continue;
379
+ if (/next-server|next start|telegram-standalone|terminal-standalone|workspace-standalone|browser-bridge-standalone|chat-standalone|memory-standalone/.test(cmd)) continue;
380
380
  // Only kill tsx-loaded scripts (typical zombie debug runner shape)
381
381
  if (!/tsx/.test(cmd)) continue;
382
382
  console.log(`[forge] Killing zombie task-runner (pid=${pid}): ${cmd.slice(0, 120)}`);
@@ -425,6 +425,7 @@ function startServices(daemonize = false) {
425
425
  spawnService('Workspace daemon', join(ROOT, 'lib', 'workspace-standalone.ts'));
426
426
  spawnService('Browser bridge', join(ROOT, 'lib', 'browser-bridge-standalone.ts'));
427
427
  spawnService('Chat', join(ROOT, 'lib', 'chat-standalone.ts'));
428
+ spawnService('Memory worker', join(ROOT, 'lib', 'memory-standalone.ts'));
428
429
 
429
430
  const childPids = services.map(c => c.pid).filter(Boolean);
430
431
  savePids(childPids);
@@ -44,6 +44,8 @@ interface FieldSchema {
44
44
  required?: boolean;
45
45
  default?: any;
46
46
  options?: string[];
47
+ /** For type: 'instances' — schema of each row's inner fields. */
48
+ fields?: Record<string, FieldSchema>;
47
49
  }
48
50
 
49
51
  interface ConnectorTool {
@@ -532,7 +534,13 @@ export default function ConnectorsPanel() {
532
534
  <label className="text-[10px] text-[var(--text-secondary)] block mb-0.5">
533
535
  {sc.label || key} {sc.required && <span className="text-red-400">*</span>}
534
536
  </label>
535
- {sc.type === 'boolean' ? (
537
+ {sc.type === 'instances' ? (
538
+ <InstancesField
539
+ schema={sc}
540
+ rawValue={values[key]}
541
+ onChange={(v) => setValues({ ...values, [key]: v })}
542
+ />
543
+ ) : sc.type === 'boolean' ? (
536
544
  <input
537
545
  type="checkbox"
538
546
  checked={values[key] === true || values[key] === 'true'}
@@ -634,3 +642,135 @@ export default function ConnectorsPanel() {
634
642
  </div>
635
643
  );
636
644
  }
645
+
646
+ /**
647
+ * Generic renderer for `type: instances` — a list of named records
648
+ * configured per-connector (Jenkins instances, GitLab tenants, etc.).
649
+ * Each row collapses into the connector's declared sub-fields. Value
650
+ * is round-tripped as a JSON-stringified array so the existing
651
+ * connector-configs.json shape (the textarea-saved string) keeps
652
+ * working unchanged.
653
+ */
654
+ function InstancesField({
655
+ schema,
656
+ rawValue,
657
+ onChange,
658
+ }: {
659
+ schema: FieldSchema;
660
+ rawValue: any;
661
+ onChange: (next: string) => void;
662
+ }) {
663
+ const subFields = schema.fields || {};
664
+ const subKeys = Object.keys(subFields);
665
+
666
+ // Parse incoming value: accept stored JSON string, in-memory array,
667
+ // null, or anything malformed (treat as empty).
668
+ const rows: Record<string, any>[] = (() => {
669
+ let v: any = rawValue;
670
+ if (v == null || v === '') return [];
671
+ if (typeof v === 'string') {
672
+ try { v = JSON.parse(v); } catch { return []; }
673
+ }
674
+ return Array.isArray(v) ? v : [];
675
+ })();
676
+
677
+ const commit = (next: Record<string, any>[]) => onChange(JSON.stringify(next));
678
+
679
+ const addRow = () => {
680
+ const blank: Record<string, any> = {};
681
+ for (const k of subKeys) blank[k] = subFields[k]?.default ?? '';
682
+ commit([...rows, blank]);
683
+ };
684
+ const removeRow = (i: number) => commit(rows.filter((_, idx) => idx !== i));
685
+ const updateRow = (i: number, key: string, val: any) => {
686
+ const next = rows.map((r, idx) => (idx === i ? { ...r, [key]: val } : r));
687
+ commit(next);
688
+ };
689
+
690
+ return (
691
+ <div className="space-y-1.5">
692
+ {rows.length === 0 && (
693
+ <div className="text-[10px] text-[var(--text-secondary)] italic py-1">
694
+ No entries yet — click “+ Add” to create one.
695
+ </div>
696
+ )}
697
+ {rows.map((row, i) => {
698
+ const rowLabel =
699
+ (typeof row.name === 'string' && row.name.trim()) || `(unnamed #${i + 1})`;
700
+ return (
701
+ <div
702
+ key={i}
703
+ className="border border-[var(--border)] rounded p-2 bg-[var(--bg-secondary)]"
704
+ >
705
+ <div className="flex items-center justify-between mb-1.5">
706
+ <span className="text-[10px] font-semibold text-[var(--text-primary)]">
707
+ {rowLabel}
708
+ </span>
709
+ <button
710
+ type="button"
711
+ onClick={() => removeRow(i)}
712
+ title="Remove this entry"
713
+ className="text-[10px] text-red-400 hover:text-red-300"
714
+ >
715
+
716
+ </button>
717
+ </div>
718
+ <div className="space-y-1.5">
719
+ {subKeys.map((k) => {
720
+ const f = subFields[k];
721
+ // Nested instances — render the same component recursively.
722
+ // Lets a connector model "rows of rows" (e.g. Jenkins
723
+ // instances each with a list of inject_params key/value
724
+ // pairs).
725
+ if (f.type === 'instances') {
726
+ return (
727
+ <div key={k}>
728
+ <label className="text-[9px] text-[var(--text-secondary)] block mb-0.5">
729
+ {f.label || k} {f.required && <span className="text-red-400">*</span>}
730
+ </label>
731
+ <InstancesField
732
+ schema={f}
733
+ rawValue={row[k]}
734
+ onChange={(next) => updateRow(i, k, next)}
735
+ />
736
+ {f.description && (
737
+ <p className="text-[9px] text-[var(--text-secondary)] mt-0.5">{f.description}</p>
738
+ )}
739
+ </div>
740
+ );
741
+ }
742
+ const inputType =
743
+ f.type === 'secret' || f.type === 'password'
744
+ ? 'password'
745
+ : f.type === 'number'
746
+ ? 'number'
747
+ : 'text';
748
+ return (
749
+ <div key={k}>
750
+ <label className="text-[9px] text-[var(--text-secondary)] block mb-0.5">
751
+ {f.label || k} {f.required && <span className="text-red-400">*</span>}
752
+ </label>
753
+ <input
754
+ type={inputType}
755
+ value={row[k] ?? ''}
756
+ onChange={(e) => updateRow(i, k, e.target.value)}
757
+ placeholder={f.description || ''}
758
+ className="w-full bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[10px] text-[var(--text-primary)] font-mono"
759
+ />
760
+ </div>
761
+ );
762
+ })}
763
+ </div>
764
+ </div>
765
+ );
766
+ })}
767
+ <button
768
+ type="button"
769
+ onClick={addRow}
770
+ className="text-[10px] px-2 py-1 rounded border border-dashed border-[var(--border)] text-[var(--text-secondary)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors w-full"
771
+ >
772
+ + Add {schema.label || 'instance'}
773
+ </button>
774
+ </div>
775
+ );
776
+ }