sigmap 6.11.1 → 6.12.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,24 @@ Format: [Semantic Versioning](https://semver.org/)
10
10
 
11
11
  ---
12
12
 
13
+ ## [6.12.0] — 2026-06-05
14
+
15
+ ### Added
16
+
17
+ - **Surgical Context Phase 2 — demand-driven retrieval (#219, PR #220):**
18
+ - **`get_lines` MCP tool** (10th tool) — fetch an exact `{ file, start, end }` line range on demand. Lines are clamped to the file bounds, secret-scanned via the existing redactor, and sandboxed to the project root. This is the demand-driven workhorse: agents read the lines behind a `:start-end` anchor instead of re-opening whole files.
19
+ - **`sigmap ask --mode index`** — two-tier output that emits only symbol-header pointers (`symbol :start-end`), dropping parameter lists, return types, and bodies. Agents re-fetch bodies via `get_lines`.
20
+ - **`sigmap ask --since <ref>`** — delta context that restricts ranked output to files changed since a git ref.
21
+ - **Token Reduction dashboard panel (Surface A)** — `sigmap --report` now renders a "Token Reduction" panel (whole-file baseline vs ranked signatures vs surgical, with per-repo rows), sourced from `benchmarks/reports/token-reduction.json` — numbers are never hand-typed.
22
+ - New **Surgical Context** guide (`docs-vp/guide/surgical-context.md`) covering line anchors, `--mode index`, `--since`, and the `get_lines` MCP tool.
23
+
24
+ ### Changed
25
+
26
+ - **Budget-aware progressive disclosure** — when generated context exceeds `maxTokens`, the token budget now collapses signature bodies to their line anchors (keeping `symbol :start-end`) *before* dropping whole files, degrading gracefully.
27
+ - **CI** — added `workflow_dispatch` to the develop→main sync workflow so it can be run on demand (PR #218).
28
+
29
+ ---
30
+
13
31
  ## [6.11.1] — 2026-06-04
14
32
 
15
33
  ### Fixed
package/README.md CHANGED
@@ -87,8 +87,8 @@ Ask → Rank → Context → Validate → Judge → Learn
87
87
  ## Benchmark
88
88
 
89
89
  ```
90
- Benchmark : sigmap-v6.11-main (21 repositories, including R language)
91
- Date : 2026-06-04
90
+ Benchmark : sigmap-v6.12-main (21 repositories, including R language)
91
+ Date : 2026-06-05
92
92
 
93
93
  Hit@5 : 81.1% (baseline 13.6% — 6.0× lift)
94
94
  Token reduction: 96.5% (across 21 repos)
@@ -183,7 +183,7 @@ Use SigMap with open-source tools and fully self-hosted setups:
183
183
  | **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 |
184
184
  | **Neovim** | lazy.nvim / packer / vim-plug | [github.com/manojmallick/sigmap.nvim](https://github.com/manojmallick/sigmap.nvim) | `:SigMap`, `:SigMapQuery` float window, statusline widget |
185
185
 
186
- **MCP server** — 9 on-demand tools for Claude Code and Cursor:
186
+ **MCP server** — 10 on-demand tools for Claude Code and Cursor:
187
187
 
188
188
  ```bash
189
189
  sigmap --mcp
package/gen-context.js CHANGED
@@ -4052,6 +4052,96 @@ __factories["./src/format/benchmark-report"] = function(module, exports) {
4052
4052
  }).join('');
4053
4053
  }
4054
4054
 
4055
+ function escapeAttr(s) {
4056
+ return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
4057
+ { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
4058
+ ));
4059
+ }
4060
+
4061
+ function readTokenReduction(cwd) {
4062
+ const p = path.join(cwd, 'benchmarks', 'reports', 'token-reduction.json');
4063
+ let data;
4064
+ try { data = JSON.parse(fs.readFileSync(p, 'utf8')); } catch (_) { return null; }
4065
+ const repos = Array.isArray(data.repos) ? data.repos : [];
4066
+ if (repos.length === 0) return null;
4067
+ let baseline = 0, signatures = 0, surgical = 0, hasSurgical = false;
4068
+ for (const r of repos) {
4069
+ baseline += toNumber(r.rawTokens) || 0;
4070
+ signatures += toNumber(r.finalTokens) || 0;
4071
+ const s = toNumber(r.surgicalTokens);
4072
+ if (s !== null) { surgical += s; hasSurgical = true; }
4073
+ }
4074
+ const out = {
4075
+ version: data.version || null,
4076
+ repoCount: repos.length,
4077
+ baseline,
4078
+ signatures,
4079
+ savedPct: baseline > 0 ? Math.round((1 - signatures / baseline) * 1000) / 10 : 0,
4080
+ perRepo: repos.map((r) => ({
4081
+ repo: r.repo, language: r.language,
4082
+ rawTokens: toNumber(r.rawTokens) || 0,
4083
+ finalTokens: toNumber(r.finalTokens) || 0,
4084
+ reductionPct: toNumber(r.reductionPct) || 0,
4085
+ })),
4086
+ };
4087
+ if (hasSurgical) {
4088
+ out.surgical = surgical;
4089
+ out.surgicalSavedPct = baseline > 0 ? Math.round((1 - surgical / baseline) * 1000) / 10 : 0;
4090
+ }
4091
+ return out;
4092
+ }
4093
+
4094
+ function tokenReductionPanelHtml(tr) {
4095
+ if (!tr) {
4096
+ return '<div class="panel"><div class="label">Token Reduction</div>' +
4097
+ '<div class="value" style="font-size:13px">No token-reduction benchmark found — run the token benchmark to populate this panel.</div></div>';
4098
+ }
4099
+ const fmt = (n) => Number(n).toLocaleString('en-US');
4100
+ const tiers = [
4101
+ { label: 'Whole-file baseline', value: fmt(tr.baseline) + ' tok' },
4102
+ { label: 'Ranked signatures (ask)', value: fmt(tr.signatures) + ' tok' },
4103
+ ];
4104
+ if (tr.surgical != null) {
4105
+ tiers.push({ label: 'Surgical (index + delta)', value: fmt(tr.surgical) + ' tok' });
4106
+ tiers.push({ label: 'Saved (surgical)', value: tr.surgicalSavedPct + '%' });
4107
+ } else {
4108
+ tiers.push({ label: 'Saved', value: tr.savedPct + '%' });
4109
+ }
4110
+ const tierHtml = tiers.map((t) =>
4111
+ `<div class="card"><div class="label">${escapeAttr(t.label)}</div><div class="value">${escapeAttr(t.value)}</div></div>`
4112
+ ).join('');
4113
+ const sigPct = tr.baseline > 0 ? Math.max(0.4, (tr.signatures / tr.baseline) * 100) : 0;
4114
+ const surgPct = (tr.surgical != null && tr.baseline > 0) ? Math.max(0.4, (tr.surgical / tr.baseline) * 100) : null;
4115
+ const barRow = (label, pct, color) =>
4116
+ `<div style="margin:4px 0;font-size:11px;color:#8ea0d9">${escapeAttr(label)}</div>` +
4117
+ `<div style="background:#0a0f1e;border:1px solid #223056;border-radius:6px;height:14px;overflow:hidden">` +
4118
+ `<div style="width:${pct.toFixed(1)}%;height:100%;background:${color}"></div></div>`;
4119
+ const bars = [
4120
+ barRow('Whole-file baseline (100%)', 100, '#3a4a78'),
4121
+ barRow(`Ranked signatures — ${tr.savedPct}% saved`, sigPct, '#2e7d6b'),
4122
+ surgPct != null ? barRow(`Surgical — ${tr.surgicalSavedPct}% saved`, surgPct, '#5ad1a8') : '',
4123
+ ].filter(Boolean).join('');
4124
+ const rows = tr.perRepo.slice(0, 8).map((r) =>
4125
+ `<tr><td>${escapeAttr(r.repo)}</td><td>${escapeAttr(r.language)}</td>` +
4126
+ `<td style="text-align:right">${fmt(r.rawTokens)}</td>` +
4127
+ `<td style="text-align:right">${fmt(r.finalTokens)}</td>` +
4128
+ `<td style="text-align:right">${r.reductionPct}%</td></tr>`
4129
+ ).join('');
4130
+ return [
4131
+ '<div class="panel">',
4132
+ `<div class="label">Token Reduction — ${tr.repoCount} benchmark repos${tr.version ? ' · v' + escapeAttr(tr.version) : ''}</div>`,
4133
+ `<div class="grid" style="margin:8px 0">${tierHtml}</div>`,
4134
+ bars,
4135
+ '<table style="width:100%;border-collapse:collapse;margin-top:10px;font-size:12px">',
4136
+ '<thead><tr style="color:#8ea0d9;text-align:left">',
4137
+ '<th>Repo</th><th>Lang</th><th style="text-align:right">Baseline</th><th style="text-align:right">Signatures</th><th style="text-align:right">Saved</th>',
4138
+ '</tr></thead>',
4139
+ `<tbody>${rows}</tbody>`,
4140
+ '</table>',
4141
+ '</div>',
4142
+ ].join('');
4143
+ }
4144
+
4055
4145
  function buildDashboardData(cwd, health) {
4056
4146
  const entries = readLog(cwd);
4057
4147
  const recent = entries.slice(-30);
@@ -4070,15 +4160,18 @@ __factories["./src/format/benchmark-report"] = function(module, exports) {
4070
4160
  overBudgetStreak: overBudgetStreak(entries),
4071
4161
  extractorCoverage: coverage.pct,
4072
4162
  };
4163
+ const tokenReduction = readTokenReduction(cwd);
4073
4164
  return {
4074
4165
  summary,
4075
4166
  tokenReductionTrend,
4076
4167
  hitAt5Trend,
4077
4168
  coverage,
4169
+ tokenReduction,
4078
4170
  charts: {
4079
4171
  tokenReductionSvg: lineChartSvg(tokenReductionTrend, 'Token reduction trend (last 30 tracked runs)', '%'),
4080
4172
  hitAt5Svg: lineChartSvg(hitAt5Trend, 'hit@5 trend (last 30 benchmark runs)', ''),
4081
4173
  coverageSvg: barChartSvg(coverage.perLanguage),
4174
+ tokenSavingsPanel: tokenReductionPanelHtml(tokenReduction),
4082
4175
  },
4083
4176
  };
4084
4177
  }
