@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/bin/ijfw ADDED
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env bash
2
+ # IJFW CLI launcher -- cross-audit/research/critique across AI families.
3
+ # Resolves script location through symlinks (POSIX-portable).
4
+
5
+ SOURCE="${BASH_SOURCE[0]:-$0}"
6
+ while [ -L "$SOURCE" ]; do
7
+ DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
8
+ SOURCE="$(readlink "$SOURCE")"
9
+ case "$SOURCE" in /*) ;; *) SOURCE="$DIR/$SOURCE" ;; esac
10
+ done
11
+ SCRIPT_DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
12
+ CLI="$SCRIPT_DIR/../src/cross-orchestrator-cli.js"
13
+
14
+ if [ ! -f "$CLI" ]; then
15
+ echo "IJFW launcher expects CLI at $CLI -- re-run the installer to restore it." >&2
16
+ exit 1
17
+ fi
18
+
19
+ if ! command -v node >/dev/null 2>&1; then
20
+ echo "IJFW needs Node 18+ -- install from https://nodejs.org then retry." >&2
21
+ exit 1
22
+ fi
23
+
24
+ # Suppress only the ExperimentalWarning from node:sqlite -- keep all other
25
+ # warnings visible (errors, deprecations, etc.). node:sqlite is marked
26
+ # experimental through Node 22.x but is stable enough for read-only use.
27
+ exec node --no-warnings=ExperimentalWarning "$CLI" "$@"
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ijfw-dashboard -- PID-managed launcher for dashboard-server.js
4
+ * Usage: ijfw-dashboard start|stop|status [--port N] [--no-open]
5
+ */
6
+
7
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, rmSync } from 'node:fs';
8
+ import { join, dirname } from 'node:path';
9
+ import { homedir } from 'node:os';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { spawn, spawnSync } from 'node:child_process';
12
+ import http from 'node:http';
13
+
14
+ const __dirname = dirname(fileURLToPath(import.meta.url));
15
+ const SERVER_JS = join(__dirname, '..', 'src', 'dashboard-server.js');
16
+ const IJFW_DIR = join(homedir(), '.ijfw');
17
+ const PID_FILE = join(IJFW_DIR, 'dashboard.pid');
18
+ const PORT_FILE = join(IJFW_DIR, 'dashboard.port');
19
+ const DEFAULT_PORT = 37891;
20
+
21
+ function ensureDir() { mkdirSync(IJFW_DIR, { recursive: true }); }
22
+
23
+ function readPid() {
24
+ try { return parseInt(readFileSync(PID_FILE, 'utf8').trim(), 10); } catch { return null; }
25
+ }
26
+
27
+ function readPort() {
28
+ try { return parseInt(readFileSync(PORT_FILE, 'utf8').trim(), 10); } catch { return DEFAULT_PORT; }
29
+ }
30
+
31
+ function isAlive(pid) {
32
+ if (!pid) return false;
33
+ try { process.kill(pid, 0); return true; } catch { return false; }
34
+ }
35
+
36
+ function removePidFiles() {
37
+ try { unlinkSync(PID_FILE); } catch {}
38
+ try { unlinkSync(PORT_FILE); } catch {}
39
+ }
40
+
41
+ function openBrowser(url) {
42
+ if (process.env.CI || process.env.NO_OPEN) return;
43
+ const cmd = process.platform === 'darwin' ? 'open'
44
+ : process.platform === 'win32' ? 'start'
45
+ : 'xdg-open';
46
+ try { spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref(); } catch {}
47
+ }
48
+
49
+ // Probe a URL for HTTP 200, retrying on connection-refused until timeoutMs elapses.
50
+ // Port file appears slightly before the HTTP listener is accepting; we retry to ride that race.
51
+ function probeHttp(url, timeoutMs, cb) {
52
+ const start = Date.now();
53
+ let done = false;
54
+ const finish = (ok) => { if (!done) { done = true; cb(ok); } };
55
+
56
+ const attempt = () => {
57
+ if (done) return;
58
+ const elapsed = Date.now() - start;
59
+ if (elapsed >= timeoutMs) return finish(false);
60
+ try {
61
+ const req = http.get(url, (res) => {
62
+ finish(res.statusCode === 200);
63
+ res.resume();
64
+ });
65
+ req.setTimeout(Math.min(500, timeoutMs - elapsed), () => req.destroy(new Error('probe-timeout')));
66
+ req.on('error', () => {
67
+ // Connection refused / reset = server still binding; retry after 100ms.
68
+ if (!done) setTimeout(attempt, 100);
69
+ });
70
+ } catch {
71
+ if (!done) setTimeout(attempt, 100);
72
+ }
73
+ };
74
+
75
+ attempt();
76
+ }
77
+
78
+ const argv = process.argv.slice(2);
79
+ const sub = argv[0] || 'status';
80
+ const noOpen = argv.includes('--no-open');
81
+
82
+ if (sub === 'start') {
83
+ ensureDir();
84
+ const existingPid = readPid();
85
+ if (existingPid && isAlive(existingPid)) {
86
+ const port = readPort();
87
+ console.log('Dashboard already running at http://localhost:' + port + ' (PID ' + existingPid + ')');
88
+ process.exit(0);
89
+ }
90
+ // Stale PID -- reclaim
91
+ removePidFiles();
92
+
93
+ // Remove stale port file before spawn so polling loop can't pick up a leftover from a dead session.
94
+ try { rmSync(PORT_FILE, { force: true }); } catch {}
95
+
96
+ // Spawn detached server; it writes its own PID + port files.
97
+ const child = spawn(process.execPath, [SERVER_JS, '--daemon'], {
98
+ detached: true,
99
+ stdio: ['ignore', 'ignore', 'ignore'],
100
+ env: { ...process.env, IJFW_PID_FILE: PID_FILE, IJFW_PORT_FILE: PORT_FILE },
101
+ });
102
+ child.unref();
103
+
104
+ // Poll for port file (server writes it after binding), max 2500ms / 25 polls.
105
+ // Cold Node startup or slow disks can take >500ms; 2500ms gives 5x headroom.
106
+ let waited = 0;
107
+ const interval = setInterval(() => {
108
+ waited += 100;
109
+ if (existsSync(PORT_FILE)) {
110
+ clearInterval(interval);
111
+ const port = readPort();
112
+ const url = 'http://127.0.0.1:' + port;
113
+ // Probe HTTP to confirm the server actually started (W4.1).
114
+ probeHttp(url, 2000, (ok) => {
115
+ if (ok) {
116
+ console.log('[ijfw] Dashboard running at http://127.0.0.1:' + port);
117
+ if (!noOpen) openBrowser('http://localhost:' + port);
118
+ process.exit(0);
119
+ } else {
120
+ console.error('[ijfw] Dashboard start failed -- check ~/.ijfw/logs/dashboard.log');
121
+ process.exit(1);
122
+ }
123
+ });
124
+ return;
125
+ }
126
+ if (waited >= 2500) {
127
+ clearInterval(interval);
128
+ console.error('[ijfw] Dashboard start failed -- check ~/.ijfw/logs/dashboard.log');
129
+ process.exit(1);
130
+ }
131
+ }, 100);
132
+
133
+ } else if (sub === 'stop') {
134
+ const pid = readPid();
135
+ if (!pid || !isAlive(pid)) {
136
+ removePidFiles();
137
+ console.log('Dashboard is not running.');
138
+ process.exit(0);
139
+ }
140
+ try {
141
+ process.kill(pid, 'SIGTERM');
142
+ // Wait up to 2s for clean exit, then SIGKILL.
143
+ let waited = 0;
144
+ const t = setInterval(() => {
145
+ waited += 100;
146
+ if (!isAlive(pid)) {
147
+ clearInterval(t);
148
+ removePidFiles();
149
+ console.log('Dashboard stopped.');
150
+ process.exit(0);
151
+ }
152
+ if (waited >= 2000) {
153
+ clearInterval(t);
154
+ try { process.kill(pid, 'SIGKILL'); } catch {}
155
+ removePidFiles();
156
+ console.log('Dashboard stopped (forced).');
157
+ process.exit(0);
158
+ }
159
+ }, 100);
160
+ } catch {
161
+ removePidFiles();
162
+ console.log('Dashboard stopped.');
163
+ process.exit(0);
164
+ }
165
+
166
+ } else if (sub === 'status') {
167
+ const pid = readPid();
168
+ if (pid && isAlive(pid)) {
169
+ const port = readPort();
170
+ console.log('Dashboard running at http://localhost:' + port + ' (PID ' + pid + ')');
171
+ } else {
172
+ if (pid) removePidFiles();
173
+ console.log('Dashboard is not running. Start with: ijfw dashboard start');
174
+ }
175
+ process.exit(0);
176
+
177
+ } else {
178
+ console.error('Usage: ijfw-dashboard start|stop|status [--no-open]');
179
+ process.exit(1);
180
+ }
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env bash
2
+ # ijfw-dispatch-plan -- emit dispatch manifest for a phase PLAN.md.
3
+ #
4
+ # Called by the ijfw-workflow skill at Wave 4 entry to decide shared-branch
5
+ # vs worktree-isolated parallelism per sub-wave.
6
+ #
7
+ # Usage:
8
+ # ijfw-dispatch-plan <plan.md> [all-worktree | all-shared]
9
+
10
+ SOURCE="${BASH_SOURCE[0]:-$0}"
11
+ while [ -L "$SOURCE" ]; do
12
+ DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
13
+ SOURCE="$(readlink "$SOURCE")"
14
+ case "$SOURCE" in /*) ;; *) SOURCE="$DIR/$SOURCE" ;; esac
15
+ done
16
+ SCRIPT_DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
17
+ MOD="$SCRIPT_DIR/../src/dispatch-planner.js"
18
+
19
+ if [ -z "${1-}" ]; then
20
+ echo "Usage: ijfw-dispatch-plan <plan.md> [all-worktree|all-shared]" >&2
21
+ exit 1
22
+ fi
23
+ if [ ! -f "$1" ]; then
24
+ echo "Plan file not found: $1" >&2
25
+ exit 1
26
+ fi
27
+ if ! command -v node >/dev/null 2>&1; then
28
+ echo "ijfw-dispatch-plan needs Node 18+ -- install from https://nodejs.org" >&2
29
+ exit 1
30
+ fi
31
+
32
+ exec node -e '
33
+ import("'"$MOD"'").then(async (m) => {
34
+ const { readFile } = await import("node:fs/promises");
35
+ const md = await readFile(process.argv[1], "utf8");
36
+ const override = process.argv[2] || null;
37
+ const manifest = m.buildManifest(m.parsePlan(md), { override });
38
+ console.log(m.manifestSummary(manifest));
39
+ console.log(JSON.stringify(manifest, null, 2));
40
+ }).catch(err => { console.error(err.message); process.exit(1); });
41
+ ' "$1" "${2-}"
@@ -0,0 +1,273 @@
1
+ #!/usr/bin/env node
2
+ // IJFW session-end synthesizer (W5.P5.2 / H1).
3
+ // Reads .ijfw/.session-signals.jsonl + .session-feedback.jsonl + transcript
4
+ // payload on stdin (Stop-hook json), produces structured memory entries,
5
+ // and appends them to .ijfw/memory/knowledge.md via the existing store helpers.
6
+ //
7
+ // Deterministic by default: feedback/signals promote 1:1. LLM-based synthesis
8
+ // activates only when IJFW_AUTOMEM_MODEL is set (documented as a wiring point;
9
+ // current build emits a TODO marker instead of calling an LLM).
10
+ //
11
+ // Consent: reads .ijfw/.automem-consent. If absent or consented=false, exits 0
12
+ // silently. User sets consent via `/ijfw memory consent yes|no|ask`.
13
+
14
+ import { readFileSync, writeFileSync, existsSync, appendFileSync, mkdirSync, rmdirSync, statSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { redactSecrets } from '../src/redactor.js';
17
+ import { applyCaps } from '../src/caps.js';
18
+ import { ensureSchemaHeader, SCHEMA_HEADER } from '../src/schema.js';
19
+ import { searchCorpus } from '../src/search-bm25.js';
20
+ import { sanitizeContent } from '../src/sanitizer.js';
21
+
22
+ const IJFW_DIR = '.ijfw';
23
+ const MEMORY_DIR = join(IJFW_DIR, 'memory');
24
+ const SIGNALS = join(IJFW_DIR, '.session-signals.jsonl');
25
+ const FEEDBACK = join(IJFW_DIR, '.session-feedback.jsonl');
26
+ const CONSENT = join(IJFW_DIR, '.automem-consent');
27
+ const KNOWLEDGE = join(MEMORY_DIR, 'knowledge.md');
28
+
29
+ function readConsent() {
30
+ if (!existsSync(CONSENT)) return { consented: null };
31
+ try { return JSON.parse(readFileSync(CONSENT, 'utf8')); } catch { return { consented: null }; }
32
+ }
33
+
34
+ function readJsonl(path) {
35
+ if (!existsSync(path)) return [];
36
+ try {
37
+ return readFileSync(path, 'utf8').split('\n').filter(Boolean).map(l => {
38
+ try { return JSON.parse(l); } catch { return null; }
39
+ }).filter(Boolean);
40
+ } catch { return []; }
41
+ }
42
+
43
+ function readExistingKnowledge() {
44
+ if (!existsSync(KNOWLEDGE)) return '';
45
+ try { return readFileSync(KNOWLEDGE, 'utf8'); } catch { return ''; }
46
+ }
47
+
48
+ // Y2 / R2-D -- relative BM25 dedupe. A fixed threshold doesn't generalize:
49
+ // BM25 scores scale with corpus size (IDF depends on N) and query length.
50
+ // Round-1 fix approximated the ceiling via a 1-doc self-corpus, but that
51
+ // produces a DIFFERENT IDF than the N-doc candidate corpus -- incomparable
52
+ // scores. R2-D fix: inject a synthetic self-doc INTO the existing corpus
53
+ // so both candidate and ceiling scores share the same IDF denominator.
54
+ function isDuplicate(summary, existingText) {
55
+ if (!existingText || !summary) return false;
56
+ const lines = existingText.split('\n').filter(l => l.trim().length > 0);
57
+ if (lines.length === 0) return false;
58
+ const corpusWithSelf = lines.map((line, i) => ({ id: `e${i}`, text: line }));
59
+ corpusWithSelf.push({ id: '_self', text: summary });
60
+ const results = searchCorpus(summary, corpusWithSelf, { limit: 5 });
61
+ if (results.length === 0) return false;
62
+ const selfHit = results.find(r => r.id === '_self');
63
+ const bestExisting = results.find(r => r.id !== '_self');
64
+ if (!selfHit || !bestExisting) return false;
65
+ // Duplicate if best existing hit is within 80% of the perfect-match ceiling.
66
+ return bestExisting.score >= selfHit.score * 0.8;
67
+ }
68
+
69
+ // C2 / ST5 / R2-C / R2-G -- file lock around multi-line structured appends.
70
+ // Uses mkdir-based locking (atomic on POSIX + Windows). On contention:
71
+ // 1. Check lock's mtime; if older than STALE_LOCK_MS, reclaim it (prior
72
+ // process likely crashed without releasing).
73
+ // 2. Otherwise sleep (not busy-wait) for ~50ms via Atomics.wait on a
74
+ // shared buffer -- sync sleep that doesn't burn CPU.
75
+ // 3. Retry up to ~2s deadline.
76
+ // Always writes the holder's pid to a sidecar file inside the lock dir so
77
+ // crashes are diagnosable.
78
+ const LOCK_DIR = join(MEMORY_DIR, '.knowledge.lock');
79
+ const LOCK_PID_FILE = join(LOCK_DIR, 'pid');
80
+ const STALE_LOCK_MS = 10_000;
81
+
82
+ // R2-G -- sync sleep without busy-wait. Atomics.wait blocks the thread on
83
+ // a SharedArrayBuffer-backed Int32Array. Timeout in ms.
84
+ const _sleepBuf = new Int32Array(new SharedArrayBuffer(4));
85
+ function sleepSync(ms) { Atomics.wait(_sleepBuf, 0, 0, ms); }
86
+
87
+ function tryReclaimStale() {
88
+ try {
89
+ const age = Date.now() - statSync(LOCK_DIR).mtimeMs;
90
+ if (age > STALE_LOCK_MS) {
91
+ try { rmdirSync(LOCK_DIR, { recursive: true }); } catch { /* fall through */ }
92
+ return true;
93
+ }
94
+ } catch { /* lock dir may have just been released */ }
95
+ return false;
96
+ }
97
+
98
+ function withLock(fn) {
99
+ const deadline = Date.now() + 2000;
100
+ let acquired = false;
101
+ while (!acquired && Date.now() < deadline) {
102
+ try {
103
+ mkdirSync(LOCK_DIR);
104
+ // Best-effort pid tag inside the lock for diagnostics.
105
+ try { writeFileSync(LOCK_PID_FILE, String(process.pid)); } catch { /* ignore */ }
106
+ acquired = true;
107
+ } catch {
108
+ // Contention. Consider reclaiming if stale; otherwise sleep + retry.
109
+ if (tryReclaimStale()) continue;
110
+ sleepSync(50);
111
+ }
112
+ }
113
+ if (!acquired) {
114
+ // Degrade to no-lock rather than lose the write -- record the incident.
115
+ try {
116
+ mkdirSync(IJFW_DIR, { recursive: true });
117
+ appendFileSync(
118
+ join(IJFW_DIR, '.error.log'),
119
+ `${new Date().toISOString()} ijfw-memorize: lock acquisition timed out after 2s; proceeding without lock\n`
120
+ );
121
+ } catch { /* ignore */ }
122
+ return fn();
123
+ }
124
+ try { return fn(); }
125
+ finally {
126
+ try { rmdirSync(LOCK_DIR, { recursive: true }); } catch { /* ignore */ }
127
+ }
128
+ }
129
+
130
+ function appendEntry({ type, summary, content, why, howToApply, tags }) {
131
+ mkdirSync(MEMORY_DIR, { recursive: true });
132
+ withLock(() => {
133
+ ensureSchemaHeader(KNOWLEDGE);
134
+ const ts = new Date().toISOString();
135
+ // X2 -- sanitize EVERY string that flows into the knowledge file.
136
+ // redactSecrets handles credentials; sanitizeContent defangs markdown
137
+ // structure + <system>-style tags + control chars. Both are needed.
138
+ const safeSummary = sanitizeContent(summary);
139
+ const safeContent = sanitizeContent(content);
140
+ const safeWhy = sanitizeContent(why);
141
+ const safeHowToApply = sanitizeContent(howToApply);
142
+ const safeTags = tags.map(t => sanitizeContent(String(t)).replace(/[^a-zA-Z0-9_-]/g, ''));
143
+ const tagLine = safeTags.join(', ');
144
+ const block = [
145
+ '',
146
+ '---',
147
+ `type: ${type}`,
148
+ `summary: ${safeSummary}`,
149
+ `stored: ${ts}`,
150
+ `tags: [${tagLine}]`,
151
+ '---',
152
+ safeContent,
153
+ safeWhy ? `\n**Why:** ${safeWhy}` : '',
154
+ safeHowToApply ? `\n**How to apply:** ${safeHowToApply}` : '',
155
+ ''
156
+ ].filter(l => l !== '').join('\n') + '\n';
157
+ appendFileSync(KNOWLEDGE, block);
158
+ });
159
+ }
160
+
161
+ async function main() {
162
+ if (!existsSync(IJFW_DIR)) return;
163
+
164
+ const consent = readConsent();
165
+ if (consent.consented !== true) {
166
+ // Silent no-op if consent not granted. Consent is an explicit opt-in.
167
+ return;
168
+ }
169
+
170
+ const signals = readJsonl(SIGNALS);
171
+ const feedback = readJsonl(FEEDBACK);
172
+ if (signals.length === 0 && feedback.length === 0) return;
173
+
174
+ const existing = readExistingKnowledge();
175
+ let stored = 0;
176
+ const storedSummaries = [];
177
+ // X4 -- in-run dedupe. isDuplicate only sees the pre-run snapshot, so
178
+ // identical feedback lines within one session would both promote.
179
+ // Track summaries stored in THIS run as an additional blocklist.
180
+ const thisRunSummaries = new Set();
181
+ const alreadyStored = (summary) =>
182
+ thisRunSummaries.has(summary) || isDuplicate(summary, existing);
183
+
184
+ // 1:1 deterministic promotion of feedback.
185
+ for (const f of feedback) {
186
+ const rawPhrase = redactSecrets(f.phrase || '');
187
+ const rawContext = redactSecrets(f.context || '');
188
+ if (!rawPhrase) continue;
189
+ const summary = rawPhrase.slice(0, 110);
190
+ if (alreadyStored(summary)) continue;
191
+ const capped = applyCaps({
192
+ content: rawContext || rawPhrase,
193
+ summary,
194
+ why: `User stated during session: "${rawPhrase}"`,
195
+ how_to_apply: f.kind === 'correction' ? 'Avoid repeating the corrected pattern.'
196
+ : f.kind === 'confirmation' ? 'Keep the approach that was confirmed.'
197
+ : f.kind === 'preference' ? 'Default to this preference on future similar tasks.'
198
+ : 'Apply the stated rule consistently.',
199
+ });
200
+ appendEntry({
201
+ type: f.kind === 'preference' ? 'preference' : 'pattern',
202
+ summary: capped.summary,
203
+ content: capped.content,
204
+ why: capped.why,
205
+ howToApply: capped.how_to_apply,
206
+ tags: ['auto-memorize', f.kind],
207
+ });
208
+ stored++;
209
+ storedSummaries.push(capped.summary);
210
+ thisRunSummaries.add(capped.summary);
211
+ }
212
+
213
+ // 1:1 promotion of signal occurrences -- but only if the same error
214
+ // appears >=2x in the session (suggests a real blocker worth remembering).
215
+ const errorCounts = new Map();
216
+ for (const s of signals) {
217
+ const key = redactSecrets(s.error || s.fail || '');
218
+ if (!key) continue;
219
+ errorCounts.set(key, (errorCounts.get(key) || 0) + 1);
220
+ }
221
+ for (const [err, count] of errorCounts) {
222
+ if (count < 2) continue;
223
+ const summary = `Recurring error: ${err.slice(0, 90)}`;
224
+ if (alreadyStored(summary)) continue;
225
+ const capped = applyCaps({
226
+ content: err,
227
+ summary,
228
+ why: `Hit ${count} times in one session -- likely not a fluke.`,
229
+ how_to_apply: 'Next time this error surfaces, check prior session handoffs before debugging from scratch.',
230
+ });
231
+ appendEntry({
232
+ type: 'observation',
233
+ summary: capped.summary,
234
+ content: capped.content,
235
+ why: capped.why,
236
+ howToApply: capped.how_to_apply,
237
+ tags: ['auto-memorize', 'recurring-error'],
238
+ });
239
+ stored++;
240
+ storedSummaries.push(capped.summary);
241
+ thisRunSummaries.add(capped.summary);
242
+ }
243
+
244
+ // TODO: LLM-synthesis path (IJFW_AUTOMEM_MODEL). Stub emits a marker
245
+ // so downstream tooling can detect that a richer synthesis could run.
246
+ if (process.env.IJFW_AUTOMEM_MODEL && process.env.IJFW_AUTOMEM_MODEL !== 'off') {
247
+ // Wiring point -- Phase 5+ connects this to Anthropic SDK / Ollama.
248
+ // For now, record the opportunity.
249
+ try {
250
+ mkdirSync(IJFW_DIR, { recursive: true });
251
+ appendFileSync(join(IJFW_DIR, '.automem-llm-queue.jsonl'),
252
+ JSON.stringify({ ts: new Date().toISOString(), model: process.env.IJFW_AUTOMEM_MODEL, pending: true }) + '\n');
253
+ } catch {}
254
+ }
255
+
256
+ if (stored > 0) {
257
+ process.stdout.write(`Stored ${stored} new memor${stored === 1 ? 'y' : 'ies'}: ${storedSummaries.slice(0, 3).join('; ')}${storedSummaries.length > 3 ? '…' : ''}\n`);
258
+ }
259
+ }
260
+
261
+ // R2-H -- log errors before silent exit so EACCES / EDQUOT / malformed-JSON
262
+ // failures are diagnosable. Hook caller sees exit 0 (never crashes Claude
263
+ // Code); .ijfw/.error.log is the debug trail.
264
+ main().catch((e) => {
265
+ try {
266
+ mkdirSync(IJFW_DIR, { recursive: true });
267
+ appendFileSync(
268
+ join(IJFW_DIR, '.error.log'),
269
+ `${new Date().toISOString()} ijfw-memorize: ${e && e.stack ? e.stack : (e && e.message) || String(e)}\n`
270
+ );
271
+ } catch { /* last-resort silent */ }
272
+ process.exit(0);
273
+ });
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env bash
2
+ # IJFW Memory Server launcher.
3
+ # Auto-resolves server.js relative to this script's location, so it works
4
+ # regardless of how it was invoked (npm link, absolute path, symlink, PATH).
5
+ # Avoids the ${IJFW_HOME} expansion pitfall in MCP client args arrays.
6
+
7
+ # Resolve real script dir even through symlinks (POSIX-portable).
8
+ SOURCE="${BASH_SOURCE[0]:-$0}"
9
+ while [ -L "$SOURCE" ]; do
10
+ DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
11
+ SOURCE="$(readlink "$SOURCE")"
12
+ case "$SOURCE" in /*) ;; *) SOURCE="$DIR/$SOURCE" ;; esac
13
+ done
14
+ SCRIPT_DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
15
+ SERVER="$SCRIPT_DIR/../src/server.js"
16
+
17
+ if [ ! -f "$SERVER" ]; then
18
+ # Server file missing -- emit MCP-spec parse-error then exit cleanly so the
19
+ # client gets a recoverable signal instead of hanging.
20
+ printf '{"jsonrpc":"2.0","id":null,"error":{"code":-32000,"message":"IJFW server file not found at %s"}}\n' "$SERVER" >&2
21
+ exit 1
22
+ fi
23
+
24
+ # Find node: command -v first, then common install locations.
25
+ # Claude Code spawns MCP servers with a stripped PATH that excludes
26
+ # /opt/homebrew/bin (macOS + Homebrew) and ~/.nvm/ (nvm users).
27
+ NODE="$(command -v node 2>/dev/null)"
28
+ if [ -z "$NODE" ]; then
29
+ for candidate in \
30
+ /opt/homebrew/bin/node \
31
+ /usr/local/bin/node \
32
+ "$HOME/.nvm/versions/node"/*/bin/node \
33
+ "$HOME/.volta/bin/node" \
34
+ "$HOME/.fnm/node-versions"/*/installation/bin/node \
35
+ /usr/bin/node; do
36
+ # shellcheck disable=SC2086
37
+ for resolved in $candidate; do
38
+ if [ -x "$resolved" ]; then
39
+ NODE="$resolved"
40
+ break 2
41
+ fi
42
+ done
43
+ done
44
+ fi
45
+
46
+ if [ -z "$NODE" ]; then
47
+ printf '{"jsonrpc":"2.0","id":null,"error":{"code":-32001,"message":"node not found in PATH or common locations (/opt/homebrew/bin, /usr/local/bin, ~/.nvm, ~/.volta). Install Node 18+ and retry."}}\n' >&2
48
+ exit 1
49
+ fi
50
+
51
+ exec "$NODE" "$SERVER" "$@"
@@ -0,0 +1,28 @@
1
+ // IJFW Demo Target - three deliberate bugs for the Trident to catch.
2
+ // Do not fix these - this file is shipped for `ijfw demo` and must stay broken.
3
+ //
4
+ // Contract (asserted by test-demo.js):
5
+ // CWE-476 - Null pointer dereference
6
+ // CWE-89 - SQL injection via string concatenation
7
+ // CWE-755 - Silent error swallow; failure is indistinguishable from success
8
+
9
+ // Bug 1: CWE-476 - Null pointer dereference.
10
+ function getUserEmail(user) {
11
+ return user.profile.email.toLowerCase(); // crashes if user.profile is null
12
+ }
13
+
14
+ // Bug 2: CWE-89 - SQL injection via string concatenation.
15
+ function findUserByName(db, name) {
16
+ return db.query("SELECT * FROM users WHERE name = '" + name + "'");
17
+ }
18
+
19
+ // Bug 3: CWE-755 - Silent error swallow; failure is indistinguishable from success.
20
+ async function loadConfig(path) {
21
+ try {
22
+ return JSON.parse(await readFile(path));
23
+ } catch (e) {
24
+ return {}; // bug: silently returns empty config instead of surfacing read/parse errors
25
+ }
26
+ }
27
+
28
+ export { getUserEmail, findUserByName, loadConfig };
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@ijfw/memory-server",
3
+ "version": "1.3.0",
4
+ "description": "Cross-platform persistent memory server for IJFW. 10 MCP tools (memory + admin/update). Works with 13 MCP-using platforms (Claude Code, Codex, Gemini CLI, Cursor, Windsurf, Copilot, Hermes, Wayland, OpenCode, QwenCode, Cline, KimiCode, OpenClaw) plus Aider via rules-only tier.",
5
+ "author": "Sean Donahoe",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "main": "src/server.js",
9
+ "bin": {
10
+ "ijfw-memory": "./bin/ijfw-memory",
11
+ "ijfw": "./bin/ijfw",
12
+ "ijfw-dispatch-plan": "./bin/ijfw-dispatch-plan"
13
+ },
14
+ "scripts": {
15
+ "start": "node src/server.js",
16
+ "dev": "node --watch src/server.js",
17
+ "test": "node test.js"
18
+ },
19
+ "engines": {
20
+ "node": ">=18.0.0"
21
+ },
22
+ "dependencies": {
23
+ "better-sqlite3": "^11.5.0"
24
+ },
25
+ "devDependencies": {
26
+ "ajv": "^8.12.0",
27
+ "ajv-formats": "^3.0.1"
28
+ },
29
+ "optionalDependencies": {
30
+ "@xenova/transformers": "^2.17.0"
31
+ },
32
+ "peerDependenciesMeta": {
33
+ "@xenova/transformers": {
34
+ "optional": true
35
+ }
36
+ },
37
+ "files": [
38
+ "bin/",
39
+ "src/",
40
+ "fixtures/",
41
+ "templates/"
42
+ ],
43
+ "keywords": [
44
+ "mcp",
45
+ "memory",
46
+ "ijfw",
47
+ "claude-code",
48
+ "codex",
49
+ "gemini",
50
+ "cursor",
51
+ "ai-efficiency"
52
+ ]
53
+ }