@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/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
- return { allowed: true };
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 `/` `-`. IJFW reads these files
393
- // and surfaces them via MCP so all platforms (not just Claude) see the same
394
- // memories -- no fighting Claude's native "Remember X" handler.
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.replace(/\//g, '-'),
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 logged in the return text but does NOT poison the store result.
1561
- // Memory-id ties facts.jsonl rows back to their journal entry.
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 but never breaks the journal-or-JSONL path.
1574
- writeFactsBitemporal(facts, factMeta);
1575
-
1576
- // 2. Type-specific secondary writes. Each tracked so we report partial
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), groups by day,
1990
- // renders compact text. Positive-framed zero-state when no sessions logged yet.
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 = within.filter(r => r.handoff).length;
2029
- const memEntries = within.reduce((s, r) => s + (r.memory_stores || 0), 0);
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}: ${within.length}`,
2032
- `Handoffs preserved: ${handoffs} (${Math.round(100 * handoffs / within.length)}%)`,
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 within) counts[r.routing || 'native'] = (counts[r.routing || 'native'] || 0) + 1;
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 within) {
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 ${within.length} session(s) -- clean session-ends only.`);
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 || '', '.ijfw');
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
- result = handleCrossProjectSearch(args || {});
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 || '', '.ijfw');
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
- if (id) return createError(id, -32601, `Method not found: ${method}`);
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
- id: msg && msg.id ? msg.id : null,
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,