@hypercarrier/rarebit 0.1.0-alpha.5 → 0.2.0
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 +24 -2
- package/README.md +96 -20
- package/RUNBOOK.md +32 -9
- package/bin/piq.mjs +14 -0
- package/docs/examples/rarebit.settings.json +19 -0
- package/package.json +29 -3
- package/scripts/e2e-fork-pi.mjs +824 -0
- package/scripts/e2e-recap-pi.mjs +456 -0
- package/scripts/e2e-settings-pi.mjs +267 -0
- package/scripts/e2e-v020-pi.mjs +269 -0
- package/scripts/verify-package.mjs +20 -12
- package/src/extension.mjs +280 -21
- package/src/index.mjs +1 -0
- package/src/lifecycle.mjs +32 -7
- package/src/pi-session.mjs +237 -0
- package/src/piq-cli.mjs +96 -0
- package/src/piq.mjs +265 -0
- package/src/rarebit-activity.mjs +8 -2
- package/src/rarebit-cli.mjs +103 -5
- package/src/rarebit-command.mjs +29 -1
- package/src/rarebit-core.mjs +87 -9
- package/src/rarebit-fork-lineage.mjs +69 -0
- package/src/rarebit-fork.mjs +241 -0
- package/src/rarebit-recap.mjs +422 -0
- package/src/rarebit-service.mjs +60 -7
- package/src/rarebit-session.mjs +12 -160
- package/src/rarebit-settings-ui.mjs +479 -0
- package/src/rarebit-settings.mjs +90 -7
- package/src/types.d.ts +91 -3
- package/test/lifecycle-contract-independent.test.mjs +2 -0
- package/test/package-artifact-identity.test.mjs +54 -0
- package/test/piq.test.mjs +212 -0
- package/test/rarebit-command.test.mjs +11 -4
- package/test/rarebit-fork.test.mjs +124 -0
- package/test/rarebit-recap.test.mjs +513 -0
- package/test/rarebit-settings-ui.test.mjs +388 -0
- package/test/rarebit-v020.test.mjs +294 -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,269 @@
|
|
|
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/);
|
|
208
|
+
assert.match(widgetPane, /FULL_RECAP_TAIL_1/);
|
|
209
|
+
assert.match(widgetPane, /FULL_RECAP_TAIL_24/);
|
|
210
|
+
assert.doesNotMatch(widgetPane, /recap expand/i);
|
|
211
|
+
assert.match(widgetAnsi, /\x1b\[[0-9;]*m/);
|
|
212
|
+
assert.match(widgetAnsi, /Recap/);
|
|
213
|
+
assert.match(widgetAnsi, /\x1b\[38;5;241m[^\n]*Recap/, "Recap header uses the muted foreground");
|
|
214
|
+
assert.match(widgetAnsi, /\x1b\[38;5;244m[^\n]*FULL_RECAP_TAIL_1/, "Recap body uses the faded foreground");
|
|
215
|
+
assert.ok(Date.now() - summaryAt >= 59_750, "Recap rendered before the one-minute default");
|
|
216
|
+
|
|
217
|
+
const requestCountAtWidget = requests.length;
|
|
218
|
+
await keys(socket, session, "typed while v020 Recap is visible");
|
|
219
|
+
const typingPane = await waitFor("typing preservation", async () => {
|
|
220
|
+
const output = await pane(socket, session);
|
|
221
|
+
return output.includes("typed while v020 Recap is visible") ? output : false;
|
|
222
|
+
});
|
|
223
|
+
assert.match(typingPane, /FULL_RECAP_TAIL_24/);
|
|
224
|
+
assert.equal(requests.length, requestCountAtWidget);
|
|
225
|
+
|
|
226
|
+
await press(socket, session, "Enter");
|
|
227
|
+
await waitFor("second provider request", async () => requests.filter((entry) => entry.kind === "interactive").length >= 2, 15_000);
|
|
228
|
+
const afterSubmit = await waitFor("Recap clear after submit", async () => {
|
|
229
|
+
const output = await pane(socket, session);
|
|
230
|
+
return !output.includes("FULL_RECAP_TAIL_24") ? output : false;
|
|
231
|
+
}, 5_000);
|
|
232
|
+
assert.doesNotMatch(afterSubmit, /FULL_RECAP_TAIL_24/);
|
|
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
|
+
assert.doesNotMatch(sessionText, /FULL_RECAP_TAIL/);
|
|
239
|
+
const settings = JSON.parse(await readFile(join(agentDir, "settings.json"), "utf8"));
|
|
240
|
+
assert.deepEqual(settings.other_extension, { preserve: true });
|
|
241
|
+
assert.equal(settings.rarebit.summary_prompt, "Use short factual bullets and state uncertainty.");
|
|
242
|
+
|
|
243
|
+
const evidence = { widgetPane, widgetAnsi, typingPane, afterSubmit, requests, sessionFiles, packageTarball: packageTarball ?? "source" };
|
|
244
|
+
const artifactLabel = packageTarball ? "packed artifact loaded by real Pi" : "source checkout loaded by real Pi";
|
|
245
|
+
if (keepTemp) await writeFile(join(temp, "evidence.json"), JSON.stringify(evidence, null, 2), { mode: 0o600 });
|
|
246
|
+
console.log(JSON.stringify({
|
|
247
|
+
ok: true,
|
|
248
|
+
artifact: packageTarball ?? "source",
|
|
249
|
+
pi: JSON.parse(await readFile(join(piRoot, "package.json"), "utf8")).version,
|
|
250
|
+
defaultDelayMs: 60_000,
|
|
251
|
+
elapsedFromSummaryMs: Date.now() - summaryAt,
|
|
252
|
+
providerRequests: requests.length,
|
|
253
|
+
evidenceRoot: keepTemp ? temp : null,
|
|
254
|
+
checks: [artifactLabel, "custom summary_prompt reached provider", "full multiline Recap tail rendered", "ANSI themed widget rendered", "typing preserved Recap", "submit cleared Recap", "display content excluded from next provider and Session"],
|
|
255
|
+
}, null, 2));
|
|
256
|
+
} catch (error) {
|
|
257
|
+
console.error(`Rarebit v0.2 Pi E2E failed: ${error?.stack ?? error}`);
|
|
258
|
+
console.error(`Temporary evidence retained at ${temp}`);
|
|
259
|
+
throw error;
|
|
260
|
+
} finally {
|
|
261
|
+
if (tmuxStarted) {
|
|
262
|
+
await press(socket, session, "C-c").catch(() => {});
|
|
263
|
+
await sleep(100);
|
|
264
|
+
await press(socket, session, "C-c").catch(() => {});
|
|
265
|
+
await execFile(tmux, ["-L", socket, "kill-server"]).catch(() => {});
|
|
266
|
+
}
|
|
267
|
+
if (provider?.listening) await new Promise((resolvePromise) => provider.close(resolvePromise));
|
|
268
|
+
if (!keepTemp) await rm(temp, { recursive: true, force: true });
|
|
269
|
+
}
|
|
@@ -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));
|
|
@@ -13,20 +13,28 @@ const npm = (args, cwd = root) => run("npm", args, cwd);
|
|
|
13
13
|
const required = [
|
|
14
14
|
"package/README.md", "package/RUNBOOK.md", "package/VISUAL-LANGUAGE.md",
|
|
15
15
|
"package/AGENTS.md", "package/CHANGELOG.md", "package/LICENSE",
|
|
16
|
-
"package/SECURITY.md", "package/bin/rarebit.mjs",
|
|
17
|
-
"package/src/index.mjs", "package/src/types.d.ts",
|
|
16
|
+
"package/SECURITY.md", "package/bin/rarebit.mjs", "package/bin/piq.mjs",
|
|
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"]);
|
|
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.5", "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", "@earendil-works/pi-tui"])
|
|
28
|
+
assert.equal(packageJson.peerDependenciesMeta?.[peer]?.optional, true, `${peer} peer must remain optional`);
|
|
27
29
|
|
|
28
|
-
const
|
|
29
|
-
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");
|
|
30
38
|
const entries = run("tar", ["-tf", tarball]).trim().split("\n").filter(Boolean).sort();
|
|
31
39
|
for (const path of required) assert(entries.includes(path), `tarball lacks ${path}`);
|
|
32
40
|
for (const path of entries) assert(!forbiddenPath.test(path), `forbidden tar entry: ${path}`);
|
|
@@ -35,7 +43,7 @@ assert(!entries.includes("package/bin/hc-rarebit.mjs"), "legacy CLI is packed");
|
|
|
35
43
|
const sourceFiles = entries.filter((path) => path.endsWith(".mjs") && !path.startsWith("package/test/"));
|
|
36
44
|
for (const entry of sourceFiles) {
|
|
37
45
|
const text = run("tar", ["-xOf", tarball, entry]);
|
|
38
|
-
for (const match of text.matchAll(/(
|
|
46
|
+
for (const match of text.matchAll(/(?:\bfrom\s+|import\s*\()\s*["']([^"']+)["']/g)) {
|
|
39
47
|
const specifier = match[1];
|
|
40
48
|
if (!specifier.startsWith(".") && !specifier.startsWith("node:") && !allowedBare.has(packageFor(specifier))) {
|
|
41
49
|
throw new Error(`undeclared runtime import ${specifier} in ${entry}`);
|
|
@@ -52,10 +60,10 @@ try {
|
|
|
52
60
|
npm(["init", "-y"], temp);
|
|
53
61
|
npm(["install", "--ignore-scripts", "--omit=dev", tarball], temp);
|
|
54
62
|
const installed = "@hypercarrier/rarebit";
|
|
55
|
-
for (const subpath of ["", "/core", "/session", "/service", "/artifact-state", "/visual-language", "/extension"]) {
|
|
63
|
+
for (const subpath of ["", "/core", "/session", "/service", "/artifact-state", "/fork", "/fork-lineage", "/visual-language", "/extension"]) {
|
|
56
64
|
run(process.execPath, ["--input-type=module", "-e", `await import(${JSON.stringify(installed + subpath)})`], temp);
|
|
57
65
|
}
|
|
58
|
-
const imports = ["", "/core", "/session", "/service", "/artifact-state", "/visual-language", "/extension"]
|
|
66
|
+
const imports = ["", "/core", "/session", "/service", "/artifact-state", "/fork", "/fork-lineage", "/visual-language", "/extension"]
|
|
59
67
|
.map((subpath) => `import ${JSON.stringify(installed + subpath)};`).join("\n");
|
|
60
68
|
await writeFile(join(temp, "imports.mts"), imports);
|
|
61
69
|
run(join(root, "node_modules/.bin/tsc"), ["--noEmit", "--module", "NodeNext", "--moduleResolution", "NodeNext", "--target", "ES2022", "--skipLibCheck", "imports.mts"], temp);
|
|
@@ -79,5 +87,5 @@ try {
|
|
|
79
87
|
process.stdout.write(`verified ${packed.filename} (${packed.integrity}) with ${entries.length} tar entries\n`);
|
|
80
88
|
} finally {
|
|
81
89
|
await rm(temp, { recursive: true, force: true });
|
|
82
|
-
await rm(tarball, { force: true });
|
|
90
|
+
if (!suppliedTarball) await rm(tarball, { force: true });
|
|
83
91
|
}
|