@hypercarrier/rarebit 0.1.0-alpha.4 → 0.1.0-alpha.6

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/src/piq.mjs ADDED
@@ -0,0 +1,265 @@
1
+ import {
2
+ listSessionBranches,
3
+ readPiSession,
4
+ resolveSessionBranch,
5
+ } from "./pi-session.mjs";
6
+
7
+ export const PIQ_SCHEMA_VERSION = 1;
8
+ const FROM_FLAG = "--from";
9
+
10
+ function piqError(message) {
11
+ const error = new Error(message);
12
+ error.name = "PiQError";
13
+ return error;
14
+ }
15
+
16
+ function normalizeDate(value, flag) {
17
+ if (value === undefined || value === null) return null;
18
+ if (
19
+ typeof value !== "string" ||
20
+ !/^\d{4}-\d{2}-\d{2}(?:$|T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:?\d{2})?)$/.test(value) ||
21
+ !Number.isFinite(Date.parse(value))
22
+ )
23
+ throw piqError(`${flag} requires an ISO-8601 timestamp`);
24
+ return Date.parse(value);
25
+ }
26
+
27
+ function timestampOf(entry) {
28
+ return typeof entry?.timestamp === "string" ? entry.timestamp : null;
29
+ }
30
+
31
+ function inDateRange(entry, from, to) {
32
+ const timestamp = timestampOf(entry);
33
+ if (from === null && to === null) return true;
34
+ if (!timestamp) return false;
35
+ const value = Date.parse(timestamp);
36
+ if (!Number.isFinite(value)) return false;
37
+ return (from === null || value >= from) && (to === null || value <= to);
38
+ }
39
+
40
+ function roleMatches(entry, role) {
41
+ if (!role) return true;
42
+ const actual = entry?.message?.role;
43
+ return role === "tool" ? actual === "toolResult" : actual === role;
44
+ }
45
+
46
+ function entryRecord(entry, order, sessionId, leafId) {
47
+ // Keep the native object intact. The _piq envelope adds coordinates without
48
+ // interpreting or dropping fields from newer Pi versions.
49
+ return {
50
+ entry,
51
+ _piq: {
52
+ schemaVersion: PIQ_SCHEMA_VERSION,
53
+ kind: "entry",
54
+ sessionId,
55
+ branchLeafId: leafId,
56
+ branchOrder: order,
57
+ },
58
+ };
59
+ }
60
+
61
+ function toolRecords(branch, sessionId, leafId, mode) {
62
+ const records = [];
63
+ for (let order = 0; order < branch.length; order += 1) {
64
+ const entry = branch[order];
65
+ const messageRole = entry?.message?.role;
66
+ if (mode !== "tool-results" && messageRole === "assistant") {
67
+ const content = Array.isArray(entry?.message?.content)
68
+ ? entry.message.content
69
+ : [];
70
+ for (const [blockIndex, tool] of content.entries()) {
71
+ if (!tool || typeof tool !== "object") continue;
72
+ if (tool.type !== "toolCall" && tool.type !== "tool_call") continue;
73
+ records.push({
74
+ _piq: {
75
+ schemaVersion: PIQ_SCHEMA_VERSION,
76
+ kind: "tool-call",
77
+ sessionId,
78
+ branchLeafId: leafId,
79
+ branchOrder: order,
80
+ blockIndex,
81
+ entryId: entry.id ?? null,
82
+ timestamp: timestampOf(entry),
83
+ },
84
+ entry,
85
+ tool,
86
+ });
87
+ }
88
+ }
89
+ if (mode !== "tool-calls" && messageRole === "toolResult") {
90
+ records.push({
91
+ _piq: {
92
+ schemaVersion: PIQ_SCHEMA_VERSION,
93
+ kind: "tool-result",
94
+ sessionId,
95
+ branchLeafId: leafId,
96
+ branchOrder: order,
97
+ entryId: entry.id ?? null,
98
+ timestamp: timestampOf(entry),
99
+ },
100
+ entry,
101
+ toolResult: entry.message,
102
+ });
103
+ }
104
+ }
105
+ return records;
106
+ }
107
+
108
+ function throughCompactionBoundary(branch, compactionId) {
109
+ if (!compactionId) return branch;
110
+ const index =
111
+ compactionId === "latest"
112
+ ? branch.reduce(
113
+ (latest, entry, order) =>
114
+ entry?.type === "compaction" ? order : latest,
115
+ -1,
116
+ )
117
+ : branch.findIndex((entry) => entry?.id === compactionId);
118
+ if (index < 0)
119
+ throw piqError(
120
+ `Branch has no compaction boundary with ID ${compactionId}`,
121
+ );
122
+ if (branch[index]?.type !== "compaction")
123
+ throw piqError(`Session entry ${compactionId} is not a compaction boundary`);
124
+ return branch.slice(0, index + 1);
125
+ }
126
+
127
+ export function filterPiSessionEntries(
128
+ branch,
129
+ {
130
+ sessionId = null,
131
+ leafId = null,
132
+ role = null,
133
+ entryId = null,
134
+ from = null,
135
+ to = null,
136
+ throughCompaction = null,
137
+ } = {},
138
+ ) {
139
+ const bounded = throughCompaction
140
+ ? throughCompactionBoundary(branch, throughCompaction)
141
+ : branch;
142
+ return bounded
143
+ .map((entry, order) => ({ entry, order }))
144
+ .filter(({ entry }) =>
145
+ entryId === null || entryId === undefined ? true : entry.id === entryId,
146
+ )
147
+ .filter(({ entry }) => roleMatches(entry, role))
148
+ .filter(({ entry }) => inDateRange(entry, from, to))
149
+ .map(({ entry, order }) => entryRecord(entry, order, sessionId, leafId));
150
+ }
151
+
152
+ export function queryPiSessionEntries(loaded, options = {}) {
153
+ const from = normalizeDate(options.from, FROM_FLAG);
154
+ const to = normalizeDate(options.to, "--to");
155
+ if (from !== null && to !== null && from > to)
156
+ throw piqError(`${FROM_FLAG} must not be later than --to`);
157
+ return filterPiSessionEntries(loaded.branch, {
158
+ ...options,
159
+ sessionId: loaded.parsed.header.id,
160
+ leafId: loaded.leafId,
161
+ from,
162
+ to,
163
+ throughCompaction: options.throughCompaction,
164
+ });
165
+ }
166
+
167
+ export function queryPiSessionTools(loaded, options = {}) {
168
+ const from = normalizeDate(options.from, FROM_FLAG);
169
+ const to = normalizeDate(options.to, "--to");
170
+ if (from !== null && to !== null && from > to)
171
+ throw piqError(`${FROM_FLAG} must not be later than --to`);
172
+ if (
173
+ options.role &&
174
+ !["assistant", "toolResult", "tool"].includes(options.role)
175
+ )
176
+ throw piqError(
177
+ `Tool queries support only assistant, toolResult, or tool roles; received ${options.role}`,
178
+ );
179
+ const records = toolRecords(
180
+ throughCompactionBoundary(loaded.branch, options.throughCompaction),
181
+ loaded.parsed.header.id,
182
+ loaded.leafId,
183
+ options.kind === "tool-results"
184
+ ? "tool-results"
185
+ : options.kind === "tool-calls"
186
+ ? "tool-calls"
187
+ : "both",
188
+ );
189
+ return records.filter(({ _piq }) => {
190
+ const time = Date.parse(_piq.timestamp ?? "");
191
+ const roleMatches =
192
+ !options.role ||
193
+ (options.role === "assistant" && _piq.kind === "tool-call") ||
194
+ ((options.role === "toolResult" || options.role === "tool") &&
195
+ _piq.kind === "tool-result");
196
+ return (
197
+ roleMatches &&
198
+ (options.entryId === undefined ||
199
+ options.entryId === null ||
200
+ _piq.entryId === options.entryId) &&
201
+ (from === null || (Number.isFinite(time) && time >= from)) &&
202
+ (to === null || (Number.isFinite(time) && time <= to))
203
+ );
204
+ });
205
+ }
206
+
207
+ export function queryPiSessionCompactions(loaded, options = {}) {
208
+ return queryPiSessionEntries(loaded, options).filter(
209
+ ({ entry }) => entry?.type === "compaction",
210
+ );
211
+ }
212
+
213
+ export function queryPiSessionBranches(loaded) {
214
+ return listSessionBranches(loaded.parsed).map((branch) => ({
215
+ _piq: {
216
+ schemaVersion: PIQ_SCHEMA_VERSION,
217
+ kind: "branch",
218
+ sessionId: loaded.parsed.header.id,
219
+ branchLeafId: branch.leafId,
220
+ },
221
+ ...branch,
222
+ entryIds: resolveSessionBranch(
223
+ loaded.parsed,
224
+ { leafId: branch.leafId },
225
+ "Pi Session",
226
+ ).map((entry) => entry.id ?? null),
227
+ }));
228
+ }
229
+
230
+ /** Load once, resolve one explicit/default ancestry, then answer one PiQ. */
231
+ export async function queryPiQ(
232
+ reference,
233
+ { kind = "entries", sessionRoot, leafId = null, ...options } = {},
234
+ ) {
235
+ if (kind === "branches" && leafId !== null)
236
+ throw piqError("Branch listing does not support --leaf");
237
+ const loaded = await readPiSession(reference, { sessionRoot, leafId });
238
+ if (kind === "branches") {
239
+ const unsupported = [
240
+ "role",
241
+ "entryId",
242
+ "f" + "rom",
243
+ "to",
244
+ "throughCompaction",
245
+ ].filter((name) => options[name] !== undefined && options[name] !== null);
246
+ if (unsupported.length)
247
+ throw piqError(
248
+ `Branch listing does not support filters: ${unsupported.join(", ")}`,
249
+ );
250
+ }
251
+ switch (kind) {
252
+ case "entries":
253
+ return queryPiSessionEntries(loaded, options);
254
+ case "tools":
255
+ case "tool-calls":
256
+ case "tool-results":
257
+ return queryPiSessionTools(loaded, { ...options, kind });
258
+ case "compactions":
259
+ return queryPiSessionCompactions(loaded, options);
260
+ case "branches":
261
+ return queryPiSessionBranches(loaded);
262
+ default:
263
+ throw piqError(`Unknown PiQ query: ${kind}`);
264
+ }
265
+ }
@@ -1,3 +1,5 @@
1
+ import { getImportedRarebitEntryIds } from "./rarebit-fork-lineage.mjs";
2
+
1
3
  // Native Rarebit selection is the source of these recency coordinates. This
