@jini-ai/agent-plugins 0.4.1 → 0.4.2
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 +6 -0
- package/core/persistent-state.d.ts +25 -0
- package/core/persistent-state.js +260 -0
- package/dist/lifecycle/activation.js +38 -166
- package/dist/lifecycle/bundled-digests.d.ts +6 -0
- package/dist/lifecycle/bundled-digests.js +156 -0
- package/dist/lifecycle/bundled-source-archive.d.ts +3 -0
- package/dist/lifecycle/bundled-source-archive.js +74 -0
- package/dist/lifecycle/contribution-loader.d.ts +102 -0
- package/dist/lifecycle/contribution-loader.js +92 -0
- package/dist/lifecycle/fetch-archive.d.ts +1 -2
- package/dist/lifecycle/index.d.ts +24 -4
- package/dist/lifecycle/index.js +11 -0
- package/dist/lifecycle/install-from-url.d.ts +2 -5
- package/dist/lifecycle/install-from-url.js +2 -5
- package/dist/lifecycle/install.d.ts +57 -2
- package/dist/lifecycle/install.js +222 -21
- package/dist/lifecycle/layout.js +1 -1
- package/dist/lifecycle/manifest.d.ts +3 -9
- package/dist/lifecycle/manifest.js +4 -11
- package/dist/lifecycle/package-paths.d.ts +2 -0
- package/dist/lifecycle/package-paths.js +62 -0
- package/dist/lifecycle/persistent-state.d.ts +1 -1
- package/dist/lifecycle/persistent-state.js +1 -1
- package/dist/lifecycle/ports.d.ts +14 -2
- package/dist/lifecycle/resolve-agent-plugin-refs.d.ts +13 -1
- package/dist/lifecycle/resolve-agent-plugin-refs.js +143 -4
- package/dist/lifecycle/retire-bundled.d.ts +50 -1
- package/dist/lifecycle/retire-bundled.js +9 -2
- package/dist/lifecycle/search.d.ts +63 -0
- package/dist/lifecycle/search.js +29 -0
- package/dist/lifecycle/seed-bundled.d.ts +18 -1
- package/dist/lifecycle/seed-bundled.js +131 -3
- package/dist/lifecycle/set-enabled.d.ts +7 -0
- package/dist/lifecycle/set-enabled.js +5 -0
- package/dist/lifecycle/trusted-plugin-files.d.ts +38 -0
- package/dist/lifecycle/trusted-plugin-files.js +18 -0
- package/dist/lifecycle/types.d.ts +12 -0
- package/dist/lifecycle/uninstall.d.ts +29 -0
- package/dist/lifecycle/uninstall.js +204 -4
- package/dist/lifecycle/yauzl-archive-reader.d.ts +0 -1
- package/package.json +25 -8
- package/persistent-state.js +2 -260
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { AgentPluginLifecyclePorts } from "./ports.js";
|
|
2
|
+
/** Raised for any path a package entry (or a later read against an already-installed package) is
|
|
3
|
+
* not allowed to reach — lexically invalid, or resolving outside the package root. */
|
|
2
4
|
export declare class PackagePathViolation extends Error {
|
|
3
5
|
constructor({ message }: {
|
|
4
6
|
readonly message: string;
|
|
@@ -1,10 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The one path-safety primitive every Agent Plugin package read goes through — extraction
|
|
3
|
+
* (`install.ts`) today, any future preview/read path tomorrow. One implementation, not two, so the
|
|
4
|
+
* containment guarantee cannot drift between "how a package is written" and "how it is later read".
|
|
5
|
+
*
|
|
6
|
+
* The rule is not invented here. The Agent Plugins spec (v1.0.0, "Path safety") states it as a MUST:
|
|
7
|
+
* "the filesystem-resolved path MUST remain within the filesystem-resolved plugin root... symlinks,
|
|
8
|
+
* junctions, reparse points, and equivalent filesystem mechanisms MAY resolve to targets within the
|
|
9
|
+
* plugin root, but clients MUST reject package paths that resolve outside it." The word
|
|
10
|
+
* "filesystem-resolved" is exactly what a lexical `../`-string check alone does not satisfy — a
|
|
11
|
+
* hostile archive can plant a symlinked directory at a lexically-clean path and let a later,
|
|
12
|
+
* innocent-looking entry walk through it (the two-step zip-slip vector; see `install.ts`'s header
|
|
13
|
+
* for the extraction-time mitigation, which is to refuse symlink ENTRIES outright rather than ever
|
|
14
|
+
* relying on this function to catch one after the fact).
|
|
15
|
+
*
|
|
16
|
+
* Architectural role:
|
|
17
|
+
* Pure path logic plus the minimum filesystem calls (`realpath`) the spec's own wording requires.
|
|
18
|
+
* No policy about what happens after containment is established — that is every caller's own job.
|
|
19
|
+
*/
|
|
1
20
|
/** Extracted from the host plugin lifecycle. Effects are supplied per host context. */
|
|
2
21
|
import path from "node:path";
|
|
22
|
+
/** Raised for any path a package entry (or a later read against an already-installed package) is
|
|
23
|
+
* not allowed to reach — lexically invalid, or resolving outside the package root. */
|
|
3
24
|
export class PackagePathViolation extends Error {
|
|
4
25
|
constructor({ message }, optional = {}) { super(message, optional); }
|
|
5
26
|
}
|
|
6
27
|
function buildModule(ports) {
|
|
7
28
|
const realpath = ports.filesystem.realpath.bind(ports.filesystem);
|
|
29
|
+
/**
|
|
30
|
+
* Rejects an absolute path, a NUL byte, a Windows drive prefix, or a lexical `..`/`.` segment —
|
|
31
|
+
* BEFORE any filesystem call. This is the cheap majority of attempts; {@link assertContainedOnDisk}
|
|
32
|
+
* catches the rest (an entry that is lexically clean but is, or passes through, a symlink).
|
|
33
|
+
*
|
|
34
|
+
* Backslashes are normalized to `/` rather than rejected: the spec models paths as POSIX-style
|
|
35
|
+
* (`./`-prefixed), and an archive built on Windows may use them as its own separator without any
|
|
36
|
+
* hostile intent — the traversal segments that actually matter (`..`, an absolute prefix) are
|
|
37
|
+
* checked after normalization either way.
|
|
38
|
+
*
|
|
39
|
+
* @throws {PackagePathViolation} If the path is unsafe by construction, independent of any
|
|
40
|
+
* filesystem state.
|
|
41
|
+
* @complexity O(n) in the path's own length.
|
|
42
|
+
*/
|
|
8
43
|
function normalizePackageEntryPath(rawEntryPath) {
|
|
9
44
|
if (rawEntryPath.includes("\0")) {
|
|
10
45
|
throw new PackagePathViolation({ message: `package entry path contains a NUL byte: '${rawEntryPath}'` });
|
|
@@ -19,6 +54,30 @@ function buildModule(ports) {
|
|
|
19
54
|
}
|
|
20
55
|
return normalized;
|
|
21
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Resolves `entryPath` against `packageRoot` and asserts the REAL, symlink-resolved result is still
|
|
59
|
+
* inside the REAL, symlink-resolved root — the spec's "filesystem-resolved" requirement, not a
|
|
60
|
+
* string comparison.
|
|
61
|
+
*
|
|
62
|
+
* `packageRoot` is `realpath`'d fresh on every call rather than accepted pre-resolved from the
|
|
63
|
+
* caller, so a root whose own directory entry is later replaced by a symlink between two calls
|
|
64
|
+
* cannot smuggle a stale trusted root in.
|
|
65
|
+
*
|
|
66
|
+
* Handles the case extraction needs and a plain read doesn't: the target may not exist yet (mid-
|
|
67
|
+
* extraction, before this entry's own write), in which case the deepest EXISTING ancestor is
|
|
68
|
+
* `realpath`'d instead — so a symlinked PARENT directory planted by an earlier archive entry is
|
|
69
|
+
* still caught even though the leaf hasn't been created yet.
|
|
70
|
+
*
|
|
71
|
+
* @param packageRoot - Absolute path to the package root (extraction destination, or an already-
|
|
72
|
+
* installed package's root).
|
|
73
|
+
* @param entryPath - A package-relative path, as found in an archive entry name or a caller's own
|
|
74
|
+
* read request. Lexically validated internally via {@link normalizePackageEntryPath}.
|
|
75
|
+
* @returns The resolved (lexical, not yet realpath'd) absolute destination — safe for the caller to
|
|
76
|
+
* `open`/`mkdir`/`stat` against.
|
|
77
|
+
* @throws {PackagePathViolation} If the path is lexically unsafe, or the filesystem-resolved result
|
|
78
|
+
* (direct or via a symlinked ancestor) falls outside `packageRoot`.
|
|
79
|
+
* @complexity O(d) in path depth — one `realpath` per unresolved ancestor in the worst case.
|
|
80
|
+
*/
|
|
22
81
|
async function assertContainedOnDisk(packageRoot, entryPath) {
|
|
23
82
|
const normalized = normalizePackageEntryPath(entryPath);
|
|
24
83
|
const realRoot = await realpath(packageRoot);
|
|
@@ -42,6 +101,9 @@ function buildModule(ports) {
|
|
|
42
101
|
const relative = path.relative(root, candidate);
|
|
43
102
|
return relative === "" || (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`));
|
|
44
103
|
}
|
|
104
|
+
/** Walks up from `candidate` until an existing ancestor is found, `realpath`s that ancestor, and
|
|
105
|
+
* reattaches the non-existent tail — so a symlinked ancestor is caught even for a path whose leaf
|
|
106
|
+
* (and possibly several parent segments) have not been created yet. */
|
|
45
107
|
async function realpathDeepestExistingAncestor(candidate) {
|
|
46
108
|
let current = path.dirname(candidate);
|
|
47
109
|
const tail = [path.basename(candidate)];
|
|
@@ -9,7 +9,7 @@ export declare function createPersistentStateModule(ports: AgentPluginLifecycleP
|
|
|
9
9
|
memory: (required: {
|
|
10
10
|
workspaceRoot: string;
|
|
11
11
|
pluginId: string;
|
|
12
|
-
}, optional?: {}) => import("../../persistent-state.js").PluginMemory;
|
|
12
|
+
}, optional?: {}) => import("../../core/persistent-state.js").PluginMemory;
|
|
13
13
|
assertOwnedPath: (required: {
|
|
14
14
|
workspaceRoot: string;
|
|
15
15
|
entryPath: string;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createPluginMemory, migratePluginLayout, assertPluginStatePath, withPluginStateLock } from '../../persistent-state.js';
|
|
1
|
+
import { createPluginMemory, migratePluginLayout, assertPluginStatePath, withPluginStateLock } from '../../core/persistent-state.js';
|
|
2
2
|
import { withFileLock } from '@jini-ai/platform/fs/file-lock';
|
|
3
3
|
import { createPackagePathsModule } from './package-paths.js';
|
|
4
4
|
import { parseAgentPluginManifest } from './manifest.js';
|
|
@@ -69,6 +69,18 @@ export interface AgentPluginLifecyclePorts {
|
|
|
69
69
|
readonly layout: AgentPluginLayoutPort;
|
|
70
70
|
readonly productName: string;
|
|
71
71
|
readonly extensionNamespace: string;
|
|
72
|
+
/** Host boot policy may migrate every workspace before seeding the requested workspace.
|
|
73
|
+
* A failure refuses all publication; absent uses the owner's single-workspace migration. */
|
|
74
|
+
readonly migrateBeforeSeed?: ((required: {
|
|
75
|
+
readonly layout: AgentPluginLayoutPort;
|
|
76
|
+
readonly workspaceId: string;
|
|
77
|
+
}, optional: Record<string, never>) => Promise<{
|
|
78
|
+
readonly complete: boolean;
|
|
79
|
+
}>) | undefined;
|
|
80
|
+
/** Host-facing recovery instructions; the bundled refusal and its security gate remain owned here. */
|
|
81
|
+
readonly formatBundledUninstallRecovery?: ((required: {
|
|
82
|
+
readonly pluginId: string;
|
|
83
|
+
}, optional: Record<string, never>) => string) | undefined;
|
|
72
84
|
readonly readServerMetadata?: AgentPluginServerMetadataReader | undefined;
|
|
73
85
|
readonly deliveryMode: AgentPluginDeliveryMode;
|
|
74
86
|
readonly mcpProvisioning: AgentPluginMcpProvisioningPort;
|
|
@@ -76,6 +88,6 @@ export interface AgentPluginLifecyclePorts {
|
|
|
76
88
|
readonly outboundGuard: AgentPluginOutboundGuardPort;
|
|
77
89
|
}
|
|
78
90
|
/** Application policy/effects are required; observers and metadata translators are optional. */
|
|
79
|
-
export type AgentPluginLifecycleRequired = Omit<AgentPluginLifecyclePorts, 'onEvent' | 'readServerMetadata'>;
|
|
80
|
-
export type AgentPluginLifecycleOptional = Pick<AgentPluginLifecyclePorts, 'onEvent' | 'readServerMetadata'>;
|
|
91
|
+
export type AgentPluginLifecycleRequired = Omit<AgentPluginLifecyclePorts, 'onEvent' | 'readServerMetadata' | 'migrateBeforeSeed' | 'formatBundledUninstallRecovery'>;
|
|
92
|
+
export type AgentPluginLifecycleOptional = Pick<AgentPluginLifecyclePorts, 'onEvent' | 'readServerMetadata' | 'migrateBeforeSeed' | 'formatBundledUninstallRecovery'>;
|
|
81
93
|
//# sourceMappingURL=ports.d.ts.map
|
|
@@ -1,4 +1,16 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { /**
|
|
2
|
+
* How a pinned ref's content reaches the agent.
|
|
3
|
+
*
|
|
4
|
+
* - `inject` — the whole eponymous SKILL.md (~15KB for `ui-ux-design`) plus every other package file
|
|
5
|
+
* listed by absolute path, in the run's prompt prefix. Today's shipped behaviour, and the default.
|
|
6
|
+
* - `pointer` — ~400 bytes naming the exact tool call that returns that SAME SKILL.md, and nothing
|
|
7
|
+
* else. The content is identical; only the delivery differs, which is what makes the two a
|
|
8
|
+
* controlled comparison rather than two different experiments.
|
|
9
|
+
*
|
|
10
|
+
* Selected per-process by the host-supplied `deliveryMode` port. This is a MEASUREMENT AFFORDANCE, not a
|
|
11
|
+
* migration flag: pointer and inject must coexist to compare delivery of identical content.
|
|
12
|
+
* Selecting an unconditional mode requires the measurement result.
|
|
13
|
+
*/ AgentPluginDeliveryMode, InstalledAgentPlugin } from "./types.js";
|
|
2
14
|
import type { AgentPluginWorkspaceLayout } from "./layout.js";
|
|
3
15
|
import type { AgentPluginLifecyclePorts } from "./ports.js";
|
|
4
16
|
export type { AgentPluginDeliveryMode } from "./types.js";
|
|
@@ -1,10 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Resolves a run's pinned Agent Plugin refs (`pluginRefIds` — `run-start-context.ts`'s
|
|
3
|
+
* decoded `contextRef` field, itself sourced from the composer's `pluginRefId` chips,
|
|
4
|
+
* `composer-capabilities.ts`'s own doc) into the real prompt-prefix text `agent-daemon-server.ts`'s
|
|
5
|
+
* `onStarted` prepends to the run's prompt.
|
|
6
|
+
*
|
|
7
|
+
* ---------------------------------------------------------------------------
|
|
8
|
+
* The activation-record gap this function works around (KNOWN GAP, not silently papered over)
|
|
9
|
+
* ---------------------------------------------------------------------------
|
|
10
|
+
* `activation.ts` owns the per-workspace enabled decision; a disabled or undetermined ref is
|
|
11
|
+
* refused before resolution.
|
|
12
|
+
* What that record deliberately does NOT carry is which installed DIGEST is current for a plugin id.
|
|
13
|
+
* For a BUNDLED plugin, `bundled-digests.ts`'s ledger answers that: the boot seeder
|
|
14
|
+
* records which digest the running build published, so a workspace holding an upgraded plugin's old
|
|
15
|
+
* and new package is no longer ambiguous — the build's own record names the winner. For every other
|
|
16
|
+
* plugin id, closing this needs the operator-facing upgrade/pin flow (and, alongside it, the
|
|
17
|
+
* admission gate for MCP servers `capability-projection.ts`'s own header names) and remains a
|
|
18
|
+
* separate, later decision.
|
|
19
|
+
*
|
|
20
|
+
* So this function still scans every digest this workspace has ever installed under
|
|
21
|
+
* `packages/sha256/*` and matches on the installed `plugin.json`'s own `name` field, after dropping
|
|
22
|
+
* any digest of a bundled plugin the ledger records as superseded:
|
|
23
|
+
*
|
|
24
|
+
* - Exactly one match: resolves normally.
|
|
25
|
+
* - Zero matches: fails with a "not installed" reason — the operator pinned a chip for a plugin
|
|
26
|
+
* that is not (or no longer) actually on disk for this workspace.
|
|
27
|
+
* - More than one match: fails with an explicit ambiguity reason naming every matching digest.
|
|
28
|
+
* Silently picking one (e.g. the lexicographically-last, or "the newest") would be a WORSE
|
|
29
|
+
* failure mode than an explicit error — a wrong plugin's content would reach the agent, and
|
|
30
|
+
* nothing about the run would look wrong until someone noticed the agent's advice didn't match
|
|
31
|
+
* what was supposedly pinned. An explicit error is loud and immediately actionable instead. That
|
|
32
|
+
* refusal is UNCHANGED and still the default: the ledger narrows a plugin id only when the build
|
|
33
|
+
* itself published one of the installed digests, which is authority rather than a guess, and it
|
|
34
|
+
* has no say at all over packages an operator installed (`bundled-digests.ts`, consequence 1).
|
|
35
|
+
*
|
|
36
|
+
* ---------------------------------------------------------------------------
|
|
37
|
+
* What gets injected, and why not the whole 280K package
|
|
38
|
+
* ---------------------------------------------------------------------------
|
|
39
|
+
* Only the resolved plugin's own eponymous skill — `skills/<pluginRefId>/SKILL.md` (the skill
|
|
40
|
+
* folder whose name equals the plugin's own id; for `ui-ux-design` specifically, this is real,
|
|
41
|
+
* ~5.5KB content, not all seven of that plugin's skill folders). The reasoning mirrors
|
|
42
|
+
* `capability-projection.ts`'s own "Skills get a real executable binding" rule: a Skill's markdown
|
|
43
|
+
* IS the plugin's real, agent-facing content, while the rest of the package (references,
|
|
44
|
+
* examples, scripts) is supporting material a plugin's own SKILL.md is written to point an agent
|
|
45
|
+
* at when it needs more depth (the Agent Plugins spec's own convention every skill in this package
|
|
46
|
+
* follows). Injecting the FULL package indiscriminately would balloon every run's prompt with
|
|
47
|
+
* pages this specific task usually never needs. Every OTHER file in the resolved package is still
|
|
48
|
+
* listed by absolute path — genuinely `Read`-able by the spawned CLI agent, which runs with real
|
|
49
|
+
* filesystem access unlike a browser-facing preview — so nothing is unreachable, only deferred to
|
|
50
|
+
* an explicit follow-up read.
|
|
51
|
+
*
|
|
52
|
+
* Architectural role:
|
|
53
|
+
* Server-side only (reads `node:fs/promises`, imports `install.ts`'s `indexInstalledRoot`) — never
|
|
54
|
+
* imported into a browser bundle. Returns a discriminated result rather than throwing, so
|
|
55
|
+
* `agent-daemon-server.ts`'s `onStarted` gets one place to branch on success/failure without a
|
|
56
|
+
* second try/catch layer duplicating this module's own error classification.
|
|
57
|
+
*/
|
|
1
58
|
import { createPersistentStateModule } from "./persistent-state.js";
|
|
2
|
-
/** Extracted from the host plugin lifecycle. Effects are supplied per host context. */
|
|
3
59
|
import path from "node:path";
|
|
4
60
|
import { createPackagePathsModule } from "./package-paths.js";
|
|
5
|
-
import {} from "./activation.js";
|
|
6
61
|
import { createActivationModule } from "./activation.js";
|
|
7
|
-
import {} from "./bundled-digests.js";
|
|
8
62
|
import { createBundledDigestsModule } from "./bundled-digests.js";
|
|
9
63
|
import { createInstallModule } from "./install.js";
|
|
10
64
|
import { createCapabilityProjectionModule } from "./capability-projection.js";
|
|
@@ -18,7 +72,35 @@ function buildModule(ports) {
|
|
|
18
72
|
const { indexInstalledRoot } = createInstallModule(ports);
|
|
19
73
|
const { readInstalledSkillMarkdown } = createCapabilityProjectionModule(ports);
|
|
20
74
|
const SHA256_DIGEST_DIRNAME_PATTERN = /^[a-f0-9]{64}$/;
|
|
75
|
+
/** Reads the delivery mode for this process. An unset or unrecognised value is `inject` — an A/B
|
|
76
|
+
* affordance must never be able to change production behaviour by typo. */
|
|
21
77
|
function resolveAgentPluginDeliveryMode() { return ports.deliveryMode; }
|
|
78
|
+
/**
|
|
79
|
+
* Resolves every pinned `pluginRefId` into real on-disk text and joins the results into one
|
|
80
|
+
* prompt-prefix block, in the order the refs were pinned.
|
|
81
|
+
*
|
|
82
|
+
* @param pluginRefIds - This run's pinned Agent Plugin ids (`run-start-context.ts`'s decoded
|
|
83
|
+
* `pluginRefIds` — already filtered to non-empty strings by that point). An empty array resolves
|
|
84
|
+
* to an empty prefix with no filesystem access at all — the common case (no plugin pinned).
|
|
85
|
+
* @param workspaceLayout - This run's own workspace's Agent Plugin layout
|
|
86
|
+
* (`resolveAgentPluginLayout().forWorkspace(workspaceId)`), never a shared/instance-level one —
|
|
87
|
+
* see `layout.ts`'s own tenant-isolation header for why a workspace's installed packages must
|
|
88
|
+
* never be resolved against another workspace's tree.
|
|
89
|
+
* @returns `{ ok: true, promptPrefix }` once every ref resolves, or the FIRST `{ ok: false, reason }`
|
|
90
|
+
* encountered — a run pinning two refs where the second is ambiguous still fails clearly, rather
|
|
91
|
+
* than partially augmenting the prompt with only the first ref's content.
|
|
92
|
+
* @complexity O(r * d) where r is `pluginRefIds.length` and d is the number of installed digests in
|
|
93
|
+
* this workspace — each ref independently re-scans the (typically small) digest list, since a
|
|
94
|
+
* different ref may resolve to a different digest.
|
|
95
|
+
*/
|
|
96
|
+
// Read ONCE for the whole call, not per ref: it is one small file, and every ref resolves against
|
|
97
|
+
// the same workspace's ledger. See `bundled-digests.ts` for why this can only ever RESOLVE an
|
|
98
|
+
// ambiguity below, never create one.
|
|
99
|
+
// See activation.ts for the shared activation rule. One small file read per pinned ref; the
|
|
100
|
+
// fail-CLOSED `resolveAgentPluginActivation` (the same reader the per-call tool gate uses)
|
|
101
|
+
// refuses on a corrupt/unreadable file instead of reading it as "nothing recorded" — injecting a
|
|
102
|
+
// plugin's guidance SPENDS it exactly like a tool call, so this surface must refuse rather than
|
|
103
|
+
// read a fault as consent.
|
|
22
104
|
async function resolveAgentPluginRefs(pluginRefIds, workspaceLayout, deliveryMode = resolveAgentPluginDeliveryMode()) {
|
|
23
105
|
if (pluginRefIds.length === 0)
|
|
24
106
|
return { ok: true, promptPrefix: "" };
|
|
@@ -35,6 +117,12 @@ function buildModule(ports) {
|
|
|
35
117
|
}
|
|
36
118
|
return { ok: true, promptPrefix: sections.join("\n\n") };
|
|
37
119
|
}
|
|
120
|
+
/** The run-start refusal for one ref's activation verdict, or `undefined` when it may be injected.
|
|
121
|
+
* @complexity O(1). */
|
|
122
|
+
// A distinct reason from "not installed", because the operator's remedy is different: the bytes
|
|
123
|
+
// ARE here and the fix is to enable the plugin, not to install it. Telling them to install
|
|
124
|
+
// something already present is the kind of wrong-but-plausible error message that costs an
|
|
125
|
+
// afternoon.
|
|
38
126
|
function activationRefusal(pluginRefId, verdict) {
|
|
39
127
|
if (verdict.verdict === "active")
|
|
40
128
|
return undefined;
|
|
@@ -45,6 +133,19 @@ function buildModule(ports) {
|
|
|
45
133
|
return (`Agent Plugin '${pluginRefId}' is installed in this workspace but is not enabled — it ships with ${ports.productName} and ` +
|
|
46
134
|
"stays inactive until an operator turns it on. Enable it before pinning it to a run.");
|
|
47
135
|
}
|
|
136
|
+
/** Every installed digest's `InstalledAgentPlugin`, indexed once per call to
|
|
137
|
+
* {@link resolveAgentPluginRefs} — one `readdir` plus one `indexInstalledRoot` walk per digest.
|
|
138
|
+
* Digest directory names that do not match the expected 64-hex-character shape are skipped rather
|
|
139
|
+
* than passed to `indexInstalledRoot` — `packages/sha256/` is not asserted empty of anything else
|
|
140
|
+
* a future tool might place there, and a non-digest entry is not this function's to interpret.
|
|
141
|
+
*
|
|
142
|
+
* Exported for a second caller outside this module: `tool-registrations.ts`'s
|
|
143
|
+
* `loadInstalledAgentPluginToolSources` needs the SAME per-digest walk (every installed digest,
|
|
144
|
+
* whichever skills each one carries) to build one `agent_plugin_<pluginId>` tool per installed
|
|
145
|
+
* plugin — the exact shape this function already builds, just consumed differently than
|
|
146
|
+
* `resolveOnePluginRef`'s own "resolve one pinned ref" use below. Reusing this rather than
|
|
147
|
+
* re-walking `packages/sha256/*` a second, less-validated way keeps the digest-directory-name check
|
|
148
|
+
* and the per-digest failure isolation in exactly one place. */
|
|
48
149
|
async function listInstalledPlugins(workspaceRoot) {
|
|
49
150
|
let folders;
|
|
50
151
|
try {
|
|
@@ -84,6 +185,28 @@ function buildModule(ports) {
|
|
|
84
185
|
}
|
|
85
186
|
return installed;
|
|
86
187
|
}
|
|
188
|
+
/**
|
|
189
|
+
* Whether one installed archive digest still has its package directory under `packagesDir` — the
|
|
190
|
+
* cheap, per-call "is this still installed" signal `tool-registrations.ts`'s tool gate needs.
|
|
191
|
+
*
|
|
192
|
+
* Lives here, beside {@link listInstalledPlugins}, because it must agree with that walk about two
|
|
193
|
+
* things a second implementation would eventually get wrong: that an installed package root IS
|
|
194
|
+
* `<packagesDir>/<digest>` and nothing else, and that a directory name is only a digest when it
|
|
195
|
+
* matches the 64-hex shape. It deliberately does NOT re-index the package (`indexInstalledRoot`
|
|
196
|
+
* reads and validates a manifest, which is far too much per tool call): directory presence is
|
|
197
|
+
* exactly what `uninstall.ts` removes, and it removes it by `rename` BEFORE it touches the
|
|
198
|
+
* activation record, so the gate observes a removal at least as early as it observes the record's
|
|
199
|
+
* deletion, never later.
|
|
200
|
+
*
|
|
201
|
+
* Follows symlinks (plain `stat`, not `lstat`) on purpose — `listInstalledPlugins` does too, so a
|
|
202
|
+
* deployment whose digest directory is a symlink registers a tool that this would otherwise refuse
|
|
203
|
+
* to authorize.
|
|
204
|
+
*
|
|
205
|
+
* @returns `false` for a digest that does not match the installed-directory grammar, for a missing
|
|
206
|
+
* directory, for a non-directory at that path, and for ANY filesystem fault — every answer this
|
|
207
|
+
* cannot establish positively is `false`, because its one caller uses it to authorize.
|
|
208
|
+
* @complexity One `stat`.
|
|
209
|
+
*/
|
|
87
210
|
async function isInstalledDigestPresent(packagesDir, archiveDigest) {
|
|
88
211
|
if (!SHA256_DIGEST_DIRNAME_PATTERN.test(archiveDigest))
|
|
89
212
|
return false;
|
|
@@ -97,6 +220,16 @@ function buildModule(ports) {
|
|
|
97
220
|
function isEnoent(error) {
|
|
98
221
|
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
99
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* Resolves one `pluginRefId`, applying the zero/one/many-match rules described in this module's
|
|
225
|
+
* own header.
|
|
226
|
+
*/
|
|
227
|
+
// Superseded installs of a BUNDLED plugin are dropped here — by the build's own recorded answer,
|
|
228
|
+
// never by picking one. Every id the ledger has no authority over reaches the many-match refusal
|
|
229
|
+
// below exactly as it always did.
|
|
230
|
+
// Framing governs whether the agent follows the plugin: describe injected SKILL.md as complete
|
|
231
|
+
// instructions and let it require its references. Calling it a summary or making required reads
|
|
232
|
+
// conditional lets the outer wrapper override the plugin. The inventory-framing test pins this.
|
|
100
233
|
async function resolveOnePluginRef(pluginRefId, packagesDir, deliveryMode, bundledDigests) {
|
|
101
234
|
const installed = preferBundledAgentPluginDigests(await listInstalledPlugins(packagesDir), bundledDigests);
|
|
102
235
|
const matches = installed.filter((plugin) => plugin.pluginId === pluginRefId);
|
|
@@ -120,9 +253,10 @@ function buildModule(ports) {
|
|
|
120
253
|
let skillMarkdown;
|
|
121
254
|
try {
|
|
122
255
|
skillMarkdown = await readInstalledSkillMarkdown(plugin.packageRoot, skillPath);
|
|
256
|
+
// Notes are user context, never an allowlist or permission grant. JSON quotes delimit untrusted text.
|
|
123
257
|
const notes = await persistentState.memory({ workspaceRoot: packagesDir, pluginId: plugin.pluginId }).list({ kind: 'notes' });
|
|
124
258
|
if (notes.length)
|
|
125
|
-
skillMarkdown += `\n\nUser notes (context only
|
|
259
|
+
skillMarkdown += `\n\nUser notes for this plugin (context only; permissions remain operator-controlled):\n${JSON.stringify(notes)}`;
|
|
126
260
|
}
|
|
127
261
|
catch (error) {
|
|
128
262
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -142,6 +276,11 @@ function buildModule(ports) {
|
|
|
142
276
|
section: `<<AGENT_PLUGIN pluginId="${pluginRefId}">>\n${skillMarkdown}${inventory}\n<</AGENT_PLUGIN>>`,
|
|
143
277
|
};
|
|
144
278
|
}
|
|
279
|
+
// Parity with `inject`'s own failure: a pinned ref whose eponymous skill is missing must fail
|
|
280
|
+
// loudly in BOTH modes. Without this check `pointer` would happily emit a well-formed instruction
|
|
281
|
+
// whose no-argument default resolves to some OTHER (alphabetically-first) skill instead of the one
|
|
282
|
+
// the pin actually named, and the run would look like the agent disobeyed rather than like the pin
|
|
283
|
+
// being wrong.
|
|
145
284
|
function buildPointerSection(pluginRefId, plugin) {
|
|
146
285
|
if (!plugin.skills.some((skill) => skill.name === pluginRefId)) {
|
|
147
286
|
return {
|
|
@@ -1,7 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file `retireBundledAgentPlugins()` — removes bundled Agent Plugins the host no longer ships from a
|
|
3
|
+
* workspace, and moves their users onto the plugin that absorbed them. Runs on every boot from
|
|
4
|
+
* `seed-bundled.ts`, after seeding, and is a no-op once a workspace has been migrated.
|
|
5
|
+
*
|
|
6
|
+
* ---------------------------------------------------------------------------
|
|
7
|
+
* Why a retirement step exists
|
|
8
|
+
* ---------------------------------------------------------------------------
|
|
9
|
+
* Deleting `content/agent-plugins/<id>/` stops the seeder installing a plugin, but it removes nothing
|
|
10
|
+
* a workspace already has: the installed package stays under `packages/sha256/`, its activation
|
|
11
|
+
* record stays in `activations.json`, and its digest stays in `bundled-digests.json`. An ENABLED
|
|
12
|
+
* leftover keeps its skill in every prompt, now duplicating (and slowly contradicting) the successor
|
|
13
|
+
* that carries the same content. And the operator cannot remove it: `uninstall.ts` refuses a
|
|
14
|
+
* bundled plugin because it would be re-seeded, which is exactly what stops being true here. Owner
|
|
15
|
+
* rule: the user never fixes anything by hand, so the boot does it.
|
|
16
|
+
*
|
|
17
|
+
* ---------------------------------------------------------------------------
|
|
18
|
+
* What happens per retired id (idempotent)
|
|
19
|
+
* ---------------------------------------------------------------------------
|
|
20
|
+
* 1. Nothing installed and no activation record: nothing to do (the second boot after a migration).
|
|
21
|
+
* A leftover ledger entry is still dropped.
|
|
22
|
+
* 2. The retired plugin was ENABLED (explicitly, or by the "absent means active" default while its
|
|
23
|
+
* package is installed): the successor is switched on — unless an operator explicitly turned the
|
|
24
|
+
* successor off, which is left exactly as it is and reported in the outcome. No silent override.
|
|
25
|
+
* 3. The retired plugin's package(s) and activation record are removed through `uninstall.ts`
|
|
26
|
+
* (`retiredBundled: true` skips only its bundled refusal). A record with no package left is
|
|
27
|
+
* deleted directly.
|
|
28
|
+
* 4. The retired id is dropped from `bundled-digests.json`.
|
|
29
|
+
*
|
|
30
|
+
* A malformed or unreadable activation record for the retired id stops that id's retirement with a
|
|
31
|
+
* `failed` outcome and changes nothing: whether the operator had it enabled cannot be established,
|
|
32
|
+
* so neither enabling the successor nor deleting the record is safe. The next boot retries.
|
|
33
|
+
*
|
|
34
|
+
* Out of scope on purpose: nothing a retired plugin could own survives it. Agent Plugins have no
|
|
35
|
+
* per-plugin settings store (a workspace's `data/<pluginId>` directory has no production writer),
|
|
36
|
+
* and custom credentials (a saved `fly.io` token, say) belong to the workspace, not to a plugin.
|
|
37
|
+
*
|
|
38
|
+
* Architectural role:
|
|
39
|
+
* Composition over `activation.ts`, `uninstall.ts`, and `bundled-digests.ts`. Never throws: every
|
|
40
|
+
* failure is captured per id, exactly like `seed-bundled.ts`'s own outcomes, so one stuck retirement
|
|
41
|
+
* cannot stop a workspace from booting.
|
|
42
|
+
*/
|
|
1
43
|
/** Extracted from the host plugin lifecycle. Effects are supplied per host context. */
|
|
2
|
-
import {
|
|
44
|
+
import type { BundledAgentPluginEnableOutcome } from "./activation.js";
|
|
3
45
|
import type { AgentPluginLayout } from "./layout.js";
|
|
4
46
|
import type { AgentPluginLifecyclePorts } from "./ports.js";
|
|
47
|
+
/** What happened to the successor. `not-needed`: the retired plugin was off, so the successor was
|
|
48
|
+
* left as it was. The other three are {@link BundledAgentPluginEnableOutcome}. */
|
|
5
49
|
export type RetiredAgentPluginSuccessorOutcome = BundledAgentPluginEnableOutcome | "not-needed";
|
|
6
50
|
export type RetiredAgentPluginOutcome = {
|
|
7
51
|
readonly pluginId: string;
|
|
@@ -11,9 +55,12 @@ export type RetiredAgentPluginOutcome = {
|
|
|
11
55
|
readonly pluginId: string;
|
|
12
56
|
readonly successorId: string;
|
|
13
57
|
readonly status: "retired";
|
|
58
|
+
/** Every installed archive digest removed; empty when only a record or ledger entry was left. */
|
|
14
59
|
readonly removedDigests: readonly string[];
|
|
15
60
|
readonly activationRemoved: boolean;
|
|
16
61
|
readonly ledgerEntryRemoved: boolean;
|
|
62
|
+
/** `left-disabled-by-operator` is the one case a caller should surface: the retired plugin was
|
|
63
|
+
* on, its successor was explicitly turned off, and this step did not override that. */
|
|
17
64
|
readonly successor: RetiredAgentPluginSuccessorOutcome;
|
|
18
65
|
} | {
|
|
19
66
|
readonly pluginId: string;
|
|
@@ -22,10 +69,12 @@ export type RetiredAgentPluginOutcome = {
|
|
|
22
69
|
readonly reason: string;
|
|
23
70
|
};
|
|
24
71
|
export interface RetireBundledAgentPluginsRequired {
|
|
72
|
+
/** The INSTANCE-level layout, for the same reason `seed-bundled.ts` takes one. */
|
|
25
73
|
readonly layout: AgentPluginLayout;
|
|
26
74
|
readonly workspaceId: string;
|
|
27
75
|
}
|
|
28
76
|
export interface RetireBundledAgentPluginsOptional {
|
|
77
|
+
/** Defaults to {@link RETIRED_BUNDLED_AGENT_PLUGINS}; tests pass their own. */
|
|
29
78
|
readonly retired?: (ReadonlyMap<string, string>) | undefined;
|
|
30
79
|
readonly now?: ((required: Record<string, never>) => Date) | undefined;
|
|
31
80
|
}
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
/** Extracted from the host plugin lifecycle. Effects are supplied per host context. */
|
|
2
|
-
import {} from "./activation.js";
|
|
3
1
|
import { createActivationModule } from "./activation.js";
|
|
4
2
|
import { createBundledDigestsModule } from "./bundled-digests.js";
|
|
5
3
|
import { createResolveAgentPluginRefsModule } from "./resolve-agent-plugin-refs.js";
|
|
@@ -9,6 +7,13 @@ function buildModule(ports) {
|
|
|
9
7
|
const { removeBundledAgentPluginDigest } = createBundledDigestsModule(ports);
|
|
10
8
|
const { listInstalledPlugins } = createResolveAgentPluginRefsModule(ports);
|
|
11
9
|
const { uninstallAgentPlugin } = createUninstallModule(ports);
|
|
10
|
+
/**
|
|
11
|
+
* Retires every id in `retired` from one workspace. See this file's header for the steps.
|
|
12
|
+
*
|
|
13
|
+
* @returns One outcome per retired id, in map order.
|
|
14
|
+
* @throws Nothing.
|
|
15
|
+
* @complexity O(r) retired ids, each one `listInstalledPlugins` walk plus a few small file writes.
|
|
16
|
+
*/
|
|
12
17
|
async function retireBundledAgentPlugins(required, optional = {}) {
|
|
13
18
|
const outcomes = [];
|
|
14
19
|
for (const [pluginId, successorId] of optional.retired ?? ports.retiredBundledPlugins) {
|
|
@@ -21,6 +26,8 @@ function buildModule(ports) {
|
|
|
21
26
|
}
|
|
22
27
|
return outcomes;
|
|
23
28
|
}
|
|
29
|
+
// The successor first: if a later step fails, the operator keeps the capability, and the next boot
|
|
30
|
+
// (which finds this step already done) retries only what is left.
|
|
24
31
|
async function retireOne(required, optional, pluginId, successorId) {
|
|
25
32
|
const workspaceLayout = required.layout.forWorkspace({ workspaceId: required.workspaceId });
|
|
26
33
|
const workspaceRoot = workspaceLayout.root;
|
|
@@ -1,18 +1,81 @@
|
|
|
1
1
|
/** Extracted from the host plugin lifecycle. Effects are supplied per host context. */
|
|
2
2
|
import type { AgentPluginLifecyclePorts } from "./ports.js";
|
|
3
|
+
/**
|
|
4
|
+
* @file Pure relevance ranking for `search_agent_plugin_local` — turns one workspace's already-
|
|
5
|
+
* resolved installed Agent Plugins into scored matches for a free-text query. No filesystem or
|
|
6
|
+
* network I/O; `tool-registrations.ts` is the caller that reads disk and hands this module already-
|
|
7
|
+
* resolved {@link AgentPluginSearchCandidate}s.
|
|
8
|
+
*
|
|
9
|
+
* ---------------------------------------------------------------------------
|
|
10
|
+
* Why field-weighted substring matching, not FTS5/BM25
|
|
11
|
+
* ---------------------------------------------------------------------------
|
|
12
|
+
* `assistant/tool-catalog-query.ts` (the `search_tools` index this tool itself is found through)
|
|
13
|
+
* seeds a real SQLite FTS5 table because its catalog is runtime-registered and can be large (170+
|
|
14
|
+
* tools, changing per boot/workspace). The catalog THIS function ranks over is different in kind:
|
|
15
|
+
* one workspace's own installed Agent Plugins, read fresh off disk on every call, realistically a
|
|
16
|
+
* handful of entries. `assistant/component-catalog-query.ts` (`search_components`) already
|
|
17
|
+
* establishes the house precedent for exactly this shape — "a handful of manifests doesn't warrant
|
|
18
|
+
* a database for substring matching" — and ranks with plain `haystack.includes(term)` scoring. This
|
|
19
|
+
* module reuses that same technique rather than inventing a second one, with one deliberate
|
|
20
|
+
* difference from that precedent: per-field WEIGHTS instead of one flat merged haystack.
|
|
21
|
+
*
|
|
22
|
+
* The weights are not decorative. `plugin.json` (agent-plugins.org) carries a `keywords` array
|
|
23
|
+
* that `component-catalog-query.ts`'s manifests have no equivalent of — author-curated discovery
|
|
24
|
+
* terms, the exact same "operator asks in different words than the code uses" gap
|
|
25
|
+
* `tool-search-keywords.ts`'s own header describes at length for native tools. A keyword hit is a
|
|
26
|
+
* stronger, more deliberate signal than an incidental word landing inside free-form prose, so it is
|
|
27
|
+
* scored higher. The id (`pluginId`) is scored highest of all: it is what a caller actually types
|
|
28
|
+
* back to `plugins_set_enabled`-shaped tools next, so an exact or near match on it (a caller typing
|
|
29
|
+
* "site-compliance" or "compliance") must win outright — mirroring `tool-catalog.ts`'s own
|
|
30
|
+
* documented "id weighted 6x description" ratio for the identical reason (id IS the thing quoted
|
|
31
|
+
* back). Skill name/summary text is weighted lowest: it is the broadest, noisiest vocabulary (every
|
|
32
|
+
* bundled skill's own frontmatter description folded in), valuable for recall on a query that names
|
|
33
|
+
* a capability rather than a plugin ("shadcn components") but not something that should let one
|
|
34
|
+
* verbose plugin outrank a plugin whose own id/keywords/description directly named the query.
|
|
35
|
+
* Plugin `description` sits in between: real author prose, but not curated tags and not the
|
|
36
|
+
* identity string itself.
|
|
37
|
+
*
|
|
38
|
+
* A plugin's own `version`, `enabled` state, and MCP server ids are carried through on every match
|
|
39
|
+
* for the caller to report, but are NOT scored — none of them are text a caller plausibly searches
|
|
40
|
+
* for ("find me the plugin on version 1.1.0" is not a real query), and an MCP server id is often an
|
|
41
|
+
* arbitrary machine-chosen string with no natural-language relationship to what an operator would
|
|
42
|
+
* type.
|
|
43
|
+
*
|
|
44
|
+
* @complexity `rankInstalledAgentPlugins` is O(c * t * f) — c candidates, t query terms, f fields
|
|
45
|
+
* (a fixed constant, 4) — so effectively O(c * t): linear in the installed-plugin count and query
|
|
46
|
+
* length, both small and locally bounded (there is no user-controlled upper bound on installed-
|
|
47
|
+
* plugin count today, matching the same unbounded-but-locally-small shape
|
|
48
|
+
* `loadInstalledAgentPluginToolSources` already accepts for the very same catalog).
|
|
49
|
+
*/
|
|
50
|
+
/** One installed plugin's skill, reduced to what search ranks and reports on — never the full
|
|
51
|
+
* SKILL.md body (that stays behind `agent_plugin_<pluginId>`'s own tool, which a caller reaches
|
|
52
|
+
* once search has told it which plugin to ask). */
|
|
3
53
|
export interface AgentPluginSearchSkill {
|
|
4
54
|
readonly name: string;
|
|
55
|
+
/** Plain-language explainer from the host extension namespace in `plugin.json`: `summary`, for the admin list
|
|
56
|
+
* route only — not ranked, and the search tool's own response projection leaves it out. */
|
|
5
57
|
readonly summary: string;
|
|
6
58
|
}
|
|
59
|
+
/** One installed plugin, already resolved into everything the ranker and the response need — no
|
|
60
|
+
* `packageRoot`/`skillPath`/any other host-filesystem detail (see `tool-registrations.ts`'s own
|
|
61
|
+
* SECURITY note for why: this candidate shape is what actually reaches the model). */
|
|
7
62
|
export interface AgentPluginSearchCandidate {
|
|
8
63
|
readonly pluginId: string;
|
|
9
64
|
readonly version?: (string) | undefined;
|
|
65
|
+
/** Human-facing title from the host extension namespace in `plugin.json`: `displayName`, when the package set one. */
|
|
66
|
+
readonly displayName?: string | undefined;
|
|
67
|
+
readonly summary?: string | undefined;
|
|
10
68
|
readonly description?: (string) | undefined;
|
|
11
69
|
readonly keywords: readonly string[];
|
|
70
|
+
/** Whether this workspace currently has the plugin enabled — reflects `activation.ts`'s
|
|
71
|
+
* absent-means-active default, same as everywhere else this codebase reads activation state. */
|
|
12
72
|
readonly enabled: boolean;
|
|
13
73
|
readonly skills: readonly AgentPluginSearchSkill[];
|
|
74
|
+
/** Server ids only, from the package's own `mcp.json` — never transport config (`command`/`args`/
|
|
75
|
+
* `env` can carry secrets; see `capability-projection.ts`'s identical restraint). */
|
|
14
76
|
readonly mcpServerIds: readonly string[];
|
|
15
77
|
}
|
|
78
|
+
/** One ranked result: a candidate plus the score it earned against a specific query. */
|
|
16
79
|
export interface AgentPluginSearchMatch extends AgentPluginSearchCandidate {
|
|
17
80
|
readonly score: number;
|
|
18
81
|
}
|
package/dist/lifecycle/search.js
CHANGED
|
@@ -4,12 +4,18 @@ function buildModule(ports) {
|
|
|
4
4
|
const KEYWORD_FIELD_WEIGHT = 4;
|
|
5
5
|
const DESCRIPTION_FIELD_WEIGHT = 2;
|
|
6
6
|
const SKILL_FIELD_WEIGHT = 1;
|
|
7
|
+
/** Lowercase, whitespace-split query terms — mirrors `component-catalog-query.ts`'s identical
|
|
8
|
+
* `query.toLowerCase().split(/\s+/).filter(Boolean)`, the house tokenizer for this "small local
|
|
9
|
+
* catalog, plain string matching" shape. Empty/whitespace-only input yields no terms. */
|
|
7
10
|
function tokenize(query) {
|
|
8
11
|
return query
|
|
9
12
|
.toLowerCase()
|
|
10
13
|
.split(/\s+/)
|
|
11
14
|
.filter((term) => term.length > 0);
|
|
12
15
|
}
|
|
16
|
+
/** One candidate's scoreable text, one entry per weighted field — see this file's header for why
|
|
17
|
+
* each weight is what it is. Split out of {@link scoreCandidate} so the field/weight pairing is a
|
|
18
|
+
* plain, independently-readable list rather than interleaved with the scoring loop itself. */
|
|
13
19
|
function fieldHaystacks(candidate) {
|
|
14
20
|
return [
|
|
15
21
|
{ text: candidate.pluginId.toLowerCase(), weight: ID_FIELD_WEIGHT },
|
|
@@ -21,10 +27,33 @@ function buildModule(ports) {
|
|
|
21
27
|
},
|
|
22
28
|
];
|
|
23
29
|
}
|
|
30
|
+
/** Sums, over every query term and every weighted field, the field's weight whenever that field's
|
|
31
|
+
* text contains the term as a substring — the same `includes()` test `component-catalog-query.ts`
|
|
32
|
+
* uses, just per-field instead of one merged haystack. A term can score against more than one field
|
|
33
|
+
* (e.g. a keyword that is also echoed in the description) — that is intentional: a plugin whose
|
|
34
|
+
* author reinforced a concept in both its keywords and its prose is a genuinely stronger match, not
|
|
35
|
+
* a bug to dedupe away. */
|
|
24
36
|
function scoreCandidate(candidate, terms) {
|
|
25
37
|
const haystacks = fieldHaystacks(candidate);
|
|
26
38
|
return terms.reduce((total, term) => total + haystacks.reduce((fieldTotal, haystack) => (haystack.text.includes(term) ? fieldTotal + haystack.weight : fieldTotal), 0), 0);
|
|
27
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Ranks already-resolved installed-plugin candidates against a free-text query.
|
|
42
|
+
*
|
|
43
|
+
* @param query - Free-text search phrase. An empty or whitespace-only query has no terms to match
|
|
44
|
+
* against and always returns no matches — this function never falls back to "return everything",
|
|
45
|
+
* matching `search_components`' own "no query, no hits" behavior rather than the different (and
|
|
46
|
+
* here wrong) choice of treating an empty query as "list all installed".
|
|
47
|
+
* @param candidates - One workspace's installed plugins, already resolved (disk already read).
|
|
48
|
+
* @param limit - Max matches to return, already clamped by the caller (mirrors
|
|
49
|
+
* `component-catalog-tool.ts`'s handler, which owns the clamp so this function can stay a pure
|
|
50
|
+
* "given these inputs, rank them" step with no knowledge of the tool's own schema bounds).
|
|
51
|
+
* @returns Matches with score > 0, sorted by score descending, truncated to `limit`. A candidate
|
|
52
|
+
* that scores 0 (no term matched anything) is dropped, not returned at the bottom — the caller's own
|
|
53
|
+
* "N installed, 0 matched" framing is what tells the model the workspace is not empty even when a
|
|
54
|
+
* query matches nothing.
|
|
55
|
+
* @complexity See this file's header.
|
|
56
|
+
*/
|
|
28
57
|
function rankInstalledAgentPlugins(query, candidates, limit) {
|
|
29
58
|
const terms = tokenize(query);
|
|
30
59
|
if (terms.length === 0)
|