@cortexkit/opencode-magic-context 0.21.8 → 0.22.1

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 (223) hide show
  1. package/README.md +124 -323
  2. package/dist/agents/magic-context-prompt.d.ts.map +1 -1
  3. package/dist/agents/permissions.d.ts +29 -14
  4. package/dist/agents/permissions.d.ts.map +1 -1
  5. package/dist/config/index.d.ts.map +1 -1
  6. package/dist/config/migrate-experimental.d.ts +29 -0
  7. package/dist/config/migrate-experimental.d.ts.map +1 -0
  8. package/dist/config/schema/agent-overrides.d.ts.map +1 -1
  9. package/dist/config/schema/magic-context.d.ts +95 -104
  10. package/dist/config/schema/magic-context.d.ts.map +1 -1
  11. package/dist/features/builtin-commands/commands.d.ts.map +1 -1
  12. package/dist/features/magic-context/compartment-embedding.d.ts +34 -0
  13. package/dist/features/magic-context/compartment-embedding.d.ts.map +1 -0
  14. package/dist/features/magic-context/compartment-events.d.ts +50 -0
  15. package/dist/features/magic-context/compartment-events.d.ts.map +1 -0
  16. package/dist/features/magic-context/compartment-storage.d.ts +22 -0
  17. package/dist/features/magic-context/compartment-storage.d.ts.map +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 +13 -2
  20. package/dist/features/magic-context/dreamer/queue.d.ts.map +1 -1
  21. package/dist/features/magic-context/dreamer/runner.d.ts +11 -0
  22. package/dist/features/magic-context/dreamer/runner.d.ts.map +1 -1
  23. package/dist/features/magic-context/dreamer/task-prompts.d.ts +1 -1
  24. package/dist/features/magic-context/dreamer/task-prompts.d.ts.map +1 -1
  25. package/dist/features/magic-context/git-commits/git-log-reader.d.ts.map +1 -1
  26. package/dist/features/magic-context/key-files/identify-key-files.d.ts +1 -1
  27. package/dist/features/magic-context/key-files/identify-key-files.d.ts.map +1 -1
  28. package/dist/features/magic-context/key-files/project-key-files.d.ts.map +1 -1
  29. package/dist/features/magic-context/key-files/read-stats.d.ts +1 -1
  30. package/dist/features/magic-context/key-files/read-stats.d.ts.map +1 -1
  31. package/dist/features/magic-context/memory/constants.d.ts +4 -0
  32. package/dist/features/magic-context/memory/constants.d.ts.map +1 -1
  33. package/dist/features/magic-context/memory/embedding-identity.d.ts.map +1 -1
  34. package/dist/features/magic-context/memory/embedding-local.d.ts.map +1 -1
  35. package/dist/features/magic-context/memory/embedding-openai.d.ts +6 -0
  36. package/dist/features/magic-context/memory/embedding-openai.d.ts.map +1 -1
  37. package/dist/features/magic-context/memory/embedding-probe.d.ts +5 -0
  38. package/dist/features/magic-context/memory/embedding-probe.d.ts.map +1 -1
  39. package/dist/features/magic-context/memory/embedding.d.ts.map +1 -1
  40. package/dist/features/magic-context/memory/index.d.ts +1 -1
  41. package/dist/features/magic-context/memory/index.d.ts.map +1 -1
  42. package/dist/features/magic-context/memory/memory-migration.d.ts +133 -0
  43. package/dist/features/magic-context/memory/memory-migration.d.ts.map +1 -0
  44. package/dist/features/magic-context/memory/project-identity.d.ts +38 -7
  45. package/dist/features/magic-context/memory/project-identity.d.ts.map +1 -1
  46. package/dist/features/magic-context/memory/storage-memory-fts.d.ts.map +1 -1
  47. package/dist/features/magic-context/memory/storage-memory.d.ts +15 -1
  48. package/dist/features/magic-context/memory/storage-memory.d.ts.map +1 -1
  49. package/dist/features/magic-context/memory/types.d.ts +3 -1
  50. package/dist/features/magic-context/memory/types.d.ts.map +1 -1
  51. package/dist/features/magic-context/message-index.d.ts.map +1 -1
  52. package/dist/features/magic-context/migrations.d.ts +7 -0
  53. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  54. package/dist/features/magic-context/project-docs-hash.d.ts +6 -0
  55. package/dist/features/magic-context/project-docs-hash.d.ts.map +1 -0
  56. package/dist/features/magic-context/project-identity.d.ts +2 -0
  57. package/dist/features/magic-context/project-identity.d.ts.map +1 -0
  58. package/dist/features/magic-context/storage-db.d.ts +51 -7
  59. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  60. package/dist/features/magic-context/storage-historian-runs.d.ts +73 -0
  61. package/dist/features/magic-context/storage-historian-runs.d.ts.map +1 -0
  62. package/dist/features/magic-context/storage-identity-rekey-map.d.ts +11 -0
  63. package/dist/features/magic-context/storage-identity-rekey-map.d.ts.map +1 -0
  64. package/dist/features/magic-context/storage-m0-mutation-log.d.ts +22 -0
  65. package/dist/features/magic-context/storage-m0-mutation-log.d.ts.map +1 -0
  66. package/dist/features/magic-context/storage-memory-mutation-log.d.ts +25 -0
  67. package/dist/features/magic-context/storage-memory-mutation-log.d.ts.map +1 -0
  68. package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
  69. package/dist/features/magic-context/storage-meta-session.d.ts.map +1 -1
  70. package/dist/features/magic-context/storage-meta-shared.d.ts +44 -0
  71. package/dist/features/magic-context/storage-meta-shared.d.ts.map +1 -1
  72. package/dist/features/magic-context/storage-meta.d.ts +1 -0
  73. package/dist/features/magic-context/storage-meta.d.ts.map +1 -1
  74. package/dist/features/magic-context/storage-project-state.d.ts +19 -0
  75. package/dist/features/magic-context/storage-project-state.d.ts.map +1 -0
  76. package/dist/features/magic-context/storage-subagent-invocations.d.ts +9 -0
  77. package/dist/features/magic-context/storage-subagent-invocations.d.ts.map +1 -1
  78. package/dist/features/magic-context/storage-tags.d.ts +21 -1
  79. package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
  80. package/dist/features/magic-context/storage-v22-backfill-failures.d.ts +24 -0
  81. package/dist/features/magic-context/storage-v22-backfill-failures.d.ts.map +1 -0
  82. package/dist/features/magic-context/storage.d.ts +12 -3
  83. package/dist/features/magic-context/storage.d.ts.map +1 -1
  84. package/dist/features/magic-context/subagent-token-capture.d.ts +1 -1
  85. package/dist/features/magic-context/subagent-token-capture.d.ts.map +1 -1
  86. package/dist/features/magic-context/tagger.d.ts +15 -1
  87. package/dist/features/magic-context/tagger.d.ts.map +1 -1
  88. package/dist/features/magic-context/types.d.ts +21 -0
  89. package/dist/features/magic-context/types.d.ts.map +1 -1
  90. package/dist/features/magic-context/user-memory/review-user-memories.d.ts.map +1 -1
  91. package/dist/features/magic-context/user-memory/storage-user-memory.d.ts.map +1 -1
  92. package/dist/features/magic-context/v22-deferred-backfill.d.ts +46 -0
  93. package/dist/features/magic-context/v22-deferred-backfill.d.ts.map +1 -0
  94. package/dist/features/magic-context/work-metrics.d.ts +66 -0
  95. package/dist/features/magic-context/work-metrics.d.ts.map +1 -1
  96. package/dist/hooks/auto-update-checker/cache.d.ts.map +1 -1
  97. package/dist/hooks/auto-update-checker/checker.d.ts.map +1 -1
  98. package/dist/hooks/magic-context/cache-busting-signals.d.ts +9 -0
  99. package/dist/hooks/magic-context/cache-busting-signals.d.ts.map +1 -1
  100. package/dist/hooks/magic-context/command-handler.d.ts +13 -1
  101. package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
  102. package/dist/hooks/magic-context/compartment-parser.d.ts +25 -0
  103. package/dist/hooks/magic-context/compartment-parser.d.ts.map +1 -1
  104. package/dist/hooks/magic-context/compartment-prompt.d.ts +27 -16
  105. package/dist/hooks/magic-context/compartment-prompt.d.ts.map +1 -1
  106. package/dist/hooks/magic-context/compartment-runner-incremental.d.ts.map +1 -1
  107. package/dist/hooks/magic-context/compartment-runner-mapping.d.ts +6 -2
  108. package/dist/hooks/magic-context/compartment-runner-mapping.d.ts.map +1 -1
  109. package/dist/hooks/magic-context/compartment-runner-partial-recomp.d.ts.map +1 -1
  110. package/dist/hooks/magic-context/compartment-runner-recomp.d.ts +9 -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 +67 -4
  113. package/dist/hooks/magic-context/compartment-runner-types.d.ts.map +1 -1
  114. package/dist/hooks/magic-context/compartment-runner-validation.d.ts.map +1 -1
  115. package/dist/hooks/magic-context/compartment-runner.d.ts.map +1 -1
  116. package/dist/hooks/magic-context/decay-curve.d.ts +78 -0
  117. package/dist/hooks/magic-context/decay-curve.d.ts.map +1 -0
  118. package/dist/hooks/magic-context/decay-render.d.ts +67 -0
  119. package/dist/hooks/magic-context/decay-render.d.ts.map +1 -0
  120. package/dist/hooks/magic-context/event-handler.d.ts +1 -1
  121. package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
  122. package/dist/hooks/magic-context/event-resolvers.d.ts +17 -0
  123. package/dist/hooks/magic-context/event-resolvers.d.ts.map +1 -1
  124. package/dist/hooks/magic-context/execute-status.d.ts.map +1 -1
  125. package/dist/hooks/magic-context/historian-prompt.generated.d.ts +2 -0
  126. package/dist/hooks/magic-context/historian-prompt.generated.d.ts.map +1 -0
  127. package/dist/hooks/magic-context/hook-handlers.d.ts +3 -0
  128. package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
  129. package/dist/hooks/magic-context/hook.d.ts +9 -21
  130. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  131. package/dist/hooks/magic-context/inject-compartments.d.ts +126 -0
  132. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  133. package/dist/hooks/magic-context/key-files-block.d.ts.map +1 -1
  134. package/dist/hooks/magic-context/live-session-state.d.ts +9 -0
  135. package/dist/hooks/magic-context/live-session-state.d.ts.map +1 -1
  136. package/dist/hooks/magic-context/m0-token-breakdown.d.ts +35 -0
  137. package/dist/hooks/magic-context/m0-token-breakdown.d.ts.map +1 -0
  138. package/dist/hooks/magic-context/read-session-chunk.d.ts +9 -0
  139. package/dist/hooks/magic-context/read-session-chunk.d.ts.map +1 -1
  140. package/dist/hooks/magic-context/read-session-db.d.ts +7 -0
  141. package/dist/hooks/magic-context/read-session-db.d.ts.map +1 -1
  142. package/dist/hooks/magic-context/recomp-orchestrator.d.ts +104 -0
  143. package/dist/hooks/magic-context/recomp-orchestrator.d.ts.map +1 -0
  144. package/dist/hooks/magic-context/reference-retrieval.d.ts +61 -0
  145. package/dist/hooks/magic-context/reference-retrieval.d.ts.map +1 -0
  146. package/dist/hooks/magic-context/reference-seeds.generated.d.ts +8 -0
  147. package/dist/hooks/magic-context/reference-seeds.generated.d.ts.map +1 -0
  148. package/dist/hooks/magic-context/send-session-notification.d.ts +1 -1
  149. package/dist/hooks/magic-context/send-session-notification.d.ts.map +1 -1
  150. package/dist/hooks/magic-context/system-prompt-hash.d.ts +5 -6
  151. package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
  152. package/dist/hooks/magic-context/tag-messages.d.ts.map +1 -1
  153. package/dist/hooks/magic-context/tokenizer-calibration.d.ts +6 -0
  154. package/dist/hooks/magic-context/tokenizer-calibration.d.ts.map +1 -1
  155. package/dist/hooks/magic-context/transform-compartment-phase.d.ts +0 -7
  156. package/dist/hooks/magic-context/transform-compartment-phase.d.ts.map +1 -1
  157. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +18 -0
  158. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  159. package/dist/hooks/magic-context/transform.d.ts +9 -7
  160. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  161. package/dist/hooks/magic-context/upgrade-reminder.d.ts +73 -0
  162. package/dist/hooks/magic-context/upgrade-reminder.d.ts.map +1 -0
  163. package/dist/index.d.ts.map +1 -1
  164. package/dist/index.js +9435 -4001
  165. package/dist/plugin/conflict-warning-hook.d.ts +13 -0
  166. package/dist/plugin/conflict-warning-hook.d.ts.map +1 -1
  167. package/dist/plugin/dream-timer.d.ts.map +1 -1
  168. package/dist/plugin/event.d.ts +10 -0
  169. package/dist/plugin/event.d.ts.map +1 -1
  170. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  171. package/dist/plugin/messages-transform.d.ts.map +1 -1
  172. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  173. package/dist/plugin/tool-registry.d.ts.map +1 -1
  174. package/dist/shared/announcement.d.ts +17 -1
  175. package/dist/shared/announcement.d.ts.map +1 -1
  176. package/dist/shared/models-dev-cache.d.ts.map +1 -1
  177. package/dist/shared/rpc-client.d.ts +1 -0
  178. package/dist/shared/rpc-client.d.ts.map +1 -1
  179. package/dist/shared/rpc-notifications.d.ts +27 -5
  180. package/dist/shared/rpc-notifications.d.ts.map +1 -1
  181. package/dist/shared/rpc-server.d.ts +1 -0
  182. package/dist/shared/rpc-server.d.ts.map +1 -1
  183. package/dist/shared/rpc-types.d.ts +30 -2
  184. package/dist/shared/rpc-types.d.ts.map +1 -1
  185. package/dist/shared/rpc-utils.d.ts +9 -0
  186. package/dist/shared/rpc-utils.d.ts.map +1 -1
  187. package/dist/shared/sqlite-helpers.d.ts +7 -7
  188. package/dist/shared/sqlite.d.ts +23 -14
  189. package/dist/shared/sqlite.d.ts.map +1 -1
  190. package/dist/shared/tag-transcript.d.ts +10 -1
  191. package/dist/shared/tag-transcript.d.ts.map +1 -1
  192. package/dist/tools/ctx-expand/tools.d.ts +5 -1
  193. package/dist/tools/ctx-expand/tools.d.ts.map +1 -1
  194. package/dist/tools/ctx-memory/tools.d.ts.map +1 -1
  195. package/dist/tui/data/context-db.d.ts +16 -1
  196. package/dist/tui/data/context-db.d.ts.map +1 -1
  197. package/package.json +5 -7
  198. package/src/shared/announcement.test.ts +23 -7
  199. package/src/shared/announcement.ts +30 -8
  200. package/src/shared/conflict-detector.test.ts +15 -2
  201. package/src/shared/conflict-fixer.test.ts +5 -1
  202. package/src/shared/models-dev-cache.test.ts +72 -4
  203. package/src/shared/models-dev-cache.ts +47 -8
  204. package/src/shared/opencode-compaction-detector.test.ts +10 -2
  205. package/src/shared/rpc-client.test.ts +54 -3
  206. package/src/shared/rpc-client.ts +19 -9
  207. package/src/shared/rpc-notifications.test.ts +54 -1
  208. package/src/shared/rpc-notifications.ts +82 -13
  209. package/src/shared/rpc-server.ts +33 -4
  210. package/src/shared/rpc-types.ts +30 -2
  211. package/src/shared/rpc-utils.ts +10 -0
  212. package/src/shared/sqlite-helpers.ts +9 -9
  213. package/src/shared/sqlite.ts +99 -80
  214. package/src/shared/tag-transcript.test.ts +280 -0
  215. package/src/shared/tag-transcript.ts +162 -33
  216. package/src/tui/data/context-db.ts +75 -11
  217. package/src/tui/index.tsx +223 -32
  218. package/src/tui/slots/sidebar-content.tsx +366 -34
  219. package/dist/hooks/magic-context/compartment-runner-compressor.d.ts +0 -87
  220. package/dist/hooks/magic-context/compartment-runner-compressor.d.ts.map +0 -1
  221. package/dist/shared/native-binding.d.ts +0 -87
  222. package/dist/shared/native-binding.d.ts.map +0 -1
  223. package/src/shared/native-binding.ts +0 -311
