@aion0/forge 0.9.18 → 0.10.2

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,22 +1,8 @@
1
- # Forge v0.9.18
1
+ # Forge v0.10.2
2
2
 
3
- Released: 2026-05-28
3
+ Released: 2026-05-30
4
4
 
5
- ## Changes since v0.9.16
5
+ ## Changes since v0.10.1
6
6
 
7
- ### Other
8
- - feat(chat): trigger_pipeline accepts skills array
9
- - feat(connectors): body_form_inject_from + nested instances UI
10
- - fix(http-protocol): JSON.parse args[X] when LLM stringified it + template inject keys
11
- - feat(connectors): http body_form_inject for server-side credential injection
12
- - Revert "feat(connectors): {secret:...} refs for cross-connector + global secrets"
13
- - Revert "fix(chat): make secret-refs system prompt push the tool-call path"
14
- - fix(chat): make secret-refs system prompt push the tool-call path
15
- - revert: drop migrateConnectorInstanceSecrets startup hook
16
- - fix(connectors): encrypt nested secrets inside type:instances rows
17
- - feat(connectors): {secret:...} refs for cross-connector + global secrets
18
- - feat(connectors): generic 'type: instances' field renderer + v0.9.17
19
- - feat(connectors): generic auth + url_encoding + body_form + multi-instance
20
7
 
21
-
22
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.9.16...v0.9.18
8
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.1...v0.10.2
@@ -35,7 +35,9 @@ interface TestResult {
35
35
  export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
36
36
  const { id } = await params;
37
37
  const settings = loadSettings();
38
- const saved = (settings.agents || {})[id] as any | undefined;
38
+ // Post-migration API profiles live in settings.apiProfiles; fall back to
39
+ // settings.agents for the migration window or for legacy entries.
40
+ const saved = (settings.apiProfiles || {})[id] || (settings.agents || {})[id] as any | undefined;
39
41
 
40
42
  let override: any = {};
41
43
  try { override = (await req.json()) ?? {}; } catch { override = {}; }
@@ -53,7 +55,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
53
55
  apiKey: unmask(override.apiKey, saved?.apiKey) ?? saved?.apiKey,
54
56
  baseUrl: override.baseUrl ?? saved?.baseUrl,
55
57
  model: override.model ?? saved?.model,
56
- env: saved?.env,
58
+ env: (saved as any)?.env, // legacy agent entries may have env; apiProfiles don't
57
59
  };
58
60
 
59
61
  if (!profile.provider) {
@@ -1,5 +1,6 @@
1
1
  import { NextResponse } from 'next/server';
2
2
  import { listAgents, getDefaultAgentId, resolveTerminalLaunch } from '@/lib/agents';
3
+ import { loadSettings } from '@/lib/settings';
3
4
 
4
5
  export async function GET(req: Request) {
5
6
  const url = new URL(req.url);
@@ -16,12 +17,31 @@ export async function GET(req: Request) {
16
17
  // default hides them. WorkspaceView's API mode passes ?include=all
17
18
  // (or ?include=api) to surface them when needed.
18
19
  const include = (url.searchParams.get('include') || 'cli').toLowerCase();
19
- const all = listAgents();
20
- const agents = include === 'all'
21
- ? all
22
- : include === 'api'
23
- ? all.filter((a: any) => a.backendType === 'api')
24
- : all.filter((a: any) => a.backendType !== 'api');
20
+ const cliAgents = listAgents();
21
+ const apiAgents = include === 'cli' ? [] : apiProfilesAsAgents();
22
+ const agents = include === 'cli' ? cliAgents
23
+ : include === 'api' ? apiAgents
24
+ : [...cliAgents, ...apiAgents];
25
25
  const defaultAgent = getDefaultAgentId();
26
26
  return NextResponse.json({ agents, defaultAgent });
27
27
  }
28
+
29
+ // Render settings.apiProfiles as agent-shaped entries so the workspace
30
+ // API-mode picker can list them (Phase 2 moved them out of listAgents).
31
+ function apiProfilesAsAgents() {
32
+ const profiles = loadSettings().apiProfiles || {};
33
+ return Object.entries(profiles)
34
+ .filter(([, p]: [string, any]) => p && p.enabled !== false)
35
+ .map(([id, p]: [string, any]) => ({
36
+ id,
37
+ name: p.name || id,
38
+ path: '',
39
+ enabled: true,
40
+ type: 'generic' as const,
41
+ capabilities: { supportsResume: false, supportsStreamJson: false, supportsModel: false, supportsSkipPermissions: false, hasSessionFiles: false, requiresTTY: false },
42
+ isProfile: true,
43
+ backendType: 'api' as const,
44
+ provider: p.provider,
45
+ model: p.model,
46
+ }));
47
+ }
@@ -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,
@@ -100,19 +100,21 @@ export async function POST(req: Request) {
100
100
  if (!text) return NextResponse.json({ ok: false, error: 'text required' }, { status: 400 });
101
101
 
102
102
  const settings = loadSettings();
103
- const agents = settings.agents || {};
104
- const candidates = Object.entries(agents).filter(([_, a]) => {
105
- if (!a || a.type !== 'api' || a.enabled === false) return false;
106
- return pickApiKey(a, inferAdapter(a.provider)).length > 0;
103
+ const profiles = settings.apiProfiles || {};
104
+ const candidates = Object.entries(profiles).filter(([_, p]: [string, any]) => {
105
+ if (!p || p.enabled === false) return false;
106
+ return (p.apiKey || '').length > 0;
107
107
  });
108
108
  if (candidates.length === 0) {
109
109
  return NextResponse.json({
110
110
  ok: false,
111
- error: 'No API agent profile with an API key. Add one under Settings → Agents (type: API).',
111
+ error: 'No API profile with an API key. Add one under Settings → API Profiles.',
112
112
  }, { status: 400 });
113
113
  }
114
114
  const preferredId = settings.chatAgent || candidates[0]![0];
115
- const profile = agents[preferredId]?.type === 'api' ? agents[preferredId] : candidates[0]![1];
115
+ const profile: any = profiles[preferredId] && profiles[preferredId].enabled !== false
116
+ ? profiles[preferredId]
117
+ : candidates[0]![1];
116
118
  const adapter = inferAdapter(profile.provider);
117
119
  if (adapter !== 'anthropic') {
118
120
  return NextResponse.json({
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);
@@ -9,6 +9,7 @@ interface MonitorData {
9
9
  telegram: { running: boolean; pid: string; startedAt?: string };
10
10
  workspace: { running: boolean; pid: string; startedAt?: string };
11
11
  browserBridge?: { running: boolean; pid: string; startedAt?: string };
12
+ memory?: { running: boolean; pid: string; startedAt?: string };
12
13
  chat?: {
13
14
  running: boolean;
14
15
  pid: string;
@@ -60,6 +61,7 @@ export default function MonitorPanel({ onClose }: { onClose: () => void }) {
60
61
  { label: 'Telegram Bot', ...data.processes.telegram },
61
62
  { label: 'Workspace Daemon', ...data.processes.workspace },
62
63
  ...(data.processes.browserBridge ? [{ label: 'Browser Bridge', ...data.processes.browserBridge }] : []),
64
+ ...(data.processes.memory ? [{ label: 'Memory Worker', ...data.processes.memory }] : []),
63
65
  { label: 'Tunnel', ...data.processes.tunnel },
64
66
  ].map(p => (
65
67
  <div key={p.label} className="flex items-center gap-2 text-xs">