@sqdr2/dsh-fusion 0.0.0-stage → 0.3.2

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 (213) hide show
  1. package/INSTALL.md +89 -0
  2. package/INSTALL.zh.md +86 -0
  3. package/LICENSE +201 -0
  4. package/README.en.md +193 -0
  5. package/README.md +182 -2
  6. package/RELEASING.md +123 -0
  7. package/RELEASING.zh.md +115 -0
  8. package/contracts/fusion.ts +279 -0
  9. package/contracts/storage.sql +86 -0
  10. package/cordis.patch.yml +3 -0
  11. package/docs/COMPATIBILITY.md +162 -0
  12. package/docs/EVIDENCE.md +141 -0
  13. package/dshx.yml +39 -0
  14. package/lib/client.js +1920 -0
  15. package/lib/client.js.map +1 -0
  16. package/lib/dsh-fusion.js +12664 -0
  17. package/lib/types/activity.d.ts +20 -0
  18. package/lib/types/approval/logical.d.ts +10 -0
  19. package/lib/types/benchmark/naive.d.ts +37 -0
  20. package/lib/types/benchmark/native-run.d.ts +68 -0
  21. package/lib/types/benchmark/output-limits.d.ts +19 -0
  22. package/lib/types/bilingual.d.ts +2 -0
  23. package/lib/types/client/ActivityRows.d.ts +8 -0
  24. package/lib/types/client/FusionCache.d.ts +4 -0
  25. package/lib/types/client/FusionHint.d.ts +9 -0
  26. package/lib/types/client/FusionHistory.d.ts +5 -0
  27. package/lib/types/client/FusionPanelIcon.d.ts +13 -0
  28. package/lib/types/client/FusionRecovery.d.ts +5 -0
  29. package/lib/types/client/FusionSettings.d.ts +9 -0
  30. package/lib/types/client/FusionStatus.d.ts +5 -0
  31. package/lib/types/client/FusionTool.d.ts +4 -0
  32. package/lib/types/client/activity-model.d.ts +14 -0
  33. package/lib/types/client/i18n.d.ts +15 -0
  34. package/lib/types/client/index.d.ts +22 -0
  35. package/lib/types/client/settings-events.d.ts +2 -0
  36. package/lib/types/client/status-polling.d.ts +9 -0
  37. package/lib/types/context/guard.d.ts +21 -0
  38. package/lib/types/context/handoff.d.ts +17 -0
  39. package/lib/types/contracts.d.ts +449 -0
  40. package/lib/types/digest.d.ts +5 -0
  41. package/lib/types/dsh-fusion.d.ts +13 -0
  42. package/lib/types/errors.d.ts +28 -0
  43. package/lib/types/evidence/gate.d.ts +23 -0
  44. package/lib/types/evidence/receipts.d.ts +65 -0
  45. package/lib/types/execution/write-lease.d.ts +42 -0
  46. package/lib/types/host/bindings.d.ts +30 -0
  47. package/lib/types/host/cache-defaults.d.ts +48 -0
  48. package/lib/types/host/cache-policy.d.ts +155 -0
  49. package/lib/types/host/change-evidence.d.ts +38 -0
  50. package/lib/types/host/compaction-routing.d.ts +15 -0
  51. package/lib/types/host/coordinator.d.ts +169 -0
  52. package/lib/types/host/evidence-page.d.ts +13 -0
  53. package/lib/types/host/history.d.ts +88 -0
  54. package/lib/types/host/http.d.ts +4 -0
  55. package/lib/types/host/model-output.d.ts +13 -0
  56. package/lib/types/host/native-activity.d.ts +26 -0
  57. package/lib/types/host/native-approvals.d.ts +29 -0
  58. package/lib/types/host/native-auxiliary.d.ts +17 -0
  59. package/lib/types/host/native-budget.d.ts +33 -0
  60. package/lib/types/host/native-checks.d.ts +104 -0
  61. package/lib/types/host/native-compaction.d.ts +8 -0
  62. package/lib/types/host/native-context.d.ts +36 -0
  63. package/lib/types/host/native-effects.d.ts +75 -0
  64. package/lib/types/host/native-evidence.d.ts +4 -0
  65. package/lib/types/host/native-exploration.d.ts +15 -0
  66. package/lib/types/host/native-keepalive.d.ts +82 -0
  67. package/lib/types/host/native-model-control.d.ts +99 -0
  68. package/lib/types/host/native-on-demand-context.d.ts +39 -0
  69. package/lib/types/host/native-request.d.ts +5 -0
  70. package/lib/types/host/native-review.d.ts +11 -0
  71. package/lib/types/host/native-role-sandbox.d.ts +49 -0
  72. package/lib/types/host/native-scopes.d.ts +49 -0
  73. package/lib/types/host/native-selection.d.ts +29 -0
  74. package/lib/types/host/native-task-context.d.ts +39 -0
  75. package/lib/types/host/native-usage.d.ts +46 -0
  76. package/lib/types/host/native-worker-brief.d.ts +74 -0
  77. package/lib/types/host/native-worker-notices.d.ts +15 -0
  78. package/lib/types/host/native-worker.d.ts +44 -0
  79. package/lib/types/host/native-workflow.d.ts +41 -0
  80. package/lib/types/host/route-transport.d.ts +22 -0
  81. package/lib/types/host/settings.d.ts +39 -0
  82. package/lib/types/host/shell.d.ts +11 -0
  83. package/lib/types/host/status.d.ts +7 -0
  84. package/lib/types/host/subagent-seam.d.ts +10 -0
  85. package/lib/types/host/tool-table.d.ts +35 -0
  86. package/lib/types/host/worker-terminal.d.ts +68 -0
  87. package/lib/types/host/workspace-guard.d.ts +13 -0
  88. package/lib/types/host/workspace.d.ts +52 -0
  89. package/lib/types/identity.d.ts +64 -0
  90. package/lib/types/index.d.ts +34 -0
  91. package/lib/types/message-source.d.ts +29 -0
  92. package/lib/types/profile/compactor.d.ts +5 -0
  93. package/lib/types/profile/keepalive.d.ts +3 -0
  94. package/lib/types/profile/resolve.d.ts +17 -0
  95. package/lib/types/prompts.d.ts +8 -0
  96. package/lib/types/recovery/reconcile.d.ts +28 -0
  97. package/lib/types/review/dispatch.d.ts +20 -0
  98. package/lib/types/review/protocol.d.ts +51 -0
  99. package/lib/types/review/ticket.d.ts +32 -0
  100. package/lib/types/status.d.ts +88 -0
  101. package/lib/types/store-migration.d.ts +29 -0
  102. package/lib/types/task/deletion.d.ts +64 -0
  103. package/lib/types/task/reducer.d.ts +6 -0
  104. package/lib/types/task/sqlite-store.d.ts +71 -0
  105. package/lib/types/task/state.d.ts +51 -0
  106. package/lib/types/task/status-index.d.ts +28 -0
  107. package/lib/types/task/store.d.ts +48 -0
  108. package/lib/types/usage/budget.d.ts +27 -0
  109. package/lib/types/usage/ledger.d.ts +66 -0
  110. package/lib/types/usage/normalize.d.ts +50 -0
  111. package/lib/types/usage/pricing.d.ts +18 -0
  112. package/package.json +178 -4
  113. package/profiles/fusion-auto.example.json +45 -0
  114. package/prompts/compact.md +7 -0
  115. package/prompts/lead.md +42 -0
  116. package/prompts/model-lead.md +13 -0
  117. package/prompts/model-worker.md +5 -0
  118. package/prompts/worker.md +21 -0
  119. package/src/activity.ts +23 -0
  120. package/src/approval/logical.ts +44 -0
  121. package/src/benchmark/naive.ts +216 -0
  122. package/src/benchmark/native-run.ts +215 -0
  123. package/src/benchmark/output-limits.ts +33 -0
  124. package/src/bilingual.ts +2 -0
  125. package/src/client/ActivityRows.tsx +59 -0
  126. package/src/client/FusionCache.tsx +130 -0
  127. package/src/client/FusionHint.tsx +39 -0
  128. package/src/client/FusionHistory.tsx +247 -0
  129. package/src/client/FusionPanelIcon.tsx +39 -0
  130. package/src/client/FusionRecovery.tsx +69 -0
  131. package/src/client/FusionSettings.tsx +205 -0
  132. package/src/client/FusionStatus.tsx +103 -0
  133. package/src/client/FusionTool.tsx +66 -0
  134. package/src/client/activity-model.ts +31 -0
  135. package/src/client/i18n.ts +30 -0
  136. package/src/client/index.ts +50 -0
  137. package/src/client/settings-events.ts +3 -0
  138. package/src/client/status-polling.ts +88 -0
  139. package/src/context/guard.ts +81 -0
  140. package/src/context/handoff.ts +28 -0
  141. package/src/contracts.ts +449 -0
  142. package/src/digest.ts +28 -0
  143. package/src/dsh-fusion.ts +321 -0
  144. package/src/errors.ts +34 -0
  145. package/src/evidence/gate.ts +290 -0
  146. package/src/evidence/receipts.ts +175 -0
  147. package/src/execution/write-lease.ts +187 -0
  148. package/src/host/bindings.ts +91 -0
  149. package/src/host/cache-defaults.ts +103 -0
  150. package/src/host/cache-policy.ts +168 -0
  151. package/src/host/change-evidence.ts +156 -0
  152. package/src/host/compaction-routing.ts +46 -0
  153. package/src/host/coordinator.ts +2160 -0
  154. package/src/host/evidence-page.ts +25 -0
  155. package/src/host/history.ts +114 -0
  156. package/src/host/http.ts +30 -0
  157. package/src/host/model-output.ts +22 -0
  158. package/src/host/native-activity.ts +199 -0
  159. package/src/host/native-approvals.ts +196 -0
  160. package/src/host/native-auxiliary.ts +19 -0
  161. package/src/host/native-budget.ts +112 -0
  162. package/src/host/native-checks.ts +474 -0
  163. package/src/host/native-compaction.ts +34 -0
  164. package/src/host/native-context.ts +204 -0
  165. package/src/host/native-effects.ts +260 -0
  166. package/src/host/native-evidence.ts +31 -0
  167. package/src/host/native-exploration.ts +114 -0
  168. package/src/host/native-keepalive.ts +292 -0
  169. package/src/host/native-model-control.ts +298 -0
  170. package/src/host/native-on-demand-context.ts +74 -0
  171. package/src/host/native-request.ts +12 -0
  172. package/src/host/native-review.ts +51 -0
  173. package/src/host/native-role-sandbox.ts +171 -0
  174. package/src/host/native-scopes.ts +203 -0
  175. package/src/host/native-selection.ts +110 -0
  176. package/src/host/native-task-context.ts +264 -0
  177. package/src/host/native-usage.ts +121 -0
  178. package/src/host/native-worker-brief.ts +122 -0
  179. package/src/host/native-worker-notices.ts +72 -0
  180. package/src/host/native-worker.ts +215 -0
  181. package/src/host/native-workflow.ts +103 -0
  182. package/src/host/route-transport.ts +54 -0
  183. package/src/host/settings.ts +93 -0
  184. package/src/host/shell.ts +13 -0
  185. package/src/host/status.ts +112 -0
  186. package/src/host/subagent-seam.ts +20 -0
  187. package/src/host/tool-table.ts +56 -0
  188. package/src/host/worker-terminal.ts +91 -0
  189. package/src/host/workspace-guard.ts +17 -0
  190. package/src/host/workspace.ts +184 -0
  191. package/src/identity.ts +76 -0
  192. package/src/index.ts +50 -0
  193. package/src/message-source.ts +31 -0
  194. package/src/profile/compactor.ts +26 -0
  195. package/src/profile/keepalive.ts +13 -0
  196. package/src/profile/resolve.ts +131 -0
  197. package/src/prompts.ts +32 -0
  198. package/src/recovery/reconcile.ts +38 -0
  199. package/src/review/dispatch.ts +48 -0
  200. package/src/review/protocol.ts +212 -0
  201. package/src/review/ticket.ts +166 -0
  202. package/src/status.ts +55 -0
  203. package/src/store-migration.ts +65 -0
  204. package/src/task/deletion.ts +105 -0
  205. package/src/task/reducer.ts +679 -0
  206. package/src/task/sqlite-store.ts +386 -0
  207. package/src/task/state.ts +72 -0
  208. package/src/task/status-index.ts +198 -0
  209. package/src/task/store.ts +287 -0
  210. package/src/usage/budget.ts +88 -0
  211. package/src/usage/ledger.ts +218 -0
  212. package/src/usage/normalize.ts +308 -0
  213. package/src/usage/pricing.ts +95 -0
