petbox-wire 0.1.0-ci.1206
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/README.md +119 -0
- package/bin/petbox-wire.js +30 -0
- package/package.json +44 -0
- package/src/agent-def-fetch.test.ts +317 -0
- package/src/agent-def-fetch.ts +409 -0
- package/src/agent-definition.ts +210 -0
- package/src/append-meta.test.ts +93 -0
- package/src/append.ts +172 -0
- package/src/apply-artifacts.ts +337 -0
- package/src/apply-root.test.ts +148 -0
- package/src/apply-root.ts +68 -0
- package/src/apply-write.test.ts +169 -0
- package/src/apply-write.ts +84 -0
- package/src/canon.ts +140 -0
- package/src/doctor-definition.test.ts +128 -0
- package/src/droid-pull-memory.ts +126 -0
- package/src/droid-push-session.ts +100 -0
- package/src/droid-transcript.ts +47 -0
- package/src/harness-capabilities.ts +126 -0
- package/src/harness-models.ts +158 -0
- package/src/hook-drain.ts +42 -0
- package/src/hook-prune.test.ts +165 -0
- package/src/hook-prune.ts +83 -0
- package/src/import-sessions.ts +261 -0
- package/src/opencode-plugin.ts +127 -0
- package/src/origin-marker.ts +37 -0
- package/src/posix-env.test.ts +99 -0
- package/src/posix-env.ts +61 -0
- package/src/protocol.test.ts +236 -0
- package/src/protocol.ts +136 -0
- package/src/pull-memory.test.ts +168 -0
- package/src/pull-memory.ts +119 -0
- package/src/push-session.ts +99 -0
- package/src/registry.ts +120 -0
- package/src/roles.test.ts +255 -0
- package/src/roles.ts +241 -0
- package/src/self-smoke.test.ts +103 -0
- package/src/self-smoke.ts +96 -0
- package/src/telemetry-settings.test.ts +65 -0
- package/src/telemetry-settings.ts +55 -0
- package/src/templates/SKILL.md +45 -0
- package/src/templates/agent-factory/SKILL.md +56 -0
- package/src/transcript.ts +86 -0
- package/src/truthfulness.test.ts +544 -0
- package/src/truthfulness.ts +164 -0
- package/src/wire-exit.test.ts +43 -0
- package/src/wire-exit.ts +25 -0
- package/src/wire-identity.test.ts +65 -0
- package/src/wire-identity.ts +64 -0
- package/src/wire.ts +1384 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// History importer (spec: wiring-history-import) — backfill the PetBox session archive
|
|
2
|
+
// from the agents' LOCAL history, so a freshly connected project starts with a warm
|
|
3
|
+
// memory instead of an empty one:
|
|
4
|
+
//
|
|
5
|
+
// node --experimental-strip-types import-sessions.ts [--agent claude|opencode|all]
|
|
6
|
+
// [--project <key>] [--dry-run] [--since YYYY-MM-DD] [--limit N] [--force]
|
|
7
|
+
//
|
|
8
|
+
// Two invariants protect the archive:
|
|
9
|
+
// native ids — a session is pushed under the SAME id its agent's live hook/plugin
|
|
10
|
+
// uses (Claude: transcript uuid; opencode: ses_…), so an import lands
|
|
11
|
+
// in the same latest-snapshot row — duplicates are impossible;
|
|
12
|
+
// upgrade-only — latest-snapshot is last-write-wins, so before pushing we compare the
|
|
13
|
+
// local message count against the server version (GET /api/sessions)
|
|
14
|
+
// and skip unless local is strictly larger (--force overrides) — a
|
|
15
|
+
// stale file read can never roll back a fresher snapshot.
|
|
16
|
+
//
|
|
17
|
+
// Only dialogue turns are sent (shared transcript.ts parsing — the same code the Stop
|
|
18
|
+
// hook runs); raw tool outputs never leave the machine. Server-side, the cursor-driven
|
|
19
|
+
// pipelines (digest, facts, patterns) backfill on their own after the push.
|
|
20
|
+
|
|
21
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
22
|
+
import { homedir } from "node:os";
|
|
23
|
+
import { join, basename } from "node:path";
|
|
24
|
+
import { readRegistry, resolveProject, type ResolvedProject } from "./registry.ts";
|
|
25
|
+
import { buildMessages, readTranscriptCwd, type Msg } from "./transcript.ts";
|
|
26
|
+
|
|
27
|
+
const FETCH_TIMEOUT_MS = 30000;
|
|
28
|
+
|
|
29
|
+
type Args = {
|
|
30
|
+
agent: "claude" | "opencode" | "all";
|
|
31
|
+
project?: string;
|
|
32
|
+
dryRun: boolean;
|
|
33
|
+
since?: Date;
|
|
34
|
+
limit?: number;
|
|
35
|
+
force: boolean;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type Candidate = {
|
|
39
|
+
agent: string;
|
|
40
|
+
sessionId: string;
|
|
41
|
+
updated: Date;
|
|
42
|
+
load: () => Promise<Msg[]>;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function parseArgs(argv: string[]): Args {
|
|
46
|
+
const a: Args = { agent: "all", dryRun: false, force: false };
|
|
47
|
+
for (let i = 0; i < argv.length; i++) {
|
|
48
|
+
const arg = argv[i];
|
|
49
|
+
if (arg === "--agent") a.agent = (argv[++i] as Args["agent"]) ?? "all";
|
|
50
|
+
else if (arg === "--project") {
|
|
51
|
+
const project = argv[++i];
|
|
52
|
+
if (project !== undefined) a.project = project;
|
|
53
|
+
} else if (arg === "--dry-run") a.dryRun = true;
|
|
54
|
+
else if (arg === "--force") a.force = true;
|
|
55
|
+
else if (arg === "--since") a.since = new Date(argv[++i] ?? "");
|
|
56
|
+
else if (arg === "--limit") {
|
|
57
|
+
const limit = parseInt(argv[++i] ?? "0", 10) || undefined;
|
|
58
|
+
if (limit !== undefined) a.limit = limit;
|
|
59
|
+
}
|
|
60
|
+
else throw new Error(`unknown argument: ${arg}`);
|
|
61
|
+
}
|
|
62
|
+
if (!["claude", "opencode", "all"].includes(a.agent)) throw new Error(`invalid --agent: ${a.agent}`);
|
|
63
|
+
if (a.since && isNaN(a.since.getTime())) throw new Error("invalid --since (use YYYY-MM-DD)");
|
|
64
|
+
return a;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The target: --project finds the registry entry by project key; otherwise the cwd
|
|
68
|
+
// resolves like the hooks do.
|
|
69
|
+
function resolveTarget(projectKey?: string): ResolvedProject {
|
|
70
|
+
if (projectKey) {
|
|
71
|
+
const entry = readRegistry().find((e) => e.project === projectKey);
|
|
72
|
+
if (!entry) throw new Error(`project '${projectKey}' not found in ~/.petbox/projects.json`);
|
|
73
|
+
const apiKey = process.env[entry.envVar];
|
|
74
|
+
if (!apiKey) throw new Error(`env var ${entry.envVar} is empty`);
|
|
75
|
+
return {
|
|
76
|
+
project: entry.project,
|
|
77
|
+
apiKey,
|
|
78
|
+
baseUrl: (entry.baseUrl ?? "https://petbox.3po.su").replace(/\/+$/, ""),
|
|
79
|
+
envVar: entry.envVar,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const resolved = resolveProject(process.cwd());
|
|
83
|
+
if (!resolved) throw new Error("cwd is not a registered project (or its env var is empty); use --project");
|
|
84
|
+
return resolved;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function listDirs(path: string): string[] {
|
|
88
|
+
try {
|
|
89
|
+
return readdirSync(path, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
|
|
90
|
+
} catch {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function listFiles(path: string, ext: string): string[] {
|
|
96
|
+
try {
|
|
97
|
+
return readdirSync(path).filter((f) => f.endsWith(ext));
|
|
98
|
+
} catch {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function readJson(path: string): any | null {
|
|
104
|
+
try {
|
|
105
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Claude Code: ~/.claude/projects/<encoded-cwd>/*.jsonl. The encoding is lossy, so a
|
|
112
|
+
// transcript is attributed by the cwd recorded INSIDE it, resolved through the same
|
|
113
|
+
// registry matching the hooks use (worktrees and subfolders included).
|
|
114
|
+
async function claudeCandidates(target: ResolvedProject): Promise<Candidate[]> {
|
|
115
|
+
const root = join(homedir(), ".claude", "projects");
|
|
116
|
+
const out: Candidate[] = [];
|
|
117
|
+
for (const dir of listDirs(root)) {
|
|
118
|
+
for (const file of listFiles(join(root, dir), ".jsonl")) {
|
|
119
|
+
const path = join(root, dir, file);
|
|
120
|
+
const cwd = await readTranscriptCwd(path);
|
|
121
|
+
if (!cwd) continue;
|
|
122
|
+
const resolved = resolveProject(cwd);
|
|
123
|
+
if (!resolved || resolved.project !== target.project) continue;
|
|
124
|
+
out.push({
|
|
125
|
+
agent: "claude-code",
|
|
126
|
+
sessionId: basename(file, ".jsonl"),
|
|
127
|
+
updated: statSync(path).mtime,
|
|
128
|
+
load: () => buildMessages(path),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// opencode: ~/.local/share/opencode/storage —
|
|
136
|
+
// session/<projectHash>/<ses>.json {id, directory, time.updated}
|
|
137
|
+
// message/<ses>/<msg>.json {id, role, time.created}
|
|
138
|
+
// part/<msg>/<prt>.json {type:"text", text}
|
|
139
|
+
// A session is attributed by its recorded `directory`; content = the text parts of each
|
|
140
|
+
// message, in message-time order — the same dialogue-only shape the live plugin pushes.
|
|
141
|
+
function opencodeCandidates(target: ResolvedProject): Candidate[] {
|
|
142
|
+
const storage = join(homedir(), ".local", "share", "opencode", "storage");
|
|
143
|
+
const out: Candidate[] = [];
|
|
144
|
+
for (const hash of listDirs(join(storage, "session"))) {
|
|
145
|
+
for (const file of listFiles(join(storage, "session", hash), ".json")) {
|
|
146
|
+
const info = readJson(join(storage, "session", hash, file));
|
|
147
|
+
if (!info || typeof info.id !== "string" || typeof info.directory !== "string") continue;
|
|
148
|
+
const resolved = resolveProject(info.directory);
|
|
149
|
+
if (!resolved || resolved.project !== target.project) continue;
|
|
150
|
+
const sessionId = info.id;
|
|
151
|
+
out.push({
|
|
152
|
+
agent: "opencode",
|
|
153
|
+
sessionId,
|
|
154
|
+
updated: new Date(info.time?.updated ?? info.time?.created ?? 0),
|
|
155
|
+
load: async () => {
|
|
156
|
+
const msgDir = join(storage, "message", sessionId);
|
|
157
|
+
const metas = listFiles(msgDir, ".json")
|
|
158
|
+
.map((f) => readJson(join(msgDir, f)))
|
|
159
|
+
.filter((m) => m && typeof m.role === "string")
|
|
160
|
+
.sort((a, b) => (a.time?.created ?? 0) - (b.time?.created ?? 0) || String(a.id).localeCompare(String(b.id)));
|
|
161
|
+
const msgs: Msg[] = [];
|
|
162
|
+
for (const meta of metas) {
|
|
163
|
+
const partDir = join(storage, "part", meta.id);
|
|
164
|
+
const text = listFiles(partDir, ".json")
|
|
165
|
+
.sort()
|
|
166
|
+
.map((f) => readJson(join(partDir, f)))
|
|
167
|
+
.filter((p) => p && p.type === "text" && typeof p.text === "string")
|
|
168
|
+
.map((p) => p.text)
|
|
169
|
+
.join("\n")
|
|
170
|
+
.trim();
|
|
171
|
+
if (text.length === 0) continue;
|
|
172
|
+
msgs.push({ role: meta.role, content: text });
|
|
173
|
+
}
|
|
174
|
+
return msgs;
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function serverVersions(target: ResolvedProject): Promise<Map<string, number>> {
|
|
183
|
+
const res = await fetch(`${target.baseUrl}/api/sessions/${target.project}`, {
|
|
184
|
+
headers: { "X-Api-Key": target.apiKey },
|
|
185
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
186
|
+
});
|
|
187
|
+
if (!res.ok) throw new Error(`GET /api/sessions/${target.project} → ${res.status}`);
|
|
188
|
+
const body = (await res.json()) as { sessions?: { sessionId: string; version: number }[] };
|
|
189
|
+
return new Map((body.sessions ?? []).map((s) => [s.sessionId, s.version]));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function push(target: ResolvedProject, c: Candidate, msgs: Msg[]): Promise<void> {
|
|
193
|
+
const body = msgs.map((m) => JSON.stringify(m)).join("\n");
|
|
194
|
+
const uri = `${target.baseUrl}/api/sessions/${target.project}/${encodeURIComponent(c.sessionId)}?agent=${encodeURIComponent(c.agent)}`;
|
|
195
|
+
const res = await fetch(uri, {
|
|
196
|
+
method: "POST",
|
|
197
|
+
headers: { "X-Api-Key": target.apiKey, "Content-Type": "application/x-ndjson; charset=utf-8" },
|
|
198
|
+
body,
|
|
199
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
200
|
+
});
|
|
201
|
+
if (!res.ok) throw new Error(`POST ${c.sessionId} → ${res.status}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function main(): Promise<void> {
|
|
205
|
+
const args = parseArgs(process.argv.slice(2));
|
|
206
|
+
const target = resolveTarget(args.project);
|
|
207
|
+
console.log(`importing into '${target.project}' @ ${target.baseUrl} (agent: ${args.agent}${args.dryRun ? ", DRY-RUN" : ""})`);
|
|
208
|
+
|
|
209
|
+
let candidates: Candidate[] = [];
|
|
210
|
+
if (args.agent === "claude" || args.agent === "all") candidates.push(...(await claudeCandidates(target)));
|
|
211
|
+
if (args.agent === "opencode" || args.agent === "all") candidates.push(...opencodeCandidates(target));
|
|
212
|
+
candidates.sort((a, b) => a.updated.getTime() - b.updated.getTime());
|
|
213
|
+
|
|
214
|
+
const filtered = args.since ? candidates.filter((c) => c.updated >= args.since!) : candidates;
|
|
215
|
+
const skippedFiltered = candidates.length - filtered.length;
|
|
216
|
+
const capped = args.limit ? filtered.slice(0, args.limit) : filtered;
|
|
217
|
+
const skippedLimit = filtered.length - capped.length;
|
|
218
|
+
|
|
219
|
+
const versions = await serverVersions(target);
|
|
220
|
+
let pushed = 0, upToDate = 0, empty = 0, failed = 0, totalMsgs = 0;
|
|
221
|
+
for (const c of capped) {
|
|
222
|
+
try {
|
|
223
|
+
const msgs = await c.load();
|
|
224
|
+
if (msgs.length === 0) {
|
|
225
|
+
empty++;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const server = versions.get(c.sessionId) ?? 0;
|
|
229
|
+
if (!args.force && msgs.length <= server) {
|
|
230
|
+
upToDate++; // upgrade-only: never roll a fresher snapshot back
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (args.dryRun) {
|
|
234
|
+
console.log(` would push ${c.agent} ${c.sessionId}: ${msgs.length} msgs (server: ${server})`);
|
|
235
|
+
pushed++;
|
|
236
|
+
totalMsgs += msgs.length;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
await push(target, c, msgs);
|
|
240
|
+
pushed++;
|
|
241
|
+
totalMsgs += msgs.length;
|
|
242
|
+
console.log(` pushed ${c.agent} ${c.sessionId}: ${msgs.length} msgs (server was ${server})`);
|
|
243
|
+
} catch (e) {
|
|
244
|
+
failed++;
|
|
245
|
+
console.error(` FAILED ${c.sessionId}: ${e instanceof Error ? e.message : e}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
console.log(
|
|
250
|
+
`done: ${pushed} pushed (${totalMsgs} msgs), ${upToDate} up-to-date, ${empty} empty, ` +
|
|
251
|
+
`${skippedFiltered} before --since, ${skippedLimit} over --limit, ${failed} failed`,
|
|
252
|
+
);
|
|
253
|
+
if (pushed > 0 && !args.dryRun)
|
|
254
|
+
console.log("note: server pipelines (digest/facts/patterns) will backfill in the background over the next minutes.");
|
|
255
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
main().catch((e) => {
|
|
259
|
+
console.error(e instanceof Error ? e.message : e);
|
|
260
|
+
process.exit(1);
|
|
261
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PetBox plugin for opencode (GLOBAL) — the opencode port of the two Claude Code hooks:
|
|
3
|
+
*
|
|
4
|
+
* 1. pull-memory (SessionStart) → inject the PetBox memory protocol so the agent recalls
|
|
5
|
+
* relevant memory and captures learnings via the connected `petbox` MCP. Appended to the
|
|
6
|
+
* system prompt via `experimental.chat.system.transform`.
|
|
7
|
+
*
|
|
8
|
+
* 2. push-session (Stop) → mirror the session conversation into PetBox's Session module so it
|
|
9
|
+
* auto-populates. Fires on `session.idle` (opencode's "the turn finished") and pushes the
|
|
10
|
+
* INCREMENT via the server-authoritative append cursor (see append.ts) — the plugin is
|
|
11
|
+
* long-lived, so it remembers each session's lastOrdinal from the previous response in
|
|
12
|
+
* process memory (no durable state); a restart self-heals off the structured 409 gap
|
|
13
|
+
* reject, and old servers without the append route fall back to the full-snapshot push.
|
|
14
|
+
*
|
|
15
|
+
* Unlike the per-project copy this is installed once at user scope. The active project + API
|
|
16
|
+
* key + base URL are resolved from `directory` (PluginInput) via the shared registry. If the
|
|
17
|
+
* cwd is not a registered project (or the key is missing) BOTH hooks are no-ops — but the
|
|
18
|
+
* plugin still loads cleanly in every project.
|
|
19
|
+
*
|
|
20
|
+
* Both hooks are best-effort and must never break a turn (every failure is swallowed).
|
|
21
|
+
*
|
|
22
|
+
* MCP note: opencode exposes MCP tools as `<server>_<tool>`, so the petbox memory verbs are
|
|
23
|
+
* `petbox_memory_search` / `petbox_memory_remember` / `petbox_memory_get` /
|
|
24
|
+
* `petbox_memory_upsert` (the Claude `mcp__petbox__*` names do not apply here).
|
|
25
|
+
*/
|
|
26
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
27
|
+
import { resolveAgentDefinitionForSession } from "./agent-def-fetch.ts";
|
|
28
|
+
import { DEFAULT_AGENT_DEFINITION, type AgentDefinition } from "./agent-definition.ts";
|
|
29
|
+
import { pushTranscript } from "./append.ts";
|
|
30
|
+
import { fetchCanonBlock } from "./canon.ts";
|
|
31
|
+
import { buildProtocol, opencodePetboxTool } from "./protocol.ts";
|
|
32
|
+
import { resolveProject } from "./registry.ts";
|
|
33
|
+
|
|
34
|
+
export const PetboxPlugin: Plugin = async ({ client, directory }) => {
|
|
35
|
+
// Resolve the active project once at load. null → both hooks no-op.
|
|
36
|
+
const resolved = resolveProject(directory ?? "");
|
|
37
|
+
|
|
38
|
+
// Resolve the banner's orchestrator notes ONCE at plugin load — server → LKG cache → the
|
|
39
|
+
// built-in default, same order `apply` uses (resolveAgentDefinitionForSession wraps
|
|
40
|
+
// agent-def-fetch.ts's resolveAgentDefinitionWithLkg). Bounded by that helper's own ~8s
|
|
41
|
+
// fetch timeout; the plugin instance is long-lived for the opencode session, so this is a
|
|
42
|
+
// one-time load-time cost, not a per-prompt one, and never throws/blocks indefinitely.
|
|
43
|
+
let agentDefinition: AgentDefinition = DEFAULT_AGENT_DEFINITION;
|
|
44
|
+
if (resolved) {
|
|
45
|
+
const got = await resolveAgentDefinitionForSession(resolved);
|
|
46
|
+
agentDefinition = got.definition;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Avoid re-POSTing the same state when session.idle fires repeatedly.
|
|
50
|
+
const lastPushed = new Map<string, string>();
|
|
51
|
+
// Per-session server cursor (lastOrdinal from the previous response). Process memory only —
|
|
52
|
+
// a plugin restart just means the first push self-heals via the structured gap reject.
|
|
53
|
+
const cursors = new Map<string, number>();
|
|
54
|
+
|
|
55
|
+
async function pushSession(sessionID: string): Promise<void> {
|
|
56
|
+
if (!resolved || !sessionID) return;
|
|
57
|
+
|
|
58
|
+
const res = await client.session.messages({ path: { id: sessionID } });
|
|
59
|
+
const messages = res.data;
|
|
60
|
+
if (!Array.isArray(messages) || messages.length === 0) return;
|
|
61
|
+
|
|
62
|
+
// The whole conversation (user + assistant text turns), ordered — pushTranscript sends
|
|
63
|
+
// only the tail past the remembered server cursor (the increment), not the full history.
|
|
64
|
+
const msgs = messages
|
|
65
|
+
.map((m: any) => {
|
|
66
|
+
const text = m.parts
|
|
67
|
+
.filter((p: any) => p.type === "text" && typeof p.text === "string")
|
|
68
|
+
.map((p: any) => p.text)
|
|
69
|
+
.join("\n")
|
|
70
|
+
.trim();
|
|
71
|
+
return text ? { role: m.info.role, content: text } : null;
|
|
72
|
+
})
|
|
73
|
+
.filter(Boolean) as { role: string; content: string }[];
|
|
74
|
+
if (msgs.length === 0) return;
|
|
75
|
+
const lastID = messages[messages.length - 1]?.info?.id ?? "";
|
|
76
|
+
if (lastPushed.get(sessionID) === lastID) return;
|
|
77
|
+
|
|
78
|
+
const lastOrdinal = await pushTranscript(
|
|
79
|
+
{
|
|
80
|
+
baseUrl: resolved.baseUrl,
|
|
81
|
+
project: resolved.project,
|
|
82
|
+
sessionId: sessionID,
|
|
83
|
+
apiKey: resolved.apiKey,
|
|
84
|
+
agent: "opencode",
|
|
85
|
+
timeoutMs: 8000,
|
|
86
|
+
},
|
|
87
|
+
msgs,
|
|
88
|
+
cursors.get(sessionID) ?? null,
|
|
89
|
+
);
|
|
90
|
+
if (lastOrdinal !== null) {
|
|
91
|
+
cursors.set(sessionID, lastOrdinal);
|
|
92
|
+
lastPushed.set(sessionID, lastID);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// No per-prompt context injection is wired here. (The kit's prompt-RAG experiment — exact-match
|
|
97
|
+
// per-prompt pointer injection on Claude Code's UserPromptSubmit — has been removed entirely, and
|
|
98
|
+
// opencode never had a clean equivalent of that hook to port it to.)
|
|
99
|
+
return {
|
|
100
|
+
// Port of pull-memory — make the memory protocol part of the system prompt.
|
|
101
|
+
"experimental.chat.system.transform": async (_input, output) => {
|
|
102
|
+
if (!resolved) return;
|
|
103
|
+
output.system.push(
|
|
104
|
+
buildProtocol(resolved.project, opencodePetboxTool, {
|
|
105
|
+
harness: "opencode",
|
|
106
|
+
definition: agentDefinition,
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
// Append the curated memory canon when available (best-effort; degrades to nothing).
|
|
110
|
+
const canon = await fetchCanonBlock(resolved);
|
|
111
|
+
if (canon) output.system.push(canon);
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
// Port of push-session — mirror the finished turn into PetBox's Session module.
|
|
115
|
+
event: async ({ event }) => {
|
|
116
|
+
if (event.type !== "session.idle") return;
|
|
117
|
+
const sessionID = (event as any).properties?.sessionID;
|
|
118
|
+
try {
|
|
119
|
+
await pushSession(sessionID);
|
|
120
|
+
} catch {
|
|
121
|
+
/* best-effort: never break the turn */
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export default PetboxPlugin;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// The origin marker every petbox-wire `apply` render embeds into a generated file's YAML
|
|
2
|
+
// frontmatter, and the ONLY thing the write guard (apply-write.ts) trusts to recognize "this
|
|
3
|
+
// file is ours" before it ever overwrites or deletes something already on disk — no content
|
|
4
|
+
// heuristics, no filename convention, no timestamp guess.
|
|
5
|
+
//
|
|
6
|
+
// Why this exists (bug: apply-clobbers-user-agent-files): `apply` used to writeFileSync
|
|
7
|
+
// unconditionally. A user with their OWN `.claude/agents/worker.md` lost it silently on the
|
|
8
|
+
// first apply — the only trace that a file was "ours" was a `description: PetBox <tier> role
|
|
9
|
+
// (<slug>)` line INSIDE the file apply had already overwritten, which is useless after the
|
|
10
|
+
// fact. The marker line below is written BEFORE any write decision is made, so a pre-existing
|
|
11
|
+
// file can be classified accurately: ours (marker present → safe to update silently) or a
|
|
12
|
+
// real user file (marker absent → refuse, loudly, never touch it).
|
|
13
|
+
//
|
|
14
|
+
// Plain TS for native node type-stripping: zero deps.
|
|
15
|
+
|
|
16
|
+
export const PETBOX_MARKER_KEY = "petbox";
|
|
17
|
+
export const PETBOX_MARKER_VALUE = "managed";
|
|
18
|
+
/** The literal frontmatter line every renderer appends to a generated file. */
|
|
19
|
+
export const PETBOX_MARKER_LINE = `${PETBOX_MARKER_KEY}: ${PETBOX_MARKER_VALUE}`;
|
|
20
|
+
|
|
21
|
+
const MARKER_LINE_RE = new RegExp(`^${PETBOX_MARKER_KEY}:\\s*\\S+`, "m");
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* True when `content`'s YAML frontmatter (the block between the first pair of `---` lines)
|
|
25
|
+
* carries our origin marker. Frontmatter-scoped on purpose: a user's OWN file that happens to
|
|
26
|
+
* mention the word "petbox" in its BODY prose must never be mistaken for ours. A file with no
|
|
27
|
+
* frontmatter at all (no leading `---` block) never matches — it cannot be one of our renders.
|
|
28
|
+
*/
|
|
29
|
+
export function hasPetboxMarker(content: string): boolean {
|
|
30
|
+
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
31
|
+
if (!m) return false;
|
|
32
|
+
const frontmatter = m[1];
|
|
33
|
+
// The capture group is mandatory in the pattern above (no `?`), so a successful match
|
|
34
|
+
// always populates it — but a stray content string could still fail to match at all.
|
|
35
|
+
if (frontmatter === undefined) return false;
|
|
36
|
+
return MARKER_LINE_RE.test(frontmatter);
|
|
37
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Unit tests for the POSIX env-sourcing logic (persistKeyForAgentsPosix), extracted into its
|
|
2
|
+
// own module specifically so it's importable here — wire.ts itself runs main() at module top
|
|
3
|
+
// level and must never be imported by a test (see bin/petbox-wire.js's comment on that).
|
|
4
|
+
//
|
|
5
|
+
// Run: node --test src/posix-env.test.ts (Node >= 23.6 native TS type-stripping; no build step)
|
|
6
|
+
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { test } from "node:test";
|
|
12
|
+
import { persistKeyForAgentsPosix } from "./posix-env.ts";
|
|
13
|
+
|
|
14
|
+
const MARKER = "# petbox-wire";
|
|
15
|
+
|
|
16
|
+
function freshHome(): string {
|
|
17
|
+
return mkdtempSync(join(tmpdir(), "petbox-wire-test-"));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test("no pre-existing profile files: .zshenv is created and gets the marker (macOS/zsh case)", () => {
|
|
21
|
+
const home = freshHome();
|
|
22
|
+
try {
|
|
23
|
+
assert.equal(existsSync(join(home, ".profile")), false);
|
|
24
|
+
assert.equal(existsSync(join(home, ".zshenv")), false);
|
|
25
|
+
|
|
26
|
+
persistKeyForAgentsPosix(home);
|
|
27
|
+
|
|
28
|
+
const zshenvPath = join(home, ".zshenv");
|
|
29
|
+
assert.equal(existsSync(zshenvPath), true, ".zshenv must be created even with no pre-existing profile files");
|
|
30
|
+
assert.match(readFileSync(zshenvPath, "utf8"), new RegExp(MARKER));
|
|
31
|
+
} finally {
|
|
32
|
+
rmSync(home, { recursive: true, force: true });
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("pre-existing .bashrc (no .zshenv): marker still appended to .bashrc, AND .zshenv is created too", () => {
|
|
37
|
+
const home = freshHome();
|
|
38
|
+
try {
|
|
39
|
+
writeFileSync(join(home, ".bashrc"), "# my existing bashrc\n", "utf8");
|
|
40
|
+
|
|
41
|
+
persistKeyForAgentsPosix(home);
|
|
42
|
+
|
|
43
|
+
const bashrc = readFileSync(join(home, ".bashrc"), "utf8");
|
|
44
|
+
assert.match(bashrc, new RegExp(MARKER), "existing .bashrc must still get the source line (no regression)");
|
|
45
|
+
assert.ok(bashrc.includes("# my existing bashrc"), "existing .bashrc content must be preserved");
|
|
46
|
+
|
|
47
|
+
const zshenvPath = join(home, ".zshenv");
|
|
48
|
+
assert.equal(existsSync(zshenvPath), true, ".zshenv must be created even when another profile pre-existed");
|
|
49
|
+
assert.match(readFileSync(zshenvPath, "utf8"), new RegExp(MARKER));
|
|
50
|
+
} finally {
|
|
51
|
+
rmSync(home, { recursive: true, force: true });
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("pre-existing .zshenv without the marker: marker gets appended, content preserved", () => {
|
|
56
|
+
const home = freshHome();
|
|
57
|
+
try {
|
|
58
|
+
writeFileSync(join(home, ".zshenv"), "# my existing zshenv\n", "utf8");
|
|
59
|
+
|
|
60
|
+
persistKeyForAgentsPosix(home);
|
|
61
|
+
|
|
62
|
+
const zshenv = readFileSync(join(home, ".zshenv"), "utf8");
|
|
63
|
+
assert.match(zshenv, new RegExp(MARKER));
|
|
64
|
+
assert.ok(zshenv.includes("# my existing zshenv"), "existing .zshenv content must be preserved");
|
|
65
|
+
} finally {
|
|
66
|
+
rmSync(home, { recursive: true, force: true });
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("idempotent: running twice does not duplicate the marker in .zshenv or .bashrc", () => {
|
|
71
|
+
const home = freshHome();
|
|
72
|
+
try {
|
|
73
|
+
writeFileSync(join(home, ".bashrc"), "# existing\n", "utf8");
|
|
74
|
+
|
|
75
|
+
persistKeyForAgentsPosix(home);
|
|
76
|
+
persistKeyForAgentsPosix(home);
|
|
77
|
+
|
|
78
|
+
const countMarker = (s: string) => s.split(MARKER).length - 1;
|
|
79
|
+
assert.equal(countMarker(readFileSync(join(home, ".bashrc"), "utf8")), 1);
|
|
80
|
+
assert.equal(countMarker(readFileSync(join(home, ".zshenv"), "utf8")), 1);
|
|
81
|
+
} finally {
|
|
82
|
+
rmSync(home, { recursive: true, force: true });
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("writes ~/.petbox/env.sh from the key store", () => {
|
|
87
|
+
const home = freshHome();
|
|
88
|
+
try {
|
|
89
|
+
mkdirSync(join(home, ".petbox"), { recursive: true });
|
|
90
|
+
writeFileSync(join(home, ".petbox", "keys.json"), JSON.stringify({ MY_PROJECT_API_KEY: "secret" }), "utf8");
|
|
91
|
+
|
|
92
|
+
persistKeyForAgentsPosix(home);
|
|
93
|
+
|
|
94
|
+
const envSh = readFileSync(join(home, ".petbox", "env.sh"), "utf8");
|
|
95
|
+
assert.match(envSh, /export MY_PROJECT_API_KEY="secret"/);
|
|
96
|
+
} finally {
|
|
97
|
+
rmSync(home, { recursive: true, force: true });
|
|
98
|
+
}
|
|
99
|
+
});
|
package/src/posix-env.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// POSIX half of persistKeyForAgents (see wire.ts) — split into its own side-effect-free module
|
|
2
|
+
// (no top-level main()) so it can be imported directly by tests without triggering the CLI.
|
|
3
|
+
// wire.ts is NOT import-safe: it runs main() at module top level (see bin/petbox-wire.js), so a
|
|
4
|
+
// test must not `import` it — this file exists to give the logic a testable home.
|
|
5
|
+
|
|
6
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
function readJson(path: string): any {
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Regenerate <homeDir>/.petbox/env.sh from the whole key store and ensure a login shell will
|
|
18
|
+
// source it. Parameterized on homeDir (instead of reading os.homedir() directly) so tests can
|
|
19
|
+
// point it at a throwaway tmp dir rather than touching the real $HOME. Returns the path written,
|
|
20
|
+
// for the caller's log message.
|
|
21
|
+
export function persistKeyForAgentsPosix(homeDir: string): string {
|
|
22
|
+
const store = readJson(join(homeDir, ".petbox", "keys.json")) ?? {};
|
|
23
|
+
const lines = Object.entries(store)
|
|
24
|
+
.filter(([, v]) => typeof v === "string")
|
|
25
|
+
.map(([k, v]) => `export ${k}=${JSON.stringify(v)}`);
|
|
26
|
+
const envShPath = join(homeDir, ".petbox", "env.sh");
|
|
27
|
+
// In production ~/.petbox/ already exists by this point (writeKeyToStore runs first in
|
|
28
|
+
// main()'s step 4), but mkdirSync recursive is a harmless no-op then — and it makes this
|
|
29
|
+
// module safe to call standalone (e.g. from tests against a bare tmp dir).
|
|
30
|
+
mkdirSync(join(homeDir, ".petbox"), { recursive: true });
|
|
31
|
+
writeFileSync(envShPath, "# Generated by petbox-wire from ~/.petbox/keys.json — do not edit.\n" + lines.join("\n") + "\n", "utf8");
|
|
32
|
+
try {
|
|
33
|
+
chmodSync(envShPath, 0o600);
|
|
34
|
+
} catch {
|
|
35
|
+
/* best-effort */
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const marker = "# petbox-wire";
|
|
39
|
+
const sourceLine = `[ -f "$HOME/.petbox/env.sh" ] && . "$HOME/.petbox/env.sh" ${marker}`;
|
|
40
|
+
const profilePath = join(homeDir, ".profile");
|
|
41
|
+
const bashrcPath = join(homeDir, ".bashrc");
|
|
42
|
+
const zshenvPath = join(homeDir, ".zshenv");
|
|
43
|
+
const profiles = [profilePath, bashrcPath, zshenvPath];
|
|
44
|
+
let sourced = false;
|
|
45
|
+
for (const p of profiles) {
|
|
46
|
+
if (!existsSync(p)) continue;
|
|
47
|
+
const content = readFileSync(p, "utf8");
|
|
48
|
+
if (!content.includes(marker)) appendFileSync(p, `\n${sourceLine}\n`, "utf8");
|
|
49
|
+
sourced = true;
|
|
50
|
+
}
|
|
51
|
+
if (!sourced) writeFileSync(profilePath, sourceLine + "\n", "utf8");
|
|
52
|
+
|
|
53
|
+
// zsh always sources ~/.zshenv for EVERY shell (login or not, interactive or not) — unlike
|
|
54
|
+
// .profile/.bashrc, which bash only sources under specific conditions. macOS ships zsh as the
|
|
55
|
+
// default shell and a fresh account typically has none of the three files, so the fallback
|
|
56
|
+
// above (which only ever creates .profile) would silently leave zsh unwired. Guarantee the
|
|
57
|
+
// marker lands in .zshenv too, independent of whichever profile the loop above touched.
|
|
58
|
+
if (!existsSync(zshenvPath)) writeFileSync(zshenvPath, sourceLine + "\n", "utf8");
|
|
59
|
+
|
|
60
|
+
return envShPath;
|
|
61
|
+
}
|