@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.
- package/bin/ijfw +27 -0
- package/bin/ijfw-dashboard +180 -0
- package/bin/ijfw-dispatch-plan +41 -0
- package/bin/ijfw-memorize +273 -0
- package/bin/ijfw-memory +51 -0
- package/fixtures/demo-target.js +28 -0
- package/package.json +53 -0
- package/src/api-client.js +190 -0
- package/src/audit-roster.js +315 -0
- package/src/caps.js +37 -0
- package/src/cold-scan-runner.mjs +37 -0
- package/src/compute/edges.js +155 -0
- package/src/compute/extract.js +560 -0
- package/src/compute/fts5.js +420 -0
- package/src/compute/graph-auto-index.js +191 -0
- package/src/compute/graph-lock.js +114 -0
- package/src/compute/index.js +18 -0
- package/src/compute/migration-runner.js +116 -0
- package/src/compute/migrations/001-initial.js +23 -0
- package/src/compute/migrations/002-porter-stemming-source.js +139 -0
- package/src/compute/migrations/003-tier-semantic.js +69 -0
- package/src/compute/migrations/004-kg-tables.js +83 -0
- package/src/compute/migrations/005-stale-candidate.js +72 -0
- package/src/compute/python-resolver.js +106 -0
- package/src/compute/runner-vm.js +185 -0
- package/src/compute/runner.js +416 -0
- package/src/compute/sandbox-detect.js +122 -0
- package/src/compute/sandbox-linux.js +164 -0
- package/src/compute/sandbox-macos.js +167 -0
- package/src/compute/sandbox-windows.js +63 -0
- package/src/compute/schema.sql +118 -0
- package/src/compute/staleness.js +239 -0
- package/src/compute/synonyms.js +367 -0
- package/src/compute/traverse.js +180 -0
- package/src/cost/aggregator.js +229 -0
- package/src/cost/pricing.js +134 -0
- package/src/cost/readers/claude.js +179 -0
- package/src/cost/readers/codex.js +131 -0
- package/src/cost/readers/gemini.js +111 -0
- package/src/cost/savings.js +243 -0
- package/src/cross-dispatcher.js +437 -0
- package/src/cross-orchestrator-cli.js +1885 -0
- package/src/cross-orchestrator.js +598 -0
- package/src/cross-project-search.js +114 -0
- package/src/dashboard-client.html +1180 -0
- package/src/dashboard-server.js +895 -0
- package/src/design-companion.js +81 -0
- package/src/dispatch/colon-syntax.js +732 -0
- package/src/dispatch-planner.js +235 -0
- package/src/dream/cooldown.js +105 -0
- package/src/dream/runner.mjs +373 -0
- package/src/dream/staleness-wiring.js +195 -0
- package/src/feedback-detector.js +57 -0
- package/src/hero-line.js +115 -0
- package/src/importers/claude-mem.js +152 -0
- package/src/importers/cli.js +311 -0
- package/src/importers/common.js +84 -0
- package/src/importers/discover.js +235 -0
- package/src/importers/rtk.js +107 -0
- package/src/intent-router.js +221 -0
- package/src/lib/atomic-io.js +201 -0
- package/src/lib/cache.js +33 -0
- package/src/lib/npm-view.js +104 -0
- package/src/lib/status-card.js +95 -0
- package/src/lib/token.js +85 -0
- package/src/memory/fts5.js +349 -0
- package/src/memory/migration-runner.js +116 -0
- package/src/memory/migrations/001-fts5-init.js +26 -0
- package/src/memory/migrations/002-tier-semantic.js +60 -0
- package/src/memory/migrations/003-stale-candidate.js +60 -0
- package/src/memory/reader.js +300 -0
- package/src/memory/recall-counter.js +76 -0
- package/src/memory/schema.sql +79 -0
- package/src/memory/search.js +431 -0
- package/src/memory/staleness.js +237 -0
- package/src/memory/tier-promotion.js +377 -0
- package/src/memory/tokenize.js +63 -0
- package/src/project-type-detector.js +866 -0
- package/src/prompt-check.js +171 -0
- package/src/ralph-allowlist.js +88 -0
- package/src/receipts.js +129 -0
- package/src/redactor.js +107 -0
- package/src/sandbox.js +275 -0
- package/src/sanitizer.js +69 -0
- package/src/scan-resume.js +167 -0
- package/src/schema.js +82 -0
- package/src/search-bm25.js +108 -0
- package/src/server.js +1414 -0
- package/src/swarm-config.js +80 -0
- package/src/trident/dispatch.js +211 -0
- package/src/trident/lens-health.js +253 -0
- package/src/update-apply.js +79 -0
- package/src/update-check.js +136 -0
- package/src/vectors.js +178 -0
- package/templates/design/bento-grid.md +84 -0
- package/templates/design/brutalist-luxe.md +82 -0
- package/templates/design/cinematic-dark.md +82 -0
- package/templates/design/data-dense-dashboard.md +88 -0
- package/templates/design/editorial-warm.md +81 -0
- package/templates/design/glassmorphic.md +84 -0
- package/templates/design/magazine-editorial.md +84 -0
- package/templates/design/maximalist-vibrant.md +85 -0
- package/templates/design/neo-swiss-tech.md +85 -0
- package/templates/design/swiss-minimal.md +80 -0
- package/templates/design/terminal-native.md +83 -0
- 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
|
+
}
|