@sqdr2/dsh-fusion 0.3.2 → 0.3.4

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.
@@ -148,12 +148,24 @@ export class CachePolicy {
148
148
  }
149
149
 
150
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)
151
+ /** A saved combo's routes, plus its optional per-role keepalive choice. */
152
+ export interface CachePairRoutes {
153
+ readonly lead: Route
154
+ readonly worker: Route
155
+ readonly cacheKeepalive?: { lead?: boolean | 'auto'; worker?: boolean | 'auto' }
156
+ }
157
+ /**
158
+ * One row of the settings page: what applies, why, what the defaults say, and
159
+ * what was observed. Every saved combo contributes its routes, so a second
160
+ * combo's models are listed and labelled even before a session uses them.
161
+ */
162
+ export function cacheView(policy: CachePolicy, pair?: CachePairRoutes | readonly CachePairRoutes[], pairChoice?: { lead?: boolean | 'auto'; worker?: boolean | 'auto' }) {
163
+ const pairs: readonly CachePairRoutes[] = Array.isArray(pair) ? pair : pair ? [pair as CachePairRoutes] : []
164
+ const same = (role: 'lead' | 'worker', provider: string, model: string) => pairs.some(item => item[role].provider === provider && item[role].model === model)
165
+ const choiceOf = (role: 'lead' | 'worker') => pairs.find(item => item.cacheKeepalive)?.['cacheKeepalive']?.[role] ?? pairChoice?.[role]
166
+ return policy.routes(pairs.flatMap(item => [item.lead, item.worker])).map(({ provider, model }) => {
167
+ const role: Role | undefined = same('lead', provider, model) ? 'lead' : same('worker', provider, model) ? 'worker' : undefined
168
+ const effective = policy.resolve(provider, model, role ?? 'lead', role ? choiceOf(role) : undefined)
157
169
  const stats = policy.stats(provider, model), { family, route } = effective.defaults
158
170
  return { provider, model, role,
159
171
  mode: effective.mode, intervalSeconds: effective.intervalSeconds, modeSource: effective.modeSource, intervalSource: effective.intervalSource,
@@ -3,11 +3,11 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
3
3
  import { SessionId as NativeSessionId } from '@deepseek-ai/dsh-session'
4
4
  import type { Session } from '@deepseek-ai/dsh-session'
5
5
  import type { ToolExecution } from '@deepseek-ai/dsh-tools'
6
- import type { Role } from '../contracts.js'
6
+ import type { Role, SandboxMode } from '../contracts.js'
7
7
  import type { SqliteFusionStore } from '../task/sqlite-store.js'
8
8
  import type { SessionBinding } from './bindings.js'
9
9
 
10
- type Mode = 'read-only' | 'workspace-write' | 'danger-full-access'
10
+ type Mode = SandboxMode
11
11
 
12
12
  // Mirrors the Host sandbox-policy augmentation (a type-only declaration; the Host
13
13
  // registers the event at runtime). The plugin does not depend on that package.
@@ -36,6 +36,19 @@ interface Intent {
36
36
 
37
37
  export const roleSeparated = (binding: SessionBinding) => binding.profile.workflowPolicy === 'enforced-v3'
38
38
 
39
+ const intentId = (sessionId: string) => `sandbox-intent:${sessionId}`
40
+
41
+ /**
42
+ * The mode the user chose for a confined Fusion conversation, which is the mode
43
+ * the Sidekick runs under. Undefined until Fusion has confined the Lead, or after
44
+ * the conversation left Fusion and its own mode was restored.
45
+ */
46
+ export function readSandboxIntent(store: SqliteFusionStore, sessionId: string): { mode: Mode } | undefined {
47
+ const intent = store.readDocument(intentId(sessionId))?.value as Intent | undefined
48
+ if (!intent || intent.schemaVersion !== 1 || !intent.active) return undefined
49
+ return { mode: intent.mode }
50
+ }
51
+
39
52
  /**
40
53
  * Role separation enforced below the prompt: the Lead's session runs under the
41
54
  * Host's own read-only sandbox (bash and filesystem are refused by the OS
@@ -53,13 +66,12 @@ export class NativeRoleSandbox {
53
66
  return (this.ctx as unknown as { get(name: string): unknown }).get('sandboxPolicy') as SandboxPolicy | undefined
54
67
  }
55
68
 
56
- #id(sessionId: string) { return `sandbox-intent:${sessionId}` }
57
69
  #intent(sessionId: string): Intent | undefined {
58
- return this.store.readDocument(this.#id(sessionId))?.value as Intent | undefined
70
+ return this.store.readDocument(intentId(sessionId))?.value as Intent | undefined
59
71
  }
60
72
  #save(intent: Intent) {
61
- const prior = this.store.readDocument(this.#id(intent.sessionId))
62
- this.store.writeDocument(this.#id(intent.sessionId), prior?.revision ?? 0, { ...intent, ownSeqs: intent.ownSeqs.slice(-32) })
73
+ const prior = this.store.readDocument(intentId(intent.sessionId))
74
+ this.store.writeDocument(intentId(intent.sessionId), prior?.revision ?? 0, { ...intent, ownSeqs: intent.ownSeqs.slice(-32) })
63
75
  }
64
76
  // Session listeners run synchronously inside append, before the seq is known here.
65
77
  #writing = false
@@ -7,7 +7,7 @@ import { requestOutputReservation } from '../context/guard.js'
7
7
  import type { PhysicalRoute, Role } from '../contracts.js'
8
8
  import type { SessionBinding } from './bindings.js'
9
9
  import { digestOf } from '../digest.js'
10
- import { FUSION_SELECTION_LABELS } from '../identity.js'
10
+ import { isFusionSelectionLabel } from '../identity.js'
11
11
  import { fusionToolDiagnostic, projectRoleTools } from './tool-table.js'
12
12
 
13
13
  export interface FusionScopeCallbacks {
@@ -122,7 +122,7 @@ export class NativeFusionScopes {
122
122
  return { ...decision, messages: [...decision.messages.filter(message => {
123
123
  const source = message.source
124
124
  return !(source?.kind === 'model-selection' && source.form === 'notice'
125
- && FUSION_SELECTION_LABELS.some(label => source.summary.endsWith(label)))
125
+ && isFusionSelectionLabel(source.summary))
126
126
  }).map(message =>
127
127
  this.callbacks.projectMessage?.(agent, entry.binding, role, message) ?? message), ...(taskContext ? [taskContext] : [])] }
128
128
  }, { prepend: true }))
@@ -5,30 +5,47 @@ import type { GenerateOptions, LlmModelInfo, LlmResolvedModelInfo, ModelModality
5
5
  import { SessionId } from '@deepseek-ai/dsh-session'
6
6
  import type {} from '@deepseek-ai/dsh-api-session-controller/types'
7
7
  import type { PhysicalRoute } from '../contracts.js'
8
- import { FUSION_MODEL, FUSION_PROVIDER, isCurrentFusionSelection, isFusionSelection } from '../identity.js'
8
+ import { FUSION_MODEL, FUSION_PROVIDER, isCurrentFusionSelection, isFusionModel, isFusionSelection } from '../identity.js'
9
+ import { modelOfProfileId } from './pairs.js'
9
10
  import type { ResolvedProfile } from '../profile/resolve.js'
10
11
  import type { FusionCoordinator } from './coordinator.js'
11
12
 
12
13
  export { FUSION_MODEL, FUSION_PROVIDER, isFusionSelection }
13
14
 
14
- /** Resolves the configured Lead's declared input modalities; undefined means unconfigured or unverifiable. */
15
- export type LeadInputModalities = () => Promise<readonly ModelModality[] | undefined>
15
+ /** One combo as the native menu needs it: its id, its label and its Lead's modalities. */
16
+ export interface FusionModelEntry {
17
+ readonly id: string
18
+ readonly name: string
19
+ readonly description?: string
20
+ readonly inputModalities: readonly ModelModality[]
21
+ }
22
+
23
+ /** Read every combo this Host currently advertises; empty means unconfigured. */
24
+ export type FusionModels = () => Promise<readonly FusionModelEntry[]>
25
+
26
+ const textOnly = [...new Set<ModelModality>(['text'])]
16
27
 
17
28
  /** Catalog-only local adapter; the native request is physically routed before dispatch. */
18
29
  export class FusionCatalogAdapter extends LlmAdapter {
19
- constructor(private readonly leadInputModalities?: LeadInputModalities) { super() }
30
+ constructor(private readonly models?: FusionModels) { super() }
20
31
  override providerInfo(provider: string) { return { id: provider, name: 'Fusion' } }
21
32
  override async listModels(provider: string): Promise<readonly LlmModelInfo[]> {
22
- // The Lead is the only role that reads user content, so the entry admits
23
- // exactly what the configured Lead admits; text-only until it resolves.
24
- const lead = (await this.leadInputModalities?.()) ?? []
25
- return [{ provider, id: FUSION_MODEL, name: 'Fusion · 自动',
26
- description: 'Lead 与 Worker 协作。在设置 → Fusion 中配置模型。',
27
- inputModalities: [...new Set<ModelModality>(['text', ...lead])] }]
33
+ const declared = await this.models?.().catch(() => []) ?? []
34
+ // The Lead is the only role that reads user content, so an entry admits
35
+ // exactly what its own Lead admits; text-only until it resolves. An
36
+ // installation with no combo yet still offers the historical entry, so the
37
+ // menu can explain what to configure instead of hiding Fusion entirely.
38
+ const entries = declared.length ? declared
39
+ : [{ id: FUSION_MODEL, name: 'Fusion · 自动', inputModalities: textOnly }]
40
+ return entries.map(entry => ({ provider, id: entry.id, name: entry.name,
41
+ description: entry.description ?? 'Lead 与 Worker 协作。在设置 → Fusion 中配置模型。',
42
+ inputModalities: [...new Set<ModelModality>(['text', ...entry.inputModalities])] }))
28
43
  }
29
44
  override async resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
30
- if (provider !== FUSION_PROVIDER || model !== FUSION_MODEL) throw new Error('Unknown local Fusion selection')
31
- return (await this.listModels(provider))[0]!
45
+ if (provider !== FUSION_PROVIDER || !isFusionModel(model)) throw new Error('Unknown local Fusion selection')
46
+ const declared = (await this.listModels(provider)).find(entry => entry.id === model)
47
+ if (!declared) throw new Error('Unknown local Fusion selection')
48
+ return declared
32
49
  }
33
50
  override async *stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
34
51
  throw new Error('Fusion 尚未就绪。请前往设置 → Fusion 配置 Lead 和 Worker。')
@@ -38,30 +55,57 @@ export class FusionCatalogAdapter extends LlmAdapter {
38
55
  /** Public native lifecycle integration. No model-menu replacement or Host patch. */
39
56
  export function installNativeFusionSelection(ctx: Context, input: {
40
57
  coordinator(): FusionCoordinator | undefined
41
- profile(): ResolvedProfile | undefined
58
+ /** The frozen profile of one combo, or undefined when it cannot be used yet. */
59
+ profile(model: string): ResolvedProfile | undefined
60
+ /** Why one combo cannot run, for the message a blocked session shows. */
61
+ reason?(model: string): string | undefined
42
62
  selection(agent: Agent): PhysicalRoute | undefined
43
63
  }): () => void {
44
64
  const pending = new Map<string, Promise<void>>()
45
65
  const failures = new Map<string, string>()
46
66
  const desired = new Map<string, PhysicalRoute>()
47
67
  const selection = (agent: Agent) => desired.get(agent.id) ?? input.selection(agent)
48
- const selected = (agent: Agent) => input.coordinator()?.bindings.read(agent.id)?.binding.selected === true
68
+ const binding = (agent: Agent) => input.coordinator()?.bindings.read(agent.id)?.binding
69
+ const selected = (agent: Agent) => binding(agent)?.selected === true
70
+ /** The combo a selected session is frozen to, or undefined when it runs no Fusion task. */
71
+ const runningCombo = (agent: Agent): string | undefined => {
72
+ const current = binding(agent)
73
+ return current?.selected ? modelOfProfileId(current.profile.id) ?? FUSION_MODEL : undefined
74
+ }
75
+ const blocked = (model: string) => input.reason?.(model) ?? '请先在设置 → Fusion 配置 Lead 和 Worker,再继续此会话。'
49
76
  const change = (agent: Agent) => {
50
77
  if (agent.session.header.parentSession || pending.has(agent.id)) return
51
- const coordinator = input.coordinator(), profile = input.profile(), route = selection(agent)
78
+ const coordinator = input.coordinator(), route = selection(agent)
52
79
  if (!coordinator) return
53
80
  if (isFusionSelection(route)) {
54
- if (!selected(agent) && profile) coordinator.selectBeforeAssembly(agent, profile)
81
+ const model = route?.model ?? FUSION_MODEL
82
+ const profile = input.profile(model), running = runningCombo(agent)
83
+ if (running !== undefined && running !== model) {
84
+ // Another combo was picked inside this conversation. Each combo freezes
85
+ // its own pair, so the drain is the same as leaving Fusion: the next
86
+ // turn starts a new task under the newly chosen combo.
87
+ const transition = (async () => {
88
+ if (agent.status !== 'idle') await coordinator.pause(agent)
89
+ await coordinator.clear(agent)
90
+ if (profile) coordinator.selectBeforeAssembly(agent, profile)
91
+ else failures.set(agent.id, blocked(model))
92
+ })().catch(error => { failures.set(agent.id, String(error)) }).finally(() => { pending.delete(agent.id) })
93
+ pending.set(agent.id, transition)
94
+ return
95
+ }
96
+ if (running === undefined) {
97
+ if (profile) coordinator.selectBeforeAssembly(agent, profile)
98
+ else failures.set(agent.id, blocked(model))
99
+ }
55
100
  if (selected(agent)) {
56
101
  // A saved Host default need not emit model/selection. Persist that
57
102
  // already-chosen intent before physical headers reach the native menu's
58
103
  // projection, otherwise it starts displaying the Lead as the selection.
59
- // A session that named the entry before the rename keeps its task, but
60
- // the route it stored no longer has an adapter; rewrite the intent so
61
- // the next assembly dispatches under the registered name.
104
+ // A session that named the combo before a rename keeps its task, but the
105
+ // route it stored no longer has an adapter; rewrite the spelling only.
62
106
  const intent = agent.session.snapshotEvents().findLast(event => event.type === 'model/selection')
63
107
  if (!isCurrentFusionSelection(intent?.data)) agent.session.append('model/selection', {
64
- provider: FUSION_PROVIDER, model: FUSION_MODEL,
108
+ provider: FUSION_PROVIDER, model,
65
109
  })
66
110
  failures.delete(agent.id)
67
111
  }
@@ -101,7 +145,7 @@ export function installNativeFusionSelection(ctx: Context, input: {
101
145
  // A newly installed exact-Agent interceptor can wrap this listener;
102
146
  // its physical route is applied after this continuation returns.
103
147
  // The catalog adapter itself cannot dispatch any upstream request.
104
- if (!selected(agent)) throw new Error('请先在设置 → Fusion 配置 Lead 和 Worker,再继续此会话。')
148
+ if (!selected(agent)) throw new Error(blocked(route?.model ?? FUSION_MODEL))
105
149
  } else if (selected(agent)) throw new Error('先停止当前 Fusion 任务,再切换模型。')
106
150
  return config
107
151
  }, { prepend: true }),
@@ -109,12 +109,25 @@ export function workerToolBriefProblem(store: SqliteFusionStore, taskId: TaskId,
109
109
  const message = native ?? (transport === undefined ? undefined
110
110
  : events?.findLast(event => event.type === 'assistant/message'
111
111
  && event.data.message.content.some(block => block.type === 'tool-call' && block.name === transport)))
112
- if (!message || message.type !== 'assistant/message' || message.data.interrupted) return 'Worker tool requires a complete native model request'
113
- const row = store.readDocument(`worker-request:${exec.agent!.id}:${message.data.turn}:${message.data.step}`)?.value as {
112
+ type WorkerRequestRow = {
114
113
  schemaVersion?: number; taskId?: string; sessionId?: string; turn?: number; step?: number; briefRevision?: number; workOrderId?: string
115
- } | undefined
116
- if (row?.schemaVersion !== 1 || row.taskId !== taskId || row.sessionId !== exec.agent!.id
117
- || row.turn !== message.data.turn || row.step !== message.data.step) return 'Worker tool has no captured brief identity'
114
+ }
115
+ let row: WorkerRequestRow | undefined
116
+ if (message && message.type === 'assistant/message' && !message.data.interrupted) {
117
+ row = store.readDocument(`worker-request:${exec.agent!.id}:${message.data.turn}:${message.data.step}`)?.value as WorkerRequestRow | undefined
118
+ if (row?.schemaVersion !== 1 || row.taskId !== taskId || row.sessionId !== exec.agent!.id
119
+ || row.turn !== message.data.turn || row.step !== message.data.step) return 'Worker tool has no captured brief identity'
120
+ } else if (transport !== undefined) {
121
+ const ids = store.listDocumentIds(`worker-request:${exec.agent!.id}:`)
122
+ const rows = ids
123
+ .map(id => store.readDocument(id)?.value as WorkerRequestRow | undefined)
124
+ .filter((r): r is NonNullable<WorkerRequestRow> => Boolean(r && r.schemaVersion === 1 && r.taskId === taskId && r.sessionId === exec.agent!.id))
125
+ rows.sort((a, b) => (a.turn ?? 0) - (b.turn ?? 0) || (a.step ?? 0) - (b.step ?? 0))
126
+ row = rows.at(-1)
127
+ if (!row) return 'Worker tool has no captured brief identity'
128
+ } else {
129
+ return 'Worker tool requires a complete native model request'
130
+ }
118
131
  if (row.briefRevision !== revision) return 'A newer Lead brief superseded this request; process it before further tools or a report'
119
132
  const order = store.load(taskId)?.currentWorkOrder
120
133
  if (row.workOrderId !== order?.id && (row.workOrderId || order?.mode)) return 'A newer work order superseded this request; process its plan before further tools'
@@ -0,0 +1,180 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { bi } from '../bilingual.js'
3
+ import { FUSION_MODEL, isFusionModel } from '../identity.js'
4
+ import { keepaliveChoice } from '../profile/keepalive.js'
5
+ import { compactorChoice } from '../profile/compactor.js'
6
+ import type { SqliteFusionStore } from '../task/sqlite-store.js'
7
+ import type { PairChoice, SettingsCatalog } from './settings.js'
8
+ import { modelProfileFromChoice, validatePairChoice } from './settings.js'
9
+
10
+ /**
11
+ * One saved combo: the model id the native menu advertises, the label beside
12
+ * it, and the pair that id freezes. The id is the durable identity — a session
13
+ * that selected it keeps its own frozen copy — so renaming never moves a combo.
14
+ */
15
+ export interface PairEntry {
16
+ readonly id: string
17
+ readonly name: string
18
+ readonly pair: PairChoice
19
+ }
20
+
21
+ /** The saved combos, in menu order. `auto` is the historical first combo. */
22
+ export interface PairsSnapshot {
23
+ readonly revision: number
24
+ readonly pairs: readonly PairEntry[]
25
+ /** True when these came from a pre-combo `settings:profile` document. */
26
+ readonly migrated: boolean
27
+ }
28
+
29
+ /** Where the combo list lives; `settings:profile` stays readable for migration. */
30
+ export const PAIRS_DOCUMENT = 'settings:pairs'
31
+ const LEGACY_PROFILE_DOCUMENT = 'settings:profile'
32
+
33
+ /** Every menu entry costs one row in the native picker, so the list stays short. */
34
+ export const MAX_PAIRS = 6
35
+ export const MAX_PAIR_NAME = 40
36
+ /** The migrated first combo keeps the label the historical entry had. */
37
+ export const DEFAULT_PAIR_NAME = '自动'
38
+
39
+ export const profileIdOf = (model: string): string => `fusion-${model}`
40
+
41
+ /** The combo a frozen profile belongs to, or undefined for a non-combo profile. */
42
+ export function modelOfProfileId(id: string): string | undefined {
43
+ const model = id.startsWith('fusion-') ? id.slice('fusion-'.length) : undefined
44
+ return isFusionModel(model) ? model : undefined
45
+ }
46
+
47
+ const newModel = (taken: readonly string[]): string => {
48
+ for (let attempt = 0; attempt < 100; attempt += 1) {
49
+ const model = `pair-${randomUUID().replace(/-/g, '').slice(0, 8)}`
50
+ if (!taken.includes(model)) return model
51
+ }
52
+ throw new Error(bi('无法生成新的组合标识', 'Could not allocate a new combo id'))
53
+ }
54
+
55
+ function object(value: unknown): Record<string, unknown> | undefined {
56
+ return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : undefined
57
+ }
58
+
59
+ function route(value: unknown): PairChoice['lead'] | undefined {
60
+ const item = object(value)
61
+ if (!item || typeof item.provider !== 'string' || !item.provider.trim() || typeof item.model !== 'string' || !item.model.trim()) return undefined
62
+ return { provider: item.provider, model: item.model,
63
+ ...(typeof item.reasoningEffort === 'string' && item.reasoningEffort.trim() ? { reasoningEffort: item.reasoningEffort } : {}) }
64
+ }
65
+
66
+ /**
67
+ * Read one stored pair without the Host catalog: a combo whose model has since
68
+ * been retired must stay visible (and editable) instead of hiding the list.
69
+ */
70
+ function structuralPair(value: unknown): PairChoice | undefined {
71
+ const raw = object(value)
72
+ const lead = route(raw?.lead), worker = route(raw?.worker)
73
+ if (!raw || !lead || !worker) return undefined
74
+ const compactorRoute = route(object(raw.compactor)?.route)
75
+ const maxOutputTokens = object(raw.compactor)?.maxOutputTokens
76
+ let cacheKeepalive: PairChoice['cacheKeepalive']
77
+ try { cacheKeepalive = keepaliveChoice(raw.cacheKeepalive) } catch { cacheKeepalive = undefined }
78
+ let compactor: PairChoice['compactor']
79
+ try { compactor = compactorRoute ? compactorChoice({ route: compactorRoute, maxOutputTokens }) : undefined } catch { compactor = undefined }
80
+ return { lead, worker, ...(compactor === undefined ? {} : { compactor }), ...(cacheKeepalive === undefined ? {} : { cacheKeepalive }) }
81
+ }
82
+
83
+ /** A pre-combo profile document as the choice it was saved from. */
84
+ export function choiceFromStoredProfile(value: unknown): PairChoice | undefined {
85
+ const raw = object(value)
86
+ const pair = structuralPair(raw)
87
+ if (!raw || !pair) return undefined
88
+ if (raw.interactionMode === 'model-like') return pair
89
+ // Legacy profiles keep explicit per-role output limits in `context`.
90
+ const context = object(raw.context)
91
+ const outputTokens = Object.fromEntries((['lead', 'worker'] as const)
92
+ .map(role => [role, object(context?.[role])?.reserveOutputTokens] as const)
93
+ .filter(([, tokens]) => Number.isSafeInteger(tokens) && Number(tokens) > 0 && Number(tokens) !== 8_000)
94
+ .map(([role, tokens]) => [role, Number(tokens)]))
95
+ return Object.keys(outputTokens).length ? { ...pair, outputTokens } : pair
96
+ }
97
+
98
+ function parsePairs(value: unknown): PairEntry[] | undefined {
99
+ const raw = object(value)
100
+ if (!raw || raw.schemaVersion !== 1 || !Array.isArray(raw.pairs)) return undefined
101
+ const seen = new Set<string>()
102
+ return raw.pairs.flatMap((item): PairEntry[] => {
103
+ const entry = object(item)
104
+ const pair = structuralPair(entry?.pair)
105
+ if (!entry || !pair || typeof entry.id !== 'string' || !isFusionModel(entry.id) || seen.has(entry.id)) return []
106
+ if (typeof entry.name !== 'string' || !entry.name.trim() || entry.name.length > MAX_PAIR_NAME) return []
107
+ seen.add(entry.id)
108
+ return [{ id: entry.id, name: entry.name, pair }]
109
+ })
110
+ }
111
+
112
+ /** The saved combos; a pre-combo installation migrates to one `auto` combo on read. */
113
+ export function readPairs(store: SqliteFusionStore): PairsSnapshot {
114
+ const document = store.readDocument(PAIRS_DOCUMENT)
115
+ if (document) {
116
+ const pairs = parsePairs(document.value)
117
+ if (!pairs?.length) throw new Error(bi('组合配置已损坏,请在 Fusion 页面重新保存', 'The combo list is unreadable; save it again from the Fusion page'))
118
+ return { revision: document.revision, pairs, migrated: false }
119
+ }
120
+ const legacy = choiceFromStoredProfile(store.readDocument(LEGACY_PROFILE_DOCUMENT)?.value)
121
+ // Revision 0 lets the first save create the document without a conflict.
122
+ return legacy ? { revision: 0, pairs: [{ id: FUSION_MODEL, name: DEFAULT_PAIR_NAME, pair: legacy }], migrated: true }
123
+ : { revision: 0, pairs: [], migrated: false }
124
+ }
125
+
126
+ export function pairName(value: unknown, fallback: string): string {
127
+ if (typeof value !== 'string') return fallback
128
+ const name = value.trim()
129
+ if (!name) return fallback
130
+ if (name.length > MAX_PAIR_NAME) throw new Error(bi(`组合名称最多 ${MAX_PAIR_NAME} 个字符`, `A combo name is at most ${MAX_PAIR_NAME} characters`))
131
+ return name
132
+ }
133
+
134
+ /**
135
+ * Validate the list a settings action proposes. Ids are assigned here, never by
136
+ * the client, so a stale tab cannot name a combo that is already frozen
137
+ * elsewhere; reordering only moves the menu rows.
138
+ */
139
+ export function validatePairs(value: unknown, catalog: SettingsCatalog, existing: readonly PairEntry[]): PairEntry[] {
140
+ if (!Array.isArray(value) || !value.length) throw new Error(bi('请至少保留一个组合', 'Keep at least one combo'))
141
+ if (value.length > MAX_PAIRS) throw new Error(bi(`最多保存 ${MAX_PAIRS} 个组合`, `At most ${MAX_PAIRS} combos can be saved`))
142
+ const known = new Set(existing.map(pair => pair.id))
143
+ const taken = new Set<string>()
144
+ const names = new Set<string>()
145
+ return value.map((item, index) => {
146
+ const raw = object(item)
147
+ if (!raw) throw new Error(bi('组合配置无效', 'Invalid combo entry'))
148
+ // A frozen session keeps its own copy, so an id may be renamed or reordered
149
+ // freely; a new row (or a duplicate) gets an id this Host has never issued.
150
+ // `auto` is the reserved historical id, so the first combo may claim it even
151
+ // on a fresh installation: selections made before combos existed still name it.
152
+ const requested = typeof raw.id === 'string' ? raw.id : ''
153
+ const id = (known.has(requested) || requested === FUSION_MODEL) && !taken.has(requested) ? requested : newModel([...taken])
154
+ taken.add(id)
155
+ const name = pairName(raw.name, index === 0 ? DEFAULT_PAIR_NAME : `组合 ${index + 1}`)
156
+ if (names.has(name)) throw new Error(bi(`组合名称 "${name}" 重复`, `The combo name "${name}" is used twice`))
157
+ names.add(name)
158
+ // The name goes in so a stale route in the second row does not read like a
159
+ // complaint about the first one.
160
+ return { id, name, pair: validatePairChoice(raw.pair, catalog, name) }
161
+ })
162
+ }
163
+
164
+ export function pairsDocument(pairs: readonly PairEntry[]): Record<string, unknown> {
165
+ return { schemaVersion: 1, pairs: pairs.map(({ id, name, pair }) => ({ id, name, pair })) }
166
+ }
167
+
168
+ /**
169
+ * Persist the list under one compare-and-swap revision. The first combo is
170
+ * mirrored into the pre-combo `settings:profile` document so an older tab (or a
171
+ * downgrade) still finds the pair it knows about.
172
+ */
173
+ export function writePairs(store: SqliteFusionStore, expectedRevision: number, pairs: readonly PairEntry[]): number {
174
+ const mirror = pairs.find(pair => pair.id === FUSION_MODEL) ?? pairs[0]!
175
+ const legacy = store.readDocument(LEGACY_PROFILE_DOCUMENT)
176
+ return store.writeDocuments([
177
+ { id: PAIRS_DOCUMENT, expectedRevision, value: pairsDocument(pairs) },
178
+ { id: LEGACY_PROFILE_DOCUMENT, expectedRevision: legacy?.revision ?? 0, value: modelProfileFromChoice(mirror.pair, profileIdOf(mirror.id)) },
179
+ ])[0]!
180
+ }
@@ -27,20 +27,49 @@ export function savedLeadRoute(value: unknown): { provider: string; model: strin
27
27
  return { provider, model }
28
28
  }
29
29
 
30
+ /** The roles a saved pair names, in the order the settings page shows them. */
31
+ export type PairRole = 'lead' | 'worker' | 'compactor'
32
+
33
+ /**
34
+ * The routes a saved choice still names that the current catalog no longer
35
+ * offers: a provider that was renamed or removed, or a model that was retired.
36
+ *
37
+ * Reading a saved combo tolerates a stale route so the row stays visible and
38
+ * editable, but saving one does not. Without this, the page can only say that
39
+ * *some* model is gone, which is unusable when the stale route is not the one
40
+ * being edited — the user then cannot tell which combo to fix.
41
+ */
42
+ export function missingRoutes(value: Partial<PairChoice> | undefined, catalog: SettingsCatalog): { role: PairRole; route: PhysicalRoute }[] {
43
+ if (!value) return []
44
+ const stale = (role: PairRole, route: PhysicalRoute | undefined) => route !== undefined
45
+ && !catalog.groups.find(group => group.id === route.provider)?.models.find(model => model.id === route.model)
46
+ ? [{ role, route }] : []
47
+ return [...stale('lead', value.lead), ...stale('worker', value.worker), ...stale('compactor', value.compactor?.route)]
48
+ }
49
+
30
50
  /** Configuration chooses registered physical models; it cannot create adapters. */
31
- export function validatePairChoice(value: unknown, catalog: SettingsCatalog): PairChoice {
32
- if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(bi('请选择 Lead 和 Worker', 'Choose a Lead and a Sidekick'))
51
+ export function validatePairChoice(value: unknown, catalog: SettingsCatalog, combo?: string): PairChoice {
52
+ // A list is validated row by row, so the failure has to say which row it came
53
+ // from; `bi` carries both languages, so the label joins the message here
54
+ // instead of being glued onto an already-bilingual string.
55
+ const say = (zh: string, en: string) => combo
56
+ ? bi(`组合「${combo}」:${zh}`, `Combo "${combo}": ${en}`) : bi(zh, en)
57
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(say('请选择 Lead 和 Worker', 'Choose a Lead and a Sidekick'))
33
58
  const raw = value as Record<string, unknown>
34
59
  const route = (role: string, candidate: unknown): PhysicalRoute => {
35
60
  const item = candidate as Record<string, unknown> | undefined
36
- if (!item || typeof item.provider !== 'string' || typeof item.model !== 'string') throw new Error(bi(`请选择 ${role} 模型`, `Choose the ${role} model`))
37
- if (isFusionProvider(item.provider)) throw new Error(bi('Fusion 必须选择真实模型', 'Fusion needs real models, not Fusion itself'))
61
+ if (!item || typeof item.provider !== 'string' || typeof item.model !== 'string') throw new Error(say(`请选择 ${role} 模型`, `Choose the ${role} model`))
62
+ if (isFusionProvider(item.provider)) throw new Error(say('Fusion 必须选择真实模型', 'Fusion needs real models, not Fusion itself'))
38
63
  const group = catalog.groups.find(group => group.id === item.provider)
39
64
  const model = group?.models.find(model => model.id === item.model)
40
- if (!model) throw new Error(bi(`${role} 模型已不可用,请刷新后重选`, `The ${role} model is no longer available; refresh and choose again`))
65
+ // Name the exact route: after a provider rename the model usually still
66
+ // exists elsewhere, so "refresh and choose again" alone leaves the user
67
+ // guessing which of the two roles went stale.
68
+ if (!model) throw new Error(say(`${role} 模型已不可用:${item.provider}/${item.model}。请为该角色重新选择模型`,
69
+ `The ${role} model is no longer available: ${item.provider}/${item.model}. Choose a model for that role again`))
41
70
  if (item.reasoningEffort !== undefined && (typeof item.reasoningEffort !== 'string'
42
71
  || !model.reasoning?.efforts.some(effort => effort.id === item.reasoningEffort))) {
43
- throw new Error(bi(`${role} 不支持所选推理强度`, `${role} does not support the selected reasoning level`))
72
+ throw new Error(say(`${role} 不支持所选推理强度`, `${role} does not support the selected reasoning level`))
44
73
  }
45
74
  return { provider: item.provider, model: item.model,
46
75
  ...(item.reasoningEffort === undefined ? {} : { reasoningEffort: item.reasoningEffort as string }) }
@@ -53,7 +82,7 @@ export function validatePairChoice(value: unknown, catalog: SettingsCatalog): Pa
53
82
  if (!value || typeof value !== 'object' || Array.isArray(value)
54
83
  || Object.entries(value).some(([role, tokens]) => !['lead', 'worker'].includes(role)
55
84
  || !Number.isSafeInteger(tokens) || Number(tokens) < 1 || Number(tokens) > 128_000)) {
56
- throw new Error(bi('每次响应上限必须是 1–128000 的整数 Token 数', 'The per-response limit must be an integer of 1–128000 tokens'))
85
+ throw new Error(say('每次响应上限必须是 1–128000 的整数 Token 数', 'The per-response limit must be an integer of 1–128000 tokens'))
57
86
  }
58
87
  outputTokens = { ...value } as PairChoice['outputTokens']
59
88
  }
@@ -66,19 +95,23 @@ export function validatePairChoice(value: unknown, catalog: SettingsCatalog): Pa
66
95
  /** New sessions can opt into this Worker cap. Frozen sessions keep their saved profile. */
67
96
  export const WORKER_IMPLEMENTATION_OUTPUT_TOKENS = 128_000
68
97
 
69
- export function profileFromChoice(pair: PairChoice): Record<string, unknown> {
98
+ export function profileFromChoice(pair: PairChoice, id = 'fusion-auto'): Record<string, unknown> {
70
99
  const { outputTokens, ...routes } = pair
71
100
  const version = outputTokens?.worker === WORKER_IMPLEMENTATION_OUTPUT_TOKENS ? '0.1.0-worker-128k' : '0.1.0-faithful'
72
- return { schemaVersion: 1, id: 'fusion-auto', version, enabled: true, quality: 'experimental',
101
+ return { schemaVersion: 1, id, version, enabled: true, quality: 'experimental',
73
102
  ...routes, workerUpgradePath: [], dataPolicyId: 'native-host-workspace-policy', evidenceCampaignIds: [],
74
103
  ...(outputTokens === undefined ? {} : { context: Object.fromEntries(Object.entries(outputTokens)
75
104
  .map(([role, reserveOutputTokens]) => [role, { reserveOutputTokens }])) }) }
76
105
  }
77
106
 
78
- /** Product defaults; explicit legacy/benchmark profiles retain their old contract. */
79
- export function modelProfileFromChoice(pair: PairChoice): Record<string, unknown> {
107
+ /**
108
+ * Product defaults; explicit legacy/benchmark profiles retain their old contract.
109
+ * `id` names the combo; the first one keeps the historical `fusion-auto` so a
110
+ * profile frozen before combos existed keeps its identity.
111
+ */
112
+ export function modelProfileFromChoice(pair: PairChoice, id = 'fusion-auto'): Record<string, unknown> {
80
113
  const { outputTokens: _output, ...native } = pair
81
- return { ...profileFromChoice(native), version: '0.2.0-enforced-v3', interactionMode: 'model-like', workflowPolicy: 'enforced-v3' }
114
+ return { ...profileFromChoice(native, id), version: '0.2.0-enforced-v3', interactionMode: 'model-like', workflowPolicy: 'enforced-v3' }
82
115
  }
83
116
 
84
117
  /** Keep existing default settings stable; expose explicit larger/smaller limits on reload. */
@@ -1,4 +1,6 @@
1
1
  import { readModelControl } from './native-model-control.js'
2
+ import { readSandboxIntent, roleSeparated } from './native-role-sandbox.js'
3
+ import type { Phase, Role } from '../contracts.js'
2
4
  import type { TaskState } from '../task/state.js'
3
5
  import type { SqliteFusionStore } from '../task/sqlite-store.js'
4
6
  import type { StatusIndex } from '../task/status-index.js'
@@ -27,6 +29,18 @@ const EN: Record<string, string> = {
27
29
  }
28
30
  const english = (text: string | null | undefined) => text == null ? text : EN[text] ?? text
29
31
 
32
+ /**
33
+ * The role working right now: an in-flight request names it exactly (requests read
34
+ * newest first), and between requests the phase does ('DIRECT' is the Lead writing).
35
+ */
36
+ function activeRole(requests: readonly { role: Role; purpose: string; outcome: string }[], phase: Phase): Role | null {
37
+ const running = requests.find(request => request.purpose === 'conversation' && request.outcome === 'entered')
38
+ if (running) return running.role
39
+ if (phase === 'WORKER_RUNNING' || phase === 'REWORK') return 'worker'
40
+ if (phase === 'PLANNING' || phase === 'REVIEWING' || phase === 'DIRECT') return 'lead'
41
+ return null
42
+ }
43
+
30
44
  export function taskStage(state: TaskState, checking: boolean, workerStepLimitReached = false, workerQuotaExhausted = false): Pick<NonNullable<FusionStatus['task']>, 'stage' | 'detail' | 'attention'> {
31
45
  const control = state.control
32
46
  const value = (stage: string, detail: string | null = null, attention = false) => ({ stage, detail, attention })
@@ -81,7 +95,8 @@ function readStatus(store: SqliteFusionStore, sessionId: string, index: StatusIn
81
95
  const { checking, ...facts } = index.read(taskId)
82
96
  const runtime = store.readDocument(`runtime:${taskId}`)?.value as { schemaVersion?: number; taskId?: string;
83
97
  workerStepStop?: { workOrderId?: string; requests: number; limit: number };
84
- workerFailure?: { workOrderId?: string; childId?: string; category?: string } } | undefined
98
+ workerFailure?: { workOrderId?: string; childId?: string; category?: string };
99
+ takeover?: boolean } | undefined
85
100
  const stop = runtime?.workerStepStop
86
101
  const workerStepLimitReached = runtime?.schemaVersion === 1 && runtime.taskId === taskId && Boolean(stop
87
102
  && state.currentWorkOrder && stop.workOrderId === state.currentWorkOrder.id && stop.limit > 0 && stop.requests >= stop.limit)
@@ -98,10 +113,18 @@ function readStatus(store: SqliteFusionStore, sessionId: string, index: StatusIn
98
113
  const pendingDelivery = modelControl?.operation && ['prepared', 'dispatching'].includes(modelControl.operation.state)
99
114
  ? { stage: '等待检查', detail: '继续消息的投递结果尚未确认。检查会话后使用 /fusion recover 提交检查记录。', attention: true } : {}
100
115
  const stage = { ...taskStage(state, checking, workerStepLimitReached, workerQuotaExhausted), ...modelStage, ...pendingDelivery }
116
+ // The composer's own permission control follows the conversation (Lead) session,
117
+ // where Fusion's read-only confinement matches no configured preset. Report the
118
+ // per-role modes so the UI can name the permission of the role that is working.
119
+ const intent = roleSeparated(binding) ? readSandboxIntent(store, sessionId) : undefined
120
+ const takeover = runtime?.schemaVersion === 1 && runtime.taskId === taskId && runtime.takeover === true
121
+ const permissions = intent && { lead: takeover ? intent.mode : 'read-only' as const,
122
+ worker: intent.mode, active: activeRole(facts.requests, state.phase) }
101
123
  return { ...base, task: { id: taskId, revision: state.revision, ...stage, stageEn: english(stage.stage)!,
102
124
  detailEn: 'detailEn' in stage && stage.detailEn ? stage.detailEn : english(stage.detail) ?? null, ...(modelControl ? { modelControl } : {}), verification: state.verification,
103
125
  workerId: state.acceptedChild ?? binding.workerId ?? null,
104
126
  models: { lead: modelControl?.routes.lead ?? binding.profile.lead, worker: modelControl?.routes.worker ?? binding.profile.worker, compactor: binding.profile.compactor?.route ?? null },
127
+ ...(permissions ? { permissions } : {}),
105
128
  ...facts,
106
129
  automatedChecks: Array.isArray((runtime as { checks?: unknown[] } | undefined)?.checks) && runtime?.taskId === taskId
107
130
  ? (runtime as { checks: unknown[] }).checks.length : null,