@paxnax/berliner 0.5.2 → 0.5.3
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/dist/agents/codex.js +25 -9
- package/dist/commands/body.js +14 -5
- package/dist/commands/hook.js +4 -2
- package/dist/commands/upgrade.js +22 -12
- package/dist/index.js +2 -1
- package/dist/setup/hooks.js +8 -6
- package/opencode/berliner.js +10 -2
- package/package.json +1 -1
package/dist/agents/codex.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { addTokens, jsonl, num, Span, ticketUrl } from "./read.js";
|
|
2
2
|
const zero = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cacheWrite1h: 0 };
|
|
3
|
+
const tokensOf = (u) => ({
|
|
4
|
+
input: Math.max(0, num(u.input_tokens) - num(u.cached_input_tokens) - num(u.cache_write_input_tokens)),
|
|
5
|
+
output: num(u.output_tokens),
|
|
6
|
+
cacheRead: num(u.cached_input_tokens),
|
|
7
|
+
cacheWrite: num(u.cache_write_input_tokens),
|
|
8
|
+
cacheWrite1h: 0,
|
|
9
|
+
});
|
|
3
10
|
// Line shapes as Codex 0.162.1 writes them (codex-rs/protocol). input_tokens includes both cached reads and cache writes,
|
|
4
11
|
// as in Codex's own parsing test (100 input = 40 cached + 60 written); reasoning_output_tokens is part of output_tokens.
|
|
5
12
|
export async function readCodex(rolloutPath) {
|
|
@@ -11,11 +18,23 @@ export async function readCodex(rolloutPath) {
|
|
|
11
18
|
let latestTotal = 0;
|
|
12
19
|
let latestLast = 0;
|
|
13
20
|
const byModel = {};
|
|
21
|
+
// Codex 0.148+ writes a token_usage_record per model response, before its token_count; where present they are the usage.
|
|
22
|
+
let beforeRecords = null;
|
|
23
|
+
const records = {};
|
|
24
|
+
const responses = new Set();
|
|
14
25
|
for await (const e of jsonl(rolloutPath)) {
|
|
15
26
|
span.add(e.timestamp);
|
|
16
27
|
const p = e.payload;
|
|
17
28
|
if (e.type === "turn_context" && typeof p?.model === "string")
|
|
18
29
|
model = p.model;
|
|
30
|
+
if (e.type === "token_usage_record" && p?.usage) {
|
|
31
|
+
// A rollout begun on an older Codex and resumed on a newer one counts its earlier part from token_count.
|
|
32
|
+
beforeRecords ??= { ...byModel };
|
|
33
|
+
if (model && !(typeof p.response_id === "string" && responses.has(p.response_id))) {
|
|
34
|
+
responses.add(p.response_id);
|
|
35
|
+
records[model] = addTokens(records[model] ?? zero, tokensOf(p.usage));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
19
38
|
if (e.type !== "event_msg")
|
|
20
39
|
continue;
|
|
21
40
|
if (p?.type === "item_completed" && p.item?.type === "UserMessage" && Array.isArray(p.item.content)) {
|
|
@@ -32,16 +51,10 @@ export async function readCodex(rolloutPath) {
|
|
|
32
51
|
const last = num(p?.info?.last_token_usage?.total_tokens);
|
|
33
52
|
// A resumed session restarts the running total in the same rollout: it drops, or its first count is just its own last request.
|
|
34
53
|
// A repeat of the previous event, last request included, is a duplicate notification.
|
|
35
|
-
// shortcut: a restart after a one-request run of the same size reads as a duplicate;
|
|
54
|
+
// shortcut: without usage records, a restart after a one-request run of the same size reads as a duplicate; drop this path once pre-0.148 rollouts no longer matter.
|
|
36
55
|
const dup = total === latestTotal && last === latestLast;
|
|
37
56
|
const restart = total < latestTotal || (latestTotal > 0 && total === last && !dup);
|
|
38
|
-
const cur =
|
|
39
|
-
input: Math.max(0, num(u.input_tokens) - num(u.cached_input_tokens) - num(u.cache_write_input_tokens)),
|
|
40
|
-
output: num(u.output_tokens),
|
|
41
|
-
cacheRead: num(u.cached_input_tokens),
|
|
42
|
-
cacheWrite: num(u.cache_write_input_tokens),
|
|
43
|
-
cacheWrite1h: 0,
|
|
44
|
-
};
|
|
57
|
+
const cur = tokensOf(u);
|
|
45
58
|
// Each event's growth goes to the model it was made with, so a switch mid-session splits the usage.
|
|
46
59
|
const delta = Object.fromEntries(Object.entries(cur).map(([k, v]) => [k, restart ? v : Math.max(0, v - latest[k])]));
|
|
47
60
|
if (model)
|
|
@@ -50,5 +63,8 @@ export async function readCodex(rolloutPath) {
|
|
|
50
63
|
latestLast = last;
|
|
51
64
|
latest = cur;
|
|
52
65
|
}
|
|
53
|
-
|
|
66
|
+
if (beforeRecords)
|
|
67
|
+
for (const [m, t] of Object.entries(records))
|
|
68
|
+
beforeRecords[m] = addTokens(beforeRecords[m] ?? zero, t);
|
|
69
|
+
return { model, tokensByModel: beforeRecords ?? byModel, costUsd: null, startedAt: span.first, endedAt: span.last, firstPrompt, taskUrl };
|
|
54
70
|
}
|
package/dist/commands/body.js
CHANGED
|
@@ -50,15 +50,24 @@ export async function fromTool(tool, values, env, command) {
|
|
|
50
50
|
}
|
|
51
51
|
// Inside a tool: the same fields, minus a derived title, for a session that exists; on a 404 (the first turn, before the hook
|
|
52
52
|
// has reported), the PUT that creates it, at cost 0 while the cost is unknown and on BERLINER_PLAN when no plan is given.
|
|
53
|
+
// Create-only: when another report created the session since the 404 (412), the PATCH after all, so its title and cost stay.
|
|
53
54
|
export async function patchFirst(cfg, path, body, titled, guess) {
|
|
54
|
-
|
|
55
|
+
const patch = async () => {
|
|
55
56
|
const res = await saveSession(cfg, "PATCH", path, titled ? body : { ...body, title: undefined }, guess);
|
|
56
57
|
return { ...res, json: { created: false, ...res.json } };
|
|
58
|
+
};
|
|
59
|
+
try {
|
|
60
|
+
return await patch();
|
|
57
61
|
}
|
|
58
62
|
catch (err) {
|
|
59
63
|
if (!(err instanceof CliError && err.status === 404))
|
|
60
64
|
throw err;
|
|
61
|
-
|
|
65
|
+
const create = { ...body, cost_usd: body.cost_usd ?? 0, plan: body.plan ?? cfg.plan ?? undefined };
|
|
66
|
+
return saveSession(cfg, "PUT", path, create, guess, { "If-None-Match": "*" }).catch((err) => {
|
|
67
|
+
if (err instanceof CliError && err.status === 412)
|
|
68
|
+
return patch();
|
|
69
|
+
throw err;
|
|
70
|
+
});
|
|
62
71
|
}
|
|
63
72
|
}
|
|
64
73
|
function number(values, flag) {
|
|
@@ -127,7 +136,7 @@ function prefilterPrs(body) {
|
|
|
127
136
|
// `guess`, the agent's --plan, in that same retry; without --plan it is left out, so a new session gets the server's pick.
|
|
128
137
|
// Anything else is thrown as before. Warnings are printed only once the save succeeded, so a failed save never claims
|
|
129
138
|
// fields were dropped.
|
|
130
|
-
export async function saveSession(cfg, method, path, input, guess) {
|
|
139
|
+
export async function saveSession(cfg, method, path, input, guess, headers) {
|
|
131
140
|
const first = prefilterPrs(input);
|
|
132
141
|
const dropped = first.dropped;
|
|
133
142
|
const fromEnv = cfg.plan !== null && input.plan === cfg.plan && guess !== cfg.plan;
|
|
@@ -142,7 +151,7 @@ export async function saveSession(cfg, method, path, input, guess) {
|
|
|
142
151
|
return { ...res, dropped };
|
|
143
152
|
};
|
|
144
153
|
try {
|
|
145
|
-
return finish(await request(cfg, method, path, first.body));
|
|
154
|
+
return finish(await request(cfg, method, path, first.body, { headers }));
|
|
146
155
|
}
|
|
147
156
|
catch (err) {
|
|
148
157
|
const fieldErrors = err instanceof CliError && err.status === 422 ? err.details?.fieldErrors : undefined;
|
|
@@ -155,6 +164,6 @@ export async function saveSession(cfg, method, path, input, guess) {
|
|
|
155
164
|
delete retry[field];
|
|
156
165
|
dropped.push({ field, reason: field === "prs" ? `all PRs dropped: ${[fieldErrors[field]].flat().join(", ")}` : [fieldErrors[field]].flat().join(", ") });
|
|
157
166
|
}
|
|
158
|
-
return finish(await request(cfg, method, path, retry));
|
|
167
|
+
return finish(await request(cfg, method, path, retry, { headers }));
|
|
159
168
|
}
|
|
160
169
|
}
|
package/dist/commands/hook.js
CHANGED
|
@@ -105,8 +105,10 @@ async function start(tool, env) {
|
|
|
105
105
|
// shortcut: payload files are kept, one small file per session, as a child can't tell whether a newer one is on its way.
|
|
106
106
|
writeFileSync(`${file}.${process.pid}`, text);
|
|
107
107
|
renameSync(`${file}.${process.pid}`, file);
|
|
108
|
-
// No stdio and its own process group: the tool waits on this process only, and the child outlives it.
|
|
109
|
-
|
|
108
|
+
// No stdio and its own process group: the tool waits on this process only, and the child outlives it. Bun gets the
|
|
109
|
+
// launcher's flags, so the cwd's .env and bunfig.toml stay out of it (see index.ts).
|
|
110
|
+
const bun = process.versions.bun ? ["--config=/dev/null", "--env-file=/dev/null"] : [];
|
|
111
|
+
spawn(process.execPath, [...bun, process.argv[1], "hook", tool, "--run", file], { detached: true, stdio: "ignore" })
|
|
110
112
|
.on("error", (err) => log(env, `${tool} ${id}: ${err.message}`))
|
|
111
113
|
.unref();
|
|
112
114
|
}
|
package/dist/commands/upgrade.js
CHANGED
|
@@ -23,11 +23,13 @@ async function latestVersion(env) {
|
|
|
23
23
|
throw new CliError(`Could not look up the latest berliner on ${registry} (${err.message}). Nothing was changed.`, "network");
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
|
-
// The skill
|
|
27
|
-
//
|
|
28
|
-
|
|
26
|
+
// The skill and the OpenCode plugin come from the release being installed, so the plan's "already up to date" and what is
|
|
27
|
+
// written match it. They are read out of the release tarball as data: running the release (npx) would load the cwd's .npmrc
|
|
28
|
+
// and node_modules.
|
|
29
|
+
async function releaseFiles(version, bundled, env) {
|
|
30
|
+
const local = (file) => readFileSync(new URL(`../../${file}`, import.meta.url), "utf8");
|
|
29
31
|
if (bundled)
|
|
30
|
-
return
|
|
32
|
+
return { skill: local("skill/SKILL.md"), plugin: local("opencode/berliner.js") };
|
|
31
33
|
try {
|
|
32
34
|
const meta = await fetch(`${registryOf(env)}/@paxnax/berliner/${version}`);
|
|
33
35
|
if (!meta.ok)
|
|
@@ -41,17 +43,23 @@ async function skillOf(version, bundled, env) {
|
|
|
41
43
|
const tar = gunzipSync(Buffer.from(await res.arrayBuffer()));
|
|
42
44
|
// shortcut: plain ustar names only (no pax or GNU long names), enough for npm's short paths; parse pax if a path passes 100 bytes.
|
|
43
45
|
const field = (at, end) => tar.toString("utf8", at, end).split("\0")[0];
|
|
46
|
+
const files = new Map();
|
|
44
47
|
for (let at = 0; at + 512 <= tar.length && tar[at];) {
|
|
45
48
|
const prefix = field(at + 345, at + 500);
|
|
46
49
|
const size = Number.parseInt(field(at + 124, at + 136).trim() || "0", 8);
|
|
47
|
-
|
|
48
|
-
return tar.toString("utf8", at + 512, at + 512 + size);
|
|
50
|
+
files.set(`${prefix && `${prefix}/`}${field(at, at + 100)}`, tar.toString("utf8", at + 512, at + 512 + size));
|
|
49
51
|
at += 512 + Math.ceil(size / 512) * 512;
|
|
50
52
|
}
|
|
51
|
-
|
|
53
|
+
const read = (file) => {
|
|
54
|
+
const text = files.get(`package/${file}`);
|
|
55
|
+
if (text === undefined)
|
|
56
|
+
throw new Error(`no ${file} in the package`);
|
|
57
|
+
return text;
|
|
58
|
+
};
|
|
59
|
+
return { skill: read("skill/SKILL.md"), plugin: read("opencode/berliner.js") };
|
|
52
60
|
}
|
|
53
61
|
catch (err) {
|
|
54
|
-
throw new CliError(`Could not read the skill of berliner ${version} (${err.message}). Nothing was changed.`, "network");
|
|
62
|
+
throw new CliError(`Could not read the skill and OpenCode plugin of berliner ${version} (${err.message}). Nothing was changed.`, "network");
|
|
55
63
|
}
|
|
56
64
|
}
|
|
57
65
|
// Release versions only (x.y.z): the registry's latest tag never points at a prerelease.
|
|
@@ -84,12 +92,14 @@ export async function upgrade(env, values) {
|
|
|
84
92
|
const install = installCommand(latest);
|
|
85
93
|
const tools = detectTools(env);
|
|
86
94
|
const targets = values["no-skill"] ? [] : skillTargets(tools, env);
|
|
87
|
-
|
|
95
|
+
// The plugin too, so --no-skill still brings it up to date.
|
|
96
|
+
const release = targets.length || tools.some((t) => t.id === "opencode" && t.found) ? await releaseFiles(target, !installing, env) : null;
|
|
97
|
+
const skill = release?.skill ?? "";
|
|
88
98
|
let stale = targets.filter((t) => !sameAs(t.path, skill));
|
|
89
99
|
// Computed now, for the plan.
|
|
90
100
|
const hooks = tools.flatMap((tool) => {
|
|
91
101
|
const path = hookPath(tool.id, env);
|
|
92
|
-
const text = updatedHooks(tool.id, path);
|
|
102
|
+
const text = updatedHooks(tool.id, path, release?.plugin);
|
|
93
103
|
return text === null ? [] : [{ ...tool, path, text }];
|
|
94
104
|
});
|
|
95
105
|
if (!installing && !stale.length && !hooks.length) {
|
|
@@ -153,8 +163,8 @@ export async function upgrade(env, values) {
|
|
|
153
163
|
}
|
|
154
164
|
}
|
|
155
165
|
for (const h of hooks) {
|
|
156
|
-
// Merged again into the file as it is now, so an edit made during the prompt or the install is kept. The plugin is the
|
|
157
|
-
// the plan showed
|
|
166
|
+
// Merged again into the file as it is now, so an edit made during the prompt or the install is kept. The plugin is the
|
|
167
|
+
// release's, as the plan showed.
|
|
158
168
|
// shortcut: an edit between this read and write is still lost; lock if that shows up.
|
|
159
169
|
const text = h.id === "opencode" ? h.text : updatedHooks(h.id, h.path);
|
|
160
170
|
if (text === null)
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/bin/sh
|
|
2
|
-
//usr/bin/true;
|
|
2
|
+
//usr/bin/true; command -v node >/dev/null && exec node "$0" "$@"; exec bun --config=/dev/null --env-file=/dev/null "$0" "$@"
|
|
3
|
+
// Bun reads the cwd's .env and bunfig.toml (whose preload runs code), so a checkout could redirect the token or run its code.
|
|
3
4
|
import { parse } from "./args.js";
|
|
4
5
|
import { CliError } from "./client.js";
|
|
5
6
|
import { del } from "./commands/delete.js";
|
package/dist/setup/hooks.js
CHANGED
|
@@ -78,18 +78,20 @@ export const hookPath = (tool, env) => tool === "claude-code"
|
|
|
78
78
|
? join(codexDir(env), "hooks.json")
|
|
79
79
|
: join(opencodeDir(env), "plugins", "berliner.js");
|
|
80
80
|
// For upgrade: the hook file's new text when a Berliner hook is installed but older than this release, else null. Hooks that
|
|
81
|
-
// are missing stay missing: adding them is setup's.
|
|
82
|
-
export function updatedHooks(tool, path) {
|
|
83
|
-
if (tool === "opencode")
|
|
84
|
-
|
|
81
|
+
// are missing stay missing: adding them is setup's. `next` is the plugin of the release being installed, else this one's.
|
|
82
|
+
export function updatedHooks(tool, path, next) {
|
|
83
|
+
if (tool === "opencode") {
|
|
84
|
+
const text = next ?? plugin();
|
|
85
|
+
return existsSync(path) && !current(path, text) ? text : null;
|
|
86
|
+
}
|
|
85
87
|
const result = merged(path, entries(tool, tool === "claude-code"), false);
|
|
86
88
|
return result === "unreadable" ? null : (result?.text ?? null);
|
|
87
89
|
}
|
|
88
90
|
// dist/setup/hooks.js -> ../../opencode/berliner.js (the package's opencode/ directory).
|
|
89
91
|
const plugin = () => readFileSync(new URL("../../opencode/berliner.js", import.meta.url), "utf8");
|
|
90
|
-
const current = (pluginPath) => {
|
|
92
|
+
const current = (pluginPath, text = plugin()) => {
|
|
91
93
|
try {
|
|
92
|
-
return readFileSync(pluginPath, "utf8") ===
|
|
94
|
+
return readFileSync(pluginPath, "utf8") === text;
|
|
93
95
|
}
|
|
94
96
|
catch {
|
|
95
97
|
return false;
|
package/opencode/berliner.js
CHANGED
|
@@ -63,14 +63,23 @@ async function withChildren(client, session, depth = 0) {
|
|
|
63
63
|
return { ...session, cost, tokens };
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
// Each save's place in the order saves started, and per session the latest one written: a save that finishes after a later
|
|
67
|
+
// one (past its 10-second deadline in serial) never writes, nor reports, its older totals.
|
|
68
|
+
let seq = 0;
|
|
69
|
+
const committed = new Map();
|
|
70
|
+
|
|
66
71
|
// Saves a top-level session with its subagents' totals where `berliner report` run by the agent finds them: its shell can't
|
|
67
|
-
// reach this client. Returns what it saved, or null for a child session, which the top-level session's totals count
|
|
72
|
+
// reach this client. Returns what it saved, or null for a child session, which the top-level session's totals count, and for
|
|
73
|
+
// a save a later one overtook.
|
|
68
74
|
async function save(client, sessionID) {
|
|
75
|
+
const mine = ++seq;
|
|
69
76
|
if (typeof sessionID !== "string" || !SAFE_ID.test(sessionID)) return null;
|
|
70
77
|
const found = await get(client, sessionID);
|
|
71
78
|
if (found?.id !== sessionID || found.parentID) return null;
|
|
72
79
|
// OpenCode moves time.updated only when a prompt arrives, so the session's last activity is now.
|
|
73
80
|
const session = { ...(await withChildren(client, found)), time: { ...found.time, updated: Date.now() } };
|
|
81
|
+
if ((committed.get(sessionID) ?? 0) > mine) return null;
|
|
82
|
+
committed.set(sessionID, mine);
|
|
74
83
|
const dir = join(process.env.XDG_STATE_HOME || join(homedir(), ".local", "state"), "berliner", "opencode");
|
|
75
84
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
76
85
|
const file = join(dir, `${sessionID}.json`);
|
|
@@ -83,7 +92,6 @@ async function save(client, sessionID) {
|
|
|
83
92
|
// One session's saves run one at a time, in the order they came, so an older total never overwrites a newer one. A save that
|
|
84
93
|
// hangs holds the next one up for 10 seconds at most.
|
|
85
94
|
// shortcut: only within this OpenCode process; two `berliner hook` processes can still write out of order, lock there if that shows up.
|
|
86
|
-
// shortcut: a save that resumes after its deadline can write older totals; the next save rewrites them.
|
|
87
95
|
const queues = new Map();
|
|
88
96
|
const serial = (id, fn) => {
|
|
89
97
|
const p = (queues.get(id) ?? Promise.resolve()).then(fn, fn);
|