@cortexkit/opencode-magic-context 0.15.7 → 0.16.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 (191) hide show
  1. package/README.md +41 -15
  2. package/dist/agents/magic-context-prompt.d.ts +2 -13
  3. package/dist/agents/magic-context-prompt.d.ts.map +1 -1
  4. package/dist/cli/diagnostics.d.ts.map +1 -1
  5. package/dist/cli/migrate.d.ts +70 -0
  6. package/dist/cli/migrate.d.ts.map +1 -0
  7. package/dist/cli.js +666 -29
  8. package/dist/config/schema/magic-context.d.ts +67 -4
  9. package/dist/config/schema/magic-context.d.ts.map +1 -1
  10. package/dist/features/magic-context/compaction-marker.d.ts.map +1 -1
  11. package/dist/features/magic-context/compaction.d.ts +1 -1
  12. package/dist/features/magic-context/compaction.d.ts.map +1 -1
  13. package/dist/features/magic-context/compartment-storage.d.ts +1 -1
  14. package/dist/features/magic-context/compartment-storage.d.ts.map +1 -1
  15. package/dist/features/magic-context/compression-depth-storage.d.ts +1 -1
  16. package/dist/features/magic-context/compression-depth-storage.d.ts.map +1 -1
  17. package/dist/features/magic-context/dreamer/lease.d.ts +1 -1
  18. package/dist/features/magic-context/dreamer/lease.d.ts.map +1 -1
  19. package/dist/features/magic-context/dreamer/queue.d.ts +8 -3
  20. package/dist/features/magic-context/dreamer/queue.d.ts.map +1 -1
  21. package/dist/features/magic-context/dreamer/runner.d.ts +1 -1
  22. package/dist/features/magic-context/dreamer/runner.d.ts.map +1 -1
  23. package/dist/features/magic-context/dreamer/scheduler.d.ts +1 -1
  24. package/dist/features/magic-context/dreamer/scheduler.d.ts.map +1 -1
  25. package/dist/features/magic-context/dreamer/storage-dream-runs.d.ts +1 -1
  26. package/dist/features/magic-context/dreamer/storage-dream-runs.d.ts.map +1 -1
  27. package/dist/features/magic-context/dreamer/storage-dream-state.d.ts +1 -1
  28. package/dist/features/magic-context/dreamer/storage-dream-state.d.ts.map +1 -1
  29. package/dist/features/magic-context/git-commits/indexer.d.ts +1 -1
  30. package/dist/features/magic-context/git-commits/indexer.d.ts.map +1 -1
  31. package/dist/features/magic-context/git-commits/search-git-commits.d.ts +1 -1
  32. package/dist/features/magic-context/git-commits/search-git-commits.d.ts.map +1 -1
  33. package/dist/features/magic-context/git-commits/storage-git-commit-embeddings.d.ts +1 -1
  34. package/dist/features/magic-context/git-commits/storage-git-commit-embeddings.d.ts.map +1 -1
  35. package/dist/features/magic-context/git-commits/storage-git-commits.d.ts +1 -1
  36. package/dist/features/magic-context/git-commits/storage-git-commits.d.ts.map +1 -1
  37. package/dist/features/magic-context/key-files/identify-key-files.d.ts +1 -1
  38. package/dist/features/magic-context/key-files/identify-key-files.d.ts.map +1 -1
  39. package/dist/features/magic-context/key-files/read-stats.d.ts +1 -1
  40. package/dist/features/magic-context/key-files/read-stats.d.ts.map +1 -1
  41. package/dist/features/magic-context/key-files/storage-key-files.d.ts +1 -1
  42. package/dist/features/magic-context/key-files/storage-key-files.d.ts.map +1 -1
  43. package/dist/features/magic-context/memory/embedding-backfill.d.ts +1 -1
  44. package/dist/features/magic-context/memory/embedding-backfill.d.ts.map +1 -1
  45. package/dist/features/magic-context/memory/embedding-cache.d.ts +1 -1
  46. package/dist/features/magic-context/memory/embedding-cache.d.ts.map +1 -1
  47. package/dist/features/magic-context/memory/embedding-local.d.ts.map +1 -1
  48. package/dist/features/magic-context/memory/embedding.d.ts +1 -1
  49. package/dist/features/magic-context/memory/embedding.d.ts.map +1 -1
  50. package/dist/features/magic-context/memory/normalize-hash.d.ts.map +1 -1
  51. package/dist/features/magic-context/memory/project-identity.d.ts.map +1 -1
  52. package/dist/features/magic-context/memory/promotion.d.ts +1 -1
  53. package/dist/features/magic-context/memory/promotion.d.ts.map +1 -1
  54. package/dist/features/magic-context/memory/storage-memory-embeddings.d.ts +1 -1
  55. package/dist/features/magic-context/memory/storage-memory-embeddings.d.ts.map +1 -1
  56. package/dist/features/magic-context/memory/storage-memory-fts.d.ts +1 -1
  57. package/dist/features/magic-context/memory/storage-memory-fts.d.ts.map +1 -1
  58. package/dist/features/magic-context/memory/storage-memory.d.ts +1 -1
  59. package/dist/features/magic-context/memory/storage-memory.d.ts.map +1 -1
  60. package/dist/features/magic-context/message-index.d.ts +1 -1
  61. package/dist/features/magic-context/message-index.d.ts.map +1 -1
  62. package/dist/features/magic-context/migrations.d.ts +1 -1
  63. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  64. package/dist/features/magic-context/mock-database.d.ts +1 -1
  65. package/dist/features/magic-context/mock-database.d.ts.map +1 -1
  66. package/dist/features/magic-context/plugin-messages.d.ts +1 -1
  67. package/dist/features/magic-context/plugin-messages.d.ts.map +1 -1
  68. package/dist/features/magic-context/search.d.ts +1 -1
  69. package/dist/features/magic-context/search.d.ts.map +1 -1
  70. package/dist/features/magic-context/sidekick/agent.d.ts +2 -1
  71. package/dist/features/magic-context/sidekick/agent.d.ts.map +1 -1
  72. package/dist/features/magic-context/sidekick/core.d.ts +38 -0
  73. package/dist/features/magic-context/sidekick/core.d.ts.map +1 -0
  74. package/dist/features/magic-context/storage-db.d.ts +20 -1
  75. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  76. package/dist/features/magic-context/storage-meta-persisted.d.ts +1 -1
  77. package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
  78. package/dist/features/magic-context/storage-meta-session.d.ts +1 -1
  79. package/dist/features/magic-context/storage-meta-session.d.ts.map +1 -1
  80. package/dist/features/magic-context/storage-meta-shared.d.ts +1 -1
  81. package/dist/features/magic-context/storage-meta-shared.d.ts.map +1 -1
  82. package/dist/features/magic-context/storage-notes.d.ts +1 -1
  83. package/dist/features/magic-context/storage-notes.d.ts.map +1 -1
  84. package/dist/features/magic-context/storage-ops.d.ts +1 -1
  85. package/dist/features/magic-context/storage-ops.d.ts.map +1 -1
  86. package/dist/features/magic-context/storage-source.d.ts +1 -1
  87. package/dist/features/magic-context/storage-source.d.ts.map +1 -1
  88. package/dist/features/magic-context/storage-tags.d.ts +17 -1
  89. package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
  90. package/dist/features/magic-context/tagger.d.ts +1 -1
  91. package/dist/features/magic-context/tagger.d.ts.map +1 -1
  92. package/dist/features/magic-context/user-memory/review-user-memories.d.ts +1 -1
  93. package/dist/features/magic-context/user-memory/review-user-memories.d.ts.map +1 -1
  94. package/dist/features/magic-context/user-memory/storage-user-memory.d.ts +1 -1
  95. package/dist/features/magic-context/user-memory/storage-user-memory.d.ts.map +1 -1
  96. package/dist/hooks/magic-context/auto-search-hint.d.ts.map +1 -1
  97. package/dist/hooks/magic-context/auto-search-runner.d.ts +1 -1
  98. package/dist/hooks/magic-context/auto-search-runner.d.ts.map +1 -1
  99. package/dist/hooks/magic-context/command-handler.d.ts +1 -1
  100. package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
  101. package/dist/hooks/magic-context/compaction-marker-manager.d.ts +1 -1
  102. package/dist/hooks/magic-context/compaction-marker-manager.d.ts.map +1 -1
  103. package/dist/hooks/magic-context/compartment-prompt.d.ts +1 -0
  104. package/dist/hooks/magic-context/compartment-prompt.d.ts.map +1 -1
  105. package/dist/hooks/magic-context/compartment-runner-compressor.d.ts +1 -1
  106. package/dist/hooks/magic-context/compartment-runner-compressor.d.ts.map +1 -1
  107. package/dist/hooks/magic-context/compartment-runner-drop-queue.d.ts +1 -1
  108. package/dist/hooks/magic-context/compartment-runner-drop-queue.d.ts.map +1 -1
  109. package/dist/hooks/magic-context/compartment-runner-incremental.d.ts +1 -0
  110. package/dist/hooks/magic-context/compartment-runner-incremental.d.ts.map +1 -1
  111. package/dist/hooks/magic-context/compartment-runner-recomp.d.ts.map +1 -1
  112. package/dist/hooks/magic-context/compartment-runner-types.d.ts +1 -1
  113. package/dist/hooks/magic-context/compartment-runner-types.d.ts.map +1 -1
  114. package/dist/hooks/magic-context/compartment-trigger.d.ts +1 -1
  115. package/dist/hooks/magic-context/compartment-trigger.d.ts.map +1 -1
  116. package/dist/hooks/magic-context/execute-flush.d.ts +1 -1
  117. package/dist/hooks/magic-context/execute-flush.d.ts.map +1 -1
  118. package/dist/hooks/magic-context/execute-status.d.ts +1 -1
  119. package/dist/hooks/magic-context/execute-status.d.ts.map +1 -1
  120. package/dist/hooks/magic-context/historian-state-file.d.ts +29 -0
  121. package/dist/hooks/magic-context/historian-state-file.d.ts.map +1 -0
  122. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  123. package/dist/hooks/magic-context/inject-compartments.d.ts +1 -1
  124. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  125. package/dist/hooks/magic-context/note-nudger.d.ts +1 -1
  126. package/dist/hooks/magic-context/note-nudger.d.ts.map +1 -1
  127. package/dist/hooks/magic-context/nudge-placement-store.d.ts +1 -1
  128. package/dist/hooks/magic-context/nudge-placement-store.d.ts.map +1 -1
  129. package/dist/hooks/magic-context/read-session-chunk.d.ts +39 -0
  130. package/dist/hooks/magic-context/read-session-chunk.d.ts.map +1 -1
  131. package/dist/hooks/magic-context/read-session-db.d.ts +1 -1
  132. package/dist/hooks/magic-context/read-session-db.d.ts.map +1 -1
  133. package/dist/hooks/magic-context/read-session-raw.d.ts +1 -1
  134. package/dist/hooks/magic-context/read-session-raw.d.ts.map +1 -1
  135. package/dist/hooks/magic-context/send-session-notification.d.ts.map +1 -1
  136. package/dist/hooks/magic-context/system-prompt-hash.d.ts +6 -5
  137. package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
  138. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  139. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  140. package/dist/index.js +8284 -8166
  141. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  142. package/dist/plugin/messages-transform.d.ts +1 -1
  143. package/dist/plugin/rpc-handlers.d.ts +4 -0
  144. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  145. package/dist/plugin/tool-registry.d.ts.map +1 -1
  146. package/dist/shared/conflict-detector.d.ts.map +1 -1
  147. package/dist/shared/data-path.d.ts +22 -0
  148. package/dist/shared/data-path.d.ts.map +1 -1
  149. package/dist/shared/harness.d.ts +43 -0
  150. package/dist/shared/harness.d.ts.map +1 -0
  151. package/dist/shared/rpc-notifications.d.ts +4 -2
  152. package/dist/shared/rpc-notifications.d.ts.map +1 -1
  153. package/dist/shared/sqlite-helpers.d.ts +16 -0
  154. package/dist/shared/sqlite-helpers.d.ts.map +1 -0
  155. package/dist/shared/sqlite.d.ts +55 -0
  156. package/dist/shared/sqlite.d.ts.map +1 -0
  157. package/dist/shared/subagent-runner.d.ts +202 -0
  158. package/dist/shared/subagent-runner.d.ts.map +1 -0
  159. package/dist/shared/tag-transcript.d.ts +66 -0
  160. package/dist/shared/tag-transcript.d.ts.map +1 -0
  161. package/dist/shared/transcript-opencode.d.ts +71 -0
  162. package/dist/shared/transcript-opencode.d.ts.map +1 -0
  163. package/dist/shared/transcript.d.ts +212 -0
  164. package/dist/shared/transcript.d.ts.map +1 -0
  165. package/dist/shared/tui-config.d.ts.map +1 -1
  166. package/dist/tools/ctx-memory/tools.d.ts.map +1 -1
  167. package/dist/tools/ctx-memory/types.d.ts +13 -2
  168. package/dist/tools/ctx-memory/types.d.ts.map +1 -1
  169. package/dist/tools/ctx-note/tools.d.ts +8 -2
  170. package/dist/tools/ctx-note/tools.d.ts.map +1 -1
  171. package/dist/tools/ctx-reduce/tools.d.ts +1 -1
  172. package/dist/tools/ctx-reduce/tools.d.ts.map +1 -1
  173. package/dist/tools/ctx-search/tools.d.ts.map +1 -1
  174. package/dist/tools/ctx-search/types.d.ts +8 -2
  175. package/dist/tools/ctx-search/types.d.ts.map +1 -1
  176. package/dist/tui/data/context-db.d.ts.map +1 -1
  177. package/package.json +7 -5
  178. package/src/shared/conflict-detector.test.ts +44 -1
  179. package/src/shared/conflict-detector.ts +24 -8
  180. package/src/shared/data-path.test.ts +53 -1
  181. package/src/shared/data-path.ts +28 -0
  182. package/src/shared/harness.ts +61 -0
  183. package/src/shared/rpc-notifications.ts +11 -5
  184. package/src/shared/sqlite-helpers.ts +27 -0
  185. package/src/shared/sqlite.ts +91 -0
  186. package/src/shared/subagent-runner.ts +206 -0
  187. package/src/shared/tag-transcript.ts +541 -0
  188. package/src/shared/transcript-opencode.ts +259 -0
  189. package/src/shared/transcript.ts +226 -0
  190. package/src/shared/tui-config.ts +34 -8
  191. package/src/tui/data/context-db.ts +5 -1
