@ijfw/memory-server 1.6.0 → 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 +72 -19
- package/src/dashboard-server.js +117 -11
- 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/model-refresh.js +4 -2
- 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 +110 -31
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
|
|
|
@@ -2117,7 +2183,7 @@ function handleMessage(msg) {
|
|
|
2117
2183
|
try {
|
|
2118
2184
|
const fs = await import('node:fs');
|
|
2119
2185
|
const path = await import('node:path');
|
|
2120
|
-
const home = process.env.IJFW_HOME || path.join(process.env.HOME ||
|
|
2186
|
+
const home = process.env.IJFW_HOME || path.join(process.env.HOME || homedir(), '.ijfw');
|
|
2121
2187
|
const s = JSON.parse(fs.readFileSync(path.join(home, 'settings.json'), 'utf8'));
|
|
2122
2188
|
injectOn = s && s.profile && s.profile.inject === 'on';
|
|
2123
2189
|
} catch { injectOn = false; }
|
|
@@ -2424,9 +2490,16 @@ function handleMessage(msg) {
|
|
|
2424
2490
|
case 'ijfw_metrics':
|
|
2425
2491
|
result = handleMetrics(args || {});
|
|
2426
2492
|
break;
|
|
2427
|
-
case 'ijfw_cross_project_search':
|
|
2428
|
-
|
|
2493
|
+
case 'ijfw_cross_project_search': {
|
|
2494
|
+
// Privacy opt-out: a dedicated off-switch + the IJFW_MINIMAL master
|
|
2495
|
+
// switch disable cross-project memory surfacing entirely.
|
|
2496
|
+
const xpOff = /^(1|true|yes|on)$/i.test(process.env.IJFW_NO_CROSS_PROJECT || '')
|
|
2497
|
+
|| process.env.IJFW_MINIMAL === '1';
|
|
2498
|
+
result = xpOff
|
|
2499
|
+
? { text: 'Cross-project search is disabled (IJFW_NO_CROSS_PROJECT / IJFW_MINIMAL).' }
|
|
2500
|
+
: handleCrossProjectSearch(args || {});
|
|
2429
2501
|
break;
|
|
2502
|
+
}
|
|
2430
2503
|
case 'ijfw_prompt_check': {
|
|
2431
2504
|
const pc = checkPrompt((args && args.prompt) || '');
|
|
2432
2505
|
const text = pc.vague
|
|
@@ -2550,7 +2623,7 @@ function handleMessage(msg) {
|
|
|
2550
2623
|
try {
|
|
2551
2624
|
const fs = await import('node:fs');
|
|
2552
2625
|
const path = await import('node:path');
|
|
2553
|
-
const home = process.env.IJFW_HOME || path.join(process.env.HOME ||
|
|
2626
|
+
const home = process.env.IJFW_HOME || path.join(process.env.HOME || homedir(), '.ijfw');
|
|
2554
2627
|
const s = JSON.parse(fs.readFileSync(path.join(home, 'settings.json'), 'utf8'));
|
|
2555
2628
|
injectOn = s && s.profile && s.profile.inject === 'on';
|
|
2556
2629
|
} catch { injectOn = false; }
|
|
@@ -2597,7 +2670,10 @@ function handleMessage(msg) {
|
|
|
2597
2670
|
return createResponse(id, {});
|
|
2598
2671
|
|
|
2599
2672
|
default:
|
|
2600
|
-
|
|
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}`);
|
|
2601
2677
|
return null;
|
|
2602
2678
|
}
|
|
2603
2679
|
}
|
|
@@ -2658,7 +2734,9 @@ function __attachStdioTransport() {
|
|
|
2658
2734
|
response.then(r => { if (r) process.stdout.write(r + '\n'); }).catch(err => {
|
|
2659
2735
|
process.stdout.write(JSON.stringify({
|
|
2660
2736
|
jsonrpc: '2.0',
|
|
2661
|
-
|
|
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,
|
|
2662
2740
|
error: { code: -32603, message: `Internal error: ${err.message}` }
|
|
2663
2741
|
}) + '\n');
|
|
2664
2742
|
});
|
|
@@ -2668,7 +2746,7 @@ function __attachStdioTransport() {
|
|
|
2668
2746
|
} catch (err) {
|
|
2669
2747
|
process.stdout.write(JSON.stringify({
|
|
2670
2748
|
jsonrpc: '2.0',
|
|
2671
|
-
id: msg && msg.id ? msg.id : null,
|
|
2749
|
+
id: (msg && msg.id !== undefined) ? msg.id : null,
|
|
2672
2750
|
error: { code: -32603, message: `Internal error: ${err.message}` }
|
|
2673
2751
|
}) + '\n');
|
|
2674
2752
|
}
|
|
@@ -2780,6 +2858,7 @@ if (__isServerEntryPoint) {
|
|
|
2780
2858
|
export {
|
|
2781
2859
|
sanitizeContent, atomicWrite, readMarkdownFile, PROJECT_HASH,
|
|
2782
2860
|
handleStore, handleRecall, handleSearch, handlePrelude,
|
|
2861
|
+
handleMetrics,
|
|
2783
2862
|
MEMORY_DIR, FACTS_FILE, FACTS_DB_FILE,
|
|
2784
2863
|
getFactsDb,
|
|
2785
2864
|
paths,
|