sigmap 7.0.0 → 7.1.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/CHANGELOG.md +29 -0
- package/README.md +20 -12
- package/gen-context.js +168 -79
- package/llms-full.txt +9 -2
- package/llms.txt +7 -7
- package/package.json +3 -3
- package/packages/cli/package.json +2 -2
- package/packages/core/README.md +2 -2
- package/packages/core/package.json +2 -2
- package/src/config/loader.js +6 -6
- package/src/discovery/source-root-scorer.js +2 -2
- package/src/format/gain-terminal.js +206 -0
- package/src/format/llms-txt.js +2 -3
- package/src/mcp/handlers.js +3 -8
- package/src/mcp/server.js +1 -1
- package/src/nudge.js +1 -6
- package/src/session/notes.js +2 -8
- package/src/tracking/aggregate.js +195 -0
- package/src/tracking/logger.js +83 -1
- package/src/tracking/pricing.js +43 -0
- package/src/util/git.js +31 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SigMap usage aggregation for the `gain` dashboard.
|
|
5
|
+
*
|
|
6
|
+
* Pure, zero-dependency functions that turn raw NDJSON usage records into the
|
|
7
|
+
* totals / by-operation / time-bucket shapes the terminal renderer consumes.
|
|
8
|
+
*
|
|
9
|
+
* Tolerant of BOTH schemas:
|
|
10
|
+
* - new: { op, baselineTokens, actualTokens, savedTokens, savedPct, durationMs, model }
|
|
11
|
+
* - legacy: { rawTokens, finalTokens, reductionPct } (from logger.js v0.9)
|
|
12
|
+
*
|
|
13
|
+
* "saved" is a counterfactual estimate (baseline − actual), never a measured
|
|
14
|
+
* delta. Callers are responsible for labeling it as such in the UI.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const { resolvePrice } = require('./pricing');
|
|
18
|
+
|
|
19
|
+
const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Normalize one raw record into a canonical shape.
|
|
23
|
+
* @param {object} rec
|
|
24
|
+
*/
|
|
25
|
+
function normalize(rec) {
|
|
26
|
+
const baseline = num(rec.baselineTokens != null ? rec.baselineTokens : rec.rawTokens);
|
|
27
|
+
const actual = num(rec.actualTokens != null ? rec.actualTokens : rec.finalTokens);
|
|
28
|
+
const saved = rec.savedTokens != null ? num(rec.savedTokens) : Math.max(0, baseline - actual);
|
|
29
|
+
const savedPct = rec.savedPct != null
|
|
30
|
+
? num(rec.savedPct)
|
|
31
|
+
: rec.reductionPct != null
|
|
32
|
+
? num(rec.reductionPct)
|
|
33
|
+
: baseline > 0 ? (saved / baseline) * 100 : 0;
|
|
34
|
+
return {
|
|
35
|
+
ts: rec.ts || null,
|
|
36
|
+
op: normalizeOp(rec.op),
|
|
37
|
+
baseline,
|
|
38
|
+
actual,
|
|
39
|
+
saved,
|
|
40
|
+
savedPct: clamp(savedPct, 0, 100),
|
|
41
|
+
durationMs: num(rec.durationMs),
|
|
42
|
+
model: rec.model || null,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizeOp(op) {
|
|
47
|
+
if (!op) return 'generate';
|
|
48
|
+
return String(op);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function num(v) {
|
|
52
|
+
const n = Number(v);
|
|
53
|
+
return Number.isFinite(n) ? n : 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Parse a --since value into a cutoff Date (or null for "all time").
|
|
58
|
+
* Accepts: "7d", "30d", "12h", or an ISO date "2026-06-01".
|
|
59
|
+
* @param {string} since
|
|
60
|
+
* @param {number} [nowMs] - injectable clock for tests
|
|
61
|
+
* @returns {Date|null}
|
|
62
|
+
*/
|
|
63
|
+
function parseSince(since, nowMs) {
|
|
64
|
+
if (!since) return null;
|
|
65
|
+
const now = nowMs != null ? nowMs : Date.now();
|
|
66
|
+
const rel = /^(\d+)([dhw])$/.exec(String(since).trim());
|
|
67
|
+
if (rel) {
|
|
68
|
+
const n = parseInt(rel[1], 10);
|
|
69
|
+
const unit = rel[2];
|
|
70
|
+
const ms = unit === 'h' ? 3.6e6 : unit === 'w' ? 6.048e8 : 8.64e7;
|
|
71
|
+
return new Date(now - n * ms);
|
|
72
|
+
}
|
|
73
|
+
const d = new Date(since);
|
|
74
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Bucket records by calendar granularity.
|
|
79
|
+
* @param {object[]} records - normalized records
|
|
80
|
+
* @param {'day'|'week'|'month'} granularity
|
|
81
|
+
* @returns {Array<{key,count,baseline,actual,saved,savedPct,ms}>} ascending by key
|
|
82
|
+
*/
|
|
83
|
+
function bucketBy(records, granularity) {
|
|
84
|
+
const map = new Map();
|
|
85
|
+
for (const r of records) {
|
|
86
|
+
if (!r.ts) continue;
|
|
87
|
+
const key = bucketKey(r.ts, granularity);
|
|
88
|
+
if (!key) continue;
|
|
89
|
+
let b = map.get(key);
|
|
90
|
+
if (!b) { b = { key, count: 0, baseline: 0, actual: 0, saved: 0, ms: 0 }; map.set(key, b); }
|
|
91
|
+
b.count += 1;
|
|
92
|
+
b.baseline += r.baseline;
|
|
93
|
+
b.actual += r.actual;
|
|
94
|
+
b.saved += r.saved;
|
|
95
|
+
b.ms += r.durationMs;
|
|
96
|
+
}
|
|
97
|
+
return [...map.values()]
|
|
98
|
+
.map((b) => ({ ...b, savedPct: b.baseline > 0 ? clamp((b.saved / b.baseline) * 100, 0, 100) : 0 }))
|
|
99
|
+
.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function bucketKey(ts, granularity) {
|
|
103
|
+
const d = new Date(ts);
|
|
104
|
+
if (Number.isNaN(d.getTime())) return null;
|
|
105
|
+
const y = d.getUTCFullYear();
|
|
106
|
+
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
|
|
107
|
+
const day = String(d.getUTCDate()).padStart(2, '0');
|
|
108
|
+
if (granularity === 'month') return `${y}-${m}`;
|
|
109
|
+
if (granularity === 'week') {
|
|
110
|
+
// ISO-ish: key by the Monday (UTC) of that week.
|
|
111
|
+
const tmp = new Date(Date.UTC(y, d.getUTCMonth(), d.getUTCDate()));
|
|
112
|
+
const dow = (tmp.getUTCDay() + 6) % 7; // 0 = Monday
|
|
113
|
+
tmp.setUTCDate(tmp.getUTCDate() - dow);
|
|
114
|
+
return `${tmp.getUTCFullYear()}-${String(tmp.getUTCMonth() + 1).padStart(2, '0')}-${String(tmp.getUTCDate()).padStart(2, '0')}`;
|
|
115
|
+
}
|
|
116
|
+
return `${y}-${m}-${day}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Full aggregation for the `gain` dashboard.
|
|
121
|
+
* @param {object[]} rawRecords
|
|
122
|
+
* @param {object} [opts]
|
|
123
|
+
* @param {string} [opts.model] pricing model
|
|
124
|
+
* @param {string} [opts.since] window filter
|
|
125
|
+
* @param {number} [opts.top] limit byOp rows (0 = all)
|
|
126
|
+
* @param {number} [opts.nowMs] injectable clock
|
|
127
|
+
* @returns {object}
|
|
128
|
+
*/
|
|
129
|
+
function aggregate(rawRecords, opts = {}) {
|
|
130
|
+
const price = resolvePrice(opts.model);
|
|
131
|
+
const cutoff = parseSince(opts.since, opts.nowMs);
|
|
132
|
+
|
|
133
|
+
let records = (rawRecords || []).map(normalize);
|
|
134
|
+
if (cutoff) records = records.filter((r) => r.ts && new Date(r.ts) >= cutoff);
|
|
135
|
+
|
|
136
|
+
const totals = {
|
|
137
|
+
count: records.length,
|
|
138
|
+
baseline: 0,
|
|
139
|
+
actual: 0,
|
|
140
|
+
saved: 0,
|
|
141
|
+
totalMs: 0,
|
|
142
|
+
savedPct: 0,
|
|
143
|
+
avgMs: 0,
|
|
144
|
+
usdSaved: 0,
|
|
145
|
+
firstTs: null,
|
|
146
|
+
lastTs: null,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const opMap = new Map();
|
|
150
|
+
for (const r of records) {
|
|
151
|
+
totals.baseline += r.baseline;
|
|
152
|
+
totals.actual += r.actual;
|
|
153
|
+
totals.saved += r.saved;
|
|
154
|
+
totals.totalMs += r.durationMs;
|
|
155
|
+
if (r.ts) {
|
|
156
|
+
if (!totals.firstTs || r.ts < totals.firstTs) totals.firstTs = r.ts;
|
|
157
|
+
if (!totals.lastTs || r.ts > totals.lastTs) totals.lastTs = r.ts;
|
|
158
|
+
}
|
|
159
|
+
let o = opMap.get(r.op);
|
|
160
|
+
if (!o) { o = { op: r.op, count: 0, baseline: 0, saved: 0, ms: 0 }; opMap.set(r.op, o); }
|
|
161
|
+
o.count += 1;
|
|
162
|
+
o.baseline += r.baseline;
|
|
163
|
+
o.saved += r.saved;
|
|
164
|
+
o.ms += r.durationMs;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
totals.savedPct = totals.baseline > 0 ? clamp((totals.saved / totals.baseline) * 100, 0, 100) : 0;
|
|
168
|
+
totals.avgMs = totals.count > 0 ? Math.round(totals.totalMs / totals.count) : 0;
|
|
169
|
+
totals.usdSaved = totals.saved * price.perToken;
|
|
170
|
+
|
|
171
|
+
let byOp = [...opMap.values()].map((o) => ({
|
|
172
|
+
op: o.op,
|
|
173
|
+
count: o.count,
|
|
174
|
+
saved: o.saved,
|
|
175
|
+
avgPct: o.baseline > 0 ? clamp((o.saved / o.baseline) * 100, 0, 100) : 0,
|
|
176
|
+
avgMs: o.count > 0 ? Math.round(o.ms / o.count) : 0,
|
|
177
|
+
usdSaved: o.saved * price.perToken,
|
|
178
|
+
sharePct: totals.saved > 0 ? (o.saved / totals.saved) * 100 : 0,
|
|
179
|
+
})).sort((a, b) => b.saved - a.saved);
|
|
180
|
+
|
|
181
|
+
if (opts.top && opts.top > 0) byOp = byOp.slice(0, opts.top);
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
price,
|
|
185
|
+
totals,
|
|
186
|
+
byOp,
|
|
187
|
+
buckets: {
|
|
188
|
+
daily: bucketBy(records, 'day'),
|
|
189
|
+
weekly: bucketBy(records, 'week'),
|
|
190
|
+
monthly: bucketBy(records, 'month'),
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
module.exports = { aggregate, bucketBy, parseSince, normalize };
|
package/src/tracking/logger.js
CHANGED
|
@@ -18,6 +18,9 @@ const fs = require('fs');
|
|
|
18
18
|
const path = require('path');
|
|
19
19
|
|
|
20
20
|
const LOG_FILE = path.join('.context', 'usage.ndjson');
|
|
21
|
+
// Dedicated log for the `gain` dashboard (extended schema). Kept separate from
|
|
22
|
+
// usage.ndjson so the legacy health/nudge history never collides with it.
|
|
23
|
+
const GAIN_FILE = path.join('.context', 'gain.ndjson');
|
|
21
24
|
|
|
22
25
|
/**
|
|
23
26
|
* Append one run entry to the usage log.
|
|
@@ -73,6 +76,25 @@ function readLog(cwd) {
|
|
|
73
76
|
}
|
|
74
77
|
}
|
|
75
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Read and parse all `gain` dashboard records (oldest first).
|
|
81
|
+
* @param {string} cwd
|
|
82
|
+
* @returns {object[]}
|
|
83
|
+
*/
|
|
84
|
+
function readGainLog(cwd) {
|
|
85
|
+
try {
|
|
86
|
+
const logPath = path.join(cwd, GAIN_FILE);
|
|
87
|
+
if (!fs.existsSync(logPath)) return [];
|
|
88
|
+
return fs.readFileSync(logPath, 'utf8')
|
|
89
|
+
.split('\n')
|
|
90
|
+
.filter(Boolean)
|
|
91
|
+
.map((line) => { try { return JSON.parse(line); } catch (_) { return null; } })
|
|
92
|
+
.filter(Boolean);
|
|
93
|
+
} catch (_) {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
76
98
|
/**
|
|
77
99
|
* Compute summary statistics from an array of log records.
|
|
78
100
|
* @param {object[]} entries
|
|
@@ -112,4 +134,64 @@ function summarize(entries) {
|
|
|
112
134
|
};
|
|
113
135
|
}
|
|
114
136
|
|
|
115
|
-
|
|
137
|
+
/**
|
|
138
|
+
* Whether `gain` savings capture is enabled. Default: ON (privacy-safe,
|
|
139
|
+
* local-only, counts only — no paths, source, or query text). This is
|
|
140
|
+
* intentionally decoupled from the legacy `config.tracking` flag (which gates
|
|
141
|
+
* the usage.ndjson health log and defaults OFF). Opt out of gain capture via:
|
|
142
|
+
* config.gainTracking === false · --no-track · SIGMAP_NO_TRACK=1
|
|
143
|
+
* @param {object} [config]
|
|
144
|
+
* @param {string[]} [argv]
|
|
145
|
+
* @returns {boolean}
|
|
146
|
+
*/
|
|
147
|
+
function isTrackingEnabled(config, argv) {
|
|
148
|
+
const a = argv || (typeof process !== 'undefined' ? process.argv : []);
|
|
149
|
+
if (process.env && process.env.SIGMAP_NO_TRACK) return false;
|
|
150
|
+
if (a && a.includes('--no-track')) return false;
|
|
151
|
+
if (config && config.gainTracking === false) return false;
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Append one operation to the usage log using the extended `gain` schema.
|
|
157
|
+
* Reuses the same NDJSON file as logRun and is tolerant of partial input.
|
|
158
|
+
* Never throws — tracking must never break the main process.
|
|
159
|
+
*
|
|
160
|
+
* @param {object} entry
|
|
161
|
+
* @param {string} entry.op e.g. 'ask' | 'generate' | 'query' | 'mcp:get_map'
|
|
162
|
+
* @param {number} entry.baselineTokens whole-file / candidate baseline (counterfactual)
|
|
163
|
+
* @param {number} entry.actualTokens tokens SigMap actually emitted
|
|
164
|
+
* @param {number} [entry.durationMs]
|
|
165
|
+
* @param {string} [entry.model]
|
|
166
|
+
* @param {string} [entry.version]
|
|
167
|
+
* @param {string} cwd
|
|
168
|
+
*/
|
|
169
|
+
function recordUsage(entry, cwd) {
|
|
170
|
+
try {
|
|
171
|
+
const logPath = path.join(cwd, GAIN_FILE);
|
|
172
|
+
const dir = path.dirname(logPath);
|
|
173
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
174
|
+
|
|
175
|
+
const baseline = Math.max(0, Number(entry.baselineTokens) || 0);
|
|
176
|
+
const actual = Math.max(0, Number(entry.actualTokens) || 0);
|
|
177
|
+
const saved = Math.max(0, baseline - actual);
|
|
178
|
+
const record = {
|
|
179
|
+
ts: new Date().toISOString(),
|
|
180
|
+
v: entry.version || '0.9.0',
|
|
181
|
+
op: entry.op || 'generate',
|
|
182
|
+
baselineTokens: baseline,
|
|
183
|
+
actualTokens: actual,
|
|
184
|
+
savedTokens: saved,
|
|
185
|
+
savedPct: baseline > 0 ? parseFloat(((saved / baseline) * 100).toFixed(1)) : 0,
|
|
186
|
+
durationMs: Math.max(0, Math.round(Number(entry.durationMs) || 0)),
|
|
187
|
+
model: entry.model || null,
|
|
188
|
+
ok: entry.ok !== false,
|
|
189
|
+
};
|
|
190
|
+
fs.appendFileSync(logPath, JSON.stringify(record) + '\n', 'utf8');
|
|
191
|
+
} catch (err) {
|
|
192
|
+
// Never crash the main process — tracking is optional.
|
|
193
|
+
if (process.stderr) process.stderr.write(`[sigmap] tracking: could not write log: ${err.message}\n`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
module.exports = { logRun, recordUsage, readLog, readGainLog, summarize, isTrackingEnabled, GAIN_FILE };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SigMap pricing table — input-token $/Mtok assumptions for the `gain` dashboard.
|
|
5
|
+
*
|
|
6
|
+
* These are ASSUMPTIONS used only to translate "tokens saved" into an estimated
|
|
7
|
+
* dollar figure. They are deliberately conservative and configurable via
|
|
8
|
+
* --model <name> or config.pricingModel
|
|
9
|
+
* The `gain` views always print the model + rate inline so the $ is never
|
|
10
|
+
* presented as exact. Zero npm dependencies.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// USD per 1,000,000 input tokens.
|
|
14
|
+
const PRICES = {
|
|
15
|
+
'claude-sonnet': 3.0,
|
|
16
|
+
'claude-opus': 15.0,
|
|
17
|
+
'claude-haiku': 0.8,
|
|
18
|
+
'gpt-4o': 2.5,
|
|
19
|
+
'gpt-4o-mini': 0.15,
|
|
20
|
+
'gemini-1.5-pro': 1.25,
|
|
21
|
+
'gemini-1.5-flash': 0.075,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const DEFAULT_MODEL = 'claude-sonnet';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve a price (USD per token) for a model name.
|
|
28
|
+
* @param {string} [model]
|
|
29
|
+
* @returns {{ model: string, perMtok: number, perToken: number }}
|
|
30
|
+
*/
|
|
31
|
+
function resolvePrice(model) {
|
|
32
|
+
const key = (model || DEFAULT_MODEL).toLowerCase();
|
|
33
|
+
const perMtok = PRICES[key] != null ? PRICES[key] : PRICES[DEFAULT_MODEL];
|
|
34
|
+
const resolved = PRICES[key] != null ? key : DEFAULT_MODEL;
|
|
35
|
+
return { model: resolved, perMtok, perToken: perMtok / 1_000_000 };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** @returns {string[]} known model keys */
|
|
39
|
+
function listModels() {
|
|
40
|
+
return Object.keys(PRICES);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = { PRICES, DEFAULT_MODEL, resolvePrice, listModels };
|
package/src/util/git.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shell-free git invocation.
|
|
5
|
+
*
|
|
6
|
+
* Uses `execFileSync('git', [...])`, which executes the git binary directly —
|
|
7
|
+
* it never spawns a system shell (`/bin/sh -c`). That means:
|
|
8
|
+
* - no shell-injection surface (arguments are passed as an array, never
|
|
9
|
+
* interpolated into a command string), and
|
|
10
|
+
* - supply-chain scanners (e.g. Socket) do not flag a "Shell access" capability.
|
|
11
|
+
*
|
|
12
|
+
* stderr is discarded by default (replaces the old `2>/dev/null` redirects).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { execFileSync } = require('child_process');
|
|
16
|
+
|
|
17
|
+
function git(args, opts = {}) {
|
|
18
|
+
return execFileSync('git', args, {
|
|
19
|
+
encoding: 'utf8',
|
|
20
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
21
|
+
...opts,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Convenience: run git and return trimmed stdout, or '' on any failure.
|
|
26
|
+
function tryGit(args, opts = {}) {
|
|
27
|
+
try { return git(args, opts).toString().trim(); }
|
|
28
|
+
catch (_) { return ''; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
module.exports = { git, tryGit };
|