@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +24 -2
  2. package/README.md +96 -20
  3. package/RUNBOOK.md +32 -9
  4. package/bin/piq.mjs +14 -0
  5. package/docs/examples/rarebit.settings.json +19 -0
  6. package/package.json +29 -3
  7. package/scripts/e2e-fork-pi.mjs +824 -0
  8. package/scripts/e2e-recap-pi.mjs +456 -0
  9. package/scripts/e2e-settings-pi.mjs +267 -0
  10. package/scripts/e2e-v020-pi.mjs +269 -0
  11. package/scripts/verify-package.mjs +20 -12
  12. package/src/extension.mjs +280 -21
  13. package/src/index.mjs +1 -0
  14. package/src/lifecycle.mjs +32 -7
  15. package/src/pi-session.mjs +237 -0
  16. package/src/piq-cli.mjs +96 -0
  17. package/src/piq.mjs +265 -0
  18. package/src/rarebit-activity.mjs +8 -2
  19. package/src/rarebit-cli.mjs +103 -5
  20. package/src/rarebit-command.mjs +29 -1
  21. package/src/rarebit-core.mjs +87 -9
  22. package/src/rarebit-fork-lineage.mjs +69 -0
  23. package/src/rarebit-fork.mjs +241 -0
  24. package/src/rarebit-recap.mjs +422 -0
  25. package/src/rarebit-service.mjs +60 -7
  26. package/src/rarebit-session.mjs +12 -160
  27. package/src/rarebit-settings-ui.mjs +479 -0
  28. package/src/rarebit-settings.mjs +90 -7
  29. package/src/types.d.ts +91 -3
  30. package/test/lifecycle-contract-independent.test.mjs +2 -0
  31. package/test/package-artifact-identity.test.mjs +54 -0
  32. package/test/piq.test.mjs +212 -0
  33. package/test/rarebit-command.test.mjs +11 -4
  34. package/test/rarebit-fork.test.mjs +124 -0
  35. package/test/rarebit-recap.test.mjs +513 -0
  36. package/test/rarebit-settings-ui.test.mjs +388 -0
  37. package/test/rarebit-v020.test.mjs +294 -0
  38. package/test/summary-attention-independent.test.mjs +1 -1
