@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
@@ -0,0 +1,115 @@
1
+ // hero-line.js -- one-line summary renderer for cross-run receipts.
2
+ // Codex U1 caveat: delta is NEVER fabricated. If real data is insufficient,
3
+ // the delta suffix is omitted entirely.
4
+
5
+ // Format duration in whole seconds (or ms if <1000ms total).
6
+ function fmtDuration(ms) {
7
+ if (ms < 1000) return `${Math.round(ms)}ms`;
8
+ return `${Math.round(ms / 1000)}s`;
9
+ }
10
+
11
+ // Normalize receipt findings into { total, consensus } counts regardless of
12
+ // whether the receipt came from audit/critique (findings.items), research
13
+ // (findings.consensus / findings.contested / findings.unique as arrays), or
14
+ // a legacy numeric shape ({ consensus: N, contested: N, unique: N }).
15
+ function countFindings(f) {
16
+ if (!f) return { total: 0, consensus: 0 };
17
+ if (Array.isArray(f.items)) return { total: f.items.length, consensus: 0 };
18
+ // Array-shape (research output)
19
+ if (Array.isArray(f.consensus)) {
20
+ const consensus = f.consensus.length;
21
+ const contested = Array.isArray(f.contested) ? f.contested.length : 0;
22
+ const unique = Object.values(f.unique || {}).reduce((a, b) => a + (Array.isArray(b) ? b.length : 0), 0);
23
+ return { total: consensus + contested + unique, consensus };
24
+ }
25
+ // Legacy numeric shape
26
+ const consensus = typeof f.consensus === 'number' ? f.consensus : 0;
27
+ const contested = typeof f.contested === 'number' ? f.contested : 0;
28
+ const unique = typeof f.unique === 'number' ? f.unique : 0;
29
+ return { total: consensus + contested + unique, consensus };
30
+ }
31
+
32
+ // Anthropic cache-read savings rate: full input $3/M, cache-read $0.30/M -> $2.70/M saved.
33
+ const CACHE_SAVINGS_PER_TOKEN = 2.70 / 1_000_000;
34
+
35
+ // renderHeroLine(receipts, sessions?)
36
+ // receipts -- array of cross-runs.jsonl records
37
+ // sessions -- array of sessions.jsonl v3 records (optional, default [])
38
+ //
39
+ // Returns a one-line string. Delta is only appended when:
40
+ // - receipts have real input_tokens (sum > 0)
41
+ // - sessions has >=3 entries with non-null input_tokens (Claude baseline)
42
+ // - baseline sum > 0
43
+ // Cache savings suffix appended when last receipt has cache_read_input_tokens > 0.
44
+ export function renderHeroLine(receipts, sessions = []) {
45
+ if (!receipts || receipts.length === 0) {
46
+ return 'No cross-audit runs yet -- fire the Trident at any file with `ijfw cross audit <file>`. First run in ~20s.';
47
+ }
48
+
49
+ // Aggregate auditor IDs (unique across all receipts).
50
+ const auditorIds = new Set();
51
+ let totalMs = 0;
52
+ let totalFindings = 0;
53
+ let totalConsensus = 0;
54
+ let receiptsInputTokens = 0;
55
+ let hasReceiptsTokens = true;
56
+ let totalCacheReadTokens = 0;
57
+
58
+ for (const r of receipts) {
59
+ if (Array.isArray(r.auditors)) {
60
+ for (const a of r.auditors) {
61
+ if (a && a.id) auditorIds.add(a.id);
62
+ }
63
+ }
64
+ totalMs += (typeof r.duration_ms === 'number') ? r.duration_ms : 0;
65
+ const counts = countFindings(r.findings);
66
+ totalFindings += counts.total;
67
+ totalConsensus += counts.consensus;
68
+ if (r.input_tokens == null) {
69
+ hasReceiptsTokens = false;
70
+ } else {
71
+ receiptsInputTokens += r.input_tokens;
72
+ }
73
+ const crt = r.cache_stats?.cache_read_input_tokens;
74
+ if (typeof crt === 'number' && crt > 0) {
75
+ totalCacheReadTokens += crt;
76
+ }
77
+ }
78
+
79
+ // Value statement (Sutherland lens): what was delivered, not what was done.
80
+ const baseline = `${auditorIds.size} AIs surfaced ${totalFindings} findings (${totalConsensus} consensus-critical) in ${fmtDuration(totalMs)}`;
81
+
82
+ // Cache savings suffix (10D.4): append only when cache reads produced a
83
+ // visible saving (>= $0.01). A sub-cent figure reads as anti-value.
84
+ const rawSaved = totalCacheReadTokens * CACHE_SAVINGS_PER_TOKEN;
85
+ const cacheSuffix = rawSaved >= 0.01
86
+ ? ` (prompt cache hit -- ~$${rawSaved.toFixed(2)} saved)`
87
+ : '';
88
+
89
+ // Codex U1: only compute delta when all guards pass.
90
+ if (!hasReceiptsTokens || receiptsInputTokens <= 0) {
91
+ return baseline + cacheSuffix;
92
+ }
93
+
94
+ // Filter sessions: must be Claude-only entries with real input_tokens.
95
+ const claudeSessions = (sessions || []).filter(
96
+ s => s && s.input_tokens != null && s.input_tokens > 0
97
+ );
98
+
99
+ const MIN_SAMPLES = 3;
100
+ if (claudeSessions.length < MIN_SAMPLES) {
101
+ return baseline + cacheSuffix;
102
+ }
103
+
104
+ const sessionBaseline = claudeSessions.reduce((sum, s) => sum + s.input_tokens, 0);
105
+ if (sessionBaseline <= 0) {
106
+ return baseline + cacheSuffix;
107
+ }
108
+
109
+ const delta = 1 - (receiptsInputTokens / sessionBaseline);
110
+ const pct = Math.round(Math.abs(delta) * 100);
111
+ const sign = delta >= 0 ? '-' : '+';
112
+ const n = claudeSessions.length;
113
+
114
+ return `${baseline} -- measured delta: ${sign}${pct}% tokens vs solo Claude ${n}x${cacheSuffix}`;
115
+ }
@@ -0,0 +1,152 @@
1
+ // --- claude-mem importer ---
2
+ //
3
+ // Reads claude-mem's SQLite store (~/.claude-mem/claude-mem.db), normalizes
4
+ // `observations` rows into IJFW entries, and (via cli.js) writes them to the
5
+ // local project's .ijfw/memory/.
6
+ //
7
+ // Schema: see .planning/phase12/IMPORTER-SCHEMAS.md.
8
+ //
9
+ // SQLite access uses Node 22.5+'s built-in `node:sqlite`. On older Node, the
10
+ // importer surfaces a positive-framed upgrade message via detect().
11
+
12
+ import { existsSync, statSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { homedir } from 'node:os';
15
+ import { makeEntry } from './common.js';
16
+
17
+ export const NAME = 'claude-mem';
18
+
19
+ const DEFAULT_CANDIDATES = (home) => [
20
+ join(home, '.claude-mem', 'claude-mem.db'),
21
+ join(home, '.config', 'claude-mem', 'claude-mem.db'),
22
+ ];
23
+
24
+ export function detect({ home = homedir(), path = null } = {}) {
25
+ const candidates = path ? [path] : DEFAULT_CANDIDATES(home);
26
+ for (const p of candidates) {
27
+ if (existsSync(p) && statSync(p).isFile()) {
28
+ return { found: true, path: p };
29
+ }
30
+ }
31
+ return { found: false, path: null };
32
+ }
33
+
34
+ // Async so we can `await import('node:sqlite')` and surface a useful error
35
+ // on Node <22.5 without crashing the CLI.
36
+ //
37
+ // opts.projectFilter: if set, only yields rows where project matches exactly.
38
+ // Used by --all mode to import one project's entries at a time.
39
+ export async function* readSource(path, opts = {}) {
40
+ if (!path || !existsSync(path)) return;
41
+ const projectFilter = opts.projectFilter || null;
42
+
43
+ let sqliteMod;
44
+ try {
45
+ sqliteMod = await import('node:sqlite');
46
+ } catch {
47
+ throw new Error(
48
+ 'claude-mem importer needs Node 22.5+ for built-in SQLite. ' +
49
+ 'Upgrade Node then retry: https://nodejs.org/en/download'
50
+ );
51
+ }
52
+
53
+ const { DatabaseSync } = sqliteMod;
54
+ const db = new DatabaseSync(path, { readOnly: true });
55
+ try {
56
+ // Introspect actual schema -- claude-mem has evolved over versions.
57
+ // Select only columns that exist; missing ones come back as undefined
58
+ // and normalize() handles that via String(row.x || '').
59
+ const wanted = [
60
+ 'title', 'subtitle', 'type', 'narrative', 'facts', 'concepts',
61
+ 'files_modified', 'project', 'session_id', 'created_at',
62
+ ];
63
+ const present = new Set(
64
+ db.prepare('PRAGMA table_info(observations)').all().map((c) => c.name)
65
+ );
66
+ const cols = wanted.filter((c) => present.has(c));
67
+ if (cols.length === 0) {
68
+ throw new Error(
69
+ `claude-mem table 'observations' has no recognizable columns. ` +
70
+ `Your schema may be newer than the importer supports. Found: ${[...present].join(', ')}`
71
+ );
72
+ }
73
+ // Order by created_at_epoch if present, otherwise created_at, otherwise rowid.
74
+ let orderBy = 'rowid';
75
+ if (present.has('created_at_epoch')) orderBy = 'created_at_epoch';
76
+ else if (present.has('created_at')) orderBy = 'created_at';
77
+ // Optional project filter for --all mode.
78
+ let where = '';
79
+ const params = [];
80
+ if (projectFilter && present.has('project')) {
81
+ where = 'WHERE project = ?';
82
+ params.push(projectFilter);
83
+ }
84
+ const sql = `SELECT ${cols.join(', ')} FROM observations ${where} ORDER BY ${orderBy} ASC`;
85
+ const rows = db.prepare(sql).all(...params);
86
+ for (const r of rows) yield r;
87
+ } finally {
88
+ db.close();
89
+ }
90
+ }
91
+
92
+ // Normalize one row. Returns null if the row is too empty to preserve.
93
+ export function normalize(row) {
94
+ if (!row || typeof row !== 'object') return null;
95
+
96
+ const narrative = String(row.narrative || '').trim();
97
+ const title = String(row.title || '').trim();
98
+ if (!narrative && !title) return null;
99
+
100
+ const type = mapType(row.type);
101
+ const tags = parseJsonArray(row.concepts).slice(0, 20).map(String);
102
+ const facts = parseJsonArray(row.facts).map(String);
103
+ const filesModified = parseJsonArray(row.files_modified).map(String);
104
+
105
+ // Compose content: narrative + optional facts bullets + files-modified trailer
106
+ // + provenance line. Keeps the original claim intact while making it
107
+ // recall-friendly in IJFW's BM25 layer.
108
+ const parts = [narrative || title];
109
+ if (facts.length > 0) {
110
+ parts.push('', 'Facts:', ...facts.map((f) => `- ${f}`));
111
+ }
112
+ if (filesModified.length > 0) {
113
+ parts.push('', `Files touched: ${filesModified.join(', ')}`);
114
+ }
115
+ const projectTag = row.project ? basename(String(row.project)) : null;
116
+ if (projectTag || row.session_id) {
117
+ parts.push('', `_Imported from claude-mem${projectTag ? ` -- project ${projectTag}` : ''}${row.session_id ? ` -- session ${row.session_id}` : ''}_`);
118
+ }
119
+
120
+ return makeEntry({
121
+ type,
122
+ content: parts.join('\n'),
123
+ summary: title || narrative.slice(0, 80),
124
+ tags,
125
+ source: NAME,
126
+ });
127
+ }
128
+
129
+ function mapType(raw) {
130
+ const t = String(raw || '').toLowerCase();
131
+ if (t === 'decision') return 'decision';
132
+ if (t === 'discovery') return 'observation';
133
+ if (t === 'feature' || t === 'refactor' || t === 'change' || t === 'bugfix') return 'pattern';
134
+ return 'observation';
135
+ }
136
+
137
+ function parseJsonArray(raw) {
138
+ if (!raw) return [];
139
+ if (Array.isArray(raw)) return raw;
140
+ if (typeof raw !== 'string') return [];
141
+ try {
142
+ const v = JSON.parse(raw);
143
+ return Array.isArray(v) ? v : [];
144
+ } catch {
145
+ return [];
146
+ }
147
+ }
148
+
149
+ function basename(p) {
150
+ const idx = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'));
151
+ return idx >= 0 ? p.slice(idx + 1) : p;
152
+ }
@@ -0,0 +1,311 @@
1
+ // --- ijfw import <tool> [--dry-run] [--force] [--path <p>] ---
2
+ //
3
+ // Wires importer modules (claude-mem, rtk) into the `ijfw` CLI. Keeps IO
4
+ // at the edge so the per-tool modules stay unit-testable with fixtures.
5
+ //
6
+ // Writes normalized entries to .ijfw/memory/ in the current project:
7
+ // decision -> appends under knowledge.md (structured frontmatter block)
8
+ // pattern -> appends under knowledge.md (structured frontmatter block)
9
+ // observation-> appends under project-journal.md
10
+ // handoff -> overwrites handoff.md (last one wins if multiple)
11
+ // preference -> appends under .ijfw/memory/preferences.md
12
+ //
13
+ // Collision policy: default SKIP (summary-based duplicate detection on
14
+ // knowledge entries; content-hash on journal entries). --force overwrites.
15
+
16
+ import { existsSync, mkdirSync, readFileSync, appendFileSync, writeFileSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { homedir } from 'node:os';
19
+ import { createHash } from 'node:crypto';
20
+ import { emptyStats, bumpStat, renderSummary } from './common.js';
21
+
22
+ import * as claudeMem from './claude-mem.js';
23
+ import * as rtk from './rtk.js';
24
+ import { buildProjectPlan } from './discover.js';
25
+
26
+ const IMPORTERS = {
27
+ 'claude-mem': claudeMem,
28
+ 'rtk': rtk,
29
+ };
30
+
31
+ export function listImporters() { return Object.keys(IMPORTERS); }
32
+
33
+ export async function runImport({ tool, dryRun = false, force = false, path = null, includeMetrics = false, projectDir = process.cwd(), projectFilter = null, memDirOverride = null } = {}) {
34
+ const importer = IMPORTERS[tool];
35
+ if (!importer) {
36
+ return { ok: false, error: `Unknown tool: ${tool}. Available: ${listImporters().join(', ')}.` };
37
+ }
38
+
39
+ // RTK default: skip unless --include-metrics. RTK is metrics-only (see
40
+ // IMPORTER-SCHEMAS.md); importing everything would drown project-journal.md.
41
+ if (tool === 'rtk' && !includeMetrics) {
42
+ return {
43
+ ok: true,
44
+ tool,
45
+ dryRun,
46
+ stats: emptyStats(),
47
+ samples: [],
48
+ summary: `Skipped rtk -- metrics-only by design. Use --include-metrics to ingest anyway (recall value is low).`,
49
+ };
50
+ }
51
+
52
+ const hit = importer.detect({ path });
53
+ if (!hit.found) {
54
+ return {
55
+ ok: false,
56
+ error: `No ${tool} data found${path ? ` at ${path}` : ' on this machine'}. Pass --path <dir> if it lives elsewhere.`,
57
+ };
58
+ }
59
+
60
+ const memDir = memDirOverride || join(projectDir, '.ijfw', 'memory');
61
+ if (!dryRun) mkdirSync(memDir, { recursive: true });
62
+
63
+ const stats = emptyStats();
64
+ const samples = [];
65
+ let records;
66
+ try {
67
+ // Pass projectFilter only to claude-mem (rtk doesn't support it).
68
+ records = tool === 'claude-mem'
69
+ ? importer.readSource(hit.path, { projectFilter })
70
+ : importer.readSource(hit.path);
71
+ } catch (err) {
72
+ return { ok: false, error: err.message || String(err) };
73
+ }
74
+
75
+ try {
76
+ const cache = {}; // file path -> cached body (avoids O(n^2) re-reads)
77
+ for await (const record of records) {
78
+ const entry = importer.normalize(record);
79
+ if (!entry) { bumpStat(stats, null, 'skipped'); continue; }
80
+
81
+ const outcome = dryRun
82
+ ? 'preview'
83
+ : writeEntry(memDir, entry, { force, cache });
84
+
85
+ if (outcome === 'failed') bumpStat(stats, entry, 'failed');
86
+ else if (outcome === 'skipped') bumpStat(stats, entry, 'skipped');
87
+ else bumpStat(stats, entry, 'ok');
88
+
89
+ if (samples.length < 3) samples.push({ type: entry.type, summary: entry.summary });
90
+ }
91
+ } catch (err) {
92
+ return { ok: false, error: err.message || String(err), stats, samples };
93
+ }
94
+
95
+ // Per Trident MED finding: ok should reflect write health, not just that
96
+ // the iteration completed.
97
+ if (stats.failed > 0 && stats.total === stats.failed) {
98
+ return { ok: false, error: `All ${stats.failed} writes failed.`, stats, samples };
99
+ }
100
+
101
+ return {
102
+ ok: true,
103
+ source: hit.path,
104
+ tool,
105
+ dryRun,
106
+ stats,
107
+ samples,
108
+ summary: renderSummary(tool, stats),
109
+ };
110
+ }
111
+
112
+ // Writer: returns 'ok' | 'skipped' | 'failed'.
113
+ // cache: { path -> body } to avoid re-reading the same memory file per entry.
114
+ function writeEntry(memDir, entry, { force, cache }) {
115
+ try {
116
+ switch (entry.type) {
117
+ case 'decision':
118
+ case 'pattern':
119
+ return appendKnowledge(memDir, entry, force, cache);
120
+ case 'handoff':
121
+ return writeHandoff(memDir, entry, force, cache);
122
+ case 'preference':
123
+ return appendFaceted(memDir, 'preferences.md', entry, force, cache);
124
+ default:
125
+ return appendJournal(memDir, entry, force, cache);
126
+ }
127
+ } catch { return 'failed'; }
128
+ }
129
+
130
+ function getBody(file, cache) {
131
+ if (cache[file] !== undefined) return cache[file];
132
+ cache[file] = existsSync(file) ? readFileSync(file, 'utf8') : '';
133
+ return cache[file];
134
+ }
135
+
136
+ function appendAndCache(file, addition, cache) {
137
+ appendFileSync(file, addition);
138
+ cache[file] = (cache[file] || '') + addition;
139
+ }
140
+
141
+ // Content-hash dedup: two decisions with the same summary but different
142
+ // bodies are NOT duplicates (Trident HIGH). Key is sha12(content).
143
+ function appendKnowledge(memDir, entry, force, cache) {
144
+ const file = join(memDir, 'knowledge.md');
145
+ const body = getBody(file, cache);
146
+ const hash = sha12(entry.content);
147
+ if (!force && body.includes(`<!-- hash:${hash} -->`)) return 'skipped';
148
+ const summary = (entry.summary || entry.content.slice(0, 80)).replace(/\n/g, ' ');
149
+ const block = [
150
+ '',
151
+ '---',
152
+ `name: ${quoteYaml(summary)}`,
153
+ `type: ${entry.type}`,
154
+ `source: ${entry.source}`,
155
+ entry.tags.length > 0 ? `tags: [${entry.tags.map(quoteYaml).join(', ')}]` : null,
156
+ `hash: ${hash}`,
157
+ '---',
158
+ '',
159
+ `<!-- hash:${hash} -->`,
160
+ entry.content,
161
+ entry.why ? `\n**Why:** ${entry.why}` : null,
162
+ entry.how_to_apply ? `\n**How to apply:** ${entry.how_to_apply}` : null,
163
+ '',
164
+ ].filter((l) => l !== null).join('\n');
165
+ appendAndCache(file, block, cache);
166
+ return 'ok';
167
+ }
168
+
169
+ // Last-one-wins per scope intent: a new handoff overwrites the existing one.
170
+ function writeHandoff(memDir, entry, force, cache) {
171
+ const file = join(memDir, 'handoff.md');
172
+ writeFileSync(file, entry.content + '\n');
173
+ cache[file] = entry.content + '\n';
174
+ return 'ok';
175
+ }
176
+
177
+ function appendFaceted(memDir, name, entry, force, cache) {
178
+ const file = join(memDir, name);
179
+ const body = getBody(file, cache);
180
+ const hash = sha12(entry.content);
181
+ if (!force && body.includes(`<!-- hash:${hash} -->`)) return 'skipped';
182
+ appendAndCache(file, `\n<!-- hash:${hash} -->\n${entry.content}\n`, cache);
183
+ return 'ok';
184
+ }
185
+
186
+ function appendJournal(memDir, entry, force, cache) {
187
+ const file = join(memDir, 'project-journal.md');
188
+ const body = getBody(file, cache);
189
+ const hash = sha12(entry.content);
190
+ if (!force && body.includes(`<!-- hash:${hash} -->`)) return 'skipped';
191
+ const iso = new Date().toISOString();
192
+ // Preserve multi-line content; encode newlines as explicit \n rather than
193
+ // flattening. Recall surfaces still work (BM25 tokenizes per-line).
194
+ const inline = entry.content.replace(/\r?\n/g, ' \\n ');
195
+ appendAndCache(file, `\n- [${iso}] <!-- hash:${hash} --> ${inline}\n`, cache);
196
+ return 'ok';
197
+ }
198
+
199
+ // Minimal YAML quoting: wrap in double quotes if the value has special chars.
200
+ function quoteYaml(s) {
201
+ const str = String(s);
202
+ if (/[:#[\]{}&*!|>'%"@`\n]/.test(str)) {
203
+ return '"' + str.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
204
+ }
205
+ return str;
206
+ }
207
+
208
+ function sha12(s) {
209
+ return createHash('sha256').update(s).digest('hex').slice(0, 12);
210
+ }
211
+
212
+ // --- `ijfw import <tool> --all` ---
213
+ //
214
+ // Discovers all projects referenced by the source, matches them to disk
215
+ // locations, and imports each project's entries into that project's own
216
+ // .ijfw/memory/. Orphans (no local match, ambiguous, or unmatched) are
217
+ // routed to ~/.ijfw/memory/global-archive/ so nothing is lost.
218
+ //
219
+ // Returns:
220
+ // {
221
+ // ok,
222
+ // plan: { matched, ambiguous, unmatched, totalEntries },
223
+ // results: [{ project, path, ok, stats } | { project, error }],
224
+ // orphanResult,
225
+ // }
226
+
227
+ export async function runImportAll({
228
+ tool,
229
+ dryRun = false,
230
+ force = false,
231
+ path = null,
232
+ home = homedir(),
233
+ // If true, ambiguous projects go to global archive automatically.
234
+ // If false (default), they land in the plan's ambiguous list for the user to resolve.
235
+ autoOrphanAmbiguous = true,
236
+ } = {}) {
237
+ if (tool !== 'claude-mem') {
238
+ return {
239
+ ok: false,
240
+ error: `--all mode is only supported for claude-mem right now. Got: ${tool}`,
241
+ };
242
+ }
243
+
244
+ const importer = IMPORTERS[tool];
245
+ const hit = importer.detect({ path });
246
+ if (!hit.found) {
247
+ return {
248
+ ok: false,
249
+ error: `No ${tool} data found${path ? ` at ${path}` : ' on this machine'}.`,
250
+ };
251
+ }
252
+
253
+ let plan;
254
+ try {
255
+ plan = await buildProjectPlan({ dbPath: hit.path, home });
256
+ } catch (err) {
257
+ return { ok: false, error: err.message || String(err) };
258
+ }
259
+
260
+ // Dry run: return the plan without importing. Caller renders it.
261
+ if (dryRun) {
262
+ return { ok: true, dryRun: true, plan, results: [], orphanResult: null };
263
+ }
264
+
265
+ const results = [];
266
+ // Import each matched project into its own .ijfw/memory/.
267
+ for (const m of plan.matched) {
268
+ const r = await runImport({
269
+ tool,
270
+ dryRun: false,
271
+ force,
272
+ path,
273
+ projectDir: m.path,
274
+ projectFilter: m.project,
275
+ });
276
+ results.push({ project: m.project, path: m.path, entryCount: m.entryCount, ...r });
277
+ }
278
+
279
+ // Orphans (ambiguous + unmatched) go to the global archive so nothing
280
+ // is lost. User can manually re-route later if desired.
281
+ const orphanDir = join(home, '.ijfw', 'memory', 'global-archive');
282
+ const orphanProjects = [
283
+ ...(autoOrphanAmbiguous ? plan.ambiguous : []),
284
+ ...plan.unmatched,
285
+ ];
286
+ let orphanResult = null;
287
+ if (orphanProjects.length > 0) {
288
+ mkdirSync(orphanDir, { recursive: true });
289
+ const agg = { ok: true, projects: [] };
290
+ for (const o of orphanProjects) {
291
+ // unmatched with no project tag => null filter imports everything
292
+ // tagged null in the DB.
293
+ const filter = (o.project && o.project !== '(no project tag)') ? o.project : null;
294
+ const r = await runImport({
295
+ tool,
296
+ dryRun: false,
297
+ force,
298
+ path,
299
+ memDirOverride: orphanDir,
300
+ projectFilter: filter,
301
+ });
302
+ agg.projects.push({ project: o.project, ok: r.ok, stats: r.stats });
303
+ if (!r.ok) agg.ok = false;
304
+ }
305
+ orphanResult = agg;
306
+ }
307
+
308
+ const allOk = results.every((r) => r.ok !== false);
309
+ return { ok: allOk, plan, results, orphanResult };
310
+ }
311
+
@@ -0,0 +1,84 @@
1
+ // --- importer contract + shared helpers ---
2
+ //
3
+ // Every importer exposes:
4
+ // export const NAME = 'claude-mem';
5
+ // export function detect({ home, platform }) -> { found: bool, path, details }
6
+ // export function readSource(path) -> async iterable of raw records
7
+ // export function normalize(record) -> IJFW memory entry | null
8
+ //
9
+ // The CLI dispatcher (cli.js) composes these into: detect -> read -> normalize
10
+ // -> write (dry-run prints, --force overwrites, default skips on collision).
11
+ //
12
+ // Pure functions where possible. Filesystem IO is isolated to readSource and
13
+ // to the writer in cli.js so importers are unit-testable with fixtures.
14
+
15
+ // IJFW memory entry shape -- mirrors mcp-server/src/server.js handleStore.
16
+ // type: one of VALID_MEMORY_TYPES (decision | pattern | observation | handoff | preference)
17
+ // content: sanitized body (caller enforces the 5000-char cap downstream)
18
+ // summary: optional 1-line ≤80 chars (frontmatter name)
19
+ // why: optional
20
+ // how_to_apply: optional
21
+ // tags: optional string[]
22
+ // source: provenance string ('claude-mem' | 'rtk' | ...)
23
+ // importedAt: ISO timestamp (set by writer)
24
+ export function makeEntry({
25
+ type,
26
+ content,
27
+ summary = null,
28
+ why = null,
29
+ how_to_apply = null,
30
+ tags = [],
31
+ source = 'unknown',
32
+ }) {
33
+ if (!type || typeof content !== 'string' || content.length === 0) return null;
34
+ return {
35
+ type,
36
+ content: content.trim(),
37
+ summary: summary ? String(summary).slice(0, 80) : null,
38
+ why: why ? String(why) : null,
39
+ how_to_apply: how_to_apply ? String(how_to_apply) : null,
40
+ tags: Array.isArray(tags) ? tags.slice(0, 20) : [],
41
+ source,
42
+ };
43
+ }
44
+
45
+ // Aggregate per-importer statistics for the CLI summary.
46
+ // Accepts { imported, skipped, failed } keys per category.
47
+ export function emptyStats() {
48
+ return {
49
+ decisions: 0,
50
+ patterns: 0,
51
+ observations: 0,
52
+ handoffs: 0,
53
+ preferences: 0,
54
+ skipped: 0,
55
+ failed: 0,
56
+ total: 0,
57
+ };
58
+ }
59
+
60
+ export function bumpStat(stats, entry, outcome) {
61
+ if (outcome === 'skipped') stats.skipped += 1;
62
+ else if (outcome === 'failed') stats.failed += 1;
63
+ else if (entry && entry.type) {
64
+ const key = `${entry.type}s`;
65
+ if (stats[key] != null) stats[key] += 1;
66
+ }
67
+ stats.total += 1;
68
+ return stats;
69
+ }
70
+
71
+ // Positive-framed summary line -- "Imported 47 sessions from claude-mem..."
72
+ export function renderSummary(source, stats) {
73
+ const parts = [];
74
+ if (stats.decisions) parts.push(`${stats.decisions} decisions`);
75
+ if (stats.patterns) parts.push(`${stats.patterns} patterns`);
76
+ if (stats.observations) parts.push(`${stats.observations} observations`);
77
+ if (stats.handoffs) parts.push(`${stats.handoffs} handoffs`);
78
+ if (stats.preferences) parts.push(`${stats.preferences} preferences`);
79
+ const body = parts.length > 0 ? parts.join(' + ') : '0 entries';
80
+ const tail = [];
81
+ if (stats.skipped) tail.push(`${stats.skipped} skipped (already present; pass --force to overwrite)`);
82
+ if (stats.failed) tail.push(`${stats.failed} failed (see log)`);
83
+ return `Imported ${body} from ${source}.` + (tail.length ? ' ' + tail.join('; ') + '.' : '');
84
+ }