@aion0/forge 0.9.18 → 0.10.2

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/lib/settings.ts CHANGED
@@ -7,29 +7,62 @@ import { getDataDir } from './dirs';
7
7
  const DATA_DIR = getDataDir();
8
8
  const SETTINGS_FILE = join(DATA_DIR, 'settings.yaml');
9
9
 
10
+ /**
11
+ * AgentEntry — a CLI agent configuration (one CLI binary + its env / model).
12
+ *
13
+ * As of v0.9.20 the agents dict is CLI-only; API endpoints live in the
14
+ * sibling `apiProfiles` dict. The `tool` field is the discriminator
15
+ * (which CLI binary to invoke). Multiple entries with the same `tool`
16
+ * are allowed (used to be "CLI profiles" — now just additional named
17
+ * agents with their own env).
18
+ *
19
+ * Deprecated fields (`base` / `cliType` / `type` / `provider` / `apiKey`
20
+ * / `baseUrl`) are migrated away by migrateAgentsFlatten on first load
21
+ * and removed from the schema in a later release.
22
+ */
10
23
  export interface AgentEntry {
11
- // Base agent fields (for detected agents like claude, codex, aider)
24
+ tool?: 'claude' | 'codex' | 'aider' | 'opencode'; // which CLI binary
12
25
  path?: string; name?: string; enabled?: boolean;
13
26
  flags?: string[]; taskFlags?: string; interactiveCmd?: string; resumeFlag?: string; outputFormat?: string;
14
27
  models?: { terminal?: string; task?: string; telegram?: string; help?: string; mobile?: string };
15
28
  skipPermissionsFlag?: string;
16
29
  requiresTTY?: boolean;
17
- // Profile fields (for profiles that extend a base agent)
18
- base?: string; // base agent ID (e.g., 'claude') — makes this a profile
19
- // API profile fields
20
- type?: 'cli' | 'api'; // 'api' = API mode, default = 'cli'
21
- provider?: string; // API provider label (e.g., 'anthropic', 'openai', 'litellm', 'grok', 'google')
22
- model?: string; // model override (for both CLI and API profiles)
23
- apiKey?: string; // per-profile API key (encrypted)
24
- /**
25
- * Base URL override for API profiles. Use this for LiteLLM / Azure /
26
- * self-hosted proxies. Empty = default for the provider's protocol.
27
- * For CLI profiles, set base URL via `env` (ANTHROPIC_BASE_URL etc.).
28
- */
29
- baseUrl?: string;
30
+ model?: string; // flat model override
30
31
  env?: Record<string, string>; // environment variables injected when spawning CLI
31
- cliType?: 'claude-code' | 'codex' | 'aider' | 'generic'; // CLI tool type — determines session support, resume flags, etc.
32
- profile?: string; // linked profile ID overrides model, env, etc. when launching
32
+
33
+ // === Deprecated (kept for migration compat; removed in a later release) ===
34
+ /** @deprecated migrated to `tool` */
35
+ base?: string;
36
+ /** @deprecated migrated to `tool` */
37
+ cliType?: 'claude-code' | 'codex' | 'aider' | 'generic';
38
+ /** @deprecated 'api' entries migrated to apiProfiles dict */
39
+ type?: 'cli' | 'api';
40
+ /** @deprecated moved to apiProfiles entry */
41
+ provider?: string;
42
+ /** @deprecated moved to apiProfiles entry */
43
+ apiKey?: string;
44
+ /** @deprecated moved to apiProfiles entry */
45
+ baseUrl?: string;
46
+ /** @deprecated unused */
47
+ profile?: string;
48
+ }
49
+
50
+ /**
51
+ * ApiProfile — a direct HTTP LLM endpoint (anthropic / openai-compat).
52
+ *
53
+ * Used by chat / summarizer / telegram chat surface — anything that
54
+ * needs a single-shot or streaming LLM call without a subprocess.
55
+ * Stored in `settings.apiProfiles`, not `settings.agents`. The two
56
+ * dicts never cross: tasks/pipelines/terminal pick from agents,
57
+ * chat/summarizer pick from apiProfiles. UI enforces the boundary.
58
+ */
59
+ export interface ApiProfile {
60
+ name?: string;
61
+ enabled?: boolean;
62
+ provider: 'anthropic' | 'openai-compatible';
63
+ model: string;
64
+ apiKey: string;
65
+ baseUrl?: string;
33
66
  }
