pennyrouter 0.3.0 → 0.3.2

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,215 @@
1
+ /* Write a canonical thread into a Claude Code session file it will resume from.
2
+
3
+ `claude --resume` enumerates ~/.claude/projects/<flattened-cwd>/*.jsonl and offers what it
4
+ finds, so a synthesized file appears in the picker and resumes with the history in context.
5
+ Verified end-to-end on 2.1.226: a file written by this shape resumed and the model quoted back
6
+ a turn it never actually saw.
7
+
8
+ THE FORMAT IS UNDOCUMENTED. It is read from ~140 real local sessions, not from a spec, and
9
+ Claude Code updates itself. Three rules follow, and none of them are optional:
10
+
11
+ 1. Never write over an existing session. Every synthesis gets a fresh uuid, so the worst
12
+ case is a stray file in the picker rather than a destroyed conversation.
13
+ 2. Version-gate. Outside the range this was verified against, write the brief instead of a
14
+ session file and say so — a malformed session file is worse than no session file,
15
+ because the user finds out at resume time.
16
+ 3. Records must chain. `parentUuid` is a linked list; a break in it truncates the history
17
+ silently, which looks like a working import that lost half the conversation.
18
+
19
+ Only `user` and `assistant` records are needed to reconstruct a conversation. Tool blocks are
20
+ deliberately NOT replayed as `tool_use`/`tool_result` pairs: those reference call ids the new
21
+ session's tools never issued, and a dangling pair is the one thing observed to make a resumed
22
+ session misbehave rather than merely look odd. They are rendered as prose instead. */
23
+
24
+ import { randomUUID } from "node:crypto";
25
+ import { mkdirSync, existsSync, writeFileSync } from "node:fs";
26
+ import { homedir } from "node:os";
27
+ import { join } from "node:path";
28
+
29
+ /** Versions this writer has been verified against. */
30
+ export const VERIFIED_MIN = "2.0.0";
31
+ export const VERIFIED_MAX = "2.999.999";
32
+
33
+ export function projectDirFor(cwd) {
34
+ // Claude Code flattens the workspace path into a single directory name: every separator and
35
+ // dot becomes "-". Confirmed against the real ~/.claude/projects listing.
36
+ return join(homedir(), ".claude", "projects", cwd.replace(/[/\\.]/g, "-"));
37
+ }
38
+
39
+ function compare(a, b) {
40
+ const pa = String(a).split(".").map((n) => parseInt(n, 10) || 0);
41
+ const pb = String(b).split(".").map((n) => parseInt(n, 10) || 0);
42
+ for (let i = 0; i < 3; i += 1) {
43
+ if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) < (pb[i] || 0) ? -1 : 1;
44
+ }
45
+ return 0;
46
+ }
47
+
48
+ export function versionSupported(version) {
49
+ if (!version) return false;
50
+ const clean = String(version).trim().split(/\s+/)[0];
51
+ return compare(clean, VERIFIED_MIN) >= 0 && compare(clean, VERIFIED_MAX) <= 0;
52
+ }
53
+
54
+ /** One block rendered as the text a resumed model should read. */
55
+ function renderBlock(block) {
56
+ switch (block?.type) {
57
+ case "text":
58
+ return block.text || "";
59
+ case "thinking":
60
+ // One model's reasoning trace is noise to a different model picking the work up.
61
+ return "";
62
+ case "tool_use": {
63
+ const input = block.input ? ` ${JSON.stringify(block.input)}` : "";
64
+ return `[used ${block.name}${input}]`.slice(0, 2000);
65
+ }
66
+ case "tool_result": {
67
+ const status = block.ok === false ? "failed" : "result";
68
+ return `[${block.name || "tool"} ${status}] ${block.output || ""}`.slice(0, 2000);
69
+ }
70
+ case "file":
71
+ return ["```" + (block.language || ""), block.text || "", "```"].join("\n");
72
+ case "diff":
73
+ return ["```diff", block.patch || "", "```"].join("\n");
74
+ case "image":
75
+ return `[image${block.alt ? `: ${block.alt}` : ""}]`;
76
+ default:
77
+ return "";
78
+ }
79
+ }
80
+
81
+ export function renderMessage(message) {
82
+ return (message.blocks || [])
83
+ .map(renderBlock)
84
+ .filter((t) => t && t.trim())
85
+ .join("\n\n")
86
+ .trim();
87
+ }
88
+
89
+ /** The record that tells the resumed agent what it is looking at. */
90
+ export function orientation(thread) {
91
+ const origin = thread.origin_app || thread.source || "another assistant";
92
+ return [
93
+ `This session was imported by PennyRouter from a ${origin} conversation.`,
94
+ thread.source_url ? `Source: ${thread.source_url}` : null,
95
+ "",
96
+ "The turns that follow are that conversation, replayed as history. They are a record of",
97
+ "what was discussed elsewhere — not work this session performed. Tool calls in it were",
98
+ "rendered as text, so no file in this workspace has been touched on their account.",
99
+ "",
100
+ "Pick up from where it left off. Do not re-litigate decisions that were already settled.",
101
+ ]
102
+ .filter((line) => line !== null)
103
+ .join("\n");
104
+ }
105
+
106
+ /**
107
+ * Build the JSONL records for a thread. Exported separately from the write so the chain can be
108
+ * asserted in tests without touching the real ~/.claude directory.
109
+ */
110
+ export function buildRecords(thread, { cwd, sessionId, version, gitBranch = "" }) {
111
+ const now = new Date().toISOString();
112
+ const base = {
113
+ isSidechain: false,
114
+ userType: "external",
115
+ cwd,
116
+ sessionId,
117
+ version,
118
+ gitBranch,
119
+ };
120
+
121
+ const records = [];
122
+ let parentUuid = null;
123
+ const push = (type, message) => {
124
+ const uuid = randomUUID();
125
+ records.push({ ...base, parentUuid, uuid, type, timestamp: now, message });
126
+ parentUuid = uuid;
127
+ };
128
+
129
+ // The resume picker labels a session with its `ai-title` and falls back to the first user
130
+ // message, which here is the orientation preamble — so without this every import reads as
131
+ // "This session was imported by PennyRouter from a…". The source thread already has a real
132
+ // title; use it. Deliberately outside the parentUuid chain: this record type carries no
133
+ // uuid/parentUuid in Claude Code's own sessions, and threading it would break the walk.
134
+ if (thread.title) {
135
+ records.push({ type: "ai-title", aiTitle: thread.title, sessionId });
136
+ }
137
+
138
+ push("user", { role: "user", content: orientation(thread) });
139
+ push("assistant", {
140
+ role: "assistant",
141
+ model: thread.origin_model || "claude-opus-4",
142
+ content: [
143
+ {
144
+ type: "text",
145
+ text: "Understood — I have the imported conversation and will continue from it.",
146
+ },
147
+ ],
148
+ });
149
+
150
+ for (const message of thread.messages || []) {
151
+ const text = renderMessage(message);
152
+ if (!text) continue;
153
+ if (message.role === "assistant") {
154
+ push("assistant", {
155
+ role: "assistant",
156
+ model: message.model || thread.origin_model || "claude-opus-4",
157
+ content: [{ type: "text", text }],
158
+ });
159
+ } else if (message.role === "user") {
160
+ push("user", { role: "user", content: text });
161
+ }
162
+ // `system` and `tool` roles carry no turn a resumed agent should replay as its own.
163
+ }
164
+
165
+ return records;
166
+ }
167
+
168
+ /** Assert the linked list is intact: one root, and every parent already seen before its child. */
169
+ export function assertChained(records) {
170
+ const seen = new Set();
171
+ let roots = 0;
172
+ // Metadata records (`ai-title`) sit outside the conversation and carry no uuid, matching
173
+ // Claude Code's own sessions. Only the message chain is walked.
174
+ for (const record of records.filter((r) => r.uuid)) {
175
+ if (record.parentUuid === null) roots += 1;
176
+ else if (!seen.has(record.parentUuid)) {
177
+ throw new Error(`session records are not chained (orphan ${record.uuid})`);
178
+ }
179
+ seen.add(record.uuid);
180
+ }
181
+ if (roots !== 1) throw new Error(`session must have exactly one root, found ${roots}`);
182
+ }
183
+
184
+ /**
185
+ * Write `thread` as a resumable Claude Code session in `cwd`.
186
+ * Returns { sessionId, path, messageCount }.
187
+ */
188
+ export function writeSession(thread, { cwd, version, gitBranch = "" }) {
189
+ if (!versionSupported(version)) {
190
+ throw new Error(
191
+ `Claude Code ${version || "(unknown version)"} is outside the range this importer has ` +
192
+ `been verified against (${VERIFIED_MIN}–${VERIFIED_MAX}). Refusing to write a session ` +
193
+ `file that may not resume.`,
194
+ );
195
+ }
196
+
197
+ const dir = projectDirFor(cwd);
198
+ mkdirSync(dir, { recursive: true });
199
+
200
+ const sessionId = randomUUID();
201
+ const path = join(dir, `${sessionId}.jsonl`);
202
+ // A fresh uuid cannot collide in practice; checking anyway is what makes "never overwrite an
203
+ // existing session" a property of the code rather than a probability.
204
+ if (existsSync(path)) throw new Error("session id collision; try again");
205
+
206
+ const records = buildRecords(thread, { cwd, sessionId, version, gitBranch });
207
+ assertChained(records);
208
+
209
+ writeFileSync(path, records.map((r) => JSON.stringify(r)).join("\n") + "\n", {
210
+ encoding: "utf8",
211
+ flag: "wx", // fail rather than clobber, even against a race
212
+ });
213
+
214
+ return { sessionId, path, messageCount: records.length };
215
+ }
@@ -0,0 +1,151 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import {
7
+ assertChained,
8
+ buildRecords,
9
+ orientation,
10
+ projectDirFor,
11
+ renderMessage,
12
+ versionSupported,
13
+ writeSession,
14
+ } from "./thread-session.js";
15
+
16
+ const thread = {
17
+ title: "Kickplate removal",
18
+ source: "perplexity_share",
19
+ origin_app: "Perplexity",
20
+ source_url: "https://www.perplexity.ai/search/x",
21
+ messages: [
22
+ { role: "user", blocks: [{ type: "text", text: "Can the kickplate be removed?" }] },
23
+ {
24
+ role: "assistant",
25
+ blocks: [
26
+ { type: "thinking", text: "REASONING-TRACE" },
27
+ { type: "text", text: "Yes, it is a snap-in base grille." },
28
+ { type: "file", language: "python", text: "print('hi')" },
29
+ ],
30
+ },
31
+ { role: "system", blocks: [{ type: "text", text: "SYSTEM-PREAMBLE" }] },
32
+ { role: "user", blocks: [] },
33
+ ],
34
+ };
35
+
36
+ // -- path flattening ------------------------------------------------------
37
+ // Asserted against the real shape of ~/.claude/projects: separators AND dots collapse to "-",
38
+ // which is why a nested path with a dotted segment doubles up its dashes.
39
+ assert.ok(
40
+ projectDirFor("/Users/x/Documents/GitHub/penny-router").endsWith(
41
+ "-Users-x-Documents-GitHub-penny-router",
42
+ ),
43
+ );
44
+ assert.ok(projectDirFor("/tmp/a.b/c").endsWith("-tmp-a-b-c"));
45
+
46
+ // -- version gate ---------------------------------------------------------
47
+ // The format is undocumented, so writing outside the verified range is refused rather than
48
+ // attempted: a malformed session file is discovered at resume time, when it is too late.
49
+ assert.equal(versionSupported("2.1.226 (Claude Code)"), true);
50
+ assert.equal(versionSupported("2.0.0"), true);
51
+ assert.equal(versionSupported("1.9.9"), false);
52
+ assert.equal(versionSupported("3.0.0"), false);
53
+ assert.equal(versionSupported(null), false);
54
+
55
+ // -- record construction --------------------------------------------------
56
+ const records = buildRecords(thread, {
57
+ cwd: "/tmp/ws",
58
+ sessionId: "sid",
59
+ version: "2.1.226",
60
+ });
61
+
62
+ // A title record, then the orientation pair, then one record per replayable turn. The empty
63
+ // user turn and the system turn are dropped: neither is something a resumed agent should
64
+ // replay as its own.
65
+ assert.deepEqual(
66
+ records.map((r) => r.type),
67
+ ["ai-title", "user", "assistant", "user", "assistant"],
68
+ );
69
+
70
+ // The resume picker labels a session with ai-title and otherwise falls back to the first user
71
+ // message — which is the orientation preamble, so every import would read as "This session was
72
+ // imported by PennyRouter from a…" instead of naming the conversation.
73
+ const title = records.find((r) => r.type === "ai-title");
74
+ assert.equal(title.aiTitle, "Kickplate removal");
75
+ assert.equal(title.sessionId, "sid");
76
+ assert.ok(!title.uuid && !("parentUuid" in title), "metadata sits outside the message chain");
77
+
78
+ const serialized = JSON.stringify(records);
79
+ assert.ok(!serialized.includes("REASONING-TRACE"), "thinking must never reach a handoff");
80
+ assert.ok(!serialized.includes("SYSTEM-PREAMBLE"), "system turns are not replayed");
81
+ assert.ok(serialized.includes("print('hi')"), "code blocks survive as fenced text");
82
+ const firstTurn = records.find((r) => r.type === "user");
83
+ assert.ok(firstTurn.message.content.includes("Perplexity"), "orientation names the origin");
84
+ assert.ok(orientation(thread).includes("Do not re-litigate"));
85
+
86
+ for (const record of records.filter((r) => r.type !== "ai-title")) {
87
+ assert.equal(record.sessionId, "sid");
88
+ assert.equal(record.cwd, "/tmp/ws");
89
+ assert.equal(record.isSidechain, false);
90
+ assert.equal(record.userType, "external");
91
+ assert.ok(record.uuid && record.timestamp);
92
+ }
93
+
94
+ // -- chain integrity ------------------------------------------------------
95
+ // A break in the parentUuid list truncates history silently, which reads as a working import
96
+ // that lost half the conversation. That has to fail loudly instead.
97
+ assertChained(records);
98
+ const chain = records.filter((r) => r.uuid);
99
+ assert.equal(chain[0].parentUuid, null);
100
+ assert.equal(chain[1].parentUuid, chain[0].uuid);
101
+
102
+ assert.throws(
103
+ () => assertChained([{ uuid: "a", parentUuid: "nope" }]),
104
+ /not chained/,
105
+ );
106
+ assert.throws(
107
+ () => assertChained([{ uuid: "a", parentUuid: null }, { uuid: "b", parentUuid: null }]),
108
+ /exactly one root/,
109
+ );
110
+
111
+ // -- writing --------------------------------------------------------------
112
+ const home = mkdtempSync(join(tmpdir(), "pr-thread-"));
113
+ const originalHome = process.env.HOME;
114
+ process.env.HOME = home;
115
+ try {
116
+ const workspace = mkdtempSync(join(tmpdir(), "pr-ws-"));
117
+ const result = writeSession(thread, { cwd: workspace, version: "2.1.226" });
118
+
119
+ const lines = readFileSync(result.path, "utf8").trim().split("\n");
120
+ assert.equal(lines.length, result.messageCount);
121
+ const parsed = lines.map((l) => JSON.parse(l));
122
+ assertChained(parsed);
123
+ assert.equal(parsed[0].sessionId, result.sessionId);
124
+ assert.ok(result.path.endsWith(`${result.sessionId}.jsonl`));
125
+
126
+ // Never overwrite an existing session: a second write of the same thread is a new file.
127
+ const again = writeSession(thread, { cwd: workspace, version: "2.1.226" });
128
+ assert.notEqual(again.sessionId, result.sessionId);
129
+ assert.notEqual(again.path, result.path);
130
+ // The first file is still intact and unmodified.
131
+ assert.equal(readFileSync(result.path, "utf8").trim().split("\n").length, lines.length);
132
+
133
+ assert.throws(
134
+ () => writeSession(thread, { cwd: workspace, version: "9.9.9" }),
135
+ /outside the range/,
136
+ );
137
+ } finally {
138
+ process.env.HOME = originalHome;
139
+ }
140
+
141
+ // -- rendering ------------------------------------------------------------
142
+ assert.equal(renderMessage({ blocks: [{ type: "text", text: "a" }] }), "a");
143
+ assert.equal(renderMessage({ blocks: [{ type: "thinking", text: "x" }] }), "");
144
+ assert.equal(renderMessage({ blocks: [] }), "");
145
+ assert.ok(
146
+ renderMessage({ blocks: [{ type: "tool_use", name: "Bash", input: { cmd: "ls" } }] }).startsWith(
147
+ "[used Bash",
148
+ ),
149
+ );
150
+
151
+ console.log("thread-session tests passed");