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 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(systemConfig, target),
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(systemConfig, target),
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(systemConfig, target),
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 allowPartial = rest.includes("--allow-partial");
1632
- const [action, systemId, ...unsupported] = rest.filter((argument) => argument !== "--allow-partial");
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 (allowPartial && action !== "query")
1637
- throw new Error(`knodin system ${action}: --allow-partial applies only to query`);
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, allowPartial, validation);
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;
File without changes
@@ -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.option("--allow-partial", "return healthy components");
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 { repository: value.repository ?? 0, offset: value.offset ?? 0 };
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, start, query, itemLimit, effectiveBytes, options);
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
- ...(next ? { cursor: encodeCursor({ hash, ...next }) } : {}),
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
  }