@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.
- package/LICENSE +21 -0
- package/dist/componentize.d.ts +10 -0
- package/dist/componentize.js +149 -0
- package/dist/delegate.d.ts +11 -0
- package/dist/delegate.js +216 -0
- package/dist/findings.d.ts +27 -0
- package/dist/findings.js +109 -0
- package/dist/gibson-tools.d.ts +45 -0
- package/dist/gibson-tools.js +175 -0
- package/dist/http-probe.d.ts +43 -0
- package/dist/http-probe.js +51 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +167 -0
- package/dist/knowledge-source.d.ts +21 -0
- package/dist/knowledge-source.js +38 -0
- package/dist/knowledge.d.ts +15 -0
- package/dist/knowledge.js +113 -0
- package/dist/opencode-run.d.ts +70 -0
- package/dist/opencode-run.js +102 -0
- package/dist/serve-agent.d.ts +37 -0
- package/dist/serve-agent.js +144 -0
- package/dist/serve.d.ts +2 -0
- package/dist/serve.js +94 -0
- package/package.json +39 -0
|
@@ -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 };
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|