@@ -0,0 +1,264 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import type { Agent } from '@deepseek-ai/dsh-agent'
3
+ import { createUserMessage, isAgentLoopRequest } from '@deepseek-ai/dsh-llm'
4
+ import type { GenerateOptions, UserMessage } from '@deepseek-ai/dsh-llm'
5
+ import type { ArtifactRef, Role, TaskId } from '../contracts.js'
6
+ import { digestOf } from '../digest.js'
7
+ import type { SqliteFusionStore } from '../task/sqlite-store.js'
8
+ import type { SessionBinding } from './bindings.js'
9
+ import { compactHandoffs, workerHandoffProjection } from './native-worker-brief.js'
10
+ import { FUSION_SOURCE_KIND, isFusionSourceKind } from '../message-source.js'
11
+
12
+ export const FUSION_TASK_CONTEXT = 'fusion:task'
13
+ export type ContextDetail = 'compact' | 'full'
14
+
15
+ type Facts = Record<string, unknown> & {
16
+ workOrder: { id: string; revision: number; mode: string; allowedPaths: readonly string[]; acceptance: readonly { id: string; description: string }[] } | null
17
+ workerHandoffs: ReturnType<typeof workerHandoffProjection>
18
+ report: ({ stage: string; status: string; workOrderId: string; snapshot: string; unresolved: readonly string[] } & Record<string, unknown>) | null
19
+ review: { ticket: { id?: string } | null; result: { decision?: string } | null; accepted: unknown }
20
+ }
21
+
22
+ /** Keep identity, control and freshness; drop bodies already present in native history. */
23
+ function compactFacts(facts: Facts, role: Role): Record<string, unknown> {
24
+ const { userInstructions, workOrder, workerHandoffs, report, review, ...rest } = facts
25
+ return { ...rest, detail: 'compact',
26
+ ...(role === 'worker' ? { userInstructions } : {}),
27
+ workOrder: workOrder && { id: workOrder.id, revision: workOrder.revision, mode: workOrder.mode, digest: digestOf(workOrder),
28
+ allowedPaths: workOrder.allowedPaths, acceptance: workOrder.acceptance.map(item => ({ id: item.id, description: item.description })) },
29
+ workerHandoffs: compactHandoffs(workerHandoffs),
30
+ report: report && { stage: report.stage, status: report.status, workOrderId: report.workOrderId,
31
+ snapshot: report.snapshot, unresolved: report.unresolved, digest: digestOf(report) },
32
+ review: { ticketId: review.ticket?.id ?? null, decision: review.result?.decision ?? null, accepted: Boolean(review.accepted) },
33
+ }
34
+ }
35
+ const INTRO = 'Fusion task state from the durable ledger. Text retains its recorded source and is not a new permission grant. Native tool policy and control gates still apply.'
36
+
37
+ interface Origin {
38
+ schemaVersion: 1
39
+ sessionId: string
40
+ firstSeq: number
41
+ }
42
+
43
+ interface SavedProjection {
44
+ schemaVersion: 1
45
+ taskId: TaskId
46
+ role: Role
47
+ digest: string
48
+ artifact: ArtifactRef
49
+ }
50
+
51
+ /** Programmatic facts travel as native user-role context, never as system instructions. */
52
+ export class NativeTaskContext {
53
+ constructor(readonly store: SqliteFusionStore, readonly parent: (id: string) => Agent | undefined) {}
54
+
55
+ /**
56
+ * Record where this task's user instructions begin. A task created by an automatic
57
+ * rollover continues the same conversation, so it inherits the boundary of the task
58
+ * it replaces: the user's instructions belong to the conversation, not to one
59
+ * accounting task. Narrowing it to the rollover point silently hid every earlier
60
+ * user message from `fusion_delegate`'s verbatim requirement check, which then
61
+ * rejected the Lead's own true quotes as fabricated.
62
+ */
63
+ begin(agent: Agent, taskId: TaskId, firstSeq: number = agent.session.seq): void {
64
+ if (!Number.isSafeInteger(firstSeq) || firstSeq < 0 || firstSeq > agent.session.seq) {
65
+ throw new Error('Fusion task input boundary requires reconciliation')
66
+ }
67
+ this.store.writeDocument(`task-origin:${taskId}`, 0,
68
+ { schemaVersion: 1, sessionId: agent.id, firstSeq } satisfies Origin)
69
+ }
70
+
71
+ /** The recorded user-instruction boundary of a task, when it has one. */
72
+ originSeq(taskId: TaskId): number | undefined {
73
+ const origin = this.store.readDocument(`task-origin:${taskId}`)?.value as Origin | undefined
74
+ return origin && Number.isSafeInteger(origin.firstSeq) ? origin.firstSeq : undefined
75
+ }
76
+
77
+ /**
78
+ * `compact` is appended on every changed step: it carries only state the
79
+ * native history does not already hold, so the expensive Lead does not
80
+ * re-read its own brief, the delivered report or the user's messages on
81
+ * every request. `full` restores everything after compaction, when those
82
+ * native messages may have been summarized away.
83
+ */
84
+ render(binding: SessionBinding, role: Role, detail: ContextDetail = 'compact'): string {
85
+ const state = this.store.load(binding.taskId), parent = this.parent(binding.sessionId)
86
+ if (!state || !parent || state.parent !== binding.sessionId || state.profileDigest !== binding.profile.digest) {
87
+ throw new Error('Fusion task context has no matching native parent or frozen profile')
88
+ }
89
+ const rawOrigin = this.store.readDocument(`task-origin:${binding.taskId}`)?.value as Origin | undefined
90
+ if (rawOrigin && (rawOrigin.schemaVersion !== 1 || rawOrigin.sessionId !== parent.id
91
+ || !Number.isSafeInteger(rawOrigin.firstSeq) || rawOrigin.firstSeq < 0 || rawOrigin.firstSeq > parent.session.seq)) {
92
+ throw new Error('Fusion task input boundary requires reconciliation')
93
+ }
94
+ // Older bindings have no recorded boundary. Conservatively retain all
95
+ // original user text; do not guess which earlier instructions can be lost.
96
+ const firstSeq = rawOrigin?.firstSeq ?? 0
97
+ const instructions = parent.session.snapshotEvents().flatMap(event => {
98
+ if (event.seq < firstSeq || event.type !== 'user/message'
99
+ || event.data.source !== undefined && event.data.source.kind !== 'user') return []
100
+ return [{ source: { sessionId: parent.id, eventSeq: event.seq, messageId: event.data.id,
101
+ kind: event.data.source?.kind ?? 'unspecified-native-user-role' },
102
+ text: event.data.content.filter(block => block.type === 'text').map(block => block.text),
103
+ // Binary/other inputs remain at their native source; do not relabel
104
+ // them as textual constraints or silently claim to have interpreted them.
105
+ otherContent: event.data.content.filter(block => block.type !== 'text').map(block => ({ type: block.type, digest: digestOf(block) })),
106
+ }]
107
+ })
108
+ const order = state.currentWorkOrder
109
+ const report = state.validatedReport ?? state.candidateReport
110
+ const refs = [...(order?.evidence ?? []), ...(state.exploration?.sources.map(source => source.excerpt) ?? []),
111
+ ...(report ? [report.changeManifest, ...report.verification] : [])]
112
+ const artifacts = new Map(this.store.artifacts(binding.taskId).map(ref => [ref.id, ref]))
113
+ for (const ref of refs) {
114
+ const stored = artifacts.get(ref.id)
115
+ if (ref.ownerTaskId !== binding.taskId || !stored || stored.digest !== ref.digest || stored.bytes !== ref.bytes) {
116
+ throw new Error('Fusion task context references missing or foreign evidence')
117
+ }
118
+ }
119
+ const facts = {
120
+ schemaVersion: 1, taskId: state.taskId, revision: state.revision, taskSeq: state.seq,
121
+ role, profileDigest: state.profileDigest,
122
+ inputScope: rawOrigin ? 'current-task-native-user-messages' : 'legacy-all-native-user-messages',
123
+ userInstructions: instructions,
124
+ phase: state.phase, intent: state.intent ?? null, control: state.control,
125
+ pendingApprovals: state.pendingApprovals, writer: state.lease ?? null,
126
+ workOrder: order && binding.profile.interactionMode === 'model-like' ? { ...order, policy: { mode: 'native', constraints: 'Native permissions, single writer, explicit check timeouts; no task request or rework cap' } } : order ?? null,
127
+ exploration: state.exploration ?? null,
128
+ workerHandoffs: workerHandoffProjection(this.store, binding.taskId),
129
+ nativeJobs: this.store.listDocumentIds(`native-effect:${binding.taskId}:`).flatMap(id => {
130
+ const effect = this.store.readDocument(id)!.value as { agentId?: string; state?: string; nativeJob?: unknown }
131
+ return effect.nativeJob ? [{ effectId: id, agentId: effect.agentId, state: effect.state, job: effect.nativeJob }] : []
132
+ }),
133
+ report: report ? { stage: state.validatedReport ? 'validated' : 'candidate', ...report } : null,
134
+ review: { ticket: state.activeReviewTicket ?? null, result: state.reviewResult ?? null,
135
+ accepted: state.acceptedReview ?? null },
136
+ verification: state.verification,
137
+ snapshot: state.lastSnapshot ?? report?.snapshot ?? order?.baseSnapshot ?? null,
138
+ }
139
+ const projected = detail === 'full' ? facts : compactFacts(facts as unknown as Facts, role)
140
+ const digest = digestOf(projected), id = this.#latest(binding, role)
141
+ const prior = this.store.readDocument(id)
142
+ const saved = prior?.value as SavedProjection | undefined
143
+ if (saved && (saved.schemaVersion !== 1 || saved.taskId !== binding.taskId || saved.role !== role)) {
144
+ throw new Error('Fusion task projection requires migration')
145
+ }
146
+ let artifact = saved?.digest === digest ? saved.artifact : undefined
147
+ if (artifact) this.store.readArtifact(binding.taskId, artifact.id)
148
+ else {
149
+ artifact = this.store.putArtifact(binding.taskId, Buffer.from(JSON.stringify(projected)), 'application/vnd.dsh-fusion.task-context+json')
150
+ this.store.writeDocument(id, prior?.revision ?? 0,
151
+ { schemaVersion: 1, taskId: binding.taskId, role, digest, artifact } satisfies SavedProjection)
152
+ }
153
+ return this.#text(projected, digest, artifact)
154
+ }
155
+
156
+ /** Run after native pre-step handlers, including the existing compaction owner. */
157
+ message(agent: Agent, binding: SessionBinding, role: Role, detail?: ContextDetail): UserMessage | undefined {
158
+ const text = this.render(binding, role, detail ?? this.#detailFor(agent, binding, role))
159
+ const retained = agent.session.deriveMessages().some(message => isFusionSourceKind(message.source?.kind) && message.source.form === 'snapshot'
160
+ && message.source.sections.some(section => section.name === FUSION_TASK_CONTEXT && section.text === text))
161
+ if (retained) return undefined
162
+ return this.#message(text)
163
+ }
164
+
165
+ /** The final frozen request must contain the snapshot that was actually assembled. */
166
+ assertPresent(agent: Agent, binding: SessionBinding, role: Role, request: GenerateOptions): void {
167
+ if (!isAgentLoopRequest(request) || request.purpose !== undefined) return
168
+ const saved = this.store.readDocument(this.#latest(binding, role))?.value as SavedProjection | undefined
169
+ if (!saved || saved.schemaVersion !== 1 || saved.taskId !== binding.taskId || saved.role !== role) {
170
+ throw new Error('Fusion task context was not assembled')
171
+ }
172
+ const facts = JSON.parse(Buffer.from(this.store.readArtifact(binding.taskId, saved.artifact.id)).toString('utf8'))
173
+ if (digestOf(facts) !== saved.digest) throw new Error('Fusion task projection checksum changed')
174
+ const expected = this.#text(facts, saved.digest, saved.artifact)
175
+ const present = request.messages.some(message => isFusionSourceKind(message.source?.kind)
176
+ && message.source.form === 'snapshot'
177
+ && message.source.sections.some(section => section.name === FUSION_TASK_CONTEXT && section.text === expected)
178
+ && message.content.some(block => block.type === 'text' && block.text.includes(expected)))
179
+ if (!present) throw new Error(`Fusion task context is absent from the native request for ${agent.id}; execution is blocked`)
180
+ }
181
+
182
+ prepareCompaction(agent: Agent, binding: SessionBinding, role: Role, sourceSurfaceSeqs: readonly number[]): string {
183
+ this.render(binding, role, 'full')
184
+ const saved = this.store.readDocument(this.#latest(binding, role))!.value as SavedProjection
185
+ const state = this.store.load(binding.taskId)!
186
+ const id = `task-checkpoint:${binding.taskId}:${randomUUID()}`
187
+ this.store.writeDocument(id, 0, { schemaVersion: 1, state: 'prepared', taskId: binding.taskId,
188
+ agentId: agent.id, role, revision: state.revision, profileDigest: binding.profile.digest,
189
+ workOrderDigest: state.currentWorkOrder ? digestOf(state.currentWorkOrder) : null,
190
+ sourceSurfaceSeqs: [...sourceSurfaceSeqs], projection: saved })
191
+ return id
192
+ }
193
+
194
+ restoreCompaction(agent: Agent, binding: SessionBinding, role: Role, id: string, compactionId: string): void {
195
+ const row = this.store.readDocument(id)
196
+ const checkpoint = row?.value as { schemaVersion: number; state: string; taskId: TaskId; agentId: string;
197
+ role: Role; revision: number; profileDigest: string; workOrderDigest: string | null } | undefined
198
+ const state = this.store.load(binding.taskId)
199
+ if (!row || !checkpoint || !state || checkpoint.schemaVersion !== 1 || checkpoint.state !== 'prepared'
200
+ || checkpoint.agentId !== agent.id || checkpoint.taskId !== binding.taskId || checkpoint.role !== role
201
+ || checkpoint.revision !== state.revision || checkpoint.profileDigest !== binding.profile.digest
202
+ || checkpoint.workOrderDigest !== (state.currentWorkOrder ? digestOf(state.currentWorkOrder) : null)) {
203
+ throw new Error('Fusion task changed during compaction; reconcile before retrying')
204
+ }
205
+ // Native request-error retries do not reassemble prompts. Restore facts as
206
+ // a real named native message before that retry, not by editing a request.
207
+ // The native compactor can retain the newest snapshot outside its replaced
208
+ // region. Appending that same 40 KB again would inflate the retry and could
209
+ // make a recoverable request fail the second compaction attempt.
210
+ const message = this.message(agent, binding, role, 'full')
211
+ const priorSnapshot = message && agent.session.surface.nodes.toReversed().find(seq => {
212
+ const event = agent.session.eventAt(seq)
213
+ if (event?.type !== 'user/message') return false
214
+ const { source, content } = event.data
215
+ // Only replace this plugin's single-section task snapshot. Other input,
216
+ // tools and summary events remain under their existing native owners.
217
+ if (!isFusionSourceKind(source?.kind) || source.form !== 'snapshot'
218
+ || source.sections.length !== 1 || source.sections[0]!.name !== FUSION_TASK_CONTEXT
219
+ || content.length !== 1 || content[0]!.type !== 'text' || content[0]!.text !== source.sections[0]!.text) return false
220
+ try {
221
+ const facts = JSON.parse(source.sections[0]!.text.slice(source.sections[0]!.text.indexOf('\n') + 1)).facts
222
+ return facts.taskId === binding.taskId && facts.role === role
223
+ } catch { return false }
224
+ })
225
+ const event = message && agent.session.append('user/message', message, priorSnapshot !== undefined
226
+ ? { surfaceOp: { op: 'replace', startSeq: priorSnapshot, endSeq: priorSnapshot }, sourceEventSeqs: [priorSnapshot] }
227
+ : { surfaceOp: 'append' })
228
+ this.store.writeDocument(id, row.revision, { ...checkpoint, state: 'committed', compactionId,
229
+ restoredEventSeq: event?.seq ?? null, reusedExistingSnapshot: message === undefined,
230
+ replacedSnapshotSeq: priorSnapshot ?? null,
231
+ restoredProjection: this.store.readDocument(this.#latest(binding, role))!.value })
232
+ }
233
+
234
+ /**
235
+ * Compact snapshots rely on native history for briefs, reports and user text.
236
+ * Any compaction, including one the Host or the user started, may summarize
237
+ * those away, so the first snapshot after it (and a task's first) is full.
238
+ */
239
+ #detailFor(agent: Agent, binding: SessionBinding, role: Role): ContextDetail {
240
+ let lastFull = -1, lastCompaction = -1
241
+ for (const event of agent.session.snapshotEvents()) {
242
+ if (event.type === 'compaction/end') lastCompaction = event.seq
243
+ if (event.type !== 'user/message' || !isFusionSourceKind(event.data.source?.kind)
244
+ || event.data.source.form !== 'snapshot') continue
245
+ for (const section of event.data.source.sections) {
246
+ if (section.name !== FUSION_TASK_CONTEXT) continue
247
+ try {
248
+ const facts = JSON.parse(section.text.slice(section.text.indexOf('\n') + 1)).facts
249
+ if (facts.taskId === binding.taskId && facts.role === role && facts.detail !== 'compact') lastFull = event.seq
250
+ } catch { /* foreign or legacy text: not a full snapshot for this task */ }
251
+ }
252
+ }
253
+ return lastFull < 0 || lastCompaction > lastFull ? 'full' : 'compact'
254
+ }
255
+
256
+ #latest(binding: SessionBinding, role: Role): string { return `task-context:${binding.taskId}:${role}` }
257
+ #message(text: string): UserMessage {
258
+ return createUserMessage({ content: [{ type: 'text', text }],
259
+ source: { kind: FUSION_SOURCE_KIND, form: 'snapshot', sections: [{ name: FUSION_TASK_CONTEXT, text }] } })
260
+ }
261
+ #text(facts: unknown, digest: string, artifact: ArtifactRef): string {
262
+ return `${INTRO}\n${JSON.stringify({ facts, digest, sourceArtifact: artifact })}`
263
+ }
264
+ }
@@ -0,0 +1,121 @@
1
+ import { usageSummary } from './history.js'
2
+ import { randomUUID } from 'node:crypto'
3
+ import type { Context } from '@deepseek-ai/cordis'
4
+ import type { Agent } from '@deepseek-ai/dsh-agent'
5
+ import type { StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm'
6
+ import type { Role, TaskId } from '../contracts.js'
7
+ import { digestOf } from '../digest.js'
8
+ import { ingestDurable, known, newLedger, unknown } from '../usage/ledger.js'
9
+ import type { CanonicalBill, UsageLedgerV2 } from '../usage/ledger.js'
10
+ import type { SqliteFusionStore } from '../task/sqlite-store.js'
11
+ import { nativeRequestAgent } from './native-request.js'
12
+ import type { NativeAuxiliaryRequests } from './native-auxiliary.js'
13
+
14
+ export interface UsageOwner { readonly taskId: TaskId; readonly role: Role }
15
+
16
+ /**
17
+ * DSH counters are disjoint; missing provider fields retain unknown applicability.
18
+ *
19
+ * A counter is known only when the provider reported a real finite number.
20
+ * Gateways that omit completion_tokens_details send reasoningTokens as null
21
+ * (seen on some claude-opus-5.5 routes). known(null) throws INVALID_TOKEN_COUNT
22
+ * inside the llm/stream hook and aborts the turn, so null and NaN stay unknown.
23
+ */
24
+ export function billFromNativeUsage(usage: TokenUsage | undefined): CanonicalBill {
25
+ const count = (value: number | undefined | null) =>
26
+ value === undefined || value === null || !Number.isFinite(value) ? unknown() : known(value)
27
+ const reasoningTokens = count(usage?.reasoningTokens)
28
+ return {
29
+ uncachedInput: count(usage?.inputTokens), cacheRead: count(usage?.cacheReadTokens), output: count(usage?.outputTokens),
30
+ cacheWrite: usage?.cacheWriteTokens === undefined || usage.cacheWriteTokens === null
31
+ || !Number.isFinite(usage.cacheWriteTokens) ? { kind: 'unknown' }
32
+ : { kind: 'aggregate', rateKey: 'provider-cache-write-rate-unknown', tokens: known(usage.cacheWriteTokens) },
33
+ reasoning: reasoningTokens.state === 'known'
34
+ ? { kind: 'separate', tokens: reasoningTokens }
35
+ : { kind: 'unknown', tokens: unknown() },
36
+ }
37
+ }
38
+
39
+ export interface NativeUsageRecord {
40
+ schemaVersion: 1
41
+ taskId: TaskId
42
+ sessionId: string
43
+ role: Role
44
+ purpose: 'conversation' | 'compaction' | 'session-title' | 'cache-keepalive'
45
+ /** Effective request envelope; Host compaction markers can retain their pre-routing cap. */
46
+ maxOutputTokens: number | null
47
+ reasoningEffort?: string
48
+ auxiliary?: { seriesId: string; iteration: number }
49
+ nativeInvocationId: string
50
+ startedAt: string
51
+ endedAt: string | null
52
+ /** Entering a Host stream is observable; the adapter's HTTP retry count is not. */
53
+ nativeStreamInvocations: 1
54
+ upstreamHttpCalls: null
55
+ outcome: 'entered' | 'stop' | 'tool-calls' | 'max-tokens' | 'aborted' | 'error' | 'unknown'
56
+ ledger: UsageLedgerV2
57
+ actualSubscriptionChargeUsd: null
58
+ apiEquivalentCostUsd: null
59
+ }
60
+
61
+ /** Read-only stream observer. It neither changes requests nor orchestrates agents. */
62
+ export function observeNativeUsage(
63
+ ctx: Context, store: SqliteFusionStore, ownerOf: (agent: Agent) => UsageOwner | undefined,
64
+ auxiliary?: NativeAuxiliaryRequests,
65
+ ): () => void {
66
+ return ctx.on('llm/stream', async function* (options, next): AsyncIterable<StreamChunk> {
67
+ const agent = nativeRequestAgent(ctx, options)
68
+ const owner = agent && ownerOf(agent)
69
+ if (!owner || !agent) { yield* next(); return }
70
+ const extra = auxiliary?.get(options)
71
+ const id = `native:${randomUUID()}`
72
+ const key = { provider: options.provider, model: options.model, requestId: id, attemptId: id,
73
+ contractDigest: digestOf({ host: '0.1.5-rc.2', input: 'disjoint', reasoning: 'unknown', missing: 'unknown' }) }
74
+ let ledger = newLedger(key)
75
+ let revision = 0, summaryRevision = 0
76
+ let sequence = 0
77
+ let lastUsage: TokenUsage | undefined
78
+ const record: NativeUsageRecord = {
79
+ schemaVersion: 1, taskId: owner.taskId, sessionId: agent.id, role: owner.role, purpose: extra?.purpose ?? options.purpose ?? 'conversation', nativeInvocationId: id,
80
+ maxOutputTokens: options.maxTokens ?? null,
81
+ ...(options.reasoningEffort === undefined ? {} : { reasoningEffort: options.reasoningEffort }),
82
+ ...(extra ? { auxiliary: { seriesId: extra.seriesId, iteration: extra.iteration } } : {}),
83
+ startedAt: new Date().toISOString(), endedAt: null, nativeStreamInvocations: 1, upstreamHttpCalls: null,
84
+ outcome: 'entered', ledger, actualSubscriptionChargeUsd: null, apiEquivalentCostUsd: null,
85
+ }
86
+ const persist = () => {
87
+ const value = { ...record, ledger }
88
+ ;[revision, summaryRevision] = store.writeDocuments([
89
+ { id: `usage:${owner.taskId}:${id}`, expectedRevision: revision, value },
90
+ { id: `usage-index:${owner.taskId}:${id}`, expectedRevision: summaryRevision, value: usageSummary(value) },
91
+ ]) as [number, number]
92
+ }
93
+ persist()
94
+ try {
95
+ for await (const chunk of next()) {
96
+ if (chunk.type === 'usage') {
97
+ lastUsage = chunk.usage
98
+ ledger = ingestDurable(ledger, { id: `${id}:${++sequence}`, key, sequence, mode: 'snapshot', bill: billFromNativeUsage(lastUsage) })
99
+ persist()
100
+ }
101
+ if (chunk.type === 'finish') {
102
+ const kind = chunk.reason.kind
103
+ record.outcome = ['stop', 'tool-calls', 'max-tokens', 'aborted', 'error'].includes(kind)
104
+ ? kind as NativeUsageRecord['outcome'] : 'unknown'
105
+ record.endedAt = new Date().toISOString()
106
+ if (['stop', 'tool-calls', 'max-tokens'].includes(kind) && lastUsage) {
107
+ ledger = ingestDurable(ledger, { id: `${id}:final`, key, sequence: ++sequence, mode: 'final', bill: billFromNativeUsage(lastUsage) })
108
+ }
109
+ persist()
110
+ }
111
+ yield chunk
112
+ }
113
+ } finally {
114
+ if (record.endedAt === null) {
115
+ record.outcome = options.signal?.aborted ? 'aborted' : 'unknown'
116
+ record.endedAt = new Date().toISOString()
117
+ persist()
118
+ }
119
+ }
120
+ })
121
+ }
@@ -0,0 +1,122 @@
1
+ import type { Agent } from '@deepseek-ai/dsh-agent'
2
+ import type { ToolExecution } from '@deepseek-ai/dsh-tools'
3
+ import { isAgentLoopRequest } from '@deepseek-ai/dsh-llm'
4
+ import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
5
+ import type { TaskId } from '../contracts.js'
6
+ import { digestOf } from '../digest.js'
7
+ import type { SqliteFusionStore } from '../task/sqlite-store.js'
8
+ import { recentHandoffSuffix } from '../context/handoff.js'
9
+ import { isFusionSourceKind } from '../message-source.js'
10
+
11
+ /** Durable Lead-authored refinements survive native compaction and request races. */
12
+ export function workerBriefs(store: SqliteFusionStore, taskId: TaskId) {
13
+ const revision = (store.readDocument(`runtime:${taskId}`)?.value as { briefRevision?: number } | undefined)?.briefRevision ?? 0
14
+ if (!Number.isSafeInteger(revision) || revision < 0) throw new Error('Worker brief revision requires reconciliation')
15
+ const rows = store.listDocumentIds(`worker-delivery:${taskId}:`).flatMap(id => {
16
+ const row = store.readDocument(id)!.value as { schemaVersion: number; taskId: string; workOrderId?: string; briefRevision: number; payloadRef: string; causeId: string; state: string }
17
+ if (row.workOrderId && row.workOrderId !== store.load(taskId)?.currentWorkOrder?.id) return []
18
+ if (row.state === 'not-applied-after-interruption') return []
19
+ if (row.briefRevision > revision) return [] // prepared before the runtime committed it
20
+ if (row.schemaVersion !== 1 || row.taskId !== taskId || !Number.isSafeInteger(row.briefRevision)
21
+ || row.briefRevision < 1 || !row.payloadRef || !row.causeId) throw new Error('Worker brief journal requires reconciliation')
22
+ return [{ revision: row.briefRevision, deliveryId: id, source: 'lead-tool-feedback' as const, nativeCallId: row.causeId, payloadRef: row.payloadRef,
23
+ feedback: Buffer.from(store.readArtifact(taskId, row.payloadRef)).toString('utf8') }]
24
+ }).sort((left, right) => left.revision - right.revision)
25
+ if (rows.length !== revision || rows.some((row, index) => row.revision !== index + 1)) throw new Error('Worker brief history is missing or conflicting')
26
+ return rows
27
+ }
28
+
29
+ /**
30
+ * Pin a bounded projection, including the first handoff, on both native roles.
31
+ * The complete owned outbox, journal and artifact bytes remain durable. Original
32
+ * user input and the frozen work order are retained separately by NativeTaskContext.
33
+ */
34
+ export function workerHandoffProjection(store: SqliteFusionStore, taskId: TaskId) {
35
+ const state = store.load(taskId)
36
+ if (!state) throw new Error('Worker handoff task is missing')
37
+ const records: { revision: number; source: 'lead-tool-handoff' | 'lead-tool-feedback';
38
+ sourceId: string; payloadRef: string; content: string }[] = []
39
+ const order = state.currentWorkOrder
40
+ if (order) {
41
+ const rows = store.outbox(taskId).filter(row => row.kind === 'native-worker' && row.operationId === order.operationId)
42
+ const row = rows[0], childId = state.acceptedChild ?? state.reservedChild
43
+ if (rows.length !== 1 || !row || row.taskId !== taskId || !childId || row.reservedChild !== childId || !row.payloadRef) {
44
+ throw new Error('Initial Worker handoff requires reconciliation')
45
+ }
46
+ records.push({ revision: 0, source: 'lead-tool-handoff', sourceId: row.operationId,
47
+ payloadRef: row.payloadRef, content: Buffer.from(store.readArtifact(taskId, row.payloadRef)).toString('utf8') })
48
+ }
49
+ const briefs = workerBriefs(store, taskId)
50
+ for (const brief of briefs) records.push({ revision: brief.revision, source: brief.source,
51
+ sourceId: brief.deliveryId, payloadRef: brief.payloadRef, content: brief.feedback })
52
+ return { schemaVersion: 1 as const, ...recentHandoffSuffix(records),
53
+ latestBriefRevision: briefs.at(-1)?.revision ?? 0, historyDigest: digestOf(records) }
54
+ }
55
+
56
+ /** Brief bodies already arrive as native messages; the snapshot pins only their identity and digests. */
57
+ export function compactHandoffs(projection: ReturnType<typeof workerHandoffProjection>) {
58
+ const { records, ...rest } = projection
59
+ return { ...rest, records: records.map(({ content, ...record }) => ({ ...record, contentDigest: digestOf(content) })) }
60
+ }
61
+
62
+ export function assertWorkerBriefRequest(store: SqliteFusionStore, taskId: TaskId, request: GenerateOptions): void {
63
+ if (!isAgentLoopRequest(request) || request.purpose !== undefined) return
64
+ const expected = workerHandoffProjection(store, taskId)
65
+ if (!expected.totalRecords) return
66
+ const present = request.messages.some(message => isFusionSourceKind(message.source?.kind) && message.source.form === 'snapshot'
67
+ && message.source.sections.some(section => {
68
+ if (section.name !== 'fusion:task') return false
69
+ try {
70
+ const seen = digestOf(JSON.parse(section.text.slice(section.text.indexOf('\n') + 1)).facts.workerHandoffs)
71
+ return seen === digestOf(expected) || seen === digestOf(compactHandoffs(expected))
72
+ } catch { return false }
73
+ }))
74
+ if (!present) throw new Error('Worker request predates the latest Lead brief; do not dispatch a stale generation')
75
+ }
76
+
77
+ /** Bind model-generated tools to the brief that their exact native request saw. */
78
+ export function captureWorkerBrief(store: SqliteFusionStore, taskId: TaskId, agent: Agent, turn: number, step: number, revision: number): void {
79
+ const id = `worker-request:${agent.id}:${turn}:${step}`
80
+ const value = { schemaVersion: 1, taskId, sessionId: agent.id, turn, step, briefRevision: revision,
81
+ workOrderId: store.load(taskId)?.currentWorkOrder?.id }
82
+ const prior = store.readDocument(id)
83
+ if (prior) {
84
+ if (digestOf(prior.value) !== digestOf(value)) throw new Error('Worker request was already bound to another brief')
85
+ return
86
+ }
87
+ store.writeDocument(id, 0, value)
88
+ }
89
+
90
+ /**
91
+ * Bind one non-read Worker tool to the brief its own request saw.
92
+ *
93
+ * A native call is identified by `rootCallId`, which is the id of the block the
94
+ * assistant message emitted. A bridge route (Antigravity's `agy`) instead runs
95
+ * the call inside its OWN loop and reports the step through its transport, so
96
+ * the bridged dispatch carries a bridge-local call id that never appears in this
97
+ * session (`agy-mcp-N`). Refusing it as "no native request" refused the one tool
98
+ * a bridge Worker exists to call — `fusion_submit_result` — and stranded the
99
+ * task (session-1d65b1d0). Such a call is still owned by a native request: it
100
+ * is dispatched synchronously while the transport call runs, so the enclosing
101
+ * assistant message is the one that emitted the transport. `transport` carries
102
+ * that name only for a role whose route really is a bridge.
103
+ */
104
+ export function workerToolBriefProblem(store: SqliteFusionStore, taskId: TaskId, exec: ToolExecution, revision: number,
105
+ transport?: string): string | undefined {
106
+ const events = exec.agent?.session.snapshotEvents()
107
+ const native = events?.findLast(event => event.type === 'assistant/message'
108
+ && event.data.message.content.some(block => block.type === 'tool-call' && block.id === exec.rootCallId))
109
+ const message = native ?? (transport === undefined ? undefined
110
+ : events?.findLast(event => event.type === 'assistant/message'
111
+ && event.data.message.content.some(block => block.type === 'tool-call' && block.name === transport)))
112
+ if (!message || message.type !== 'assistant/message' || message.data.interrupted) return 'Worker tool requires a complete native model request'
113
+ const row = store.readDocument(`worker-request:${exec.agent!.id}:${message.data.turn}:${message.data.step}`)?.value as {
114
+ schemaVersion?: number; taskId?: string; sessionId?: string; turn?: number; step?: number; briefRevision?: number; workOrderId?: string
115
+ } | undefined
116
+ if (row?.schemaVersion !== 1 || row.taskId !== taskId || row.sessionId !== exec.agent!.id
117
+ || row.turn !== message.data.turn || row.step !== message.data.step) return 'Worker tool has no captured brief identity'
118
+ if (row.briefRevision !== revision) return 'A newer Lead brief superseded this request; process it before further tools or a report'
119
+ const order = store.load(taskId)?.currentWorkOrder
120
+ if (row.workOrderId !== order?.id && (row.workOrderId || order?.mode)) return 'A newer work order superseded this request; process its plan before further tools'
121
+ return undefined
122
+ }
@@ -0,0 +1,72 @@
1
+ import type { Agent } from '@deepseek-ai/dsh-agent'
2
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
3
+ import type { UserMessage } from '@deepseek-ai/dsh-llm'
4
+ import type {} from '@deepseek-ai/dsh-subagent'
5
+ import type { ArtifactRef, TaskId } from '../contracts.js'
6
+ import { digestOf } from '../digest.js'
7
+ import type { SqliteFusionStore } from '../task/sqlite-store.js'
8
+ import type { SessionBinding } from './bindings.js'
9
+ import { workerSettlementGuidance } from './worker-terminal.js'
10
+ import { FUSION_SOURCE_KIND } from '../message-source.js'
11
+
12
+ interface NoticeProjection {
13
+ schemaVersion: 1
14
+ taskId: TaskId
15
+ parentId: string
16
+ childId: string
17
+ originalMessageId: string
18
+ originalDigest: string
19
+ artifact: ArtifactRef
20
+ message: UserMessage
21
+ }
22
+
23
+ /** Reduce only our Worker's runtime closing notice, never its structured report. */
24
+ export class NativeWorkerNotices {
25
+ constructor(readonly store: SqliteFusionStore) {}
26
+
27
+ project(agent: Agent, binding: SessionBinding, original: UserMessage): UserMessage {
28
+ const source = original.source
29
+ if (agent.id !== binding.sessionId || source?.kind !== 'subagent-settled') return original
30
+ const child = this.store.readDocument(`child:${source.senderSessionId}`)?.value as { parent?: string; taskId?: TaskId } | undefined
31
+ const task = child?.taskId && this.store.load(child.taskId)
32
+ if (child?.parent !== agent.id || !task || task.parent !== String(agent.id)
33
+ || (task.acceptedChild !== String(source.senderSessionId) && task.reservedChild !== String(source.senderSessionId))) return original
34
+
35
+ const originalDigest = digestOf(original)
36
+ const id = `worker-notice:${binding.taskId}:${digestOf({ parent: agent.id, message: original.id })}`
37
+ const saved = this.store.readDocument(id)?.value as NoticeProjection | undefined
38
+ if (saved) {
39
+ if (saved.schemaVersion !== 1 || saved.taskId !== binding.taskId || saved.parentId !== agent.id
40
+ || saved.childId !== source.senderSessionId || saved.originalDigest !== originalDigest) {
41
+ throw new Error('Fusion Worker notice identity requires reconciliation')
42
+ }
43
+ this.store.readArtifact(binding.taskId, saved.artifact.id)
44
+ return saved.message
45
+ }
46
+ // Keep the entire original, including failure detail and closing blocks,
47
+ // available through owned evidence. This projection claims no task success.
48
+ const artifact = this.store.putArtifact(binding.taskId, Buffer.from(JSON.stringify(original)), 'application/vnd.dsh-fusion.worker-notice+json')
49
+ const submitted = Boolean((this.store.readDocument(`runtime:${binding.taskId}`)?.value as { submitted?: unknown } | undefined)?.submitted)
50
+ const message = createUserMessage({ source: { kind: FUSION_SOURCE_KIND, form: 'notice', summary: source.summary },
51
+ content: [{ type: 'text', text: `${source.summary}\n${workerSettlementGuidance(submitted)} Closing transcript is archived rather than repeated here. For missing failure details use fusion_read_evidence with id ${artifact.id}.\n${JSON.stringify({ childId: source.senderSessionId, originalMessageId: original.id, artifact })}` }] })
52
+ this.store.writeDocument(id, 0, { schemaVersion: 1, taskId: binding.taskId, parentId: agent.id,
53
+ childId: source.senderSessionId, originalMessageId: original.id, originalDigest, artifact, message } satisfies NoticeProjection)
54
+ return message
55
+ }
56
+
57
+ /** Replace retained legacy notices before the native compactor reads history.
58
+ * Public surface replacement preserves the original append-only event and
59
+ * cites it. Already compacted prose cannot be safely reverse-transformed.
60
+ */
61
+ projectHistory(agent: Agent, binding: SessionBinding): void {
62
+ for (const seq of [...agent.session.surface.nodes]) {
63
+ const event = agent.session.eventAt(seq)
64
+ if (event?.type !== 'user/message') continue
65
+ const message = this.project(agent, binding, event.data)
66
+ if (message === event.data) continue
67
+ agent.session.append('user/message', message, {
68
+ surfaceOp: { op: 'replace', startSeq: seq, endSeq: seq }, sourceEventSeqs: [seq],
69
+ })
70
+ }
71
+ }
72
+ }