@zeroroot-ai/zerocool 0.2.0

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.
@@ -0,0 +1,175 @@
1
+ import { tool } from "@opencode-ai/plugin";
2
+ import { callGibsonTool, listGibsonPlugins, listGibsonTools, queryGibsonPlugin, } from "@zeroroot-ai/sdk";
3
+ /**
4
+ * Gibson tools and plugins as opencode tools (zerocool-plugins#9).
5
+ *
6
+ * Discovered tools are registered **individually** — one opencode tool per
7
+ * Gibson tool, named `gibson_<tool>` — so the model sees them in its tool list
8
+ * with their own descriptions, rather than having to know that a generic
9
+ * dispatcher exists and what to pass it.
10
+ *
11
+ * ON SCHEMAS — the catalog carries no JSON Schema. `ToolDescriptorProto` only
12
+ * names the proto message types of a tool's input and output
13
+ * (`gibson.tool.v1.ScanRequest`), and there is no RPC that resolves those to a
14
+ * schema. So a per-tool wrapper takes a single free-form `input` object and
15
+ * names the expected message type in its description. Inventing a Zod schema
16
+ * from a message type name would be a guess, and a wrong guess would reject
17
+ * valid input before it ever reached the tool.
18
+ *
19
+ * A generic `gibson_call_tool` is always registered as well: discovery is
20
+ * `Unimplemented` on the current daemon (gibson#1186), and without it there
21
+ * would be no way to reach a tool whose name the operator already knows.
22
+ */
23
+ /** Sanitise a Gibson tool name into an opencode tool key. */
24
+ export function toolKey(name) {
25
+ return `gibson_${name.replace(/[^a-zA-Z0-9_]/g, "_")}`;
26
+ }
27
+ /** Wrap one discovered Gibson tool as an opencode tool. */
28
+ export function wrapGibsonTool(session, t) {
29
+ const z = tool.schema;
30
+ const schemaNote = t.inputMessageType
31
+ ? ` Input must match the Gibson message type ${t.inputMessageType}.`
32
+ : "";
33
+ return tool({
34
+ description: `${t.description || `Gibson tool "${t.name}"`}${schemaNote} Runs through the Gibson harness (authorized and metered).`,
35
+ args: {
36
+ input: z
37
+ .record(z.string(), z.any())
38
+ .describe(`Input object for the ${t.name} tool${t.inputMessageType ? ` (${t.inputMessageType})` : ""}.`),
39
+ timeout_ms: z.number().optional().describe("Per-call timeout in milliseconds."),
40
+ },
41
+ async execute(args) {
42
+ const result = await callGibsonTool(session.clients.component, {
43
+ name: t.name,
44
+ input: args.input,
45
+ ...(args.timeout_ms ? { timeoutMs: args.timeout_ms } : {}),
46
+ });
47
+ return formatToolResult(t.name, result);
48
+ },
49
+ });
50
+ }
51
+ /** The always-present dispatcher for tools the catalog did not surface. */
52
+ export function callToolTool(session) {
53
+ const z = tool.schema;
54
+ return tool({
55
+ description: "Invoke a Gibson tool by name through the harness. Use this when you know a " +
56
+ "tool's name but it is not in your tool list — Gibson tool discovery is not " +
57
+ "available on every daemon.",
58
+ args: {
59
+ name: z.string().describe("Registered Gibson tool name."),
60
+ input: z.record(z.string(), z.any()).describe("Input object matching the tool's input schema."),
61
+ timeout_ms: z.number().optional().describe("Per-call timeout in milliseconds."),
62
+ },
63
+ async execute(args) {
64
+ const result = await callGibsonTool(session.clients.component, {
65
+ name: args.name,
66
+ input: args.input,
67
+ ...(args.timeout_ms ? { timeoutMs: args.timeout_ms } : {}),
68
+ });
69
+ return formatToolResult(args.name, result);
70
+ },
71
+ });
72
+ }
73
+ /** List what Gibson exposes to this tenant — tools and enabled plugins. */
74
+ export function listToolsTool(session) {
75
+ return tool({
76
+ description: "List the Gibson tools and plugins available to this tenant, with the input " +
77
+ "message type each tool expects.",
78
+ args: {},
79
+ async execute() {
80
+ const [discovery, plugins] = await Promise.all([
81
+ listGibsonTools(session.clients.component),
82
+ listGibsonPlugins(session.clients.component).catch(() => []),
83
+ ]);
84
+ const lines = [];
85
+ if (discovery.unavailable) {
86
+ lines.push(`Tool discovery is unavailable: ${discovery.unavailable}.`);
87
+ }
88
+ else if (discovery.tools.length === 0) {
89
+ lines.push("No Gibson tools are registered for this tenant.");
90
+ }
91
+ else {
92
+ lines.push("Gibson tools:");
93
+ for (const t of discovery.tools) {
94
+ lines.push(`- ${t.name} (${t.version}) — ${t.description || "no description"}`);
95
+ if (t.inputMessageType)
96
+ lines.push(` input: ${t.inputMessageType}`);
97
+ }
98
+ }
99
+ const enabled = plugins.filter((p) => p.enabled);
100
+ if (enabled.length > 0) {
101
+ lines.push("", "Enabled plugins:");
102
+ for (const p of enabled) {
103
+ lines.push(`- ${p.name} (${p.version}) — methods: ${p.methods.join(", ") || "none"}`);
104
+ }
105
+ }
106
+ return {
107
+ title: `${discovery.tools.length} tools, ${enabled.length} plugins`,
108
+ output: lines.join("\n"),
109
+ metadata: { tool_count: discovery.tools.length, plugin_count: enabled.length },
110
+ };
111
+ },
112
+ });
113
+ }
114
+ /** Invoke a method on an enabled Gibson plugin. */
115
+ export function queryPluginTool(session) {
116
+ const z = tool.schema;
117
+ return tool({
118
+ description: "Call a method on an enabled Gibson plugin through the harness. Use " +
119
+ "gibson_list_tools first to see which plugins and methods are available.",
120
+ args: {
121
+ plugin: z.string().describe("Registered plugin name."),
122
+ method: z.string().describe("Method to invoke on the plugin."),
123
+ params: z.record(z.string(), z.any()).optional().describe("Method parameters."),
124
+ timeout_ms: z.number().optional().describe("Per-call timeout in milliseconds."),
125
+ },
126
+ async execute(args) {
127
+ const result = await queryGibsonPlugin(session.clients.component, {
128
+ plugin: args.plugin,
129
+ method: args.method,
130
+ ...(args.params ? { params: args.params } : {}),
131
+ ...(args.timeout_ms ? { timeoutMs: args.timeout_ms } : {}),
132
+ });
133
+ return formatToolResult(`${args.plugin}.${args.method}`, result);
134
+ },
135
+ });
136
+ }
137
+ /**
138
+ * Discover the tenant's tools and build one opencode tool for each, plus the
139
+ * always-present dispatcher, catalog and plugin tools.
140
+ *
141
+ * Discovery runs once at plugin load. opencode reads the `tool` hook as a
142
+ * static object, so a tool registered later in the session would not be visible
143
+ * to the model anyway.
144
+ */
145
+ export async function buildGibsonTools(session) {
146
+ const tools = {
147
+ gibson_call_tool: callToolTool(session),
148
+ gibson_list_tools: listToolsTool(session),
149
+ gibson_query_plugin: queryPluginTool(session),
150
+ };
151
+ const discovery = await listGibsonTools(session.clients.component);
152
+ for (const t of discovery.tools) {
153
+ // Never let a discovered tool shadow the built-ins.
154
+ const key = toolKey(t.name);
155
+ if (!(key in tools))
156
+ tools[key] = wrapGibsonTool(session, t);
157
+ }
158
+ return {
159
+ tools,
160
+ discovered: discovery.tools.length,
161
+ ...(discovery.unavailable ? { note: discovery.unavailable } : {}),
162
+ };
163
+ }
164
+ /** Render a harness tool result, keeping an in-band tool error visible. */
165
+ function formatToolResult(name, result) {
166
+ if (result.error) {
167
+ return {
168
+ title: `${name} failed`,
169
+ output: `Gibson tool ${name} failed [${result.error.code}]: ${result.error.message}${result.error.retryable ? " (retryable)" : ""}`,
170
+ metadata: { error_code: result.error.code, retryable: result.error.retryable },
171
+ };
172
+ }
173
+ const text = typeof result.output === "string" ? result.output : JSON.stringify(result.output, null, 2);
174
+ return { title: name, output: text ?? "(no output)" };
175
+ }
@@ -0,0 +1,43 @@
1
+ import { decodeToolInput, type GibsonClients, type ToolInvocation } from "@zeroroot-ai/sdk";
2
+ /**
3
+ * The http_probe tool zerocool serves to the fleet.
4
+ *
5
+ * A single GET, reported as structured facts. Deliberately the smallest useful
6
+ * thing a mission node can ask an external component to do: it proves the whole
7
+ * dispatch path — mission node → work queue → this process → result → mission
8
+ * completion — without the result depending on anything about the target.
9
+ *
10
+ * SAFETY. This is a fetch, not a scanner: one request, no redirect chasing
11
+ * beyond what fetch does by default, no body execution, and a hard timeout. The
12
+ * body is measured, never returned — a probe that echoes arbitrary remote
13
+ * content back into a mission's knowledge graph is an injection surface, and
14
+ * the size is what the caller actually asked for.
15
+ */
16
+ export interface ProbeResult {
17
+ url: string;
18
+ status: number;
19
+ statusText: string;
20
+ server: string | null;
21
+ contentType: string | null;
22
+ bytes: number;
23
+ elapsedMs: number;
24
+ }
25
+ export declare const HTTP_PROBE_TIMEOUT_MS = 15000;
26
+ /** Perform the probe. Exported separately from the handler so it is testable without a work item. */
27
+ export declare function probe(url: string, timeoutMs?: number): Promise<ProbeResult>;
28
+ /**
29
+ * Work handler: takes a claimed tool invocation and returns the probe result.
30
+ *
31
+ * `url` is required. A missing url throws rather than defaulting to something,
32
+ * because a probe that quietly fetches a different target than the mission
33
+ * asked for is worse than a failed node.
34
+ *
35
+ * `clients` is the callback-harness seam: every dispatched handler receives
36
+ * the session's Gibson clients so it can reach LLM (`clients.component`),
37
+ * tools, findings and knowledge (`clients.harness`) during a dispatched run.
38
+ * The probe itself needs none of that — one request, structured facts — so
39
+ * the parameter is optional and unused here; richer served tools consume it.
40
+ */
41
+ export declare function httpProbeHandler(invocation: ToolInvocation, _clients?: GibsonClients): Promise<ProbeResult>;
42
+ /** Re-exported so a caller can decode a raw payload without importing the SDK directly. */
43
+ export { decodeToolInput };
@@ -0,0 +1,51 @@
1
+ import { decodeToolInput } from "@zeroroot-ai/sdk";
2
+ export const HTTP_PROBE_TIMEOUT_MS = 15_000;
3
+ /** Perform the probe. Exported separately from the handler so it is testable without a work item. */
4
+ export async function probe(url, timeoutMs = HTTP_PROBE_TIMEOUT_MS) {
5
+ const parsed = new URL(url);
6
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
7
+ throw new Error(`unsupported scheme ${parsed.protocol} (http and https only)`);
8
+ }
9
+ const started = Date.now();
10
+ const ctl = new AbortController();
11
+ const timer = setTimeout(() => ctl.abort(), timeoutMs);
12
+ try {
13
+ const res = await fetch(parsed.toString(), { method: "GET", signal: ctl.signal });
14
+ const body = await res.arrayBuffer();
15
+ return {
16
+ url: parsed.toString(),
17
+ status: res.status,
18
+ statusText: res.statusText,
19
+ server: res.headers.get("server"),
20
+ contentType: res.headers.get("content-type"),
21
+ bytes: body.byteLength,
22
+ elapsedMs: Date.now() - started,
23
+ };
24
+ }
25
+ finally {
26
+ clearTimeout(timer);
27
+ }
28
+ }
29
+ /**
30
+ * Work handler: takes a claimed tool invocation and returns the probe result.
31
+ *
32
+ * `url` is required. A missing url throws rather than defaulting to something,
33
+ * because a probe that quietly fetches a different target than the mission
34
+ * asked for is worse than a failed node.
35
+ *
36
+ * `clients` is the callback-harness seam: every dispatched handler receives
37
+ * the session's Gibson clients so it can reach LLM (`clients.component`),
38
+ * tools, findings and knowledge (`clients.harness`) during a dispatched run.
39
+ * The probe itself needs none of that — one request, structured facts — so
40
+ * the parameter is optional and unused here; richer served tools consume it.
41
+ */
42
+ export async function httpProbeHandler(invocation, _clients) {
43
+ const url = invocation.input.url;
44
+ if (typeof url !== "string" || !url.trim()) {
45
+ throw new Error("http_probe requires a `url` parameter");
46
+ }
47
+ const timeout = typeof invocation.input.timeout_ms === "number" ? invocation.input.timeout_ms : HTTP_PROBE_TIMEOUT_MS;
48
+ return probe(url, timeout);
49
+ }
50
+ /** Re-exported so a caller can decode a raw payload without importing the SDK directly. */
51
+ export { decodeToolInput };
@@ -0,0 +1,34 @@
1
+ import type { Plugin } from "@opencode-ai/plugin";
2
+ /**
3
+ * @zeroroot-ai/zerocool — the main Gibson plugin.
4
+ *
5
+ * ENROLLMENT — check in once, then run unattended. A human enrolls this host
6
+ * one time:
7
+ *
8
+ * gibson login # device flow, in a browser
9
+ * gibson agent enroll # prints a ONE-TIME token
10
+ * GIBSON_BOOTSTRAP_TOKEN=<token> opencode # first start only
11
+ *
12
+ * That first start completes the Capability Grant handshake and persists a host
13
+ * key. Every later start re-registers with that key and needs no token and no
14
+ * human. This is the platform's design (ADR-0045), not a limitation to work
15
+ * around: a coding agent joins a tenant's fleet on a person's authority, once.
16
+ *
17
+ * Standalone (never enrolled), opencode is almost unchanged: the agent gets a
18
+ * local findings log and a componentize tool, and nothing else. Enrolled, it
19
+ * also:
20
+ * - checks in as a Gibson component (Capability Grant + RegisterComponent + heartbeat),
21
+ * - starts a local OpenAI-compatible shim over the harness,
22
+ * - auto-registers a zero-config `gibson` provider via the `config` hook (#6),
23
+ * - emits findings into the tenant World (#7),
24
+ * - reads the tenant knowledge graph (#8),
25
+ * - exposes Gibson tools and plugins as opencode tools (#9),
26
+ * - delegates to other agents and drives missions (#10),
27
+ * - enrolls produced artifacts into the fleet (#11).
28
+ *
29
+ * The plugin fails open throughout. A coding agent that cannot reach its
30
+ * platform must still be a working coding agent, so every platform failure
31
+ * degrades to the standalone behaviour instead of refusing to start.
32
+ */
33
+ export declare const GibsonPlugin: Plugin;
34
+ export default GibsonPlugin;
package/dist/index.js ADDED
@@ -0,0 +1,167 @@
1
+ import { connectGibson, startCompletionsShim } from "@zeroroot-ai/sdk";
2
+ import { existsSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { componentizeTool, enrollComponentTool } from "./componentize.js";
6
+ import { delegationTools } from "./delegate.js";
7
+ import { buildGibsonTools } from "./gibson-tools.js";
8
+ import { ambientKnowledge, recallTool } from "./knowledge.js";
9
+ import { selectKnowledgeSource } from "./knowledge-source.js";
10
+ import { gibsonFindingsBackend, localFindingsBackend, submitFindingTool, } from "./findings.js";
11
+ /**
12
+ * @zeroroot-ai/zerocool — the main Gibson plugin.
13
+ *
14
+ * ENROLLMENT — check in once, then run unattended. A human enrolls this host
15
+ * one time:
16
+ *
17
+ * gibson login # device flow, in a browser
18
+ * gibson agent enroll # prints a ONE-TIME token
19
+ * GIBSON_BOOTSTRAP_TOKEN=<token> opencode # first start only
20
+ *
21
+ * That first start completes the Capability Grant handshake and persists a host
22
+ * key. Every later start re-registers with that key and needs no token and no
23
+ * human. This is the platform's design (ADR-0045), not a limitation to work
24
+ * around: a coding agent joins a tenant's fleet on a person's authority, once.
25
+ *
26
+ * Standalone (never enrolled), opencode is almost unchanged: the agent gets a
27
+ * local findings log and a componentize tool, and nothing else. Enrolled, it
28
+ * also:
29
+ * - checks in as a Gibson component (Capability Grant + RegisterComponent + heartbeat),
30
+ * - starts a local OpenAI-compatible shim over the harness,
31
+ * - auto-registers a zero-config `gibson` provider via the `config` hook (#6),
32
+ * - emits findings into the tenant World (#7),
33
+ * - reads the tenant knowledge graph (#8),
34
+ * - exposes Gibson tools and plugins as opencode tools (#9),
35
+ * - delegates to other agents and drives missions (#10),
36
+ * - enrolls produced artifacts into the fleet (#11).
37
+ *
38
+ * The plugin fails open throughout. A coding agent that cannot reach its
39
+ * platform must still be a working coding agent, so every platform failure
40
+ * degrades to the standalone behaviour instead of refusing to start.
41
+ */
42
+ export const GibsonPlugin = async () => {
43
+ const platformURL = process.env.GIBSON_PLATFORM_URL;
44
+ const hostKeyPath = process.env.GIBSON_HOST_KEY_PATH ?? join(homedir(), ".zerocool", "host.key");
45
+ const context = {};
46
+ // Check in once, then run unattended — the platform's enrollment model
47
+ // (ADR-0045). A human runs `gibson login` and `gibson agent enroll` once and
48
+ // hands over the resulting ONE-TIME bootstrap token. That token completes the
49
+ // first Capability Grant handshake and the host key it registers is persisted
50
+ // at `hostKeyPath`. Every later start re-registers by proving possession of
51
+ // that host key — the daemon routes on credential type: `host+jwt` is
52
+ // re-registration, anything else is first registration
53
+ // (gibson `internal/server/daemon/capabilitygrant_register.go:134-155`).
54
+ //
55
+ // So the bootstrap token is passed ONLY when no host key exists yet. Replaying
56
+ // a one-time token on every start would be rejected, and it would mean asking
57
+ // the operator to keep a spent credential in their environment forever.
58
+ const checkedIn = existsSync(hostKeyPath);
59
+ const bootstrapToken = checkedIn ? undefined : process.env.GIBSON_BOOTSTRAP_TOKEN;
60
+ // Standalone: findings go to a local log, and componentize still works —
61
+ // producing a manifest needs no platform.
62
+ const standaloneTools = () => ({
63
+ submit_finding: submitFindingTool(localFindingsBackend(process.env.ZEROCOOL_FINDINGS_LOG ?? join(homedir(), ".zerocool", "findings.jsonl")), context),
64
+ gibson_componentize: componentizeTool(),
65
+ });
66
+ // Standalone unless we can authenticate: a platform URL plus either a token
67
+ // for the first check-in or an already-registered host key.
68
+ if (!platformURL || (!bootstrapToken && !checkedIn)) {
69
+ if (platformURL && !checkedIn) {
70
+ console.error("[zerocool] GIBSON_PLATFORM_URL is set but this host has not checked in. " +
71
+ "Run `gibson login` then `gibson agent enroll`, and start once with " +
72
+ "GIBSON_BOOTSTRAP_TOKEN=<one-time token>. After that the host key at " +
73
+ `${hostKeyPath} is enough — you can drop the token.`);
74
+ }
75
+ return { tool: standaloneTools() };
76
+ }
77
+ let session;
78
+ let shim;
79
+ try {
80
+ session = await connectGibson({
81
+ platformURL,
82
+ daemonURL: process.env.GIBSON_DAEMON_URL,
83
+ bootstrapToken,
84
+ hostKeyPath,
85
+ agentName: "zerocool",
86
+ agentMode: process.env.GIBSON_AGENT_MODE ?? "autonomous",
87
+ agent: { name: "zerocool", version: "0.0.0", capabilities: ["code"] },
88
+ });
89
+ shim = await startCompletionsShim({
90
+ component: session.clients.component,
91
+ port: Number(process.env.GIBSON_SHIM_PORT ?? 8787),
92
+ });
93
+ console.error(`[zerocool] Gibson connected via ${checkedIn ? "the registered host key" : "first check-in"} ` +
94
+ `(component_scope=${session.componentScope}); provider "gibson" auto-configured at ` +
95
+ `${shim.url} — select a gibson/<slot> model`);
96
+ if (!checkedIn) {
97
+ console.error(`[zerocool] Host key written to ${hostKeyPath}. The bootstrap token is spent — ` +
98
+ "unset GIBSON_BOOTSTRAP_TOKEN; later starts re-register with the host key.");
99
+ }
100
+ }
101
+ catch (e) {
102
+ // Fail open to standalone: a coding agent must still work if the platform is unreachable.
103
+ console.error(`[zerocool] Gibson connect failed; continuing standalone: ${e.message}`);
104
+ await shim?.close();
105
+ return { tool: standaloneTools() };
106
+ }
107
+ const shimUrl = shim.url;
108
+ const live = session;
109
+ // Tool discovery runs once at load — opencode reads the `tool` hook as a
110
+ // static object, so a tool registered mid-session would never reach the model.
111
+ // Which grant this process reads the knowledge graph with. A dispatched run
112
+ // reads as the TASK; an interactive one keeps the component grant. Chosen once
113
+ // here so nothing downstream has to ask.
114
+ const { knowledge, scope: knowledgeScope } = selectKnowledgeSource(live);
115
+ console.error(`[zerocool] knowledge reads use the ${knowledgeScope} grant`);
116
+ const gibson = await buildGibsonTools(live);
117
+ if (gibson.note)
118
+ console.error(`[zerocool] Gibson tool discovery: ${gibson.note}`);
119
+ else
120
+ console.error(`[zerocool] ${gibson.discovered} Gibson tool(s) registered`);
121
+ const tools = {
122
+ submit_finding: submitFindingTool(gibsonFindingsBackend(live), context),
123
+ recall: recallTool(knowledge),
124
+ gibson_componentize: componentizeTool(),
125
+ gibson_enroll_component: enrollComponentTool(live),
126
+ ...gibson.tools,
127
+ ...delegationTools(live),
128
+ };
129
+ const injectKnowledge = ambientKnowledge(knowledge, process.env.ZEROCOOL_AMBIENT_QUERY ?? "prior findings and security facts for this codebase");
130
+ return {
131
+ tool: tools,
132
+ // Zero-config LLM: inject the Gibson provider at config-load time (#6).
133
+ //
134
+ // `shimUrl` is always live here, and that is a type-level guarantee rather
135
+ // than a timing assumption: `Plugin` returns `Promise<Hooks>`, so opencode
136
+ // cannot reach this hook until the plugin body has resolved — and the body
137
+ // starts the shim before it builds this object. There is no ordering in
138
+ // which `config` runs against an unstarted shim.
139
+ config: async (config) => {
140
+ const cfg = config;
141
+ cfg.provider = cfg.provider ?? {};
142
+ if (!cfg.provider.gibson) {
143
+ cfg.provider.gibson = {
144
+ npm: "@ai-sdk/openai-compatible",
145
+ name: "Gibson",
146
+ options: { baseURL: shimUrl, apiKey: "gibson" },
147
+ };
148
+ }
149
+ },
150
+ // Ambient knowledge (#8): one cached GraphRAG lookup per session, injected
151
+ // into the system prompt from the second turn on.
152
+ "experimental.chat.system.transform": injectKnowledge,
153
+ // Track the session so findings carry provenance (#7). This hook does not
154
+ // emit findings: a file edit is not a security finding, and inventing one
155
+ // would fill the tenant graph with noise a human then has to triage.
156
+ event: async ({ event }) => {
157
+ const sessionID = event.properties?.sessionID;
158
+ if (sessionID)
159
+ context.sessionID = sessionID;
160
+ },
161
+ dispose: async () => {
162
+ session?.stop();
163
+ await shim?.close();
164
+ },
165
+ };
166
+ };
167
+ export default GibsonPlugin;
@@ -0,0 +1,21 @@
1
+ import { type GibsonSession, type KnowledgeSource } from "@zeroroot-ai/sdk";
2
+ /**
3
+ * Pick the grant this process should read the knowledge graph with.
4
+ *
5
+ * A DISPATCHED run must read as the TASK, not as the component. gibson mints a
6
+ * per-dispatch capability grant and `zerocool-agent` passes it to the child as
7
+ * GIBSON_CALLBACK_ENDPOINT / GIBSON_CALLBACK_TOKEN. Reading with the component's
8
+ * own grant instead is broader authority than the dispatch intended and leaves
9
+ * no per-task attribution on anything the run reads — the gap ADR-0006 recorded.
10
+ *
11
+ * An INTERACTIVE run has no callback seam and keeps the component grant, which
12
+ * is correct: a human started it, and there is no task to scope to.
13
+ *
14
+ * A dispatch that carries an endpoint but no token FAILS rather than falling
15
+ * back. Falling back is how the gap reappears silently, and a caller who wanted
16
+ * task-scoped reads would get component-scoped ones without being told.
17
+ */
18
+ export declare function selectKnowledgeSource(session: GibsonSession): {
19
+ knowledge: KnowledgeSource;
20
+ scope: "task" | "component";
21
+ };
@@ -0,0 +1,38 @@
1
+ import { componentKnowledge, connectTaskHarness, taskKnowledge, } from "@zeroroot-ai/sdk";
2
+ /**
3
+ * Pick the grant this process should read the knowledge graph with.
4
+ *
5
+ * A DISPATCHED run must read as the TASK, not as the component. gibson mints a
6
+ * per-dispatch capability grant and `zerocool-agent` passes it to the child as
7
+ * GIBSON_CALLBACK_ENDPOINT / GIBSON_CALLBACK_TOKEN. Reading with the component's
8
+ * own grant instead is broader authority than the dispatch intended and leaves
9
+ * no per-task attribution on anything the run reads — the gap ADR-0006 recorded.
10
+ *
11
+ * An INTERACTIVE run has no callback seam and keeps the component grant, which
12
+ * is correct: a human started it, and there is no task to scope to.
13
+ *
14
+ * A dispatch that carries an endpoint but no token FAILS rather than falling
15
+ * back. Falling back is how the gap reappears silently, and a caller who wanted
16
+ * task-scoped reads would get component-scoped ones without being told.
17
+ */
18
+ export function selectKnowledgeSource(session) {
19
+ const endpoint = process.env.GIBSON_CALLBACK_ENDPOINT;
20
+ const token = process.env.GIBSON_CALLBACK_TOKEN;
21
+ if (!endpoint) {
22
+ return { knowledge: componentKnowledge(session.clients.component), scope: "component" };
23
+ }
24
+ if (!token) {
25
+ throw new Error("GIBSON_CALLBACK_ENDPOINT is set but GIBSON_CALLBACK_TOKEN is not. A dispatched run " +
26
+ "must read with its task grant; falling back to the component grant would silently " +
27
+ "widen this run's authority.");
28
+ }
29
+ return {
30
+ knowledge: taskKnowledge(connectTaskHarness({
31
+ endpoint,
32
+ token,
33
+ // A local or kind daemon may serve the callback listener without TLS.
34
+ insecure: process.env.GIBSON_CALLBACK_INSECURE === "1",
35
+ })),
36
+ scope: "task",
37
+ };
38
+ }
@@ -0,0 +1,15 @@
1
+ import type { ToolDefinition } from "@opencode-ai/plugin";
2
+ import { type KnowledgeSource } from "@zeroroot-ai/sdk";
3
+ export declare function recallTool(knowledge: KnowledgeSource): ToolDefinition;
4
+ /**
5
+ * Per-session ambient knowledge for the system prompt.
6
+ *
7
+ * Returns a transform that injects a short context block once a session has
8
+ * produced one. The lookup is fired once and cached, so a session pays for at
9
+ * most one GraphRAG query no matter how many turns it runs.
10
+ */
11
+ export declare function ambientKnowledge(knowledge: KnowledgeSource, seedQuery: string): (input: {
12
+ sessionID?: string;
13
+ }, output: {
14
+ system: string[];
15
+ }) => Promise<void>;
@@ -0,0 +1,113 @@
1
+ import { tool } from "@opencode-ai/plugin";
2
+ import { formatKnowledgeForPrompt, isSeamUnavailable, } from "@zeroroot-ai/sdk";
3
+ /**
4
+ * Knowledge — read the tenant graph so the agent sharpens across runs
5
+ * (zerocool-plugins#8).
6
+ *
7
+ * Two surfaces, because opencode offers two and they answer different needs:
8
+ *
9
+ * - a **`recall` tool**, which the model calls when it wants prior context.
10
+ * This is the primary path: the model knows when a lookup is worth a round
11
+ * trip, and the query it writes is better than anything derived from the
12
+ * prompt.
13
+ * - a **`system.transform` hook**, which injects a small ambient block once
14
+ * per session. `experimental.chat.system.transform` runs on every request,
15
+ * so an un-cached GraphRAG query there would add a network round trip to
16
+ * every single turn. {@link ambientKnowledge} caches per session and injects
17
+ * nothing until the first successful lookup.
18
+ *
19
+ * All reads are tenant-scoped server-side; there is no tenant argument to get
20
+ * wrong. Standalone, both surfaces are absent — the plugin does not register
21
+ * them without a Gibson session.
22
+ */
23
+ /** How many hits the ambient block injects. Kept small — it is prompt overhead. */
24
+ const AMBIENT_LIMIT = 5;
25
+ export function recallTool(knowledge) {
26
+ const z = tool.schema;
27
+ return tool({
28
+ description: "Search this tenant's Gibson knowledge graph for prior findings, targets and " +
29
+ "security facts recorded by earlier runs. Use it before starting work on an " +
30
+ "unfamiliar area, or to check whether an issue was already reported.",
31
+ args: {
32
+ query: z.string().describe("What to look for, in natural language."),
33
+ limit: z.number().min(1).max(50).optional().describe("Maximum hits to return. Defaults to 10."),
34
+ node_types: z
35
+ .array(z.string())
36
+ .optional()
37
+ .describe('Restrict to node types, e.g. ["finding"] or ["host", "technique"].'),
38
+ },
39
+ async execute(args) {
40
+ let hits;
41
+ try {
42
+ hits = await knowledge.query({
43
+ text: args.query,
44
+ topK: args.limit ?? 10,
45
+ ...(args.node_types ? { nodeTypes: args.node_types } : {}),
46
+ });
47
+ }
48
+ catch (e) {
49
+ if (isSeamUnavailable(e)) {
50
+ return {
51
+ title: "recall unavailable",
52
+ output: "The Gibson knowledge graph is not available on this daemon: the GraphRAG " +
53
+ "seam is not wired (gibson#1186). Continue without prior context.",
54
+ };
55
+ }
56
+ throw e;
57
+ }
58
+ if (hits.length === 0) {
59
+ return { title: "no matches", output: `Nothing in the tenant graph matches "${args.query}".` };
60
+ }
61
+ return {
62
+ title: `${hits.length} match${hits.length === 1 ? "" : "es"}`,
63
+ output: formatKnowledgeForPrompt(hits),
64
+ metadata: { hit_count: hits.length, node_types: [...new Set(hits.map((h) => h.type))] },
65
+ };
66
+ },
67
+ });
68
+ }
69
+ /**
70
+ * Per-session ambient knowledge for the system prompt.
71
+ *
72
+ * Returns a transform that injects a short context block once a session has
73
+ * produced one. The lookup is fired once and cached, so a session pays for at
74
+ * most one GraphRAG query no matter how many turns it runs.
75
+ */
76
+ export function ambientKnowledge(knowledge, seedQuery) {
77
+ const cache = new Map();
78
+ const inFlight = new Map();
79
+ const load = async (_key) => {
80
+ try {
81
+ const hits = await knowledge.query({
82
+ text: seedQuery,
83
+ topK: AMBIENT_LIMIT,
84
+ });
85
+ return formatKnowledgeForPrompt(hits);
86
+ }
87
+ catch {
88
+ // A knowledge failure must never break a chat turn — the agent works
89
+ // without prior context, it just works less well.
90
+ return "";
91
+ }
92
+ };
93
+ return async (input, output) => {
94
+ const key = input.sessionID ?? "default";
95
+ const cached = cache.get(key);
96
+ if (cached) {
97
+ output.system.push(cached);
98
+ return;
99
+ }
100
+ // Fire the lookup once per session and let this turn proceed without it;
101
+ // blocking the first turn on a graph query would be felt as latency on
102
+ // every new session.
103
+ if (!inFlight.has(key)) {
104
+ const p = load(key).then((block) => {
105
+ if (block)
106
+ cache.set(key, block);
107
+ inFlight.delete(key);
108
+ return block;
109
+ });
110
+ inFlight.set(key, p);
111
+ }
112
+ };
113
+ }