@kolisachint/hoocode-agent 0.5.2 → 0.5.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/CHANGELOG.md +31 -0
- package/dist/cli/args.d.ts +1 -1
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +10 -7
- package/dist/cli/args.js.map +1 -1
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +10 -2
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/capabilities/deferral.d.ts +78 -0
- package/dist/core/capabilities/deferral.d.ts.map +1 -0
- package/dist/core/capabilities/deferral.js +99 -0
- package/dist/core/capabilities/deferral.js.map +1 -0
- package/dist/core/capabilities/dense.d.ts +51 -0
- package/dist/core/capabilities/dense.d.ts.map +1 -0
- package/dist/core/capabilities/dense.js +121 -0
- package/dist/core/capabilities/dense.js.map +1 -0
- package/dist/core/capabilities/lexical.d.ts +43 -0
- package/dist/core/capabilities/lexical.d.ts.map +1 -0
- package/dist/core/capabilities/lexical.js +104 -0
- package/dist/core/capabilities/lexical.js.map +1 -0
- package/dist/core/capabilities/registry.d.ts +56 -0
- package/dist/core/capabilities/registry.d.ts.map +1 -0
- package/dist/core/capabilities/registry.js +0 -0
- package/dist/core/capabilities/registry.js.map +1 -0
- package/dist/core/capabilities/search.d.ts +47 -0
- package/dist/core/capabilities/search.d.ts.map +1 -0
- package/dist/core/capabilities/search.js +82 -0
- package/dist/core/capabilities/search.js.map +1 -0
- package/dist/core/extensions/loader.d.ts +27 -3
- package/dist/core/extensions/loader.d.ts.map +1 -1
- package/dist/core/extensions/loader.js +61 -7
- package/dist/core/extensions/loader.js.map +1 -1
- package/dist/core/extensions/plugins/authoring.d.ts +56 -9
- package/dist/core/extensions/plugins/authoring.d.ts.map +1 -1
- package/dist/core/extensions/plugins/authoring.js +0 -0
- package/dist/core/extensions/plugins/authoring.js.map +1 -1
- package/dist/core/extensions/plugins/drift.d.ts +75 -0
- package/dist/core/extensions/plugins/drift.d.ts.map +1 -0
- package/dist/core/extensions/plugins/drift.js +194 -0
- package/dist/core/extensions/plugins/drift.js.map +1 -0
- package/dist/core/extensions/plugins/formats/claude.d.ts.map +1 -1
- package/dist/core/extensions/plugins/formats/claude.js +5 -0
- package/dist/core/extensions/plugins/formats/claude.js.map +1 -1
- package/dist/core/extensions/plugins/formats/copilot.d.ts +6 -0
- package/dist/core/extensions/plugins/formats/copilot.d.ts.map +1 -1
- package/dist/core/extensions/plugins/formats/copilot.js +57 -16
- package/dist/core/extensions/plugins/formats/copilot.js.map +1 -1
- package/dist/core/extensions/plugins/formats/index.d.ts +39 -1
- package/dist/core/extensions/plugins/formats/index.d.ts.map +1 -1
- package/dist/core/extensions/plugins/formats/index.js +90 -13
- package/dist/core/extensions/plugins/formats/index.js.map +1 -1
- package/dist/core/extensions/plugins/formats/jsonManifest.d.ts +14 -0
- package/dist/core/extensions/plugins/formats/jsonManifest.d.ts.map +1 -1
- package/dist/core/extensions/plugins/formats/jsonManifest.js +37 -4
- package/dist/core/extensions/plugins/formats/jsonManifest.js.map +1 -1
- package/dist/core/extensions/plugins/formats/platform-targets.d.ts +48 -24
- package/dist/core/extensions/plugins/formats/platform-targets.d.ts.map +1 -1
- package/dist/core/extensions/plugins/formats/platform-targets.js +62 -30
- package/dist/core/extensions/plugins/formats/platform-targets.js.map +1 -1
- package/dist/core/extensions/plugins/formats/shared.d.ts +22 -0
- package/dist/core/extensions/plugins/formats/shared.d.ts.map +1 -1
- package/dist/core/extensions/plugins/formats/shared.js +81 -4
- package/dist/core/extensions/plugins/formats/shared.js.map +1 -1
- package/dist/core/extensions/plugins/formats/types.d.ts +10 -1
- package/dist/core/extensions/plugins/formats/types.d.ts.map +1 -1
- package/dist/core/extensions/plugins/formats/types.js.map +1 -1
- package/dist/core/extensions/plugins/gates.d.ts +64 -0
- package/dist/core/extensions/plugins/gates.d.ts.map +1 -0
- package/dist/core/extensions/plugins/gates.js +297 -0
- package/dist/core/extensions/plugins/gates.js.map +1 -0
- package/dist/core/extensions/plugins/hooks-bridge.d.ts +1 -1
- package/dist/core/extensions/plugins/hooks-bridge.d.ts.map +1 -1
- package/dist/core/extensions/plugins/hooks-bridge.js +12 -8
- package/dist/core/extensions/plugins/hooks-bridge.js.map +1 -1
- package/dist/core/extensions/plugins/index.d.ts +36 -5
- package/dist/core/extensions/plugins/index.d.ts.map +1 -1
- package/dist/core/extensions/plugins/index.js +66 -15
- package/dist/core/extensions/plugins/index.js.map +1 -1
- package/dist/core/extensions/plugins/install.d.ts +43 -24
- package/dist/core/extensions/plugins/install.d.ts.map +1 -1
- package/dist/core/extensions/plugins/install.js +167 -54
- package/dist/core/extensions/plugins/install.js.map +1 -1
- package/dist/core/extensions/plugins/locations.d.ts +84 -0
- package/dist/core/extensions/plugins/locations.d.ts.map +1 -0
- package/dist/core/extensions/plugins/locations.js +137 -0
- package/dist/core/extensions/plugins/locations.js.map +1 -0
- package/dist/core/extensions/plugins/manifest.d.ts +21 -1
- package/dist/core/extensions/plugins/manifest.d.ts.map +1 -1
- package/dist/core/extensions/plugins/manifest.js +2 -2
- package/dist/core/extensions/plugins/manifest.js.map +1 -1
- package/dist/core/extensions/plugins/packaging.d.ts +115 -0
- package/dist/core/extensions/plugins/packaging.d.ts.map +1 -0
- package/dist/core/extensions/plugins/packaging.js +362 -0
- package/dist/core/extensions/plugins/packaging.js.map +1 -0
- package/dist/core/extensions/plugins/smoke.d.ts +33 -0
- package/dist/core/extensions/plugins/smoke.d.ts.map +1 -0
- package/dist/core/extensions/plugins/smoke.js +231 -0
- package/dist/core/extensions/plugins/smoke.js.map +1 -0
- package/dist/core/extensions/plugins/trigger-eval.d.ts +136 -0
- package/dist/core/extensions/plugins/trigger-eval.d.ts.map +1 -0
- package/dist/core/extensions/plugins/trigger-eval.js +0 -0
- package/dist/core/extensions/plugins/trigger-eval.js.map +1 -0
- package/dist/core/package-manager.d.ts +6 -0
- package/dist/core/package-manager.d.ts.map +1 -1
- package/dist/core/package-manager.js.map +1 -1
- package/dist/core/resource-loader.d.ts +2 -0
- package/dist/core/resource-loader.d.ts.map +1 -1
- package/dist/core/resource-loader.js +15 -0
- package/dist/core/resource-loader.js.map +1 -1
- package/dist/core/settings-manager.d.ts +2 -2
- package/dist/core/settings-manager.d.ts.map +1 -1
- package/dist/core/settings-manager.js +3 -3
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/core/settings-types.d.ts +1 -1
- package/dist/core/settings-types.d.ts.map +1 -1
- package/dist/core/settings-types.js.map +1 -1
- package/dist/core/skills.d.ts +9 -0
- package/dist/core/skills.d.ts.map +1 -1
- package/dist/core/skills.js +23 -3
- package/dist/core/skills.js.map +1 -1
- package/dist/core/tools/package-plugin.d.ts +19 -0
- package/dist/core/tools/package-plugin.d.ts.map +1 -0
- package/dist/core/tools/package-plugin.js +81 -0
- package/dist/core/tools/package-plugin.js.map +1 -0
- package/dist/core/tools/plugin-tool-names.d.ts +1 -0
- package/dist/core/tools/plugin-tool-names.d.ts.map +1 -1
- package/dist/core/tools/plugin-tool-names.js +6 -0
- package/dist/core/tools/plugin-tool-names.js.map +1 -1
- package/dist/core/tools/plugins.d.ts.map +1 -1
- package/dist/core/tools/plugins.js +48 -7
- package/dist/core/tools/plugins.js.map +1 -1
- package/dist/core/tools/propose-plugin.d.ts.map +1 -1
- package/dist/core/tools/propose-plugin.js +49 -21
- package/dist/core/tools/propose-plugin.js.map +1 -1
- package/dist/core/tools/webfetch.d.ts +3 -1
- package/dist/core/tools/webfetch.d.ts.map +1 -1
- package/dist/core/tools/webfetch.js +8 -2
- package/dist/core/tools/webfetch.js.map +1 -1
- package/dist/core/tools/websearch.d.ts +2 -0
- package/dist/core/tools/websearch.d.ts.map +1 -1
- package/dist/core/tools/websearch.js +2 -1
- package/dist/core/tools/websearch.js.map +1 -1
- package/dist/core/tools/webtools-shared.d.ts +23 -2
- package/dist/core/tools/webtools-shared.d.ts.map +1 -1
- package/dist/core/tools/webtools-shared.js +59 -9
- package/dist/core/tools/webtools-shared.js.map +1 -1
- package/dist/extensions/core/marketplace.d.ts +14 -3
- package/dist/extensions/core/marketplace.d.ts.map +1 -1
- package/dist/extensions/core/marketplace.js +77 -18
- package/dist/extensions/core/marketplace.js.map +1 -1
- package/dist/extensions/core/mcp-deferred.d.ts +39 -2
- package/dist/extensions/core/mcp-deferred.d.ts.map +1 -1
- package/dist/extensions/core/mcp-deferred.js +74 -10
- package/dist/extensions/core/mcp-deferred.js.map +1 -1
- package/dist/extensions/core/mcp-loader.d.ts.map +1 -1
- package/dist/extensions/core/mcp-loader.js +52 -9
- package/dist/extensions/core/mcp-loader.js.map +1 -1
- package/dist/extensions/core/scaffold.d.ts +3 -3
- package/dist/extensions/core/scaffold.d.ts.map +1 -1
- package/dist/extensions/core/scaffold.js +9 -9
- package/dist/extensions/core/scaffold.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +25 -7
- package/dist/main.js.map +1 -1
- package/dist/utils/tools-manager.d.ts +8 -0
- package/dist/utils/tools-manager.d.ts.map +1 -1
- package/dist/utils/tools-manager.js +12 -3
- package/dist/utils/tools-manager.js.map +1 -1
- package/docs/providers.md +11 -0
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/with-deps/package.json +1 -1
- package/package.json +5 -4
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is deferring a tool's schema actually worth it?
|
|
3
|
+
*
|
|
4
|
+
* §6.3 said the threshold should be measurable rather than asserted, and until
|
|
5
|
+
* now it was neither. The question turns out not to be "how many tokens do the
|
|
6
|
+
* schemas cost" — `--print-token-surface` already answered that — but a
|
|
7
|
+
* trade-off with a term nobody had priced:
|
|
8
|
+
*
|
|
9
|
+
* Deferring withholds D tokens of schema from every request. But those tokens
|
|
10
|
+
* would have sat in the *cached* prefix, so what deferral saves per request is
|
|
11
|
+
* D at the cache-read rate, not at full price — an order of magnitude less
|
|
12
|
+
* than it looks. And the moment the model resolves a deferred tool, the
|
|
13
|
+
* harness adds it to `tools`, which renders at position 0 and invalidates the
|
|
14
|
+
* whole prefix P — so the next request pays P at the cache-*write* rate
|
|
15
|
+
* instead of the cache-read rate it would otherwise have paid.
|
|
16
|
+
*
|
|
17
|
+
* Break-even is therefore the number of requests before the first resolve:
|
|
18
|
+
*
|
|
19
|
+
* N = P × (cacheWrite − cacheRead) / (D × cacheRead)
|
|
20
|
+
*
|
|
21
|
+
* The counterintuitive consequence is worth stating plainly: **P grows with the
|
|
22
|
+
* conversation, D does not.** Deferral gets *worse* the longer a session runs,
|
|
23
|
+
* which is the opposite of the intuition that motivated it.
|
|
24
|
+
*
|
|
25
|
+
* See docs/plugin-system-architecture.md §6.3 and §8.6 item 6.
|
|
26
|
+
*/
|
|
27
|
+
/** Per-million-token prices, as carried on a model. */
|
|
28
|
+
export interface TokenPrices {
|
|
29
|
+
input: number;
|
|
30
|
+
cacheRead: number;
|
|
31
|
+
cacheWrite: number;
|
|
32
|
+
}
|
|
33
|
+
export interface DeferralInput {
|
|
34
|
+
/** Tokens of schema that would be withheld (the deferrable tools). */
|
|
35
|
+
deferredTokens: number;
|
|
36
|
+
/**
|
|
37
|
+
* Tokens in the cached prefix a resolve would invalidate: system prompt plus
|
|
38
|
+
* every tool schema, and in a live session the conversation too. Callers that
|
|
39
|
+
* only know the startup surface are supplying a floor — see `prefixIsFloor`.
|
|
40
|
+
*/
|
|
41
|
+
prefixTokens: number;
|
|
42
|
+
prices: TokenPrices;
|
|
43
|
+
}
|
|
44
|
+
export type DeferralVerdict = {
|
|
45
|
+
kind: "no-cache";
|
|
46
|
+
reason: string;
|
|
47
|
+
} | {
|
|
48
|
+
kind: "nothing-deferred";
|
|
49
|
+
reason: string;
|
|
50
|
+
} | {
|
|
51
|
+
kind: "unpriced";
|
|
52
|
+
reason: string;
|
|
53
|
+
} | {
|
|
54
|
+
kind: "break-even";
|
|
55
|
+
requestsBeforeResolve: number;
|
|
56
|
+
savingPerRequest: number;
|
|
57
|
+
resolvePenalty: number;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Price the deferral trade-off for one model.
|
|
61
|
+
*
|
|
62
|
+
* Deliberately returns a discriminated verdict rather than a bare number: three
|
|
63
|
+
* of the four outcomes mean "the break-even question does not apply here", and
|
|
64
|
+
* collapsing them into a number would invite reading 0 or Infinity as an answer.
|
|
65
|
+
*/
|
|
66
|
+
export declare function analyzeDeferral(input: DeferralInput): DeferralVerdict;
|
|
67
|
+
/**
|
|
68
|
+
* Render the verdict for `--print-token-surface`.
|
|
69
|
+
*
|
|
70
|
+
* `prefixIsFloor` marks the common case where the caller measured the startup
|
|
71
|
+
* surface only. The number is then a lower bound in the direction that matters:
|
|
72
|
+
* the real prefix is larger, so the real break-even is higher and deferral looks
|
|
73
|
+
* worse than printed, never better.
|
|
74
|
+
*/
|
|
75
|
+
export declare function formatDeferral(verdict: DeferralVerdict, options?: {
|
|
76
|
+
prefixIsFloor?: boolean;
|
|
77
|
+
}): string;
|
|
78
|
+
//# sourceMappingURL=deferral.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deferral.d.ts","sourceRoot":"","sources":["../../../src/core/capabilities/deferral.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,uDAAuD;AACvD,MAAM,WAAW,WAAW;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,aAAa;IAC7B,sEAAsE;IACtE,cAAc,EAAE,MAAM,CAAC;IACvB;;;;OAIG;IACH,YAAY,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,WAAW,CAAC;CACpB;AAED,MAAM,MAAM,eAAe,GACxB;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACpC;IAAE,IAAI,EAAE,kBAAkB,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAC5C;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACpC;IAAE,IAAI,EAAE,YAAY,CAAC;IAAC,qBAAqB,EAAE,MAAM,CAAC;IAAC,gBAAgB,EAAE,MAAM,CAAC;IAAC,cAAc,EAAE,MAAM,CAAA;CAAE,CAAC;AAO3G;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,aAAa,GAAG,eAAe,CA4BrE;AAQD;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,eAAe,EAAE,OAAO,GAAE;IAAE,aAAa,CAAC,EAAE,OAAO,CAAA;CAAO,GAAG,MAAM,CAsB1G","sourcesContent":["/**\n * Is deferring a tool's schema actually worth it?\n *\n * §6.3 said the threshold should be measurable rather than asserted, and until\n * now it was neither. The question turns out not to be \"how many tokens do the\n * schemas cost\" — `--print-token-surface` already answered that — but a\n * trade-off with a term nobody had priced:\n *\n * Deferring withholds D tokens of schema from every request. But those tokens\n * would have sat in the *cached* prefix, so what deferral saves per request is\n * D at the cache-read rate, not at full price — an order of magnitude less\n * than it looks. And the moment the model resolves a deferred tool, the\n * harness adds it to `tools`, which renders at position 0 and invalidates the\n * whole prefix P — so the next request pays P at the cache-*write* rate\n * instead of the cache-read rate it would otherwise have paid.\n *\n * Break-even is therefore the number of requests before the first resolve:\n *\n * N = P × (cacheWrite − cacheRead) / (D × cacheRead)\n *\n * The counterintuitive consequence is worth stating plainly: **P grows with the\n * conversation, D does not.** Deferral gets *worse* the longer a session runs,\n * which is the opposite of the intuition that motivated it.\n *\n * See docs/plugin-system-architecture.md §6.3 and §8.6 item 6.\n */\n\n/** Per-million-token prices, as carried on a model. */\nexport interface TokenPrices {\n\tinput: number;\n\tcacheRead: number;\n\tcacheWrite: number;\n}\n\nexport interface DeferralInput {\n\t/** Tokens of schema that would be withheld (the deferrable tools). */\n\tdeferredTokens: number;\n\t/**\n\t * Tokens in the cached prefix a resolve would invalidate: system prompt plus\n\t * every tool schema, and in a live session the conversation too. Callers that\n\t * only know the startup surface are supplying a floor — see `prefixIsFloor`.\n\t */\n\tprefixTokens: number;\n\tprices: TokenPrices;\n}\n\nexport type DeferralVerdict =\n\t| { kind: \"no-cache\"; reason: string }\n\t| { kind: \"nothing-deferred\"; reason: string }\n\t| { kind: \"unpriced\"; reason: string }\n\t| { kind: \"break-even\"; requestsBeforeResolve: number; savingPerRequest: number; resolvePenalty: number };\n\n/** Dollars for `tokens` at a per-million-token price. */\nfunction priceOf(tokens: number, perMillion: number): number {\n\treturn (tokens / 1_000_000) * perMillion;\n}\n\n/**\n * Price the deferral trade-off for one model.\n *\n * Deliberately returns a discriminated verdict rather than a bare number: three\n * of the four outcomes mean \"the break-even question does not apply here\", and\n * collapsing them into a number would invite reading 0 or Infinity as an answer.\n */\nexport function analyzeDeferral(input: DeferralInput): DeferralVerdict {\n\tconst { deferredTokens, prefixTokens, prices } = input;\n\n\tif (deferredTokens <= 0) {\n\t\treturn { kind: \"nothing-deferred\", reason: \"no deferrable tools in this configuration\" };\n\t}\n\tif (prices.input <= 0 && prices.cacheRead <= 0 && prices.cacheWrite <= 0) {\n\t\treturn { kind: \"unpriced\", reason: \"model carries no pricing, so the trade-off cannot be costed\" };\n\t}\n\t// A provider that does not price caching separately never had a cached prefix\n\t// to lose, so a resolve costs nothing extra and deferral is unambiguously\n\t// good — the saving is the full input rate on every request.\n\tif (prices.cacheRead <= 0 || prices.cacheWrite <= 0) {\n\t\treturn {\n\t\t\tkind: \"no-cache\",\n\t\t\treason: \"provider does not price prompt caching, so a resolve invalidates nothing — deferral is a pure saving\",\n\t\t};\n\t}\n\n\tconst savingPerRequest = priceOf(deferredTokens, prices.cacheRead);\n\tconst resolvePenalty = priceOf(prefixTokens, prices.cacheWrite - prices.cacheRead);\n\n\treturn {\n\t\tkind: \"break-even\",\n\t\trequestsBeforeResolve: resolvePenalty / savingPerRequest,\n\t\tsavingPerRequest,\n\t\tresolvePenalty,\n\t};\n}\n\nfunction usd(amount: number): string {\n\tif (amount === 0) return \"$0\";\n\tif (amount < 0.01) return `$${amount.toFixed(6)}`;\n\treturn `$${amount.toFixed(4)}`;\n}\n\n/**\n * Render the verdict for `--print-token-surface`.\n *\n * `prefixIsFloor` marks the common case where the caller measured the startup\n * surface only. The number is then a lower bound in the direction that matters:\n * the real prefix is larger, so the real break-even is higher and deferral looks\n * worse than printed, never better.\n */\nexport function formatDeferral(verdict: DeferralVerdict, options: { prefixIsFloor?: boolean } = {}): string {\n\tswitch (verdict.kind) {\n\t\tcase \"nothing-deferred\":\n\t\tcase \"unpriced\":\n\t\tcase \"no-cache\":\n\t\t\treturn ` deferral: ${verdict.reason}`;\n\t\tcase \"break-even\": {\n\t\t\tconst n = Math.ceil(verdict.requestsBeforeResolve);\n\t\t\tconst lines = [\n\t\t\t\t` deferral saves ${usd(verdict.savingPerRequest)}/request (withheld schema at the cache-read rate)`,\n\t\t\t\t` a resolve costs ${usd(verdict.resolvePenalty)} once (whole prefix re-written, not read)`,\n\t\t\t\t` break-even: deferral pays off only if the model waits ${n}+ request(s) before resolving`,\n\t\t\t];\n\t\t\tif (options.prefixIsFloor) {\n\t\t\t\tlines.push(\n\t\t\t\t\t\" (prefix measured at startup, so this is a floor — the conversation grows it,\",\n\t\t\t\t\t\" and with it the resolve penalty, making deferral worse as a session runs on)\",\n\t\t\t\t);\n\t\t\t}\n\t\t\treturn lines.join(\"\\n\");\n\t\t}\n\t}\n}\n"]}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is deferring a tool's schema actually worth it?
|
|
3
|
+
*
|
|
4
|
+
* §6.3 said the threshold should be measurable rather than asserted, and until
|
|
5
|
+
* now it was neither. The question turns out not to be "how many tokens do the
|
|
6
|
+
* schemas cost" — `--print-token-surface` already answered that — but a
|
|
7
|
+
* trade-off with a term nobody had priced:
|
|
8
|
+
*
|
|
9
|
+
* Deferring withholds D tokens of schema from every request. But those tokens
|
|
10
|
+
* would have sat in the *cached* prefix, so what deferral saves per request is
|
|
11
|
+
* D at the cache-read rate, not at full price — an order of magnitude less
|
|
12
|
+
* than it looks. And the moment the model resolves a deferred tool, the
|
|
13
|
+
* harness adds it to `tools`, which renders at position 0 and invalidates the
|
|
14
|
+
* whole prefix P — so the next request pays P at the cache-*write* rate
|
|
15
|
+
* instead of the cache-read rate it would otherwise have paid.
|
|
16
|
+
*
|
|
17
|
+
* Break-even is therefore the number of requests before the first resolve:
|
|
18
|
+
*
|
|
19
|
+
* N = P × (cacheWrite − cacheRead) / (D × cacheRead)
|
|
20
|
+
*
|
|
21
|
+
* The counterintuitive consequence is worth stating plainly: **P grows with the
|
|
22
|
+
* conversation, D does not.** Deferral gets *worse* the longer a session runs,
|
|
23
|
+
* which is the opposite of the intuition that motivated it.
|
|
24
|
+
*
|
|
25
|
+
* See docs/plugin-system-architecture.md §6.3 and §8.6 item 6.
|
|
26
|
+
*/
|
|
27
|
+
/** Dollars for `tokens` at a per-million-token price. */
|
|
28
|
+
function priceOf(tokens, perMillion) {
|
|
29
|
+
return (tokens / 1_000_000) * perMillion;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Price the deferral trade-off for one model.
|
|
33
|
+
*
|
|
34
|
+
* Deliberately returns a discriminated verdict rather than a bare number: three
|
|
35
|
+
* of the four outcomes mean "the break-even question does not apply here", and
|
|
36
|
+
* collapsing them into a number would invite reading 0 or Infinity as an answer.
|
|
37
|
+
*/
|
|
38
|
+
export function analyzeDeferral(input) {
|
|
39
|
+
const { deferredTokens, prefixTokens, prices } = input;
|
|
40
|
+
if (deferredTokens <= 0) {
|
|
41
|
+
return { kind: "nothing-deferred", reason: "no deferrable tools in this configuration" };
|
|
42
|
+
}
|
|
43
|
+
if (prices.input <= 0 && prices.cacheRead <= 0 && prices.cacheWrite <= 0) {
|
|
44
|
+
return { kind: "unpriced", reason: "model carries no pricing, so the trade-off cannot be costed" };
|
|
45
|
+
}
|
|
46
|
+
// A provider that does not price caching separately never had a cached prefix
|
|
47
|
+
// to lose, so a resolve costs nothing extra and deferral is unambiguously
|
|
48
|
+
// good — the saving is the full input rate on every request.
|
|
49
|
+
if (prices.cacheRead <= 0 || prices.cacheWrite <= 0) {
|
|
50
|
+
return {
|
|
51
|
+
kind: "no-cache",
|
|
52
|
+
reason: "provider does not price prompt caching, so a resolve invalidates nothing — deferral is a pure saving",
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const savingPerRequest = priceOf(deferredTokens, prices.cacheRead);
|
|
56
|
+
const resolvePenalty = priceOf(prefixTokens, prices.cacheWrite - prices.cacheRead);
|
|
57
|
+
return {
|
|
58
|
+
kind: "break-even",
|
|
59
|
+
requestsBeforeResolve: resolvePenalty / savingPerRequest,
|
|
60
|
+
savingPerRequest,
|
|
61
|
+
resolvePenalty,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function usd(amount) {
|
|
65
|
+
if (amount === 0)
|
|
66
|
+
return "$0";
|
|
67
|
+
if (amount < 0.01)
|
|
68
|
+
return `$${amount.toFixed(6)}`;
|
|
69
|
+
return `$${amount.toFixed(4)}`;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Render the verdict for `--print-token-surface`.
|
|
73
|
+
*
|
|
74
|
+
* `prefixIsFloor` marks the common case where the caller measured the startup
|
|
75
|
+
* surface only. The number is then a lower bound in the direction that matters:
|
|
76
|
+
* the real prefix is larger, so the real break-even is higher and deferral looks
|
|
77
|
+
* worse than printed, never better.
|
|
78
|
+
*/
|
|
79
|
+
export function formatDeferral(verdict, options = {}) {
|
|
80
|
+
switch (verdict.kind) {
|
|
81
|
+
case "nothing-deferred":
|
|
82
|
+
case "unpriced":
|
|
83
|
+
case "no-cache":
|
|
84
|
+
return ` deferral: ${verdict.reason}`;
|
|
85
|
+
case "break-even": {
|
|
86
|
+
const n = Math.ceil(verdict.requestsBeforeResolve);
|
|
87
|
+
const lines = [
|
|
88
|
+
` deferral saves ${usd(verdict.savingPerRequest)}/request (withheld schema at the cache-read rate)`,
|
|
89
|
+
` a resolve costs ${usd(verdict.resolvePenalty)} once (whole prefix re-written, not read)`,
|
|
90
|
+
` break-even: deferral pays off only if the model waits ${n}+ request(s) before resolving`,
|
|
91
|
+
];
|
|
92
|
+
if (options.prefixIsFloor) {
|
|
93
|
+
lines.push(" (prefix measured at startup, so this is a floor — the conversation grows it,", " and with it the resolve penalty, making deferral worse as a session runs on)");
|
|
94
|
+
}
|
|
95
|
+
return lines.join("\n");
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
//# sourceMappingURL=deferral.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deferral.js","sourceRoot":"","sources":["../../../src/core/capabilities/deferral.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AA2BH,yDAAyD;AACzD,SAAS,OAAO,CAAC,MAAc,EAAE,UAAkB,EAAU;IAC5D,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,GAAG,UAAU,CAAC;AAAA,CACzC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,eAAe,CAAC,KAAoB,EAAmB;IACtE,MAAM,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,EAAE,GAAG,KAAK,CAAC;IAEvD,IAAI,cAAc,IAAI,CAAC,EAAE,CAAC;QACzB,OAAO,EAAE,IAAI,EAAE,kBAAkB,EAAE,MAAM,EAAE,2CAA2C,EAAE,CAAC;IAC1F,CAAC;IACD,IAAI,MAAM,CAAC,KAAK,IAAI,CAAC,IAAI,MAAM,CAAC,SAAS,IAAI,CAAC,IAAI,MAAM,CAAC,UAAU,IAAI,CAAC,EAAE,CAAC;QAC1E,OAAO,EAAE,IAAI,EAAE,UAAU,EAAE,MAAM,EAAE,6DAA6D,EAAE,CAAC;IACpG,CAAC;IACD,8EAA8E;IAC9E,0EAA0E;IAC1E,+DAA6D;IAC7D,IAAI,MAAM,CAAC,SAAS,IAAI,CAAC,IAAI,MAAM,CAAC,UAAU,IAAI,CAAC,EAAE,CAAC;QACrD,OAAO;YACN,IAAI,EAAE,UAAU;YAChB,MAAM,EAAE,wGAAsG;SAC9G,CAAC;IACH,CAAC;IAED,MAAM,gBAAgB,GAAG,OAAO,CAAC,cAAc,EAAE,MAAM,CAAC,SAAS,CAAC,CAAC;IACnE,MAAM,cAAc,GAAG,OAAO,CAAC,YAAY,EAAE,MAAM,CAAC,UAAU,GAAG,MAAM,CAAC,SAAS,CAAC,CAAC;IAEnF,OAAO;QACN,IAAI,EAAE,YAAY;QAClB,qBAAqB,EAAE,cAAc,GAAG,gBAAgB;QACxD,gBAAgB;QAChB,cAAc;KACd,CAAC;AAAA,CACF;AAED,SAAS,GAAG,CAAC,MAAc,EAAU;IACpC,IAAI,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IAC9B,IAAI,MAAM,GAAG,IAAI;QAAE,OAAO,IAAI,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC;IAClD,OAAO,IAAI,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC;AAAA,CAC/B;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,cAAc,CAAC,OAAwB,EAAE,OAAO,GAAgC,EAAE,EAAU;IAC3G,QAAQ,OAAO,CAAC,IAAI,EAAE,CAAC;QACtB,KAAK,kBAAkB,CAAC;QACxB,KAAK,UAAU,CAAC;QAChB,KAAK,UAAU;YACd,OAAO,eAAe,OAAO,CAAC,MAAM,EAAE,CAAC;QACxC,KAAK,YAAY,EAAE,CAAC;YACnB,MAAM,CAAC,GAAG,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,qBAAqB,CAAC,CAAC;YACnD,MAAM,KAAK,GAAG;gBACb,oBAAoB,GAAG,CAAC,OAAO,CAAC,gBAAgB,CAAC,mDAAmD;gBACpG,qBAAqB,GAAG,CAAC,OAAO,CAAC,cAAc,CAAC,2CAA2C;gBAC3F,2DAA2D,CAAC,+BAA+B;aAC3F,CAAC;YACF,IAAI,OAAO,CAAC,aAAa,EAAE,CAAC;gBAC3B,KAAK,CAAC,IAAI,CACT,kFAAgF,EAChF,iFAAiF,CACjF,CAAC;YACH,CAAC;YACD,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACzB,CAAC;IACF,CAAC;AAAA,CACD","sourcesContent":["/**\n * Is deferring a tool's schema actually worth it?\n *\n * §6.3 said the threshold should be measurable rather than asserted, and until\n * now it was neither. The question turns out not to be \"how many tokens do the\n * schemas cost\" — `--print-token-surface` already answered that — but a\n * trade-off with a term nobody had priced:\n *\n * Deferring withholds D tokens of schema from every request. But those tokens\n * would have sat in the *cached* prefix, so what deferral saves per request is\n * D at the cache-read rate, not at full price — an order of magnitude less\n * than it looks. And the moment the model resolves a deferred tool, the\n * harness adds it to `tools`, which renders at position 0 and invalidates the\n * whole prefix P — so the next request pays P at the cache-*write* rate\n * instead of the cache-read rate it would otherwise have paid.\n *\n * Break-even is therefore the number of requests before the first resolve:\n *\n * N = P × (cacheWrite − cacheRead) / (D × cacheRead)\n *\n * The counterintuitive consequence is worth stating plainly: **P grows with the\n * conversation, D does not.** Deferral gets *worse* the longer a session runs,\n * which is the opposite of the intuition that motivated it.\n *\n * See docs/plugin-system-architecture.md §6.3 and §8.6 item 6.\n */\n\n/** Per-million-token prices, as carried on a model. */\nexport interface TokenPrices {\n\tinput: number;\n\tcacheRead: number;\n\tcacheWrite: number;\n}\n\nexport interface DeferralInput {\n\t/** Tokens of schema that would be withheld (the deferrable tools). */\n\tdeferredTokens: number;\n\t/**\n\t * Tokens in the cached prefix a resolve would invalidate: system prompt plus\n\t * every tool schema, and in a live session the conversation too. Callers that\n\t * only know the startup surface are supplying a floor — see `prefixIsFloor`.\n\t */\n\tprefixTokens: number;\n\tprices: TokenPrices;\n}\n\nexport type DeferralVerdict =\n\t| { kind: \"no-cache\"; reason: string }\n\t| { kind: \"nothing-deferred\"; reason: string }\n\t| { kind: \"unpriced\"; reason: string }\n\t| { kind: \"break-even\"; requestsBeforeResolve: number; savingPerRequest: number; resolvePenalty: number };\n\n/** Dollars for `tokens` at a per-million-token price. */\nfunction priceOf(tokens: number, perMillion: number): number {\n\treturn (tokens / 1_000_000) * perMillion;\n}\n\n/**\n * Price the deferral trade-off for one model.\n *\n * Deliberately returns a discriminated verdict rather than a bare number: three\n * of the four outcomes mean \"the break-even question does not apply here\", and\n * collapsing them into a number would invite reading 0 or Infinity as an answer.\n */\nexport function analyzeDeferral(input: DeferralInput): DeferralVerdict {\n\tconst { deferredTokens, prefixTokens, prices } = input;\n\n\tif (deferredTokens <= 0) {\n\t\treturn { kind: \"nothing-deferred\", reason: \"no deferrable tools in this configuration\" };\n\t}\n\tif (prices.input <= 0 && prices.cacheRead <= 0 && prices.cacheWrite <= 0) {\n\t\treturn { kind: \"unpriced\", reason: \"model carries no pricing, so the trade-off cannot be costed\" };\n\t}\n\t// A provider that does not price caching separately never had a cached prefix\n\t// to lose, so a resolve costs nothing extra and deferral is unambiguously\n\t// good — the saving is the full input rate on every request.\n\tif (prices.cacheRead <= 0 || prices.cacheWrite <= 0) {\n\t\treturn {\n\t\t\tkind: \"no-cache\",\n\t\t\treason: \"provider does not price prompt caching, so a resolve invalidates nothing — deferral is a pure saving\",\n\t\t};\n\t}\n\n\tconst savingPerRequest = priceOf(deferredTokens, prices.cacheRead);\n\tconst resolvePenalty = priceOf(prefixTokens, prices.cacheWrite - prices.cacheRead);\n\n\treturn {\n\t\tkind: \"break-even\",\n\t\trequestsBeforeResolve: resolvePenalty / savingPerRequest,\n\t\tsavingPerRequest,\n\t\tresolvePenalty,\n\t};\n}\n\nfunction usd(amount: number): string {\n\tif (amount === 0) return \"$0\";\n\tif (amount < 0.01) return `$${amount.toFixed(6)}`;\n\treturn `$${amount.toFixed(4)}`;\n}\n\n/**\n * Render the verdict for `--print-token-surface`.\n *\n * `prefixIsFloor` marks the common case where the caller measured the startup\n * surface only. The number is then a lower bound in the direction that matters:\n * the real prefix is larger, so the real break-even is higher and deferral looks\n * worse than printed, never better.\n */\nexport function formatDeferral(verdict: DeferralVerdict, options: { prefixIsFloor?: boolean } = {}): string {\n\tswitch (verdict.kind) {\n\t\tcase \"nothing-deferred\":\n\t\tcase \"unpriced\":\n\t\tcase \"no-cache\":\n\t\t\treturn ` deferral: ${verdict.reason}`;\n\t\tcase \"break-even\": {\n\t\t\tconst n = Math.ceil(verdict.requestsBeforeResolve);\n\t\t\tconst lines = [\n\t\t\t\t` deferral saves ${usd(verdict.savingPerRequest)}/request (withheld schema at the cache-read rate)`,\n\t\t\t\t` a resolve costs ${usd(verdict.resolvePenalty)} once (whole prefix re-written, not read)`,\n\t\t\t\t` break-even: deferral pays off only if the model waits ${n}+ request(s) before resolving`,\n\t\t\t];\n\t\t\tif (options.prefixIsFloor) {\n\t\t\t\tlines.push(\n\t\t\t\t\t\" (prefix measured at startup, so this is a floor — the conversation grows it,\",\n\t\t\t\t\t\" and with it the resolve penalty, making deferral worse as a session runs on)\",\n\t\t\t\t);\n\t\t\t}\n\t\t\treturn lines.join(\"\\n\");\n\t\t}\n\t}\n}\n"]}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional dense leg for capability retrieval.
|
|
3
|
+
*
|
|
4
|
+
* A separate embsearch store from the repo index, for two reasons that both had
|
|
5
|
+
* to hold. The repo service goes **dormant below a byte threshold**, and a
|
|
6
|
+
* capability index that stops working in a small repo is worse than none — the
|
|
7
|
+
* whole point is finding tools, which have nothing to do with how much code is
|
|
8
|
+
* checked out. And the lifecycles do not line up: the repo index invalidates on
|
|
9
|
+
* file edits, this one on a server appearing or a plugin being installed.
|
|
10
|
+
*
|
|
11
|
+
* Everything here is best-effort. It never downloads the binary — it uses one
|
|
12
|
+
* already on disk, and if there is none, retrieval is lexical-only and says so
|
|
13
|
+
* rather than waiting. Nothing on this path may block a session or a tool call.
|
|
14
|
+
*
|
|
15
|
+
* See docs/plugin-system-architecture.md §6.2.
|
|
16
|
+
*/
|
|
17
|
+
import { type CapabilityDoc } from "./registry.js";
|
|
18
|
+
/** Where a capability index for a given capability-set hash lives. */
|
|
19
|
+
export declare function capabilityStoreDir(hash: string, agentDir?: string): string;
|
|
20
|
+
export interface DenseHit {
|
|
21
|
+
id: string;
|
|
22
|
+
score: number;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* State of the dense leg, as reported to callers so they can say *why* a search
|
|
26
|
+
* was lexical-only rather than silently returning fewer results.
|
|
27
|
+
*/
|
|
28
|
+
export type DenseState = {
|
|
29
|
+
status: "off";
|
|
30
|
+
reason: string;
|
|
31
|
+
} | {
|
|
32
|
+
status: "building";
|
|
33
|
+
} | {
|
|
34
|
+
status: "ready";
|
|
35
|
+
count: number;
|
|
36
|
+
};
|
|
37
|
+
export declare function denseState(): DenseState;
|
|
38
|
+
/**
|
|
39
|
+
* Bring the dense index up for `docs`, if it can be. Idempotent, and a no-op
|
|
40
|
+
* when the same capability set is already indexed.
|
|
41
|
+
*
|
|
42
|
+
* Returns once the index is usable or has been ruled out. Callers that must not
|
|
43
|
+
* wait should not await it — {@link denseSearch} works the moment it is ready
|
|
44
|
+
* and returns nothing before that.
|
|
45
|
+
*/
|
|
46
|
+
export declare function ensureDenseIndex(docs: readonly CapabilityDoc[], agentDir?: string): Promise<void>;
|
|
47
|
+
/** Top `k` dense hits, or nothing when the leg is not ready. Never throws. */
|
|
48
|
+
export declare function denseSearch(query: string, k?: number): Promise<DenseHit[]>;
|
|
49
|
+
/** Shut the daemon down and forget the index. Session teardown, and tests. */
|
|
50
|
+
export declare function disposeDenseIndex(): Promise<void>;
|
|
51
|
+
//# sourceMappingURL=dense.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dense.d.ts","sourceRoot":"","sources":["../../../src/core/capabilities/dense.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAMH,OAAO,EAAE,KAAK,aAAa,EAAqB,MAAM,eAAe,CAAC;AAKtE,sEAAsE;AACtE,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,GAAE,MAAsB,GAAG,MAAM,CAEzF;AAED,MAAM,WAAW,QAAQ;IACxB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;CACd;AAED;;;GAGG;AACH,MAAM,MAAM,UAAU,GACnB;IAAE,MAAM,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACjC;IAAE,MAAM,EAAE,UAAU,CAAA;CAAE,GACtB;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAOtC,wBAAgB,UAAU,IAAI,UAAU,CAEvC;AAED;;;;;;;GAOG;AACH,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,SAAS,aAAa,EAAE,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAoDvG;AAED,8EAA8E;AAC9E,wBAAsB,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,CAAC,SAAK,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC,CAO5E;AAQD,8EAA8E;AAC9E,wBAAsB,iBAAiB,IAAI,OAAO,CAAC,IAAI,CAAC,CAKvD","sourcesContent":["/**\n * Optional dense leg for capability retrieval.\n *\n * A separate embsearch store from the repo index, for two reasons that both had\n * to hold. The repo service goes **dormant below a byte threshold**, and a\n * capability index that stops working in a small repo is worse than none — the\n * whole point is finding tools, which have nothing to do with how much code is\n * checked out. And the lifecycles do not line up: the repo index invalidates on\n * file edits, this one on a server appearing or a plugin being installed.\n *\n * Everything here is best-effort. It never downloads the binary — it uses one\n * already on disk, and if there is none, retrieval is lexical-only and says so\n * rather than waiting. Nothing on this path may block a session or a tool call.\n *\n * See docs/plugin-system-architecture.md §6.2.\n */\n\nimport { join } from \"node:path\";\nimport { getAgentDir } from \"../../config.js\";\nimport { getToolPath } from \"../../utils/tools-manager.js\";\nimport { EmbSearchClient } from \"../embsearch/client.js\";\nimport { type CapabilityDoc, capabilitySetHash } from \"./registry.js\";\n\n/** The Rust mock backend's model id — semantically meaningless, never index with it. */\nconst MOCK_MODEL_ID = \"mock-hash-v1\";\n\n/** Where a capability index for a given capability-set hash lives. */\nexport function capabilityStoreDir(hash: string, agentDir: string = getAgentDir()): string {\n\treturn join(agentDir, \"capability-index\", hash);\n}\n\nexport interface DenseHit {\n\tid: string;\n\tscore: number;\n}\n\n/**\n * State of the dense leg, as reported to callers so they can say *why* a search\n * was lexical-only rather than silently returning fewer results.\n */\nexport type DenseState =\n\t| { status: \"off\"; reason: string }\n\t| { status: \"building\" }\n\t| { status: \"ready\"; count: number };\n\nlet client: EmbSearchClient | undefined;\nlet state: DenseState = { status: \"off\", reason: \"not started\" };\nlet building: Promise<void> | undefined;\nlet indexedHash: string | undefined;\n\nexport function denseState(): DenseState {\n\treturn state;\n}\n\n/**\n * Bring the dense index up for `docs`, if it can be. Idempotent, and a no-op\n * when the same capability set is already indexed.\n *\n * Returns once the index is usable or has been ruled out. Callers that must not\n * wait should not await it — {@link denseSearch} works the moment it is ready\n * and returns nothing before that.\n */\nexport async function ensureDenseIndex(docs: readonly CapabilityDoc[], agentDir?: string): Promise<void> {\n\tconst hash = capabilitySetHash(docs);\n\tif (indexedHash === hash && state.status === \"ready\") return;\n\tif (building) return building;\n\n\tbuilding = (async () => {\n\t\ttry {\n\t\t\tconst binary = getToolPath(\"embsearch\");\n\t\t\tif (!binary) {\n\t\t\t\t// Deliberately not `ensureTool`: downloading a model runtime because\n\t\t\t\t// someone has MCP servers installed is not a trade the user agreed to.\n\t\t\t\tstate = { status: \"off\", reason: \"embsearch binary not installed\" };\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (docs.length === 0) {\n\t\t\t\tstate = { status: \"off\", reason: \"no capabilities registered\" };\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tstate = { status: \"building\" };\n\t\t\tawait closeClient();\n\t\t\tconst next = new EmbSearchClient({\n\t\t\t\tbinaryPath: binary,\n\t\t\t\tstorePath: capabilityStoreDir(hash, agentDir),\n\t\t\t\thybrid: false, // The lexical leg is ours; the daemon only provides dense.\n\t\t\t});\n\t\t\tconst info = await next.info();\n\t\t\tif (info.modelId === MOCK_MODEL_ID) {\n\t\t\t\t// The mock embedder returns hashes, not semantics. Indexing with it\n\t\t\t\t// would produce a store that looks healthy and ranks at random.\n\t\t\t\tawait next.close().catch(() => {});\n\t\t\t\tstate = { status: \"off\", reason: \"embsearch is using the mock embedder\" };\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// The store is keyed on the content hash, so a hit means these exact\n\t\t\t// documents were embedded before and nothing needs re-embedding.\n\t\t\tif (info.count < docs.length) {\n\t\t\t\tawait next.bulk(docs.map((d) => ({ id: d.id, text: `${d.name}. ${d.description}` })));\n\t\t\t\tawait next.save();\n\t\t\t}\n\t\t\tclient = next;\n\t\t\tindexedHash = hash;\n\t\t\tstate = { status: \"ready\", count: docs.length };\n\t\t} catch (error) {\n\t\t\tstate = { status: \"off\", reason: `embsearch unavailable: ${(error as Error).message}` };\n\t\t\tawait closeClient();\n\t\t} finally {\n\t\t\tbuilding = undefined;\n\t\t}\n\t})();\n\treturn building;\n}\n\n/** Top `k` dense hits, or nothing when the leg is not ready. Never throws. */\nexport async function denseSearch(query: string, k = 10): Promise<DenseHit[]> {\n\tif (!client || state.status !== \"ready\") return [];\n\ttry {\n\t\treturn await client.query(query, k, \"dense\");\n\t} catch {\n\t\treturn [];\n\t}\n}\n\nasync function closeClient(): Promise<void> {\n\tconst current = client;\n\tclient = undefined;\n\tif (current) await current.close().catch(() => {});\n}\n\n/** Shut the daemon down and forget the index. Session teardown, and tests. */\nexport async function disposeDenseIndex(): Promise<void> {\n\tawait closeClient();\n\tindexedHash = undefined;\n\tbuilding = undefined;\n\tstate = { status: \"off\", reason: \"not started\" };\n}\n"]}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional dense leg for capability retrieval.
|
|
3
|
+
*
|
|
4
|
+
* A separate embsearch store from the repo index, for two reasons that both had
|
|
5
|
+
* to hold. The repo service goes **dormant below a byte threshold**, and a
|
|
6
|
+
* capability index that stops working in a small repo is worse than none — the
|
|
7
|
+
* whole point is finding tools, which have nothing to do with how much code is
|
|
8
|
+
* checked out. And the lifecycles do not line up: the repo index invalidates on
|
|
9
|
+
* file edits, this one on a server appearing or a plugin being installed.
|
|
10
|
+
*
|
|
11
|
+
* Everything here is best-effort. It never downloads the binary — it uses one
|
|
12
|
+
* already on disk, and if there is none, retrieval is lexical-only and says so
|
|
13
|
+
* rather than waiting. Nothing on this path may block a session or a tool call.
|
|
14
|
+
*
|
|
15
|
+
* See docs/plugin-system-architecture.md §6.2.
|
|
16
|
+
*/
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { getAgentDir } from "../../config.js";
|
|
19
|
+
import { getToolPath } from "../../utils/tools-manager.js";
|
|
20
|
+
import { EmbSearchClient } from "../embsearch/client.js";
|
|
21
|
+
import { capabilitySetHash } from "./registry.js";
|
|
22
|
+
/** The Rust mock backend's model id — semantically meaningless, never index with it. */
|
|
23
|
+
const MOCK_MODEL_ID = "mock-hash-v1";
|
|
24
|
+
/** Where a capability index for a given capability-set hash lives. */
|
|
25
|
+
export function capabilityStoreDir(hash, agentDir = getAgentDir()) {
|
|
26
|
+
return join(agentDir, "capability-index", hash);
|
|
27
|
+
}
|
|
28
|
+
let client;
|
|
29
|
+
let state = { status: "off", reason: "not started" };
|
|
30
|
+
let building;
|
|
31
|
+
let indexedHash;
|
|
32
|
+
export function denseState() {
|
|
33
|
+
return state;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Bring the dense index up for `docs`, if it can be. Idempotent, and a no-op
|
|
37
|
+
* when the same capability set is already indexed.
|
|
38
|
+
*
|
|
39
|
+
* Returns once the index is usable or has been ruled out. Callers that must not
|
|
40
|
+
* wait should not await it — {@link denseSearch} works the moment it is ready
|
|
41
|
+
* and returns nothing before that.
|
|
42
|
+
*/
|
|
43
|
+
export async function ensureDenseIndex(docs, agentDir) {
|
|
44
|
+
const hash = capabilitySetHash(docs);
|
|
45
|
+
if (indexedHash === hash && state.status === "ready")
|
|
46
|
+
return;
|
|
47
|
+
if (building)
|
|
48
|
+
return building;
|
|
49
|
+
building = (async () => {
|
|
50
|
+
try {
|
|
51
|
+
const binary = getToolPath("embsearch");
|
|
52
|
+
if (!binary) {
|
|
53
|
+
// Deliberately not `ensureTool`: downloading a model runtime because
|
|
54
|
+
// someone has MCP servers installed is not a trade the user agreed to.
|
|
55
|
+
state = { status: "off", reason: "embsearch binary not installed" };
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (docs.length === 0) {
|
|
59
|
+
state = { status: "off", reason: "no capabilities registered" };
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
state = { status: "building" };
|
|
63
|
+
await closeClient();
|
|
64
|
+
const next = new EmbSearchClient({
|
|
65
|
+
binaryPath: binary,
|
|
66
|
+
storePath: capabilityStoreDir(hash, agentDir),
|
|
67
|
+
hybrid: false, // The lexical leg is ours; the daemon only provides dense.
|
|
68
|
+
});
|
|
69
|
+
const info = await next.info();
|
|
70
|
+
if (info.modelId === MOCK_MODEL_ID) {
|
|
71
|
+
// The mock embedder returns hashes, not semantics. Indexing with it
|
|
72
|
+
// would produce a store that looks healthy and ranks at random.
|
|
73
|
+
await next.close().catch(() => { });
|
|
74
|
+
state = { status: "off", reason: "embsearch is using the mock embedder" };
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
// The store is keyed on the content hash, so a hit means these exact
|
|
78
|
+
// documents were embedded before and nothing needs re-embedding.
|
|
79
|
+
if (info.count < docs.length) {
|
|
80
|
+
await next.bulk(docs.map((d) => ({ id: d.id, text: `${d.name}. ${d.description}` })));
|
|
81
|
+
await next.save();
|
|
82
|
+
}
|
|
83
|
+
client = next;
|
|
84
|
+
indexedHash = hash;
|
|
85
|
+
state = { status: "ready", count: docs.length };
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
state = { status: "off", reason: `embsearch unavailable: ${error.message}` };
|
|
89
|
+
await closeClient();
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
building = undefined;
|
|
93
|
+
}
|
|
94
|
+
})();
|
|
95
|
+
return building;
|
|
96
|
+
}
|
|
97
|
+
/** Top `k` dense hits, or nothing when the leg is not ready. Never throws. */
|
|
98
|
+
export async function denseSearch(query, k = 10) {
|
|
99
|
+
if (!client || state.status !== "ready")
|
|
100
|
+
return [];
|
|
101
|
+
try {
|
|
102
|
+
return await client.query(query, k, "dense");
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async function closeClient() {
|
|
109
|
+
const current = client;
|
|
110
|
+
client = undefined;
|
|
111
|
+
if (current)
|
|
112
|
+
await current.close().catch(() => { });
|
|
113
|
+
}
|
|
114
|
+
/** Shut the daemon down and forget the index. Session teardown, and tests. */
|
|
115
|
+
export async function disposeDenseIndex() {
|
|
116
|
+
await closeClient();
|
|
117
|
+
indexedHash = undefined;
|
|
118
|
+
building = undefined;
|
|
119
|
+
state = { status: "off", reason: "not started" };
|
|
120
|
+
}
|
|
121
|
+
//# sourceMappingURL=dense.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dense.js","sourceRoot":"","sources":["../../../src/core/capabilities/dense.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Optional dense leg for capability retrieval.\n *\n * A separate embsearch store from the repo index, for two reasons that both had\n * to hold. The repo service goes **dormant below a byte threshold**, and a\n * capability index that stops working in a small repo is worse than none — the\n * whole point is finding tools, which have nothing to do with how much code is\n * checked out. And the lifecycles do not line up: the repo index invalidates on\n * file edits, this one on a server appearing or a plugin being installed.\n *\n * Everything here is best-effort. It never downloads the binary — it uses one\n * already on disk, and if there is none, retrieval is lexical-only and says so\n * rather than waiting. Nothing on this path may block a session or a tool call.\n *\n * See docs/plugin-system-architecture.md §6.2.\n */\n\nimport { join } from \"node:path\";\nimport { getAgentDir } from \"../../config.js\";\nimport { getToolPath } from \"../../utils/tools-manager.js\";\nimport { EmbSearchClient } from \"../embsearch/client.js\";\nimport { type CapabilityDoc, capabilitySetHash } from \"./registry.js\";\n\n/** The Rust mock backend's model id — semantically meaningless, never index with it. */\nconst MOCK_MODEL_ID = \"mock-hash-v1\";\n\n/** Where a capability index for a given capability-set hash lives. */\nexport function capabilityStoreDir(hash: string, agentDir: string = getAgentDir()): string {\n\treturn join(agentDir, \"capability-index\", hash);\n}\n\nexport interface DenseHit {\n\tid: string;\n\tscore: number;\n}\n\n/**\n * State of the dense leg, as reported to callers so they can say *why* a search\n * was lexical-only rather than silently returning fewer results.\n */\nexport type DenseState =\n\t| { status: \"off\"; reason: string }\n\t| { status: \"building\" }\n\t| { status: \"ready\"; count: number };\n\nlet client: EmbSearchClient | undefined;\nlet state: DenseState = { status: \"off\", reason: \"not started\" };\nlet building: Promise<void> | undefined;\nlet indexedHash: string | undefined;\n\nexport function denseState(): DenseState {\n\treturn state;\n}\n\n/**\n * Bring the dense index up for `docs`, if it can be. Idempotent, and a no-op\n * when the same capability set is already indexed.\n *\n * Returns once the index is usable or has been ruled out. Callers that must not\n * wait should not await it — {@link denseSearch} works the moment it is ready\n * and returns nothing before that.\n */\nexport async function ensureDenseIndex(docs: readonly CapabilityDoc[], agentDir?: string): Promise<void> {\n\tconst hash = capabilitySetHash(docs);\n\tif (indexedHash === hash && state.status === \"ready\") return;\n\tif (building) return building;\n\n\tbuilding = (async () => {\n\t\ttry {\n\t\t\tconst binary = getToolPath(\"embsearch\");\n\t\t\tif (!binary) {\n\t\t\t\t// Deliberately not `ensureTool`: downloading a model runtime because\n\t\t\t\t// someone has MCP servers installed is not a trade the user agreed to.\n\t\t\t\tstate = { status: \"off\", reason: \"embsearch binary not installed\" };\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (docs.length === 0) {\n\t\t\t\tstate = { status: \"off\", reason: \"no capabilities registered\" };\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tstate = { status: \"building\" };\n\t\t\tawait closeClient();\n\t\t\tconst next = new EmbSearchClient({\n\t\t\t\tbinaryPath: binary,\n\t\t\t\tstorePath: capabilityStoreDir(hash, agentDir),\n\t\t\t\thybrid: false, // The lexical leg is ours; the daemon only provides dense.\n\t\t\t});\n\t\t\tconst info = await next.info();\n\t\t\tif (info.modelId === MOCK_MODEL_ID) {\n\t\t\t\t// The mock embedder returns hashes, not semantics. Indexing with it\n\t\t\t\t// would produce a store that looks healthy and ranks at random.\n\t\t\t\tawait next.close().catch(() => {});\n\t\t\t\tstate = { status: \"off\", reason: \"embsearch is using the mock embedder\" };\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// The store is keyed on the content hash, so a hit means these exact\n\t\t\t// documents were embedded before and nothing needs re-embedding.\n\t\t\tif (info.count < docs.length) {\n\t\t\t\tawait next.bulk(docs.map((d) => ({ id: d.id, text: `${d.name}. ${d.description}` })));\n\t\t\t\tawait next.save();\n\t\t\t}\n\t\t\tclient = next;\n\t\t\tindexedHash = hash;\n\t\t\tstate = { status: \"ready\", count: docs.length };\n\t\t} catch (error) {\n\t\t\tstate = { status: \"off\", reason: `embsearch unavailable: ${(error as Error).message}` };\n\t\t\tawait closeClient();\n\t\t} finally {\n\t\t\tbuilding = undefined;\n\t\t}\n\t})();\n\treturn building;\n}\n\n/** Top `k` dense hits, or nothing when the leg is not ready. Never throws. */\nexport async function denseSearch(query: string, k = 10): Promise<DenseHit[]> {\n\tif (!client || state.status !== \"ready\") return [];\n\ttry {\n\t\treturn await client.query(query, k, \"dense\");\n\t} catch {\n\t\treturn [];\n\t}\n}\n\nasync function closeClient(): Promise<void> {\n\tconst current = client;\n\tclient = undefined;\n\tif (current) await current.close().catch(() => {});\n}\n\n/** Shut the daemon down and forget the index. Session teardown, and tests. */\nexport async function disposeDenseIndex(): Promise<void> {\n\tawait closeClient();\n\tindexedHash = undefined;\n\tbuilding = undefined;\n\tstate = { status: \"off\", reason: \"not started\" };\n}\n"]}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory BM25 over the capability index — the leg that always works.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately not the repo's lexical retriever: that one shells out to ripgrep
|
|
5
|
+
* over files on disk, and capabilities are a few hundred short strings held in
|
|
6
|
+
* memory. It is also not optional. The dense leg needs a binary that may not be
|
|
7
|
+
* installed and a store that may still be building, so if retrieval depended on
|
|
8
|
+
* it, "find me a tool that sends email" would work on some machines and not
|
|
9
|
+
* others. This leg makes the floor deterministic and dependency-free; dense is
|
|
10
|
+
* strictly additive on top.
|
|
11
|
+
*
|
|
12
|
+
* The tokenizer does the load-bearing work here. Tool names are the query terms
|
|
13
|
+
* that matter most and they arrive as `mcp_github_create_pull_request` or
|
|
14
|
+
* `createPullRequest`, so a naive whitespace split would make the single most
|
|
15
|
+
* common query shape — a name the model half-remembers — the one thing BM25
|
|
16
|
+
* cannot match.
|
|
17
|
+
*/
|
|
18
|
+
import type { CapabilityDoc } from "./registry.js";
|
|
19
|
+
/**
|
|
20
|
+
* Split identifiers the way a person reads them: `mcp_github_create_pr` and
|
|
21
|
+
* `createPullRequest` both yield their parts, and the original token is kept so
|
|
22
|
+
* an exact name still scores as an exact match.
|
|
23
|
+
*/
|
|
24
|
+
export declare function tokenize(text: string): string[];
|
|
25
|
+
export interface LexicalHit {
|
|
26
|
+
id: string;
|
|
27
|
+
score: number;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A built BM25 index. Cheap enough to rebuild whenever the capability set
|
|
31
|
+
* changes — a few hundred short documents — so there is no invalidation story
|
|
32
|
+
* to get wrong.
|
|
33
|
+
*/
|
|
34
|
+
export declare class LexicalIndex {
|
|
35
|
+
private readonly postings;
|
|
36
|
+
private readonly docFreq;
|
|
37
|
+
private readonly avgLength;
|
|
38
|
+
constructor(docs: readonly CapabilityDoc[]);
|
|
39
|
+
get size(): number;
|
|
40
|
+
/** Top `k` documents for `query`, best first. Documents scoring zero are omitted. */
|
|
41
|
+
search(query: string, k?: number): LexicalHit[];
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=lexical.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lexical.d.ts","sourceRoot":"","sources":["../../../src/core/capabilities/lexical.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAMnD;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAc/C;AAOD,MAAM,WAAW,UAAU;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;CACd;AAQD;;;;GAIG;AACH,qBAAa,YAAY;IACxB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAiB;IAC1C,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA6B;IACrD,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IAEnC,YAAY,IAAI,EAAE,SAAS,aAAa,EAAE,EAWzC;IAED,IAAI,IAAI,IAAI,MAAM,CAEjB;IAED,qFAAqF;IACrF,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,CAAC,SAAK,GAAG,UAAU,EAAE,CA2B1C;CACD","sourcesContent":["/**\n * In-memory BM25 over the capability index — the leg that always works.\n *\n * Deliberately not the repo's lexical retriever: that one shells out to ripgrep\n * over files on disk, and capabilities are a few hundred short strings held in\n * memory. It is also not optional. The dense leg needs a binary that may not be\n * installed and a store that may still be building, so if retrieval depended on\n * it, \"find me a tool that sends email\" would work on some machines and not\n * others. This leg makes the floor deterministic and dependency-free; dense is\n * strictly additive on top.\n *\n * The tokenizer does the load-bearing work here. Tool names are the query terms\n * that matter most and they arrive as `mcp_github_create_pull_request` or\n * `createPullRequest`, so a naive whitespace split would make the single most\n * common query shape — a name the model half-remembers — the one thing BM25\n * cannot match.\n */\n\nimport type { CapabilityDoc } from \"./registry.js\";\n\n/** Standard Okapi BM25 parameters. Nothing here justifies tuning them. */\nconst K1 = 1.2;\nconst B = 0.75;\n\n/**\n * Split identifiers the way a person reads them: `mcp_github_create_pr` and\n * `createPullRequest` both yield their parts, and the original token is kept so\n * an exact name still scores as an exact match.\n */\nexport function tokenize(text: string): string[] {\n\tconst out: string[] = [];\n\tfor (const raw of text.toLowerCase().match(/[a-z0-9]+(?:[_-][a-z0-9]+)*/gi) ?? []) {\n\t\tconst token = raw.toLowerCase();\n\t\tout.push(token);\n\t\t// Split on separators, then on camelCase boundaries in the source text.\n\t\tconst parts = token.split(/[_-]+/).filter(Boolean);\n\t\tif (parts.length > 1) out.push(...parts);\n\t}\n\tfor (const camel of text.match(/[a-z][a-z0-9]*|[A-Z][a-z0-9]*|[A-Z]+(?![a-z])/g) ?? []) {\n\t\tconst lower = camel.toLowerCase();\n\t\tif (lower.length > 1) out.push(lower);\n\t}\n\treturn out;\n}\n\n/** The text a document is matched on: name first, since that is what queries name. */\nfunction documentText(doc: CapabilityDoc): string {\n\treturn `${doc.name} ${doc.name} ${doc.source ?? \"\"} ${doc.description}`;\n}\n\nexport interface LexicalHit {\n\tid: string;\n\tscore: number;\n}\n\ninterface Posting {\n\tid: string;\n\tlength: number;\n\tcounts: Map<string, number>;\n}\n\n/**\n * A built BM25 index. Cheap enough to rebuild whenever the capability set\n * changes — a few hundred short documents — so there is no invalidation story\n * to get wrong.\n */\nexport class LexicalIndex {\n\tprivate readonly postings: Posting[] = [];\n\tprivate readonly docFreq = new Map<string, number>();\n\tprivate readonly avgLength: number;\n\n\tconstructor(docs: readonly CapabilityDoc[]) {\n\t\tlet total = 0;\n\t\tfor (const doc of docs) {\n\t\t\tconst tokens = tokenize(documentText(doc));\n\t\t\tconst counts = new Map<string, number>();\n\t\t\tfor (const t of tokens) counts.set(t, (counts.get(t) ?? 0) + 1);\n\t\t\tfor (const t of counts.keys()) this.docFreq.set(t, (this.docFreq.get(t) ?? 0) + 1);\n\t\t\tthis.postings.push({ id: doc.id, length: tokens.length, counts });\n\t\t\ttotal += tokens.length;\n\t\t}\n\t\tthis.avgLength = this.postings.length > 0 ? total / this.postings.length : 0;\n\t}\n\n\tget size(): number {\n\t\treturn this.postings.length;\n\t}\n\n\t/** Top `k` documents for `query`, best first. Documents scoring zero are omitted. */\n\tsearch(query: string, k = 10): LexicalHit[] {\n\t\tconst terms = tokenize(query);\n\t\tif (terms.length === 0 || this.postings.length === 0) return [];\n\t\tconst n = this.postings.length;\n\n\t\tconst hits: LexicalHit[] = [];\n\t\tfor (const posting of this.postings) {\n\t\t\tlet score = 0;\n\t\t\tfor (const term of new Set(terms)) {\n\t\t\t\tconst tf = posting.counts.get(term);\n\t\t\t\tif (!tf) continue;\n\t\t\t\tconst df = this.docFreq.get(term) ?? 0;\n\t\t\t\t// Okapi IDF, floored at zero: a term in every document carries no\n\t\t\t\t// signal, and the raw formula would make it actively negative.\n\t\t\t\tconst idf = Math.max(0, Math.log(1 + (n - df + 0.5) / (df + 0.5)));\n\t\t\t\tconst norm = tf * (K1 + 1);\n\t\t\t\tconst denom = tf + K1 * (1 - B + (B * posting.length) / (this.avgLength || 1));\n\t\t\t\tscore += idf * (norm / denom);\n\t\t\t}\n\t\t\tif (score > 0) hits.push({ id: posting.id, score });\n\t\t}\n\n\t\t// Ties break by id so the same query always returns the same order — a\n\t\t// retrieval tool that reshuffles equal-scoring results is a reproducibility\n\t\t// problem disguised as a ranking one.\n\t\thits.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));\n\t\treturn hits.slice(0, k);\n\t}\n}\n"]}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory BM25 over the capability index — the leg that always works.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately not the repo's lexical retriever: that one shells out to ripgrep
|
|
5
|
+
* over files on disk, and capabilities are a few hundred short strings held in
|
|
6
|
+
* memory. It is also not optional. The dense leg needs a binary that may not be
|
|
7
|
+
* installed and a store that may still be building, so if retrieval depended on
|
|
8
|
+
* it, "find me a tool that sends email" would work on some machines and not
|
|
9
|
+
* others. This leg makes the floor deterministic and dependency-free; dense is
|
|
10
|
+
* strictly additive on top.
|
|
11
|
+
*
|
|
12
|
+
* The tokenizer does the load-bearing work here. Tool names are the query terms
|
|
13
|
+
* that matter most and they arrive as `mcp_github_create_pull_request` or
|
|
14
|
+
* `createPullRequest`, so a naive whitespace split would make the single most
|
|
15
|
+
* common query shape — a name the model half-remembers — the one thing BM25
|
|
16
|
+
* cannot match.
|
|
17
|
+
*/
|
|
18
|
+
/** Standard Okapi BM25 parameters. Nothing here justifies tuning them. */
|
|
19
|
+
const K1 = 1.2;
|
|
20
|
+
const B = 0.75;
|
|
21
|
+
/**
|
|
22
|
+
* Split identifiers the way a person reads them: `mcp_github_create_pr` and
|
|
23
|
+
* `createPullRequest` both yield their parts, and the original token is kept so
|
|
24
|
+
* an exact name still scores as an exact match.
|
|
25
|
+
*/
|
|
26
|
+
export function tokenize(text) {
|
|
27
|
+
const out = [];
|
|
28
|
+
for (const raw of text.toLowerCase().match(/[a-z0-9]+(?:[_-][a-z0-9]+)*/gi) ?? []) {
|
|
29
|
+
const token = raw.toLowerCase();
|
|
30
|
+
out.push(token);
|
|
31
|
+
// Split on separators, then on camelCase boundaries in the source text.
|
|
32
|
+
const parts = token.split(/[_-]+/).filter(Boolean);
|
|
33
|
+
if (parts.length > 1)
|
|
34
|
+
out.push(...parts);
|
|
35
|
+
}
|
|
36
|
+
for (const camel of text.match(/[a-z][a-z0-9]*|[A-Z][a-z0-9]*|[A-Z]+(?![a-z])/g) ?? []) {
|
|
37
|
+
const lower = camel.toLowerCase();
|
|
38
|
+
if (lower.length > 1)
|
|
39
|
+
out.push(lower);
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
/** The text a document is matched on: name first, since that is what queries name. */
|
|
44
|
+
function documentText(doc) {
|
|
45
|
+
return `${doc.name} ${doc.name} ${doc.source ?? ""} ${doc.description}`;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A built BM25 index. Cheap enough to rebuild whenever the capability set
|
|
49
|
+
* changes — a few hundred short documents — so there is no invalidation story
|
|
50
|
+
* to get wrong.
|
|
51
|
+
*/
|
|
52
|
+
export class LexicalIndex {
|
|
53
|
+
postings = [];
|
|
54
|
+
docFreq = new Map();
|
|
55
|
+
avgLength;
|
|
56
|
+
constructor(docs) {
|
|
57
|
+
let total = 0;
|
|
58
|
+
for (const doc of docs) {
|
|
59
|
+
const tokens = tokenize(documentText(doc));
|
|
60
|
+
const counts = new Map();
|
|
61
|
+
for (const t of tokens)
|
|
62
|
+
counts.set(t, (counts.get(t) ?? 0) + 1);
|
|
63
|
+
for (const t of counts.keys())
|
|
64
|
+
this.docFreq.set(t, (this.docFreq.get(t) ?? 0) + 1);
|
|
65
|
+
this.postings.push({ id: doc.id, length: tokens.length, counts });
|
|
66
|
+
total += tokens.length;
|
|
67
|
+
}
|
|
68
|
+
this.avgLength = this.postings.length > 0 ? total / this.postings.length : 0;
|
|
69
|
+
}
|
|
70
|
+
get size() {
|
|
71
|
+
return this.postings.length;
|
|
72
|
+
}
|
|
73
|
+
/** Top `k` documents for `query`, best first. Documents scoring zero are omitted. */
|
|
74
|
+
search(query, k = 10) {
|
|
75
|
+
const terms = tokenize(query);
|
|
76
|
+
if (terms.length === 0 || this.postings.length === 0)
|
|
77
|
+
return [];
|
|
78
|
+
const n = this.postings.length;
|
|
79
|
+
const hits = [];
|
|
80
|
+
for (const posting of this.postings) {
|
|
81
|
+
let score = 0;
|
|
82
|
+
for (const term of new Set(terms)) {
|
|
83
|
+
const tf = posting.counts.get(term);
|
|
84
|
+
if (!tf)
|
|
85
|
+
continue;
|
|
86
|
+
const df = this.docFreq.get(term) ?? 0;
|
|
87
|
+
// Okapi IDF, floored at zero: a term in every document carries no
|
|
88
|
+
// signal, and the raw formula would make it actively negative.
|
|
89
|
+
const idf = Math.max(0, Math.log(1 + (n - df + 0.5) / (df + 0.5)));
|
|
90
|
+
const norm = tf * (K1 + 1);
|
|
91
|
+
const denom = tf + K1 * (1 - B + (B * posting.length) / (this.avgLength || 1));
|
|
92
|
+
score += idf * (norm / denom);
|
|
93
|
+
}
|
|
94
|
+
if (score > 0)
|
|
95
|
+
hits.push({ id: posting.id, score });
|
|
96
|
+
}
|
|
97
|
+
// Ties break by id so the same query always returns the same order — a
|
|
98
|
+
// retrieval tool that reshuffles equal-scoring results is a reproducibility
|
|
99
|
+
// problem disguised as a ranking one.
|
|
100
|
+
hits.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
|
|
101
|
+
return hits.slice(0, k);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=lexical.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lexical.js","sourceRoot":"","sources":["../../../src/core/capabilities/lexical.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * In-memory BM25 over the capability index — the leg that always works.\n *\n * Deliberately not the repo's lexical retriever: that one shells out to ripgrep\n * over files on disk, and capabilities are a few hundred short strings held in\n * memory. It is also not optional. The dense leg needs a binary that may not be\n * installed and a store that may still be building, so if retrieval depended on\n * it, \"find me a tool that sends email\" would work on some machines and not\n * others. This leg makes the floor deterministic and dependency-free; dense is\n * strictly additive on top.\n *\n * The tokenizer does the load-bearing work here. Tool names are the query terms\n * that matter most and they arrive as `mcp_github_create_pull_request` or\n * `createPullRequest`, so a naive whitespace split would make the single most\n * common query shape — a name the model half-remembers — the one thing BM25\n * cannot match.\n */\n\nimport type { CapabilityDoc } from \"./registry.js\";\n\n/** Standard Okapi BM25 parameters. Nothing here justifies tuning them. */\nconst K1 = 1.2;\nconst B = 0.75;\n\n/**\n * Split identifiers the way a person reads them: `mcp_github_create_pr` and\n * `createPullRequest` both yield their parts, and the original token is kept so\n * an exact name still scores as an exact match.\n */\nexport function tokenize(text: string): string[] {\n\tconst out: string[] = [];\n\tfor (const raw of text.toLowerCase().match(/[a-z0-9]+(?:[_-][a-z0-9]+)*/gi) ?? []) {\n\t\tconst token = raw.toLowerCase();\n\t\tout.push(token);\n\t\t// Split on separators, then on camelCase boundaries in the source text.\n\t\tconst parts = token.split(/[_-]+/).filter(Boolean);\n\t\tif (parts.length > 1) out.push(...parts);\n\t}\n\tfor (const camel of text.match(/[a-z][a-z0-9]*|[A-Z][a-z0-9]*|[A-Z]+(?![a-z])/g) ?? []) {\n\t\tconst lower = camel.toLowerCase();\n\t\tif (lower.length > 1) out.push(lower);\n\t}\n\treturn out;\n}\n\n/** The text a document is matched on: name first, since that is what queries name. */\nfunction documentText(doc: CapabilityDoc): string {\n\treturn `${doc.name} ${doc.name} ${doc.source ?? \"\"} ${doc.description}`;\n}\n\nexport interface LexicalHit {\n\tid: string;\n\tscore: number;\n}\n\ninterface Posting {\n\tid: string;\n\tlength: number;\n\tcounts: Map<string, number>;\n}\n\n/**\n * A built BM25 index. Cheap enough to rebuild whenever the capability set\n * changes — a few hundred short documents — so there is no invalidation story\n * to get wrong.\n */\nexport class LexicalIndex {\n\tprivate readonly postings: Posting[] = [];\n\tprivate readonly docFreq = new Map<string, number>();\n\tprivate readonly avgLength: number;\n\n\tconstructor(docs: readonly CapabilityDoc[]) {\n\t\tlet total = 0;\n\t\tfor (const doc of docs) {\n\t\t\tconst tokens = tokenize(documentText(doc));\n\t\t\tconst counts = new Map<string, number>();\n\t\t\tfor (const t of tokens) counts.set(t, (counts.get(t) ?? 0) + 1);\n\t\t\tfor (const t of counts.keys()) this.docFreq.set(t, (this.docFreq.get(t) ?? 0) + 1);\n\t\t\tthis.postings.push({ id: doc.id, length: tokens.length, counts });\n\t\t\ttotal += tokens.length;\n\t\t}\n\t\tthis.avgLength = this.postings.length > 0 ? total / this.postings.length : 0;\n\t}\n\n\tget size(): number {\n\t\treturn this.postings.length;\n\t}\n\n\t/** Top `k` documents for `query`, best first. Documents scoring zero are omitted. */\n\tsearch(query: string, k = 10): LexicalHit[] {\n\t\tconst terms = tokenize(query);\n\t\tif (terms.length === 0 || this.postings.length === 0) return [];\n\t\tconst n = this.postings.length;\n\n\t\tconst hits: LexicalHit[] = [];\n\t\tfor (const posting of this.postings) {\n\t\t\tlet score = 0;\n\t\t\tfor (const term of new Set(terms)) {\n\t\t\t\tconst tf = posting.counts.get(term);\n\t\t\t\tif (!tf) continue;\n\t\t\t\tconst df = this.docFreq.get(term) ?? 0;\n\t\t\t\t// Okapi IDF, floored at zero: a term in every document carries no\n\t\t\t\t// signal, and the raw formula would make it actively negative.\n\t\t\t\tconst idf = Math.max(0, Math.log(1 + (n - df + 0.5) / (df + 0.5)));\n\t\t\t\tconst norm = tf * (K1 + 1);\n\t\t\t\tconst denom = tf + K1 * (1 - B + (B * posting.length) / (this.avgLength || 1));\n\t\t\t\tscore += idf * (norm / denom);\n\t\t\t}\n\t\t\tif (score > 0) hits.push({ id: posting.id, score });\n\t\t}\n\n\t\t// Ties break by id so the same query always returns the same order — a\n\t\t// retrieval tool that reshuffles equal-scoring results is a reproducibility\n\t\t// problem disguised as a ranking one.\n\t\thits.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));\n\t\treturn hits.slice(0, k);\n\t}\n}\n"]}
|