@@ -4112,6 +4205,7 @@ __factories["./src/format/benchmark-report"] = function(module, exports) {
4112
4205
  '<h1>SigMap v2.10 dashboard</h1>',
4113
4206
  '<div class="sub">Self-contained report. No external scripts, styles, or network calls.</div>',
4114
4207
  `<div class="grid">${cardHtml}</div>`,
4208
+ data.charts.tokenSavingsPanel,
4115
4209
  `<div class="panel">${data.charts.tokenReductionSvg}</div>`,
4116
4210
  `<div class="panel">${data.charts.hitAt5Svg}</div>`,
4117
4211
  `<div class="panel">${data.charts.coverageSvg}</div>`,
@@ -5719,7 +5813,56 @@ __factories["./src/mcp/handlers"] = function(module, exports) {
5719
5813
  }
5720
5814
  }
5721
5815
 
5722
- module.exports = { readContext, searchSignatures, getMap, createCheckpoint, getRouting, explainFile, listModules, queryContext, getImpact };
5816
+ function getLines(args, cwd) {
5817
+ if (!args || !args.file) return 'Missing required argument: file';
5818
+
5819
+ const rel = String(args.file).replace(/\\/g, '/').replace(/^\//, '');
5820
+ const abs = path.resolve(cwd, rel);
5821
+
5822
+ // Sandbox: refuse paths that resolve outside the project root.
5823
+ const root = path.resolve(cwd);
5824
+ if (abs !== root && !abs.startsWith(root + path.sep)) {
5825
+ return `Refused: ${rel} resolves outside the project root`;
5826
+ }
5827
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) {
5828
+ return `File not found: ${rel}`;
5829
+ }
5830
+
5831
+ const start = parseInt(args.start, 10);
5832
+ const end = parseInt(args.end, 10);
5833
+ if (!Number.isFinite(start) || !Number.isFinite(end)) {
5834
+ return 'Arguments "start" and "end" must be numbers (1-based line numbers).';
5835
+ }
5836
+
5837
+ let lines;
5838
+ try {
5839
+ lines = fs.readFileSync(abs, 'utf8').split('\n');
5840
+ } catch (err) {
5841
+ return `Could not read ${rel}: ${err.message}`;
5842
+ }
5843
+
5844
+ const total = lines.length;
5845
+ const from = Math.max(1, Math.min(start, end));
5846
+ const to = Math.min(total, Math.max(start, end));
5847
+ if (from > total) return `${rel} has only ${total} lines; requested ${start}-${end}`;
5848
+
5849
+ const slice = lines.slice(from - 1, to);
5850
+
5851
+ let safeLines = slice;
5852
+ try {
5853
+ const { scan } = __require('./src/security/scanner');
5854
+ safeLines = scan(slice, rel).safe;
5855
+ } catch (_) {}
5856
+
5857
+ return [
5858
+ `# ${rel}:${from}-${to}`,
5859
+ '```',
5860
+ ...safeLines,
5861
+ '```',
5862
+ ].join('\n');
5863
+ }
5864
+
5865
+ module.exports = { readContext, searchSignatures, getMap, createCheckpoint, getRouting, explainFile, listModules, queryContext, getImpact, getLines };
5723
5866
  };