@@ -0,0 +1,237 @@
1
+ import { open, readFile, readdir, stat } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join, resolve, sep } from "node:path";
4
+
5
+ export const DEFAULT_PI_SESSION_ROOT = join(
6
+ homedir(),
7
+ ".pi",
8
+ "agent",
9
+ "sessions",
10
+ );
11
+
12
+ function queryError(message) {
13
+ const error = new Error(message);
14
+ error.name = "PiSessionQueryError";
15
+ return error;
16
+ }
17
+
18
+ async function isRegularFile(path) {
19
+ try {
20
+ return (await stat(path)).isFile();
21
+ } catch (error) {
22
+ if (error?.code === "ENOENT") return false;
23
+ throw error;
24
+ }
25
+ }
26
+
27
+ async function* jsonlFiles(root) {
28
+ let entries;
29
+ try {
30
+ entries = await readdir(root, { withFileTypes: true });
31
+ } catch (error) {
32
+ if (error?.code === "ENOENT") return;
33
+ throw error;
34
+ }
35
+ for (const entry of entries) {
36
+ const path = join(root, entry.name);
37
+ if (entry.isDirectory()) yield* jsonlFiles(path);
38
+ else if (entry.isFile() && entry.name.endsWith(".jsonl")) yield path;
39
+ }
40
+ }
41
+
42
+ async function readHeader(path) {
43
+ const handle = await open(path, "r");
44
+ try {
45
+ const buffer = Buffer.alloc(16384);
46
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
47
+ const firstLine = buffer
48
+ .subarray(0, bytesRead)
49
+ .toString("utf8")
50
+ .split("\n", 1)[0]
51
+ ?.trim();
52
+ if (!firstLine) return null;
53
+ try {
54
+ const header = JSON.parse(firstLine);
55
+ return header?.type === "session" ? header : null;
56
+ } catch {
57
+ return null;
58
+ }
59
+ } finally {
60
+ await handle.close();
61
+ }
62
+ }
63
+
64
+ function looksLikePath(reference) {
65
+ return (
66
+ reference.includes(sep) ||
67
+ reference.startsWith(".") ||
68
+ reference.endsWith(".jsonl")
69
+ );
70
+ }
71
+
72
+ /** Resolve one exact path or one unambiguous persisted Session ID. */
73
+ export async function resolvePiSessionFile(
74
+ reference,
75
+ { sessionRoot = DEFAULT_PI_SESSION_ROOT } = {},
76
+ ) {
77
+ if (typeof reference !== "string" || !reference.trim())
78
+ throw queryError("--session requires an exact Pi Session path or ID");
79
+ const input = reference.trim();
80
+ const path = resolve(input);
81
+ if (await isRegularFile(path)) return path;
82
+ if (looksLikePath(input))
83
+ throw queryError(`Pi Session file not found: ${input}`);
84
+ const matches = [];
85
+ for await (const candidate of jsonlFiles(resolve(sessionRoot))) {
86
+ const header = await readHeader(candidate);
87
+ if (header?.id === input) matches.push(candidate);
88
+ }
89
+ if (matches.length === 0)
90
+ throw queryError(`No persisted Pi Session has exact ID ${input}`);
91
+ if (matches.length > 1)
92
+ throw queryError(
93
+ `Pi Session ID ${input} is ambiguous across ${matches.length} files; pass an exact path`,
94
+ );
95
+ return resolve(matches[0]);
96
+ }
97
+
98
+ /** Parse native JSONL without normalizing away unknown evidence fields. */
99
+ export function parseNativeSession(content, source = "Pi Session") {
100
+ const records = [];
101
+ for (const [index, line] of String(content).split("\n").entries()) {
102
+ if (!line.trim()) continue;
103
+ try {
104
+ records.push(JSON.parse(line));
105
+ } catch {
106
+ throw queryError(`${source} contains malformed JSON at line ${index + 1}`);
107
+ }
108
+ }
109
+ const header = records[0];
110
+ if (
111
+ !header ||
112
+ header.type !== "session" ||
113
+ typeof header.id !== "string" ||
114
+ !header.id
115
+ )
116
+ throw queryError(`${source} does not begin with a valid Pi Session header`);
117
+ const entries = records.slice(1);
118
+ const version = header.version ?? 1;
119
+ if (typeof version !== "number" || !Number.isInteger(version) || version < 1)
120
+ throw queryError(`${source} has an invalid Session version`);
121
+ const byId = new Map();
122
+ const linear = version < 2;
123
+ if (!linear) {
124
+ for (const entry of entries) {
125
+ if (typeof entry?.id !== "string" || !entry.id)
126
+ throw queryError(`${source} contains a Session entry without an ID`);
127
+ if (byId.has(entry.id))
128
+ throw queryError(
129
+ `${source} contains duplicate Session entry ID ${entry.id}`,
130
+ );
131
+ byId.set(entry.id, entry);
132
+ }
133
+ }
134
+ return { header, entries, byId, linear };
135
+ }
136
+
137
+ function ancestryFromLeaf(parsed, leaf, source) {
138
+ const reversed = [];
139
+ const visited = new Set();
140
+ let current = leaf;
141
+ while (current) {
142
+ if (visited.has(current.id))
143
+ throw queryError(`${source} contains a cycle at Session entry ${current.id}`);
144
+ visited.add(current.id);
145
+ reversed.push(current);
146
+ if (current.parentId === null || current.parentId === undefined) break;
147
+ if (typeof current.parentId !== "string" || !current.parentId)
148
+ throw queryError(`${source} contains an invalid parent ID at ${current.id}`);
149
+ current = parsed.byId.get(current.parentId);
150
+ if (!current)
151
+ throw queryError(
152
+ `${source} is missing parent Session entry ${reversed.at(-1).parentId}`,
153
+ );
154
+ }
155
+ return reversed.reverse();
156
+ }
157
+
158
+ /** Resolve the persisted active branch. The final entry is Pi's default leaf. */
159
+ export function resolveActiveBranch(parsed, source = "Pi Session") {
160
+ if (parsed.linear) return parsed.entries.slice();
161
+ const leaf = parsed.entries.at(-1);
162
+ return leaf ? ancestryFromLeaf(parsed, leaf, source) : [];
163
+ }
164
+
165
+ /** Resolve one explicit leaf, or the native default leaf when omitted. */
166
+ export function resolveSessionBranch(
167
+ parsed,
168
+ { leafId = null } = {},
169
+ source = "Pi Session",
170
+ ) {
171
+ if (parsed.linear) {
172
+ if (leafId !== null && leafId !== undefined)
173
+ throw queryError(
174
+ `${source} uses a linear format and has no explicit branch leaf`,
175
+ );
176
+ return parsed.entries.slice();
177
+ }
178
+ const leaf =
179
+ leafId === null || leafId === undefined
180
+ ? parsed.entries.at(-1)
181
+ : parsed.byId.get(leafId);
182
+ if (!leaf) {
183
+ if (leafId === null || leafId === undefined) return [];
184
+ throw queryError(`${source} has no Session entry with leaf ID ${leafId}`);
185
+ }
186
+ return ancestryFromLeaf(parsed, leaf, source);
187
+ }
188
+
189
+ function childIds(parsed) {
190
+ const children = new Set();
191
+ if (parsed.linear) return children;
192
+ for (const entry of parsed.entries) {
193
+ if (entry.parentId !== null && entry.parentId !== undefined) {
194
+ if (!parsed.byId.has(entry.parentId)) continue;
195
+ children.add(entry.parentId);
196
+ }
197
+ }
198
+ return children;
199
+ }
200
+
201
+ /** Return every persisted leaf in file order; no timestamp-based inference. */
202
+ export function listSessionBranches(parsed, source = "Pi Session") {
203
+ if (parsed.linear) {
204
+ return parsed.entries.length
205
+ ? [{ leafId: null, entryCount: parsed.entries.length }]
206
+ : [];
207
+ }
208
+ // Validate every connected component before looking for leaves. A cycle has
209
+ // no leaf and must remain an explicit error instead of an empty branch list.
210
+ for (const entry of parsed.entries) ancestryFromLeaf(parsed, entry, source);
211
+ const children = childIds(parsed);
212
+ return parsed.entries
213
+ .filter((entry) => !children.has(entry.id))
214
+ .map((entry) => ({
215
+ leafId: entry.id,
216
+ entryCount: ancestryFromLeaf(parsed, entry, source).length,
217
+ }));
218
+ }
219
+
220
+ export async function readPiSession(reference, options = {}) {
221
+ const sessionFile = await resolvePiSessionFile(reference, options);
222
+ const parsed = parseNativeSession(
223
+ await readFile(sessionFile, "utf8"),
224
+ sessionFile,
225
+ );
226
+ const branch = resolveSessionBranch(
227
+ parsed,
228
+ { leafId: options.leafId ?? null },
229
+ sessionFile,
230
+ );
231
+ return {
232
+ sessionFile,
233
+ parsed,
234
+ branch,
235
+ leafId: parsed.linear ? null : (branch.at(-1)?.id ?? null),
236
+ };
237
+ }
@@ -0,0 +1,96 @@
1
+ import { queryPiQ } from "./piq.mjs";
2
+
3
+ const FROM_FLAG = "--from";
4
+
5
+ export const PIQ_CLI_USAGE = `Usage:
6
+ piq <entries|branches|tools|tool-calls|tool-results|compactions> --session <exact-path-or-id> [options]
7
+
8
+ Options:
9
+ --leaf <entry-id> Read one explicit branch leaf (default: native active leaf)
10
+ --entry-id <entry-id> Restrict entries or tools to one native entry ID
11
+ --role <role> Restrict entries to a native message role
12
+ --from <ISO-8601> Include evidence at or after this timestamp
13
+ --to <ISO-8601> Include evidence at or before this timestamp
14
+ --through-compaction <entry-id> Stop the branch at this compaction boundary (or latest)
15
+
16
+ PiQ writes one JSON object per result line. It never writes the source Session.`;
17
+
18
+ function value(argv, index, flag) {
19
+ const next = argv[index + 1];
20
+ if (!next || next.startsWith("--")) throw new Error(`${flag} requires a value`);
21
+ return next;
22
+ }
23
+
24
+ export function parsePiQCliArgs(argv) {
25
+ if (argv.includes("--help") || argv.includes("-h")) return { help: true };
26
+ const [first, ...rest] = argv;
27
+ const commands = new Set([
28
+ "entries",
29
+ "branches",
30
+ "tools",
31
+ "tool-calls",
32
+ "tool-results",
33
+ "compactions",
34
+ ]);
35
+ const kind = commands.has(first) ? first : "entries";
36
+ const args = commands.has(first) ? rest : argv;
37
+ const options = { kind };
38
+ for (let index = 0; index < args.length; index += 1) {
39
+ const argument = args[index];
40
+ switch (argument) {
41
+ case "--session":
42
+ options.session = value(args, index, argument);
43
+ index += 1;
44
+ break;
45
+ case "--leaf":
46
+ options.leafId = value(args, index, argument);
47
+ index += 1;
48
+ break;
49
+ case "--entry-id":
50
+ options.entryId = value(args, index, argument);
51
+ index += 1;
52
+ break;
53
+ case "--role":
54
+ options.role = value(args, index, argument);
55
+ index += 1;
56
+ break;
57
+ case FROM_FLAG:
58
+ options.from = value(args, index, argument);
59
+ index += 1;
60
+ break;
61
+ case "--to":
62
+ options.to = value(args, index, argument);
63
+ index += 1;
64
+ break;
65
+ case "--through-compaction":
66
+ options.throughCompaction = value(args, index, argument);
67
+ index += 1;
68
+ break;
69
+ case "--session-root":
70
+ options.sessionRoot = value(args, index, argument);
71
+ index += 1;
72
+ break;
73
+ case "--jsonl":
74
+ case "--json":
75
+ break;
76
+ default:
77
+ throw new Error(`Unknown argument: ${argument}`);
78
+ }
79
+ }
80
+ if (!options.session) throw new Error("--session is required");
81
+ return options;
82
+ }
83
+
84
+ export async function runPiQCli(options, dependencies = {}) {
85
+ const query = dependencies.query ?? queryPiQ;
86
+ return query(options.session, {
87
+ kind: options.kind,
88
+ leafId: options.leafId,
89
+ sessionRoot: options.sessionRoot,
90
+ entryId: options.entryId,
91
+ role: options.role,
92
+ from: options.from,
93
+ to: options.to,
94
+ throughCompaction: options.throughCompaction,
95
+ });
96
+ }
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
  ),