@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
|
@@ -265,6 +265,55 @@ export function listMessages(session_id: string, opts?: { limit?: number; after_
|
|
|
265
265
|
return rows.map(rowToMessage);
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
+
/** Last N messages in chronological order — used by agent-loop to cap LLM context. */
|
|
269
|
+
export function listRecentMessages(session_id: string, limit: number): Message[] {
|
|
270
|
+
ensureSchema();
|
|
271
|
+
const rows = db().prepare(`
|
|
272
|
+
SELECT * FROM chat_messages WHERE session_id = ?
|
|
273
|
+
ORDER BY ts DESC LIMIT ?
|
|
274
|
+
`).all(session_id, limit) as MessageRow[];
|
|
275
|
+
return rows.map(rowToMessage).reverse();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Take the most recent messages, stopping when either the message-count
|
|
280
|
+
* budget OR the token-estimate budget would be exceeded. Walks
|
|
281
|
+
* newest-first so the most recent dialogue is always kept; returns
|
|
282
|
+
* chronological order for the LLM history slot.
|
|
283
|
+
*
|
|
284
|
+
* msgBudget is enforced via SQL LIMIT (cheap). tokenBudget is enforced
|
|
285
|
+
* via the caller-supplied estimator (decoupled to avoid pulling the
|
|
286
|
+
* token-estimator into the storage layer).
|
|
287
|
+
*/
|
|
288
|
+
export function listMessagesCapped(
|
|
289
|
+
session_id: string,
|
|
290
|
+
msgBudget: number,
|
|
291
|
+
tokenBudget: number,
|
|
292
|
+
estimateTokens: (m: Message) => number,
|
|
293
|
+
): Message[] {
|
|
294
|
+
ensureSchema();
|
|
295
|
+
const cap = Math.max(1, Math.floor(msgBudget));
|
|
296
|
+
// Pull newest-first via SQL — bounded by msgBudget so we never load
|
|
297
|
+
// more rows than we could possibly keep.
|
|
298
|
+
const rows = db().prepare(`
|
|
299
|
+
SELECT * FROM chat_messages WHERE session_id = ?
|
|
300
|
+
ORDER BY ts DESC LIMIT ?
|
|
301
|
+
`).all(session_id, cap) as MessageRow[];
|
|
302
|
+
const newestFirst = rows.map(rowToMessage);
|
|
303
|
+
|
|
304
|
+
// Now apply tokenBudget walking newest → oldest. Always keep at
|
|
305
|
+
// least one (so an oversized last message doesn't strand the loop).
|
|
306
|
+
const kept: Message[] = [];
|
|
307
|
+
let used = 0;
|
|
308
|
+
for (const m of newestFirst) {
|
|
309
|
+
const cost = estimateTokens(m);
|
|
310
|
+
if (kept.length > 0 && used + cost > tokenBudget) break;
|
|
311
|
+
kept.push(m);
|
|
312
|
+
used += cost;
|
|
313
|
+
}
|
|
314
|
+
return kept.reverse();
|
|
315
|
+
}
|
|
316
|
+
|
|
268
317
|
export function deleteMessage(id: string): boolean {
|
|
269
318
|
ensureSchema();
|
|
270
319
|
const r = db().prepare(`DELETE FROM chat_messages WHERE id = ?`).run(id);
|
package/lib/chat-standalone.ts
CHANGED
|
@@ -144,6 +144,12 @@ async function handleSessionDelete(_req: IncomingMessage, res: ServerResponse, i
|
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
async function handleSessionClearMessages(_req: IncomingMessage, res: ServerResponse, id: string): Promise<void> {
|
|
147
|
+
// Intent: "Clear chat" only drops chat_messages rows. memory_store
|
|
148
|
+
// blocks (cursor / health / summary / facts) stay — once a fact has
|
|
149
|
+
// been extracted into long-term memory it should survive clearing
|
|
150
|
+
// the conversation it came from. Users can delete memory explicitly
|
|
151
|
+
// from the memory tab if they really want to forget. See
|
|
152
|
+
// forge-chat-memory-summarizer-design.md §11 decision 3.
|
|
147
153
|
const session = getSession(id);
|
|
148
154
|
if (!session) return sendJson(res, 404, { error: 'session not found' });
|
|
149
155
|
const removed = clearSessionMessages(id);
|
package/lib/init.ts
CHANGED
|
@@ -249,6 +249,7 @@ export function ensureInitialized() {
|
|
|
249
249
|
startWorkspaceProcess(); // spawns workspace-standalone
|
|
250
250
|
startBrowserBridgeProcess(); // spawns browser-bridge-standalone
|
|
251
251
|
startChatProcess(); // spawns chat-standalone
|
|
252
|
+
startMemoryProcess(); // spawns memory-standalone
|
|
252
253
|
|
|
253
254
|
const settings = loadSettings();
|
|
254
255
|
if (settings.tunnelAutoStart) {
|
|
@@ -402,3 +403,18 @@ function startBrowserBridgeProcess() {
|
|
|
402
403
|
});
|
|
403
404
|
tester.listen(bridgePort);
|
|
404
405
|
}
|
|
406
|
+
|
|
407
|
+
let memoryChild: ReturnType<typeof spawn> | null = null;
|
|
408
|
+
|
|
409
|
+
function startMemoryProcess() {
|
|
410
|
+
if (memoryChild) return;
|
|
411
|
+
// No HTTP port — pure background poller. Just spawn-if-not-running.
|
|
412
|
+
const script = join(process.cwd(), 'lib', 'memory-standalone.ts');
|
|
413
|
+
memoryChild = spawn('npx', ['tsx', script], {
|
|
414
|
+
stdio: ['ignore', 'inherit', 'inherit'],
|
|
415
|
+
env: { ...process.env },
|
|
416
|
+
detached: false,
|
|
417
|
+
});
|
|
418
|
+
memoryChild.on('exit', () => { memoryChild = null; });
|
|
419
|
+
console.log('[memory] Started standalone (pid:', memoryChild.pid, ')');
|
|
420
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compact chat messages into a summarizer-friendly transcript.
|
|
3
|
+
*
|
|
4
|
+
* Raw tool_use / tool_result blocks can each carry kilobytes of JSON
|
|
5
|
+
* (stack traces, encoded args, HTML responses). Feeding those into the
|
|
6
|
+
* summarizer LLM wastes input tokens and crowds out actual dialogue.
|
|
7
|
+
*
|
|
8
|
+
* This module flattens each Message into one or more text lines:
|
|
9
|
+
* - text blocks pass through (truncated to MAX_TEXT_CHARS)
|
|
10
|
+
* - tool_use → `tool[name](key1, key2, …)`
|
|
11
|
+
* - tool_result → `→ ok: <first line>` or `→ err: <first line>`
|
|
12
|
+
*
|
|
13
|
+
* Output is a plain string ready to drop into the summarizer prompt.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ContentBlock, Message, ToolResultBlock, ToolUseBlock } from '../chat/types';
|
|
17
|
+
|
|
18
|
+
const MAX_TEXT_CHARS = 1200;
|
|
19
|
+
const MAX_TOOL_RESULT_CHARS = 200;
|
|
20
|
+
const MAX_INPUT_KEYS = 8;
|
|
21
|
+
|
|
22
|
+
export function compressMessagesForSummarizer(messages: Message[]): string {
|
|
23
|
+
const lines: string[] = [];
|
|
24
|
+
for (const m of messages) {
|
|
25
|
+
for (const block of m.blocks) {
|
|
26
|
+
const rendered = renderBlock(m.role, block);
|
|
27
|
+
if (rendered) lines.push(rendered);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return lines.join('\n');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function renderBlock(role: 'user' | 'assistant', block: ContentBlock): string | null {
|
|
34
|
+
if (block.type === 'text') {
|
|
35
|
+
const text = truncate(block.text.trim(), MAX_TEXT_CHARS);
|
|
36
|
+
if (!text) return null;
|
|
37
|
+
return `${role}: ${text}`;
|
|
38
|
+
}
|
|
39
|
+
if (block.type === 'tool_use') {
|
|
40
|
+
return `${role}: ${renderToolUse(block)}`;
|
|
41
|
+
}
|
|
42
|
+
if (block.type === 'tool_result') {
|
|
43
|
+
return `${role}: ${renderToolResult(block)}`;
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function renderToolUse(block: ToolUseBlock): string {
|
|
49
|
+
const keys = block.input && typeof block.input === 'object'
|
|
50
|
+
? Object.keys(block.input as Record<string, unknown>).slice(0, MAX_INPUT_KEYS)
|
|
51
|
+
: [];
|
|
52
|
+
const argsStr = keys.length > 0 ? `(${keys.join(', ')})` : '()';
|
|
53
|
+
return `tool[${block.name}]${argsStr}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function renderToolResult(block: ToolResultBlock): string {
|
|
57
|
+
const firstLine = (block.content ?? '').split(/\r?\n/, 1)[0] ?? '';
|
|
58
|
+
const head = truncate(firstLine, MAX_TOOL_RESULT_CHARS);
|
|
59
|
+
return block.is_error ? `→ err: ${head}` : `→ ok: ${head}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function truncate(s: string, max: number): string {
|
|
63
|
+
if (s.length <= max) return s;
|
|
64
|
+
return s.slice(0, max) + '…';
|
|
65
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Key-naming conventions for the chat memory summarizer.
|
|
3
|
+
*
|
|
4
|
+
* MemoryStore is a flat key/value API; this module encodes the
|
|
5
|
+
* design's "scope + subject" classification into deterministic key
|
|
6
|
+
* strings so that:
|
|
7
|
+
* - cursor / health blocks land on a single stable key (upsert covers)
|
|
8
|
+
* - repeated ingest of the same fact maps to the same key (upsert
|
|
9
|
+
* replaces, not appends — this is what "memory reinforcement" looks
|
|
10
|
+
* like at the storage layer)
|
|
11
|
+
* - buildMemoryContext can post-filter by prefix to keep internal
|
|
12
|
+
* bookkeeping blocks (cursor / health) out of the LLM prompt
|
|
13
|
+
*
|
|
14
|
+
* See forge-chat-memory-summarizer-design.md §4.2 for the full table.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createHash } from 'node:crypto';
|
|
18
|
+
|
|
19
|
+
/** Stable 12-char content hash. Used in fact keys so re-ingesting the
|
|
20
|
+
* same fact maps to the same memory block (upsert = reinforcement). */
|
|
21
|
+
export function stableHash(input: string): string {
|
|
22
|
+
return createHash('sha256').update(input).digest('hex').slice(0, 12);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Session summary at a given cursor end-ts. */
|
|
26
|
+
export function summaryKey(sessionId: string, toTs: number): string {
|
|
27
|
+
return `chat:${sessionId}:summary:${toTs}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Long-term fact. scope/subject classify it; hash keys re-ingest. */
|
|
31
|
+
export function factKey(scope: string, subject: string, contentHash: string): string {
|
|
32
|
+
return `fact:${scope}:${subject}:${contentHash}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Per-session ingest progress cursor. One row per session. */
|
|
36
|
+
export function cursorKey(sessionId: string): string {
|
|
37
|
+
return `forge.summarizer.cursor:${sessionId}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Per-session summarizer health (last_run, errors, counts). */
|
|
41
|
+
export function healthKey(sessionId: string): string {
|
|
42
|
+
return `forge.summarizer.health:${sessionId}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Prefixes buildMemoryContext should exclude when rendering context —
|
|
46
|
+
* bookkeeping blocks the LLM shouldn't see. */
|
|
47
|
+
export const INTERNAL_KEY_PREFIXES: readonly string[] = [
|
|
48
|
+
'forge.summarizer.cursor:',
|
|
49
|
+
'forge.summarizer.health:',
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
export interface CursorValue {
|
|
53
|
+
last_ingested_ts: number;
|
|
54
|
+
last_run_ts: number;
|
|
55
|
+
ingest_count: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface SummaryValue {
|
|
59
|
+
text: string;
|
|
60
|
+
from_ts: number;
|
|
61
|
+
to_ts: number;
|
|
62
|
+
message_count: number;
|
|
63
|
+
model: string;
|
|
64
|
+
provider: string;
|
|
65
|
+
ingest_ts: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface FactValue {
|
|
69
|
+
content: string;
|
|
70
|
+
subject_kind: string;
|
|
71
|
+
subject: string;
|
|
72
|
+
source_ref: string;
|
|
73
|
+
confidence: number | null;
|
|
74
|
+
extracted_by: 'summarizer' | string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface HealthValue {
|
|
78
|
+
last_run_ts: number;
|
|
79
|
+
error: string | null;
|
|
80
|
+
ingest_count: number;
|
|
81
|
+
last_token_estimate: number;
|
|
82
|
+
}
|