5724
5867
 
5725
5868
  // ── ./src/learning/weights ──
@@ -5890,17 +6033,17 @@ __factories["./src/mcp/server"] = function(module, exports) {
5890
6033
  *
5891
6034
  * Supported methods:
5892
6035
  * initialize → serverInfo + capabilities
5893
- * tools/list → 3 tool definitions
6036
+ * tools/list → 10 tool definitions
5894
6037
  * tools/call → dispatch to handler, return result
5895
6038
  */
5896
-
6039
+
5897
6040
  const readline = require('readline');
5898
6041
  const { TOOLS } = __require('./src/mcp/tools');
5899
- const { readContext, searchSignatures, getMap, createCheckpoint, getRouting, explainFile, listModules, queryContext, getImpact } = __require('./src/mcp/handlers');
5900
-
6042
+ const { readContext, searchSignatures, getMap, createCheckpoint, getRouting, explainFile, listModules, queryContext, getImpact, getLines } = __require('./src/mcp/handlers');
6043
+
5901
6044
  const SERVER_INFO = {
5902
6045
  name: 'sigmap',
5903
- version: '6.11.1',
6046
+ version: '6.12.0',
5904
6047
  description: 'SigMap MCP server — code signatures on demand',
5905
6048
  };
5906
6049
 
@@ -5957,6 +6100,7 @@ __factories["./src/mcp/server"] = function(module, exports) {
5957
6100
  else if (name === 'list_modules') text = listModules(args, cwd);
5958
6101
  else if (name === 'query_context') text = queryContext(args, cwd);
5959
6102
  else if (name === 'get_impact') text = getImpact(args, cwd);
6103
+ else if (name === 'get_lines') text = getLines(args, cwd);
5960
6104
  else {
5961
6105
  respondError(id, -32601, `Unknown tool: ${name}`);
5962
6106
  return;
@@ -6184,8 +6328,36 @@ __factories["./src/mcp/tools"] = function(module, exports) {
6184
6328
  required: ['file'],
6185
6329
  },
6186
6330
  },
6331
+ {
6332
+ name: 'get_lines',
6333
+ description:
6334
+ 'Fetch an exact line range from a source file on demand — the Surgical Context ' +
6335
+ 'workhorse. Signatures carry `path:start-end` anchors; call this to read just those ' +
6336
+ 'lines instead of re-opening the whole file. Lines are clamped to the file bounds and ' +
6337
+ 'secret-scanned (redacted) before return. Path is sandboxed to the project root.',
6338
+ inputSchema: {
6339
+ type: 'object',
6340
+ properties: {
6341
+ file: {
6342
+ type: 'string',
6343
+ description:
6344
+ 'Relative path from the project root (e.g. "src/config/loader.js"). ' +
6345
+ 'Use the path shown in a signature anchor. Use forward slashes.',
6346
+ },
6347
+ start: {
6348
+ type: 'number',
6349
+ description: '1-based start line (inclusive). Clamped to the file bounds.',
6350
+ },
6351
+ end: {
6352
+ type: 'number',
6353
+ description: '1-based end line (inclusive). Clamped to the file bounds.',
6354
+ },
6355
+ },
6356
+ required: ['file', 'start', 'end'],
6357
+ },
6358
+ },
6187
6359
  ];
6188
-
6360
+
6189
6361
  module.exports = { TOOLS };
6190
6362
 
6191
6363
  };
