@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.
@@ -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);
@@ -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
+ }