@aion0/forge 0.9.16 → 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.
@@ -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
+ }
@@ -0,0 +1,485 @@
1
+ /**
2
+ * Temper Summary — the v1 memory-standalone sub-task.
3
+ *
4
+ * One pass scans every chat_session: for each, reads the ingest cursor
5
+ * from the memory_store, fetches chat_messages since cursor, decides
6
+ * whether the new chunk crosses the trigger thresholds, and if so calls
7
+ * summarizeAndIngest() to compress it and push summary + extracted
8
+ * facts back into memory_store. Cursor + health blocks are upserted at
9
+ * the same keys (see lib/memory/keys.ts) so two ticks racing the same
10
+ * session converge instead of duplicating work.
11
+ *
12
+ * The core function is split out as a generic
13
+ * summarizeAndIngest({ store, messages, scope, cursorKey, provider })
14
+ * so the Phase E6 global skill (workspace smiths / pipeline nodes /
15
+ * task claude subprocesses) can reuse it without going through the
16
+ * chat-session-scanner wrapper.
17
+ *
18
+ * See forge-chat-memory-summarizer-design.md §4 and §5.
19
+ */
20
+
21
+ import {
22
+ cursorKey,
23
+ factKey,
24
+ healthKey,
25
+ stableHash,
26
+ summaryKey,
27
+ type CursorValue,
28
+ type FactValue,
29
+ type HealthValue,
30
+ type SummaryValue,
31
+ } from './keys';
32
+ import { estimateTokens } from './token-estimate';
33
+ import { compressMessagesForSummarizer } from './compress-messages';
34
+ import { listSessions, listMessages } from '../chat/session-store';
35
+ import { getMemoryStore, type MemoryStore } from '../chat/memory-store';
36
+ import { resolveProvider, type ProviderResolution } from '../chat/agent-loop';
37
+ import type { Message } from '../chat/types';
38
+ import type { MemorySubTask } from '../memory-standalone';
39
+
40
+ // ── Tunables (see design §8) ──────────────────────────────────────────
41
+ const WORKING_BUFFER_MSGS = 40;
42
+ const TRIGGER_MIN_MSGS = 30;
43
+ const TRIGGER_MIN_TOKENS = 4000;
44
+ const SUMMARIZER_INPUT_TOKEN_BUDGET = 12000;
45
+ const SUMMARIZER_MAX_OUTPUT_TOKENS = 2000;
46
+
47
+ // ── Public types ──────────────────────────────────────────────────────
48
+ export interface FactInput {
49
+ scope: string; // e.g. 'user' / 'project' / 'preference'
50
+ subject_kind: string; // free-form, set by the LLM
51
+ subject: string; // 'zliu', 'forge-project', …
52
+ content: string;
53
+ }
54
+
55
+ export interface SummarizeAndIngestArgs {
56
+ store: MemoryStore;
57
+ /** The slice of conversation to compress (compressed, not raw blocks). */
58
+ messages: Message[];
59
+ /** Logical scope passed to keys.ts (e.g. session id). */
60
+ scope: string;
61
+ /** Where the cursor lives in memory_store. Caller owns the key shape. */
62
+ cursorKeyName: string;
63
+ /** Optional previous cursor value — used to bump ingest_count. */
64
+ prevCursor: CursorValue | null;
65
+ /** Resolved API provider. */
66
+ provider: ProviderResolution;
67
+ /** The to_ts boundary of this slice (last message ts in the slice). */
68
+ toTs: number;
69
+ }
70
+
71
+ export interface SummarizeAndIngestResult {
72
+ ok: boolean;
73
+ summary?: string;
74
+ factCount?: number;
75
+ error?: string;
76
+ promptTokens?: number;
77
+ completionTokens?: number;
78
+ }
79
+
80
+ // ── Generic summarize + ingest (Phase B1 + B5 + B7 + B8 + B9) ─────────
81
+ export async function summarizeAndIngest(args: SummarizeAndIngestArgs): Promise<SummarizeAndIngestResult> {
82
+ const { store, messages, scope, cursorKeyName, prevCursor, provider, toTs } = args;
83
+ const fromTs = messages[0]?.ts ?? toTs;
84
+ const transcript = compressMessagesForSummarizer(messages);
85
+ const promptTokens = estimateTokens(transcript);
86
+
87
+ // Call LLM
88
+ let raw: string;
89
+ try {
90
+ raw = await callSummarizerLlm(provider, transcript);
91
+ } catch (err) {
92
+ return { ok: false, error: 'llm: ' + (err instanceof Error ? err.message : String(err)), promptTokens };
93
+ }
94
+
95
+ const { summary, facts } = parseSummarizerResponse(raw);
96
+ if (!summary) {
97
+ return { ok: false, error: 'empty summary in response', promptTokens };
98
+ }
99
+
100
+ // Persist
101
+ const now = Date.now();
102
+ const summaryValue: SummaryValue = {
103
+ text: summary,
104
+ from_ts: fromTs,
105
+ to_ts: toTs,
106
+ message_count: messages.length,
107
+ model: provider.model,
108
+ provider: provider.type,
109
+ ingest_ts: now,
110
+ };
111
+ try {
112
+ await store.putBlock(
113
+ summaryKey(scope, toTs),
114
+ summaryValue,
115
+ { description: summary.slice(0, 120), scope: 'own' },
116
+ );
117
+ } catch (err) {
118
+ return { ok: false, error: 'putBlock summary: ' + (err instanceof Error ? err.message : String(err)), promptTokens };
119
+ }
120
+
121
+ // Dual-write: putBlock above gives us upsert/cursor/recall by key,
122
+ // but Temper's KG (Graphiti) only ingests writeEpisode payloads. Push
123
+ // the same summary text as an episode so entity/relation extraction
124
+ // sees chat history. Failure here doesn't block the pass.
125
+ try {
126
+ await store.writeEpisode({
127
+ content: summary,
128
+ source_type: 'text',
129
+ source_description: `chat:${scope} summary @ ${toTs}`,
130
+ tags: ['summarizer', 'session_summary', `chat:${scope}`],
131
+ });
132
+ } catch (err) {
133
+ console.warn(`[temper-summary] writeEpisode(summary) failed for ${scope}:`, err instanceof Error ? err.message : err);
134
+ }
135
+
136
+ let factCount = 0;
137
+ for (const f of facts) {
138
+ if (!f.content || !f.subject) continue;
139
+ const v: FactValue = {
140
+ content: f.content,
141
+ subject_kind: f.subject_kind || 'fact',
142
+ subject: f.subject,
143
+ source_ref: `chat:${scope}@${toTs}`,
144
+ confidence: null,
145
+ extracted_by: 'summarizer',
146
+ };
147
+ try {
148
+ await store.putBlock(
149
+ factKey(f.scope || 'other', f.subject, stableHash(f.content)),
150
+ v,
151
+ { description: f.content.slice(0, 120), scope: 'own' },
152
+ );
153
+ factCount += 1;
154
+ } catch (err) {
155
+ console.warn(`[temper-summary] fact putBlock failed for ${f.subject}:`, err instanceof Error ? err.message : err);
156
+ }
157
+ // Episode-mirror each fact so the KG sees structured propositions.
158
+ // Per-fact rather than batched so Graphiti can attribute extraction
159
+ // to the right subject without re-parsing.
160
+ try {
161
+ await store.writeEpisode({
162
+ content: `${f.subject_kind || 'fact'} about ${f.subject}: ${f.content}`,
163
+ source_type: 'text',
164
+ source_description: `chat:${scope} fact: ${f.subject}`,
165
+ tags: ['summarizer', 'fact', f.scope || 'other', f.subject_kind || 'fact'],
166
+ });
167
+ } catch (err) {
168
+ console.warn(`[temper-summary] writeEpisode(fact) failed for ${f.subject}:`, err instanceof Error ? err.message : err);
169
+ }
170
+ }
171
+
172
+ // Advance cursor (B9)
173
+ const newCursor: CursorValue = {
174
+ last_ingested_ts: toTs,
175
+ last_run_ts: now,
176
+ ingest_count: (prevCursor?.ingest_count ?? 0) + 1,
177
+ };
178
+ try {
179
+ await store.putBlock(
180
+ cursorKeyName,
181
+ newCursor,
182
+ { description: `summarizer cursor for ${scope}`, scope: 'own' },
183
+ );
184
+ } catch (err) {
185
+ // Cursor failed to advance — next tick will retry the same range.
186
+ return { ok: false, error: 'putBlock cursor: ' + (err instanceof Error ? err.message : String(err)), promptTokens };
187
+ }
188
+
189
+ return {
190
+ ok: true,
191
+ summary,
192
+ factCount,
193
+ promptTokens,
194
+ completionTokens: estimateTokens(raw),
195
+ };
196
+ }
197
+
198
+ // ── Per-session scanner (B1 wrapper around summarizeAndIngest) ────────
199
+ async function tickSession(store: MemoryStore, sessionId: string, sessionProvider: string | null, sessionModel: string | null): Promise<void> {
200
+ const ck = cursorKey(sessionId);
201
+ const prev = await readCursor(store, ck);
202
+ const lastTs = prev?.last_ingested_ts ?? 0;
203
+
204
+ const newMsgs = listMessages(sessionId, { after_ts: lastTs, limit: 5000 });
205
+ if (newMsgs.length === 0) return;
206
+
207
+ // Reserve the most recent N messages — they belong to the working
208
+ // window the agent-loop reads directly.
209
+ const candidates = newMsgs.slice(0, Math.max(0, newMsgs.length - WORKING_BUFFER_MSGS));
210
+ if (candidates.length === 0) return;
211
+
212
+ // Double-threshold trigger.
213
+ const tokenEstimate = estimateTokens(candidates);
214
+ if (candidates.length < TRIGGER_MIN_MSGS && tokenEstimate < TRIGGER_MIN_TOKENS) return;
215
+
216
+ // Cap input by token budget — take from the front until we hit it.
217
+ const slice = takeFromFrontUntilBudget(candidates, SUMMARIZER_INPUT_TOKEN_BUDGET);
218
+ if (slice.length === 0) return;
219
+
220
+ // Resolve provider (B6).
221
+ const provider = resolveProvider(sessionProvider, sessionModel);
222
+ if ('error' in provider) {
223
+ await writeHealth(store, sessionId, { error: `provider: ${provider.error}`, ingestCount: prev?.ingest_count ?? 0, tokenEstimate });
224
+ return;
225
+ }
226
+
227
+ const toTs = slice[slice.length - 1]!.ts;
228
+ const sliceTokens = estimateTokens(slice);
229
+ console.log(`[temper-summary] session=${sessionId} ingesting ${slice.length}/${candidates.length} msgs (~${sliceTokens}t, candidates ~${tokenEstimate}t) up to ts=${toTs}`);
230
+ const result = await summarizeAndIngest({
231
+ store,
232
+ messages: slice,
233
+ scope: sessionId,
234
+ cursorKeyName: ck,
235
+ prevCursor: prev ?? null,
236
+ provider,
237
+ toTs,
238
+ });
239
+
240
+ await writeHealth(store, sessionId, {
241
+ error: result.ok ? null : result.error ?? 'unknown',
242
+ ingestCount: result.ok ? (prev?.ingest_count ?? 0) + 1 : (prev?.ingest_count ?? 0),
243
+ tokenEstimate: result.promptTokens ?? tokenEstimate,
244
+ });
245
+
246
+ if (result.ok) {
247
+ console.log(`[temper-summary] session=${sessionId} ok (${result.factCount} fact${result.factCount === 1 ? '' : 's'})`);
248
+ } else {
249
+ console.warn(`[temper-summary] session=${sessionId} failed: ${result.error}`);
250
+ }
251
+ }
252
+
253
+ // ── Sub-task export (B1) ──────────────────────────────────────────────
254
+ export function temperSummaryTask(): MemorySubTask {
255
+ return {
256
+ name: 'temper-summary',
257
+ async tick() {
258
+ const store = getMemoryStore();
259
+ if (!store.enabled) {
260
+ console.log('[temper-summary] memory store not enabled, skipping tick');
261
+ return;
262
+ }
263
+ const sessions = listSessions(500);
264
+ for (const s of sessions) {
265
+ try {
266
+ await tickSession(store, s.id, s.provider, s.model);
267
+ } catch (err) {
268
+ console.warn(`[temper-summary] session ${s.id} tick crashed:`, err instanceof Error ? err.message : err);
269
+ }
270
+ }
271
+ },
272
+ };
273
+ }
274
+
275
+ // ── Helpers ────────────────────────────────────────────────────────────
276
+
277
+ async function readCursor(store: MemoryStore, key: string): Promise<CursorValue | null> {
278
+ try {
279
+ const b = await store.getBlock(key);
280
+ if (!b) return null;
281
+ const v = b.value as Partial<CursorValue> | undefined;
282
+ if (!v || typeof v.last_ingested_ts !== 'number') return null;
283
+ return {
284
+ last_ingested_ts: v.last_ingested_ts,
285
+ last_run_ts: typeof v.last_run_ts === 'number' ? v.last_run_ts : 0,
286
+ ingest_count: typeof v.ingest_count === 'number' ? v.ingest_count : 0,
287
+ };
288
+ } catch (err) {
289
+ console.warn('[temper-summary] readCursor failed:', err instanceof Error ? err.message : err);
290
+ return null;
291
+ }
292
+ }
293
+
294
+ async function writeHealth(
295
+ store: MemoryStore,
296
+ sessionId: string,
297
+ opts: { error: string | null; ingestCount: number; tokenEstimate: number },
298
+ ): Promise<void> {
299
+ const v: HealthValue = {
300
+ last_run_ts: Date.now(),
301
+ error: opts.error,
302
+ ingest_count: opts.ingestCount,
303
+ last_token_estimate: opts.tokenEstimate,
304
+ };
305
+ try {
306
+ await store.putBlock(
307
+ healthKey(sessionId),
308
+ v,
309
+ { description: opts.error ? `summarizer error: ${opts.error.slice(0, 80)}` : 'summarizer ok', scope: 'own' },
310
+ );
311
+ } catch (err) {
312
+ console.warn('[temper-summary] writeHealth failed:', err instanceof Error ? err.message : err);
313
+ }
314
+ }
315
+
316
+ function takeFromFrontUntilBudget(messages: Message[], budget: number): Message[] {
317
+ const out: Message[] = [];
318
+ let used = 0;
319
+ for (const m of messages) {
320
+ const cost = estimateTokens(m);
321
+ if (used + cost > budget && out.length > 0) break;
322
+ out.push(m);
323
+ used += cost;
324
+ }
325
+ return out;
326
+ }
327
+
328
+ // ── LLM call (B5) ─────────────────────────────────────────────────────
329
+ const SUMMARIZER_INSTRUCTIONS = [
330
+ 'You are a chat memory ingestor. Read the conversation segment below and produce:',
331
+ '1. A concise prose summary capturing decisions, facts, and the user\'s state at the END of this segment.',
332
+ '2. A JSON list of long-term facts worth remembering across sessions:',
333
+ ' - user identity / preferences / role',
334
+ ' - project decisions / constraints',
335
+ ' - named third-party facts ("Alice prefers Postgres")',
336
+ 'DO NOT include speculation. Only what\'s explicitly stated or unambiguously implied.',
337
+ '',
338
+ 'OUTPUT FORMAT (strict):',
339
+ '<summary>',
340
+ '...prose summary, max ~800 chars...',
341
+ '</summary>',
342
+ '<facts>',
343
+ '[ {"scope":"user|project|other", "subject_kind":"...", "subject":"...", "content":"..."} ]',
344
+ '</facts>',
345
+ ].join('\n');
346
+
347
+ /**
348
+ * Direct HTTP call to the provider, bypassing the ai-sdk wrapper.
349
+ *
350
+ * Why not streamLlm: ai-sdk's streamText injects a `tools` field even
351
+ * when we don't pass one. Strict OpenAI-spec gateways (litellm + vLLM)
352
+ * reject `tools: []` with 400 per the OpenAI Chat Completions spec
353
+ * ("if present, must contain at least one tool"). Summarizer has no
354
+ * tools by design, so we just don't send the field at all.
355
+ *
356
+ * Supports openai-compatible (litellm, openai, deepseek-direct, etc.)
357
+ * and anthropic. Both protocols are simple enough that hand-rolling a
358
+ * single-shot non-streaming call is shorter than fighting the wrapper.
359
+ */
360
+ async function callSummarizerLlm(provider: ProviderResolution, transcript: string): Promise<string> {
361
+ const userText = 'CONVERSATION SEGMENT:\n' + transcript;
362
+ if (provider.type === 'anthropic') {
363
+ return callAnthropic(provider, userText);
364
+ }
365
+ return callOpenAI(provider, userText);
366
+ }
367
+
368
+ async function callOpenAI(provider: ProviderResolution, userText: string): Promise<string> {
369
+ const url = joinUrl(provider.baseUrl, '/v1/chat/completions', '/chat/completions');
370
+ const body = {
371
+ model: provider.model,
372
+ messages: [
373
+ { role: 'system', content: SUMMARIZER_INSTRUCTIONS },
374
+ { role: 'user', content: userText },
375
+ ],
376
+ max_tokens: SUMMARIZER_MAX_OUTPUT_TOKENS,
377
+ stream: false,
378
+ };
379
+ const res = await fetch(url, {
380
+ method: 'POST',
381
+ headers: {
382
+ 'content-type': 'application/json',
383
+ authorization: `Bearer ${provider.apiKey}`,
384
+ },
385
+ body: JSON.stringify(body),
386
+ });
387
+ if (!res.ok) {
388
+ const text = await res.text().catch(() => '');
389
+ throw new Error(`openai ${res.status}: ${text.slice(0, 300)}`);
390
+ }
391
+ const json = await res.json() as { choices?: Array<{ message?: { content?: string } }> };
392
+ const content = json.choices?.[0]?.message?.content;
393
+ if (typeof content !== 'string' || content.length === 0) {
394
+ throw new Error(`openai response missing choices[0].message.content: ${JSON.stringify(json).slice(0, 300)}`);
395
+ }
396
+ return content;
397
+ }
398
+
399
+ async function callAnthropic(provider: ProviderResolution, userText: string): Promise<string> {
400
+ const url = joinUrl(provider.baseUrl, '/v1/messages', '/messages');
401
+ const body = {
402
+ model: provider.model,
403
+ system: SUMMARIZER_INSTRUCTIONS,
404
+ messages: [{ role: 'user', content: [{ type: 'text', text: userText }] }],
405
+ max_tokens: SUMMARIZER_MAX_OUTPUT_TOKENS,
406
+ };
407
+ const res = await fetch(url, {
408
+ method: 'POST',
409
+ headers: {
410
+ 'content-type': 'application/json',
411
+ 'x-api-key': provider.apiKey,
412
+ 'anthropic-version': '2023-06-01',
413
+ },
414
+ body: JSON.stringify(body),
415
+ });
416
+ if (!res.ok) {
417
+ const text = await res.text().catch(() => '');
418
+ throw new Error(`anthropic ${res.status}: ${text.slice(0, 300)}`);
419
+ }
420
+ const json = await res.json() as { content?: Array<{ type?: string; text?: string }> };
421
+ const text = (json.content || [])
422
+ .filter((b) => b.type === 'text' && typeof b.text === 'string')
423
+ .map((b) => b.text)
424
+ .join('\n');
425
+ if (!text) {
426
+ throw new Error(`anthropic response missing text blocks: ${JSON.stringify(json).slice(0, 300)}`);
427
+ }
428
+ return text;
429
+ }
430
+
431
+ /** Strip trailing slash + try preferred path first; if base already
432
+ * ends with /v1 don't double it. */
433
+ function joinUrl(base: string, ...candidates: string[]): string {
434
+ const b = base.replace(/\/+$/, '');
435
+ const path = candidates[0]!;
436
+ // If user-configured baseUrl already ends with /v1, fall back to the
437
+ // second candidate (path without the v1 prefix) so we don't get /v1/v1/…
438
+ if (/\/v\d+$/i.test(b) && candidates.length > 1) {
439
+ return b + candidates[1];
440
+ }
441
+ return b + path;
442
+ }
443
+
444
+ // ── Response parser (B7) ──────────────────────────────────────────────
445
+ export interface ParsedSummarizerResponse {
446
+ summary: string;
447
+ facts: FactInput[];
448
+ }
449
+
450
+ export function parseSummarizerResponse(raw: string): ParsedSummarizerResponse {
451
+ const summary = extractTag(raw, 'summary').trim();
452
+ const factsRaw = extractTag(raw, 'facts').trim();
453
+
454
+ // If both tags missing, treat the whole output as summary.
455
+ if (!summary && !factsRaw) {
456
+ return { summary: raw.trim(), facts: [] };
457
+ }
458
+
459
+ let facts: FactInput[] = [];
460
+ if (factsRaw) {
461
+ try {
462
+ const parsed = JSON.parse(factsRaw);
463
+ if (Array.isArray(parsed)) {
464
+ facts = parsed
465
+ .filter((f): f is Record<string, unknown> => typeof f === 'object' && f !== null)
466
+ .map((f) => ({
467
+ scope: typeof f.scope === 'string' ? f.scope : 'other',
468
+ subject_kind: typeof f.subject_kind === 'string' ? f.subject_kind : 'fact',
469
+ subject: typeof f.subject === 'string' ? f.subject : '',
470
+ content: typeof f.content === 'string' ? f.content : '',
471
+ }))
472
+ .filter((f) => f.subject && f.content);
473
+ }
474
+ } catch (err) {
475
+ console.warn('[temper-summary] facts JSON parse failed, ignoring:', err instanceof Error ? err.message : err);
476
+ }
477
+ }
478
+
479
+ return { summary: summary || raw.trim(), facts };
480
+ }
481
+
482
+ function extractTag(s: string, tag: string): string {
483
+ const m = s.match(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'i'));
484
+ return m ? m[1]! : '';
485
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Cheap token estimator used by the summarizer triggers and the
3
+ * working-window cap.
4
+ *
5
+ * `len / 4` is the standard back-of-envelope for English text under
6
+ * GPT/Claude tokenizers; on CJK it under-counts (closer to len * 0.6)
7
+ * and on code it over-counts. Good enough for "do we summarize yet?"
8
+ * gating — the real token count comes back from the provider after
9
+ * each call and can be used to recalibrate.
10
+ *
11
+ * No tiktoken dep on purpose: this runs inside lib/ which loads under
12
+ * tsx standalone, and we don't want native or wasm baggage on the cold
13
+ * path.
14
+ */
15
+
16
+ export function estimateTokens(input: unknown): number {
17
+ if (input == null) return 0;
18
+ const s = typeof input === 'string' ? input : safeStringify(input);
19
+ return Math.ceil(s.length / 4);
20
+ }
21
+
22
+ function safeStringify(v: unknown): string {
23
+ try {
24
+ return JSON.stringify(v) ?? '';
25
+ } catch {
26
+ return String(v);
27
+ }
28
+ }
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env -S npx tsx
2
+ /**
3
+ * Standalone Memory worker.
4
+ *
5
+ * Generic process container for background memory operations. Hosts a
6
+ * registry of MemorySubTask implementations and ticks each one on a
7
+ * fixed interval. v1 only ships the Temper Summary sub-task (chat raw
8
+ * → memory_store ingest); decay / profile compaction / cross-session
9
+ * linking land in v1.x as additional sub-tasks under the same loop.
10
+ *
11
+ * Lifecycle:
12
+ * - Spawned by bin/forge-server.mjs alongside terminal/workspace/telegram
13
+ * - --forge-port=<webPort> tag for cleanupOrphans (multi-instance safe)
14
+ * - SIGTERM is honored; the current tick finishes before exit
15
+ * - No HTTP port; pure poller
16
+ *
17
+ * Failure isolation: a sub-task's tick() rejecting is logged and the
18
+ * next sub-task still runs in the same cycle.
19
+ */
20
+
21
+ const MEMORY_TICK_SEC = 120;
22
+
23
+ export interface MemorySubTask {
24
+ /** Stable identifier for logs + future health introspection. */
25
+ readonly name: string;
26
+ /** One pass of work. Errors are caught at the supervisor level. */
27
+ tick(): Promise<void>;
28
+ }
29
+
30
+ const subTasks: MemorySubTask[] = [];
31
+
32
+ function registerSubTask(task: MemorySubTask): void {
33
+ subTasks.push(task);
34
+ console.log(`[memory] registered sub-task: ${task.name}`);
35
+ }
36
+
37
+ let running = true;
38
+ let tickInProgress = false;
39
+
40
+ async function runTick(): Promise<void> {
41
+ if (!running) return;
42
+ tickInProgress = true;
43
+ const cycleStart = Date.now();
44
+ for (const t of subTasks) {
45
+ const start = Date.now();
46
+ try {
47
+ await t.tick();
48
+ console.log(`[memory] ${t.name} tick ok (${Date.now() - start}ms)`);
49
+ } catch (err) {
50
+ const msg = err instanceof Error ? err.message : String(err);
51
+ console.warn(`[memory] ${t.name} tick failed: ${msg}`);
52
+ }
53
+ }
54
+ console.log(`[memory] cycle done (${Date.now() - cycleStart}ms, ${subTasks.length} sub-task${subTasks.length === 1 ? '' : 's'})`);
55
+ tickInProgress = false;
56
+ }
57
+
58
+ async function loop(): Promise<void> {
59
+ await runTick();
60
+ if (!running) return;
61
+ setTimeout(loop, MEMORY_TICK_SEC * 1000);
62
+ }
63
+
64
+ async function main(): Promise<void> {
65
+ console.log(`[memory] starting standalone (tick every ${MEMORY_TICK_SEC}s)`);
66
+
67
+ // Register v1 sub-tasks. Lazy import keeps this entry declarative
68
+ // and means a sub-task module failing to load doesn't kill the
69
+ // process — the supervisor loop still runs (with one fewer task).
70
+ await safeRegister('temper-summary', async () => {
71
+ const { temperSummaryTask } = await import('./memory/temper-summary');
72
+ return temperSummaryTask();
73
+ });
74
+
75
+ if (subTasks.length === 0) {
76
+ console.log('[memory] no sub-tasks registered, exiting');
77
+ return;
78
+ }
79
+
80
+ await loop();
81
+ }
82
+
83
+ async function safeRegister(name: string, factory: () => Promise<MemorySubTask>): Promise<void> {
84
+ try {
85
+ registerSubTask(await factory());
86
+ } catch (err) {
87
+ const msg = err instanceof Error ? err.message : String(err);
88
+ console.warn(`[memory] failed to register ${name}: ${msg}`);
89
+ }
90
+ }
91
+
92
+ process.on('SIGTERM', () => {
93
+ console.log('[memory] SIGTERM received');
94
+ running = false;
95
+ // Let the in-flight tick finish; exit after a generous grace window.
96
+ setTimeout(() => process.exit(0), tickInProgress ? 30000 : 0);
97
+ });
98
+
99
+ process.on('SIGINT', () => {
100
+ console.log('[memory] SIGINT received');
101
+ running = false;
102
+ setTimeout(() => process.exit(0), tickInProgress ? 30000 : 0);
103
+ });
104
+
105
+ main().catch((err) => {
106
+ console.error('[memory] fatal:', err);
107
+ process.exit(1);
108
+ });