sandoichi 0.4.2 → 0.6.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/README.md +5 -1
- package/index.mjs +18 -1
- package/package.json +1 -1
- package/src/accounting-cli.mjs +1 -1
- package/src/artifact-lifecycle.mjs +67 -0
- package/src/artifact-recovery.mjs +5 -0
- package/src/artifact-store.mjs +2 -1
- package/src/cache-attribution.mjs +13 -3
- package/src/context-transform.mjs +169 -17
- package/src/core.mjs +283 -34
- package/src/history-archive.mjs +80 -0
- package/src/hook-cli.mjs +17 -1
- package/src/lazy-mcp-gateway.mjs +10 -6
- package/src/mcp-server.mjs +47 -12
- package/src/metrics.mjs +4 -3
- package/src/provider-usage.mjs +103 -23
- package/src/proxy.mjs +83 -7
- package/src/result-disclosure.mjs +8 -2
- package/src/semantic-gate.mjs +69 -0
- package/src/semantic-judge.mjs +261 -0
- package/src/slice.mjs +419 -0
- package/src/statusline.mjs +5 -9
- package/src/telemetry.mjs +101 -19
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { estimateTokens } from './core.mjs';
|
|
4
|
+
import { redact } from './secret-redaction.mjs';
|
|
5
|
+
|
|
6
|
+
export const SEMANTIC_JUDGMENT_SCHEMA = 'sando-semantic-judgment/v1';
|
|
7
|
+
const QUESTION_ID = 'preview_loses_diagnostic_evidence';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_POLICY = Object.freeze({
|
|
10
|
+
minInputTokens: 8000,
|
|
11
|
+
maxTextChars: 6000,
|
|
12
|
+
timeoutMs: 1500,
|
|
13
|
+
maxRequests: 20,
|
|
14
|
+
lossThreshold: 0.7,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function object(value) {
|
|
18
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function sha256(value) {
|
|
22
|
+
return `sha256:${createHash('sha256').update(value).digest('hex')}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function validatePolicy(policy) {
|
|
26
|
+
const result = { ...DEFAULT_POLICY, ...(policy ?? {}) };
|
|
27
|
+
if (!Number.isInteger(result.minInputTokens) || result.minInputTokens < 1
|
|
28
|
+
|| !Number.isInteger(result.maxTextChars) || result.maxTextChars < 64
|
|
29
|
+
|| !Number.isInteger(result.timeoutMs) || result.timeoutMs < 1
|
|
30
|
+
|| !Number.isInteger(result.maxRequests) || result.maxRequests < 1
|
|
31
|
+
|| !Number.isFinite(result.lossThreshold) || result.lossThreshold < 0 || result.lossThreshold > 1) {
|
|
32
|
+
throw new TypeError('invalid semantic judge policy');
|
|
33
|
+
}
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function boundedText(text, maxChars) {
|
|
38
|
+
if (text.length <= maxChars) return { text, sampled: false };
|
|
39
|
+
const marker = '\n[sando semantic judge middle elided]\n';
|
|
40
|
+
if (maxChars <= marker.length) return { text: text.slice(0, maxChars), sampled: true };
|
|
41
|
+
const available = maxChars - marker.length;
|
|
42
|
+
const headChars = Math.ceil(available / 2);
|
|
43
|
+
return {
|
|
44
|
+
text: `${text.slice(0, headChars)}${marker}${text.slice(-available + headChars)}`,
|
|
45
|
+
sampled: true,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function redactedBounded(text, maxChars, redactionProfile) {
|
|
50
|
+
const safe = redactionProfile ? redactionProfile.redact(text) : redact(text);
|
|
51
|
+
const bounded = boundedText(safe.text, maxChars);
|
|
52
|
+
return { ...bounded, count: safe.count };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function prepareSemanticJudgeRequest(options) {
|
|
56
|
+
const {
|
|
57
|
+
originalText, previewText, maxTextChars = DEFAULT_POLICY.maxTextChars, redactionProfile,
|
|
58
|
+
} = options;
|
|
59
|
+
requireText(originalText, 'originalText');
|
|
60
|
+
requireText(previewText, 'previewText');
|
|
61
|
+
if (!Number.isInteger(maxTextChars) || maxTextChars < 64) throw new TypeError('maxTextChars is invalid');
|
|
62
|
+
if (typeof options.recoverable !== 'boolean') throw new TypeError('recoverable must be a boolean');
|
|
63
|
+
|
|
64
|
+
const original = redactedBounded(originalText, maxTextChars, redactionProfile);
|
|
65
|
+
const preview = redactedBounded(previewText, maxTextChars, redactionProfile);
|
|
66
|
+
return {
|
|
67
|
+
request: {
|
|
68
|
+
state: {
|
|
69
|
+
provider: typeof options.provider === 'string' ? options.provider : 'unknown',
|
|
70
|
+
model: typeof options.model === 'string' ? options.model : 'unknown',
|
|
71
|
+
tool: typeof options.toolName === 'string' ? options.toolName : 'unknown',
|
|
72
|
+
original: original.text,
|
|
73
|
+
preview: preview.text,
|
|
74
|
+
originalSampled: original.sampled,
|
|
75
|
+
previewSampled: preview.sampled,
|
|
76
|
+
recoverable: options.recoverable,
|
|
77
|
+
},
|
|
78
|
+
questions: {
|
|
79
|
+
[QUESTION_ID]: {
|
|
80
|
+
type: 'noul',
|
|
81
|
+
instructions: 'Does `preview` omit diagnostic information from `original` that a coding agent would need to diagnose the result? If `recoverable` is true, treat intentionally omitted information as available through the Sando recovery artifact.',
|
|
82
|
+
criteria: 'Answer yes only for task-relevant diagnostic evidence that is absent from the preview and not recoverable; answer no when the preview preserves the evidence or the omission is recoverable.',
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
redactions: original.count + preview.count,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function requireText(value, name) {
|
|
91
|
+
if (typeof value !== 'string') throw new TypeError(`${name} must be a string`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function buildSemanticJudgeRequest({
|
|
95
|
+
provider, model, toolName, originalText, previewText, recoverable = false,
|
|
96
|
+
maxTextChars = DEFAULT_POLICY.maxTextChars, redactionProfile,
|
|
97
|
+
} = {}) {
|
|
98
|
+
return prepareSemanticJudgeRequest({
|
|
99
|
+
provider, model, toolName, originalText, previewText, recoverable, maxTextChars, redactionProfile,
|
|
100
|
+
}).request;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function cacheKey(candidate, request) {
|
|
104
|
+
return sha256(JSON.stringify({
|
|
105
|
+
schema: SEMANTIC_JUDGMENT_SCHEMA,
|
|
106
|
+
id: candidate.id ?? null,
|
|
107
|
+
provider: candidate.provider ?? null,
|
|
108
|
+
model: candidate.model ?? null,
|
|
109
|
+
toolName: candidate.toolName ?? null,
|
|
110
|
+
recoverable: candidate.recoverable === true,
|
|
111
|
+
state: request.state,
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function probability(value) {
|
|
116
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1 ? value : null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function usageSummary(value) {
|
|
120
|
+
if (!object(value)) return null;
|
|
121
|
+
const result = {};
|
|
122
|
+
const fields = {
|
|
123
|
+
inputTokens: ['inputTokens', 'input_tokens'],
|
|
124
|
+
outputTokens: ['outputTokens', 'output_tokens'],
|
|
125
|
+
};
|
|
126
|
+
for (const [target, keys] of Object.entries(fields)) {
|
|
127
|
+
const tokenValue = keys.map((key) => value[key]).find(
|
|
128
|
+
(item) => Number.isSafeInteger(item) && item >= 0,
|
|
129
|
+
);
|
|
130
|
+
if (tokenValue !== undefined) result[target] = tokenValue;
|
|
131
|
+
}
|
|
132
|
+
return Object.keys(result).length ? result : null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function resultBase(candidate, inputTokens, previewTokens) {
|
|
136
|
+
return {
|
|
137
|
+
schema: SEMANTIC_JUDGMENT_SCHEMA,
|
|
138
|
+
mode: 'shadow',
|
|
139
|
+
status: 'fallback',
|
|
140
|
+
id: typeof candidate.id === 'string' ? candidate.id : null,
|
|
141
|
+
inputTokens,
|
|
142
|
+
previewTokens,
|
|
143
|
+
omittedTokens: Math.max(0, inputTokens - previewTokens),
|
|
144
|
+
lossProbability: null,
|
|
145
|
+
cacheHit: false,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function validCandidate(candidate) {
|
|
150
|
+
return candidate && typeof candidate === 'object' && !Array.isArray(candidate);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function evaluateWithTimeout(evaluate, request, timeoutMs) {
|
|
154
|
+
const controller = new AbortController();
|
|
155
|
+
let timer;
|
|
156
|
+
const timeout = new Promise((_, reject) => {
|
|
157
|
+
timer = setTimeout(() => {
|
|
158
|
+
controller.abort();
|
|
159
|
+
const error = new Error('semantic judge timeout');
|
|
160
|
+
error.code = 'SEMANTIC_JUDGE_TIMEOUT';
|
|
161
|
+
reject(error);
|
|
162
|
+
}, timeoutMs);
|
|
163
|
+
});
|
|
164
|
+
const operation = Promise.resolve().then(() => evaluate(request, { signal: controller.signal }));
|
|
165
|
+
try {
|
|
166
|
+
return await Promise.race([operation, timeout]);
|
|
167
|
+
} finally {
|
|
168
|
+
clearTimeout(timer);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function cachedResult(value, lossThreshold) {
|
|
173
|
+
const lossProbability = probability(value?.lossProbability);
|
|
174
|
+
if (lossProbability === null) return null;
|
|
175
|
+
const result = {
|
|
176
|
+
status: 'judged',
|
|
177
|
+
lossProbability,
|
|
178
|
+
verdict: lossProbability >= lossThreshold ? 'loss' : 'preserved',
|
|
179
|
+
cacheHit: true,
|
|
180
|
+
};
|
|
181
|
+
if (Number.isSafeInteger(value?.latencyMs) && value.latencyMs >= 0) result.latencyMs = value.latencyMs;
|
|
182
|
+
if (Number.isSafeInteger(value?.redactions) && value.redactions >= 0) result.redactions = value.redactions;
|
|
183
|
+
const usage = usageSummary(value?.usage);
|
|
184
|
+
if (usage) result.usage = usage;
|
|
185
|
+
return result;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function createSemanticJudge({ evaluate, cache = new Map(), policy, redactionProfile } = {}) {
|
|
189
|
+
if (typeof evaluate !== 'function') throw new TypeError('semantic judge evaluate callback is required');
|
|
190
|
+
if (!cache || typeof cache.get !== 'function' || typeof cache.set !== 'function') {
|
|
191
|
+
throw new TypeError('semantic judge cache must implement get and set');
|
|
192
|
+
}
|
|
193
|
+
if (redactionProfile && typeof redactionProfile.redact !== 'function') {
|
|
194
|
+
throw new TypeError('redactionProfile is invalid');
|
|
195
|
+
}
|
|
196
|
+
const options = validatePolicy(policy);
|
|
197
|
+
const pending = new Map();
|
|
198
|
+
let requests = 0;
|
|
199
|
+
|
|
200
|
+
return async function judge(candidate = {}) {
|
|
201
|
+
if (!validCandidate(candidate)) throw new TypeError('semantic judge candidate must be an object');
|
|
202
|
+
requireText(candidate.originalText, 'originalText');
|
|
203
|
+
requireText(candidate.previewText, 'previewText');
|
|
204
|
+
const inputTokens = estimateTokens(candidate.originalText);
|
|
205
|
+
const previewTokens = estimateTokens(candidate.previewText);
|
|
206
|
+
const base = resultBase(candidate, inputTokens, previewTokens);
|
|
207
|
+
if (candidate.historical === false) return { ...base, status: 'skipped', reason: 'current-result' };
|
|
208
|
+
if (candidate.isError === true) return { ...base, status: 'skipped', reason: 'error-result' };
|
|
209
|
+
if (inputTokens < options.minInputTokens) return { ...base, status: 'skipped', reason: 'below-threshold' };
|
|
210
|
+
|
|
211
|
+
const prepared = prepareSemanticJudgeRequest({
|
|
212
|
+
provider: candidate.provider,
|
|
213
|
+
model: candidate.model,
|
|
214
|
+
toolName: candidate.toolName,
|
|
215
|
+
originalText: candidate.originalText,
|
|
216
|
+
previewText: candidate.previewText,
|
|
217
|
+
recoverable: candidate.recoverable === true,
|
|
218
|
+
maxTextChars: options.maxTextChars,
|
|
219
|
+
redactionProfile,
|
|
220
|
+
});
|
|
221
|
+
const request = prepared.request;
|
|
222
|
+
const key = cacheKey(candidate, request);
|
|
223
|
+
let cached;
|
|
224
|
+
try { cached = cachedResult(cache.get(key), options.lossThreshold); } catch { cached = null; }
|
|
225
|
+
if (cached) return { ...base, ...cached };
|
|
226
|
+
if (pending.has(key)) return { ...base, ...(await pending.get(key)), cacheHit: true };
|
|
227
|
+
if (requests >= options.maxRequests) return { ...base, reason: 'budget' };
|
|
228
|
+
|
|
229
|
+
requests += 1;
|
|
230
|
+
const started = Date.now();
|
|
231
|
+
const operation = (async () => {
|
|
232
|
+
let raw;
|
|
233
|
+
try {
|
|
234
|
+
raw = await evaluateWithTimeout(evaluate, request, options.timeoutMs);
|
|
235
|
+
} catch (error) {
|
|
236
|
+
return { ...base, reason: error?.code === 'SEMANTIC_JUDGE_TIMEOUT' ? 'timeout' : 'judge-error', latencyMs: Date.now() - started };
|
|
237
|
+
}
|
|
238
|
+
const lossProbability = probability(raw?.answers?.[QUESTION_ID]?.noul);
|
|
239
|
+
if (lossProbability === null) {
|
|
240
|
+
return { ...base, reason: 'invalid-response', latencyMs: Date.now() - started };
|
|
241
|
+
}
|
|
242
|
+
const judged = {
|
|
243
|
+
status: 'judged',
|
|
244
|
+
lossProbability,
|
|
245
|
+
verdict: lossProbability >= options.lossThreshold ? 'loss' : 'preserved',
|
|
246
|
+
cacheHit: false,
|
|
247
|
+
latencyMs: Number.isSafeInteger(raw?.elapsedMs) ? raw.elapsedMs : Date.now() - started,
|
|
248
|
+
usage: usageSummary(raw?.usage),
|
|
249
|
+
redactions: prepared.redactions,
|
|
250
|
+
};
|
|
251
|
+
try { cache.set(key, judged); } catch { /* cache is best-effort */ }
|
|
252
|
+
return { ...base, ...judged };
|
|
253
|
+
})();
|
|
254
|
+
pending.set(key, operation);
|
|
255
|
+
try {
|
|
256
|
+
return await operation;
|
|
257
|
+
} finally {
|
|
258
|
+
pending.delete(key);
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
}
|
package/src/slice.mjs
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { loadProjectRedactionProfile } from './redaction-config.mjs';
|
|
6
|
+
import { PLUGIN_VERSION } from './version.mjs';
|
|
7
|
+
|
|
8
|
+
const READ_ANNOTATIONS = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
9
|
+
const WRITE_ANNOTATIONS = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
|
|
10
|
+
const REPLACE_ANNOTATIONS = { ...WRITE_ANNOTATIONS, destructiveHint: true };
|
|
11
|
+
const INTEGER = { type: 'integer', minimum: 1 };
|
|
12
|
+
const MAX_FETCH_LINES = 400;
|
|
13
|
+
const MAX_RESPONSE_BYTES = 1024 * 1024;
|
|
14
|
+
const REQUEST_TIMEOUT_MS = 120_000;
|
|
15
|
+
const HANDLE_PATTERN = '^sym#[a-f0-9]{16}@[a-f0-9]{16}$';
|
|
16
|
+
const HANDLE_RE = new RegExp(HANDLE_PATTERN);
|
|
17
|
+
|
|
18
|
+
const definitions = [
|
|
19
|
+
{
|
|
20
|
+
name: 'sando_slice_for', upstream: 'for', write: false,
|
|
21
|
+
description: 'Discover task-relevant symbols from the configured workspace. Returns native content and index freshness metadata unchanged.',
|
|
22
|
+
required: ['task'], properties: { task: { type: 'string', minLength: 1 }, budget_tokens: INTEGER }, annotations: READ_ANNOTATIONS,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: 'sando_slice_find_symbol', upstream: 'find_symbol', write: false,
|
|
26
|
+
description: 'Find one symbol and its direct callers and callees. Preserves handles, ambiguity, floors, and index freshness metadata.',
|
|
27
|
+
required: ['symbol'], properties: { symbol: { type: 'string', minLength: 1 }, limit: INTEGER, offset: { type: 'integer', minimum: 0 } }, annotations: READ_ANNOTATIONS,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'sando_slice_find_referencing_symbols', upstream: 'find_referencing_symbols', write: false,
|
|
31
|
+
description: 'Find direct referencing symbols. Preserves handles, ambiguity, floors, and index freshness metadata.',
|
|
32
|
+
required: ['symbol'], properties: { symbol: { type: 'string', minLength: 1 }, limit: INTEGER, offset: { type: 'integer', minimum: 0 } }, annotations: READ_ANNOTATIONS,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'sando_slice_fetch_body', upstream: 'fetch_body', write: false,
|
|
36
|
+
description: `Fetch source for a symbol handle, bounded to at most ${MAX_FETCH_LINES} body-relative lines. Stale and ambiguous handles are refused.`,
|
|
37
|
+
required: ['handle'], properties: { handle: { type: 'string', pattern: HANDLE_PATTERN }, start_line: INTEGER, end_line: INTEGER }, annotations: READ_ANNOTATIONS,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'sando_slice_replace_symbol_body', upstream: 'replace_symbol_body', write: true,
|
|
41
|
+
description: 'Replace exactly the definition span returned by sando_slice_fetch_body. Modifiers outside that span are preserved; do not repeat them in new_body. Requires a fresh handle and SANDO_SLICE_WRITE=1.',
|
|
42
|
+
required: ['handle', 'new_body'], properties: { handle: { type: 'string', pattern: HANDLE_PATTERN }, new_body: { type: 'string', minLength: 1 }, post_check: { type: 'boolean' } }, annotations: REPLACE_ANNOTATIONS,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'sando_slice_insert_after_symbol', upstream: 'insert_after_symbol', write: true,
|
|
46
|
+
description: 'Insert text after the definition identified by a fresh handle. Requires SANDO_SLICE_WRITE=1; the native engine owns stale-handle refusal, newline handling, and atomic writes.',
|
|
47
|
+
required: ['handle', 'text'], properties: { handle: { type: 'string', pattern: HANDLE_PATTERN }, text: { type: 'string', minLength: 1 }, post_check: { type: 'boolean' } }, annotations: WRITE_ANNOTATIONS,
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const byName = new Map(definitions.map((definition) => [definition.name, definition]));
|
|
52
|
+
|
|
53
|
+
export class SliceRpcError extends Error {
|
|
54
|
+
constructor(code, message, data) {
|
|
55
|
+
super(message);
|
|
56
|
+
this.name = 'SliceRpcError';
|
|
57
|
+
this.code = code;
|
|
58
|
+
if (data !== undefined) this.data = data;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function configurationError(message) { return new SliceRpcError(-32603, message); }
|
|
63
|
+
|
|
64
|
+
function configuration(env) {
|
|
65
|
+
const binarySetting = env?.SANDO_SLICE_BINARY;
|
|
66
|
+
if (typeof binarySetting !== 'string' || !path.isAbsolute(binarySetting) || binarySetting.includes('\0')) {
|
|
67
|
+
throw configurationError('SANDO_SLICE_BINARY must be an absolute executable file');
|
|
68
|
+
}
|
|
69
|
+
let executable;
|
|
70
|
+
try {
|
|
71
|
+
executable = fs.realpathSync(binarySetting);
|
|
72
|
+
const stat = fs.statSync(executable);
|
|
73
|
+
if (!stat.isFile()) throw new Error('not a file');
|
|
74
|
+
fs.accessSync(executable, fs.constants.X_OK);
|
|
75
|
+
} catch {
|
|
76
|
+
throw configurationError('SANDO_SLICE_BINARY must be an absolute executable file');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const rootSetting = env?.SANDO_SLICE_ROOT;
|
|
80
|
+
if (typeof rootSetting !== 'string' || !path.isAbsolute(rootSetting) || rootSetting.includes('\0')) {
|
|
81
|
+
throw configurationError('SANDO_SLICE_ROOT must be an absolute canonical directory');
|
|
82
|
+
}
|
|
83
|
+
let root;
|
|
84
|
+
try {
|
|
85
|
+
root = fs.realpathSync(rootSetting);
|
|
86
|
+
const stat = fs.lstatSync(rootSetting);
|
|
87
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || root !== path.resolve(rootSetting)) throw new Error('not canonical');
|
|
88
|
+
} catch {
|
|
89
|
+
throw configurationError('SANDO_SLICE_ROOT must be an absolute canonical directory');
|
|
90
|
+
}
|
|
91
|
+
return { executable, root };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function publicTool({ name, description, required, properties, annotations }) {
|
|
95
|
+
return { name, description, inputSchema: { type: 'object', additionalProperties: false, required, properties }, annotations };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function SLICE_TOOLS(env = process.env) {
|
|
99
|
+
try { configuration(env); } catch { return []; }
|
|
100
|
+
return definitions.filter((definition) => !definition.write || env?.SANDO_SLICE_WRITE === '1').map(publicTool);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function spawnSliceProcess({ executable, root }) {
|
|
104
|
+
return spawn(executable, [root, '--mcp'], { cwd: root, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
class SliceSession {
|
|
108
|
+
constructor(child) {
|
|
109
|
+
if (!child?.stdin || !child?.stdout || !child?.stderr) throw configurationError('Slice backend did not provide stdio pipes');
|
|
110
|
+
this.child = child;
|
|
111
|
+
this.pending = new Map();
|
|
112
|
+
this.nextId = 1;
|
|
113
|
+
this.closed = false;
|
|
114
|
+
this.stdout = Buffer.alloc(0);
|
|
115
|
+
child.stdout.on('data', (chunk) => this.consume(Buffer.from(chunk)));
|
|
116
|
+
child.stderr.resume();
|
|
117
|
+
child.stdin.on('error', (error) => this.close(configurationError(`Slice backend stdin failed: ${error.message}`)));
|
|
118
|
+
child.on('error', (error) => this.close(configurationError(`Slice backend unavailable: ${error.message}`)));
|
|
119
|
+
child.on('exit', (code, signal) => this.fail(configurationError(
|
|
120
|
+
`Slice backend exited before replying (code=${code ?? 'null'}, signal=${signal ?? 'none'})`,
|
|
121
|
+
)));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async initialize(context) {
|
|
125
|
+
const result = await this.request('initialize', {
|
|
126
|
+
protocolVersion: '2025-11-25',
|
|
127
|
+
capabilities: {},
|
|
128
|
+
clientInfo: { name: 'sando', version: PLUGIN_VERSION },
|
|
129
|
+
}, context);
|
|
130
|
+
if (!result || typeof result !== 'object' || typeof result.protocolVersion !== 'string'
|
|
131
|
+
|| !result.capabilities || typeof result.capabilities !== 'object'
|
|
132
|
+
|| !result.serverInfo || typeof result.serverInfo.name !== 'string') {
|
|
133
|
+
const error = configurationError('Slice backend returned an invalid initialize result');
|
|
134
|
+
this.close(error);
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
this.notify('notifications/initialized', {});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
request(method, params, { signal, deadline } = {}) {
|
|
141
|
+
if (this.closed) return Promise.reject(configurationError('Slice backend is closed'));
|
|
142
|
+
if (signal?.aborted) return Promise.reject(new SliceRpcError(-32800, 'Slice request cancelled'));
|
|
143
|
+
const remaining = (deadline ?? Date.now() + REQUEST_TIMEOUT_MS) - Date.now();
|
|
144
|
+
if (remaining <= 0) return Promise.reject(configurationError('Slice request timed out before execution'));
|
|
145
|
+
const id = this.nextId++;
|
|
146
|
+
return new Promise((resolve, reject) => {
|
|
147
|
+
const abort = () => {
|
|
148
|
+
const error = new SliceRpcError(-32800, 'Slice request cancelled');
|
|
149
|
+
this.close(error);
|
|
150
|
+
};
|
|
151
|
+
if (signal) signal.addEventListener('abort', abort, { once: true });
|
|
152
|
+
const timer = setTimeout(() => this.close(configurationError('Slice request timed out')), remaining);
|
|
153
|
+
this.pending.set(id, {
|
|
154
|
+
resolve,
|
|
155
|
+
reject,
|
|
156
|
+
cleanup: () => { clearTimeout(timer); signal?.removeEventListener('abort', abort); },
|
|
157
|
+
});
|
|
158
|
+
this.write({ jsonrpc: '2.0', id, method, params }, id);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
consume(chunk) {
|
|
163
|
+
this.stdout = Buffer.concat([this.stdout, chunk]);
|
|
164
|
+
while (true) {
|
|
165
|
+
const newline = this.stdout.indexOf(0x0a);
|
|
166
|
+
if (newline < 0) {
|
|
167
|
+
if (this.stdout.length > MAX_RESPONSE_BYTES) this.close(configurationError(`Slice response exceeded ${MAX_RESPONSE_BYTES} bytes`));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (newline > MAX_RESPONSE_BYTES) {
|
|
171
|
+
this.close(configurationError(`Slice response exceeded ${MAX_RESPONSE_BYTES} bytes`));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const line = this.stdout.subarray(0, newline).toString('utf8');
|
|
175
|
+
this.stdout = this.stdout.subarray(newline + 1);
|
|
176
|
+
this.receive(line);
|
|
177
|
+
if (this.closed) return;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
notify(method, params) { this.write({ jsonrpc: '2.0', method, params }); }
|
|
182
|
+
|
|
183
|
+
write(message, id) {
|
|
184
|
+
this.child.stdin.write(`${JSON.stringify(message)}\n`, (error) => {
|
|
185
|
+
if (!error || id === undefined) return;
|
|
186
|
+
const pending = this.pending.get(id);
|
|
187
|
+
if (!pending) return;
|
|
188
|
+
this.close(configurationError(`Slice request write failed: ${error.message}`));
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
receive(line) {
|
|
193
|
+
let message;
|
|
194
|
+
try { message = JSON.parse(line); }
|
|
195
|
+
catch { this.close(configurationError('Slice backend returned invalid JSON')); return; }
|
|
196
|
+
if (!message || typeof message !== 'object' || Array.isArray(message)) {
|
|
197
|
+
this.close(configurationError('Slice backend returned an invalid JSON-RPC envelope'));
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const pending = this.pending.get(message.id);
|
|
201
|
+
if (!pending) return;
|
|
202
|
+
this.pending.delete(message.id);
|
|
203
|
+
pending.cleanup();
|
|
204
|
+
if (message.error) pending.reject(new SliceRpcError(message.error.code, message.error.message, message.error.data));
|
|
205
|
+
else pending.resolve(message.result);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
fail(error) {
|
|
209
|
+
if (this.closed) return;
|
|
210
|
+
this.closed = true;
|
|
211
|
+
for (const pending of this.pending.values()) {
|
|
212
|
+
pending.cleanup();
|
|
213
|
+
pending.reject(error);
|
|
214
|
+
}
|
|
215
|
+
this.pending.clear();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
close(error = configurationError('Slice backend closed')) {
|
|
219
|
+
if (this.closed) return;
|
|
220
|
+
this.fail(error);
|
|
221
|
+
this.child.kill();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function argumentsFor(definition, args) {
|
|
226
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)
|
|
227
|
+
|| ![Object.prototype, null].includes(Object.getPrototypeOf(args))) {
|
|
228
|
+
throw new SliceRpcError(-32602, 'Slice arguments must be an object');
|
|
229
|
+
}
|
|
230
|
+
const allowed = new Set(Object.keys(definition.properties));
|
|
231
|
+
for (const key of Reflect.ownKeys(args)) {
|
|
232
|
+
if (typeof key !== 'string') throw new SliceRpcError(-32602, 'unknown Slice argument');
|
|
233
|
+
if (!allowed.has(key)) throw new SliceRpcError(-32602, `unknown argument: ${key}`);
|
|
234
|
+
}
|
|
235
|
+
for (const key of definition.required) {
|
|
236
|
+
if (!Object.hasOwn(args, key)) throw new SliceRpcError(-32602, `missing required argument: ${key}`);
|
|
237
|
+
}
|
|
238
|
+
for (const [key, value] of Object.entries(args)) {
|
|
239
|
+
const schema = definition.properties[key];
|
|
240
|
+
const validType = schema.type === 'string' ? typeof value === 'string'
|
|
241
|
+
: schema.type === 'integer' ? Number.isSafeInteger(value)
|
|
242
|
+
: schema.type === 'boolean' ? typeof value === 'boolean'
|
|
243
|
+
: false;
|
|
244
|
+
if (!validType
|
|
245
|
+
|| (schema.minLength !== undefined && value.length < schema.minLength)
|
|
246
|
+
|| (schema.minimum !== undefined && value < schema.minimum)
|
|
247
|
+
|| (schema.pattern !== undefined && !(new RegExp(schema.pattern)).test(value))) {
|
|
248
|
+
throw new SliceRpcError(-32602, `invalid argument: ${key}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const forwarded = { ...args };
|
|
252
|
+
if (definition.upstream === 'fetch_body') {
|
|
253
|
+
const start = forwarded.start_line ?? 1;
|
|
254
|
+
const end = forwarded.end_line ?? (start + MAX_FETCH_LINES - 1);
|
|
255
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 1 || end < start || end - start + 1 > MAX_FETCH_LINES) {
|
|
256
|
+
throw new SliceRpcError(-32602, `fetch_body range must span 1..${MAX_FETCH_LINES} positive body-relative lines`);
|
|
257
|
+
}
|
|
258
|
+
forwarded.start_line = start;
|
|
259
|
+
forwarded.end_line = end;
|
|
260
|
+
}
|
|
261
|
+
if (definition.write) {
|
|
262
|
+
if (typeof forwarded.handle !== 'string' || !HANDLE_RE.test(forwarded.handle)) {
|
|
263
|
+
throw new SliceRpcError(-32602, 'Slice writes require a fresh handle from sando_slice_find_symbol');
|
|
264
|
+
}
|
|
265
|
+
forwarded.symbol = forwarded.handle;
|
|
266
|
+
delete forwarded.handle;
|
|
267
|
+
}
|
|
268
|
+
return forwarded;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function isSliceTool(name) { return byName.has(name); }
|
|
272
|
+
|
|
273
|
+
function resolveRedactionProfile(root) {
|
|
274
|
+
try {
|
|
275
|
+
return loadProjectRedactionProfile(root).profile;
|
|
276
|
+
} catch (error) {
|
|
277
|
+
throw configurationError(`Slice redaction config is invalid: ${error.message}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function redactError(error, profile) {
|
|
282
|
+
const message = profile.redact(error instanceof Error ? error.message : String(error)).text;
|
|
283
|
+
let data;
|
|
284
|
+
if (error?.data !== undefined) data = profile.redactStructured(error.data).value;
|
|
285
|
+
return new SliceRpcError(Number.isInteger(error?.code) ? error.code : -32603, message, data);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function redactContentText(text, profile) {
|
|
289
|
+
try {
|
|
290
|
+
const redacted = profile.redactStructured(JSON.parse(text));
|
|
291
|
+
return { value: JSON.stringify(redacted.value), count: redacted.count };
|
|
292
|
+
} catch (error) {
|
|
293
|
+
if (!(error instanceof SyntaxError)) throw error;
|
|
294
|
+
const redacted = profile.redact(text);
|
|
295
|
+
return { value: redacted.text, count: redacted.count };
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function redactResult(result, profile) {
|
|
300
|
+
if (!result || typeof result !== 'object' || Array.isArray(result)
|
|
301
|
+
|| ![Object.prototype, null].includes(Object.getPrototypeOf(result))) {
|
|
302
|
+
throw configurationError('Slice backend returned an invalid tool result');
|
|
303
|
+
}
|
|
304
|
+
const outer = profile.redactStructured(Object.fromEntries(
|
|
305
|
+
Object.entries(result).filter(([key]) => key !== 'content'),
|
|
306
|
+
));
|
|
307
|
+
let count = outer.count;
|
|
308
|
+
let content;
|
|
309
|
+
if (Object.hasOwn(result, 'content')) {
|
|
310
|
+
if (!Array.isArray(result.content)) throw configurationError('Slice backend returned invalid tool content');
|
|
311
|
+
content = result.content.map((item) => {
|
|
312
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)
|
|
313
|
+
|| ![Object.prototype, null].includes(Object.getPrototypeOf(item))) {
|
|
314
|
+
throw configurationError('Slice backend returned invalid tool content');
|
|
315
|
+
}
|
|
316
|
+
const metadata = profile.redactStructured(Object.fromEntries(
|
|
317
|
+
Object.entries(item).filter(([key]) => key !== 'text'),
|
|
318
|
+
));
|
|
319
|
+
count += metadata.count;
|
|
320
|
+
if (!Object.hasOwn(item, 'text')) return metadata.value;
|
|
321
|
+
if (typeof item.text !== 'string') throw configurationError('Slice backend returned invalid tool content');
|
|
322
|
+
const text = redactContentText(item.text, profile);
|
|
323
|
+
count += text.count;
|
|
324
|
+
return { ...metadata.value, text: text.value };
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
const value = { ...outer.value, ...(content === undefined ? {} : { content }) };
|
|
328
|
+
if (count === 0) return { value, count };
|
|
329
|
+
return {
|
|
330
|
+
count,
|
|
331
|
+
value: {
|
|
332
|
+
...value,
|
|
333
|
+
_sando_redaction: {
|
|
334
|
+
count,
|
|
335
|
+
source_round_trip: false,
|
|
336
|
+
message: 'Redacted source is not round-trippable and cannot be used for symbol replacement.',
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function createSliceBridge({
|
|
343
|
+
env = process.env,
|
|
344
|
+
spawnBackend = spawnSliceProcess,
|
|
345
|
+
contextKey = () => '',
|
|
346
|
+
requestTimeoutMs = REQUEST_TIMEOUT_MS,
|
|
347
|
+
} = {}) {
|
|
348
|
+
if (!Number.isSafeInteger(requestTimeoutMs) || requestTimeoutMs < 1) {
|
|
349
|
+
throw configurationError('Slice requestTimeoutMs must be a positive integer');
|
|
350
|
+
}
|
|
351
|
+
let session;
|
|
352
|
+
let sessionKey;
|
|
353
|
+
let queue = Promise.resolve();
|
|
354
|
+
let closed = false;
|
|
355
|
+
const redactedHandles = new Set();
|
|
356
|
+
|
|
357
|
+
async function invoke(name, args, context) {
|
|
358
|
+
if (closed) throw configurationError('Slice bridge is closed');
|
|
359
|
+
if (context.signal?.aborted) throw new SliceRpcError(-32800, 'Slice request cancelled');
|
|
360
|
+
if (Date.now() >= context.deadline) throw configurationError('Slice request timed out before execution');
|
|
361
|
+
const definition = byName.get(name);
|
|
362
|
+
if (!definition) throw new SliceRpcError(-32602, 'Unknown Slice tool');
|
|
363
|
+
const forwarded = argumentsFor(definition, args);
|
|
364
|
+
if (definition.write && env?.SANDO_SLICE_WRITE !== '1') {
|
|
365
|
+
throw new SliceRpcError(-32602, 'Slice writes are disabled; set SANDO_SLICE_WRITE=1 to enable them');
|
|
366
|
+
}
|
|
367
|
+
const config = configuration(env);
|
|
368
|
+
const profile = resolveRedactionProfile(config.root);
|
|
369
|
+
if (definition.upstream === 'replace_symbol_body' && redactedHandles.has(args.handle)) {
|
|
370
|
+
throw new SliceRpcError(-32602, 'Slice fetched redacted source is not round-trippable; fetch an unredacted handle before writing');
|
|
371
|
+
}
|
|
372
|
+
const key = `${config.executable}\0${config.root}\0${contextKey(context)}`;
|
|
373
|
+
try {
|
|
374
|
+
if (!session || session.closed || key !== sessionKey) {
|
|
375
|
+
session?.close();
|
|
376
|
+
session = new SliceSession(spawnBackend(config, context));
|
|
377
|
+
sessionKey = key;
|
|
378
|
+
await session.initialize(context);
|
|
379
|
+
}
|
|
380
|
+
const result = await session.request('tools/call', { name: definition.upstream, arguments: forwarded }, context);
|
|
381
|
+
const redacted = redactResult(result, profile);
|
|
382
|
+
if (definition.upstream === 'fetch_body' && redacted.count > 0) redactedHandles.add(args.handle);
|
|
383
|
+
return redacted.value;
|
|
384
|
+
} catch (error) {
|
|
385
|
+
throw redactError(error, profile);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
call(name, args = {}, context = {}) {
|
|
391
|
+
if (closed) return Promise.reject(configurationError('Slice bridge is closed'));
|
|
392
|
+
if (context.signal?.aborted) return Promise.reject(new SliceRpcError(-32800, 'Slice request cancelled'));
|
|
393
|
+
const deadline = Date.now() + requestTimeoutMs;
|
|
394
|
+
const admitted = { ...context, deadline };
|
|
395
|
+
let timer;
|
|
396
|
+
let abort;
|
|
397
|
+
const waiting = new Promise((_, reject) => {
|
|
398
|
+
timer = setTimeout(() => reject(configurationError('Slice request timed out')), requestTimeoutMs);
|
|
399
|
+
if (context.signal) {
|
|
400
|
+
abort = () => reject(new SliceRpcError(-32800, 'Slice request cancelled'));
|
|
401
|
+
context.signal.addEventListener('abort', abort, { once: true });
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
const result = queue.then(() => invoke(name, args, admitted));
|
|
405
|
+
queue = result.catch(() => {});
|
|
406
|
+
return Promise.race([result, waiting]).finally(() => {
|
|
407
|
+
clearTimeout(timer);
|
|
408
|
+
context.signal?.removeEventListener('abort', abort);
|
|
409
|
+
});
|
|
410
|
+
},
|
|
411
|
+
close() {
|
|
412
|
+
if (closed) return;
|
|
413
|
+
closed = true;
|
|
414
|
+
session?.close();
|
|
415
|
+
session = undefined;
|
|
416
|
+
sessionKey = undefined;
|
|
417
|
+
},
|
|
418
|
+
};
|
|
419
|
+
}
|