@bivy/bivy 0.16.25-staging.30 → 0.16.25-staging.31

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.
@@ -29,6 +29,51 @@
29
29
  import fs from "node:fs";
30
30
  import path from "node:path";
31
31
  import { normalizedIntermediateText, thinkingTextFromContent, mergeTranscript } from "./transcript-merge.js";
32
+ /**
33
+ * Decompose a transcript message into serialization-independent "atoms": the
34
+ * agent's own tool ids (unique + identical across serializations) and its text
35
+ * fragments. Used by mergeBases to recognize when a native-store reload re-states
36
+ * turns already in the streamed event-log base, even when the two serializations
37
+ * group blocks into messages differently (Claude keeps tool + text separate;
38
+ * OpenCode bundles them; a reload may drop tool blocks Bivy recorded as overlays).
39
+ */
40
+ export function messageContentAtoms(message) {
41
+ const toolIds = [];
42
+ const texts = [];
43
+ if (!message)
44
+ return { toolIds, texts };
45
+ const content = message.content;
46
+ const role = typeof message.role === "string" ? message.role : "";
47
+ const pushText = (value) => {
48
+ if (typeof value !== "string")
49
+ return;
50
+ const text = value.trim();
51
+ if (text)
52
+ texts.push(`${role}#${text}`);
53
+ };
54
+ if (typeof content === "string") {
55
+ pushText(content);
56
+ return { toolIds, texts };
57
+ }
58
+ if (Array.isArray(content)) {
59
+ for (const block of content) {
60
+ if (!block || typeof block !== "object")
61
+ continue;
62
+ const b = block;
63
+ const type = String(b.type || "");
64
+ if (type === "tool_use" && b.id)
65
+ toolIds.push(`tu:${String(b.id)}`);
66
+ else if (type === "tool_result") {
67
+ const id = b.tool_use_id ?? b.toolUseId;
68
+ if (id)
69
+ toolIds.push(`tr:${String(id)}`);
70
+ }
71
+ else if (type === "text")
72
+ pushText(b.text);
73
+ }
74
+ }
75
+ return { toolIds, texts };
76
+ }
32
77
  /**
33
78
  * Union of a persisted base and a runtime's live transcript, such that the result
34
79
  * is never shorter than either input alone. A live session's runtime transcript
@@ -53,32 +98,89 @@ export function mergeBases(logged, runtime) {
53
98
  const keys = [];
54
99
  const known = new Set();
55
100
  const identities = new Map();
56
- const add = (m) => {
57
- const key = JSON.stringify(m);
58
- if (known.has(key))
59
- return;
101
+ const identityOf = (m) => {
60
102
  const identified = m;
61
- const identity = typeof identified.id === "string" && identified.id
103
+ return typeof identified.id === "string" && identified.id
62
104
  ? `id:${identified.id}`
63
105
  : typeof identified.timestamp === "number" ? `time:${identified.role}:${identified.timestamp}` : "";
106
+ };
107
+ const add = (m) => {
108
+ const key = JSON.stringify(m);
109
+ if (known.has(key))
110
+ return false;
111
+ const identity = identityOf(m);
64
112
  const index = identity ? identities.get(identity) : undefined;
65
113
  if (index !== undefined) {
66
114
  known.delete(keys[index]);
67
115
  keys[index] = key;
68
116
  known.add(key);
69
117
  merged[index] = m;
70
- return;
118
+ return false;
71
119
  }
72
120
  known.add(key);
73
121
  keys.push(key);
74
122
  merged.push(m);
75
123
  if (identity)
76
124
  identities.set(identity, merged.length - 1);
125
+ return true;
77
126
  };
78
127
  for (const m of logged)
79
128
  add(m);
80
- for (const m of runtime)
81
- add(m);
129
+ // Cross-serialization fold (the reopen case): a native-store agent (Claude,
130
+ // Codex, Pi, OpenCode) reloads its FULL transcript on reopen, but that reload
131
+ // reconstructs each turn with the agent's OWN timestamps and a possibly very
132
+ // different block shape than the event-log base Bivy streamed and stored live
133
+ // (e.g. OpenCode's reload is just user + final text, the tool blocks living
134
+ // only in Bivy's overlays). The id/timestamp fold above can't match those two
135
+ // serializations, so every turn double-appeared on reopen. Recognize a
136
+ // re-serialization by its serialization-independent atoms — the agent's own
137
+ // unique tool ids and its text fragments — and drop a runtime message whose
138
+ // atoms are ALL already covered by the logged base. Tool ids are globally
139
+ // unique (a covered id is literally the same call); text is matched through a
140
+ // consumed multiset so a genuinely repeated turn ("run tests" twice) and a
141
+ // disjoint resumed turn are both preserved rather than collapsed.
142
+ const loggedToolIds = new Set();
143
+ const loggedTexts = new Map();
144
+ for (const m of merged) {
145
+ const atoms = messageContentAtoms(m);
146
+ for (const id of atoms.toolIds)
147
+ loggedToolIds.add(id);
148
+ for (const text of atoms.texts)
149
+ loggedTexts.set(text, (loggedTexts.get(text) ?? 0) + 1);
150
+ }
151
+ const coveredByLogged = (m) => {
152
+ const atoms = messageContentAtoms(m);
153
+ if (!atoms.toolIds.length && !atoms.texts.length)
154
+ return false; // opaque → keep
155
+ for (const id of atoms.toolIds)
156
+ if (!loggedToolIds.has(id))
157
+ return false;
158
+ // Text must be available in the (remaining) logged multiset; tentatively
159
+ // consume so two runtime copies can't both match a single logged fragment.
160
+ const consumed = new Map();
161
+ for (const text of atoms.texts) {
162
+ const available = (loggedTexts.get(text) ?? 0) - (consumed.get(text) ?? 0);
163
+ if (available <= 0)
164
+ return false;
165
+ consumed.set(text, (consumed.get(text) ?? 0) + 1);
166
+ }
167
+ for (const [text, n] of consumed)
168
+ loggedTexts.set(text, (loggedTexts.get(text) ?? 0) - n);
169
+ return true;
170
+ };
171
+ for (const m of runtime) {
172
+ const key = JSON.stringify(m);
173
+ if (known.has(key))
174
+ continue;
175
+ const identity = identityOf(m);
176
+ if (identity && identities.has(identity)) {
177
+ add(m);
178
+ continue;
179
+ } // streaming refinement of a known turn
180
+ if (coveredByLogged(m))
181
+ continue; // reopen re-serialization already in the base
182
+ add(m); // a genuinely new (resumed) turn
183
+ }
82
184
  return merged;
83
185
  }
84
186
  /** Content-block type carried by a folded outbound attachment. MUST match
@@ -152,7 +152,14 @@ export function createTranscriptPersistence(deps) {
152
152
  return;
153
153
  }
154
154
  else {
155
- eventLog.append(record.id, { ...base, id: `bivy-tool-call-${callId}`, content: [{ type: "tool_use", id: callId, name, input, ...(event.detail ? { detail: event.detail } : {}) }] });
155
+ // Carry the delegation/sub-agent parent id onto the persisted tool_use
156
+ // block so a reloaded transcript nests a sub-agent's calls under the
157
+ // delegation that spawned them (toolEntriesFromContent → toolParentId),
158
+ // exactly like the live stream does. Without this the parent id lived only
159
+ // on the in-flight event and reopening a session flattened sub-agent work.
160
+ // Generic: any runtime whose tool_call event carries parentToolUseId nests.
161
+ const parentToolUseId = typeof event.parentToolUseId === "string" && event.parentToolUseId ? event.parentToolUseId : undefined;
162
+ eventLog.append(record.id, { ...base, id: `bivy-tool-call-${callId}`, content: [{ type: "tool_use", id: callId, name, input, ...(parentToolUseId ? { parentToolUseId } : {}), ...(event.detail ? { detail: event.detail } : {}) }] });
156
163
  }
157
164
  }
158
165
  function persistIntermediateFromEvent(record, event, final = false) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.16.25-staging.30",
3
+ "version": "0.16.25-staging.31",
4
4
  "type": "module",
5
5
  "license": "AGPL-3.0-only",
6
6
  "description": "Run coding agents on machines you own. Open-source, self-hostable agent workspace.",