knodin 0.8.6 → 0.8.7
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 +6 -0
- package/dist/bin/cli.js +153 -11
- package/dist/bin/launcher.js +0 -0
- package/dist/src/cli-model.js +31 -1
- package/dist/src/init.js +16 -0
- package/dist/src/repository-management.js +219 -3
- package/dist/src/skill-management.js +149 -0
- package/dist/src/system-config.js +176 -16
- package/dist/src/system-management.js +508 -0
- package/dist/src/tools/knodin-tools.js +108 -15
- package/docs/CLI.md +7 -0
- package/docs/SYSTEMS-AND-RELATIONSHIPS.md +27 -6
- package/docs/prompts/declare-multi-repository-system.md +18 -0
- package/docs/releases/0.8.7.md +27 -0
- package/package.json +5 -2
- package/skills/knodin-debug/SKILL.md +12 -0
- package/skills/knodin-debug/agents/openai.yaml +4 -0
- package/skills/knodin-explore/SKILL.md +11 -0
- package/skills/knodin-explore/agents/openai.yaml +4 -0
- package/skills/knodin-impact/SKILL.md +11 -0
- package/skills/knodin-impact/agents/openai.yaml +4 -0
- package/skills/knodin-lifecycle/SKILL.md +12 -0
- package/skills/knodin-lifecycle/agents/openai.yaml +4 -0
- package/skills/knodin-refactor/SKILL.md +12 -0
- package/skills/knodin-refactor/agents/openai.yaml +4 -0
- package/skills/knodin-review/SKILL.md +11 -0
- package/skills/knodin-review/agents/openai.yaml +4 -0
- package/skills/knodin-systems/SKILL.md +14 -0
- package/skills/knodin-systems/agents/openai.yaml +4 -0
package/README.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# knodin
|
|
2
2
|
|
|
3
|
+
For an approval-gated, evidence-first way to declare a multi-repository system, use the sanitized [assistant prompt](docs/prompts/declare-multi-repository-system.md).
|
|
4
|
+
|
|
5
|
+
`knodin init` installs focused `knodin-*` assistant skills for detected Codex,
|
|
6
|
+
Claude, and Gemini clients. Use `knodin skills list|install|remove|doctor` to
|
|
7
|
+
inspect or manage them explicitly; CLI-only scope installs none.
|
|
8
|
+
|
|
3
9
|
<img src="https://cdn.jsdelivr.net/npm/knodin@latest/docs/assets/knodin-favicon.svg" alt="knodin caret-beak logo" width="96" height="96">
|
|
4
10
|
|
|
5
11
|
> Local code intelligence that remembers how your code connects—and shows what
|
package/dist/bin/cli.js
CHANGED
|
@@ -46,7 +46,9 @@ import { runRepositoryInitializationProcess } from "../src/repository-init-proce
|
|
|
46
46
|
import { detectRepositorySignals, discoverRepositories, formatRepositoryHuman, initializeRepositories, inventoryRepository, parseFleetInitArgs, parseRepositoryCommandArgs, repositorySignalInspectionLimit, searchRepositories, withRepositorySignals, } from "../src/repository-management.js";
|
|
47
47
|
import { applyResponseBudget } from "../src/response-budget.js";
|
|
48
48
|
import { appendSessionEvent, clearSessionTelemetry, disableSessionTelemetry, enableSessionTelemetry, readSessionEvents, sessionTelemetryStatus, } from "../src/session-telemetry.js";
|
|
49
|
+
import { inspectKnodinSkills, installKnodinSkills, KNODIN_SKILLS, removeKnodinSkills, } from "../src/skill-management.js";
|
|
49
50
|
import { configuredRepositoryInitMemoryLimitBytes, enrichSystemRelationships, incorporateSystemQueryEvidence, indexModeForPath, loadSystemConfiguration, queryConfiguredSystem, systemMembershipsForPath, validateSystemHealth, } from "../src/system-config.js";
|
|
51
|
+
import { applySystemPlan, planSystemImport, planSystemLink, planSystemRelate, planSystemUnlink, planSystemUnrelate, } from "../src/system-management.js";
|
|
50
52
|
import { applyTrustedUpdate, checkTrustedUpdate, claimScheduledUpdateCheck, detectUpdateInstallMethod, explainTrustedUpdate, releaseScheduledUpdateCheck, rollbackTrustedUpdate, trustedUpdateStatus, } from "../src/update-policy.js";
|
|
51
53
|
import { KNODIN_VERSION } from "../src/version.js";
|
|
52
54
|
import { writeVisualization, } from "../src/visualization.js";
|
|
@@ -643,7 +645,6 @@ async function main() {
|
|
|
643
645
|
process.send?.({ type: "rss", rssBytes: process.memoryUsage().rss });
|
|
644
646
|
const engine = createEngine({ watcher: "disabled" });
|
|
645
647
|
try {
|
|
646
|
-
const systemConfig = loadSystemConfiguration(repository);
|
|
647
648
|
const summary = await initializeRepositories([repository], {
|
|
648
649
|
// A portfolio worker owns exactly one already-discovered worktree.
|
|
649
650
|
// Do not recursively initialize nested repositories inside the same
|
|
@@ -654,7 +655,7 @@ async function main() {
|
|
|
654
655
|
index: (target) => engine.index(target),
|
|
655
656
|
status: (target) => engine.status(target),
|
|
656
657
|
agents: detectSupportedAgents(),
|
|
657
|
-
indexMode: (target) => indexModeForPath(
|
|
658
|
+
indexMode: (target) => indexModeForPath(loadSystemConfiguration(target), target),
|
|
658
659
|
});
|
|
659
660
|
const result = summary.results.find(({ repository: target }) => target === repository);
|
|
660
661
|
if (!result)
|
|
@@ -805,7 +806,6 @@ async function main() {
|
|
|
805
806
|
linkedWorktrees: plan.linkedWorktrees,
|
|
806
807
|
});
|
|
807
808
|
const engine = createEngine({ watcher: "disabled" });
|
|
808
|
-
const systemConfig = loadSystemConfiguration(plan.roots[0] ?? process.cwd());
|
|
809
809
|
const repositories = [];
|
|
810
810
|
const discoveryRecords = plan.signals
|
|
811
811
|
? discovery.repositories.slice(0, repositorySignalInspectionLimit(plan))
|
|
@@ -813,7 +813,7 @@ async function main() {
|
|
|
813
813
|
for (const record of discoveryRecords) {
|
|
814
814
|
const inventory = await inventoryRepository(record, {
|
|
815
815
|
status: async (target) => attachLifecycleHealth(target, await engine.status(target, { audit: "cached" })),
|
|
816
|
-
systemMemberships: (target) => systemMembershipsForPath(
|
|
816
|
+
systemMemberships: (target) => systemMembershipsForPath(loadSystemConfiguration(target), target),
|
|
817
817
|
});
|
|
818
818
|
repositories.push(withRepositorySignals(inventory, plan.signals, plan.signals ? await detectRepositorySignals(record.path) : {}));
|
|
819
819
|
}
|
|
@@ -910,7 +910,6 @@ async function main() {
|
|
|
910
910
|
}
|
|
911
911
|
if (plan.command === "search") {
|
|
912
912
|
const engine = createEngine({ watcher: "disabled" });
|
|
913
|
-
const systemConfig = loadSystemConfiguration(plan.roots[0] ?? process.cwd());
|
|
914
913
|
const output = await searchRepositories(plan.roots, plan.query ?? "", {
|
|
915
914
|
depth: plan.depth,
|
|
916
915
|
linkedWorktrees: plan.linkedWorktrees,
|
|
@@ -927,7 +926,7 @@ async function main() {
|
|
|
927
926
|
includeSource: true,
|
|
928
927
|
federate: false,
|
|
929
928
|
}),
|
|
930
|
-
systemMemberships: (target) => systemMembershipsForPath(
|
|
929
|
+
systemMemberships: (target) => systemMembershipsForPath(loadSystemConfiguration(target), target),
|
|
931
930
|
});
|
|
932
931
|
await engine.close();
|
|
933
932
|
process.stdout.write(`${JSON.stringify(output, null, plan.json ? 0 : 2)}\n`);
|
|
@@ -1628,13 +1627,119 @@ async function main() {
|
|
|
1628
1627
|
break;
|
|
1629
1628
|
}
|
|
1630
1629
|
case "system": {
|
|
1631
|
-
const
|
|
1632
|
-
|
|
1630
|
+
const requestedAction = invocation.commandPath[1];
|
|
1631
|
+
if (requestedAction === "link") {
|
|
1632
|
+
const [systemId, ...repositories] = invocation.positionals;
|
|
1633
|
+
if (!systemId)
|
|
1634
|
+
throw new Error("knodin system link requires <system-id>");
|
|
1635
|
+
const overrides = Object.fromEntries((Array.isArray(invocation.options.repositoryId) ? invocation.options.repositoryId : [])
|
|
1636
|
+
.map(String)
|
|
1637
|
+
.map((value) => {
|
|
1638
|
+
const at = value.lastIndexOf("=");
|
|
1639
|
+
if (at < 1)
|
|
1640
|
+
throw new Error("--repository-id must be <path>=<id>");
|
|
1641
|
+
return [value.slice(0, at), value.slice(at + 1)];
|
|
1642
|
+
}));
|
|
1643
|
+
const componentOverrides = Object.fromEntries((Array.isArray(invocation.options.component) ? invocation.options.component : [])
|
|
1644
|
+
.map(String)
|
|
1645
|
+
.map((value) => {
|
|
1646
|
+
const at = value.indexOf("=");
|
|
1647
|
+
if (at < 1)
|
|
1648
|
+
throw new Error("--component must be <repo-id>=<component-id>[:type]");
|
|
1649
|
+
const repositoryId = value.slice(0, at);
|
|
1650
|
+
const [id, componentType] = value.slice(at + 1).split(":");
|
|
1651
|
+
if (!id)
|
|
1652
|
+
throw new Error("--component requires a component id");
|
|
1653
|
+
if (componentType &&
|
|
1654
|
+
!["component", "api", "resource", "artifact"].includes(componentType))
|
|
1655
|
+
throw new Error(`--component: unsupported type ${componentType}`);
|
|
1656
|
+
return [
|
|
1657
|
+
repositoryId,
|
|
1658
|
+
{
|
|
1659
|
+
id,
|
|
1660
|
+
type: componentType,
|
|
1661
|
+
},
|
|
1662
|
+
];
|
|
1663
|
+
}));
|
|
1664
|
+
if (invocation.options.initMissing === true && invocation.options.dryRun !== true) {
|
|
1665
|
+
for (const checkout of repositories.map((value) => path.resolve(repo, value))) {
|
|
1666
|
+
if (fs.existsSync(path.join(checkout, ".knodin")))
|
|
1667
|
+
continue;
|
|
1668
|
+
await initializeRepository(checkout, {
|
|
1669
|
+
command: runtimeCommand,
|
|
1670
|
+
index: (target, indexOptions) => engine.index(target, undefined, false, indexOptions),
|
|
1671
|
+
scope: "personal",
|
|
1672
|
+
agents: [],
|
|
1673
|
+
});
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
const plan = planSystemLink({
|
|
1677
|
+
systemId,
|
|
1678
|
+
repositories,
|
|
1679
|
+
scope: invocation.options.scope === "team" ? "team" : "personal",
|
|
1680
|
+
distributed: invocation.options.distributed === true,
|
|
1681
|
+
repositoryIds: overrides,
|
|
1682
|
+
components: componentOverrides,
|
|
1683
|
+
});
|
|
1684
|
+
result = invocation.options.dryRun === true ? plan : applySystemPlan(plan);
|
|
1685
|
+
break;
|
|
1686
|
+
}
|
|
1687
|
+
if (requestedAction === "relate") {
|
|
1688
|
+
const [systemId] = invocation.positionals;
|
|
1689
|
+
if (!systemId)
|
|
1690
|
+
throw new Error("knodin system relate requires <system-id>");
|
|
1691
|
+
const plan = planSystemRelate({
|
|
1692
|
+
systemId,
|
|
1693
|
+
type: String(invocation.options.type),
|
|
1694
|
+
from: String(invocation.options.from),
|
|
1695
|
+
to: String(invocation.options.to),
|
|
1696
|
+
evidence: String(invocation.options.evidence),
|
|
1697
|
+
repoPath: repo,
|
|
1698
|
+
});
|
|
1699
|
+
result = invocation.options.dryRun === true ? plan : applySystemPlan(plan);
|
|
1700
|
+
break;
|
|
1701
|
+
}
|
|
1702
|
+
if (requestedAction === "unlink") {
|
|
1703
|
+
const [systemId, ...repositories] = invocation.positionals;
|
|
1704
|
+
if (!systemId)
|
|
1705
|
+
throw new Error("knodin system unlink requires <system-id>");
|
|
1706
|
+
const plan = planSystemUnlink({ systemId, repositories, repoPath: repo });
|
|
1707
|
+
result = invocation.options.dryRun === true ? plan : applySystemPlan(plan);
|
|
1708
|
+
break;
|
|
1709
|
+
}
|
|
1710
|
+
if (requestedAction === "unrelate") {
|
|
1711
|
+
const [systemId] = invocation.positionals;
|
|
1712
|
+
if (!systemId)
|
|
1713
|
+
throw new Error("knodin system unrelate requires <system-id>");
|
|
1714
|
+
const plan = planSystemUnrelate({
|
|
1715
|
+
systemId,
|
|
1716
|
+
type: String(invocation.options.type),
|
|
1717
|
+
from: String(invocation.options.from),
|
|
1718
|
+
to: String(invocation.options.to),
|
|
1719
|
+
repoPath: repo,
|
|
1720
|
+
});
|
|
1721
|
+
result = invocation.options.dryRun === true ? plan : applySystemPlan(plan);
|
|
1722
|
+
break;
|
|
1723
|
+
}
|
|
1724
|
+
if (requestedAction === "import") {
|
|
1725
|
+
const [manifestPath] = invocation.positionals;
|
|
1726
|
+
if (!manifestPath)
|
|
1727
|
+
throw new Error("knodin system import requires <path>");
|
|
1728
|
+
const plan = planSystemImport({
|
|
1729
|
+
manifestPath,
|
|
1730
|
+
scope: invocation.options.scope === "team" ? "team" : "personal",
|
|
1731
|
+
repoPath: repo,
|
|
1732
|
+
});
|
|
1733
|
+
result = invocation.options.dryRun === true ? plan : applySystemPlan(plan);
|
|
1734
|
+
break;
|
|
1735
|
+
}
|
|
1736
|
+
const requireComplete = rest.includes("--require-complete");
|
|
1737
|
+
const [action, systemId, ...unsupported] = rest.filter((argument) => argument !== "--allow-partial" && argument !== "--require-complete");
|
|
1633
1738
|
if (!action || !["list", "show", "validate", "query"].includes(action)) {
|
|
1634
1739
|
throw new Error("knodin system requires list, show, validate, or query");
|
|
1635
1740
|
}
|
|
1636
|
-
if (
|
|
1637
|
-
throw new Error(`knodin system ${action}: --
|
|
1741
|
+
if (requireComplete && action !== "query")
|
|
1742
|
+
throw new Error(`knodin system ${action}: --require-complete applies only to query`);
|
|
1638
1743
|
if (unsupported.length > 0)
|
|
1639
1744
|
throw new Error(`knodin system ${action}: unknown argument ${unsupported[0]}`);
|
|
1640
1745
|
const config = await enrichSystemRelationships(loadSystemConfiguration(repo));
|
|
@@ -1673,12 +1778,49 @@ async function main() {
|
|
|
1673
1778
|
}
|
|
1674
1779
|
else {
|
|
1675
1780
|
const validation = await validateSystemHealth(config, systemId, async (target) => attachLifecycleHealth(target, await engine.status(target, { audit: "cached" })), repo);
|
|
1676
|
-
result = queryConfiguredSystem(config, systemId,
|
|
1781
|
+
result = queryConfiguredSystem(config, systemId, requireComplete, validation);
|
|
1677
1782
|
if (result.status === "unavailable")
|
|
1678
1783
|
process.exitCode = 1;
|
|
1679
1784
|
}
|
|
1680
1785
|
break;
|
|
1681
1786
|
}
|
|
1787
|
+
case "skills": {
|
|
1788
|
+
const action = invocation.commandPath[1];
|
|
1789
|
+
const requested = Array.isArray(invocation.options.client)
|
|
1790
|
+
? invocation.options.client.map(String)
|
|
1791
|
+
: [];
|
|
1792
|
+
const supported = new Set(["codex", "claude", "gemini"]);
|
|
1793
|
+
for (const client of requested)
|
|
1794
|
+
if (!supported.has(client))
|
|
1795
|
+
throw new Error(`knodin skills: unsupported client ${client}`);
|
|
1796
|
+
const clients = (requested.length ? requested : ["codex", "claude", "gemini"]);
|
|
1797
|
+
const scope = typeof invocation.options.scope === "string"
|
|
1798
|
+
? parseInitScope(invocation.options.scope)
|
|
1799
|
+
: "personal";
|
|
1800
|
+
if (action === "list") {
|
|
1801
|
+
result = { skills: KNODIN_SKILLS, clients, scope };
|
|
1802
|
+
break;
|
|
1803
|
+
}
|
|
1804
|
+
if (action === "install")
|
|
1805
|
+
result = installKnodinSkills({
|
|
1806
|
+
repo,
|
|
1807
|
+
scope,
|
|
1808
|
+
clients,
|
|
1809
|
+
dryRun: invocation.options.dryRun === true,
|
|
1810
|
+
});
|
|
1811
|
+
else if (action === "remove")
|
|
1812
|
+
result = removeKnodinSkills({
|
|
1813
|
+
repo,
|
|
1814
|
+
scope,
|
|
1815
|
+
clients,
|
|
1816
|
+
dryRun: invocation.options.dryRun === true,
|
|
1817
|
+
});
|
|
1818
|
+
else if (action === "doctor")
|
|
1819
|
+
result = inspectKnodinSkills({ repo, scope, clients });
|
|
1820
|
+
else
|
|
1821
|
+
throw new Error("knodin skills requires list, install, remove, or doctor");
|
|
1822
|
+
break;
|
|
1823
|
+
}
|
|
1682
1824
|
case "hook-refresh": {
|
|
1683
1825
|
const [kind, first, second] = rest;
|
|
1684
1826
|
let event;
|
package/dist/bin/launcher.js
CHANGED
|
File without changes
|
package/dist/src/cli-model.js
CHANGED
|
@@ -295,8 +295,38 @@ function createCliProgram(capture = () => { }) {
|
|
|
295
295
|
for (const action of ["show", "validate", "query"]) {
|
|
296
296
|
const command = leaf(system, `${action} <system-id>`, `${action} one configured system`, capture);
|
|
297
297
|
if (action === "query")
|
|
298
|
-
command
|
|
298
|
+
command
|
|
299
|
+
.option("--require-complete", "fail unless every declared repository is available")
|
|
300
|
+
.option("--allow-partial", "compatibility alias; partial results are now the default");
|
|
299
301
|
}
|
|
302
|
+
leaf(system, "link <system-id> <repos...>", "declare repositories as one system", capture)
|
|
303
|
+
.option("--scope <scope>", "personal or team", "personal")
|
|
304
|
+
.option("--distributed", "write a team fragment to every selected repository")
|
|
305
|
+
.addOption(option("--repository-id <path=id>", "override a derived repository id", "collect"))
|
|
306
|
+
.addOption(option("--component <repo=id[:type]>", "override a component id and type", "collect"))
|
|
307
|
+
.option("--init-missing", "initialize repositories that do not yet have knodin lifecycle state")
|
|
308
|
+
.option("--dry-run", "preview exact files and semantic changes");
|
|
309
|
+
leaf(system, "relate <system-id>", "add an explicit evidenced relationship", capture)
|
|
310
|
+
.requiredOption("--type <type>", "relationship type")
|
|
311
|
+
.requiredOption("--from <component>", "source component")
|
|
312
|
+
.requiredOption("--to <component>", "target component")
|
|
313
|
+
.requiredOption("--evidence <repo:path:line>", "checked-in relationship evidence")
|
|
314
|
+
.option("--dry-run", "preview exact files and semantic changes");
|
|
315
|
+
leaf(system, "unlink <system-id> [repos...]", "remove repositories or a whole system", capture).option("--dry-run", "preview exact files and semantic changes");
|
|
316
|
+
leaf(system, "unrelate <system-id>", "remove an explicit relationship", capture)
|
|
317
|
+
.requiredOption("--type <type>", "relationship type")
|
|
318
|
+
.requiredOption("--from <component>", "source component")
|
|
319
|
+
.requiredOption("--to <component>", "target component")
|
|
320
|
+
.option("--dry-run", "preview exact files and semantic changes");
|
|
321
|
+
leaf(system, "import <path>", "register an existing system manifest", capture)
|
|
322
|
+
.option("--scope <scope>", "personal or team", "personal")
|
|
323
|
+
.option("--dry-run", "preview exact files and semantic changes");
|
|
324
|
+
const skills = program.command("skills").description("manage packaged knodin assistant skills");
|
|
325
|
+
for (const action of ["list", "install", "remove", "doctor"])
|
|
326
|
+
leaf(skills, action, `${action} knodin assistant skills`, capture)
|
|
327
|
+
.option("--scope <scope>", "personal, team, or cli-only")
|
|
328
|
+
.addOption(option("--client <client>", "codex, claude, or gemini", "collect"))
|
|
329
|
+
.option("--dry-run", "preview skill changes without writing");
|
|
300
330
|
const update = program
|
|
301
331
|
.command("update")
|
|
302
332
|
.description("inspect or apply threshold-signed updates")
|
package/dist/src/init.js
CHANGED
|
@@ -7,6 +7,7 @@ import { compareBytes } from "./compare.js";
|
|
|
7
7
|
import { isIndexableSourcePath } from "./engine/source-policy.js";
|
|
8
8
|
import { lookupMirror } from "./engine/state-paths.js";
|
|
9
9
|
import { inspectLefthookIntegration, installHookManagerIntegration, isActiveLefthookHook, } from "./hook-manager-integration.js";
|
|
10
|
+
import { installKnodinSkills, removeKnodinSkills } from "./skill-management.js";
|
|
10
11
|
import { registerInitializedWorktree } from "./worktree-lifecycle.js";
|
|
11
12
|
const MANAGED_MARKER = "KNODIN MANAGED HOOK";
|
|
12
13
|
const HOOK_NAMES = ["post-commit", "post-checkout", "post-merge", "post-rewrite"];
|
|
@@ -1176,6 +1177,21 @@ export async function initializeRepository(repo, options) {
|
|
|
1176
1177
|
agents: eligibleAgents,
|
|
1177
1178
|
run: options.runAgentCommand,
|
|
1178
1179
|
});
|
|
1180
|
+
const skillClients = (scope === "team"
|
|
1181
|
+
? ["codex", "claude", "gemini"]
|
|
1182
|
+
: agentIntegration.configured.filter((agent) => ["codex", "claude", "gemini"].includes(agent)));
|
|
1183
|
+
const skillIntegration = scope === "cli-only"
|
|
1184
|
+
? removeKnodinSkills({
|
|
1185
|
+
repo: resolvedRepo,
|
|
1186
|
+
scope: "team",
|
|
1187
|
+
clients: ["codex", "claude", "gemini"],
|
|
1188
|
+
})
|
|
1189
|
+
: installKnodinSkills({ repo: resolvedRepo, scope, clients: skillClients });
|
|
1190
|
+
for (const issue of skillIntegration.issues)
|
|
1191
|
+
agentIntegration.failed.push({
|
|
1192
|
+
agent: skillClients.find((client) => issue.path.includes(`.${client}`)) ?? "codex",
|
|
1193
|
+
message: `${issue.path}: ${issue.message}`,
|
|
1194
|
+
});
|
|
1179
1195
|
if (scope === "personal" && agentIntegration.configured.includes("claude"))
|
|
1180
1196
|
externalConfigurationOutcomes.push({
|
|
1181
1197
|
system: "claude-local-mcp",
|
|
@@ -901,7 +901,12 @@ function decodeCursor(cursor, hash) {
|
|
|
901
901
|
(value.repository ?? -1) < 0 ||
|
|
902
902
|
(value.offset ?? -1) < 0)
|
|
903
903
|
throw new Error("invalid cursor");
|
|
904
|
-
return {
|
|
904
|
+
return {
|
|
905
|
+
repository: value.repository ?? 0,
|
|
906
|
+
offset: value.offset ?? 0,
|
|
907
|
+
...(value.mode === "fallback" ? { mode: "fallback" } : {}),
|
|
908
|
+
...(value.mode === "symbol-only" ? { mode: "symbol-only" } : {}),
|
|
909
|
+
};
|
|
905
910
|
}
|
|
906
911
|
catch {
|
|
907
912
|
throw new Error("knodin repos search: cursor does not match this query and selection");
|
|
@@ -930,8 +935,146 @@ function searchCandidate(inventory, match, exposePaths) {
|
|
|
930
935
|
confidence: match.rrfScore || match.similarity,
|
|
931
936
|
freshness: inventory.freshness,
|
|
932
937
|
health: "healthy",
|
|
938
|
+
matchType: "symbol",
|
|
939
|
+
systemMemberships: inventory.systemMemberships,
|
|
933
940
|
};
|
|
934
941
|
}
|
|
942
|
+
const TRACKED_TEXT_CANDIDATE_LIMIT = 1000;
|
|
943
|
+
const TRACKED_TEXT_REPOSITORY_LIMIT = 200;
|
|
944
|
+
const TRACKED_TEXT_OUTPUT_LIMIT = 2 * 1024 * 1024;
|
|
945
|
+
const TRACKED_TEXT_TIMEOUT_MS = 5000;
|
|
946
|
+
function queryTokens(query) {
|
|
947
|
+
return [
|
|
948
|
+
...new Set(query
|
|
949
|
+
.toLowerCase()
|
|
950
|
+
.match(/[a-z0-9][a-z0-9._-]{2,}/g)
|
|
951
|
+
?.map((token) => token.replace(/^[._-]+|[._-]+$/g, ""))
|
|
952
|
+
.filter(Boolean) ?? []),
|
|
953
|
+
];
|
|
954
|
+
}
|
|
955
|
+
function tokenCoverage(value, tokens) {
|
|
956
|
+
const lower = value.toLowerCase();
|
|
957
|
+
return tokens.filter((token) => lower.includes(token)).length;
|
|
958
|
+
}
|
|
959
|
+
function textFallbackReason(results, query) {
|
|
960
|
+
if (results.length === 0)
|
|
961
|
+
return "no-symbol-results";
|
|
962
|
+
const tokens = queryTokens(query);
|
|
963
|
+
if (tokens.length < 3)
|
|
964
|
+
return undefined;
|
|
965
|
+
return results.every((result) => {
|
|
966
|
+
const identityCoverage = tokenCoverage(`${result.symbol} ${result.file}`, tokens);
|
|
967
|
+
const sourceCoverage = tokenCoverage(result.sourceEvidence, tokens);
|
|
968
|
+
return identityCoverage < 2 && (identityCoverage === 0 || sourceCoverage < 2);
|
|
969
|
+
})
|
|
970
|
+
? "weak-symbol-coverage"
|
|
971
|
+
: undefined;
|
|
972
|
+
}
|
|
973
|
+
function committedFile(repository, file, timeout) {
|
|
974
|
+
const result = child_process.spawnSync(gitExecutable(), ["show", `HEAD:${file}`], {
|
|
975
|
+
cwd: repository,
|
|
976
|
+
encoding: "buffer",
|
|
977
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
978
|
+
timeout,
|
|
979
|
+
maxBuffer: 1024 * 1024,
|
|
980
|
+
});
|
|
981
|
+
return result.status === 0 && !result.error ? result.stdout : undefined;
|
|
982
|
+
}
|
|
983
|
+
function trackedTextWindow(repository, file, line, timeout, cache) {
|
|
984
|
+
try {
|
|
985
|
+
let content = cache?.get(file);
|
|
986
|
+
if (!cache?.has(file)) {
|
|
987
|
+
content = committedFile(repository, file, timeout);
|
|
988
|
+
cache?.set(file, content);
|
|
989
|
+
}
|
|
990
|
+
if (!content || content.includes(0))
|
|
991
|
+
return "";
|
|
992
|
+
const lines = content.toString("utf8").split(/\r?\n/);
|
|
993
|
+
const start = Math.max(0, line - 3);
|
|
994
|
+
return lines
|
|
995
|
+
.slice(start, Math.min(lines.length, line + 2))
|
|
996
|
+
.map((value, index) => `${start + index + 1}: ${value}`)
|
|
997
|
+
.join("\n");
|
|
998
|
+
}
|
|
999
|
+
catch {
|
|
1000
|
+
return "";
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
function trackedTextMatches(inventory, query, exposePaths) {
|
|
1004
|
+
if (!inventory.path)
|
|
1005
|
+
return { matches: [], truncated: false };
|
|
1006
|
+
const tokens = queryTokens(query);
|
|
1007
|
+
if (tokens.length === 0)
|
|
1008
|
+
return { matches: [], truncated: false };
|
|
1009
|
+
const started = Date.now();
|
|
1010
|
+
const remaining = () => Math.max(1, TRACKED_TEXT_TIMEOUT_MS - (Date.now() - started));
|
|
1011
|
+
const args = ["grep", "-n", "-I", "-F", "-i"];
|
|
1012
|
+
for (const token of tokens)
|
|
1013
|
+
args.push("-e", token);
|
|
1014
|
+
args.push("HEAD");
|
|
1015
|
+
const result = child_process.spawnSync(gitExecutable(), args, {
|
|
1016
|
+
cwd: inventory.path,
|
|
1017
|
+
encoding: "utf8",
|
|
1018
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1019
|
+
timeout: remaining(),
|
|
1020
|
+
maxBuffer: TRACKED_TEXT_OUTPUT_LIMIT,
|
|
1021
|
+
});
|
|
1022
|
+
if (result.error && result.error.code !== "ENOBUFS")
|
|
1023
|
+
return { matches: [], truncated: true };
|
|
1024
|
+
const lines = String(result.stdout ?? "")
|
|
1025
|
+
.split(/\r?\n/)
|
|
1026
|
+
.filter(Boolean);
|
|
1027
|
+
const truncated = lines.length > TRACKED_TEXT_REPOSITORY_LIMIT || result.error !== undefined;
|
|
1028
|
+
const records = lines.flatMap((record) => {
|
|
1029
|
+
const match = /^HEAD:(.+?):(\d+):(.*)$/.exec(record);
|
|
1030
|
+
if (!match?.[1] || !match[2])
|
|
1031
|
+
return [];
|
|
1032
|
+
const file = match[1];
|
|
1033
|
+
const extension = path.extname(file).toLowerCase();
|
|
1034
|
+
if (![".yaml", ".yml", ".sh", ".bash", ".zsh", ""].includes(extension))
|
|
1035
|
+
return [];
|
|
1036
|
+
if (extension === "") {
|
|
1037
|
+
const content = committedFile(inventory.path ?? "", file, remaining());
|
|
1038
|
+
if (!content || !content.toString("utf8", 0, 128).startsWith("#!"))
|
|
1039
|
+
return [];
|
|
1040
|
+
}
|
|
1041
|
+
return [{ file, line: Number(match[2]), matchedSource: match[3] ?? "" }];
|
|
1042
|
+
});
|
|
1043
|
+
const phrase = query.trim().toLowerCase();
|
|
1044
|
+
const contentCache = new Map();
|
|
1045
|
+
const matches = records.slice(0, TRACKED_TEXT_REPOSITORY_LIMIT).flatMap((record) => {
|
|
1046
|
+
const { file, line, matchedSource } = record;
|
|
1047
|
+
const source = trackedTextWindow(inventory.path ?? "", file, line, remaining(), contentCache) ||
|
|
1048
|
+
matchedSource;
|
|
1049
|
+
const coverage = tokenCoverage(`${file} ${source}`, tokens);
|
|
1050
|
+
const exactPhrase = phrase.length > 0 && source.toLowerCase().includes(phrase) ? 1 : 0;
|
|
1051
|
+
const pathCoverage = tokenCoverage(file, tokens);
|
|
1052
|
+
return [
|
|
1053
|
+
{
|
|
1054
|
+
repositoryId: inventory.id,
|
|
1055
|
+
...(exposePaths === false ? {} : { repositoryPath: inventory.path }),
|
|
1056
|
+
identity: `text_${crypto.createHash("sha256").update(`${inventory.id}\0${file}\0${line}`).digest("hex").slice(0, 20)}`,
|
|
1057
|
+
symbol: path.basename(file),
|
|
1058
|
+
kind: "tracked-text",
|
|
1059
|
+
file,
|
|
1060
|
+
line,
|
|
1061
|
+
sourceEvidence: source.slice(0, 4000),
|
|
1062
|
+
sourceTruncated: source.length > 4000,
|
|
1063
|
+
confidence: coverage / tokens.length + exactPhrase * 0.01 + pathCoverage * 0.001,
|
|
1064
|
+
freshness: inventory.freshness,
|
|
1065
|
+
health: "healthy",
|
|
1066
|
+
matchType: "tracked-text",
|
|
1067
|
+
systemMemberships: inventory.systemMemberships,
|
|
1068
|
+
_score: [coverage, exactPhrase, pathCoverage],
|
|
1069
|
+
},
|
|
1070
|
+
];
|
|
1071
|
+
});
|
|
1072
|
+
matches.sort((left, right) => right._score[0] - left._score[0] ||
|
|
1073
|
+
right._score[1] - left._score[1] ||
|
|
1074
|
+
right._score[2] - left._score[2] ||
|
|
1075
|
+
compareBytes(`${left.repositoryId}:${left.file}:${left.line}`, `${right.repositoryId}:${right.file}:${right.line}`));
|
|
1076
|
+
return { matches: matches.map(({ _score: _ignored, ...match }) => match), truncated };
|
|
1077
|
+
}
|
|
935
1078
|
async function searchHealthyRepository(inventory, repositoryIndex, offset, context) {
|
|
936
1079
|
const { query, itemLimit, effectiveBytes, results, omissions, selected, options } = context;
|
|
937
1080
|
const page = await options.search(query, inventory.path ?? "", itemLimit - results.length + 1, offset);
|
|
@@ -1013,6 +1156,7 @@ export async function searchRepositories(roots, query, options) {
|
|
|
1013
1156
|
.digest("hex")
|
|
1014
1157
|
.slice(0, 20);
|
|
1015
1158
|
const start = decodeCursor(options.cursor, hash);
|
|
1159
|
+
const symbolStart = start.mode === "fallback" ? { repository: 0, offset: 0 } : start;
|
|
1016
1160
|
const itemLimit = Math.min(Math.max(options.itemBudget ?? 25, 1), 1000);
|
|
1017
1161
|
if (options.byteBudget !== undefined && options.byteBudget < 1024)
|
|
1018
1162
|
throw new Error("knodin repos search: byte budget must be at least 1024");
|
|
@@ -1021,7 +1165,58 @@ export async function searchRepositories(roots, query, options) {
|
|
|
1021
1165
|
const byteLimit = options.byteBudget ?? 65_536;
|
|
1022
1166
|
const tokenLimit = options.tokenBudget ?? 16_384;
|
|
1023
1167
|
const effectiveBytes = Math.min(byteLimit, tokenLimit * 4);
|
|
1024
|
-
const { results, omissions, next } = await collectRepositorySearchResults(selected,
|
|
1168
|
+
const { results, omissions, next } = await collectRepositorySearchResults(selected, symbolStart, query, itemLimit, effectiveBytes, options);
|
|
1169
|
+
const fallbackReason = start.mode === "symbol-only" ? undefined : textFallbackReason(results, query);
|
|
1170
|
+
let textFallbackTruncated = false;
|
|
1171
|
+
let fallbackCursor;
|
|
1172
|
+
if (fallbackReason) {
|
|
1173
|
+
const textMatches = [];
|
|
1174
|
+
const eligible = selected.filter(({ health }) => health === "healthy");
|
|
1175
|
+
const inspected = eligible.slice(0, 25);
|
|
1176
|
+
if (inspected.length < eligible.length)
|
|
1177
|
+
textFallbackTruncated = true;
|
|
1178
|
+
for (const inventory of inspected) {
|
|
1179
|
+
const text = trackedTextMatches(inventory, query, options.exposePaths);
|
|
1180
|
+
textFallbackTruncated ||= text.truncated;
|
|
1181
|
+
textMatches.push(...text.matches);
|
|
1182
|
+
if (textMatches.length >= TRACKED_TEXT_CANDIDATE_LIMIT) {
|
|
1183
|
+
textFallbackTruncated = true;
|
|
1184
|
+
break;
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
textMatches.sort((left, right) => right.confidence - left.confidence ||
|
|
1188
|
+
compareBytes(`${left.repositoryId}:${left.file}:${left.line ?? 0}`, `${right.repositoryId}:${right.file}:${right.line ?? 0}`));
|
|
1189
|
+
const combined = textMatches.slice(0, TRACKED_TEXT_CANDIDATE_LIMIT);
|
|
1190
|
+
const fallbackOffset = start.mode === "fallback" ? start.offset : 0;
|
|
1191
|
+
const page = [];
|
|
1192
|
+
let nextOffset = fallbackOffset;
|
|
1193
|
+
while (nextOffset < combined.length && page.length < itemLimit) {
|
|
1194
|
+
const candidate = combined[nextOffset];
|
|
1195
|
+
if (!candidate)
|
|
1196
|
+
break;
|
|
1197
|
+
const probe = JSON.stringify({
|
|
1198
|
+
results: [...page, candidate],
|
|
1199
|
+
omissions,
|
|
1200
|
+
repositories: selected,
|
|
1201
|
+
});
|
|
1202
|
+
if (Buffer.byteLength(probe) > effectiveBytes && page.length > 0)
|
|
1203
|
+
break;
|
|
1204
|
+
page.push(candidate);
|
|
1205
|
+
nextOffset += 1;
|
|
1206
|
+
if (Buffer.byteLength(probe) > effectiveBytes)
|
|
1207
|
+
break;
|
|
1208
|
+
}
|
|
1209
|
+
results.splice(0, results.length, ...page);
|
|
1210
|
+
if (nextOffset < combined.length)
|
|
1211
|
+
fallbackCursor = encodeCursor({
|
|
1212
|
+
hash,
|
|
1213
|
+
repository: 0,
|
|
1214
|
+
offset: nextOffset,
|
|
1215
|
+
mode: "fallback",
|
|
1216
|
+
});
|
|
1217
|
+
else if (results.length > 0 || next)
|
|
1218
|
+
fallbackCursor = encodeCursor({ hash, repository: 0, offset: 0, mode: "symbol-only" });
|
|
1219
|
+
}
|
|
1025
1220
|
if (omissions.length > 0 && options.allowPartial !== true) {
|
|
1026
1221
|
return finalizeRepositorySearch({
|
|
1027
1222
|
schemaVersion: 1,
|
|
@@ -1040,7 +1235,28 @@ export async function searchRepositories(roots, query, options) {
|
|
|
1040
1235
|
results,
|
|
1041
1236
|
repositories: selected,
|
|
1042
1237
|
omissions,
|
|
1043
|
-
...(
|
|
1238
|
+
...(fallbackReason
|
|
1239
|
+
? {
|
|
1240
|
+
retrieval: {
|
|
1241
|
+
textFallback: true,
|
|
1242
|
+
reason: fallbackReason,
|
|
1243
|
+
inspectedRepositories: Math.min(25, selected.filter(({ health }) => health === "healthy").length),
|
|
1244
|
+
candidateLimit: TRACKED_TEXT_CANDIDATE_LIMIT,
|
|
1245
|
+
truncated: textFallbackTruncated,
|
|
1246
|
+
},
|
|
1247
|
+
}
|
|
1248
|
+
: {}),
|
|
1249
|
+
...(fallbackCursor
|
|
1250
|
+
? { cursor: fallbackCursor }
|
|
1251
|
+
: !fallbackReason && next
|
|
1252
|
+
? {
|
|
1253
|
+
cursor: encodeCursor({
|
|
1254
|
+
hash,
|
|
1255
|
+
...next,
|
|
1256
|
+
mode: start.mode === "symbol-only" ? "symbol-only" : "symbol",
|
|
1257
|
+
}),
|
|
1258
|
+
}
|
|
1259
|
+
: {}),
|
|
1044
1260
|
};
|
|
1045
1261
|
return finalizeRepositorySearch(base, byteLimit, tokenLimit, itemLimit);
|
|
1046
1262
|
}
|