@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 CHANGED
@@ -1,22 +1,18 @@
1
- # Forge v0.9.18
1
+ # Forge v0.9.19
2
2
 
3
- Released: 2026-05-28
3
+ Released: 2026-05-29
4
4
 
5
- ## Changes since v0.9.16
5
+ ## Changes since v0.9.18
6
6
 
7
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
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.16...v0.9.18
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
+ }
@@ -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);
@@ -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">
@@ -16,16 +16,17 @@ import { loadSettings } from '../settings';
16
16
  import {
17
17
  appendMessage,
18
18
  getSession,
19
- listMessages,
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
- const [bp, ba, sp] = await Promise.allSettled([
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 = renderMemoryContext(allBlocks, searchHits);
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 = listMessages(args.sessionId);
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
+ }
@@ -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
 
@@ -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
- const like = `%${q.replace(/[%_]/g, (m) => '\\' + m)}%`;
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 (value LIKE ? ESCAPE '\\' OR key LIKE ? ESCAPE '\\' OR description LIKE ? ESCAPE '\\')
158
+ AND (${blockWhere})
144
159
  ORDER BY pinned DESC, updated_at DESC
145
160
  LIMIT ?`,
146
- ).all(this.ns, like, like, like, cap) as Array<Pick<BlockRow, 'key' | 'value' | 'description' | 'updated_at'>>;
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 content LIKE ? ESCAPE '\\'
169
+ AND (${episodeWhere})
153
170
  ORDER BY created_at DESC
154
171
  LIMIT ?`,
155
- ).all(this.ns, like, cap) as Array<Pick<EpisodeRow, 'id' | 'content' | 'reference_time' | 'created_at'>>;
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) {