sigmap 6.15.0 → 7.0.1

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.
@@ -35,11 +35,36 @@ function diffSignatures(baseSigs, currentSigs) {
35
35
  return { added, removed, modified };
36
36
  }
37
37
 
38
+ /**
39
+ * Extract the declared symbol name from a signature line.
40
+ *
41
+ * Robust to the real forms SigMap emits — `export class X`, `const x = () =>`,
42
+ * `async function x`, members, a trailing `:start-end` anchor, and `→ return`
43
+ * suffixes. Anchored so it never returns a mid-string fragment (the old regex
44
+ * could turn a signature into a 2-char name like `is`), and returns '' for
45
+ * non-symbol lines (`module.exports = {…}`, markdown headers) instead of guessing.
46
+ */
38
47
  function extractName(sig) {
39
48
  if (!sig) return '';
40
- const t = sig.trim();
41
- const m = t.match(/(?:def|function|func|class|interface|trait|struct|enum|record)?\s*([A-Za-z_][A-Za-z0-9_]*)\s*(?:\(|$)/);
42
- return m ? m[1] : '';
49
+ // Drop a trailing `:start-end` (or `:line`) line anchor.
50
+ let t = String(sig).replace(/\s*:\d+(?:-\d+)?\s*$/, '').trim();
51
+ // Re-export / barrel lines carry no single declared name.
52
+ if (/^(?:module\.)?exports\b/.test(t) || /^export\s*\{/.test(t) || /^export\s+\*/.test(t)) return '';
53
+ // Strip leading modifiers so the keyword/name is at the start.
54
+ t = t.replace(/^export\s+/, '')
55
+ .replace(/^default\s+/, '')
56
+ .replace(/^(?:public|private|protected|static|abstract|final|override|readonly)\s+/g, '')
57
+ .replace(/^async\s+/, '');
58
+ let m;
59
+ // Declared forms: function/def/func/fn/class/interface/trait/struct/enum/record/type <name>
60
+ if ((m = t.match(/^(?:def|function|func|fn|class|interface|trait|struct|enum|record|type)\s+([A-Za-z_$][\w$]*)/))) return m[1];
61
+ // const/let/var/val <name> = … (arrow functions, assigned values)
62
+ if ((m = t.match(/^(?:const|let|var|val)\s+([A-Za-z_$][\w$]*)/))) return m[1];
63
+ // Call / method form: <name>(…)
64
+ if ((m = t.match(/^([A-Za-z_$][\w$]*)\s*\(/))) return m[1];
65
+ // Lone identifier (e.g. a collapsed `symbol` after the anchor was stripped).
66
+ if ((m = t.match(/^([A-Za-z_$][\w$]*)$/))) return m[1];
67
+ return '';
43
68
  }
44
69
 
45
70
  module.exports = { diffSignatures, extractName };
@@ -1,14 +1,13 @@
1
1
  'use strict';
2
2
  const path = require('path');
3
3
  const fs = require('fs');
4
- const { execSync } = require('child_process');
4
+ const { tryGit } = require('../util/git');
5
5
  module.exports = { format, outputPath };
6
6
 
7
7
  function outputPath(cwd) { return path.join(cwd, 'llms.txt'); }
8
8
 
9
9
  function getShortCommit(cwd) {
10
- try { return execSync('git rev-parse --short HEAD', { cwd, timeout: 2000 }).toString().trim(); }
11
- catch (_) { return ''; }
10
+ return tryGit(['rev-parse', '--short', 'HEAD'], { cwd, timeout: 2000 });
12
11
  }
13
12
 
14
13
  function detectVersion(cwd) {
@@ -0,0 +1,28 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Canonical "how to use SigMap" guidance block (v6.16/v7.0).
5
+ *
6
+ * Every adapter emits this one identical block so all generated context files
7
+ * (CLAUDE.md, AGENTS.md, .github/copilot-instructions.md, GEMINI.md, .cursorrules,
8
+ * …) carry the same, single usage section — instead of each adapter inventing
9
+ * its own wording (and codex emitting a redundant second JSON block).
10
+ */
11
+
12
+ function usageBlock() {
13
+ return [
14
+ '## SigMap commands',
15
+ '',
16
+ '| When | Command |',
17
+ '|------|---------|',
18
+ '| Before answering a question about code | `sigmap ask "<your question>"` |',
19
+ '| To rank files by topic | `sigmap --query "<topic>"` |',
20
+ '| After changing config or source dirs | `sigmap validate` |',
21
+ '| To verify an AI answer is grounded | `sigmap judge --response <file>` |',
22
+ '',
23
+ 'Always run `sigmap ask` (or `sigmap --query`) before searching for files relevant to a task.',
24
+ '',
25
+ ].join('\n');
26
+ }
27
+
28
+ module.exports = { usageBlock };
@@ -2,7 +2,7 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
- const { execSync } = require('child_process');
5
+ const { git } = require('../util/git');
6
6
 
7
7
  const CONTEXT_FILE = path.join('.github', 'copilot-instructions.md');
8
8
  const CONTEXT_COLD_FILE = path.join('.github', 'context-cold.md');
@@ -149,19 +149,14 @@ function createCheckpoint(args, cwd) {
149
149
  // ── Git info ────────────────────────────────────────────────────────────
150
150
  lines.push('## Git state');
151
151
  try {
152
- const branch = execSync('git rev-parse --abbrev-ref HEAD', {
153
- cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
154
- }).trim();
152
+ const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd }).trim();
155
153
  lines.push(`**Branch:** ${branch}`);
156
154
  } catch (_) {
157
155
  lines.push('**Branch:** (not a git repo)');
158
156
  }
159
157
 
160
158
  try {
161
- const log = execSync(
162
- 'git log --oneline -5 --no-decorate 2>/dev/null',
163
- { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
164
- ).trim();
159
+ const log = git(['log', '--oneline', '-5', '--no-decorate'], { cwd }).trim();
165
160
  if (log) {
166
161
  lines.push('');
167
162
  lines.push('**Recent commits:**');
package/src/mcp/server.js CHANGED
@@ -18,7 +18,7 @@ const { readContext, searchSignatures, getMap, createCheckpoint, getRouting, exp
18
18
 
19
19
  const SERVER_INFO = {
20
20
  name: 'sigmap',
21
- version: '6.15.0',
21
+ version: '7.0.1',
22
22
  description: 'SigMap MCP server — code signatures on demand',
23
23
  };
24
24
 
package/src/nudge.js ADDED
@@ -0,0 +1,92 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Star nudge + usage tracking (v7.0.0).
5
+ *
6
+ * Records run counts in `.context/usage.json` and shows a one-time GitHub-star
7
+ * message after the tool has been genuinely useful (≥10 runs, ≥8 successes).
8
+ * Shown exactly once per machine — even under concurrent runs (an `wx` lock
9
+ * file makes the show race-safe). Wired into `ask` (and the `squeeze` path).
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ const RUN_THRESHOLD = 10;
16
+ const SUCCESS_THRESHOLD = 8;
17
+
18
+ function usagePath(cwd) { return path.join(cwd, '.context', 'usage.json'); }
19
+
20
+ function defaultUsage() {
21
+ return {
22
+ totalRuns: 0, successfulRuns: 0, squeezeOffered: 0, squeezeAccepted: 0,
23
+ starNudgeShown: false, firstRunDate: null, lastRunDate: null,
24
+ };
25
+ }
26
+
27
+ function readUsage(cwd) {
28
+ try { return { ...defaultUsage(), ...JSON.parse(fs.readFileSync(usagePath(cwd), 'utf8')) }; }
29
+ catch (_) { return defaultUsage(); }
30
+ }
31
+
32
+ function writeUsageAtomic(cwd, usage) {
33
+ const p = usagePath(cwd);
34
+ fs.mkdirSync(path.dirname(p), { recursive: true });
35
+ const tmp = `${p}.${process.pid}.tmp`;
36
+ fs.writeFileSync(tmp, JSON.stringify(usage, null, 2));
37
+ fs.renameSync(tmp, p); // atomic on POSIX
38
+ }
39
+
40
+ const STAR_MESSAGE = [
41
+ '─────────────────────────────────────────────────────────',
42
+ ' SigMap has helped you 10 times now.',
43
+ '',
44
+ " If it's been useful, a GitHub star takes 5 seconds and",
45
+ ' helps other developers find it:',
46
+ ' → github.com/manojmallick/sigmap',
47
+ '',
48
+ " (Won't ask again. Press Enter to continue.)",
49
+ '─────────────────────────────────────────────────────────',
50
+ ].join('\n');
51
+
52
+ function showStarNudge(write) {
53
+ (write || ((s) => process.stderr.write(s)))('\n' + STAR_MESSAGE + '\n');
54
+ }
55
+
56
+ /**
57
+ * Record one run and, when the thresholds are first met, show the star nudge.
58
+ * @param {string} cwd
59
+ * @param {boolean} runSuccess
60
+ * @param {object} [opts]
61
+ * @param {boolean} [opts.silent] record only — never print
62
+ * @param {function} [opts.write] sink for the message (default stderr)
63
+ * @param {string} [opts.today] override date (testing)
64
+ * @param {object} [opts.bump] counter deltas to merge (e.g. { squeezeAccepted: 1 })
65
+ * @returns {{ usage, nudged }}
66
+ */
67
+ function checkStarNudge(cwd, runSuccess, opts = {}) {
68
+ const usage = readUsage(cwd);
69
+ usage.totalRuns += 1;
70
+ if (runSuccess) usage.successfulRuns += 1;
71
+ if (opts.bump) for (const k of Object.keys(opts.bump)) usage[k] = (usage[k] || 0) + opts.bump[k];
72
+
73
+ const today = opts.today || new Date().toISOString().slice(0, 10);
74
+ if (!usage.firstRunDate) usage.firstRunDate = today;
75
+ usage.lastRunDate = today;
76
+
77
+ let nudged = false;
78
+ if (!usage.starNudgeShown && usage.totalRuns >= RUN_THRESHOLD && usage.successfulRuns >= SUCCESS_THRESHOLD) {
79
+ // Race-safe single-show: only the process that creates the lock prints.
80
+ let won = false;
81
+ try { fs.closeSync(fs.openSync(usagePath(cwd) + '.nudge.lock', 'wx')); won = true; }
82
+ catch (_) { won = false; }
83
+ if (won && !opts.silent) showStarNudge(opts.write);
84
+ nudged = won;
85
+ usage.starNudgeShown = true;
86
+ }
87
+
88
+ writeUsageAtomic(cwd, usage);
89
+ return { usage, nudged };
90
+ }
91
+
92
+ module.exports = { checkStarNudge, readUsage, usagePath, showStarNudge, RUN_THRESHOLD, SUCCESS_THRESHOLD };
@@ -15,7 +15,7 @@
15
15
 
16
16
  const fs = require('fs');
17
17
  const path = require('path');
18
- const { execSync } = require('child_process');
18
+ const { tryGit } = require('../util/git');
19
19
 
20
20
  const NOTES_FILE = path.join('.context', 'notes.ndjson');
21
21
  const MAX_TEXT = 2000;
@@ -25,13 +25,7 @@ function notesPath(cwd) {
25
25
  }
26
26
 
27
27
  function _currentBranch(cwd) {
28
- try {
29
- return execSync('git rev-parse --abbrev-ref HEAD', {
30
- cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
31
- }).trim() || null;
32
- } catch (_) {
33
- return null;
34
- }
28
+ return tryGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd }) || null;
35
29
  }
36
30
 
37
31
  /**
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * CI / build-log squeeze (v7.0.0).
5
+ *
6
+ * Strips timestamps, progress bars, and repeated noise; keeps every error line
7
+ * plus a small context window around it. Never returns empty — when there are
8
+ * no errors it falls back to a head/tail summary. Also reused by the stacktrace
9
+ * squeezer to clean noise surrounding a trace.
10
+ */
11
+
12
+ const TS_PREFIX_RE = /^\s*(?:\[?\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:[.,]\d+)?Z?\]?\s*|\[?\d{1,2}:\d{2}:\d{2}(?:[.,]\d+)?\]?\s*)+/;
13
+ const PROGRESS_LINE_RE = /(?:^|\s)(?:\d{1,3}%|Downloading|Receiving objects|Resolving deltas|Compressing objects|npm (?:WARN|notice|http|sill|verb)|##\[(?:group|endgroup|command|section)\]|\[\d+\/\d+\]|ETA[: ]|█|━|▕|⣿)/;
14
+ const ERROR_RE = /\b(?:error|err!|fail(?:ed|ure)?|exception|panic|fatal|traceback|ERR_|E[A-Z]{3,})\b|✗|❌/i;
15
+
16
+ /** Remove a leading timestamp prefix from a single line. */
17
+ function stripTimestamp(line) {
18
+ return line.replace(TS_PREFIX_RE, '');
19
+ }
20
+
21
+ /**
22
+ * @param {string} input
23
+ * @param {object} [opts]
24
+ * @param {number} [opts.context=2] context lines kept around each error
25
+ * @returns {{ squeezed: string, kept: string[], stripped: string[] }}
26
+ */
27
+ function squeezeCiLog(input, opts = {}) {
28
+ const ctx = opts.context != null ? opts.context : 2;
29
+ const lines = input.split('\n');
30
+ const keep = new Set();
31
+ const errorIdx = [];
32
+
33
+ for (let i = 0; i < lines.length; i++) {
34
+ if (ERROR_RE.test(lines[i])) {
35
+ errorIdx.push(i);
36
+ for (let j = Math.max(0, i - ctx); j <= Math.min(lines.length - 1, i + ctx); j++) keep.add(j);
37
+ }
38
+ }
39
+
40
+ let body;
41
+ let keptDesc;
42
+ if (errorIdx.length === 0) {
43
+ const head = lines.slice(0, 10).map(stripTimestamp);
44
+ const tail = lines.length > 20 ? lines.slice(-10).map(stripTimestamp) : [];
45
+ body = head.slice();
46
+ if (tail.length) body.push(`… (${lines.length - 20} lines omitted) …`, ...tail);
47
+ keptDesc = ['head/tail summary (no error lines found)'];
48
+ } else {
49
+ const sorted = [...keep].sort((a, b) => a - b);
50
+ body = [];
51
+ let prev = -2;
52
+ for (const idx of sorted) {
53
+ // Drop pure progress/noise lines unless they are themselves errors.
54
+ const line = stripTimestamp(lines[idx]);
55
+ if (PROGRESS_LINE_RE.test(line) && !ERROR_RE.test(line)) continue;
56
+ if (idx > prev + 1) body.push(`… (${idx - prev - 1} lines) …`);
57
+ body.push(line);
58
+ prev = idx;
59
+ }
60
+ if (body.length === 0) body = errorIdx.map((i) => stripTimestamp(lines[i])); // safety net
61
+ keptDesc = [`${errorIdx.length} error line(s) + ${ctx}-line context`];
62
+ }
63
+
64
+ return {
65
+ squeezed: body.join('\n'),
66
+ kept: keptDesc,
67
+ stripped: [`${Math.max(0, lines.length - body.length)} timestamp/progress/noise line(s)`],
68
+ };
69
+ }
70
+
71
+ module.exports = { squeezeCiLog, stripTimestamp, ERROR_RE };
@@ -0,0 +1,115 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Squeeze input classifier (v7.0.0).
5
+ *
6
+ * Deterministic, zero-dep detector that labels a pasted blob as a
7
+ * `stacktrace`, `cilog`, or `json` payload — or `null` (pass through). Pure
8
+ * regex/heuristics; runs in well under 10ms even on large input.
9
+ *
10
+ * Order matters: stack traces are highest value and a CI log often *contains*
11
+ * a trace, so stacktrace is checked first, then cilog, then json.
12
+ */
13
+
14
+ const FRAME_RE = [
15
+ /^\s*at\s+.+\(.+:\d+:\d+\)\s*$/, // JS: at fn (file:line:col)
16
+ /^\s*at\s+.+:\d+:\d+\s*$/, // JS: at file:line:col
17
+ /^\s*at\s+[\w$.<>]+\(.+\.\w+:\d+\)/, // Java/Kotlin: at pkg.Cls.m(File.java:42)
18
+ /^\s*File\s+".+",\s+line\s+\d+/, // Python frame
19
+ /^\s+\w+.*\([^)]*\.(go|rs):\d+\)/, // Go/Rust frame with file:line
20
+ /^\s*#\d+\s+0x[0-9a-f]+/, // native/gdb frame
21
+ ];
22
+
23
+ const STACK_HEADER_RE = [
24
+ /Traceback \(most recent call last\)/,
25
+ /Exception in thread/,
26
+ /\bat Object\.<anonymous>\b/,
27
+ /thread '.*' panicked at/,
28
+ /^panic:/m,
29
+ /goroutine \d+ \[/,
30
+ ];
31
+
32
+ function countFrames(lines) {
33
+ let n = 0;
34
+ for (const line of lines) {
35
+ if (FRAME_RE.some((re) => re.test(line))) n++;
36
+ }
37
+ return n;
38
+ }
39
+
40
+ function matchesStackTrace(input, lines) {
41
+ const frames = countFrames(lines);
42
+ const header = STACK_HEADER_RE.some((re) => re.test(input));
43
+ // A single explicit header (Traceback/panic) counts as strong evidence even
44
+ // with few parsed frames; otherwise require 2+ frame-like lines.
45
+ if (!header && frames < 2) return { match: false, confidence: 0, frames };
46
+ // Confidence scales with frame count; a header alone floors it at 0.6.
47
+ let confidence = Math.min(0.97, 0.15 * frames);
48
+ if (header) confidence = Math.max(confidence, 0.6 + Math.min(0.3, 0.05 * frames));
49
+ return { match: true, confidence: Number(confidence.toFixed(2)), frames };
50
+ }
51
+
52
+ const TS_RE = /(\b\d{1,2}:\d{2}:\d{2}\b)|(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2})/;
53
+ const PROGRESS_RE = /(\d{1,3}%)|(\bDownloading\b)|(\bnpm (WARN|notice|http)\b)|(##\[(group|endgroup|command)\])|(\[\d+\/\d+\])|(▕|█|━|⠿)|(\bETA\b)|(\r$)/;
54
+
55
+ function matchesCiLog(input, lines) {
56
+ if (lines.length < 8) return { match: false, confidence: 0 };
57
+ let logish = 0;
58
+ const seen = new Map();
59
+ let repeats = 0;
60
+ for (const line of lines) {
61
+ if (TS_RE.test(line) || PROGRESS_RE.test(line)) logish++;
62
+ const norm = line.replace(/\d+/g, '#').trim();
63
+ if (norm) {
64
+ const c = (seen.get(norm) || 0) + 1;
65
+ seen.set(norm, c);
66
+ if (c > 1) repeats++;
67
+ }
68
+ }
69
+ const density = logish / lines.length;
70
+ const repeatRatio = repeats / lines.length;
71
+ if (density < 0.4 && repeatRatio < 0.4) return { match: false, confidence: 0 };
72
+ const confidence = Math.min(0.95, Math.max(density, repeatRatio) + 0.15);
73
+ return { match: true, confidence: Number(confidence.toFixed(2)) };
74
+ }
75
+
76
+ function matchesJsonPayload(input) {
77
+ const trimmed = input.trim();
78
+ if (/^[[{]/.test(trimmed)) {
79
+ try {
80
+ JSON.parse(trimmed);
81
+ return { match: true, confidence: 0.95 };
82
+ } catch (_) { /* not strict JSON — fall through to heuristic */ }
83
+ }
84
+ const lines = trimmed.split('\n');
85
+ if (lines.length < 4) return { match: false, confidence: 0 };
86
+ let kv = 0;
87
+ for (const line of lines) {
88
+ if (/^\s*"[^"]+"\s*:\s*.+/.test(line) || /^\s*[}\]],?\s*$/.test(line)) kv++;
89
+ }
90
+ const ratio = kv / lines.length;
91
+ if (ratio < 0.6) return { match: false, confidence: 0 };
92
+ return { match: true, confidence: Number(Math.min(0.9, ratio).toFixed(2)) };
93
+ }
94
+
95
+ /**
96
+ * @param {string} input
97
+ * @returns {{ category: 'stacktrace'|'cilog'|'json'|null, confidence: number }}
98
+ */
99
+ function classify(input) {
100
+ if (typeof input !== 'string' || !input.trim()) return { category: null, confidence: 0 };
101
+ const lines = input.split('\n');
102
+
103
+ const st = matchesStackTrace(input, lines);
104
+ if (st.match) return { category: 'stacktrace', confidence: st.confidence };
105
+
106
+ const ci = matchesCiLog(input, lines);
107
+ if (ci.match) return { category: 'cilog', confidence: ci.confidence };
108
+
109
+ const js = matchesJsonPayload(input);
110
+ if (js.match) return { category: 'json', confidence: js.confidence };
111
+
112
+ return { category: null, confidence: 0 };
113
+ }
114
+
115
+ module.exports = { classify, countFrames };
@@ -0,0 +1,69 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Squeeze orchestrator (v7.0.0): classify → squeeze → reduction → decision.
5
+ *
6
+ * Always-on and silent: callers run `squeeze()` on pasted input, then use
7
+ * `shouldPrompt()` to decide whether the reduction is worth interrupting for.
8
+ * Everything is deterministic and offline; the symbol index for stack-trace
9
+ * enrichment is passed through via `opts.symbolIndex`.
10
+ */
11
+
12
+ const { classify } = require('./classify');
13
+ const { squeezeStackTrace } = require('./stacktrace');
14
+ const { squeezeCiLog } = require('./cilog');
15
+ const { squeezeJsonPayload } = require('./jsonpayload');
16
+
17
+ function estimateTokens(s) { return Math.ceil(String(s || '').length / 4); }
18
+
19
+ /**
20
+ * @param {string} input
21
+ * @param {object} [opts] forwarded to the category squeezer (srcDirs, symbolIndex, …)
22
+ * @returns {{ category, confidence, original, squeezed, rawTokens, squeezedTokens, reduction, kept, stripped, enriched, applies }}
23
+ */
24
+ function squeeze(input, opts = {}) {
25
+ const { category, confidence } = classify(input);
26
+ const rawTokens = estimateTokens(input);
27
+ const base = {
28
+ category, confidence, original: input, squeezed: input,
29
+ rawTokens, squeezedTokens: rawTokens, reduction: 0,
30
+ kept: [], stripped: [], enriched: false, applies: false,
31
+ };
32
+ if (!category) return base;
33
+
34
+ let r;
35
+ if (category === 'stacktrace') r = squeezeStackTrace(input, opts);
36
+ else if (category === 'cilog') r = squeezeCiLog(input, opts);
37
+ else r = squeezeJsonPayload(input, opts);
38
+
39
+ const squeezedTokens = estimateTokens(r.squeezed);
40
+ const reduction = rawTokens > 0 ? (rawTokens - squeezedTokens) / rawTokens : 0;
41
+ return {
42
+ category, confidence,
43
+ original: input, squeezed: r.squeezed,
44
+ rawTokens, squeezedTokens,
45
+ reduction: Math.max(0, Number(reduction.toFixed(4))),
46
+ kept: r.kept || [], stripped: r.stripped || [], enriched: !!r.enriched,
47
+ applies: squeezedTokens < rawTokens,
48
+ };
49
+ }
50
+
51
+ /** True when the reduction clears the threshold (accepts 0–1 or 0–100). */
52
+ function shouldPrompt(reduction, threshold) {
53
+ const t = threshold > 1 ? threshold / 100 : threshold;
54
+ return reduction >= t;
55
+ }
56
+
57
+ /** A compact human summary of what squeeze would do (for the prompt). */
58
+ function formatSummary(result) {
59
+ const pct = Math.round(result.reduction * 100);
60
+ const lines = [
61
+ `Input: ${result.rawTokens.toLocaleString()} tokens`,
62
+ `Can reduce to ${result.squeezedTokens.toLocaleString()} tokens (${pct}% smaller):`,
63
+ ];
64
+ for (const k of result.kept) lines.push(` ✓ Kept: ${k}`);
65
+ for (const s of result.stripped) lines.push(` ✗ Stripped: ${s}`);
66
+ return lines.join('\n');
67
+ }
68
+
69
+ module.exports = { squeeze, shouldPrompt, formatSummary, estimateTokens };
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * JSON-payload squeeze (v7.0.0).
5
+ *
6
+ * Collapses repeated array elements, truncates long string values, and
7
+ * preserves the schema shape at every depth — so an LLM still sees the
8
+ * structure of an API/GraphQL/validation error without the bulk.
9
+ */
10
+
11
+ const MAX_STR = 500;
12
+ const ARRAY_KEEP = 2;
13
+
14
+ function squeezeValue(v, opts) {
15
+ const maxStr = opts.maxStr;
16
+ const keep = opts.arrayKeep;
17
+ if (Array.isArray(v)) {
18
+ if (v.length <= keep + 1) return v.map((x) => squeezeValue(x, opts));
19
+ const head = v.slice(0, keep).map((x) => squeezeValue(x, opts));
20
+ head.push(`…${v.length - keep} more similar items`);
21
+ return head;
22
+ }
23
+ if (v && typeof v === 'object') {
24
+ const o = {};
25
+ for (const k of Object.keys(v)) o[k] = squeezeValue(v[k], opts);
26
+ return o;
27
+ }
28
+ if (typeof v === 'string' && v.length > maxStr) {
29
+ return v.slice(0, maxStr) + `…(${v.length} chars)`;
30
+ }
31
+ return v;
32
+ }
33
+
34
+ /**
35
+ * @param {string} input
36
+ * @param {object} [opts]
37
+ * @param {number} [opts.maxStr=500] truncate strings longer than this
38
+ * @param {number} [opts.arrayKeep=2] array items kept before collapsing
39
+ * @returns {{ squeezed, kept, stripped }}
40
+ */
41
+ function squeezeJsonPayload(input, opts = {}) {
42
+ let parsed;
43
+ try { parsed = JSON.parse(input); }
44
+ catch (_) { return { squeezed: input, kept: ['(not valid JSON — unchanged)'], stripped: [] }; }
45
+ const cfg = { maxStr: opts.maxStr != null ? opts.maxStr : MAX_STR, arrayKeep: opts.arrayKeep != null ? opts.arrayKeep : ARRAY_KEEP };
46
+ const squeezed = JSON.stringify(squeezeValue(parsed, cfg), null, 2);
47
+ return {
48
+ squeezed,
49
+ kept: ['schema shape preserved at all depths'],
50
+ stripped: ['collapsed repeated array items; truncated long string values'],
51
+ };
52
+ }
53
+
54
+ module.exports = { squeezeJsonPayload, squeezeValue };