@kolisachint/hoocode-agent 0.5.25 → 0.5.27

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.
Files changed (88) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/dist/core/canvas/lifecycle.d.ts +93 -0
  3. package/dist/core/canvas/lifecycle.d.ts.map +1 -0
  4. package/dist/core/canvas/lifecycle.js +165 -0
  5. package/dist/core/canvas/lifecycle.js.map +1 -0
  6. package/dist/core/canvas/registry.d.ts +89 -0
  7. package/dist/core/canvas/registry.d.ts.map +1 -1
  8. package/dist/core/canvas/registry.js +205 -10
  9. package/dist/core/canvas/registry.js.map +1 -1
  10. package/dist/core/canvas/scaffold.d.ts +123 -0
  11. package/dist/core/canvas/scaffold.d.ts.map +1 -0
  12. package/dist/core/canvas/scaffold.js +376 -0
  13. package/dist/core/canvas/scaffold.js.map +1 -0
  14. package/dist/core/canvas/session.d.ts +39 -1
  15. package/dist/core/canvas/session.d.ts.map +1 -1
  16. package/dist/core/canvas/session.js +83 -1
  17. package/dist/core/canvas/session.js.map +1 -1
  18. package/dist/core/capabilities/lexical.d.ts +4 -0
  19. package/dist/core/capabilities/lexical.d.ts.map +1 -1
  20. package/dist/core/capabilities/lexical.js +104 -4
  21. package/dist/core/capabilities/lexical.js.map +1 -1
  22. package/dist/core/capabilities/registry.d.ts +3 -1
  23. package/dist/core/capabilities/registry.d.ts.map +1 -1
  24. package/dist/core/capabilities/registry.js.map +1 -1
  25. package/dist/core/self-docs.d.ts +103 -0
  26. package/dist/core/self-docs.d.ts.map +1 -0
  27. package/dist/core/self-docs.js +351 -0
  28. package/dist/core/self-docs.js.map +1 -0
  29. package/dist/core/system-prompt.d.ts +12 -0
  30. package/dist/core/system-prompt.d.ts.map +1 -1
  31. package/dist/core/system-prompt.js +11 -1
  32. package/dist/core/system-prompt.js.map +1 -1
  33. package/dist/core/tools/canvas.d.ts +23 -3
  34. package/dist/core/tools/canvas.d.ts.map +1 -1
  35. package/dist/core/tools/canvas.js +99 -4
  36. package/dist/core/tools/canvas.js.map +1 -1
  37. package/dist/extensions/core/canvas.d.ts +20 -2
  38. package/dist/extensions/core/canvas.d.ts.map +1 -1
  39. package/dist/extensions/core/canvas.js +279 -36
  40. package/dist/extensions/core/canvas.js.map +1 -1
  41. package/dist/extensions/core/hoo-core.d.ts +1 -0
  42. package/dist/extensions/core/hoo-core.d.ts.map +1 -1
  43. package/dist/extensions/core/hoo-core.js +3 -0
  44. package/dist/extensions/core/hoo-core.js.map +1 -1
  45. package/dist/extensions/core/mcp-loader.d.ts.map +1 -1
  46. package/dist/extensions/core/mcp-loader.js +8 -2
  47. package/dist/extensions/core/mcp-loader.js.map +1 -1
  48. package/dist/extensions/core/scaffold.d.ts +7 -1
  49. package/dist/extensions/core/scaffold.d.ts.map +1 -1
  50. package/dist/extensions/core/scaffold.js +7 -185
  51. package/dist/extensions/core/scaffold.js.map +1 -1
  52. package/dist/extensions/core/self-knowledge.d.ts +28 -0
  53. package/dist/extensions/core/self-knowledge.d.ts.map +1 -0
  54. package/dist/extensions/core/self-knowledge.js +199 -0
  55. package/dist/extensions/core/self-knowledge.js.map +1 -0
  56. package/docs/canvas.md +117 -0
  57. package/docs/compaction.md +4 -4
  58. package/docs/custom-provider.md +1 -1
  59. package/docs/development.md +1 -1
  60. package/docs/docs.json +27 -2
  61. package/docs/extensions.md +12 -12
  62. package/docs/index.md +8 -0
  63. package/docs/keybindings.md +2 -2
  64. package/docs/mcp.md +97 -0
  65. package/docs/models.md +1 -1
  66. package/docs/modes.md +87 -0
  67. package/docs/packages.md +4 -4
  68. package/docs/plugins.md +124 -0
  69. package/docs/prompt-templates.md +1 -1
  70. package/docs/providers.md +2 -2
  71. package/docs/quickstart.md +2 -2
  72. package/docs/rpc.md +5 -5
  73. package/docs/sdk.md +5 -5
  74. package/docs/session-format.md +3 -3
  75. package/docs/sessions.md +1 -1
  76. package/docs/settings.md +3 -3
  77. package/docs/shell-aliases.md +1 -1
  78. package/docs/skills.md +2 -2
  79. package/docs/terminal-setup.md +1 -1
  80. package/docs/termux.md +2 -2
  81. package/docs/themes.md +3 -3
  82. package/docs/usage.md +93 -4
  83. package/docs/windows.md +1 -1
  84. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  85. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  86. package/examples/extensions/sandbox/package.json +1 -1
  87. package/examples/extensions/with-deps/package.json +1 -1
  88. package/package.json +4 -4
@@ -16,7 +16,8 @@
16
16
  import type { DiscoveredCanvasExtension } from "./discovery.js";
17
17
  import { type CanvasSearchRoot } from "./discovery.js";
18
18
  import { type CanvasAvailability } from "./launch.js";
19
- import { type CanvasInstance, CanvasRegistry, type CanvasRegistryEvents } from "./registry.js";
19
+ import { type CanvasLifecycleRefusal, type CanvasRemoveResult, type CanvasRenameResult } from "./lifecycle.js";
20
+ import { type CanvasInstance, CanvasRegistry, type CanvasRegistryEvents, type CanvasReloadResult } from "./registry.js";
20
21
  import type { CanvasCallOptions } from "./runner.js";
21
22
  /** One canvas a person could open, or has open. */
