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.
package/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # petbox-wire
2
+
3
+ Wire any project to [PetBox](https://petbox.3po.su) for **Claude Code**, **opencode** and
4
+ **Factory Droid** — with one command, no repo clone:
5
+
6
+ ```bash
7
+ npx petbox-wire <dir> <project> --key <KEY>
8
+ ```
9
+
10
+ - `<dir>` — the project directory to wire (its cwd prefix is registered).
11
+ - `<project>` — the PetBox project key the directory maps to.
12
+ - `--key <KEY>` — the project's API key. Mint one from a Claude session on the `$system`
13
+ project (`mcp__petbox__apikey_create`); key minting is out of scope for this tool.
14
+ - `--env <VAR>` — override the env-var name (default `<PROJECT>_API_KEY`, uppercased).
15
+ - `--workspace <WS>` — workspace for the SKILL.md permalink (default `stdray`).
16
+ - `--cleanup-legacy` — remove a project's old per-project hook/plugin copies.
17
+
18
+ ## Requirements
19
+
20
+ **Node >= 23.6** — the kit is plain TypeScript run through Node's native type-stripping
21
+ (no build, zero runtime dependencies). Older Node exits with a clear version error.
22
+
23
+ ## What it installs
24
+
25
+ - **Global hooks** for Claude Code (`~/.claude/settings.json`) and Factory Droid
26
+ (`~/.factory/settings.json`): a `Stop` hook that mirrors each session into PetBox and a
27
+ `SessionStart` hook that injects the memory protocol + canon. Merged, never clobbered.
28
+ - **A global opencode plugin** (`~/.config/opencode/plugins/petbox.ts`) with the same two
29
+ behaviors.
30
+ - **Per-project config** in `<dir>`: `.mcp.json` (Claude Code MCP), `.opencode/opencode.json`
31
+ (opencode MCP), `.factory/mcp.json` (Droid MCP, merged), and the rendered `SKILL.md` under
32
+ `.claude/skills/petbox/` and `.factory/skills/petbox/`.
33
+
34
+ ## Where things live
35
+
36
+ - **Stable kit copy:** `~/.petbox/wire/` — every run copies the kit here, and all global hooks
37
+ point at this stable location (so they survive `npx` cache eviction).
38
+ - **Registry:** `~/.petbox/projects.json` — maps a filesystem prefix → project + env var. The
39
+ hooks and opencode plugin resolve the active project by cwd against it.
40
+ - **Key store:** `~/.petbox/keys.json` — `{ "<ENV_VAR>": "<key>" }` (POSIX: `chmod 0600`). The
41
+ kit hooks read `process.env[<ENV_VAR>]` first, then this file. Header values in the committed
42
+ configs stay as `${VAR}` references — no secret is written into any project file.
43
+ - **Environment variable:** the per-project MCP configs resolve `${<ENV_VAR>}` from the real
44
+ environment, so wire also persists it — Windows: user-scope env; POSIX: `~/.petbox/env.sh`
45
+ (generated from the key store) sourced from your login profiles. Start a **new terminal**
46
+ before launching agents after the first wiring.
47
+
48
+ ## Updating the kit
49
+
50
+ Re-run `npx petbox-wire@latest <dir> <project> --key <KEY>` to refresh the stable copy in
51
+ `~/.petbox/wire/` and the generated config. Runs are idempotent.
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+ // Thin launcher for the PetBox agent-wiring kit.
3
+ //
4
+ // Plain JS (no TypeScript) so it still starts on an old Node and can print a clear
5
+ // version error. The kit itself is plain TypeScript executed by Node's native
6
+ // type-stripping, which needs Node >= 23.6.
7
+
8
+ const [maj, min] = process.versions.node.split(".").map((n) => parseInt(n, 10));
9
+ if (maj < 23 || (maj === 23 && min < 6)) {
10
+ console.error(
11
+ `petbox-wire needs Node >= 23.6 (native TypeScript type-stripping); you have ${process.versions.node}`,
12
+ );
13
+ process.exit(1);
14
+ }
15
+
16
+ // wire.ts runs main() at module top level, so importing it executes the CLI.
17
+ await import("../src/wire.ts");
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "petbox-wire",
3
+ "version": "0.1.0-ci.543",
4
+ "description": "Wire any project to PetBox (Claude Code, opencode, Factory Droid) — global hooks, MCP configs and skills — with a single npx command, no repo clone.",
5
+ "type": "module",
6
+ "bin": {
7
+ "petbox-wire": "bin/petbox-wire.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=23.6.0"
16
+ },
17
+ "keywords": [
18
+ "petbox",
19
+ "wiring",
20
+ "claude-code",
21
+ "opencode",
22
+ "factory-droid",
23
+ "mcp"
24
+ ],
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/stdray/petbox.git",
29
+ "directory": "src/clients-ts/petbox-wire"
30
+ },
31
+ "publishConfig": {
32
+ "registry": "https://registry.npmjs.org",
33
+ "access": "public"
34
+ }
35
+ }
package/src/append.ts ADDED
@@ -0,0 +1,123 @@
1
+ // Shared incremental session push — the ONE append-flow implementation both Stop hooks
2
+ // (push-session.ts for Claude Code, opencode-plugin.ts for opencode) use, so the wire
3
+ // protocol cannot drift between agents (spec: session-append-wire, wiring-single-source).
4
+ //
5
+ // The server owns the cursor: a session's lastOrdinal is its stored message count. The
6
+ // client sends only a tail batch tagged with `fromOrdinal` (the ordinal of the batch's
7
+ // first message):
8
+ // - contiguous / overlapping → 200 { lastOrdinal, appended } (overlap is idempotent —
9
+ // ordinals the server already holds are ignored, so guessing "a little too early"
10
+ // is always safe);
11
+ // - gap → 409 { error: "gap", lastOrdinal } → self-heal: resend from lastOrdinal+1.
12
+ //
13
+ // The client keeps NO durable state. A long-lived host (the opencode plugin) passes the
14
+ // lastOrdinal remembered from the previous response; a per-invocation host (the Claude
15
+ // Code Stop hook process is fresh each turn) passes null and we optimistically resend a
16
+ // small overlap window from the end of the local transcript — one round-trip in the
17
+ // steady state, two after a restart/outage (the 409 tells us where to resume).
18
+ //
19
+ // Fallback: an old server without the append route 404s → push the full transcript to the
20
+ // legacy last-write-wins endpoint, exactly what the hooks did before.
21
+ //
22
+ // Plain TS for native node type-stripping: zero deps.
23
+
24
+ import type { Msg } from "./transcript.ts";
25
+
26
+ // How many trailing messages to optimistically resend when the server cursor is unknown.
27
+ // A turn typically adds 2-4 messages; overlap is idempotent, so oversizing only costs bytes.
28
+ const OVERLAP_WINDOW = 8;
29
+
30
+ const MAX_APPEND_ATTEMPTS = 3;
31
+
32
+ export type PushTarget = {
33
+ baseUrl: string;
34
+ project: string;
35
+ sessionId: string;
36
+ apiKey: string;
37
+ agent: string;
38
+ timeoutMs: number;
39
+ };
40
+
41
+ function ndjson(msgs: readonly Msg[]): string {
42
+ return msgs.map((m) => JSON.stringify(m)).join("\n");
43
+ }
44
+
45
+ async function post(url: string, apiKey: string, body: string, timeoutMs: number): Promise<Response> {
46
+ const ctrl = new AbortController();
47
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
48
+ try {
49
+ return await fetch(url, {
50
+ method: "POST",
51
+ headers: { "X-Api-Key": apiKey, "Content-Type": "application/x-ndjson; charset=utf-8" },
52
+ body,
53
+ signal: ctrl.signal,
54
+ });
55
+ } finally {
56
+ clearTimeout(timer);
57
+ }
58
+ }
59
+
60
+ // Push the (full, ordered) local transcript incrementally. `knownLastOrdinal` is the cursor
61
+ // remembered from a previous response in THIS process, or null when unknown. Returns the
62
+ // server's lastOrdinal after the push (feed it back next call), or null when every path
63
+ // failed — callers are best-effort and must swallow that.
64
+ export async function pushTranscript(
65
+ t: PushTarget,
66
+ msgs: readonly Msg[],
67
+ knownLastOrdinal: number | null,
68
+ ): Promise<number | null> {
69
+ if (msgs.length === 0) return knownLastOrdinal;
70
+
71
+ // The server already has everything we know about → nothing to send (the transcript is
72
+ // append-only, so equal length means equal content).
73
+ if (knownLastOrdinal !== null && knownLastOrdinal >= msgs.length) return knownLastOrdinal;
74
+
75
+ const base = `${t.baseUrl}/api/sessions/${t.project}/${encodeURIComponent(t.sessionId)}`;
76
+
77
+ let from =
78
+ knownLastOrdinal !== null && knownLastOrdinal >= 0
79
+ ? knownLastOrdinal + 1
80
+ : Math.max(1, msgs.length - OVERLAP_WINDOW + 1);
81
+
82
+ for (let attempt = 0; attempt < MAX_APPEND_ATTEMPTS; attempt++) {
83
+ let resp: Response;
84
+ try {
85
+ resp = await post(
86
+ `${base}/append?agent=${encodeURIComponent(t.agent)}&fromOrdinal=${from}`,
87
+ t.apiKey,
88
+ ndjson(msgs.slice(from - 1)),
89
+ t.timeoutMs,
90
+ );
91
+ } catch {
92
+ return null; // network failure — a full-snapshot retry would fail the same way
93
+ }
94
+
95
+ if (resp.ok) {
96
+ const j = (await resp.json().catch(() => null)) as { lastOrdinal?: number } | null;
97
+ return j && typeof j.lastOrdinal === "number" ? j.lastOrdinal : msgs.length;
98
+ }
99
+
100
+ if (resp.status === 409) {
101
+ // Structured contiguity gap: the body carries the server's cursor. Resend from there.
102
+ const j = (await resp.json().catch(() => null)) as { lastOrdinal?: number } | null;
103
+ const last = j && typeof j.lastOrdinal === "number" ? j.lastOrdinal : null;
104
+ if (last === null) break; // unparseable reject → full-snapshot fallback
105
+ if (last >= msgs.length) return last; // server is already ahead of our local view
106
+ from = last + 1;
107
+ continue;
108
+ }
109
+
110
+ // 404 = old server without the append route; anything else = unknown failure.
111
+ // Either way the legacy full-snapshot push is the safe fallback.
112
+ break;
113
+ }
114
+
115
+ try {
116
+ const resp = await post(`${base}?agent=${encodeURIComponent(t.agent)}`, t.apiKey, ndjson(msgs), t.timeoutMs);
117
+ if (!resp.ok) return null;
118
+ const j = (await resp.json().catch(() => null)) as { version?: number } | null;
119
+ return j && typeof j.version === "number" ? j.version : msgs.length;
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
package/src/canon.ts ADDED
@@ -0,0 +1,128 @@
1
+ // Shared memory-canon fetch + offline cache — the ONE implementation both SessionStart hooks
2
+ // (pull-memory.ts for Claude Code, opencode-plugin.ts for opencode) use, so the injected canon
3
+ // block is byte-identical across agents (spec: agent-wiring, wiring-canon-inject).
4
+ //
5
+ // The server exposes the curated memory index (canon) at
6
+ // GET {baseUrl}/api/memory/{project}/canon (header X-Api-Key)
7
+ // → 200 { "project": {body,updatedAt,version}|null, "workspace": {...}|null }
8
+ // We turn that into a markdown block appended to the session context. On any failure we fall
9
+ // back to a local cache (~/.petbox/cache/{project}.canon.md) written on the last good fetch,
10
+ // marked stale. This is best-effort and TOTAL: every path returns string | null, never throws.
11
+ //
12
+ // NOTE: production may not have this endpoint yet — a 404/error degrades gracefully (no canon
13
+ // block, the memory protocol is still injected by the caller).
14
+ //
15
+ // Plain TS for native node type-stripping: no enum/namespace/parameter-properties, type-only
16
+ // imports, zero deps.
17
+
18
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
19
+ import { homedir } from "node:os";
20
+ import { join } from "node:path";
21
+ import type { ResolvedProject } from "./registry.ts";
22
+
23
+ const FETCH_TIMEOUT_MS = 8000;
24
+
25
+ const STALE_MARKER = "⚠ Canon below is from the local cache (PetBox unreachable) — may be stale.";
26
+
27
+ type CanonPart = { body?: unknown; updatedAt?: unknown; version?: unknown };
28
+ type CanonResponse = { project?: CanonPart | null; workspace?: CanonPart | null };
29
+
30
+ function cacheDir(): string {
31
+ return join(homedir(), ".petbox", "cache");
32
+ }
33
+
34
+ function cachePath(project: string): string {
35
+ return join(cacheDir(), `${project}.canon.md`);
36
+ }
37
+
38
+ // Pull a usable markdown body out of a canon part, or null when the part is missing/empty.
39
+ function partBody(part: CanonPart | null | undefined): string | null {
40
+ if (!part || typeof part.body !== "string") return null;
41
+ const body = part.body.trim();
42
+ return body.length > 0 ? body : null;
43
+ }
44
+
45
+ // Assemble the canon block from the two parts. Returns null when both are empty.
46
+ function buildBlock(project: string, resp: CanonResponse | null): string | null {
47
+ if (!resp) return null;
48
+ const projectBody = partBody(resp.project);
49
+ const workspaceBody = partBody(resp.workspace);
50
+ if (projectBody === null && workspaceBody === null) return null;
51
+
52
+ let out = `## PetBox memory canon
53
+
54
+ The curated memory index (canon) for this project — pointers to durable facts; pull full bodies via memory_get/memory_search.`;
55
+ if (projectBody !== null) {
56
+ out += `\n\n### Project (${project})\n\n${projectBody}`;
57
+ }
58
+ if (workspaceBody !== null) {
59
+ out += `\n\n### Workspace\n\n${workspaceBody}`;
60
+ }
61
+ return out;
62
+ }
63
+
64
+ // Returns { ok: true, resp } on a successful HTTP fetch (resp may still carry empty canon),
65
+ // or { ok: false } on any failure (404 endpoint-absent / 401 / 5xx / network / timeout / bad
66
+ // JSON) — the caller uses ok to decide whether to fall back to the stale offline cache.
67
+ async function fetchCanon(
68
+ resolved: ResolvedProject,
69
+ ): Promise<{ ok: true; resp: CanonResponse | null } | { ok: false }> {
70
+ const ctrl = new AbortController();
71
+ const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
72
+ try {
73
+ const url = `${resolved.baseUrl}/api/memory/${resolved.project}/canon`;
74
+ const resp = await fetch(url, {
75
+ method: "GET",
76
+ headers: { "X-Api-Key": resolved.apiKey },
77
+ signal: ctrl.signal,
78
+ });
79
+ if (!resp.ok) return { ok: false }; // 404 (endpoint absent) / 401 / 5xx → degrade to cache
80
+ const j = (await resp.json().catch(() => null)) as CanonResponse | null;
81
+ return { ok: true, resp: j };
82
+ } catch {
83
+ return { ok: false }; // network/timeout → degrade to cache
84
+ } finally {
85
+ clearTimeout(timer);
86
+ }
87
+ }
88
+
89
+ async function writeCache(project: string, block: string): Promise<void> {
90
+ try {
91
+ await mkdir(cacheDir(), { recursive: true });
92
+ await writeFile(cachePath(project), block, "utf8");
93
+ } catch {
94
+ // best-effort: a failed cache write must not affect the returned block
95
+ }
96
+ }
97
+
98
+ async function readCache(project: string): Promise<string | null> {
99
+ try {
100
+ const body = await readFile(cachePath(project), "utf8");
101
+ return body.trim().length > 0 ? body : null;
102
+ } catch {
103
+ return null; // no cache file yet
104
+ }
105
+ }
106
+
107
+ // Build the canon block for a resolved project. On a successful fetch the fresh block is
108
+ // cached and returned; on failure a cached block (if any) is returned PREFIXED with a stale
109
+ // marker. Returns null when there is nothing to show (fetch failed AND no cache, or both
110
+ // canon parts are empty). Never throws.
111
+ export async function fetchCanonBlock(resolved: ResolvedProject): Promise<string | null> {
112
+ try {
113
+ const result = await fetchCanon(resolved);
114
+ if (result.ok) {
115
+ // Successful fetch — the server is authoritative. A real block is cached and returned;
116
+ // an empty canon returns null (do NOT show a stale cache when the server says "nothing").
117
+ const block = buildBlock(resolved.project, result.resp);
118
+ if (block !== null) await writeCache(resolved.project, block);
119
+ return block;
120
+ }
121
+ // Fetch failed (endpoint absent / unreachable) → fall back to the offline cache if present.
122
+ const cached = await readCache(resolved.project);
123
+ if (cached !== null) return `${STALE_MARKER}\n\n${cached}`;
124
+ return null;
125
+ } catch {
126
+ return null; // total: any unexpected error → no canon block
127
+ }
128
+ }
@@ -0,0 +1,65 @@
1
+ // Factory Droid SessionStart hook (global) — the droid port of pull-memory.ts.
2
+ //
3
+ // Injects the PetBox memory protocol + curated canon so the agent recalls relevant memory at
4
+ // session start and captures learnings as it works, via the already-connected petbox MCP.
5
+ //
6
+ // Droid names MCP tools `<server>___<tool>` (triple underscore) at runtime — observed live
7
+ // in exec mode; the docs' `mcp__<server>__<tool>` form did not match. The protocol renders
8
+ // with droidPetboxTool (`petbox___*`); the canon block stays byte-identical across agents. Droid's SessionStart stdin
9
+ // is snake_case (`session_id`, `transcript_path`, `cwd`, `source`), the same shape Claude Code
10
+ // uses, so we resolve the project from `cwd` and pass `source` through for the resume nudge.
11
+ //
12
+ // Output contract (docs): a SessionStart hook returns context to the model via the structured
13
+ // JSON `{ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext } }` on stdout
14
+ // (stdout-as-context is also accepted, but the structured form is the documented preference).
15
+ //
16
+ // Best-effort, always exit 0, no output for an unregistered cwd.
17
+
18
+ import { fetchCanonBlock } from "./canon.ts";
19
+ import { buildProtocol, droidPetboxTool } from "./protocol.ts";
20
+ import { resolveProject } from "./registry.ts";
21
+
22
+ type HookInput = { cwd?: string; source?: string };
23
+
24
+ function readStdin(): Promise<string> {
25
+ return new Promise((resolve) => {
26
+ let buf = "";
27
+ process.stdin.setEncoding("utf8");
28
+ process.stdin.on("data", (c) => (buf += c));
29
+ process.stdin.on("end", () => resolve(buf));
30
+ process.stdin.on("error", () => resolve(buf));
31
+ });
32
+ }
33
+
34
+ async function main(): Promise<void> {
35
+ let source = "startup";
36
+ let cwd = "";
37
+ try {
38
+ const raw = await readStdin();
39
+ const j: HookInput = JSON.parse(raw);
40
+ if (typeof j.source === "string" && j.source.trim()) source = j.source.trim();
41
+ if (typeof j.cwd === "string") cwd = j.cwd;
42
+ } catch {
43
+ // fall through with defaults; cwd stays empty → resolves to null below
44
+ }
45
+
46
+ try {
47
+ const resolved = resolveProject(cwd);
48
+ if (!resolved) return; // not a registered project → no output
49
+ let context = buildProtocol(resolved.project, droidPetboxTool, { source });
50
+ // Append the curated memory canon when available (best-effort; degrades to nothing).
51
+ const canon = await fetchCanonBlock(resolved);
52
+ if (canon) context += `\n\n${canon}`;
53
+ const out = {
54
+ hookSpecificOutput: {
55
+ hookEventName: "SessionStart",
56
+ additionalContext: context,
57
+ },
58
+ };
59
+ process.stdout.write(JSON.stringify(out));
60
+ } catch {
61
+ // best-effort
62
+ }
63
+ }
64
+
65
+ main().finally(() => process.exit(0));
@@ -0,0 +1,87 @@
1
+ // Factory Droid Stop hook (global) — the droid port of push-session.ts.
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
+ // Droid's Stop stdin is snake_case (`session_id`, `transcript_path`, `cwd`, `stop_hook_active`)
8
+ // — the same shape Claude Code uses. We parse the droid JSONL (buildDroidMessages: user/
9
+ // assistant TEXT turns only, tool dumps + `<system-reminder>` injections excluded) and push
10
+ // only the INCREMENT via the server-authoritative append cursor (see append.ts): this process
11
+ // is fresh each turn, so it passes null and pushTranscript optimistically resends a small
12
+ // idempotent overlap window; a contiguity gap comes back as a structured 409 with the server's
13
+ // lastOrdinal and the tail is resent from there. Old servers without the append route fall back
14
+ // to the legacy full-snapshot push. Best-effort: every failure is swallowed and we ALWAYS
15
+ // exit 0 — never break the user's session.
16
+
17
+ import { pushTranscript } from "./append.ts";
18
+ import { buildDroidMessages } from "./droid-transcript.ts";
19
+ import { resolveProject } from "./registry.ts";
20
+ import type { Msg } from "./transcript.ts";
21
+
22
+ const FETCH_TIMEOUT_MS = 12000;
23
+
24
+ type HookInput = {
25
+ session_id?: string;
26
+ transcript_path?: string;
27
+ cwd?: string;
28
+ stop_hook_active?: boolean;
29
+ };
30
+
31
+ function readStdin(): Promise<string> {
32
+ return new Promise((resolve) => {
33
+ let buf = "";
34
+ process.stdin.setEncoding("utf8");
35
+ process.stdin.on("data", (c) => (buf += c));
36
+ process.stdin.on("end", () => resolve(buf));
37
+ process.stdin.on("error", () => resolve(buf));
38
+ });
39
+ }
40
+
41
+ async function main(): Promise<void> {
42
+ try {
43
+ const raw = await readStdin();
44
+ let j: HookInput;
45
+ try {
46
+ j = JSON.parse(raw);
47
+ } catch {
48
+ return;
49
+ }
50
+ if (j.stop_hook_active) return;
51
+
52
+ // FIRST guard: not a registered project → silent no-op (before any file/network work).
53
+ const resolved = resolveProject(j.cwd ?? "");
54
+ if (!resolved) return;
55
+
56
+ const sid = (j.session_id ?? "").trim();
57
+ const tp = (j.transcript_path ?? "").trim();
58
+ if (!sid || !tp) return;
59
+
60
+ let msgs: Msg[];
61
+ try {
62
+ msgs = await buildDroidMessages(tp);
63
+ } catch {
64
+ return; // transcript missing/unreadable
65
+ }
66
+ if (msgs.length === 0) return; // empty body → server returns 400, don't push
67
+
68
+ // Fresh process each turn → no remembered cursor (null): pushTranscript guesses an
69
+ // idempotent overlap window and self-heals off the server's structured gap reject.
70
+ await pushTranscript(
71
+ {
72
+ baseUrl: resolved.baseUrl,
73
+ project: resolved.project,
74
+ sessionId: sid,
75
+ apiKey: resolved.apiKey,
76
+ agent: "droid",
77
+ timeoutMs: FETCH_TIMEOUT_MS,
78
+ },
79
+ msgs,
80
+ null,
81
+ );
82
+ } catch {
83
+ // best-effort: never break the user's session
84
+ }
85
+ }
86
+
87
+ main().finally(() => process.exit(0));
@@ -0,0 +1,47 @@
1
+ // Factory Droid transcript parsing — the droid analogue of transcript.ts, kept as a thin
2
+ // adapter over the SHARED text-extraction/exclusion rules so what a "session turn" is cannot
3
+ // drift between agents (spec: agent-wiring, wiring-single-source).
4
+ //
5
+ // A droid transcript is JSONL, but its record shape differs from Claude Code's:
6
+ // - line 1 is `{type:"session_start", id, title, cwd, ...}` (skipped);
7
+ // - each turn is `{type:"message", message:{role, content}}` where content is either a
8
+ // string or an array of parts `{type:"text"|"thinking"|"tool_use"|"tool_result", ...}`.
9
+ // We keep the user/assistant TEXT turns in order and drop tool_use/tool_result/thinking dumps
10
+ // and harness chrome — reusing extractText (text-parts only) + isExcluded (`<system-reminder>`
11
+ // and friends) from transcript.ts so both agents share one definition. Droid also flags
12
+ // injected context with `visibility:"llm_only"`; we skip those too as a belt-and-suspenders
13
+ // guard alongside the system-reminder prefix check.
14
+ //
15
+ // Plain TS for native node type-stripping: zero deps.
16
+
17
+ import { createReadStream } from "node:fs";
18
+ import { createInterface } from "node:readline";
19
+ import { extractText, isExcluded, type Msg } from "./transcript.ts";
20
+
21
+ // Collect the user/assistant text messages in droid transcript order. No rendering and no
22
+ // cap: the server needs the full, ordered transcript to assign stable per-message ordinals.
23
+ export async function buildDroidMessages(transcriptPath: string): Promise<Msg[]> {
24
+ const rl = createInterface({
25
+ input: createReadStream(transcriptPath, { encoding: "utf8" }),
26
+ crlfDelay: Infinity,
27
+ });
28
+ const msgs: Msg[] = [];
29
+ for await (const line of rl) {
30
+ if (!line || line.trim().length === 0) continue;
31
+ let e: any;
32
+ try {
33
+ e = JSON.parse(line);
34
+ } catch {
35
+ continue;
36
+ }
37
+ if (e.type !== "message" || !e.message) continue;
38
+ const role = e.message.role;
39
+ if (role !== "user" && role !== "assistant") continue;
40
+ if (e.message.visibility === "llm_only") continue; // injected context, not a real turn
41
+ const text = extractText(e.message);
42
+ if (text.length === 0) continue; // thinking/tool_use/tool_result-only turn → no text
43
+ if (isExcluded(text)) continue; // <system-reminder> / harness chrome
44
+ msgs.push({ role, content: text });
45
+ }
46
+ return msgs;
47
+ }