@@ -0,0 +1,280 @@
1
+ /// <reference types="bun-types" />
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import type { ContextDatabase } from "../features/magic-context/storage";
5
+ import type { Tagger } from "../features/magic-context/tagger";
6
+ import { tagTranscript } from "./tag-transcript";
7
+ import type { Transcript, TranscriptPart, TranscriptPartKind } from "./transcript";
8
+
9
+ class FakeTagger implements Tagger {
10
+ private nextTag = 1;
11
+ private toolTags = new Map<string, number>();
12
+ readonly owners: string[] = [];
13
+ readonly byteSizes = new Map<number, number>();
14
+
15
+ assignTag(): number {
16
+ return this.nextTag++;
17
+ }
18
+
19
+ getTag(): number | undefined {
20
+ return undefined;
21
+ }
22
+
23
+ assignToolTag(
24
+ _sessionId: string,
25
+ callId: string,
26
+ ownerMsgId: string,
27
+ byteSize: number,
28
+ ): number {
29
+ const key = `${ownerMsgId}\0${callId}`;
30
+ const existing = this.toolTags.get(key);
31
+ if (existing !== undefined) return existing;
32
+ const tag = this.nextTag++;
33
+ this.toolTags.set(key, tag);
34
+ this.byteSizes.set(tag, byteSize);
35
+ this.owners.push(ownerMsgId);
36
+ return tag;
37
+ }
38
+
39
+ getToolTag(_sessionId: string, callId: string, ownerMsgId: string): number | undefined {
40
+ return this.toolTags.get(`${ownerMsgId}\0${callId}`);
41
+ }
42
+
43
+ bindTag(): void {}
44
+
45
+ bindToolTag(_sessionId: string, callId: string, ownerMsgId: string, tagNumber: number): void {
46
+ this.toolTags.set(`${ownerMsgId}\0${callId}`, tagNumber);
47
+ }
48
+
49
+ getAssignments(): ReadonlyMap<string, number> {
50
+ return this.toolTags;
51
+ }
52
+
53
+ resetCounter(): void {
54
+ this.nextTag = 1;
55
+ }
56
+
57
+ getCounter(): number {
58
+ return this.nextTag - 1;
59
+ }
60
+
61
+ initFromDb(): void {}
62
+
63
+ cleanup(): void {}
64
+ }
65
+
66
+ class TestPart implements TranscriptPart {
67
+ constructor(
68
+ readonly kind: TranscriptPartKind,
69
+ readonly id: string | undefined,
70
+ private text: string,
71
+ private readonly toolName = "read",
72
+ ) {}
73
+
74
+ getText(): string | undefined {
75
+ return this.text;
76
+ }
77
+
78
+ setText(newText: string): boolean {
79
+ if (this.text === newText) return false;
80
+ this.text = newText;
81
+ return true;
82
+ }
83
+
84
+ setToolOutput(newText: string): boolean {
85
+ return this.setText(newText);
86
+ }
87
+
88
+ getToolMetadata(): { toolName: string | undefined; inputByteSize: number } {
89
+ return {
90
+ toolName: this.toolName,
91
+ inputByteSize: this.kind === "tool_use" ? this.text.length : 0,
92
+ };
93
+ }
94
+
95
+ replaceWithSentinel(sentinelText: string): boolean {
96
+ return this.setText(sentinelText);
97
+ }
98
+ }
99
+
100
+ class ThrowingToolOutputPart extends TestPart {
101
+ setToolOutput(): boolean {
102
+ throw new Error("setToolOutput on assistant part");
103
+ }
104
+ }
105
+
106
+ class NonTextToolResultPart extends TestPart {
107
+ constructor(
108
+ id: string,
109
+ readonly content: unknown,
110
+ ) {
111
+ super("tool_result", id, "");
112
+ }
113
+
114
+ getText(): string | undefined {
115
+ return undefined;
116
+ }
117
+
118
+ setText(): boolean {
119
+ return false;
120
+ }
121
+ }
122
+
123
+ class FakeDb {
124
+ readonly byteSizeUpdates: Array<{ byteSize: number; sessionId: string; tagNumber: number }> =
125
+ [];
126
+
127
+ prepare(sql: string): { run: (...args: unknown[]) => void } {
128
+ return {
129
+ run: (...args: unknown[]) => {
130
+ if (!sql.startsWith("UPDATE tags SET byte_size =")) return;
131
+ const [byteSize, sessionId, tagNumber] = args;
132
+ if (
133
+ typeof byteSize === "number" &&
134
+ typeof sessionId === "string" &&
135
+ typeof tagNumber === "number"
136
+ ) {
137
+ this.byteSizeUpdates.push({ byteSize, sessionId, tagNumber });
138
+ }
139
+ },
140
+ };
141
+ }
142
+ }
143
+
144
+ describe("tagTranscript tool aggregation", () => {
145
+ it("keeps repeated callIds in separate owner-scoped aggregate targets", () => {
146
+ const tagger = new FakeTagger();
147
+ const firstUse = new TestPart("tool_use", "read:32", '{"file":"long-a"}');
148
+ const firstResult = new TestPart("tool_result", "read:32", "r1");
149
+ const secondUse = new TestPart("tool_use", "read:32", '{"file":"long-b"}');
150
+ const secondResult = new TestPart("tool_result", "read:32", "r2");
151
+ const transcript: Transcript = {
152
+ harness: "pi",
153
+ messages: [
154
+ { info: { id: "assistant-1", role: "assistant" }, parts: [firstUse] },
155
+ { info: { id: "user-1", role: "user" }, parts: [firstResult] },
156
+ { info: { id: "assistant-2", role: "assistant" }, parts: [secondUse] },
157
+ { info: { id: "user-2", role: "user" }, parts: [secondResult] },
158
+ ],
159
+ commit() {},
160
+ };
161
+
162
+ const { targets } = tagTranscript("session-1", transcript, tagger, {} as ContextDatabase);
163
+
164
+ const firstTag = tagger.getToolTag("session-1", "read:32", "assistant-1");
165
+ const secondTag = tagger.getToolTag("session-1", "read:32", "assistant-2");
166
+ expect(firstTag).toBeDefined();
167
+ expect(secondTag).toBeDefined();
168
+ expect(firstTag).not.toBe(secondTag);
169
+ expect(tagger.owners).toEqual(["assistant-1", "assistant-2"]);
170
+ expect(targets.size).toBe(2);
171
+
172
+ expect(targets.get(firstTag ?? -1)?.drop()).toBe("removed");
173
+ expect(firstUse.getText()).toBe(`[dropped §${firstTag}§]`);
174
+ expect(firstResult.getText()).toBe(`[dropped §${firstTag}§]`);
175
+ expect(secondUse.getText()).toBe('{"file":"long-b"}');
176
+ expect(secondResult.getText()).toContain("r2");
177
+ });
178
+
179
+ it("truncates assistant tool_use parts via text fallback when setToolOutput asserts", () => {
180
+ const tagger = new FakeTagger();
181
+ const toolUse = new ThrowingToolOutputPart("tool_use", "read:99", '{"file":"long-a"}');
182
+ const transcript: Transcript = {
183
+ harness: "pi",
184
+ messages: [{ info: { id: "assistant-1", role: "assistant" }, parts: [toolUse] }],
185
+ commit() {},
186
+ };
187
+
188
+ const { targets } = tagTranscript("session-1", transcript, tagger, {} as ContextDatabase);
189
+ const tag = tagger.getToolTag("session-1", "read:99", "assistant-1");
190
+
191
+ let result: "truncated" | "absent" | undefined;
192
+ expect(() => {
193
+ result = targets.get(tag ?? -1)?.truncate?.();
194
+ }).not.toThrow();
195
+ expect(result).toBe("truncated");
196
+ expect(toolUse.getText()).toBe("[truncated]");
197
+ });
198
+
199
+ it("drops every contiguous folded tool_result block for the paired callId", () => {
200
+ const tagger = new FakeTagger();
201
+ const toolUse = new TestPart("tool_use", "read:multi", '{"file":"long-a"}');
202
+ const firstResult = new TestPart("tool_result", "read:multi", "r1");
203
+ const secondResult = new TestPart("tool_result", "read:multi", "r2");
204
+ const transcript: Transcript = {
205
+ harness: "pi",
206
+ messages: [
207
+ { info: { id: "assistant-1", role: "assistant" }, parts: [toolUse] },
208
+ { info: { id: "user-1", role: "user" }, parts: [firstResult, secondResult] },
209
+ ],
210
+ commit() {},
211
+ };
212
+
213
+ const { targets } = tagTranscript("session-1", transcript, tagger, {} as ContextDatabase);
214
+ const tag = tagger.getToolTag("session-1", "read:multi", "assistant-1");
215
+
216
+ expect(targets.size).toBe(1);
217
+ expect(targets.get(tag ?? -1)?.drop()).toBe("removed");
218
+ expect(toolUse.getText()).toBe(`[dropped §${tag}§]`);
219
+ expect(firstResult.getText()).toBe(`[dropped §${tag}§]`);
220
+ expect(secondResult.getText()).toBe(`[dropped §${tag}§]`);
221
+ });
222
+
223
+ it("pairs a reused callId result with the nearest previous unresolved owner", () => {
224
+ const tagger = new FakeTagger();
225
+ const olderUse = new TestPart("tool_use", "read:reused", '{"file":"older"}');
226
+ const nearestUse = new TestPart("tool_use", "read:reused", '{"file":"nearest"}');
227
+ const result = new TestPart("tool_result", "read:reused", "nearest result");
228
+ const transcript: Transcript = {
229
+ harness: "pi",
230
+ messages: [
231
+ { info: { id: "assistant-old", role: "assistant" }, parts: [olderUse] },
232
+ { info: { id: "assistant-near", role: "assistant" }, parts: [nearestUse] },
233
+ { info: { id: "user-result", role: "user" }, parts: [result] },
234
+ ],
235
+ commit() {},
236
+ };
237
+
238
+ const { targets } = tagTranscript("session-1", transcript, tagger, {} as ContextDatabase);
239
+
240
+ const olderTag = tagger.getToolTag("session-1", "read:reused", "assistant-old");
241
+ const nearestTag = tagger.getToolTag("session-1", "read:reused", "assistant-near");
242
+ expect(olderTag).toBeDefined();
243
+ expect(nearestTag).toBeDefined();
244
+ expect(olderTag).not.toBe(nearestTag);
245
+
246
+ expect(targets.get(nearestTag ?? -1)?.drop()).toBe("removed");
247
+ expect(olderUse.getText()).toBe('{"file":"older"}');
248
+ expect(nearestUse.getText()).toBe(`[dropped §${nearestTag}§]`);
249
+ expect(result.getText()).toBe(`[dropped §${nearestTag}§]`);
250
+ });
251
+
252
+ it("accounts non-text tool_result content when ranking tool output byte size", () => {
253
+ const tagger = new FakeTagger();
254
+ const db = new FakeDb();
255
+ const toolUse = new TestPart("tool_use", "read:image", "{}");
256
+ const caption = new TestPart("tool_result", "read:image", "c");
257
+ const image = new NonTextToolResultPart("read:image", {
258
+ type: "image",
259
+ data: "x".repeat(512),
260
+ mediaType: "image/png",
261
+ });
262
+ const transcript: Transcript = {
263
+ harness: "pi",
264
+ messages: [
265
+ { info: { id: "assistant-1", role: "assistant" }, parts: [toolUse] },
266
+ { info: { id: "user-1", role: "user" }, parts: [caption, image] },
267
+ ],
268
+ commit() {},
269
+ };
270
+
271
+ tagTranscript("session-1", transcript, tagger, db as unknown as ContextDatabase);
272
+
273
+ const tag = tagger.getToolTag("session-1", "read:image", "assistant-1");
274
+ expect(tag).toBeDefined();
275
+ expect(tagger.byteSizes.get(tag ?? -1)).toBe(2);
276
+ expect(db.byteSizeUpdates).toHaveLength(1);
277
+ expect(db.byteSizeUpdates[0]?.tagNumber).toBe(tag);
278
+ expect(db.byteSizeUpdates[0]?.byteSize).toBeGreaterThan(512);
279
+ });
280
+ });
@@ -48,7 +48,7 @@
48
48
  import type { ContextDatabase } from "../features/magic-context/storage";
