knodin 0.5.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 (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +590 -0
  3. package/dist/bin/cli.js +1704 -0
  4. package/dist/src/agent-integration.js +250 -0
  5. package/dist/src/artifact-refresh.js +81 -0
  6. package/dist/src/cli-args.js +267 -0
  7. package/dist/src/cli-model.js +324 -0
  8. package/dist/src/compact-structural.js +96 -0
  9. package/dist/src/competitive-constraints.js +20 -0
  10. package/dist/src/competitive-manifest.js +330 -0
  11. package/dist/src/competitive-measurement.js +183 -0
  12. package/dist/src/competitive-runner.js +453 -0
  13. package/dist/src/competitive-sandbox.js +108 -0
  14. package/dist/src/context-export.js +422 -0
  15. package/dist/src/context.js +102 -0
  16. package/dist/src/docs-sections.js +141 -0
  17. package/dist/src/doctor.js +380 -0
  18. package/dist/src/engine/ann-hnsw.js +271 -0
  19. package/dist/src/engine/embeddings.js +193 -0
  20. package/dist/src/engine/file-walker.js +43 -0
  21. package/dist/src/engine/index.js +13030 -0
  22. package/dist/src/engine/perf.js +115 -0
  23. package/dist/src/engine/prune.js +112 -0
  24. package/dist/src/engine/source-policy.js +69 -0
  25. package/dist/src/engine/sqlite.js +71 -0
  26. package/dist/src/engine/symbol-delete.js +58 -0
  27. package/dist/src/failure-diagnosis.js +590 -0
  28. package/dist/src/fleet.js +7 -0
  29. package/dist/src/git-executable.js +31 -0
  30. package/dist/src/graph-query-health.js +115 -0
  31. package/dist/src/index-activity.js +125 -0
  32. package/dist/src/init-progress-worker.js +107 -0
  33. package/dist/src/init-progress.js +155 -0
  34. package/dist/src/init.js +985 -0
  35. package/dist/src/lifecycle-health.js +213 -0
  36. package/dist/src/lsp-readonly.js +217 -0
  37. package/dist/src/output-compression.js +629 -0
  38. package/dist/src/output-telemetry.js +359 -0
  39. package/dist/src/pr-triage.js +638 -0
  40. package/dist/src/relationship-adapters.js +370 -0
  41. package/dist/src/release-attestation.js +533 -0
  42. package/dist/src/repair-progress-worker.js +121 -0
  43. package/dist/src/repair-progress.js +262 -0
  44. package/dist/src/repository-init-process.js +173 -0
  45. package/dist/src/repository-management.js +1089 -0
  46. package/dist/src/response-budget.js +184 -0
  47. package/dist/src/server.js +53 -0
  48. package/dist/src/system-config.js +615 -0
  49. package/dist/src/terminal-help.js +83 -0
  50. package/dist/src/tools/knodin-tools.js +1438 -0
  51. package/dist/src/tools/reckon-tools.js +5 -0
  52. package/dist/src/update-policy.js +944 -0
  53. package/dist/src/update-trust.js +503 -0
  54. package/dist/src/version.js +13 -0
  55. package/dist/src/visualization.js +162 -0
  56. package/dist/src/wait-for-fresh.js +98 -0
  57. package/dist/src/worktree-lifecycle.js +231 -0
  58. package/docs/CLI.md +39 -0
  59. package/docs/COMMAND-OUTPUT-COMPRESSION.md +194 -0
  60. package/docs/DEAD-CODE-AND-IMPACT.md +27 -0
  61. package/docs/DOCTOR-AND-UPDATES.md +84 -0
  62. package/docs/INDEXING-POLICY-AND-PROVENANCE.md +37 -0
  63. package/docs/INSTALLATION.md +208 -0
  64. package/docs/MCP.md +100 -0
  65. package/docs/PT-ACCESS-RECOMMENDATION.md +91 -0
  66. package/docs/RELEASE-0.3-EVIDENCE.md +73 -0
  67. package/docs/REPOSITORIES-AND-WORKTREES.md +81 -0
  68. package/docs/SIGNED-UPDATES.md +146 -0
  69. package/docs/SYSTEMS-AND-RELATIONSHIPS.md +45 -0
  70. package/docs/TELEMETRY.md +42 -0
  71. package/docs/releases/0.3.0.md +46 -0
  72. package/docs/releases/0.4.0.md +68 -0
  73. package/docs/releases/0.4.1.md +28 -0
  74. package/docs/releases/0.4.2.md +27 -0
  75. package/docs/releases/0.4.3.md +23 -0
  76. package/docs/releases/0.5.0.md +29 -0
  77. package/package.json +110 -0
  78. package/schemas/release-attestation-v1.schema.json +210 -0
  79. package/tree-sitter-prisma.wasm +0 -0
  80. package/tree-sitter-sql.wasm +0 -0
  81. package/tree-sitter-xml.wasm +0 -0
@@ -0,0 +1,422 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { RE2 } from "re2-wasm";
5
+ import { measurePerfPhaseSync } from "./engine/perf.js";
6
+ const DEFAULT_EXCLUDES = [".git/**", ".reckon/**", "node_modules/**", "dist/**", "build/**"];
7
+ const GLOB_CACHE_LIMIT = 256;
8
+ const globCache = new Map();
9
+ function normalizeRelative(value) {
10
+ return value.replaceAll("\\", "/").replace(/^\.\//, "");
11
+ }
12
+ function globRegex(glob) {
13
+ const normalized = normalizeRelative(glob);
14
+ const cached = globCache.get(normalized);
15
+ if (cached)
16
+ return cached;
17
+ let source = "^";
18
+ for (let i = 0; i < normalized.length; i++) {
19
+ const char = normalized[i];
20
+ if (char === "*") {
21
+ if (normalized[i + 1] === "*") {
22
+ i++;
23
+ if (normalized[i + 1] === "/") {
24
+ i++;
25
+ source += "(?:.*/)?";
26
+ }
27
+ else
28
+ source += ".*";
29
+ }
30
+ else
31
+ source += "[^/]*";
32
+ }
33
+ else if (char === "?")
34
+ source += "[^/]";
35
+ else
36
+ source += char.replace(/[\\^$+?.()|{}[\]]/g, "\\$&");
37
+ }
38
+ const compiled = new RegExp(`${source}$`);
39
+ if (globCache.size >= GLOB_CACHE_LIMIT) {
40
+ const oldest = globCache.keys().next().value;
41
+ if (oldest !== undefined)
42
+ globCache.delete(oldest);
43
+ }
44
+ globCache.set(normalized, compiled);
45
+ return compiled;
46
+ }
47
+ function matches(file, patterns) {
48
+ return patterns.some((pattern) => globRegex(normalizeRelative(pattern)).test(file));
49
+ }
50
+ function compilePatterns(patterns) {
51
+ return patterns.map((pattern) => globRegex(normalizeRelative(pattern)));
52
+ }
53
+ function matchesCompiled(file, patterns) {
54
+ return patterns.some((pattern) => pattern.test(file));
55
+ }
56
+ function walk(root, current = root, result = []) {
57
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
58
+ const absolute = path.join(current, entry.name);
59
+ const relative = normalizeRelative(path.relative(root, absolute));
60
+ if (matches(relative, DEFAULT_EXCLUDES) || matches(`${relative}/`, DEFAULT_EXCLUDES))
61
+ continue;
62
+ if (entry.isDirectory())
63
+ walk(root, absolute, result);
64
+ else if (entry.isFile())
65
+ result.push(relative);
66
+ }
67
+ return result;
68
+ }
69
+ function policyFor(file, policies) {
70
+ for (const { pattern, policy } of policies)
71
+ if (pattern.test(file))
72
+ return policy;
73
+ return "full";
74
+ }
75
+ function structureOnly(source, lineNumbers = false) {
76
+ return source
77
+ .split(/\r?\n/)
78
+ .map((line, index) => ({ line, index }))
79
+ .filter(({ line }) => /^\s*(export\s+)?(async\s+)?(class|interface|type|enum|function|const|let|var|def|module|namespace)\b/.test(line) || /^\s*(import|export)\b/.test(line))
80
+ .map(({ line, index }) => (lineNumbers ? `${index + 1}: ${line}` : line))
81
+ .join("\n");
82
+ }
83
+ function summarize(source, lineNumbers = false) {
84
+ const lines = source.split(/\r?\n/);
85
+ const structure = structureOnly(source, lineNumbers);
86
+ return [`[summary: ${lines.length} lines, ${Buffer.byteLength(source)} bytes]`, structure]
87
+ .filter(Boolean)
88
+ .join("\n");
89
+ }
90
+ function numbered(source) {
91
+ return source
92
+ .split(/\r?\n/)
93
+ .map((line, index) => `${index + 1}: ${line}`)
94
+ .join("\n");
95
+ }
96
+ function xmlEscape(value) {
97
+ return value
98
+ .replaceAll("&", "&amp;")
99
+ .replaceAll("<", "&lt;")
100
+ .replaceAll(">", "&gt;")
101
+ .replaceAll('"', "&quot;");
102
+ }
103
+ function serialize(format, files, tree, git) {
104
+ if (format === "json")
105
+ return JSON.stringify({ tree, files, git }, null, 2);
106
+ if (format === "xml") {
107
+ const treeXml = tree
108
+ ? `<tree>${tree.map((item) => `<path>${xmlEscape(item)}</path>`).join("")}</tree>`
109
+ : "";
110
+ const filesXml = files
111
+ .map((file) => `<file path="${xmlEscape(file.path)}" policy="${file.policy}"><![CDATA[${file.content.replaceAll("]]>", "]]]]><![CDATA[>")}]]></file>`)
112
+ .join("");
113
+ const gitXml = git
114
+ ? `<git>${git.diff === undefined ? "" : `<diff><![CDATA[${git.diff.replaceAll("]]>", "]]]]><![CDATA[>")}]]></diff>`}${git.log === undefined ? "" : `<log><![CDATA[${git.log.replaceAll("]]>", "]]]]><![CDATA[>")}]]></log>`}</git>`
115
+ : "";
116
+ return `<context>${treeXml}<files>${filesXml}</files>${gitXml}</context>`;
117
+ }
118
+ const fenced = (content, language = "") => {
119
+ const longest = Math.max(0, ...[...content.matchAll(/`+/g)].map((match) => match[0].length));
120
+ const fence = "`".repeat(Math.max(3, longest + 1));
121
+ return [`${fence}${language}`, content, fence];
122
+ };
123
+ const chunks = ["# knodin context export"];
124
+ if (tree)
125
+ chunks.push("## Tree", ...fenced(tree.join("\n"), "text"));
126
+ for (const file of files)
127
+ chunks.push(`## ${file.path} (${file.policy})`, ...fenced(file.content));
128
+ if (git?.diff !== undefined)
129
+ chunks.push("## Git diff", ...fenced(git.diff, "diff"));
130
+ if (git?.log !== undefined)
131
+ chunks.push("## Git log", ...fenced(git.log, "text"));
132
+ return chunks.join("\n");
133
+ }
134
+ /**
135
+ * Exact serialized-byte contribution for one additional file. Serializing a
136
+ * single candidate (plus a constant sentinel for non-first entries) keeps pack
137
+ * selection linear in total source bytes instead of repeatedly serializing the
138
+ * whole growing artifact.
139
+ */
140
+ function serializedFileByteDelta(format, file, hasFiles) {
141
+ if (!hasFiles)
142
+ return Buffer.byteLength(serialize(format, [file])) - Buffer.byteLength(serialize(format, []));
143
+ const sentinel = { path: "", policy: "full", content: "" };
144
+ return (Buffer.byteLength(serialize(format, [sentinel, file])) -
145
+ Buffer.byteLength(serialize(format, [sentinel])));
146
+ }
147
+ function gitSections(repo, request) {
148
+ if (!request)
149
+ return undefined;
150
+ const run = (args) => execFileSync("git", args, { cwd: repo, encoding: "utf8", maxBuffer: 4 * 1024 * 1024 });
151
+ const result = {};
152
+ if (request.diffScope) {
153
+ const revision = (value) => {
154
+ const hasControl = [...value].some((character) => {
155
+ const code = character.charCodeAt(0);
156
+ return code < 32 || code === 127;
157
+ });
158
+ if (!value || value.startsWith("-") || hasControl)
159
+ throw new Error(`knodin pack: invalid git revision: ${value}`);
160
+ return value;
161
+ };
162
+ const args = request.diffScope === "unstaged"
163
+ ? ["diff", "--"]
164
+ : request.diffScope === "staged"
165
+ ? ["diff", "--cached", "--"]
166
+ : request.diffScope === "compare"
167
+ ? ["diff", revision(request.from ?? "HEAD~1"), revision(request.to ?? "HEAD"), "--"]
168
+ : ["diff", revision(request.from ?? "HEAD"), "--"];
169
+ result.diff = run(args);
170
+ }
171
+ if (request.log !== undefined)
172
+ result.log = run([
173
+ "log",
174
+ `-${Math.max(0, Math.min(100, Math.floor(request.log)))}`,
175
+ "--oneline",
176
+ ]);
177
+ return result;
178
+ }
179
+ function safeOutput(repo, output) {
180
+ const absolute = path.resolve(repo, output);
181
+ if (absolute !== repo && !absolute.startsWith(`${repo}${path.sep}`))
182
+ throw new Error("knodin pack: outputPath must stay inside the repository");
183
+ if (fs.existsSync(absolute)) {
184
+ const realTarget = fs.realpathSync(absolute);
185
+ if (realTarget !== repo && !realTarget.startsWith(`${repo}${path.sep}`))
186
+ throw new Error("knodin pack: outputPath may not follow a symlink outside the repository");
187
+ }
188
+ let parent = path.dirname(absolute);
189
+ while (!fs.existsSync(parent) && parent !== repo)
190
+ parent = path.dirname(parent);
191
+ const realParent = fs.realpathSync(parent);
192
+ if (realParent !== repo && !realParent.startsWith(`${repo}${path.sep}`))
193
+ throw new Error("knodin pack: outputPath may not traverse a symlink outside the repository");
194
+ return absolute;
195
+ }
196
+ export function exportContext(repoPath, request = {}) {
197
+ const repo = fs.realpathSync(repoPath);
198
+ const format = request.format ?? "markdown";
199
+ if (!["markdown", "json", "xml"].includes(format))
200
+ throw new Error("knodin pack: format must be markdown, json, or xml");
201
+ if (request.byteBudget !== undefined &&
202
+ (!Number.isFinite(request.byteBudget) || request.byteBudget < 256))
203
+ throw new Error("knodin pack: byteBudget must be at least 256");
204
+ if (request.tokenBudget !== undefined &&
205
+ (!Number.isFinite(request.tokenBudget) || request.tokenBudget < 64))
206
+ throw new Error("knodin pack: tokenBudget must be at least 64");
207
+ if (request.git?.diffScope !== undefined &&
208
+ !["unstaged", "staged", "all", "compare"].includes(request.git.diffScope))
209
+ throw new Error("knodin pack: invalid git diff scope");
210
+ if (request.git?.log !== undefined &&
211
+ (!Number.isInteger(request.git.log) || request.git.log < 0 || request.git.log > 100))
212
+ throw new Error("knodin pack: git log must be an integer from 0 to 100");
213
+ if (Object.values(request.policies ?? {}).some((policy) => !["full", "summary", "structure-only"].includes(policy)))
214
+ throw new Error("knodin pack: invalid file policy");
215
+ const byteLimit = Math.floor(request.byteBudget ?? 65_536);
216
+ const tokenLimit = Math.floor(request.tokenBudget ?? 16_384);
217
+ const hardLimit = Math.min(byteLimit, tokenLimit * 4);
218
+ const include = request.include?.length ? request.include : ["**"];
219
+ const exclude = [...DEFAULT_EXCLUDES, ...(request.exclude ?? [])];
220
+ const includePatterns = compilePatterns(include);
221
+ const excludePatterns = compilePatterns(exclude);
222
+ const policyRules = request.policies ?? {};
223
+ const policies = Object.keys(policyRules)
224
+ .sort()
225
+ .map((pattern) => ({
226
+ pattern: globRegex(normalizeRelative(pattern)),
227
+ policy: policyRules[pattern],
228
+ }));
229
+ const omitted = new Set([...(request.alreadyPresent ?? []), ...(request.chatFiles ?? [])].map(normalizeRelative));
230
+ if (request.outputPath)
231
+ omitted.add(normalizeRelative(request.outputPath));
232
+ const candidates = measurePerfPhaseSync("pack_walk", () => walk(repo))
233
+ .filter((file) => matchesCompiled(file, includePatterns) &&
234
+ !matchesCompiled(file, excludePatterns) &&
235
+ !omitted.has(file))
236
+ .sort();
237
+ let tree = request.includeTree ? [...candidates] : undefined;
238
+ let git = gitSections(repo, request.git);
239
+ const files = [];
240
+ const measuredSerialize = () => measurePerfPhaseSync("pack_serialize", () => serialize(format, files, tree, git));
241
+ let artifact = measuredSerialize();
242
+ if (Buffer.byteLength(artifact) > hardLimit) {
243
+ git = undefined;
244
+ artifact = measuredSerialize();
245
+ }
246
+ if (Buffer.byteLength(artifact) > hardLimit) {
247
+ tree = undefined;
248
+ artifact = measuredSerialize();
249
+ }
250
+ let serializedSize = Buffer.byteLength(artifact);
251
+ for (const file of candidates) {
252
+ const policy = policyFor(file, policies);
253
+ let content = fs.readFileSync(path.join(repo, file), "utf8");
254
+ if (policy === "summary")
255
+ content = summarize(content, request.lineNumbers);
256
+ else if (policy === "structure-only")
257
+ content = structureOnly(content, request.lineNumbers);
258
+ else if (request.lineNumbers)
259
+ content = numbered(content);
260
+ const packed = { path: file, policy, content };
261
+ const delta = measurePerfPhaseSync("pack_serialize", () => serializedFileByteDelta(format, packed, files.length > 0));
262
+ if (serializedSize + delta > hardLimit)
263
+ continue;
264
+ files.push(packed);
265
+ serializedSize += delta;
266
+ }
267
+ artifact = measuredSerialize();
268
+ const serializedBytes = Buffer.byteLength(artifact);
269
+ const outputAbsolute = request.outputPath ? safeOutput(repo, request.outputPath) : undefined;
270
+ const result = {
271
+ format,
272
+ artifact,
273
+ files: files.map(({ path: filePath, policy }) => ({ path: filePath, policy })),
274
+ telemetry: {
275
+ byteLimit,
276
+ tokenLimit,
277
+ serializedBytes,
278
+ responseBytes: 0,
279
+ estimatedTokens: Math.ceil(serializedBytes / 4),
280
+ tokenizer: "estimate:deterministic-utf8-bytes-divided-by-4",
281
+ includedFiles: files.length,
282
+ candidateFiles: candidates.length,
283
+ truncated: files.length < candidates.length,
284
+ },
285
+ ...(outputAbsolute
286
+ ? { outputPath: normalizeRelative(path.relative(repo, outputAbsolute)) }
287
+ : {}),
288
+ };
289
+ const updateResponseBytes = () => {
290
+ for (let index = 0; index < 4; index++) {
291
+ const actual = Buffer.byteLength(JSON.stringify(result));
292
+ if (actual === result.telemetry.responseBytes)
293
+ break;
294
+ result.telemetry.responseBytes = actual;
295
+ }
296
+ };
297
+ updateResponseBytes();
298
+ while (result.telemetry.responseBytes > hardLimit && files.length > 0) {
299
+ files.pop();
300
+ result.files.pop();
301
+ result.artifact = measuredSerialize();
302
+ result.telemetry.serializedBytes = Buffer.byteLength(result.artifact);
303
+ result.telemetry.estimatedTokens = Math.ceil(result.telemetry.serializedBytes / 4);
304
+ result.telemetry.includedFiles = files.length;
305
+ result.telemetry.truncated = true;
306
+ updateResponseBytes();
307
+ }
308
+ if (result.telemetry.responseBytes > hardLimit)
309
+ throw new Error("knodin pack: budget is too small for the requested format and telemetry");
310
+ if (outputAbsolute) {
311
+ fs.mkdirSync(path.dirname(outputAbsolute), { recursive: true });
312
+ fs.writeFileSync(outputAbsolute, result.artifact);
313
+ }
314
+ return result;
315
+ }
316
+ function safeArtifact(repoPath, artifactPath) {
317
+ const repo = fs.realpathSync(repoPath);
318
+ const absolute = path.resolve(repo, artifactPath);
319
+ if (absolute !== repo && !absolute.startsWith(`${repo}${path.sep}`))
320
+ throw new Error("knodin pack: artifactPath must stay inside the repository");
321
+ const real = fs.realpathSync(absolute);
322
+ if (real !== repo && !real.startsWith(`${repo}${path.sep}`))
323
+ throw new Error("knodin pack: artifactPath may not follow a symlink outside the repository");
324
+ const stat = fs.statSync(real);
325
+ if (!stat.isFile() || stat.size > 16 * 1024 * 1024)
326
+ throw new Error("knodin pack: artifactPath must be a packed file no larger than 16 MiB");
327
+ return real;
328
+ }
329
+ const ARTIFACT_CACHE_ENTRY_LIMIT = 2;
330
+ const ARTIFACT_CACHE_FILE_LIMIT = 4 * 1024 * 1024;
331
+ const artifactLineCache = new Map();
332
+ function artifactLines(repoPath, artifactPath) {
333
+ const real = safeArtifact(repoPath, artifactPath);
334
+ const stat = fs.statSync(real);
335
+ const cached = artifactLineCache.get(real);
336
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
337
+ artifactLineCache.delete(real);
338
+ artifactLineCache.set(real, cached);
339
+ return cached.lines;
340
+ }
341
+ if (cached)
342
+ artifactLineCache.delete(real);
343
+ const lines = fs.readFileSync(real, "utf8").split(/\r?\n/);
344
+ if (stat.size <= ARTIFACT_CACHE_FILE_LIMIT) {
345
+ while (artifactLineCache.size >= ARTIFACT_CACHE_ENTRY_LIMIT) {
346
+ const oldest = artifactLineCache.keys().next().value;
347
+ if (oldest === undefined)
348
+ break;
349
+ artifactLineCache.delete(oldest);
350
+ }
351
+ artifactLineCache.set(real, { mtimeMs: stat.mtimeMs, size: stat.size, lines });
352
+ }
353
+ return lines;
354
+ }
355
+ function truncateUtf8(value, byteLimit) {
356
+ if (Buffer.byteLength(value) <= byteLimit)
357
+ return value;
358
+ let low = 0;
359
+ let high = value.length;
360
+ while (low < high) {
361
+ const midpoint = Math.ceil((low + high) / 2);
362
+ if (Buffer.byteLength(value.slice(0, midpoint)) <= byteLimit)
363
+ low = midpoint;
364
+ else
365
+ high = midpoint - 1;
366
+ }
367
+ return value.slice(0, low);
368
+ }
369
+ export function readPackedArtifact(repoPath, artifactPath, startLine = 1, endLine = startLine + 199, byteBudget = 16_384) {
370
+ if (!Number.isFinite(byteBudget) || !Number.isInteger(byteBudget) || byteBudget < 256)
371
+ throw new Error("knodin pack read: byteBudget must be an integer >= 256");
372
+ if (!Number.isInteger(startLine) ||
373
+ !Number.isInteger(endLine) ||
374
+ startLine < 1 ||
375
+ endLine < startLine ||
376
+ endLine - startLine > 999)
377
+ throw new Error("knodin pack read: invalid or unbounded line range");
378
+ const lines = measurePerfPhaseSync("artifact_read", () => artifactLines(repoPath, artifactPath).slice(startLine - 1, endLine));
379
+ let content = lines.join("\n");
380
+ const limit = Math.max(256, Math.min(65_536, Math.floor(byteBudget)));
381
+ content = truncateUtf8(content, limit);
382
+ return {
383
+ artifactPath: normalizeRelative(artifactPath),
384
+ startLine,
385
+ endLine: startLine + content.split(/\r?\n/).length - 1,
386
+ content,
387
+ truncated: lines.join("\n") !== content,
388
+ };
389
+ }
390
+ export function grepPackedArtifact(repoPath, artifactPath, pattern, flags = "", limit = 100) {
391
+ if (!Number.isFinite(limit) || !Number.isInteger(limit) || limit < 1 || limit > 1000)
392
+ throw new Error("knodin pack grep: limit must be an integer from 1 to 1000");
393
+ if (pattern.length === 0 || pattern.length > 1000 || !/^[gimsuy]*$/.test(flags))
394
+ throw new Error("knodin pack grep: invalid regex or flags");
395
+ let regex;
396
+ try {
397
+ const safeFlags = flags.replace("g", "").includes("u")
398
+ ? flags.replace("g", "")
399
+ : `${flags.replace("g", "")}u`;
400
+ regex = new RE2(pattern, safeFlags);
401
+ }
402
+ catch {
403
+ throw new Error("knodin pack grep: invalid or unsupported linear-time regex");
404
+ }
405
+ const matches = [];
406
+ measurePerfPhaseSync("artifact_grep", () => {
407
+ for (const [index, text] of artifactLines(repoPath, artifactPath).entries()) {
408
+ regex.lastIndex = 0;
409
+ if (regex.test(text))
410
+ matches.push({ line: index + 1, text });
411
+ if (matches.length >= Math.max(1, Math.min(1000, Math.floor(limit))))
412
+ break;
413
+ }
414
+ });
415
+ return {
416
+ artifactPath: normalizeRelative(artifactPath),
417
+ pattern,
418
+ flags,
419
+ matches,
420
+ truncated: matches.length >= limit,
421
+ };
422
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Logic for the `knodin context` operation — the "call this FIRST" ultra-
3
+ * compact orientation. Lives in its own neutral module, mirroring
4
+ * `pr-triage.ts`'s role for the `prs` operation: `bin/cli.ts` and
5
+ * `src/tools/knodin-tools.ts` both import from here independently, rather
6
+ * than the CLI reaching into the MCP-gateway module the way it previously did
7
+ * (code review finding — R23-R28 reuse/altitude pass).
8
+ */
9
+ import { measurePerfPhase } from "./engine/perf.js";
10
+ /** Keyword heuristic mapping a free-text `task` to a suggested next knodin
11
+ * operation for the `context` op. This is explicitly a hint, not a claim of
12
+ * understanding intent — a wrong guess must never stop the caller from reaching
13
+ * for any operation directly (R26). Order matters: review-shaped tasks win over
14
+ * map-shaped ones, and a bare single-symbol token routes to `explain`. */
15
+ export function suggestNextOperation(task) {
16
+ const t = task.toLowerCase();
17
+ if (/\b(bug|review|diff|pr|pull request|regression|risk|approve|merge)\b/.test(t)) {
18
+ return "review";
19
+ }
20
+ if (/\b(batch outline|file outline|outline files?|project overview|directory overview)\b/.test(t)) {
21
+ return "query";
22
+ }
23
+ if (/\b(how does|how do|architecture|architectural|structure|structured|design|subsystem|boundaries|overview)\b/.test(t)) {
24
+ return "map";
25
+ }
26
+ // A bare identifier-shaped token (a single symbol name) → explain it directly.
27
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(task.trim())) {
28
+ return "explain";
29
+ }
30
+ // Otherwise no exact symbol is known → fuzzy search to find a starting point.
31
+ return "search";
32
+ }
33
+ function suggestedStructuralPattern(task) {
34
+ const normalized = task.toLowerCase();
35
+ if (/\bbatch outline\b/.test(normalized))
36
+ return "batch_outline";
37
+ if (/\b(project|directory) overview\b/.test(normalized))
38
+ return "project_overview";
39
+ if (/\bfile outline\b|\boutline (?:the )?file\b/.test(normalized))
40
+ return "file_summary";
41
+ return undefined;
42
+ }
43
+ /**
44
+ * R26 `context` operation — the "call this FIRST" ultra-compact orientation.
45
+ * Composes EXISTING public engine methods only (`map`, `query stats`,
46
+ * `query flows`, `review`) so it never touches `src/engine/index.ts` and stays
47
+ * parallel-safe against the query-dispatch work landing there. Takes an
48
+ * `engine: KnodinEngine` parameter rather than constructing its own, so the
49
+ * `knodin-tools.ts` module-level `createEngine()` singleton is never
50
+ * duplicated or side-effected by this module's existence.
51
+ */
52
+ export async function buildKnodinContext(eng, task, repo, base, changedFiles) {
53
+ return measurePerfPhase("context_composition", async () => {
54
+ // Establish one full graph snapshot first: stats requires that same standard
55
+ // map, so asking for a separate minimal map would run community analytics
56
+ // twice. Once initialized, the remaining read-only facets can safely share
57
+ // the generation and freshness lease.
58
+ const CONTEXT_TOP = 5;
59
+ const mapResult = await eng.map(repo, "standard");
60
+ const shouldReview = changedFiles === undefined || changedFiles.length > 0;
61
+ const [statsResult, flowsResult, review] = await Promise.all([
62
+ eng.query("stats", "", repo),
63
+ eng.query("flows", "", repo, undefined, CONTEXT_TOP),
64
+ shouldReview ? eng.review(base ?? "HEAD~1", repo, "minimal") : Promise.resolve(undefined),
65
+ ]);
66
+ // Risk score: eng.review() always re-derives the diff from `git diff <base>`;
67
+ // it has no seam for an explicit file list, so `changedFiles`, when supplied,
68
+ // is only a caller hint — an explicit empty array means "no changes, skip the
69
+ // diff". When there IS a diff but it touches no indexed symbols, review
70
+ // returns changedSymbolCount 0 and a meaningless riskScore 0; omit the field
71
+ // in that case rather than report a misleading 0 (R26 acceptance criterion).
72
+ let riskScore;
73
+ if (review && review.changedSymbolCount > 0)
74
+ riskScore = review.riskScore;
75
+ // Truncate aggressively — this is a ~100-300 token orientation, not a dump of
76
+ // map()'s full payload. Sort defensively so "top" holds even if an engine
77
+ // method's ordering changes; flows already arrive most-critical first (capped
78
+ // at CONTEXT_TOP via the query limit above).
79
+ return {
80
+ repoPath: repo,
81
+ task,
82
+ stats: statsResult.totals,
83
+ communities: [...mapResult.communities]
84
+ .sort((a, b) => b.size - a.size)
85
+ .slice(0, CONTEXT_TOP)
86
+ .map((c) => ({ name: c.name, size: c.size })),
87
+ hubs: [...mapResult.hubs]
88
+ .sort((a, b) => b.degree - a.degree)
89
+ .slice(0, CONTEXT_TOP)
90
+ .map((h) => ({ symbol: h.symbol, degree: h.degree })),
91
+ flows: flowsResult.results
92
+ .slice(0, CONTEXT_TOP)
93
+ .map((f) => ({ symbol: f.symbol, criticality: f.criticality })),
94
+ ...(riskScore !== undefined ? { riskScore } : {}),
95
+ suggestedOperation: suggestNextOperation(task),
96
+ ...(suggestedStructuralPattern(task)
97
+ ? { suggestedQueryPattern: suggestedStructuralPattern(task) }
98
+ : {}),
99
+ suggestionNote: "Heuristic from keywords in `task`; a hint only — any operation can be called directly regardless.",
100
+ };
101
+ });
102
+ }
@@ -0,0 +1,141 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ export const DOCS_SECTIONS = {
5
+ quickstart: `# Quickstart Guide
6
+
7
+ knodin — source-evidenced local code intelligence with known bounds. It builds a semantic and syntactic knowledge graph of your repository and exposes five primary operations through its MCP tool gateway or CLI:
8
+
9
+ 1. **context**: Orient on a repository. Returns high-level statistics, key subsystems/hubs/flows, a heuristic next-operation recommendation, and a diff-based risk score if changes are detected.
10
+ 2. **explain**: Understand a specific symbol (function, class, or file). Returns edit-ready source code, inbound/outbound call paths, and blast radius (transitive callers).
11
+ 3. **review**: Analyze local modifications. Displays risk-scored changes, affected execution flows, and missing test coverage before you commit or create a PR.
12
+ 4. **map**: Visualize subsystem boundaries. Partitions the code into cohesive modular communities using Louvain community detection.
13
+ 5. **search**: Fuzzy semantic and keyword lookup to find symbols when you do not know their exact name.
14
+
15
+ ### Command Usage (CLI)
16
+ \`\`\`bash
17
+ knodin context "explore auth"
18
+ knodin explain login
19
+ knodin map
20
+ \`\`\``,
21
+ "query-patterns": `# Structured Query Patterns
22
+
23
+ The \`query\` operation lets you ask precise, structured questions about your code using a variety of built-in graph patterns:
24
+
25
+ - **callers_of <target>**: Who invokes this symbol (transitive/direct).
26
+ - **callees_of <target>**: What other symbols does this symbol invoke.
27
+ - **tests_for <target>**: Find test files that cover this symbol.
28
+ - **file_summary <target>**: Lists all symbols defined in a file.
29
+ - **shortest_path <from> <to>**: Find the call-graph chain connecting two symbols.
30
+ - **impact <symbol>**: Directional, depth-bounded symbol reach with stable selectors, relation/confidence/test filters, edge evidence, and heuristic summaries. Use \`impactMode: "file"\` explicitly for a distinctly labeled changed-file blast radius.
31
+ - **dead_code**: Surface exported or unexported symbols with zero references.
32
+ - **large_functions / large_files**: Spot complex "god" functions and files exceeding size thresholds.
33
+ - **traverse <symbol>**: Perform a bounded BFS neighborhood walk. Use \`direction\` (\`upstream|downstream|both\`), \`relationKinds\`, and optional \`includeDataFlow\`; every discovered hop carries its exact kind, direction, confidence, provenance, source line, and files. Call arguments are labeled heuristic source evidence, never runtime proof.
34
+ - **knowledge_gaps**: Report repo-health weaknesses like thin communities (<3 symbols) and untested hubs/bridges.
35
+ - **surprising_connections**: Score and rank coupling that crosses community, language, or test boundaries.
36
+ - **suggested_questions**: prioritizes human-readable review prompts based on untested hotspots and high-surprise edges.
37
+ - **architecture_overview**: Formats community cohesion and coupling plus independently selectable \`architectureFacets\`: packages, layers, boundaries, hotspots, entryPoints, and languages. \`path\` scopes every facet consistently.`,
38
+ federation: `# Cross-Repository Composition
39
+
40
+ Federation is knodin's internal query-composition mechanism, not the primary
41
+ user-facing model. Use \`knodin repos\` for operational checkout management and
42
+ \`knodin system\` with commit-ready \`reckon.yaml\` stable identities for
43
+ cooperating components. Sibling directory placement and shared search matches
44
+ never establish system membership.
45
+
46
+ Legacy \`.reckon/federation.json\` remains a compatibility input and is not
47
+ silently discarded. Migrate it to \`reckon.yaml\` plus personal XDG checkout
48
+ paths. The \`federated_repos\` query remains available for inspecting the
49
+ engine's currently composed repository paths; it does not prove those
50
+ repositories form one system.`,
51
+ "rename-safety": `# Rename Safety and Refactoring
52
+
53
+ knodin provides an AST-backed, type-safe refactoring pipeline for renaming symbols.
54
+
55
+ ### Two-Step Pipeline
56
+ 1. **Dry-Run (default)**: Returns every edit site (definitions, references, imports) and generates a unified diff. Refuses to make changes if there's an ambiguity or conflict.
57
+ 2. **Apply (\`apply: true\` or \`--apply\`)**: Atomically writes line-scoped word-boundary edits to disk and triggers an automatic reindexing of touched files.
58
+
59
+ ### Safety Guards
60
+ The engine refuses to write to disk and rolls back any changes if:
61
+ - **Ambiguity**: The old symbol name resolves to more than one definition file.
62
+ - **Collision**: The new name already exists as a symbol in any of the edit-site files.
63
+ - **Out of Scope**: The edit targets files outside the repository.
64
+ - **Type Errors**: If verification is enabled (default), it verifies the workspace compiles successfully using \`tsc\` (or equivalent) after applying the edit, and automatically rolls back if compilation fails.`,
65
+ "language-support": `# Language and Metadata Support
66
+
67
+ knodin is multi-language, not language-universal.
68
+
69
+ ### Native syntax
70
+ TypeScript (\`.ts/.tsx/.mts/.cts\`), JavaScript (\`.js/.jsx/.mjs/.cjs\`), Python, Java, C#, Salesforce Apex, SQL/PLSQL, Prisma, and XML-backed formats.
71
+
72
+ ### Domain-specific structure
73
+ Salesforce LWC, Aura, Visualforce, Experience Cloud, and selected Salesforce DX metadata/automations; Terraform/HCL; Dockerfiles; dbt manifests; and Workday Studio XML.
74
+
75
+ ### Imported graph
76
+ LSIF can import symbols and relationships produced by a compatible language server. This is not native parsing.
77
+
78
+ Go, Rust, PHP, Ruby, Kotlin, Swift, Perl, PowerShell, Bash, and MuleSoft/RAML are known native-parser gaps. Coverage differs by language, and static dead-code candidates must be corroborated when runtime or platform configuration can invoke code dynamically. Salesforce candidates should be checked against deployed-org and platform dependency data before deletion.`,
79
+ troubleshooting: `# Troubleshooting and Recovery
80
+
81
+ ### Index health and surgical repair
82
+
83
+ Run \`knodin status\` (or MCP \`operation: "status"\`) to inspect the local schema/model/version, file and symbol coverage, orphaned or missing records, and the last successful reconciliation. Its repair steps are actionable. Run \`knodin repair\` (or MCP \`operation: "repair"\`) to rebuild only missing/damaged rows and verify health; healthy indexed files are not deleted or rebuilt.
84
+
85
+ The MCP \`telemetry\` operation reads process-local metadata-only measurements: actual response bytes and \`gpt-tokenizer@3.4.0:o200k_base\` tokens, executed baselines where available, negative or positive savings, latency, RSS, schema cost, truncation, and detail mode. It never contains source or file paths and does not change existing operation response shapes. Telemetry is not persisted by default. Set MCP \`persistTelemetry: true\` to append metadata-only JSONL to the repo-root \`.reckon-telemetry.jsonl\`; knodin never sends it over the network. Use \`telemetryAction: "report"\` or \`knodin telemetry report\` for a static repository-local HTML report.
86
+
87
+ Common issues and how to resolve them when using knodin:
88
+
89
+ ### Corrupt Embedding Row
90
+ If you see warnings like \`Skipping corrupt embedding... dimension mismatch\`, your index database contains malformed embeddings.
91
+ - **Fix**: Force a clean re-index of the repository to rebuild the vector store.
92
+ \`\`\`bash
93
+ # Prefer surgical local repair; it preserves healthy state
94
+ knodin repair
95
+ \`\`\`
96
+
97
+ ### Missing 'gh' CLI Dependency
98
+ The \`prs\` triage operation requires the official GitHub CLI (\`gh\`) to be installed, in your PATH, and authenticated.
99
+ - **Fix**: Run \`gh auth login\` to authenticate locally.
100
+
101
+ ### Legacy federation configuration
102
+ If legacy federation configuration fails, validate
103
+ \`.reckon/federation.json\`, then migrate stable identities to \`reckon.yaml\`
104
+ and local paths to XDG configuration. Do not infer membership from siblings.`,
105
+ };
106
+ const DOC_TOPIC_FILES = {
107
+ installation: "INSTALLATION.md",
108
+ mcp: "MCP.md",
109
+ repositories: "REPOSITORIES-AND-WORKTREES.md",
110
+ systems: "SYSTEMS-AND-RELATIONSHIPS.md",
111
+ provenance: "INDEXING-POLICY-AND-PROVENANCE.md",
112
+ "dead-code": "DEAD-CODE-AND-IMPACT.md",
113
+ doctor: "DOCTOR-AND-UPDATES.md",
114
+ compression: "COMMAND-OUTPUT-COMPRESSION.md",
115
+ };
116
+ function packageRoot() {
117
+ let current = path.dirname(fileURLToPath(import.meta.url));
118
+ while (!fs.existsSync(path.join(current, "package.json")) &&
119
+ current !== path.parse(current).root) {
120
+ current = path.dirname(current);
121
+ }
122
+ return current;
123
+ }
124
+ export function listDocTopics() {
125
+ return [...new Set([...Object.keys(DOCS_SECTIONS), ...Object.keys(DOC_TOPIC_FILES)])].sort((left, right) => left.localeCompare(right));
126
+ }
127
+ /** Read canonical long-form guides directly so CLI and MCP cannot drift from Markdown. */
128
+ export function getDocSection(topic) {
129
+ const file = DOC_TOPIC_FILES[topic];
130
+ if (file) {
131
+ try {
132
+ return fs.readFileSync(path.join(packageRoot(), "docs", file), "utf-8");
133
+ }
134
+ catch (error) {
135
+ if (error.code === "ENOENT")
136
+ return undefined;
137
+ throw error;
138
+ }
139
+ }
140
+ return DOCS_SECTIONS[topic];
141
+ }