sigmap 7.0.1 → 7.2.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/gen-context.js CHANGED
@@ -6254,7 +6254,7 @@ const { readContext, searchSignatures, getMap, createCheckpoint, getRouting, exp
6254
6254
 
6255
6255
  const SERVER_INFO = {
6256
6256
  name: 'sigmap',
6257
- version: '7.0.1',
6257
+ version: '7.2.0',
6258
6258
  description: 'SigMap MCP server — code signatures on demand',
6259
6259
  };
6260
6260
 
@@ -7023,12 +7023,15 @@ __factories["./src/tracking/logger"] = function(module, exports) {
7023
7023
  * config.tracking: true (gen-context.config.json)
7024
7024
  * --track CLI flag
7025
7025
  */
7026
-
7026
+
7027
7027
  const fs = require('fs');
7028
7028
  const path = require('path');
7029
-
7029
+
7030
7030
  const LOG_FILE = path.join('.context', 'usage.ndjson');
7031
-
7031
+ // Dedicated log for the `gain` dashboard (extended schema). Kept separate from
7032
+ // usage.ndjson so the legacy health/nudge history never collides with it.
7033
+ const GAIN_FILE = path.join('.context', 'gain.ndjson');
7034
+
7032
7035
  /**
7033
7036
  * Append one run entry to the usage log.
7034
7037
  * @param {object} entry - Run metrics from runGenerate()
@@ -7039,7 +7042,7 @@ __factories["./src/tracking/logger"] = function(module, exports) {
7039
7042
  const logPath = path.join(cwd, LOG_FILE);
7040
7043
  const dir = path.dirname(logPath);
7041
7044
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
7042
-
7045
+
7043
7046
  const record = {
7044
7047
  ts: new Date().toISOString(),
7045
7048
  version: entry.version || '0.9.0',
@@ -7053,14 +7056,14 @@ __factories["./src/tracking/logger"] = function(module, exports) {
7053
7056
  overBudget: entry.overBudget || false,
7054
7057
  budgetLimit: entry.budgetLimit || 6000,
7055
7058
  };
7056
-
7059
+
7057
7060
  fs.appendFileSync(logPath, JSON.stringify(record) + '\n', 'utf8');
7058
7061
  } catch (err) {
7059
7062
  // Never crash the main process — tracking is optional
7060
7063
  process.stderr.write(`[sigmap] tracking: could not write log: ${err.message}\n`);
7061
7064
  }
7062
7065
  }
7063
-
7066
+
7064
7067
  /**
7065
7068
  * Read and parse all usage log entries.
7066
7069
  * @param {string} cwd - Project root (absolute path)
@@ -7082,7 +7085,26 @@ __factories["./src/tracking/logger"] = function(module, exports) {
7082
7085
  return [];
7083
7086
  }
7084
7087
  }
7085
-
7088
+
7089
+ /**
7090
+ * Read and parse all `gain` dashboard records (oldest first).
7091
+ * @param {string} cwd
7092
+ * @returns {object[]}
7093
+ */
7094
+ function readGainLog(cwd) {
7095
+ try {
7096
+ const logPath = path.join(cwd, GAIN_FILE);
7097
+ if (!fs.existsSync(logPath)) return [];
7098
+ return fs.readFileSync(logPath, 'utf8')
7099
+ .split('\n')
7100
+ .filter(Boolean)
7101
+ .map((line) => { try { return JSON.parse(line); } catch (_) { return null; } })
7102
+ .filter(Boolean);
7103
+ } catch (_) {
7104
+ return [];
7105
+ }
7106
+ }
7107
+
7086
7108
  /**
7087
7109
  * Compute summary statistics from an array of log records.
7088
7110
  * @param {object[]} entries
@@ -7102,13 +7124,13 @@ __factories["./src/tracking/logger"] = function(module, exports) {
7102
7124
  overBudgetRuns: 0,
7103
7125
  };
7104
7126
  }
7105
-
7127
+
7106
7128
  const reductions = entries.map((e) => e.reductionPct || 0);
7107
7129
  const finals = entries.map((e) => e.finalTokens || 0);
7108
7130
  const raws = entries.map((e) => e.rawTokens || 0);
7109
-
7131
+
7110
7132
  const avg = (arr) => arr.reduce((a, b) => a + b, 0) / arr.length;
7111
-
7133
+
7112
7134
  return {
7113
7135
  totalRuns: entries.length,
7114
7136
  avgReductionPct: parseFloat(avg(reductions).toFixed(1)),
@@ -7121,8 +7143,524 @@ __factories["./src/tracking/logger"] = function(module, exports) {
7121
7143
  overBudgetRuns: entries.filter((e) => e.overBudget).length,
7122
7144
  };
7123
7145
  }
7146
+
7147
+ /**
7148
+ * Whether `gain` savings capture is enabled. Default: ON (privacy-safe,
7149
+ * local-only, counts only — no paths, source, or query text). This is
7150
+ * intentionally decoupled from the legacy `config.tracking` flag (which gates
7151
+ * the usage.ndjson health log and defaults OFF). Opt out of gain capture via:
7152
+ * config.gainTracking === false · --no-track · SIGMAP_NO_TRACK=1
7153
+ * @param {object} [config]
7154
+ * @param {string[]} [argv]
7155
+ * @returns {boolean}
7156
+ */
7157
+ function isTrackingEnabled(config, argv) {
7158
+ const a = argv || (typeof process !== 'undefined' ? process.argv : []);
7159
+ if (process.env && process.env.SIGMAP_NO_TRACK) return false;
7160
+ if (a && a.includes('--no-track')) return false;
7161
+ if (config && config.gainTracking === false) return false;
7162
+ return true;
7163
+ }
7164
+
7165
+ /**
7166
+ * Append one operation to the usage log using the extended `gain` schema.
7167
+ * Reuses the same NDJSON file as logRun and is tolerant of partial input.
7168
+ * Never throws — tracking must never break the main process.
7169
+ *
7170
+ * @param {object} entry
7171
+ * @param {string} entry.op e.g. 'ask' | 'generate' | 'query' | 'mcp:get_map'
7172
+ * @param {number} entry.baselineTokens whole-file / candidate baseline (counterfactual)
7173
+ * @param {number} entry.actualTokens tokens SigMap actually emitted
7174
+ * @param {number} [entry.durationMs]
7175
+ * @param {string} [entry.model]
7176
+ * @param {string} [entry.version]
7177
+ * @param {string} cwd
7178
+ */
7179
+ function recordUsage(entry, cwd) {
7180
+ try {
7181
+ const logPath = path.join(cwd, GAIN_FILE);
7182
+ const dir = path.dirname(logPath);
7183
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
7184
+
7185
+ const baseline = Math.max(0, Number(entry.baselineTokens) || 0);
7186
+ const actual = Math.max(0, Number(entry.actualTokens) || 0);
7187
+ const saved = Math.max(0, baseline - actual);
7188
+ const record = {
7189
+ ts: new Date().toISOString(),
7190
+ v: entry.version || '0.9.0',
7191
+ op: entry.op || 'generate',
7192
+ baselineTokens: baseline,
7193
+ actualTokens: actual,
7194
+ savedTokens: saved,
7195
+ savedPct: baseline > 0 ? parseFloat(((saved / baseline) * 100).toFixed(1)) : 0,
7196
+ durationMs: Math.max(0, Math.round(Number(entry.durationMs) || 0)),
7197
+ model: entry.model || null,
7198
+ ok: entry.ok !== false,
7199
+ };
7200
+ fs.appendFileSync(logPath, JSON.stringify(record) + '\n', 'utf8');
7201
+ } catch (err) {
7202
+ // Never crash the main process — tracking is optional.
7203
+ if (process.stderr) process.stderr.write(`[sigmap] tracking: could not write log: ${err.message}\n`);
7204
+ }
7205
+ }
7206
+
7207
+ module.exports = { logRun, recordUsage, readLog, readGainLog, summarize, isTrackingEnabled, GAIN_FILE };
7208
+
7209
+ };
7210
+
7211
+ // ── ./src/tracking/pricing ──
7212
+ __factories["./src/tracking/pricing"] = function(module, exports) {
7213
+
7214
+ /**
7215
+ * SigMap pricing table — input-token $/Mtok assumptions for the `gain` dashboard.
7216
+ *
7217
+ * These are ASSUMPTIONS used only to translate "tokens saved" into an estimated
7218
+ * dollar figure. They are deliberately conservative and configurable via
7219
+ * --model <name> or config.pricingModel
7220
+ * The `gain` views always print the model + rate inline so the $ is never
7221
+ * presented as exact. Zero npm dependencies.
7222
+ */
7223
+
7224
+ // USD per 1,000,000 input tokens.
7225
+ const PRICES = {
7226
+ 'claude-sonnet': 3.0,
7227
+ 'claude-opus': 15.0,
7228
+ 'claude-haiku': 0.8,
7229
+ 'gpt-4o': 2.5,
7230
+ 'gpt-4o-mini': 0.15,
7231
+ 'gemini-1.5-pro': 1.25,
7232
+ 'gemini-1.5-flash': 0.075,
7233
+ };
7234
+
7235
+ const DEFAULT_MODEL = 'claude-sonnet';
7236
+
7237
+ /**
7238
+ * Resolve a price (USD per token) for a model name.
7239
+ * @param {string} [model]
7240
+ * @returns {{ model: string, perMtok: number, perToken: number }}
7241
+ */
7242
+ function resolvePrice(model) {
7243
+ const key = (model || DEFAULT_MODEL).toLowerCase();
7244
+ const perMtok = PRICES[key] != null ? PRICES[key] : PRICES[DEFAULT_MODEL];
7245
+ const resolved = PRICES[key] != null ? key : DEFAULT_MODEL;
7246
+ return { model: resolved, perMtok, perToken: perMtok / 1_000_000 };
7247
+ }
7248
+
7249
+ /** @returns {string[]} known model keys */
7250
+ function listModels() {
7251
+ return Object.keys(PRICES);
7252
+ }
7253
+
7254
+ module.exports = { PRICES, DEFAULT_MODEL, resolvePrice, listModels };
7255
+
7256
+ };
7257
+
7258
+ // ── ./src/tracking/aggregate ──
7259
+ __factories["./src/tracking/aggregate"] = function(module, exports) {
7260
+
7261
+ /**
7262
+ * SigMap usage aggregation for the `gain` dashboard.
7263
+ *
7264
+ * Pure, zero-dependency functions that turn raw NDJSON usage records into the
7265
+ * totals / by-operation / time-bucket shapes the terminal renderer consumes.
7266
+ *
7267
+ * Tolerant of BOTH schemas:
7268
+ * - new: { op, baselineTokens, actualTokens, savedTokens, savedPct, durationMs, model }
7269
+ * - legacy: { rawTokens, finalTokens, reductionPct } (from logger.js v0.9)
7270
+ *
7271
+ * "saved" is a counterfactual estimate (baseline − actual), never a measured
7272
+ * delta. Callers are responsible for labeling it as such in the UI.
7273
+ */
7274
+
7275
+ const { resolvePrice } = __require('./src/tracking/pricing');
7276
+
7277
+ const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
7278
+
7279
+ /**
7280
+ * Normalize one raw record into a canonical shape.
7281
+ * @param {object} rec
7282
+ */
7283
+ function normalize(rec) {
7284
+ const baseline = num(rec.baselineTokens != null ? rec.baselineTokens : rec.rawTokens);
7285
+ const actual = num(rec.actualTokens != null ? rec.actualTokens : rec.finalTokens);
7286
+ const saved = rec.savedTokens != null ? num(rec.savedTokens) : Math.max(0, baseline - actual);
7287
+ const savedPct = rec.savedPct != null
7288
+ ? num(rec.savedPct)
7289
+ : rec.reductionPct != null
7290
+ ? num(rec.reductionPct)
7291
+ : baseline > 0 ? (saved / baseline) * 100 : 0;
7292
+ return {
7293
+ ts: rec.ts || null,
7294
+ op: normalizeOp(rec.op),
7295
+ baseline,
7296
+ actual,
7297
+ saved,
7298
+ savedPct: clamp(savedPct, 0, 100),
7299
+ durationMs: num(rec.durationMs),
7300
+ model: rec.model || null,
7301
+ };
7302
+ }
7303
+
7304
+ function normalizeOp(op) {
7305
+ if (!op) return 'generate';
7306
+ return String(op);
7307
+ }
7308
+
7309
+ function num(v) {
7310
+ const n = Number(v);
7311
+ return Number.isFinite(n) ? n : 0;
7312
+ }
7313
+
7314
+ /**
7315
+ * Parse a --since value into a cutoff Date (or null for "all time").
7316
+ * Accepts: "7d", "30d", "12h", or an ISO date "2026-06-01".
7317
+ * @param {string} since
7318
+ * @param {number} [nowMs] - injectable clock for tests
7319
+ * @returns {Date|null}
7320
+ */
7321
+ function parseSince(since, nowMs) {
7322
+ if (!since) return null;
7323
+ const now = nowMs != null ? nowMs : Date.now();
7324
+ const rel = /^(\d+)([dhw])$/.exec(String(since).trim());
7325
+ if (rel) {
7326
+ const n = parseInt(rel[1], 10);
7327
+ const unit = rel[2];
7328
+ const ms = unit === 'h' ? 3.6e6 : unit === 'w' ? 6.048e8 : 8.64e7;
7329
+ return new Date(now - n * ms);
7330
+ }
7331
+ const d = new Date(since);
7332
+ return Number.isNaN(d.getTime()) ? null : d;
7333
+ }
7334
+
7335
+ /**
7336
+ * Bucket records by calendar granularity.
7337
+ * @param {object[]} records - normalized records
7338
+ * @param {'day'|'week'|'month'} granularity
7339
+ * @returns {Array<{key,count,baseline,actual,saved,savedPct,ms}>} ascending by key
7340
+ */
7341
+ function bucketBy(records, granularity) {
7342
+ const map = new Map();
7343
+ for (const r of records) {
7344
+ if (!r.ts) continue;
7345
+ const key = bucketKey(r.ts, granularity);
7346
+ if (!key) continue;
7347
+ let b = map.get(key);
7348
+ if (!b) { b = { key, count: 0, baseline: 0, actual: 0, saved: 0, ms: 0 }; map.set(key, b); }
7349
+ b.count += 1;
7350
+ b.baseline += r.baseline;
7351
+ b.actual += r.actual;
7352
+ b.saved += r.saved;
7353
+ b.ms += r.durationMs;
7354
+ }
7355
+ return [...map.values()]
7356
+ .map((b) => ({ ...b, savedPct: b.baseline > 0 ? clamp((b.saved / b.baseline) * 100, 0, 100) : 0 }))
7357
+ .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
7358
+ }
7359
+
7360
+ function bucketKey(ts, granularity) {
7361
+ const d = new Date(ts);
7362
+ if (Number.isNaN(d.getTime())) return null;
7363
+ const y = d.getUTCFullYear();
7364
+ const m = String(d.getUTCMonth() + 1).padStart(2, '0');
7365
+ const day = String(d.getUTCDate()).padStart(2, '0');
7366
+ if (granularity === 'month') return `${y}-${m}`;
7367
+ if (granularity === 'week') {
7368
+ // ISO-ish: key by the Monday (UTC) of that week.
7369
+ const tmp = new Date(Date.UTC(y, d.getUTCMonth(), d.getUTCDate()));
7370
+ const dow = (tmp.getUTCDay() + 6) % 7; // 0 = Monday
7371
+ tmp.setUTCDate(tmp.getUTCDate() - dow);
7372
+ return `${tmp.getUTCFullYear()}-${String(tmp.getUTCMonth() + 1).padStart(2, '0')}-${String(tmp.getUTCDate()).padStart(2, '0')}`;
7373
+ }
7374
+ return `${y}-${m}-${day}`;
7375
+ }
7376
+
7377
+ /**
7378
+ * Full aggregation for the `gain` dashboard.
7379
+ * @param {object[]} rawRecords
7380
+ * @param {object} [opts]
7381
+ * @param {string} [opts.model] pricing model
7382
+ * @param {string} [opts.since] window filter
7383
+ * @param {number} [opts.top] limit byOp rows (0 = all)
7384
+ * @param {number} [opts.nowMs] injectable clock
7385
+ * @returns {object}
7386
+ */
7387
+ function aggregate(rawRecords, opts = {}) {
7388
+ const price = resolvePrice(opts.model);
7389
+ const cutoff = parseSince(opts.since, opts.nowMs);
7390
+
7391
+ let records = (rawRecords || []).map(normalize);
7392
+ if (cutoff) records = records.filter((r) => r.ts && new Date(r.ts) >= cutoff);
7393
+
7394
+ const totals = {
7395
+ count: records.length,
7396
+ baseline: 0,
7397
+ actual: 0,
7398
+ saved: 0,
7399
+ totalMs: 0,
7400
+ savedPct: 0,
7401
+ avgMs: 0,
7402
+ usdSaved: 0,
7403
+ firstTs: null,
7404
+ lastTs: null,
7405
+ };
7406
+
7407
+ const opMap = new Map();
7408
+ for (const r of records) {
7409
+ totals.baseline += r.baseline;
7410
+ totals.actual += r.actual;
7411
+ totals.saved += r.saved;
7412
+ totals.totalMs += r.durationMs;
7413
+ if (r.ts) {
7414
+ if (!totals.firstTs || r.ts < totals.firstTs) totals.firstTs = r.ts;
7415
+ if (!totals.lastTs || r.ts > totals.lastTs) totals.lastTs = r.ts;
7416
+ }
7417
+ let o = opMap.get(r.op);
7418
+ if (!o) { o = { op: r.op, count: 0, baseline: 0, saved: 0, ms: 0 }; opMap.set(r.op, o); }
7419
+ o.count += 1;
7420
+ o.baseline += r.baseline;
7421
+ o.saved += r.saved;
7422
+ o.ms += r.durationMs;
7423
+ }
7424
+
7425
+ totals.savedPct = totals.baseline > 0 ? clamp((totals.saved / totals.baseline) * 100, 0, 100) : 0;
7426
+ totals.avgMs = totals.count > 0 ? Math.round(totals.totalMs / totals.count) : 0;
7427
+ totals.usdSaved = totals.saved * price.perToken;
7428
+
7429
+ let byOp = [...opMap.values()].map((o) => ({
7430
+ op: o.op,
7431
+ count: o.count,
7432
+ saved: o.saved,
7433
+ avgPct: o.baseline > 0 ? clamp((o.saved / o.baseline) * 100, 0, 100) : 0,
7434
+ avgMs: o.count > 0 ? Math.round(o.ms / o.count) : 0,
7435
+ usdSaved: o.saved * price.perToken,
7436
+ sharePct: totals.saved > 0 ? (o.saved / totals.saved) * 100 : 0,
7437
+ })).sort((a, b) => b.saved - a.saved);
7438
+
7439
+ if (opts.top && opts.top > 0) byOp = byOp.slice(0, opts.top);
7440
+
7441
+ return {
7442
+ price,
7443
+ totals,
7444
+ byOp,
7445
+ buckets: {
7446
+ daily: bucketBy(records, 'day'),
7447
+ weekly: bucketBy(records, 'week'),
7448
+ monthly: bucketBy(records, 'month'),
7449
+ },
7450
+ };
7451
+ }
7452
+
7453
+ module.exports = { aggregate, bucketBy, parseSince, normalize };
7124
7454
 
7125
- module.exports = { logRun, readLog, summarize };
7455
+ };
7456
+
7457
+ // ── ./src/format/gain-terminal ──
7458
+ __factories["./src/format/gain-terminal"] = function(module, exports) {
7459
+
7460
+ /**
7461
+ * SigMap `gain` — terminal renderer.
7462
+ *
7463
+ * Zero-dependency ANSI dashboard for token savings. Honors NO_COLOR and
7464
+ * non-TTY output (plain text for pipes / --json consumers elsewhere).
7465
+ *
7466
+ * "saved" is a counterfactual estimate (whole-file baseline − actual context),
7467
+ * never a measured delta — the footer says so on every view.
7468
+ */
7469
+
7470
+ const USE_COLOR = !process.env.NO_COLOR && process.stdout.isTTY;
7471
+
7472
+ const C = {
7473
+ reset: USE_COLOR ? '\x1b[0m' : '',
7474
+ dim: USE_COLOR ? '\x1b[2m' : '',
7475
+ bold: USE_COLOR ? '\x1b[1m' : '',
7476
+ green: USE_COLOR ? '\x1b[32m' : '',
7477
+ yellow: USE_COLOR ? '\x1b[33m' : '',
7478
+ red: USE_COLOR ? '\x1b[31m' : '',
7479
+ cyan: USE_COLOR ? '\x1b[36m' : '',
7480
+ gray: USE_COLOR ? '\x1b[90m' : '',
7481
+ };
7482
+
7483
+ // ── formatters ───────────────────────────────────────────────────────────
7484
+ function humanTokens(n) {
7485
+ n = Number(n) || 0;
7486
+ if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B';
7487
+ if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
7488
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
7489
+ return String(Math.round(n));
7490
+ }
7491
+
7492
+ function fmtInt(n) {
7493
+ return (Number(n) || 0).toLocaleString('en-US');
7494
+ }
7495
+
7496
+ function fmtUSD(n) {
7497
+ n = Number(n) || 0;
7498
+ if (n >= 1000) return '$' + n.toLocaleString('en-US', { maximumFractionDigits: 0 });
7499
+ return '$' + n.toFixed(2);
7500
+ }
7501
+
7502
+ function fmtDuration(ms) {
7503
+ ms = Number(ms) || 0;
7504
+ if (ms >= 3.6e6) return (ms / 3.6e6).toFixed(1) + 'h';
7505
+ if (ms >= 60000) return (ms / 60000).toFixed(1) + 'm';
7506
+ if (ms >= 1000) return (ms / 1000).toFixed(1) + 's';
7507
+ return Math.round(ms) + 'ms';
7508
+ }
7509
+
7510
+ function fmtPct(p) {
7511
+ return (Number(p) || 0).toFixed(1) + '%';
7512
+ }
7513
+
7514
+ function colorPct(p, text) {
7515
+ if (!USE_COLOR) return text;
7516
+ const c = p >= 85 ? C.green : p >= 60 ? C.yellow : C.red;
7517
+ return c + text + C.reset;
7518
+ }
7519
+
7520
+ function pad(s, w, align) {
7521
+ s = String(s);
7522
+ const visible = s.replace(/\x1b\[[0-9;]*m/g, '');
7523
+ const gap = Math.max(0, w - visible.length);
7524
+ return align === 'right' ? ' '.repeat(gap) + s : s + ' '.repeat(gap);
7525
+ }
7526
+
7527
+ /** Solid horizontal efficiency bar with a dotted remainder. */
7528
+ function bar(pct, width) {
7529
+ const filled = Math.round((clamp(pct, 0, 100) / 100) * width);
7530
+ const solid = '█'.repeat(filled);
7531
+ const rest = '░'.repeat(Math.max(0, width - filled));
7532
+ return (USE_COLOR ? C.green : '') + solid + (USE_COLOR ? C.gray : '') + rest + C.reset;
7533
+ }
7534
+
7535
+ /** Proportional impact bar (share of total saved). */
7536
+ function impactBar(sharePct, width) {
7537
+ const n = Math.max(1, Math.round((clamp(sharePct, 0, 100) / 100) * width));
7538
+ return (USE_COLOR ? C.cyan : '') + '█'.repeat(n) + C.reset;
7539
+ }
7540
+
7541
+ const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
7542
+ const rule = (w) => C.gray + '─'.repeat(w) + C.reset;
7543
+
7544
+ // ── views ──────────────────────────────────────────────────────────────
7545
+ /**
7546
+ * Render the global summary + by-operation table.
7547
+ * @param {object} agg output of aggregate()
7548
+ * @param {object} [opts] { version, scope }
7549
+ * @returns {string}
7550
+ */
7551
+ function renderSummary(agg, opts = {}) {
7552
+ const W = 74;
7553
+ const t = agg.totals;
7554
+ const L = [];
7555
+ const scope = opts.scope || 'this repo';
7556
+ const ver = opts.version ? `v${opts.version}` : '';
7557
+
7558
+ L.push('');
7559
+ const heading = `⚡ SigMap — Token Savings (${scope})`;
7560
+ L.push(' ' + C.bold + C.cyan + heading + C.reset +
7561
+ pad(`${C.green}✓${C.reset} ${C.dim}${ver}${C.reset}`, W - heading.length, 'right'));
7562
+ L.push(' ' + rule(W));
7563
+ L.push('');
7564
+
7565
+ if (t.count === 0) {
7566
+ L.push(` ${C.yellow}No usage recorded yet.${C.reset}`);
7567
+ L.push(` ${C.dim}Run a few queries (sigmap ask "...") or seed a demo:${C.reset}`);
7568
+ L.push(` ${C.dim} node scripts/gain.mjs --demo${C.reset}`);
7569
+ L.push('');
7570
+ return L.join('\n');
7571
+ }
7572
+
7573
+ const label = (k) => C.dim + pad(k, 21) + C.reset;
7574
+ L.push(' ' + label('Total operations') + ': ' + C.bold + fmtInt(t.count) + C.reset);
7575
+ L.push(' ' + label('Whole-file baseline') + ': ' + humanTokens(t.baseline) + ' tok' +
7576
+ ` ${C.dim}← est. cost of feeding full files${C.reset}`);
7577
+ L.push(' ' + label('SigMap context') + ': ' + humanTokens(t.actual) + ' tok');
7578
+ L.push(' ' + label('Tokens saved') + ': ' + C.bold + humanTokens(t.saved) + C.reset +
7579
+ ' (' + colorPct(t.savedPct, fmtPct(t.savedPct)) + ')');
7580
+ L.push(' ' + label('Est. money saved') + ': ' + C.bold + C.green + fmtUSD(t.usdSaved) + C.reset +
7581
+ ` ${C.dim}(${agg.price.model} input @ $${agg.price.perMtok}/M · --model to change)${C.reset}`);
7582
+ L.push(' ' + label('Avg latency') + ': ' + fmtDuration(t.avgMs) + ' / op' +
7583
+ ` ${C.dim}(local, no API round-trip)${C.reset}`);
7584
+ L.push('');
7585
+ L.push(' ' + label('Efficiency') + ': ▕' + bar(t.savedPct, 30) + '▏ ' +
7586
+ colorPct(t.savedPct, C.bold + fmtPct(t.savedPct) + C.reset));
7587
+ L.push('');
7588
+
7589
+ // By operation table
7590
+ L.push(` ${C.bold}By operation${C.reset}`);
7591
+ L.push(' ' + rule(W));
7592
+ L.push(' ' + C.dim +
7593
+ pad('#', 3) + pad('Operation', 24) + pad('Count', 8, 'right') +
7594
+ pad('Saved', 9, 'right') + pad('Avg%', 8, 'right') + pad('Time', 8, 'right') +
7595
+ ' Impact' + C.reset);
7596
+ agg.byOp.forEach((o, i) => {
7597
+ L.push(' ' +
7598
+ pad(`${i + 1}.`, 3) +
7599
+ pad(o.op, 24) +
7600
+ pad(fmtInt(o.count), 8, 'right') +
7601
+ pad(humanTokens(o.saved), 9, 'right') +
7602
+ pad(colorPct(o.avgPct, fmtPct(o.avgPct)), 8, 'right') +
7603
+ pad(fmtDuration(o.avgMs), 8, 'right') +
7604
+ ' ' + impactBar(o.sharePct, 12));
7605
+ });
7606
+ L.push(' ' + rule(W));
7607
+ L.push(` ${C.dim}saved = baseline − actual · estimated vs whole-file reads · local-only${C.reset}`);
7608
+ L.push('');
7609
+ return L.join('\n');
7610
+ }
7611
+
7612
+ /**
7613
+ * Render daily / weekly / monthly trend tables.
7614
+ * @param {object} agg
7615
+ * @returns {string}
7616
+ */
7617
+ function renderBreakdown(agg) {
7618
+ const L = [];
7619
+ if (agg.totals.count === 0) return renderSummary(agg);
7620
+
7621
+ const section = (icon, title, rows, keyHeader) => {
7622
+ L.push('');
7623
+ L.push(` ${C.bold}${icon} ${title}${C.reset}`);
7624
+ L.push(' ' + C.dim +
7625
+ pad(keyHeader, 14) + pad('Ops', 7, 'right') + pad('Baseline', 11, 'right') +
7626
+ pad('Actual', 10, 'right') + pad('Saved', 10, 'right') + pad('Save%', 8, 'right') +
7627
+ pad('Time', 8, 'right') + C.reset);
7628
+ let TB = 0, TA = 0, TS = 0, TC = 0, TM = 0;
7629
+ for (const r of rows) {
7630
+ TB += r.baseline; TA += r.actual; TS += r.saved; TC += r.count; TM += r.ms;
7631
+ L.push(' ' +
7632
+ pad(r.key, 14) + pad(fmtInt(r.count), 7, 'right') +
7633
+ pad(humanTokens(r.baseline), 11, 'right') + pad(humanTokens(r.actual), 10, 'right') +
7634
+ pad(humanTokens(r.saved), 10, 'right') +
7635
+ pad(colorPct(r.savedPct, fmtPct(r.savedPct)), 8, 'right') +
7636
+ pad(fmtDuration(r.ms), 8, 'right'));
7637
+ }
7638
+ const tp = TB > 0 ? (TS / TB) * 100 : 0;
7639
+ L.push(' ' + C.bold +
7640
+ pad('TOTAL', 14) + pad(fmtInt(TC), 7, 'right') +
7641
+ pad(humanTokens(TB), 11, 'right') + pad(humanTokens(TA), 10, 'right') +
7642
+ pad(humanTokens(TS), 10, 'right') + pad(fmtPct(tp), 8, 'right') +
7643
+ pad(fmtDuration(TM), 8, 'right') + C.reset);
7644
+ };
7645
+
7646
+ const daily = agg.buckets.daily.slice(-30);
7647
+ section('📅', `Daily (last ${daily.length})`, daily, 'Date');
7648
+ const weekly = agg.buckets.weekly.slice(-6);
7649
+ section('📆', `Weekly (last ${weekly.length})`, weekly, 'Week of');
7650
+ const monthly = agg.buckets.monthly.slice(-3);
7651
+ section('🗓️', `Monthly (last ${monthly.length})`, monthly, 'Month');
7652
+ L.push('');
7653
+ L.push(` ${C.dim}saved = baseline − actual · estimated vs whole-file reads · local-only${C.reset}`);
7654
+ L.push('');
7655
+ return L.join('\n');
7656
+ }
7657
+
7658
+ module.exports = {
7659
+ renderSummary,
7660
+ renderBreakdown,
7661
+ // exported for tests
7662
+ humanTokens, fmtUSD, fmtDuration, fmtPct,
7663
+ };
7126
7664
 
7127
7665
  };
7128
7666
 
@@ -8928,6 +9466,20 @@ __factories["./src/discovery/source-root-registry"] = function(module, exports)
8928
9466
  module.exports = { REGISTRY };
8929
9467
  };
8930
9468
 
9469
+ // ── ./src/util/git ──
9470
+ __factories["./src/util/git"] = function(module, exports) {
9471
+ 'use strict';
9472
+ const { execFileSync } = require('child_process');
9473
+ function git(args, opts = {}) {
9474
+ return execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], ...opts });
9475
+ }
9476
+ function tryGit(args, opts = {}) {
9477
+ try { return git(args, opts).toString().trim(); }
9478
+ catch (_) { return ''; }
9479
+ }
9480
+ module.exports = { git, tryGit };
9481
+ };
9482
+
8931
9483
  // ── ./src/discovery/sigmapignore ──
