knodin 0.8.2 → 0.8.4
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 +36 -13
- package/dist/bin/cli.js +248 -62
- package/dist/src/agent-events.js +128 -0
- package/dist/src/agent-hooks.js +156 -0
- package/dist/src/cli-model.js +16 -1
- package/dist/src/docs-sections.js +1 -0
- package/dist/src/engine/index.js +911 -55
- package/dist/src/init-progress-worker.js +4 -40
- package/dist/src/output-telemetry.js +8 -3
- package/dist/src/progress-worker-runtime.js +46 -0
- package/dist/src/repair-progress-worker.js +3 -40
- package/dist/src/resource-reachability.js +456 -0
- package/dist/src/response-budget.js +69 -65
- package/dist/src/session-telemetry.js +163 -0
- package/dist/src/tools/knodin-tools.js +12 -7
- package/docs/BEHAVIORAL-CONTRACT.md +47 -5
- package/docs/CLI.md +27 -0
- package/docs/COMPARISON.md +10 -0
- package/docs/DEMO.md +49 -0
- package/docs/INSTALLATION.md +11 -0
- package/docs/MCP.md +5 -0
- package/docs/TELEMETRY.md +19 -1
- package/docs/releases/0.8.3.md +47 -0
- package/docs/releases/0.8.4.md +22 -0
- package/package.json +14 -1
- package/roadmap/competitive-roadmap.md +115 -0
package/README.md
CHANGED
|
@@ -31,16 +31,32 @@ one persistent local graph.
|
|
|
31
31
|
- **Adopt locally:** initialize the checkout you already have and use the CLI or
|
|
32
32
|
connect one MCP gateway.
|
|
33
33
|
|
|
34
|
-
The practical
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
34
|
+
The practical value is less manual context assembly and fewer dependencies
|
|
35
|
+
missed during review, with no hosted-service or source-egress overhead. Fewer
|
|
36
|
+
agent round trips are an intended payoff; the narrow C93 replay measured no
|
|
37
|
+
round-trip difference, so knodin does not claim a general productivity effect.
|
|
38
|
+
|
|
39
|
+
Four checked answers define the product rather than a feature-count claim:
|
|
40
|
+
|
|
41
|
+
- **Value:** less manual context assembly and fewer missed dependencies. C93
|
|
42
|
+
records the paired task method; C100 narrows one missed-dependency result from
|
|
43
|
+
a Salesforce class file to the exact Flow-invoked Apex method.
|
|
44
|
+
- **ROI:** the intended mechanism is fewer corrective agent round trips without
|
|
45
|
+
hosted-service, authentication, or source-egress overhead. C93's accepted
|
|
46
|
+
two-task replay measured no round-trip benefit (both arms required one
|
|
47
|
+
correction), so this remains a product hypothesis rather than a measured
|
|
48
|
+
productivity claim. Billed cost and unobserved tokens remain unknown.
|
|
49
|
+
- **Tomorrow:** run `knodin init`, check `knodin status`, then use the local CLI
|
|
50
|
+
or single MCP gateway against the checkout already on disk.
|
|
51
|
+
- **Secret sauce:** stable identity, exact source evidence, explicit freshness,
|
|
52
|
+
and truthful budgets compose behind one compact local tool.
|
|
53
|
+
|
|
54
|
+
The checked [behavioral contract](docs/BEHAVIORAL-CONTRACT.md) release-gates
|
|
55
|
+
that composition. C101 binds the bounded resource-reachability, TypeScript DI,
|
|
56
|
+
and Salesforce Flow-to-Apex evidence to the retained one-process semantic
|
|
57
|
+
design. It rejects stale, truncated, ambiguous, or omission-only output when a
|
|
58
|
+
mutation tries to promote it into a complete or exact claim. These are
|
|
59
|
+
fixture-scoped guarantees with recorded limits, not universal superiority.
|
|
44
60
|
|
|
45
61
|
## How it compares
|
|
46
62
|
|
|
@@ -50,10 +66,11 @@ context over one locally persisted index.
|
|
|
50
66
|
|
|
51
67
|
| Job | Typical specialized tools | knodin |
|
|
52
68
|
|---|---|---|
|
|
53
|
-
| Symbol navigation | CodeGraph, Serena, Sourcegraph | Ambiguity-safe definitions, callers, callees, paths, and verbatim source |
|
|
69
|
+
| Symbol navigation | CodeGraph, Serena, Sourcegraph | Ambiguity-safe definitions, callers, callees, paths, bounded Inversify/tsyringe DI edges, and verbatim source |
|
|
54
70
|
| Change impact and review | code-review-graph, Greptile, Graphify | Diff-aware review, impact traversal, tests, risks, and PR triage |
|
|
55
71
|
| Architecture mapping | Graphify, GitNexus | Communities, hubs, boundaries, flows, wiki, and local visualization |
|
|
56
72
|
| Search and context assembly | Aider repo map, Repomix, semantic search tools | Hybrid search, bounded source packs, and recoverable diagnostic compression |
|
|
73
|
+
| Bounded resource and framework relationships | Code-Graph-RAG and framework-aware analyzers | Literal TS/JS resource paths, declared Inversify/tsyringe wiring, and one exact Salesforce Flow-to-Apex boundary, each with explicit omissions |
|
|
57
74
|
| Deployment | Hosted services or separate local tools | One local process, SQLite graph, CLI, and one MCP gateway |
|
|
58
75
|
|
|
59
76
|
### Measured results
|
|
@@ -159,7 +176,7 @@ knodin repair
|
|
|
159
176
|
| Command | Purpose |
|
|
160
177
|
|---|---|
|
|
161
178
|
| `knodin context` | Compact repository orientation and next-operation hint |
|
|
162
|
-
| `knodin explain <symbol>` | Source, identity, callers,
|
|
179
|
+
| `knodin explain <symbol>` | Source, identity, typed callers/callees, static-analysis omissions, and blast radius |
|
|
163
180
|
| `knodin review` | Itemized graph impact, test gaps, centrality, and bounded Git-history evidence |
|
|
164
181
|
| `knodin query …` | Impact, paths, callers, callees, tests, flows, and structured graph queries |
|
|
165
182
|
| `knodin search <query>` | Explainable structural-first search with a bounded local embedding fallback |
|
|
@@ -173,6 +190,8 @@ knodin repair
|
|
|
173
190
|
| `knodin status --deep` | Index freshness, health, hooks, and integration status |
|
|
174
191
|
| `knodin repair` | Repair or rebuild unhealthy local graph state |
|
|
175
192
|
| `knodin diagnostics …` | Preview and archive explicit-allowlist local support evidence |
|
|
193
|
+
| `knodin agent-hooks …` | Explicitly install, inspect, or remove optional user-global Claude lifecycle hooks |
|
|
194
|
+
| `knodin telemetry …` | Opt into private repository-local adoption evidence and render the local dashboard |
|
|
176
195
|
|
|
177
196
|
Run `knodin --help` or read the [CLI reference](docs/CLI.md) for complete syntax.
|
|
178
197
|
|
|
@@ -201,7 +220,11 @@ repository, and lifecycle capabilities. See the [MCP guide](docs/MCP.md).
|
|
|
201
220
|
Each checkout stores its graph and lifecycle state in `.knodin/`. Shared model
|
|
202
221
|
files live in the user cache rather than being duplicated per repository.
|
|
203
222
|
Source and graph data stay local. Telemetry is metadata-only, disabled by
|
|
204
|
-
default, and never sent by knodin.
|
|
223
|
+
default, and never sent by knodin. Optional Claude hooks are installed only by
|
|
224
|
+
`knodin agent-hooks install --client claude`; they inject bounded, freshness-aware
|
|
225
|
+
session orientation and no-op outside initialized repositories. Adoption
|
|
226
|
+
capture remains a separate opt-in through `knodin telemetry enable`.
|
|
227
|
+
Troubleshooting diagnostics are also
|
|
205
228
|
explicitly enabled, local-only, bounded, and never uploaded automatically.
|
|
206
229
|
|
|
207
230
|
The optional `prs` command invokes the user's authenticated `gh` CLI. Network
|
package/dist/bin/cli.js
CHANGED
|
@@ -16,6 +16,8 @@ import fs from "node:fs";
|
|
|
16
16
|
import path from "node:path";
|
|
17
17
|
import readline from "node:readline/promises";
|
|
18
18
|
import { fileURLToPath } from "node:url";
|
|
19
|
+
import { canUseSessionContextCache, parseClaudeHookPayload, readSessionContextCache, recordClaudeLifecycleEvent, renderSessionContext, SESSION_CONTEXT_BUDGET_LINE, sessionStartHookOutput, writeSessionContextCache, } from "../src/agent-events.js";
|
|
20
|
+
import { inspectClaudeAgentHooks, installClaudeAgentHooks, uninstallClaudeAgentHooks, } from "../src/agent-hooks.js";
|
|
19
21
|
import { detectSupportedAgents, parseInitScope, } from "../src/agent-integration.js";
|
|
20
22
|
import { refreshExternalGraphArtifacts, writeArtifactRefreshRecord, } from "../src/artifact-refresh.js";
|
|
21
23
|
import { checkIndexed, extractPositionals, extractRepoFlag, parseReviewArgs, planIndex, resolveCliRuntimeCommand, resolveRepo, } from "../src/cli-args.js";
|
|
@@ -43,6 +45,7 @@ import { createRepairPlan, createRepairProgressRenderer, parseRepairCliArgs, res
|
|
|
43
45
|
import { runRepositoryInitializationProcess } from "../src/repository-init-process.js";
|
|
44
46
|
import { detectRepositorySignals, discoverRepositories, formatRepositoryHuman, initializeRepositories, inventoryRepository, parseFleetInitArgs, parseRepositoryCommandArgs, repositorySignalInspectionLimit, searchRepositories, withRepositorySignals, } from "../src/repository-management.js";
|
|
45
47
|
import { applyResponseBudget } from "../src/response-budget.js";
|
|
48
|
+
import { appendSessionEvent, clearSessionTelemetry, disableSessionTelemetry, enableSessionTelemetry, readSessionEvents, sessionTelemetryStatus, } from "../src/session-telemetry.js";
|
|
46
49
|
import { configuredRepositoryInitMemoryLimitBytes, enrichSystemRelationships, incorporateSystemQueryEvidence, indexModeForPath, loadSystemConfiguration, queryConfiguredSystem, systemMembershipsForPath, validateSystemHealth, } from "../src/system-config.js";
|
|
47
50
|
import { applyTrustedUpdate, checkTrustedUpdate, claimScheduledUpdateCheck, detectUpdateInstallMethod, explainTrustedUpdate, releaseScheduledUpdateCheck, rollbackTrustedUpdate, trustedUpdateStatus, } from "../src/update-policy.js";
|
|
48
51
|
import { KNODIN_VERSION } from "../src/version.js";
|
|
@@ -158,7 +161,11 @@ function formatRepairHuman(result) {
|
|
|
158
161
|
return `Repair verified: graph is healthy (${coverage.indexedFiles} indexed files, ${coverage.filesWithSymbols} files with symbols), but lifecycle routing is degraded. Run \`knodin init\`, then \`knodin status\`.\n`;
|
|
159
162
|
return `Repair verified: graph is healthy (${coverage.indexedFiles} indexed files, ${coverage.filesWithSymbols} files with symbols).\n`;
|
|
160
163
|
}
|
|
161
|
-
|
|
164
|
+
const outstanding = result.outstandingIssues?.total ??
|
|
165
|
+
result.after.missing.files.length + result.after.missing.records.length;
|
|
166
|
+
const firstIssue = result.after.missing.files[0] ?? result.after.missing.records[0];
|
|
167
|
+
const detail = firstIssue ? ` First issue: ${firstIssue}.` : "";
|
|
168
|
+
return `Repair finished with ${outstanding.toLocaleString()} remaining issue(s).${detail} Run \`knodin status --deep\` for details.\n`;
|
|
162
169
|
}
|
|
163
170
|
function formatIndexHuman(result) {
|
|
164
171
|
if (result.indexed.length === 0 && result.unchanged.length > 0) {
|
|
@@ -680,9 +687,9 @@ async function main() {
|
|
|
680
687
|
};
|
|
681
688
|
const repoFlag = selectorValue("--repo");
|
|
682
689
|
const runtimeCommand = resolveCliRuntimeCommand(process);
|
|
683
|
-
//
|
|
690
|
+
// Internal machine-to-machine commands stay JSON regardless of TTY state.
|
|
684
691
|
// when an older installed hook predates the explicit --json argument.
|
|
685
|
-
const jsonOutput = invocation.options.json === true || cmd === "hook-refresh";
|
|
692
|
+
const jsonOutput = invocation.options.json === true || cmd === "hook-refresh" || cmd === "agent-event";
|
|
686
693
|
// `--json` is a shared output flag. Repair owns its richer --json/--jsonl
|
|
687
694
|
// parser; all other commands receive their original arguments minus it.
|
|
688
695
|
const rest = cmd === "repair" ? rawRest : rawRest.filter((argument) => argument !== "--json");
|
|
@@ -1071,12 +1078,43 @@ async function main() {
|
|
|
1071
1078
|
background.unref();
|
|
1072
1079
|
}
|
|
1073
1080
|
}
|
|
1081
|
+
if (cmd === "agent-hooks") {
|
|
1082
|
+
const action = invocation.commandPath[1];
|
|
1083
|
+
const client = selectorValue("--client") ?? "claude";
|
|
1084
|
+
if (client !== "claude")
|
|
1085
|
+
throw new Error("knodin agent-hooks: only --client claude is supported");
|
|
1086
|
+
if (invocation.options.dryRun === true && action !== "install")
|
|
1087
|
+
throw new Error("knodin agent-hooks: --dry-run applies only to install");
|
|
1088
|
+
let hookResult;
|
|
1089
|
+
if (action === "install") {
|
|
1090
|
+
const quote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
1091
|
+
hookResult = installClaudeAgentHooks({
|
|
1092
|
+
command: runtimeCommand.map(quote).join(" "),
|
|
1093
|
+
dryRun: invocation.options.dryRun === true,
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
else if (action === "status")
|
|
1097
|
+
hookResult = inspectClaudeAgentHooks();
|
|
1098
|
+
else if (action === "uninstall")
|
|
1099
|
+
hookResult = uninstallClaudeAgentHooks();
|
|
1100
|
+
else
|
|
1101
|
+
throw new Error("knodin agent-hooks requires install, status, or uninstall");
|
|
1102
|
+
process.stdout.write(jsonOutput
|
|
1103
|
+
? `${JSON.stringify(hookResult)}\n`
|
|
1104
|
+
: formatGenericHuman("agent-hooks", hookResult));
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1074
1107
|
const resolved = resolveRepo(repoFlag, process.cwd());
|
|
1075
1108
|
if (!resolved.ok) {
|
|
1109
|
+
// Global agent hooks also run outside repositories and must fail open.
|
|
1110
|
+
if (cmd === "agent-event")
|
|
1111
|
+
return;
|
|
1076
1112
|
process.stderr.write(`${resolved.error}\n`);
|
|
1077
1113
|
process.exit(1);
|
|
1078
1114
|
}
|
|
1079
1115
|
const repo = resolved.repo;
|
|
1116
|
+
if (cmd === "agent-event" && !fs.existsSync(resolveDbPath(repo)))
|
|
1117
|
+
return;
|
|
1080
1118
|
if (cmd === "doctor") {
|
|
1081
1119
|
const gitProbe = spawnSync(gitExecutable(), ["rev-parse", "--is-inside-work-tree"], {
|
|
1082
1120
|
cwd: repo,
|
|
@@ -1227,6 +1265,7 @@ async function main() {
|
|
|
1227
1265
|
let repairWasPlan = false;
|
|
1228
1266
|
let repairExitCode = 0;
|
|
1229
1267
|
let statusWasWatched = false;
|
|
1268
|
+
let agentEventOutput = false;
|
|
1230
1269
|
const graphRead = async (run) => {
|
|
1231
1270
|
const health = await inspectGraphQueryHealth(repo, async (target) => attachLifecycleHealth(target, await engine.status(target, { audit: "cached" })));
|
|
1232
1271
|
if (!health.available) {
|
|
@@ -1242,6 +1281,103 @@ async function main() {
|
|
|
1242
1281
|
return decorateGraphQueryResult(value, verified.state, verified.graph.freshness);
|
|
1243
1282
|
};
|
|
1244
1283
|
switch (cmd) {
|
|
1284
|
+
case "agent-event": {
|
|
1285
|
+
agentEventOutput = true;
|
|
1286
|
+
const event = invocation.positionals[0];
|
|
1287
|
+
if (!event)
|
|
1288
|
+
throw new Error("knodin agent-event requires an event");
|
|
1289
|
+
let payload;
|
|
1290
|
+
try {
|
|
1291
|
+
payload = parseClaudeHookPayload(await readBoundedStdin(65_536));
|
|
1292
|
+
}
|
|
1293
|
+
catch {
|
|
1294
|
+
result = null;
|
|
1295
|
+
break;
|
|
1296
|
+
}
|
|
1297
|
+
if (event !== "session-start") {
|
|
1298
|
+
try {
|
|
1299
|
+
recordClaudeLifecycleEvent(repo, event, payload);
|
|
1300
|
+
}
|
|
1301
|
+
catch {
|
|
1302
|
+
// Optional telemetry must never interrupt the agent.
|
|
1303
|
+
}
|
|
1304
|
+
result = null;
|
|
1305
|
+
break;
|
|
1306
|
+
}
|
|
1307
|
+
const startedAt = performance.now();
|
|
1308
|
+
let status;
|
|
1309
|
+
try {
|
|
1310
|
+
status = await engine.status(repo, { audit: "cached" });
|
|
1311
|
+
}
|
|
1312
|
+
catch {
|
|
1313
|
+
if (payload.session_id)
|
|
1314
|
+
try {
|
|
1315
|
+
appendSessionEvent(repo, {
|
|
1316
|
+
event: "session_start",
|
|
1317
|
+
sessionId: payload.session_id,
|
|
1318
|
+
context: "unavailable",
|
|
1319
|
+
freshness: "unavailable",
|
|
1320
|
+
latencyMs: Math.round((performance.now() - startedAt) * 100) / 100,
|
|
1321
|
+
cacheHit: false,
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
catch {
|
|
1325
|
+
// Best-effort local recording remains fail-open.
|
|
1326
|
+
}
|
|
1327
|
+
result = sessionStartHookOutput(`## knodin session context\n${SESSION_CONTEXT_BUDGET_LINE}\nContext generation was unavailable. Run knodin status before relying on graph evidence.`);
|
|
1328
|
+
break;
|
|
1329
|
+
}
|
|
1330
|
+
const freshness = status.freshness?.state ?? "unknown";
|
|
1331
|
+
const cacheKey = JSON.stringify({
|
|
1332
|
+
schemaVersion: 1,
|
|
1333
|
+
head: status.freshness?.currentHead,
|
|
1334
|
+
fingerprint: status.freshness?.workingTree?.indexedFingerprint,
|
|
1335
|
+
generation: status.indexGeneration,
|
|
1336
|
+
budgets: [600, 8192, 12],
|
|
1337
|
+
});
|
|
1338
|
+
const cacheEligible = canUseSessionContextCache(status.status, freshness);
|
|
1339
|
+
let text = cacheEligible ? readSessionContextCache(repo, cacheKey) : null;
|
|
1340
|
+
let cacheHit = text !== null;
|
|
1341
|
+
let contextState = "delivered";
|
|
1342
|
+
if (!cacheEligible) {
|
|
1343
|
+
contextState = "degraded";
|
|
1344
|
+
text = `## knodin session context\n${SESSION_CONTEXT_BUDGET_LINE}\nGraph evidence is ${freshness}. Run knodin status and follow its repair or reindex guidance before relying on graph evidence.`;
|
|
1345
|
+
}
|
|
1346
|
+
else if (!text) {
|
|
1347
|
+
cacheHit = false;
|
|
1348
|
+
try {
|
|
1349
|
+
const context = await buildKnodinContext(engine, "Orient this coding session", repo, undefined, []);
|
|
1350
|
+
text = renderSessionContext(context, freshness);
|
|
1351
|
+
writeSessionContextCache(repo, cacheKey, text);
|
|
1352
|
+
}
|
|
1353
|
+
catch {
|
|
1354
|
+
contextState = "unavailable";
|
|
1355
|
+
text = `## knodin session context\n${SESSION_CONTEXT_BUDGET_LINE}\nContext generation was unavailable. Run knodin status, then call the connected knodin context operation when needed.`;
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
if (payload.session_id)
|
|
1359
|
+
try {
|
|
1360
|
+
appendSessionEvent(repo, {
|
|
1361
|
+
event: "session_start",
|
|
1362
|
+
sessionId: payload.session_id,
|
|
1363
|
+
context: contextState,
|
|
1364
|
+
freshness: freshness === "fresh"
|
|
1365
|
+
? "fresh"
|
|
1366
|
+
: status.status === "repair-needed"
|
|
1367
|
+
? "repair-needed"
|
|
1368
|
+
: freshness === "unknown"
|
|
1369
|
+
? "unavailable"
|
|
1370
|
+
: "stale",
|
|
1371
|
+
latencyMs: Math.round((performance.now() - startedAt) * 100) / 100,
|
|
1372
|
+
cacheHit,
|
|
1373
|
+
});
|
|
1374
|
+
}
|
|
1375
|
+
catch {
|
|
1376
|
+
// Best-effort local recording remains fail-open.
|
|
1377
|
+
}
|
|
1378
|
+
result = sessionStartHookOutput(text);
|
|
1379
|
+
break;
|
|
1380
|
+
}
|
|
1245
1381
|
case "doctor": {
|
|
1246
1382
|
const client = selectorValue("--client");
|
|
1247
1383
|
if (client !== undefined &&
|
|
@@ -1781,7 +1917,7 @@ async function main() {
|
|
|
1781
1917
|
? ""
|
|
1782
1918
|
: (rest[1] ?? "");
|
|
1783
1919
|
if (!pattern) {
|
|
1784
|
-
process.stderr.write("knodin query requires a <pattern> (lsp_diagnostics|lsp_definitions|lsp_declarations|lsp_implementations|callers_of|callees_of|imports_of|importers_of|import_cycles|file_summary|batch_outline|project_overview|shortest_path|inheritors_of|structural_implementations_of|tests_for|impact|dead_code|large_functions|large_files|rename_preview|flows|flow_of|stats|traverse|feature_path|flow_analysis|knowledge_gaps|surprising_connections|suggested_questions|architecture_overview|community|triggers_of|publishers_of|listeners_of|handlers_of|endpoints_for|consumers_of|children_of|federated_repos|mcp_tools|api_contract_mismatches)\n");
|
|
1920
|
+
process.stderr.write("knodin query requires a <pattern> (lsp_diagnostics|lsp_definitions|lsp_declarations|lsp_implementations|callers_of|callees_of|imports_of|importers_of|import_cycles|file_summary|batch_outline|project_overview|shortest_path|cross_substrate_path|inheritors_of|structural_implementations_of|tests_for|impact|dead_code|large_functions|large_files|rename_preview|flows|flow_of|stats|traverse|feature_path|flow_analysis|resource_reachability|knowledge_gaps|surprising_connections|suggested_questions|architecture_overview|community|triggers_of|publishers_of|listeners_of|handlers_of|endpoints_for|consumers_of|children_of|federated_repos|mcp_tools|api_contract_mismatches)\n");
|
|
1785
1921
|
process.exit(1);
|
|
1786
1922
|
}
|
|
1787
1923
|
const directionValue = selectorValue("--direction");
|
|
@@ -1800,7 +1936,15 @@ async function main() {
|
|
|
1800
1936
|
}
|
|
1801
1937
|
// shortest_path: `knodin query shortest_path <from> <to>`
|
|
1802
1938
|
// rename_preview: `knodin query rename_preview <old> <new>`
|
|
1803
|
-
const to = pattern === "shortest_path" ||
|
|
1939
|
+
const to = pattern === "shortest_path" ||
|
|
1940
|
+
pattern === "cross_substrate_path" ||
|
|
1941
|
+
pattern === "rename_preview"
|
|
1942
|
+
? rest[2]
|
|
1943
|
+
: undefined;
|
|
1944
|
+
if (pattern === "cross_substrate_path" && !to) {
|
|
1945
|
+
process.stderr.write("knodin query cross_substrate_path requires <from> and <to>\n");
|
|
1946
|
+
process.exit(1);
|
|
1947
|
+
}
|
|
1804
1948
|
if (pattern === "rename_preview" && !to) {
|
|
1805
1949
|
process.stderr.write("knodin query rename_preview requires <old> and <new>\n");
|
|
1806
1950
|
process.exit(1);
|
|
@@ -1863,6 +2007,11 @@ async function main() {
|
|
|
1863
2007
|
: undefined,
|
|
1864
2008
|
includeDataFlow: pattern === "traverse" ? rest.includes("--data-flow") : undefined,
|
|
1865
2009
|
flowVariable: pattern === "flow_analysis" ? selectorValue("--variable") : undefined,
|
|
2010
|
+
resourceOffset: pattern === "resource_reachability" && selectorValue("--offset")
|
|
2011
|
+
? Number(selectorValue("--offset"))
|
|
2012
|
+
: undefined,
|
|
2013
|
+
resourceMaxBytes: pattern === "resource_reachability" ? responseBudget.bytes : undefined,
|
|
2014
|
+
resourceMaxTokens: pattern === "resource_reachability" ? responseBudget.tokens : undefined,
|
|
1866
2015
|
architectureFacets: facetsValue,
|
|
1867
2016
|
topN: selectorValue("--top") ? Number(selectorValue("--top")) : undefined,
|
|
1868
2017
|
sort: selectorValue("--sort"),
|
|
@@ -1956,16 +2105,23 @@ async function main() {
|
|
|
1956
2105
|
if (!Number.isInteger(retentionDays) || retentionDays < 1 || retentionDays > 3650)
|
|
1957
2106
|
throw new Error("knodin telemetry: --retention-days must be an integer from 1 to 3650");
|
|
1958
2107
|
const input = selectorValue("--input");
|
|
1959
|
-
if (action === "
|
|
1960
|
-
result =
|
|
2108
|
+
if (action === "enable")
|
|
2109
|
+
result = enableSessionTelemetry(repo);
|
|
2110
|
+
else if (action === "disable")
|
|
2111
|
+
result = disableSessionTelemetry(repo);
|
|
2112
|
+
else if (action === "status")
|
|
2113
|
+
result = {
|
|
2114
|
+
...telemetryStatus(repo, input, retentionDays),
|
|
2115
|
+
session: sessionTelemetryStatus(repo, retentionDays),
|
|
2116
|
+
};
|
|
1961
2117
|
else if (action === "report")
|
|
1962
|
-
result = writeTelemetryReport(repo, readTelemetryRecords(repo, input, retentionDays), selectorValue("--output"));
|
|
2118
|
+
result = writeTelemetryReport(repo, readTelemetryRecords(repo, input, retentionDays), selectorValue("--output"), readSessionEvents(repo, retentionDays));
|
|
1963
2119
|
else if (action === "export")
|
|
1964
|
-
result = exportTelemetry(repo, readTelemetryRecords(repo, input, retentionDays), selectorValue("--output"));
|
|
2120
|
+
result = exportTelemetry(repo, readTelemetryRecords(repo, input, retentionDays), selectorValue("--output"), readSessionEvents(repo, retentionDays));
|
|
1965
2121
|
else if (action === "clear")
|
|
1966
|
-
result = clearTelemetry(repo, input);
|
|
2122
|
+
result = { ...clearTelemetry(repo, input), session: clearSessionTelemetry(repo) };
|
|
1967
2123
|
else
|
|
1968
|
-
throw new Error("knodin telemetry requires status, report, export, or clear");
|
|
2124
|
+
throw new Error("knodin telemetry requires enable, disable, status, report, export, or clear");
|
|
1969
2125
|
break;
|
|
1970
2126
|
}
|
|
1971
2127
|
case "diagnostics": {
|
|
@@ -2057,6 +2213,11 @@ async function main() {
|
|
|
2057
2213
|
process.exit(1);
|
|
2058
2214
|
}
|
|
2059
2215
|
await engine.close();
|
|
2216
|
+
if (agentEventOutput) {
|
|
2217
|
+
if (result !== null && result !== undefined)
|
|
2218
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
2219
|
+
return;
|
|
2220
|
+
}
|
|
2060
2221
|
if (statusWasWatched)
|
|
2061
2222
|
return;
|
|
2062
2223
|
const boundedResult = applyResponseBudget(result, cmd, responseBudget, {
|
|
@@ -2111,55 +2272,80 @@ try {
|
|
|
2111
2272
|
}
|
|
2112
2273
|
catch (err) {
|
|
2113
2274
|
const argv = process.argv.slice(2);
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
if (argument
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
"
|
|
2130
|
-
"
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
"
|
|
2137
|
-
"
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2275
|
+
let invokedCommand;
|
|
2276
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
2277
|
+
const argument = argv[index];
|
|
2278
|
+
if (argument === "--repo") {
|
|
2279
|
+
index += 1;
|
|
2280
|
+
continue;
|
|
2281
|
+
}
|
|
2282
|
+
if (argument?.startsWith("--repo=") || argument === "--json")
|
|
2283
|
+
continue;
|
|
2284
|
+
if (!argument?.startsWith("-")) {
|
|
2285
|
+
invokedCommand = argument;
|
|
2286
|
+
break;
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
if (invokedCommand === "agent-event") {
|
|
2290
|
+
const eventIndex = argv.indexOf("agent-event");
|
|
2291
|
+
if (argv[eventIndex + 1] === "session-start") {
|
|
2292
|
+
process.stdout.write(`${JSON.stringify(sessionStartHookOutput(`## knodin session context\n${SESSION_CONTEXT_BUDGET_LINE}\nContext generation was unavailable. Run knodin status before relying on graph evidence.`))}\n`);
|
|
2293
|
+
}
|
|
2294
|
+
process.exitCode = 0;
|
|
2295
|
+
}
|
|
2296
|
+
else {
|
|
2297
|
+
const repoIndex = argv.indexOf("--repo");
|
|
2298
|
+
const equalsRepo = argv.find((argument) => argument.startsWith("--repo="));
|
|
2299
|
+
const explicitRepo = repoIndex >= 0 ? argv[repoIndex + 1] : undefined;
|
|
2300
|
+
let candidate = process.cwd();
|
|
2301
|
+
if (explicitRepo)
|
|
2302
|
+
candidate = explicitRepo;
|
|
2303
|
+
else if (equalsRepo)
|
|
2304
|
+
candidate = equalsRepo.slice("--repo=".length);
|
|
2305
|
+
const command = argv.find((argument, index) => {
|
|
2306
|
+
if (argument.startsWith("-"))
|
|
2307
|
+
return false;
|
|
2308
|
+
return !(index > 0 && argv[index - 1] === "--repo") && argument !== candidate;
|
|
2309
|
+
});
|
|
2310
|
+
const knownCommands = new Set([
|
|
2311
|
+
"init",
|
|
2312
|
+
"configure",
|
|
2313
|
+
"agent-hooks",
|
|
2314
|
+
"agent-event",
|
|
2315
|
+
"index",
|
|
2316
|
+
"doctor",
|
|
2317
|
+
"status",
|
|
2318
|
+
"wait",
|
|
2319
|
+
"repair",
|
|
2320
|
+
"serve",
|
|
2321
|
+
"context",
|
|
2322
|
+
"explain",
|
|
2323
|
+
"review",
|
|
2324
|
+
"map",
|
|
2325
|
+
"search",
|
|
2326
|
+
"query",
|
|
2327
|
+
"rename",
|
|
2328
|
+
"wiki",
|
|
2329
|
+
"visualize",
|
|
2330
|
+
"pack",
|
|
2331
|
+
"compress",
|
|
2332
|
+
"prs",
|
|
2333
|
+
"worktrees",
|
|
2334
|
+
"telemetry",
|
|
2335
|
+
"diagnostics",
|
|
2336
|
+
"system",
|
|
2337
|
+
"repos",
|
|
2338
|
+
"remote",
|
|
2339
|
+
"update",
|
|
2340
|
+
]);
|
|
2341
|
+
const diagnostic = recordDiagnosticFailure(candidate, {
|
|
2342
|
+
surface: "cli",
|
|
2343
|
+
operation: command && knownCommands.has(command) ? command : "unknown",
|
|
2344
|
+
phase: "dispatch",
|
|
2345
|
+
error: err,
|
|
2346
|
+
});
|
|
2347
|
+
const correlation = diagnostic.recorded ? ` [diagnostic ${diagnostic.correlationId}]` : "";
|
|
2348
|
+
console.error(`${err instanceof Error ? err.message : String(err)}${correlation}`);
|
|
2349
|
+
process.exit(1);
|
|
2350
|
+
}
|
|
2165
2351
|
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { countOutputTokens } from "./output-telemetry.js";
|
|
4
|
+
import { appendSessionEvent, } from "./session-telemetry.js";
|
|
5
|
+
export const SESSION_CONTEXT_BUDGET_LINE = "Budget: at most 600 tokens, 8 KiB, 12 items.";
|
|
6
|
+
const KNOWN_OPERATIONS = new Set([
|
|
7
|
+
"context",
|
|
8
|
+
"explain",
|
|
9
|
+
"review",
|
|
10
|
+
"map",
|
|
11
|
+
"search",
|
|
12
|
+
"query",
|
|
13
|
+
"pack",
|
|
14
|
+
"compress",
|
|
15
|
+
"execute",
|
|
16
|
+
"prs",
|
|
17
|
+
"wiki",
|
|
18
|
+
"docs",
|
|
19
|
+
"remote",
|
|
20
|
+
]);
|
|
21
|
+
function knownOperation(value) {
|
|
22
|
+
return typeof value === "string" && KNOWN_OPERATIONS.has(value) ? value : undefined;
|
|
23
|
+
}
|
|
24
|
+
export function parseClaudeHookPayload(raw) {
|
|
25
|
+
if (Buffer.byteLength(raw) > 65_536)
|
|
26
|
+
throw new Error("knodin agent-event: hook input exceeds 65536 bytes");
|
|
27
|
+
const parsed = JSON.parse(raw);
|
|
28
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
29
|
+
throw new Error("knodin agent-event: hook input must be a JSON object");
|
|
30
|
+
return parsed;
|
|
31
|
+
}
|
|
32
|
+
export function classifyTool(payload) {
|
|
33
|
+
const name = payload.tool_name ?? "";
|
|
34
|
+
if (name === "mcp__knodin__knodin") {
|
|
35
|
+
const operation = knownOperation(payload.tool_input?.operation);
|
|
36
|
+
return {
|
|
37
|
+
toolCategory: "knodin",
|
|
38
|
+
...(operation ? { operation } : {}),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (["Read", "Glob", "Grep"].includes(name))
|
|
42
|
+
return { toolCategory: "manual_traversal" };
|
|
43
|
+
if (["Edit", "Write", "NotebookEdit", "MultiEdit"].includes(name))
|
|
44
|
+
return { toolCategory: "edit" };
|
|
45
|
+
if (name === "Bash") {
|
|
46
|
+
const command = typeof payload.tool_input?.command === "string" ? payload.tool_input.command : "";
|
|
47
|
+
const knodin = command.match(/(?:^|[;&|]\s*)knodin\s+([a-z][a-z0-9-]*)/i);
|
|
48
|
+
if (knodin) {
|
|
49
|
+
const operation = knownOperation(knodin[1]?.toLowerCase());
|
|
50
|
+
return { toolCategory: "knodin", ...(operation ? { operation } : {}) };
|
|
51
|
+
}
|
|
52
|
+
if (/(?:^|[;&|]\s*)(?:rg|grep|find)\b/.test(command))
|
|
53
|
+
return { toolCategory: "manual_traversal" };
|
|
54
|
+
}
|
|
55
|
+
return { toolCategory: "other" };
|
|
56
|
+
}
|
|
57
|
+
function cachePath(repo) {
|
|
58
|
+
return path.join(repo, ".knodin", "session-context-cache.json");
|
|
59
|
+
}
|
|
60
|
+
export function readSessionContextCache(repo, key, now = Date.now()) {
|
|
61
|
+
try {
|
|
62
|
+
const parsed = JSON.parse(fs.readFileSync(cachePath(repo), "utf8"));
|
|
63
|
+
return parsed.key === key && typeof parsed.at === "number" && now - parsed.at <= 300_000
|
|
64
|
+
? (parsed.text ?? null)
|
|
65
|
+
: null;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export function writeSessionContextCache(repo, key, text) {
|
|
72
|
+
const filePath = cachePath(repo);
|
|
73
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
74
|
+
const temporary = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
75
|
+
fs.writeFileSync(temporary, JSON.stringify({ schemaVersion: 1, key, at: Date.now(), text }), {
|
|
76
|
+
mode: 0o600,
|
|
77
|
+
flag: "wx",
|
|
78
|
+
});
|
|
79
|
+
fs.renameSync(temporary, filePath);
|
|
80
|
+
}
|
|
81
|
+
export function canUseSessionContextCache(status, freshness) {
|
|
82
|
+
return status === "healthy" && freshness === "fresh";
|
|
83
|
+
}
|
|
84
|
+
export function renderSessionContext(context, freshness) {
|
|
85
|
+
const list = (values, render) => (values ?? []).slice(0, 3).map(render).join(", ") || "none reported";
|
|
86
|
+
let text = [
|
|
87
|
+
"## knodin session context",
|
|
88
|
+
`Graph evidence: ${freshness}. Treat stale or unavailable evidence as incomplete.`,
|
|
89
|
+
SESSION_CONTEXT_BUDGET_LINE,
|
|
90
|
+
`Repository: ${context.stats?.files ?? "?"} files, ${context.stats?.symbols ?? "?"} symbols.`,
|
|
91
|
+
`Subsystems: ${list(context.communities, (value) => `${value.name} (${value.size})`)}`,
|
|
92
|
+
`Hubs: ${list(context.hubs, (value) => `${value.symbol} (${value.degree})`)}`,
|
|
93
|
+
`Flows: ${list(context.flows, (value) => value.symbol ?? "unnamed")}`,
|
|
94
|
+
`Suggested next operation: ${context.suggestedOperation ?? "context"} (heuristic only).`,
|
|
95
|
+
"Use exact source evidence and preserve ambiguity, freshness, omissions, and response budgets.",
|
|
96
|
+
].join("\n");
|
|
97
|
+
while ((countOutputTokens(text) > 600 || Buffer.byteLength(text) > 8192) && text.includes("\n")) {
|
|
98
|
+
text = `${text.slice(0, text.lastIndexOf("\n"))}\nContext truncated to the configured session budget.`;
|
|
99
|
+
}
|
|
100
|
+
return text;
|
|
101
|
+
}
|
|
102
|
+
export function recordClaudeLifecycleEvent(repo, event, payload) {
|
|
103
|
+
const sessionId = payload.session_id;
|
|
104
|
+
if (!sessionId)
|
|
105
|
+
return false;
|
|
106
|
+
const names = {
|
|
107
|
+
"session-start": "session_start",
|
|
108
|
+
"session-end": "session_end",
|
|
109
|
+
"turn-start": "turn_start",
|
|
110
|
+
"turn-end": "turn_end",
|
|
111
|
+
"pre-tool": "tool_start",
|
|
112
|
+
"post-tool": "tool_success",
|
|
113
|
+
"tool-failure": "tool_failure",
|
|
114
|
+
};
|
|
115
|
+
const mapped = names[event];
|
|
116
|
+
if (!mapped)
|
|
117
|
+
return false;
|
|
118
|
+
const tool = event.includes("tool") ? classifyTool(payload) : undefined;
|
|
119
|
+
return appendSessionEvent(repo, { event: mapped, sessionId, ...tool });
|
|
120
|
+
}
|
|
121
|
+
export function sessionStartHookOutput(text) {
|
|
122
|
+
return {
|
|
123
|
+
hookSpecificOutput: {
|
|
124
|
+
hookEventName: "SessionStart",
|
|
125
|
+
additionalContext: text,
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|