22
23
  export interface CanvasListing {
@@ -37,6 +38,15 @@ export interface CanvasOverview {
37
38
  listings: CanvasListing[];
38
39
  /** Extensions withheld by the trust gate — surfaced, never hidden (§5.1). */
39
40
  withheldCount: number;
41
+ /**
42
+ * Action names per open instance.
43
+ *
44
+ * Beside the listings rather than inside them because an action belongs to a
45
+ * running instance, not to a canvas on disk: a listing exists for extensions
46
+ * that have never been forked, and those have no actions to report — not zero
47
+ * of them, none knowable.
48
+ */
49
+ actionsByInstance: Map<string, string[]>;
40
50
  }
41
51
  /** Configuration for a session's canvas facade. */
42
52
  export interface CanvasSessionOptions extends CanvasRegistryEvents {
@@ -100,6 +110,34 @@ export declare class CanvasSession {
100
110
  open(ref: CanvasRef, options?: CanvasCallOptions): Promise<CanvasInstance>;
101
111
  /** Close one open instance. Unknown ids are a no-op, so closing twice is harmless. */
102
112
  close(instanceId: string): Promise<CanvasInstance | undefined>;
113
+ /**
114
+ * Re-fork an open extension so an edit to its code takes effect.
115
+ *
116
+ * Reached by extension id rather than instance id because a reload restarts the
117
+ * *process*, and one child serves every instance of every canvas the extension
118
+ * declares — pretending it could reload one instance would be a lie about what
119
+ * happens. {@link CanvasRegistry.reload} carries the open instances across.
120
+ */
121
+ reload(extensionId: string, options?: CanvasCallOptions): Promise<CanvasReloadResult>;
122
+ /**
123
+ * Rename a canvas extension, closing anything it has open first.
124
+ *
125
+ * Closing is not politeness: the directory is about to move, and an instance
126
+ * left open would be serving from a path that no longer exists while the
127
+ * registry still believed it was there. The closed instance ids are returned so
128
+ * the caller can say what it cost.
129
+ */
130
+ rename(extensionId: string, to: string): Promise<CanvasRenameResult | CanvasLifecycleRefusal>;
131
+ /** Delete a canvas extension, closing anything it has open first. */
132
+ remove(extensionId: string): Promise<CanvasRemoveResult | CanvasLifecycleRefusal>;
133
+ private closeAllOf;
134
+ /** The discovered extension with this id, runnable or withheld. */
135
+ private find;
136
+ private notFound;
137
+ /** Everything that could be renamed or removed, for completions and messages. */
138
+ knownExtensionIds(): string[];
139
+ /** The extension ids with at least one open instance — what {@link reload} accepts. */
140
+ runningExtensionIds(): string[];
103
141
  /** Every open instance. */
104
142
  instances(): CanvasInstance[];
105
143
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../../../src/core/canvas/session.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Session-scoped canvas facade: everything the TUI needs, with no TUI in it.\n *\n * Design: `docs/canvas-extensions-design.md` §11. The pieces underneath — discovery,\n * the trust gate, availability, the registry — are each small and separately tested.\n * This is what stitches them into the four questions a user surface actually asks:\n * what is there, can it run, open this one, close that one.\n *\n * It holds no TUI types on purpose. `extensions/core/canvas.ts` renders and supplies\n * an `AbortSignal` from a cancellable loader; everything decided here stays testable\n * without a terminal.\n *\n * Availability is resolved once and cached, because resolving can spawn\n * `node --version` (§11.1) and the answer cannot change within a session.\n */\n\nimport { getAgentDir } from \"../../config.js\";\nimport type { DiscoveredCanvasExtension } from \"./discovery.js\";\nimport { type CanvasSearchRoot, canvasSearchRoots, discoverCanvasExtensions } from \"./discovery.js\";\nimport { type CanvasAvailability, resolveCanvasRuntime } from \"./launch.js\";\nimport { pluginCanvasExtensions } from \"./plugin-canvases.js\";\nimport { type CanvasInstance, CanvasRegistry, type CanvasRegistryEvents } from \"./registry.js\";\nimport type { CanvasCallOptions } from \"./runner.js\";\nimport { gateCanvasExtensions } from \"./trust.js\";\n\n/** One canvas a person could open, or has open. */\nexport interface CanvasListing {\n\textensionId: string;\n\t/** Undefined until the extension has been forked, since declarations come from it. */\n\tcanvasId: string | undefined;\n\tdisplayName: string | undefined;\n\tscope: DiscoveredCanvasExtension[\"scope\"];\n\t/** Why it cannot be opened, if it cannot. */\n\twithheld: \"untrusted-workspace\" | undefined;\n\t/** Instances of this canvas that are currently open. */\n\topen: CanvasInstance[];\n}\n\n/** What `list()` reports. */\nexport interface CanvasOverview {\n\t/** Absent `reason` means canvases can run here. */\n\tavailability: CanvasAvailability;\n\tlistings: CanvasListing[];\n\t/** Extensions withheld by the trust gate — surfaced, never hidden (§5.1). */\n\twithheldCount: number;\n}\n\n/** Configuration for a session's canvas facade. */\nexport interface CanvasSessionOptions extends CanvasRegistryEvents {\n\tcwd: string;\n\thomeDir: string;\n\tagentDir?: string;\n\t/** Override the search roots; defaults to {@link canvasSearchRoots}. */\n\troots?: CanvasSearchRoot[];\n\t/**\n\t * Override how plugin-shipped canvases are found; defaults to\n\t * {@link pluginCanvasExtensions}. Pass `() => []` to look at the search roots\n\t * and nothing else.\n\t */\n\tpluginExtensions?: () => DiscoveredCanvasExtension[];\n\t/** Override availability resolution, for tests and for hosts that already know. */\n\tresolveRuntime?: () => Promise<CanvasAvailability>;\n}\n\n/** Reference to a canvas: an extension id, optionally narrowed to one of its canvases. */\nexport interface CanvasRef {\n\textensionId: string;\n\tcanvasId?: string;\n}\n\n/**\n * Parse `extension` or `extension:canvas`.\n *\n * Extension ids are directory names and canvas ids are provider-local, so a single\n * colon is unambiguous and needs no quoting.\n */\nexport function parseCanvasRef(input: string): CanvasRef | undefined {\n\tconst trimmed = input.trim();\n\tif (trimmed.length === 0) return undefined;\n\tconst colon = trimmed.indexOf(\":\");\n\tif (colon === -1) return { extensionId: trimmed };\n\tconst extensionId = trimmed.slice(0, colon).trim();\n\tconst canvasId = trimmed.slice(colon + 1).trim();\n\tif (extensionId.length === 0 || canvasId.length === 0) return undefined;\n\treturn { extensionId, canvasId };\n}\n\nexport class CanvasSession {\n\tprivate readonly options: CanvasSessionOptions;\n\tprivate readonly roots: CanvasSearchRoot[];\n\tprivate readonly pluginExtensions: () => DiscoveredCanvasExtension[];\n\t/** Cached because resolving can spawn `node --version` and cannot change mid-session. */\n\tprivate availabilityPromise: Promise<CanvasAvailability> | undefined;\n\t/** Created on first successful open, not at construction: listing must not fork. */\n\tprivate registry: CanvasRegistry | undefined;\n\n\tconstructor(options: CanvasSessionOptions) {\n\t\tthis.options = options;\n\t\tthis.roots = options.roots ?? canvasSearchRoots(options.cwd, options.homeDir);\n\t\t// Re-read on every discover rather than cached: /plugin install can add a\n\t\t// canvas mid-session, and a listing that cannot see it is the bug this\n\t\t// exists to fix.\n\t\tthis.pluginExtensions =\n\t\t\toptions.pluginExtensions ?? (() => pluginCanvasExtensions(options.cwd, options.agentDir ?? getAgentDir()));\n\t}\n\n\t/** Discovered extensions, partitioned by the trust gate. Read-only and always safe. */\n\tdiscover(): { runnable: DiscoveredCanvasExtension[]; withheld: DiscoveredCanvasExtension[] } {\n\t\tconst gated = gateCanvasExtensions(\n\t\t\tdiscoverCanvasExtensions(this.roots, this.pluginExtensions()),\n\t\t\tthis.options.cwd,\n\t\t\tthis.options.agentDir ?? getAgentDir(),\n\t\t);\n\t\treturn { runnable: gated.runnable, withheld: gated.withheld.map((entry) => entry.extension) };\n\t}\n\n\t/** Whether canvases can run here. Resolved once per session and cached. */\n\tasync availability(): Promise<CanvasAvailability> {\n\t\tthis.availabilityPromise ??= (this.options.resolveRuntime ?? resolveCanvasRuntime)();\n\t\treturn this.availabilityPromise;\n\t}\n\n\t/**\n\t * What is installed, what is open, and what is being withheld.\n\t *\n\t * Deliberately does not fork anything: listing must stay free and safe, so a\n\t * `canvasId` is only known for extensions already running. That is the visible\n\t * consequence of a canvas having no passive half (§5.1) — even its name comes from\n\t * running its code.\n\t */\n\tasync list(): Promise<CanvasOverview> {\n\t\tconst availability = await this.availability();\n\t\tconst { runnable, withheld } = this.discover();\n\t\tconst open = this.registryOrUndefined()?.listInstances() ?? [];\n\n\t\tconst listings: CanvasListing[] = [];\n\t\tfor (const extension of runnable) {\n\t\t\tconst instances = open.filter((instance) => instance.extensionId === extension.id);\n\t\t\tif (instances.length === 0) {\n\t\t\t\tlistings.push({\n\t\t\t\t\textensionId: extension.id,\n\t\t\t\t\tcanvasId: undefined,\n\t\t\t\t\tdisplayName: undefined,\n\t\t\t\t\tscope: extension.scope,\n\t\t\t\t\twithheld: undefined,\n\t\t\t\t\topen: [],\n\t\t\t\t});\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tfor (const canvasId of new Set(instances.map((instance) => instance.canvasId))) {\n\t\t\t\tconst forCanvas = instances.filter((instance) => instance.canvasId === canvasId);\n\t\t\t\tlistings.push({\n\t\t\t\t\textensionId: extension.id,\n\t\t\t\t\tcanvasId,\n\t\t\t\t\tdisplayName: forCanvas[0]?.title,\n\t\t\t\t\tscope: extension.scope,\n\t\t\t\t\twithheld: undefined,\n\t\t\t\t\topen: forCanvas,\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t\tfor (const extension of withheld) {\n\t\t\tlistings.push({\n\t\t\t\textensionId: extension.id,\n\t\t\t\tcanvasId: undefined,\n\t\t\t\tdisplayName: undefined,\n\t\t\t\tscope: extension.scope,\n\t\t\t\twithheld: \"untrusted-workspace\",\n\t\t\t\topen: [],\n\t\t\t});\n\t\t}\n\t\treturn { availability, listings, withheldCount: withheld.length };\n\t}\n\n\t/**\n\t * Open a canvas.\n\t *\n\t * `options.signal` comes from the caller's cancellable loader, so a person's Esc\n\t * reaches the registry's abandon path (§11.6) rather than merely hiding a spinner.\n\t */\n\tasync open(ref: CanvasRef, options?: CanvasCallOptions): Promise<CanvasInstance> {\n\t\tconst availability = await this.availability();\n\t\tif (!availability.available) throw new Error(availability.reason);\n\n\t\tconst { runnable, withheld } = this.discover();\n\t\tconst extension = runnable.find((candidate) => candidate.id === ref.extensionId);\n\t\tif (!extension) {\n\t\t\tif (withheld.some((candidate) => candidate.id === ref.extensionId)) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`Canvas extension \"${ref.extensionId}\" came with this repository, which is not a trusted workspace. ` +\n\t\t\t\t\t\t\"Run /plugin trust to allow this directory to run code it ships.\",\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst known = runnable.map((candidate) => candidate.id).join(\", \") || \"none\";\n\t\t\tthrow new Error(`No canvas extension \"${ref.extensionId}\" (found: ${known}).`);\n\t\t}\n\n\t\tconst registry = this.ensureRegistry(availability);\n\t\tconst canvasId = ref.canvasId ?? (await this.soleCanvasId(registry, extension));\n\t\treturn registry.open(extension, canvasId, undefined, options);\n\t}\n\n\t/** Close one open instance. Unknown ids are a no-op, so closing twice is harmless. */\n\tasync close(instanceId: string): Promise<CanvasInstance | undefined> {\n\t\tconst registry = this.registryOrUndefined();\n\t\tconst instance = registry?.listInstances().find((open) => open.instanceId === instanceId);\n\t\tif (!registry || !instance) return undefined;\n\t\tawait registry.close(instance);\n\t\treturn instance;\n\t}\n\n\t/** Every open instance. */\n\tinstances(): CanvasInstance[] {\n\t\treturn this.registryOrUndefined()?.listInstances() ?? [];\n\t}\n\n\t/**\n\t * The live registry, or undefined if nothing has been opened yet.\n\t *\n\t * Exposed so the host can hand it to the canvas tools, which read\n\t * `listInstances()` and `activeActions()` from it.\n\t */\n\tregistryOrUndefined(): CanvasRegistry | undefined {\n\t\treturn this.registry;\n\t}\n\n\t/** Advisory cleanup, driven by whoever owns the session clock. */\n\tasync reapIdle(): Promise<string[]> {\n\t\treturn (await this.registryOrUndefined()?.reapIdle()) ?? [];\n\t}\n\n\t/** Close everything and stop every child. Safe to call twice. */\n\tasync dispose(): Promise<void> {\n\t\tawait this.registry?.shutdown();\n\t\tthis.registry = undefined;\n\t}\n\n\tprivate ensureRegistry(availability: Extract<CanvasAvailability, { available: true }>): CanvasRegistry {\n\t\tif (!this.registry) {\n\t\t\tthis.registry = new CanvasRegistry({\n\t\t\t\truntime: availability.runtime,\n\t\t\t\tcwd: this.options.cwd,\n\t\t\t\tagentDir: this.options.agentDir,\n\t\t\t\tonLog: this.options.onLog,\n\t\t\t\tonStray: this.options.onStray,\n\t\t\t\tonStderr: this.options.onStderr,\n\t\t\t\tonDiagnostic: this.options.onDiagnostic,\n\t\t\t});\n\t\t}\n\t\treturn this.registry;\n\t}\n\n\t/**\n\t * Pick the canvas when the caller named only an extension.\n\t *\n\t * Forking to read the declarations is unavoidable: they arrive in the child's\n\t * `ready` message. A multi-canvas extension must be named explicitly rather than\n\t * guessed at.\n\t */\n\tprivate async soleCanvasId(registry: CanvasRegistry, extension: DiscoveredCanvasExtension): Promise<string> {\n\t\tconst declarations = await registry.declarations(extension);\n\t\tif (declarations.length === 1) return declarations[0]?.id as string;\n\t\tif (declarations.length === 0) throw new Error(`Canvas extension \"${extension.id}\" declares no canvases.`);\n\t\tconst ids = declarations.map((declaration) => `${extension.id}:${declaration.id}`).join(\", \");\n\t\tthrow new Error(`Canvas extension \"${extension.id}\" declares several canvases; name one of: ${ids}.`);\n\t}\n}\n"]}
1
+ {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../../../src/core/canvas/session.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Session-scoped canvas facade: everything the TUI needs, with no TUI in it.\n *\n * Design: `docs/canvas-extensions-design.md` §11. The pieces underneath — discovery,\n * the trust gate, availability, the registry — are each small and separately tested.\n * This is what stitches them into the four questions a user surface actually asks:\n * what is there, can it run, open this one, close that one.\n *\n * It holds no TUI types on purpose. `extensions/core/canvas.ts` renders and supplies\n * an `AbortSignal` from a cancellable loader; everything decided here stays testable\n * without a terminal.\n *\n * Availability is resolved once and cached, because resolving can spawn\n * `node --version` (§11.1) and the answer cannot change within a session.\n */\n\nimport { getAgentDir } from \"../../config.js\";\nimport type { DiscoveredCanvasExtension } from \"./discovery.js\";\nimport { type CanvasSearchRoot, canvasSearchRoots, discoverCanvasExtensions } from \"./discovery.js\";\nimport { type CanvasAvailability, resolveCanvasRuntime } from \"./launch.js\";\nimport {\n\ttype CanvasLifecycleRefusal,\n\ttype CanvasRemoveResult,\n\ttype CanvasRenameResult,\n\tremoveCanvasExtension,\n\trenameCanvasExtension,\n} from \"./lifecycle.js\";\nimport { pluginCanvasExtensions } from \"./plugin-canvases.js\";\nimport { type CanvasInstance, CanvasRegistry, type CanvasRegistryEvents, type CanvasReloadResult } from \"./registry.js\";\nimport type { CanvasCallOptions } from \"./runner.js\";\nimport { gateCanvasExtensions } from \"./trust.js\";\n\n/** One canvas a person could open, or has open. */\nexport interface CanvasListing {\n\textensionId: string;\n\t/** Undefined until the extension has been forked, since declarations come from it. */\n\tcanvasId: string | undefined;\n\tdisplayName: string | undefined;\n\tscope: DiscoveredCanvasExtension[\"scope\"];\n\t/** Why it cannot be opened, if it cannot. */\n\twithheld: \"untrusted-workspace\" | undefined;\n\t/** Instances of this canvas that are currently open. */\n\topen: CanvasInstance[];\n}\n\n/** What `list()` reports. */\nexport interface CanvasOverview {\n\t/** Absent `reason` means canvases can run here. */\n\tavailability: CanvasAvailability;\n\tlistings: CanvasListing[];\n\t/** Extensions withheld by the trust gate — surfaced, never hidden (§5.1). */\n\twithheldCount: number;\n\t/**\n\t * Action names per open instance.\n\t *\n\t * Beside the listings rather than inside them because an action belongs to a\n\t * running instance, not to a canvas on disk: a listing exists for extensions\n\t * that have never been forked, and those have no actions to report — not zero\n\t * of them, none knowable.\n\t */\n\tactionsByInstance: Map<string, string[]>;\n}\n\n/** Configuration for a session's canvas facade. */\nexport interface CanvasSessionOptions extends CanvasRegistryEvents {\n\tcwd: string;\n\thomeDir: string;\n\tagentDir?: string;\n\t/** Override the search roots; defaults to {@link canvasSearchRoots}. */\n\troots?: CanvasSearchRoot[];\n\t/**\n\t * Override how plugin-shipped canvases are found; defaults to\n\t * {@link pluginCanvasExtensions}. Pass `() => []` to look at the search roots\n\t * and nothing else.\n\t */\n\tpluginExtensions?: () => DiscoveredCanvasExtension[];\n\t/** Override availability resolution, for tests and for hosts that already know. */\n\tresolveRuntime?: () => Promise<CanvasAvailability>;\n}\n\n/** Reference to a canvas: an extension id, optionally narrowed to one of its canvases. */\nexport interface CanvasRef {\n\textensionId: string;\n\tcanvasId?: string;\n}\n\n/**\n * Parse `extension` or `extension:canvas`.\n *\n * Extension ids are directory names and canvas ids are provider-local, so a single\n * colon is unambiguous and needs no quoting.\n */\nexport function parseCanvasRef(input: string): CanvasRef | undefined {\n\tconst trimmed = input.trim();\n\tif (trimmed.length === 0) return undefined;\n\tconst colon = trimmed.indexOf(\":\");\n\tif (colon === -1) return { extensionId: trimmed };\n\tconst extensionId = trimmed.slice(0, colon).trim();\n\tconst canvasId = trimmed.slice(colon + 1).trim();\n\tif (extensionId.length === 0 || canvasId.length === 0) return undefined;\n\treturn { extensionId, canvasId };\n}\n\nexport class CanvasSession {\n\tprivate readonly options: CanvasSessionOptions;\n\tprivate readonly roots: CanvasSearchRoot[];\n\tprivate readonly pluginExtensions: () => DiscoveredCanvasExtension[];\n\t/** Cached because resolving can spawn `node --version` and cannot change mid-session. */\n\tprivate availabilityPromise: Promise<CanvasAvailability> | undefined;\n\t/** Created on first successful open, not at construction: listing must not fork. */\n\tprivate registry: CanvasRegistry | undefined;\n\n\tconstructor(options: CanvasSessionOptions) {\n\t\tthis.options = options;\n\t\tthis.roots = options.roots ?? canvasSearchRoots(options.cwd, options.homeDir);\n\t\t// Re-read on every discover rather than cached: /plugin install can add a\n\t\t// canvas mid-session, and a listing that cannot see it is the bug this\n\t\t// exists to fix.\n\t\tthis.pluginExtensions =\n\t\t\toptions.pluginExtensions ?? (() => pluginCanvasExtensions(options.cwd, options.agentDir ?? getAgentDir()));\n\t}\n\n\t/** Discovered extensions, partitioned by the trust gate. Read-only and always safe. */\n\tdiscover(): { runnable: DiscoveredCanvasExtension[]; withheld: DiscoveredCanvasExtension[] } {\n\t\tconst gated = gateCanvasExtensions(\n\t\t\tdiscoverCanvasExtensions(this.roots, this.pluginExtensions()),\n\t\t\tthis.options.cwd,\n\t\t\tthis.options.agentDir ?? getAgentDir(),\n\t\t);\n\t\treturn { runnable: gated.runnable, withheld: gated.withheld.map((entry) => entry.extension) };\n\t}\n\n\t/** Whether canvases can run here. Resolved once per session and cached. */\n\tasync availability(): Promise<CanvasAvailability> {\n\t\tthis.availabilityPromise ??= (this.options.resolveRuntime ?? resolveCanvasRuntime)();\n\t\treturn this.availabilityPromise;\n\t}\n\n\t/**\n\t * What is installed, what is open, and what is being withheld.\n\t *\n\t * Deliberately does not fork anything: listing must stay free and safe, so a\n\t * `canvasId` is only known for extensions already running. That is the visible\n\t * consequence of a canvas having no passive half (§5.1) — even its name comes from\n\t * running its code.\n\t */\n\tasync list(): Promise<CanvasOverview> {\n\t\tconst availability = await this.availability();\n\t\tconst { runnable, withheld } = this.discover();\n\t\tconst open = this.registryOrUndefined()?.listInstances() ?? [];\n\n\t\tconst listings: CanvasListing[] = [];\n\t\tfor (const extension of runnable) {\n\t\t\tconst instances = open.filter((instance) => instance.extensionId === extension.id);\n\t\t\tif (instances.length === 0) {\n\t\t\t\tlistings.push({\n\t\t\t\t\textensionId: extension.id,\n\t\t\t\t\tcanvasId: undefined,\n\t\t\t\t\tdisplayName: undefined,\n\t\t\t\t\tscope: extension.scope,\n\t\t\t\t\twithheld: undefined,\n\t\t\t\t\topen: [],\n\t\t\t\t});\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tfor (const canvasId of new Set(instances.map((instance) => instance.canvasId))) {\n\t\t\t\tconst forCanvas = instances.filter((instance) => instance.canvasId === canvasId);\n\t\t\t\tlistings.push({\n\t\t\t\t\textensionId: extension.id,\n\t\t\t\t\tcanvasId,\n\t\t\t\t\tdisplayName: forCanvas[0]?.title,\n\t\t\t\t\tscope: extension.scope,\n\t\t\t\t\twithheld: undefined,\n\t\t\t\t\topen: forCanvas,\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t\tfor (const extension of withheld) {\n\t\t\tlistings.push({\n\t\t\t\textensionId: extension.id,\n\t\t\t\tcanvasId: undefined,\n\t\t\t\tdisplayName: undefined,\n\t\t\t\tscope: extension.scope,\n\t\t\t\twithheld: \"untrusted-workspace\",\n\t\t\t\topen: [],\n\t\t\t});\n\t\t}\n\t\tconst actionsByInstance = new Map<string, string[]>();\n\t\tfor (const binding of this.registryOrUndefined()?.activeActions() ?? []) {\n\t\t\tconst names = actionsByInstance.get(binding.instanceId) ?? [];\n\t\t\tnames.push(binding.action.name);\n\t\t\tactionsByInstance.set(binding.instanceId, names);\n\t\t}\n\t\treturn { availability, listings, withheldCount: withheld.length, actionsByInstance };\n\t}\n\n\t/**\n\t * Open a canvas.\n\t *\n\t * `options.signal` comes from the caller's cancellable loader, so a person's Esc\n\t * reaches the registry's abandon path (§11.6) rather than merely hiding a spinner.\n\t */\n\tasync open(ref: CanvasRef, options?: CanvasCallOptions): Promise<CanvasInstance> {\n\t\tconst availability = await this.availability();\n\t\tif (!availability.available) throw new Error(availability.reason);\n\n\t\tconst { runnable, withheld } = this.discover();\n\t\tconst extension = runnable.find((candidate) => candidate.id === ref.extensionId);\n\t\tif (!extension) {\n\t\t\tif (withheld.some((candidate) => candidate.id === ref.extensionId)) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`Canvas extension \"${ref.extensionId}\" came with this repository, which is not a trusted workspace. ` +\n\t\t\t\t\t\t\"Run /plugin trust to allow this directory to run code it ships.\",\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst known = runnable.map((candidate) => candidate.id).join(\", \") || \"none\";\n\t\t\tthrow new Error(`No canvas extension \"${ref.extensionId}\" (found: ${known}).`);\n\t\t}\n\n\t\tconst registry = this.ensureRegistry(availability);\n\t\tconst canvasId = ref.canvasId ?? (await this.soleCanvasId(registry, extension));\n\t\treturn registry.open(extension, canvasId, undefined, options);\n\t}\n\n\t/** Close one open instance. Unknown ids are a no-op, so closing twice is harmless. */\n\tasync close(instanceId: string): Promise<CanvasInstance | undefined> {\n\t\tconst registry = this.registryOrUndefined();\n\t\tconst instance = registry?.listInstances().find((open) => open.instanceId === instanceId);\n\t\tif (!registry || !instance) return undefined;\n\t\tawait registry.close(instance);\n\t\treturn instance;\n\t}\n\n\t/**\n\t * Re-fork an open extension so an edit to its code takes effect.\n\t *\n\t * Reached by extension id rather than instance id because a reload restarts the\n\t * *process*, and one child serves every instance of every canvas the extension\n\t * declares — pretending it could reload one instance would be a lie about what\n\t * happens. {@link CanvasRegistry.reload} carries the open instances across.\n\t */\n\tasync reload(extensionId: string, options?: CanvasCallOptions): Promise<CanvasReloadResult> {\n\t\tconst registry = this.registryOrUndefined();\n\t\tif (!registry) {\n\t\t\tthrow new Error(\n\t\t\t\t`Canvas extension \"${extensionId}\" is not running, so there is nothing to reload. Open it first.`,\n\t\t\t);\n\t\t}\n\t\treturn registry.reload(extensionId, options);\n\t}\n\n\t/**\n\t * Rename a canvas extension, closing anything it has open first.\n\t *\n\t * Closing is not politeness: the directory is about to move, and an instance\n\t * left open would be serving from a path that no longer exists while the\n\t * registry still believed it was there. The closed instance ids are returned so\n\t * the caller can say what it cost.\n\t */\n\tasync rename(extensionId: string, to: string): Promise<CanvasRenameResult | CanvasLifecycleRefusal> {\n\t\tconst extension = this.find(extensionId);\n\t\tif (!extension) return { reason: \"unwritable\", detail: this.notFound(extensionId) };\n\t\tawait this.closeAllOf(extensionId);\n\t\treturn renameCanvasExtension(extension, to, this.roots);\n\t}\n\n\t/** Delete a canvas extension, closing anything it has open first. */\n\tasync remove(extensionId: string): Promise<CanvasRemoveResult | CanvasLifecycleRefusal> {\n\t\tconst extension = this.find(extensionId);\n\t\tif (!extension) return { reason: \"unwritable\", detail: this.notFound(extensionId) };\n\t\tawait this.closeAllOf(extensionId);\n\t\treturn removeCanvasExtension(extension, this.roots);\n\t}\n\n\t/**\n\t * Close every instance of an extension and stop its child.\n\t *\n\t * `close` alone would leave the process alive for its linger period, still\n\t * holding the code we are about to move or delete. Rename and remove both need\n\t * it actually gone.\n\t */\n\tprivate async closeAllOf(extensionId: string): Promise<string[]> {\n\t\tconst registry = this.registryOrUndefined();\n\t\tif (!registry) return [];\n\t\tconst open = registry.listInstances().filter((instance) => instance.extensionId === extensionId);\n\t\tfor (const instance of open) await registry.close(instance);\n\t\tawait registry.stopChild(extensionId);\n\t\treturn open.map((instance) => instance.instanceId);\n\t}\n\n\t/** The discovered extension with this id, runnable or withheld. */\n\tprivate find(extensionId: string): DiscoveredCanvasExtension | undefined {\n\t\tconst { runnable, withheld } = this.discover();\n\t\treturn [...runnable, ...withheld].find((candidate) => candidate.id === extensionId);\n\t}\n\n\tprivate notFound(extensionId: string): string {\n\t\tconst known =\n\t\t\tthis.discover()\n\t\t\t\t.runnable.map((candidate) => candidate.id)\n\t\t\t\t.join(\", \") || \"none\";\n\t\treturn `No canvas extension \"${extensionId}\" (found: ${known}).`;\n\t}\n\n\t/** Everything that could be renamed or removed, for completions and messages. */\n\tknownExtensionIds(): string[] {\n\t\tconst { runnable, withheld } = this.discover();\n\t\treturn [...runnable, ...withheld].map((candidate) => candidate.id).sort();\n\t}\n\n\t/** The extension ids with at least one open instance — what {@link reload} accepts. */\n\trunningExtensionIds(): string[] {\n\t\treturn [...new Set(this.instances().map((instance) => instance.extensionId))];\n\t}\n\n\t/** Every open instance. */\n\tinstances(): CanvasInstance[] {\n\t\treturn this.registryOrUndefined()?.listInstances() ?? [];\n\t}\n\n\t/**\n\t * The live registry, or undefined if nothing has been opened yet.\n\t *\n\t * Exposed so the host can hand it to the canvas tools, which read\n\t * `listInstances()` and `activeActions()` from it.\n\t */\n\tregistryOrUndefined(): CanvasRegistry | undefined {\n\t\treturn this.registry;\n\t}\n\n\t/** Advisory cleanup, driven by whoever owns the session clock. */\n\tasync reapIdle(): Promise<string[]> {\n\t\treturn (await this.registryOrUndefined()?.reapIdle()) ?? [];\n\t}\n\n\t/** Close everything and stop every child. Safe to call twice. */\n\tasync dispose(): Promise<void> {\n\t\tawait this.registry?.shutdown();\n\t\tthis.registry = undefined;\n\t}\n\n\tprivate ensureRegistry(availability: Extract<CanvasAvailability, { available: true }>): CanvasRegistry {\n\t\tif (!this.registry) {\n\t\t\tthis.registry = new CanvasRegistry({\n\t\t\t\truntime: availability.runtime,\n\t\t\t\tcwd: this.options.cwd,\n\t\t\t\tagentDir: this.options.agentDir,\n\t\t\t\tonLog: this.options.onLog,\n\t\t\t\tonStray: this.options.onStray,\n\t\t\t\tonStderr: this.options.onStderr,\n\t\t\t\tonDiagnostic: this.options.onDiagnostic,\n\t\t\t});\n\t\t}\n\t\treturn this.registry;\n\t}\n\n\t/**\n\t * Pick the canvas when the caller named only an extension.\n\t *\n\t * Forking to read the declarations is unavoidable: they arrive in the child's\n\t * `ready` message. A multi-canvas extension must be named explicitly rather than\n\t * guessed at.\n\t */\n\tprivate async soleCanvasId(registry: CanvasRegistry, extension: DiscoveredCanvasExtension): Promise<string> {\n\t\tconst declarations = await registry.declarations(extension);\n\t\tif (declarations.length === 1) return declarations[0]?.id as string;\n\t\tif (declarations.length === 0) throw new Error(`Canvas extension \"${extension.id}\" declares no canvases.`);\n\t\tconst ids = declarations.map((declaration) => `${extension.id}:${declaration.id}`).join(\", \");\n\t\tthrow new Error(`Canvas extension \"${extension.id}\" declares several canvases; name one of: ${ids}.`);\n\t}\n}\n"]}
@@ -16,6 +16,7 @@
16
16
  import { getAgentDir } from "../../config.js";
