@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,56 @@
1
+ /** A model-facing tool, identified by the name the request will advertise. */
2
+ export interface NamedTool { name: string }
3
+
4
+ export interface ProjectedTools<T extends NamedTool> {
5
+ tools: T[]
6
+ /** Fusion tools this role must see that neither the assembled table nor the registry still has. */
7
+ missing: string[]
8
+ }
9
+
10
+ /**
11
+ * Rebuild one role's request tool table after another plugin replaces `assembled.tools`.
12
+ *
13
+ * The allowlist is a capability ceiling, not a list of mandatory registrations.
14
+ * It filters the assembled table, then permitted names are copied back from this
15
+ * agent's own registry: allowlisted tools (such as `job_output`) and Fusion's own
16
+ * `fusion_*` registrations. An undefined allowlist keeps the assembled table and only
17
+ * unions Fusion's registrations, so a writing role is not narrowed.
18
+ *
19
+ * A Fusion tool is required only when this agent actually registered it and the role
20
+ * may see it. `fusion_read_state` and `fusion_delegate_text` are registered only for
21
+ * model-like profiles; a legacy allowlist that merely names them is not incomplete.
22
+ * Restore copies every permitted registry tool before the gap is measured, so `missing`
23
+ * stays empty for every table this function itself builds. The field remains so a later
24
+ * change that stops restoring a registered tool fails closed instead of sending a
25
+ * silently smaller catalog.
26
+ */
27
+ export function projectRoleTools<T extends NamedTool>(input: {
28
+ assembled: readonly T[]
29
+ allowlist: readonly string[] | undefined
30
+ registry: readonly T[]
31
+ /** Names this role keeps even when the allowlist omits them. */
32
+ always?: readonly string[]
33
+ }): ProjectedTools<T> {
34
+ const always = input.always ?? []
35
+ const permitted = (name: string) => input.allowlist === undefined || input.allowlist.includes(name) || always.includes(name)
36
+ const tools = input.allowlist === undefined ? [...input.assembled] : input.assembled.filter(tool => permitted(tool.name))
37
+ const present = new Set(tools.map(tool => tool.name))
38
+ const owned = input.registry.filter(tool => tool.name.startsWith('fusion_'))
39
+ const registered = new Set(input.registry.map(tool => tool.name))
40
+ const restore = input.allowlist === undefined
41
+ ? owned
42
+ : input.registry.filter(tool => permitted(tool.name))
43
+ for (const tool of restore) {
44
+ if (present.has(tool.name)) continue
45
+ tools.push(tool)
46
+ present.add(tool.name)
47
+ }
48
+ const candidates = input.allowlist === undefined ? owned.map(tool => tool.name) : [...input.allowlist, ...always]
49
+ const required = [...new Set(candidates)].filter(name => name.startsWith('fusion_') && registered.has(name))
50
+ return { tools, missing: required.filter(name => !present.has(name)) }
51
+ }
52
+
53
+ /** Shown when the role's Fusion tools cannot be restored. The request must not proceed as a normal completion. */
54
+ export function fusionToolDiagnostic(missing: readonly string[]): string {
55
+ return `FUSION_TOOL_TABLE_INCOMPLETE: required Fusion tools are absent from this request (${missing.join(', ')}). Another plugin replaced the tool table and Fusion could not restore them from its own registry. Do not tell the user that delegation is unavailable by design, and do not end the task as completed with no work. Report this diagnostic.`
56
+ }
@@ -0,0 +1,91 @@
1
+ /** What fusion_wait should do once the resident Worker handle is already gone. */
2
+ export function absentWorkerOutcome(input: { pendingEffects: number; outcomeUnknown: boolean; submitted: boolean }):
3
+ { kind: 'recovery'; reason: string } | { kind: 'collect-report' } | { kind: 'incomplete' } {
4
+ if (input.pendingEffects > 0 || input.outcomeUnknown) {
5
+ return { kind: 'recovery', reason: 'Worker is absent with unfinished native effects; reconcile its persisted session' }
6
+ }
7
+ return input.submitted ? { kind: 'collect-report' } : { kind: 'incomplete' }
8
+ }
9
+
10
+ /** Settlement notice text. It must match whether a report was persisted, and must not send the Lead back to a disposed Worker. */
11
+ export function workerSettlementGuidance(submitted: boolean): string {
12
+ if (submitted) {
13
+ return 'The Worker has ended and a structured report is already persisted. This notice is not task acceptance. Review the report and check evidence. Call fusion_wait only if that report has not been returned yet; it reads persisted state and does not restart the Worker.'
14
+ }
15
+ return 'The Worker has ended without a structured report. This notice is not task acceptance, and it is not a provider or quota outage. Call fusion_wait once: it returns the terminal status "worker ended without submitting a report" (WORKFLOW_INCOMPLETE) from persisted state and does not restart the Worker. Do not keep waiting.'
16
+ }
17
+
18
+ /**
19
+ * How to describe a Worker model-control wait to the Lead.
20
+ * WORKFLOW_INCOMPLETE and NO_PROGRESS are missing work, not an unavailable provider.
21
+ */
22
+ export function workerStopExplanation(code: string): { reasonCode: string; next: string } {
23
+ if (code === 'WORKFLOW_INCOMPLETE') return {
24
+ reasonCode: 'workflow-incomplete',
25
+ next: 'The required report or workflow transition is missing. This is not a provider outage or quota limit. Do not tell the user the Sidekick or its model is unavailable. Explain the unfinished report and await a user decision.',
26
+ }
27
+ if (code === 'NO_PROGRESS') return {
28
+ reasonCode: 'workflow-incomplete',
29
+ next: 'Repeated unchanged results made no progress. This is not a provider outage or quota limit. Change the approach; do not describe the Sidekick as unavailable.',
30
+ }
31
+ return {
32
+ reasonCode: 'worker-unavailable',
33
+ next: 'Explain which role is unavailable. Use local Fusion controls to continue or replace its model; do not retry delegation or take over automatically.',
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Why a quiescent Worker produced no report.
39
+ *
40
+ * A Worker that was offered a tool table but never dispatched one tool call of it
41
+ * did not "run out of steps" or lose work: its route could not call those tools at
42
+ * all. Adapters that drive their own execution loop (for example an external CLI
43
+ * agent) only surface tool calls when the request also carries their own transport
44
+ * tool, so `fusion_submit_result` stays unreachable even though this plugin did
45
+ * advertise it. That is a configuration mismatch the Lead must report accurately
46
+ * instead of retrying or blaming the model.
47
+ *
48
+ * Both counts are read from the Worker's own session log, so a text assignment —
49
+ * offered the Worker Fusion tools and nothing else — is judged the same way. An
50
+ * unreadable log yields no evidence and therefore keeps the generic verdict.
51
+ */
52
+ export function workerNoReportCause(input: { requestedTools: number; dispatchedToolCalls: number }): 'unreachable-tools' | 'no-report' {
53
+ return input.requestedTools > 0 && input.dispatchedToolCalls === 0 ? 'unreachable-tools' : 'no-report'
54
+ }
55
+
56
+ /**
57
+ * Whether the recorded unreachable route still speaks for the current order.
58
+ *
59
+ * The verdict is per work order: a new order must be judged on its own dispatch,
60
+ * so a replacement route is never refused on a predecessor's evidence. The task
61
+ * is only wedged while the same order that failed still owns the handoff.
62
+ */
63
+ export function workerUnreachableFor(order: { id?: string } | undefined,
64
+ record: { workOrderId?: string } | undefined): boolean {
65
+ return Boolean(order?.id && record && record.workOrderId === order.id)
66
+ }
67
+
68
+ /**
69
+ * The one wording for a route that cannot reach Host tools, shared by every entry
70
+ * point that can rediscover the verdict, so a later `fusion_wait` says the same
71
+ * thing as the delegation that first failed.
72
+ */
73
+ export function workerUnreachableReply(childId: string | undefined,
74
+ route: { provider: string; model: string }): string {
75
+ return JSON.stringify({
76
+ status: 'needs-decision',
77
+ reason: 'worker dispatched no tool call, so it could not reach fusion_submit_result',
78
+ reasonCode: 'worker-tools-unreachable', failureCode: 'WORKER_TOOLS_UNREACHABLE',
79
+ childId, workerRunning: false, provider: route.provider, model: route.model,
80
+ next: 'This is a route/adapter mismatch, not a provider outage, quota limit or a model capability. The Worker route ran its own agent loop and only surfaces a tool call when the request also carries that adapter\'s own transport tool, so the Fusion tools this Host advertised were unreachable. Fusion adds that transport (agy_tool for the antigravity/agy route) to the Worker ceiling automatically, so first check the route itself: mcpBridge on, permissionMode skip, and the bridge tool allowlist permitting fusion_submit_result. Do not retry fusion_rework, do not take over and do not claim completion. Tell the user which Worker route failed, and that it must be configured to reach Host tools or be replaced by a direct-call route in Settings → Fusion.',
81
+ })
82
+ }
83
+
84
+ /** Continue/switch is refused before the route is written. Say so when a switch was requested. */
85
+ export function unsettledContinuationError(switching: boolean): { zh: string; en: string } {
86
+ return switching
87
+ ? { zh: '中断的操作尚未确认结束,本次尚未切换模型。请先使用 /fusion recover <检查记录> 核对,再更换并继续。',
88
+ en: 'Interrupted operations are not confirmed finished, so this switch was not applied. Use /fusion recover <inspection note> first, then switch and continue.' }
89
+ : { zh: '中断的操作尚未确认结束,请先使用 /fusion recover 核对',
90
+ en: 'Interrupted operations are not confirmed finished; use /fusion recover first' }
91
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Workspace write mutual exclusion for agents Fusion owns.
3
+ * Ordinary sessions are not governed: a foreign lease does not make them read-only.
4
+ * `exempt` covers read tools, Fusion's own control tools, and `run_code`.
5
+ * The caller passes the lease for this call's cwd only, so another directory is isolated.
6
+ */
7
+ export function workspaceWriterDenial(input: {
8
+ fusionOwned: boolean
9
+ exempt: boolean
10
+ cwd?: string
11
+ leaseHolder?: string
12
+ agentId: string
13
+ }): string | undefined {
14
+ if (!input.fusionOwned || input.exempt) return undefined
15
+ if (!input.cwd || !input.leaseHolder || input.leaseHolder === input.agentId) return undefined
16
+ return `Workspace writer is ${input.leaseHolder}; this Agent is read-only`
17
+ }
@@ -0,0 +1,184 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import type { Stats } from 'node:fs'
3
+ import { lstatSync, readdirSync, readFileSync, readlinkSync, realpathSync } from 'node:fs'
4
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
5
+ import { SnapshotId } from '../contracts.js'
6
+ import { digestOf, sha256Hex } from '../digest.js'
7
+
8
+ // These directories are not source evidence. The manifest records the exclusion
9
+ // contract explicitly; callers must not describe this as a whole-disk snapshot.
10
+ export const SNAPSHOT_EXCLUSIONS = ['.git', 'node_modules', '__pycache__', '.pytest_cache', '.mypy_cache', '.ruff_cache'] as const
11
+ // Outside a git work tree there are no ignore rules to consult; also skip common environment and cache directories.
12
+ export const FALLBACK_EXCLUSIONS = [...SNAPSHOT_EXCLUSIONS, '.venv', 'venv', '.tox', '.nox', '.eggs', '.gradle', '.next', '.turbo', '.cache'] as const
13
+ export interface WorkspaceEntry { path: string; kind: 'file' | 'symlink'; digest: string; executable: boolean }
14
+ export interface WorkspaceSnapshot {
15
+ schemaVersion: 1
16
+ root: string
17
+ excludedDirectoryNames: readonly string[]
18
+ /** Present when the entry list came from git (tracked + untracked, minus the repository's ignore rules). */
19
+ ignoreRules?: 'git-exclude-standard'
20
+ entries: readonly WorkspaceEntry[]
21
+ id: SnapshotId
22
+ }
23
+
24
+ export function workspacePath(root: string, path: string): string {
25
+ if (!path || isAbsolute(path) || path.includes('\0')) throw new Error('Workspace paths must be nonempty relative paths')
26
+ const absolute = resolve(root, path)
27
+ const rel = relative(root, absolute)
28
+ if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) throw new Error('Path escapes the workspace')
29
+ // Never traverse a link when interpreting an allowed path or verifier input.
30
+ let current = root
31
+ for (const part of rel.split(sep).filter(Boolean)) {
32
+ current = join(current, part)
33
+ try { if (lstatSync(current).isSymbolicLink()) throw new Error('Workspace path traverses a symlink') }
34
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
35
+ }
36
+ return absolute
37
+ }
38
+
39
+ /**
40
+ * The root-relative form of a model-supplied path ('' for the root itself), or undefined outside the workspace.
41
+ * `root` is the canonical (realpath) workspace, but models write the session's own spelling of it: macOS
42
+ * `/tmp/x` for `/private/tmp/x`, a symlinked checkout, another drive-letter case. So an absolute path is
43
+ * matched by the filesystem identity of its ancestors, not by string. The shortest ancestor that is the root
44
+ * wins, so a link below the root is never followed here; workspacePath still refuses one.
45
+ */
46
+ export function workspaceRelative(root: string, path: string): string | undefined {
47
+ const absolute = resolve(root, path)
48
+ const ancestors = [absolute]
49
+ for (let parent = dirname(absolute); parent !== ancestors.at(-1); parent = dirname(parent)) ancestors.push(parent)
50
+ for (const candidate of ancestors.reverse()) {
51
+ let real: string
52
+ try { real = realpathSync.native(candidate) } catch { return undefined }
53
+ if (real === root) return relative(candidate, absolute)
54
+ }
55
+ return undefined
56
+ }
57
+
58
+ const MAX_ENTRIES = 200_000
59
+ const LARGE_FILE = 16 * 1024 * 1024
60
+
61
+ /**
62
+ * Source files git would consider (tracked plus untracked, minus the repository's ignore rules), relative
63
+ * to root; undefined outside a git work tree or without git. Ignore rules are the project's own statement
64
+ * of what is not source (virtualenvs, build output, caches), so no per-ecosystem list has to guess.
65
+ */
66
+ function gitSourceFiles(root: string): string[] | undefined {
67
+ try {
68
+ const out = execFileSync('git', ['-C', root, 'ls-files', '-z', '--cached', '--others', '--exclude-standard'],
69
+ { encoding: 'buffer', maxBuffer: 512 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'], env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' } })
70
+ return [...new Set(out.toString('utf8').split('\0').filter(Boolean))].sort()
71
+ } catch { return undefined }
72
+ }
73
+
74
+ export function snapshotWorkspace(cwd: string): WorkspaceSnapshot {
75
+ const root = realpathSync.native(cwd)
76
+ const entries: WorkspaceEntry[] = []
77
+ const excluded = (path: string) => path.split('/').some(part => (SNAPSHOT_EXCLUSIONS as readonly string[]).includes(part))
78
+ function add(full: string, path: string, stat: Stats): void {
79
+ if (stat.isSymbolicLink()) {
80
+ entries.push({ path, kind: 'symlink', digest: sha256Hex(readlinkSync(full)), executable: false })
81
+ } else if (stat.isFile()) {
82
+ // A very large file (dataset, binary asset) is fingerprinted by size and mtime instead of content.
83
+ const large = stat.size > LARGE_FILE
84
+ const contents = large ? undefined : readFileSync(full)
85
+ const after = lstatSync(full)
86
+ if (after.ino !== stat.ino || after.mtimeMs !== stat.mtimeMs || after.size !== (contents?.length ?? stat.size)) throw new Error('Workspace changed while taking the snapshot')
87
+ entries.push({ path, kind: 'file', digest: contents ? sha256Hex(contents) : sha256Hex(`large:${stat.size}:${stat.mtimeMs}`), executable: Boolean(stat.mode & 0o111) })
88
+ } else if (!stat.isDirectory()) { throw new Error(`Unsupported source file type: ${path}`) }
89
+ if (entries.length > MAX_ENTRIES) throw new Error('Workspace exceeds the bounded file manifest; choose a smaller project root')
90
+ }
91
+ function visit(directory: string): void {
92
+ for (const name of readdirSync(directory).sort()) {
93
+ const full = join(directory, name)
94
+ const stat = lstatSync(full)
95
+ if (stat.isDirectory()) { if (!(FALLBACK_EXCLUSIONS as readonly string[]).includes(name)) visit(full); continue }
96
+ add(full, relative(root, full).split(sep).join('/'), stat)
97
+ }
98
+ }
99
+ const listed = gitSourceFiles(root)
100
+ if (listed) {
101
+ for (const path of listed) {
102
+ if (excluded(path)) continue
103
+ const full = join(root, ...path.split('/'))
104
+ let stat: Stats
105
+ try { stat = lstatSync(full) } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue; throw error }
106
+ if (!stat.isDirectory()) add(full, path, stat) // a nested repository (gitlink) is not traversed
107
+ }
108
+ } else visit(root)
109
+ const manifest = { schemaVersion: 1 as const, root, excludedDirectoryNames: listed ? SNAPSHOT_EXCLUSIONS : FALLBACK_EXCLUSIONS,
110
+ ...(listed ? { ignoreRules: 'git-exclude-standard' as const } : {}), entries }
111
+ return { ...manifest, id: SnapshotId(`source:${digestOf(manifest)}`) }
112
+ }
113
+
114
+ export function changedPaths(before: WorkspaceSnapshot, after: WorkspaceSnapshot): string[] {
115
+ if (before.root !== after.root) throw new Error('Workspace root changed')
116
+ const old = new Map(before.entries.map(entry => [entry.path, digestOf(entry)]))
117
+ const now = new Map(after.entries.map(entry => [entry.path, digestOf(entry)]))
118
+ return [...new Set([...old.keys(), ...now.keys()])].filter(path => old.get(path) !== now.get(path)).sort()
119
+ }
120
+
121
+ /**
122
+ * Workspace-relative allowlist check. `/` always separates segments; `\` is a
123
+ * separator only on Windows — on POSIX it is a legal filename character, so
124
+ * `src\file.ts` must not match a `src` grant there. `.`/`..` segments then
125
+ * resolve lexically, so `src/../outside` (and its backslash form on Windows)
126
+ * never matches a `src` grant. workspacePath only bounds a path to the
127
+ * workspace root; membership in allowedPaths is decided here. Absolute paths
128
+ * (`/x`, `C:\x`, drive-relative `C:x`, UNC), empty paths and NUL are not
129
+ * workspace-relative and never normalize.
130
+ */
131
+ export function normalizeRelativePath(path: string): string | undefined {
132
+ if (!path || path.includes('\0') || isAbsolute(path) || (process.platform === 'win32' && /^[a-z]:/i.test(path))) return undefined
133
+ const segments: string[] = []
134
+ for (const segment of path.split(process.platform === 'win32' ? /[\\/]+/ : /\//)) {
135
+ if (!segment || segment === '.') continue
136
+ if (segment === '..') { if (!segments.pop()) return undefined }
137
+ else segments.push(segment)
138
+ }
139
+ return segments.join('/')
140
+ }
141
+
142
+ /** Wildcards other than a trailing `/**` would authorize an unpredictable set of files. */
143
+ const WILDCARD = /[*?[\]{}]/
144
+
145
+ /**
146
+ * One `allowedPaths` entry → the prefix it grants. `dir/**` is the conventional spelling of
147
+ * "this directory and everything under it", so it grants exactly what `dir` grants.
148
+ *
149
+ * A grant that matches nothing is not a narrow scope, it is an unrecoverable deadlock: the
150
+ * Host freezes it, the Worker's writes then fail the freeze check forever, and widening the
151
+ * scope needs the delegation-time bytes those writes already replaced (session-daa71a4a: a
152
+ * `lib/types/**` grant authorized nothing while the task's own build rewrote exactly those
153
+ * declarations). So a wildcard the Host cannot honour is refused when the Lead grants it.
154
+ */
155
+ export function grantOf(raw: string): string {
156
+ const trimmed = raw.trim().replace(/\/+$/, '')
157
+ const prefix = trimmed.endsWith('/**') ? trimmed.slice(0, -3) : trimmed
158
+ if (WILDCARD.test(prefix)) {
159
+ throw new Error(`allowedPaths takes a workspace-relative file or directory, not a wildcard: ${JSON.stringify(raw)} grants nothing. `
160
+ + 'Name the directory (a directory grant covers every file under it, so `lib/types` already means `lib/types/**`) or name each file.')
161
+ }
162
+ const normalized = prefix === '' ? undefined : normalizeRelativePath(prefix)
163
+ if (normalized === undefined) throw new Error(`allowedPaths entry ${JSON.stringify(raw)} is not a workspace-relative path`)
164
+ // `'.'` is the legitimate whole-workspace grant; keep its spelling so it survives a round trip.
165
+ return normalized === '' ? '.' : normalized
166
+ }
167
+
168
+ /** The prefix a stored grant matches by, tolerating `dir/**` spelling from older work orders. */
169
+ function grantPrefix(raw: string): string | undefined {
170
+ const trimmed = raw.trim().replace(/\/+$/, '')
171
+ if (!trimmed) return undefined
172
+ return normalizeRelativePath(trimmed.endsWith('/**') ? trimmed.slice(0, -3) : trimmed)
173
+ }
174
+
175
+ export function pathAllowed(path: string, allowed: readonly string[]): boolean {
176
+ const normalized = normalizeRelativePath(path)
177
+ if (normalized === undefined) return false
178
+ // `'.'` normalizes to the root: it is the legitimate whole-workspace grant. An empty
179
+ // or absolute allowed entry instead fails normalization above and never authorizes.
180
+ return allowed.some(raw => {
181
+ const prefix = grantPrefix(raw)
182
+ return prefix !== undefined && (prefix === '' || normalized === prefix || normalized.startsWith(`${prefix}/`))
183
+ })
184
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The identity this plugin registers under.
3
+ *
4
+ * Both halves import this module, so the provider route, the model entry and
5
+ * the durable producers keep one spelling. It deliberately has no imports: the
6
+ * browser bundle inlines it and must not pull a Host module in.
7
+ */
8
+
9
+ /** The HTTP path the panel's own views and actions are served from. */
10
+ export const FUSION_API_PATH = '/api/fusion'
11
+
12
+ /**
13
+ * The path this plugin served before the rename. The host keeps answering it
14
+ * so a browser tab holding the previous client bundle keeps working until its
15
+ * next reload; nothing in the current bundle requests it.
16
+ */
17
+ export const LEGACY_FUSION_API_PATH = '/api/model-fusion'
18
+
19
+ /**
20
+ * The npm package this project publishes under.
21
+ *
22
+ * Distinct from {@link PLUGIN_NAME}: the package name is the registry identity
23
+ * and the module specifier a profile's patch resolves, while the plugin id is
24
+ * the runtime identity that names the state directory, the bundle patch row and
25
+ * the log marker. Keeping them apart is what lets the package move into a scope
26
+ * without moving anyone's data.
27
+ */
28
+ export const PACKAGE_NAME = '@sqdr2/dsh-fusion'
29
+
30
+ /** The plugin id this project registers under, and the name of its state directory. */
31
+ export const PLUGIN_NAME = 'dsh-fusion'
32
+
33
+ /** The plugin id and package name this project used before the rename. */
34
+ export const LEGACY_PLUGIN_NAME = 'dsh-model-fusion'
35
+
36
+ /** Registered provider route for the Fusion model entry. */
37
+ export const FUSION_PROVIDER = 'dsh-fusion'
38
+
39
+ /** The single model id the Fusion entry advertises. */
40
+ export const FUSION_MODEL = 'auto'
41
+
42
+ /**
43
+ * The provider route this plugin registered before it was renamed from
44
+ * `dsh-model-fusion`. Sessions created then still name it in their durable
45
+ * `model/selection` event, so reading it as Fusion is what keeps those
46
+ * sessions working; writing it is never correct.
47
+ */
48
+ export const LEGACY_FUSION_PROVIDER = 'dsh-model-fusion'
49
+
50
+ /** Whether a provider route belongs to this plugin, under either name. */
51
+ export const isFusionProvider = (provider: string | undefined): boolean =>
52
+ provider === FUSION_PROVIDER || provider === LEGACY_FUSION_PROVIDER
53
+
54
+ /** Whether a route names the Fusion entry, under either name. */
55
+ export const isFusionSelection = (route: { provider?: string; model?: string } | undefined): boolean =>
56
+ isFusionProvider(route?.provider) && route?.model === FUSION_MODEL
57
+
58
+ /**
59
+ * How a native model-switch notice names this entry (`provider/model`).
60
+ *
61
+ * Both spellings are listed: a session that switched to Fusion before the
62
+ * rename carries the old one in its durable log, and the notice is the same
63
+ * non-change on every Fusion step either way.
64
+ */
65
+ export const FUSION_SELECTION_LABELS = [
66
+ `${FUSION_PROVIDER}/${FUSION_MODEL}`,
67
+ `${LEGACY_FUSION_PROVIDER}/${FUSION_MODEL}`,
68
+ ] as const
69
+
70
+ /**
71
+ * Whether a route already names the entry under its current name. A durable
72
+ * intent that fails this is rewritten before the next request is assembled;
73
+ * the legacy spelling is a read-side alias, not a route that still dispatches.
74
+ */
75
+ export const isCurrentFusionSelection = (route: { provider?: string; model?: string } | undefined): boolean =>
76
+ route?.provider === FUSION_PROVIDER && route.model === FUSION_MODEL
package/src/index.ts ADDED
@@ -0,0 +1,50 @@
1
+ export { FusionError, FUSION_CONTEXT_BUDGET, NEEDS_REPAIR, OUTCOME_UNKNOWN, SPEND_UNAUTHORIZED, STORE_MIGRATION_REQUIRED, USAGE_LEDGER_REQUIRED, WORK_ORDER_CONFLICT } from './errors.js'
2
+ export * from './contracts.js'
3
+ export { digestOf, sha256Hex } from './digest.js'
4
+ export { reduce, reduceAll } from './task/reducer.js'
5
+ export { FileFusionStore, failingRenameIo, requireStorageProjectionVersion, STORE_PROJECTION_VERSION } from './task/store.js'
6
+ export type { FusionStore } from './task/store.js'
7
+ export { SqliteFusionStore } from './task/sqlite-store.js'
8
+ export type { TaskState } from './task/state.js'
9
+ export { normalizeUsage, observationFromNormalized, apiEquivalentUsd, priceUsage } from './usage/normalize.js'
10
+ export type { UsageObservationMeta, UsagePrice, UsagePriceStatus } from './usage/normalize.js'
11
+ export {
12
+ ingestDurable,
13
+ ingestObservation,
14
+ newLedger,
15
+ projectLedger,
16
+ restoreLedger,
17
+ splitInput,
18
+ } from './usage/ledger.js'
19
+ export type { CanonicalBill, UsageLedgerV2, UsageObservation, UsageProjection, UsageRequestKey } from './usage/ledger.js'
20
+ export { quoteLedger } from './usage/pricing.js'
21
+ export type { PriceRates, RequestQuote } from './usage/pricing.js'
22
+ export { BudgetLedger, loadSpendingAuthorization } from './usage/budget.js'
23
+ export { WorkspaceWriteLease, classifyEffect } from './execution/write-lease.js'
24
+ export { resolveProfile, completeProfile, loadJsonObject, promptDigests } from './profile/resolve.js'
25
+ export { assertFinalBudget, measureRequest, inputBudget, boundedOverflowRecovery } from './context/guard.js'
26
+ export { consumeApproval, answerApproval, neverMeansDeny, pendingDoesNotExpire } from './approval/logical.js'
27
+ export { evaluateReport, DEFAULT_TEST_VERIFIERS } from './evidence/gate.js'
28
+ export type { TrustedEvidenceRegistry, RegisteredVerifier, EvaluateReportOptions } from './evidence/gate.js'
29
+ export { assertPlannedCheck, invocationHeader, memoryVerificationRegistry, resolveInvocation, checkPlanDigest } from './evidence/receipts.js'
30
+ export type { CheckPlan, InvocationHeader, PlannedReceipt, VerificationRegistry } from './evidence/receipts.js'
31
+ export {
32
+ assertCurrentSubject,
33
+ captureTicket,
34
+ createReviewTicket,
35
+ resultFromCapturedTicket,
36
+ reviewOutboxRow,
37
+ reportSubject,
38
+ reviewReadinessReceipt,
39
+ reviewRequestedEvent,
40
+ } from './review/ticket.js'
41
+ export { appendCapturedReviewResult, loadReviewOutboxByRequestId, persistReviewRequest } from './review/dispatch.js'
42
+ export { classifyReview, reviewAttemptFields, normalizeTermination, toReducerReviewDecision } from './review/protocol.js'
43
+ export type { ReviewClassification, ReviewDecision, ReviewStatus, ReviewTermination } from './review/protocol.js'
44
+ export { reconcile } from './recovery/reconcile.js'
45
+ export { loadPromptBundle, promptManifest } from './prompts.js'
46
+ export { runNativeBenchmark, NativeRequestBudget } from './benchmark/native-run.js'
47
+ export type { NativeBenchmarkOptions } from './benchmark/native-run.js'
48
+ export { resolveBenchmarkOutputLimits } from './benchmark/output-limits.js'
49
+ export type { BenchmarkOutputLimits } from './benchmark/output-limits.js'
50
+ export { snapshotWorkspace } from './host/workspace.js'
@@ -0,0 +1,31 @@
1
+ import type { ContextFormed } from '@deepseek-ai/dsh-llm'
2
+ declare module '@deepseek-ai/dsh-llm' {
3
+ interface MessageSourceMap {
4
+ 'plugin:dsh-fusion': { kind: 'plugin:dsh-fusion' } & ContextFormed
5
+ /**
6
+ * The producer kind this plugin wrote before it was renamed from
7
+ * `dsh-model-fusion`. Sessions that already exist still carry it in their
8
+ * durable log, so it stays a readable alias; nothing new is written under
9
+ * it and no migration rewrites history.
10
+ */
11
+ 'plugin:dsh-model-fusion': { kind: 'plugin:dsh-model-fusion' } & ContextFormed
12
+ }
13
+ }
14
+
15
+ /** The kind every new Fusion message is written under. */
16
+ export const FUSION_SOURCE_KIND = 'plugin:dsh-fusion'
17
+
18
+ /** Kinds a Fusion-produced message may already carry in an existing session. */
19
+ export const FUSION_SOURCE_KINDS = [FUSION_SOURCE_KIND, 'plugin:dsh-model-fusion'] as const
20
+
21
+ export type FusionSourceKind = (typeof FUSION_SOURCE_KINDS)[number]
22
+
23
+ /**
24
+ * Whether a message source was produced by this plugin, under either name.
25
+ *
26
+ * Read-side call sites use this guard instead of comparing one literal, so a
27
+ * session started before the rename keeps its context across the upgrade.
28
+ */
29
+ export function isFusionSourceKind(kind: unknown): kind is FusionSourceKind {
30
+ return typeof kind === 'string' && (FUSION_SOURCE_KINDS as readonly string[]).includes(kind)
31
+ }
@@ -0,0 +1,26 @@
1
+ import { bi } from '../bilingual.js'
2
+ import type { PairProfile, PhysicalRoute } from '../contracts.js'
3
+
4
+ /** A separate summary model is never inferred from a provider name or enabled by default. */
5
+ export function compactorChoice(value: unknown): PairProfile['compactor'] {
6
+ if (value === undefined) return undefined
7
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(bi('压缩模型配置无效', 'Invalid compaction model setting'))
8
+ const raw = value as Record<string, unknown>, route = raw.route as Record<string, unknown> | undefined
9
+ if (Object.keys(raw).some(key => key !== 'route' && key !== 'maxOutputTokens')
10
+ || !route || typeof route !== 'object' || Array.isArray(route)
11
+ || Object.keys(route).some(key => !['provider', 'model', 'reasoningEffort'].includes(key))
12
+ || typeof route.provider !== 'string' || !route.provider.trim()
13
+ || typeof route.model !== 'string' || !route.model.trim()
14
+ || (route.reasoningEffort !== undefined && (typeof route.reasoningEffort !== 'string' || !route.reasoningEffort.trim()))
15
+ || !Number.isSafeInteger(raw.maxOutputTokens) || Number(raw.maxOutputTokens) < 1 || Number(raw.maxOutputTokens) > 128_000) {
16
+ throw new Error(bi('请选择压缩模型,并设置 1–128000 的输出 Token 上限', 'Choose a compaction model and an output limit of 1–128000 tokens'))
17
+ }
18
+ return { route: { provider: route.provider, model: route.model,
19
+ ...(route.reasoningEffort === undefined ? {} : { reasoningEffort: route.reasoningEffort as string }) },
20
+ maxOutputTokens: Number(raw.maxOutputTokens) }
21
+ }
22
+
23
+ /** All physical routes in a frozen configuration share the same explicit spending authorization. */
24
+ export function profileRoutes(profile: Pick<PairProfile, 'lead' | 'worker' | 'compactor'>): PhysicalRoute[] {
25
+ return [profile.lead, profile.worker, ...(profile.compactor ? [profile.compactor.route] : [])]
26
+ }
@@ -0,0 +1,13 @@
1
+ import type { PairProfile } from '../contracts.js'
2
+
3
+ /** No provider-name heuristic can establish cache support for a DSH route. */
4
+ export function keepaliveChoice(value: unknown): PairProfile['cacheKeepalive'] {
5
+ if (value === undefined) return undefined
6
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid cache keepalive selection')
7
+ const raw = value as Record<string, unknown>
8
+ const valid = (item: unknown): item is boolean | 'auto' => typeof item === 'boolean' || item === 'auto'
9
+ if (Object.keys(raw).some(key => key !== 'lead' && key !== 'worker') || !valid(raw.lead) || !valid(raw.worker)) {
10
+ throw new Error('Cache keepalive requires explicit Lead and Worker choices (true, false or "auto")')
11
+ }
12
+ return { lead: raw.lead, worker: raw.worker }
13
+ }