@ijfw/memory-server 1.6.1 → 1.6.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/bin/ijfw-dashboard +13 -4
- package/package.json +1 -1
- package/src/audit-roster.js +16 -4
- package/src/compute/fts5.js +44 -10
- package/src/compute/staleness.js +9 -8
- package/src/cost/readers/codex.js +6 -6
- package/src/cross-orchestrator-cli.js +28 -21
- package/src/dashboard-server.js +103 -13
- package/src/design/iframe-bridge.js +13 -1
- package/src/memory/fts5.js +67 -12
- package/src/memory/search.js +33 -5
- package/src/memory/staleness.js +1 -1
- package/src/profile/eval/corpus-from-reddit.test.mjs +1 -1
- package/src/profile/eval/gate-b-run.mjs +2 -2
- package/src/profile/eval/harness.mjs +1 -1
- package/src/profile/eval/prereg.mjs +1 -1
- package/src/profile/eval/wrong-target-control.mjs +3 -3
- package/src/profile/exemplar-store.js +1 -1
- package/src/profile/telemetry.js +2 -2
- package/src/recovery/code-fixer.js +26 -5
- package/src/runtime-mediator.js +20 -2
- package/src/server.js +99 -27
package/bin/ijfw-dashboard
CHANGED
|
@@ -40,10 +40,19 @@ function removePidFiles() {
|
|
|
40
40
|
|
|
41
41
|
function openBrowser(url) {
|
|
42
42
|
if (process.env.CI || process.env.NO_OPEN) return;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
// `start` is a cmd.exe builtin (no start.exe on PATH), so it must be run
|
|
44
|
+
// through cmd. The empty '' arg is start's window-title slot, which would
|
|
45
|
+
// otherwise swallow the URL. url is built internally from a parsed port.
|
|
46
|
+
const [cmd, args] = process.platform === 'darwin' ? ['open', [url]]
|
|
47
|
+
: process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]]
|
|
48
|
+
: ['xdg-open', [url]];
|
|
49
|
+
try {
|
|
50
|
+
const child = spawn(cmd, args, { detached: true, stdio: 'ignore' });
|
|
51
|
+
// Missing opener surfaces as an async 'error' event, not a throw --
|
|
52
|
+
// swallow it so it can never become an uncaught exception.
|
|
53
|
+
child.on('error', () => {});
|
|
54
|
+
child.unref();
|
|
55
|
+
} catch {}
|
|
47
56
|
}
|
|
48
57
|
|
|
49
58
|
// Probe a URL for HTTP 200, retrying on connection-refused until timeoutMs elapses.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ijfw/memory-server",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.2",
|
|
4
4
|
"description": "Cross-platform persistent memory server for IJFW. 14 MCP tools (memory + admin/update + brain). Works with 15 platforms: 14 via MCP (Claude Code, Codex, Gemini CLI, Cursor, Windsurf, Copilot, Hermes, Wayland, OpenCode, QwenCode, Cline, KimiCode, OpenClaw, Antigravity) plus Aider via the rules-only tier.",
|
|
5
5
|
"author": "Sean Donahoe",
|
|
6
6
|
"contributors": [
|
package/src/audit-roster.js
CHANGED
|
@@ -281,10 +281,22 @@ export function isInstalled(id) {
|
|
|
281
281
|
// `[ -x ]` (or be a shell builtin/keyword/function with no filesystem path,
|
|
282
282
|
// which `command -v` reports without a leading slash — those are genuinely
|
|
283
283
|
// runnable). A real installed CLI is an executable file and still passes.
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
284
|
+
//
|
|
285
|
+
// On native Windows `bash` is either absent (probe would mark every auditor
|
|
286
|
+
// uninstalled) or the WSL launcher (probe would report the Linux distro's
|
|
287
|
+
// PATH, which the Windows-side invoker cannot run). Use `where` instead:
|
|
288
|
+
// it resolves .cmd/.exe shims on the real Windows PATH, matching how the
|
|
289
|
+
// auditors are actually spawned (shell:true on win32).
|
|
290
|
+
let installed;
|
|
291
|
+
if (process.platform === 'win32') {
|
|
292
|
+
const r = spawnSync('where', [bin], { timeout: 2000, encoding: 'utf8', windowsHide: true });
|
|
293
|
+
installed = r.status === 0 && Boolean((r.stdout || '').trim());
|
|
294
|
+
} else {
|
|
295
|
+
const probe = `p=$(command -v ${JSON.stringify(bin)} 2>/dev/null) || exit 1; ` +
|
|
296
|
+
`case "$p" in /*) [ -x "$p" ] ;; *) : ;; esac`;
|
|
297
|
+
const r = spawnSync('bash', ['-lc', probe], { timeout: 2000 });
|
|
298
|
+
installed = r.status === 0;
|
|
299
|
+
}
|
|
288
300
|
_installedCache.set(id, { value: installed, ts: Date.now() });
|
|
289
301
|
return installed;
|
|
290
302
|
}
|
package/src/compute/fts5.js
CHANGED
|
@@ -242,9 +242,34 @@ function readUserVersion(db) {
|
|
|
242
242
|
return Number(row.user_version ?? row.USER_VERSION ?? 0);
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
+
// quick_check throttle state, keyed per db handle. First write per handle
|
|
246
|
+
// always checks; then every Nth write or once the time floor elapses.
|
|
247
|
+
const QUICK_CHECK_EVERY_N = 100;
|
|
248
|
+
const QUICK_CHECK_MIN_INTERVAL_MS = 5 * 60 * 1000;
|
|
249
|
+
const quickCheckState = new WeakMap(); // db handle -> { writes, lastTs }
|
|
250
|
+
|
|
251
|
+
function shouldQuickCheck(db, now = Date.now()) {
|
|
252
|
+
let st = quickCheckState.get(db);
|
|
253
|
+
if (!st) {
|
|
254
|
+
st = { writes: 0, lastTs: 0 };
|
|
255
|
+
quickCheckState.set(db, st);
|
|
256
|
+
}
|
|
257
|
+
st.writes++;
|
|
258
|
+
if (
|
|
259
|
+
st.writes === 1 ||
|
|
260
|
+
st.writes % QUICK_CHECK_EVERY_N === 0 ||
|
|
261
|
+
(now - st.lastTs) >= QUICK_CHECK_MIN_INTERVAL_MS
|
|
262
|
+
) {
|
|
263
|
+
st.lastTs = now;
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
|
|
245
269
|
// Insert one row into a content table inside a transaction, then run
|
|
246
|
-
// PRAGMA quick_check on the whole db
|
|
247
|
-
// other than 'ok'. Returns { id } of the
|
|
270
|
+
// PRAGMA quick_check on the whole db (throttled; see above). Throws
|
|
271
|
+
// IntegrityError on anything other than 'ok'. Returns { id } of the
|
|
272
|
+
// inserted row.
|
|
248
273
|
//
|
|
249
274
|
// Allowed tables: raw, compiled, trident_run, schema_meta. Caller passes a
|
|
250
275
|
// row object whose keys match the table's columns; binding is positional
|
|
@@ -291,19 +316,28 @@ export function safeWrite(db, table, row) {
|
|
|
291
316
|
const sql = `INSERT INTO ${table} (${cols.join(', ')}) VALUES (${placeholders})`;
|
|
292
317
|
const values = cols.map(c => row[c]);
|
|
293
318
|
|
|
294
|
-
// Run insert + quick_check inside a single transaction.
|
|
295
|
-
// either failure so we never leave a half-written FTS index
|
|
319
|
+
// Run insert + (throttled) quick_check inside a single transaction.
|
|
320
|
+
// Rollback on either failure so we never leave a half-written FTS index
|
|
321
|
+
// behind. quick_check is a full-database scan, so it can't run on every
|
|
322
|
+
// insert (O(db size) per write while the lock is held); the corruption
|
|
323
|
+
// tripwire fires on the FIRST write per handle (compute callers hold
|
|
324
|
+
// long-lived handles, so reopen-after-corruption is still caught), then
|
|
325
|
+
// every Nth write or after a time floor, whichever comes first. State is
|
|
326
|
+
// keyed per HANDLE (WeakMap), unlike memory/fts5.js which keys per
|
|
327
|
+
// filename because server.js re-opens that db per store.
|
|
296
328
|
let inserted;
|
|
297
329
|
const tx = db.txn(() => {
|
|
298
330
|
const stmt = db.prepare(sql);
|
|
299
331
|
const info = stmt.run(...values);
|
|
300
332
|
inserted = { id: info && info.lastInsertRowid != null ? Number(info.lastInsertRowid) : null };
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
333
|
+
if (shouldQuickCheck(db)) {
|
|
334
|
+
const qc = db.prepare('PRAGMA quick_check').get();
|
|
335
|
+
const status = qc && (qc.quick_check ?? qc.QUICK_CHECK);
|
|
336
|
+
if (status !== 'ok') {
|
|
337
|
+
throw new IntegrityError(
|
|
338
|
+
`PRAGMA quick_check failed after insert into ${table}: ${status || '(no result)'}.`
|
|
339
|
+
);
|
|
340
|
+
}
|
|
307
341
|
}
|
|
308
342
|
});
|
|
309
343
|
tx();
|
package/src/compute/staleness.js
CHANGED
|
@@ -166,11 +166,11 @@ export function propagateStale(db, supersededNodeId, options = {}) {
|
|
|
166
166
|
// timeout in fts5.openDb, so concurrent reads remain unblocked.
|
|
167
167
|
const updateRaw = db.prepare(
|
|
168
168
|
`UPDATE raw SET stale_candidate = ? ` +
|
|
169
|
-
`WHERE stale_candidate < ? AND body LIKE
|
|
169
|
+
`WHERE stale_candidate < ? AND body LIKE ? ESCAPE '\\'`
|
|
170
170
|
);
|
|
171
171
|
const updateCompiled = db.prepare(
|
|
172
172
|
`UPDATE compiled SET stale_candidate = ? ` +
|
|
173
|
-
`WHERE stale_candidate < ? AND (topic LIKE ? OR body LIKE ?)`
|
|
173
|
+
`WHERE stale_candidate < ? AND (topic LIKE ? ESCAPE '\\' OR body LIKE ? ESCAPE '\\')`
|
|
174
174
|
);
|
|
175
175
|
|
|
176
176
|
const tx = (typeof db.txn === 'function')
|
|
@@ -204,12 +204,13 @@ export function propagateStale(db, supersededNodeId, options = {}) {
|
|
|
204
204
|
};
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
-
// LIKE pattern escape --
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
207
|
+
// LIKE pattern escape -- kg_node names routinely contain `_` (the entity
|
|
208
|
+
// extractor's snake_case/UPPER_SNAKE regexes REQUIRE underscores) and can
|
|
209
|
+
// contain `%`. Escape both, plus the backslash escape character. SQLite
|
|
210
|
+
// LIKE has NO default escape character, so every consuming statement above
|
|
211
|
+
// carries an explicit ESCAPE '\' clause -- without it, `\_` matches a
|
|
212
|
+
// literal backslash followed by ANY character and snake_case names
|
|
213
|
+
// silently flag zero rows.
|
|
213
214
|
function escapeLike(s) {
|
|
214
215
|
return String(s).replace(/[\\%_]/g, '\\$&');
|
|
215
216
|
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs';
|
|
11
|
-
import { join } from 'node:path';
|
|
11
|
+
import { join, basename } from 'node:path';
|
|
12
12
|
import { homedir } from 'node:os';
|
|
13
13
|
|
|
14
14
|
const CODEX_SESSIONS_DIR = join(homedir(), '.codex', 'sessions');
|
|
@@ -63,13 +63,13 @@ function processCodexFile(filePath, turns) {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
if (!sessionId) {
|
|
66
|
-
// Derive session id from filename
|
|
67
|
-
|
|
68
|
-
sessionId = base;
|
|
66
|
+
// Derive session id from filename (basename handles both path separators)
|
|
67
|
+
sessionId = basename(filePath, '.jsonl');
|
|
69
68
|
}
|
|
70
69
|
|
|
71
|
-
// Extract timestamp from session path (YYYY/MM/DD/filename)
|
|
72
|
-
|
|
70
|
+
// Extract timestamp from session path (YYYY/MM/DD/filename); paths are
|
|
71
|
+
// join()-built, so accept either separator (backslashes on Windows).
|
|
72
|
+
const dateParts = filePath.match(/(\d{4})[\\/](\d{2})[\\/](\d{2})/);
|
|
73
73
|
const datePrefix = dateParts ? `${dateParts[1]}-${dateParts[2]}-${dateParts[3]}` : null;
|
|
74
74
|
|
|
75
75
|
// Accumulate per-session totals from response_item/message content
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { readFileSync, existsSync, writeFileSync, mkdirSync, statSync, openSync, readSync, closeSync, readdirSync, rmSync, realpathSync, copyFileSync } from 'node:fs';
|
|
11
11
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
12
|
-
import { join, dirname, basename, isAbsolute, resolve } from 'node:path';
|
|
12
|
+
import { join, dirname, basename, isAbsolute, resolve, parse as parsePath, sep } from 'node:path';
|
|
13
13
|
import { homedir } from 'node:os';
|
|
14
14
|
import { spawnSync } from 'node:child_process';
|
|
15
15
|
import { writeAtomic } from './lib/atomic-io.js';
|
|
@@ -210,7 +210,7 @@ function emitJson(value) {
|
|
|
210
210
|
process.stdout.write(JSON.stringify(value, null, 2) + '\n');
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
-
function parseArgs(argv) {
|
|
213
|
+
export function parseArgs(argv) {
|
|
214
214
|
const args = argv.slice(2); // strip node + script path
|
|
215
215
|
|
|
216
216
|
// Global --json flag: any command can be forced to JSON output regardless
|
|
@@ -508,20 +508,12 @@ function parseArgsInner(args) {
|
|
|
508
508
|
return { cmd: 'cross-project-audit', rule, dryRun };
|
|
509
509
|
}
|
|
510
510
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
for (let i = 3; i < args.length; i++) {
|
|
518
|
-
if (args[i] === '--confirm') { confirm = true; }
|
|
519
|
-
else if (args[i] === '--expand') { expand = true; }
|
|
520
|
-
else if (args[i] === '--chunk') { chunk = true; } // v1.5.1 H1.6 — wire chunker
|
|
521
|
-
else if (args[i] === '--with' && args[i + 1]) { only = args[++i]; }
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
return { cmd: 'cross', mode, target, only, confirm, expand, chunk };
|
|
511
|
+
// Reuse the alias parser so `ijfw cross <mode>` and `ijfw cross-<mode>`
|
|
512
|
+
// share one grammar: --with=<id> form, flags-before-target ordering, and
|
|
513
|
+
// flag tokens never consumed as the target. The old position-rigid parser
|
|
514
|
+
// (target = args[2], space-separated --with only) silently dropped
|
|
515
|
+
// --with=<id> and dispatched the full paid roster.
|
|
516
|
+
return parseCrossAlias(mode, args.slice(1));
|
|
525
517
|
}
|
|
526
518
|
|
|
527
519
|
return { cmd: 'unknown', raw: args[0] };
|
|
@@ -1373,7 +1365,7 @@ async function cmdCross({ mode, target, only, confirm, expand, chunk }) {
|
|
|
1373
1365
|
console.log(`Auditors fired (union): ${[...auditorIds].join(', ') || '(none)'}`);
|
|
1374
1366
|
if (!firedAny) {
|
|
1375
1367
|
console.log('No auditors fired -- run `ijfw doctor` to see the install hints.');
|
|
1376
|
-
process.exit(
|
|
1368
|
+
process.exit(3); // zero picks contributed -- INCONCLUSIVE, same code as the normal path
|
|
1377
1369
|
}
|
|
1378
1370
|
for (const f of merged) {
|
|
1379
1371
|
const sev = (f.severity || 'note').toUpperCase();
|
|
@@ -1401,7 +1393,10 @@ async function cmdCross({ mode, target, only, confirm, expand, chunk }) {
|
|
|
1401
1393
|
console.log('Trident is standing by -- no auditors reachable yet.');
|
|
1402
1394
|
console.log('Wire one in 30 seconds: run `ijfw doctor` for the exact install commands.');
|
|
1403
1395
|
console.log('Tip: any one of codex / gemini / claude / copilot is enough to start.');
|
|
1404
|
-
|
|
1396
|
+
// No audit happened: exit 3 per the documented contract below (zero picks
|
|
1397
|
+
// contributed = INCONCLUSIVE). Exit 0 here let CI gates pass green on a
|
|
1398
|
+
// machine with no auditors installed.
|
|
1399
|
+
process.exit(3);
|
|
1405
1400
|
}
|
|
1406
1401
|
|
|
1407
1402
|
const projectDir = process.cwd();
|
|
@@ -1425,7 +1420,7 @@ async function cmdCross({ mode, target, only, confirm, expand, chunk }) {
|
|
|
1425
1420
|
if (picks.length === 0) {
|
|
1426
1421
|
console.log('\nIJFW has the Trident ready -- install codex or gemini (or set OPENAI_API_KEY / GEMINI_API_KEY), then run `ijfw demo`.');
|
|
1427
1422
|
console.log('Run `ijfw doctor` to see which auditors are available on this machine.');
|
|
1428
|
-
|
|
1423
|
+
process.exit(3); // zero picks contributed -- INCONCLUSIVE per the exit contract
|
|
1429
1424
|
}
|
|
1430
1425
|
|
|
1431
1426
|
console.log(`Fired: ${picks.map(p => p.id).join(', ')}`);
|
|
@@ -2972,8 +2967,14 @@ function cmdInit(parsed = {}) {
|
|
|
2972
2967
|
try { phys = realpathSync(cwd); } catch { phys = resolve(cwd); }
|
|
2973
2968
|
let homePhys;
|
|
2974
2969
|
try { homePhys = realpathSync(homedir()); } catch { homePhys = homedir(); }
|
|
2975
|
-
|
|
2976
|
-
|
|
2970
|
+
// Refuse any filesystem/drive root (parse().root catches '/', 'C:\\', UNC
|
|
2971
|
+
// roots), the home directory itself, or any ANCESTOR of home (blessing
|
|
2972
|
+
// /Users or /home would let the indexer walk every user's home -- the
|
|
2973
|
+
// issue #16 privacy hole one directory up).
|
|
2974
|
+
const isFsRoot = parsePath(phys).root === phys;
|
|
2975
|
+
const isHomeOrAncestor = phys === homePhys || homePhys.startsWith(phys + sep);
|
|
2976
|
+
if (isFsRoot || isHomeOrAncestor) {
|
|
2977
|
+
console.error('ijfw init: refusing to bless your home directory, its ancestors, or a filesystem root for indexing.');
|
|
2977
2978
|
console.error('Run `ijfw init` from inside an actual project folder.');
|
|
2978
2979
|
process.exit(1);
|
|
2979
2980
|
}
|
|
@@ -3007,6 +3008,9 @@ function cmdInstall() {
|
|
|
3007
3008
|
process.exit(1);
|
|
3008
3009
|
}
|
|
3009
3010
|
const res = spawnSync('bash', [script, ...process.argv.slice(3)], { stdio: 'inherit' });
|
|
3011
|
+
// spawnSync failure (bash missing, EACCES) yields status null + res.error
|
|
3012
|
+
// and stdio:'inherit' prints nothing -- surface it instead of exiting mute.
|
|
3013
|
+
if (res.error) console.error(`ijfw: failed to launch ${script}: ${res.error.message}`);
|
|
3010
3014
|
process.exit(res.status ?? 1);
|
|
3011
3015
|
}
|
|
3012
3016
|
function cmdUninstall() {
|
|
@@ -3016,6 +3020,7 @@ function cmdUninstall() {
|
|
|
3016
3020
|
process.exit(1);
|
|
3017
3021
|
}
|
|
3018
3022
|
const res = spawnSync(process.execPath, [script, ...process.argv.slice(3)], { stdio: 'inherit' });
|
|
3023
|
+
if (res.error) console.error(`ijfw: failed to launch ${script}: ${res.error.message}`);
|
|
3019
3024
|
process.exit(res.status ?? 1);
|
|
3020
3025
|
}
|
|
3021
3026
|
function cmdPreflight() {
|
|
@@ -3025,6 +3030,7 @@ function cmdPreflight() {
|
|
|
3025
3030
|
process.exit(1);
|
|
3026
3031
|
}
|
|
3027
3032
|
const res = spawnSync(process.execPath, [script, ...process.argv.slice(3)], { stdio: 'inherit' });
|
|
3033
|
+
if (res.error) console.error(`ijfw: failed to launch ${script}: ${res.error.message}`);
|
|
3028
3034
|
process.exit(res.status ?? 1);
|
|
3029
3035
|
}
|
|
3030
3036
|
function cmdDashboard(sub) {
|
|
@@ -3044,6 +3050,7 @@ function cmdDashboard(sub) {
|
|
|
3044
3050
|
// stays authoritative. argv = [node, cli, 'dashboard', <sub>, ...flags].
|
|
3045
3051
|
const passthrough = process.argv.slice(4);
|
|
3046
3052
|
const res = spawnSync(process.execPath, [launcher, sub, ...passthrough], { stdio: 'inherit' });
|
|
3053
|
+
if (res.error) console.error(`ijfw: failed to launch ${launcher}: ${res.error.message}`);
|
|
3047
3054
|
process.exit(res.status ?? 1);
|
|
3048
3055
|
}
|
|
3049
3056
|
|
package/src/dashboard-server.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { createServer } from 'node:http';
|
|
10
|
-
import { existsSync, readFileSync, watch, writeFileSync, mkdirSync, readdirSync, statSync, realpathSync, renameSync, unlinkSync } from 'node:fs';
|
|
10
|
+
import { existsSync, readFileSync, watch, writeFileSync, mkdirSync, readdirSync, statSync, realpathSync, renameSync, unlinkSync, openSync, readSync, closeSync, chmodSync } from 'node:fs';
|
|
11
11
|
import { readFile } from 'node:fs/promises';
|
|
12
12
|
import { homedir } from 'node:os';
|
|
13
13
|
import { join, dirname, resolve, relative, isAbsolute, basename, sep } from 'node:path';
|
|
@@ -178,14 +178,31 @@ function requireLocalhost(req, res) {
|
|
|
178
178
|
// stamp Sec-Fetch-Site; the dashboard's own page is 'same-origin', direct tools
|
|
179
179
|
// (curl, address bar) send 'none'/nothing. Only same-machine cross-origin pages
|
|
180
180
|
// hit 'cross-site'/'same-site' -- block those on /api.
|
|
181
|
+
//
|
|
182
|
+
// State-changing methods (POST/PUT/PATCH/DELETE) fail CLOSED when Sec-Fetch-Site
|
|
183
|
+
// is absent: a browser cross-origin write always carries an Origin header (even
|
|
184
|
+
// for no-preflight text/plain "simple requests"), so any Origin/Referer that does
|
|
185
|
+
// not match our own Host is rejected. Requests with no browser markers at all
|
|
186
|
+
// (curl, local scripts) carry neither header and stay allowed.
|
|
181
187
|
function rejectCrossSiteApi(req, res, path) {
|
|
182
188
|
if (!path.startsWith('/api')) return false;
|
|
183
|
-
|
|
184
|
-
if (sfs === 'cross-site' || sfs === 'same-site') {
|
|
189
|
+
function reject() {
|
|
185
190
|
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
186
191
|
res.end('{"error":"cross-origin request rejected"}');
|
|
187
192
|
return true;
|
|
188
193
|
}
|
|
194
|
+
const sfs = req.headers['sec-fetch-site'];
|
|
195
|
+
if (sfs === 'cross-site' || sfs === 'same-site') return reject();
|
|
196
|
+
const method = (req.method || 'GET').toUpperCase();
|
|
197
|
+
if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') return false;
|
|
198
|
+
// Writes: 'same-origin'/'none' are browser-proven safe; otherwise require any
|
|
199
|
+
// Origin/Referer present to match the host the request was addressed to.
|
|
200
|
+
if (sfs === 'same-origin' || sfs === 'none') return false;
|
|
201
|
+
const proof = req.headers['origin'] || req.headers['referer'];
|
|
202
|
+
if (!proof) return false;
|
|
203
|
+
let proofHost = null;
|
|
204
|
+
try { proofHost = new URL(proof).host; } catch {}
|
|
205
|
+
if (proofHost === null || proofHost !== (req.headers.host || '')) return reject();
|
|
189
206
|
return false;
|
|
190
207
|
}
|
|
191
208
|
|
|
@@ -205,14 +222,43 @@ function route(req, res, routes) {
|
|
|
205
222
|
}
|
|
206
223
|
|
|
207
224
|
// ---------- JSONL reader ----------
|
|
225
|
+
// observations.jsonl is append-only with no rotation, so an unbounded
|
|
226
|
+
// synchronous read+parse on every poll would stall the single-threaded server
|
|
227
|
+
// (same hazard tailEvents() was rewritten to avoid). Bound the read to the
|
|
228
|
+
// last OBS_TAIL_BYTES via fd+position, and cache the parsed array keyed on
|
|
229
|
+
// mtime+size so repeated polls of an unchanged file never re-read it.
|
|
230
|
+
const OBS_TAIL_BYTES = 5 * 1024 * 1024; // 5MB
|
|
231
|
+
const obsCache = new Map(); // ledgerPath -> { key, data }
|
|
232
|
+
|
|
208
233
|
function readObservations(ledgerPath) {
|
|
209
|
-
|
|
234
|
+
let st;
|
|
235
|
+
try { st = statSync(ledgerPath); } catch { return []; }
|
|
236
|
+
const key = `${st.mtimeMs}:${st.size}`;
|
|
237
|
+
const cached = obsCache.get(ledgerPath);
|
|
238
|
+
if (cached && cached.key === key) return cached.data;
|
|
210
239
|
try {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
240
|
+
const start = Math.max(0, st.size - OBS_TAIL_BYTES);
|
|
241
|
+
const len = st.size - start;
|
|
242
|
+
const buf = Buffer.allocUnsafe(len);
|
|
243
|
+
const fd = openSync(ledgerPath, 'r');
|
|
244
|
+
let read = 0;
|
|
245
|
+
try {
|
|
246
|
+
while (read < len) {
|
|
247
|
+
const n = readSync(fd, buf, read, len - read, start + read);
|
|
248
|
+
if (n === 0) break;
|
|
249
|
+
read += n;
|
|
250
|
+
}
|
|
251
|
+
} finally {
|
|
252
|
+
closeSync(fd);
|
|
253
|
+
}
|
|
254
|
+
let lines = buf.subarray(0, read).toString('utf8').split('\n').filter(Boolean);
|
|
255
|
+
// If we sliced mid-line, the first element may be truncated -- drop it.
|
|
256
|
+
if (start > 0) lines = lines.slice(1);
|
|
257
|
+
const data = lines
|
|
214
258
|
.map(l => { try { return JSON.parse(l); } catch { return null; } })
|
|
215
259
|
.filter(Boolean);
|
|
260
|
+
obsCache.set(ledgerPath, { key, data });
|
|
261
|
+
return data;
|
|
216
262
|
} catch {
|
|
217
263
|
return [];
|
|
218
264
|
}
|
|
@@ -830,8 +876,11 @@ export async function startServer(options = {}) {
|
|
|
830
876
|
req.on('end', () => {
|
|
831
877
|
try {
|
|
832
878
|
const parsed = JSON.parse(body);
|
|
833
|
-
mkdirSync(join(homedir(), '.ijfw'), { recursive: true });
|
|
834
|
-
|
|
879
|
+
mkdirSync(join(homedir(), '.ijfw'), { recursive: true, mode: 0o700 });
|
|
880
|
+
// 0o600: config holds account/subscription data; keep it owner-only
|
|
881
|
+
// (mode only applies on create, so chmod existing files too).
|
|
882
|
+
writeFileSync(configPath, JSON.stringify(parsed, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
883
|
+
try { chmodSync(configPath, 0o600); } catch {}
|
|
835
884
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
836
885
|
res.end(JSON.stringify({ ok: true }));
|
|
837
886
|
} catch (err) {
|
|
@@ -1497,6 +1546,50 @@ if (process.argv.includes('--daemon')) {
|
|
|
1497
1546
|
const pidFile = process.env.IJFW_PID_FILE || join(homedir(), '.ijfw', 'dashboard.pid');
|
|
1498
1547
|
const portFile = process.env.IJFW_PORT_FILE || join(homedir(), '.ijfw', 'dashboard.port');
|
|
1499
1548
|
|
|
1549
|
+
// Singleton guard: the spawner's pid-check (session-start.sh) is a classic
|
|
1550
|
+
// check-then-act race -- two sessions starting in the same window both see no
|
|
1551
|
+
// live daemon and both spawn --daemon. Without a guard here, the loser walks
|
|
1552
|
+
// to the next port and OVERWRITES the shared pid/port files, orphaning the
|
|
1553
|
+
// winner forever. Acquire the pid file with O_EXCL BEFORE binding: exactly
|
|
1554
|
+
// one starter wins; losers exit 0 without binding or clobbering. A pid file
|
|
1555
|
+
// whose owner is dead is stale and reclaimed once.
|
|
1556
|
+
const ijfwDir = dirname(pidFile);
|
|
1557
|
+
mkdirSync(ijfwDir, { recursive: true });
|
|
1558
|
+
|
|
1559
|
+
function pidAlive(pid) {
|
|
1560
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
1561
|
+
try { process.kill(pid, 0); return true; }
|
|
1562
|
+
catch (err) { return err.code === 'EPERM'; }
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
function acquirePidFile() {
|
|
1566
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1567
|
+
try {
|
|
1568
|
+
writeFileSync(pidFile, String(process.pid), { encoding: 'utf8', flag: 'wx' });
|
|
1569
|
+
return true;
|
|
1570
|
+
} catch (err) {
|
|
1571
|
+
if (err.code !== 'EEXIST') throw err;
|
|
1572
|
+
let holder = NaN;
|
|
1573
|
+
try { holder = Number.parseInt(readFileSync(pidFile, 'utf8').trim(), 10); } catch {}
|
|
1574
|
+
if (pidAlive(holder)) return false; // live daemon already owns it
|
|
1575
|
+
try { unlinkSync(pidFile); } catch {} // stale -- reclaim and retry once
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
return false; // lost the reclaim race to another starter; that one serves
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
if (!acquirePidFile()) {
|
|
1582
|
+
process.stderr.write('[ijfw-dashboard] daemon already running -- exiting\n');
|
|
1583
|
+
process.exit(0);
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
function releasePidFile() {
|
|
1587
|
+
// Only remove the pid file if it is still ours.
|
|
1588
|
+
try {
|
|
1589
|
+
if (readFileSync(pidFile, 'utf8').trim() === String(process.pid)) unlinkSync(pidFile);
|
|
1590
|
+
} catch {}
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1500
1593
|
// Optional preferred-port override (forwarded by the launcher's `--port N`).
|
|
1501
1594
|
// Unset = default 37891-37900 walk. Invalid values fall back to the default.
|
|
1502
1595
|
const startOpts = {};
|
|
@@ -1504,10 +1597,6 @@ if (process.argv.includes('--daemon')) {
|
|
|
1504
1597
|
if (Number.isInteger(envPort) && envPort > 0 && envPort < 65536) startOpts.port = envPort;
|
|
1505
1598
|
|
|
1506
1599
|
startServer(startOpts).then(({ port }) => {
|
|
1507
|
-
const ijfwDir = dirname(pidFile);
|
|
1508
|
-
mkdirSync(ijfwDir, { recursive: true });
|
|
1509
|
-
// PID file: plain write (single writer; pid is meaningless mid-write)
|
|
1510
|
-
writeFileSync(pidFile, String(process.pid), 'utf8');
|
|
1511
1600
|
// Port file: atomic write via tmp+rename so readers never see a partial value (W4.2).
|
|
1512
1601
|
// Cleanup tmp on rename failure so it doesn't leak (W9-M1).
|
|
1513
1602
|
const portTmp = `${portFile}.tmp.${process.pid}.${Date.now()}`;
|
|
@@ -1519,6 +1608,7 @@ if (process.argv.includes('--daemon')) {
|
|
|
1519
1608
|
throw new Error(`atomic write failed for ${portFile}: ${err.message}`);
|
|
1520
1609
|
}
|
|
1521
1610
|
}).catch(err => {
|
|
1611
|
+
releasePidFile();
|
|
1522
1612
|
process.stderr.write('[ijfw-dashboard] ' + err.message + '\n');
|
|
1523
1613
|
process.exit(1);
|
|
1524
1614
|
});
|
|
@@ -99,10 +99,18 @@ export async function createPreviewSandbox({ html, name } = {}) {
|
|
|
99
99
|
// surface for vercel-sandbox is evolving; we accept any JSON line that
|
|
100
100
|
// contains a `url` field.
|
|
101
101
|
try {
|
|
102
|
+
// shell:true on Windows so the vercel.cmd npm shim resolves; harmless on
|
|
103
|
+
// POSIX. sandboxId/tmpFile are internally generated (sanitized name +
|
|
104
|
+
// uuid under tmpdir), not user input.
|
|
102
105
|
const r = spawnSync('vercel', ['sandbox', 'create', '--file', tmpFile, '--name', sandboxId], {
|
|
103
106
|
encoding: 'utf8',
|
|
104
107
|
timeout: PROVISION_TIMEOUT_MS,
|
|
108
|
+
shell: process.platform === 'win32',
|
|
105
109
|
});
|
|
110
|
+
if (r.error) {
|
|
111
|
+
_advise(`createPreviewSandbox: vercel CLI not spawnable (${r.error.message}) -- falling back to static`);
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
106
114
|
if (r.status !== 0) {
|
|
107
115
|
_advise(`createPreviewSandbox: vercel CLI exit ${r.status} -- falling back to static`);
|
|
108
116
|
return null;
|
|
@@ -140,7 +148,11 @@ export async function destroySandbox(sandboxId) {
|
|
|
140
148
|
return;
|
|
141
149
|
}
|
|
142
150
|
if (entry.mode === 'cli') {
|
|
143
|
-
spawnSync('vercel', ['sandbox', 'delete', sandboxId], {
|
|
151
|
+
spawnSync('vercel', ['sandbox', 'delete', sandboxId], {
|
|
152
|
+
encoding: 'utf8',
|
|
153
|
+
timeout: DESTROY_TIMEOUT_MS,
|
|
154
|
+
shell: process.platform === 'win32',
|
|
155
|
+
});
|
|
144
156
|
return;
|
|
145
157
|
}
|
|
146
158
|
} catch (err) {
|
package/src/memory/fts5.js
CHANGED
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
// Mirrors src/compute/fts5.js patterns:
|
|
9
9
|
// - WAL journal mode for concurrent readers
|
|
10
10
|
// - PRAGMA busy_timeout = 5000 + BEGIN IMMEDIATE for racing writers
|
|
11
|
-
// - PRAGMA quick_check
|
|
11
|
+
// - PRAGMA quick_check corruption tripwire on a throttled cadence
|
|
12
|
+
// (first write per db file per process, then every Nth write or
|
|
13
|
+
// after a time floor -- never on every single-row insert, because
|
|
14
|
+
// quick_check is a full-database scan)
|
|
12
15
|
//
|
|
13
16
|
// Security model (D-PILLAR-SPEC section 12, real fix-wave C3):
|
|
14
17
|
// indexEntry runs `redactSecrets()` over `entry.body` AND `entry.source`
|
|
@@ -182,9 +185,52 @@ function readUserVersion(db) {
|
|
|
182
185
|
return Number(row.user_version ?? row.USER_VERSION ?? 0);
|
|
183
186
|
}
|
|
184
187
|
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
+
// Corruption tripwire cadence. PRAGMA quick_check walks every page of the
|
|
189
|
+
// database, so running it inside EVERY single-row insert transaction is
|
|
190
|
+
// O(db size) per write while the RESERVED lock is held -- a quadratic
|
|
191
|
+
// total-cost cliff as the warm tier grows. The tripwire is kept, but on a
|
|
192
|
+
// throttle: the FIRST write per db file per process always checks (so a
|
|
193
|
+
// reopen-after-corruption is caught on the next write), then every Nth
|
|
194
|
+
// write or once the time floor elapses, whichever fires first. State is
|
|
195
|
+
// keyed by filename, NOT by handle, because server.js re-opens the db per
|
|
196
|
+
// store -- a per-open or per-handle check would put the full scan right
|
|
197
|
+
// back on the hot path.
|
|
198
|
+
const QUICK_CHECK_EVERY_N = 100;
|
|
199
|
+
const QUICK_CHECK_MIN_INTERVAL_MS = 5 * 60 * 1000;
|
|
200
|
+
const __quickCheckState = new Map(); // filename -> { writes, lastTs }
|
|
201
|
+
|
|
202
|
+
function shouldQuickCheck(filename, now = Date.now()) {
|
|
203
|
+
const key = filename || ':unknown:';
|
|
204
|
+
let st = __quickCheckState.get(key);
|
|
205
|
+
if (!st) {
|
|
206
|
+
st = { writes: 0, lastTs: 0 };
|
|
207
|
+
__quickCheckState.set(key, st);
|
|
208
|
+
}
|
|
209
|
+
st.writes++;
|
|
210
|
+
if (
|
|
211
|
+
st.writes === 1 ||
|
|
212
|
+
st.writes % QUICK_CHECK_EVERY_N === 0 ||
|
|
213
|
+
(now - st.lastTs) >= QUICK_CHECK_MIN_INTERVAL_MS
|
|
214
|
+
) {
|
|
215
|
+
st.lastTs = now;
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Test hook -- cadence logic is invisible from outside (it only changes
|
|
222
|
+
// WHEN the scan runs), so tests assert on it directly.
|
|
223
|
+
export const __quickCheck = {
|
|
224
|
+
shouldQuickCheck,
|
|
225
|
+
QUICK_CHECK_EVERY_N,
|
|
226
|
+
QUICK_CHECK_MIN_INTERVAL_MS,
|
|
227
|
+
reset: () => __quickCheckState.clear(),
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
// Insert one row into memory_entries inside a BEGIN IMMEDIATE transaction.
|
|
231
|
+
// On the throttled cadence above, runs PRAGMA quick_check inside the same
|
|
232
|
+
// transaction and throws MemoryIntegrityError on anything other than 'ok'
|
|
233
|
+
// (rolling the insert back -- fail-safe). Returns { id } of the inserted row.
|
|
188
234
|
//
|
|
189
235
|
// Caller passes { body, source?, session_id? }. created_at is set here
|
|
190
236
|
// (unix ms) so callers don't have to remember the convention.
|
|
@@ -224,12 +270,14 @@ export function indexEntry(db, entry) {
|
|
|
224
270
|
inserted = {
|
|
225
271
|
id: info && info.lastInsertRowid != null ? Number(info.lastInsertRowid) : null,
|
|
226
272
|
};
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
273
|
+
if (shouldQuickCheck(db.__ijfw_filename)) {
|
|
274
|
+
const qc = db.prepare('PRAGMA quick_check').get();
|
|
275
|
+
const status = qc && (qc.quick_check ?? qc.QUICK_CHECK);
|
|
276
|
+
if (status !== 'ok') {
|
|
277
|
+
throw new MemoryIntegrityError(
|
|
278
|
+
`PRAGMA quick_check failed after insert into memory_entries: ${status || '(no result)'}.`
|
|
279
|
+
);
|
|
280
|
+
}
|
|
233
281
|
}
|
|
234
282
|
});
|
|
235
283
|
tx();
|
|
@@ -282,6 +330,13 @@ export function indexEntry(db, entry) {
|
|
|
282
330
|
const ts = row.created_at;
|
|
283
331
|
const sessionId = row.session_id;
|
|
284
332
|
const body = row.body;
|
|
333
|
+
// Receipt path must belong to the project that owns THIS db, never the
|
|
334
|
+
// process cwd (MCP hosts commonly spawn servers from $HOME, and openDb
|
|
335
|
+
// supports an explicit projectRoot distinct from cwd). The db lives at
|
|
336
|
+
// <root>/.ijfw/index/memory.db, so dirname(filename) IS the index dir.
|
|
337
|
+
const receiptDir = db.__ijfw_filename
|
|
338
|
+
? dirname(db.__ijfw_filename)
|
|
339
|
+
: join(process.env.IJFW_PROJECT_DIR || process.cwd(), IJFW_DIR_NAME, INDEX_DIR_NAME);
|
|
285
340
|
// v1.5.0 audit-LOW-memory-#14: dead-letter receipt for auto-index failures.
|
|
286
341
|
// Fire-and-forget was already swallowed silently; now we append an
|
|
287
342
|
// append-only JSONL receipt so silent indexer breakage is detectable in
|
|
@@ -293,10 +348,10 @@ export function indexEntry(db, entry) {
|
|
|
293
348
|
// Lazy import; node:fs/promises is always available.
|
|
294
349
|
import('node:fs/promises').then(({ appendFile, mkdir }) => {
|
|
295
350
|
try {
|
|
296
|
-
const indexDir =
|
|
351
|
+
const indexDir = receiptDir;
|
|
297
352
|
return mkdir(indexDir, { recursive: true })
|
|
298
353
|
.then(() => appendFile(
|
|
299
|
-
|
|
354
|
+
join(indexDir, 'graph-errors.jsonl'),
|
|
300
355
|
JSON.stringify({
|
|
301
356
|
ts: new Date().toISOString(),
|
|
302
357
|
session_id: sessionId || null,
|
package/src/memory/search.js
CHANGED
|
@@ -38,6 +38,12 @@ import { loadMigrations } from './migration-runner.js';
|
|
|
38
38
|
// is imported directly so M1 runs synchronously inside the same txn batch.
|
|
39
39
|
import { indexObsidianRelations } from './obsidian-parser.js';
|
|
40
40
|
import { autoLink } from './auto-linker.js';
|
|
41
|
+
// Ingest scrub gate (D-PILLAR-SPEC section 12) -- the warm-tier rebuild
|
|
42
|
+
// reads raw markdown from disk, which is NOT guaranteed pre-scrubbed
|
|
43
|
+
// (hand-edited notes, hook-written files, imports never went through
|
|
44
|
+
// handleStore's redaction). autoIndex must apply the same redactSecrets
|
|
45
|
+
// pass as fts5.js#indexEntry or secrets land cleartext in memory.db.
|
|
46
|
+
import { redactSecrets } from '../redactor.js';
|
|
41
47
|
|
|
42
48
|
const MAX_RESULTS = 50;
|
|
43
49
|
const SNIPPET_HALF = 60;
|
|
@@ -259,25 +265,35 @@ function runMemoryMigrationsSync(db, currentVersion, targetVersion) {
|
|
|
259
265
|
}
|
|
260
266
|
|
|
261
267
|
function autoIndex(db, files) {
|
|
262
|
-
let n = 0;
|
|
263
268
|
// v1.5.1 R4-H2 — capture the rowid of every inserted entry so the
|
|
264
269
|
// memory-moat aux indexing (M1 Obsidian relations, M2 auto-link) can run
|
|
265
270
|
// over the warm-tier rebuild, not just the benchmark harness. The bulk
|
|
266
271
|
// INSERT stays in one transaction for FTS write performance; M1/M2 run
|
|
267
272
|
// AFTER commit so a parse/link failure can never abort the rebuild.
|
|
273
|
+
//
|
|
274
|
+
// Rollback safety: ids are collected in a transaction-local array and
|
|
275
|
+
// only published to `inserted` after txfn commits. If the batch rolls
|
|
276
|
+
// back, the rowids it produced no longer exist (and AUTOINCREMENT will
|
|
277
|
+
// reuse them), so running M1/M2 over them would attach links/tags/meta
|
|
278
|
+
// to the WRONG future entries.
|
|
268
279
|
const inserted = [];
|
|
269
280
|
const txfn = db.transaction((batch) => {
|
|
270
281
|
const stmt = db.prepare(
|
|
271
282
|
'INSERT INTO memory_entries (body, source, session_id, created_at) VALUES (?, ?, ?, ?)'
|
|
272
283
|
);
|
|
284
|
+
const out = [];
|
|
273
285
|
for (const item of batch) {
|
|
274
286
|
const info = stmt.run(item.body, item.source, null, item.created_at);
|
|
275
287
|
const id = info && info.lastInsertRowid != null ? Number(info.lastInsertRowid) : null;
|
|
276
|
-
|
|
277
|
-
n++;
|
|
288
|
+
out.push({ id, body: item.body });
|
|
278
289
|
}
|
|
290
|
+
return out;
|
|
279
291
|
});
|
|
280
292
|
|
|
293
|
+
// Same ingest scrub gate as fts5.js#indexEntry (IJFW_INGEST_SCRUB=0 is
|
|
294
|
+
// the only escape hatch, local debugging only). Body AND source are
|
|
295
|
+
// scrubbed so the FTS index and downstream M1/M2 only see safe text.
|
|
296
|
+
const scrub = process.env.IJFW_INGEST_SCRUB !== '0';
|
|
281
297
|
const batch = [];
|
|
282
298
|
const now = Date.now();
|
|
283
299
|
for (const f of files) {
|
|
@@ -286,10 +302,22 @@ function autoIndex(db, files) {
|
|
|
286
302
|
let body;
|
|
287
303
|
try { body = readFileSync(f.path, 'utf8'); } catch { continue; }
|
|
288
304
|
if (!body) continue;
|
|
289
|
-
|
|
305
|
+
const rawSource = f.relpath || f.path;
|
|
306
|
+
batch.push({
|
|
307
|
+
body: scrub ? redactSecrets(body) : body,
|
|
308
|
+
source: scrub ? redactSecrets(String(rawSource)) : rawSource,
|
|
309
|
+
created_at: now,
|
|
310
|
+
});
|
|
290
311
|
}
|
|
291
312
|
if (batch.length === 0) return 0;
|
|
292
|
-
|
|
313
|
+
let n = 0;
|
|
314
|
+
try {
|
|
315
|
+
const committed = txfn.immediate(batch);
|
|
316
|
+
if (Array.isArray(committed)) {
|
|
317
|
+
inserted.push(...committed);
|
|
318
|
+
n = committed.length;
|
|
319
|
+
}
|
|
320
|
+
} catch { /* one bad batch should not abort the search; rollback discards ids */ }
|
|
293
321
|
|
|
294
322
|
// v1.5.1 R4-H2 — M1: Obsidian wikilink/tag/meta indexing into
|
|
295
323
|
// memory_links/_tags/_meta. Synchronous + idempotent (indexObsidianRelations
|
package/src/memory/staleness.js
CHANGED
|
@@ -169,7 +169,7 @@ export function propagateStaleMemory(memDb, computeDb, supersededNodeId, options
|
|
|
169
169
|
if (namesToFlag.length > 0) {
|
|
170
170
|
const updateMem = memDb.prepare(
|
|
171
171
|
`UPDATE memory_entries SET stale_candidate = ? ` +
|
|
172
|
-
`WHERE COALESCE(stale_candidate, 0) < ? AND body LIKE
|
|
172
|
+
`WHERE COALESCE(stale_candidate, 0) < ? AND body LIKE ? ESCAPE '\\'`
|
|
173
173
|
);
|
|
174
174
|
|
|
175
175
|
const txWrap = (typeof memDb.transaction === 'function')
|
|
@@ -42,7 +42,7 @@ function writeJson(rows) {
|
|
|
42
42
|
return p;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
// 10 authors
|
|
45
|
+
// 10 authors x 6 long docs each — comfortably over the floors.
|
|
46
46
|
function tenAuthors() {
|
|
47
47
|
const rows = [];
|
|
48
48
|
for (let a = 0; a < 10; a += 1) rows.push(...makeAuthorRows(`u${a}`, 6));
|
|
@@ -218,7 +218,7 @@ export async function runGateBProduction(opts = {}) {
|
|
|
218
218
|
// budget-guarded cloud transport here: the allowed-set is the closed set of EVERY brief
|
|
219
219
|
// the pool's own personas + foreigner-pool produce (baseline '' + derived + fewShotOracle
|
|
220
220
|
// + register-echo) — foreign prose is never a target, only a fingerprint. The budget is
|
|
221
|
-
// sized from arms
|
|
221
|
+
// sized from arms x pool x probes x (pilot + confirmatory) with headroom.
|
|
222
222
|
const poolForGuard = [...personas, ...foreigners];
|
|
223
223
|
const budget = opts.budget || {
|
|
224
224
|
calls: 0,
|
|
@@ -328,7 +328,7 @@ export function buildAllowedSys(personas, cfg = {}) {
|
|
|
328
328
|
return sys;
|
|
329
329
|
}
|
|
330
330
|
|
|
331
|
-
// Estimate the cloud-call budget: arms
|
|
331
|
+
// Estimate the cloud-call budget: arms x subjects x probes, per spend phase.
|
|
332
332
|
export function estimateCalls({
|
|
333
333
|
nArms = 4, nSubjects, nProbes,
|
|
334
334
|
}) {
|
|
@@ -225,7 +225,7 @@ export function cohenKappa(raterA = [], raterB = []) {
|
|
|
225
225
|
|
|
226
226
|
// ---------------------------------------------------------------------------
|
|
227
227
|
// ECE — Expected Calibration Error on the profile's `confidence` field. Bins
|
|
228
|
-
// (confidence, correctness) pairs and measures |avg-confidence
|
|
228
|
+
// (confidence, correctness) pairs and measures |avg-confidence - accuracy| per
|
|
229
229
|
// bin, weighted by bin mass. A well-calibrated profile that says "0.7 confident"
|
|
230
230
|
// is right ~70% of the time. This is what makes `confidence` an honest number
|
|
231
231
|
// instead of decoration.
|
|
@@ -52,7 +52,7 @@ export function bonferroniAlpha(familyAlpha, verdictArms) {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
// Measured-scale floor: the minimum mean margin that counts as a real effect, expressed
|
|
55
|
-
// in the instrument's OWN units = floorK * (betweenMean
|
|
55
|
+
// in the instrument's OWN units = floorK * (betweenMean - withinMean) from validateInstrument.
|
|
56
56
|
// This REPLACES the blind absolute constant (the prior attempt's failure class). Frozen
|
|
57
57
|
// before any cloud spend (floorK is hashed; the derived value is recorded in the run).
|
|
58
58
|
export function deriveMinMeanMargin(validation, floorK) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// wrong-target-control.mjs — Gate B v2, Task T5. THE discriminator.
|
|
2
2
|
//
|
|
3
3
|
// For each subject P and arm, the margin is:
|
|
4
|
-
// m_P = distance(output, NEAREST same-register foreigner)
|
|
4
|
+
// m_P = distance(output, NEAREST same-register foreigner) - distance(output, OWN test)
|
|
5
5
|
// m_P > 0 means the styled output landed closer to P's OWN held-out fingerprint than to
|
|
6
6
|
// the CLOSEST same-register stranger. A generic register-obeyer is ~equidistant from all
|
|
7
7
|
// same-register targets ⇒ m≈0 ⇒ NULL. Only idiosyncratic voice capture wins.
|
|
@@ -118,7 +118,7 @@ export function wrongTargetControl(harnessOut, personas, opts = {}) {
|
|
|
118
118
|
}
|
|
119
119
|
const ownLoss = margins.map((m) => (m < 0 ? 1 : 0));
|
|
120
120
|
const ci = bootstrapCI(margins, { iters: cfg.bootstrapIters, alpha: cfg.alpha, seed: cfg.seed });
|
|
121
|
-
// zeros-vs-wins sign test: b = #(margin>0), c = #(margin<0); two-sided p on |b
|
|
121
|
+
// zeros-vs-wins sign test: b = #(margin>0), c = #(margin<0); two-sided p on |b-c|.
|
|
122
122
|
const sign = mcnemar(ownLoss, ownWin);
|
|
123
123
|
perArm[arm] = {
|
|
124
124
|
arm,
|
|
@@ -141,7 +141,7 @@ export function wrongTargetControl(harnessOut, personas, opts = {}) {
|
|
|
141
141
|
for (const arm of harnessOut.arms) {
|
|
142
142
|
if (arm === 'baseline' || !perArm.baseline) continue;
|
|
143
143
|
const m = mcnemar(perArm.baseline.ownWin, perArm[arm].ownWin);
|
|
144
|
-
// mcnemar.pValue is TWO-SIDED (|b
|
|
144
|
+
// mcnemar.pValue is TWO-SIDED (|b-c|), so the direction guard m.b > m.c is mandatory:
|
|
145
145
|
// the arm must FLIP MORE subjects to own-match than baseline does, not merely differ.
|
|
146
146
|
perArm[arm].vsBaseline = {
|
|
147
147
|
b: m.b, c: m.c, pValue: m.pValue, beatsBaseline: significantAt(m.pValue, cfg.perTestAlpha) && m.b > m.c,
|
|
@@ -61,7 +61,7 @@ export const EXEMPLAR_TEXT_MAX = 600;
|
|
|
61
61
|
* Max bytes we will read from the on-disk JSONL. The store is bounded by
|
|
62
62
|
* MAX_EXEMPLARS short records, so a file larger than this is a corrupt/hand-
|
|
63
63
|
* edited artifact; refusing to slurp it whole avoids an OOM. ~2 MiB is orders
|
|
64
|
-
* of magnitude above any legitimate exemplar set (200
|
|
64
|
+
* of magnitude above any legitimate exemplar set (200 x 600 chars ≈ 120 KiB).
|
|
65
65
|
*/
|
|
66
66
|
const MAX_STORE_BYTES = 2 * 1024 * 1024;
|
|
67
67
|
|
package/src/profile/telemetry.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The NO-JUDGE behavioral metric (design spec §"The honest bar", claim 2):
|
|
5
5
|
* "Repeat-correction-rate drop — how often you re-issue the SAME correction,
|
|
6
|
-
* bucketed by session age. A working system bends the curve down (
|
|
7
|
-
* ->
|
|
6
|
+
* bucketed by session age. A working system bends the curve down (3x in week 1
|
|
7
|
+
* -> 0x by week 4). The most honest single number."
|
|
8
8
|
*
|
|
9
9
|
* This module records, per preference SLUG, every time the user RE-ISSUES a
|
|
10
10
|
* correction that the profile should already have learned, and computes the drop
|
|
@@ -286,14 +286,25 @@ export function tier2SyntaxCheckCmd(filePath) {
|
|
|
286
286
|
],
|
|
287
287
|
};
|
|
288
288
|
case '.py':
|
|
289
|
-
|
|
289
|
+
// Windows ships python.exe, not python3. If neither exists the spawn
|
|
290
|
+
// ENOENT is treated as SKIP by verifyTier2, not a syntax failure.
|
|
291
|
+
return {
|
|
292
|
+
cmd: process.platform === 'win32' ? 'python' : 'python3',
|
|
293
|
+
args: ['-m', 'py_compile', filePath],
|
|
294
|
+
};
|
|
290
295
|
case '.sh':
|
|
291
296
|
case '.bash':
|
|
297
|
+
// On Windows this only works when a real bash.exe (Git Bash) is on
|
|
298
|
+
// PATH; otherwise verifyTier2 maps the ENOENT to SKIP.
|
|
292
299
|
return { cmd: 'bash', args: ['-n', filePath] };
|
|
293
300
|
case '.ts':
|
|
294
301
|
case '.tsx': {
|
|
295
302
|
// Only if tsc on PATH. The agent contract says SKIP when absent.
|
|
296
|
-
|
|
303
|
+
// On Windows tsc is a .cmd shim which Node cannot spawn without a
|
|
304
|
+
// shell (CVE-2024-27980), and shelling out with an interpolated
|
|
305
|
+
// filePath would be an injection vector -- so SKIP honestly there.
|
|
306
|
+
if (process.platform === 'win32') return null;
|
|
307
|
+
const which = spawnSync('which', ['tsc'], {
|
|
297
308
|
encoding: 'utf8',
|
|
298
309
|
});
|
|
299
310
|
if (which.status === 0 && which.stdout.trim()) {
|
|
@@ -319,6 +330,11 @@ export async function verifyTier2(filePath) {
|
|
|
319
330
|
await execFileAsync(spec.cmd, spec.args, { timeout: 15_000 });
|
|
320
331
|
return { ok: true, skipped: false };
|
|
321
332
|
} catch (err) {
|
|
333
|
+
// Checker binary missing/not spawnable (ENOENT, or EINVAL for Windows
|
|
334
|
+
// .cmd shims) is "cannot verify", not "syntax error" -- honest SKIP.
|
|
335
|
+
if (err && (err.code === 'ENOENT' || err.code === 'EINVAL')) {
|
|
336
|
+
return { ok: true, skipped: true };
|
|
337
|
+
}
|
|
322
338
|
const stderr = err.stderr || err.stdout || err.message || '';
|
|
323
339
|
return {
|
|
324
340
|
ok: false,
|
|
@@ -369,10 +385,15 @@ async function resolveProjectVerifyCmd(projectRoot, verifyCmdOverride) {
|
|
|
369
385
|
export async function verifyTier3(projectRoot, verifyCmdOverride) {
|
|
370
386
|
const cmd = await resolveProjectVerifyCmd(projectRoot, verifyCmdOverride);
|
|
371
387
|
if (!cmd) return { ok: true, skipped: true };
|
|
372
|
-
// Run the command via
|
|
373
|
-
//
|
|
388
|
+
// Run the command via the platform shell so script lines like
|
|
389
|
+
// `npm test --silent` work verbatim: `sh -c` on POSIX, `cmd /d /s /c` on
|
|
390
|
+
// Windows ('sh' is not on PATH there). Timeout is generous (5 min)
|
|
391
|
+
// because real test suites can be slow.
|
|
392
|
+
const [shellBin, shellArgs] = process.platform === 'win32'
|
|
393
|
+
? [process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', cmd]]
|
|
394
|
+
: ['sh', ['-c', cmd]];
|
|
374
395
|
return new Promise((resolve) => {
|
|
375
|
-
execFile(
|
|
396
|
+
execFile(shellBin, shellArgs, { cwd: projectRoot, timeout: 5 * 60_000 }, (err, stdout, stderr) => {
|
|
376
397
|
const combined = `${String(stdout || '')}\n${String(stderr || '')}`;
|
|
377
398
|
if (err) {
|
|
378
399
|
const evidence = combined.split('\n').slice(0, 20).join('\n');
|
package/src/runtime-mediator.js
CHANGED
|
@@ -215,8 +215,11 @@ export async function maybeWarnDivergence(opts = {}) {
|
|
|
215
215
|
|
|
216
216
|
/**
|
|
217
217
|
* Map an MCP tool name (+ args) to the (action, target) tuple used for
|
|
218
|
-
* permission checks. Returns null for unrecognised tool names
|
|
219
|
-
*
|
|
218
|
+
* permission checks. Returns null for unrecognised tool names. Callers MUST
|
|
219
|
+
* treat null as fail-closed whenever an extension is active: every tool the
|
|
220
|
+
* server advertises has an explicit mapping here, so a null mapping means a
|
|
221
|
+
* future tool was added without a policy entry -- denying is the only answer
|
|
222
|
+
* that keeps the sandbox sound (see gatePermissionAndQuota in server.js).
|
|
220
223
|
*/
|
|
221
224
|
export function toolNameToActionTarget(toolName, args) {
|
|
222
225
|
switch (toolName) {
|
|
@@ -225,8 +228,23 @@ export function toolNameToActionTarget(toolName, args) {
|
|
|
225
228
|
case 'ijfw_memory_recall':
|
|
226
229
|
case 'ijfw_memory_search':
|
|
227
230
|
case 'ijfw_memory_prelude':
|
|
231
|
+
case 'ijfw_memory_facts':
|
|
228
232
|
case 'ijfw_cross_project_search':
|
|
229
233
|
return { action: 'read', target: 'memory:read' };
|
|
234
|
+
case 'ijfw_brain': {
|
|
235
|
+
// Brain verbs can write to the facts DB (wiki rebuilds, fact upserts),
|
|
236
|
+
// so classify the whole facade as a write -- conservative by design.
|
|
237
|
+
const verb = (args && typeof args.verb === 'string' && args.verb) ? args.verb : '*';
|
|
238
|
+
return { action: 'write', target: `brain:${verb}` };
|
|
239
|
+
}
|
|
240
|
+
case 'ijfw_state': {
|
|
241
|
+
// state-sdk verbs mutate project orchestration state.
|
|
242
|
+
const verb = (args && typeof args.verb === 'string' && args.verb) ? args.verb : '*';
|
|
243
|
+
return { action: 'write', target: `state:${verb}` };
|
|
244
|
+
}
|
|
245
|
+
case 'ijfw_cross_audit_converge':
|
|
246
|
+
// autoFix:true mutates source -- always treat as a write.
|
|
247
|
+
return { action: 'write', target: 'audit:converge' };
|
|
230
248
|
case 'ijfw_metrics':
|
|
231
249
|
return { action: 'read', target: 'metrics:read' };
|
|
232
250
|
case 'ijfw_update_check':
|
package/src/server.js
CHANGED
|
@@ -119,7 +119,29 @@ export async function gatePermissionAndQuota({ toolName, args, activeExt, home,
|
|
|
119
119
|
}
|
|
120
120
|
const mapping = toolNameToActionTarget(toolName, args || {});
|
|
121
121
|
if (!mapping) {
|
|
122
|
-
|
|
122
|
+
// Fail-closed: an extension is active (possibly MALFORMED) and this tool
|
|
123
|
+
// has no policy mapping. Allowing here would let any future tool silently
|
|
124
|
+
// bypass the sandbox -- and would also defeat the malformed-state deny,
|
|
125
|
+
// which lives inside checkPermission. Every advertised tool must have an
|
|
126
|
+
// explicit entry in toolNameToActionTarget (runtime-mediator.js).
|
|
127
|
+
const reason = `tool "${toolName}" not covered by extension policy`;
|
|
128
|
+
await logPermissionEvent({
|
|
129
|
+
tool: toolName,
|
|
130
|
+
extension: activeExt && activeExt.name ? activeExt.name : null,
|
|
131
|
+
action: null,
|
|
132
|
+
target: null,
|
|
133
|
+
allowed: false,
|
|
134
|
+
reason,
|
|
135
|
+
ts: new Date().toISOString(),
|
|
136
|
+
}).catch(() => {});
|
|
137
|
+
return {
|
|
138
|
+
allowed: false,
|
|
139
|
+
reason,
|
|
140
|
+
response: {
|
|
141
|
+
content: [{ type: 'text', text: `extension permission denied: ${reason}` }],
|
|
142
|
+
isError: true,
|
|
143
|
+
},
|
|
144
|
+
};
|
|
123
145
|
}
|
|
124
146
|
const permCheck = checkPermission(mapping.action, mapping.target, activeExt);
|
|
125
147
|
if (!permCheck.allowed) {
|
|
@@ -389,12 +411,21 @@ const TEAM_DIR_NAME = 'team';
|
|
|
389
411
|
const TEAM_FACETS = ['decisions', 'patterns', 'stack', 'members'];
|
|
390
412
|
|
|
391
413
|
// Claude Code's native auto-memory lives at ~/.claude/projects/<encoded>/memory/
|
|
392
|
-
// where <encoded> is the project path with
|
|
393
|
-
// and surfaces them via MCP so all platforms (not just
|
|
394
|
-
// memories -- no fighting Claude's native "Remember X"
|
|
414
|
+
// where <encoded> is the project path with separators replaced by `-`. IJFW
|
|
415
|
+
// reads these files and surfaces them via MCP so all platforms (not just
|
|
416
|
+
// Claude) see the same memories -- no fighting Claude's native "Remember X"
|
|
417
|
+
// handler. On Windows the path is `C:\Users\...` -- strip the drive letter and
|
|
418
|
+
// replace both separator styles so the slug is a single flat dir segment
|
|
419
|
+
// (a bare `\/`-only replace left backslashes + the drive colon in the segment,
|
|
420
|
+
// producing a nonexistent nested path, so this source was silently empty on
|
|
421
|
+
// Windows). Mirrors pathToSlug() in src/memory/reader.js -- keep in sync.
|
|
422
|
+
// Exported for the Windows-encoding regression test.
|
|
423
|
+
export function encodeClaudeProjectSlug(projectPath) {
|
|
424
|
+
return String(projectPath).replace(/^[A-Za-z]:/, '').replace(/[\\/]/g, '-');
|
|
425
|
+
}
|
|
395
426
|
const NATIVE_CLAUDE_DIR = join(
|
|
396
427
|
homedir(), '.claude', 'projects',
|
|
397
|
-
PROJECT_DIR
|
|
428
|
+
encodeClaudeProjectSlug(PROJECT_DIR),
|
|
398
429
|
'memory'
|
|
399
430
|
);
|
|
400
431
|
|
|
@@ -736,7 +767,11 @@ function appendFactsToSidecar(facts, meta) {
|
|
|
736
767
|
return { ok: true, written: facts.length };
|
|
737
768
|
} catch (err) {
|
|
738
769
|
// Non-fatal: facts are augmentation, not source-of-truth. Journal already
|
|
739
|
-
// captured the raw memory.
|
|
770
|
+
// captured the raw memory. Still surface on stderr so operators see the
|
|
771
|
+
// degradation -- callers also fold the failure into the store result.
|
|
772
|
+
try {
|
|
773
|
+
process.stderr.write(`[ijfw facts] sidecar append failed (${err.code || err.message}); fact extraction degraded\n`);
|
|
774
|
+
} catch { /* stderr may be detached */ }
|
|
740
775
|
return { ok: false, code: err.code || 'EUNKNOWN', message: err.message };
|
|
741
776
|
}
|
|
742
777
|
}
|
|
@@ -1556,26 +1591,30 @@ function handleStore({ content, type, tags = [], summary, why, how_to_apply }) {
|
|
|
1556
1591
|
try { console.error('[ijfw memory] FTS5 index dispatch failed:', e?.message || e); } catch { /* never throw */ }
|
|
1557
1592
|
}
|
|
1558
1593
|
|
|
1594
|
+
// Secondary writes (facts + type-specific). Each tracked so we report
|
|
1595
|
+
// partial success accurately rather than lying about "stored."
|
|
1596
|
+
const failures = [];
|
|
1597
|
+
|
|
1559
1598
|
// H5.5 — Fact extraction AFTER successful append. Best-effort: a failure
|
|
1560
|
-
// here is
|
|
1561
|
-
// Memory-id ties facts.jsonl rows
|
|
1599
|
+
// here is folded into the partial-failure return text (and stderr) but the
|
|
1600
|
+
// journal write above already succeeded. Memory-id ties facts.jsonl rows
|
|
1601
|
+
// back to their journal entry.
|
|
1562
1602
|
const factMeta = {
|
|
1563
1603
|
ts: new Date().toISOString(),
|
|
1564
1604
|
memory_id: factMemoryIdFor(journalEntry),
|
|
1565
1605
|
source: `memory_store:${type}`,
|
|
1566
1606
|
};
|
|
1567
1607
|
const facts = extractFacts(safeContent);
|
|
1568
|
-
appendFactsToSidecar(facts, factMeta);
|
|
1608
|
+
const factsResult = appendFactsToSidecar(facts, factMeta);
|
|
1609
|
+
if (!factsResult.ok) failures.push(`facts sidecar (${factsResult.code})`);
|
|
1569
1610
|
// v1.5.0 audit H5.4 — mirror to bi-temporal SQL store. For each fact,
|
|
1570
1611
|
// closes any prior currently-valid fact with the same (subject, predicate)
|
|
1571
1612
|
// but different object before inserting. Same-object stores are a no-op.
|
|
1572
1613
|
// Wrapped in a per-fact transaction inside temporal.js. Best-effort: any
|
|
1573
|
-
// failure is logged to stderr
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
// success accurately rather than lying about "stored."
|
|
1578
|
-
const failures = [];
|
|
1614
|
+
// failure is logged to stderr and never breaks the journal-or-JSONL path,
|
|
1615
|
+
// but an unavailable facts DB is reported honestly in the store result.
|
|
1616
|
+
const bitemporalResult = writeFactsBitemporal(facts, factMeta);
|
|
1617
|
+
if (!bitemporalResult.ok) failures.push(`facts db (${bitemporalResult.code})`);
|
|
1579
1618
|
|
|
1580
1619
|
if (type === 'decision' || type === 'pattern') {
|
|
1581
1620
|
// Richer frontmatter block for retrieval-quality entries.
|
|
@@ -1986,8 +2025,9 @@ function handleCrossProjectSearch({ pattern, limit = 10 } = {}) {
|
|
|
1986
2025
|
}
|
|
1987
2026
|
|
|
1988
2027
|
// Phase 3 #6: aggregate session metrics. Reads .ijfw/metrics/sessions.jsonl,
|
|
1989
|
-
// tolerates v1 lines (treats missing token/cost fields as 0),
|
|
1990
|
-
//
|
|
2028
|
+
// tolerates v1 lines (treats missing token/cost fields as 0), dedupes the
|
|
2029
|
+
// per-turn cumulative v5 rows to the latest row per session_id, groups by
|
|
2030
|
+
// day, renders compact text. Positive-framed zero-state when nothing logged.
|
|
1991
2031
|
function handleMetrics({ period = '7d', metric = 'tokens' } = {}) {
|
|
1992
2032
|
const file = join(IJFW_DIR, 'metrics', 'sessions.jsonl');
|
|
1993
2033
|
const r = readMarkdownFile(file);
|
|
@@ -2024,19 +2064,45 @@ function handleMetrics({ period = '7d', metric = 'tokens' } = {}) {
|
|
|
2024
2064
|
return { text: `Window ${period}: no sessions in range.${hint}` };
|
|
2025
2065
|
}
|
|
2026
2066
|
|
|
2067
|
+
// Schema v5: the Stop hook fires after EVERY assistant turn and appends one
|
|
2068
|
+
// row per turn carrying the CUMULATIVE totals for the whole session so far,
|
|
2069
|
+
// tagged with session_id + a monotonic turn counter. Summing every row
|
|
2070
|
+
// therefore overcounts quadratically -- keep only the LATEST row per
|
|
2071
|
+
// session_id (highest turn, falling back to timestamp; ties keep the later
|
|
2072
|
+
// line). Old-format rows without a session_id are treated as one session
|
|
2073
|
+
// per row, exactly as before.
|
|
2074
|
+
const latestBySession = new Map();
|
|
2075
|
+
const sessions = [];
|
|
2076
|
+
for (const row of within) {
|
|
2077
|
+
const sid = row.session_id;
|
|
2078
|
+
if (typeof sid !== 'string' || sid.length === 0) {
|
|
2079
|
+
sessions.push(row);
|
|
2080
|
+
continue;
|
|
2081
|
+
}
|
|
2082
|
+
const prev = latestBySession.get(sid);
|
|
2083
|
+
if (!prev) { latestBySession.set(sid, row); continue; }
|
|
2084
|
+
const rowTurn = typeof row.turn === 'number' ? row.turn : null;
|
|
2085
|
+
const prevTurn = typeof prev.turn === 'number' ? prev.turn : null;
|
|
2086
|
+
const later = (rowTurn !== null && prevTurn !== null && rowTurn !== prevTurn)
|
|
2087
|
+
? rowTurn > prevTurn
|
|
2088
|
+
: Date.parse(row.timestamp) >= Date.parse(prev.timestamp);
|
|
2089
|
+
if (later) latestBySession.set(sid, row);
|
|
2090
|
+
}
|
|
2091
|
+
for (const row of latestBySession.values()) sessions.push(row);
|
|
2092
|
+
|
|
2027
2093
|
if (metric === 'sessions') {
|
|
2028
|
-
const handoffs =
|
|
2029
|
-
const memEntries =
|
|
2094
|
+
const handoffs = sessions.filter(r => r.handoff).length;
|
|
2095
|
+
const memEntries = sessions.reduce((s, r) => s + (r.memory_stores || 0), 0);
|
|
2030
2096
|
return { text: [
|
|
2031
|
-
`Sessions in ${period}: ${
|
|
2032
|
-
`Handoffs preserved: ${handoffs} (${Math.round(100 * handoffs /
|
|
2097
|
+
`Sessions in ${period}: ${sessions.length}`,
|
|
2098
|
+
`Handoffs preserved: ${handoffs} (${Math.round(100 * handoffs / sessions.length)}%)`,
|
|
2033
2099
|
`Memory entries logged: ${memEntries}`
|
|
2034
2100
|
].join('\n') };
|
|
2035
2101
|
}
|
|
2036
2102
|
|
|
2037
2103
|
if (metric === 'routing') {
|
|
2038
2104
|
const counts = {};
|
|
2039
|
-
for (const r of
|
|
2105
|
+
for (const r of sessions) counts[r.routing || 'native'] = (counts[r.routing || 'native'] || 0) + 1;
|
|
2040
2106
|
return { text: ['Routing mix:'].concat(
|
|
2041
2107
|
Object.entries(counts).map(([k, v]) => ` ${k}: ${v}`)
|
|
2042
2108
|
).join('\n') };
|
|
@@ -2044,7 +2110,7 @@ function handleMetrics({ period = '7d', metric = 'tokens' } = {}) {
|
|
|
2044
2110
|
|
|
2045
2111
|
// Group by UTC day for tokens / cost.
|
|
2046
2112
|
const byDay = {};
|
|
2047
|
-
for (const row of
|
|
2113
|
+
for (const row of sessions) {
|
|
2048
2114
|
const day = String(row.timestamp).slice(0, 10);
|
|
2049
2115
|
byDay[day] = byDay[day] || { in: 0, out: 0, cr: 0, cc: 0, cost: 0, n: 0 };
|
|
2050
2116
|
byDay[day].in += row.input_tokens || 0;
|
|
@@ -2060,7 +2126,7 @@ function handleMetrics({ period = '7d', metric = 'tokens' } = {}) {
|
|
|
2060
2126
|
const total = days.reduce((s, d) => s + byDay[d].cost, 0);
|
|
2061
2127
|
const lines = ['Day | sessions | cost (USD)'];
|
|
2062
2128
|
for (const d of days) lines.push(`${d} | ${String(byDay[d].n).padStart(8)} | $${byDay[d].cost.toFixed(4)}`);
|
|
2063
|
-
lines.push(`Total: $${total.toFixed(4)} across ${
|
|
2129
|
+
lines.push(`Total: $${total.toFixed(4)} across ${sessions.length} session(s) -- clean session-ends only.`);
|
|
2064
2130
|
return { text: lines.join('\n') };
|
|
2065
2131
|
}
|
|
2066
2132
|
|
|
@@ -2604,7 +2670,10 @@ function handleMessage(msg) {
|
|
|
2604
2670
|
return createResponse(id, {});
|
|
2605
2671
|
|
|
2606
2672
|
default:
|
|
2607
|
-
|
|
2673
|
+
// Presence check, not truthiness: id 0 and id "" are valid JSON-RPC
|
|
2674
|
+
// request ids (the MCP TS SDK numbers requests from 0) and MUST get a
|
|
2675
|
+
// response. Only notifications (id absent/null) go unanswered.
|
|
2676
|
+
if (id !== undefined && id !== null) return createError(id, -32601, `Method not found: ${method}`);
|
|
2608
2677
|
return null;
|
|
2609
2678
|
}
|
|
2610
2679
|
}
|
|
@@ -2665,7 +2734,9 @@ function __attachStdioTransport() {
|
|
|
2665
2734
|
response.then(r => { if (r) process.stdout.write(r + '\n'); }).catch(err => {
|
|
2666
2735
|
process.stdout.write(JSON.stringify({
|
|
2667
2736
|
jsonrpc: '2.0',
|
|
2668
|
-
|
|
2737
|
+
// Presence check: id 0 / "" must round-trip so the client can
|
|
2738
|
+
// correlate the error to its pending request.
|
|
2739
|
+
id: (msg && msg.id !== undefined) ? msg.id : null,
|
|
2669
2740
|
error: { code: -32603, message: `Internal error: ${err.message}` }
|
|
2670
2741
|
}) + '\n');
|
|
2671
2742
|
});
|
|
@@ -2675,7 +2746,7 @@ function __attachStdioTransport() {
|
|
|
2675
2746
|
} catch (err) {
|
|
2676
2747
|
process.stdout.write(JSON.stringify({
|
|
2677
2748
|
jsonrpc: '2.0',
|
|
2678
|
-
id: msg && msg.id ? msg.id : null,
|
|
2749
|
+
id: (msg && msg.id !== undefined) ? msg.id : null,
|
|
2679
2750
|
error: { code: -32603, message: `Internal error: ${err.message}` }
|
|
2680
2751
|
}) + '\n');
|
|
2681
2752
|
}
|
|
@@ -2787,6 +2858,7 @@ if (__isServerEntryPoint) {
|
|
|
2787
2858
|
export {
|
|
2788
2859
|
sanitizeContent, atomicWrite, readMarkdownFile, PROJECT_HASH,
|
|
2789
2860
|
handleStore, handleRecall, handleSearch, handlePrelude,
|
|
2861
|
+
handleMetrics,
|
|
2790
2862
|
MEMORY_DIR, FACTS_FILE, FACTS_DB_FILE,
|
|
2791
2863
|
getFactsDb,
|
|
2792
2864
|
paths,
|