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 +51 -0
- package/bin/petbox-wire.js +17 -0
- package/package.json +35 -0
- package/src/append.ts +123 -0
- package/src/canon.ts +128 -0
- package/src/droid-pull-memory.ts +65 -0
- package/src/droid-push-session.ts +87 -0
- package/src/droid-transcript.ts +47 -0
- package/src/import-sessions.ts +256 -0
- package/src/opencode-plugin.ts +106 -0
- package/src/protocol.ts +72 -0
- package/src/pull-memory.ts +51 -0
- package/src/push-session.ts +84 -0
- package/src/registry.ts +113 -0
- package/src/templates/SKILL.md +45 -0
- package/src/transcript.ts +86 -0
- package/src/wire.ts +639 -0
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
|
+
}
|