sigmap 6.11.0 → 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,32 @@ 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
+
31
+ ## [6.11.1] — 2026-06-04
32
+
33
+ ### Fixed
34
+
35
+ - **MCP hot-cold cold signatures in bundled server** — the bundled MCP server now includes the hot-cold "cold" signatures, so context lookups return complete results under the hot-cold strategy (closes #201, PR #216). Thanks @rudi193-cmd.
36
+
37
+ ---
38
+
13
39
  ## [6.11.0] — 2026-06-03
14
40
 
15
41
  ### Added
package/README.md CHANGED
@@ -47,10 +47,10 @@ SigMap extracts function and class signatures from your codebase and feeds the r
47
47
 
48
48
  ## Why SigMap?
49
49
 
50
- - **78.9% hit@5** — right file found in top 5 results (vs 13.6% baseline)
51
- - **97.9% token reduction** — 278K instead of 13.5M tokens across 21 repos
52
- - **52.2% task success rate** — up from 10% without context
53
- - **1.66 prompts per task** — down from 2.84 (40.6% fewer retries)
50
+ - **81.1% hit@5** — right file found in top 5 results (vs 13.6% baseline)
51
+ - **96.5% token reduction** — average across 21 real repos
52
+ - **53.3% task success rate** — up from 10% without context
53
+ - **1.66 prompts per task** — down from 2.84 (41.8% fewer retries)
54
54
  - **31 languages supported** — TypeScript, Python, Go, Rust, Java, R, and 25 others
55
55
  - **No vendor lock-in** — works with any AI assistant or local LLM
56
56
  - **No API costs** — use local models (Ollama, llama.cpp, vLLM) with zero token fees
@@ -63,7 +63,7 @@ SigMap extracts function and class signatures from your codebase and feeds the r
63
63
 
64
64
  | Without SigMap | With SigMap |
65
65
  |---|---|
66
- | ❌ Guessing which files are relevant | ✅ Right file in context — 80% of the time |
66
+ | ❌ Guessing which files are relevant | ✅ Right file in context — 81% of the time |
67
67
  | ❌ Sending the full repo to your AI | ✅ Minimal context — only what matters |
68
68
  | ❌ Embeddings / vector DB required | ✅ Grounded answers, no infra needed |
69
69
 
@@ -87,12 +87,12 @@ Ask → Rank → Context → Validate → Judge → Learn
87
87
  ## Benchmark
88
88
 
89
89
  ```
90
- Benchmark : sigmap-v6.10-main (21 repositories, including R language)
91
- Date : 2026-05-22
90
+ Benchmark : sigmap-v6.12-main (21 repositories, including R language)
91
+ Date : 2026-06-05
92
92
 
93
- Hit@5 : 80% (baseline 13.6% — 5.9× lift)
93
+ Hit@5 : 81.1% (baseline 13.6% — 6.0× lift)
94
94
  Token reduction: 96.5% (across 21 repos)
95
- Prompt reduction : 41.4% (2.84 → 1.67 prompts per task)
95
+ Prompt reduction : 41.8% (2.84 → 1.66 prompts per task)
96
96
  Task success : 53.3% (baseline 10%)
97
97
  Repos tested : 21 (JavaScript, Python, Go, Rust, Java, R, C++, C#, Dart, Swift, Ruby, PHP, Scala, Kotlin, and more)
98
98
  ```
@@ -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
@@ -1174,6 +1174,30 @@ __factories["./src/extractors/php"] = function(module, exports) {
1174
1174
 
1175
1175
  };
1176
1176
 
1177
+ // ── ./src/extractors/line-anchor ──
1178
+ __factories["./src/extractors/line-anchor"] = function(module, exports) {
1179
+
1180
+ function lineAt(src, idx) {
1181
+ let line = 1;
1182
+ const end = Math.min(idx, src.length);
1183
+ for (let i = 0; i < end; i++) {
1184
+ if (src.charCodeAt(i) === 10) line++;
1185
+ }
1186
+ return line;
1187
+ }
1188
+
1189
+ function anchor(start, end) {
1190
+ return ` :${start}-${end}`;
1191
+ }
1192
+
1193
+ function withAnchor(sig, start, end) {
1194
+ return `${sig}${anchor(start, end)}`;
1195
+ }
1196
+
1197
+ module.exports = { lineAt, anchor, withAnchor };
1198
+
1199
+ };
1200
+
1177
1201
  // ── ./src/extractors/python ──
1178
1202
  __factories["./src/extractors/python"] = function(module, exports) {
1179
1203
 
@@ -4028,6 +4052,96 @@ __factories["./src/format/benchmark-report"] = function(module, exports) {
4028
4052
  }).join('');
4029
4053
  }