49
49
  import { saveSourceContent } from "../features/magic-context/storage-source";
50
50
  import { updateTagByteSize, updateTagInputByteSize } from "../features/magic-context/storage-tags";
51
- import type { Tagger } from "../features/magic-context/tagger";
51
+ import { makeToolCompositeKey, type Tagger } from "../features/magic-context/tagger";
52
52
  import {
53
53
  byteSize,
54
54
  prependTag,
@@ -67,6 +67,15 @@ export interface TagTranscriptOptions {
67
67
  * consistent across passes.
68
68
  */
69
69
  skipPrefixInjection?: boolean;
70
+ /**
71
+ * Pi-only: map of messageId → raw-message fingerprint. When a NEW message
72
+ * text tag is created, its fingerprint is persisted on the tag row so a
73
+ * later pass can adopt the fallback-id tag onto the real SessionEntry id
74
+ * (keeping tag_number/§N§ stable). OpenCode omits this → tags store NULL
75
+ * → adoption never fires. Keyed by the bare messageId (not the `:pN`
76
+ * contentId) since all parts of a message share one fingerprint.
77
+ */
78
+ entryFingerprintByMessageId?: ReadonlyMap<string, string>;
70
79
  }
71
80
 
72
81
  export interface TagTranscriptResult {
@@ -138,13 +147,13 @@ export function tagTranscript(
138
147
  const skipPrefixInjection = options.skipPrefixInjection === true;
139
148
  const targets = new Map<number, TagTarget>();
140
149
 
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.
150
+ // Tool aggregation is keyed by the same owner+callId identity used by
151
+ // assignToolTag. OpenCode/Pi callId counters can repeat across turns, so
152
+ // a bare callId key can merge distinct invocations and replay drops/status
153
+ // changes against the wrong tool pair.
147
154
  const toolAggregates = new Map<string, ToolAggregate & { tagId: number }>();
155
+ const openToolAggregateKeysByCallId = new Map<string, string[]>();
156
+ let activeToolResultRun: { callId: string; aggregateKey: string } | undefined;
148
157
 
149
158
  // v3.3.1 Layer C (plan v3.3.1 Finding #16): the previous outer
150
159
  // db.transaction() wrapper rolled back EVERY tag insert + savedSource
@@ -156,6 +165,7 @@ export function tagTranscript(
156
165
  for (let msgIndex = 0; msgIndex < transcript.messages.length; msgIndex += 1) {
157
166
  const message = transcript.messages[msgIndex];
158
167
  if (message === undefined) continue;
168
+ activeToolResultRun = undefined;
159
169
  const messageId = message.info.id;
160
170
 
161
171
  let textOrdinal = 0;
@@ -165,6 +175,10 @@ export function tagTranscript(
165
175
  const part = parts[partIndex];
166
176
  if (part === undefined) continue;
167
177
 
178
+ if (part.kind !== "tool_result") {
179
+ activeToolResultRun = undefined;
180
+ }
181
+
168
182
  if (part.kind === "text") {
169
183
  // Synthetic message ids (Pi tail synthetic user with
170
184
  // no id) cannot be tagged — there's no stable handle
@@ -186,19 +200,25 @@ export function tagTranscript(
186
200
  db,
187
201
  targets,
188
202
  skipPrefixInjection,
203
+ entryFingerprint: options.entryFingerprintByMessageId?.get(messageId) ?? null,
189
204
  });
190
205
  textOrdinal += 1;
191
206
  continue;
192
207
  }
193
208
 
194
209
  if (part.kind === "tool_use" || part.kind === "tool_result") {
195
- if (messageId === undefined) continue;
210
+ if (messageId === undefined) {
211
+ activeToolResultRun = undefined;
212
+ continue;
213
+ }
196
214
 
197
215
  const callId = part.id;
198
216
  const text = part.getText() ?? "";
217
+ const toolByteSize = getToolPartByteSize(part, text);
199
218
  const meta = part.getToolMetadata();
200
219
 
201
220
  if (typeof callId !== "string" || callId.length === 0) {
221
+ activeToolResultRun = undefined;
202
222
  // No stable callId to aggregate on. Tag independently.
203
223
  tagToolPart({
204
224
  sessionId,
@@ -215,21 +235,35 @@ export function tagTranscript(
215
235
  continue;
216
236
  }
217
237
 
218
- const existing = toolAggregates.get(callId);
238
+ const pendingKeys = openToolAggregateKeysByCallId.get(callId) ?? [];
239
+ let existingKey: string | undefined;
240
+ if (part.kind === "tool_result") {
241
+ if (
242
+ activeToolResultRun !== undefined &&
243
+ activeToolResultRun.callId === callId
244
+ ) {
245
+ existingKey = activeToolResultRun.aggregateKey;
246
+ } else {
247
+ existingKey = findLastUnresolvedToolAggregateKey(
248
+ pendingKeys,
249
+ toolAggregates,
250
+ );
251
+ }
252
+ }
253
+ const aggregateKey: string = existingKey ?? makeToolCompositeKey(messageId, callId);
254
+ const existing = toolAggregates.get(aggregateKey);
219
255
  if (existing) {
220
- // Second (or later) occurrence for this call_id.
221
- // Merge into the existing aggregate, update byte_size
222
- // in DB if larger, and rebuild the TagTarget so the
223
- // closures over `occurrences` see all parts.
256
+ // Later occurrence for this owner+callId pair. Merge into the
257
+ // aggregate, update byte accounting if larger, and rebuild the
258
+ // TagTarget so drops mutate both invocation and result.
224
259
  existing.occurrences.push({
225
260
  message,
226
261
  part,
227
262
  kind: part.kind,
228
263
  });
229
- const newByteSize = byteSize(text);
230
- if (newByteSize > existing.maxByteSize) {
231
- existing.maxByteSize = newByteSize;
232
- updateTagByteSize(db, sessionId, existing.tagId, newByteSize);
264
+ if (toolByteSize > existing.maxByteSize) {
265
+ existing.maxByteSize = toolByteSize;
266
+ updateTagByteSize(db, sessionId, existing.tagId, toolByteSize);
233
267
  }
234
268
  if (existing.toolName === null && meta.toolName) {
235
269
  existing.toolName = meta.toolName;
@@ -253,18 +287,23 @@ export function tagTranscript(
253
287
  existing.tagId,
254
288
  buildAggregateTarget(existing.tagId, existing.occurrences),
255
289
  );
290
+ if (part.kind === "tool_result") {
291
+ markToolAggregateResolved(
292
+ callId,
293
+ aggregateKey,
294
+ openToolAggregateKeysByCallId,
295
+ );
296
+ activeToolResultRun = { callId, aggregateKey };
297
+ }
256
298
  } else {
257
- // First occurrence reserve the tag number.
258
- // v3.3.1 Layer C: Pi main aggregation path. Owner
259
- // is the Pi message hosting the tool aggregate.
260
- // Owner stays stable across passes because Pi
261
- // re-emits the full transcript each time and
262
- // message ids are durable.
299
+ // First occurrence for this owner+callId identity — reserve
300
+ // the tag number. Owner stays stable across passes because
301
+ // transcript message ids are durable.
263
302
  const tagId = tagger.assignToolTag(
264
303
  sessionId,
265
304
  callId,
266
305
  messageId,
267
- byteSize(text),
306
+ toolByteSize,
268
307
  db,
269
308
  0,
270
309
  meta.toolName ?? null,
@@ -280,11 +319,14 @@ export function tagTranscript(
280
319
  kind: part.kind,
281
320
  },
282
321
  ],
283
- maxByteSize: byteSize(text),
322
+ maxByteSize: toolByteSize,
284
323
  toolName: meta.toolName ?? null,
285
324
  inputByteSize: part.kind === "tool_use" ? meta.inputByteSize : 0,
286
325
  };
287
- toolAggregates.set(callId, aggregate);
326
+ toolAggregates.set(aggregateKey, aggregate);
327
+ if (part.kind === "tool_use") {
328
+ openToolAggregateKeysByCallId.set(callId, [...pendingKeys, aggregateKey]);
329
+ }
288
330
  // Inject §N§ prefix into this occurrence's visible text
289
331
  // when it's a tool_result. (OpenCode parity: prefix
290
332
  // only goes on the result, not the invocation.)
@@ -292,6 +334,14 @@ export function tagTranscript(
292
334
  part.setText(prependTag(tagId, text));
293
335
  }
294
336
  targets.set(tagId, buildAggregateTarget(tagId, aggregate.occurrences));
337
+ if (part.kind === "tool_result") {
338
+ markToolAggregateResolved(
339
+ callId,
340
+ aggregateKey,
341
+ openToolAggregateKeysByCallId,
342
+ );
343
+ activeToolResultRun = { callId, aggregateKey };
344
+ }
295
345
  }
296
346
  }
297
347
  // thinking, image, file, structural, unknown → skip.
@@ -301,6 +351,69 @@ export function tagTranscript(
301
351
  return { targets };
302
352
  }
303
353
 
354
+ function findLastUnresolvedToolAggregateKey(
355
+ pendingKeys: string[],
356
+ toolAggregates: Map<string, ToolAggregate & { tagId: number }>,
357
+ ): string | undefined {
358
+ for (let i = pendingKeys.length - 1; i >= 0; i -= 1) {
359
+ const key = pendingKeys[i];
360
+ if (key === undefined) continue;
361
+ const aggregate = toolAggregates.get(key);
362
+ if (aggregate === undefined) continue;
363
+ if (!aggregate.occurrences.some((occ) => occ.kind === "tool_result")) {
364
+ return key;
365
+ }
366
+ }
367
+ return undefined;
368
+ }
369
+
370
+ function markToolAggregateResolved(
371
+ callId: string,
372
+ aggregateKey: string,
373
+ openToolAggregateKeysByCallId: Map<string, string[]>,
374
+ ): void {
375
+ const pendingKeys = openToolAggregateKeysByCallId.get(callId);
376
+ if (pendingKeys === undefined) return;
377
+ const nextPendingKeys = pendingKeys.filter((key) => key !== aggregateKey);
378
+ if (nextPendingKeys.length === 0) {
379
+ openToolAggregateKeysByCallId.delete(callId);
380
+ return;
381
+ }
382
+ openToolAggregateKeysByCallId.set(callId, nextPendingKeys);
383
+ }
384
+
385
+ function getToolPartByteSize(part: TranscriptPart, text: string): number {
386
+ const textByteSize = byteSize(text);
387
+ if (textByteSize > 0 || part.kind !== "tool_result") return textByteSize;
388
+ return getNonTextToolResultByteSize(part);
389
+ }
390
+
391
+ function getNonTextToolResultByteSize(part: TranscriptPart): number {
392
+ const record = isRecord(part) ? part : undefined;
393
+ const content =
394
+ record?.content ??
395
+ record?.rawContent ??
396
+ record?.rawPart ??
397
+ record?.part ??
398
+ record?.data ??
399
+ record?.image ??
400
+ record?.source;
401
+ const serialized = safeJsonStringify(content ?? part);
402
+ return serialized === undefined ? 0 : byteSize(serialized);
403
+ }
404
+
405
+ function safeJsonStringify(value: unknown): string | undefined {
406
+ try {
407
+ return JSON.stringify(value);
408
+ } catch {
409
+ return undefined;
410
+ }
411
+ }
412
+
413
+ function isRecord(value: unknown): value is Record<string, unknown> {
414
+ return typeof value === "object" && value !== null;
415
+ }
416
+
304
417
  interface TagTextPartArgs {
305
418
  sessionId: string;
306
419
  message: { info: { id?: string; role: string } };
@@ -312,6 +425,7 @@ interface TagTextPartArgs {
312
425
  db: ContextDatabase;
313
426
  targets: Map<number, TagTarget>;
314
427
  skipPrefixInjection: boolean;
428
+ entryFingerprint: string | null;
315
429
  }
316
430
 
317
431
  function tagTextPart(args: TagTextPartArgs): void {
@@ -323,6 +437,10 @@ function tagTextPart(args: TagTextPartArgs): void {
323
437
  "message",
324
438
  byteSize(text),
325
439
  args.db,
440
+ 0,
441
+ null,
442
+ 0,
443
+ args.entryFingerprint,
326
444
  );
327
445
 
328
446
  // Persist the original (pre-tagged) source content so caveman
@@ -371,6 +489,7 @@ function tagToolPart(args: TagToolPartArgs): void {
371
489
  const stableId = args.part.id;
372
490
  const contentId = stableId ?? `${args.messageId}:t${args.partIndex}`;
373
491
  const text = args.part.getText() ?? "";
492
+ const toolByteSize = getToolPartByteSize(args.part, text);
374
493
  const meta = args.part.getToolMetadata();
375
494
  // v3.3.1 Layer C: synthetic ownership for the no-callId Pi
376
495
  // fallback. Owner == callId == contentId. The composite key
@@ -382,7 +501,7 @@ function tagToolPart(args: TagToolPartArgs): void {
382
501
  args.sessionId,
383
502
  contentId,
384
503
  contentId,
385
- byteSize(text),
504
+ toolByteSize,
386
505
  args.db,
387
506
  0,
388
507
  meta.toolName ?? null,
@@ -400,6 +519,18 @@ function tagToolPart(args: TagToolPartArgs): void {
400
519
  args.targets.set(tagId, buildToolTarget(args.part, args.message));
401
520
  }
402
521
 
522
+ function setToolContentOrText(part: TranscriptPart, content: string): boolean {
523
+ try {
524
+ if (part.setToolOutput(content)) return true;
525
+ } catch {
526
+ // Pi assistant tool_use parts deliberately assert if callers try
527
+ // to write a nonexistent output slot. Truncated-mode drops still
528
+ // need to shrink the invocation, so fall back to visible text/args
529
+ // replacement while preserving the adapter-level invariant.
530
+ }
531
+ return part.setText(content);
532
+ }
533
+
403
534
  /**
404
535
  * Build a TagTarget that walks ALL occurrences of a tool call (invocation
405
536
  * + result) when mutating. This is the per-callId aggregate target used
@@ -428,9 +559,7 @@ function buildAggregateTarget(tagId: number, occurrences: ToolOccurrence[]): Tag
428
559
  for (const occ of occurrences) {
429
560
  // Try setToolOutput first (works on tool_result-shaped parts);
430
561
  // fall back to setText so tool_use parts also get sentinelized.
431
- if (occ.part.setToolOutput(content)) {
432
- changed = true;
433
- } else if (occ.part.setText(content)) {
562
+ if (setToolContentOrText(occ.part, content)) {
434
563
  changed = true;
435
564
  }
436
565
  }
@@ -461,7 +590,7 @@ function buildAggregateTarget(tagId: number, occurrences: ToolOccurrence[]): Tag
461
590
  const sentinel = "[truncated]";
462
591
  let any = false;
463
592
  for (const occ of occurrences) {
464
- if (occ.part.setToolOutput(sentinel) || occ.part.setText(sentinel)) {
593
+ if (setToolContentOrText(occ.part, sentinel)) {
465
594
  any = true;
466
595
  }
467
596
  }
@@ -521,7 +650,7 @@ function buildToolTarget(
521
650
  ): TagTarget {
522
651
  return {
523
652
  setContent(content: string): boolean {
524
- return part.setToolOutput(content) || part.setText(content);
653
+ return setToolContentOrText(part, content);
525
654
  },
526
655
  getContent(): string | null {
527
656
  return part.getText() ?? null;
@@ -542,7 +671,7 @@ function buildToolTarget(
542
671
  // via setToolOutput so the underlying tool_result content
543
672
  // gets the truncation; falls back to setText for cases
544
673
  // where the part type doesn't support setToolOutput.
545
- const ok = part.setToolOutput("[truncated]") || part.setText("[truncated]");
674
+ const ok = setToolContentOrText(part, "[truncated]");
546
675
  return ok ? "truncated" : "absent";
547
676
  },
548
677
  message: {