8932
9484
  __factories["./src/discovery/sigmapignore"] = function(module, exports) {
8933
9485
  'use strict';
@@ -10947,7 +11499,7 @@ function __tryGit(args, opts = {}) {
10947
11499
  catch (_) { return ''; }
10948
11500
  }
10949
11501
 
10950
- const VERSION = '7.0.1';
11502
+ const VERSION = '7.2.0';
10951
11503
  const MARKER = '\n\n## Auto-generated signatures\n<!-- Updated by gen-context.js -->\n';
10952
11504
 
10953
11505
  function requireSourceOrBundled(key) {
@@ -12180,6 +12732,7 @@ function runDiff(cwd, config, stagedOnly, baseRef) {
12180
12732
  // Core generate pipeline
12181
12733
  // ---------------------------------------------------------------------------
12182
12734
  function runGenerate(cwd, config, reportMode, reportJson = false) {
12735
+ const __genT0 = Date.now();
12183
12736
  const ignorePatterns = loadIgnorePatterns(cwd);
12184
12737
  let allFiles = buildFileList(cwd, config);
12185
12738
 
@@ -12379,7 +12932,7 @@ function runGenerate(cwd, config, reportMode, reportJson = false) {
12379
12932
  printReport(result.inputTokenTotal, result.finalTokens, result.fileCount, result.droppedCount, reportJson, effectiveMaxTokens, result.coverageResult, config.autoMaxTokens !== false && effectiveMaxTokens !== config.maxTokens, config.maxTokens);
12380
12933
  }
12381
12934
 
12382
- // Usage tracking (v0.9) — optional append-only NDJSON log
12935
+ // Usage tracking (v0.9) — legacy health log, opt-in via config.tracking / --track.
12383
12936
  const trackingEnabled = !!(config.tracking || process.argv.includes('--track'));
12384
12937
  if (trackingEnabled && !reportMode) {
12385
12938
  try {
@@ -12399,6 +12952,22 @@ function runGenerate(cwd, config, reportMode, reportJson = false) {
12399
12952
  }
12400
12953
  }
12401
12954
 
12955
+ // gain: capture generate savings (separate gain.ndjson; default ON, opt-out).
12956
+ if (!reportMode) {
12957
+ try {
12958
+ const { recordUsage, isTrackingEnabled } = requireSourceOrBundled('./src/tracking/logger');
12959
+ if (isTrackingEnabled(config, process.argv)) {
12960
+ recordUsage({
12961
+ version: VERSION,
12962
+ op: 'generate',
12963
+ baselineTokens: result.inputTokenTotal,
12964
+ actualTokens: result.finalTokens,
12965
+ durationMs: Date.now() - __genT0,
12966
+ }, cwd);
12967
+ }
12968
+ } catch (_) { /* gain capture is best-effort */ }
12969
+ }
12970
+
12402
12971
  // Feature 8: post-run summary — 6-line stdout after every normal run
12403
12972
  if (!reportMode
12404
12973
  && !process.argv.includes('--json')
@@ -12695,6 +13264,13 @@ Usage:
12695
13264
  ${cmd} --suggest-tool "<task>" --json Machine-readable tier recommendation
12696
13265
  ${cmd} --health Print composite health score
12697
13266
  ${cmd} --health --json Machine-readable health score
13267
+ ${cmd} gain Token-savings dashboard (totals + by-operation)
13268
+ ${cmd} gain --all Add daily / weekly / monthly trend tables
13269
+ ${cmd} gain --json Aggregate savings as JSON
13270
+ ${cmd} gain --since 7d Window filter (7d, 30d, 12h, or ISO date)
13271
+ ${cmd} gain --top <n> | --model <name> Limit rows / set $ pricing model
13272
+ ${cmd} gain --reset Clear the local savings log (.context/gain.ndjson)
13273
+ ${cmd} ... --no-track Disable gain savings capture for this run
12698
13274
  ${cmd} --diff Generate context for git-changed files only
12699
13275
  ${cmd} --diff <base-ref> Generate context + structural diff vs base ref (e.g. main)
12700
13276
  ${cmd} --diff --staged Generate context for staged files only
@@ -13094,6 +13670,7 @@ function main() {
13094
13670
 
13095
13671
  // v4.2: `sigmap ask "<query>"` — unified pipeline
13096
13672
  if (args[0] === 'ask') {
13673
+ const __askT0 = Date.now();
13097
13674
  // v6.8: Handle --followup flag which may appear before or after query
13098
13675
  let query = args[1];
13099
13676
  if (query === '--followup' && args[2]) {
@@ -13261,6 +13838,21 @@ function main() {
13261
13838
  bar,
13262
13839
  ].join('\n'));
13263
13840
  }
13841
+ // gain: capture this query's savings for the `sigmap gain` dashboard.
13842
+ try {
13843
+ const { recordUsage, isTrackingEnabled } = requireSourceOrBundled('./src/tracking/logger');
13844
+ if (isTrackingEnabled(config, args)) {
13845
+ recordUsage({
13846
+ version: VERSION,
13847
+ op: 'ask',
13848
+ baselineTokens: rawTok,
13849
+ actualTokens: ctxTok,
13850
+ durationMs: Date.now() - __askT0,
13851
+ model,
13852
+ }, cwd);
13853
+ }
13854
+ } catch (_) { /* gain capture is best-effort */ }
13855
+
13264
13856
  // v7.0.0: record the run and show the one-time star nudge (interactive only).
13265
13857
  try {
13266
13858
  const { checkStarNudge } = requireSourceOrBundled('./src/nudge');
@@ -13273,6 +13865,44 @@ function main() {
13273
13865
  process.exit(0);
13274
13866
  }
13275
13867
 
13868
+ // `sigmap gain` — token-savings dashboard (totals, by-operation, trends).
13869
+ if (args[0] === 'gain') {
13870
+ const valOf = (f, d) => { const i = args.indexOf(f); return i >= 0 && args[i + 1] ? args[i + 1] : d; };
13871
+ if (args.includes('--reset')) {
13872
+ try {
13873
+ const { GAIN_FILE } = requireSourceOrBundled('./src/tracking/logger');
13874
+ const p = path.join(cwd, GAIN_FILE);
13875
+ if (fs.existsSync(p)) fs.unlinkSync(p);
13876
+ console.log('[sigmap] gain: usage log cleared.');
13877
+ } catch (e) { console.error(`[sigmap] gain: ${e.message}`); }
13878
+ process.exit(0);
13879
+ }
13880
+ try {
13881
+ const { readGainLog } = requireSourceOrBundled('./src/tracking/logger');
13882
+ const { aggregate } = requireSourceOrBundled('./src/tracking/aggregate');
13883
+ const { renderSummary, renderBreakdown } = requireSourceOrBundled('./src/format/gain-terminal');
13884
+ const records = readGainLog(cwd);
13885
+ const agg = aggregate(records, {
13886
+ model: valOf('--model', 'claude-sonnet'),
13887
+ since: valOf('--since', null),
13888
+ top: parseInt(valOf('--top', args.includes('--all') ? '0' : '10'), 10),
13889
+ });
13890
+ if (args.includes('--json')) {
13891
+ process.stdout.write(JSON.stringify(agg, null, 2) + '\n');
13892
+ } else {
13893
+ const out = args.includes('--all')
13894
+ ? renderSummary(agg, { version: VERSION }) + '\n' + renderBreakdown(agg)
13895
+ : renderSummary(agg, { version: VERSION });
13896
+ process.stdout.write(out + '\n');
13897
+ }
13898
+ } catch (e) {
13899
+ console.error(`[sigmap] gain: ${e.message}`);
13900
+ console.error(' (gain modules require src/ — for single-file installs, regenerate the bundle)');
13901
+ process.exit(1);
13902
+ }
13903
+ process.exit(0);
13904
+ }
13905
+
13276
13906
  // v4.2: `sigmap suggest-profile` — auto-detect task type from git state
13277
13907
  if (args[0] === 'suggest-profile') {
13278
13908
  const short = args.includes('--short');
@@ -13359,7 +13989,7 @@ function main() {
13359
13989
  const shareText = [
13360
13990
  'Generated with SigMap — zero-dependency AI context engine',
13361
13991
  `${reduction}% fewer tokens · ${hitAt5}% retrieval accuracy · 6× better results`,
13362
- 'https://sigmap.dev',
13992
+ 'https://sigmap.io',
13363
13993
  ].join('\n');
13364
13994
 
13365
13995
  console.log(shareText);
@@ -14284,7 +14914,8 @@ function main() {
14284
14914
  console.log(` p50 token count : ${result.p50TokenCount}`);
14285
14915
  console.log(` p95 token count : ${result.p95TokenCount}`);
14286
14916
  console.log(` overbudget streak: ${result.overBudgetStreak}`);
14287
- console.log(` extractor cover.: ${result.extractorCoverage}%`);
14917
+ console.log(` repo languages : ${result.extractorCoverage}% of supported langs used here (informational — not scored)`);
14918
+ console.log(` ${'score basis'.padEnd(16)}: staleness · token reduction · over-budget rate`);
14288
14919
  }
14289
14920
  process.exit(0);
14290
14921
  }