4030
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
+
4031
4145
  function buildDashboardData(cwd, health) {
4032
4146
  const entries = readLog(cwd);
4033
4147
  const recent = entries.slice(-30);
@@ -4046,15 +4160,18 @@ __factories["./src/format/benchmark-report"] = function(module, exports) {
4046
4160
  overBudgetStreak: overBudgetStreak(entries),
4047
4161
  extractorCoverage: coverage.pct,
4048
4162
  };
4163
+ const tokenReduction = readTokenReduction(cwd);
4049
4164
  return {
4050
4165
  summary,
4051
4166
  tokenReductionTrend,
4052
4167
  hitAt5Trend,
4053
4168
  coverage,
4169
+ tokenReduction,
4054
4170
  charts: {
4055
4171
  tokenReductionSvg: lineChartSvg(tokenReductionTrend, 'Token reduction trend (last 30 tracked runs)', '%'),
4056
4172
  hitAt5Svg: lineChartSvg(hitAt5Trend, 'hit@5 trend (last 30 benchmark runs)', ''),
4057
4173
  coverageSvg: barChartSvg(coverage.perLanguage),
4174
+ tokenSavingsPanel: tokenReductionPanelHtml(tokenReduction),
4058
4175
  },
4059
4176
  };
4060
4177
  }
