@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,25 @@
1
+ /** Lossless text decoding; binary artifacts stay available as explicit base64. */
2
+ export function evidenceText(bytes: Uint8Array): string | undefined {
3
+ if (bytes.includes(0)) return undefined
4
+ try { return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes) }
5
+ catch { return undefined }
6
+ }
7
+
8
+ /** Offsets count UTF-16 code units of text, or characters of base64. */
9
+ export function evidencePage(bytes: Uint8Array, offset = 0, limit = 24_000) {
10
+ if (!Number.isSafeInteger(offset) || offset < 0) throw new Error('Evidence offset must be a nonnegative safe integer')
11
+ if (!Number.isSafeInteger(limit) || limit < 2 || limit > 24_000) throw new Error('Evidence limit must be an integer from 2 to 24000')
12
+ const decoded = evidenceText(bytes)
13
+ const encoding = decoded === undefined ? 'base64' : 'utf-8'
14
+ const content = decoded ?? Buffer.from(bytes).toString('base64')
15
+ if (offset > content.length) throw new Error('Evidence offset exceeds the artifact text length')
16
+ const splitsPair = (at: number) => at > 0 && at < content.length
17
+ && content.charCodeAt(at - 1) >= 0xd800 && content.charCodeAt(at - 1) <= 0xdbff
18
+ && content.charCodeAt(at) >= 0xdc00 && content.charCodeAt(at) <= 0xdfff
19
+ if (splitsPair(offset)) throw new Error('Evidence offset splits a Unicode character; use the returned nextOffset')
20
+ let end = Math.min(content.length, offset + limit)
21
+ if (splitsPair(end)) end--
22
+ const truncated = end < content.length
23
+ return { bytes: bytes.byteLength, encoding, offsetUnit: 'utf-16-code-units', offset, totalCharacters: content.length,
24
+ text: content.slice(offset, end), truncated, nextOffset: truncated ? end : null }
25
+ }
@@ -0,0 +1,114 @@
1
+ import type { SqliteFusionStore } from '../task/sqlite-store.js'
2
+ import type { TaskState } from '../task/state.js'
3
+ import type { NativeUsageRecord } from './native-usage.js'
4
+ import { projectLedger, restoreLedger, type Count } from '../usage/ledger.js'
5
+
6
+ export interface TaskSummary {
7
+ id: string; sessionId: string; seq: number; createdAt: string; updatedAt: string
8
+ title: string; phase: string; verification: string; mode: string
9
+ }
10
+ export function taskSummary(state: TaskState, createdAt: string, updatedAt: string): TaskSummary {
11
+ return { id: state.taskId, sessionId: state.parent, seq: state.seq, createdAt, updatedAt,
12
+ title: state.currentWorkOrder?.goal?.slice(0, 160) || '对话任务', phase: state.phase, verification: state.verification, mode: state.control.mode }
13
+ }
14
+ export interface UsageSummary {
15
+ id: string; taskId: string; role: string; provider: string; model: string; purpose: string; startedAt: string
16
+ outcome: string; authority: 'none' | 'provisional' | 'final'; input: Count; output: Count
17
+ reasoning: Count; totalOutput: Count; cacheRead: Count
18
+ }
19
+ export function usageSummary(row: NativeUsageRecord): UsageSummary {
20
+ const projection = projectLedger(restoreLedger(row.ledger))
21
+ const bill = projection.bill
22
+ const input = bill?.uncachedInput ?? { state: 'unknown' }
23
+ const output = bill?.output ?? { state: 'unknown' }
24
+ const cacheRead = bill?.cacheRead ?? { state: 'unknown' }
25
+ const reasoning = bill?.reasoning?.tokens ?? { state: 'unknown' }
26
+
27
+ let totalOutput: Count = { state: 'unknown' }
28
+ if (output.state === 'known') {
29
+ if (bill?.reasoning?.kind === 'included') {
30
+ totalOutput = { state: 'known', tokens: output.tokens }
31
+ } else if (reasoning.state === 'known') {
32
+ totalOutput = { state: 'known', tokens: output.tokens + reasoning.tokens }
33
+ } else {
34
+ totalOutput = { state: 'unknown' }
35
+ }
36
+ }
37
+
38
+ return { id: row.nativeInvocationId, taskId: row.taskId, role: row.role, provider: row.ledger.key.provider, model: row.ledger.key.model,
39
+ purpose: row.purpose ?? 'conversation', startedAt: row.startedAt, outcome: row.outcome, authority: projection.authority,
40
+ input, output, reasoning, totalOutput, cacheRead }
41
+ }
42
+
43
+ /** Once at plugin startup; GET never writes, starts agents, or scans full task/usage snapshots. */
44
+ export function backfillHistory(store: SqliteFusionStore) {
45
+ for (const id of store.listTaskIds()) {
46
+ if (store.readDocument(`task-index:${id}`)) continue
47
+ const state = store.load(id)!, events = store.events(id)
48
+ store.writeDocument(`task-index:${id}`, 0, taskSummary(state, events[0]!.createdAt, events.at(-1)!.createdAt))
49
+ }
50
+ for (const id of store.listDocumentIds('usage:')) {
51
+ const projectionId = `usage-index:${id.slice(6)}`
52
+ const existing = store.readDocument(projectionId)
53
+ if (existing && (existing.value as UsageSummary).totalOutput !== undefined) continue
54
+ const usageDoc = store.readDocument(id)
55
+ if (usageDoc) {
56
+ store.writeDocument(projectionId, existing?.revision ?? 0, usageSummary(usageDoc.value as NativeUsageRecord))
57
+ }
58
+ }
59
+ }
60
+ export interface HistoryTotals { calls: number; final: number; provisional: number; unreported: number;
61
+ input: { known: number; reported: number }; output: { known: number; reported: number }
62
+ reasoning: { known: number; reported: number }; totalOutput: { known: number; reported: number }; cacheRead: { known: number; reported: number } }
63
+ const empty = (): HistoryTotals => ({ calls: 0, final: 0, provisional: 0, unreported: 0,
64
+ input: { known: 0, reported: 0 }, output: { known: 0, reported: 0 },
65
+ reasoning: { known: 0, reported: 0 }, totalOutput: { known: 0, reported: 0 }, cacheRead: { known: 0, reported: 0 } })
66
+ function add(total: HistoryTotals, row: UsageSummary) {
67
+ total.calls++; total[row.authority === 'none' ? 'unreported' : row.authority]++
68
+ for (const key of ['input', 'output', 'reasoning', 'totalOutput', 'cacheRead'] as const) {
69
+ const count = row[key]
70
+ if (count && count.state === 'known') { total[key].known += count.tokens; total[key].reported++ }
71
+ }
72
+ if (!row.totalOutput && row.output && row.output.state === 'known') {
73
+ if (row.reasoning && row.reasoning.state === 'known') {
74
+ total.totalOutput.known += row.output.tokens + row.reasoning.tokens
75
+ total.totalOutput.reported++
76
+ }
77
+ }
78
+ }
79
+ /** How often the Host unlocked Lead takeover (enforced-v3), by reason. */
80
+ export interface TakeoverTotals { total: number; tasks: number; reasons: Record<string, number> }
81
+ export interface HistoryView {
82
+ observedAt: string; totalTasks: number; tasks: (TaskSummary & { usage: HistoryTotals; takeovers: number })[]; nextCursor: string | null
83
+ takeovers: TakeoverTotals
84
+ totals: HistoryTotals; groups: ({ role: string; provider: string; model: string; purpose: string } & HistoryTotals)[]
85
+ actualBilledUsd: null; savingsPercent: null; upstreamHttpCalls: null
86
+ }
87
+ export function readHistory(store: SqliteFusionStore, cursor = '', limit = 20): HistoryView {
88
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 50) throw new Error('History page size must be 1–50')
89
+ const tasks = store.listDocumentIds('task-index:').map(id => store.readDocument(id)!.value as TaskSummary)
90
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id))
91
+ const all = store.listDocumentIds('usage-index:').map(id => store.readDocument(id)!.value as UsageSummary)
92
+ const groups = new Map<string, HistoryView['groups'][number]>(), byTask = new Map<string, HistoryTotals>(), totals = empty()
93
+ for (const row of all) {
94
+ add(totals, row)
95
+ const own = byTask.get(row.taskId) ?? empty(); add(own, row); byTask.set(row.taskId, own)
96
+ const key = JSON.stringify([row.role, row.provider, row.model, row.purpose])
97
+ const group = groups.get(key) ?? { role: row.role, provider: row.provider, model: row.model, purpose: row.purpose, ...empty() }
98
+ add(group, row); groups.set(key, group)
99
+ }
100
+ const takeovers: TakeoverTotals = { total: 0, tasks: 0, reasons: {} }, takeoversByTask = new Map<string, number>()
101
+ for (const id of store.listDocumentIds('lead-takeover:')) {
102
+ const row = store.readDocument(id)!.value as { taskId: string; reason: string }
103
+ takeovers.total++; takeovers.reasons[row.reason] = (takeovers.reasons[row.reason] ?? 0) + 1
104
+ takeoversByTask.set(row.taskId, (takeoversByTask.get(row.taskId) ?? 0) + 1)
105
+ }
106
+ takeovers.tasks = takeoversByTask.size
107
+ const start = cursor ? tasks.findIndex(task => task.id === cursor) + 1 : 0
108
+ if (cursor && start === 0) throw new Error('History cursor is no longer available')
109
+ const page = tasks.slice(start, start + limit)
110
+ return { observedAt: new Date().toISOString(), totalTasks: tasks.length,
111
+ tasks: page.map(task => ({ ...task, usage: byTask.get(task.id) ?? empty(), takeovers: takeoversByTask.get(task.id) ?? 0 })),
112
+ nextCursor: start + limit < tasks.length ? page.at(-1)!.id : null, takeovers,
113
+ totals, groups: [...groups.values()].sort((a, b) => b.calls - a.calls), actualBilledUsd: null, savingsPercent: null, upstreamHttpCalls: null }
114
+ }
@@ -0,0 +1,30 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http'
2
+
3
+ export function trustedRequest(req: IncomingMessage): boolean {
4
+ if (!['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(req.socket.remoteAddress ?? '')) return false
5
+ if (req.headers['sec-fetch-site'] === 'cross-site' || !req.headers.host) return false
6
+ try {
7
+ const target = new URL(`http://${req.headers.host}`)
8
+ if (!['localhost', '127.0.0.1', '[::1]'].includes(target.hostname)) return false
9
+ return !req.headers.origin || new URL(req.headers.origin).host === target.host
10
+ } catch { return false }
11
+ }
12
+
13
+ export function json(res: ServerResponse, status: number, body: unknown): void {
14
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' })
15
+ res.end(JSON.stringify(body))
16
+ }
17
+
18
+ export async function readJson(req: IncomingMessage): Promise<Record<string, unknown>> {
19
+ if (!req.headers['content-type']?.toLowerCase().startsWith('application/json')) throw new Error('JSON content type required')
20
+ const chunks: Buffer[] = []
21
+ let size = 0
22
+ for await (const chunk of req) {
23
+ size += chunk.length
24
+ if (size > 256 * 1024) throw new Error('Request body exceeds 256 KiB')
25
+ chunks.push(Buffer.from(chunk))
26
+ }
27
+ const value: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
28
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('JSON object required')
29
+ return value as Record<string, unknown>
30
+ }
@@ -0,0 +1,22 @@
1
+ import type { Context } from '@deepseek-ai/cordis'
2
+ import { fittedOutputReservation } from '../context/guard.js'
3
+
4
+ /**
5
+ * Output limits come only from the public model metadata DSH discloses for the route (context window and
6
+ * the adapter's default max tokens). No route or model names are special-cased: route names differ between
7
+ * installations, and a guessed cap would be wrong for someone else's setup.
8
+ */
9
+ export interface ModelOutputLimits {
10
+ contextWindow: number
11
+ defaultMaxTokens?: number
12
+ testedOutputCap?: number
13
+ }
14
+
15
+ /** Public model metadata only; resolving limits does not start a model turn. */
16
+ export async function resolveModelOutputLimits(ctx: Context, provider: string, model: string): Promise<ModelOutputLimits> {
17
+ const info = await ctx.llm.resolveModelInfo(provider, model)
18
+ const contextWindow = info.context?.contextWindow
19
+ if (!Number.isSafeInteger(contextWindow) || Number(contextWindow) <= 0) throw new Error('The configured model does not disclose a usable context window')
20
+ fittedOutputReservation(Number(contextWindow))
21
+ return { contextWindow: Number(contextWindow), defaultMaxTokens: info.defaultMaxTokens }
22
+ }
@@ -0,0 +1,199 @@
1
+ import type { Context } from '@deepseek-ai/cordis'
2
+ import { SessionId } from '@deepseek-ai/dsh-session'
3
+ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
4
+ import type {} from '@deepseek-ai/dsh-session-query'
5
+ import type { FusionActivity, FusionActivityPage } from '../activity.js'
6
+ import type { SqliteFusionStore } from '../task/sqlite-store.js'
7
+
8
+ interface ActivityLink {
9
+ parentId: string
10
+ childId: string
11
+ taskId: string
12
+ callId: string
13
+ turn: number
14
+ step: number
15
+ anchorSeq: number
16
+ fromSeq: number | null
17
+ done?: boolean
18
+ }
19
+ const prefix = 'activity-link:'
20
+ const visibleTools = new Set(['bash', 'pwsh', 'read', 'write', 'edit', 'glob', 'grep', 'job_output', 'job_kill', 'str_replace_editor'])
21
+ const parentPrefix = (parentId: string) => `activity:${encodeURIComponent(parentId)}:`
22
+ const callPrefix = (parentId: string, callId: string) => `${parentPrefix(parentId)}${encodeURIComponent(callId)}:`
23
+
24
+ /** Paginated read-only data for a plugin-owned tool view; no runtime activation. */
25
+ export function readFusionActivity(store: SqliteFusionStore, parentId: string, callId: string, after = -1): FusionActivityPage {
26
+ if (!Number.isSafeInteger(after) || after < -1) throw new Error('Invalid activity cursor')
27
+ const rows = store.listDocumentIds(callPrefix(parentId, callId)).map(id => store.readDocument(id)!.value as FusionActivity)
28
+ .filter(row => row.source.seq > after).sort((a, b) => a.source.seq - b.source.seq)
29
+ const events = rows.slice(0, 100)
30
+ const links = store.listDocumentIds(prefix).map(id => store.readDocument(id)!.value as ActivityLink)
31
+ const link = links.find(row => row.parentId === parentId && row.callId === callId)
32
+ return { events, cursor: events.at(-1)?.source.seq ?? after, more: rows.length > events.length, done: !link || link.done === true }
33
+ }
34
+
35
+ /** Old conversations remain readable through the public, non-activating history
36
+ * API. Correlate accepted message ids, never timing guesses or model narration.
37
+ * This fallback neither rewrites the old log nor creates a replacement Worker.
38
+ */
39
+ export async function readHistoricalFusionActivity(ctx: Context, store: SqliteFusionStore,
40
+ parentId: string, callId: string, after = -1): Promise<FusionActivityPage> {
41
+ const cached = readFusionActivity(store, parentId, callId, after)
42
+ const linked = store.listDocumentIds(prefix).some(id => {
43
+ const row = store.readDocument(id)!.value as ActivityLink
44
+ return row.parentId === parentId && row.callId === callId
45
+ })
46
+ if (linked || !parentId || !callId) return cached
47
+ const tasks = store.listTaskIds().map(id => store.load(id)!).filter(task => task.parent === parentId)
48
+ for (const task of tasks) {
49
+ if (!task.acceptedChild) continue
50
+ const history = store.events(task.taskId)
51
+ const prepared = history.find(event => event.type === 'work-order/prepared' && event.causeId === callId)
52
+ const delivery = store.listDocumentIds(`worker-delivery:${task.taskId}:`).map(id => store.readDocument(id)!.value as
53
+ { causeId: string; messageId?: string; state: string }).find(row => row.causeId === callId && row.state === 'accepted')
54
+ const accepted = prepared && history.find(event => event.type === 'child/accepted' && event.seq > prepared.seq)
55
+ const messageId = delivery?.messageId ?? (accepted?.type === 'child/accepted' ? accepted.payload.messageId : undefined)
56
+ if (!messageId) continue
57
+ const [parent, child] = await Promise.all([ctx.sessionQuery.readSession(SessionId(parentId)), ctx.sessionQuery.readSession(SessionId(task.acceptedChild))])
58
+ if (child.session.parentSession !== parentId) throw new Error('Fusion activity child ownership mismatch')
59
+ const call = parent.events.find(event => event.type === 'tool/call' && event.data.callId === callId)
60
+ if (!call || call.type !== 'tool/call' || !['fusion_explore', 'fusion_delegate', 'fusion_rework'].includes(call.data.name)) return cached
61
+ const start = child.events.find(event => event.type === 'user/message' && event.data.id === messageId)
62
+ if (!start) return cached
63
+ const acceptedIds = new Set(tasks.flatMap(item => store.events(item.taskId).flatMap(event =>
64
+ event.type === 'child/accepted' && event.payload.child === task.acceptedChild ? [event.payload.messageId] : [])))
65
+ const end = child.events.find(event => event.seq > start.seq && event.type === 'user/message' && acceptedIds.has(event.data.id))?.seq ?? Infinity
66
+ const calls = new Set<string>()
67
+ const all: FusionActivity[] = []
68
+ for (const source of child.events) {
69
+ if (source.seq <= start.seq) continue
70
+ if (source.type === 'tool/call' && source.seq < end && visibleTools.has(source.data.name)) calls.add(source.data.callId)
71
+ else if (source.type !== 'tool/result' || !calls.has(source.data.message.source.callId)) continue
72
+ if (source.type !== 'tool/call' && source.type !== 'tool/result') continue
73
+ all.push({ schemaVersion: 1, taskId: task.taskId, childSessionId: task.acceptedChild, parentCallId: callId,
74
+ turn: call.data.turn, step: call.data.step, anchorSeq: call.seq, source })
75
+ }
76
+ const remaining = all.filter(event => event.source.seq > after), events = remaining.slice(0, 100)
77
+ return { events, cursor: events.at(-1)?.source.seq ?? after, more: remaining.length > events.length,
78
+ done: task.control.mode === 'completed' || child.events.at(-1)?.type === 'turn/end' }
79
+ }
80
+ return cached
81
+ }
82
+
83
+ /** Copies actual Worker tool events into plugin-owned presentation storage only.
84
+ * Model history still contains solely the Lead's native messages and reports.
85
+ * Stored source coordinates make replay/HMR idempotent without executing tools.
86
+ */
87
+ export class NativeFusionActivity {
88
+ readonly #dispose: (() => void)[] = []
89
+ #pending: Promise<void> = Promise.resolve()
90
+ readonly #seen = new WeakMap<Session, Set<string>>()
91
+ readonly #calls = new WeakMap<Session, Map<string, FusionActivity>>()
92
+ readonly errors = new Map<string, string>()
93
+
94
+ constructor(readonly ctx: Context, readonly store: SqliteFusionStore) {
95
+ this.#dispose.push(ctx.on('session/event', (session, event) => {
96
+ if (!session.header.parentSession) return
97
+ if (event.type === 'turn/end') { this.#enqueue(() => this.#ended(session)); return }
98
+ if (event.type !== 'tool/call' && event.type !== 'tool/result') return
99
+ this.#enqueue(() => this.#mirror(session, event, false))
100
+ }))
101
+ this.#dispose.push(ctx.on('agent/created', ({ agent }) => {
102
+ this.#replay(agent.session)
103
+ for (const child of ctx.sessions.list()) if (child.header.parentSession === agent.id) this.#replay(child)
104
+ return undefined
105
+ }))
106
+ for (const session of ctx.sessions.list()) this.#replay(session)
107
+ }
108
+
109
+ /** Called before each native delivery. No inference, dispatch or approval. */
110
+ link(parent: Session, childId: string, taskId: string, callId: string): void {
111
+ const call = parent.snapshotEvents().find(event => event.type === 'tool/call' && event.data.callId === callId)
112
+ if (!call || call.type !== 'tool/call') return // Direct programmatic control has no native UI call to own a row.
113
+ const id = `${prefix}${childId}:${call.seq}`
114
+ if (this.store.readDocument(id)) return
115
+ this.store.writeDocument(id, 0, {
116
+ parentId: parent.id, childId, taskId, callId, turn: call.data.turn, step: call.data.step,
117
+ anchorSeq: call.seq, fromSeq: this.ctx.sessions.get(SessionId(childId))?.seq ?? null,
118
+ } satisfies ActivityLink)
119
+ }
120
+
121
+ #enqueue(action: () => void): void {
122
+ // Session.append forbids reentry from event observers, including same-session
123
+ // parent callbacks. Awaited microtasks run after the native append boundary.
124
+ this.#pending = this.#pending.then(action).catch(error => {
125
+ this.errors.set('presentation', String(error))
126
+ this.ctx.logger.warn('Fusion activity presentation failed: %s', String(error))
127
+ })
128
+ }
129
+
130
+ #replay(session: Session): void {
131
+ if (!session.header.parentSession) return
132
+ this.#enqueue(() => {
133
+ for (const event of session.snapshotEvents()) {
134
+ if (event.type === 'tool/call' || event.type === 'tool/result') this.#mirror(session, event, true)
135
+ if (event.type === 'turn/end') this.#ended(session, event.seq, false)
136
+ }
137
+ })
138
+ }
139
+
140
+ #ended(child: Session, throughSeq: number = child.seq, live = true): void {
141
+ for (const id of this.store.listDocumentIds(`${prefix}${child.id}:`)) {
142
+ const row = this.store.readDocument(id)!, link = row.value as ActivityLink
143
+ if ((link.fromSeq === null ? live : link.fromSeq <= throughSeq) && !link.done) this.store.writeDocument(id, row.revision, { ...link, done: true })
144
+ }
145
+ }
146
+
147
+ #mirror(child: Session, event: SessionEvent<'tool/call'> | SessionEvent<'tool/result'>, replay: boolean): void {
148
+ const parent = child.header.parentSession && this.ctx.sessions.get(child.header.parentSession)
149
+ if (!parent) return
150
+ let seen = this.#seen.get(parent), calls = this.#calls.get(parent)
151
+ if (!seen || !calls) {
152
+ seen = new Set(); calls = new Map()
153
+ for (const id of this.store.listDocumentIds(parentPrefix(parent.id))) {
154
+ const activity = this.store.readDocument(id)!.value as FusionActivity
155
+ seen.add(`${activity.childSessionId}:${activity.source.seq}`)
156
+ if (activity.source.type === 'tool/call') calls.set(`${activity.childSessionId}:${activity.source.data.callId}`, activity)
157
+ }
158
+ this.#seen.set(parent, seen); this.#calls.set(parent, calls)
159
+ }
160
+ const key = `${child.id}:${event.seq}`
161
+ if (seen.has(key)) return
162
+ const sourceCallId = event.type === 'tool/call' ? event.data.callId : event.data.message.source.callId
163
+ const callKey = `${child.id}:${sourceCallId}`
164
+ let origin: Omit<FusionActivity, 'source'> | undefined = calls.get(callKey)
165
+ if (event.type === 'tool/call') {
166
+ if (!visibleTools.has(event.data.name)) return
167
+ const links = this.store.listDocumentIds(`${prefix}${child.id}:`).map(id => ({ id, ...this.store.readDocument(id)! }))
168
+ .filter(row => (row.value as ActivityLink).parentId === parent.id)
169
+ .sort((a, b) => (b.value as ActivityLink).anchorSeq - (a.value as ActivityLink).anchorSeq)
170
+ const row = links.find(row => {
171
+ const link = row.value as ActivityLink
172
+ return link.fromSeq === null ? !replay : event.seq >= link.fromSeq
173
+ })
174
+ if (!row) return // Historical, pre-feature calls are not fabricated.
175
+ let link = row.value as ActivityLink
176
+ if (link.fromSeq === null) {
177
+ link = { ...link, fromSeq: event.seq }
178
+ this.store.writeDocument(row.id, row.revision, link)
179
+ }
180
+ origin = { schemaVersion: 1, taskId: link.taskId, childSessionId: child.id,
181
+ parentCallId: link.callId, turn: link.turn, step: link.step,
182
+ // Place all calls below the owning delivery, in original source order.
183
+ anchorSeq: link.anchorSeq + 0.5 - 0.5 / (event.seq + 2) }
184
+ }
185
+ if (!origin) return // Results never acquire an invented call head.
186
+ const activity = { ...origin, source: event } satisfies FusionActivity
187
+ const id = `${callPrefix(parent.id, origin.parentCallId)}${String(event.seq).padStart(12, '0')}`
188
+ if (!this.store.readDocument(id)) this.store.writeDocument(id, 0, activity)
189
+ seen.add(key)
190
+ if (event.type === 'tool/call') calls.set(callKey, activity)
191
+ }
192
+
193
+ async flush(): Promise<void> { await this.#pending }
194
+
195
+ async close(): Promise<void> {
196
+ for (const dispose of this.#dispose.splice(0).reverse()) dispose()
197
+ await this.flush()
198
+ }
199
+ }
@@ -0,0 +1,196 @@
1
+ import type { Context } from '@deepseek-ai/cordis'
2
+ import type { Agent } from '@deepseek-ai/dsh-agent'
3
+ import { SessionId as NativeSessionId } from '@deepseek-ai/dsh-session'
4
+ import type { ToolExecution } from '@deepseek-ai/dsh-tools'
5
+ import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval'
6
+ import type { ArtifactRef, LogicalApproval, Role, TaskId } from '../contracts.js'
7
+ import { OperationId, SessionId } from '../contracts.js'
8
+ import { digestOf } from '../digest.js'
9
+ import type { SqliteFusionStore } from '../task/sqlite-store.js'
10
+ import type { TaskState } from '../task/state.js'
11
+ import type { SessionBinding } from './bindings.js'
12
+ import { snapshotWorkspace } from './workspace.js'
13
+
14
+ type Owner = { binding: SessionBinding; role: Role }
15
+ interface Callbacks {
16
+ owner(agent: Agent): Owner | undefined
17
+ state(id: TaskId): TaskState
18
+ pending(id: TaskId, approval: LogicalApproval): void
19
+ answered(id: TaskId, approvalId: string, state: 'approved' | 'rejected' | 'withdrawn'): void
20
+ failed(agent: Agent, error: unknown): void
21
+ }
22
+ interface ApprovalRecord {
23
+ schemaVersion: 1
24
+ logical: LogicalApproval
25
+ agentId: string
26
+ role: Role
27
+ toolName: string
28
+ callId: string
29
+ rootCallId: string
30
+ askedSeq: number
31
+ askedAt: string
32
+ processId: number
33
+ scopeDigest: string
34
+ evidence: ArtifactRef
35
+ state: 'pending' | 'decided' | 'withdrawn-after-inspection'
36
+ nativeOutcome?: ApprovalOutcome
37
+ decidedSeq?: number
38
+ scopeRejection?: string
39
+ effect?: 'settled' | 'failed'
40
+ inspectionEvidence?: string
41
+ reissuedAs?: string
42
+ previousRequest?: string
43
+ }
44
+
45
+ /** Observe native decisions; never answer on the user's behalf or replay a grant. */
46
+ export class NativeApprovals {
47
+ readonly #calls = new Map<Agent, Map<string, Readonly<ToolExecution>>>()
48
+ readonly #requests = new Map<string, string>()
49
+ constructor(readonly ctx: Context, readonly store: SqliteFusionStore, readonly callbacks: Callbacks) {}
50
+
51
+ install(): () => void {
52
+ const disposers = [
53
+ this.ctx.on('tools/pre-execute', async (exec, next) => {
54
+ if (exec.agent && this.callbacks.owner(exec.agent)) {
55
+ let calls = this.#calls.get(exec.agent)
56
+ if (!calls) this.#calls.set(exec.agent, calls = new Map())
57
+ calls.set(exec.callId, exec)
58
+ }
59
+ return next()
60
+ }, { prepend: true }),
61
+ this.ctx.on('session/event', (session, event) => {
62
+ if (event.type !== 'approval/asked' && event.type !== 'approval/decided') return
63
+ const agent = this.ctx.agents.get(NativeSessionId(session.id)), owner = agent && this.callbacks.owner(agent)
64
+ if (!agent || !owner) return
65
+ try {
66
+ if (event.type === 'approval/asked') this.#asked(agent, owner, event.data, event.seq)
67
+ else this.#decided(owner, event.data.id, event.data.outcome, event.seq)
68
+ } catch (error) { this.callbacks.failed(agent, error) }
69
+ }),
70
+ this.ctx.on('approval/request', async (request, next) => {
71
+ const owner = this.callbacks.owner(request.agent)
72
+ if (!owner) return next()
73
+ // This is deny-only middleware around the native answerer. The audit
74
+ // event already exists; a missing projection must not permit execution.
75
+ const key = this.#requests.get(this.#callKey(request.agent.id, request.callId ?? ''))
76
+ if (!key) return 'unavailable'
77
+ const outcome = await next() // deliberately no timer around human input
78
+ if (outcome !== 'allowed-once') return outcome
79
+ try {
80
+ const row = this.#read(key), exec = this.#calls.get(request.agent)?.get(row.callId)
81
+ const current = this.callbacks.state(owner.binding.taskId)
82
+ const control = current.control
83
+ if (!exec || request.signal?.aborted || control.mode !== 'running' || control.recovering
84
+ || control.outcomeUnknown || control.budgetBlocked
85
+ || row.scopeDigest !== this.#scope(request.agent, owner, exec).digest) {
86
+ this.#write(key, { ...row, scopeRejection: 'The action, workspace, policy or task control changed while approval was pending' })
87
+ return 'rejected'
88
+ }
89
+ return outcome
90
+ } catch (error) { this.callbacks.failed(request.agent, error); return 'unavailable' }
91
+ }, { prepend: true }),
92
+ this.ctx.on('tools/result', (exec, result) => {
93
+ if (!exec.agent) return
94
+ const key = this.#requests.get(this.#callKey(exec.agent.id, exec.callId))
95
+ if (key) {
96
+ try {
97
+ const row = this.#read(key)
98
+ this.#write(key, { ...row,
99
+ logical: row.nativeOutcome === 'allowed-once' ? { ...row.logical, state: 'consumed' } : row.logical,
100
+ effect: result.isError ? 'failed' : 'settled' })
101
+ }
102
+ catch (error) { this.callbacks.failed(exec.agent, error) }
103
+ this.#requests.delete(this.#callKey(exec.agent.id, exec.callId))
104
+ }
105
+ this.#calls.get(exec.agent)?.delete(exec.callId)
106
+ }),
107
+ ]
108
+ return () => { for (const dispose of disposers.reverse()) dispose(); this.#calls.clear(); this.#requests.clear() }
109
+ }
110
+
111
+ /** Caller must first stop/release both native Agents and inspect uncertain effects. */
112
+ reconcile(taskId: TaskId, evidence: string): void {
113
+ if (!evidence) throw new Error('Approval recovery requires inspection evidence')
114
+ for (const key of this.store.listDocumentIds(`native-approval:${taskId}:`)) {
115
+ const row = this.#read(key)
116
+ if (row.state !== 'pending') continue
117
+ const agent = this.ctx.agents.get(NativeSessionId(row.agentId))
118
+ if (agent && agent.status !== 'idle' || [...this.#requests.values()].includes(key)) {
119
+ throw new Error('The native approval is still live; settle it before recovery')
120
+ }
121
+ if (row.processId !== process.pid) {
122
+ try { process.kill(row.processId, 0); throw new Error('The recorded approval process is still live') }
123
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error }
124
+ }
125
+ // This withdraws the plugin's logical wait after explicit inspection.
126
+ // It does not fabricate a native decision or execute the interrupted call.
127
+ if (this.callbacks.state(taskId).pendingApprovalIds.includes(row.logical.id)) {
128
+ this.callbacks.answered(taskId, row.logical.id, 'withdrawn')
129
+ }
130
+ this.#write(key, { ...row, logical: { ...row.logical, state: 'withdrawn' }, state: 'withdrawn-after-inspection', inspectionEvidence: evidence })
131
+ }
132
+ }
133
+
134
+ #asked(agent: Agent, owner: Owner, asked: { id: string; toolName: string; callId?: string; reason?: string }, seq: number): void {
135
+ const exec = asked.callId && this.#calls.get(agent)?.get(asked.callId)
136
+ if (!exec || exec.name !== asked.toolName) throw new Error('Native approval has no matching immutable tool execution')
137
+ const key = `native-approval:${owner.binding.taskId}:${asked.id}`
138
+ if (this.store.readDocument(key)) throw new Error('Native approval identity was reused')
139
+ const scope = this.#scope(agent, owner, exec), state = this.callbacks.state(owner.binding.taskId)
140
+ const previousRequest = this.store.listDocumentIds(`native-approval:${owner.binding.taskId}:`).find(id => {
141
+ const prior = this.#read(id)
142
+ return prior.state === 'withdrawn-after-inspection' && !prior.reissuedAs && prior.scopeDigest === scope.digest
143
+ })
144
+ const prior = previousRequest && this.#read(previousRequest)
145
+ const logical: LogicalApproval = {
146
+ id: prior ? prior.logical.id : `native:${asked.id}`, taskId: owner.binding.taskId, revision: state.revision,
147
+ operationId: prior ? prior.logical.operationId : state.currentWorkOrder?.operationId ?? OperationId(`native-tool:${agent.id}:${exec.callId}`),
148
+ nativeRequestId: asked.id, argsDigest: scope.argsDigest, snapshot: scope.snapshot.id,
149
+ permissionPolicyDigest: scope.policyDigest, state: 'pending',
150
+ requestingAgent: SessionId(agent.id), toolName: exec.name, callId: exec.callId, role: owner.role,
151
+ ...(asked.reason ? { reason: asked.reason } : {}),
152
+ }
153
+ const evidence = this.store.putArtifact(owner.binding.taskId, Buffer.from(JSON.stringify({
154
+ nativeRequestId: asked.id, toolName: exec.name, arguments: exec.arguments,
155
+ snapshot: scope.snapshot, policy: scope.policy,
156
+ })), 'application/vnd.dsh-fusion.approval-scope+json')
157
+ this.store.writeDocument(key, 0, { schemaVersion: 1, logical, agentId: agent.id, role: owner.role,
158
+ toolName: exec.name, callId: exec.callId, rootCallId: exec.rootCallId, askedSeq: seq,
159
+ askedAt: new Date().toISOString(), processId: process.pid, scopeDigest: scope.digest,
160
+ evidence, state: 'pending', ...(previousRequest ? { previousRequest } : {}) } satisfies ApprovalRecord)
161
+ this.callbacks.pending(owner.binding.taskId, logical)
162
+ if (prior && previousRequest) this.#write(previousRequest, { ...prior, reissuedAs: key })
163
+ this.#requests.set(this.#callKey(agent.id, exec.callId), key)
164
+ }
165
+
166
+ #decided(owner: Owner, nativeId: string, outcome: ApprovalOutcome, seq: number): void {
167
+ const key = `native-approval:${owner.binding.taskId}:${nativeId}`, row = this.#read(key)
168
+ if (row.state !== 'pending') throw new Error('Native approval already settled')
169
+ // Audit outcomes do not identify a human vs. automated answerer. Leave
170
+ // decisionBy unknown rather than attributing an auto-review to the user.
171
+ const state = outcome === 'allowed-once' ? 'approved' : outcome === 'cancelled' ? 'withdrawn' : 'rejected'
172
+ this.callbacks.answered(owner.binding.taskId, row.logical.id, state)
173
+ this.#write(key, { ...row, logical: { ...row.logical, state }, state: 'decided', nativeOutcome: outcome, decidedSeq: seq })
174
+ }
175
+
176
+ #scope(agent: Agent, owner: Owner, exec: Readonly<ToolExecution>) {
177
+ const state = this.callbacks.state(owner.binding.taskId), service = agent.ctx.get('approval') ?? this.ctx.get('approval')
178
+ if (!service || !agent.session.header.cwd) throw new Error('Native approval policy or workspace is unavailable')
179
+ const snapshot = snapshotWorkspace(agent.session.header.cwd)
180
+ const policy = { scope: 'native-approval-and-frozen-fusion-policy',
181
+ nativeApproval: service.overrideOf(agent.session) ?? service.config.policy ?? 'ask',
182
+ profileDigest: owner.binding.profile.digest, allowedPaths: state.currentWorkOrder?.allowedPaths ?? null }
183
+ const argsDigest = digestOf({ tool: exec.name, arguments: exec.arguments }), policyDigest = digestOf(policy)
184
+ return { snapshot, policy, argsDigest, policyDigest,
185
+ digest: digestOf({ taskId: state.taskId, revision: state.revision, agentId: agent.id,
186
+ role: owner.role, argsDigest, snapshot: snapshot.id, policyDigest }) }
187
+ }
188
+
189
+ #callKey(agent: string, call: string): string { return JSON.stringify([agent, call]) }
190
+ #read(key: string): ApprovalRecord {
191
+ const row = this.store.readDocument(key)?.value as ApprovalRecord | undefined
192
+ if (!row || row.schemaVersion !== 1 || !row.logical || !row.scopeDigest) throw new Error('Native approval journal requires reconciliation')
193
+ return row
194
+ }
195
+ #write(key: string, row: ApprovalRecord): void { this.store.writeDocument(key, this.store.readDocument(key)!.revision, row) }
196
+ }
@@ -0,0 +1,19 @@
1
+ import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
2
+ import type { Digest, Role, TaskId } from '../contracts.js'
3
+
4
+ export interface AuxiliaryRequest {
5
+ purpose: 'cache-keepalive'
6
+ taskId: TaskId
7
+ sessionId: string
8
+ profileDigest: Digest
9
+ role: Role
10
+ seriesId: string
11
+ iteration: number
12
+ }
13
+
14
+ /** Plugin-local identity: do not widen or misuse the Host's purpose enum. */
15
+ export class NativeAuxiliaryRequests {
16
+ readonly #requests = new WeakMap<GenerateOptions, Readonly<AuxiliaryRequest>>()
17
+ tag(request: GenerateOptions, metadata: AuxiliaryRequest): void { this.#requests.set(request, Object.freeze(metadata)) }
18
+ get(request: GenerateOptions): Readonly<AuxiliaryRequest> | undefined { return this.#requests.get(request) }
19
+ }