@hypercarrier/rarebit 0.1.0-alpha.6 → 0.2.1
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/CHANGELOG.md +33 -3
- package/README.md +296 -122
- package/RUNBOOK.md +16 -9
- package/docs/examples/rarebit.settings.json +19 -0
- package/package.json +9 -2
- package/schemas/rarebit.schema.json +2 -1
- package/scripts/e2e-recap-pi.mjs +458 -0
- package/scripts/e2e-settings-pi.mjs +267 -0
- package/scripts/e2e-v020-pi.mjs +271 -0
- package/scripts/experiment-recap-fullscreen.mjs +56 -0
- package/scripts/verify-package.mjs +14 -8
- package/src/extension.mjs +246 -24
- package/src/lifecycle.mjs +32 -7
- package/src/rarebit-artifact-state.mjs +8 -12
- package/src/rarebit-cli.mjs +2 -0
- package/src/rarebit-command.mjs +26 -1
- package/src/rarebit-core.mjs +122 -23
- package/src/rarebit-read-checkpoint.mjs +59 -0
- package/src/rarebit-recap.mjs +479 -0
- package/src/rarebit-service.mjs +65 -9
- package/src/rarebit-settings-ui.mjs +479 -0
- package/src/rarebit-settings.mjs +90 -7
- package/src/types.d.ts +65 -5
- package/test/lifecycle-contract-independent.test.mjs +2 -0
- package/test/package-artifact-identity.test.mjs +54 -0
- package/test/rarebit-artifact-state.test.mjs +10 -4
- package/test/rarebit-command.test.mjs +11 -4
- package/test/rarebit-core.test.mjs +50 -0
- package/test/rarebit-recap.test.mjs +662 -0
- package/test/rarebit-settings-ui.test.mjs +400 -0
- package/test/rarebit-sidecar-protocol.test.mjs +20 -0
- package/test/rarebit-v020.test.mjs +299 -0
- package/test/recap-read-checkpoint.test.mjs +126 -0
- package/test/summary-attention-independent.test.mjs +1 -1
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Real Pi TUI settings coverage for Rarebit.
|
|
5
|
+
*
|
|
6
|
+
* The run uses a disposable agent directory, trusted project, Session root,
|
|
7
|
+
* and loopback-only model registry. The settings flow must render its tabs,
|
|
8
|
+
* save only the Rarebit namespace, and reopen with the saved value. It must
|
|
9
|
+
* not start a provider request or append a Session record.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { createServer } from "node:http";
|
|
14
|
+
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
17
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
18
|
+
import { spawn } from "node:child_process";
|
|
19
|
+
import { execFile as execFileCallback } from "node:child_process";
|
|
20
|
+
import { promisify } from "node:util";
|
|
21
|
+
|
|
22
|
+
const execFile = promisify(execFileCallback);
|
|
23
|
+
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
24
|
+
const piEntry = fileURLToPath(await import.meta.resolve("@earendil-works/pi-coding-agent"));
|
|
25
|
+
const piRoot = resolve(dirname(dirname(piEntry)));
|
|
26
|
+
const piCli = join(piRoot, "dist/cli.js");
|
|
27
|
+
const tmux = process.env.TMUX_BIN ?? "tmux";
|
|
28
|
+
|
|
29
|
+
const sleep = (ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
|
|
30
|
+
async function pane(socket, session) {
|
|
31
|
+
try {
|
|
32
|
+
const { stdout } = await execFile(tmux, ["-L", socket, "capture-pane", "-p", "-J", "-t", session, "-S", "-200"]);
|
|
33
|
+
return stdout;
|
|
34
|
+
} catch {
|
|
35
|
+
return "";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async function keys(socket, session, text) {
|
|
39
|
+
await execFile(tmux, ["-L", socket, "send-keys", "-l", "-t", session, text]);
|
|
40
|
+
}
|
|
41
|
+
async function press(socket, session, key) {
|
|
42
|
+
await execFile(tmux, ["-L", socket, "send-keys", "-t", session, key]);
|
|
43
|
+
}
|
|
44
|
+
async function submitSlash(socket, session, command) {
|
|
45
|
+
await keys(socket, session, command);
|
|
46
|
+
// Pi's editor keeps slash-command autocomplete active after the literal
|
|
47
|
+
// command text arrives through tmux. Tab accepts the selected command token;
|
|
48
|
+
// Enter then submits the now-complete extension command.
|
|
49
|
+
await sleep(500);
|
|
50
|
+
await press(socket, session, "Tab");
|
|
51
|
+
await sleep(100);
|
|
52
|
+
await press(socket, session, "Enter");
|
|
53
|
+
}
|
|
54
|
+
async function waitFor(label, predicate, timeout = 10_000, interval = 100) {
|
|
55
|
+
const deadline = Date.now() + timeout;
|
|
56
|
+
while (Date.now() < deadline) {
|
|
57
|
+
const value = await predicate();
|
|
58
|
+
if (value) return value;
|
|
59
|
+
await sleep(interval);
|
|
60
|
+
}
|
|
61
|
+
throw new Error(`Timed out waiting for ${label}`);
|
|
62
|
+
}
|
|
63
|
+
async function walk(root) {
|
|
64
|
+
const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
|
|
65
|
+
const files = [];
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
const file = join(root, entry.name);
|
|
68
|
+
if (entry.isDirectory()) files.push(...await walk(file));
|
|
69
|
+
else files.push(file);
|
|
70
|
+
}
|
|
71
|
+
return files;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const temp = await mkdtemp(join(tmpdir(), "rarebit-settings-pi-e2e-"));
|
|
75
|
+
const agentDir = join(temp, "agent");
|
|
76
|
+
const projectDir = join(temp, "project");
|
|
77
|
+
const sessionDir = join(temp, "sessions");
|
|
78
|
+
const rarebitRoot = join(temp, "receipts");
|
|
79
|
+
const fixtureExtension = join(temp, "rarebit-fixture-extension.mjs");
|
|
80
|
+
const requestLog = join(temp, "provider-requests.jsonl");
|
|
81
|
+
const settingsFile = join(agentDir, "settings.json");
|
|
82
|
+
const projectSettingsFile = join(projectDir, ".pi", "settings.json");
|
|
83
|
+
const socket = `rarebit-settings-${process.pid}`;
|
|
84
|
+
const session = "rarebit-settings-e2e";
|
|
85
|
+
let provider;
|
|
86
|
+
let tmuxStarted = false;
|
|
87
|
+
let keepTemp = process.env.RAREBIT_KEEP_E2E_TEMP === "1";
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
await mkdir(agentDir, { recursive: true });
|
|
91
|
+
await mkdir(join(projectDir, ".pi"), { recursive: true });
|
|
92
|
+
await mkdir(sessionDir, { recursive: true });
|
|
93
|
+
|
|
94
|
+
const requests = [];
|
|
95
|
+
provider = createServer(async (request, response) => {
|
|
96
|
+
let body = "";
|
|
97
|
+
for await (const chunk of request) body += chunk;
|
|
98
|
+
const entry = { at: Date.now(), body: JSON.parse(body) };
|
|
99
|
+
requests.push(entry);
|
|
100
|
+
await writeFile(requestLog, `${JSON.stringify(entry)}\n`, { flag: "a", mode: 0o600 });
|
|
101
|
+
const chunk = JSON.stringify({
|
|
102
|
+
id: `rarebit-settings-${requests.length}`,
|
|
103
|
+
object: "chat.completion.chunk",
|
|
104
|
+
choices: [{ index: 0, delta: { role: "assistant", content: "SETTINGS_BASELINE_DONE" }, finish_reason: "stop" }],
|
|
105
|
+
});
|
|
106
|
+
response.writeHead(200, { "content-type": "text/event-stream", connection: "keep-alive" });
|
|
107
|
+
response.end(`data: ${chunk}\n\ndata: [DONE]\n\n`);
|
|
108
|
+
});
|
|
109
|
+
await new Promise((resolvePromise, reject) => {
|
|
110
|
+
provider.once("error", reject);
|
|
111
|
+
provider.listen(0, "127.0.0.1", resolvePromise);
|
|
112
|
+
});
|
|
113
|
+
const port = provider.address().port;
|
|
114
|
+
await writeFile(join(agentDir, "models.json"), JSON.stringify({
|
|
115
|
+
providers: {
|
|
116
|
+
local: {
|
|
117
|
+
baseUrl: `http://127.0.0.1:${port}/v1`,
|
|
118
|
+
api: "openai-completions",
|
|
119
|
+
apiKey: "fixture",
|
|
120
|
+
models: [{ id: "fixture", name: "settings fixture", reasoning: false, input: ["text"], contextWindow: 100_000, maxTokens: 512 }],
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
}, null, 2), { mode: 0o600 });
|
|
124
|
+
await writeFile(settingsFile, JSON.stringify({
|
|
125
|
+
theme: "dark",
|
|
126
|
+
another_extension: { keep: true },
|
|
127
|
+
rarebit: { model: "local/fixture", auto_title: false },
|
|
128
|
+
}, null, 2), { mode: 0o600 });
|
|
129
|
+
await writeFile(projectSettingsFile, "{}\n", { mode: 0o600 });
|
|
130
|
+
await writeFile(fixtureExtension, `import registerPiRarebit from ${JSON.stringify(pathToFileURL(join(packageRoot, "src/extension.mjs")).href)};
|
|
131
|
+
export default function registerFixtureRarebit(pi) {
|
|
132
|
+
return registerPiRarebit(pi, { sessionRoot: ${JSON.stringify(sessionDir)}, rarebitRoot: ${JSON.stringify(rarebitRoot)} });
|
|
133
|
+
}
|
|
134
|
+
`, { mode: 0o600 });
|
|
135
|
+
|
|
136
|
+
const childEnv = { ...process.env, PI_CODING_AGENT_DIR: agentDir, PI_OFFLINE: "1", PI_TELEMETRY: "0" };
|
|
137
|
+
const command = [
|
|
138
|
+
"env", `PI_CODING_AGENT_DIR=${agentDir}`, "PI_OFFLINE=1", "PI_TELEMETRY=0",
|
|
139
|
+
process.execPath, piCli, "--no-extensions", "--no-skills", "--no-prompt-templates", "--no-themes", "--no-context-files", "--approve",
|
|
140
|
+
"--extension", fixtureExtension, "--session-dir", sessionDir, "--model", "local/fixture", "--tui-mode", "regular",
|
|
141
|
+
];
|
|
142
|
+
const child = spawn(tmux, ["-L", socket, "new-session", "-d", "-x", "140", "-y", "48", "-s", session, ...command], { cwd: projectDir, env: childEnv, stdio: "ignore" });
|
|
143
|
+
await new Promise((resolvePromise, reject) => {
|
|
144
|
+
child.once("error", reject);
|
|
145
|
+
child.once("exit", (code) => code === 0 ? resolvePromise() : reject(new Error(`tmux exited ${code}`)));
|
|
146
|
+
});
|
|
147
|
+
tmuxStarted = true;
|
|
148
|
+
await waitFor("Pi editor", async () => (await pane(socket, session)).includes("0.0%/100k"));
|
|
149
|
+
await sleep(750);
|
|
150
|
+
|
|
151
|
+
// Establish a persisted Session before measuring settings side effects.
|
|
152
|
+
// Keep this baseline request separate from the settings no-model-call check.
|
|
153
|
+
await keys(socket, session, "settings baseline");
|
|
154
|
+
await press(socket, session, "Enter");
|
|
155
|
+
await waitFor("baseline provider response", async () => (await pane(socket, session)).includes("SETTINGS_BASELINE_DONE"), 15_000);
|
|
156
|
+
const providerRequestsBeforeSettings = requests.length;
|
|
157
|
+
const sessionSnapshot = async () => Object.fromEntries(await Promise.all(
|
|
158
|
+
(await walk(sessionDir)).filter((file) => file.endsWith(".jsonl")).sort()
|
|
159
|
+
.map(async (file) => [file, await readFile(file, "utf8")]),
|
|
160
|
+
));
|
|
161
|
+
await waitFor("persisted baseline assistant", async () =>
|
|
162
|
+
Object.values(await sessionSnapshot()).some((text) => text.includes("SETTINGS_BASELINE_DONE")));
|
|
163
|
+
const sessionsBeforeSettings = await sessionSnapshot();
|
|
164
|
+
|
|
165
|
+
await submitSlash(socket, session, "/rarebit");
|
|
166
|
+
const palettePane = await waitFor("Rarebit parent palette", async () => {
|
|
167
|
+
const output = await pane(socket, session);
|
|
168
|
+
return output.includes("Human-only command palette") && output.includes("Command help") ? output : false;
|
|
169
|
+
});
|
|
170
|
+
await press(socket, session, "Escape");
|
|
171
|
+
await waitFor("editor after palette close", async () => !(await pane(socket, session)).includes("Human-only command palette"));
|
|
172
|
+
await sleep(200);
|
|
173
|
+
|
|
174
|
+
// Open the real command and settings surface.
|
|
175
|
+
await submitSlash(socket, session, "/rarebit settings");
|
|
176
|
+
const initialPane = await waitFor("settings tabs", async () => {
|
|
177
|
+
const output = await pane(socket, session);
|
|
178
|
+
return output.includes("Rarebit Settings") && output.includes("Actions") && output.includes("Summary") && output.includes("Recap") && output.includes("Session") ? output : false;
|
|
179
|
+
});
|
|
180
|
+
assert.match(initialPane, /Show Summary triggered/);
|
|
181
|
+
assert.match(initialPane, /Actions\s+\/\s+Summary\s+\/\s+Recap\s+\/\s+Session/);
|
|
182
|
+
|
|
183
|
+
// The editor opens on Summary. Visit Recap in the rendered TUI so the
|
|
184
|
+
// timezone and delay fields are covered, then cycle back to Summary.
|
|
185
|
+
await press(socket, session, "Tab");
|
|
186
|
+
const recapPane = await waitFor("Recap settings tab", async () => {
|
|
187
|
+
const output = await pane(socket, session);
|
|
188
|
+
return output.includes("Recap timezone") && output.includes("Recap delay (ms)") ? output : false;
|
|
189
|
+
});
|
|
190
|
+
assert.match(recapPane, /Recap timezone/);
|
|
191
|
+
assert.match(recapPane, /60,000/);
|
|
192
|
+
await press(socket, session, "Tab");
|
|
193
|
+
await sleep(100);
|
|
194
|
+
await press(socket, session, "Tab");
|
|
195
|
+
await sleep(100);
|
|
196
|
+
await press(socket, session, "Tab");
|
|
197
|
+
await sleep(100);
|
|
198
|
+
await waitFor("Summary tab after Recap", async () => (await pane(socket, session)).includes("Show Summary triggered"));
|
|
199
|
+
|
|
200
|
+
// Summary is the initial tab. Choose its first row, set `on`, and confirm.
|
|
201
|
+
await press(socket, session, "Enter");
|
|
202
|
+
await waitFor("diagnostic choices", async () => {
|
|
203
|
+
const output = await pane(socket, session);
|
|
204
|
+
return output.includes("Show Summary triggered") && output.includes("Remove override") ? output : false;
|
|
205
|
+
});
|
|
206
|
+
await press(socket, session, "Down");
|
|
207
|
+
await sleep(100);
|
|
208
|
+
await press(socket, session, "Down");
|
|
209
|
+
await sleep(100);
|
|
210
|
+
await press(socket, session, "Enter");
|
|
211
|
+
await waitFor("save confirmation", async () => (await pane(socket, session)).includes("Save Rarebit global settings?"));
|
|
212
|
+
await press(socket, session, "Enter");
|
|
213
|
+
await waitFor("saved settings notice", async () => (await pane(socket, session)).includes("Saved Rarebit global settings."));
|
|
214
|
+
await press(socket, session, "Escape");
|
|
215
|
+
await waitFor("editor after settings close", async () => !(await pane(socket, session)).includes("Rarebit Settings"));
|
|
216
|
+
|
|
217
|
+
const saved = JSON.parse(await readFile(settingsFile, "utf8"));
|
|
218
|
+
assert.equal(saved.theme, "dark");
|
|
219
|
+
assert.deepEqual(saved.another_extension, { keep: true });
|
|
220
|
+
assert.equal(saved.rarebit.diagnostics.summary_triggered, true);
|
|
221
|
+
assert.equal(requests.length, providerRequestsBeforeSettings, "settings must not call a model provider");
|
|
222
|
+
|
|
223
|
+
// Reopen in the same real TUI and verify the persisted field is visible.
|
|
224
|
+
await submitSlash(socket, session, "/rarebit settings");
|
|
225
|
+
const reopenedPane = await waitFor("reopened settings", async () => {
|
|
226
|
+
const output = await pane(socket, session);
|
|
227
|
+
return output.includes("Rarebit Settings") && output.includes("Show Summary triggered: on") ? output : false;
|
|
228
|
+
});
|
|
229
|
+
assert.match(reopenedPane, /Show Summary triggered: on/);
|
|
230
|
+
await press(socket, session, "Escape");
|
|
231
|
+
await press(socket, session, "Escape");
|
|
232
|
+
await sleep(250);
|
|
233
|
+
const sessionText = (await Promise.all((await walk(sessionDir)).filter((file) => file.endsWith(".jsonl")).map((file) => readFile(file, "utf8")))).join("\n");
|
|
234
|
+
assert.doesNotMatch(sessionText, /Saved Rarebit|Summary triggered/);
|
|
235
|
+
assert.deepEqual(await sessionSnapshot(), sessionsBeforeSettings, "palette and settings must not append or change Session records");
|
|
236
|
+
assert.equal(requests.length, providerRequestsBeforeSettings, "reopening settings must not call a model provider");
|
|
237
|
+
|
|
238
|
+
const evidence = { palettePane, initialPane, recapPane, reopenedPane, requests, settings: saved };
|
|
239
|
+
if (keepTemp) await writeFile(join(temp, "evidence.json"), JSON.stringify(evidence, null, 2), { mode: 0o600 });
|
|
240
|
+
console.log(JSON.stringify({
|
|
241
|
+
ok: true,
|
|
242
|
+
pi: JSON.parse(await readFile(join(piRoot, "package.json"), "utf8")).version,
|
|
243
|
+
providerRequests: requests.length,
|
|
244
|
+
settingsFile,
|
|
245
|
+
checks: ["real Pi TUI rendered Actions/Summary/Recap/Session tabs", "confirmed edit persisted", "reopen showed saved value", "other settings namespaces preserved", "settings made no provider request or Session write"],
|
|
246
|
+
evidenceRoot: keepTemp ? temp : null,
|
|
247
|
+
}, null, 2));
|
|
248
|
+
} catch (error) {
|
|
249
|
+
keepTemp = true;
|
|
250
|
+
const failurePane = tmuxStarted ? await pane(socket, session) : "";
|
|
251
|
+
console.error(`Rarebit settings Pi E2E failed: ${error?.stack ?? error}`);
|
|
252
|
+
if (failurePane) console.error(`Pi pane at failure:\n${failurePane}`);
|
|
253
|
+
console.error(`Temporary evidence retained at ${temp}`);
|
|
254
|
+
throw error;
|
|
255
|
+
} finally {
|
|
256
|
+
if (tmuxStarted) {
|
|
257
|
+
await press(socket, session, "C-c").catch(() => {});
|
|
258
|
+
await sleep(100);
|
|
259
|
+
await press(socket, session, "C-c").catch(() => {});
|
|
260
|
+
await execFile(tmux, ["-L", socket, "kill-server"]).catch(() => {});
|
|
261
|
+
}
|
|
262
|
+
if (provider?.listening) await new Promise((resolvePromise) => provider.close(resolvePromise));
|
|
263
|
+
if (keepTemp) {
|
|
264
|
+
const cleanup = await execFile(tmux, ["-L", socket, "has-session"]).then(() => "server_still_running").catch(() => "clean");
|
|
265
|
+
await writeFile(join(temp, "tmux-cleanup.txt"), `${cleanup}\n`, { mode: 0o600 });
|
|
266
|
+
} else await rm(temp, { recursive: true, force: true });
|
|
267
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Packed-artifact Pi 0.84.2 verification for the v0.2 Summary/Recap surface.
|
|
5
|
+
*
|
|
6
|
+
* Set RAREBIT_PACKAGE_TGZ to a tarball produced by `npm pack` to exercise the
|
|
7
|
+
* installed artifact. Without it, the fixture imports this checkout's source.
|
|
8
|
+
* The provider is loopback-only and the Pi agent/project/Session roots are
|
|
9
|
+
* disposable.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { createServer } from "node:http";
|
|
14
|
+
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
17
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
18
|
+
import { spawn } from "node:child_process";
|
|
19
|
+
import { execFile as execFileCallback } from "node:child_process";
|
|
20
|
+
import { createRequire } from "node:module";
|
|
21
|
+
import { promisify } from "node:util";
|
|
22
|
+
|
|
23
|
+
const execFile = promisify(execFileCallback);
|
|
24
|
+
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
25
|
+
const piEntry = fileURLToPath(await import.meta.resolve("@earendil-works/pi-coding-agent"));
|
|
26
|
+
const piRoot = resolve(dirname(dirname(piEntry)));
|
|
27
|
+
const piCli = join(piRoot, "dist/cli.js");
|
|
28
|
+
const resolveTuiRoot = (fromRoot) => {
|
|
29
|
+
const entry = createRequire(join(fromRoot, "package.json")).resolve("@earendil-works/pi-tui");
|
|
30
|
+
return resolve(dirname(entry), "..");
|
|
31
|
+
};
|
|
32
|
+
const sourceTuiRoot = process.env.RAREBIT_PI_TUI_ROOT ?? resolveTuiRoot(packageRoot);
|
|
33
|
+
const tmux = process.env.TMUX_BIN ?? "tmux";
|
|
34
|
+
const keepTemp = process.env.RAREBIT_KEEP_E2E_TEMP === "1";
|
|
35
|
+
const packageTarball = process.env.RAREBIT_PACKAGE_TGZ;
|
|
36
|
+
|
|
37
|
+
const sleep = (milliseconds) => new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds));
|
|
38
|
+
|
|
39
|
+
async function pane(socket, session, ansi = false) {
|
|
40
|
+
try {
|
|
41
|
+
const args = ["-L", socket, "capture-pane", "-p", "-J"];
|
|
42
|
+
if (ansi) args.push("-e");
|
|
43
|
+
args.push("-t", session, "-S", "-240");
|
|
44
|
+
return (await execFile(tmux, args)).stdout;
|
|
45
|
+
} catch {
|
|
46
|
+
return "";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function keys(socket, session, text) {
|
|
51
|
+
await execFile(tmux, ["-L", socket, "send-keys", "-l", "-t", session, text]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function press(socket, session, key) {
|
|
55
|
+
await execFile(tmux, ["-L", socket, "send-keys", "-t", session, key]);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function waitFor(label, predicate, timeout = 10_000, interval = 100) {
|
|
59
|
+
const deadline = Date.now() + timeout;
|
|
60
|
+
while (Date.now() < deadline) {
|
|
61
|
+
const value = await predicate();
|
|
62
|
+
if (value) return value;
|
|
63
|
+
await sleep(interval);
|
|
64
|
+
}
|
|
65
|
+
throw new Error(`Timed out waiting for ${label}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function walk(root) {
|
|
69
|
+
const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
|
|
70
|
+
const files = [];
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
const file = join(root, entry.name);
|
|
73
|
+
if (entry.isDirectory()) files.push(...await walk(file));
|
|
74
|
+
else files.push(file);
|
|
75
|
+
}
|
|
76
|
+
return files;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const temp = await mkdtemp(join(tmpdir(), "rarebit-v020-pi-e2e-"));
|
|
80
|
+
const agentDir = join(temp, "agent");
|
|
81
|
+
const projectDir = join(temp, "project");
|
|
82
|
+
const sessionDir = join(temp, "sessions");
|
|
83
|
+
const rarebitRoot = join(temp, "receipts");
|
|
84
|
+
const fixtureExtension = join(temp, "rarebit-fixture-extension.mjs");
|
|
85
|
+
const requestLog = join(temp, "provider-requests.jsonl");
|
|
86
|
+
const installedRoot = join(temp, "installed");
|
|
87
|
+
const socket = `rarebit-v020-${process.pid}`;
|
|
88
|
+
const session = "rarebit-v020-e2e";
|
|
89
|
+
let provider;
|
|
90
|
+
let tmuxStarted = false;
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
await mkdir(agentDir, { recursive: true });
|
|
94
|
+
await mkdir(join(projectDir, ".pi"), { recursive: true });
|
|
95
|
+
await mkdir(sessionDir, { recursive: true });
|
|
96
|
+
|
|
97
|
+
const installedPackageRoot = packageTarball
|
|
98
|
+
? join(installedRoot, "package")
|
|
99
|
+
: packageRoot;
|
|
100
|
+
if (packageTarball) {
|
|
101
|
+
await mkdir(installedRoot, { recursive: true });
|
|
102
|
+
await execFile("tar", ["-xzf", resolve(packageTarball), "-C", installedRoot]);
|
|
103
|
+
await mkdir(join(installedRoot, "node_modules", "@earendil-works"), { recursive: true });
|
|
104
|
+
await execFile("ln", ["-s", sourceTuiRoot, join(installedRoot, "node_modules/@earendil-works/pi-tui")]);
|
|
105
|
+
await execFile("ln", ["-s", piRoot, join(installedRoot, "node_modules/@earendil-works/pi-coding-agent")]);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const requests = [];
|
|
109
|
+
provider = createServer(async (request, response) => {
|
|
110
|
+
let body = "";
|
|
111
|
+
for await (const chunk of request) body += chunk;
|
|
112
|
+
const payload = JSON.parse(body);
|
|
113
|
+
const messages = Array.isArray(payload.messages) ? payload.messages : [];
|
|
114
|
+
const userText = messages
|
|
115
|
+
.filter((message) => message?.role === "user")
|
|
116
|
+
.map((message) => typeof message.content === "string" ? message.content : JSON.stringify(message.content ?? ""))
|
|
117
|
+
.join("\n");
|
|
118
|
+
const isSummary = /You are the HyperCarrier Rarebit summarizer/i.test(userText);
|
|
119
|
+
const interactiveNumber = requests.filter((entry) => entry.kind === "interactive").length + 1;
|
|
120
|
+
const fullSummary = Array.from({ length: 24 }, (_, index) => `line-${index + 1} FULL_RECAP_TAIL_${index + 1}`).join("\n");
|
|
121
|
+
const text = isSummary
|
|
122
|
+
? JSON.stringify({ summary: fullSummary, sessionStatus: "finished", statusReason: "all_requests_accomplished" })
|
|
123
|
+
: `INTERACTIVE_${interactiveNumber}_DONE`;
|
|
124
|
+
const entry = { kind: isSummary ? "summary" : "interactive", body: payload, text, requestAt: Date.now(), responseAt: Date.now() };
|
|
125
|
+
requests.push(entry);
|
|
126
|
+
await writeFile(requestLog, `${JSON.stringify(entry)}\n`, { flag: "a", mode: 0o600 });
|
|
127
|
+
const id = `rarebit-v020-${requests.length}`;
|
|
128
|
+
const first = JSON.stringify({ id, object: "chat.completion.chunk", choices: [{ index: 0, delta: { role: "assistant", content: text }, finish_reason: null }] });
|
|
129
|
+
const finish = JSON.stringify({ id, object: "chat.completion.chunk", choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } });
|
|
130
|
+
response.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
|
|
131
|
+
response.write(`data: ${first}\n\n`);
|
|
132
|
+
response.write(`data: ${finish}\n\n`);
|
|
133
|
+
response.end("data: [DONE]\n\n");
|
|
134
|
+
});
|
|
135
|
+
await new Promise((resolvePromise, reject) => {
|
|
136
|
+
provider.once("error", reject);
|
|
137
|
+
provider.listen(0, "127.0.0.1", resolvePromise);
|
|
138
|
+
});
|
|
139
|
+
const port = provider.address().port;
|
|
140
|
+
|
|
141
|
+
await writeFile(join(agentDir, "models.json"), JSON.stringify({
|
|
142
|
+
providers: {
|
|
143
|
+
local: {
|
|
144
|
+
baseUrl: `http://127.0.0.1:${port}/v1`,
|
|
145
|
+
api: "openai-completions",
|
|
146
|
+
apiKey: "fixture",
|
|
147
|
+
models: [{ id: "fixture", name: "v0.2 fixture", reasoning: false, input: ["text"], contextWindow: 100_000, maxTokens: 512 }],
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
}, null, 2), { mode: 0o600 });
|
|
151
|
+
await writeFile(join(agentDir, "settings.json"), JSON.stringify({
|
|
152
|
+
theme: "dark",
|
|
153
|
+
other_extension: { preserve: true },
|
|
154
|
+
rarebit: {
|
|
155
|
+
model: "local/fixture",
|
|
156
|
+
auto_title: false,
|
|
157
|
+
min_total_length: 0,
|
|
158
|
+
max_rarebit_ratio: 1,
|
|
159
|
+
summary_prompt: "Use short factual bullets and state uncertainty.",
|
|
160
|
+
recap: { timezone: "Asia/Hong_Kong" },
|
|
161
|
+
},
|
|
162
|
+
}, null, 2), { mode: 0o600 });
|
|
163
|
+
await writeFile(join(projectDir, ".pi", "settings.json"), "{}\n", { mode: 0o600 });
|
|
164
|
+
|
|
165
|
+
await writeFile(fixtureExtension, `import registerPiRarebit from ${JSON.stringify(pathToFileURL(join(installedPackageRoot, "src/extension.mjs")).href)};
|
|
166
|
+
export default function registerFixtureRarebit(pi) {
|
|
167
|
+
return registerPiRarebit(pi, { sessionRoot: ${JSON.stringify(sessionDir)}, rarebitRoot: ${JSON.stringify(rarebitRoot)} });
|
|
168
|
+
}
|
|
169
|
+
`, { mode: 0o600 });
|
|
170
|
+
|
|
171
|
+
const childEnv = { ...process.env, PI_CODING_AGENT_DIR: agentDir, PI_OFFLINE: "1", PI_TELEMETRY: "0" };
|
|
172
|
+
const command = [
|
|
173
|
+
"env", `PI_CODING_AGENT_DIR=${agentDir}`, "PI_OFFLINE=1", "PI_TELEMETRY=0",
|
|
174
|
+
process.execPath, piCli, "--no-extensions", "--no-skills", "--no-prompt-templates", "--no-themes", "--no-context-files", "--approve",
|
|
175
|
+
"--extension", fixtureExtension, "--session-dir", sessionDir, "--model", "local/fixture", "--tui-mode", "regular",
|
|
176
|
+
];
|
|
177
|
+
const child = spawn(tmux, ["-L", socket, "new-session", "-d", "-x", "140", "-y", "48", "-s", session, ...command], { cwd: projectDir, env: childEnv, stdio: "ignore" });
|
|
178
|
+
await new Promise((resolvePromise, reject) => {
|
|
179
|
+
child.once("error", reject);
|
|
180
|
+
child.once("exit", (code) => code === 0 ? resolvePromise() : reject(new Error(`tmux exited ${code}`)));
|
|
181
|
+
});
|
|
182
|
+
tmuxStarted = true;
|
|
183
|
+
await waitFor("Pi editor", async () => (await pane(socket, session)).includes("0.0%/100k"));
|
|
184
|
+
await sleep(750);
|
|
185
|
+
|
|
186
|
+
await keys(socket, session, "first v020 recap turn");
|
|
187
|
+
await press(socket, session, "Enter");
|
|
188
|
+
await waitFor("interactive response", async () => (await pane(socket, session)).includes("INTERACTIVE_1_DONE"), 15_000);
|
|
189
|
+
const summaryResponse = await waitFor("custom Summary request", async () => {
|
|
190
|
+
const summary = requests.find((entry) => entry.kind === "summary");
|
|
191
|
+
return summary?.body?.messages?.some((message) => {
|
|
192
|
+
if (message?.role !== "user") return false;
|
|
193
|
+
const content = typeof message.content === "string"
|
|
194
|
+
? message.content
|
|
195
|
+
: JSON.stringify(message.content ?? "");
|
|
196
|
+
return content.includes("Use short factual bullets");
|
|
197
|
+
}) ? summary : false;
|
|
198
|
+
}, 15_000);
|
|
199
|
+
assert.ok(summaryResponse, "configured summary_prompt reached the provider");
|
|
200
|
+
const summaryAt = summaryResponse.responseAt;
|
|
201
|
+
|
|
202
|
+
const widgetPane = await waitFor("full Recap widget", async () => {
|
|
203
|
+
const output = await pane(socket, session);
|
|
204
|
+
return output.includes("FULL_RECAP_TAIL_24") ? output : false;
|
|
205
|
+
}, 75_000);
|
|
206
|
+
const widgetAnsi = await pane(socket, session, true);
|
|
207
|
+
assert.match(widgetPane, /Recap·appearsfinished·[0-9]{2}\/[0-9]{2} [0-9]{2}:[0-9]{2}\(GMT[+-][0-9][^\n]*\[got it\]/);
|
|
208
|
+
assert.match(widgetPane, /\[got it\]/);
|
|
209
|
+
assert.match(widgetPane, /FULL_RECAP_TAIL_1/);
|
|
210
|
+
assert.match(widgetPane, /FULL_RECAP_TAIL_24/);
|
|
211
|
+
assert.doesNotMatch(widgetPane, /recap expand/i);
|
|
212
|
+
assert.match(widgetAnsi, /\x1b\[[0-9;]*m/);
|
|
213
|
+
assert.match(widgetAnsi, /Recap/);
|
|
214
|
+
assert.match(widgetAnsi, /\x1b\[38;(?:5;241|2;102;102;102)m[^\n]*Recap/, "Recap header uses the muted foreground");
|
|
215
|
+
assert.match(widgetAnsi, /\x1b\[38;(?:5;244|2;128;128;128)m[^\n]*FULL_RECAP_TAIL_1/, "Recap body uses the faded foreground");
|
|
216
|
+
assert.ok(Date.now() - summaryAt >= 59_750, "Recap rendered before the one-minute default");
|
|
217
|
+
|
|
218
|
+
const requestCountAtWidget = requests.length;
|
|
219
|
+
await keys(socket, session, "typed while v020 Recap is visible");
|
|
220
|
+
const typingPane = await waitFor("typing preservation", async () => {
|
|
221
|
+
const output = await pane(socket, session);
|
|
222
|
+
return output.includes("typed while v020 Recap is visible") ? output : false;
|
|
223
|
+
});
|
|
224
|
+
assert.match(typingPane, /FULL_RECAP_TAIL_24/);
|
|
225
|
+
assert.equal(requests.length, requestCountAtWidget);
|
|
226
|
+
|
|
227
|
+
await press(socket, session, "Enter");
|
|
228
|
+
await waitFor("second provider request", async () => requests.filter((entry) => entry.kind === "interactive").length >= 2, 15_000);
|
|
229
|
+
const afterSubmit = await waitFor("second interactive response", async () => {
|
|
230
|
+
const output = await pane(socket, session);
|
|
231
|
+
return output.includes("INTERACTIVE_2_DONE") ? output : false;
|
|
232
|
+
}, 15_000);
|
|
233
|
+
|
|
234
|
+
const secondInteractive = requests.filter((entry) => entry.kind === "interactive")[1];
|
|
235
|
+
assert.doesNotMatch(JSON.stringify(secondInteractive.body), /FULL_RECAP_TAIL/);
|
|
236
|
+
const sessionFiles = (await walk(sessionDir)).filter((file) => file.endsWith(".jsonl"));
|
|
237
|
+
const sessionText = (await Promise.all(sessionFiles.map((file) => readFile(file, "utf8")))).join("\n");
|
|
238
|
+
const nativeEntries = sessionText.split("\n").filter(Boolean).map(JSON.parse);
|
|
239
|
+
assert.ok(nativeEntries.some((entry) => entry.type === "custom" && entry.customType === "rarebit-recap" && entry.data?.summary?.includes("FULL_RECAP_TAIL_24")), "the scrollable Recap is persisted as native custom evidence");
|
|
240
|
+
assert.ok(!nativeEntries.filter((entry) => entry.type === "message").some((entry) => JSON.stringify(entry).includes("FULL_RECAP_TAIL")), "Recap prose stays out of model conversation messages");
|
|
241
|
+
const settings = JSON.parse(await readFile(join(agentDir, "settings.json"), "utf8"));
|
|
242
|
+
assert.deepEqual(settings.other_extension, { preserve: true });
|
|
243
|
+
assert.equal(settings.rarebit.summary_prompt, "Use short factual bullets and state uncertainty.");
|
|
244
|
+
|
|
245
|
+
const evidence = { widgetPane, widgetAnsi, typingPane, afterSubmit, requests, sessionFiles, packageTarball: packageTarball ?? "source" };
|
|
246
|
+
const artifactLabel = packageTarball ? "packed artifact loaded by real Pi" : "source checkout loaded by real Pi";
|
|
247
|
+
if (keepTemp) await writeFile(join(temp, "evidence.json"), JSON.stringify(evidence, null, 2), { mode: 0o600 });
|
|
248
|
+
console.log(JSON.stringify({
|
|
249
|
+
ok: true,
|
|
250
|
+
artifact: packageTarball ?? "source",
|
|
251
|
+
pi: JSON.parse(await readFile(join(piRoot, "package.json"), "utf8")).version,
|
|
252
|
+
defaultDelayMs: 60_000,
|
|
253
|
+
elapsedFromSummaryMs: Date.now() - summaryAt,
|
|
254
|
+
providerRequests: requests.length,
|
|
255
|
+
evidenceRoot: keepTemp ? temp : null,
|
|
256
|
+
checks: [artifactLabel, "custom summary_prompt reached provider", "full multiline native Recap rendered", "ANSI themed Recap rendered", "typing preserved Recap", "native Recap evidence survived submit", "display content excluded from next provider conversation messages"],
|
|
257
|
+
}, null, 2));
|
|
258
|
+
} catch (error) {
|
|
259
|
+
console.error(`Rarebit v0.2 Pi E2E failed: ${error?.stack ?? error}`);
|
|
260
|
+
console.error(`Temporary evidence retained at ${temp}`);
|
|
261
|
+
throw error;
|
|
262
|
+
} finally {
|
|
263
|
+
if (tmuxStarted) {
|
|
264
|
+
await press(socket, session, "C-c").catch(() => {});
|
|
265
|
+
await sleep(100);
|
|
266
|
+
await press(socket, session, "C-c").catch(() => {});
|
|
267
|
+
await execFile(tmux, ["-L", socket, "kill-server"]).catch(() => {});
|
|
268
|
+
}
|
|
269
|
+
if (provider?.listening) await new Promise((resolvePromise) => provider.close(resolvePromise));
|
|
270
|
+
if (!keepTemp) await rm(temp, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
271
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Empirical Pi 0.84.2 renderer probe. Uses the native fullscreen renderer and
|
|
2
|
+
// a synthetic terminal adapter; no provider, credentials, or private sessions.
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { TuiAltScreen, ScrollView, Container, Text } from "@earendil-works/pi-tui";
|
|
5
|
+
import { createRarebitRecapController } from "../src/rarebit-recap.mjs";
|
|
6
|
+
import { selectRarebits } from "../src/rarebit-core.mjs";
|
|
7
|
+
|
|
8
|
+
const { renderLayoutFrame } = await import(new URL("layout.js", import.meta.resolve("@earendil-works/pi-tui")));
|
|
9
|
+
|
|
10
|
+
let input;
|
|
11
|
+
const terminal = {
|
|
12
|
+
columns: 80, rows: 12, kittyProtocolActive: false,
|
|
13
|
+
start(fn) { input = fn; }, stop() {}, write() {}, moveBy() {},
|
|
14
|
+
hideCursor() {}, showCursor() {}, clearLine() {}, clearFromCursor() {},
|
|
15
|
+
clearScreen() {}, setTitle() {}, setProgress() {},
|
|
16
|
+
};
|
|
17
|
+
const branch = [{ type: "message", id: "synthetic-owner", message: { role: "user", content: "Synthetic renderer probe" } }];
|
|
18
|
+
const selection = selectRarebits(branch);
|
|
19
|
+
const receipt = { status: "ok", sessionId: "synthetic-session", jobId: "synthetic-recap",
|
|
20
|
+
observedAt: new Date(0).toISOString(), sessionStatus: "finished", summary: "SCROLLABLE_RECAP_SENTINEL",
|
|
21
|
+
selection: { manifestHash: selection.manifestHash, selectorVersion: selection.manifest.selectorVersion } };
|
|
22
|
+
const ctx = { mode: "tui", ui: { setWidget() {} }, sessionManager: {
|
|
23
|
+
getHeader: () => ({ id: receipt.sessionId }), getSessionFile: () => "/tmp/synthetic-probe.jsonl", getBranch: () => branch,
|
|
24
|
+
} };
|
|
25
|
+
const controller = createRarebitRecapController({
|
|
26
|
+
appendEntry: (customType, data) => branch.push({ type: "custom", id: "synthetic-recap-entry", customType, data }),
|
|
27
|
+
readCurrent: async () => ({ receipt, artifactState: { syncState: "assessment_current", applicability: "exact_selection",
|
|
28
|
+
receiptRef: { jobId: receipt.jobId }, projection: { status: receipt.sessionStatus } } }),
|
|
29
|
+
});
|
|
30
|
+
assert.equal((await controller.showExisting(ctx)).shown, true);
|
|
31
|
+
const document = new Container();
|
|
32
|
+
document.addChild(controller.renderEntry(branch.at(-1), {}, { fg: (_color, text) => text }));
|
|
33
|
+
document.addChild(new Text(Array.from({ length: 30 }, (_, i) => `Synthetic update ${i}`).join("\n"), 0, 0));
|
|
34
|
+
const scroll = new ScrollView(document, { primary: true, follow: "none" });
|
|
35
|
+
const tui = new TuiAltScreen(terminal);
|
|
36
|
+
tui.setLayoutRoot(scroll);
|
|
37
|
+
const received = [];
|
|
38
|
+
tui.addInputListener((data) => { received.push(data); });
|
|
39
|
+
try {
|
|
40
|
+
tui.start(); tui.renderNow();
|
|
41
|
+
scroll.scrollToStart(); tui.renderNow();
|
|
42
|
+
const atTop = renderLayoutFrame(scroll, 80, 12, () => {}).lines;
|
|
43
|
+
assert.ok(atTop.some((line) => line.includes("SCROLLABLE_RECAP_SENTINEL")));
|
|
44
|
+
input("\x1b[<65;3;4M"); tui.renderNow();
|
|
45
|
+
const afterWheel = scroll.scrollTop;
|
|
46
|
+
assert.ok(afterWheel > 0, "native wheel events scroll the transcript");
|
|
47
|
+
input("\x1b[<0;3;2M"); input("\x1b[<0;3;2m"); input("g");
|
|
48
|
+
scroll.scrollToEnd(); tui.renderNow();
|
|
49
|
+
const atBottom = renderLayoutFrame(scroll, 80, 12, () => {}).lines;
|
|
50
|
+
assert.ok(!atBottom.some((line) => line.includes("SCROLLABLE_RECAP_SENTINEL")), "recap is not pinned");
|
|
51
|
+
console.log(JSON.stringify({ pi: "0.84.2", nativeRenderer: true, syntheticTerminal: true,
|
|
52
|
+
recapVisibleAtTop: true, recapVisibleAtBottom: false, wheelScrollTop: afterWheel,
|
|
53
|
+
keyboardReachedExtension: received.includes("g"),
|
|
54
|
+
mouseReachedExtension: received.some((data) => data.startsWith("\x1b[<")),
|
|
55
|
+
}, null, 2));
|
|
56
|
+
} finally { tui.stop(); controller.dispose(); }
|
|
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
|
|
|
3
3
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
4
4
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
|
-
import { basename, join } from "node:path";
|
|
6
|
+
import { basename, join, resolve } from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
|
|
9
9
|
const root = fileURLToPath(new URL("..", import.meta.url));
|
|
@@ -15,20 +15,26 @@ const required = [
|
|
|
15
15
|
"package/AGENTS.md", "package/CHANGELOG.md", "package/LICENSE",
|
|
16
16
|
"package/SECURITY.md", "package/bin/rarebit.mjs", "package/bin/piq.mjs",
|
|
17
17
|
"package/src/index.mjs", "package/src/rarebit-fork-lineage.mjs", "package/src/rarebit-fork.mjs", "package/src/types.d.ts",
|
|
18
|
+
"package/docs/examples/rarebit.settings.json",
|
|
18
19
|
"package/schemas/rarebit.schema.json", "package/test/rarebit-cli.test.mjs",
|
|
19
20
|
"package/scripts/e2e-recall-pi083.mjs", "package/scripts/e2e-recall-pi0842.mjs", "package/scripts/verify-package.mjs",
|
|
20
21
|
];
|
|
21
22
|
const forbiddenPath = /(^|\/)(\.git|node_modules|\.github|hc-rarebit\.mjs)(\/|$)|HyperCarrier|timeline|pi-team/i;
|
|
22
|
-
const allowedBare = new Set(["@earendil-works/pi-ai", "@earendil-works/pi-coding-agent"]);
|
|
23
|
+
const allowedBare = new Set(["@earendil-works/pi-ai", "@earendil-works/pi-coding-agent", "@earendil-works/pi-tui"]);
|
|
23
24
|
const packageFor = (specifier) => specifier.startsWith("@") ? specifier.split("/").slice(0, 2).join("/") : specifier.split("/")[0];
|
|
24
|
-
const packageJson = JSON.parse(
|
|
25
|
-
assert.equal(packageJson.version, "0.1.0-alpha.6", "release version mismatch");
|
|
25
|
+
const packageJson = JSON.parse(run("git", ["show", "HEAD:package.json"]));
|
|
26
26
|
assert.equal(packageJson.peerDependencies?.["@earendil-works/pi-ai"], ">=0.83.0", "Pi AI peer range mismatch");
|
|
27
|
-
for (const peer of ["@earendil-works/pi-ai", "@earendil-works/pi-coding-agent"])
|
|
27
|
+
for (const peer of ["@earendil-works/pi-ai", "@earendil-works/pi-coding-agent", "@earendil-works/pi-tui"])
|
|
28
28
|
assert.equal(packageJson.peerDependenciesMeta?.[peer]?.optional, true, `${peer} peer must remain optional`);
|
|
29
29
|
|
|
30
|
-
const
|
|
31
|
-
const
|
|
30
|
+
const suppliedTarball = process.env.RELEASE_TARBALL;
|
|
31
|
+
const packed = suppliedTarball
|
|
32
|
+
? { filename: basename(suppliedTarball), integrity: "provided-tarball" }
|
|
33
|
+
: JSON.parse(npm(["pack", "--json", "--ignore-scripts"]))[0];
|
|
34
|
+
const tarball = suppliedTarball ? resolve(root, suppliedTarball) : join(root, packed.filename);
|
|
35
|
+
const packedPackageJson = JSON.parse(run("tar", ["-xOf", tarball, "package/package.json"]));
|
|
36
|
+
assert.equal(packedPackageJson.name, packageJson.name, "packed package name differs from selected source");
|
|
37
|
+
assert.equal(packedPackageJson.version, packageJson.version, "packed package version differs from selected source");
|
|
32
38
|
const entries = run("tar", ["-tf", tarball]).trim().split("\n").filter(Boolean).sort();
|
|
33
39
|
for (const path of required) assert(entries.includes(path), `tarball lacks ${path}`);
|
|
34
40
|
for (const path of entries) assert(!forbiddenPath.test(path), `forbidden tar entry: ${path}`);
|
|
@@ -81,5 +87,5 @@ try {
|
|
|
81
87
|
process.stdout.write(`verified ${packed.filename} (${packed.integrity}) with ${entries.length} tar entries\n`);
|
|
82
88
|
} finally {
|
|
83
89
|
await rm(temp, { recursive: true, force: true });
|
|
84
|
-
await rm(tarball, { force: true });
|
|
90
|
+
if (!suppliedTarball) await rm(tarball, { force: true });
|
|
85
91
|
}
|