@@ -4088,6 +4205,7 @@ __factories["./src/format/benchmark-report"] = function(module, exports) {
4088
4205
  '<h1>SigMap v2.10 dashboard</h1>',
4089
4206
  '<div class="sub">Self-contained report. No external scripts, styles, or network calls.</div>',
4090
4207
  `<div class="grid">${cardHtml}</div>`,
4208
+ data.charts.tokenSavingsPanel,
4091
4209
  `<div class="panel">${data.charts.tokenReductionSvg}</div>`,
4092
4210
  `<div class="panel">${data.charts.hitAt5Svg}</div>`,
4093
4211
  `<div class="panel">${data.charts.coverageSvg}</div>`,
@@ -5339,42 +5457,29 @@ __factories["./src/mcp/handlers"] = function(module, exports) {
5339
5457
  */
5340
5458
  function searchSignatures(args, cwd) {
5341
5459
  if (!args || !args.query) return 'Missing required argument: query';
5342
-
5343
- const contextPath = path.join(cwd, CONTEXT_FILE);
5344
- if (!fs.existsSync(contextPath)) {
5345
- return 'No context file found. Run: node gen-context.js';
5346
- }
5347
-
5348
- const content = fs.readFileSync(contextPath, 'utf8');
5349
5460
  const query = args.query.toLowerCase();
5350
- const lines = content.split('\n');
5351
-
5352
- const result = [];
5353
- let currentFile = '';
5354
- let fileHeaderAdded = false;
5355
-
5356
- for (const line of lines) {
5357
- if (line.startsWith('### ')) {
5358
- currentFile = line.slice(4).trim();
5359
- fileHeaderAdded = false;
5360
- continue;
5361
- }
5362
- // Skip markdown fences and top-level headers
5363
- if (line.startsWith('```') || line.startsWith('## ') || line.startsWith('# ') || line.startsWith('<!--')) {
5364
- continue;
5461
+
5462
+ try {
5463
+ const { buildSigIndex } = __require('./src/retrieval/ranker');
5464
+ const index = buildSigIndex(cwd);
5465
+ if (index.size === 0) {
5466
+ return 'No context file found. Run: node gen-context.js';
5365
5467
  }
5366
- if (line.toLowerCase().includes(query)) {
5367
- if (currentFile && !fileHeaderAdded) {
5368
- if (result.length > 0) result.push('');
5369
- result.push(`### ${currentFile}`);
5370
- fileHeaderAdded = true;
5371
- }
5372
- result.push(line);
5468
+
5469
+ const result = [];
5470
+ for (const [file, sigs] of index.entries()) {
5471
+ const hits = sigs.filter((s) => s.toLowerCase().includes(query));
5472
+ if (hits.length === 0) continue;
5473
+ if (result.length > 0) result.push('');
5474
+ result.push(`### ${file}`);
5475
+ result.push(...hits);
5373
5476
  }
5477
+
5478
+ if (result.length === 0) return `No signatures found matching: ${args.query}`;
5479
+ return result.join('\n');
5480
+ } catch (err) {
5481
+ return `_search_signatures failed: ${err.message}_`;
5374
5482
  }
5375
-
5376
- if (result.length === 0) return `No signatures found matching: ${args.query}`;
5377
- return result.join('\n');
5378
5483
  }
5379
5484
 
5380
5485
  /**
@@ -5640,56 +5745,44 @@ __factories["./src/mcp/handlers"] = function(module, exports) {
5640
5745
  }
5641
5746
 
5642
5747
  function listModules(args, cwd) {
5643
- const contextPath = path.join(cwd, CONTEXT_FILE);
5644
- if (!fs.existsSync(contextPath)) {
5645
- return 'No context file found. Run: node gen-context.js';
5646
- }
5647
-
5648
- const content = fs.readFileSync(contextPath, 'utf8');
5649
- const ctxLines = content.split('\n');
5650
- const groups = {};
5651
- let currentGroup = null;
5652
- let blockBuf = [];
5653
-
5654
- function flushBlock() {
5655
- if (currentGroup === null || blockBuf.length === 0) return;
5656
- if (!groups[currentGroup]) groups[currentGroup] = { fileCount: 0, tokenCount: 0 };
5657
- groups[currentGroup].fileCount++;
5658
- groups[currentGroup].tokenCount += Math.ceil(blockBuf.join('\n').length / 4);
5659
- blockBuf = [];
5660
- }
5661
-
5662
- for (const line of ctxLines) {
5663
- if (line.startsWith('### ')) {
5664
- flushBlock();
5665
- const rel = line.slice(4).trim().replace(/\\/g, '/');
5748
+ try {
5749
+ const { buildSigIndex } = __require('./src/retrieval/ranker');
5750
+ const index = buildSigIndex(cwd);
5751
+ if (index.size === 0) {
5752
+ return 'No context file found. Run: node gen-context.js';
5753
+ }
5754
+
5755
+ const groups = {};
5756
+ for (const [rel, sigs] of index.entries()) {
5666
5757
  const parts = rel.split('/');
5667
- currentGroup = parts.length > 1 ? parts[0] : '.';
5668
- } else if (currentGroup !== null) {
5669
- blockBuf.push(line);
5758
+ const mod = parts.length > 1 ? parts[0] : '.';
5759
+ if (!groups[mod]) groups[mod] = { fileCount: 0, tokenCount: 0 };
5760
+ groups[mod].fileCount++;
5761
+ groups[mod].tokenCount += Math.ceil(sigs.join('\n').length / 4);
5670
5762
  }
5763
+
5764
+ const sorted = Object.entries(groups)
5765
+ .map(([mod, data]) => ({ module: mod, fileCount: data.fileCount, tokenCount: data.tokenCount }))
5766
+ .sort((a, b) => b.tokenCount - a.tokenCount);
5767
+
5768
+ if (sorted.length === 0) return 'No modules found in context file.';
5769
+
5770
+ const total = sorted.reduce((s, m) => s + m.tokenCount, 0);
5771
+
5772
+ return [
5773
+ '# Modules',
5774
+ '',
5775
+ '| Module | Files | Tokens |',
5776
+ '|--------|-------|--------|',
5777
+ ...sorted.map((m) => `| ${m.module} | ${m.fileCount} | ~${m.tokenCount} |`),
5778
+ '',
5779
+ `**Total context tokens: ~${total}**`,
5780
+ '',
5781
+ '_Use `read_context({ module: "name" })` to get signatures for a specific module._',
5782
+ ].join('\n');
5783
+ } catch (err) {
5784
+ return `_list_modules failed: ${err.message}_`;
5671
5785
  }
5672
- flushBlock();
5673
-
5674
- const sorted = Object.entries(groups)
5675
- .map(([mod, data]) => ({ module: mod, fileCount: data.fileCount, tokenCount: data.tokenCount }))
5676
- .sort((a, b) => b.tokenCount - a.tokenCount);
5677
-
5678
- if (sorted.length === 0) return 'No modules found in context file.';
5679
-
5680
- const total = sorted.reduce((s, m) => s + m.tokenCount, 0);
5681
-
5682
- return [
5683
- '# Modules',
5684
- '',
5685
- '| Module | Files | Tokens |',
5686
- '|--------|-------|--------|',
5687
- ...sorted.map((m) => `| ${m.module} | ${m.fileCount} | ~${m.tokenCount} |`),
5688
- '',
5689
- `**Total context tokens: ~${total}**`,
5690
- '',
5691
- '_Use `read_context({ module: "name" })` to get signatures for a specific module._',
5692
- ].join('\n');
5693
5786
  }
5694
5787
 
5695
5788
  function queryContext(args, cwd) {
@@ -5720,7 +5813,56 @@ __factories["./src/mcp/handlers"] = function(module, exports) {
5720
5813
  }
5721
5814
  }
5722
5815
 
5723
- 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 };
5724
5866
  };
5725
5867
 
5726
5868
  // ── ./src/learning/weights ──
@@ -5891,17 +6033,17 @@ __factories["./src/mcp/server"] = function(module, exports) {
5891
6033
  *
5892
6034
  * Supported methods:
5893
6035
  * initialize → serverInfo + capabilities
5894
- * tools/list → 3 tool definitions
6036
+ * tools/list → 10 tool definitions
5895
6037
  * tools/call → dispatch to handler, return result
5896
6038
  */
5897
-
6039
+
5898
6040
  const readline = require('readline');
5899
6041
  const { TOOLS } = __require('./src/mcp/tools');
5900
- const { readContext, searchSignatures, getMap, createCheckpoint, getRouting, explainFile, listModules, queryContext, getImpact } = __require('./src/mcp/handlers');
5901
-
6042
+ const { readContext, searchSignatures, getMap, createCheckpoint, getRouting, explainFile, listModules, queryContext, getImpact, getLines } = __require('./src/mcp/handlers');
6043
+
5902
6044
  const SERVER_INFO = {
5903
6045
  name: 'sigmap',
5904
- version: '6.11.0',
6046
+ version: '6.12.0',
5905
6047
  description: 'SigMap MCP server — code signatures on demand',
5906
6048
  };
5907
6049
 
@@ -5958,6 +6100,7 @@ __factories["./src/mcp/server"] = function(module, exports) {
5958
6100
  else if (name === 'list_modules') text = listModules(args, cwd);
5959
6101
  else if (name === 'query_context') text = queryContext(args, cwd);
5960
6102
  else if (name === 'get_impact') text = getImpact(args, cwd);
6103
+ else if (name === 'get_lines') text = getLines(args, cwd);
5961
6104
  else {
5962
6105
  respondError(id, -32601, `Unknown tool: ${name}`);
5963
6106
  return;
@@ -6185,8 +6328,36 @@ __factories["./src/mcp/tools"] = function(module, exports) {
6185
6328
  required: ['file'],
6186
6329
  },
6187
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
+ },
6188
6359
  ];
6189
-
6360
+
6190
6361
  module.exports = { TOOLS };
6191
6362
 
6192
6363
  };
@@ -6968,9 +7139,50 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
6968
7139
  if (currentFile !== null) index.set(currentFile, sigs);
6969
7140
  return index;
6970
7141
  }
7142
+
7143
+ function _mergeSigIndex(target, source) {
7144
+ for (const [file, sigs] of source.entries()) {
7145
+ if (!sigs || sigs.length === 0) continue;
7146
+ if (!target.has(file) || target.get(file).length < sigs.length) {
7147
+ target.set(file, sigs);
7148
+ }
7149
+ }
7150
+ return target;
7151
+ }
7152
+
7153
+ function _buildSigIndexFromCache(cwd) {
7154
+ const fs = require('fs');
7155
+ const path = require('path');
7156
+ const index = new Map();
7157
+ try {
7158
+ const { loadCache } = require('../cache/sig-cache');
7159
+ const pkgPath = path.join(cwd, 'package.json');
7160
+ let version = '0.0.0';
7161
+ if (fs.existsSync(pkgPath)) {
7162
+ version = JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version || version;
7163
+ }
7164
+ const cache = loadCache(cwd, version);
7165
+ for (const [absPath, entry] of cache.entries()) {
7166
+ if (!entry || !entry.sigs || entry.sigs.length === 0) continue;
7167
+ const rel = path.relative(cwd, absPath).replace(/\\/g, '/');
7168
+ if (!rel || rel.startsWith('..')) continue;
7169
+ index.set(rel, entry.sigs);
7170
+ }
7171
+ } catch (_) {}
7172
+ return index;
7173
+ }
7174
+
7175
+ function _enrichSigIndexFromStrategy(cwd, index) {
7176
+ const path = require('path');
7177
+ const coldPath = path.join(cwd, '.github', 'context-cold.md');
7178
+ _mergeSigIndex(index, _parseContextFile(coldPath));
7179
+ _mergeSigIndex(index, _buildSigIndexFromCache(cwd));
7180
+ return index;
7181
+ }
7182
+
6971
7183
  function buildSigIndex(cwd, opts) {
6972
7184
  const fs = require('fs'); const path = require('path');
6973
- if (opts && opts.contextPath) return _parseContextFile(opts.contextPath);
7185
+ if (opts && opts.contextPath) return _enrichSigIndexFromStrategy(cwd, _parseContextFile(opts.contextPath));
6974
7186
  // Check gen-context.config.json for a persisted customOutput path.
6975
7187
  try {
6976
7188
  const cfgPath = path.join(cwd, 'gen-context.config.json');
@@ -6978,16 +7190,16 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
6978
7190
  const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
6979
7191
  if (cfg.customOutput) {
6980
7192
  const idx = _parseContextFile(path.resolve(cwd, cfg.customOutput));
6981
- if (idx.size > 0) return idx;
7193
+ if (idx.size > 0) return _enrichSigIndexFromStrategy(cwd, idx);
6982
7194
  }
6983
7195
  }
6984
7196
  } catch (_) {}
6985
7197
  for (const parts of ADAPTER_OUTPUT_PATHS) {
6986
7198
  const contextPath = path.join(cwd, ...parts);
6987
7199
  const index = _parseContextFile(contextPath);
6988
- if (index.size > 0) return index;
7200
+ if (index.size > 0) return _enrichSigIndexFromStrategy(cwd, index);
6989
7201
  }
6990
- return new Map();
7202
+ return _enrichSigIndexFromStrategy(cwd, new Map());
6991
7203
  }
6992
7204
  function formatRankTable(results, query) {
6993
7205
  if (!results || results.length === 0) return `No matching files found for query: "${query}"\n`;
@@ -8655,7 +8867,7 @@ const path = require('path');
8655
8867
  const os = require('os');
8656
8868
  const { execSync } = require('child_process');
8657
8869
 
8658
- const VERSION = '6.11.0';
8870
+ const VERSION = '6.12.0';
8659
8871
  const MARKER = '\n\n## Auto-generated signatures\n<!-- Updated by gen-context.js -->\n';
8660
8872
 
8661
8873
  function requireSourceOrBundled(key) {
@@ -8947,8 +9159,28 @@ function applyTokenBudget(fileEntries, maxTokens) {
8947
9159
  let total = fileEntries.reduce((s, e) => s + estimateTokens(e.sigs.join('\n')), 0);
8948
9160
  if (total <= effectiveBudget) return fileEntries;
8949
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
+
8950
9182
  // Sort by drop priority (drop first = index 0)
8951
- const withPriority = fileEntries.map((e) => {
9183
+ const withPriority = working.map((e) => {
8952
9184
  let priority = 0;
8953
9185
  let dropReason = 'budget: low recency';
8954
9186
  if (isGeneratedFile(e.filePath)) { priority = 10; dropReason = 'budget: generated file'; }
@@ -10536,6 +10768,40 @@ function buildMiniContext(ranked, cwd) {
10536
10768
  return lines.join('\n');
10537
10769
  }
10538
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
+
10539
10805
  function computeCurrentRisk(cwd) {
10540
10806
  try {
10541
10807
  const { execSync } = require('child_process');
@@ -10706,7 +10972,7 @@ function main() {
10706
10972
  const mode = modeIdx !== -1
10707
10973
  ? args[modeIdx + 1]
10708
10974
  : (config.mode || 'default');
10709
- const VALID_MODES = ['default', 'fast', 'full', 'both'];
10975
+ const VALID_MODES = ['default', 'fast', 'full', 'both', 'index'];
10710
10976
  if (mode && !VALID_MODES.includes(mode)) {
10711
10977
  console.error(`[sigmap] unknown --mode "${mode}". Valid: ${VALID_MODES.join(', ')}`);
10712
10978
  process.exit(1);
@@ -10785,9 +11051,23 @@ function main() {
10785
11051
  }
10786
11052
  }
10787
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
+
10788
11066
  // v6.8: Save session for future --followup calls
10789
11067
  saveSession(cwd, { intent, topFiles: ranked.slice(0, 5).map(r => ({ file: r.file, score: r.score })), query });
10790
- 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);
10791
11071
  const outPath = path.join(cwd, '.context', 'query-context.md');
10792
11072
  fs.mkdirSync(path.dirname(outPath), { recursive: true });
10793
11073
  fs.writeFileSync(outPath, miniCtx, 'utf8');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigmap",
3
- "version": "6.11.0",
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.0",
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.0",
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.0',
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 };