17
17
  import { canvasSearchRoots, discoverCanvasExtensions } from "./discovery.js";
18
18
  import { resolveCanvasRuntime } from "./launch.js";
19
+ import { removeCanvasExtension, renameCanvasExtension, } from "./lifecycle.js";
19
20
  import { pluginCanvasExtensions } from "./plugin-canvases.js";
20
21
  import { CanvasRegistry } from "./registry.js";
21
22
  import { gateCanvasExtensions } from "./trust.js";
@@ -113,7 +114,13 @@ export class CanvasSession {
113
114
  open: [],
114
115
  });
115
116
  }
116
- return { availability, listings, withheldCount: withheld.length };
117
+ const actionsByInstance = new Map();
118
+ for (const binding of this.registryOrUndefined()?.activeActions() ?? []) {
119
+ const names = actionsByInstance.get(binding.instanceId) ?? [];
120
+ names.push(binding.action.name);
121
+ actionsByInstance.set(binding.instanceId, names);
122
+ }
123
+ return { availability, listings, withheldCount: withheld.length, actionsByInstance };
117
124
  }
118
125
  /**
119
126
  * Open a canvas.
@@ -148,6 +155,81 @@ export class CanvasSession {
148
155
  await registry.close(instance);
149
156
  return instance;
150
157
  }
158
+ /**
159
+ * Re-fork an open extension so an edit to its code takes effect.
160
+ *
161
+ * Reached by extension id rather than instance id because a reload restarts the
162
+ * *process*, and one child serves every instance of every canvas the extension
163
+ * declares — pretending it could reload one instance would be a lie about what
164
+ * happens. {@link CanvasRegistry.reload} carries the open instances across.
165
+ */
166
+ async reload(extensionId, options) {
167
+ const registry = this.registryOrUndefined();
168
+ if (!registry) {
169
+ throw new Error(`Canvas extension "${extensionId}" is not running, so there is nothing to reload. Open it first.`);
170
+ }
171
+ return registry.reload(extensionId, options);
172
+ }
173
+ /**
174
+ * Rename a canvas extension, closing anything it has open first.
175
+ *
176
+ * Closing is not politeness: the directory is about to move, and an instance
177
+ * left open would be serving from a path that no longer exists while the
178
+ * registry still believed it was there. The closed instance ids are returned so
179
+ * the caller can say what it cost.
180
+ */
181
+ async rename(extensionId, to) {
182
+ const extension = this.find(extensionId);
183
+ if (!extension)
184
+ return { reason: "unwritable", detail: this.notFound(extensionId) };
185
+ await this.closeAllOf(extensionId);
186
+ return renameCanvasExtension(extension, to, this.roots);
187
+ }
188
+ /** Delete a canvas extension, closing anything it has open first. */
189
+ async remove(extensionId) {
190
+ const extension = this.find(extensionId);
191
+ if (!extension)
192
+ return { reason: "unwritable", detail: this.notFound(extensionId) };
193
+ await this.closeAllOf(extensionId);
194
+ return removeCanvasExtension(extension, this.roots);
195
+ }
196
+ /**
197
+ * Close every instance of an extension and stop its child.
198
+ *
199
+ * `close` alone would leave the process alive for its linger period, still
200
+ * holding the code we are about to move or delete. Rename and remove both need
201
+ * it actually gone.
202
+ */
203
+ async closeAllOf(extensionId) {
204
+ const registry = this.registryOrUndefined();
205
+ if (!registry)
206
+ return [];
207
+ const open = registry.listInstances().filter((instance) => instance.extensionId === extensionId);
208
+ for (const instance of open)
209
+ await registry.close(instance);
210
+ await registry.stopChild(extensionId);
211
+ return open.map((instance) => instance.instanceId);
212
+ }
213
+ /** The discovered extension with this id, runnable or withheld. */
214
+ find(extensionId) {
215
+ const { runnable, withheld } = this.discover();
216
+ return [...runnable, ...withheld].find((candidate) => candidate.id === extensionId);
217
+ }
218
+ notFound(extensionId) {
219
+ const known = this.discover()
220
+ .runnable.map((candidate) => candidate.id)
221
+ .join(", ") || "none";
222
+ return `No canvas extension "${extensionId}" (found: ${known}).`;
223
+ }
224
+ /** Everything that could be renamed or removed, for completions and messages. */
225
+ knownExtensionIds() {
226
+ const { runnable, withheld } = this.discover();
227
+ return [...runnable, ...withheld].map((candidate) => candidate.id).sort();
228
+ }
229
+ /** The extension ids with at least one open instance — what {@link reload} accepts. */
230
+ runningExtensionIds() {
231
+ return [...new Set(this.instances().map((instance) => instance.extensionId))];
232
+ }
151
233
  /** Every open instance. */
