@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
package/src/types.d.ts
CHANGED
|
@@ -1,5 +1,77 @@
|
|
|
1
1
|
export type RarebitModel = { provider: string; id: string };
|
|
2
2
|
|
|
3
|
+
export type RarebitDiagnosticsSettings = {
|
|
4
|
+
summaryTriggered: boolean;
|
|
5
|
+
summaryUpdated: boolean;
|
|
6
|
+
};
|
|
7
|
+
export type RarebitRecapSettings = {
|
|
8
|
+
enabled: boolean;
|
|
9
|
+
delayMs: number;
|
|
10
|
+
timezone: string;
|
|
11
|
+
};
|
|
12
|
+
export type RarebitResolvedSettings = {
|
|
13
|
+
model?: RarebitModel;
|
|
14
|
+
modelConfigurationError?: string;
|
|
15
|
+
summaryPolicy: RarebitSummaryPolicy;
|
|
16
|
+
autoTitle: boolean;
|
|
17
|
+
summaryPrompt?: unknown;
|
|
18
|
+
maxInputTokens: number;
|
|
19
|
+
diagnostics: RarebitDiagnosticsSettings;
|
|
20
|
+
recap: RarebitRecapSettings;
|
|
21
|
+
};
|
|
22
|
+
export const DEFAULT_RAREBIT_RECAP_DELAY_MS: 60000;
|
|
23
|
+
export const DEFAULT_RAREBIT_RECAP_TIMEZONE: "host";
|
|
24
|
+
export const DEFAULT_RAREBIT_MAX_INPUT_TOKENS: 64000;
|
|
25
|
+
export const MAX_RAREBIT_INPUT_TOKENS: number;
|
|
26
|
+
export const DEFAULT_RAREBIT_DIAGNOSTICS: Readonly<RarebitDiagnosticsSettings>;
|
|
27
|
+
export const RAREBIT_SUMMARY_PROMPT_IDENTITY_VERSION: "rarebit-summary-prompt-v1";
|
|
28
|
+
export const MAX_RAREBIT_SUMMARY_PROMPT_CHARS: 64000;
|
|
29
|
+
export const DEFAULT_RAREBIT_SUMMARY_PROMPT_GUIDANCE: string;
|
|
30
|
+
export const MAX_RAREBIT_SUMMARY_CHARS: 8000;
|
|
31
|
+
export function normalizeRarebitSummaryPrompt(value?: unknown): {
|
|
32
|
+
guidance: string;
|
|
33
|
+
promptIdentity: string | null;
|
|
34
|
+
};
|
|
35
|
+
export function mergeRarebitSettings(
|
|
36
|
+
globalSettings?: Record<string, unknown>,
|
|
37
|
+
projectSettings?: Record<string, unknown>,
|
|
38
|
+
): Record<string, unknown>;
|
|
39
|
+
export function normalizeRarebitRecapTimezone(value?: unknown): string;
|
|
40
|
+
export function resolveRarebitSettings(
|
|
41
|
+
globalSettings?: Record<string, unknown>,
|
|
42
|
+
projectSettings?: Record<string, unknown>,
|
|
43
|
+
): RarebitResolvedSettings;
|
|
44
|
+
|
|
45
|
+
/** Seeded destination entries, including the generated fork notice. */
|
|
46
|
+
export function getImportedRarebitEntryIds(entries: readonly unknown[]): Set<string>;
|
|
47
|
+
export const RAREBIT_FORK_LINEAGE_SCHEMA_VERSION: 1;
|
|
48
|
+
export const RAREBIT_FORK_ENTRY_MARKER_VERSION: "rarebit-fork-entry/1";
|
|
49
|
+
export const RAREBIT_FORK_LINEAGE_CUSTOM_TYPE: "rarebit-fork-lineage";
|
|
50
|
+
export const RAREBIT_FORK_OPENING_MARKER: "rarebit-fork-opening-v1";
|
|
51
|
+
export const RAREBIT_FORK_DEFAULT_MAX_TOKEN_LENGTH: 64000;
|
|
52
|
+
export const RAREBIT_FORK_DEFAULT_RESERVE_TOKENS: 16384;
|
|
53
|
+
export type RarebitForkHeadroom = {
|
|
54
|
+
seedTokens: number;
|
|
55
|
+
overheadTokens: number;
|
|
56
|
+
requiredTokens: number;
|
|
57
|
+
contextWindow: number | null;
|
|
58
|
+
reserveTokens: number;
|
|
59
|
+
availableTokens: number | null;
|
|
60
|
+
basis: string;
|
|
61
|
+
};
|
|
62
|
+
/** A null context window yields an estimate without admission. Runtime adapters must require a known window before creating a Session. */
|
|
63
|
+
export function checkRarebitForkHeadroom(options: {
|
|
64
|
+
seedText: string;
|
|
65
|
+
targetModel?: RarebitModel | null;
|
|
66
|
+
contextWindow?: number | null;
|
|
67
|
+
reserveTokens?: number;
|
|
68
|
+
promptOverheadTokens?: number;
|
|
69
|
+
toolOverheadTokens?: number;
|
|
70
|
+
messageRoundingOverheadTokens?: number;
|
|
71
|
+
}): RarebitForkHeadroom;
|
|
72
|
+
export function getRarebitForkSeedEntryIds(entries: readonly unknown[]): Set<string>;
|
|
73
|
+
export function getRarebitForkLineageRecords(entries: readonly unknown[]): readonly Record<string, unknown>[];
|
|
74
|
+
|
|
3
75
|
export type RarebitAutomaticSummaryPolicyDecision =
|
|
4
76
|
| {
|
|
5
77
|
contractVersion: "rarebit-automatic-summary-policy/1";
|
|
@@ -119,6 +191,7 @@ export function projectRarebitSessionActivity(selection: {
|
|
|
119
191
|
occurrences?: RarebitOccurrence[];
|
|
120
192
|
manifest?: { selectorVersion?: string };
|
|
121
193
|
manifestHash?: string;
|
|
194
|
+
entries?: readonly unknown[];
|
|
122
195
|
}): RarebitSessionActivity;
|
|
123
196
|
|
|
124
197
|
export type RarebitVisualTone =
|
|
@@ -196,14 +269,20 @@ export function composeRarebitSummaryDerivationInput(
|
|
|
196
269
|
selection: ReturnType<typeof selectRarebits>,
|
|
197
270
|
options?: {
|
|
198
271
|
promptVersion?: string;
|
|
272
|
+
summaryPrompt?: unknown;
|
|
199
273
|
lifecycleBoundary?: "owner_request" | "agent_settled" | "manual";
|
|
200
274
|
maxPromptChars?: number;
|
|
201
|
-
}
|
|
202
|
-
|
|
275
|
+
}): {
|
|
276
|
+
prompt: string;
|
|
277
|
+
promptIdentity: string | null;
|
|
278
|
+
promptVersion: string;
|
|
279
|
+
coverage: RarebitSummaryInputCoverage;
|
|
280
|
+
};
|
|
203
281
|
export function composeRarebitSummaryPrompt(
|
|
204
282
|
selection: ReturnType<typeof selectRarebits>,
|
|
205
283
|
options?: {
|
|
206
284
|
promptVersion?: string;
|
|
285
|
+
summaryPrompt?: unknown;
|
|
207
286
|
lifecycleBoundary?: "owner_request" | "agent_settled" | "manual";
|
|
208
287
|
maxPromptChars?: number;
|
|
209
288
|
},
|
|
@@ -211,7 +290,10 @@ export function composeRarebitSummaryPrompt(
|
|
|
211
290
|
export function composeRarebitTitlePrompt(
|
|
212
291
|
selection: ReturnType<typeof selectRarebits>,
|
|
213
292
|
): string;
|
|
214
|
-
export function normalizeRarebitSummary(
|
|
293
|
+
export function normalizeRarebitSummary(
|
|
294
|
+
value: unknown,
|
|
295
|
+
options?: { maxChars?: number },
|
|
296
|
+
): string;
|
|
215
297
|
export function normalizeRarebitSummarySynthesis(
|
|
216
298
|
value: unknown,
|
|
217
299
|
options?: { maxChars?: number },
|
|
@@ -364,11 +446,17 @@ export type RarebitNativeObservation =
|
|
|
364
446
|
|
|
365
447
|
export const RAREBIT_SUMMARY_SCHEMA_VERSION: 4;
|
|
366
448
|
export const RAREBIT_SUMMARY_IMPLEMENTATION_VERSION: "hc-rarebit-summary-v6";
|
|
449
|
+
export const RAREBIT_INPUT_CHARS_PER_TOKEN: 4;
|
|
450
|
+
export const DEFAULT_RAREBIT_MAX_PROMPT_CHARS: 256000;
|
|
367
451
|
export function processRarebitSummary(
|
|
368
452
|
ctx: unknown,
|
|
369
453
|
config?: {
|
|
370
454
|
model?: RarebitModel;
|
|
371
455
|
summaryPolicy?: RarebitSummaryPolicy;
|
|
456
|
+
summaryPrompt?: unknown;
|
|
457
|
+
maxInputTokens?: number;
|
|
458
|
+
maxPromptChars?: number;
|
|
459
|
+
diagnostics?: RarebitDiagnosticsSettings;
|
|
372
460
|
forceSynthesis?: boolean;
|
|
373
461
|
// New derivation accepts only evidence-transition or explicit boundaries;
|
|
374
462
|
// RarebitSummaryReceiptV4 retains session_start for historical reads.
|
|
@@ -532,6 +532,7 @@ test("TUI notices state input cardinality, clearly estimated trigger tokens, and
|
|
|
532
532
|
rarebitRoot: join(root, "rarebit"),
|
|
533
533
|
summaryPolicy: { minTotalLength: 0, maxRarebitRatio: 1 },
|
|
534
534
|
model: { provider: "test-provider", id: "cheap-model" },
|
|
535
|
+
diagnostics: { summaryTriggered: true, summaryUpdated: true },
|
|
535
536
|
modelClient: {
|
|
536
537
|
complete: async () => ({
|
|
537
538
|
text: JSON.stringify({
|
|
@@ -588,6 +589,7 @@ test("updated notice never substitutes the local estimate for absent provider us
|
|
|
588
589
|
rarebitRoot: join(root, "rarebit"),
|
|
589
590
|
summaryPolicy: { minTotalLength: 0, maxRarebitRatio: 1 },
|
|
590
591
|
model: { provider: "requested-provider", id: "requested-model" },
|
|
592
|
+
diagnostics: { summaryTriggered: true, summaryUpdated: true },
|
|
591
593
|
modelClient: {
|
|
592
594
|
complete: async () => ({
|
|
593
595
|
text: JSON.stringify({
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
4
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join, resolve } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
10
|
+
const verifier = join(root, "scripts/verify-package.mjs");
|
|
11
|
+
|
|
12
|
+
function syntheticTarball(field, value) {
|
|
13
|
+
const work = mkdtempSync(join(tmpdir(), "rarebit-artifact-test-"));
|
|
14
|
+
const packageDir = join(work, "package");
|
|
15
|
+
execFileSync("mkdir", ["-p", packageDir]);
|
|
16
|
+
const packageJson = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
17
|
+
packageJson[field] = value;
|
|
18
|
+
writeFileSync(join(packageDir, "package.json"), `${JSON.stringify(packageJson)}\n`);
|
|
19
|
+
const tarball = join(work, `${field}.tgz`);
|
|
20
|
+
execFileSync("tar", ["-czf", tarball, "-C", work, "package"]);
|
|
21
|
+
return { work, tarball };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function runVerifier(tarball) {
|
|
25
|
+
return spawnSync(process.execPath, [verifier], {
|
|
26
|
+
cwd: root,
|
|
27
|
+
env: { ...process.env, RELEASE_TARBALL: tarball },
|
|
28
|
+
encoding: "utf8",
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
test("rejects packed name and version changes against selected source", () => {
|
|
33
|
+
for (const [field, value] of [["name", "invalid-name"], ["version", "0.0.0-invalid-test"]]) {
|
|
34
|
+
const fixture = syntheticTarball(field, value);
|
|
35
|
+
try {
|
|
36
|
+
const result = runVerifier(fixture.tarball);
|
|
37
|
+
assert.notEqual(result.status, 0, `${field} mutation was accepted`);
|
|
38
|
+
assert.match(`${result.stdout}\n${result.stderr}`, /packed package (name|version) differs from selected source/);
|
|
39
|
+
} finally {
|
|
40
|
+
rmSync(fixture.work, { recursive: true, force: true });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("accepts the normal packed artifact", () => {
|
|
46
|
+
const filename = JSON.parse(execFileSync("npm", ["pack", "--json", "--ignore-scripts"], { cwd: root, encoding: "utf8" }))[0].filename;
|
|
47
|
+
const tarball = join(root, filename);
|
|
48
|
+
try {
|
|
49
|
+
const result = runVerifier(tarball);
|
|
50
|
+
assert.equal(result.status, 0, result.stderr);
|
|
51
|
+
} finally {
|
|
52
|
+
rmSync(tarball, { force: true });
|
|
53
|
+
}
|
|
54
|
+
});
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { readFile, writeFile, mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { readPiSession } from "../src/pi-session.mjs";
|
|
8
|
+
import {
|
|
9
|
+
queryPiQ,
|
|
10
|
+
queryPiSessionBranches,
|
|
11
|
+
} from "../src/piq.mjs";
|
|
12
|
+
import { parsePiQCliArgs } from "../src/piq-cli.mjs";
|
|
13
|
+
|
|
14
|
+
const records = [
|
|
15
|
+
{
|
|
16
|
+
type: "session",
|
|
17
|
+
version: 3,
|
|
18
|
+
id: "piq-fixture",
|
|
19
|
+
timestamp: "2026-09-21T00:00:00.000Z",
|
|
20
|
+
cwd: "/private/project",
|
|
21
|
+
unknownHeader: { retained: true },
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
type: "message",
|
|
25
|
+
id: "root",
|
|
26
|
+
parentId: null,
|
|
27
|
+
timestamp: "2026-09-21T00:00:01.000Z",
|
|
28
|
+
message: { role: "user", content: "root" },
|
|
29
|
+
unknownEntryField: "kept",
|
|
30
|
+
_piq: { source: "native" },
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
type: "message",
|
|
34
|
+
id: "tool-call",
|
|
35
|
+
parentId: "root",
|
|
36
|
+
timestamp: "2026-09-21T00:00:02.000Z",
|
|
37
|
+
message: {
|
|
38
|
+
role: "assistant",
|
|
39
|
+
content: [
|
|
40
|
+
"unknown-content-block",
|
|
41
|
+
{ type: "text", text: "calling" },
|
|
42
|
+
{ type: "toolCall", id: "call-1", name: "search", arguments: { q: "x" } },
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
type: "compaction",
|
|
48
|
+
id: "compact",
|
|
49
|
+
parentId: "tool-call",
|
|
50
|
+
timestamp: "2026-09-21T00:00:03.000Z",
|
|
51
|
+
firstKeptEntryId: "tool-call",
|
|
52
|
+
summary: "older context",
|
|
53
|
+
futureField: { retained: "raw" },
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
type: "message",
|
|
57
|
+
id: "tool-result",
|
|
58
|
+
parentId: "compact",
|
|
59
|
+
timestamp: "2026-09-21T00:00:04.000Z",
|
|
60
|
+
message: {
|
|
61
|
+
role: "toolResult",
|
|
62
|
+
toolCallId: "call-1",
|
|
63
|
+
content: [{ type: "text", text: "result" }],
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
type: "message",
|
|
68
|
+
id: "active",
|
|
69
|
+
parentId: "tool-result",
|
|
70
|
+
timestamp: "2026-09-21T00:00:05.000Z",
|
|
71
|
+
message: { role: "assistant", stopReason: "stop", content: "done" },
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
type: "message",
|
|
75
|
+
id: "sibling",
|
|
76
|
+
parentId: "root",
|
|
77
|
+
timestamp: "2026-09-21T00:00:06.000Z",
|
|
78
|
+
message: { role: "user", content: "sibling" },
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
async function fixture(testContext) {
|
|
83
|
+
const root = await mkdtemp(join(tmpdir(), "piq-"));
|
|
84
|
+
testContext.after(() => rm(root, { recursive: true, force: true }));
|
|
85
|
+
const file = join(root, "session.jsonl");
|
|
86
|
+
const source = records.map(JSON.stringify).join("\n") + "\n";
|
|
87
|
+
await writeFile(file, source);
|
|
88
|
+
return { file, root, source };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
test("generic reader resolves the default and explicit branches without timestamps", async (t) => {
|
|
92
|
+
const { file } = await fixture(t);
|
|
93
|
+
const loaded = await readPiSession(file);
|
|
94
|
+
assert.deepEqual(loaded.branch.map((entry) => entry.id), ["root", "sibling"]);
|
|
95
|
+
const active = await readPiSession(file, { leafId: "active" });
|
|
96
|
+
assert.deepEqual(active.branch.map((entry) => entry.id), [
|
|
97
|
+
"root",
|
|
98
|
+
"tool-call",
|
|
99
|
+
"compact",
|
|
100
|
+
"tool-result",
|
|
101
|
+
"active",
|
|
102
|
+
]);
|
|
103
|
+
const branches = queryPiSessionBranches(active);
|
|
104
|
+
assert.deepEqual(
|
|
105
|
+
branches.map((branch) => branch.leafId),
|
|
106
|
+
["active", "sibling"],
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("PiQ preserves raw fields and filters evidence", async (t) => {
|
|
111
|
+
const { file, source } = await fixture(t);
|
|
112
|
+
const entries = await queryPiQ(file, { kind: "entries", leafId: "active" });
|
|
113
|
+
assert.equal(entries[0].entry.unknownEntryField, "kept");
|
|
114
|
+
assert.equal(entries[0].entry._piq.source, "native");
|
|
115
|
+
assert.equal(entries[1]._piq.branchLeafId, "active");
|
|
116
|
+
const roles = await queryPiQ(file, { kind: "entries", leafId: "active", role: "toolResult" });
|
|
117
|
+
assert.deepEqual(roles.map((record) => record.entry.id), ["tool-result"]);
|
|
118
|
+
const tools = await queryPiQ(file, { kind: "tools", leafId: "active" });
|
|
119
|
+
assert.deepEqual(tools.map((record) => record._piq.kind), ["tool-call", "tool-result"]);
|
|
120
|
+
assert.equal(tools[0]._piq.blockIndex, 2);
|
|
121
|
+
const boundaries = await queryPiQ(file, { kind: "compactions", leafId: "active" });
|
|
122
|
+
assert.equal(boundaries[0].entry.futureField.retained, "raw");
|
|
123
|
+
const filteredBoundary = await queryPiQ(file, {
|
|
124
|
+
kind: "compactions",
|
|
125
|
+
leafId: "active",
|
|
126
|
+
entryId: "compact",
|
|
127
|
+
from: "2026-09-21T00:00:03.000Z",
|
|
128
|
+
to: "2026-09-21T00:00:03.000Z",
|
|
129
|
+
});
|
|
130
|
+
assert.equal(filteredBoundary.length, 1);
|
|
131
|
+
const prior = await queryPiQ(file, {
|
|
132
|
+
kind: "entries",
|
|
133
|
+
leafId: "active",
|
|
134
|
+
throughCompaction: "compact",
|
|
135
|
+
});
|
|
136
|
+
assert.deepEqual(prior.map((record) => record.entry.id), ["root", "tool-call", "compact"]);
|
|
137
|
+
const dated = await queryPiQ(file, {
|
|
138
|
+
kind: "entries",
|
|
139
|
+
leafId: "active",
|
|
140
|
+
from: "2026-09-21T00:00:04.000Z",
|
|
141
|
+
});
|
|
142
|
+
assert.deepEqual(dated.map((record) => record.entry.id), ["tool-result", "active"]);
|
|
143
|
+
const epochRange = await queryPiQ(file, {
|
|
144
|
+
kind: "entries",
|
|
145
|
+
leafId: "active",
|
|
146
|
+
from: "1970-01-01T00:00:00.000Z",
|
|
147
|
+
to: "1970-01-01T00:00:00.000Z",
|
|
148
|
+
});
|
|
149
|
+
assert.deepEqual(epochRange, []);
|
|
150
|
+
assert.equal(await readFile(file, "utf8"), source);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("PiQ CLI emits JSONL and rejects malformed or ambiguous branch choices", async (t) => {
|
|
154
|
+
const { file, root } = await fixture(t);
|
|
155
|
+
const parsed = parsePiQCliArgs([
|
|
156
|
+
"entries",
|
|
157
|
+
"--session",
|
|
158
|
+
file,
|
|
159
|
+
"--leaf",
|
|
160
|
+
"sibling",
|
|
161
|
+
"--role",
|
|
162
|
+
"user",
|
|
163
|
+
]);
|
|
164
|
+
assert.equal(parsed.leafId, "sibling");
|
|
165
|
+
const cli = new URL("../bin/piq.mjs", import.meta.url);
|
|
166
|
+
const output = spawnSync(
|
|
167
|
+
process.execPath,
|
|
168
|
+
[cli.pathname, "entries", "--session", file, "--leaf", "sibling"],
|
|
169
|
+
{ encoding: "utf8" },
|
|
170
|
+
);
|
|
171
|
+
assert.equal(output.status, 0, output.stderr);
|
|
172
|
+
assert.deepEqual(
|
|
173
|
+
output.stdout.trim().split("\n").map((line) => JSON.parse(line).entry.id),
|
|
174
|
+
["root", "sibling"],
|
|
175
|
+
);
|
|
176
|
+
const unsupported = spawnSync(
|
|
177
|
+
process.execPath,
|
|
178
|
+
[cli.pathname, "branches", "--session", file, "--role", "user"],
|
|
179
|
+
{ encoding: "utf8" },
|
|
180
|
+
);
|
|
181
|
+
assert.notEqual(unsupported.status, 0);
|
|
182
|
+
assert.match(unsupported.stderr, /Branch listing does not support filters/);
|
|
183
|
+
await assert.rejects(
|
|
184
|
+
queryPiQ(file, { kind: "entries", leafId: "missing" }),
|
|
185
|
+
/no Session entry with leaf ID missing/i,
|
|
186
|
+
);
|
|
187
|
+
await assert.rejects(
|
|
188
|
+
queryPiQ(file, { kind: "entries", from: "not-a-date" }),
|
|
189
|
+
/ISO-8601 timestamp/,
|
|
190
|
+
);
|
|
191
|
+
await assert.rejects(
|
|
192
|
+
queryPiQ(file, { kind: "branches", role: "user" }),
|
|
193
|
+
/does not support filters/,
|
|
194
|
+
);
|
|
195
|
+
await assert.rejects(
|
|
196
|
+
queryPiQ(file, { kind: "branches", leafId: "active" }),
|
|
197
|
+
/does not support --leaf/,
|
|
198
|
+
);
|
|
199
|
+
const malformed = join(root, "malformed.jsonl");
|
|
200
|
+
await writeFile(malformed, `${JSON.stringify(records[0])}\n{bad\n`);
|
|
201
|
+
await assert.rejects(queryPiQ(malformed), /malformed JSON/);
|
|
202
|
+
const cycle = join(root, "cycle.jsonl");
|
|
203
|
+
await writeFile(
|
|
204
|
+
cycle,
|
|
205
|
+
[
|
|
206
|
+
{ type: "session", version: 3, id: "cycle" },
|
|
207
|
+
{ type: "message", id: "a", parentId: "b", message: { role: "user", content: "a" } },
|
|
208
|
+
{ type: "message", id: "b", parentId: "a", message: { role: "user", content: "b" } },
|
|
209
|
+
].map(JSON.stringify).join("\n"),
|
|
210
|
+
);
|
|
211
|
+
await assert.rejects(queryPiQ(cycle, { kind: "branches" }), /cycle/);
|
|
212
|
+
});
|
|
@@ -14,11 +14,11 @@ const values = (prefix) =>
|
|
|
14
14
|
test("one grammar derives command description and usage", () => {
|
|
15
15
|
assert.equal(
|
|
16
16
|
rarebitCommandDescription(),
|
|
17
|
-
"Rarebit status/recall/config/auto-title/title/summarize",
|
|
17
|
+
"Rarebit menu/settings/help/status/recall/fork/config/auto-title/title/recap/summarize",
|
|
18
18
|
);
|
|
19
19
|
assert.equal(
|
|
20
20
|
rarebitCommandUsage(),
|
|
21
|
-
"Usage: /rarebit [status|recall|config|auto-title|title|summarize]",
|
|
21
|
+
"Usage: /rarebit [menu|settings|help|status|recall|fork|config|auto-title|title|recap|summarize]",
|
|
22
22
|
);
|
|
23
23
|
assert.equal(
|
|
24
24
|
rarebitCommandUsage("config"),
|
|
@@ -41,9 +41,11 @@ test("one grammar derives command description and usage", () => {
|
|
|
41
41
|
test("parser accepts exactly the command grammar", () => {
|
|
42
42
|
assert.deepEqual(parseRarebitCommand(""), {
|
|
43
43
|
ok: true,
|
|
44
|
-
subcommand: "
|
|
44
|
+
subcommand: "menu",
|
|
45
45
|
arguments: [],
|
|
46
46
|
});
|
|
47
|
+
assert.equal(parseRarebitCommand("fork").ok, true);
|
|
48
|
+
assert.equal(parseRarebitCommand("fork --max-token-length 64000").ok, true);
|
|
47
49
|
assert.equal(parseRarebitCommand("config").ok, true);
|
|
48
50
|
assert.equal(parseRarebitCommand("config max_rarebit_ratio 0.4").ok, true);
|
|
49
51
|
assert.equal(parseRarebitCommand("config min_total_length 80000").ok, true);
|
|
@@ -91,14 +93,19 @@ test("parser accepts exactly the command grammar", () => {
|
|
|
91
93
|
|
|
92
94
|
test("autocomplete covers subcommands and discrete nested arguments", () => {
|
|
93
95
|
assert.deepEqual(values(""), [
|
|
96
|
+
"menu",
|
|
97
|
+
"settings",
|
|
98
|
+
"help",
|
|
94
99
|
"status",
|
|
95
100
|
"recall",
|
|
101
|
+
"fork",
|
|
96
102
|
"config",
|
|
97
103
|
"auto-title",
|
|
98
104
|
"title",
|
|
105
|
+
"recap",
|
|
99
106
|
"summarize",
|
|
100
107
|
]);
|
|
101
|
-
assert.deepEqual(values("s"), ["status", "summarize"]);
|
|
108
|
+
assert.deepEqual(values("s"), ["settings", "status", "summarize"]);
|
|
102
109
|
assert.deepEqual(values("config "), [
|
|
103
110
|
"config max_rarebit_ratio ",
|
|
104
111
|
"config min_total_length ",
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { mkdtemp } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import {
|
|
10
|
+
buildRarebitForkPlan,
|
|
11
|
+
checkRarebitForkHeadroom,
|
|
12
|
+
createRarebitForkEntries,
|
|
13
|
+
} from "../src/rarebit-fork.mjs";
|
|
14
|
+
import {
|
|
15
|
+
getImportedRarebitEntryIds,
|
|
16
|
+
getRarebitForkSeedEntryIds,
|
|
17
|
+
} from "../src/rarebit-fork-lineage.mjs";
|
|
18
|
+
import { projectRarebitSessionActivity } from "../src/rarebit-activity.mjs";
|
|
19
|
+
import registerPiRarebit from "../src/extension.mjs";
|
|
20
|
+
|
|
21
|
+
const entry = (id, message, order) => ({
|
|
22
|
+
type: "message", id, parentId: order ? `e${order - 1}` : null,
|
|
23
|
+
timestamp: `2026-09-21T00:00:0${order}.000Z`, message,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("fork keeps a maximal newest suffix under aggregate chars/4 and preserves outcomes", () => {
|
|
27
|
+
const branch = [
|
|
28
|
+
entry("e0", { role: "user", content: "old" }, 0),
|
|
29
|
+
entry("e1", { role: "assistant", stopReason: "toolUse", content: "middle" }, 1),
|
|
30
|
+
entry("e2", { role: "assistant", stopReason: "stop", content: "newest" }, 2),
|
|
31
|
+
];
|
|
32
|
+
const plan = buildRarebitForkPlan({
|
|
33
|
+
header: { id: "source", cwd: "/source" }, sessionFile: "/source/s.jsonl",
|
|
34
|
+
cwd: "/source", branch, targetCwd: "/target", maxTokenLength: 2,
|
|
35
|
+
targetModel: { provider: "test", id: "target", contextWindow: 100_000 },
|
|
36
|
+
reserveTokens: 16_384,
|
|
37
|
+
});
|
|
38
|
+
assert.deepEqual(plan.selected.map((item) => item.sourceEntryId), ["e2"]);
|
|
39
|
+
assert.equal(plan.importedTokens, 2);
|
|
40
|
+
const built = createRarebitForkEntries(plan, { sessionId: "forked" });
|
|
41
|
+
const imported = built.entries.filter((item) => item.rarebitFork?.kind === "import");
|
|
42
|
+
assert.equal(imported[0].message.stopReason, "stop");
|
|
43
|
+
assert.equal(imported[0].message.provider, "rarebit-import");
|
|
44
|
+
assert.equal(imported[0].timestamp, plan.forkCreatedAt);
|
|
45
|
+
const modelChange = built.entries.at(-1);
|
|
46
|
+
assert.equal(modelChange.type, "model_change");
|
|
47
|
+
assert.deepEqual({ provider: modelChange.provider, id: modelChange.modelId }, { provider: "test", id: "target" });
|
|
48
|
+
assert.deepEqual(getImportedRarebitEntryIds(built.entries), new Set([built.entries[0].id, imported[0].id]));
|
|
49
|
+
assert.deepEqual(getRarebitForkSeedEntryIds(built.entries), new Set([built.entries[0].id]));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("fork marker stays machine-only in Pi context and activity excludes seeded entries", () => {
|
|
53
|
+
const branch = [entry("e0", { role: "user", content: "source" }, 0)];
|
|
54
|
+
const plan = buildRarebitForkPlan({
|
|
55
|
+
header: { id: "source", cwd: "/source" }, sessionFile: "/source/s.jsonl", cwd: "/source",
|
|
56
|
+
branch, targetCwd: "/target", targetModel: { provider: "test", id: "target", contextWindow: 100_000 },
|
|
57
|
+
});
|
|
58
|
+
const built = createRarebitForkEntries(plan, { sessionId: "forked" });
|
|
59
|
+
const seeded = built.entries[0];
|
|
60
|
+
assert.equal(sessionEntryToContextMessages(seeded)[0].role, "user");
|
|
61
|
+
assert.equal("rarebitFork" in sessionEntryToContextMessages(seeded)[0], false);
|
|
62
|
+
const activity = projectRarebitSessionActivity({ ...({ occurrences: [{ sourceEntryId: seeded.id, role: "user", outcome: "user", timestamp: seeded.timestamp }] }), entries: built.entries });
|
|
63
|
+
assert.equal(activity.latestUser, null);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("repeat forks preserve marker ancestry and apostrophe-safe PiQ recovery", () => {
|
|
67
|
+
const sourceBranch = [entry("e0", { role: "user", content: "source" }, 0)];
|
|
68
|
+
const sourcePlan = buildRarebitForkPlan({ header: { id: "source", cwd: "/source" }, sessionFile: "/tmp/a'b.jsonl", cwd: "/source", branch: sourceBranch, targetCwd: "/target", targetModel: { provider: "test", id: "target", contextWindow: 100_000 } });
|
|
69
|
+
const first = createRarebitForkEntries(sourcePlan, { sessionId: "first" });
|
|
70
|
+
const secondPlan = buildRarebitForkPlan({ header: first.header, sessionFile: "/target/first.jsonl", cwd: "/target", branch: first.entries.slice(0, -2), targetCwd: "/target", targetModel: { provider: "test", id: "target", contextWindow: 100_000 } });
|
|
71
|
+
assert.equal(secondPlan.selected.length, 1);
|
|
72
|
+
const second = createRarebitForkEntries(secondPlan, { sessionId: "second" });
|
|
73
|
+
assert.equal(second.manifest.mappings[0].ancestry.length, 2);
|
|
74
|
+
assert.equal(second.manifest.mappings[0].ancestry[0].sessionId, "source");
|
|
75
|
+
assert.equal(second.manifest.mappings[0].ancestry[1].sessionId, "first");
|
|
76
|
+
const recovery = first.entries[0].message.content.split("\n").find((line) => line.startsWith("Recover source"));
|
|
77
|
+
const command = recovery.slice(recovery.indexOf(": ") + 2).replace(/^piq entries/, "set --");
|
|
78
|
+
const shell = spawnSync("sh", ["-c", `${command}; printf '%s' \"$2\"`], { encoding: "utf8" });
|
|
79
|
+
assert.equal(shell.status, 0);
|
|
80
|
+
assert.equal(shell.stdout, "/tmp/a'b.jsonl");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("tool schema overhead can refuse when prose and prompt alone fit", () => {
|
|
84
|
+
assert.doesNotThrow(() => checkRarebitForkHeadroom({ seedText: "x", contextWindow: 100, reserveTokens: 90, promptOverheadTokens: 8, toolOverheadTokens: 0 }));
|
|
85
|
+
assert.throws(() => checkRarebitForkHeadroom({ seedText: "x", contextWindow: 100, reserveTokens: 90, promptOverheadTokens: 8, toolOverheadTokens: 3 }), /headroom|reserve/i);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("slash fork counts active Pi tool schemas before writing or switching", async () => {
|
|
89
|
+
const commands = new Map();
|
|
90
|
+
let switched = false;
|
|
91
|
+
const notifications = [];
|
|
92
|
+
const pi = {
|
|
93
|
+
on: () => {},
|
|
94
|
+
registerCommand: (name, command) => commands.set(name, command),
|
|
95
|
+
getActiveTools: () => ["large_tool"],
|
|
96
|
+
getAllTools: () => [{ name: "large_tool", description: "tool", parameters: { type: "object", properties: { payload: { type: "string", description: "x".repeat(2_000) } } } }],
|
|
97
|
+
};
|
|
98
|
+
registerPiRarebit(pi, { settingsLoader: async () => ({ reserveTokens: 0 }) });
|
|
99
|
+
const branch = [entry("source-entry", { role: "user", content: "source" }, 0)];
|
|
100
|
+
const ctx = {
|
|
101
|
+
hasUI: true, cwd: "/tmp", model: { provider: "test", id: "target", contextWindow: 100 },
|
|
102
|
+
getSystemPrompt: () => "", getActiveTools: () => ["large_tool"],
|
|
103
|
+
waitForIdle: async () => {}, isIdle: () => true,
|
|
104
|
+
ui: { notify: (message, level) => notifications.push({ message, level }) },
|
|
105
|
+
switchSession: async () => { switched = true; return { cancelled: false }; },
|
|
106
|
+
sessionManager: {
|
|
107
|
+
getSessionFile: () => "/tmp/source.jsonl", getHeader: () => ({ id: "source" }), getBranch: () => branch,
|
|
108
|
+
getLeafId: () => "source-entry", getSessionDir: () => "/tmp",
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
await commands.get("rarebit").handler("fork", ctx);
|
|
112
|
+
assert.equal(switched, false);
|
|
113
|
+
ctx.model = null;
|
|
114
|
+
await commands.get("rarebit").handler("fork", ctx);
|
|
115
|
+
assert.equal(switched, false);
|
|
116
|
+
assert.match(notifications.at(-1).message, /destination model\/context window is unavailable/);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("headroom refuses before a destination can be switched", () => {
|
|
120
|
+
assert.throws(() => buildRarebitForkPlan({
|
|
121
|
+
header: { id: "source" }, sessionFile: "/source/s.jsonl", branch: [entry("e0", { role: "user", content: "x" }, 0)],
|
|
122
|
+
maxTokenLength: 10, targetModel: { provider: "test", id: "tiny", contextWindow: 10 }, reserveTokens: 9,
|
|
123
|
+
}), /headroom|reserve/i);
|
|
124
|
+
});
|