sigmap 8.22.0 → 8.24.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 CHANGED
@@ -10,6 +10,30 @@ Format: [Semantic Versioning](https://semver.org/)
10
10
 
11
11
  ---
12
12
 
13
+ ## [8.24.0] — 2026-07-28
14
+
15
+ Minor release — **"Trust Quick Wins I" (v8.24, G3a)**: the secret-redaction engine that already protects signatures, `get_lines`, and evidence packs becomes a standalone command for arbitrary text.
16
+
17
+ ### Added
18
+ - **`sigmap redact` (#511, PR #512):** new `src/security/redact.js` — `redactText()` masks only the matched secret substring (`[REDACTED:<pattern name>]`), preserving surrounding text (unlike the generation-time scanner's whole-line replacement, which is unchanged). Findings carry 1-based line numbers with per-pattern counts. CLI: `sigmap redact [file] [--json]` — file argument or stdin; redacted text on stdout (pipe-clean), summary on stderr. Reuses the existing 10-pattern bank verbatim; zero new dependencies; never throws. 6 new integration tests including an every-pattern mask sweep (130 test files).
19
+
20
+ ### Changed
21
+ - Test fixtures for secret patterns are assembled at runtime (no secret-shaped literals in committed blobs) — GitHub Push Protection rejected the first push over the fake samples, which validated the detection class this command implements.
22
+
23
+ ---
24
+
25
+ ## [8.23.0] — 2026-07-28
26
+
27
+ Minor release — **"Agent Economy I" (v8.23, F1)**: SigMap's token savings become queryable *during* a session. A spend ledger over the existing gain log, an optional budget threshold, and context-freshness age — as a CLI command and the 21st MCP tool.
28
+
29
+ ### Added
30
+ - **`sigmap budget` + MCP `get_budget` (#508, PR #509):** new `src/tracking/budget.js` — `budgetStatus()` sums the session's gain-log entries (estimated SigMap-emitted tokens: spent/baseline/saved, op count), computes remaining/pct against an optional budget, and reports generated-context age with a stale flag. Session identity: `SIGMAP_SESSION` env override, else UTC day bucket; `recordUsage` now stamps every entry with `session` (legacy entries match day buckets by timestamp prefix). New opt-in config keys `sessionBudgetTokens` and `contextTtlDays` (both default `null`). CLI: `sigmap budget [--json] [--session <key>] [--budget <tokens>]`. The MCP tool advises degrade-gracefully tactics (terse encoding, `squeeze`, summarize-then-drop) at ≥80% budget. 7 new integration tests (129 test files); tool-count guards advanced 20→21.
31
+
32
+ ### Changed
33
+ - **Scope honesty by design:** the ledger counts tokens **SigMap emitted** (chars/4, labeled `estimated-tokens` on every surface) — not the host chat's total spend, which a CLI cannot see. Context freshness replaces the originally-planned prompt-cache "injection TTL", which was unverifiable and got cut in the plan audit.
34
+
35
+ ---
36
+
13
37
  ## [8.22.0] — 2026-07-28
14
38
 
15
39
  Minor release — **"Hard Corpus" (v8.22)**: the benchmark corpus gains a no-leakage hard split with a deterministic leakage gate, and per-repo-size buckets stop tiny repos from flattering the average. The headline retrieval number gets harder — and honest.
package/README.md CHANGED
@@ -122,8 +122,8 @@ Ask → Rank → Context → Validate → Judge → Learn
122
122
 
123
123
  <!--SM:benchmarkBlock-->
124
124
  ```
125
- Benchmark : sigmap-v8.22-main (21 repositories, including R language)
126
- Date : 2026-07-27
125
+ Benchmark : sigmap-v8.24-main (21 repositories, including R language)
126
+ Date : 2026-07-28
127
127
 
128
128
  Hit@5 : 82.2% (grep-agent baseline 44.8% — 1.59× lift)
129
129
  Token reduction: 96.8% (across 21 repos)
@@ -222,7 +222,7 @@ Use SigMap with open-source tools and fully self-hosted setups:
222
222
  | **JetBrains** | [Marketplace](https://plugins.jetbrains.com/plugin/31109-sigmap--ai-context-engine/) | [github.com/manojmallick/sigmap-jetbrains](https://github.com/manojmallick/sigmap-jetbrains) | IntelliJ IDEA, WebStorm, PyCharm, GoLand — tool window + actions |
223
223
  | **Neovim** | lazy.nvim / packer / vim-plug | [github.com/manojmallick/sigmap.nvim](https://github.com/manojmallick/sigmap.nvim) | `:SigMap`, `:SigMapQuery` float window, statusline widget |
224
224
 
225
- **MCP server** — 20 on-demand tools for Claude Code and Cursor:
225
+ **MCP server** — 21 on-demand tools for Claude Code and Cursor:
226
226
 
227
227
  ```bash
228
228
  sigmap --mcp
@@ -269,7 +269,7 @@ SigMap treats coding agents as **consumers, not competitors**: it hands them a d
269
269
 
270
270
  | Agent | One-time setup | How it consumes SigMap |
271
271
  |---|---|---|
272
- | **Claude Code** | `sigmap mcp install claude` | 20 MCP tools (`search_signatures`, `get_lines`, `get_diff_context`, `squeeze_output`…) |
272
+ | **Claude Code** | `sigmap mcp install claude` | 21 MCP tools (`search_signatures`, `get_lines`, `get_diff_context`, `squeeze_output`…) |
273
273
  | **Cursor** | `sigmap mcp install cursor` | MCP tools, plus the `cursor` adapter writes `.cursorrules` |
274
274
  | **Cline** | `sigmap mcp install cursor` | Reads `.cursorrules`; same MCP server |
275
275
  | **Continue** | `sigmap mcp install vscode` | MCP tools inside the Continue extension |
package/gen-context.js CHANGED
@@ -1490,6 +1490,14 @@ __factories["./src/config/defaults"] = function(module, exports) {
1490
1490
  // Append run metrics to .context/usage.ndjson after each generate
1491
1491
  tracking: false,
1492
1492
 
1493
+ // Session spend ledger (`sigmap budget` / MCP get_budget). Estimates only —
1494
+ // counts tokens SigMap emitted (chars/4), not the host chat's total spend.
1495
+ // Number → warn threshold for estimated SigMap-emitted tokens per session.
1496
+ sessionBudgetTokens: null,
1497
+
1498
+ // Number of days before generated context counts as stale in budget output.
1499
+ contextTtlDays: null,
1500
+
1493
1501
  // MCP server configuration
1494
1502
  mcp: {
1495
1503
  autoRegister: true,
@@ -14374,6 +14382,41 @@ __factories["./src/mcp/handlers"] = function(module, exports) {
14374
14382
  return out.join('\n');
14375
14383
  }
14376
14384
 
14385
+ /**
14386
+ * get_budget({ session?, budgetTokens? }) → string
14387
+ *
14388
+ * Session spend ledger (F1): estimated tokens SigMap emitted this session,
14389
+ * optional budget remaining, and context freshness. Estimates only (chars/4);
14390
+ * does NOT see the host chat's total spend.
14391
+ */
14392
+ function getBudget(args, cwd) {
14393
+ const { budgetStatus } = __require('./src/tracking/budget');
14394
+ let config = {};
14395
+ try { config = __require('./src/config/loader').loadConfig(cwd) || {}; } catch (_) {}
14396
+ const opts = { config };
14397
+ if (args && args.session) opts.session = String(args.session);
14398
+ if (args && args.budgetTokens != null) opts.budgetTokens = Number(args.budgetTokens);
14399
+ const s = budgetStatus(cwd, opts);
14400
+
14401
+ const out = ['# SigMap session spend (estimates — chars/4; SigMap-emitted tokens only)'];
14402
+ out.push('');
14403
+ out.push(`Session : ${s.session}`);
14404
+ out.push(`Ops : ${s.ops}`);
14405
+ out.push(`Spent : ~${s.spentTokens.toLocaleString()} tokens (baseline ~${s.baselineTokens.toLocaleString()}, saved ~${s.savedTokens.toLocaleString()})`);
14406
+ if (s.budgetTokens != null) {
14407
+ out.push(`Budget : ${s.budgetTokens.toLocaleString()} → remaining ~${s.remainingTokens.toLocaleString()} (${s.pctUsed}% used)${s.overBudget ? ' ⚠ OVER BUDGET' : ''}`);
14408
+ if (s.overBudget || (s.pctUsed != null && s.pctUsed >= 80)) {
14409
+ out.push('Advice : prefer terse output, squeeze large inputs, summarize-then-drop context.');
14410
+ }
14411
+ } else {
14412
+ out.push('Budget : none set (config sessionBudgetTokens or budgetTokens arg)');
14413
+ }
14414
+ out.push(s.context.exists
14415
+ ? `Context : ${s.context.ageDays} day(s) old${s.context.stale ? ` — STALE (> ${s.context.ttlDays}d TTL); re-run sigmap` : ''}`
14416
+ : 'Context : no generated context found — run sigmap first');
14417
+ return out.join('\n');
14418
+ }
14419
+
14377
14420
  /**
14378
14421
  * get_callee_signatures — return the exact defining signature(s) of named
14379
14422
  * symbols from the index, so an agent never guesses a callee's parameter types.
@@ -14767,7 +14810,7 @@ __factories["./src/mcp/handlers"] = function(module, exports) {
14767
14810
  return header + sq.squeezed;
14768
14811
  }
14769
14812
 
14770
- module.exports = { readContext, searchSignatures, getMap, createCheckpoint, getRouting, explainFile, listModules, queryContext, getMethodImpact, getImpact, getLines, readMemory, getCalleeSignatures, notifyFileCreated, notifySymbolAdded, notifyFileDeleted, getDiffContext, getArchitectureOverview, verifySuggestion, squeezeOutput };
14813
+ module.exports = { readContext, searchSignatures, getMap, createCheckpoint, getRouting, explainFile, listModules, queryContext, getMethodImpact, getImpact, getLines, readMemory, getCalleeSignatures, notifyFileCreated, notifySymbolAdded, notifyFileDeleted, getDiffContext, getArchitectureOverview, verifySuggestion, squeezeOutput, getBudget };
14771
14814
 
14772
14815
  };
14773
14816
 
@@ -14934,11 +14977,11 @@ __factories["./src/mcp/server"] = function(module, exports) {
14934
14977
 
14935
14978
  const readline = require('readline');
14936
14979
  const { TOOLS } = __require('./src/mcp/tools');
14937
- const { readContext, searchSignatures, getMap, createCheckpoint, getRouting, explainFile, listModules, queryContext, getMethodImpact, getImpact, getLines, readMemory, getCalleeSignatures, notifyFileCreated, notifySymbolAdded, notifyFileDeleted, getDiffContext, getArchitectureOverview, verifySuggestion, squeezeOutput } = __require('./src/mcp/handlers');
14980
+ const { readContext, searchSignatures, getMap, createCheckpoint, getRouting, explainFile, listModules, queryContext, getMethodImpact, getImpact, getLines, readMemory, getCalleeSignatures, notifyFileCreated, notifySymbolAdded, notifyFileDeleted, getDiffContext, getArchitectureOverview, verifySuggestion, squeezeOutput, getBudget } = __require('./src/mcp/handlers');
14938
14981
 
14939
14982
  const SERVER_INFO = {
14940
14983
  name: 'sigmap',
14941
- version: '8.22.0',
14984
+ version: '8.24.0',
14942
14985
  description: 'SigMap MCP server — code signatures on demand',
14943
14986
  };
14944
14987
 
@@ -15006,6 +15049,7 @@ __factories["./src/mcp/server"] = function(module, exports) {
15006
15049
  else if (name === 'get_architecture_overview') text = getArchitectureOverview(args, cwd);
15007
15050
  else if (name === 'verify_suggestion') text = verifySuggestion(args, cwd);
15008
15051
  else if (name === 'squeeze_output') text = squeezeOutput(args, cwd);
15052
+ else if (name === 'get_budget') text = getBudget(args, cwd);
15009
15053
  else {
15010
15054
  respondError(id, -32601, `Unknown tool: ${name}`);
15011
15055
  return;
@@ -15452,6 +15496,32 @@ __factories["./src/mcp/tools"] = function(module, exports) {
15452
15496
  required: ['content'],
15453
15497
  },
15454
15498
  },
15499
+ {
15500
+ name: 'get_budget',
15501
+ description:
15502
+ 'Session spend ledger — estimated tokens SigMap has emitted this session ' +
15503
+ '(chars/4 estimates from the local gain log), optional budget remaining, and ' +
15504
+ 'context freshness. Scope honesty: counts only SigMap output, NOT the whole ' +
15505
+ "chat's spend. Check this mid-session and degrade gracefully when near budget: " +
15506
+ 'prefer terse encoding, squeeze large outputs, summarize-then-drop context. ' +
15507
+ 'Local-only; no LLM, no network.',
15508
+ inputSchema: {
15509
+ type: 'object',
15510
+ properties: {
15511
+ session: {
15512
+ type: 'string',
15513
+ description:
15514
+ 'Session key to report on (default: SIGMAP_SESSION env or the UTC day bucket).',
15515
+ },
15516
+ budgetTokens: {
15517
+ type: 'number',
15518
+ description:
15519
+ 'Budget override in estimated tokens (default: config sessionBudgetTokens).',
15520
+ },
15521
+ },
15522
+ required: [],
15523
+ },
15524
+ },
15455
15525
  ];
15456
15526
 
15457
15527
  module.exports = { TOOLS };
@@ -17458,6 +17528,66 @@ __factories["./src/security/patterns"] = function(module, exports) {
17458
17528
 
17459
17529
  };
17460
17530
 
17531
+ // ── ./src/security/redact ──
17532
+ __factories["./src/security/redact"] = function(module, exports) {
17533
+
17534
+ /**
17535
+ * Standalone text redaction (v8.24 G3a) — the same pattern bank the
17536
+ * generation-time scanner uses (security/patterns.js), applied to arbitrary
17537
+ * text. Unlike scanner.js (which replaces whole signature lines), this masks
17538
+ * only the matched secret substring so surrounding text stays readable.
17539
+ *
17540
+ * Zero dependencies; never throws — on any error the original text is
17541
+ * returned unredacted.
17542
+ */
17543
+
17544
+ const { PATTERNS } = __require('./src/security/patterns');
17545
+
17546
+ // Global variants of the pattern regexes (needed for replace-all per line).
17547
+ const GLOBAL_PATTERNS = PATTERNS.map((p) => ({
17548
+ name: p.name,
17549
+ regex: new RegExp(p.regex.source, p.regex.flags.includes('g') ? p.regex.flags : p.regex.flags + 'g'),
17550
+ }));
17551
+
17552
+ /**
17553
+ * Redact secrets in arbitrary text.
17554
+ * @param {string} text
17555
+ * @returns {{
17556
+ * text: string, redacted: boolean,
17557
+ * findings: Array<{ line: number, pattern: string }>,
17558
+ * counts: Object<string, number>
17559
+ * }}
17560
+ */
17561
+ function redactText(text) {
17562
+ if (typeof text !== 'string' || text.length === 0) {
17563
+ return { text: typeof text === 'string' ? text : '', redacted: false, findings: [], counts: {} };
17564
+ }
17565
+ try {
17566
+ const findings = [];
17567
+ const counts = {};
17568
+ const lines = text.split('\n');
17569
+ const out = lines.map((line, i) => {
17570
+ let masked = line;
17571
+ for (const p of GLOBAL_PATTERNS) {
17572
+ p.regex.lastIndex = 0;
17573
+ if (!p.regex.test(masked)) continue;
17574
+ p.regex.lastIndex = 0;
17575
+ masked = masked.replace(p.regex, `[REDACTED:${p.name}]`);
17576
+ findings.push({ line: i + 1, pattern: p.name });
17577
+ counts[p.name] = (counts[p.name] || 0) + 1;
17578
+ }
17579
+ return masked;
17580
+ });
17581
+ return { text: out.join('\n'), redacted: findings.length > 0, findings, counts };
17582
+ } catch (_) {
17583
+ return { text, redacted: false, findings: [], counts: {} };
17584
+ }
17585
+ }
17586
+
17587
+ module.exports = { redactText };
17588
+
17589
+ };
17590
+
17461
17591
  // ── ./src/security/scanner ──
17462
17592
  __factories["./src/security/scanner"] = function(module, exports) {
17463
17593
 
@@ -18408,6 +18538,123 @@ __factories["./src/tracking/aggregate"] = function(module, exports) {
18408
18538
 
18409
18539
  };
18410
18540
 
18541
+ // ── ./src/tracking/budget ──
18542
+ __factories["./src/tracking/budget"] = function(module, exports) {
18543
+
18544
+ /**
18545
+ * Session spend ledger (v8.23 F1) — a queryable view over the existing gain
18546
+ * log (.context/gain.ndjson, written by recordUsage).
18547
+ *
18548
+ * Honesty scope: this ledger counts tokens **SigMap emitted** (chars/4
18549
+ * estimates), not the host chat's total spend — conversation history, model
18550
+ * output, and other tools are invisible to a CLI. Every number here is an
18551
+ * estimate and is labeled as such in output.
18552
+ *
18553
+ * Session identity: `SIGMAP_SESSION` env var when the host sets one, else a
18554
+ * UTC day bucket (YYYY-MM-DD). Legacy gain entries (written before the
18555
+ * `session` field existed) match day-bucket sessions by timestamp prefix.
18556
+ *
18557
+ * Zero dependencies; local JSON only.
18558
+ */
18559
+
18560
+ const fs = require('fs');
18561
+ const path = require('path');
18562
+ const { readGainLog } = __require('./src/tracking/logger');
18563
+
18564
+ // Same generated-context surfaces cache/freshen.js watches.
18565
+ const CONTEXT_PATHS = [
18566
+ ['.github', 'copilot-instructions.md'],
18567
+ ['CLAUDE.md'], ['AGENTS.md'], ['.github', 'context-cold.md'],
18568
+ ];
18569
+
18570
+ /** Session key: SIGMAP_SESSION override, else UTC day bucket. */
18571
+ function sessionKey(env) {
18572
+ const e = env || process.env;
18573
+ if (e.SIGMAP_SESSION) return String(e.SIGMAP_SESSION);
18574
+ return new Date().toISOString().slice(0, 10);
18575
+ }
18576
+
18577
+ /** Newest mtime (ms) among generated context files, or 0 if none exist. */
18578
+ function contextMtime(cwd) {
18579
+ let newest = 0;
18580
+ for (const parts of CONTEXT_PATHS) {
18581
+ try { newest = Math.max(newest, fs.statSync(path.join(cwd, ...parts)).mtimeMs); } catch (_) {}
18582
+ }
18583
+ return newest;
18584
+ }
18585
+
18586
+ /** Does a gain-log entry belong to this session? */
18587
+ function entryInSession(entry, session) {
18588
+ if (entry.session) return entry.session === session;
18589
+ // Legacy entry: match day-bucket sessions on the timestamp date.
18590
+ return /^\d{4}-\d{2}-\d{2}$/.test(session) && String(entry.ts || '').startsWith(session);
18591
+ }
18592
+
18593
+ /**
18594
+ * Session spend status.
18595
+ * @param {string} cwd
18596
+ * @param {object} [opts]
18597
+ * @param {string} [opts.session] session key (default: sessionKey())
18598
+ * @param {number} [opts.budgetTokens] budget override (else config sessionBudgetTokens)
18599
+ * @param {number} [opts.contextTtlDays] TTL override (else config contextTtlDays)
18600
+ * @param {object} [opts.config] loaded config (for the two keys above)
18601
+ * @param {number} [opts.now] clock override for tests (ms)
18602
+ * @returns {{
18603
+ * session: string, unit: 'estimated-tokens', ops: number,
18604
+ * spentTokens: number, baselineTokens: number, savedTokens: number,
18605
+ * budgetTokens: number|null, remainingTokens: number|null, pctUsed: number|null,
18606
+ * overBudget: boolean,
18607
+ * context: { exists: boolean, ageMs: number|null, ageDays: number|null,
18608
+ * ttlDays: number|null, stale: boolean }
18609
+ * }}
18610
+ */
18611
+ function budgetStatus(cwd, opts = {}) {
18612
+ const session = opts.session || sessionKey();
18613
+ const cfg = opts.config || {};
18614
+ const budget = opts.budgetTokens != null ? Number(opts.budgetTokens)
18615
+ : (Number.isFinite(cfg.sessionBudgetTokens) ? cfg.sessionBudgetTokens : null);
18616
+ const ttlDays = opts.contextTtlDays != null ? Number(opts.contextTtlDays)
18617
+ : (Number.isFinite(cfg.contextTtlDays) ? cfg.contextTtlDays : null);
18618
+ const now = opts.now != null ? opts.now : Date.now();
18619
+
18620
+ let ops = 0, spent = 0, baseline = 0, saved = 0;
18621
+ for (const e of readGainLog(cwd)) {
18622
+ if (!entryInSession(e, session)) continue;
18623
+ ops++;
18624
+ spent += Number(e.actualTokens) || 0;
18625
+ baseline += Number(e.baselineTokens) || 0;
18626
+ saved += Number(e.savedTokens) || 0;
18627
+ }
18628
+
18629
+ const mtime = contextMtime(cwd);
18630
+ const ageMs = mtime > 0 ? Math.max(0, now - mtime) : null;
18631
+ const ageDays = ageMs != null ? ageMs / 86400000 : null;
18632
+
18633
+ return {
18634
+ session,
18635
+ unit: 'estimated-tokens',
18636
+ ops,
18637
+ spentTokens: spent,
18638
+ baselineTokens: baseline,
18639
+ savedTokens: saved,
18640
+ budgetTokens: budget,
18641
+ remainingTokens: budget != null ? Math.max(0, budget - spent) : null,
18642
+ pctUsed: budget > 0 ? Math.round((spent / budget) * 1000) / 10 : null,
18643
+ overBudget: budget != null && spent > budget,
18644
+ context: {
18645
+ exists: mtime > 0,
18646
+ ageMs,
18647
+ ageDays: ageDays != null ? Math.round(ageDays * 10) / 10 : null,
18648
+ ttlDays,
18649
+ stale: ttlDays != null && ageDays != null && ageDays > ttlDays,
18650
+ },
18651
+ };
18652
+ }
18653
+
18654
+ module.exports = { sessionKey, budgetStatus, contextMtime, entryInSession };
18655
+
18656
+ };
18657
+
18411
18658
  // ── ./src/tracking/logger ──
18412
18659
  __factories["./src/tracking/logger"] = function(module, exports) {
18413
18660
 
@@ -18586,10 +18833,12 @@ __factories["./src/tracking/logger"] = function(module, exports) {
18586
18833
  const baseline = Math.max(0, Number(entry.baselineTokens) || 0);
18587
18834
  const actual = Math.max(0, Number(entry.actualTokens) || 0);
18588
18835
  const saved = Math.max(0, baseline - actual);
18836
+ const ts = new Date().toISOString();
18589
18837
  const record = {
18590
- ts: new Date().toISOString(),
18838
+ ts,
18591
18839
  v: entry.version || '0.9.0',
18592
18840
  op: entry.op || 'generate',
18841
+ session: entry.session || process.env.SIGMAP_SESSION || ts.slice(0, 10),
18593
18842
  baselineTokens: baseline,
18594
18843
  actualTokens: actual,
18595
18844
  savedTokens: saved,
@@ -20158,7 +20407,7 @@ function __tryGit(args, opts = {}) {
20158
20407
  catch (_) { return ''; }
20159
20408
  }
20160
20409
 
20161
- const VERSION = '8.22.0';
20410
+ const VERSION = '8.24.0';
20162
20411
  const MARKER = '\n\n## Auto-generated signatures\n<!-- Updated by gen-context.js -->\n';
20163
20412
 
20164
20413
  function requireSourceOrBundled(key) {
@@ -22043,6 +22292,9 @@ Usage:
22043
22292
  ${cmd} evidence "<query>" --top <n> --budget <n> --out <path> Tune ranked files / token budget / write rendered output
22044
22293
  ${cmd} memory List cross-session stores (.context/) — entries, size, age
22045
22294
  ${cmd} memory --clear <store> Clear one store: session|notes|weights|evidence|all (--json supported)
22295
+ ${cmd} budget Session spend ledger — estimated SigMap-emitted tokens, budget, context age (--json)
22296
+ ${cmd} budget --budget <tokens> One-off budget override (config: sessionBudgetTokens, contextTtlDays)
22297
+ ${cmd} redact [file] Mask secrets in a file or stdin (10-pattern bank); redacted text to stdout (--json)
22046
22298
  ${cmd} note "<text>" Append a note to the cross-session decision log
22047
22299
  ${cmd} note List recent notes (also: note --list <N>)
22048
22300
  ${cmd} status Show repo state — branch, dirty files, index freshness, notes
@@ -23494,6 +23746,72 @@ function main() {
23494
23746
  process.exit(0);
23495
23747
  }
23496
23748
 
23749
+ if (args[0] === 'budget') {
23750
+ const jsonOut = args.includes('--json');
23751
+ const { budgetStatus } = requireSourceOrBundled('./src/tracking/budget');
23752
+ let config = {};
23753
+ try { config = requireSourceOrBundled('./src/config/loader').loadConfig(cwd) || {}; } catch (_) {}
23754
+ const opts = { config };
23755
+ const sIdx = args.indexOf('--session');
23756
+ if (sIdx !== -1 && args[sIdx + 1] && !args[sIdx + 1].startsWith('--')) opts.session = args[sIdx + 1];
23757
+ const bIdx = args.indexOf('--budget');
23758
+ if (bIdx !== -1 && args[bIdx + 1] && !args[bIdx + 1].startsWith('--')) opts.budgetTokens = Number(args[bIdx + 1]);
23759
+ const st = budgetStatus(cwd, opts);
23760
+ if (jsonOut) {
23761
+ process.stdout.write(JSON.stringify(st) + '\n');
23762
+ process.exit(0);
23763
+ }
23764
+ console.log('[sigmap] session spend (estimates — chars/4; SigMap-emitted tokens only)');
23765
+ console.log(` session ${st.session}`);
23766
+ console.log(` ops ${st.ops}`);
23767
+ console.log(` spent ~${st.spentTokens.toLocaleString()} tokens (baseline ~${st.baselineTokens.toLocaleString()}, saved ~${st.savedTokens.toLocaleString()})`);
23768
+ if (st.budgetTokens != null) {
23769
+ console.log(` budget ${st.budgetTokens.toLocaleString()} → remaining ~${st.remainingTokens.toLocaleString()} (${st.pctUsed}% used)${st.overBudget ? ' ⚠ OVER BUDGET' : ''}`);
23770
+ } else {
23771
+ console.log(' budget none set (config sessionBudgetTokens or --budget <tokens>)');
23772
+ }
23773
+ console.log(st.context.exists
23774
+ ? ` context ${st.context.ageDays} day(s) old${st.context.stale ? ` ⚠ STALE (> ${st.context.ttlDays}d TTL) — re-run sigmap` : ''}`
23775
+ : ' context none generated yet — run sigmap first');
23776
+ process.exit(0);
23777
+ }
23778
+
23779
+ if (args[0] === 'redact') {
23780
+ const jsonOut = args.includes('--json');
23781
+ const { redactText } = requireSourceOrBundled('./src/security/redact');
23782
+ const fileArg = args.slice(1).find((a) => !a.startsWith('--'));
23783
+ let input;
23784
+ if (fileArg) {
23785
+ try {
23786
+ input = fs.readFileSync(path.resolve(cwd, fileArg), 'utf8');
23787
+ } catch (err) {
23788
+ console.error(`[sigmap] redact: cannot read ${fileArg}: ${err.message}`);
23789
+ process.exit(1);
23790
+ }
23791
+ } else if (!process.stdin.isTTY) {
23792
+ try {
23793
+ input = fs.readFileSync(0, 'utf8');
23794
+ } catch (err) {
23795
+ console.error(`[sigmap] redact: cannot read stdin: ${err.message}`);
23796
+ process.exit(1);
23797
+ }
23798
+ } else {
23799
+ console.error('[sigmap] usage: sigmap redact <file> or <cmd> | sigmap redact');
23800
+ process.exit(1);
23801
+ }
23802
+ const r = redactText(input);
23803
+ if (jsonOut) {
23804
+ process.stdout.write(JSON.stringify(r) + '\n');
23805
+ process.exit(0);
23806
+ }
23807
+ process.stdout.write(r.text);
23808
+ const parts = Object.entries(r.counts).map(([k, v]) => `${k}×${v}`);
23809
+ console.error(r.redacted
23810
+ ? `[sigmap] redact: masked ${r.findings.length} secret(s) — ${parts.join(', ')}`
23811
+ : '[sigmap] redact: no secrets found');
23812
+ process.exit(0);
23813
+ }
23814
+
23497
23815
  if (args[0] === 'note') {
23498
23816
  const jsonOut = args.includes('--json');
23499
23817
  const { addNote, readNotes, formatNotes } = requireSourceOrBundled('./src/session/notes');
package/llms-full.txt CHANGED
@@ -11,13 +11,13 @@ ranking keeps the relevant context in scope (cutting tokens ~97% as a side
11
11
  effect), with no LLM calls, embeddings, or vector database. Works with Claude,
12
12
  Cursor, GitHub Copilot, Aider, Windsurf, local LLMs, and MCP.
13
13
 
14
- # Version: 8.22.0 | Benchmark: sigmap-v8.22-main (2026-07-27)
14
+ # Version: 8.24.0 | Benchmark: sigmap-v8.24-main (2026-07-28)
15
15
  # Source: auto-generated from package.json, version.json, benchmarks/latest.json, src/mcp/tools.js, src/config/defaults.js
16
16
  # Regenerate: npm run generate:llms | Validate: npm run validate:llms
17
17
 
18
18
  ---
19
19
 
20
- ## Core metrics (benchmark: sigmap-v8.22-main, 2026-07-27)
20
+ ## Core metrics (benchmark: sigmap-v8.24-main, 2026-07-28)
21
21
 
22
22
  | Metric | Without SigMap | With SigMap |
23
23
  |--------|----------------|-------------|
@@ -26,7 +26,7 @@ Cursor, GitHub Copilot, Aider, Windsurf, local LLMs, and MCP.
26
26
  | Task-success proxy (modeled) | — | 64.8% |
27
27
  | Prompts per task | 2.84 | 1.53 (46.1% fewer) |
28
28
  | Supported languages | — | 33 |
29
- | MCP tools | — | 20 |
29
+ | MCP tools | — | 21 |
30
30
  | npm runtime dependencies | — | 0 |
31
31
 
32
32
  ---
@@ -125,6 +125,9 @@ sigmap evidence "<query>" --markdown Emit the Markdown handoff rendering to
125
125
  sigmap evidence "<query>" --top <n> --budget <n> --out <path> Tune ranked files / token budget / write rendered output
126
126
  sigmap memory List cross-session stores (.context/) — entries, size, age
127
127
  sigmap memory --clear <store> Clear one store: session|notes|weights|evidence|all (--json supported)
128
+ sigmap budget Session spend ledger — estimated SigMap-emitted tokens, budget, context age (--json)
129
+ sigmap budget --budget <tokens> One-off budget override (config: sessionBudgetTokens, contextTtlDays)
130
+ sigmap redact [file] Mask secrets in a file or stdin (10-pattern bank); redacted text to stdout (--json)
128
131
  sigmap note "<text>" Append a note to the cross-session decision log
129
132
  sigmap note List recent notes (also: note --list <N>)
130
133
  sigmap status Show repo state — branch, dirty files, index freshness, notes
@@ -138,7 +141,7 @@ sigmap --version Show version
138
141
 
139
142
  ---
140
143
 
141
- ## MCP server — 20 tools
144
+ ## MCP server — 21 tools
142
145
 
143
146
  Start with `sigmap --mcp` (stdio JSON-RPC). Configure once:
144
147
 
@@ -306,6 +309,14 @@ Compress noisy tool, command, or agent output before it enters context — a sta
306
309
  Input: { content: string }
307
310
  ```
308
311
 
312
+ ### get_budget
313
+
314
+ Session spend ledger — estimated tokens SigMap has emitted this session (chars/4 estimates from the local gain log), optional budget remaining, and context freshness. Scope honesty: counts only SigMap output, NOT the whole chat's spend. Check this mid-session and degrade gracefully when near budget: prefer terse encoding, squeeze large outputs, summarize-then-drop context. Local-only; no LLM, no network.
315
+
316
+ ```
317
+ Input: { session?: string, budgetTokens?: number }
318
+ ```
319
+
309
320
  ---
310
321
 
311
322
  ## Configuration (gen-context.config.json)
@@ -334,6 +345,8 @@ watchDebounce = 300
334
345
  routing = false
335
346
  format = default
336
347
  tracking = false
348
+ sessionBudgetTokens = null
349
+ contextTtlDays = null
337
350
  mcp = {"autoRegister":true}
338
351
  depMap = true
339
352
  versionPins = true
package/llms.txt CHANGED
@@ -11,7 +11,7 @@ ranking keeps the relevant context in scope (cutting tokens ~97% as a side
11
11
  effect), with no LLM calls, embeddings, or vector database. Works with Claude,
12
12
  Cursor, GitHub Copilot, Aider, Windsurf, local LLMs, and MCP.
13
13
 
14
- # Version: 8.22.0 | Benchmark: sigmap-v8.22-main (2026-07-27)
14
+ # Version: 8.24.0 | Benchmark: sigmap-v8.24-main (2026-07-28)
15
15
  # Source: auto-generated from package.json, version.json, benchmarks/latest.json, src/mcp/tools.js, src/config/defaults.js
16
16
  # Regenerate: npm run generate:llms | Validate: npm run validate:llms
17
17
 
@@ -23,13 +23,13 @@ Cursor, GitHub Copilot, Aider, Windsurf, local LLMs, and MCP.
23
23
  - No blast-radius awareness before editing a hub file — `--impact` shows every file a change touches.
24
24
  - Pasted stack traces, CI logs, and JSON bloat the prompt — `squeeze` minimizes them and enriches the top frame from the symbol index.
25
25
 
26
- ## Core metrics (benchmark: sigmap-v8.22-main, 2026-07-27)
26
+ ## Core metrics (benchmark: sigmap-v8.24-main, 2026-07-28)
27
27
 
28
28
  - hit@5 retrieval: 82.2% vs 44.8% single-shot grep baseline (1.59× lift)
29
29
  - Token reduction: 96.8% average across benchmark repos
30
30
  - Task-success proxy: 64.8% (modeled from retrieval tiers, not measured LLM sessions)
31
31
  - Prompts per task: 1.53 vs 2.84 baseline (46.1% fewer, modeled)
32
- - Languages: 33 supported · MCP tools: 20
32
+ - Languages: 33 supported · MCP tools: 21
33
33
  - Dependencies: zero npm runtime dependencies · fully offline
34
34
 
35
35
  ## Quick start
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigmap",
3
- "version": "8.22.0",
3
+ "version": "8.24.0",
4
4
  "description": "The deterministic, verifiable grounding layer for AI code work — a zero-dependency signature-and-evidence map that grounds Claude, Cursor, Copilot, Aider, Windsurf, local LLMs & MCP agents against your real code (repo + installed libraries) so they stop hallucinating files, imports & APIs. Runs offline via npx; byte-stable output; ~97% token reduction as proof.",
5
5
  "main": "packages/core/index.js",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigmap-cli",
3
- "version": "8.22.0",
3
+ "version": "8.24.0",
4
4
  "description": "SigMap CLI wrapper — thin adapter for programmatic CLI invocation",
5
5
  "main": "index.js",
6
6
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigmap-core",
3
- "version": "8.22.0",
3
+ "version": "8.24.0",
4
4
  "description": "SigMap core library — zero-dependency code signature extraction, retrieval, and security scanning",
5
5
  "main": "index.js",
6
6
  "keywords": [
@@ -106,6 +106,14 @@ const DEFAULTS = {
106
106
  // Append run metrics to .context/usage.ndjson after each generate
107
107
  tracking: false,
108
108
 
109
+ // Session spend ledger (`sigmap budget` / MCP get_budget). Estimates only —
110
+ // counts tokens SigMap emitted (chars/4), not the host chat's total spend.
111
+ // Number → warn threshold for estimated SigMap-emitted tokens per session.
112
+ sessionBudgetTokens: null,
113
+
114
+ // Number of days before generated context counts as stale in budget output.
115
+ contextTtlDays: null,
116
+
109
117
  // MCP server configuration
110
118
  mcp: {
111
119
  autoRegister: true,
@@ -586,6 +586,41 @@ function readMemory(args, cwd) {
586
586
  return out.join('\n');
587
587
  }
588
588
 
589
+ /**
590
+ * get_budget({ session?, budgetTokens? }) → string
591
+ *
592
+ * Session spend ledger (F1): estimated tokens SigMap emitted this session,
593
+ * optional budget remaining, and context freshness. Estimates only (chars/4);
594
+ * does NOT see the host chat's total spend.
595
+ */
596
+ function getBudget(args, cwd) {
597
+ const { budgetStatus } = require('../tracking/budget');
598
+ let config = {};
599
+ try { config = require('../config/loader').loadConfig(cwd) || {}; } catch (_) {}
600
+ const opts = { config };
601
+ if (args && args.session) opts.session = String(args.session);
602
+ if (args && args.budgetTokens != null) opts.budgetTokens = Number(args.budgetTokens);
603
+ const s = budgetStatus(cwd, opts);
604
+
605
+ const out = ['# SigMap session spend (estimates — chars/4; SigMap-emitted tokens only)'];
606
+ out.push('');
607
+ out.push(`Session : ${s.session}`);
608
+ out.push(`Ops : ${s.ops}`);
609
+ out.push(`Spent : ~${s.spentTokens.toLocaleString()} tokens (baseline ~${s.baselineTokens.toLocaleString()}, saved ~${s.savedTokens.toLocaleString()})`);
610
+ if (s.budgetTokens != null) {
611
+ out.push(`Budget : ${s.budgetTokens.toLocaleString()} → remaining ~${s.remainingTokens.toLocaleString()} (${s.pctUsed}% used)${s.overBudget ? ' ⚠ OVER BUDGET' : ''}`);
612
+ if (s.overBudget || (s.pctUsed != null && s.pctUsed >= 80)) {
613
+ out.push('Advice : prefer terse output, squeeze large inputs, summarize-then-drop context.');
614
+ }
615
+ } else {
616
+ out.push('Budget : none set (config sessionBudgetTokens or budgetTokens arg)');
617
+ }
618
+ out.push(s.context.exists
619
+ ? `Context : ${s.context.ageDays} day(s) old${s.context.stale ? ` — STALE (> ${s.context.ttlDays}d TTL); re-run sigmap` : ''}`
620
+ : 'Context : no generated context found — run sigmap first');
621
+ return out.join('\n');
622
+ }
623
+
589
624
  /**
590
625
  * get_callee_signatures — return the exact defining signature(s) of named
591
626
  * symbols from the index, so an agent never guesses a callee's parameter types.
@@ -979,4 +1014,4 @@ function squeezeOutput(args, cwd) {
979
1014
  return header + sq.squeezed;
980
1015
  }
981
1016
 
982
- module.exports = { readContext, searchSignatures, getMap, createCheckpoint, getRouting, explainFile, listModules, queryContext, getMethodImpact, getImpact, getLines, readMemory, getCalleeSignatures, notifyFileCreated, notifySymbolAdded, notifyFileDeleted, getDiffContext, getArchitectureOverview, verifySuggestion, squeezeOutput };
1017
+ module.exports = { readContext, searchSignatures, getMap, createCheckpoint, getRouting, explainFile, listModules, queryContext, getMethodImpact, getImpact, getLines, readMemory, getCalleeSignatures, notifyFileCreated, notifySymbolAdded, notifyFileDeleted, getDiffContext, getArchitectureOverview, verifySuggestion, squeezeOutput, getBudget };
package/src/mcp/server.js CHANGED
@@ -14,11 +14,11 @@
14
14
 
15
15
  const readline = require('readline');
16
16
  const { TOOLS } = require('./tools');
17
- const { readContext, searchSignatures, getMap, createCheckpoint, getRouting, explainFile, listModules, queryContext, getMethodImpact, getImpact, getLines, readMemory, getCalleeSignatures, notifyFileCreated, notifySymbolAdded, notifyFileDeleted, getDiffContext, getArchitectureOverview, verifySuggestion, squeezeOutput } = require('./handlers');
17
+ const { readContext, searchSignatures, getMap, createCheckpoint, getRouting, explainFile, listModules, queryContext, getMethodImpact, getImpact, getLines, readMemory, getCalleeSignatures, notifyFileCreated, notifySymbolAdded, notifyFileDeleted, getDiffContext, getArchitectureOverview, verifySuggestion, squeezeOutput, getBudget } = require('./handlers');
18
18
 
19
19
  const SERVER_INFO = {
20
20
  name: 'sigmap',
21
- version: '8.22.0',
21
+ version: '8.24.0',
22
22
  description: 'SigMap MCP server — code signatures on demand',
23
23
  };
24
24
 
@@ -86,6 +86,7 @@ function dispatch(msg, cwd) {
86
86
  else if (name === 'get_architecture_overview') text = getArchitectureOverview(args, cwd);
87
87
  else if (name === 'verify_suggestion') text = verifySuggestion(args, cwd);
88
88
  else if (name === 'squeeze_output') text = squeezeOutput(args, cwd);
89
+ else if (name === 'get_budget') text = getBudget(args, cwd);
89
90
  else {
90
91
  respondError(id, -32601, `Unknown tool: ${name}`);
91
92
  return;
package/src/mcp/tools.js CHANGED
@@ -387,6 +387,32 @@ const TOOLS = [
387
387
  required: ['content'],
388
388
  },
389
389
  },
390
+ {
391
+ name: 'get_budget',
392
+ description:
393
+ 'Session spend ledger — estimated tokens SigMap has emitted this session ' +
394
+ '(chars/4 estimates from the local gain log), optional budget remaining, and ' +
395
+ 'context freshness. Scope honesty: counts only SigMap output, NOT the whole ' +
396
+ "chat's spend. Check this mid-session and degrade gracefully when near budget: " +
397
+ 'prefer terse encoding, squeeze large outputs, summarize-then-drop context. ' +
398
+ 'Local-only; no LLM, no network.',
399
+ inputSchema: {
400
+ type: 'object',
401
+ properties: {
402
+ session: {
403
+ type: 'string',
404
+ description:
405
+ 'Session key to report on (default: SIGMAP_SESSION env or the UTC day bucket).',
406
+ },
407
+ budgetTokens: {
408
+ type: 'number',
409
+ description:
410
+ 'Budget override in estimated tokens (default: config sessionBudgetTokens).',
411
+ },
412
+ },
413
+ required: [],
414
+ },
415
+ },
390
416
  ];
391
417
 
392
418
  module.exports = { TOOLS };
@@ -0,0 +1,56 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Standalone text redaction (v8.24 G3a) — the same pattern bank the
5
+ * generation-time scanner uses (security/patterns.js), applied to arbitrary
6
+ * text. Unlike scanner.js (which replaces whole signature lines), this masks
7
+ * only the matched secret substring so surrounding text stays readable.
8
+ *
9
+ * Zero dependencies; never throws — on any error the original text is
10
+ * returned unredacted.
11
+ */
12
+
13
+ const { PATTERNS } = require('./patterns');
14
+
15
+ // Global variants of the pattern regexes (needed for replace-all per line).
16
+ const GLOBAL_PATTERNS = PATTERNS.map((p) => ({
17
+ name: p.name,
18
+ regex: new RegExp(p.regex.source, p.regex.flags.includes('g') ? p.regex.flags : p.regex.flags + 'g'),
19
+ }));
20
+
21
+ /**
22
+ * Redact secrets in arbitrary text.
23
+ * @param {string} text
24
+ * @returns {{
25
+ * text: string, redacted: boolean,
26
+ * findings: Array<{ line: number, pattern: string }>,
27
+ * counts: Object<string, number>
28
+ * }}
29
+ */
30
+ function redactText(text) {
31
+ if (typeof text !== 'string' || text.length === 0) {
32
+ return { text: typeof text === 'string' ? text : '', redacted: false, findings: [], counts: {} };
33
+ }
34
+ try {
35
+ const findings = [];
36
+ const counts = {};
37
+ const lines = text.split('\n');
38
+ const out = lines.map((line, i) => {
39
+ let masked = line;
40
+ for (const p of GLOBAL_PATTERNS) {
41
+ p.regex.lastIndex = 0;
42
+ if (!p.regex.test(masked)) continue;
43
+ p.regex.lastIndex = 0;
44
+ masked = masked.replace(p.regex, `[REDACTED:${p.name}]`);
45
+ findings.push({ line: i + 1, pattern: p.name });
46
+ counts[p.name] = (counts[p.name] || 0) + 1;
47
+ }
48
+ return masked;
49
+ });
50
+ return { text: out.join('\n'), redacted: findings.length > 0, findings, counts };
51
+ } catch (_) {
52
+ return { text, redacted: false, findings: [], counts: {} };
53
+ }
54
+ }
55
+
56
+ module.exports = { redactText };
@@ -0,0 +1,113 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Session spend ledger (v8.23 F1) — a queryable view over the existing gain
5
+ * log (.context/gain.ndjson, written by recordUsage).
6
+ *
7
+ * Honesty scope: this ledger counts tokens **SigMap emitted** (chars/4
8
+ * estimates), not the host chat's total spend — conversation history, model
9
+ * output, and other tools are invisible to a CLI. Every number here is an
10
+ * estimate and is labeled as such in output.
11
+ *
12
+ * Session identity: `SIGMAP_SESSION` env var when the host sets one, else a
13
+ * UTC day bucket (YYYY-MM-DD). Legacy gain entries (written before the
14
+ * `session` field existed) match day-bucket sessions by timestamp prefix.
15
+ *
16
+ * Zero dependencies; local JSON only.
17
+ */
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const { readGainLog } = require('./logger');
22
+
23
+ // Same generated-context surfaces cache/freshen.js watches.
24
+ const CONTEXT_PATHS = [
25
+ ['.github', 'copilot-instructions.md'],
26
+ ['CLAUDE.md'], ['AGENTS.md'], ['.github', 'context-cold.md'],
27
+ ];
28
+
29
+ /** Session key: SIGMAP_SESSION override, else UTC day bucket. */
30
+ function sessionKey(env) {
31
+ const e = env || process.env;
32
+ if (e.SIGMAP_SESSION) return String(e.SIGMAP_SESSION);
33
+ return new Date().toISOString().slice(0, 10);
34
+ }
35
+
36
+ /** Newest mtime (ms) among generated context files, or 0 if none exist. */
37
+ function contextMtime(cwd) {
38
+ let newest = 0;
39
+ for (const parts of CONTEXT_PATHS) {
40
+ try { newest = Math.max(newest, fs.statSync(path.join(cwd, ...parts)).mtimeMs); } catch (_) {}
41
+ }
42
+ return newest;
43
+ }
44
+
45
+ /** Does a gain-log entry belong to this session? */
46
+ function entryInSession(entry, session) {
47
+ if (entry.session) return entry.session === session;
48
+ // Legacy entry: match day-bucket sessions on the timestamp date.
49
+ return /^\d{4}-\d{2}-\d{2}$/.test(session) && String(entry.ts || '').startsWith(session);
50
+ }
51
+
52
+ /**
53
+ * Session spend status.
54
+ * @param {string} cwd
55
+ * @param {object} [opts]
56
+ * @param {string} [opts.session] session key (default: sessionKey())
57
+ * @param {number} [opts.budgetTokens] budget override (else config sessionBudgetTokens)
58
+ * @param {number} [opts.contextTtlDays] TTL override (else config contextTtlDays)
59
+ * @param {object} [opts.config] loaded config (for the two keys above)
60
+ * @param {number} [opts.now] clock override for tests (ms)
61
+ * @returns {{
62
+ * session: string, unit: 'estimated-tokens', ops: number,
63
+ * spentTokens: number, baselineTokens: number, savedTokens: number,
64
+ * budgetTokens: number|null, remainingTokens: number|null, pctUsed: number|null,
65
+ * overBudget: boolean,
66
+ * context: { exists: boolean, ageMs: number|null, ageDays: number|null,
67
+ * ttlDays: number|null, stale: boolean }
68
+ * }}
69
+ */
70
+ function budgetStatus(cwd, opts = {}) {
71
+ const session = opts.session || sessionKey();
72
+ const cfg = opts.config || {};
73
+ const budget = opts.budgetTokens != null ? Number(opts.budgetTokens)
74
+ : (Number.isFinite(cfg.sessionBudgetTokens) ? cfg.sessionBudgetTokens : null);
75
+ const ttlDays = opts.contextTtlDays != null ? Number(opts.contextTtlDays)
76
+ : (Number.isFinite(cfg.contextTtlDays) ? cfg.contextTtlDays : null);
77
+ const now = opts.now != null ? opts.now : Date.now();
78
+
79
+ let ops = 0, spent = 0, baseline = 0, saved = 0;
80
+ for (const e of readGainLog(cwd)) {
81
+ if (!entryInSession(e, session)) continue;
82
+ ops++;
83
+ spent += Number(e.actualTokens) || 0;
84
+ baseline += Number(e.baselineTokens) || 0;
85
+ saved += Number(e.savedTokens) || 0;
86
+ }
87
+
88
+ const mtime = contextMtime(cwd);
89
+ const ageMs = mtime > 0 ? Math.max(0, now - mtime) : null;
90
+ const ageDays = ageMs != null ? ageMs / 86400000 : null;
91
+
92
+ return {
93
+ session,
94
+ unit: 'estimated-tokens',
95
+ ops,
96
+ spentTokens: spent,
97
+ baselineTokens: baseline,
98
+ savedTokens: saved,
99
+ budgetTokens: budget,
100
+ remainingTokens: budget != null ? Math.max(0, budget - spent) : null,
101
+ pctUsed: budget > 0 ? Math.round((spent / budget) * 1000) / 10 : null,
102
+ overBudget: budget != null && spent > budget,
103
+ context: {
104
+ exists: mtime > 0,
105
+ ageMs,
106
+ ageDays: ageDays != null ? Math.round(ageDays * 10) / 10 : null,
107
+ ttlDays,
108
+ stale: ttlDays != null && ageDays != null && ageDays > ttlDays,
109
+ },
110
+ };
111
+ }
112
+
113
+ module.exports = { sessionKey, budgetStatus, contextMtime, entryInSession };
@@ -175,10 +175,12 @@ function recordUsage(entry, cwd) {
175
175
  const baseline = Math.max(0, Number(entry.baselineTokens) || 0);
176
176
  const actual = Math.max(0, Number(entry.actualTokens) || 0);
177
177
  const saved = Math.max(0, baseline - actual);
178
+ const ts = new Date().toISOString();
178
179
  const record = {
179
- ts: new Date().toISOString(),
180
+ ts,
180
181
  v: entry.version || '0.9.0',
181
182
  op: entry.op || 'generate',
183
+ session: entry.session || process.env.SIGMAP_SESSION || ts.slice(0, 10),
182
184
  baselineTokens: baseline,
183
185
  actualTokens: actual,
184
186
  savedTokens: saved,