petbox-wire 0.1.0-ci.543

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,256 @@
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") a.project = argv[++i];
51
+ else if (arg === "--dry-run") a.dryRun = true;
52
+ else if (arg === "--force") a.force = true;
53
+ else if (arg === "--since") a.since = new Date(argv[++i] ?? "");
54
+ else if (arg === "--limit") a.limit = parseInt(argv[++i] ?? "0", 10) || undefined;
55
+ else throw new Error(`unknown argument: ${arg}`);
56
+ }
57
+ if (!["claude", "opencode", "all"].includes(a.agent)) throw new Error(`invalid --agent: ${a.agent}`);
58
+ if (a.since && isNaN(a.since.getTime())) throw new Error("invalid --since (use YYYY-MM-DD)");
59
+ return a;
60
+ }
61
+
62
+ // The target: --project finds the registry entry by project key; otherwise the cwd
63
+ // resolves like the hooks do.
64
+ function resolveTarget(projectKey?: string): ResolvedProject {
65
+ if (projectKey) {
66
+ const entry = readRegistry().find((e) => e.project === projectKey);
67
+ if (!entry) throw new Error(`project '${projectKey}' not found in ~/.petbox/projects.json`);
68
+ const apiKey = process.env[entry.envVar];
69
+ if (!apiKey) throw new Error(`env var ${entry.envVar} is empty`);
70
+ return {
71
+ project: entry.project,
72
+ apiKey,
73
+ baseUrl: (entry.baseUrl ?? "https://petbox.3po.su").replace(/\/+$/, ""),
74
+ envVar: entry.envVar,
75
+ };
76
+ }
77
+ const resolved = resolveProject(process.cwd());
78
+ if (!resolved) throw new Error("cwd is not a registered project (or its env var is empty); use --project");
79
+ return resolved;
80
+ }
81
+
82
+ function listDirs(path: string): string[] {
83
+ try {
84
+ return readdirSync(path, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
85
+ } catch {
86
+ return [];
87
+ }
88
+ }
89
+
90
+ function listFiles(path: string, ext: string): string[] {
91
+ try {
92
+ return readdirSync(path).filter((f) => f.endsWith(ext));
93
+ } catch {
94
+ return [];
95
+ }
96
+ }
97
+
98
+ function readJson(path: string): any | null {
99
+ try {
100
+ return JSON.parse(readFileSync(path, "utf8"));
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ // Claude Code: ~/.claude/projects/<encoded-cwd>/*.jsonl. The encoding is lossy, so a
107
+ // transcript is attributed by the cwd recorded INSIDE it, resolved through the same
108
+ // registry matching the hooks use (worktrees and subfolders included).
109
+ async function claudeCandidates(target: ResolvedProject): Promise<Candidate[]> {
110
+ const root = join(homedir(), ".claude", "projects");
111
+ const out: Candidate[] = [];
112
+ for (const dir of listDirs(root)) {
113
+ for (const file of listFiles(join(root, dir), ".jsonl")) {
114
+ const path = join(root, dir, file);
115
+ const cwd = await readTranscriptCwd(path);
116
+ if (!cwd) continue;
117
+ const resolved = resolveProject(cwd);
118
+ if (!resolved || resolved.project !== target.project) continue;
119
+ out.push({
120
+ agent: "claude-code",
121
+ sessionId: basename(file, ".jsonl"),
122
+ updated: statSync(path).mtime,
123
+ load: () => buildMessages(path),
124
+ });
125
+ }
126
+ }
127
+ return out;
128
+ }
129
+
130
+ // opencode: ~/.local/share/opencode/storage —
131
+ // session/<projectHash>/<ses>.json {id, directory, time.updated}
132
+ // message/<ses>/<msg>.json {id, role, time.created}
133
+ // part/<msg>/<prt>.json {type:"text", text}
134
+ // A session is attributed by its recorded `directory`; content = the text parts of each
135
+ // message, in message-time order — the same dialogue-only shape the live plugin pushes.
136
+ function opencodeCandidates(target: ResolvedProject): Candidate[] {
137
+ const storage = join(homedir(), ".local", "share", "opencode", "storage");
138
+ const out: Candidate[] = [];
139
+ for (const hash of listDirs(join(storage, "session"))) {
140
+ for (const file of listFiles(join(storage, "session", hash), ".json")) {
141
+ const info = readJson(join(storage, "session", hash, file));
142
+ if (!info || typeof info.id !== "string" || typeof info.directory !== "string") continue;
143
+ const resolved = resolveProject(info.directory);
144
+ if (!resolved || resolved.project !== target.project) continue;
145
+ const sessionId = info.id;
146
+ out.push({
147
+ agent: "opencode",
148
+ sessionId,
149
+ updated: new Date(info.time?.updated ?? info.time?.created ?? 0),
150
+ load: async () => {
151
+ const msgDir = join(storage, "message", sessionId);
152
+ const metas = listFiles(msgDir, ".json")
153
+ .map((f) => readJson(join(msgDir, f)))
154
+ .filter((m) => m && typeof m.role === "string")
155
+ .sort((a, b) => (a.time?.created ?? 0) - (b.time?.created ?? 0) || String(a.id).localeCompare(String(b.id)));
156
+ const msgs: Msg[] = [];
157
+ for (const meta of metas) {
158
+ const partDir = join(storage, "part", meta.id);
159
+ const text = listFiles(partDir, ".json")
160
+ .sort()
161
+ .map((f) => readJson(join(partDir, f)))
162
+ .filter((p) => p && p.type === "text" && typeof p.text === "string")
163
+ .map((p) => p.text)
164
+ .join("\n")
165
+ .trim();
166
+ if (text.length === 0) continue;
167
+ msgs.push({ role: meta.role, content: text });
168
+ }
169
+ return msgs;
170
+ },
171
+ });
172
+ }
173
+ }
174
+ return out;
175
+ }
176
+
177
+ async function serverVersions(target: ResolvedProject): Promise<Map<string, number>> {
178
+ const res = await fetch(`${target.baseUrl}/api/sessions/${target.project}`, {
179
+ headers: { "X-Api-Key": target.apiKey },
180
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
181
+ });
182
+ if (!res.ok) throw new Error(`GET /api/sessions/${target.project} → ${res.status}`);
183
+ const body = (await res.json()) as { sessions?: { sessionId: string; version: number }[] };
184
+ return new Map((body.sessions ?? []).map((s) => [s.sessionId, s.version]));
185
+ }
186
+
187
+ async function push(target: ResolvedProject, c: Candidate, msgs: Msg[]): Promise<void> {
188
+ const body = msgs.map((m) => JSON.stringify(m)).join("\n");
189
+ const uri = `${target.baseUrl}/api/sessions/${target.project}/${encodeURIComponent(c.sessionId)}?agent=${encodeURIComponent(c.agent)}`;
190
+ const res = await fetch(uri, {
191
+ method: "POST",
192
+ headers: { "X-Api-Key": target.apiKey, "Content-Type": "application/x-ndjson; charset=utf-8" },
193
+ body,
194
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
195
+ });
196
+ if (!res.ok) throw new Error(`POST ${c.sessionId} → ${res.status}`);
197
+ }
198
+
199
+ async function main(): Promise<void> {
200
+ const args = parseArgs(process.argv.slice(2));
201
+ const target = resolveTarget(args.project);
202
+ console.log(`importing into '${target.project}' @ ${target.baseUrl} (agent: ${args.agent}${args.dryRun ? ", DRY-RUN" : ""})`);
203
+
204
+ let candidates: Candidate[] = [];
205
+ if (args.agent === "claude" || args.agent === "all") candidates.push(...(await claudeCandidates(target)));
206
+ if (args.agent === "opencode" || args.agent === "all") candidates.push(...opencodeCandidates(target));
207
+ candidates.sort((a, b) => a.updated.getTime() - b.updated.getTime());
208
+
209
+ const filtered = args.since ? candidates.filter((c) => c.updated >= args.since!) : candidates;
210
+ const skippedFiltered = candidates.length - filtered.length;
211
+ const capped = args.limit ? filtered.slice(0, args.limit) : filtered;
212
+ const skippedLimit = filtered.length - capped.length;
213
+
214
+ const versions = await serverVersions(target);
215
+ let pushed = 0, upToDate = 0, empty = 0, failed = 0, totalMsgs = 0;
216
+ for (const c of capped) {
217
+ try {
218
+ const msgs = await c.load();
219
+ if (msgs.length === 0) {
220
+ empty++;
221
+ continue;
222
+ }
223
+ const server = versions.get(c.sessionId) ?? 0;
224
+ if (!args.force && msgs.length <= server) {
225
+ upToDate++; // upgrade-only: never roll a fresher snapshot back
226
+ continue;
227
+ }
228
+ if (args.dryRun) {
229
+ console.log(` would push ${c.agent} ${c.sessionId}: ${msgs.length} msgs (server: ${server})`);
230
+ pushed++;
231
+ totalMsgs += msgs.length;
232
+ continue;
233
+ }
234
+ await push(target, c, msgs);
235
+ pushed++;
236
+ totalMsgs += msgs.length;
237
+ console.log(` pushed ${c.agent} ${c.sessionId}: ${msgs.length} msgs (server was ${server})`);
238
+ } catch (e) {
239
+ failed++;
240
+ console.error(` FAILED ${c.sessionId}: ${e instanceof Error ? e.message : e}`);
241
+ }
242
+ }
243
+
244
+ console.log(
245
+ `done: ${pushed} pushed (${totalMsgs} msgs), ${upToDate} up-to-date, ${empty} empty, ` +
246
+ `${skippedFiltered} before --since, ${skippedLimit} over --limit, ${failed} failed`,
247
+ );
248
+ if (pushed > 0 && !args.dryRun)
249
+ console.log("note: server pipelines (digest/facts/patterns) will backfill in the background over the next minutes.");
250
+ process.exit(failed > 0 ? 1 : 0);
251
+ }
252
+
253
+ main().catch((e) => {
254
+ console.error(e instanceof Error ? e.message : e);
255
+ process.exit(1);
256
+ });
@@ -0,0 +1,106 @@
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 { pushTranscript } from "./append.ts";
28
+ import { fetchCanonBlock } from "./canon.ts";
29
+ import { buildProtocol, opencodePetboxTool } from "./protocol.ts";
30
+ import { resolveProject } from "./registry.ts";
31
+
32
+ export const PetboxPlugin: Plugin = async ({ client, directory }) => {
33
+ // Resolve the active project once at load. null → both hooks no-op.
34
+ const resolved = resolveProject(directory ?? "");
35
+
36
+ // Avoid re-POSTing the same state when session.idle fires repeatedly.
37
+ const lastPushed = new Map<string, string>();
38
+ // Per-session server cursor (lastOrdinal from the previous response). Process memory only —
39
+ // a plugin restart just means the first push self-heals via the structured gap reject.
40
+ const cursors = new Map<string, number>();
41
+
42
+ async function pushSession(sessionID: string): Promise<void> {
43
+ if (!resolved || !sessionID) return;
44
+
45
+ const res = await client.session.messages({ path: { id: sessionID } });
46
+ const messages = res.data;
47
+ if (!Array.isArray(messages) || messages.length === 0) return;
48
+
49
+ // The whole conversation (user + assistant text turns), ordered — pushTranscript sends
50
+ // only the tail past the remembered server cursor (the increment), not the full history.
51
+ const msgs = messages
52
+ .map((m: any) => {
53
+ const text = m.parts
54
+ .filter((p: any) => p.type === "text" && typeof p.text === "string")
55
+ .map((p: any) => p.text)
56
+ .join("\n")
57
+ .trim();
58
+ return text ? { role: m.info.role, content: text } : null;
59
+ })
60
+ .filter(Boolean) as { role: string; content: string }[];
61
+ if (msgs.length === 0) return;
62
+ const lastID = messages[messages.length - 1]?.info?.id ?? "";
63
+ if (lastPushed.get(sessionID) === lastID) return;
64
+
65
+ const lastOrdinal = await pushTranscript(
66
+ {
67
+ baseUrl: resolved.baseUrl,
68
+ project: resolved.project,
69
+ sessionId: sessionID,
70
+ apiKey: resolved.apiKey,
71
+ agent: "opencode",
72
+ timeoutMs: 8000,
73
+ },
74
+ msgs,
75
+ cursors.get(sessionID) ?? null,
76
+ );
77
+ if (lastOrdinal !== null) {
78
+ cursors.set(sessionID, lastOrdinal);
79
+ lastPushed.set(sessionID, lastID);
80
+ }
81
+ }
82
+
83
+ return {
84
+ // Port of pull-memory — make the memory protocol part of the system prompt.
85
+ "experimental.chat.system.transform": async (_input, output) => {
86
+ if (!resolved) return;
87
+ output.system.push(buildProtocol(resolved.project, opencodePetboxTool));
88
+ // Append the curated memory canon when available (best-effort; degrades to nothing).
89
+ const canon = await fetchCanonBlock(resolved);
90
+ if (canon) output.system.push(canon);
91
+ },
92
+
93
+ // Port of push-session — mirror the finished turn into PetBox's Session module.
94
+ event: async ({ event }) => {
95
+ if (event.type !== "session.idle") return;
96
+ const sessionID = (event as any).properties?.sessionID;
97
+ try {
98
+ await pushSession(sessionID);
99
+ } catch {
100
+ /* best-effort: never break the turn */
101
+ }
102
+ },
103
+ };
104
+ };
105
+
106
+ export default PetboxPlugin;
@@ -0,0 +1,72 @@
1
+ // Shared PetBox memory-protocol builder — the ONE implementation every SessionStart injector
2
+ // renders from (pull-memory.ts for Claude Code, opencode-plugin.ts for opencode,
3
+ // droid-pull-memory.ts for Factory Droid), so the protocol text can no longer drift between
4
+ // agents as hand-synced copies (spec: agent-wiring, wiring-single-source).
5
+ //
6
+ // The ONLY thing that varies per agent is how an MCP tool is named: pass a `tool` mapper that
7
+ // turns a bare verb (e.g. "memory_search") into that agent's fully-qualified tool name. Claude
8
+ // Code and Droid both expose petbox tools as `mcp__petbox__<verb>`; opencode as `petbox_<verb>`.
9
+ //
10
+ // The CC-only "resume/compact" suffix is opt-in via `opts.source` (Droid's SessionStart payload
11
+ // also carries a `source`, so it shares the same behavior). Any other source = no suffix.
12
+ //
13
+ // Plain TS for native node type-stripping: no enum/namespace/parameter-properties, zero deps.
14
+
15
+ export type ToolNamer = (verb: string) => string;
16
+
17
+ export type ProtocolOpts = {
18
+ // The SessionStart `source` (Claude Code / Droid). "resume" | "compact" append a recall nudge;
19
+ // anything else (or omitted) renders the base protocol only.
20
+ source?: string;
21
+ };
22
+
23
+ // Build the memory-protocol block for a project. `tool` maps a bare verb to the agent's
24
+ // fully-qualified MCP tool name so the SAME text renders correct tool names everywhere.
25
+ export function buildProtocol(project: string, tool: ToolNamer, opts?: ProtocolOpts): string {
26
+ const memorySearch = tool("memory_search");
27
+ const memoryGet = tool("memory_get");
28
+ const sessionSearch = tool("session_search");
29
+ const sessionGet = tool("session_get");
30
+ const memoryRemember = tool("memory_remember");
31
+ const memoryUpsert = tool("memory_upsert");
32
+ const tasksUpsert = tool("tasks_upsert");
33
+
34
+ let out = `## PetBox memory
35
+
36
+ This project is wired to PetBox (project \`${project}\`) over the connected \`petbox\` MCP.
37
+
38
+ In your FIRST response this session, open with exactly this line (so it's visible the protocol is active):
39
+ \`🧠 PetBox memory active\`
40
+
41
+ PetBox remembers a LOT about this project — the curated facts AND the full session history (imported + auto-pushed). Start reasoning about anything past from a SEARCH, not from assumption.
42
+
43
+ **Rule — search before you (re)work:** before re-deriving, re-investigating, or re-deciding ANYTHING about this project's past, run \`${memorySearch}\` FIRST — redoing work the project already remembers is the failure mode this protocol exists to prevent. And before you store a new fact, \`${memorySearch}\` for an existing one and edit that instead (duplicates poison recall).
44
+
45
+ The entry point has two legs:
46
+
47
+ - **Facts — \`${memorySearch}\`**: a \`q\` of a few words you are confident appear (tokens are ANDed, prefix-matched; wordforms stem), pass \`bodyLen\` (e.g. 240) for cheap snippets. With no \`scope\` it cascades project ⊕ workspace and EVERY store — curated notes and the machine-distilled \`autocaptured\` quarantine alike (the store label in each hit tells you which). Without \`q\` it's a plain listing (freshest first). Pull a full body with \`${memoryGet}\`.
48
+ - **Past conversations — \`${sessionSearch}\`**: when you need HOW something was decided, an error text, or any detail a fact wouldn't carry — two-stage search over the whole session archive; every hit carries the message ordinal, so \`${sessionGet}\` jumps to the verbatim source.
49
+
50
+ As you work, **capture** incrementally (don't wait for session end): after a decision, a fixed bug, a discovered pattern, or a stated preference, store a concise fact via \`${memoryRemember}\` (\`text\` = the learning; \`type\` = User|Feedback|Project|Reference; \`scope\` = workspace for facts that span projects or are about the user, else omit for this project). Curated/temporal edits go through \`${memoryUpsert}\`.
51
+
52
+ **Background autocapture is LIVE:** after a session settles (~minutes), the server distills durable facts and recurring behavior patterns from it into the \`autocaptured\` store on its own. So: (1) don't re-store what memory_search already shows as autocaptured — promote-worthy entries are the owner's call; (2) the **end-of-session sweep** is now an INSURANCE pass, not the only capture: before you stop, store the 1-3 learnings that must not wait for background distillation (a key decision + why, a root cause, a gotcha) — and also record 0-2 process-friction observations (what got in the way, what you had to work around, what looked stale) — skip narration and anything derivable from code/git.
53
+
54
+ **Process defects are findings, not obstacles:** never silently work around a process/doc defect or a contradiction between a document and reality — file an intake issue on the project's \`intake\` board (\`${tasksUpsert}\` type:"issue" status:"reported") instead of swallowing it. Criticism of the process is explicitly welcome and is never scope creep.`;
55
+
56
+ const source = opts?.source;
57
+ if (source === "resume" || source === "compact") {
58
+ out += `\n\nSession ${source} — also recall recent session/decision memories to pick up where you left off.`;
59
+ }
60
+ return out;
61
+ }
62
+
63
+ // The Claude-Code / Droid tool namer: petbox tools are exposed as `mcp__petbox__<verb>`.
64
+ export const mcpPetboxTool: ToolNamer = (verb) => `mcp__petbox__${verb}`;
65
+
66
+ // The opencode tool namer: petbox tools are exposed as `petbox_<verb>`.
67
+ export const opencodePetboxTool: ToolNamer = (verb) => `petbox_${verb}`;
68
+
69
+ // Factory Droid exposes MCP tools as `<server>___<tool>` (triple underscore) — observed
70
+ // live in exec mode (session 7be9f6c2: `mcp__petbox__*` answers "not permitted in exec
71
+ // mode"); the docs' `mcp__<server>__<tool>` form did not match the shipped CLI.
72
+ export const droidPetboxTool: ToolNamer = (verb) => `petbox___${verb}`;
@@ -0,0 +1,51 @@
1
+ // Claude Code SessionStart hook (global) — port of pull-memory.ps1.
2
+ //
3
+ // Injects the PetBox memory protocol so the agent recalls relevant memory at session start
4
+ // and captures learnings as it works, via the already-connected petbox MCP (native memory.*
5
+ // tools). Stdout is added to the session context by Claude Code.
6
+ //
7
+ // The project is resolved from cwd via the shared registry; if the cwd is not a registered
8
+ // project this prints nothing and exits 0. Best-effort, never blocks — always exit 0.
9
+
10
+ import { fetchCanonBlock } from "./canon.ts";
11
+ import { buildProtocol, mcpPetboxTool } from "./protocol.ts";
12
+ import { resolveProject } from "./registry.ts";
13
+
14
+ type HookInput = { cwd?: string; source?: string };
15
+
16
+ function readStdin(): Promise<string> {
17
+ return new Promise((resolve) => {
18
+ let buf = "";
19
+ process.stdin.setEncoding("utf8");
20
+ process.stdin.on("data", (c) => (buf += c));
21
+ process.stdin.on("end", () => resolve(buf));
22
+ process.stdin.on("error", () => resolve(buf));
23
+ });
24
+ }
25
+
26
+ async function main(): Promise<void> {
27
+ let source = "startup";
28
+ let cwd = "";
29
+ try {
30
+ const raw = await readStdin();
31
+ const j: HookInput = JSON.parse(raw);
32
+ if (typeof j.source === "string" && j.source.trim()) source = j.source.trim();
33
+ if (typeof j.cwd === "string") cwd = j.cwd;
34
+ } catch {
35
+ // fall through with defaults; cwd stays empty → resolves to null below
36
+ }
37
+
38
+ try {
39
+ const resolved = resolveProject(cwd);
40
+ if (!resolved) return; // not a registered project → no output
41
+ let out = buildProtocol(resolved.project, mcpPetboxTool, { source });
42
+ // Append the curated memory canon when available (best-effort; degrades to nothing).
43
+ const canon = await fetchCanonBlock(resolved);
44
+ if (canon) out += `\n\n${canon}`;
45
+ process.stdout.write(out);
46
+ } catch {
47
+ // best-effort
48
+ }
49
+ }
50
+
51
+ main().finally(() => process.exit(0));
@@ -0,0 +1,84 @@
1
+ // Claude Code Stop hook (global) — port of push-session.ps1.
2
+ //
3
+ // Mirrors the session conversation into PetBox's Session module so the board auto-populates.
4
+ // The project + API key are resolved from cwd via the shared registry; if the cwd is not a
5
+ // registered project this exits immediately (first guard, before any work).
6
+ //
7
+ // Reads the full transcript JSONL, extracts the user/assistant text turns (tool dumps and
8
+ // system reminders excluded), and pushes only the INCREMENT via the server-authoritative
9
+ // append cursor (see append.ts): this process is fresh each turn, so it optimistically
10
+ // resends a small idempotent overlap window; a contiguity gap comes back as a structured
11
+ // 409 with the server's lastOrdinal and the tail is resent from there. Old servers without
12
+ // the append route fall back to the legacy full-snapshot push. Best-effort: every failure
13
+ // is swallowed and we ALWAYS exit 0 — never break the user's session.
14
+
15
+ import { pushTranscript } from "./append.ts";
16
+ import { resolveProject } from "./registry.ts";
17
+ import { buildMessages, type Msg } from "./transcript.ts";
18
+
19
+ const FETCH_TIMEOUT_MS = 12000;
20
+
21
+ type HookInput = {
22
+ session_id?: string;
23
+ transcript_path?: string;
24
+ cwd?: string;
25
+ stop_hook_active?: boolean;
26
+ };
27
+
28
+ function readStdin(): Promise<string> {
29
+ return new Promise((resolve) => {
30
+ let buf = "";
31
+ process.stdin.setEncoding("utf8");
32
+ process.stdin.on("data", (c) => (buf += c));
33
+ process.stdin.on("end", () => resolve(buf));
34
+ process.stdin.on("error", () => resolve(buf));
35
+ });
36
+ }
37
+
38
+ async function main(): Promise<void> {
39
+ try {
40
+ const raw = await readStdin();
41
+ let j: HookInput;
42
+ try {
43
+ j = JSON.parse(raw);
44
+ } catch {
45
+ return;
46
+ }
47
+ if (j.stop_hook_active) return;
48
+
49
+ // FIRST guard: not a registered project → silent no-op.
50
+ const resolved = resolveProject(j.cwd ?? "");
51
+ if (!resolved) return;
52
+
53
+ const sid = (j.session_id ?? "").trim();
54
+ const tp = (j.transcript_path ?? "").trim();
55
+ if (!sid || !tp) return;
56
+
57
+ let msgs: Msg[];
58
+ try {
59
+ msgs = await buildMessages(tp);
60
+ } catch {
61
+ return; // transcript missing/unreadable
62
+ }
63
+ if (msgs.length === 0) return; // empty body → server returns 400, don't push
64
+
65
+ // Fresh process each turn → no remembered cursor (null): pushTranscript guesses an
66
+ // idempotent overlap window and self-heals off the server's structured gap reject.
67
+ await pushTranscript(
68
+ {
69
+ baseUrl: resolved.baseUrl,
70
+ project: resolved.project,
71
+ sessionId: sid,
72
+ apiKey: resolved.apiKey,
73
+ agent: "claude-code",
74
+ timeoutMs: FETCH_TIMEOUT_MS,
75
+ },
76
+ msgs,
77
+ null,
78
+ );
79
+ } catch {
80
+ // best-effort: never break the user's session
81
+ }
82
+ }
83
+
84
+ main().finally(() => process.exit(0));