@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/RELEASE_NOTES.md +4 -18
- package/app/api/agents/[id]/test/route.ts +4 -2
- package/app/api/agents/route.ts +26 -6
- package/app/api/memory/blocks/route.ts +56 -0
- package/app/api/monitor/route.ts +2 -0
- package/app/api/schedules/extract/route.ts +8 -6
- package/app/chat/page.tsx +189 -2
- package/bin/forge-server.mjs +3 -2
- package/components/MonitorPanel.tsx +2 -0
- package/components/SettingsModal.tsx +87 -68
- package/lib/agents/claude-adapter.ts +6 -1
- package/lib/agents/generic-adapter.ts +2 -1
- package/lib/agents/index.ts +23 -19
- package/lib/agents/migrate.ts +159 -0
- package/lib/chat/agent-loop.ts +53 -24
- 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 +25 -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/lib/settings.ts +84 -22
- package/lib/workspace/skill-installer.ts +26 -6
- package/package.json +1 -1
- package/scripts/test-agents-migrate.ts +149 -0
- package/scripts/test-memory-local.ts +139 -0
- package/scripts/test-memory-upsert.ts +106 -0
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
32
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
+
});
|