@@ -8695,7 +8867,7 @@ const path = require('path');
8695
8867
  const os = require('os');
8696
8868
  const { execSync } = require('child_process');
8697
8869
 
8698
- const VERSION = '6.11.1';
8870
+ const VERSION = '6.12.0';
8699
8871
  const MARKER = '\n\n## Auto-generated signatures\n<!-- Updated by gen-context.js -->\n';
8700
8872
 
8701
8873
  function requireSourceOrBundled(key) {
@@ -8987,8 +9159,28 @@ function applyTokenBudget(fileEntries, maxTokens) {
8987
9159
  let total = fileEntries.reduce((s, e) => s + estimateTokens(e.sigs.join('\n')), 0);
8988
9160
  if (total <= effectiveBudget) return fileEntries;
8989
9161
 
9162
+ // v6.12 Surgical Context — progressive disclosure: before dropping whole files,
9163
+ // collapse signature BODIES to their line-anchor pointers (keep `symbol :start-end`).
9164
+ // Only sigs that actually carry an anchor shrink; the agent can re-fetch bodies via
9165
+ // the get_lines MCP tool. This degrades gracefully instead of losing files outright.
9166
+ let working = fileEntries;
9167
+ const collapsed = fileEntries.map((e) => {
9168
+ const slim = (e.sigs || []).map((s) => {
9169
+ const line = toIndexLine(s);
9170
+ return line && /:\d+-\d+/.test(line) ? line : s; // replace only when it yields an anchor
9171
+ });
9172
+ return { ...e, sigs: slim };
9173
+ });
9174
+ const collapsedTotal = collapsed.reduce((s, e) => s + estimateTokens(e.sigs.join('\n')), 0);
9175
+ if (collapsedTotal < total) {
9176
+ console.warn(`[sigmap] budget: collapsed bodies to anchors, reclaimed ~${total - collapsedTotal} tokens`);
9177
+ working = collapsed;
9178
+ total = collapsedTotal;
9179
+ if (total <= effectiveBudget) return working; // anchor collapse alone fit the budget
9180
+ }
9181
+
8990
9182
  // Sort by drop priority (drop first = index 0)
8991
- const withPriority = fileEntries.map((e) => {
9183
+ const withPriority = working.map((e) => {
8992
9184
  let priority = 0;
8993
9185
  let dropReason = 'budget: low recency';
8994
9186
  if (isGeneratedFile(e.filePath)) { priority = 10; dropReason = 'budget: generated file'; }
@@ -10576,6 +10768,40 @@ function buildMiniContext(ranked, cwd) {
10576
10768
  return lines.join('\n');
10577
10769
  }
10578
10770
 
10771
+ // Surgical Context Phase 2 (v6.12.0): collapse one signature to a symbol-header
10772
+ // pointer — `<declaration head> :start-end` — dropping param lists, return types,
10773
+ // and any body so the agent fetches the real lines on demand via the get_lines MCP tool.
10774
+ function toIndexLine(sig) {
10775
+ if (typeof sig !== 'string') return '';
10776
+ // Anchor is `:start-end`, optionally followed by a `# doc hint` (Python extractor).
10777
+ const m = sig.match(/\s*:(\d+)-(\d+)(?:\s*#.*)?\s*$/);
10778
+ if (!m) {
10779
+ // No anchor (e.g. an indented member) — keep a trimmed head so it stays listed.
10780
+ return sig.trim().slice(0, 60);
10781
+ }
10782
+ const range = `:${m[1]}-${m[2]}`;
10783
+ let head = sig.slice(0, m.index).trim();
10784
+ const paren = head.indexOf('(');
10785
+ if (paren > 0) head = head.slice(0, paren).trim(); // drop params
10786
+ head = head.replace(/\s*[=→].*$/, '').trim(); // drop return type / assignment tail
10787
+ return head ? `${head} ${range}` : range;
10788
+ }
10789
+
10790
+ // Two-tier index output: emit only `file → [symbol:start-end]` headers (no bodies).
10791
+ function buildIndexContext(ranked, cwd) {
10792
+ const lines = [
10793
+ '# SigMap Query Context (index mode)',
10794
+ `Generated: ${new Date().toISOString()}`,
10795
+ '> Symbol index only — fetch exact lines on demand via the `get_lines` MCP tool.',
10796
+ '',
10797
+ ];
10798
+ for (const { file, sigs } of ranked) {
10799
+ const idx = sigs.slice(0, 40).map(toIndexLine).filter(Boolean);
10800
+ lines.push(`## ${file}`, '```', ...idx, '```', '');
10801
+ }
10802
+ return lines.join('\n');
10803
+ }
10804
+
10579
10805
  function computeCurrentRisk(cwd) {
10580
10806
  try {
10581
10807
  const { execSync } = require('child_process');
@@ -10746,7 +10972,7 @@ function main() {
10746
10972
  const mode = modeIdx !== -1
10747
10973
  ? args[modeIdx + 1]
10748
10974
  : (config.mode || 'default');
10749
- const VALID_MODES = ['default', 'fast', 'full', 'both'];
10975
+ const VALID_MODES = ['default', 'fast', 'full', 'both', 'index'];
10750
10976
  if (mode && !VALID_MODES.includes(mode)) {
10751
10977
  console.error(`[sigmap] unknown --mode "${mode}". Valid: ${VALID_MODES.join(', ')}`);
10752
10978
  process.exit(1);
@@ -10825,9 +11051,23 @@ function main() {
10825
11051
  }
10826
11052
  }
10827
11053
 
11054
+ // v6.12: Delta context — restrict to files changed since a git ref (--since <ref>).
11055
+ const sinceIdx = args.indexOf('--since');
11056
+ if (sinceIdx !== -1 && args[sinceIdx + 1]) {
11057
+ const baseRef = args[sinceIdx + 1];
11058
+ const changed = getFilesChangedSinceBase(cwd, baseRef);
11059
+ const before = ranked.length;
11060
+ ranked = ranked.filter((r) => changed.has(path.resolve(cwd, r.file)));
11061
+ if (!args.includes('--json')) {
11062
+ process.stderr.write(`[sigmap] Δ since ${baseRef}: ${ranked.length}/${before} ranked files changed\n`);
11063
+ }
11064
+ }
11065
+
10828
11066
  // v6.8: Save session for future --followup calls
10829
11067
  saveSession(cwd, { intent, topFiles: ranked.slice(0, 5).map(r => ({ file: r.file, score: r.score })), query });
10830
- const miniCtx = buildMiniContext(ranked, cwd);
11068
+ // v6.12: Two-tier output — `--mode index` emits symbol pointers only (bodies via get_lines).
11069
+ const indexMode = mode === 'index' || args.includes('--index');
11070
+ const miniCtx = indexMode ? buildIndexContext(ranked, cwd) : buildMiniContext(ranked, cwd);
10831
11071
  const outPath = path.join(cwd, '.context', 'query-context.md');
10832
11072
  fs.mkdirSync(path.dirname(outPath), { recursive: true });
10833
11073
  fs.writeFileSync(outPath, miniCtx, 'utf8');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigmap",
3
- "version": "6.11.1",
3
+ "version": "6.12.0",
4
4
  "description": "Zero-dependency AI context engine — 97% token reduction. No npm install. Runs on Node 18+.",
5
5
  "main": "gen-context.js",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigmap-cli",
3
- "version": "6.11.1",
3
+ "version": "6.12.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": "6.11.1",
3
+ "version": "6.12.0",
4
4
  "description": "SigMap core library — zero-dependency code signature extraction, retrieval, and security scanning",
5
5
  "main": "index.js",
6
6
  "keywords": [
@@ -304,6 +304,106 @@ function sparkline(values) {
304
304
  }).join('');
305
305
  }
306
306
 
307
+ function escapeAttr(s) {
308
+ return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
309
+ { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
310
+ ));
311
+ }
312
+
313
+ // Surgical Context (v6.12.0): read the published token-reduction benchmark and
314
+ // aggregate it for the dashboard panel. Numbers are never hand-typed — they come
315
+ // straight from benchmarks/reports/token-reduction.json.
316
+ function readTokenReduction(cwd) {
317
+ const p = path.join(cwd, 'benchmarks', 'reports', 'token-reduction.json');
318
+ let data;
319
+ try { data = JSON.parse(fs.readFileSync(p, 'utf8')); } catch (_) { return null; }
320
+ const repos = Array.isArray(data.repos) ? data.repos : [];
321
+ if (repos.length === 0) return null;
322
+
323
+ let baseline = 0, signatures = 0, surgical = 0, hasSurgical = false;
324
+ for (const r of repos) {
325
+ baseline += toNumber(r.rawTokens) || 0;
326
+ signatures += toNumber(r.finalTokens) || 0;
327
+ const s = toNumber(r.surgicalTokens);
328
+ if (s !== null) { surgical += s; hasSurgical = true; }
329
+ }
330
+
331
+ const out = {
332
+ version: data.version || null,
333
+ repoCount: repos.length,
334
+ baseline,
335
+ signatures,
336
+ savedPct: baseline > 0 ? Math.round((1 - signatures / baseline) * 1000) / 10 : 0,
337
+ perRepo: repos.map((r) => ({
338
+ repo: r.repo,
339
+ language: r.language,
340
+ rawTokens: toNumber(r.rawTokens) || 0,
341
+ finalTokens: toNumber(r.finalTokens) || 0,
342
+ reductionPct: toNumber(r.reductionPct) || 0,
343
+ })),
344
+ };
345
+ if (hasSurgical) {
346
+ out.surgical = surgical;
347
+ out.surgicalSavedPct = baseline > 0 ? Math.round((1 - surgical / baseline) * 1000) / 10 : 0;
348
+ }
349
+ return out;
350
+ }
351
+
352
+ function tokenReductionPanelHtml(tr) {
353
+ if (!tr) {
354
+ return '<div class="panel"><div class="label">Token Reduction</div>' +
355
+ '<div class="value" style="font-size:13px">No token-reduction benchmark found — run the token benchmark to populate this panel.</div></div>';
356
+ }
357
+ const fmt = (n) => Number(n).toLocaleString('en-US');
358
+ const tiers = [
359
+ { label: 'Whole-file baseline', value: fmt(tr.baseline) + ' tok' },
360
+ { label: 'Ranked signatures (ask)', value: fmt(tr.signatures) + ' tok' },
361
+ ];
362
+ if (tr.surgical != null) {
363
+ tiers.push({ label: 'Surgical (index + delta)', value: fmt(tr.surgical) + ' tok' });
364
+ tiers.push({ label: 'Saved (surgical)', value: tr.surgicalSavedPct + '%' });
365
+ } else {
366
+ tiers.push({ label: 'Saved', value: tr.savedPct + '%' });
367
+ }
368
+ const tierHtml = tiers.map((t) =>
369
+ `<div class="card"><div class="label">${escapeAttr(t.label)}</div><div class="value">${escapeAttr(t.value)}</div></div>`
370
+ ).join('');
371
+
372
+ // Proportional comparison bar (baseline = full width).
373
+ const sigPct = tr.baseline > 0 ? Math.max(0.4, (tr.signatures / tr.baseline) * 100) : 0;
374
+ const surgPct = (tr.surgical != null && tr.baseline > 0) ? Math.max(0.4, (tr.surgical / tr.baseline) * 100) : null;
375
+ const barRow = (label, pct, color) =>
376
+ `<div style="margin:4px 0;font-size:11px;color:#8ea0d9">${escapeAttr(label)}</div>` +
377
+ `<div style="background:#0a0f1e;border:1px solid #223056;border-radius:6px;height:14px;overflow:hidden">` +
378
+ `<div style="width:${pct.toFixed(1)}%;height:100%;background:${color}"></div></div>`;
379
+ const bars = [
380
+ barRow('Whole-file baseline (100%)', 100, '#3a4a78'),
381
+ barRow(`Ranked signatures — ${tr.savedPct}% saved`, sigPct, '#2e7d6b'),
382
+ surgPct != null ? barRow(`Surgical — ${tr.surgicalSavedPct}% saved`, surgPct, '#5ad1a8') : '',
383
+ ].filter(Boolean).join('');
384
+
385
+ const rows = tr.perRepo.slice(0, 8).map((r) =>
386
+ `<tr><td>${escapeAttr(r.repo)}</td><td>${escapeAttr(r.language)}</td>` +
387
+ `<td style="text-align:right">${fmt(r.rawTokens)}</td>` +
388
+ `<td style="text-align:right">${fmt(r.finalTokens)}</td>` +
389
+ `<td style="text-align:right">${r.reductionPct}%</td></tr>`
390
+ ).join('');
391
+
392
+ return [
393
+ '<div class="panel">',
394
+ `<div class="label">Token Reduction — ${tr.repoCount} benchmark repos${tr.version ? ' · v' + escapeAttr(tr.version) : ''}</div>`,
395
+ `<div class="grid" style="margin:8px 0">${tierHtml}</div>`,
396
+ bars,
397
+ '<table style="width:100%;border-collapse:collapse;margin-top:10px;font-size:12px">',
398
+ '<thead><tr style="color:#8ea0d9;text-align:left">',
399
+ '<th>Repo</th><th>Lang</th><th style="text-align:right">Baseline</th><th style="text-align:right">Signatures</th><th style="text-align:right">Saved</th>',
400
+ '</tr></thead>',
401
+ `<tbody>${rows}</tbody>`,
402
+ '</table>',
403
+ '</div>',
404
+ ].join('');
405
+ }
406
+
307
407
  function buildDashboardData(cwd, health) {
308
408
  const entries = readLog(cwd);
309
409
  const recent = entries.slice(-30);
@@ -326,15 +426,19 @@ function buildDashboardData(cwd, health) {
326
426
  extractorCoverage: coverage.pct,
327
427
  };
328
428
 
429
+ const tokenReduction = readTokenReduction(cwd);
430
+
329
431
  return {
330
432
  summary,
331
433
  tokenReductionTrend,
332
434
  hitAt5Trend,
333
435
  coverage,
436
+ tokenReduction,
334
437
  charts: {
335
438
  tokenReductionSvg: lineChartSvg(tokenReductionTrend, 'Token reduction trend (last 30 tracked runs)', '%'),
336
439
  hitAt5Svg: lineChartSvg(hitAt5Trend, 'hit@5 trend (last 30 benchmark runs)', ''),
337
440
  coverageSvg: barChartSvg(coverage.perLanguage),
441
+ tokenSavingsPanel: tokenReductionPanelHtml(tokenReduction),
338
442
  },
339
443
  };
340
444
  }
@@ -379,6 +483,7 @@ function generateDashboardHtml(cwd, health) {
379
483
  '<h1>SigMap v2.10 dashboard</h1>',
380
484
  '<div class="sub">Self-contained report. No external scripts, styles, or network calls.</div>',
381
485
  `<div class="grid">${cardHtml}</div>`,
486
+ data.charts.tokenSavingsPanel,
382
487
  `<div class="panel">${data.charts.tokenReductionSvg}</div>`,
383
488
  `<div class="panel">${data.charts.hitAt5Svg}</div>`,
384
489
  `<div class="panel">${data.charts.coverageSvg}</div>`,
@@ -448,4 +448,61 @@ function getImpact(args, cwd) {
448
448
  }
449
449
  }
450
450
 
451
- module.exports = { readContext, searchSignatures, getMap, createCheckpoint, getRouting, explainFile, listModules, queryContext, getImpact };
451
+ /**
452
+ * get_lines({ file, start, end }) → string
453
+ *
454
+ * Surgical Context demand-driven fetch: returns an exact, clamped line range from a
455
+ * source file. The path is resolved inside the project root (no traversal escape) and
456
+ * the returned lines are secret-scanned via the same redactor used for signatures.
457
+ */
458
+ function getLines(args, cwd) {
459
+ if (!args || !args.file) return 'Missing required argument: file';
460
+
461
+ const rel = String(args.file).replace(/\\/g, '/').replace(/^\//, '');
462
+ const abs = path.resolve(cwd, rel);
463
+
464
+ // Sandbox: refuse paths that resolve outside the project root.
465
+ const root = path.resolve(cwd);
466
+ if (abs !== root && !abs.startsWith(root + path.sep)) {
467
+ return `Refused: ${rel} resolves outside the project root`;
468
+ }
469
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) {
470
+ return `File not found: ${rel}`;
471
+ }
472
+
473
+ const start = parseInt(args.start, 10);
474
+ const end = parseInt(args.end, 10);
475
+ if (!Number.isFinite(start) || !Number.isFinite(end)) {
476
+ return 'Arguments "start" and "end" must be numbers (1-based line numbers).';
477
+ }
478
+
479
+ let lines;
480
+ try {
481
+ lines = fs.readFileSync(abs, 'utf8').split('\n');
482
+ } catch (err) {
483
+ return `Could not read ${rel}: ${err.message}`;
484
+ }
485
+
486
+ const total = lines.length;
487
+ const from = Math.max(1, Math.min(start, end));
488
+ const to = Math.min(total, Math.max(start, end));
489
+ if (from > total) return `${rel} has only ${total} lines; requested ${start}-${end}`;
490
+
491
+ const slice = lines.slice(from - 1, to);
492
+
493
+ // Redaction scan: reuse the signature secret scanner line-by-line.
494
+ let safeLines = slice;
495
+ try {
496
+ const { scan } = require('../security/scanner');
497
+ safeLines = scan(slice, rel).safe;
498
+ } catch (_) {} // non-fatal: fall back to raw slice
499
+
500
+ return [
501
+ `# ${rel}:${from}-${to}`,
502
+ '```',
503
+ ...safeLines,
504
+ '```',
505
+ ].join('\n');
506
+ }
507
+
508
+ module.exports = { readContext, searchSignatures, getMap, createCheckpoint, getRouting, explainFile, listModules, queryContext, getImpact, getLines };
package/src/mcp/server.js CHANGED
@@ -8,17 +8,17 @@
8
8
  *
9
9
  * Supported methods:
10
10
  * initialize → serverInfo + capabilities
11
- * tools/list → 3 tool definitions
11
+ * tools/list → 10 tool definitions
12
12
  * tools/call → dispatch to handler, return result
13
13
  */
14
14
 
15
15
  const readline = require('readline');
16
16
  const { TOOLS } = require('./tools');
17
- const { readContext, searchSignatures, getMap, createCheckpoint, getRouting, explainFile, listModules, queryContext, getImpact } = require('./handlers');
17
+ const { readContext, searchSignatures, getMap, createCheckpoint, getRouting, explainFile, listModules, queryContext, getImpact, getLines } = require('./handlers');
18
18
 
19
19
  const SERVER_INFO = {
20
20
  name: 'sigmap',
21
- version: '6.11.1',
21
+ version: '6.12.0',
22
22
  description: 'SigMap MCP server — code signatures on demand',
23
23
  };
24
24
 
@@ -75,6 +75,7 @@ function dispatch(msg, cwd) {
75
75
  else if (name === 'list_modules') text = listModules(args, cwd);
76
76
  else if (name === 'query_context') text = queryContext(args, cwd);
77
77
  else if (name === 'get_impact') text = getImpact(args, cwd);
78
+ else if (name === 'get_lines') text = getLines(args, cwd);
78
79
  else {
79
80
  respondError(id, -32601, `Unknown tool: ${name}`);
80
81
  return;
package/src/mcp/tools.js CHANGED
@@ -1,8 +1,9 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * MCP tool definitions for SigMap.
5
- * Three tools: read_context, search_signatures, get_map.
4
+ * MCP tool definitions for SigMap (10 tools).
5
+ * read_context, search_signatures, get_map, create_checkpoint, get_routing,
6
+ * explain_file, list_modules, query_context, get_impact, get_lines.
6
7
  */
7
8
 
8
9
  const TOOLS = [
@@ -168,6 +169,34 @@ const TOOLS = [
168
169
  required: ['file'],
169
170
  },
170
171
  },
172
+ {
173
+ name: 'get_lines',
174
+ description:
175
+ 'Fetch an exact line range from a source file on demand — the Surgical Context ' +
176
+ 'workhorse. Signatures carry `path:start-end` anchors; call this to read just those ' +
177
+ 'lines instead of re-opening the whole file. Lines are clamped to the file bounds and ' +
178
+ 'secret-scanned (redacted) before return. Path is sandboxed to the project root.',
179
+ inputSchema: {
180
+ type: 'object',
181
+ properties: {
182
+ file: {
183
+ type: 'string',
184
+ description:
185
+ 'Relative path from the project root (e.g. "src/config/loader.js"). ' +
186
+ 'Use the path shown in a signature anchor. Use forward slashes.',
187
+ },
188
+ start: {
189
+ type: 'number',
190
+ description: '1-based start line (inclusive). Clamped to the file bounds.',
191
+ },
192
+ end: {
193
+ type: 'number',
194
+ description: '1-based end line (inclusive). Clamped to the file bounds.',
195
+ },
196
+ },
197
+ required: ['file', 'start', 'end'],
198
+ },
199
+ },
171
200
  ];
172
201
 
173
202
  module.exports = { TOOLS };