152
234
  instances() {
153
235
  return this.registryOrUndefined()?.listInstances() ?? [];
@@ -1 +1 @@
1
- {"version":3,"file":"session.js","sourceRoot":"","sources":["../../../src/core/canvas/session.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Session-scoped canvas facade: everything the TUI needs, with no TUI in it.\n *\n * Design: `docs/canvas-extensions-design.md` §11. The pieces underneath — discovery,\n * the trust gate, availability, the registry — are each small and separately tested.\n * This is what stitches them into the four questions a user surface actually asks:\n * what is there, can it run, open this one, close that one.\n *\n * It holds no TUI types on purpose. `extensions/core/canvas.ts` renders and supplies\n * an `AbortSignal` from a cancellable loader; everything decided here stays testable\n * without a terminal.\n *\n * Availability is resolved once and cached, because resolving can spawn\n * `node --version` (§11.1) and the answer cannot change within a session.\n */\n\nimport { getAgentDir } from \"../../config.js\";\nimport type { DiscoveredCanvasExtension } from \"./discovery.js\";\nimport { type CanvasSearchRoot, canvasSearchRoots, discoverCanvasExtensions } from \"./discovery.js\";\nimport { type CanvasAvailability, resolveCanvasRuntime } from \"./launch.js\";\nimport { pluginCanvasExtensions } from \"./plugin-canvases.js\";\nimport { type CanvasInstance, CanvasRegistry, type CanvasRegistryEvents } from \"./registry.js\";\nimport type { CanvasCallOptions } from \"./runner.js\";\nimport { gateCanvasExtensions } from \"./trust.js\";\n\n/** One canvas a person could open, or has open. */\nexport interface CanvasListing {\n\textensionId: string;\n\t/** Undefined until the extension has been forked, since declarations come from it. */\n\tcanvasId: string | undefined;\n\tdisplayName: string | undefined;\n\tscope: DiscoveredCanvasExtension[\"scope\"];\n\t/** Why it cannot be opened, if it cannot. */\n\twithheld: \"untrusted-workspace\" | undefined;\n\t/** Instances of this canvas that are currently open. */\n\topen: CanvasInstance[];\n}\n\n/** What `list()` reports. */\nexport interface CanvasOverview {\n\t/** Absent `reason` means canvases can run here. */\n\tavailability: CanvasAvailability;\n\tlistings: CanvasListing[];\n\t/** Extensions withheld by the trust gate — surfaced, never hidden (§5.1). */\n\twithheldCount: number;\n}\n\n/** Configuration for a session's canvas facade. */\nexport interface CanvasSessionOptions extends CanvasRegistryEvents {\n\tcwd: string;\n\thomeDir: string;\n\tagentDir?: string;\n\t/** Override the search roots; defaults to {@link canvasSearchRoots}. */\n\troots?: CanvasSearchRoot[];\n\t/**\n\t * Override how plugin-shipped canvases are found; defaults to\n\t * {@link pluginCanvasExtensions}. Pass `() => []` to look at the search roots\n\t * and nothing else.\n\t */\n\tpluginExtensions?: () => DiscoveredCanvasExtension[];\n\t/** Override availability resolution, for tests and for hosts that already know. */\n\tresolveRuntime?: () => Promise<CanvasAvailability>;\n}\n\n/** Reference to a canvas: an extension id, optionally narrowed to one of its canvases. */\nexport interface CanvasRef {\n\textensionId: string;\n\tcanvasId?: string;\n}\n\n/**\n * Parse `extension` or `extension:canvas`.\n *\n * Extension ids are directory names and canvas ids are provider-local, so a single\n * colon is unambiguous and needs no quoting.\n */\nexport function parseCanvasRef(input: string): CanvasRef | undefined {\n\tconst trimmed = input.trim();\n\tif (trimmed.length === 0) return undefined;\n\tconst colon = trimmed.indexOf(\":\");\n\tif (colon === -1) return { extensionId: trimmed };\n\tconst extensionId = trimmed.slice(0, colon).trim();\n\tconst canvasId = trimmed.slice(colon + 1).trim();\n\tif (extensionId.length === 0 || canvasId.length === 0) return undefined;\n\treturn { extensionId, canvasId };\n}\n\nexport class CanvasSession {\n\tprivate readonly options: CanvasSessionOptions;\n\tprivate readonly roots: CanvasSearchRoot[];\n\tprivate readonly pluginExtensions: () => DiscoveredCanvasExtension[];\n\t/** Cached because resolving can spawn `node --version` and cannot change mid-session. */\n\tprivate availabilityPromise: Promise<CanvasAvailability> | undefined;\n\t/** Created on first successful open, not at construction: listing must not fork. */\n\tprivate registry: CanvasRegistry | undefined;\n\n\tconstructor(options: CanvasSessionOptions) {\n\t\tthis.options = options;\n\t\tthis.roots = options.roots ?? canvasSearchRoots(options.cwd, options.homeDir);\n\t\t// Re-read on every discover rather than cached: /plugin install can add a\n\t\t// canvas mid-session, and a listing that cannot see it is the bug this\n\t\t// exists to fix.\n\t\tthis.pluginExtensions =\n\t\t\toptions.pluginExtensions ?? (() => pluginCanvasExtensions(options.cwd, options.agentDir ?? getAgentDir()));\n\t}\n\n\t/** Discovered extensions, partitioned by the trust gate. Read-only and always safe. */\n\tdiscover(): { runnable: DiscoveredCanvasExtension[]; withheld: DiscoveredCanvasExtension[] } {\n\t\tconst gated = gateCanvasExtensions(\n\t\t\tdiscoverCanvasExtensions(this.roots, this.pluginExtensions()),\n\t\t\tthis.options.cwd,\n\t\t\tthis.options.agentDir ?? getAgentDir(),\n\t\t);\n\t\treturn { runnable: gated.runnable, withheld: gated.withheld.map((entry) => entry.extension) };\n\t}\n\n\t/** Whether canvases can run here. Resolved once per session and cached. */\n\tasync availability(): Promise<CanvasAvailability> {\n\t\tthis.availabilityPromise ??= (this.options.resolveRuntime ?? resolveCanvasRuntime)();\n\t\treturn this.availabilityPromise;\n\t}\n\n\t/**\n\t * What is installed, what is open, and what is being withheld.\n\t *\n\t * Deliberately does not fork anything: listing must stay free and safe, so a\n\t * `canvasId` is only known for extensions already running. That is the visible\n\t * consequence of a canvas having no passive half (§5.1) — even its name comes from\n\t * running its code.\n\t */\n\tasync list(): Promise<CanvasOverview> {\n\t\tconst availability = await this.availability();\n\t\tconst { runnable, withheld } = this.discover();\n\t\tconst open = this.registryOrUndefined()?.listInstances() ?? [];\n\n\t\tconst listings: CanvasListing[] = [];\n\t\tfor (const extension of runnable) {\n\t\t\tconst instances = open.filter((instance) => instance.extensionId === extension.id);\n\t\t\tif (instances.length === 0) {\n\t\t\t\tlistings.push({\n\t\t\t\t\textensionId: extension.id,\n\t\t\t\t\tcanvasId: undefined,\n\t\t\t\t\tdisplayName: undefined,\n\t\t\t\t\tscope: extension.scope,\n\t\t\t\t\twithheld: undefined,\n\t\t\t\t\topen: [],\n\t\t\t\t});\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tfor (const canvasId of new Set(instances.map((instance) => instance.canvasId))) {\n\t\t\t\tconst forCanvas = instances.filter((instance) => instance.canvasId === canvasId);\n\t\t\t\tlistings.push({\n\t\t\t\t\textensionId: extension.id,\n\t\t\t\t\tcanvasId,\n\t\t\t\t\tdisplayName: forCanvas[0]?.title,\n\t\t\t\t\tscope: extension.scope,\n\t\t\t\t\twithheld: undefined,\n\t\t\t\t\topen: forCanvas,\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t\tfor (const extension of withheld) {\n\t\t\tlistings.push({\n\t\t\t\textensionId: extension.id,\n\t\t\t\tcanvasId: undefined,\n\t\t\t\tdisplayName: undefined,\n\t\t\t\tscope: extension.scope,\n\t\t\t\twithheld: \"untrusted-workspace\",\n\t\t\t\topen: [],\n\t\t\t});\n\t\t}\n\t\treturn { availability, listings, withheldCount: withheld.length };\n\t}\n\n\t/**\n\t * Open a canvas.\n\t *\n\t * `options.signal` comes from the caller's cancellable loader, so a person's Esc\n\t * reaches the registry's abandon path (§11.6) rather than merely hiding a spinner.\n\t */\n\tasync open(ref: CanvasRef, options?: CanvasCallOptions): Promise<CanvasInstance> {\n\t\tconst availability = await this.availability();\n\t\tif (!availability.available) throw new Error(availability.reason);\n\n\t\tconst { runnable, withheld } = this.discover();\n\t\tconst extension = runnable.find((candidate) => candidate.id === ref.extensionId);\n\t\tif (!extension) {\n\t\t\tif (withheld.some((candidate) => candidate.id === ref.extensionId)) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`Canvas extension \"${ref.extensionId}\" came with this repository, which is not a trusted workspace. ` +\n\t\t\t\t\t\t\"Run /plugin trust to allow this directory to run code it ships.\",\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst known = runnable.map((candidate) => candidate.id).join(\", \") || \"none\";\n\t\t\tthrow new Error(`No canvas extension \"${ref.extensionId}\" (found: ${known}).`);\n\t\t}\n\n\t\tconst registry = this.ensureRegistry(availability);\n\t\tconst canvasId = ref.canvasId ?? (await this.soleCanvasId(registry, extension));\n\t\treturn registry.open(extension, canvasId, undefined, options);\n\t}\n\n\t/** Close one open instance. Unknown ids are a no-op, so closing twice is harmless. */\n\tasync close(instanceId: string): Promise<CanvasInstance | undefined> {\n\t\tconst registry = this.registryOrUndefined();\n\t\tconst instance = registry?.listInstances().find((open) => open.instanceId === instanceId);\n\t\tif (!registry || !instance) return undefined;\n\t\tawait registry.close(instance);\n\t\treturn instance;\n\t}\n\n\t/** Every open instance. */\n\tinstances(): CanvasInstance[] {\n\t\treturn this.registryOrUndefined()?.listInstances() ?? [];\n\t}\n\n\t/**\n\t * The live registry, or undefined if nothing has been opened yet.\n\t *\n\t * Exposed so the host can hand it to the canvas tools, which read\n\t * `listInstances()` and `activeActions()` from it.\n\t */\n\tregistryOrUndefined(): CanvasRegistry | undefined {\n\t\treturn this.registry;\n\t}\n\n\t/** Advisory cleanup, driven by whoever owns the session clock. */\n\tasync reapIdle(): Promise<string[]> {\n\t\treturn (await this.registryOrUndefined()?.reapIdle()) ?? [];\n\t}\n\n\t/** Close everything and stop every child. Safe to call twice. */\n\tasync dispose(): Promise<void> {\n\t\tawait this.registry?.shutdown();\n\t\tthis.registry = undefined;\n\t}\n\n\tprivate ensureRegistry(availability: Extract<CanvasAvailability, { available: true }>): CanvasRegistry {\n\t\tif (!this.registry) {\n\t\t\tthis.registry = new CanvasRegistry({\n\t\t\t\truntime: availability.runtime,\n\t\t\t\tcwd: this.options.cwd,\n\t\t\t\tagentDir: this.options.agentDir,\n\t\t\t\tonLog: this.options.onLog,\n\t\t\t\tonStray: this.options.onStray,\n\t\t\t\tonStderr: this.options.onStderr,\n\t\t\t\tonDiagnostic: this.options.onDiagnostic,\n\t\t\t});\n\t\t}\n\t\treturn this.registry;\n\t}\n\n\t/**\n\t * Pick the canvas when the caller named only an extension.\n\t *\n\t * Forking to read the declarations is unavoidable: they arrive in the child's\n\t * `ready` message. A multi-canvas extension must be named explicitly rather than\n\t * guessed at.\n\t */\n\tprivate async soleCanvasId(registry: CanvasRegistry, extension: DiscoveredCanvasExtension): Promise<string> {\n\t\tconst declarations = await registry.declarations(extension);\n\t\tif (declarations.length === 1) return declarations[0]?.id as string;\n\t\tif (declarations.length === 0) throw new Error(`Canvas extension \"${extension.id}\" declares no canvases.`);\n\t\tconst ids = declarations.map((declaration) => `${extension.id}:${declaration.id}`).join(\", \");\n\t\tthrow new Error(`Canvas extension \"${extension.id}\" declares several canvases; name one of: ${ids}.`);\n\t}\n}\n"]}
1
+ {"version":3,"file":"session.js","sourceRoot":"","sources":["../../../src/core/canvas/session.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Session-scoped canvas facade: everything the TUI needs, with no TUI in it.\n *\n * Design: `docs/canvas-extensions-design.md` §11. The pieces underneath — discovery,\n * the trust gate, availability, the registry — are each small and separately tested.\n * This is what stitches them into the four questions a user surface actually asks:\n * what is there, can it run, open this one, close that one.\n *\n * It holds no TUI types on purpose. `extensions/core/canvas.ts` renders and supplies\n * an `AbortSignal` from a cancellable loader; everything decided here stays testable\n * without a terminal.\n *\n * Availability is resolved once and cached, because resolving can spawn\n * `node --version` (§11.1) and the answer cannot change within a session.\n */\n\nimport { getAgentDir } from \"../../config.js\";\nimport type { DiscoveredCanvasExtension } from \"./discovery.js\";\nimport { type CanvasSearchRoot, canvasSearchRoots, discoverCanvasExtensions } from \"./discovery.js\";\nimport { type CanvasAvailability, resolveCanvasRuntime } from \"./launch.js\";\nimport {\n\ttype CanvasLifecycleRefusal,\n\ttype CanvasRemoveResult,\n\ttype CanvasRenameResult,\n\tremoveCanvasExtension,\n\trenameCanvasExtension,\n} from \"./lifecycle.js\";\nimport { pluginCanvasExtensions } from \"./plugin-canvases.js\";\nimport { type CanvasInstance, CanvasRegistry, type CanvasRegistryEvents, type CanvasReloadResult } from \"./registry.js\";\nimport type { CanvasCallOptions } from \"./runner.js\";\nimport { gateCanvasExtensions } from \"./trust.js\";\n\n/** One canvas a person could open, or has open. */\nexport interface CanvasListing {\n\textensionId: string;\n\t/** Undefined until the extension has been forked, since declarations come from it. */\n\tcanvasId: string | undefined;\n\tdisplayName: string | undefined;\n\tscope: DiscoveredCanvasExtension[\"scope\"];\n\t/** Why it cannot be opened, if it cannot. */\n\twithheld: \"untrusted-workspace\" | undefined;\n\t/** Instances of this canvas that are currently open. */\n\topen: CanvasInstance[];\n}\n\n/** What `list()` reports. */\nexport interface CanvasOverview {\n\t/** Absent `reason` means canvases can run here. */\n\tavailability: CanvasAvailability;\n\tlistings: CanvasListing[];\n\t/** Extensions withheld by the trust gate — surfaced, never hidden (§5.1). */\n\twithheldCount: number;\n\t/**\n\t * Action names per open instance.\n\t *\n\t * Beside the listings rather than inside them because an action belongs to a\n\t * running instance, not to a canvas on disk: a listing exists for extensions\n\t * that have never been forked, and those have no actions to report — not zero\n\t * of them, none knowable.\n\t */\n\tactionsByInstance: Map<string, string[]>;\n}\n\n/** Configuration for a session's canvas facade. */\nexport interface CanvasSessionOptions extends CanvasRegistryEvents {\n\tcwd: string;\n\thomeDir: string;\n\tagentDir?: string;\n\t/** Override the search roots; defaults to {@link canvasSearchRoots}. */\n\troots?: CanvasSearchRoot[];\n\t/**\n\t * Override how plugin-shipped canvases are found; defaults to\n\t * {@link pluginCanvasExtensions}. Pass `() => []` to look at the search roots\n\t * and nothing else.\n\t */\n\tpluginExtensions?: () => DiscoveredCanvasExtension[];\n\t/** Override availability resolution, for tests and for hosts that already know. */\n\tresolveRuntime?: () => Promise<CanvasAvailability>;\n}\n\n/** Reference to a canvas: an extension id, optionally narrowed to one of its canvases. */\nexport interface CanvasRef {\n\textensionId: string;\n\tcanvasId?: string;\n}\n\n/**\n * Parse `extension` or `extension:canvas`.\n *\n * Extension ids are directory names and canvas ids are provider-local, so a single\n * colon is unambiguous and needs no quoting.\n */\nexport function parseCanvasRef(input: string): CanvasRef | undefined {\n\tconst trimmed = input.trim();\n\tif (trimmed.length === 0) return undefined;\n\tconst colon = trimmed.indexOf(\":\");\n\tif (colon === -1) return { extensionId: trimmed };\n\tconst extensionId = trimmed.slice(0, colon).trim();\n\tconst canvasId = trimmed.slice(colon + 1).trim();\n\tif (extensionId.length === 0 || canvasId.length === 0) return undefined;\n\treturn { extensionId, canvasId };\n}\n\nexport class CanvasSession {\n\tprivate readonly options: CanvasSessionOptions;\n\tprivate readonly roots: CanvasSearchRoot[];\n\tprivate readonly pluginExtensions: () => DiscoveredCanvasExtension[];\n\t/** Cached because resolving can spawn `node --version` and cannot change mid-session. */\n\tprivate availabilityPromise: Promise<CanvasAvailability> | undefined;\n\t/** Created on first successful open, not at construction: listing must not fork. */\n\tprivate registry: CanvasRegistry | undefined;\n\n\tconstructor(options: CanvasSessionOptions) {\n\t\tthis.options = options;\n\t\tthis.roots = options.roots ?? canvasSearchRoots(options.cwd, options.homeDir);\n\t\t// Re-read on every discover rather than cached: /plugin install can add a\n\t\t// canvas mid-session, and a listing that cannot see it is the bug this\n\t\t// exists to fix.\n\t\tthis.pluginExtensions =\n\t\t\toptions.pluginExtensions ?? (() => pluginCanvasExtensions(options.cwd, options.agentDir ?? getAgentDir()));\n\t}\n\n\t/** Discovered extensions, partitioned by the trust gate. Read-only and always safe. */\n\tdiscover(): { runnable: DiscoveredCanvasExtension[]; withheld: DiscoveredCanvasExtension[] } {\n\t\tconst gated = gateCanvasExtensions(\n\t\t\tdiscoverCanvasExtensions(this.roots, this.pluginExtensions()),\n\t\t\tthis.options.cwd,\n\t\t\tthis.options.agentDir ?? getAgentDir(),\n\t\t);\n\t\treturn { runnable: gated.runnable, withheld: gated.withheld.map((entry) => entry.extension) };\n\t}\n\n\t/** Whether canvases can run here. Resolved once per session and cached. */\n\tasync availability(): Promise<CanvasAvailability> {\n\t\tthis.availabilityPromise ??= (this.options.resolveRuntime ?? resolveCanvasRuntime)();\n\t\treturn this.availabilityPromise;\n\t}\n\n\t/**\n\t * What is installed, what is open, and what is being withheld.\n\t *\n\t * Deliberately does not fork anything: listing must stay free and safe, so a\n\t * `canvasId` is only known for extensions already running. That is the visible\n\t * consequence of a canvas having no passive half (§5.1) — even its name comes from\n\t * running its code.\n\t */\n\tasync list(): Promise<CanvasOverview> {\n\t\tconst availability = await this.availability();\n\t\tconst { runnable, withheld } = this.discover();\n\t\tconst open = this.registryOrUndefined()?.listInstances() ?? [];\n\n\t\tconst listings: CanvasListing[] = [];\n\t\tfor (const extension of runnable) {\n\t\t\tconst instances = open.filter((instance) => instance.extensionId === extension.id);\n\t\t\tif (instances.length === 0) {\n\t\t\t\tlistings.push({\n\t\t\t\t\textensionId: extension.id,\n\t\t\t\t\tcanvasId: undefined,\n\t\t\t\t\tdisplayName: undefined,\n\t\t\t\t\tscope: extension.scope,\n\t\t\t\t\twithheld: undefined,\n\t\t\t\t\topen: [],\n\t\t\t\t});\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tfor (const canvasId of new Set(instances.map((instance) => instance.canvasId))) {\n\t\t\t\tconst forCanvas = instances.filter((instance) => instance.canvasId === canvasId);\n\t\t\t\tlistings.push({\n\t\t\t\t\textensionId: extension.id,\n\t\t\t\t\tcanvasId,\n\t\t\t\t\tdisplayName: forCanvas[0]?.title,\n\t\t\t\t\tscope: extension.scope,\n\t\t\t\t\twithheld: undefined,\n\t\t\t\t\topen: forCanvas,\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t\tfor (const extension of withheld) {\n\t\t\tlistings.push({\n\t\t\t\textensionId: extension.id,\n\t\t\t\tcanvasId: undefined,\n\t\t\t\tdisplayName: undefined,\n\t\t\t\tscope: extension.scope,\n\t\t\t\twithheld: \"untrusted-workspace\",\n\t\t\t\topen: [],\n\t\t\t});\n\t\t}\n\t\tconst actionsByInstance = new Map<string, string[]>();\n\t\tfor (const binding of this.registryOrUndefined()?.activeActions() ?? []) {\n\t\t\tconst names = actionsByInstance.get(binding.instanceId) ?? [];\n\t\t\tnames.push(binding.action.name);\n\t\t\tactionsByInstance.set(binding.instanceId, names);\n\t\t}\n\t\treturn { availability, listings, withheldCount: withheld.length, actionsByInstance };\n\t}\n\n\t/**\n\t * Open a canvas.\n\t *\n\t * `options.signal` comes from the caller's cancellable loader, so a person's Esc\n\t * reaches the registry's abandon path (§11.6) rather than merely hiding a spinner.\n\t */\n\tasync open(ref: CanvasRef, options?: CanvasCallOptions): Promise<CanvasInstance> {\n\t\tconst availability = await this.availability();\n\t\tif (!availability.available) throw new Error(availability.reason);\n\n\t\tconst { runnable, withheld } = this.discover();\n\t\tconst extension = runnable.find((candidate) => candidate.id === ref.extensionId);\n\t\tif (!extension) {\n\t\t\tif (withheld.some((candidate) => candidate.id === ref.extensionId)) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`Canvas extension \"${ref.extensionId}\" came with this repository, which is not a trusted workspace. ` +\n\t\t\t\t\t\t\"Run /plugin trust to allow this directory to run code it ships.\",\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst known = runnable.map((candidate) => candidate.id).join(\", \") || \"none\";\n\t\t\tthrow new Error(`No canvas extension \"${ref.extensionId}\" (found: ${known}).`);\n\t\t}\n\n\t\tconst registry = this.ensureRegistry(availability);\n\t\tconst canvasId = ref.canvasId ?? (await this.soleCanvasId(registry, extension));\n\t\treturn registry.open(extension, canvasId, undefined, options);\n\t}\n\n\t/** Close one open instance. Unknown ids are a no-op, so closing twice is harmless. */\n\tasync close(instanceId: string): Promise<CanvasInstance | undefined> {\n\t\tconst registry = this.registryOrUndefined();\n\t\tconst instance = registry?.listInstances().find((open) => open.instanceId === instanceId);\n\t\tif (!registry || !instance) return undefined;\n\t\tawait registry.close(instance);\n\t\treturn instance;\n\t}\n\n\t/**\n\t * Re-fork an open extension so an edit to its code takes effect.\n\t *\n\t * Reached by extension id rather than instance id because a reload restarts the\n\t * *process*, and one child serves every instance of every canvas the extension\n\t * declares — pretending it could reload one instance would be a lie about what\n\t * happens. {@link CanvasRegistry.reload} carries the open instances across.\n\t */\n\tasync reload(extensionId: string, options?: CanvasCallOptions): Promise<CanvasReloadResult> {\n\t\tconst registry = this.registryOrUndefined();\n\t\tif (!registry) {\n\t\t\tthrow new Error(\n\t\t\t\t`Canvas extension \"${extensionId}\" is not running, so there is nothing to reload. Open it first.`,\n\t\t\t);\n\t\t}\n\t\treturn registry.reload(extensionId, options);\n\t}\n\n\t/**\n\t * Rename a canvas extension, closing anything it has open first.\n\t *\n\t * Closing is not politeness: the directory is about to move, and an instance\n\t * left open would be serving from a path that no longer exists while the\n\t * registry still believed it was there. The closed instance ids are returned so\n\t * the caller can say what it cost.\n\t */\n\tasync rename(extensionId: string, to: string): Promise<CanvasRenameResult | CanvasLifecycleRefusal> {\n\t\tconst extension = this.find(extensionId);\n\t\tif (!extension) return { reason: \"unwritable\", detail: this.notFound(extensionId) };\n\t\tawait this.closeAllOf(extensionId);\n\t\treturn renameCanvasExtension(extension, to, this.roots);\n\t}\n\n\t/** Delete a canvas extension, closing anything it has open first. */\n\tasync remove(extensionId: string): Promise<CanvasRemoveResult | CanvasLifecycleRefusal> {\n\t\tconst extension = this.find(extensionId);\n\t\tif (!extension) return { reason: \"unwritable\", detail: this.notFound(extensionId) };\n\t\tawait this.closeAllOf(extensionId);\n\t\treturn removeCanvasExtension(extension, this.roots);\n\t}\n\n\t/**\n\t * Close every instance of an extension and stop its child.\n\t *\n\t * `close` alone would leave the process alive for its linger period, still\n\t * holding the code we are about to move or delete. Rename and remove both need\n\t * it actually gone.\n\t */\n\tprivate async closeAllOf(extensionId: string): Promise<string[]> {\n\t\tconst registry = this.registryOrUndefined();\n\t\tif (!registry) return [];\n\t\tconst open = registry.listInstances().filter((instance) => instance.extensionId === extensionId);\n\t\tfor (const instance of open) await registry.close(instance);\n\t\tawait registry.stopChild(extensionId);\n\t\treturn open.map((instance) => instance.instanceId);\n\t}\n\n\t/** The discovered extension with this id, runnable or withheld. */\n\tprivate find(extensionId: string): DiscoveredCanvasExtension | undefined {\n\t\tconst { runnable, withheld } = this.discover();\n\t\treturn [...runnable, ...withheld].find((candidate) => candidate.id === extensionId);\n\t}\n\n\tprivate notFound(extensionId: string): string {\n\t\tconst known =\n\t\t\tthis.discover()\n\t\t\t\t.runnable.map((candidate) => candidate.id)\n\t\t\t\t.join(\", \") || \"none\";\n\t\treturn `No canvas extension \"${extensionId}\" (found: ${known}).`;\n\t}\n\n\t/** Everything that could be renamed or removed, for completions and messages. */\n\tknownExtensionIds(): string[] {\n\t\tconst { runnable, withheld } = this.discover();\n\t\treturn [...runnable, ...withheld].map((candidate) => candidate.id).sort();\n\t}\n\n\t/** The extension ids with at least one open instance — what {@link reload} accepts. */\n\trunningExtensionIds(): string[] {\n\t\treturn [...new Set(this.instances().map((instance) => instance.extensionId))];\n\t}\n\n\t/** Every open instance. */\n\tinstances(): CanvasInstance[] {\n\t\treturn this.registryOrUndefined()?.listInstances() ?? [];\n\t}\n\n\t/**\n\t * The live registry, or undefined if nothing has been opened yet.\n\t *\n\t * Exposed so the host can hand it to the canvas tools, which read\n\t * `listInstances()` and `activeActions()` from it.\n\t */\n\tregistryOrUndefined(): CanvasRegistry | undefined {\n\t\treturn this.registry;\n\t}\n\n\t/** Advisory cleanup, driven by whoever owns the session clock. */\n\tasync reapIdle(): Promise<string[]> {\n\t\treturn (await this.registryOrUndefined()?.reapIdle()) ?? [];\n\t}\n\n\t/** Close everything and stop every child. Safe to call twice. */\n\tasync dispose(): Promise<void> {\n\t\tawait this.registry?.shutdown();\n\t\tthis.registry = undefined;\n\t}\n\n\tprivate ensureRegistry(availability: Extract<CanvasAvailability, { available: true }>): CanvasRegistry {\n\t\tif (!this.registry) {\n\t\t\tthis.registry = new CanvasRegistry({\n\t\t\t\truntime: availability.runtime,\n\t\t\t\tcwd: this.options.cwd,\n\t\t\t\tagentDir: this.options.agentDir,\n\t\t\t\tonLog: this.options.onLog,\n\t\t\t\tonStray: this.options.onStray,\n\t\t\t\tonStderr: this.options.onStderr,\n\t\t\t\tonDiagnostic: this.options.onDiagnostic,\n\t\t\t});\n\t\t}\n\t\treturn this.registry;\n\t}\n\n\t/**\n\t * Pick the canvas when the caller named only an extension.\n\t *\n\t * Forking to read the declarations is unavoidable: they arrive in the child's\n\t * `ready` message. A multi-canvas extension must be named explicitly rather than\n\t * guessed at.\n\t */\n\tprivate async soleCanvasId(registry: CanvasRegistry, extension: DiscoveredCanvasExtension): Promise<string> {\n\t\tconst declarations = await registry.declarations(extension);\n\t\tif (declarations.length === 1) return declarations[0]?.id as string;\n\t\tif (declarations.length === 0) throw new Error(`Canvas extension \"${extension.id}\" declares no canvases.`);\n\t\tconst ids = declarations.map((declaration) => `${extension.id}:${declaration.id}`).join(\", \");\n\t\tthrow new Error(`Canvas extension \"${extension.id}\" declares several canvases; name one of: ${ids}.`);\n\t}\n}\n"]}
@@ -20,6 +20,10 @@ import type { CapabilityDoc } from "./registry.js";
20
20
  * Split identifiers the way a person reads them: `mcp_github_create_pr` and
21
21
  * `createPullRequest` both yield their parts, and the original token is kept so
22
22
  * an exact name still scores as an exact match.
23
+ *
24
+ * Singular forms are emitted *alongside* the originals rather than replacing
25
+ * them, which is the same bargain the identifier splitting makes: an exact
26
+ * token still matches exactly, and a near miss now matches too.
23
27
  */
24
28
  export declare function tokenize(text: string): string[];
25
29
  export interface LexicalHit {
@@ -1 +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"]}
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;AA4BnD;;;;;;;;GAQG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAmB/C;AAsED,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,CA+B1C;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 * Fold a regular English plural to its singular, or return undefined.\n *\n * Deliberately crude: only the endings that are unambiguous without a\n * dictionary. Documentation headings are written in whichever number reads best\n * (\"Themes\", \"Custom Providers\") while questions are asked in the other (\"how\n * do I add a theme\"), and with no folding at all those two never meet — the\n * single most useful term in the query is the one term that cannot match.\n *\n * Irregulars are left alone. Getting \"indices\" wrong costs a missed hit;\n * inventing a stemmer that mangles \"status\" into \"statu\" would cost matches\n * that work today.\n */\nfunction singularize(token: string): string | undefined {\n\tif (token.length > 4 && token.endsWith(\"ies\")) return `${token.slice(0, -3)}y`;\n\tif (token.length > 4 && /(?:ss|sh|ch|x|z)es$/.test(token)) return token.slice(0, -2);\n\tif (token.length > 3 && token.endsWith(\"s\") && !token.endsWith(\"ss\") && !token.endsWith(\"us\")) {\n\t\treturn token.slice(0, -1);\n\t}\n\treturn undefined;\n}\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 *\n * Singular forms are emitted *alongside* the originals rather than replacing\n * them, which is the same bargain the identifier splitting makes: an exact\n * token still matches exactly, and a near miss now matches too.\n */\nexport function tokenize(text: string): string[] {\n\tconst out: string[] = [];\n\tconst push = (token: string): void => {\n\t\tout.push(token);\n\t\tconst singular = singularize(token);\n\t\tif (singular && singular !== token) out.push(singular);\n\t};\n\tfor (const raw of text.toLowerCase().match(/[a-z0-9]+(?:[_-][a-z0-9]+)*/gi) ?? []) {\n\t\tconst token = raw.toLowerCase();\n\t\tpush(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) for (const part of parts) push(part);\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) push(lower);\n\t}\n\treturn out;\n}\n\n/**\n * Function words dropped from *queries* only.\n *\n * Questions arrive as \"how do I add a custom theme\", and in a corpus of a few\n * hundred short documents the filler carries real weight: a section whose prose\n * happens to say \"Add an AGENTS.md file ... to tell it how to work\" outscores\n * the section actually titled \"Creating a Custom Theme\", because it matched\n * three throwaway words to the target's one meaningful one.\n *\n * Query-side only, deliberately. Stripping these from documents too would\n * change every document length and every average, re-tuning a ranking that\n * works; dropping a term from the query just stops it contributing, which is\n * the whole intent.\n */\nconst QUERY_STOPWORDS = new Set([\n\t\"a\",\n\t\"an\",\n\t\"and\",\n\t\"are\",\n\t\"as\",\n\t\"at\",\n\t\"be\",\n\t\"by\",\n\t\"can\",\n\t\"do\",\n\t\"does\",\n\t\"for\",\n\t\"from\",\n\t\"get\",\n\t\"how\",\n\t\"i\",\n\t\"in\",\n\t\"is\",\n\t\"it\",\n\t\"its\",\n\t\"me\",\n\t\"my\",\n\t\"of\",\n\t\"on\",\n\t\"or\",\n\t\"so\",\n\t\"that\",\n\t\"the\",\n\t\"then\",\n\t\"there\",\n\t\"this\",\n\t\"to\",\n\t\"use\",\n\t\"using\",\n\t\"want\",\n\t\"was\",\n\t\"what\",\n\t\"when\",\n\t\"where\",\n\t\"which\",\n\t\"who\",\n\t\"why\",\n\t\"will\",\n\t\"with\",\n\t\"you\",\n\t\"your\",\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 raw = tokenize(query);\n\t\t// Fall back to the unfiltered terms when a query is nothing but function\n\t\t// words, so \"what is it\" still searches rather than silently matching all.\n\t\tconst filtered = raw.filter((t) => !QUERY_STOPWORDS.has(t));\n\t\tconst terms = filtered.length > 0 ? filtered : raw;\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"]}
@@ -18,28 +18,124 @@
18
18
  /** Standard Okapi BM25 parameters. Nothing here justifies tuning them. */
19
19
  const K1 = 1.2;
20
20
  const B = 0.75;
21
+ /**
22
+ * Fold a regular English plural to its singular, or return undefined.
23
+ *
24
+ * Deliberately crude: only the endings that are unambiguous without a
25
+ * dictionary. Documentation headings are written in whichever number reads best
26
+ * ("Themes", "Custom Providers") while questions are asked in the other ("how
27
+ * do I add a theme"), and with no folding at all those two never meet — the
28
+ * single most useful term in the query is the one term that cannot match.
29
+ *
30
+ * Irregulars are left alone. Getting "indices" wrong costs a missed hit;
31
+ * inventing a stemmer that mangles "status" into "statu" would cost matches
32
+ * that work today.
33
+ */
34
+ function singularize(token) {
35
+ if (token.length > 4 && token.endsWith("ies"))
36
+ return `${token.slice(0, -3)}y`;
37
+ if (token.length > 4 && /(?:ss|sh|ch|x|z)es$/.test(token))
38
+ return token.slice(0, -2);
39
+ if (token.length > 3 && token.endsWith("s") && !token.endsWith("ss") && !token.endsWith("us")) {
40
+ return token.slice(0, -1);
41
+ }
42
+ return undefined;
43
+ }
21
44
  /**
22
45
  * Split identifiers the way a person reads them: `mcp_github_create_pr` and
23
46
  * `createPullRequest` both yield their parts, and the original token is kept so
24
47
  * an exact name still scores as an exact match.
48
+ *
49
+ * Singular forms are emitted *alongside* the originals rather than replacing
50
+ * them, which is the same bargain the identifier splitting makes: an exact
51
+ * token still matches exactly, and a near miss now matches too.
25
52
  */
26
53
  export function tokenize(text) {
27
54
  const out = [];
55
+ const push = (token) => {
56
+ out.push(token);
57
+ const singular = singularize(token);
58
+ if (singular && singular !== token)
59
+ out.push(singular);
60
+ };
28
61
  for (const raw of text.toLowerCase().match(/[a-z0-9]+(?:[_-][a-z0-9]+)*/gi) ?? []) {
29
62
  const token = raw.toLowerCase();
30
- out.push(token);
63
+ push(token);
31
64
  // Split on separators, then on camelCase boundaries in the source text.
32
65
  const parts = token.split(/[_-]+/).filter(Boolean);
33
66
  if (parts.length > 1)
34
- out.push(...parts);
67
+ for (const part of parts)
68
+ push(part);
35
69
  }
36
70
  for (const camel of text.match(/[a-z][a-z0-9]*|[A-Z][a-z0-9]*|[A-Z]+(?![a-z])/g) ?? []) {
37
71
  const lower = camel.toLowerCase();
38
72
  if (lower.length > 1)
39
- out.push(lower);
73
+ push(lower);
40
74
  }
41
75
  return out;
42
76
  }
77
+ /**
78
+ * Function words dropped from *queries* only.
79
+ *
80
+ * Questions arrive as "how do I add a custom theme", and in a corpus of a few
81
+ * hundred short documents the filler carries real weight: a section whose prose
82
+ * happens to say "Add an AGENTS.md file ... to tell it how to work" outscores
83
+ * the section actually titled "Creating a Custom Theme", because it matched
84
+ * three throwaway words to the target's one meaningful one.
85
+ *
86
+ * Query-side only, deliberately. Stripping these from documents too would
87
+ * change every document length and every average, re-tuning a ranking that
88
+ * works; dropping a term from the query just stops it contributing, which is
89
+ * the whole intent.
90
+ */
91
+ const QUERY_STOPWORDS = new Set([
92
+ "a",
93
+ "an",
94
+ "and",
95
+ "are",
96
+ "as",
97
+ "at",
98
+ "be",
99
+ "by",
100
+ "can",
101
+ "do",
102
+ "does",
103
+ "for",
104
+ "from",
105
+ "get",
106
+ "how",
107
+ "i",
108
+ "in",
109
+ "is",
110
+ "it",
111
+ "its",
112
+ "me",
113
+ "my",
114
+ "of",
115
+ "on",
116
+ "or",
117
+ "so",
118
+ "that",
119
+ "the",
120
+ "then",
121
+ "there",
122
+ "this",
123
+ "to",
124
+ "use",
125
+ "using",
126
+ "want",
127
+ "was",
128
+ "what",
129
+ "when",
130
+ "where",
131
+ "which",
132
+ "who",
133
+ "why",
134
+ "will",
135
+ "with",
136
+ "you",
137
+ "your",
138
+ ]);
43
139
  /** The text a document is matched on: name first, since that is what queries name. */
44
140
  function documentText(doc) {
45
141
  return `${doc.name} ${doc.name} ${doc.source ?? ""} ${doc.description}`;
@@ -72,7 +168,11 @@ export class LexicalIndex {
72
168
  }
73
169
  /** Top `k` documents for `query`, best first. Documents scoring zero are omitted. */
74
170
  search(query, k = 10) {
75
- const terms = tokenize(query);
171
+ const raw = tokenize(query);
172
+ // Fall back to the unfiltered terms when a query is nothing but function
173
+ // words, so "what is it" still searches rather than silently matching all.
174
+ const filtered = raw.filter((t) => !QUERY_STOPWORDS.has(t));
175
+ const terms = filtered.length > 0 ? filtered : raw;
76
176
  if (terms.length === 0 || this.postings.length === 0)
77
177
  return [];
78
178
  const n = this.postings.length;
@@ -1 +1 @@
1
- {"version":3,"file":"lexical.js","sourceRoot":"","sources":["../../../src/core/capabilities/lexical.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAIH,0EAA0E;AAC1E,MAAM,EAAE,GAAG,GAAG,CAAC;AACf,MAAM,CAAC,GAAG,IAAI,CAAC;AAEf;;;;GAIG;AACH,MAAM,UAAU,QAAQ,CAAC,IAAY,EAAY;IAChD,MAAM,GAAG,GAAa,EAAE,CAAC;IACzB,KAAK,MAAM,GAAG,IAAI,IAAI,CAAC,WAAW,EAAE,CAAC,KAAK,CAAC,+BAA+B,CAAC,IAAI,EAAE,EAAE,CAAC;QACnF,MAAM,KAAK,GAAG,GAAG,CAAC,WAAW,EAAE,CAAC;QAChC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAChB,wEAAwE;QACxE,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;QACnD,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC;YAAE,GAAG,CAAC,IAAI,CAAC,GAAG,KAAK,CAAC,CAAC;IAC1C,CAAC;IACD,KAAK,MAAM,KAAK,IAAI,IAAI,CAAC,KAAK,CAAC,gDAAgD,CAAC,IAAI,EAAE,EAAE,CAAC;QACxF,MAAM,KAAK,GAAG,KAAK,CAAC,WAAW,EAAE,CAAC;QAClC,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC;YAAE,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IACvC,CAAC;IACD,OAAO,GAAG,CAAC;AAAA,CACX;AAED,sFAAsF;AACtF,SAAS,YAAY,CAAC,GAAkB,EAAU;IACjD,OAAO,GAAG,GAAG,CAAC,IAAI,IAAI,GAAG,CAAC,IAAI,IAAI,GAAG,CAAC,MAAM,IAAI,EAAE,IAAI,GAAG,CAAC,WAAW,EAAE,CAAC;AAAA,CACxE;AAaD;;;;GAIG;AACH,MAAM,OAAO,YAAY;IACP,QAAQ,GAAc,EAAE,CAAC;IACzB,OAAO,GAAG,IAAI,GAAG,EAAkB,CAAC;IACpC,SAAS,CAAS;IAEnC,YAAY,IAA8B,EAAE;QAC3C,IAAI,KAAK,GAAG,CAAC,CAAC;QACd,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;YACxB,MAAM,MAAM,GAAG,QAAQ,CAAC,YAAY,CAAC,GAAG,CAAC,CAAC,CAAC;YAC3C,MAAM,MAAM,GAAG,IAAI,GAAG,EAAkB,CAAC;YACzC,KAAK,MAAM,CAAC,IAAI,MAAM;gBAAE,MAAM,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;YAChE,KAAK,MAAM,CAAC,IAAI,MAAM,CAAC,IAAI,EAAE;gBAAE,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;YACnF,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,GAAG,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;YAClE,KAAK,IAAI,MAAM,CAAC,MAAM,CAAC;QACxB,CAAC;QACD,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,GAAG,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC;IAAA,CAC7E;IAED,IAAI,IAAI,GAAW;QAClB,OAAO,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC;IAAA,CAC5B;IAED,qFAAqF;IACrF,MAAM,CAAC,KAAa,EAAE,CAAC,GAAG,EAAE,EAAgB;QAC3C,MAAM,KAAK,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC;QAC9B,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,IAAI,IAAI,CAAC,QAAQ,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,EAAE,CAAC;QAChE,MAAM,CAAC,GAAG,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC;QAE/B,MAAM,IAAI,GAAiB,EAAE,CAAC;QAC9B,KAAK,MAAM,OAAO,IAAI,IAAI,CAAC,QAAQ,EAAE,CAAC;YACrC,IAAI,KAAK,GAAG,CAAC,CAAC;YACd,KAAK,MAAM,IAAI,IAAI,IAAI,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC;gBACnC,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;gBACpC,IAAI,CAAC,EAAE;oBAAE,SAAS;gBAClB,MAAM,EAAE,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;gBACvC,kEAAkE;gBAClE,+DAA+D;gBAC/D,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,GAAG,GAAG,CAAC,GAAG,CAAC,EAAE,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC;gBACnE,MAAM,IAAI,GAAG,EAAE,GAAG,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC;gBAC3B,MAAM,KAAK,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,IAAI,CAAC,CAAC,CAAC,CAAC;gBAC/E,KAAK,IAAI,GAAG,GAAG,CAAC,IAAI,GAAG,KAAK,CAAC,CAAC;YAC/B,CAAC;YACD,IAAI,KAAK,GAAG,CAAC;gBAAE,IAAI,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,OAAO,CAAC,EAAE,EAAE,KAAK,EAAE,CAAC,CAAC;QACrD,CAAC;QAED,yEAAuE;QACvE,4EAA4E;QAC5E,sCAAsC;QACtC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,EAAE,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;QACnE,OAAO,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IAAA,CACxB;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"]}
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 * Fold a regular English plural to its singular, or return undefined.\n *\n * Deliberately crude: only the endings that are unambiguous without a\n * dictionary. Documentation headings are written in whichever number reads best\n * (\"Themes\", \"Custom Providers\") while questions are asked in the other (\"how\n * do I add a theme\"), and with no folding at all those two never meet — the\n * single most useful term in the query is the one term that cannot match.\n *\n * Irregulars are left alone. Getting \"indices\" wrong costs a missed hit;\n * inventing a stemmer that mangles \"status\" into \"statu\" would cost matches\n * that work today.\n */\nfunction singularize(token: string): string | undefined {\n\tif (token.length > 4 && token.endsWith(\"ies\")) return `${token.slice(0, -3)}y`;\n\tif (token.length > 4 && /(?:ss|sh|ch|x|z)es$/.test(token)) return token.slice(0, -2);\n\tif (token.length > 3 && token.endsWith(\"s\") && !token.endsWith(\"ss\") && !token.endsWith(\"us\")) {\n\t\treturn token.slice(0, -1);\n\t}\n\treturn undefined;\n}\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 *\n * Singular forms are emitted *alongside* the originals rather than replacing\n * them, which is the same bargain the identifier splitting makes: an exact\n * token still matches exactly, and a near miss now matches too.\n */\nexport function tokenize(text: string): string[] {\n\tconst out: string[] = [];\n\tconst push = (token: string): void => {\n\t\tout.push(token);\n\t\tconst singular = singularize(token);\n\t\tif (singular && singular !== token) out.push(singular);\n\t};\n\tfor (const raw of text.toLowerCase().match(/[a-z0-9]+(?:[_-][a-z0-9]+)*/gi) ?? []) {\n\t\tconst token = raw.toLowerCase();\n\t\tpush(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) for (const part of parts) push(part);\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) push(lower);\n\t}\n\treturn out;\n}\n\n/**\n * Function words dropped from *queries* only.\n *\n * Questions arrive as \"how do I add a custom theme\", and in a corpus of a few\n * hundred short documents the filler carries real weight: a section whose prose\n * happens to say \"Add an AGENTS.md file ... to tell it how to work\" outscores\n * the section actually titled \"Creating a Custom Theme\", because it matched\n * three throwaway words to the target's one meaningful one.\n *\n * Query-side only, deliberately. Stripping these from documents too would\n * change every document length and every average, re-tuning a ranking that\n * works; dropping a term from the query just stops it contributing, which is\n * the whole intent.\n */\nconst QUERY_STOPWORDS = new Set([\n\t\"a\",\n\t\"an\",\n\t\"and\",\n\t\"are\",\n\t\"as\",\n\t\"at\",\n\t\"be\",\n\t\"by\",\n\t\"can\",\n\t\"do\",\n\t\"does\",\n\t\"for\",\n\t\"from\",\n\t\"get\",\n\t\"how\",\n\t\"i\",\n\t\"in\",\n\t\"is\",\n\t\"it\",\n\t\"its\",\n\t\"me\",\n\t\"my\",\n\t\"of\",\n\t\"on\",\n\t\"or\",\n\t\"so\",\n\t\"that\",\n\t\"the\",\n\t\"then\",\n\t\"there\",\n\t\"this\",\n\t\"to\",\n\t\"use\",\n\t\"using\",\n\t\"want\",\n\t\"was\",\n\t\"what\",\n\t\"when\",\n\t\"where\",\n\t\"which\",\n\t\"who\",\n\t\"why\",\n\t\"will\",\n\t\"with\",\n\t\"you\",\n\t\"your\",\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 raw = tokenize(query);\n\t\t// Fall back to the unfiltered terms when a query is nothing but function\n\t\t// words, so \"what is it\" still searches rather than silently matching all.\n\t\tconst filtered = raw.filter((t) => !QUERY_STOPWORDS.has(t));\n\t\tconst terms = filtered.length > 0 ? filtered : raw;\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"]}
@@ -17,7 +17,9 @@
17
17
  *
18
18
  * See docs/plugin-system-architecture.md §6.
19
19
  */
20
- export type CapabilityKind = "mcp-tool" | "skill" | "command" | "agent" | "plugin-available" | "plugin-installed";
20
+ export type CapabilityKind = "mcp-tool" | "skill" | "command" | "agent" | "plugin-available" | "plugin-installed"
21
+ /** A heading-level slice of hoocode's own shipped documentation. */
22
+ | "doc";
21
23
  export interface CapabilityDoc {
22
24
  /** Unique and stable within a session; `<kind>:<name>` by convention. */
23
25
  id: string;