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.
Files changed (45) hide show
  1. package/README.md +16 -1
  2. package/dist/bin/cli.js +173 -14
  3. package/dist/src/agent-events.js +25 -7
  4. package/dist/src/authenticated-cursor.js +81 -0
  5. package/dist/src/class-consumer-contract.js +18 -0
  6. package/dist/src/class-consumer-cursor.js +91 -0
  7. package/dist/src/class-consumer-delivery.js +22 -0
  8. package/dist/src/class-consumer-page.js +148 -0
  9. package/dist/src/cli-model.js +9 -3
  10. package/dist/src/docs-sections.js +1 -0
  11. package/dist/src/engine/apex-class-uses.js +430 -0
  12. package/dist/src/engine/apex-entry-points.js +98 -0
  13. package/dist/src/engine/apex-receiver.js +301 -0
  14. package/dist/src/engine/embedding-reuse.js +57 -0
  15. package/dist/src/engine/embeddings.js +22 -0
  16. package/dist/src/engine/index-coverage.js +215 -0
  17. package/dist/src/engine/index.js +2495 -252
  18. package/dist/src/engine/salesforce-components.js +460 -0
  19. package/dist/src/engine/seal.js +3 -0
  20. package/dist/src/engine/sqlite.js +44 -0
  21. package/dist/src/evidence-bundle.js +283 -0
  22. package/dist/src/evidence-graph.js +163 -0
  23. package/dist/src/failure-diagnosis.js +80 -5
  24. package/dist/src/file-dependency.js +35 -0
  25. package/dist/src/graph-query-health.js +47 -1
  26. package/dist/src/implementation-search.js +69 -0
  27. package/dist/src/index-coverage-read.js +33 -0
  28. package/dist/src/investigation.js +195 -0
  29. package/dist/src/mcp-reliability.js +4 -0
  30. package/dist/src/mcp-worker-supervisor.js +122 -6
  31. package/dist/src/progressive-evidence.js +4 -4
  32. package/dist/src/response-budget.js +129 -3
  33. package/dist/src/server.js +18 -5
  34. package/dist/src/shared-index/publisher.js +41 -1
  35. package/dist/src/tools/knodin-tools.js +224 -41
  36. package/docs/CLI.md +92 -0
  37. package/docs/MCP.md +67 -0
  38. package/docs/PROGRESSIVE-EVIDENCE.md +62 -0
  39. package/docs/SALESFORCE-BINDINGS.md +121 -0
  40. package/docs/SALESFORCE-DEAD-CODE.md +45 -0
  41. package/docs/SCOPED-INDEXING.md +76 -0
  42. package/docs/apex-receiver-resolution.md +41 -0
  43. package/docs/releases/0.13.0.md +62 -0
  44. package/docs/structural-only-indexing.md +20 -0
  45. 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("$/sharedIndex/") &&
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
  }
@@ -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: [{ type: "text", text: JSON.stringify(result, null, 2) }],
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
- const retrySafe = RETRY_SAFE_OPERATIONS.has(operation);
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: retrySafe
196
- ? "Retry once. If the transport closes, restart the MCP client and run `knodin doctor`; include `knodin diagnostics export` when diagnostics are enabled."
197
- : "Do not retry automatically. Run `knodin doctor`, inspect repository state, and include `knodin diagnostics export` when diagnostics are enabled.",
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
- await pipeline(fs.createReadStream(databasePath), countingHash(uncompressedHash, uncompressed), createZstdCompress(), countingHash(compressedHash, compressed), fs.createWriteStream(graphFile, { flags: "wx", mode: 0o600 }));
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 ||