34
67
 
35
68
  /**
@@ -108,6 +141,9 @@ export interface Settings {
108
141
  */
109
142
  memoryBackend: 'auto' | 'local' | 'temper';
110
143
  agents: Record<string, AgentEntry>;
144
+ /** API endpoint profiles — chat / summarizer / telegram-chat target.
145
+ * Separate from `agents` (CLI subprocess) so type guard is structural. */
146
+ apiProfiles: Record<string, ApiProfile>;
111
147
  /** Extra MCP servers merged into each project's .mcp.json (chrome-devtools-mcp etc.) */
112
148
  mcpServers: Record<string, McpServerConfig>;
113
149
  /**
@@ -177,6 +213,7 @@ const defaults: Settings = {
177
213
  temperNamespace: '',
178
214
  memoryBackend: 'auto',
179
215
  agents: {},
216
+ apiProfiles: {},
180
217
  mcpServers: {},
181
218
  timezone: '',
182
219
  smtpHost: '',
@@ -191,25 +228,35 @@ const defaults: Settings = {
191
228
  pipelineTmpGcIntervalHours: 6,
192
229
  };
193
230
 
194
- /** Decrypt nested apiKey fields in agent profiles */
231
+ /** Decrypt nested apiKey fields in agents (legacy migration window) +
232
+ * apiProfiles (current canonical location). */
195
233
  function decryptNestedSecrets(settings: Settings): void {
234
+ // Legacy: agent entries may still carry apiKey until migration runs
196
235
  if (settings.agents) {
197
236
  for (const [id, a] of Object.entries(settings.agents)) {
198
237
  if (a.apiKey && isEncrypted(a.apiKey)) {
199
238
  a.apiKey = decryptSecret(a.apiKey);
200
239
  }
201
- // Defensive: a past bug let the masked placeholder get encrypted
202
- // and written back. Clear it so callers see "no key" instead of
203
- // trying to send '••••••••' as a Bearer header.
204
240
  if (a.apiKey && /^•+$/.test(a.apiKey)) {
205
241
  console.warn(`[settings] agent '${id}' has a placeholder apiKey — clearing. Re-enter it via Settings.`);
206
242
  a.apiKey = '';
207
243
  }
208
244
  }
209
245
  }
246
+ if (settings.apiProfiles) {
247
+ for (const [id, p] of Object.entries(settings.apiProfiles)) {
248
+ if (p.apiKey && isEncrypted(p.apiKey)) {
249
+ p.apiKey = decryptSecret(p.apiKey);
250
+ }
251
+ if (p.apiKey && /^•+$/.test(p.apiKey)) {
252
+ console.warn(`[settings] apiProfile '${id}' has a placeholder apiKey — clearing. Re-enter it via Settings.`);
253
+ p.apiKey = '';
254
+ }
255
+ }
256
+ }
210
257
  }
211
258
 
212
- /** Encrypt nested apiKey fields in agent profiles */
259
+ /** Encrypt nested apiKey fields */
213
260
  function encryptNestedSecrets(settings: Settings): void {
214
261
  if (settings.agents) {
215
262
  for (const a of Object.values(settings.agents)) {
@@ -218,6 +265,13 @@ function encryptNestedSecrets(settings: Settings): void {
218
265
  }
219
266
  }
220
267
  }
268
+ if (settings.apiProfiles) {
269
+ for (const p of Object.values(settings.apiProfiles)) {
270
+ if (p.apiKey && !isEncrypted(p.apiKey)) {
271
+ p.apiKey = encryptSecret(p.apiKey);
272
+ }
273
+ }
274
+ }
221
275
  }
222
276
 
223
277
  /** Load settings with secrets decrypted (for internal use) */
@@ -248,7 +302,7 @@ export function loadSettingsMasked(): Settings & { _secretStatus: Record<string,
248
302
  status[field] = !!settings[field];
249
303
  settings[field] = settings[field] ? '••••••••' : '';
250
304
  }