@@ -0,0 +1,541 @@
1
+ /**
2
+ * Harness-agnostic tagging over the Transcript interface.
3
+ *
4
+ * This is a deliberately minimal alternative to the OpenCode-specific
5
+ * `tag-messages.ts` that operates on `MessageLike[]`. The OpenCode flow
6
+ * carries 380+ lines of accumulated complexity:
7
+ *
8
+ * - source-content persistence (for cross-pass detag/restore behavior),
9
+ * - tool-call indexing across separate "tool" and "tool_result" parts,
10
+ * - reasoning-byte tracking for historian projection,
11
+ * - file-part stable IDs,
12
+ * - existing-tag resolver with content-id fallback.
13
+ *
14
+ * Most of that is OpenCode-specific (cache stability across multi-pass
15
+ * transforms, AI SDK part-id semantics, file part shapes). Pi's
16
+ * `pi.on("context", ...)` fires once per LLM call with a complete
17
+ * `AgentMessage[]`, so we can use a simpler tagging contract:
18
+ *
19
+ * 1. Walk the transcript in order.
20
+ * 2. For each tag-eligible part (text, tool_use, tool_result), assign
21
+ * a tag number via the shared `Tagger`.
22
+ * 3. Inject `§N§ ` prefix into the visible text (unless skipped).
23
+ * 4. Build a `TagTarget` so `applyPendingOperations` from
24
+ * `apply-operations.ts` can replace this part with a sentinel when
25
+ * a queued drop fires.
26
+ *
27
+ * Tool drops aggregate by call_id across both invocation and result
28
+ * occurrences (mirrors OpenCode tag-messages.ts:196-220). When a drop
29
+ * fires for a tool tag, BOTH the assistant `toolCall`/`tool_use` part
30
+ * and the user `toolResult`/`tool_result` part are mutated together so
31
+ * the LLM sees consistent dropped state. Without this aggregation:
32
+ *
33
+ * - Tool tag byte_size reflects only the args (~58 bytes for a `read`)
34
+ * because the FIRST occurrence (invocation) is tagged first and
35
+ * `assignTag` short-circuits the SECOND occurrence (result, ~4KB)
36
+ * to the same tag without updating byte_size.
37
+ * - Drops touch only the second occurrence (last write wins on
38
+ * `targets.set`), leaving the first in original form.
39
+ *
40
+ * Reuses unchanged from the OpenCode path:
41
+ *
42
+ * - `Tagger` (DB-backed counter + assignment store).
43
+ * - `applyPendingOperations` (operates on `Map<number, TagTarget>`).
44
+ * - `applyFlushedStatuses` (same).
45
+ * - Tag prefix primitives (`prependTag`, `stripTagPrefix`, `byteSize`).
46
+ */
47
+
48
+ import type { ContextDatabase } from "../features/magic-context/storage";
49
+ import { saveSourceContent } from "../features/magic-context/storage-source";
50
+ import { updateTagByteSize, updateTagInputByteSize } from "../features/magic-context/storage-tags";
51
+ import type { Tagger } from "../features/magic-context/tagger";
52
+ import {
53
+ byteSize,
54
+ prependTag,
55
+ stripTagPrefix,
56
+ } from "../hooks/magic-context/tag-content-primitives";
57
+ import type { TagTarget } from "../hooks/magic-context/tag-messages";
58
+ import type { Transcript, TranscriptPart } from "./transcript";
59
+
60
+ export interface TagTranscriptOptions {
61
+ /**
62
+ * When true, skip injecting `§N§` prefix into visible text. Tags
63
+ * still get assigned in the DB so historian/drops can reference
64
+ * them; the agent just doesn't see the markers. Used when
65
+ * `ctx_reduce_enabled: false` (agent has no `ctx_reduce` tool to
66
+ * act on the markers). Cache-safe because skip behavior is
67
+ * consistent across passes.
68
+ */
69
+ skipPrefixInjection?: boolean;
70
+ }
71
+
72
+ export interface TagTranscriptResult {
73
+ targets: Map<number, TagTarget>;
74
+ }
75
+
76
+ /**
77
+ * Tag eligible parts of a transcript and build TagTargets for them.
78
+ *
79
+ * "Eligible" means: parts that contribute meaningfully to the LLM input
80
+ * and whose content can be replaced when dropped. Specifically:
81
+ *
82
+ * - text parts (user or assistant): tagged as type "message", inject
83
+ * prefix into the visible text, target supports setContent.
84
+ * - thinking parts: NOT tagged. Reasoning content has provider-
85
+ * specific signed-content semantics (Anthropic redacted_thinking,
86
+ * etc.) and replacing them mid-conversation breaks signature
87
+ * verification. The historian's clear-reasoning pass handles them
88
+ * separately if needed.
89
+ * - tool_use parts (assistant tool invocations): tagged as type
90
+ * "tool", target supports drop/truncate via the tag-content
91
+ * primitives.
92
+ * - tool_result parts (folded into user messages by the Pi adapter):
93
+ * tagged as type "tool", paired with the corresponding invocation
94
+ * for full-pair drops.
95
+ * - image, file, structural, unknown: skipped.
96
+ *
97
+ * The contentId we pass to the tagger uses the part's stable id when
98
+ * available, otherwise a synthetic locator. Pi's adapter exposes:
99
+ * - tool_use parts: id = ToolCall.id (from pi-ai)
100
+ * - tool_result parts: id = ToolResultMessage.toolCallId
101
+ * - text parts: id = undefined → we synthesize from message+ordinal
102
+ */
103
+ /**
104
+ * Per-callId aggregation of tool occurrences across the transcript.
105
+ * Built up during the walk and used to:
106
+ * 1. Assign one tag per call_id with byte_size from the LARGEST
107
+ * occurrence (typically the result, ~4KB) instead of the args
108
+ * (~58 bytes). Without this, drop projection underestimates
109
+ * reclaimable bytes by ~70× per tool.
110
+ * 2. Build a single aggregate TagTarget that mutates BOTH the
111
+ * invocation and result occurrences atomically, so a queued drop
112
+ * replaces both halves with a sentinel instead of last-write-wins.
113
+ */
114
+ interface ToolOccurrence {
115
+ message: { info: { id?: string; role: string } };
116
+ part: TranscriptPart;
117
+ kind: "tool_use" | "tool_result";
118
+ }
119
+
120
+ interface ToolAggregate {
121
+ callId: string;
122
+ occurrences: ToolOccurrence[];
123
+ /** Largest byteSize seen across occurrences — used as the tag size. */
124
+ maxByteSize: number;
125
+ /** Tool name from the first occurrence we see one on. */
126
+ toolName: string | null;
127
+ /** Input byte size from the invocation occurrence (for storage projection). */
128
+ inputByteSize: number;
129
+ }
130
+
131
+ export function tagTranscript(
132
+ sessionId: string,
133
+ transcript: Transcript,
134
+ tagger: Tagger,
135
+ db: ContextDatabase,
136
+ options: TagTranscriptOptions = {},
137
+ ): TagTranscriptResult {
138
+ const skipPrefixInjection = options.skipPrefixInjection === true;
139
+ const targets = new Map<number, TagTarget>();
140
+
141
+ // Per-callId tool aggregation tracked across the single walk. Tags
142
+ // get allocated in transcript order (first occurrence reserves the
143
+ // tag number); subsequent occurrences reuse the same tag and merge
144
+ // their occurrence into the aggregate's TagTarget. This preserves
145
+ // chronological tag numbering while still aggregating drop behavior
146
+ // across both invocation and result.
147
+ const toolAggregates = new Map<string, ToolAggregate & { tagId: number }>();
148
+
149
+ db.transaction(() => {
150
+ for (let msgIndex = 0; msgIndex < transcript.messages.length; msgIndex += 1) {
151
+ const message = transcript.messages[msgIndex];
152
+ if (message === undefined) continue;
153
+ const messageId = message.info.id;
154
+
155
+ let textOrdinal = 0;
156
+
157
+ for (let partIndex = 0; partIndex < message.parts.length; partIndex += 1) {
158
+ const part = message.parts[partIndex];
159
+ if (part === undefined) continue;
160
+
161
+ if (part.kind === "text") {
162
+ // Synthetic message ids (Pi tail synthetic user with
163
+ // no id) cannot be tagged — there's no stable handle
164
+ // to bind a tag to across passes. Pass through
165
+ // untagged; this is rare (only happens for the
166
+ // dangling tool-result tail case in Pi).
167
+ if (messageId === undefined) {
168
+ textOrdinal += 1;
169
+ continue;
170
+ }
171
+ tagTextPart({
172
+ sessionId,
173
+ message,
174
+ messageId,
175
+ msgIndex,
176
+ textOrdinal,
177
+ part,
178
+ tagger,
179
+ db,
180
+ targets,
181
+ skipPrefixInjection,
182
+ });
183
+ textOrdinal += 1;
184
+ continue;
185
+ }
186
+
187
+ if (part.kind === "tool_use" || part.kind === "tool_result") {
188
+ if (messageId === undefined) continue;
189
+
190
+ const callId = part.id;
191
+ const text = part.getText() ?? "";
192
+ const meta = part.getToolMetadata();
193
+
194
+ if (typeof callId !== "string" || callId.length === 0) {
195
+ // No stable callId to aggregate on. Tag independently.
196
+ tagToolPart({
197
+ sessionId,
198
+ message,
199
+ messageId,
200
+ msgIndex,
201
+ partIndex,
202
+ part,
203
+ tagger,
204
+ db,
205
+ targets,
206
+ skipPrefixInjection,
207
+ });
208
+ continue;
209
+ }
210
+
211
+ const existing = toolAggregates.get(callId);
212
+ if (existing) {
213
+ // Second (or later) occurrence for this call_id.
214
+ // Merge into the existing aggregate, update byte_size
215
+ // in DB if larger, and rebuild the TagTarget so the
216
+ // closures over `occurrences` see all parts.
217
+ existing.occurrences.push({
218
+ message,
219
+ part,
220
+ kind: part.kind,
221
+ });
222
+ const newByteSize = byteSize(text);
223
+ if (newByteSize > existing.maxByteSize) {
224
+ existing.maxByteSize = newByteSize;
225
+ updateTagByteSize(db, sessionId, existing.tagId, newByteSize);
226
+ }
227
+ if (existing.toolName === null && meta.toolName) {
228
+ existing.toolName = meta.toolName;
229
+ }
230
+ if (
231
+ existing.inputByteSize === 0 &&
232
+ part.kind === "tool_use" &&
233
+ meta.inputByteSize > 0
234
+ ) {
235
+ existing.inputByteSize = meta.inputByteSize;
236
+ updateTagInputByteSize(
237
+ db,
238
+ sessionId,
239
+ existing.tagId,
240
+ meta.inputByteSize,
241
+ );
242
+ }
243
+ // Inject §N§ prefix into this tool_result occurrence
244
+ // (matches OpenCode behavior — only result gets the prefix).
245
+ if (!skipPrefixInjection && part.kind === "tool_result") {
246
+ part.setText(prependTag(existing.tagId, text));
247
+ }
248
+ // Rebuild the aggregate target so it walks the now-
249
+ // longer occurrences list.
250
+ targets.set(
251
+ existing.tagId,
252
+ buildAggregateTarget(existing.tagId, existing.occurrences),
253
+ );
254
+ } else {
255
+ // First occurrence — reserve the tag number.
256
+ const tagId = tagger.assignTag(
257
+ sessionId,
258
+ callId,
259
+ "tool",
260
+ byteSize(text),
261
+ db,
262
+ 0,
263
+ meta.toolName ?? null,
264
+ meta.inputByteSize,
265
+ );
266
+ const aggregate = {
267
+ callId,
268
+ tagId,
269
+ occurrences: [
270
+ {
271
+ message,
272
+ part,
273
+ kind: part.kind,
274
+ },
275
+ ],
276
+ maxByteSize: byteSize(text),
277
+ toolName: meta.toolName ?? null,
278
+ inputByteSize: part.kind === "tool_use" ? meta.inputByteSize : 0,
279
+ };
280
+ toolAggregates.set(callId, aggregate);
281
+ // Inject §N§ prefix into this occurrence's visible text
282
+ // when it's a tool_result. (OpenCode parity: prefix
283
+ // only goes on the result, not the invocation.)
284
+ if (!skipPrefixInjection && part.kind === "tool_result") {
285
+ part.setText(prependTag(tagId, text));
286
+ }
287
+ targets.set(tagId, buildAggregateTarget(tagId, aggregate.occurrences));
288
+ }
289
+ }
290
+ // thinking, image, file, structural, unknown → skip.
291
+ }
292
+ }
293
+ })();
294
+
295
+ return { targets };
296
+ }
297
+
298
+ interface TagTextPartArgs {
299
+ sessionId: string;
300
+ message: { info: { id?: string; role: string } };
301
+ messageId: string;
302
+ msgIndex: number;
303
+ textOrdinal: number;
304
+ part: TranscriptPart;
305
+ tagger: Tagger;
306
+ db: ContextDatabase;
307
+ targets: Map<number, TagTarget>;
308
+ skipPrefixInjection: boolean;
309
+ }
310
+
311
+ function tagTextPart(args: TagTextPartArgs): void {
312
+ const text = args.part.getText() ?? "";
313
+ const contentId = `${args.messageId}:p${args.textOrdinal}`;
314
+ const tagId = args.tagger.assignTag(
315
+ args.sessionId,
316
+ contentId,
317
+ "message",
318
+ byteSize(text),
319
+ args.db,
320
+ );
321
+
322
+ // Persist the original (pre-tagged) source content so caveman
323
+ // compression and other "compress from original" heuristics have
324
+ // pristine text to read on later passes. saveSourceContent uses
325
+ // INSERT OR IGNORE — first write wins; later passes that re-tag
326
+ // the same (sessionId, tagId) pair from already-prefixed text won't
327
+ // overwrite the original. Cache-stable.
328
+ //
329
+ // We strip any existing §N§ prefix before saving in case a previous
330
+ // pass already injected one and the persisted source got lost
331
+ // (e.g. legacy session created before this code shipped). For new
332
+ // sessions stripTagPrefix is a no-op on the very first pass.
333
+ const sourceContent = stripTagPrefix(text);
334
+ if (sourceContent.trim().length > 0) {
335
+ saveSourceContent(args.db, args.sessionId, tagId, sourceContent);
336
+ }
337
+
338
+ if (!args.skipPrefixInjection) {
339
+ args.part.setText(prependTag(tagId, text));
340
+ }
341
+
342
+ args.targets.set(tagId, buildTextTarget(args.part, args.message));
343
+ }
344
+
345
+ interface TagToolPartArgs {
346
+ sessionId: string;
347
+ message: { info: { id?: string; role: string } };
348
+ messageId: string;
349
+ msgIndex: number;
350
+ partIndex: number;
351
+ part: TranscriptPart;
352
+ tagger: Tagger;
353
+ db: ContextDatabase;
354
+ targets: Map<number, TagTarget>;
355
+ skipPrefixInjection: boolean;
356
+ }
357
+
358
+ function tagToolPart(args: TagToolPartArgs): void {
359
+ // Prefer the part's stable id (tool call id from Pi/OpenCode); fall
360
+ // back to a synthetic locator. Tool calls and their results MAY
361
+ // share an id (Pi sets toolCallId on ToolResultMessage to match the
362
+ // originating ToolCall.id); when that happens, both tag operations
363
+ // resolve to the same tag number — desired behavior, since drops
364
+ // target the call-id pair as a unit.
365
+ const stableId = args.part.id;
366
+ const contentId = stableId ?? `${args.messageId}:t${args.partIndex}`;
367
+ const text = args.part.getText() ?? "";
368
+ const meta = args.part.getToolMetadata();
369
+ const tagId = args.tagger.assignTag(
370
+ args.sessionId,
371
+ contentId,
372
+ "tool",
373
+ byteSize(text),
374
+ args.db,
375
+ 0,
376
+ meta.toolName ?? null,
377
+ meta.inputByteSize,
378
+ );
379
+
380
+ // For tool parts, the visible payload is the tool result text. We
381
+ // can inject the tag prefix into it for in-text references; this
382
+ // matches the OpenCode behavior of tagging tool outputs.
383
+ if (!args.skipPrefixInjection && args.part.kind === "tool_result") {
384
+ const tagged = prependTag(tagId, text);
385
+ args.part.setText(tagged);
386
+ }
387
+
388
+ args.targets.set(tagId, buildToolTarget(args.part, args.message));
389
+ }
390
+
391
+ /**
392
+ * Build a TagTarget that walks ALL occurrences of a tool call (invocation
393
+ * + result) when mutating. This is the per-callId aggregate target used
394
+ * by `tagTranscript` so a single drop replaces both halves.
395
+ *
396
+ * The closures hold a reference to the same `occurrences` array stored
397
+ * on the aggregate, so when the array gets mutated (a second occurrence
398
+ * is pushed mid-walk), the next call to setContent/drop/truncate sees
399
+ * all occurrences automatically. Callers MUST rebuild the target after
400
+ * pushing a new occurrence so the targets map points to a fresh closure
401
+ * over the updated array — otherwise consumers that captured the target
402
+ * before the push won't see the new occurrence.
403
+ *
404
+ * Mirrors OpenCode's createToolDropTarget semantics in tool-drop-target.ts.
405
+ */
406
+ function buildAggregateTarget(tagId: number, occurrences: ToolOccurrence[]): TagTarget {
407
+ const role = occurrences[0]?.message.info.role ?? "user";
408
+ const messageId = occurrences[0]?.message.info.id;
409
+
410
+ return {
411
+ setContent(content: string): boolean {
412
+ // Walk all occurrences; mutate every one. Return true if at
413
+ // least one occurrence's content actually changed (used to
414
+ // gate sentinel-replay re-writes).
415
+ let changed = false;
416
+ for (const occ of occurrences) {
417
+ // Try setToolOutput first (works on tool_result-shaped parts);
418
+ // fall back to setText so tool_use parts also get sentinelized.
419
+ if (occ.part.setToolOutput(content)) {
420
+ changed = true;
421
+ } else if (occ.part.setText(content)) {
422
+ changed = true;
423
+ }
424
+ }
425
+ return changed;
426
+ },
427
+ getContent(): string | null {
428
+ // Prefer the result occurrence's content (the bulky payload).
429
+ for (const occ of occurrences) {
430
+ if (occ.kind === "tool_result") {
431
+ return occ.part.getText() ?? null;
432
+ }
433
+ }
434
+ return occurrences[0]?.part.getText() ?? null;
435
+ },
436
+ drop(): "removed" | "absent" {
437
+ // Replace BOTH halves with the dropped sentinel.
438
+ const sentinel = `[dropped \u00a7${tagId}\u00a7]`;
439
+ let any = false;
440
+ for (const occ of occurrences) {
441
+ if (occ.part.replaceWithSentinel(sentinel)) any = true;
442
+ }
443
+ return any ? "removed" : "absent";
444
+ },
445
+ truncate(): "truncated" | "absent" {
446
+ // Truncate BOTH halves. For tool_use, this typically truncates
447
+ // the args; for tool_result, the output. The sentinel string
448
+ // matches OpenCode's truncate sentinel exactly.
449
+ const sentinel = "[truncated]";
450
+ let any = false;
451
+ for (const occ of occurrences) {
452
+ if (occ.part.setToolOutput(sentinel) || occ.part.setText(sentinel)) {
453
+ any = true;
454
+ }
455
+ }
456
+ return any ? "truncated" : "absent";
457
+ },
458
+ message: {
459
+ info: { id: messageId, role },
460
+ parts: [],
461
+ },
462
+ };
463
+ }
464
+
465
+ /**
466
+ * TagTarget for a tag-eligible text part. The shared
467
+ * `applyPendingOperations` flow calls `setContent` to swap in a
468
+ * sentinel like `[dropped §N§]` when a queued drop fires; `getContent`
469
+ * returns the current visible text so the truncated-preview path can
470
+ * compute its before/after.
471
+ *
472
+ * The `message.info.role` is used by `buildReplacementContent` in
473
+ * `apply-operations.ts` to differentiate user-message drops (which
474
+ * preserve a truncated preview) from assistant drops (full sentinel).
475
+ */
476
+ function buildTextTarget(
477
+ part: TranscriptPart,
478
+ message: { info: { id?: string; role: string } },
479
+ ): TagTarget {
480
+ return {
481
+ setContent(content: string): boolean {
482
+ return part.setText(content);
483
+ },
484
+ getContent(): string | null {
485
+ return part.getText() ?? null;
486
+ },
487
+ // `message` is typed as MessageLike, which has parts: unknown[].
488
+ // We don't carry parts here (the apply-operations flow only
489
+ // reads `info.role` on this field), so a minimal stub is
490
+ // sufficient.
491
+ message: {
492
+ info: { id: message.info.id, role: message.info.role },
493
+ parts: [],
494
+ },
495
+ };
496
+ }
497
+
498
+ /**
499
+ * TagTarget for a tag-eligible tool part. Tool parts get full-drop
500
+ * (replace with `[dropped §N§]`) or truncated-drop (replace with
501
+ * `[truncated]`) treatment from `applyFlushedStatuses` based on the
502
+ * stored `drop_mode` column. We expose both via the standard target
503
+ * surface; replaceWithSentinel is the canonical mutation, with
504
+ * truncated-drop using the "[truncated]" string.
505
+ */
506
+ function buildToolTarget(
507
+ part: TranscriptPart,
508
+ message: { info: { id?: string; role: string } },
509
+ ): TagTarget {
510
+ return {
511
+ setContent(content: string): boolean {
512
+ return part.setToolOutput(content) || part.setText(content);
513
+ },
514
+ getContent(): string | null {
515
+ return part.getText() ?? null;
516
+ },
517
+ drop(): "removed" | "absent" {
518
+ // Replace the tool part's visible content with a "[dropped]"
519
+ // shell. We can't physically remove the part because Pi
520
+ // requires tool_use ↔ tool_result pairing for the LLM call
521
+ // to validate; instead we shrink the content to a sentinel.
522
+ // For Pi the current Transcript contract treats both
523
+ // invocation and result parts symmetrically — both expose
524
+ // setText / setToolOutput.
525
+ const replaced = part.replaceWithSentinel(`[dropped \u00a7${part.id ?? "?"}\u00a7]`);
526
+ return replaced ? "removed" : "absent";
527
+ },
528
+ truncate(): "truncated" | "absent" {
529
+ // Truncate the tool output to a fixed sentinel string. Done
530
+ // via setToolOutput so the underlying tool_result content
531
+ // gets the truncation; falls back to setText for cases
532
+ // where the part type doesn't support setToolOutput.
533
+ const ok = part.setToolOutput("[truncated]") || part.setText("[truncated]");
534
+ return ok ? "truncated" : "absent";
535
+ },
536
+ message: {
537
+ info: { id: message.info.id, role: message.info.role },
538
+ parts: [],
539
+ },
540
+ };
541
+ }