@ijfw/memory-server 1.3.0

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.
Files changed (106) hide show
  1. package/bin/ijfw +27 -0
  2. package/bin/ijfw-dashboard +180 -0
  3. package/bin/ijfw-dispatch-plan +41 -0
  4. package/bin/ijfw-memorize +273 -0
  5. package/bin/ijfw-memory +51 -0
  6. package/fixtures/demo-target.js +28 -0
  7. package/package.json +53 -0
  8. package/src/api-client.js +190 -0
  9. package/src/audit-roster.js +315 -0
  10. package/src/caps.js +37 -0
  11. package/src/cold-scan-runner.mjs +37 -0
  12. package/src/compute/edges.js +155 -0
  13. package/src/compute/extract.js +560 -0
  14. package/src/compute/fts5.js +420 -0
  15. package/src/compute/graph-auto-index.js +191 -0
  16. package/src/compute/graph-lock.js +114 -0
  17. package/src/compute/index.js +18 -0
  18. package/src/compute/migration-runner.js +116 -0
  19. package/src/compute/migrations/001-initial.js +23 -0
  20. package/src/compute/migrations/002-porter-stemming-source.js +139 -0
  21. package/src/compute/migrations/003-tier-semantic.js +69 -0
  22. package/src/compute/migrations/004-kg-tables.js +83 -0
  23. package/src/compute/migrations/005-stale-candidate.js +72 -0
  24. package/src/compute/python-resolver.js +106 -0
  25. package/src/compute/runner-vm.js +185 -0
  26. package/src/compute/runner.js +416 -0
  27. package/src/compute/sandbox-detect.js +122 -0
  28. package/src/compute/sandbox-linux.js +164 -0
  29. package/src/compute/sandbox-macos.js +167 -0
  30. package/src/compute/sandbox-windows.js +63 -0
  31. package/src/compute/schema.sql +118 -0
  32. package/src/compute/staleness.js +239 -0
  33. package/src/compute/synonyms.js +367 -0
  34. package/src/compute/traverse.js +180 -0
  35. package/src/cost/aggregator.js +229 -0
  36. package/src/cost/pricing.js +134 -0
  37. package/src/cost/readers/claude.js +179 -0
  38. package/src/cost/readers/codex.js +131 -0
  39. package/src/cost/readers/gemini.js +111 -0
  40. package/src/cost/savings.js +243 -0
  41. package/src/cross-dispatcher.js +437 -0
  42. package/src/cross-orchestrator-cli.js +1885 -0
  43. package/src/cross-orchestrator.js +598 -0
  44. package/src/cross-project-search.js +114 -0
  45. package/src/dashboard-client.html +1180 -0
  46. package/src/dashboard-server.js +895 -0
  47. package/src/design-companion.js +81 -0
  48. package/src/dispatch/colon-syntax.js +732 -0
  49. package/src/dispatch-planner.js +235 -0
  50. package/src/dream/cooldown.js +105 -0
  51. package/src/dream/runner.mjs +373 -0
  52. package/src/dream/staleness-wiring.js +195 -0
  53. package/src/feedback-detector.js +57 -0
  54. package/src/hero-line.js +115 -0
  55. package/src/importers/claude-mem.js +152 -0
  56. package/src/importers/cli.js +311 -0
  57. package/src/importers/common.js +84 -0
  58. package/src/importers/discover.js +235 -0
  59. package/src/importers/rtk.js +107 -0
  60. package/src/intent-router.js +221 -0
  61. package/src/lib/atomic-io.js +201 -0
  62. package/src/lib/cache.js +33 -0
  63. package/src/lib/npm-view.js +104 -0
  64. package/src/lib/status-card.js +95 -0
  65. package/src/lib/token.js +85 -0
  66. package/src/memory/fts5.js +349 -0
  67. package/src/memory/migration-runner.js +116 -0
  68. package/src/memory/migrations/001-fts5-init.js +26 -0
  69. package/src/memory/migrations/002-tier-semantic.js +60 -0
  70. package/src/memory/migrations/003-stale-candidate.js +60 -0
  71. package/src/memory/reader.js +300 -0
  72. package/src/memory/recall-counter.js +76 -0
  73. package/src/memory/schema.sql +79 -0
  74. package/src/memory/search.js +431 -0
  75. package/src/memory/staleness.js +237 -0
  76. package/src/memory/tier-promotion.js +377 -0
  77. package/src/memory/tokenize.js +63 -0
  78. package/src/project-type-detector.js +866 -0
  79. package/src/prompt-check.js +171 -0
  80. package/src/ralph-allowlist.js +88 -0
  81. package/src/receipts.js +129 -0
  82. package/src/redactor.js +107 -0
  83. package/src/sandbox.js +275 -0
  84. package/src/sanitizer.js +69 -0
  85. package/src/scan-resume.js +167 -0
  86. package/src/schema.js +82 -0
  87. package/src/search-bm25.js +108 -0
  88. package/src/server.js +1414 -0
  89. package/src/swarm-config.js +80 -0
  90. package/src/trident/dispatch.js +211 -0
  91. package/src/trident/lens-health.js +253 -0
  92. package/src/update-apply.js +79 -0
  93. package/src/update-check.js +136 -0
  94. package/src/vectors.js +178 -0
  95. package/templates/design/bento-grid.md +84 -0
  96. package/templates/design/brutalist-luxe.md +82 -0
  97. package/templates/design/cinematic-dark.md +82 -0
  98. package/templates/design/data-dense-dashboard.md +88 -0
  99. package/templates/design/editorial-warm.md +81 -0
  100. package/templates/design/glassmorphic.md +84 -0
  101. package/templates/design/magazine-editorial.md +84 -0
  102. package/templates/design/maximalist-vibrant.md +85 -0
  103. package/templates/design/neo-swiss-tech.md +85 -0
  104. package/templates/design/swiss-minimal.md +80 -0
  105. package/templates/design/terminal-native.md +83 -0
  106. package/templates/design/warm-organic.md +84 -0