251
- // Mask nested apiKeys (agent profiles only)
305
+ // Mask nested apiKeys — agents (legacy) + apiProfiles (canonical)
252
306
  if (settings.agents) {
253
307
  for (const [name, a] of Object.entries(settings.agents)) {
254
308
  if (a.apiKey) {
@@ -257,6 +311,14 @@ export function loadSettingsMasked(): Settings & { _secretStatus: Record<string,
257
311
  }
258
312
  }
259
313
  }
314
+ if (settings.apiProfiles) {
315
+ for (const [name, p] of Object.entries(settings.apiProfiles)) {
316
+ if (p.apiKey) {
317
+ status[`apiProfiles.${name}.apiKey`] = true;
318
+ p.apiKey = '••••••••';
319
+ }
320
+ }
321
+ }
260
322
  return { ...settings, _secretStatus: status };
261
323
  }
262
324
 
@@ -177,6 +177,11 @@ const FORGE_HOOK_MARKER = '# forge-stop-hook';
177
177
  * When Claude Code finishes a turn, the hook notifies Forge via HTTP.
178
178
  * Preserves existing user hooks. Creates backup before modifying.
179
179
  */
180
+ // Auto-prune leaves this many timestamped backups behind. `forge-backup-manual`
181
+ // (or anything not matching the YYYYMMDD-HHMM suffix) is left alone.
182
+ const FORGE_BACKUP_KEEP = 5;
183
+ const FORGE_BACKUP_RE = /^settings\.json\.forge-backup-\d{8}-\d{4}$/;
184
+
180
185
  function installForgeStopHook(forgePort: number): void {
181
186
  const settingsFile = join(homedir(), '.claude', 'settings.json');
182
187
  const now = new Date();
@@ -191,11 +196,11 @@ function installForgeStopHook(forgePort: number): void {
191
196
 
192
197
  try {
193
198
  let settings: any = {};
199
+ let raw = '';
194
200
  if (existsSync(settingsFile)) {
195
- const raw = readFileSync(settingsFile, 'utf-8');
201
+ raw = readFileSync(settingsFile, 'utf-8');
196
202
  settings = JSON.parse(raw);
197
203
 
198
- // Check if hook already installed
199
204
  // Remove old forge hook if present (will re-add with latest version)
200
205
  if (settings.hooks?.Stop) {
201
206
  settings.hooks.Stop = settings.hooks.Stop.filter((h: any) => {
@@ -204,9 +209,6 @@ function installForgeStopHook(forgePort: number): void {
204
209
  return true;
205
210
  });
206
211
  }
207
-
208
- // Backup before modifying
209
- writeFileSync(backupFile, raw, 'utf-8');
210
212
  }
211
213
 
212
214
  if (!settings.hooks) settings.hooks = {};
@@ -222,14 +224,32 @@ function installForgeStopHook(forgePort: number): void {
222
224
  }],
223
225
  });
224
226
 
227
+ const next = JSON.stringify(settings, null, 2) + '\n';
228
+ if (next === raw) return; // no-op — settings already had the current hook
229
+
225
230
  mkdirSync(join(homedir(), '.claude'), { recursive: true });
226
- writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
231
+ if (raw) writeFileSync(backupFile, raw, 'utf-8');
232
+ writeFileSync(settingsFile, next, 'utf-8');
233
+ pruneForgeBackups();
227
234
  console.log('[skills] Installed Forge Stop hook in ~/.claude/settings.json');
228
235
  } catch (err: any) {
229
236
  console.error('[skills] Failed to install Stop hook:', err.message);
230
237
  }
231
238
  }
232
239
 
