knodin 0.12.2 → 0.13.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 +16 -1
- package/dist/bin/cli.js +173 -14
- package/dist/src/agent-events.js +25 -7
- package/dist/src/authenticated-cursor.js +81 -0
- package/dist/src/class-consumer-contract.js +18 -0
- package/dist/src/class-consumer-cursor.js +91 -0
- package/dist/src/class-consumer-delivery.js +22 -0
- package/dist/src/class-consumer-page.js +148 -0
- package/dist/src/cli-model.js +9 -3
- package/dist/src/docs-sections.js +1 -0
- package/dist/src/engine/apex-class-uses.js +430 -0
- package/dist/src/engine/apex-entry-points.js +98 -0
- package/dist/src/engine/apex-receiver.js +301 -0
- package/dist/src/engine/embedding-reuse.js +57 -0
- package/dist/src/engine/embeddings.js +22 -0
- package/dist/src/engine/index-coverage.js +215 -0
- package/dist/src/engine/index.js +2495 -252
- package/dist/src/engine/salesforce-components.js +460 -0
- package/dist/src/engine/seal.js +3 -0
- package/dist/src/engine/sqlite.js +44 -0
- package/dist/src/evidence-bundle.js +283 -0
- package/dist/src/evidence-graph.js +163 -0
- package/dist/src/failure-diagnosis.js +80 -5
- package/dist/src/file-dependency.js +35 -0
- package/dist/src/graph-query-health.js +47 -1
- package/dist/src/implementation-search.js +69 -0
- package/dist/src/index-coverage-read.js +33 -0
- package/dist/src/investigation.js +195 -0
- package/dist/src/mcp-reliability.js +4 -0
- package/dist/src/mcp-worker-supervisor.js +122 -6
- package/dist/src/progressive-evidence.js +4 -4
- package/dist/src/response-budget.js +129 -3
- package/dist/src/server.js +18 -5
- package/dist/src/shared-index/publisher.js +41 -1
- package/dist/src/tools/knodin-tools.js +224 -41
- package/docs/CLI.md +92 -0
- package/docs/MCP.md +67 -0
- package/docs/PROGRESSIVE-EVIDENCE.md +62 -0
- package/docs/SALESFORCE-BINDINGS.md +121 -0
- package/docs/SALESFORCE-DEAD-CODE.md +45 -0
- package/docs/SCOPED-INDEXING.md +76 -0
- package/docs/apex-receiver-resolution.md +41 -0
- package/docs/releases/0.13.0.md +62 -0
- package/docs/structural-only-indexing.md +20 -0
- package/package.json +11 -5
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
1
2
|
import { compareBytes } from "./compare.js";
|
|
2
3
|
const encoder = new TextEncoder();
|
|
3
4
|
export const MIN_RESPONSE_BUDGET_BYTES = 256;
|
|
5
|
+
export const MIN_FLOW_ANALYSIS_BUDGET_BYTES = 1536;
|
|
4
6
|
const MAX_REPORTED_TOTAL_PATHS = 128;
|
|
5
7
|
function bytes(value) {
|
|
6
8
|
return encoder.encode(JSON.stringify(value)).byteLength;
|
|
@@ -46,9 +48,34 @@ const PROTECTED_CONTRACT_KEYS = new Set([
|
|
|
46
48
|
"target",
|
|
47
49
|
"to",
|
|
48
50
|
]);
|
|
49
|
-
const PROTECTED_CONTRACT_ARRAY_PATHS = new Set(["$/agents/clients"]);
|
|
51
|
+
const PROTECTED_CONTRACT_ARRAY_PATHS = new Set(["$/agents/clients", "$/flowAnalysis/limitations"]);
|
|
52
|
+
// Only the fixed scalar contract is mandatory. In particular, selector previews
|
|
53
|
+
// and arbitrary arrays beneath indexCoverage must still obey normal budgets.
|
|
54
|
+
const COVERAGE_SCALAR_PATH = /\/indexCoverage\/(?:version|mode|revision|digest|origin|prefixCount|fileCount|intentState|repositoryComplete|negativeScope)$/;
|
|
55
|
+
// Scope coverage and impact uncertainty are independent qualifications. Keep
|
|
56
|
+
// the generated impact contract intact before dropping evidence/detail rows.
|
|
57
|
+
const IMPACT_CONTRACT_PATH = /\/impact\/(?:mode|direction|summary)$/;
|
|
58
|
+
function coveragePreviews(value) {
|
|
59
|
+
if (!value || typeof value !== "object")
|
|
60
|
+
return [];
|
|
61
|
+
const found = [];
|
|
62
|
+
for (const [key, child] of Object.entries(value)) {
|
|
63
|
+
if (key === "indexCoverage" && child && typeof child === "object" && !Array.isArray(child)) {
|
|
64
|
+
const coverage = child;
|
|
65
|
+
if ("selectorPreview" in coverage)
|
|
66
|
+
found.push(coverage);
|
|
67
|
+
}
|
|
68
|
+
found.push(...coveragePreviews(child));
|
|
69
|
+
}
|
|
70
|
+
return found;
|
|
71
|
+
}
|
|
50
72
|
function isTruncatableDetail(entry) {
|
|
51
|
-
return (!entry.path.startsWith("$/
|
|
73
|
+
return (!entry.path.startsWith("$/flowAnalysis/limitations/") &&
|
|
74
|
+
!entry.path.startsWith("$/sharedIndex/") &&
|
|
75
|
+
!COVERAGE_SCALAR_PATH.test(entry.path) &&
|
|
76
|
+
!IMPACT_CONTRACT_PATH.test(entry.path) &&
|
|
77
|
+
!/^\$\/availability\/(?:state|graphState|outcome)$/.test(entry.path) &&
|
|
78
|
+
!/\/freshness\/(?:state|currentHead|indexedHead)$/.test(entry.path) &&
|
|
52
79
|
(typeof entry.key !== "string" || !PROTECTED_CONTRACT_KEYS.has(entry.key)));
|
|
53
80
|
}
|
|
54
81
|
function synchronizeNestedCapabilityBudget(root, metadata, operation) {
|
|
@@ -83,6 +110,23 @@ function synchronizeNestedCapabilityBudget(root, metadata, operation) {
|
|
|
83
110
|
}
|
|
84
111
|
function truncatePayloadToBytes(root, byteLimit) {
|
|
85
112
|
let truncated = false;
|
|
113
|
+
for (const coverage of coveragePreviews(root)) {
|
|
114
|
+
if (bytes(root) <= byteLimit)
|
|
115
|
+
break;
|
|
116
|
+
const removedArrays = new Set(collectArrays(coverage.selectorPreview).map((entry) => entry.value));
|
|
117
|
+
const metadata = root.responseBudget;
|
|
118
|
+
if (metadata?.totals) {
|
|
119
|
+
for (const entry of collectArrays(root)) {
|
|
120
|
+
if (removedArrays.has(entry.value) && Object.hasOwn(metadata.totals, entry.path)) {
|
|
121
|
+
delete metadata.totals[entry.path];
|
|
122
|
+
metadata.omittedTotalPaths = (metadata.omittedTotalPaths ?? 0) + 1;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
delete coverage.selectorPreview;
|
|
127
|
+
truncated = true;
|
|
128
|
+
}
|
|
129
|
+
truncated = trimEmptyFreshnessDiagnostics(root, byteLimit) || truncated;
|
|
86
130
|
while (bytes(root) > byteLimit) {
|
|
87
131
|
const candidate = collectStrings(root)
|
|
88
132
|
.filter((entry) => !entry.path.startsWith("$/responseBudget") &&
|
|
@@ -105,6 +149,7 @@ function truncatePayloadToBytes(root, byteLimit) {
|
|
|
105
149
|
}
|
|
106
150
|
truncated = true;
|
|
107
151
|
}
|
|
152
|
+
truncated = trimEmptyFreshnessDiagnostics(root, byteLimit) || truncated;
|
|
108
153
|
while (bytes(root) > byteLimit) {
|
|
109
154
|
const candidate = collectArrays(root)
|
|
110
155
|
.filter((entry) => !entry.path.startsWith("$/responseBudget") &&
|
|
@@ -116,6 +161,63 @@ function truncatePayloadToBytes(root, byteLimit) {
|
|
|
116
161
|
candidate.value.pop();
|
|
117
162
|
truncated = true;
|
|
118
163
|
}
|
|
164
|
+
truncated = trimDiagnosticTotals(root, byteLimit) || truncated;
|
|
165
|
+
return truncated;
|
|
166
|
+
}
|
|
167
|
+
function trimEmptyFreshnessDiagnostics(root, byteLimit) {
|
|
168
|
+
if (bytes(root) <= byteLimit)
|
|
169
|
+
return false;
|
|
170
|
+
let truncated = false;
|
|
171
|
+
const freshness = root.freshness;
|
|
172
|
+
if (freshness && typeof freshness === "object" && !Array.isArray(freshness)) {
|
|
173
|
+
const record = freshness;
|
|
174
|
+
for (const key of ["currentHead", "indexedHead", "commitDistance", "lastSuccessfulRefresh"]) {
|
|
175
|
+
if (record[key] === null) {
|
|
176
|
+
delete record[key];
|
|
177
|
+
truncated = true;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const workingTree = record.workingTree;
|
|
181
|
+
if (workingTree && typeof workingTree === "object" && !Array.isArray(workingTree)) {
|
|
182
|
+
const diagnostics = workingTree;
|
|
183
|
+
for (const key of ["pendingPaths", "indexedFingerprint"]) {
|
|
184
|
+
if (diagnostics[key] === null) {
|
|
185
|
+
delete diagnostics[key];
|
|
186
|
+
truncated = true;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (Object.keys(diagnostics).length === 0) {
|
|
190
|
+
delete record.workingTree;
|
|
191
|
+
truncated = true;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const availability = root.availability;
|
|
196
|
+
if (availability &&
|
|
197
|
+
typeof availability === "object" &&
|
|
198
|
+
Object.keys(availability).length === 0 &&
|
|
199
|
+
!Array.isArray(availability)) {
|
|
200
|
+
delete root.availability;
|
|
201
|
+
truncated = true;
|
|
202
|
+
}
|
|
203
|
+
return truncated;
|
|
204
|
+
}
|
|
205
|
+
function trimDiagnosticTotals(root, byteLimit) {
|
|
206
|
+
let truncated = false;
|
|
207
|
+
// Totals are diagnostic metadata, not source evidence. The path-count cap
|
|
208
|
+
// already permits omitting them with an explicit count; byte pressure uses
|
|
209
|
+
// the same contract rather than discarding mandatory scope/freshness facts.
|
|
210
|
+
const metadata = root.responseBudget;
|
|
211
|
+
if (metadata?.totals) {
|
|
212
|
+
const paths = Object.keys(metadata.totals).sort((a, b) => b.length - a.length || compareBytes(a, b));
|
|
213
|
+
for (const path of paths) {
|
|
214
|
+
if (bytes(root) <= byteLimit)
|
|
215
|
+
break;
|
|
216
|
+
delete metadata.totals[path];
|
|
217
|
+
metadata.omittedTotalPaths = (metadata.omittedTotalPaths ?? 0) + 1;
|
|
218
|
+
truncated = true;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
119
221
|
return truncated;
|
|
120
222
|
}
|
|
121
223
|
/**
|
|
@@ -127,6 +229,9 @@ export function applyResponseBudget(value, operation, request, defaults) {
|
|
|
127
229
|
const byteRequest = positiveInt(request.bytes, defaults.bytes);
|
|
128
230
|
const tokenLimit = positiveInt(request.tokens, defaults.tokens);
|
|
129
231
|
const byteLimit = Math.min(byteRequest, tokenLimit * 4);
|
|
232
|
+
if (operation === "query:flow_analysis" && byteLimit < MIN_FLOW_ANALYSIS_BUDGET_BYTES) {
|
|
233
|
+
throw new RangeError(`query:flow_analysis response budget must allow at least ${MIN_FLOW_ANALYSIS_BUDGET_BYTES} serialized bytes (384 estimated tokens)`);
|
|
234
|
+
}
|
|
130
235
|
if (byteLimit < MIN_RESPONSE_BUDGET_BYTES) {
|
|
131
236
|
throw new RangeError(`response budget must allow at least ${MIN_RESPONSE_BUDGET_BYTES} serialized bytes (64 estimated tokens)`);
|
|
132
237
|
}
|
|
@@ -136,8 +241,28 @@ export function applyResponseBudget(value, operation, request, defaults) {
|
|
|
136
241
|
? payload
|
|
137
242
|
: { result: payload };
|
|
138
243
|
const root = Array.isArray(payload) ? { results: payload } : objectRoot;
|
|
244
|
+
// Producers stamp coverage on both the result and its freshness snapshot.
|
|
245
|
+
// When they are identical, one authoritative root copy carries the same
|
|
246
|
+
// evidence; do not make tight budgets pay twice for a mandatory contract.
|
|
247
|
+
const freshness = root.freshness;
|
|
248
|
+
if (root.indexCoverage &&
|
|
249
|
+
typeof root.indexCoverage === "object" &&
|
|
250
|
+
!Array.isArray(root.indexCoverage) &&
|
|
251
|
+
freshness &&
|
|
252
|
+
typeof freshness === "object" &&
|
|
253
|
+
!Array.isArray(freshness) &&
|
|
254
|
+
isDeepStrictEqual(root.indexCoverage, freshness.indexCoverage))
|
|
255
|
+
delete freshness.indexCoverage;
|
|
139
256
|
const totals = {};
|
|
140
257
|
let truncated = false;
|
|
258
|
+
// A shortened preview must not retain its original truncated:false claim.
|
|
259
|
+
// Drop it as one optional unit instead of mutating its selector arrays.
|
|
260
|
+
for (const coverage of coveragePreviews(root)) {
|
|
261
|
+
if (collectArrays(coverage.selectorPreview).some((entry) => entry.value.length > itemLimit)) {
|
|
262
|
+
delete coverage.selectorPreview;
|
|
263
|
+
truncated = true;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
141
266
|
const initialArrays = collectArrays(root);
|
|
142
267
|
const reportedTotals = [...initialArrays]
|
|
143
268
|
// Byte order: this tie-break decides which paths survive the slice below,
|
|
@@ -199,7 +324,8 @@ export function applyResponseBudget(value, operation, request, defaults) {
|
|
|
199
324
|
metadata.serializedBytes = actual;
|
|
200
325
|
metadata.estimatedTokens = tokens;
|
|
201
326
|
}
|
|
202
|
-
if (bytes(root) > byteLimit)
|
|
327
|
+
if (bytes(root) > byteLimit) {
|
|
203
328
|
throw new RangeError(`response budget ${byteLimit} cannot preserve the ${operation} response contract; narrow the request or increase the budget`);
|
|
329
|
+
}
|
|
204
330
|
return root;
|
|
205
331
|
}
|
package/dist/src/server.js
CHANGED
|
@@ -152,6 +152,10 @@ export function createServer(dispatch = handleKnodinTool) {
|
|
|
152
152
|
}, { cooperativeCancellation: operation === "repair" })),
|
|
153
153
|
};
|
|
154
154
|
const result = execution.result;
|
|
155
|
+
// Bundle receipts and budgets cover compact JSON text. Pretty-printing
|
|
156
|
+
// afterward would exceed the advertised bytes without changing its budget.
|
|
157
|
+
const selfBudgetedBundle = operation === "evidence" &&
|
|
158
|
+
result?.protocol === "knodin-evidence-bundle-v1";
|
|
155
159
|
recordMcpLifecycle(context, "success", {
|
|
156
160
|
elapsedMs: Date.now() - startedAt,
|
|
157
161
|
});
|
|
@@ -163,7 +167,12 @@ export function createServer(dispatch = handleKnodinTool) {
|
|
|
163
167
|
? { predecessorTraceId: execution.predecessorTraceId }
|
|
164
168
|
: {}),
|
|
165
169
|
},
|
|
166
|
-
content: [
|
|
170
|
+
content: [
|
|
171
|
+
{
|
|
172
|
+
type: "text",
|
|
173
|
+
text: selfBudgetedBundle ? JSON.stringify(result) : JSON.stringify(result, null, 2),
|
|
174
|
+
},
|
|
175
|
+
],
|
|
167
176
|
};
|
|
168
177
|
}
|
|
169
178
|
catch (error) {
|
|
@@ -174,7 +183,9 @@ export function createServer(dispatch = handleKnodinTool) {
|
|
|
174
183
|
});
|
|
175
184
|
const recorded = recordMcpDiagnosticFailure(values, error);
|
|
176
185
|
const message = recorded instanceof Error ? recorded.message : String(recorded);
|
|
177
|
-
|
|
186
|
+
// Admission was rejected before dispatch, including for mutating operations.
|
|
187
|
+
const capacityRejected = failure.code === "KNODIN_WORKER_CAPACITY";
|
|
188
|
+
const retrySafe = capacityRejected || RETRY_SAFE_OPERATIONS.has(operation);
|
|
178
189
|
return {
|
|
179
190
|
isError: true,
|
|
180
191
|
_meta: { requestId: context.requestId, traceId: context.traceId },
|
|
@@ -192,9 +203,11 @@ export function createServer(dispatch = handleKnodinTool) {
|
|
|
192
203
|
deadlineMs: context.deadlineMs,
|
|
193
204
|
retrySafe,
|
|
194
205
|
diagnosticLog: ".knodin/diagnostics/events.jsonl (only when opt-in diagnostics are enabled)",
|
|
195
|
-
recovery:
|
|
196
|
-
? "Retry
|
|
197
|
-
:
|
|
206
|
+
recovery: capacityRejected
|
|
207
|
+
? "Retry after an active repository request finishes; this request was not dispatched."
|
|
208
|
+
: retrySafe
|
|
209
|
+
? "Retry once. If the transport closes, restart the MCP client and run `knodin doctor`; include `knodin diagnostics export` when diagnostics are enabled."
|
|
210
|
+
: "Do not retry automatically. Run `knodin doctor`, inspect repository state, and include `knodin diagnostics export` when diagnostics are enabled.",
|
|
198
211
|
}, null, 2),
|
|
199
212
|
},
|
|
200
213
|
],
|
|
@@ -61,6 +61,36 @@ function countingHash(hash, count) {
|
|
|
61
61
|
},
|
|
62
62
|
});
|
|
63
63
|
}
|
|
64
|
+
function createPublisherSnapshot(databasePath, snapshotPath, metadata) {
|
|
65
|
+
const source = new Database(databasePath, { readonly: true });
|
|
66
|
+
try {
|
|
67
|
+
// A consistent copy includes committed WAL state without changing the
|
|
68
|
+
// live cache or requiring the original database to remain idle while
|
|
69
|
+
// compression streams it.
|
|
70
|
+
source.run("VACUUM INTO ?", [snapshotPath]);
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
source.close();
|
|
74
|
+
}
|
|
75
|
+
const snapshot = new Database(snapshotPath);
|
|
76
|
+
try {
|
|
77
|
+
const value = (key) => snapshot.query("SELECT value FROM meta WHERE key = ?").get(key)
|
|
78
|
+
?.value;
|
|
79
|
+
if (value("lastIndexedHead") !== metadata.commit ||
|
|
80
|
+
value("sharedIndexInputFingerprint") !== metadata.inputFingerprint)
|
|
81
|
+
throw new SharedIndexError("integrity", "shared-index publisher graph changed before snapshot");
|
|
82
|
+
// Historical exact-input vectors are local acceleration only. Current
|
|
83
|
+
// symbol_embeddings remain in the shared-index contract.
|
|
84
|
+
snapshot.run("DROP TABLE IF EXISTS embedding_reuse_v1");
|
|
85
|
+
// DROP alone leaves vector bytes on freelist pages in the exported file.
|
|
86
|
+
snapshot.run("VACUUM");
|
|
87
|
+
snapshot.run("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
88
|
+
snapshot.run("PRAGMA journal_mode = DELETE");
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
snapshot.close();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
64
94
|
export async function createSharedPublisherBundle(options) {
|
|
65
95
|
if (!options.config.enabled)
|
|
66
96
|
throw new SharedIndexError("configuration", "shared-index publishing is disabled");
|
|
@@ -86,7 +116,17 @@ export async function createSharedPublisherBundle(options) {
|
|
|
86
116
|
const compressedHash = crypto.createHash("sha256");
|
|
87
117
|
const uncompressed = { bytes: 0 };
|
|
88
118
|
const compressed = { bytes: 0 };
|
|
89
|
-
|
|
119
|
+
// A private, exclusively created directory belongs to this invocation only.
|
|
120
|
+
// A competing publisher must never clean up another process's snapshot.
|
|
121
|
+
const snapshotDirectory = fs.mkdtempSync(path.join(output, ".publisher-snapshot-"));
|
|
122
|
+
const snapshotPath = path.join(snapshotDirectory, "graph.sqlite");
|
|
123
|
+
try {
|
|
124
|
+
createPublisherSnapshot(databasePath, snapshotPath, metadata);
|
|
125
|
+
await pipeline(fs.createReadStream(snapshotPath), countingHash(uncompressedHash, uncompressed), createZstdCompress(), countingHash(compressedHash, compressed), fs.createWriteStream(graphFile, { flags: "wx", mode: 0o600 }));
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
fs.rmSync(snapshotDirectory, { recursive: true, force: true });
|
|
129
|
+
}
|
|
90
130
|
if (uncompressed.bytes <= 0 ||
|
|
91
131
|
uncompressed.bytes > SHARED_INDEX_LIMITS.uncompressedBytes ||
|
|
92
132
|
compressed.bytes <= 0 ||
|