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,113 @@
1
+ // Shared project resolver for the global agent-wiring kit.
2
+ //
3
+ // One global registry (`~/.petbox/projects.json`) maps a filesystem prefix to a PetBox
4
+ // project + the env var that holds its API key. The Claude Code user hooks and the global
5
+ // opencode plugin both run in EVERY project on the machine, so they resolve the active
6
+ // project by the current working directory (longest-prefix match) and no-op cleanly when
7
+ // the cwd is not registered.
8
+ //
9
+ // Plain TS for native node type-stripping: no enum/namespace/parameter-properties, type-only
10
+ // imports, zero deps.
11
+
12
+ import { readFileSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { join } from "node:path";
15
+
16
+ const DEFAULT_BASE_URL = "https://petbox.3po.su";
17
+
18
+ export type RegistryEntry = {
19
+ prefix: string;
20
+ project: string;
21
+ envVar: string;
22
+ baseUrl?: string;
23
+ };
24
+
25
+ export type ResolvedProject = {
26
+ project: string;
27
+ apiKey: string;
28
+ baseUrl: string;
29
+ envVar: string;
30
+ };
31
+
32
+ export function registryPath(): string {
33
+ return join(homedir(), ".petbox", "projects.json");
34
+ }
35
+
36
+ // Cross-platform key store written by wire.ts: ~/.petbox/keys.json is a flat JSON map
37
+ // { "<ENV_VAR>": "<key>" }. Read as a fallback when the env var is not set in the process
38
+ // (so a machine wired via `npx petbox-wire` works without a user-scope env var). Never throws.
39
+ function readKeyStore(envVar: string): string {
40
+ try {
41
+ const raw = readFileSync(join(homedir(), ".petbox", "keys.json"), "utf8");
42
+ const parsed = JSON.parse(raw);
43
+ const v = parsed && typeof parsed === "object" ? parsed[envVar] : undefined;
44
+ return typeof v === "string" ? v : "";
45
+ } catch {
46
+ return "";
47
+ }
48
+ }
49
+
50
+ // Normalize a path for prefix comparison: unify separators to "/", drop a trailing
51
+ // separator, and lowercase on Windows (case-insensitive filesystem).
52
+ function normalize(p: string): string {
53
+ let n = String(p).replace(/[\\/]+/g, "/");
54
+ if (n.length > 1 && n.endsWith("/")) n = n.slice(0, -1);
55
+ if (process.platform === "win32") n = n.toLowerCase();
56
+ return n;
57
+ }
58
+
59
+ // Segment-boundary prefix match: "d:/my/prj/yoba" must NOT match "d:/my/prj/yobapub".
60
+ // dir is a prefix of, or equal to, the entry path (so worktree subfolders are covered).
61
+ function isUnderPrefix(dir: string, prefix: string): boolean {
62
+ if (dir === prefix) return true;
63
+ return dir.startsWith(prefix + "/");
64
+ }
65
+
66
+ export function readRegistry(): RegistryEntry[] {
67
+ try {
68
+ const raw = readFileSync(registryPath(), "utf8");
69
+ const parsed = JSON.parse(raw);
70
+ const entries = parsed && Array.isArray(parsed.entries) ? parsed.entries : [];
71
+ return entries.filter(
72
+ (e: unknown): e is RegistryEntry =>
73
+ !!e &&
74
+ typeof (e as RegistryEntry).prefix === "string" &&
75
+ typeof (e as RegistryEntry).project === "string" &&
76
+ typeof (e as RegistryEntry).envVar === "string",
77
+ );
78
+ } catch {
79
+ return [];
80
+ }
81
+ }
82
+
83
+ // Resolve the active project for a directory. Returns null on ANY failure
84
+ // (no registry file, no match, empty env var) — never throws, because the hooks
85
+ // that call this run globally and must be a no-op outside registered projects.
86
+ export function resolveProject(dir: string): ResolvedProject | null {
87
+ try {
88
+ if (!dir || typeof dir !== "string") return null;
89
+ const entries = readRegistry();
90
+ if (entries.length === 0) return null;
91
+
92
+ const nd = normalize(dir);
93
+ let best: RegistryEntry | null = null;
94
+ let bestLen = -1;
95
+ for (const e of entries) {
96
+ const np = normalize(e.prefix);
97
+ if (isUnderPrefix(nd, np) && np.length > bestLen) {
98
+ best = e;
99
+ bestLen = np.length;
100
+ }
101
+ }
102
+ if (!best) return null;
103
+
104
+ // env var wins; fall back to ~/.petbox/keys.json (the wire.ts key store).
105
+ const apiKey = process.env[best.envVar] || readKeyStore(best.envVar);
106
+ if (!apiKey || apiKey.trim().length === 0) return null;
107
+
108
+ const baseUrl = (best.baseUrl && best.baseUrl.trim()) || DEFAULT_BASE_URL;
109
+ return { project: best.project, apiKey, baseUrl: baseUrl.replace(/\/+$/, ""), envVar: best.envVar };
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
@@ -0,0 +1,45 @@
1
+ ---
2
+ name: petbox
3
+ description: Shared task boards, memory and session plans for this project via the PetBox MCP server (server name `petbox`). Use to record/read plans, durable notes and working-session state for {{PROJECT}} development.
4
+ ---
5
+
6
+ This project is connected to a PetBox instance over MCP (server `petbox`, https://petbox.3po.su).
7
+ Pass projectKey "{{PROJECT}}" in every call (the key is scoped to the {{PROJECT}} project;
8
+ boards/memory/sessions live at https://petbox.3po.su/ui/{{WORKSPACE}}/{{PROJECT}}).
9
+
10
+ **Tool naming:** the base verbs are underscore-delimited (`tasks_upsert`, `memory_search`, …).
11
+ In opencode the MCP tools are `petbox_<verb>` (e.g. `petbox_tasks_upsert`, `petbox_memory_search`);
12
+ in Claude Code they are `mcp__petbox__<verb>`. Just prefix the base verb per runtime.
13
+
14
+ **Plan nodes are FLAT slugs** (`key` = [a-z][a-z0-9_-]*); hierarchy is the `partOf` edge,
15
+ grouping is `tags` (`area:*` / `concern:*`). Give each node a short `title` and a markdown
16
+ `body`. A cold `tasks_upsert` auto-creates the board. The upsert response is a pure ack for
17
+ YOUR call (added/updated/removed cover only your nodes); to catch up on everyone's changes
18
+ call `tasks_delta` with `sinceVersion` = a previous `currentVersion`. `nodes`/`entries` are
19
+ TYPED arrays — pass real JSON arrays, not stringified JSON.
20
+
21
+ **`tasks_search` is THE read verb** — two modes: without `q` it's a deterministic LISTING
22
+ (pass `board` for one board, omit for the whole project; default order priority-then-key),
23
+ with `q` it's hybrid relevance search (FTS ⊕ vectors). Filters work in both modes:
24
+ `status[]`, `keys[]` (slug|NodeId), `under` (subtree), `includeClosed`; `sort{by,desc}`
25
+ reorders; `bodyLen` snippets bodies. One node in full: `tasks_node_get`.
26
+
27
+ **Memory entries are typed** (`user` | `feedback` | `project` | `reference`) — `type` is
28
+ required on `memory_upsert`; `tags` is an ARRAY of strings ([] clears, omit keeps).
29
+ `memory_search` is THE read verb: with `q`
30
+ a hybrid relevance search (FTS ⊕ vectors), without `q` a deterministic listing (updated
31
+ desc); no `scope` cascades project ⊕ workspace over every store (use `bodyLen` for snippets).
32
+
33
+ **What goes where:**
34
+ - Session (`session_*`) — the current working plan/thinking. "Stale next week?" → session.
35
+ - Tasks (`tasks_*`) — a unit of work with a status tracked to Done.
36
+ - Memory (`memory_*`) — a durable fact that should outlive the work. Don't store what
37
+ code/git already records, transient state, secrets, or actionable work (that's a task).
38
+
39
+ **Tools:**
40
+ - `tasks_board_list / board_create / board_delete / search / node_get / upsert / delta / workflow`
41
+ - `memory_store_list / store_create / store_delete / search / remember / get / upsert / delta`
42
+ - `session_search / get / upsert / append / delete` (`search` without `q` = the session listing; with `q` = two-stage archive search whose hits carry message ordinals for `session_get`)
43
+ - Logs: `log_query` (KQL), `log_create / list / delete`
44
+ - Admin (per-type, flat params): `project_create / list`, `apikey_create / list / delete`,
45
+ `db_create / list / delete / describe`
@@ -0,0 +1,86 @@
1
+ // Shared Claude Code transcript parsing — the ONE implementation both the Stop hook
2
+ // (push-session.ts) and the history importer (import-sessions.ts) use, so what a session
3
+ // "is" cannot drift between live pushes and imports (spec: wiring-single-source).
4
+ //
5
+ // A transcript is JSONL; we keep the user/assistant TEXT turns in order and exclude tool
6
+ // dumps, meta/sidechain entries and harness chrome — tool outputs can carry secrets and
7
+ // the server only wants the dialogue (spec: wiring-history-import).
8
+ //
9
+ // Plain TS for native node type-stripping: zero deps.
10
+
11
+ import { createReadStream } from "node:fs";
12
+ import { createInterface } from "node:readline";
13
+
14
+ export type Msg = { role: string; content: string };
15
+
16
+ export function extractText(message: unknown): string {
17
+ const msg = message as { content?: unknown } | null;
18
+ if (!msg || msg.content == null) return "";
19
+ if (typeof msg.content === "string") return msg.content.trim();
20
+ if (Array.isArray(msg.content)) {
21
+ const parts = msg.content
22
+ .filter((p: any) => p && p.type === "text" && typeof p.text === "string")
23
+ .map((p: any) => p.text);
24
+ return parts.join("\n").trim();
25
+ }
26
+ return "";
27
+ }
28
+
29
+ export function isExcluded(text: string): boolean {
30
+ return (
31
+ text.startsWith("<system-reminder") ||
32
+ text.startsWith("<command-name>") ||
33
+ text.startsWith("<local-command")
34
+ );
35
+ }
36
+
37
+ // Collect the user/assistant text messages in transcript order. No rendering and no cap:
38
+ // the server needs the full, ordered transcript to assign stable per-message ordinals.
39
+ export async function buildMessages(transcriptPath: string): Promise<Msg[]> {
40
+ const rl = createInterface({
41
+ input: createReadStream(transcriptPath, { encoding: "utf8" }),
42
+ crlfDelay: Infinity,
43
+ });
44
+ const msgs: Msg[] = [];
45
+ for await (const line of rl) {
46
+ if (!line || line.trim().length === 0) continue;
47
+ let e: any;
48
+ try {
49
+ e = JSON.parse(line);
50
+ } catch {
51
+ continue;
52
+ }
53
+ if (e.type !== "user" && e.type !== "assistant") continue;
54
+ if (e.isMeta || e.isSidechain) continue;
55
+ const text = extractText(e.message);
56
+ if (text.length === 0) continue;
57
+ if (isExcluded(text)) continue;
58
+ msgs.push({ role: e.type, content: text });
59
+ }
60
+ return msgs;
61
+ }
62
+
63
+ // The cwd a transcript was recorded in (the first entry that carries one). Lets the
64
+ // importer attribute a transcript to a registered project WITHOUT reversing Claude's
65
+ // lossy path-encoding of the directory name.
66
+ export async function readTranscriptCwd(transcriptPath: string, maxLines = 25): Promise<string | null> {
67
+ const rl = createInterface({
68
+ input: createReadStream(transcriptPath, { encoding: "utf8" }),
69
+ crlfDelay: Infinity,
70
+ });
71
+ let seen = 0;
72
+ for await (const line of rl) {
73
+ if (++seen > maxLines) break;
74
+ if (!line || line.trim().length === 0) continue;
75
+ try {
76
+ const e = JSON.parse(line);
77
+ if (e && typeof e.cwd === "string" && e.cwd.length > 0) {
78
+ rl.close();
79
+ return e.cwd;
80
+ }
81
+ } catch {
82
+ /* skip unparseable line */
83
+ }
84
+ }
85
+ return null;
86
+ }