240
+ function pruneForgeBackups(): void {
241
+ try {
242
+ const dir = join(homedir(), '.claude');
243
+ const stamped = readdirSync(dir)
244
+ .filter(f => FORGE_BACKUP_RE.test(f))
245
+ .sort(); // lexicographic == chronological (YYYYMMDD-HHMM)
246
+ const stale = stamped.slice(0, -FORGE_BACKUP_KEEP);
247
+ for (const f of stale) {
248
+ try { unlinkSync(join(dir, f)); } catch {}
249
+ }
250
+ } catch {}
251
+ }
252
+
233
253
  /**
234
254
  * Remove Forge Stop hook from user-level settings (cleanup).
235
255
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.9.18",
3
+ "version": "0.10.2",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Migration smoke test — feed several known-shape settings through
3
+ * migrateAgentsFlatten and assert post-state. Runs offline, no real
4
+ * data needed.
5
+ *
6
+ * npx tsx scripts/test-agents-migrate.ts
7
+ */
8
+
9
+ import { migrateAgentsFlatten } from '../lib/agents/migrate';
10
+ import type { Settings } from '../lib/settings';
11
+
12
+ let failures = 0;
13
+ const fail = (msg: string) => { console.log(` ✗ ${msg}`); failures += 1; };
14
+ const pass = (msg: string) => console.log(` ✓ ${msg}`);
15
+
16
+ function baseSettings(): Settings {
17
+ return {
18
+ projectRoots: [], docRoots: [], claudePath: '', claudeHome: '',
19
+ telegramBotToken: '', telegramChatId: '',
20
+ notifyOnComplete: true, notifyOnFailure: true,
21
+ tunnelAutoStart: false, telegramTunnelPassword: '',
22
+ taskModel: 'default', pipelineModel: 'default', telegramModel: 'sonnet',
23
+ skipPermissions: false, manageClaudeConfig: true,
24
+ notificationRetentionDays: 30,
25
+ skillsRepoUrl: '', connectorsRepoUrl: '', workflowRepoUrl: '',
26
+ maxConcurrentPipelines: 5,
27
+ displayName: '', displayEmail: '', favoriteProjects: [],
28
+ defaultAgent: 'claude', telegramAgent: '', docsAgent: '', chatAgent: '',
29
+ temperUrl: '', temperKey: '', temperNamespace: '', memoryBackend: 'auto',
30
+ agents: {}, apiProfiles: {}, mcpServers: {}, timezone: '',
31
+ smtpHost: '', smtpPort: 587, smtpSecure: false, smtpUser: '', smtpPassword: '', smtpFrom: '',
32
+ pipelineTmpCleanDoneImmediate: true,
33
+ pipelineTmpKeepFailedDays: 3, pipelineTmpKeepCancelledDays: 3,
34
+ pipelineTmpGcIntervalHours: 6,
35
+ } as Settings;
36
+ }
37
+
38
+ // ── Test 1: type='api' moves to apiProfiles ──────────────────────────
39
+ {
40
+ console.log('Test 1 — API profile moves out of agents');
41
+ const s = baseSettings();
42
+ s.agents = {
43
+ claude: { enabled: true, path: '/usr/local/bin/claude' },
44
+ 'forti-api': { type: 'api', provider: 'litellm', model: 'DeepSeek-V4-Pro', apiKey: 'sk-...', baseUrl: 'https://x' } as any,
45
+ };
46
+ s.chatAgent = 'forti-api';
47
+ const mutated = migrateAgentsFlatten(s);
48
+ if (!mutated) fail('expected mutated=true');
49
+ if (s.agents['forti-api']) fail('forti-api still in agents');
50
+ else pass('forti-api removed from agents');
51
+ const p = (s as any).apiProfiles?.['forti-api'];
52
+ if (!p) fail('forti-api not in apiProfiles');
53
+ else if (p.provider !== 'openai-compatible') fail(`provider mapped wrong: ${p.provider}`);
54
+ else if (p.model !== 'DeepSeek-V4-Pro') fail('model lost');
55
+ else pass(`forti-api in apiProfiles (provider=${p.provider}, model=${p.model})`);
56
+ if (s.chatAgent !== 'forti-api') fail(`chatAgent corrupted: ${s.chatAgent}`);
57
+ else pass('chatAgent unchanged');
58
+ }
59
+
60
+ // ── Test 2: base/cliType → tool flattening ──────────────────────────
61
+ {
62
+ console.log('Test 2 — CLI profile flattens to tool field');
63
+ const s = baseSettings();
64
+ s.agents = {
65
+ claude: { enabled: true, path: '/usr/local/bin/claude' },
66
+ 'forti-coder': { base: 'claude', model: 'sonnet', env: { ANTHROPIC_AUTH_TOKEN: 'tok' } } as any,
67
+ 'codex-dev': { cliType: 'codex', env: { OPENAI_API_KEY: 'k' } } as any,
68
+ };
69
+ migrateAgentsFlatten(s);
70
+ if (s.agents['claude']?.tool !== 'claude') fail('claude builtin tool not inferred');
71
+ else pass('claude tool inferred from id');
72
+ if (s.agents['forti-coder']?.tool !== 'claude') fail(`forti-coder tool wrong: ${s.agents['forti-coder']?.tool}`);
73
+ else pass('forti-coder tool inferred from base');
74
+ if ((s.agents['forti-coder'] as any).base) fail('base field not cleaned');
75
+ else pass('base field removed');
76
+ if (s.agents['codex-dev']?.tool !== 'codex') fail(`codex-dev tool wrong: ${s.agents['codex-dev']?.tool}`);
77
+ else pass('codex-dev tool inferred from cliType');
78
+ }
79
+
80
+ // ── Test 3: defaultAgent pointing to API profile downgrades ──────────
81
+ {
82
+ console.log('Test 3 — defaultAgent → apiProfile downgrades to claude');
83
+ const s = baseSettings();
84
+ s.agents = {
85
+ claude: { enabled: true, path: '' },
86
+ 'forti-api': { type: 'api', provider: 'anthropic', model: 'm', apiKey: 'k' } as any,
87
+ };
88
+ s.defaultAgent = 'forti-api'; // wrong! API id as task default
89
+ migrateAgentsFlatten(s);
90
+ if (s.defaultAgent !== 'claude') fail(`expected 'claude', got '${s.defaultAgent}'`);
91
+ else pass('defaultAgent downgraded to claude');
92
+ }
93
+
94
+ // ── Test 4: chatAgent pointing to CLI downgrades ─────────────────────
95
+ {
96
+ console.log('Test 4 — chatAgent → CLI agent downgrades to first apiProfile');
97
+ const s = baseSettings();
98
+ s.agents = {
99
+ claude: { enabled: true, path: '' },
100
+ 'forti-coder': { base: 'claude', model: 'sonnet' } as any,
101
+ };
102
+ (s as any).apiProfiles = {
103
+ 'real-api': { provider: 'anthropic', model: 'claude-sonnet-4-6', apiKey: 'k', enabled: true },
104
+ };
105
+ s.chatAgent = 'forti-coder'; // wrong! CLI as chat default
106
+ migrateAgentsFlatten(s);
107
+ if (s.chatAgent !== 'real-api') fail(`expected 'real-api', got '${s.chatAgent}'`);
108
+ else pass('chatAgent downgraded to real-api');
109
+ }
110
+
111
+ // ── Test 5: idempotent on already-migrated shape ────────────────────
112
+ {
113
+ console.log('Test 5 — idempotent');
114
+ const s = baseSettings();
115
+ s.agents = {
116
+ claude: { tool: 'claude', enabled: true, path: '' },
117
+ 'forti-coder': { tool: 'claude', enabled: true, model: 'sonnet', env: {} },
118
+ };
119
+ (s as any).apiProfiles = {
120
+ 'forti-api': { provider: 'openai-compatible', model: 'X', apiKey: 'k', enabled: true },
121
+ };
122
+ s.defaultAgent = 'claude';
123
+ s.chatAgent = 'forti-api';
124
+ const mutated = migrateAgentsFlatten(s);
125
+ if (mutated) fail('expected no mutation on already-migrated input');
126
+ else pass('idempotent (no-op)');
127
+ }
128
+
129
+ // ── Test 6: orphan entry (no inference possible) is left alone ──────
130
+ {
131
+ console.log('Test 6 — orphan entry preserved');
132
+ const s = baseSettings();
133
+ s.agents = {
134
+ 'mystery-thing': { enabled: true, model: 'whatever' } as any,
135
+ };
136
+ migrateAgentsFlatten(s);
137
+ if (!s.agents['mystery-thing']) fail('mystery-thing got deleted');
138
+ else if ((s.agents['mystery-thing'] as any).tool) fail('mystery-thing tool inferred when it shouldn\'t');
139
+ else pass('orphan preserved without tool (UI will skip, user re-adds)');
140
+ }
141
+
142
+ console.log('');
143
+ if (failures === 0) {
144
+ console.log('✓ All migration smoke checks passed');
145
+ process.exit(0);
146
+ } else {
147
+ console.log(`✗ ${failures} check(s) failed`);
148
+ process.exit(1);
149
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Phase A §15.4 self-check — LocalMemoryStore must satisfy the
3
+ * summarizer's needs end-to-end without Temper.
4
+ *
5
+ * pnpm tsx scripts/test-memory-local.ts
6
+ *
7
+ * Constructs LocalMemoryStore directly (bypasses getMemoryStore so the
8
+ * user's settings don't matter — we always exercise the local SQLite
9
+ * path). Writes summary + fact + cursor + health blocks via the key
10
+ * helpers, then asserts:
11
+ * 1. Summarizer happy-path round trip works (put → get → search)
12
+ * 2. buildMemoryContext recalls user-relevant blocks via search
13
+ * 3. Cursor putBlock(k, v1) then putBlock(k, v2) replaces, not appends
14
+ * 4. INTERNAL_KEY_PREFIXES filtering keeps cursor/health out of context
15
+ */
16
+
17
+ import { LocalMemoryStore } from '../lib/chat/local-memory';
18
+ import { buildMemoryContext } from '../lib/chat/build-memory-context';
19
+ import {
20
+ cursorKey,
21
+ factKey,
22
+ healthKey,
23
+ stableHash,
24
+ summaryKey,
25
+ INTERNAL_KEY_PREFIXES,
26
+ type CursorValue,
27
+ } from '../lib/memory/keys';
28
+
29
+ const NS = '__phaseA_selfcheck__';
30
+ const SID = 'sid-test-1';
31
+ const NOW = Date.now();
32
+
33
+ async function main() {
34
+ const store = new LocalMemoryStore(NS);
35
+ console.log(`Backend: ${store.kind} ns=${store.currentNamespace}`);
36
+
37
+ let failures = 0;
38
+ const fail = (msg: string) => { console.log(` ✗ ${msg}`); failures += 1; };
39
+ const pass = (msg: string) => console.log(` ✓ ${msg}`);
40
+
41
+ // ── 1. Summarizer happy-path round trip ──────────────────────────
42
+ console.log('Test 1 — summarizer round trip');
43
+ const sk = summaryKey(SID, NOW);
44
+ await store.putBlock(
45
+ sk,
46
+ {
47
+ text: 'User asked about Forge architecture. Discussed memory layer + summarizer design.',
48
+ from_ts: NOW - 1000, to_ts: NOW, message_count: 10,
49
+ model: 'haiku', provider: 'anthropic', ingest_ts: NOW,
50
+ },
51
+ { description: 'session summary', scope: 'own' },
52
+ );
53
+
54
+ const fk = factKey('user', 'zliu', stableHash('prefers terse responses with code'));
55
+ await store.putBlock(
56
+ fk,
57
+ {
58
+ content: 'prefers terse responses with code',
59
+ subject_kind: 'preference', subject: 'zliu',
60
+ source_ref: `chat:${SID}@${NOW}`, confidence: null,
61
+ extracted_by: 'summarizer',
62
+ },
63
+ { description: 'prefers terse responses with code', scope: 'own' },
64
+ );
65
+
66
+ const sb = await store.getBlock(sk);
67
+ const fb = await store.getBlock(fk);
68
+ if (sb?.value) pass(`summary readable at ${sk}`); else fail('summary block missing');
69
+ if (fb?.value) pass(`fact readable at ${fk}`); else fail('fact block missing');
70
+
71
+ // ── 2. buildMemoryContext recalls relevant content ───────────────
72
+ console.log('Test 2 — buildMemoryContext recalls summary via search');
73
+ const ctxA = await buildMemoryContext({
74
+ store,
75
+ currentUserMessage: 'tell me about the memory summarizer',
76
+ });
77
+ if (ctxA.hits.length > 0) pass(`got ${ctxA.hits.length} hit(s) for query`);
78
+ else fail('expected search hits for "memory summarizer" query, got 0');
79
+ const hitText = ctxA.hits.map((h) => h.fact ?? '').join(' | ');
80
+ if (/summarizer|memory layer/i.test(hitText)) pass('hit contains expected keywords');
81
+ else fail(`hit text didn't match: ${hitText.slice(0, 200)}`);
82
+
83
+ // ── 3. Cursor upsert replaces ────────────────────────────────────
84
+ console.log('Test 3 — cursor upsert replaces');
85
+ const ck = cursorKey(SID);
86
+ const v1: CursorValue = { last_ingested_ts: 1, last_run_ts: 1, ingest_count: 1 };
87
+ const v2: CursorValue = { last_ingested_ts: 999, last_run_ts: 999, ingest_count: 2 };
88
+ await store.putBlock(ck, v1);
89
+ await store.putBlock(ck, v2);
90
+ const after = (await store.getBlock(ck))?.value as CursorValue | undefined;
91
+ if (after?.last_ingested_ts === 999) pass('cursor replaced (v2 wins)');
92
+ else fail(`expected last_ingested_ts=999, got ${after?.last_ingested_ts}`);
93
+ const rows = (await store.listBlocks()).filter((b) => b.key === ck);
94
+ if (rows.length === 1) pass('cursor row count = 1 (no append)');
95
+ else fail(`expected 1 cursor row, got ${rows.length}`);
96
+
97
+ // ── 4. INTERNAL_KEY_PREFIXES filters cursor/health out of context ─
98
+ console.log('Test 4 — buildMemoryContext excludes cursor/health by prefix');
99
+ await store.putBlock(healthKey(SID), { last_run_ts: NOW, error: null, ingest_count: 1, last_token_estimate: 100 });
100
+
101
+ // Make cursor + health look attractive to search by giving the user
102
+ // message a literal token they'd LIKE-match.
103
+ const ctxB = await buildMemoryContext({
104
+ store,
105
+ currentUserMessage: 'summarizer cursor health status',
106
+ });
107
+ const allKeys = [
108
+ ...ctxB.blocks.map((b) => b.key),
109
+ ...ctxB.hits.map((h) => h.id),
110
+ ];
111
+ const leaked = allKeys.filter((k) =>
112
+ INTERNAL_KEY_PREFIXES.some((p) =>
113
+ k.startsWith(p) || k.startsWith('block:' + p),
114
+ ),
115
+ );
116
+ if (leaked.length === 0) pass('no cursor/health leaked into context');
117
+ else fail(`leaked internal blocks: ${leaked.join(', ')}`);
118
+
119
+ // Sanity: render output must not literally contain the cursor key
120
+ if (!ctxB.text.includes('forge.summarizer.cursor:') && !ctxB.text.includes('forge.summarizer.health:')) {
121
+ pass('rendered context does not contain internal prefixes');
122
+ } else {
123
+ fail('rendered context still mentions internal prefix');
124
+ }
125
+
126
+ console.log('');
127
+ if (failures === 0) {
128
+ console.log('✓ Phase A LocalMemoryStore self-check passed.');
129
+ process.exit(0);
130
+ } else {
131
+ console.log(`✗ ${failures} check(s) failed.`);
132
+ process.exit(1);
133
+ }
134
+ }
135
+
136
+ main().catch((err) => {
137
+ console.error('Self-check crashed:', err);
138
+ process.exit(2);
139
+ });
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Memory store upsert idempotency check.
3
+ *
4
+ * pnpm tsx scripts/test-memory-upsert.ts
5
+ *
6
+ * Verifies putBlock semantics needed by the chat summarizer:
7
+ * - Same key written twice → second value wins (replace, not append)
8
+ * - listBlocks shows one row, not two
9
+ * - factKey() produces stable hashes across runs
10
+ *
11
+ * Runs against whichever backend getMemoryStore() picks — typically
12
+ * LocalMemoryStore unless Temper creds are set. Writes to a sentinel
13
+ * namespace key prefix and cleans up after itself.
14
+ */
15
+
16
+ import { getMemoryStore } from '../lib/chat/memory-store';
17
+ import {
18
+ cursorKey,
19
+ factKey,
20
+ summaryKey,
21
+ healthKey,
22
+ stableHash,
23
+ type CursorValue,
24
+ } from '../lib/memory/keys';
25
+
26
+ const SENTINEL_SESSION = '__upsert_test__';
27
+
28
+ async function main() {
29
+ const store = getMemoryStore();
30
+ console.log(`Backend: ${store.kind} (enabled=${store.enabled})`);
31
+ if (!store.enabled) {
32
+ console.error('Store not enabled — cannot run test.');
33
+ process.exit(1);
34
+ }
35
+
36
+ let failures = 0;
37
+ const fail = (msg: string) => {
38
+ console.log(` ✗ ${msg}`);
39
+ failures += 1;
40
+ };
41
+ const pass = (msg: string) => console.log(` ✓ ${msg}`);
42
+
43
+ // ── 1. cursor upsert: v1 then v2 → v2 wins ───────────────────────
44
+ console.log('Test 1 — cursor upsert');
45
+ const ck = cursorKey(SENTINEL_SESSION);
46
+ const v1: CursorValue = { last_ingested_ts: 100, last_run_ts: 200, ingest_count: 1 };
47
+ const v2: CursorValue = { last_ingested_ts: 500, last_run_ts: 600, ingest_count: 2 };
48
+ await store.putBlock(ck, v1);
49
+ await store.putBlock(ck, v2);
50
+ const read = (await store.getBlock(ck))?.value as CursorValue | undefined;
51
+ if (!read) fail('getBlock returned null after putBlock');
52
+ else if (read.last_ingested_ts !== 500) fail(`expected last_ingested_ts=500, got ${read.last_ingested_ts}`);
53
+ else if (read.ingest_count !== 2) fail(`expected ingest_count=2, got ${read.ingest_count}`);
54
+ else pass('second putBlock replaced first');
55
+
56
+ // ── 2. listBlocks has one row for the key, not two ───────────────
57
+ console.log('Test 2 — listBlocks dedup');
58
+ const all = await store.listBlocks();
59
+ const matching = all.filter((b) => b.key === ck);
60
+ if (matching.length === 1) pass(`exactly one row for ${ck}`);
61
+ else fail(`expected 1 row for ${ck}, got ${matching.length}`);
62
+
63
+ // ── 3. factKey is stable across calls ────────────────────────────
64
+ console.log('Test 3 — factKey stable hash');
65
+ const fk1 = factKey('user', 'zliu', stableHash('prefers terse responses'));
66
+ const fk2 = factKey('user', 'zliu', stableHash('prefers terse responses'));
67
+ if (fk1 === fk2) pass(`same content → same key (${fk1})`);
68
+ else fail(`hash drift: ${fk1} vs ${fk2}`);
69
+
70
+ // Different content → different key
71
+ const fk3 = factKey('user', 'zliu', stableHash('uses Chinese in chat'));
72
+ if (fk3 !== fk1) pass('different content → different key');
73
+ else fail('hash collision: distinct content produced same key');
74
+
75
+ // ── 4. summary / health keys round-trip ──────────────────────────
76
+ console.log('Test 4 — summary/health keys round-trip');
77
+ const sk = summaryKey(SENTINEL_SESSION, 12345);
78
+ const hk = healthKey(SENTINEL_SESSION);
79
+ await store.putBlock(sk, { text: 'hello', from_ts: 1, to_ts: 12345, message_count: 5, model: 'm', provider: 'p', ingest_ts: Date.now() });
80
+ await store.putBlock(hk, { last_run_ts: Date.now(), error: null, ingest_count: 1, last_token_estimate: 100 });
81
+ const sb = await store.getBlock(sk);
82
+ const hb = await store.getBlock(hk);
83
+ if (sb) pass('summary block round-tripped');
84
+ else fail('summary block missing after putBlock');
85
+ if (hb) pass('health block round-tripped');
86
+ else fail('health block missing after putBlock');
87
+
88
+ // ── cleanup ──────────────────────────────────────────────────────
89
+ console.log('Cleanup: removing sentinel blocks');
90
+ // No deleteBlock on the interface — leave them; sentinel ns prefix
91
+ // makes them identifiable. Re-running the test overwrites in place.
92
+
93
+ console.log('');
94
+ if (failures === 0) {
95
+ console.log('✓ All checks passed.');
96
+ process.exit(0);
97
+ } else {
98
+ console.log(`✗ ${failures} check(s) failed.`);
99
+ process.exit(1);
100
+ }
101
+ }
102
+
103
+ main().catch((err) => {
104
+ console.error('Test crashed:', err);
105
+ process.exit(2);
106
+ });