@aion0/forge 0.9.18 → 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 +12 -16
- package/app/api/memory/blocks/route.ts +56 -0
- package/app/api/monitor/route.ts +2 -0
- package/app/chat/page.tsx +189 -2
- package/bin/forge-server.mjs +3 -2
- package/components/MonitorPanel.tsx +2 -0
- package/lib/chat/agent-loop.ts +39 -8
- package/lib/chat/build-memory-context.ts +91 -0
- package/lib/chat/llm/openai.ts +4 -1
- package/lib/chat/local-memory.ts +22 -5
- package/lib/chat/session-store.ts +49 -0
- package/lib/chat-standalone.ts +6 -0
- package/lib/init.ts +16 -0
- package/lib/memory/compress-messages.ts +65 -0
- package/lib/memory/keys.ts +82 -0
- package/lib/memory/temper-summary.ts +485 -0
- package/lib/memory/token-estimate.ts +28 -0
- package/lib/memory-standalone.ts +108 -0
- package/package.json +1 -1
- package/scripts/test-memory-local.ts +139 -0
- package/scripts/test-memory-upsert.ts +106 -0
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,22 +1,18 @@
|
|
|
1
|
-
# Forge v0.9.
|
|
1
|
+
# Forge v0.9.19
|
|
2
2
|
|
|
3
|
-
Released: 2026-05-
|
|
3
|
+
Released: 2026-05-29
|
|
4
4
|
|
|
5
|
-
## Changes since v0.9.
|
|
5
|
+
## Changes since v0.9.18
|
|
6
6
|
|
|
7
7
|
### Other
|
|
8
|
-
- feat(
|
|
9
|
-
- feat(
|
|
10
|
-
-
|
|
11
|
-
- feat(
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
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
|
|
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
|
|
20
16
|
|
|
21
17
|
|
|
22
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.9.
|
|
18
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.9.18...v0.9.19
|
|
@@ -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
|
+
}
|
package/app/api/monitor/route.ts
CHANGED
|
@@ -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
|
-
<
|
|
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
|
-
|
|
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
|
+
}
|
package/bin/forge-server.mjs
CHANGED
|
@@ -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">
|
package/lib/chat/agent-loop.ts
CHANGED
|
@@ -16,16 +16,17 @@ import { loadSettings } from '../settings';
|
|
|
16
16
|
import {
|
|
17
17
|
appendMessage,
|
|
18
18
|
getSession,
|
|
19
|
-
|
|
19
|
+
listMessagesCapped,
|
|
20
20
|
} from './session-store';
|
|
21
21
|
import {
|
|
22
22
|
dispatchTool,
|
|
23
23
|
BUILTIN_TOOL_DEFS,
|
|
24
24
|
type BuiltinHandler,
|
|
25
25
|
} from './tool-dispatcher';
|
|
26
|
-
import { renderMemoryContext } from './temper';
|
|
27
26
|
import { getMemoryStore } from './memory-store';
|
|
27
|
+
import { buildMemoryContext } from './build-memory-context';
|
|
28
28
|
import { buildMemoryTools } from './memory-tools';
|
|
29
|
+
import { estimateTokens } from '../memory/token-estimate';
|
|
29
30
|
import {
|
|
30
31
|
listInstalledConnectors,
|
|
31
32
|
getConnector,
|
|
@@ -41,6 +42,28 @@ import type {
|
|
|
41
42
|
|
|
42
43
|
const MAX_ITERATIONS = 6;
|
|
43
44
|
const MAX_TOKENS = 16000;
|
|
45
|
+
// Working-window budgets for the LLM history. Capped by message count
|
|
46
|
+
// AND by token estimate (whichever hits first), see design §8. Older
|
|
47
|
+
// raw is summarized by the memory-standalone Temper Summary sub-task
|
|
48
|
+
// and recalled via buildMemoryContext as compact blocks instead.
|
|
49
|
+
const HISTORY_MSG_BUDGET = 60;
|
|
50
|
+
const HISTORY_TOKEN_BUDGET = 8000;
|
|
51
|
+
|
|
52
|
+
// After clipping to last N, the first kept message may be a tool_result
|
|
53
|
+
// whose tool_use was cut. Anthropic/OpenAI both reject that, so drop
|
|
54
|
+
// leading tool_result-bearing user messages until the slice starts clean.
|
|
55
|
+
function trimOrphanToolResults(history: Message[]): Message[] {
|
|
56
|
+
let i = 0;
|
|
57
|
+
while (i < history.length) {
|
|
58
|
+
const m = history[i];
|
|
59
|
+
const hasToolResult = m.role === 'user'
|
|
60
|
+
&& Array.isArray(m.blocks)
|
|
61
|
+
&& m.blocks.some((b) => (b as any).type === 'tool_result');
|
|
62
|
+
if (!hasToolResult) break;
|
|
63
|
+
i += 1;
|
|
64
|
+
}
|
|
65
|
+
return i === 0 ? history : history.slice(i);
|
|
66
|
+
}
|
|
44
67
|
|
|
45
68
|
export interface AgentEvent {
|
|
46
69
|
type:
|
|
@@ -59,7 +82,7 @@ export type AgentCallbacks = {
|
|
|
59
82
|
onEvent: (event: AgentEvent) => void;
|
|
60
83
|
};
|
|
61
84
|
|
|
62
|
-
interface ProviderResolution {
|
|
85
|
+
export interface ProviderResolution {
|
|
63
86
|
name: string;
|
|
64
87
|
type: 'anthropic' | 'openai';
|
|
65
88
|
apiKey: string;
|
|
@@ -126,7 +149,7 @@ export function pickApiKey(profile: { apiKey?: string; env?: Record<string, stri
|
|
|
126
149
|
return env.OPENAI_API_KEY || '';
|
|
127
150
|
}
|
|
128
151
|
|
|
129
|
-
function resolveProvider(sessionProvider: string | null, sessionModel: string | null): ProviderResolution | { error: string } {
|
|
152
|
+
export function resolveProvider(sessionProvider: string | null, sessionModel: string | null): ProviderResolution | { error: string } {
|
|
130
153
|
const settings = loadSettings();
|
|
131
154
|
const agents = settings.agents || {};
|
|
132
155
|
|
|
@@ -372,18 +395,24 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
|
|
|
372
395
|
for (const t of memTools) memHandlers[t.def.name] = t.handle;
|
|
373
396
|
|
|
374
397
|
if (memStore.enabled) {
|
|
375
|
-
|
|
398
|
+
// Inspector strip (memory_status event) wants the full inventory —
|
|
399
|
+
// keep its own listBlocks call. The prompt-injection text comes
|
|
400
|
+
// from buildMemoryContext which excludes internal bookkeeping
|
|
401
|
+
// (cursor / health) and combines pinned + query-driven retrieval
|
|
402
|
+
// hits in one pass.
|
|
403
|
+
const [bp, ba, sp, ctx] = await Promise.allSettled([
|
|
376
404
|
memStore.listBlocks({ pinned: true, scope: 'both' }),
|
|
377
405
|
memStore.listBlocks({ scope: 'both' }),
|
|
378
406
|
memStore.search(args.userText, 8),
|
|
407
|
+
buildMemoryContext({ store: memStore, currentUserMessage: args.userText }),
|
|
379
408
|
]);
|
|
380
409
|
const pinnedBlocks = bp.status === 'fulfilled' ? bp.value : [];
|
|
381
410
|
const allBlocks = ba.status === 'fulfilled' ? ba.value : [];
|
|
382
411
|
const searchHits = sp.status === 'fulfilled' ? sp.value : [];
|
|
383
|
-
const firstErr = [bp, ba, sp].find((r) => r.status === 'rejected') as PromiseRejectedResult | undefined;
|
|
412
|
+
const firstErr = [bp, ba, sp, ctx].find((r) => r.status === 'rejected') as PromiseRejectedResult | undefined;
|
|
384
413
|
const memError = firstErr ? (firstErr.reason instanceof Error ? firstErr.reason.message : String(firstErr.reason)) : undefined;
|
|
385
414
|
|
|
386
|
-
memContext =
|
|
415
|
+
memContext = ctx.status === 'fulfilled' ? ctx.value.text : '';
|
|
387
416
|
|
|
388
417
|
cb({
|
|
389
418
|
type: 'memory_status',
|
|
@@ -470,7 +499,9 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
|
|
|
470
499
|
while (iter < MAX_ITERATIONS) {
|
|
471
500
|
iter += 1;
|
|
472
501
|
|
|
473
|
-
const history =
|
|
502
|
+
const history = trimOrphanToolResults(
|
|
503
|
+
listMessagesCapped(args.sessionId, HISTORY_MSG_BUDGET, HISTORY_TOKEN_BUDGET, estimateTokens),
|
|
504
|
+
);
|
|
474
505
|
|
|
475
506
|
assistantBlocksAccum = [];
|
|
476
507
|
let currentTextBuf = '';
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* buildMemoryContext — assemble the memory chunk for the agent-loop
|
|
3
|
+
* system prompt.
|
|
4
|
+
*
|
|
5
|
+
* Wraps store.listBlocks (for pinned + recall) and store.search (for
|
|
6
|
+
* query-driven retrieval) and post-filters out internal bookkeeping
|
|
7
|
+
* blocks like the summarizer cursor/health by key prefix. The actual
|
|
8
|
+
* string rendering reuses renderMemoryContext(blocks, hits) — this
|
|
9
|
+
* helper is just the assembly + filtering layer so callers don't have
|
|
10
|
+
* to think about it.
|
|
11
|
+
*
|
|
12
|
+
* Why post-filter instead of extending MemoryStore.search/listBlocks
|
|
13
|
+
* with scope filters: the existing API is flat key/value across both
|
|
14
|
+
* backends (LocalMemoryStore + Temper) and we want zero changes there.
|
|
15
|
+
* Forge owns the key naming convention (see lib/memory/keys.ts), so we
|
|
16
|
+
* own the prefix-exclusion decision client-side.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { MemoryBlock, MemoryStore, SearchHit } from './memory-store';
|
|
20
|
+
import { renderMemoryContext } from './temper';
|
|
21
|
+
import { INTERNAL_KEY_PREFIXES } from '../memory/keys';
|
|
22
|
+
|
|
23
|
+
export interface BuildMemoryContextOpts {
|
|
24
|
+
store: MemoryStore;
|
|
25
|
+
/** Used as `store.search(query)` — typically the latest user message. */
|
|
26
|
+
currentUserMessage?: string;
|
|
27
|
+
/** Cap on hits returned from store.search. Default 6. */
|
|
28
|
+
topK?: number;
|
|
29
|
+
/** Cap on inlined pinned blocks. Default 50 (renderMemoryContext default). */
|
|
30
|
+
maxBlocks?: number;
|
|
31
|
+
/** Prefixes that mark internal-only blocks (cursor / health / etc).
|
|
32
|
+
* Defaults to lib/memory/keys.INTERNAL_KEY_PREFIXES. */
|
|
33
|
+
excludeKeyPrefixes?: readonly string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface BuildMemoryContextResult {
|
|
37
|
+
text: string;
|
|
38
|
+
blocks: MemoryBlock[];
|
|
39
|
+
hits: SearchHit[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function buildMemoryContext(opts: BuildMemoryContextOpts): Promise<BuildMemoryContextResult> {
|
|
43
|
+
const {
|
|
44
|
+
store,
|
|
45
|
+
currentUserMessage,
|
|
46
|
+
topK = 6,
|
|
47
|
+
maxBlocks = 50,
|
|
48
|
+
excludeKeyPrefixes = INTERNAL_KEY_PREFIXES,
|
|
49
|
+
} = opts;
|
|
50
|
+
|
|
51
|
+
const blocks = filterInternal(
|
|
52
|
+
await safe(() => store.listBlocks({ pinned: true }), [] as MemoryBlock[]),
|
|
53
|
+
excludeKeyPrefixes,
|
|
54
|
+
).slice(0, maxBlocks);
|
|
55
|
+
|
|
56
|
+
const q = (currentUserMessage || '').trim();
|
|
57
|
+
let hits: SearchHit[] = [];
|
|
58
|
+
if (q) {
|
|
59
|
+
const rawHits = await safe(() => store.search(q, topK), [] as SearchHit[]);
|
|
60
|
+
hits = filterInternalHits(rawHits, excludeKeyPrefixes);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { text: renderMemoryContext(blocks, hits), blocks, hits };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function filterInternal(blocks: MemoryBlock[], prefixes: readonly string[]): MemoryBlock[] {
|
|
67
|
+
if (prefixes.length === 0) return blocks;
|
|
68
|
+
return blocks.filter((b) => !prefixes.some((p) => b.key.startsWith(p)));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function filterInternalHits(hits: SearchHit[], prefixes: readonly string[]): SearchHit[] {
|
|
72
|
+
if (prefixes.length === 0) return hits;
|
|
73
|
+
// SearchHit.id encodes its source: LocalMemoryStore returns 'block:<key>'
|
|
74
|
+
// for block-derived hits. Temper returns Graphiti UUIDs — those won't
|
|
75
|
+
// match prefixes, so they pass through (correct: Temper hits aren't
|
|
76
|
+
// direct block references).
|
|
77
|
+
return hits.filter((h) => {
|
|
78
|
+
if (!h.id?.startsWith('block:')) return true;
|
|
79
|
+
const key = h.id.slice('block:'.length);
|
|
80
|
+
return !prefixes.some((p) => key.startsWith(p));
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function safe<T>(fn: () => Promise<T>, fallback: T): Promise<T> {
|
|
85
|
+
try {
|
|
86
|
+
return await fn();
|
|
87
|
+
} catch (err) {
|
|
88
|
+
console.warn('[buildMemoryContext]', err instanceof Error ? err.message : err);
|
|
89
|
+
return fallback;
|
|
90
|
+
}
|
|
91
|
+
}
|
package/lib/chat/llm/openai.ts
CHANGED
|
@@ -77,11 +77,14 @@ export const openaiAdapter: LlmAdapter = {
|
|
|
77
77
|
};
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// Some providers (litellm/vLLM) reject `tools: []` — they want the
|
|
81
|
+
// field omitted entirely when there are no tools.
|
|
82
|
+
const hasTools = Object.keys(tools).length > 0;
|
|
80
83
|
const result = streamText({
|
|
81
84
|
model: client(req.model),
|
|
82
85
|
system: req.system,
|
|
83
86
|
messages: historyToModelMessages(req.history),
|
|
84
|
-
tools,
|
|
87
|
+
...(hasTools ? { tools } : {}),
|
|
85
88
|
maxOutputTokens: req.maxTokens,
|
|
86
89
|
});
|
|
87
90
|
|
package/lib/chat/local-memory.ts
CHANGED
|
@@ -133,26 +133,43 @@ export class LocalMemoryStore implements MemoryStore {
|
|
|
133
133
|
const q = (query || '').trim();
|
|
134
134
|
if (!q) return [];
|
|
135
135
|
const cap = Math.min(50, Math.max(1, limit));
|
|
136
|
-
|
|
136
|
+
// Tokenize on whitespace and OR-match. Natural-language queries
|
|
137
|
+
// like "tell me about the X" can't be AND-matched (stop words
|
|
138
|
+
// wouldn't appear in stored content), so OR keeps recall useful.
|
|
139
|
+
// Drop tokens shorter than 3 chars to avoid runaway noise. If
|
|
140
|
+
// every token is too short, fall back to a single-substring match
|
|
141
|
+
// on the raw query.
|
|
142
|
+
const allTokens = q.split(/\s+/).filter((t) => t.length > 0);
|
|
143
|
+
const tokens = allTokens.filter((t) => t.length >= 3);
|
|
144
|
+
const useTokens = tokens.length > 0 ? tokens : [q];
|
|
145
|
+
const likes = useTokens.map((t) => `%${t.replace(/[%_]/g, (m) => '\\' + m)}%`);
|
|
137
146
|
const conn = db();
|
|
138
147
|
|
|
148
|
+
const blockWhere = useTokens
|
|
149
|
+
.map(() => `(value LIKE ? ESCAPE '\\' OR key LIKE ? ESCAPE '\\' OR description LIKE ? ESCAPE '\\')`)
|
|
150
|
+
.join(' OR ');
|
|
151
|
+
const blockParams: unknown[] = [this.ns];
|
|
152
|
+
for (const like of likes) { blockParams.push(like, like, like); }
|
|
153
|
+
blockParams.push(cap);
|
|
139
154
|
const blockHits = conn.prepare(
|
|
140
155
|
`SELECT key, value, description, updated_at
|
|
141
156
|
FROM memory_blocks
|
|
142
157
|
WHERE ns = ?
|
|
143
|
-
AND (
|
|
158
|
+
AND (${blockWhere})
|
|
144
159
|
ORDER BY pinned DESC, updated_at DESC
|
|
145
160
|
LIMIT ?`,
|
|
146
|
-
).all(
|
|
161
|
+
).all(...blockParams) as Array<Pick<BlockRow, 'key' | 'value' | 'description' | 'updated_at'>>;
|
|
147
162
|
|
|
163
|
+
const episodeWhere = useTokens.map(() => `content LIKE ? ESCAPE '\\'`).join(' OR ');
|
|
164
|
+
const episodeParams: unknown[] = [this.ns, ...likes, cap];
|
|
148
165
|
const episodeHits = conn.prepare(
|
|
149
166
|
`SELECT id, content, reference_time, created_at
|
|
150
167
|
FROM memory_episodes
|
|
151
168
|
WHERE ns = ?
|
|
152
|
-
AND
|
|
169
|
+
AND (${episodeWhere})
|
|
153
170
|
ORDER BY created_at DESC
|
|
154
171
|
LIMIT ?`,
|
|
155
|
-
).all(
|
|
172
|
+
).all(...episodeParams) as Array<Pick<EpisodeRow, 'id' | 'content' | 'reference_time' | 'created_at'>>;
|
|
156
173
|
|
|
157
174
|
const hits: SearchHit[] = [];
|
|
158
175
|
for (const b of blockHits) {
|