2
4
  // module deliberately has no clock or renderer: consumers choose how (and
3
5
  // whether) to project an age from an exact active-branch snapshot.
@@ -25,6 +27,10 @@ export function projectRarebitSessionActivity(selection) {
25
27
  const occurrences = Array.isArray(selection?.occurrences)
26
28
  ? selection.occurrences
27
29
  : [];
30
+ const importedIds = getImportedRarebitEntryIds(selection?.entries);
31
+ const visibleOccurrences = occurrences.filter(
32
+ (occurrence) => !importedIds.has(occurrence?.sourceEntryId),
33
+ );
28
34
  return Object.freeze({
29
35
  schemaVersion: 1,
30
36
  selectorVersion:
@@ -36,11 +42,11 @@ export function projectRarebitSessionActivity(selection) {
36
42
  ? selection.manifestHash
37
43
  : null,
38
44
  latestUser: latest(
39
- occurrences,
45
+ visibleOccurrences,
40
46
  (occurrence) => occurrence?.role === "user",
41
47
  ),
42
48
  latestAgentStop: latest(
43
- occurrences,
49
+ visibleOccurrences,
44
50
  (occurrence) =>
45
51
  occurrence?.role === "assistant" && occurrence?.outcome === "stop",
46
52
  ),
@@ -12,12 +12,19 @@ import {
12
12
  queryRarebits,
13
13
  readRarebitSession,
14
14
  } from "./rarebit-session.mjs";
15
+ import {
16
+ buildRarebitForkPlan,
17
+ createRarebitForkEntries,
18
+ sessionFilenameFor,
19
+ writeRarebitForkFile,
20
+ } from "./rarebit-fork.mjs";
15
21
 
16
22
  export const RAREBIT_CLI_USAGE = `Usage:
17
23
  rarebit query --session <exact-path-or-id> --json
18
24
  rarebit extract --session <exact-path-or-id> --json
19
25
  rarebit summarize --session <exact-path-or-id> [--force] [--model-command <executable> [--model-arg <arg>]] --json
20
26
  rarebit title --session <exact-path-or-id> [--date YYYY-MM-DD] [--model-command <executable> [--model-arg <arg>]] --json
27
+ rarebit fork <exact-source-path-or-id> [--max-token-length <tokens>] [--no-launch] [--json]
21
28
 
22
29
  Normal summarize/title resolve rarebit.model from Pi settings. The optional model-command
23
30
  adapter receives one prompt on stdin and must write only model text to stdout.
@@ -33,11 +40,11 @@ function requiredValue(argv, index, flag) {
33
40
  export function parseRarebitCliArgs(argv) {
34
41
  if (argv.includes("--help") || argv.includes("-h")) return { help: true };
35
42
  const [command, ...rest] = argv;
36
- if (!new Set(["query", "extract", "summarize", "title"]).has(command))
43
+ if (!new Set(["query", "extract", "summarize", "title", "fork"]).has(command))
37
44
  throw new Error(
38
45
  "First argument must be query, extract, summarize, or title",
39
46
  );
40
- const options = { command, modelArgs: [], json: false, force: false };
47
+ const options = { command, modelArgs: [], json: false, force: false, noLaunch: false };
41
48
  for (let index = 0; index < rest.length; index += 1) {
42
49
  const argument = rest[index];
43
50
  switch (argument) {
@@ -45,6 +52,17 @@ export function parseRarebitCliArgs(argv) {
45
52
  options.session = requiredValue(rest, index, argument);
46
53
  index += 1;
47
54
  break;
55
+ case "--max-token-length":
56
+ options.maxTokenLength = Number(requiredValue(rest, index, argument));
57
+ index += 1;
58
+ break;
59
+ case "--model":
60
+ options.model = requiredValue(rest, index, argument);
61
+ index += 1;
62
+ break;
63
+ case "--no-launch":
64
+ options.noLaunch = true;
65
+ break;
48
66
  case "--model-command":
49
67
  options.modelCommand = requiredValue(rest, index, argument);
50
68
  index += 1;
@@ -64,11 +82,13 @@ export function parseRarebitCliArgs(argv) {
64
82
  options.force = true;
65
83
  break;
66
84
  default:
67
- throw new Error(`Unknown argument: ${argument}`);
85
+ if (command === "fork" && !argument.startsWith("--") && !options.session) options.session = argument;
86
+ else throw new Error(`Unknown argument: ${argument}`);
68
87
  }
69
88
  }
70
- if (!options.session) throw new Error("--session is required");
71
- if (!options.json) throw new Error("--json is required");
89
+ if (!options.session) throw new Error(command === "fork" ? "fork requires an exact source Session path or ID" : "--session is required");
90
+ if (options.maxTokenLength !== undefined && (!Number.isSafeInteger(options.maxTokenLength) || options.maxTokenLength < 1)) throw new Error("--max-token-length must be a positive integer");
91
+ if (!options.json && command !== "fork") throw new Error("--json is required");
72
92
  if (options.force && command !== "summarize")
73
93
  throw new Error("--force only applies to summarize");
74
94
  return options;
@@ -235,6 +255,79 @@ async function modelRuntime(options, dependencies) {
235
255
  };
236
256
  }
237
257
 
258
+ async function resolveForkTarget({ cwd, explicitModel, agentDir, resolveTarget }) {
259
+ if (resolveTarget) return resolveTarget({ cwd, explicitModel, agentDir });
260
+ const {
261
+ ModelRuntime,
262
+ SessionManager,
263
+ SettingsManager,
264
+ createAgentSession,
265
+ } = await import("@earendil-works/pi-coding-agent");
266
+ const settings = SettingsManager.create(cwd, agentDir);
267
+ const runtime = await ModelRuntime.create({ allowModelNetwork: false });
268
+ let targetModel;
269
+ if (explicitModel) {
270
+ const [provider, ...idParts] = explicitModel.split("/");
271
+ const id = idParts.join("/");
272
+ targetModel = runtime.getModel(provider, id);
273
+ } else {
274
+ const provider = settings.getDefaultProvider();
275
+ const id = settings.getDefaultModel();
276
+ targetModel = provider && id ? runtime.getModel(provider, id) : undefined;
277
+ if (!targetModel) targetModel = (await runtime.getAvailable())[0];
278
+ }
279
+ if (!targetModel?.provider || !targetModel?.id || !Number.isFinite(targetModel.contextWindow))
280
+ throw new Error("Cannot resolve the target Pi model and context window before forking; set Pi's default model or pass --model provider/model");
281
+ const global = settings.getGlobalSettings();
282
+ const project = settings.getProjectSettings();
283
+ const key = `${targetModel.provider}/${targetModel.id}`;
284
+ const overrides = { ...(global.compaction?.modelOverrides ?? {}), ...(project.compaction?.modelOverrides ?? {}) };
285
+ const reserveTokens = overrides[key]?.reserveTokens ?? settings.getCompactionReserveTokens();
286
+ const { session } = await createAgentSession({
287
+ cwd,
288
+ agentDir,
289
+ model: targetModel,
290
+ modelRuntime: runtime,
291
+ settingsManager: settings,
292
+ sessionManager: SessionManager.inMemory(cwd),
293
+ });
294
+ try {
295
+ const promptOverheadTokens = Math.ceil(String(session.agent.state.systemPrompt ?? "").length / 4);
296
+ const toolOverheadTokens = Math.ceil(JSON.stringify(session.agent.state.tools ?? []).length / 4);
297
+ return { model: targetModel, reserveTokens, promptOverheadTokens, toolOverheadTokens };
298
+ } finally {
299
+ session.dispose();
300
+ }
301
+ }
302
+
303
+ export async function runPiFork({ session, maxTokenLength, cwd = process.cwd(), piCommand = "pi", noLaunch = false, model, agentDir, resolveTarget, spawnProcess = spawn } = {}) {
304
+ const loaded = await readRarebitSession(session);
305
+ const target = await resolveForkTarget({ cwd, explicitModel: model, agentDir, resolveTarget });
306
+ const targetModel = target.model;
307
+ const plan = buildRarebitForkPlan({
308
+ header: loaded.parsed.header,
309
+ sessionFile: loaded.sessionFile,
310
+ cwd,
311
+ branch: loaded.branch,
312
+ targetCwd: cwd,
313
+ maxTokenLength,
314
+ targetModel,
315
+ reserveTokens: target.reserveTokens,
316
+ promptOverheadTokens: target.promptOverheadTokens,
317
+ toolOverheadTokens: target.toolOverheadTokens,
318
+ });
319
+ const { SessionManager } = await import("@earendil-works/pi-coding-agent");
320
+ const destinationManager = SessionManager.create(cwd);
321
+ const destination = sessionFilenameFor({ sessionDir: destinationManager.getSessionDir(), forkCreatedAt: plan.forkCreatedAt, sessionId: destinationManager.getSessionId() });
322
+ const built = createRarebitForkEntries(plan, { sessionId: destinationManager.getSessionId() });
323
+ await writeRarebitForkFile(destination, built);
324
+ const result = { operation: "fork", session: destination, sourceSessionId: loaded.session.id, ...plan.omission, headroom: plan.headroom, launched: false };
325
+ if (noLaunch) return result;
326
+ const child = spawnProcess(piCommand, ["--session", destination], { cwd, stdio: "inherit", shell: false, env: process.env });
327
+ const exit = await new Promise((resolve, reject) => { child.on("error", reject); child.on("close", (code, signal) => resolve({ code, signal })); });
328
+ return { ...result, launched: true, exitCode: exit.code, signal: exit.signal };
329
+ }
330
+
238
331
  export async function runRarebitCli(options, dependencies = {}) {
239
332
  const runtimeDependencies = {
240
333
  readSettings: dependencies.readSettings ?? readRarebitCliSettings,
@@ -245,6 +338,9 @@ export async function runRarebitCli(options, dependencies = {}) {
245
338
  };
246
339
  if (options.command === "query") return queryRarebits(options.session);
247
340
  if (options.command === "extract") return extractRarebits(options.session);
341
+ if (options.command === "fork") {
342
+ return runPiFork({ session: options.session, maxTokenLength: options.maxTokenLength, noLaunch: options.noLaunch, piCommand: dependencies.piCommand, model: options.model, agentDir: dependencies.agentDir, resolveTarget: dependencies.resolveTarget, spawnProcess: dependencies.spawnProcess });
343
+ }
248
344
 
249
345
  const loaded = await readRarebitSession(options.session);
250
346
  const runtime = await modelRuntime(options, runtimeDependencies);
@@ -29,6 +29,14 @@ export const RAREBIT_COMMAND_GRAMMAR = Object.freeze({
29
29
  description: "Recall active-branch Rarebit messages with a prompt",
30
30
  forms: [[rest("<prompt...>")]],
31
31
  },
32
+ {
33
+ name: "fork",
34
+ description: "Open a bounded Rarebit fork without starting a model turn",
35
+ forms: [
36
+ [],
37
+ [literal("--max-token-length", "Imported Rarebit token budget"), number("<positive tokens>", (value) => Number.isSafeInteger(value) && value > 0)],
38
+ ],
39
+ },
32
40
  {
33
41
  name: "config",
34
42
  description: "Show or override summary policy for this process",
@@ -0,0 +1,69 @@
1
+ export const RAREBIT_FORK_LINEAGE_SCHEMA_VERSION = 1;
2
+ export const RAREBIT_FORK_ENTRY_MARKER_VERSION = "rarebit-fork-entry/1";
3
+ export const RAREBIT_FORK_LINEAGE_CUSTOM_TYPE = "rarebit-fork-lineage";
4
+ export const RAREBIT_FORK_OPENING_MARKER = "rarebit-fork-opening-v1";
5
+
6
+ function validMarker(marker) {
7
+ return (
8
+ marker &&
9
+ typeof marker === "object" &&
10
+ marker.version === RAREBIT_FORK_ENTRY_MARKER_VERSION &&
11
+ (marker.kind === "seed" || marker.kind === "import")
12
+ );
13
+ }
14
+
15
+ function entriesWithIds(entries) {
16
+ return (Array.isArray(entries) ? entries : []).filter(
17
+ (entry) => typeof entry?.id === "string" && entry.id,
18
+ );
19
+ }
20
+
21
+ function lineageRecords(entries) {
22
+ return (Array.isArray(entries) ? entries : []).filter(
23
+ (entry) =>
24
+ entry?.type === "custom" &&
25
+ entry.customType === RAREBIT_FORK_LINEAGE_CUSTOM_TYPE &&
26
+ entry.data?.schemaVersion === RAREBIT_FORK_LINEAGE_SCHEMA_VERSION,
27
+ );
28
+ }
29
+
30
+ /**
31
+ * Return only destination entry IDs present in the supplied entries. This
32
+ * intentionally does not resolve parentSession or return source ancestor IDs,
33
+ * so bounded head/tail consumers can classify a marker without a full scan.
34
+ */
35
+ export function getImportedRarebitEntryIds(entries) {
36
+ const supplied = new Set(entriesWithIds(entries).map((entry) => entry.id));
37
+ const ids = new Set();
38
+ for (const entry of entriesWithIds(entries)) {
39
+ if (validMarker(entry.rarebitFork)) ids.add(entry.id);
40
+ }
41
+ for (const record of lineageRecords(entries)) {
42
+ const data = record.data;
43
+ const imported = Array.isArray(data.importedEntryIds)
44
+ ? data.importedEntryIds
45
+ : [];
46
+ for (const id of [data.opening?.seedEntryId, ...imported]) {
47
+ if (typeof id === "string" && supplied.has(id)) ids.add(id);
48
+ }
49
+ }
50
+ return ids;
51
+ }
52
+
53
+ export function getRarebitForkSeedEntryIds(entries) {
54
+ const supplied = new Set(entriesWithIds(entries).map((entry) => entry.id));
55
+ const ids = new Set();
56
+ for (const entry of entriesWithIds(entries)) {
57
+ if (validMarker(entry.rarebitFork) && entry.rarebitFork.kind === "seed")
58
+ ids.add(entry.id);
59
+ }
60
+ for (const record of lineageRecords(entries)) {
61
+ const id = record.data.opening?.seedEntryId;
62
+ if (typeof id === "string" && supplied.has(id)) ids.add(id);
63
+ }
64
+ return ids;
65
+ }
66
+
67
+ export function getRarebitForkLineageRecords(entries) {
68
+ return lineageRecords(entries).map((entry) => entry.data);
69
+ }