package/src/server.js ADDED
@@ -0,0 +1,1414 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * IJFW Memory Server -- Cross-platform MCP memory for AI coding agents
5
+ * By Sean Donahoe | "It Just Fucking Works"
6
+ *
7
+ * 4 tools: recall, store, search, status
8
+ * Storage: append-only markdown (hot layer, zero dependencies)
9
+ * Protocol: MCP over stdio (JSON-RPC 2.0)
10
+ *
11
+ * Hardened against: prompt injection via stored content, cross-project worming,
12
+ * non-atomic writes, silent storage failures, Windows path traversal.
13
+ */
14
+
15
+ import { createInterface } from 'readline';
16
+ import {
17
+ existsSync, mkdirSync, readFileSync, writeFileSync,
18
+ appendFileSync, readdirSync, statSync, renameSync, unlinkSync,
19
+ openSync, closeSync, fsyncSync, realpathSync
20
+ } from 'fs';
21
+ import { join, resolve, isAbsolute, normalize, basename, dirname } from 'path';
22
+ import { homedir } from 'os';
23
+ import { fileURLToPath } from 'url';
24
+ import { createHash, randomBytes } from 'crypto';
25
+
26
+ // Read version dynamically from package.json so bumps don't require a code change.
27
+ const __pkg_dirname = dirname(fileURLToPath(import.meta.url));
28
+ const PKG_VERSION = (() => {
29
+ try { return JSON.parse(readFileSync(join(__pkg_dirname, '..', 'package.json'), 'utf8')).version; }
30
+ catch { return 'unknown'; }
31
+ })();
32
+ import { checkPrompt } from './prompt-check.js';
33
+ import { applyCaps, CAP_CONTENT } from './caps.js';
34
+ import { ensureSchemaHeader, SCHEMA_HEADER } from './schema.js';
35
+ import { searchCorpus } from './search-bm25.js';
36
+ import { crossProjectSearch } from './cross-project-search.js';
37
+ // R2-E -- single source of truth for markdown/HTML/control-char defanger.
38
+ import { sanitizeContent } from './sanitizer.js';
39
+ // 1.1.6: update tools (cap 8 -> 10) -- token-issuance + OOB terminal confirm.
40
+ // Per CLAUDE.md policy: future growth triggers retirement review, not raise.
41
+ import { ijfwUpdateCheck, TOOL_DEF as UPDATE_CHECK_TOOL } from './update-check.js';
42
+ import { ijfwUpdateApply, TOOL_DEF as UPDATE_APPLY_TOOL } from './update-apply.js';
43
+ // ijfw_run: sandbox large command output to disk, return terse summary to context.
44
+ import { runCommand, detectDomain, summarize, writeToSandbox, readFromSandbox, purgeSandboxOld, stripAnsi } from './sandbox.js';
45
+ // W1B (1.3.0-alpha) -- colon-syntax dispatcher. Extends ijfw_run + ijfw_memory_search
46
+ // with compute:/index:/detect: sub-commands without registering new MCP tools.
47
+ import { parseColonCommand, dispatchRun, dispatchSearch } from './dispatch/colon-syntax.js';
48
+ const SANDBOX_DIR = join(process.env.HOME || homedir(), '.ijfw', 'session-sandbox');
49
+
50
+ // --- Constants ---
51
+ const SCHEMA_VERSION = 1;
52
+ const MAX_STORE_LENGTH = CAP_CONTENT;
53
+ const MAX_TAGS = 20;
54
+ const MAX_TAG_LEN = 50;
55
+ const MAX_SEARCH_RESULTS = 20;
56
+ const MAX_FILE_READ = 5_000_000; // 5MB -- large enough that unbounded growth doesn't hit during normal lifetime
57
+ const VALID_MEMORY_TYPES = ['decision', 'observation', 'pattern', 'handoff', 'preference'];
58
+
59
+ // --- Project root resolution (path-traversal-safe; cross-platform) ---
60
+ // Strategy:
61
+ // 1. IJFW_PROJECT_DIR env (explicit) -- validated for traversal, used as-is.
62
+ // 2. CLAUDE_PROJECT_DIR env (set by Claude Code when project known) -- same validation.
63
+ // 3. process.cwd() -- used ONLY if writable. Claude Code sometimes spawns
64
+ // MCP servers in directories the user can't write to (/, /tmp).
65
+ // 4. os.homedir() -- final fallback. Always writable for the user.
66
+ //
67
+ // Picking a writable root at startup eliminates the EACCES-on-mkdir failure
68
+ // mode that corrupts the MCP stdio handshake (any stderr byte during init
69
+ // can make the client mark the server as failed).
70
+ function validatePath(raw) {
71
+ if (!raw) return null;
72
+ const resolved = resolve(raw);
73
+ const normalized = normalize(resolved);
74
+ if (!isAbsolute(normalized)) return null;
75
+ const parts = normalized.split(/[\\/]+/);
76
+ if (parts.includes('..')) return null;
77
+ return normalized;
78
+ }
79
+
80
+ function isWritable(dir) {
81
+ try {
82
+ if (!existsSync(dir)) {
83
+ // Try to create it; if that works it's writable.
84
+ mkdirSync(dir, { recursive: true });
85
+ return true;
86
+ }
87
+ // Exists -- probe with a tmp file.
88
+ const probe = join(dir, `.ijfw-probe-${process.pid}-${Date.now()}`);
89
+ writeFileSync(probe, '');
90
+ unlinkSync(probe);
91
+ return true;
92
+ } catch {
93
+ return false;
94
+ }
95
+ }
96
+
97
+ function safeProjectDir() {
98
+ // 1. Explicit IJFW_PROJECT_DIR wins (user or installer set it deliberately).
99
+ const fromIjfw = validatePath(process.env.IJFW_PROJECT_DIR);
100
+ if (fromIjfw && isWritable(fromIjfw)) return fromIjfw;
101
+
102
+ // 2. CLAUDE_PROJECT_DIR (set by some Claude Code versions).
103
+ const fromClaude = validatePath(process.env.CLAUDE_PROJECT_DIR);
104
+ if (fromClaude && isWritable(fromClaude)) return fromClaude;
105
+
106
+ // 3. CWD if writable -- normal case for shell-invoked use and Claude Code
107
+ // sessions rooted in a project.
108
+ const cwd = process.cwd();
109
+ if (isWritable(cwd)) return cwd;
110
+
111
+ // 4. HOME fallback -- always writable for the user. Memory becomes
112
+ // user-global but we stay alive instead of crashing.
113
+ return homedir();
114
+ }
115
+
116
+ const PROJECT_DIR = safeProjectDir();
117
+ const PROJECT_HASH = createHash('sha256').update(PROJECT_DIR).digest('hex').slice(0, 12);
118
+ const IJFW_DIR = join(PROJECT_DIR, '.ijfw');
119
+ const MEMORY_DIR = join(IJFW_DIR, 'memory');
120
+ const SESSIONS_DIR = join(IJFW_DIR, 'sessions');
121
+ const GLOBAL_DIR = join(homedir(), '.ijfw', 'memory');
122
+ // Legacy single-file location (pre-Phase 2). Still read for backward compat
123
+ // but new writes go to the faceted structure.
124
+ const LEGACY_GLOBAL_FILE = join(GLOBAL_DIR, 'global-knowledge.md');
125
+ // Faceted global memory (Phase 2). Each file is bounded, human-readable, git-friendly.
126
+ const GLOBAL_FACETS_DIR = join(GLOBAL_DIR, 'global');
127
+ const GLOBAL_FACETS = ['preferences', 'patterns', 'stack', 'anti-patterns', 'lessons'];
128
+ const DEFAULT_FACET = 'preferences';
129
+ // Phase 3: cross-project registry. Session-start hooks append one line per
130
+ // known IJFW project. Used by search(scope:'all') and recall(from_project:X).
131
+ const REGISTRY_FILE = join(homedir(), '.ijfw', 'registry.md');
132
+ // Phase 3 #8: team memory tier. Project-local, faceted, committed alongside
133
+ // personal memory but distinguished as shared decisions/patterns/stack/members.
134
+ // Precedence: team > personal > global. Empty by default -- no behavior change
135
+ // until user creates .ijfw/team/<facet>.md (commits it for teammates).
136
+ const TEAM_DIR_NAME = 'team';
137
+ const TEAM_FACETS = ['decisions', 'patterns', 'stack', 'members'];
138
+
139
+ // Claude Code's native auto-memory lives at ~/.claude/projects/<encoded>/memory/
140
+ // where <encoded> is the project path with `/` → `-`. IJFW reads these files
141
+ // and surfaces them via MCP so all platforms (not just Claude) see the same
142
+ // memories -- no fighting Claude's native "Remember X" handler.
143
+ const NATIVE_CLAUDE_DIR = join(
144
+ homedir(), '.claude', 'projects',
145
+ PROJECT_DIR.replace(/\//g, '-'),
146
+ 'memory'
147
+ );
148
+
149
+ // --- Bootstrap directories ---
150
+ // Project dirs are required; global is best-effort (HOME may be read-only on CI).
151
+ // Failures here do NOT write to stderr during startup -- any stderr byte during
152
+ // MCP handshake can make strict clients (incl. Claude Code) mark the server
153
+ // as failed. Subsequent store/read calls surface structured errors instead.
154
+ try {
155
+ [MEMORY_DIR, SESSIONS_DIR].forEach(dir => {
156
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
157
+ });
158
+ } catch { /* handleStore/recall surface structured errors on first use */ }
159
+ try {
160
+ if (!existsSync(GLOBAL_DIR)) mkdirSync(GLOBAL_DIR, { recursive: true });
161
+ } catch { /* handleStore reports on attempted write */ }
162
+
163
+ // R2-E -- sanitizeContent moved to mcp-server/src/sanitizer.js so MCP stores
164
+ // and auto-memorize stores share a single implementation. Imported above.
165
+
166
+ // --- Atomic write (write to .tmp, fsync, rename) ---
167
+ //
168
+ // Eliminates partial-write corruption on crash and makes concurrent writers
169
+ // from two server instances on the same project safe at the file level
170
+ // (last writer wins atomically, no interleaved bytes).
171
+ function atomicWrite(filepath, content) {
172
+ const tmp = `${filepath}.tmp.${process.pid}.${randomBytes(4).toString('hex')}`;
173
+ let fd;
174
+ try {
175
+ fd = openSync(tmp, 'w');
176
+ writeFileSync(fd, content, 'utf-8');
177
+ fsyncSync(fd);
178
+ closeSync(fd);
179
+ fd = null;
180
+ renameSync(tmp, filepath);
181
+ return { ok: true };
182
+ } catch (err) {
183
+ if (fd != null) { try { closeSync(fd); } catch {} }
184
+ try { unlinkSync(tmp); } catch {}
185
+ return { ok: false, code: err.code || 'EUNKNOWN', message: err.message };
186
+ }
187
+ }
188
+
189
+ // --- Read with explicit error reporting ---
190
+ //
191
+ // Returns { ok: true, content } on success including empty file.
192
+ // Returns { ok: false, reason } so callers can distinguish "absent" from
193
+ // "permission denied" / "too big" / "I/O error" -- silent null was the
194
+ // previous root of multiple bugs.
195
+ function readMarkdownFile(filepath) {
196
+ if (!existsSync(filepath)) return { ok: false, reason: 'absent' };
197
+ let stats;
198
+ try {
199
+ stats = statSync(filepath);
200
+ } catch (err) {
201
+ return { ok: false, reason: err.code || 'stat-failed' };
202
+ }
203
+ if (stats.size > MAX_FILE_READ) return { ok: false, reason: 'too-large', size: stats.size };
204
+ try {
205
+ return { ok: true, content: readFileSync(filepath, 'utf-8') };
206
+ } catch (err) {
207
+ return { ok: false, reason: err.code || 'read-failed' };
208
+ }
209
+ }
210
+
211
+ // Convenience wrapper: returns string ('' if absent or unreadable) for the
212
+ // recall hot-path where we just need text. Logs unexpected failures.
213
+ function readOr(filepath, fallback = '') {
214
+ const r = readMarkdownFile(filepath);
215
+ if (r.ok) return r.content;
216
+ if (r.reason !== 'absent') {
217
+ process.stderr.write(`IJFW: read ${basename(filepath)}: ${r.reason}\n`);
218
+ }
219
+ return fallback;
220
+ }
221
+
222
+ // --- Append helper (atomic for entries < PIPE_BUF; append-only growth) ---
223
+ //
224
+ // We rely on POSIX O_APPEND atomicity for entries under 4KB. Sanitized
225
+ // entries are bounded at MAX_STORE_LENGTH=5000 chars, but the entry header
226
+ // keeps each *line* well under 4KB after sanitization (single-line collapse).
227
+ function appendLine(filepath, line) {
228
+ try {
229
+ if (!existsSync(filepath)) {
230
+ // First write seeds the schema header (audit R1). Best-effort atomic.
231
+ const seed = `${SCHEMA_HEADER}\n# ${basename(filepath, '.md')}\n${line}\n`;
232
+ const r = atomicWrite(filepath, seed);
233
+ if (!r.ok) return r;
234
+ return { ok: true };
235
+ }
236
+ // Existing file: migrate if it predates the schema header.
237
+ try { ensureSchemaHeader(filepath); } catch { /* best-effort; append still runs */ }
238
+ appendFileSync(filepath, line + '\n');
239
+ return { ok: true };
240
+ } catch (err) {
241
+ return { ok: false, code: err.code || 'EUNKNOWN', message: err.message };
242
+ }
243
+ }
244
+
245
+ // --- Recall observation emitter ---
246
+ // Appends a lightweight "memory-recall" entry to ~/.ijfw/observations.jsonl
247
+ // so the dashboard recall-counter can track per-file recall frequency.
248
+ // Best-effort: never throws, never blocks the recall response.
249
+ function emitRecallObservation({ context_hint, from_project } = {}) {
250
+ try {
251
+ const obsPath = join(homedir(), '.ijfw', 'observations.jsonl');
252
+ // Derive a plausible file_path from context_hint if it looks like a filename
253
+ const fp = (context_hint && context_hint.includes('.md'))
254
+ ? join(GLOBAL_DIR, context_hint)
255
+ : null;
256
+ const obs = {
257
+ type: 'memory-recall',
258
+ ts: new Date().toISOString(),
259
+ tool_name: 'ijfw_memory_recall',
260
+ context_hint: context_hint || null,
261
+ file_path: fp,
262
+ from_project: from_project || null,
263
+ platform: 'mcp',
264
+ };
265
+ appendFileSync(obsPath, JSON.stringify(obs) + '\n');
266
+ } catch { /* best-effort */ }
267
+ }
268
+
269
+ // --- Storage helpers ---
270
+ function appendToJournal(entry) {
271
+ const journalPath = join(MEMORY_DIR, 'project-journal.md');
272
+ const ts = new Date().toISOString();
273
+ const line = `- [${ts}] ${entry}`;
274
+ return appendLine(journalPath, line);
275
+ }
276
+
277
+ // Structured append for decisions/patterns -- produces a richer frontmatter block
278
+ // similar to Claude's native auto-memory format: YAML frontmatter plus a body with
279
+ // Why / How-to-apply sections. This is the format users retrieve well from.
280
+ function appendStructuredToKnowledge({ type, summary, content, why, howToApply, tags }) {
281
+ const filepath = join(MEMORY_DIR, 'knowledge.md');
282
+ const ts = new Date().toISOString();
283
+ const tagLine = tags && tags.length ? tags.join(', ') : '';
284
+ const block = [
285
+ '',
286
+ '---',
287
+ `type: ${type}`,
288
+ `summary: ${summary}`,
289
+ `stored: ${ts}`,
290
+ tagLine ? `tags: [${tagLine}]` : '',
291
+ '---',
292
+ content,
293
+ why ? `\n**Why:** ${why}` : '',
294
+ howToApply ? `\n**How to apply:** ${howToApply}` : '',
295
+ ''
296
+ ].filter(l => l !== '').join('\n') + '\n';
297
+
298
+ try {
299
+ if (!existsSync(filepath)) {
300
+ const seed = `${SCHEMA_HEADER}\n# Knowledge Base\n${block}`;
301
+ return atomicWrite(filepath, seed);
302
+ }
303
+ try { ensureSchemaHeader(filepath); } catch { /* best-effort */ }
304
+ appendFileSync(filepath, block);
305
+ return { ok: true };
306
+ } catch (err) {
307
+ return { ok: false, code: err.code || 'EUNKNOWN', message: err.message };
308
+ }
309
+ }
310
+
311
+ // Per-project namespacing prevents cross-project worming. A poisoned preference
312
+ // stored from project A is namespaced to A's hash, so project B never reads it
313
+ // as if it were its own preference.
314
+ //
315
+ // Phase 2: writes go to faceted files. facet is inferred from tags when present
316
+ // (tag matches facet name → that facet; else preferences). Legacy global file is
317
+ // read but not written -- future migration can merge it into facets.
318
+ function appendToGlobalPrefs(entry, tags = []) {
319
+ try {
320
+ if (!existsSync(GLOBAL_FACETS_DIR)) mkdirSync(GLOBAL_FACETS_DIR, { recursive: true });
321
+ } catch { /* best-effort -- if HOME is RO we can't write global */ }
322
+ const facet = GLOBAL_FACETS.find(f => tags.some(t => t.toLowerCase() === f)) || DEFAULT_FACET;
323
+ const namespaced = `[ns:${PROJECT_HASH}] ${entry}`;
324
+ return appendLine(join(GLOBAL_FACETS_DIR, `${facet}.md`), namespaced);
325
+ }
326
+
327
+ function readKnowledgeBase() {
328
+ return readOr(join(MEMORY_DIR, 'knowledge.md'));
329
+ }
330
+ function readHandoff() {
331
+ return readOr(join(MEMORY_DIR, 'handoff.md'));
332
+ }
333
+ // Read Claude Code native auto-memory for this project. Returns concatenated
334
+ // sanitized content of all project_*.md files (skipping MEMORY.md index).
335
+ // This lets IJFW surface Claude-native memories to other platforms that don't
336
+ // have an equivalent built-in system.
337
+ function readNativeClaudeMemory() {
338
+ try {
339
+ if (!existsSync(NATIVE_CLAUDE_DIR)) return '';
340
+ const files = readdirSync(NATIVE_CLAUDE_DIR)
341
+ .filter(f => f.endsWith('.md') && f !== 'MEMORY.md')
342
+ .sort();
343
+ const parts = [];
344
+ for (const f of files) {
345
+ const r = readMarkdownFile(join(NATIVE_CLAUDE_DIR, f));
346
+ if (!r.ok) continue;
347
+ // Strip YAML frontmatter for brevity in prelude -- keep the body that
348
+ // already includes the **Why:** / **How to apply:** sections.
349
+ const body = r.content.replace(/^---[\s\S]*?---\n/, '').trim();
350
+ if (body) parts.push(body);
351
+ }
352
+ return parts.join('\n\n---\n\n');
353
+ } catch {
354
+ return '';
355
+ }
356
+ }
357
+
358
+ // Phase 3 #8: team memory -- shared, project-local, committed. Read-only here.
359
+ // Faceted (decisions/patterns/stack/members) for parity with global tier;
360
+ // each facet is a plain markdown file that teammates edit via PR.
361
+ function readTeamKnowledge() {
362
+ const teamDir = join(IJFW_DIR, TEAM_DIR_NAME);
363
+ if (!existsSync(teamDir)) return '';
364
+ const out = [];
365
+ for (const facet of TEAM_FACETS) {
366
+ const raw = readOr(join(teamDir, `${facet}.md`));
367
+ if (raw) out.push(`### ${facet} (team)\n${raw}`);
368
+ }
369
+ return out.join('\n\n');
370
+ }
371
+
372
+ // Global prefs are filtered to entries matching this project's namespace OR
373
+ // entries with no namespace (legacy/manual entries). Cross-project prefs are
374
+ // not exposed by default. Phase 2: reads both faceted files and legacy flat.
375
+ function readGlobalKnowledge() {
376
+ const sources = [];
377
+ // Faceted files (Phase 2)
378
+ if (existsSync(GLOBAL_FACETS_DIR)) {
379
+ for (const facet of GLOBAL_FACETS) {
380
+ const p = join(GLOBAL_FACETS_DIR, `${facet}.md`);
381
+ const raw = readOr(p);
382
+ if (raw) sources.push(`### ${facet}\n${raw}`);
383
+ }
384
+ }
385
+ // Legacy single-file (pre-Phase 2) -- still surface if present, unfaceted
386
+ const legacy = readOr(LEGACY_GLOBAL_FILE);
387
+ if (legacy) sources.push(`### legacy\n${legacy}`);
388
+
389
+ if (sources.length === 0) return '';
390
+
391
+ // Filter to entries matching this project's namespace (or unnamespaced).
392
+ return sources.map(section =>
393
+ section.split('\n').filter(line => {
394
+ if (!line.startsWith('[ns:')) return true;
395
+ return line.startsWith(`[ns:${PROJECT_HASH}]`);
396
+ }).join('\n')
397
+ ).join('\n\n');
398
+ }
399
+
400
+ function getSessionCount() {
401
+ try {
402
+ if (!existsSync(SESSIONS_DIR)) return 0;
403
+ return readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.md')).length;
404
+ } catch {
405
+ return 0;
406
+ }
407
+ }
408
+
409
+ function getDecisionCount() {
410
+ const journal = readOr(join(MEMORY_DIR, 'project-journal.md'));
411
+ if (!journal) return 0;
412
+ // Match only journal entry lines (we now prefix with - [timestamp]) -- not
413
+ // arbitrary list bullets that might appear in seeded content.
414
+ return (journal.match(/^- \[\d{4}-\d{2}-\d{2}T/gm) || []).length;
415
+ }
416
+
417
+ function getRecentJournalEntries(count = 5) {
418
+ const journal = readOr(join(MEMORY_DIR, 'project-journal.md'));
419
+ if (!journal) return '';
420
+ const entries = journal.split('\n').filter(l => /^- \[\d{4}-/.test(l));
421
+ return entries.slice(-count).join('\n');
422
+ }
423
+
424
+ // --- Cross-project registry (Phase 3) ---
425
+ //
426
+ // Registry lines look like: <abs-path> | <sha256-12> | <first-seen-iso>
427
+ // Returns [{path, hash, iso}]. Skips malformed lines; excludes current project.
428
+ function readRegistry({ includeCurrent = false } = {}) {
429
+ const r = readMarkdownFile(REGISTRY_FILE);
430
+ if (!r.ok) return [];
431
+ const out = [];
432
+ for (const line of r.content.split('\n')) {
433
+ const parts = line.split('|').map(s => s.trim());
434
+ if (parts.length < 3) continue;
435
+ const [path, hash, iso] = parts;
436
+ if (!path || !isAbsolute(path)) continue;
437
+ if (!includeCurrent && path === PROJECT_DIR) continue;
438
+ out.push({ path, hash, iso });
439
+ }
440
+ return out;
441
+ }
442
+
443
+ // Resolve a from_project arg (path OR 12-char hash) to a registry entry.
444
+ function resolveProject(spec) {
445
+ if (!spec || typeof spec !== 'string') return null;
446
+ const all = readRegistry({ includeCurrent: true });
447
+ const trimmed = spec.trim();
448
+ // Try absolute path first, then hash, then basename suffix match.
449
+ return all.find(e => e.path === trimmed)
450
+ || all.find(e => e.hash === trimmed)
451
+ || all.find(e => basename(e.path) === trimmed)
452
+ || null;
453
+ }
454
+
455
+ // Read this-project-shape memory for an arbitrary project root. Mirrors the
456
+ // sources the local search uses, but isolated to that project's directory.
457
+ function readProjectMemory(projectPath) {
458
+ const memDir = join(projectPath, '.ijfw', 'memory');
459
+ return {
460
+ knowledge: readOr(join(memDir, 'knowledge.md')),
461
+ journal: readOr(join(memDir, 'project-journal.md')),
462
+ handoff: readOr(join(memDir, 'handoff.md'))
463
+ };
464
+ }
465
+
466
+ function searchAcrossProjects(query, limit) {
467
+ const queryLower = String(query).toLowerCase();
468
+ const keywords = queryLower.split(/\s+/).filter(w => w.length > 2);
469
+ if (keywords.length === 0) return [];
470
+
471
+ const results = [];
472
+ for (const entry of readRegistry()) {
473
+ const tag = basename(entry.path);
474
+ const mem = readProjectMemory(entry.path);
475
+ for (const [src, content] of Object.entries(mem)) {
476
+ if (!content) continue;
477
+ const lines = content.split('\n');
478
+ for (let i = 0; i < lines.length; i++) {
479
+ const line = lines[i];
480
+ if (line.trim().length === 0) continue;
481
+ const score = keywords.filter(k => line.toLowerCase().includes(k)).length;
482
+ if (score > 0) {
483
+ results.push({
484
+ source: `${src}@${tag}`,
485
+ line: i + 1,
486
+ content: `[project:${tag}] ${line.trim().substring(0, 200)}`,
487
+ score
488
+ });
489
+ }
490
+ }
491
+ }
492
+ }
493
+ results.sort((a, b) => b.score - a.score);
494
+ return results.slice(0, limit);
495
+ }
496
+
497
+ // --- Search ---
498
+ // P5.1 / H4 -- BM25 ranking over line-level docs. Source tags and line
499
+ // numbers preserved so callers get the same output shape; scoring is
500
+ // BM25 (IDF + TF + length-normalized) with per-source boost. Team tier
501
+ // ranks first via a score bump for ties.
502
+ function searchMemory(query, limit = 10, scope = 'project') {
503
+ limit = Math.min(Math.max(1, limit | 0), MAX_SEARCH_RESULTS);
504
+ if (scope === 'all') return searchAcrossProjects(query, limit);
505
+
506
+ const sources = [
507
+ { name: 'team', content: readTeamKnowledge(), boost: 1.25 },
508
+ { name: 'knowledge', content: readKnowledgeBase(), boost: 1.15 },
509
+ { name: 'journal', content: readOr(join(MEMORY_DIR, 'project-journal.md')), boost: 1.0 },
510
+ { name: 'handoff', content: readHandoff(), boost: 1.1 },
511
+ { name: 'global', content: readGlobalKnowledge(), boost: 0.95 },
512
+ { name: 'claude-native', content: readNativeClaudeMemory(), boost: 0.95 },
513
+ ];
514
+
515
+ const docs = [];
516
+ const meta = new Map();
517
+ for (const src of sources) {
518
+ if (!src.content) continue;
519
+ const lines = src.content.split('\n');
520
+ for (let i = 0; i < lines.length; i++) {
521
+ const line = lines[i];
522
+ if (line.trim().length === 0) continue;
523
+ const id = `${src.name}:${i + 1}`;
524
+ docs.push({ id, text: line });
525
+ meta.set(id, { source: src.name, line: i + 1, boost: src.boost });
526
+ }
527
+ }
528
+ if (docs.length === 0) return [];
529
+
530
+ const ranked = searchCorpus(query, docs, { limit: limit * 3 });
531
+ if (ranked.length === 0) return [];
532
+
533
+ const boosted = ranked.map(r => {
534
+ const m = meta.get(r.id);
535
+ return {
536
+ source: m.source,
537
+ line: m.line,
538
+ content: (r.snippet || '').substring(0, 200),
539
+ score: r.score * (m.boost || 1),
540
+ };
541
+ });
542
+ boosted.sort((a, b) => b.score - a.score);
543
+ return boosted.slice(0, limit);
544
+ }
545
+
546
+ // --- DESIGN picker (1.2.0 Phase 5) ---
547
+ // MCP-only delivery of the 12-template design catalog for OpenCode / Qwen
548
+ // Code / Kimi Code / OpenClaw / Aider. No new tool -- served via existing
549
+ // ijfw_memory_recall using context_hint colon-syntax:
550
+ // 'design_template' -> catalog of 12 names + descriptions
551
+ // 'design_template:<name>' -> full template body
552
+ const DESIGN_TEMPLATES_DIR = join(__pkg_dirname, '..', 'templates', 'design');
553
+ const DESIGN_TEMPLATE_NAME_RE = /^[a-z][a-z0-9-]{0,40}$/;
554
+ const DESIGN_TEMPLATE_CATALOG = [
555
+ ['bento-grid', 'Modular card grid with varied sizes; Apple/Notion-style product pages.'],
556
+ ['brutalist-luxe', 'Raw concrete textures + luxury editorial restraint; fashion and architecture brands.'],
557
+ ['cinematic-dark', 'Film-grade dark UI with dramatic contrast; streaming, media, portfolio.'],
558
+ ['data-dense-dashboard', 'Monitoring/BI layout optimized for information density.'],
559
+ ['editorial-warm', 'Magazine feel in warm off-white; newsletters, blogs, long-form content.'],
560
+ ['glassmorphic', 'Frosted translucency with soft blur; creative SaaS and fintech premium.'],
561
+ ['magazine-editorial', 'Print-magazine hierarchy with bold display type; publishing, agency work.'],
562
+ ['maximalist-vibrant', 'Saturated palettes, bold pattern, high energy; consumer lifestyle brands.'],
563
+ ['neo-swiss-tech', 'Updated Swiss style with accent color and modern sans; dev tools, SaaS.'],
564
+ ['swiss-minimal', 'Classical Swiss typographic school; developer-facing and documentation sites.'],
565
+ ['terminal-native', 'Monospaced terminal aesthetic; CLIs, infra tools, hacker-brand products.'],
566
+ ['warm-organic', 'Soft curves and earthy tones; wellness, sustainable brands, lifestyle.'],
567
+ ];
568
+
569
+ function designCatalogText() {
570
+ const lines = DESIGN_TEMPLATE_CATALOG.map(([n, d]) => `- ${n} -- ${d}`);
571
+ lines.push('');
572
+ lines.push('Pick one with: ijfw_memory_recall({context_hint: "design_template:<name>"}).');
573
+ return `# DESIGN templates (12)\n${lines.join('\n')}`;
574
+ }
575
+
576
+ function handleDesignTemplate(hint) {
577
+ // Catalog mode: bare 'design_template'.
578
+ if (hint === 'design_template') {
579
+ return { text: designCatalogText() };
580
+ }
581
+ // Body mode: 'design_template:<name>'.
582
+ const name = hint.slice('design_template:'.length);
583
+ const catalogNames = DESIGN_TEMPLATE_CATALOG.map(([n]) => n).join(', ');
584
+ if (!DESIGN_TEMPLATE_NAME_RE.test(name)) {
585
+ return { text: `Unknown template: ${name}. Catalog: ${catalogNames}`, isError: true };
586
+ }
587
+ const file = join(DESIGN_TEMPLATES_DIR, `${name}.md`);
588
+ // Defence-in-depth: realpath both sides so a symlink inside templates/design/
589
+ // can't escape the directory. Regex already blocks raw `..`, NUL, URL-encoded
590
+ // traversal; this closes the symlink hole caught by codex Round-4 audit.
591
+ try {
592
+ const baseReal = realpathSync.native(DESIGN_TEMPLATES_DIR);
593
+ const resolvedReal = realpathSync.native(file);
594
+ const expected = join(baseReal, `${name}.md`);
595
+ if (resolvedReal !== expected) {
596
+ return { text: `Unknown template: ${name}. Catalog: ${catalogNames}`, isError: true };
597
+ }
598
+ const body = readFileSync(resolvedReal, 'utf8');
599
+ return { text: body };
600
+ } catch {
601
+ return { text: `Unknown template: ${name}. Catalog: ${catalogNames}`, isError: true };
602
+ }
603
+ }
604
+
605
+ // --- MCP Tool Definitions ---
606
+ const TOOLS = [
607
+ {
608
+ name: 'ijfw_memory_recall',
609
+ description: 'Wake up with project context intact -- past decisions, handoff state, and knowledge base in one call. Use at session start or when you need to remember why something was built a certain way. Pass from_project to pull from a different IJFW project by basename (simplest), 12-char hash, or absolute path. Also accepts context_hint "design_template" (12-template catalog) or "design_template:<name>" (full template body) for the DESIGN picker.',
610
+ inputSchema: {
611
+ type: 'object',
612
+ properties: {
613
+ context_hint: {
614
+ type: 'string',
615
+ description: 'What context is needed: "session_start" for wake-up injection, "handoff" for last session state, "decisions" for recent decisions, "design_template" for the 12-template DESIGN picker catalog, "design_template:<name>" for a specific template body, or a natural language query.'
616
+ },
617
+ detail_level: {
618
+ type: 'string',
619
+ enum: ['summary', 'standard', 'full'],
620
+ description: 'Level of detail. Summary: ~200 tokens. Standard: recent context. Full: everything.'
621
+ },
622
+ from_project: {
623
+ type: 'string',
624
+ description: 'Optional. Pull from a different IJFW project by absolute path, 12-char hash, or basename. Project must exist in the registry (~/.ijfw/registry.md).'
625
+ }
626
+ },
627
+ required: ['context_hint']
628
+ }
629
+ },
630
+ {
631
+ name: 'ijfw_memory_store',
632
+ description: 'Persist a decision, observation, or session state so it survives context resets. For decisions and patterns, add summary/why/how_to_apply for a richer knowledge-base entry. Returns isError on storage failure.',
633
+ inputSchema: {
634
+ type: 'object',
635
+ properties: {
636
+ content: { type: 'string', description: 'Full statement of what to remember. Max 5000 chars. Sanitised on storage.' },
637
+ type: { type: 'string', enum: VALID_MEMORY_TYPES, description: 'Memory tier: decision or pattern -> knowledge base (frontmatter). handoff -> overwrites handoff.md. preference -> project-namespaced global. observation -> journal only.' },
638
+ summary: { type: 'string', description: 'Optional 1-line summary (≤80 chars). Used as the frontmatter name for decisions/patterns.' },
639
+ why: { type: 'string', description: 'Optional rationale -- why this decision was made. Populates the Why section in the knowledge base entry.' },
640
+ how_to_apply: { type: 'string', description: 'Optional guidance -- when and how to apply this. Populates the How-to-apply section.' },
641
+ tags: { type: 'array', items: { type: 'string' }, description: 'Up to 20 tags, 50 chars each.' }
642
+ },
643
+ required: ['content', 'type']
644
+ }
645
+ },
646
+ {
647
+ name: 'ijfw_memory_search',
648
+ description: 'Keyword search across memory sources. Up to 20 results. Scope defaults to current project; pass scope:"all" to search across every IJFW project ever opened on this machine (results tagged [project:<name>]). Pass scope:"sandbox" to retrieve sandboxed ijfw_run output -- include label to get the full output of a specific run, or omit label to list all available sandbox entries.',
649
+ inputSchema: {
650
+ type: 'object',
651
+ properties: {
652
+ query: { type: 'string', description: 'Natural language search query. Not required when scope is "sandbox".' },
653
+ limit: { type: 'number', description: 'Max results (default 10, max 20).' },
654
+ scope: { type: 'string', enum: ['project', 'all', 'sandbox'], description: 'project (default) = this project only. all = every known IJFW project on this machine. sandbox = retrieve sandboxed ijfw_run output.' },
655
+ label: { type: 'string', description: 'Sandbox entry label to retrieve. Only used when scope is "sandbox". Omit to list all available entries.' }
656
+ },
657
+ required: []
658
+ }
659
+ },
660
+ {
661
+ name: 'ijfw_memory_prelude',
662
+ description: 'CALL THIS AT SESSION START. Returns all relevant project memory in one pass -- knowledge base, handoff state, recent activity. Eliminates the need to grep/search/recall separately. Call once at the start of a session before answering the user.',
663
+ inputSchema: {
664
+ type: 'object',
665
+ properties: {
666
+ detail_level: {
667
+ type: 'string',
668
+ enum: ['summary', 'standard', 'full'],
669
+ description: 'summary ≈ 200 tokens (defaults). standard ≈ 500 tokens. full = everything available.'
670
+ }
671
+ },
672
+ required: []
673
+ }
674
+ },
675
+ {
676
+ name: 'ijfw_prompt_check',
677
+ description: 'Call on the first turn when the user prompt is short (<30 tokens) or likely vague. Returns whether the prompt is under-specified and a sharpening suggestion. Deterministic regex detector -- no LLM call. Use for Codex/Cursor/Windsurf/Copilot/Gemini where pre-prompt hooks are not available.',
678
+ inputSchema: {
679
+ type: 'object',
680
+ properties: {
681
+ prompt: { type: 'string', description: 'The full user prompt text.' }
682
+ },
683
+ required: ['prompt']
684
+ }
685
+ },
686
+ {
687
+ name: 'ijfw_metrics',
688
+ description: 'See tokens/spend, model routing mix, and session totals -- the receipts behind your IJFW sessions. Aggregates from .ijfw/metrics/sessions.jsonl. Tolerates mixed v1/v2 lines.',
689
+ inputSchema: {
690
+ type: 'object',
691
+ properties: {
692
+ period: { type: 'string', enum: ['today', '7d', '30d', 'all'], description: 'Time window (default 7d).' },
693
+ metric: { type: 'string', enum: ['tokens', 'cost', 'sessions', 'routing'], description: 'Which metric to render (default tokens).' }
694
+ },
695
+ required: []
696
+ }
697
+ },
698
+ {
699
+ name: 'ijfw_cross_project_search',
700
+ description: 'BM25-ranked search across every IJFW project ever opened on this machine. Results tagged [project:<basename>] with line numbers + snippets. Use when you need to recall how a similar problem was solved in another project. Reads ~/.ijfw/registry.md as the source of truth.',
701
+ inputSchema: {
702
+ type: 'object',
703
+ properties: {
704
+ pattern: { type: 'string', description: 'Search query. Supports plain words and "quoted phrases". Use BM25 relevance ranking.' },
705
+ limit: { type: 'number', description: 'Max results (default 10, max 50).' }
706
+ },
707
+ required: ['pattern']
708
+ }
709
+ },
710
+ UPDATE_CHECK_TOOL,
711
+ UPDATE_APPLY_TOOL,
712
+ {
713
+ name: 'ijfw_run',
714
+ description: 'Run a shell command. For commands likely to produce large output (builds, test suites, grep -r, log tails), use this instead of Bash -- full output is sandboxed to disk and a smart summary is returned to context. For git/nav/quick ops, use Bash directly.',
715
+ inputSchema: {
716
+ type: 'object',
717
+ properties: {
718
+ command: { type: 'string', description: 'Shell command to run' },
719
+ label: { type: 'string', description: 'Optional label for sandbox retrieval. Auto-generated if omitted.' },
720
+ cwd: { type: 'string', description: 'Working directory. Defaults to process.cwd().' },
721
+ },
722
+ required: ['command'],
723
+ },
724
+ }
725
+ ];
726
+
727
+ // --- Tool Handlers ---
728
+
729
+ function handleRecall({ context_hint, detail_level = 'standard', from_project }) {
730
+ // Cross-project explicit pull. We bypass current-project sources and read
731
+ // the target project's knowledge/handoff/journal directly. Search queries
732
+ // are routed through searchAcrossProjects via scope:'all' on the search tool;
733
+ // recall here is for "give me everything from X."
734
+ if (from_project) {
735
+ const target = resolveProject(from_project);
736
+ if (!target) {
737
+ return { text: `No registered IJFW project matches: ${from_project}`, isError: true };
738
+ }
739
+ const mem = readProjectMemory(target.path);
740
+ const tag = basename(target.path);
741
+ const out = [];
742
+ if (mem.knowledge) out.push(`## Knowledge [${tag}]\n${mem.knowledge}`);
743
+ if (mem.handoff) out.push(`## Handoff [${tag}]\n${mem.handoff}`);
744
+ if (mem.journal && (context_hint === 'decisions' || detail_level === 'full')) {
745
+ out.push(`## Journal [${tag}]\n${mem.journal}`);
746
+ }
747
+ return { text: out.join('\n\n') || `No memory found in project: ${tag}` };
748
+ }
749
+
750
+ // 1.2.0 Phase 5: DESIGN picker -- MCP-only delivery of the 12-template
751
+ // catalog to platforms without a skills tree (OpenCode/Qwen/Kimi/OpenClaw)
752
+ // or with MCP-less rules (Aider + CONVENTIONS.md).
753
+ if (typeof context_hint === 'string'
754
+ && (context_hint === 'design_template' || context_hint.startsWith('design_template:'))) {
755
+ return handleDesignTemplate(context_hint);
756
+ }
757
+
758
+ const parts = [];
759
+
760
+ if (context_hint === 'session_start' || detail_level === 'summary') {
761
+ const knowledge = readKnowledgeBase();
762
+ const handoff = readHandoff();
763
+ const global = readGlobalKnowledge();
764
+
765
+ if (knowledge) parts.push(`## Knowledge\n${knowledge.split('\n').slice(0, 20).join('\n')}`);
766
+ if (handoff) parts.push(`## Last Session\n${handoff.split('\n').slice(0, 15).join('\n')}`);
767
+ if (global) parts.push(`## Preferences\n${global.split('\n').slice(0, 10).join('\n')}`);
768
+
769
+ return { text: parts.join('\n\n') || 'First session on this project. No memory stored yet.' };
770
+ }
771
+
772
+ if (context_hint === 'handoff') {
773
+ return { text: readHandoff() || 'No handoff from previous session.' };
774
+ }
775
+
776
+ if (context_hint === 'decisions') {
777
+ return { text: getRecentJournalEntries(10) || 'No decisions recorded yet.' };
778
+ }
779
+
780
+ const results = searchMemory(context_hint);
781
+ if (results.length === 0) return { text: `No memories matching: ${context_hint}` };
782
+ return { text: results.map(r => `[${r.source}] ${r.content}`).join('\n') };
783
+ }
784
+
785
+ function handleStore({ content, type, tags = [], summary, why, how_to_apply }) {
786
+ // --- Input Validation ---
787
+ if (!content || typeof content !== 'string') {
788
+ return { text: 'content is required and must be a string.', isError: true };
789
+ }
790
+ if (content.length > MAX_STORE_LENGTH) {
791
+ return { text: `content exceeds ${MAX_STORE_LENGTH} character limit (got ${content.length}). Summarize and retry.`, isError: true };
792
+ }
793
+ if (!VALID_MEMORY_TYPES.includes(type)) {
794
+ return { text: `type must be one of: ${VALID_MEMORY_TYPES.join(', ')}`, isError: true };
795
+ }
796
+ if (!Array.isArray(tags)) tags = [];
797
+ // S2 -- tag whitelist. Rejects path-traversal / null bytes / punctuation
798
+ // in tag values that are later used as grep arguments or filenames.
799
+ tags = tags
800
+ .filter(t => typeof t === 'string')
801
+ .slice(0, MAX_TAGS)
802
+ .map(t => sanitizeContent(t).substring(0, MAX_TAG_LEN))
803
+ .map(t => t.replace(/[^a-zA-Z0-9_-]/g, ''))
804
+ .filter(t => t.length > 0);
805
+
806
+ // Enforce per-field caps before sanitize (audit S1). content is rejected
807
+ // above at the MAX_STORE_LENGTH gate so callers aren't silently truncated.
808
+ // why/how/summary are truncated rather than rejected so structured stores
809
+ // never silently drop the whole entry over one long field.
810
+ const capped = applyCaps({ summary, why, how_to_apply });
811
+ summary = capped.summary;
812
+ why = capped.why;
813
+ how_to_apply = capped.how_to_apply;
814
+
815
+ // Sanitize ALL text fields -- never store raw user/agent text in markdown
816
+ // that gets re-injected into a future LLM context.
817
+ const safeContent = sanitizeContent(content);
818
+ if (!safeContent) {
819
+ return { text: 'content was empty after sanitisation (only control/format chars).', isError: true };
820
+ }
821
+ const safeSummary = summary ? sanitizeContent(summary).substring(0, 120) : '';
822
+ const safeWhy = why ? sanitizeContent(why) : '';
823
+ const safeHow = how_to_apply ? sanitizeContent(how_to_apply) : '';
824
+
825
+ const tagStr = tags.length > 0 ? ` [${tags.join(', ')}]` : '';
826
+ const journalEntry = `**${type}**${tagStr}: ${safeSummary || safeContent.substring(0, 200)}`;
827
+
828
+ // 1. Always append to journal (one-line timeline). Hard failure → report.
829
+ const journalResult = appendToJournal(journalEntry);
830
+ if (!journalResult.ok) {
831
+ return { text: `Memory journal is not writable (${journalResult.code}) -- check .ijfw/ directory permissions and retry.`, isError: true };
832
+ }
833
+
834
+ // 2. Type-specific secondary writes. Each tracked so we report partial
835
+ // success accurately rather than lying about "stored."
836
+ const failures = [];
837
+
838
+ if (type === 'decision' || type === 'pattern') {
839
+ // Richer frontmatter block for retrieval-quality entries.
840
+ const r = appendStructuredToKnowledge({
841
+ type,
842
+ summary: safeSummary || safeContent.substring(0, 80),
843
+ content: safeContent,
844
+ why: safeWhy,
845
+ howToApply: safeHow,
846
+ tags
847
+ });
848
+ if (!r.ok) failures.push(`knowledge base (${r.code})`);
849
+ }
850
+
851
+ if (type === 'preference') {
852
+ const r = appendToGlobalPrefs(`**preference**${tagStr}: ${safeContent}`, tags);
853
+ if (!r.ok) failures.push(`global preferences (${r.code})`);
854
+ }
855
+
856
+ if (type === 'handoff') {
857
+ const handoffPath = join(MEMORY_DIR, 'handoff.md');
858
+ const prior = readMarkdownFile(handoffPath);
859
+ if (prior.ok && prior.content.trim()) {
860
+ appendToJournal(`prior-handoff-archived: ${sanitizeContent(prior.content).substring(0, 500)}`);
861
+ }
862
+ const r = atomicWrite(handoffPath, safeContent + '\n');
863
+ if (!r.ok) failures.push(`handoff (${r.code})`);
864
+ }
865
+
866
+ if (failures.length > 0) {
867
+ return {
868
+ text: `Stored ${type} to journal. Secondary writes failed: ${failures.join(', ')}`,
869
+ isError: true
870
+ };
871
+ }
872
+
873
+ return { text: `Stored ${type}${tagStr}` };
874
+ }
875
+
876
+ // Universal first-turn recall -- call once at session start to hydrate context.
877
+ // Returns a compact, structured block that agents on any platform can ingest
878
+ // without cascading into multiple exploratory tool calls.
879
+ // 1.1.6 update-nudge composer for cross-platform prelude parity.
880
+ // Reads ~/.ijfw/state.json + ~/.ijfw/cache/update-check.json. Returns the
881
+ // terse nudge line, or '' when up-to-date / re-entrancy / no cache.
882
+ function composeUpdateNudge() {
883
+ try {
884
+ const root = process.env.IJFW_HOME || join(homedir(), '.ijfw');
885
+ let state, cache;
886
+ try { state = JSON.parse(readFileSync(join(root, 'state.json'), 'utf8')); }
887
+ catch { state = {}; }
888
+ try { cache = JSON.parse(readFileSync(join(root, 'cache', 'update-check.json'), 'utf8')); }
889
+ catch { cache = {}; }
890
+ if (!cache.last_latest_seen) return '';
891
+ const installed = state.installed_version || '0.0.0';
892
+ const lastApplied = state.last_applied_version;
893
+ // Re-entrancy: if we just applied this version, don't nudge
894
+ if (lastApplied && cmpSemverPrelude(lastApplied, cache.last_latest_seen) >= 0) return '';
895
+ if (cmpSemverPrelude(installed, cache.last_latest_seen) >= 0) return '';
896
+ return `## IJFW update available\n` +
897
+ `Installed: v${installed} -- latest: v${cache.last_latest_seen}.\n` +
898
+ `Run 'ijfw update' in your TERMINAL to upgrade. ` +
899
+ `(I cannot run this for you -- the MCP path is air-gapped from code execution.)`;
900
+ } catch { return ''; }
901
+ }
902
+
903
+ function cmpSemverPrelude(a, b) {
904
+ const parse = v => {
905
+ const [main, pre] = String(v).split('-', 2);
906
+ const nums = main.split('.').map(n => parseInt(n, 10) || 0);
907
+ while (nums.length < 3) nums.push(0);
908
+ return { nums, pre: pre || null };
909
+ };
910
+ const A = parse(a); const B = parse(b);
911
+ for (let i = 0; i < 3; i++) {
912
+ if (A.nums[i] !== B.nums[i]) return A.nums[i] < B.nums[i] ? -1 : 1;
913
+ }
914
+ if (A.pre === B.pre) return 0;
915
+ if (A.pre && !B.pre) return -1;
916
+ if (!A.pre && B.pre) return 1;
917
+ return A.pre < B.pre ? -1 : 1;
918
+ }
919
+
920
+ function handlePrelude({ detail_level = 'summary' } = {}) {
921
+ const KB_LINES = detail_level === 'full' ? 200 : detail_level === 'standard' ? 80 : 40;
922
+ const HO_LINES = detail_level === 'full' ? 80 : detail_level === 'standard' ? 30 : 15;
923
+ const JN_LINES = detail_level === 'full' ? 20 : detail_level === 'standard' ? 10 : 5;
924
+
925
+ const TM_LINES = detail_level === 'full' ? 200 : detail_level === 'standard' ? 60 : 20;
926
+
927
+ const parts = ['<ijfw-memory>'];
928
+ parts.push('Project memory hydrated. Treat as background context -- no further recall needed unless the user asks something not covered here.');
929
+ parts.push('');
930
+
931
+ // 1.1.6: surface update availability for cross-platform parity.
932
+ // Claude Code shows this in statusLine; Codex/Gemini/Cursor/Windsurf/
933
+ // Copilot/Hermes/Wayland surface it here in the first-turn prelude.
934
+ // Re-entrancy: suppressed when last_applied_version >= last_latest_seen.
935
+ const updateNudge = composeUpdateNudge();
936
+ if (updateNudge) parts.push(updateNudge, '');
937
+
938
+ // 1.2.0 Phase 5: surface the DESIGN picker to platforms without a skills tree.
939
+ // Skip when the project already has a DESIGN.md (contract exists; no picker).
940
+ try {
941
+ if (!existsSync(join(PROJECT_DIR, 'DESIGN.md'))) {
942
+ const names = DESIGN_TEMPLATE_CATALOG.map(([n]) => n);
943
+ parts.push('## Design picker');
944
+ parts.push('No DESIGN.md in project. 12 curated templates available:');
945
+ parts.push(names.slice(0, 5).join(', ') + ',');
946
+ parts.push(names.slice(5, 10).join(', ') + ',');
947
+ parts.push(names.slice(10).join(', ') + '.');
948
+ parts.push('');
949
+ parts.push('Pick one: ijfw_memory_recall({context_hint: "design_template:<name>"}).');
950
+ parts.push('Full catalog with descriptions: ijfw_memory_recall({context_hint: "design_template"}).');
951
+ parts.push('');
952
+ }
953
+ } catch { /* cwd unreadable -- skip picker block */ }
954
+
955
+ // Team knowledge first -- shared decisions/patterns/stack rank above personal.
956
+ const team = readTeamKnowledge();
957
+ if (team) {
958
+ const body = team.split('\n').slice(0, TM_LINES).join('\n').trim();
959
+ if (body) parts.push('## Team knowledge', body, '');
960
+ }
961
+
962
+ const knowledge = readKnowledgeBase();
963
+ if (knowledge) {
964
+ const body = knowledge.split('\n')
965
+ .filter(l => !l.startsWith('<!-- ijfw'))
966
+ .filter(l => !/^#[^#]/.test(l))
967
+ .slice(0, KB_LINES)
968
+ .join('\n')
969
+ .trim();
970
+ if (body) parts.push('## Knowledge base', body, '');
971
+ }
972
+
973
+ // Claude Code's native auto-memory -- Claude's own skill writes here on
974
+ // "Remember X". Surfacing it via IJFW makes those memories available to
975
+ // Codex/Gemini/Cursor too, fulfilling the cross-platform promise without
976
+ // fighting Claude's native handler.
977
+ const nativeMem = readNativeClaudeMemory();
978
+ if (nativeMem) {
979
+ const body = nativeMem.split('\n').slice(0, KB_LINES).join('\n').trim();
980
+ if (body) parts.push('## Claude-native project memory', body, '');
981
+ }
982
+
983
+ const handoff = readHandoff();
984
+ if (handoff) {
985
+ const body = handoff.split('\n')
986
+ .filter(l => !l.startsWith('<!-- ijfw'))
987
+ .slice(0, HO_LINES)
988
+ .join('\n')
989
+ .trim();
990
+ if (body) parts.push('## Last session handoff', body, '');
991
+ }
992
+
993
+ const recent = getRecentJournalEntries(JN_LINES);
994
+ if (recent) parts.push('## Recent activity', recent, '');
995
+
996
+ const global = readGlobalKnowledge();
997
+ if (global) {
998
+ const body = global.split('\n').slice(0, 10).join('\n').trim();
999
+ if (body) parts.push('## Project preferences', body, '');
1000
+ }
1001
+
1002
+ parts.push('</ijfw-memory>');
1003
+
1004
+ const text = parts.join('\n');
1005
+ if (text.length < 60) {
1006
+ return { text: 'Fresh project -- no memory stored yet. Proceed normally.' };
1007
+ }
1008
+ return { text };
1009
+ }
1010
+
1011
+ function handleSearch({ query, limit = 10, scope = 'project', label }) {
1012
+ if (scope === 'sandbox') {
1013
+ if (label) {
1014
+ const content = readFromSandbox(label);
1015
+ if (content === null) return { text: `Sandbox entry not found: ${label}` };
1016
+ return { text: content };
1017
+ }
1018
+ // List available sandbox entries.
1019
+ if (!existsSync(SANDBOX_DIR)) return { text: 'No sandbox entries found.' };
1020
+ let files;
1021
+ try { files = readdirSync(SANDBOX_DIR).filter(f => f.endsWith('.json')); }
1022
+ catch { return { text: 'No sandbox entries found.' }; }
1023
+ if (files.length === 0) return { text: 'No sandbox entries found.' };
1024
+ const entries = [];
1025
+ for (const f of files) {
1026
+ try {
1027
+ const meta = JSON.parse(readFileSync(join(SANDBOX_DIR, f), 'utf8'));
1028
+ entries.push(`${meta.label} | ${meta.command} | exit=${meta.exitCode} | ${meta.lines} lines | ${meta.timestamp}`);
1029
+ } catch { /* skip malformed */ }
1030
+ }
1031
+ return { text: entries.length > 0 ? entries.join('\n') : 'No sandbox entries found.' };
1032
+ }
1033
+
1034
+ if (!query || typeof query !== 'string') {
1035
+ return { text: 'query is required and must be a string.', isError: true };
1036
+ }
1037
+ if (query.length > 500) query = query.substring(0, 500);
1038
+ if (scope !== 'project' && scope !== 'all') scope = 'project';
1039
+ const results = searchMemory(query, limit, scope);
1040
+ if (results.length === 0) {
1041
+ const where = scope === 'all' ? ' across all projects' : '';
1042
+ return { text: `No results for: "${query}"${where}` };
1043
+ }
1044
+ return { text: results.map(r => `[${r.source}:L${r.line}] ${r.content}`).join('\n') };
1045
+ }
1046
+
1047
+ // Phase 12 / Wave 12B (R1): BM25-ranked cross-project search. Distinct from
1048
+ // handleSearch(scope:'all') which is a naive keyword-count scan retained for
1049
+ // backward compat. This handler is the canonical cross-project path.
1050
+ function handleCrossProjectSearch({ pattern, limit = 10 } = {}) {
1051
+ if (!pattern || typeof pattern !== 'string') {
1052
+ return { text: 'pattern is required and must be a string.', isError: true };
1053
+ }
1054
+ if (pattern.length > 500) pattern = pattern.substring(0, 500);
1055
+ const projects = readRegistry();
1056
+ if (projects.length === 0) {
1057
+ return { text: 'No other IJFW projects on record. Open one more project to enable cross-project search.' };
1058
+ }
1059
+ const hits = crossProjectSearch(pattern, projects, readProjectMemory, { limit });
1060
+ if (hits.length === 0) {
1061
+ return { text: `No matches for "${pattern}" across ${projects.length} project${projects.length === 1 ? '' : 's'}.` };
1062
+ }
1063
+ const body = hits.map(h => `[${h.source}:L${h.line}] (score ${h.score}) ${h.snippet}`).join('\n');
1064
+ return { text: body };
1065
+ }
1066
+
1067
+ // Phase 3 #6: aggregate session metrics. Reads .ijfw/metrics/sessions.jsonl,
1068
+ // tolerates v1 lines (treats missing token/cost fields as 0), groups by day,
1069
+ // renders compact text. Positive-framed zero-state when no sessions logged yet.
1070
+ function handleMetrics({ period = '7d', metric = 'tokens' } = {}) {
1071
+ const file = join(IJFW_DIR, 'metrics', 'sessions.jsonl');
1072
+ const r = readMarkdownFile(file);
1073
+ if (!r.ok) {
1074
+ return { text: 'Ready to track -- run a session and metrics will populate here.' };
1075
+ }
1076
+
1077
+ const lines = r.content.split('\n').filter(l => l.trim());
1078
+ const rows = [];
1079
+ for (const line of lines) {
1080
+ try { rows.push(JSON.parse(line)); } catch { /* skip malformed line */ }
1081
+ }
1082
+ if (rows.length === 0) {
1083
+ return { text: 'Ready to track -- run a session and metrics will populate here.' };
1084
+ }
1085
+
1086
+ // Window filter (UTC day comparison via ISO prefix).
1087
+ const now = Date.now();
1088
+ const cutoff = period === 'today' ? now - 24 * 3600e3
1089
+ : period === '7d' ? now - 7 * 24 * 3600e3
1090
+ : period === '30d' ? now - 30 * 24 * 3600e3
1091
+ : 0;
1092
+ const within = rows.filter(row => {
1093
+ if (!row.timestamp) return false;
1094
+ const t = Date.parse(row.timestamp);
1095
+ return Number.isFinite(t) && t >= cutoff;
1096
+ });
1097
+ if (within.length === 0) {
1098
+ return { text: `Window ${period}: no sessions yet. Earlier history available -- try period: 'all'.` };
1099
+ }
1100
+
1101
+ if (metric === 'sessions') {
1102
+ const handoffs = within.filter(r => r.handoff).length;
1103
+ const memEntries = within.reduce((s, r) => s + (r.memory_stores || 0), 0);
1104
+ return { text: [
1105
+ `Sessions in ${period}: ${within.length}`,
1106
+ `Handoffs preserved: ${handoffs} (${Math.round(100 * handoffs / within.length)}%)`,
1107
+ `Memory entries logged: ${memEntries}`
1108
+ ].join('\n') };
1109
+ }
1110
+
1111
+ if (metric === 'routing') {
1112
+ const counts = {};
1113
+ for (const r of within) counts[r.routing || 'native'] = (counts[r.routing || 'native'] || 0) + 1;
1114
+ return { text: ['Routing mix:'].concat(
1115
+ Object.entries(counts).map(([k, v]) => ` ${k}: ${v}`)
1116
+ ).join('\n') };
1117
+ }
1118
+
1119
+ // Group by UTC day for tokens / cost.
1120
+ const byDay = {};
1121
+ for (const row of within) {
1122
+ const day = String(row.timestamp).slice(0, 10);
1123
+ byDay[day] = byDay[day] || { in: 0, out: 0, cr: 0, cc: 0, cost: 0, n: 0 };
1124
+ byDay[day].in += row.input_tokens || 0;
1125
+ byDay[day].out += row.output_tokens || 0;
1126
+ byDay[day].cr += row.cache_read_tokens || 0;
1127
+ byDay[day].cc += row.cache_creation_tokens || 0;
1128
+ byDay[day].cost += row.cost_usd || 0;
1129
+ byDay[day].n += 1;
1130
+ }
1131
+
1132
+ const days = Object.keys(byDay).sort();
1133
+ if (metric === 'cost') {
1134
+ const total = days.reduce((s, d) => s + byDay[d].cost, 0);
1135
+ const lines = ['Day | sessions | cost (USD)'];
1136
+ for (const d of days) lines.push(`${d} | ${String(byDay[d].n).padStart(8)} | $${byDay[d].cost.toFixed(4)}`);
1137
+ lines.push(`Total: $${total.toFixed(4)} across ${within.length} session(s) -- clean session-ends only.`);
1138
+ return { text: lines.join('\n') };
1139
+ }
1140
+
1141
+ // tokens (default)
1142
+ const totals = days.reduce((acc, d) => {
1143
+ acc.in += byDay[d].in; acc.out += byDay[d].out; acc.cr += byDay[d].cr; acc.cc += byDay[d].cc;
1144
+ return acc;
1145
+ }, { in: 0, out: 0, cr: 0, cc: 0 });
1146
+ const out = ['Day | sessions | input | output | cache-read'];
1147
+ for (const d of days) {
1148
+ const r = byDay[d];
1149
+ out.push(`${d} | ${String(r.n).padStart(8)} | ${r.in.toLocaleString().padStart(7)} | ${r.out.toLocaleString().padStart(7)} | ${r.cr.toLocaleString().padStart(10)}`);
1150
+ }
1151
+ out.push(`Total: ${(totals.in + totals.out).toLocaleString()} tokens (${totals.in.toLocaleString()} in / ${totals.out.toLocaleString()} out / ${totals.cr.toLocaleString()} cache-read).`);
1152
+ return { text: out.join('\n') };
1153
+ }
1154
+
1155
+ function handleStatus() {
1156
+ const sessionCount = getSessionCount();
1157
+ const decisionCount = getDecisionCount();
1158
+ const hasKnowledge = existsSync(join(MEMORY_DIR, 'knowledge.md'));
1159
+ const hasHandoff = existsSync(join(MEMORY_DIR, 'handoff.md'));
1160
+ const hasGlobal = readGlobalKnowledge().trim().length > 0;
1161
+
1162
+ const parts = [];
1163
+ if (hasKnowledge) {
1164
+ const kb = readKnowledgeBase();
1165
+ const kbLines = kb.split('\n').filter(l => l.trim().startsWith('**')).length;
1166
+ parts.push(`Knowledge: ${kbLines} entries`);
1167
+ }
1168
+ if (sessionCount > 0 || decisionCount > 0) {
1169
+ parts.push(`History: ${sessionCount} sessions, ${decisionCount} decisions`);
1170
+ }
1171
+ if (hasHandoff) {
1172
+ const handoff = readHandoff();
1173
+ const statusLine = handoff.split('\n').find(l => l.trim().length > 0 && !l.startsWith('<!--') && !l.startsWith('#'));
1174
+ if (statusLine) parts.push(`Last: ${statusLine.trim().substring(0, 150)}`);
1175
+ }
1176
+ if (hasGlobal) parts.push('Project preferences loaded');
1177
+
1178
+ return { text: parts.join('\n') || 'Fresh project -- no memory yet.' };
1179
+ }
1180
+
1181
+ // --- MCP Protocol Handler (JSON-RPC 2.0 over stdio) ---
1182
+
1183
+ function createResponse(id, result) {
1184
+ return JSON.stringify({ jsonrpc: '2.0', id, result });
1185
+ }
1186
+
1187
+ function createError(id, code, message) {
1188
+ return JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } });
1189
+ }
1190
+
1191
+ function handleMessage(msg) {
1192
+ const { method, params, id } = msg;
1193
+
1194
+ switch (method) {
1195
+ case 'initialize':
1196
+ return createResponse(id, {
1197
+ protocolVersion: '2024-11-05',
1198
+ capabilities: { tools: {}, resources: {}, prompts: {} },
1199
+ serverInfo: { name: 'ijfw-memory', version: PKG_VERSION, schemaVersion: SCHEMA_VERSION }
1200
+ });
1201
+
1202
+ case 'notifications/initialized':
1203
+ case 'notifications/cancelled':
1204
+ return null;
1205
+
1206
+ case 'tools/list':
1207
+ return createResponse(id, { tools: TOOLS });
1208
+
1209
+ case 'tools/call': {
1210
+ const { name, arguments: args } = params || {};
1211
+ // ijfw_run is async; wrap the entire case in a Promise so the caller
1212
+ // can await it. All other cases resolve synchronously via Promise.resolve().
1213
+ return (async () => {
1214
+ let result;
1215
+ try {
1216
+ switch (name) {
1217
+ case 'ijfw_update_check': {
1218
+ const r = await ijfwUpdateCheck(args || {});
1219
+ result = { text: JSON.stringify(r, null, 2), isError: !!(r && r.error) };
1220
+ break;
1221
+ }
1222
+ case 'ijfw_update_apply': {
1223
+ const r = ijfwUpdateApply(args || {});
1224
+ result = { text: JSON.stringify(r, null, 2), isError: r && r.status === 'error' };
1225
+ break;
1226
+ }
1227
+ case 'ijfw_memory_recall':
1228
+ result = handleRecall(args || {});
1229
+ emitRecallObservation(args || {});
1230
+ break;
1231
+ case 'ijfw_memory_store':
1232
+ result = handleStore(args || {});
1233
+ break;
1234
+ case 'ijfw_memory_search': {
1235
+ // W1B (1.3.0-alpha): colon-syntax dispatch. compute:<query> hits
1236
+ // the per-project FTS5 db; anything else falls through to the
1237
+ // legacy keyword search.
1238
+ const searchArgs = args || {};
1239
+ const parsedQuery = typeof searchArgs.query === 'string'
1240
+ ? parseColonCommand(searchArgs.query)
1241
+ : null;
1242
+ if (parsedQuery && (parsedQuery.namespace === 'compute' || parsedQuery.namespace === 'graph')) {
1243
+ const dispatched = await dispatchSearch(parsedQuery, {
1244
+ projectRoot: searchArgs.projectRoot,
1245
+ limit: searchArgs.limit,
1246
+ });
1247
+ if (dispatched !== null) {
1248
+ result = {
1249
+ text: JSON.stringify(dispatched, null, 2),
1250
+ isError: dispatched.ok === false,
1251
+ };
1252
+ break;
1253
+ }
1254
+ }
1255
+ result = handleSearch(searchArgs);
1256
+ break;
1257
+ }
1258
+ case 'ijfw_memory_status':
1259
+ result = handleStatus();
1260
+ break;
1261
+ case 'ijfw_memory_prelude':
1262
+ result = handlePrelude(args || {});
1263
+ break;
1264
+ case 'ijfw_metrics':
1265
+ result = handleMetrics(args || {});
1266
+ break;
1267
+ case 'ijfw_cross_project_search':
1268
+ result = handleCrossProjectSearch(args || {});
1269
+ break;
1270
+ case 'ijfw_prompt_check': {
1271
+ const pc = checkPrompt((args && args.prompt) || '');
1272
+ const text = pc.vague
1273
+ ? `vague: yes\nsignals: ${pc.signals.join(', ')}\nsuggestion: ${pc.suggestion}`
1274
+ : `vague: no${pc.bypass_reason ? ` (bypass: ${pc.bypass_reason})` : pc.signals.length ? ` (signals: ${pc.signals.join(', ')} -- below threshold)` : ''}`;
1275
+ result = { text };
1276
+ break;
1277
+ }
1278
+ case 'ijfw_run': {
1279
+ purgeSandboxOld();
1280
+ const { command, label: userLabel, cwd } = args || {};
1281
+ if (!command || typeof command !== 'string') {
1282
+ result = { text: 'command is required and must be a string.', isError: true };
1283
+ break;
1284
+ }
1285
+ // W1B (1.3.0-alpha): colon-syntax dispatch. compute:python /
1286
+ // compute:js / index:<source> / detect:project_type ride this
1287
+ // tool surface so we don't grow tool count past 10.
1288
+ // L2: trust dispatcher's null contract -- the redundant
1289
+ // namespace tuple here would drift if dispatch adds new ones.
1290
+ // dispatchRun() returns null for unrecognized namespaces and a
1291
+ // result object for owned ones.
1292
+ const parsedRun = parseColonCommand(command);
1293
+ if (parsedRun) {
1294
+ const dispatched = await dispatchRun(parsedRun, {
1295
+ projectRoot: cwd,
1296
+ sessionId: process.env.IJFW_SESSION_ID,
1297
+ });
1298
+ if (dispatched !== null) {
1299
+ result = {
1300
+ text: JSON.stringify(dispatched, null, 2),
1301
+ isError: dispatched.ok === false,
1302
+ };
1303
+ break;
1304
+ }
1305
+ }
1306
+ const runResult = await runCommand(command, { cwd });
1307
+ const { stdout, exitCode, durationMs, lines, bytes, timedOut } = runResult;
1308
+
1309
+ const INLINE_LINES = 40;
1310
+ const INLINE_BYTES = 50 * 1024;
1311
+
1312
+ if (lines <= INLINE_LINES && bytes <= INLINE_BYTES && !timedOut) {
1313
+ result = { text: stdout || '(no output)', isError: exitCode !== 0 };
1314
+ break;
1315
+ }
1316
+
1317
+ const stripped = stripAnsi(stdout);
1318
+ const domain = detectDomain(stripped);
1319
+ const label = userLabel || `run-${Date.now()}`;
1320
+ const summary = summarize(stripped, domain, command, exitCode, durationMs);
1321
+ writeToSandbox(label, command, stripped, { exitCode, lines, bytes });
1322
+
1323
+ result = {
1324
+ text: summary + '\n\nFull output sandboxed. Retrieve: ijfw_memory_search({ scope: "sandbox", label: "' + label + '" })',
1325
+ isError: exitCode !== 0,
1326
+ };
1327
+ break;
1328
+ }
1329
+ default:
1330
+ return createError(id, -32601, `Unknown tool: ${name}`);
1331
+ }
1332
+
1333
+ // Handlers now return {text, isError?}. Forward both to the MCP client
1334
+ // so failures aren't silently labelled as success.
1335
+ return createResponse(id, {
1336
+ content: [{ type: 'text', text: String(result.text) }],
1337
+ isError: result.isError === true
1338
+ });
1339
+ } catch (err) {
1340
+ return createResponse(id, {
1341
+ content: [{ type: 'text', text: `Internal error: ${err.message}` }],
1342
+ isError: true
1343
+ });
1344
+ }
1345
+ })(); // end async IIFE for tools/call
1346
+ }
1347
+
1348
+ case 'resources/list':
1349
+ return createResponse(id, { resources: [] });
1350
+ case 'resources/read':
1351
+ return createError(id, -32601, 'No resources available');
1352
+ case 'resources/templates/list':
1353
+ return createResponse(id, { resourceTemplates: [] });
1354
+ case 'prompts/list':
1355
+ return createResponse(id, { prompts: [] });
1356
+ case 'prompts/get':
1357
+ return createError(id, -32601, 'No prompts available');
1358
+ case 'ping':
1359
+ return createResponse(id, {});
1360
+
1361
+ default:
1362
+ if (id) return createError(id, -32601, `Method not found: ${method}`);
1363
+ return null;
1364
+ }
1365
+ }
1366
+
1367
+ // --- stdio Transport ---
1368
+ const rl = createInterface({ input: process.stdin, terminal: false });
1369
+
1370
+ rl.on('line', (line) => {
1371
+ if (!line.trim()) return;
1372
+ let msg;
1373
+ try {
1374
+ msg = JSON.parse(line);
1375
+ } catch {
1376
+ process.stdout.write(JSON.stringify({
1377
+ jsonrpc: '2.0', id: null,
1378
+ error: { code: -32700, message: 'Parse error' }
1379
+ }) + '\n');
1380
+ return;
1381
+ }
1382
+ try {
1383
+ const response = handleMessage(msg);
1384
+ if (response && typeof response.then === 'function') {
1385
+ response.then(r => { if (r) process.stdout.write(r + '\n'); }).catch(err => {
1386
+ process.stdout.write(JSON.stringify({
1387
+ jsonrpc: '2.0',
1388
+ id: msg && msg.id ? msg.id : null,
1389
+ error: { code: -32603, message: `Internal error: ${err.message}` }
1390
+ }) + '\n');
1391
+ });
1392
+ } else if (response) {
1393
+ process.stdout.write(response + '\n');
1394
+ }
1395
+ } catch (err) {
1396
+ process.stdout.write(JSON.stringify({
1397
+ jsonrpc: '2.0',
1398
+ id: msg && msg.id ? msg.id : null,
1399
+ error: { code: -32603, message: `Internal error: ${err.message}` }
1400
+ }) + '\n');
1401
+ }
1402
+ });
1403
+
1404
+ process.on('SIGINT', () => process.exit(0));
1405
+ process.on('SIGTERM', () => process.exit(0));
1406
+ process.on('uncaughtException', (err) => {
1407
+ process.stderr.write(`IJFW: uncaught: ${err.stack || err.message}\n`);
1408
+ });
1409
+ process.on('unhandledRejection', (err) => {
1410
+ process.stderr.write(`IJFW: unhandled rejection: ${err}\n`);
1411
+ });
1412
+
1413
+ // Export for tests (Node ESM allows this -- only consumed when imported, not on stdio run)
1414
+ export { sanitizeContent, atomicWrite, readMarkdownFile, PROJECT_HASH };