@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,168 @@
1
+ import type { Role } from '../contracts.js'
2
+ import type { SqliteFusionStore } from '../task/sqlite-store.js'
3
+ import { cacheDefaults, type CacheDefaults, type CacheMode } from './cache-defaults.js'
4
+
5
+ /** The user's choice for one physical model route; absent fields fall back to learned/default values. */
6
+ export interface ModelCacheSetting { mode?: CacheMode; intervalSeconds?: number }
7
+
8
+ /** One model request that followed a wait: the evidence the policy learns from and the settings page shows. */
9
+ export interface CacheSample { at: string; gapSeconds: number; ping: boolean; hit: boolean; cacheRead: number; input: number; output?: number }
10
+
11
+ export interface ModelCacheStats {
12
+ schemaVersion: 1
13
+ provider: string
14
+ model: string
15
+ samples: CacheSample[]
16
+ totals: {
17
+ /** Real requests after a wait of at least WAIT_SECONDS, and how many still found their prefix cached. */
18
+ waits: number; waitHits: number
19
+ /** Keepalive pings and the tokens they read (mostly at the cache price). */
20
+ pings: number; pingHits: number; pingTokens: number
21
+ /** Output tokens of pings; absent on stats recorded before 0.2.3. A route that ignores the output cap shows here. */
22
+ pingOutputTokens?: number
23
+ /** Prefix tokens served from cache after a wait, and tokens resent uncached after a wait. */
24
+ keptWarmTokens: number; resentTokens: number
25
+ }
26
+ /** Interval shortened after pings kept missing (never lengthened automatically). */
27
+ learnedIntervalSeconds?: number
28
+ /** Rule that produced learnedIntervalSeconds; values from older rules are ignored. */
29
+ learnedRule?: number
30
+ updatedAt: string
31
+ }
32
+
33
+ export interface EffectiveCachePolicy {
34
+ mode: CacheMode
35
+ intervalSeconds: number
36
+ modeSource: 'user' | 'pair' | 'role' | CacheDefaults['source']
37
+ intervalSource: 'user' | 'learned' | CacheDefaults['source']
38
+ defaults: CacheDefaults
39
+ }
40
+
41
+ /** A request counts as a wait when this long passed since the previous request of the same agent. */
42
+ export const WAIT_SECONDS = 120
43
+ /**
44
+ * Rule 2 (0.2.3): shorten only when most recent pings at this interval missed (3 of the last up to 5).
45
+ * Rule 1 shortened on 2 misses, and providers also drop single prefixes at random: the live log had
46
+ * 4 partial misses in 123 pings, each keeping only the shared 3,968-token system+tools head, while
47
+ * a probe of the same route still hit after 10+ idle minutes. That noise cut a 285 s interval to 214 s.
48
+ */
49
+ export const LEARNING_RULE = 2
50
+ const SAMPLE_LIMIT = 60
51
+ const MIN_INTERVAL = 60, MAX_INTERVAL = 3_540
52
+
53
+ /** A request reused its prefix when most of its input came from the cache. */
54
+ export const cacheHit = (cacheRead: number, input: number) => cacheRead > 0 && cacheRead / (cacheRead + input) >= 0.5
55
+
56
+ const key = (provider: string, model: string) => `${provider}\u0001${model}`
57
+ export const clampInterval = (seconds: number) => Math.min(MAX_INTERVAL, Math.max(MIN_INTERVAL, Math.round(seconds)))
58
+
59
+ export class CachePolicy {
60
+ constructor(readonly store: SqliteFusionStore) {}
61
+
62
+ setting(provider: string, model: string): ModelCacheSetting | undefined {
63
+ return this.store.readDocument(`cache-setting:${key(provider, model)}`)?.value as ModelCacheSetting | undefined
64
+ }
65
+
66
+ /** Save or clear (null) the user's choice; invalid values are refused, not coerced. */
67
+ save(provider: string, model: string, setting: ModelCacheSetting | null): void {
68
+ const id = `cache-setting:${key(provider, model)}`, prior = this.store.readDocument(id)
69
+ if (setting === null) { if (prior) this.store.writeDocument(id, prior.revision, {}); return }
70
+ if (setting.mode !== undefined && !['auto', 'on', 'off'].includes(setting.mode)) throw new Error('Keepalive mode must be auto, on or off')
71
+ if (setting.intervalSeconds !== undefined && (!Number.isFinite(setting.intervalSeconds)
72
+ || setting.intervalSeconds < MIN_INTERVAL || setting.intervalSeconds > MAX_INTERVAL)) {
73
+ throw new Error(`Keepalive interval must be ${MIN_INTERVAL}–${MAX_INTERVAL} seconds`)
74
+ }
75
+ this.store.writeDocument(id, prior?.revision ?? 0, { ...(setting.mode ? { mode: setting.mode } : {}),
76
+ ...(setting.intervalSeconds ? { intervalSeconds: Math.round(setting.intervalSeconds) } : {}) })
77
+ }
78
+
79
+ stats(provider: string, model: string): ModelCacheStats | undefined {
80
+ return this.store.readDocument(`cache-stats:${key(provider, model)}`)?.value as ModelCacheStats | undefined
81
+ }
82
+
83
+ /**
84
+ * Record a request that followed a wait (or any keepalive ping). Pings that keep missing at the current
85
+ * interval shorten it by a quarter: a too-long interval wastes every ping, a too-short one only costs extra
86
+ * cheap reads, so the rule only ever moves toward safety. Lengthening is left to the user (see suggestion).
87
+ */
88
+ observe(provider: string, model: string, sample: Omit<CacheSample, 'at'>, currentIntervalSeconds: number): void {
89
+ if (!sample.ping && sample.gapSeconds < WAIT_SECONDS) return
90
+ const id = `cache-stats:${key(provider, model)}`, prior = this.store.readDocument(id)
91
+ const stats: ModelCacheStats = (prior?.value as ModelCacheStats | undefined) ?? { schemaVersion: 1, provider, model, samples: [],
92
+ totals: { waits: 0, waitHits: 0, pings: 0, pingHits: 0, pingTokens: 0, keptWarmTokens: 0, resentTokens: 0 }, updatedAt: '' }
93
+ const row: CacheSample = { at: new Date().toISOString(), ...sample }
94
+ stats.samples = [...stats.samples, row].slice(-SAMPLE_LIMIT)
95
+ const totals = stats.totals
96
+ if (row.ping) {
97
+ totals.pings++; totals.pingTokens += row.cacheRead + row.input
98
+ totals.pingOutputTokens = (totals.pingOutputTokens ?? 0) + (row.output ?? 0)
99
+ if (row.hit) totals.pingHits++
100
+ const recent = stats.samples.filter(item => item.ping && Math.abs(item.gapSeconds - currentIntervalSeconds) <= currentIntervalSeconds * 0.25).slice(-5)
101
+ if (recent.filter(item => !item.hit).length >= 3) {
102
+ stats.learnedIntervalSeconds = clampInterval(currentIntervalSeconds * 0.75); stats.learnedRule = LEARNING_RULE
103
+ stats.samples = stats.samples.filter(item => !item.ping) // judge the new interval on fresh pings
104
+ }
105
+ } else {
106
+ totals.waits++
107
+ if (row.hit) { totals.waitHits++; totals.keptWarmTokens += row.cacheRead } else totals.resentTokens += row.input
108
+ }
109
+ stats.updatedAt = row.at
110
+ this.store.writeDocument(id, prior?.revision ?? 0, stats)
111
+ }
112
+
113
+ /**
114
+ * User setting > pair profile (older explicit choice) > learned interval > our own route's billing default >
115
+ * model family default > generic. The Worker defaults to off: it rarely waits long enough to need pings.
116
+ */
117
+ resolve(provider: string, model: string, role: Role, pairChoice?: boolean | 'auto'): EffectiveCachePolicy {
118
+ const defaults = cacheDefaults(provider, model)
119
+ const user = this.setting(provider, model) ?? {}
120
+ const stats = this.stats(provider, model)
121
+ const learned = stats?.learnedRule === LEARNING_RULE ? stats.learnedIntervalSeconds : undefined
122
+ let mode: CacheMode, modeSource: EffectiveCachePolicy['modeSource']
123
+ if (user.mode) { mode = user.mode; modeSource = 'user' }
124
+ else if (typeof pairChoice === 'boolean') { mode = pairChoice ? 'on' : 'off'; modeSource = 'pair' }
125
+ else if (role === 'worker') { mode = 'off'; modeSource = 'role' }
126
+ else { mode = defaults.keepalive; modeSource = defaults.source }
127
+ const intervalSeconds = user.intervalSeconds ?? learned ?? defaults.intervalSeconds
128
+ const intervalSource: EffectiveCachePolicy['intervalSource'] = user.intervalSeconds ? 'user' : learned ? 'learned' : defaults.source
129
+ return { mode, intervalSeconds, modeSource, intervalSource, defaults }
130
+ }
131
+
132
+ /** Suggest a longer interval only on repeated evidence: real waits that still hit well beyond the interval. */
133
+ suggestion(provider: string, model: string, intervalSeconds: number): number | undefined {
134
+ const long = (this.stats(provider, model)?.samples ?? []).filter(item => !item.ping && item.hit && item.gapSeconds >= intervalSeconds * 1.5)
135
+ if (long.length < 3) return undefined
136
+ return clampInterval(Math.floor(Math.min(...long.map(item => item.gapSeconds)) / 30) * 30)
137
+ }
138
+
139
+ /** Every route the plugin has evidence or a setting for, plus the given routes (the configured pair). */
140
+ routes(extra: readonly { provider: string; model: string }[] = []): { provider: string; model: string }[] {
141
+ const ids = [...this.store.listDocumentIds('cache-stats:'), ...this.store.listDocumentIds('cache-setting:')]
142
+ .map(id => id.slice(id.indexOf(':') + 1).split('\u0001') as [string, string])
143
+ const all = [...extra.map(item => [item.provider, item.model] as [string, string]), ...ids]
144
+ const seen = new Set<string>()
145
+ return all.filter(([provider, model]) => provider && model && !seen.has(key(provider, model)) && seen.add(key(provider, model)))
146
+ .map(([provider, model]) => ({ provider, model }))
147
+ }
148
+ }
149
+
150
+ type Route = { provider: string; model: string }
151
+ /** One row of the settings page: what applies, why, what the defaults say, and what was observed. */
152
+ export function cacheView(policy: CachePolicy, pair?: { lead: Route; worker: Route }, pairChoice?: { lead?: boolean | 'auto'; worker?: boolean | 'auto' }) {
153
+ const same = (a: Route | undefined, provider: string, model: string) => a?.provider === provider && a.model === model
154
+ return policy.routes(pair ? [pair.lead, pair.worker] : []).map(({ provider, model }) => {
155
+ const role: Role | undefined = same(pair?.lead, provider, model) ? 'lead' : same(pair?.worker, provider, model) ? 'worker' : undefined
156
+ const effective = policy.resolve(provider, model, role ?? 'lead', role ? pairChoice?.[role] : undefined)
157
+ const stats = policy.stats(provider, model), { family, route } = effective.defaults
158
+ return { provider, model, role,
159
+ mode: effective.mode, intervalSeconds: effective.intervalSeconds, modeSource: effective.modeSource, intervalSource: effective.intervalSource,
160
+ setting: policy.setting(provider, model) ?? {},
161
+ defaults: { mode: effective.defaults.keepalive, intervalSeconds: effective.defaults.intervalSeconds, source: effective.defaults.source,
162
+ ...(family ? { family: { label: family.label, lifetime: family.lifetime, discount: family.discount, docs: family.docs } } : {}),
163
+ ...(route ? { route: { label: route.label, reason: route.reason } } : {}) },
164
+ totals: stats?.totals ?? null, learnedIntervalSeconds: stats?.learnedRule === LEARNING_RULE ? stats.learnedIntervalSeconds ?? null : null,
165
+ suggestion: policy.suggestion(provider, model, effective.intervalSeconds) ?? null,
166
+ recent: (stats?.samples ?? []).slice(-8).map(item => ({ gapSeconds: Math.round(item.gapSeconds), ping: item.ping, hit: item.hit })) }
167
+ })
168
+ }
@@ -0,0 +1,156 @@
1
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, readlinkSync, realpathSync } from 'node:fs'
2
+ import { basename, dirname, join } from 'node:path'
3
+ import type { ArtifactRef, SnapshotId, TaskId } from '../contracts.js'
4
+ import { sha256Hex } from '../digest.js'
5
+ import type { SqliteFusionStore } from '../task/sqlite-store.js'
6
+ import { evidenceText } from './evidence-page.js'
7
+ import { changedPaths, pathAllowed, workspacePath } from './workspace.js'
8
+ import type { WorkspaceEntry, WorkspaceSnapshot } from './workspace.js'
9
+
10
+ export interface ChangeBase {
11
+ schemaVersion: 1
12
+ snapshot: SnapshotId
13
+ contents: Record<string, ArtifactRef>
14
+ /**
15
+ * Paths the Lead widened the scope to only after they had already changed, so their
16
+ * delegation-time bytes are gone. Diffs carry no pre-image for them and the
17
+ * rewritten-test gate cannot compare original lines; the review must say so instead of
18
+ * pretending the file was untouched (`session-daa71a4a`: refusing to record them at all
19
+ * left the task with no admissible report and no admissible widening).
20
+ */
21
+ unavailable?: readonly string[]
22
+ }
23
+
24
+ /** Read only the exact leaf described by the snapshot, never its symlink target. */
25
+ function readEntry(snapshot: WorkspaceSnapshot, entry: WorkspaceEntry): Buffer {
26
+ if (realpathSync.native(snapshot.root) !== snapshot.root) throw new Error('Evidence workspace root changed')
27
+ const path = join(workspacePath(snapshot.root, dirname(entry.path)), basename(entry.path))
28
+ const before = lstatSync(path)
29
+ let bytes: Buffer
30
+ if (entry.kind === 'symlink') {
31
+ if (!before.isSymbolicLink()) throw new Error('Evidence file kind changed')
32
+ bytes = readlinkSync(path, { encoding: 'buffer' })
33
+ } else {
34
+ if (!before.isFile() || before.size > 16 * 1024 * 1024) throw new Error('Evidence file is not a bounded regular source file')
35
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW)
36
+ try {
37
+ const opened = fstatSync(fd)
38
+ if (!opened.isFile() || opened.ino !== before.ino || opened.dev !== before.dev || opened.size !== before.size) throw new Error('Evidence source changed while opening')
39
+ bytes = Buffer.alloc(opened.size)
40
+ let offset = 0
41
+ while (offset < bytes.length) {
42
+ const count = readSync(fd, bytes, offset, bytes.length - offset, offset)
43
+ if (!count) throw new Error('Evidence source shrank while reading')
44
+ offset += count
45
+ }
46
+ if (readSync(fd, Buffer.alloc(1), 0, 1, offset)) throw new Error('Evidence source grew while reading')
47
+ const after = fstatSync(fd)
48
+ if (after.size !== bytes.length || after.mtimeMs !== opened.mtimeMs) throw new Error('Evidence source changed while reading')
49
+ } finally { closeSync(fd) }
50
+ }
51
+ const after = lstatSync(path)
52
+ if (after.ino !== before.ino || after.dev !== before.dev || after.mtimeMs !== before.mtimeMs
53
+ || (entry.kind === 'file' && Boolean(after.mode & 0o111) !== entry.executable)
54
+ || sha256Hex(bytes) !== entry.digest) throw new Error('Evidence bytes do not match the frozen snapshot')
55
+ // Recheck parents after reading; a replacement must never become trusted evidence.
56
+ workspacePath(snapshot.root, dirname(entry.path))
57
+ if (realpathSync.native(snapshot.root) !== snapshot.root) throw new Error('Evidence workspace root changed')
58
+ return bytes
59
+ }
60
+
61
+ export function captureChangeBase(store: SqliteFusionStore, taskId: TaskId, base: WorkspaceSnapshot, allowed: readonly string[]): ChangeBase {
62
+ const entries = base.entries.filter(entry => pathAllowed(entry.path, allowed))
63
+ const refs = store.putArtifacts(taskId, entries.map(entry => ({ bytes: readEntry(base, entry), mediaType: 'application/octet-stream' })))
64
+ return { schemaVersion: 1, snapshot: base.id, contents: Object.fromEntries(entries.map((entry, i) => [entry.path, refs[i]])) }
65
+ }
66
+
67
+ /**
68
+ * The same base bytes, for paths the Lead adds to the frozen scope after a report. A file the
69
+ * Worker (or the task's own build command) already changed no longer matches the snapshot, so
70
+ * its delegation-time bytes cannot be recorded: that path is reported as unavailable instead of
71
+ * failing, which would leave the task with no admissible report and no admissible widening.
72
+ */
73
+ export function captureLaterChangeBase(store: SqliteFusionStore, taskId: TaskId, base: WorkspaceSnapshot, allowed: readonly string[]): ChangeBase {
74
+ const readable: { path: string; bytes: Buffer }[] = [], unavailable: string[] = []
75
+ for (const entry of base.entries.filter(entry => pathAllowed(entry.path, allowed))) {
76
+ try { readable.push({ path: entry.path, bytes: readEntry(base, entry) }) } catch { unavailable.push(entry.path) }
77
+ }
78
+ const refs = store.putArtifacts(taskId, readable.map(entry => ({ bytes: entry.bytes, mediaType: 'application/octet-stream' })))
79
+ return { schemaVersion: 1, snapshot: base.id, contents: Object.fromEntries(readable.map((entry, i) => [entry.path, refs[i]])),
80
+ ...(unavailable.length ? { unavailable } : {}) }
81
+ }
82
+
83
+ function quotePath(path: string): string {
84
+ const bytes = Buffer.from(path)
85
+ if (bytes.every(byte => byte > 32 && byte < 127 && byte !== 34 && byte !== 92)) return path
86
+ return '"' + Array.from(bytes, byte => byte === 34 || byte === 92 ? '\\' + String.fromCharCode(byte)
87
+ : byte >= 32 && byte < 127 ? String.fromCharCode(byte) : '\\' + byte.toString(8).padStart(3, '0')).join('') + '"'
88
+ }
89
+ const mode = (entry: WorkspaceEntry) => entry.kind === 'symlink' ? '120000' : entry.executable ? '100755' : '100644'
90
+
91
+ /** A full, applicable single hunk, trimmed only at unchanged leading/trailing lines. */
92
+ function patch(path: string, before: WorkspaceEntry | undefined, after: WorkspaceEntry | undefined, oldText: string, newText: string): string {
93
+ const a = quotePath(`a/${path}`), b = quotePath(`b/${path}`)
94
+ let output = `diff --git ${a} ${b}\n`
95
+ if (!before) output += `new file mode ${mode(after!)}\n`
96
+ else if (!after) output += `deleted file mode ${mode(before)}\n`
97
+ else if (mode(before) !== mode(after)) output += `old mode ${mode(before)}\nnew mode ${mode(after)}\n`
98
+ if (oldText === newText) return output
99
+ const oldLines = oldText.match(/[^\n]*\n|[^\n]+$/g) ?? [], newLines = newText.match(/[^\n]*\n|[^\n]+$/g) ?? []
100
+ let prefix = 0, suffix = 0
101
+ while (prefix < Math.min(oldLines.length, newLines.length) && oldLines[prefix] === newLines[prefix]) prefix++
102
+ while (suffix < Math.min(oldLines.length, newLines.length) - prefix && oldLines[oldLines.length - suffix - 1] === newLines[newLines.length - suffix - 1]) suffix++
103
+ const start = Math.max(0, prefix - 3), oldEnd = oldLines.length - Math.max(0, suffix - 3), newEnd = newLines.length - Math.max(0, suffix - 3)
104
+ const line = (tag: string, value: string) => tag + value + (value.endsWith('\n') ? '' : '\n\\n')
105
+ output += `--- ${before ? a : '/dev/null'}\n+++ ${after ? b : '/dev/null'}\n@@ -${oldEnd === start ? 0 : start + 1},${oldEnd - start} +${newEnd === start ? 0 : start + 1},${newEnd - start} @@\n`
106
+ for (let i = start; i < prefix; i++) output += line(' ', oldLines[i])
107
+ for (let i = prefix; i < oldLines.length - suffix; i++) output += line('-', oldLines[i])
108
+ for (let i = prefix; i < newLines.length - suffix; i++) output += line('+', newLines[i])
109
+ for (let i = oldLines.length - suffix; i < oldEnd; i++) output += line(' ', oldLines[i])
110
+ return output
111
+ }
112
+
113
+ export interface ChangeDiff {
114
+ path: string
115
+ status: 'text' | 'binary' | 'type-change' | 'unavailable-base'
116
+ before?: { entry: WorkspaceEntry; content?: ArtifactRef }
117
+ after?: { entry: WorkspaceEntry; content: ArtifactRef }
118
+ patch?: ArtifactRef
119
+ }
120
+
121
+ export function saveChangeManifest(store: SqliteFusionStore, taskId: TaskId, base: WorkspaceSnapshot, candidate: WorkspaceSnapshot, captured?: ChangeBase): ArtifactRef {
122
+ if (captured && (captured.schemaVersion !== 1 || captured.snapshot !== base.id)) throw new Error('Change evidence belongs to another base snapshot')
123
+ const changes = changedPaths(base, candidate)
124
+ const old = new Map(base.entries.map(entry => [entry.path, entry])), now = new Map(candidate.entries.map(entry => [entry.path, entry]))
125
+ const baseRefs = changes.flatMap(path => {
126
+ const ref = old.has(path) && captured && Object.hasOwn(captured.contents, path) ? captured.contents[path] : undefined
127
+ return ref ? [ref] : []
128
+ })
129
+ const baseBytes = new Map(store.readArtifacts(taskId, baseRefs.map(ref => ref.id)).map((bytes, i) => [baseRefs[i].id, bytes]))
130
+ const pending: { bytes: Uint8Array; mediaType: string }[] = []
131
+ const prepared = changes.map(path => {
132
+ const before = old.get(path), after = now.get(path)
133
+ const beforeRef = before && captured && Object.hasOwn(captured.contents, path) ? captured.contents[path] : undefined
134
+ const oldBytes = beforeRef ? baseBytes.get(beforeRef.id) : undefined
135
+ if (beforeRef && (!before || beforeRef.digest !== before.digest || sha256Hex(oldBytes!) !== before.digest)) throw new Error('Change evidence does not match the base file')
136
+ // A path the Lead widened the scope to after it had already changed has no recordable
137
+ // pre-image; it is reported as `unavailable-base` instead of blocking every report.
138
+ if (before && captured && !beforeRef && !captured.unavailable?.includes(path)) throw new Error('Frozen change evidence is missing the base file')
139
+ const newBytes = after ? readEntry(candidate, after) : undefined
140
+ const afterIndex = pending.length
141
+ if (newBytes) pending.push({ bytes: newBytes, mediaType: 'application/octet-stream' })
142
+ const oldText = oldBytes ? evidenceText(oldBytes) : '', newText = newBytes ? evidenceText(newBytes) : ''
143
+ const status: ChangeDiff['status'] = before && !beforeRef ? 'unavailable-base'
144
+ : before && after && before.kind !== after.kind ? 'type-change'
145
+ : oldText === undefined || newText === undefined ? 'binary' : 'text'
146
+ const patchIndex = pending.length
147
+ if (status === 'text') pending.push({ bytes: Buffer.from(patch(path, before, after, oldText!, newText!)), mediaType: 'text/x-diff' })
148
+ return { path, status, before, beforeRef, after, afterIndex, patchIndex }
149
+ })
150
+ const refs = store.putArtifacts(taskId, pending)
151
+ const diffs: ChangeDiff[] = prepared.map(item => ({ path: item.path, status: item.status,
152
+ ...(item.before ? { before: { entry: item.before, ...(item.beforeRef ? { content: item.beforeRef } : {}) } } : {}),
153
+ ...(item.after ? { after: { entry: item.after, content: refs[item.afterIndex] } } : {}),
154
+ ...(item.status === 'text' ? { patch: refs[item.patchIndex] } : {}) }))
155
+ return store.putArtifact(taskId, Buffer.from(JSON.stringify({ schemaVersion: 2, base: base.id, changes, diffs, candidate })), 'application/vnd.dsh-fusion.change-manifest+json')
156
+ }
@@ -0,0 +1,46 @@
1
+ import type { Context } from '@deepseek-ai/cordis'
2
+ import type { Agent } from '@deepseek-ai/dsh-agent'
3
+ import type { PhysicalRoute, Role } from '../contracts.js'
4
+ import { digestOf } from '../digest.js'
5
+ import type { SessionBinding } from './bindings.js'
6
+
7
+ /** Structural v1 optional contract exported by dsh-compact-saviour. */
8
+ interface RouteSnapshot {
9
+ revision: string
10
+ context: PhysicalRoute
11
+ target: PhysicalRoute
12
+ maxOutputTokens?: number
13
+ }
14
+ interface Registry {
15
+ apiVersion: number
16
+ register(owner: string, resolve: (agent: Agent) => RouteSnapshot | undefined): () => void
17
+ }
18
+ interface Owner { binding: SessionBinding; role: Role }
19
+
20
+ /** Publish exact session routes before another plugin estimates or chunks input. */
21
+ export function installCompactionRouting(ctx: Context, input: {
22
+ owner(agent: Agent): Owner | undefined
23
+ route(binding: SessionBinding, role: Role): PhysicalRoute
24
+ epoch(binding: SessionBinding): number
25
+ }): () => void {
26
+ let active = true
27
+ const fiber = ctx.inject(['compactionRoutes'], child => {
28
+ const registry = child.get('compactionRoutes') as Registry
29
+ if (registry.apiVersion !== 1 || typeof registry.register !== 'function')
30
+ throw new Error('Unsupported public compaction routing contract')
31
+ child.effect(() => registry.register('dsh-fusion', agent => {
32
+ if (!active) return undefined
33
+ const owner = input.owner(agent)
34
+ if (!owner) return undefined
35
+ const { binding, role } = owner
36
+ const context = input.route(binding, role)
37
+ const target = binding.profile.compactor?.route ?? context
38
+ const maxOutputTokens = binding.profile.compactor?.maxOutputTokens
39
+ return { context: { ...context }, target: { ...target },
40
+ ...(maxOutputTokens === undefined ? {} : { maxOutputTokens }),
41
+ revision: digestOf({ session: agent.id, task: binding.taskId, profile: binding.profile.digest,
42
+ role, epoch: input.epoch(binding), context, target, maxOutputTokens: maxOutputTokens ?? null }) }
43
+ }))
44
+ })
45
+ return () => { active = false; void fiber.dispose() }
46
+ }