@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,111 @@
1
+ /**
2
+ * IJFW cost/readers/gemini.js
3
+ * Reads Gemini CLI chat JSON files from ~/.gemini/tmp/<projectHash>/chats/*.json
4
+ * Gemini CLI does not expose per-turn token counts in its chat format.
5
+ * We estimate from message content length.
6
+ * Data approach adapted from tokscale (junhoyeo, MIT).
7
+ */
8
+
9
+ import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { homedir } from 'node:os';
12
+
13
+ const GEMINI_TMP_DIR = join(homedir(), '.gemini', 'tmp');
14
+
15
+ export function readGeminiSessions(tmpDir = GEMINI_TMP_DIR) {
16
+ if (!existsSync(tmpDir)) return [];
17
+
18
+ const turns = [];
19
+ let projectDirs;
20
+ try { projectDirs = readdirSync(tmpDir); } catch { return []; }
21
+
22
+ for (const projectHash of projectDirs) {
23
+ const projectPath = join(tmpDir, projectHash);
24
+ let stat;
25
+ try { stat = statSync(projectPath); } catch { continue; }
26
+ if (!stat.isDirectory()) continue;
27
+
28
+ const chatsDir = join(projectPath, 'chats');
29
+ if (!existsSync(chatsDir)) continue;
30
+
31
+ let chatFiles;
32
+ try { chatFiles = readdirSync(chatsDir); } catch { continue; }
33
+
34
+ for (const file of chatFiles) {
35
+ if (!file.endsWith('.json')) continue;
36
+ const filePath = join(chatsDir, file);
37
+
38
+ try {
39
+ const raw = readFileSync(filePath, 'utf8');
40
+ const chat = JSON.parse(raw);
41
+ const sessionTurns = processGeminiChat(chat, projectHash);
42
+ for (const t of sessionTurns) turns.push(t);
43
+ } catch {
44
+ // corrupt file -- skip
45
+ }
46
+ }
47
+ }
48
+
49
+ return turns;
50
+ }
51
+
52
+ function processGeminiChat(chat, projectHash) {
53
+ const sessionId = chat.sessionId || projectHash;
54
+ const startTime = chat.startTime || chat.lastUpdated || null;
55
+ const messages = chat.messages || [];
56
+
57
+ // Gemini CLI chat files: messages have type 'user' or 'gemini'
58
+ // No token counts exposed; estimate from text content
59
+ let totalInputChars = 0;
60
+ let totalOutputChars = 0;
61
+ let lastTimestamp = startTime;
62
+
63
+ for (const msg of messages) {
64
+ if (msg.timestamp) lastTimestamp = msg.timestamp;
65
+
66
+ const chars = extractChars(msg.content);
67
+ if (!chars) continue;
68
+
69
+ if (msg.type === 'user') {
70
+ totalInputChars += chars;
71
+ } else {
72
+ // 'gemini' type or model response
73
+ totalOutputChars += chars;
74
+ // Count thoughts as output tokens (they are billed)
75
+ if (Array.isArray(msg.thoughts)) {
76
+ for (const t of msg.thoughts) {
77
+ if (t.description) totalOutputChars += t.description.length;
78
+ }
79
+ }
80
+ }
81
+ }
82
+
83
+ const inputTokens = Math.ceil(totalInputChars / 4);
84
+ const outputTokens = Math.ceil(totalOutputChars / 4);
85
+
86
+ if (!inputTokens && !outputTokens) return [];
87
+
88
+ return [{
89
+ platform: 'gemini',
90
+ session_id: sessionId,
91
+ project: projectHash,
92
+ timestamp: lastTimestamp,
93
+ model: 'gemini-2.0-flash', // Gemini CLI default; no model id in chat file
94
+ input_tokens: inputTokens,
95
+ output_tokens: outputTokens,
96
+ cache_create_tokens_5m: 0,
97
+ cache_create_tokens_1h: 0,
98
+ cache_read_tokens: 0,
99
+ tool_name: null,
100
+ estimated: true,
101
+ }];
102
+ }
103
+
104
+ function extractChars(content) {
105
+ if (!content) return 0;
106
+ if (typeof content === 'string') return content.length;
107
+ if (Array.isArray(content)) {
108
+ return content.reduce((s, block) => s + (block && block.text ? block.text.length : 0), 0);
109
+ }
110
+ return 0;
111
+ }
@@ -0,0 +1,243 @@
1
+ /**
2
+ * IJFW cost/savings.js
3
+ * Computes savings from token usage + observation data.
4
+ *
5
+ * Savings categories (conservative, defensible):
6
+ * cache -- cache_read_tokens * in_price * 0.9 (90% discount vs fresh input)
7
+ * SOURCE: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching
8
+ * CONFIDENCE: high -- measured from Claude's JSONL, real token counts.
9
+ * Only from non-estimated turns (Codex/Gemini estimates excluded).
10
+ *
11
+ * memory -- per FIRST recall of each file per session: tokens * in_price
12
+ * SOURCE: ijfw_memory_recall MCP tool fires + memory file sizes
13
+ * CONFIDENCE: medium -- assumes user would otherwise paste context manually.
14
+ * Only counts first recall per file per session (subsequent recals are
15
+ * already in the prompt cache, so no additional savings).
16
+ *
17
+ * trident -- HIGH findings closed pre-ship * $5 (conservative rework estimate)
18
+ * SOURCE: cross-audit observation records (deduplicated by finding id)
19
+ * CONFIDENCE: medium -- $5 is conservative; rework cost studies cite
20
+ * $15-$75 per bug found post-ship vs pre-ship (McConnell, Code Complete).
21
+ * Capped at 20 per week to prevent runaway from stale/duplicate records.
22
+ *
23
+ * terse -- REMOVED. The 1.4x multiplier was a made-up heuristic with no
24
+ * baseline data. Removed for honesty.
25
+ *
26
+ * All values are non-negative. Returns structured objects with confidence metadata.
27
+ */
28
+
29
+ import { existsSync, readFileSync } from 'node:fs';
30
+ import { join } from 'node:path';
31
+ import { homedir } from 'node:os';
32
+ import { getPricing } from './pricing.js';
33
+
34
+ // Conservative estimate: tokens in a memory file that would have been re-pasted
35
+ const MEMORY_RECALL_CONTEXT_TOKENS = 800;
36
+
37
+ // Conservative: $5 per HIGH finding pre-ship (rework cost studies cite $15-$75)
38
+ const TRIDENT_FINDING_VALUE = 5.0;
39
+
40
+ // Sanity cap: no more than 20 trident HIGH findings credited per week
41
+ const TRIDENT_WEEKLY_CAP = 20;
42
+
43
+ /**
44
+ * Compute savings for a set of turns.
45
+ * @param {Array} turns - from aggregator (each has platform, model, token fields)
46
+ * @param {Array} observations - from observations.jsonl (for memory + trident events)
47
+ * @returns structured savings object with value + metadata per component
48
+ */
49
+ export function computeSavings(turns, observations = []) {
50
+ // Cache savings: ONLY from non-estimated turns (real measured token counts)
51
+ let cacheSavingsValue = 0;
52
+ for (const turn of turns) {
53
+ if (turn.estimated) continue; // exclude Codex/Gemini char-estimated data
54
+ const p = getPricing(turn.model);
55
+ cacheSavingsValue += (turn.cache_read_tokens || 0) * p.in * 0.9;
56
+ }
57
+ cacheSavingsValue = Math.max(0, cacheSavingsValue);
58
+
59
+ // Memory recall savings: count only FIRST recall per file per session
60
+ const recallObs = (observations || []).filter(o =>
61
+ (o.tool_name || '').includes('ijfw_memory_recall') ||
62
+ (o.tool_name || '').includes('memory_recall') ||
63
+ (o.title || '').toLowerCase().includes('memory recall')
64
+ );
65
+
66
+ // Deduplicate: first recall per (session_id, file_path or title) combination
67
+ const seenRecalls = new Set();
68
+ let uniqueRecalls = 0;
69
+ for (const obs of recallObs) {
70
+ const key = `${obs.session_id || 'session'}:${obs.file_path || obs.title || obs.id}`;
71
+ if (!seenRecalls.has(key)) {
72
+ seenRecalls.add(key);
73
+ uniqueRecalls++;
74
+ }
75
+ }
76
+
77
+ // Price memory savings at the most recent Claude turn's rate
78
+ const claudeTurns = turns.filter(t => t.platform === 'claude' && !t.estimated);
79
+ const latestModel = claudeTurns.length ? claudeTurns[claudeTurns.length - 1].model : 'claude-sonnet-4-5';
80
+ const recallPrice = getPricing(latestModel);
81
+ const memorySavingsValue = Math.max(0, uniqueRecalls * MEMORY_RECALL_CONTEXT_TOKENS * recallPrice.in);
82
+
83
+ // Trident savings: deduplicated HIGH findings, capped at weekly max
84
+ const tridentHighObs = (observations || []).filter(o =>
85
+ ((o.type || '') === 'decision' || (o.title || '').toLowerCase().includes('trident')) &&
86
+ (o.title || '').toLowerCase().includes('high')
87
+ );
88
+
89
+ // Deduplicate by finding_id if present, else by title
90
+ const seenFindings = new Set();
91
+ let uniqueHighFindings = 0;
92
+ for (const obs of tridentHighObs) {
93
+ const key = obs.finding_id || obs.id || obs.title || String(uniqueHighFindings);
94
+ if (!seenFindings.has(key)) {
95
+ seenFindings.add(key);
96
+ uniqueHighFindings++;
97
+ }
98
+ }
99
+ const cappedFindings = Math.min(uniqueHighFindings, TRIDENT_WEEKLY_CAP);
100
+ const tridentSavingsValue = Math.max(0, cappedFindings * TRIDENT_FINDING_VALUE);
101
+
102
+ const total = cacheSavingsValue + memorySavingsValue + tridentSavingsValue;
103
+
104
+ // Cache hit rate for display (measured turns only)
105
+ const measuredTurns = turns.filter(t => !t.estimated);
106
+ const totalCacheRead = measuredTurns.reduce((s, t) => s + (t.cache_read_tokens || 0), 0);
107
+ const totalInput = measuredTurns.reduce((s, t) => s + (t.input_tokens || 0), 0);
108
+ const hitRateDenom = totalInput + totalCacheRead;
109
+ const hitRatePct = hitRateDenom > 0
110
+ ? (totalCacheRead / hitRateDenom * 100).toFixed(1) + '%'
111
+ : null;
112
+
113
+ return {
114
+ cache: {
115
+ value: cacheSavingsValue,
116
+ confidence: 'high',
117
+ source: 'anthropic-pricing-docs',
118
+ attribution: 'claude-code-automatic-caching',
119
+ description: 'Cache-read tokens would have cost 10x more at full input price. Claude Code caches automatically; IJFW measures the effect.',
120
+ note: 'Excludes estimated Codex/Gemini data. Real Claude JSONL only.',
121
+ displayPrimary: hitRatePct,
122
+ displaySecondary: cacheSavingsValue > 0
123
+ ? '$' + cacheSavingsValue.toFixed(2) + ' at full input pricing'
124
+ : null,
125
+ },
126
+ memory: {
127
+ value: memorySavingsValue,
128
+ confidence: 'medium',
129
+ attribution: 'ijfw',
130
+ description: `First recall per file per session * ${MEMORY_RECALL_CONTEXT_TOKENS} tokens * input_price`,
131
+ source: 'ijfw_memory_recall MCP tool observations',
132
+ note: 'Assumes user would otherwise re-paste context. Only first recall per file credited.',
133
+ },
134
+ trident: {
135
+ value: tridentSavingsValue,
136
+ confidence: 'medium',
137
+ attribution: 'ijfw',
138
+ description: `Unique HIGH findings pre-ship * $${TRIDENT_FINDING_VALUE} (capped at ${TRIDENT_WEEKLY_CAP}/week)`,
139
+ source: 'cross-audit observation records (deduplicated by finding_id)',
140
+ note: 'McConnell (Code Complete): pre-ship bug cost $5-$75 vs post-ship rework.',
141
+ },
142
+ // terse savings intentionally omitted -- no defensible baseline
143
+ total,
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Return the methodology for the /api/savings/methodology endpoint.
149
+ */
150
+ export function getSavingsMethodology() {
151
+ return {
152
+ version: '2',
153
+ updated: '2026-04-16',
154
+ note: 'Conservative, defensible formulas. Terse savings removed (no baseline data).',
155
+ components: {
156
+ cache: {
157
+ formula: 'cache_read_tokens * input_price * 0.9',
158
+ confidence: 'high',
159
+ source: 'https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching',
160
+ assumption: 'Each cached read saves 90% of what fresh input would cost (Anthropic pricing).',
161
+ dataQuality: 'Measured -- real token counts from Claude JSONL. Estimated Codex/Gemini data excluded.',
162
+ },
163
+ memory: {
164
+ formula: 'unique_first_recalls * 800 tokens * input_price',
165
+ confidence: 'medium',
166
+ source: 'ijfw_memory_recall MCP tool observation records',
167
+ assumption: 'User would otherwise paste 800-token context manually. First recall per file per session only (subsequent recalls already cached).',
168
+ dataQuality: 'Estimated -- recall count is measured, token size is conservative estimate.',
169
+ },
170
+ trident: {
171
+ formula: 'unique_HIGH_findings * $5.00 (cap: 20/week)',
172
+ confidence: 'medium',
173
+ source: 'cross-audit observation records',
174
+ assumption: '$5 conservative rework estimate per HIGH finding caught pre-ship. McConnell (Code Complete) cites $15-$75 range; we use the floor.',
175
+ dataQuality: 'Estimated -- finding count is measured, dollar value is a conservative fixed estimate.',
176
+ },
177
+ terse: {
178
+ formula: 'REMOVED',
179
+ confidence: 'none',
180
+ reason: 'Previous formula used a 1.4x baseline multiplier with no empirical basis. Removed for honesty. Will revisit when baseline A/B data is available.',
181
+ },
182
+ },
183
+ };
184
+ }
185
+
186
+ /**
187
+ * Compute subscription value-delivered and ROI for a configured tier.
188
+ *
189
+ * For subscription users, per-token prices are sunk cost -- "saved via cache" is
190
+ * meaningless. What matters is PAYG-equivalent value received vs flat monthly fee.
191
+ *
192
+ * @param {object} tier - { tier, price_monthly_usd } from config.subscriptions
193
+ * @param {Array} turns - annotated turns from aggregator (must have cost_usd)
194
+ * @param {number} periodDays - days in the measurement window (default 7)
195
+ * @returns {{ payg_equivalent, value_delivered, roi, utilization, framing, is_subscription }}
196
+ */
197
+ export function computeValueDelivered(tier, turns, periodDays = 7) {
198
+ if (!tier || !Array.isArray(turns)) {
199
+ return { payg_equivalent: 0, value_delivered: 0, roi: null, utilization: null, framing: 'unconfigured', is_subscription: false };
200
+ }
201
+
202
+ const isSubscription = tier.tier !== 'payg' && tier.price_monthly_usd > 0;
203
+ const payg_equivalent = turns.reduce((s, t) => s + (t.cost_usd || 0), 0);
204
+
205
+ if (!isSubscription) {
206
+ return { payg_equivalent, value_delivered: 0, roi: null, utilization: null, framing: 'payg', is_subscription: false };
207
+ }
208
+
209
+ // Pro-rate monthly price to the measurement window
210
+ const daily_price = tier.price_monthly_usd / 30;
211
+ const window_price = daily_price * periodDays;
212
+
213
+ const roi = window_price > 0 ? payg_equivalent / window_price : null;
214
+ const utilization = window_price > 0 ? Math.min(2.0, payg_equivalent / window_price) : null;
215
+ const value_delivered = Math.max(0, payg_equivalent - window_price);
216
+
217
+ return {
218
+ payg_equivalent,
219
+ value_delivered,
220
+ roi,
221
+ utilization,
222
+ framing: 'delivered',
223
+ is_subscription: true,
224
+ window_price,
225
+ };
226
+ }
227
+
228
+ /**
229
+ * Load observations.jsonl from the default IJFW path.
230
+ */
231
+ export function loadObservations(ledgerPath) {
232
+ const path = ledgerPath || join(homedir(), '.ijfw', 'observations.jsonl');
233
+ if (!existsSync(path)) return [];
234
+ try {
235
+ return readFileSync(path, 'utf8')
236
+ .split('\n')
237
+ .filter(Boolean)
238
+ .map(l => { try { return JSON.parse(l); } catch { return null; } })
239
+ .filter(Boolean);
240
+ } catch {
241
+ return [];
242
+ }
243
+ }