@hypercarrier/rarebit 0.3.0 → 0.4.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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 — 2026-10-07
4
+
5
+ - Replace Recall's hour-bucketed `rarebit_conversation/v1` with a flat
6
+ `rarebit_conversation/v2` message list. Each message has `seq`, `round`,
7
+ `role`, `kind`, second-precision UTC `time`, and `text`. Detailed evidence
8
+ occurrences gain the same `seq` join key.
9
+ - Add the conversation schema and `jq` tail queries to the Recall message.
10
+
3
11
  ## 0.3.0 — 2026-10-04
4
12
 
5
13
  - Add `/rarebit view [context|all|peek|off]` and a View action in the palette.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hypercarrier/rarebit",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Sparse, provenance-bearing conversational evidence recovery for Pi sessions.",
5
5
  "license": "MIT",
6
6
  "author": "HyperCarrier contributors",
@@ -18,29 +18,47 @@ async function waitFor(predicate, label, timeout = 2_000) {
18
18
  function expectedRecallMarkdown(conversationPath, detailedPath, request) {
19
19
  return `# Rarebit Recall
20
20
 
21
- *Use these local private evidence files to recover historical context for this turn.*
21
+ *Answer the **Current request** below. Use these local private files to recover earlier context from this Session.*
22
22
 
23
- ## How to use this bundle
23
+ ## Files
24
24
 
25
- 1. Treat **Current request** below as the exact current user request.
26
- 2. Read **Conversation** first for meaning.
27
- 3. Use **Detailed evidence** only for source, Session, branch, or lineage facts.
28
- 4. Answer the current request using this evidence.
29
-
30
- ## Local private evidence files
31
-
32
- **Conversation** — \`rarebit_conversation/v1\`
25
+ **Conversation** — \`rarebit_conversation/v2\`. Read this first.
33
26
 
34
27
  \`\`\`text
35
28
  ${conversationPath}
36
29
  \`\`\`
37
30
 
38
- **Detailed evidence** — \`rarebit_message_recall/v1\`
31
+ **Detailed evidence** — \`rarebit_message_recall/v1\`. Use it only for source, Session, branch, or lineage facts. Join on \`seq\`.
39
32
 
40
33
  \`\`\`text
41
34
  ${detailedPath}
42
35
  \`\`\`
43
36
 
37
+ ## Conversation schema
38
+
39
+ \`\`\`text
40
+ { messageCount, lastRound, messages: [{ seq, round, role, kind, time, text }] }
41
+ seq 1-based, oldest first
42
+ round increments at each user message; agent messages before the first user message have round 0
43
+ role "user" | "agent"
44
+ kind "request" (user) | "progress" (agent text before a tool call) | "reply" (agent final answer)
45
+ time UTC "YYYY-MM-DDTHH:MM:SSZ", or null
46
+ \`\`\`
47
+
48
+ Messages contain user and agent text only. Tool calls and tool results are excluded.
49
+
50
+ ## Example queries
51
+
52
+ Set \`F\` to the Conversation path. Read the smallest slice that answers the request.
53
+
54
+ \`\`\`sh
55
+ jq '.messages[-20:]' "$F" # last 20 messages
56
+ jq '[.messages[] | select(.role == "user")][-3:]' "$F" # last 3 user messages
57
+ jq '[.messages[] | select(.kind == "reply")][-10:]' "$F" # last 10 agent replies
58
+ jq '.lastRound as $r | [.messages[] | select(.round > $r - 5)]' "$F" # last 5 rounds
59
+ jq '(now - 8*3600 | todate) as $t | [.messages[] | select(.time >= $t)]' "$F" # last 8 hours
60
+ \`\`\`
61
+
44
62
  ## Current request
45
63
 
46
64
  \`\`\`text
@@ -50,7 +68,7 @@ ${request}
50
68
 
51
69
  function conversationPathFrom(message) {
52
70
  const match = message.match(
53
- /\*\*Conversation\*\* — `rarebit_conversation\/v1`\n\n```text\n([^\n]*rarebit-conversation\.json)\n```/,
71
+ /\*\*Conversation\*\* — `rarebit_conversation\/v2`\. Read this first\.\n\n```text\n([^\n]*rarebit-conversation\.json)\n```/,
54
72
  );
55
73
  assert.ok(match, "Recall Markdown must contain the fenced conversation path");
56
74
  return match[1];
@@ -58,7 +76,7 @@ function conversationPathFrom(message) {
58
76
 
59
77
  function detailedPathFrom(message) {
60
78
  const match = message.match(
61
- /\*\*Detailed evidence\*\* — `rarebit_message_recall\/v1`\n\n```text\n([^\n]*rarebit-evidence\.json)\n```/,
79
+ /\*\*Detailed evidence\*\* — `rarebit_message_recall\/v1`\. Use it only for source, Session, branch, or lineage facts\. Join on `seq`\.\n\n```text\n([^\n]*rarebit-evidence\.json)\n```/,
62
80
  );
63
81
  assert.ok(match, "Recall Markdown must contain the fenced detailed path");
64
82
  return match[1];
package/src/extension.mjs CHANGED
@@ -543,25 +543,43 @@ export default function registerPiRarebit(pi, config = {}) {
543
543
  };
544
544
  const recallMessage = (recall, requestText) => `# Rarebit Recall
545
545
 
546
- *Use these local private evidence files to recover historical context for this turn.*
546
+ *Answer the **Current request** below. Use these local private files to recover earlier context from this Session.*
547
547
 
548
- ## How to use this bundle
548
+ ## Files
549
549
 
550
- 1. Treat **Current request** below as the exact current user request.
551
- 2. Read **Conversation** first for meaning.
552
- 3. Use **Detailed evidence** only for source, Session, branch, or lineage facts.
553
- 4. Answer the current request using this evidence.
554
-
555
- ## Local private evidence files
556
-
557
- **Conversation** — \`rarebit_conversation/v${RAREBIT_CONVERSATION_SCHEMA_VERSION}\`
550
+ **Conversation** — \`rarebit_conversation/v${RAREBIT_CONVERSATION_SCHEMA_VERSION}\`. Read this first.
558
551
 
559
552
  ${fence(recall.conversationPath)}
560
553
 
561
- **Detailed evidence** — \`rarebit_message_recall/v${RAREBIT_RECALL_SCHEMA_VERSION}\`
554
+ **Detailed evidence** — \`rarebit_message_recall/v${RAREBIT_RECALL_SCHEMA_VERSION}\`. Use it only for source, Session, branch, or lineage facts. Join on \`seq\`.
562
555
 
563
556
  ${fence(recall.detailedPath)}
564
557
 
558
+ ## Conversation schema
559
+
560
+ \`\`\`text
561
+ { messageCount, lastRound, messages: [{ seq, round, role, kind, time, text }] }
562
+ seq 1-based, oldest first
563
+ round increments at each user message; agent messages before the first user message have round 0
564
+ role "user" | "agent"
565
+ kind "request" (user) | "progress" (agent text before a tool call) | "reply" (agent final answer)
566
+ time UTC "YYYY-MM-DDTHH:MM:SSZ", or null
567
+ \`\`\`
568
+
569
+ Messages contain user and agent text only. Tool calls and tool results are excluded.
570
+
571
+ ## Example queries
572
+
573
+ Set \`F\` to the Conversation path. Read the smallest slice that answers the request.
574
+
575
+ \`\`\`sh
576
+ jq '.messages[-20:]' "$F" # last 20 messages
577
+ jq '[.messages[] | select(.role == "user")][-3:]' "$F" # last 3 user messages
578
+ jq '[.messages[] | select(.kind == "reply")][-10:]' "$F" # last 10 agent replies
579
+ jq '.lastRound as $r | [.messages[] | select(.round > $r - 5)]' "$F" # last 5 rounds
580
+ jq '(now - 8*3600 | todate) as $t | [.messages[] | select(.time >= $t)]' "$F" # last 8 hours
581
+ \`\`\`
582
+
565
583
  ## Current request
566
584
 
567
585
  ${fence(requestText)}`;
@@ -6,7 +6,7 @@ import { isAbsolute, join, resolve } from "node:path";
6
6
  import { selectRarebits } from "./rarebit-core.mjs";
7
7
 
8
8
  export const RAREBIT_RECALL_SCHEMA_VERSION = 1;
9
- export const RAREBIT_CONVERSATION_SCHEMA_VERSION = 1;
9
+ export const RAREBIT_CONVERSATION_SCHEMA_VERSION = 2;
10
10
  export const RAREBIT_RECALL_DETAILED_FILENAME = "rarebit-evidence.json";
11
11
  export const RAREBIT_RECALL_CONVERSATION_FILENAME = "rarebit-conversation.json";
12
12
 
@@ -52,16 +52,20 @@ function recallDocument({ sessionId, sessionFile, branch, selection, now }) {
52
52
  occurrenceCount: selection.occurrences.length,
53
53
  uniquePayloadCount: selection.payloads.length,
54
54
  occurrences: selection.occurrences.map(
55
- ({
56
- occurrenceId,
55
+ (
56
+ {
57
+ occurrenceId,
57
58
  sourceEntryId,
58
59
  order,
59
60
  timestamp,
60
61
  role,
61
62
  outcome,
62
- contentHash,
63
- text,
64
- }) => ({
63
+ contentHash,
64
+ text,
65
+ },
66
+ index,
67
+ ) => ({
68
+ seq: index + 1,
65
69
  occurrenceId,
66
70
  sourceEntryId,
67
71
  order,
@@ -76,37 +80,45 @@ function recallDocument({ sessionId, sessionFile, branch, selection, now }) {
76
80
  };
77
81
  }
78
82
 
79
- function conversationHour(timestamp) {
83
+ // Second precision matches jq `todate`, so `.time >= (now - N | todate)` and
84
+ // `fromdate` work without parsing fractional seconds.
85
+ function conversationTime(timestamp) {
80
86
  if (typeof timestamp !== "string") return null;
81
87
  const value = new Date(timestamp);
82
88
  if (Number.isNaN(value.getTime())) return null;
83
- return `${value.toISOString().slice(0, 13)}:00Z`;
89
+ return `${value.toISOString().slice(0, 19)}Z`;
84
90
  }
85
91
 
92
+ const CONVERSATION_KIND = Object.freeze({
93
+ user: "request",
94
+ continuation: "progress",
95
+ stop: "reply",
96
+ });
97
+
98
+ /**
99
+ * One flat, branch-ordered message list. `seq` matches the detailed
100
+ * occurrence `seq`. A round starts at each user message, so tail slices by
101
+ * message, role, kind, round, or time are single jq filters.
102
+ */
86
103
  function conversationDocument(selection) {
87
- const byHour = new Map();
88
- for (const occurrence of selection.occurrences) {
89
- const hour = conversationHour(occurrence.timestamp);
90
- let bucket = byHour.get(hour);
91
- if (!bucket) {
92
- bucket = { hour, messages: [] };
93
- byHour.set(hour, bucket);
94
- }
95
- bucket.messages.push({
96
- role: occurrence.role === "user" ? "user" : "agent",
97
- content: occurrence.text,
98
- });
99
- }
100
- const hours = [...byHour.values()].sort((left, right) => {
101
- if (left.hour === null) return right.hour === null ? 0 : 1;
102
- if (right.hour === null) return -1;
103
- return left.hour.localeCompare(right.hour);
104
+ let round = 0;
105
+ const messages = selection.occurrences.map((occurrence, index) => {
106
+ if (occurrence.outcome === "user") round += 1;
107
+ return {
108
+ seq: index + 1,
109
+ round,
110
+ role: occurrence.outcome === "user" ? "user" : "agent",
111
+ kind: CONVERSATION_KIND[occurrence.outcome],
112
+ time: conversationTime(occurrence.timestamp),
113
+ text: occurrence.text,
114
+ };
104
115
  });
105
116
  return {
106
117
  schemaVersion: RAREBIT_CONVERSATION_SCHEMA_VERSION,
107
118
  type: "rarebit_conversation",
108
- timeZone: "UTC",
109
- hours,
119
+ messageCount: messages.length,
120
+ lastRound: round,
121
+ messages,
110
122
  };
111
123
  }
112
124
 
@@ -140,27 +140,52 @@ test("recall materialization writes private detailed evidence and a chronologica
140
140
  await readFile(result.conversationPath, "utf8"),
141
141
  );
142
142
  assert.deepEqual(conversation, {
143
- schemaVersion: 1,
143
+ schemaVersion: 2,
144
144
  type: "rarebit_conversation",
145
- timeZone: "UTC",
146
- hours: [
145
+ messageCount: 3,
146
+ lastRound: 1,
147
+ messages: [
147
148
  {
148
- hour: "2026-07-24T01:00Z",
149
- messages: [{ role: "user", content: "Exact owner evidence." }],
149
+ seq: 1,
150
+ round: 1,
151
+ role: "user",
152
+ kind: "request",
153
+ time: "2026-07-24T01:02:03Z",
154
+ text: "Exact owner evidence.",
150
155
  },
151
156
  {
152
- hour: "2026-07-24T02:00Z",
153
- messages: [
154
- { role: "agent", content: "Exact continuation evidence." },
155
- { role: "agent", content: "Exact stop evidence." },
156
- ],
157
+ seq: 2,
158
+ round: 1,
159
+ role: "agent",
160
+ kind: "progress",
161
+ time: "2026-07-24T02:00:00Z",
162
+ text: "Exact continuation evidence.",
163
+ },
164
+ {
165
+ seq: 3,
166
+ round: 1,
167
+ role: "agent",
168
+ kind: "reply",
169
+ time: "2026-07-24T02:45:00Z",
170
+ text: "Exact stop evidence.",
157
171
  },
158
172
  ],
159
173
  });
174
+ assert.deepEqual(
175
+ detailed.selection.occurrences.map(({ seq, sourceEntryId }) => ({
176
+ seq,
177
+ sourceEntryId,
178
+ })),
179
+ [
180
+ { seq: 1, sourceEntryId: "owner" },
181
+ { seq: 2, sourceEntryId: "continue" },
182
+ { seq: 3, sourceEntryId: "stop" },
183
+ ],
184
+ );
160
185
  const conversationText = await readFile(result.conversationPath, "utf8");
161
186
  assert.doesNotMatch(
162
187
  conversationText,
163
- /sourceEntryId|occurrenceId|manifestHash|contentHash|timestamp|session/i,
188
+ /sourceEntryId|occurrenceId|manifestHash|contentHash|session/i,
164
189
  );
165
190
  } finally {
166
191
  await result.discard();
@@ -168,7 +193,7 @@ test("recall materialization writes private detailed evidence and a chronologica
168
193
  }
169
194
  });
170
195
 
171
- test("conversation buckets sort chronologically and treat producer-tagged input as a normal user message", async () => {
196
+ test("conversation keeps branch order, numbers rounds, and treats producer-tagged input as a normal user message", async () => {
172
197
  const root = await fixtureRoot("bucket-order");
173
198
  const sessionFile = join(root, "session.jsonl");
174
199
  await writeFile(sessionFile, "{}\n");
@@ -218,23 +243,23 @@ test("conversation buckets sort chronologically and treat producer-tagged input
218
243
  const conversation = JSON.parse(
219
244
  await readFile(result.conversationPath, "utf8"),
220
245
  );
221
- assert.deepEqual(conversation.hours, [
222
- {
223
- hour: "2026-07-24T01:00Z",
224
- messages: [{ role: "agent", content: "Earlier agent evidence." }],
225
- },
226
- {
227
- hour: "2026-07-24T02:00Z",
228
- messages: [
229
- { role: "user", content: "Unknown-origin user evidence." },
230
- { role: "agent", content: "Later agent evidence." },
231
- ],
232
- },
233
- {
234
- hour: null,
235
- messages: [{ role: "agent", content: "Unknown-time agent evidence." }],
236
- },
237
- ]);
246
+ assert.equal(conversation.lastRound, 1);
247
+ assert.deepEqual(
248
+ conversation.messages.map(({ seq, round, role, kind, time, text }) => [
249
+ seq,
250
+ round,
251
+ role,
252
+ kind,
253
+ time,
254
+ text,
255
+ ]),
256
+ [
257
+ [1, 1, "user", "request", "2026-07-24T02:10:00Z", "Unknown-origin user evidence."],
258
+ [2, 1, "agent", "reply", "2026-07-24T01:10:00Z", "Earlier agent evidence."],
259
+ [3, 1, "agent", "reply", null, "Unknown-time agent evidence."],
260
+ [4, 1, "agent", "reply", "2026-07-24T02:20:00Z", "Later agent evidence."],
261
+ ],
262
+ );
238
263
  assert.doesNotMatch(
239
264
  await readFile(result.conversationPath, "utf8"),
240
265
  /human|producer|rpc/,
@@ -318,29 +343,47 @@ test("recall sends one exact fenced Markdown steer message in idle and busy cont
318
343
  "\n# Heading-looking request\n- list-looking item\n```text\ninside backticks\n```\n~~~~\ninside tildes\n~~~~\nTrailing request\n";
319
344
  const expectedMessage = `# Rarebit Recall
320
345
 
321
- *Use these local private evidence files to recover historical context for this turn.*
322
-
323
- ## How to use this bundle
346
+ *Answer the **Current request** below. Use these local private files to recover earlier context from this Session.*
324
347
 
325
- 1. Treat **Current request** below as the exact current user request.
326
- 2. Read **Conversation** first for meaning.
327
- 3. Use **Detailed evidence** only for source, Session, branch, or lineage facts.
328
- 4. Answer the current request using this evidence.
348
+ ## Files
329
349
 
330
- ## Local private evidence files
331
-
332
- **Conversation** — \`rarebit_conversation/v1\`
350
+ **Conversation** — \`rarebit_conversation/v2\`. Read this first.
333
351
 
334
352
  \`\`\`text
335
353
  ${recall.conversationPath}
336
354
  \`\`\`
337
355
 
338
- **Detailed evidence** — \`rarebit_message_recall/v1\`
356
+ **Detailed evidence** — \`rarebit_message_recall/v1\`. Use it only for source, Session, branch, or lineage facts. Join on \`seq\`.
339
357
 
340
358
  ~~~~text
341
359
  ${recall.detailedPath}
342
360
  ~~~~
343
361
 
362
+ ## Conversation schema
363
+
364
+ \`\`\`text
365
+ { messageCount, lastRound, messages: [{ seq, round, role, kind, time, text }] }
366
+ seq 1-based, oldest first
367
+ round increments at each user message; agent messages before the first user message have round 0
368
+ role "user" | "agent"
369
+ kind "request" (user) | "progress" (agent text before a tool call) | "reply" (agent final answer)
370
+ time UTC "YYYY-MM-DDTHH:MM:SSZ", or null
371
+ \`\`\`
372
+
373
+ Messages contain user and agent text only. Tool calls and tool results are excluded.
374
+
375
+ ## Example queries
376
+
377
+ Set \`F\` to the Conversation path. Read the smallest slice that answers the request.
378
+
379
+ \`\`\`sh
380
+ jq '.messages[-20:]' "$F" # last 20 messages
381
+ jq '[.messages[] | select(.role == "user")][-3:]' "$F" # last 3 user messages
382
+ jq '[.messages[] | select(.kind == "reply")][-10:]' "$F" # last 10 agent replies
383
+ jq '.lastRound as $r | [.messages[] | select(.round > $r - 5)]' "$F" # last 5 rounds
384
+ jq '(now - 8*3600 | todate) as $t | [.messages[] | select(.time >= $t)]' "$F" # last 8 hours
385
+ \`\`\`
386
+
344
387
  ## Current request
345
388
 
346
389
  \`\`\`\`text
@@ -390,7 +433,7 @@ test("long delimiter runs ending at value boundaries use the shortest safe fence
390
433
  assert.equal(harness.sent.length, 1);
391
434
  const message = harness.sent[0].args[0];
392
435
  const conversationBlock = message.match(
393
- /\*\*Conversation\*\* — `rarebit_conversation\/v1`\n\n([`~]+)text\n([^\n]*)\n\1\n\n\*\*Detailed evidence\*\*/,
436
+ /\*\*Conversation\*\* — `rarebit_conversation\/v2`\. Read this first\.\n\n([`~]+)text\n([^\n]*)\n\1\n\n\*\*Detailed evidence\*\*/,
394
437
  );
395
438
  assert.ok(conversationBlock);
396
439
  assert.equal(conversationBlock[2], conversationPath);