@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.
- package/INSTALL.md +89 -0
- package/INSTALL.zh.md +86 -0
- package/LICENSE +201 -0
- package/README.en.md +193 -0
- package/README.md +182 -2
- package/RELEASING.md +123 -0
- package/RELEASING.zh.md +115 -0
- package/contracts/fusion.ts +279 -0
- package/contracts/storage.sql +86 -0
- package/cordis.patch.yml +3 -0
- package/docs/COMPATIBILITY.md +162 -0
- package/docs/EVIDENCE.md +141 -0
- package/dshx.yml +39 -0
- package/lib/client.js +1920 -0
- package/lib/client.js.map +1 -0
- package/lib/dsh-fusion.js +12664 -0
- package/lib/types/activity.d.ts +20 -0
- package/lib/types/approval/logical.d.ts +10 -0
- package/lib/types/benchmark/naive.d.ts +37 -0
- package/lib/types/benchmark/native-run.d.ts +68 -0
- package/lib/types/benchmark/output-limits.d.ts +19 -0
- package/lib/types/bilingual.d.ts +2 -0
- package/lib/types/client/ActivityRows.d.ts +8 -0
- package/lib/types/client/FusionCache.d.ts +4 -0
- package/lib/types/client/FusionHint.d.ts +9 -0
- package/lib/types/client/FusionHistory.d.ts +5 -0
- package/lib/types/client/FusionPanelIcon.d.ts +13 -0
- package/lib/types/client/FusionRecovery.d.ts +5 -0
- package/lib/types/client/FusionSettings.d.ts +9 -0
- package/lib/types/client/FusionStatus.d.ts +5 -0
- package/lib/types/client/FusionTool.d.ts +4 -0
- package/lib/types/client/activity-model.d.ts +14 -0
- package/lib/types/client/i18n.d.ts +15 -0
- package/lib/types/client/index.d.ts +22 -0
- package/lib/types/client/settings-events.d.ts +2 -0
- package/lib/types/client/status-polling.d.ts +9 -0
- package/lib/types/context/guard.d.ts +21 -0
- package/lib/types/context/handoff.d.ts +17 -0
- package/lib/types/contracts.d.ts +449 -0
- package/lib/types/digest.d.ts +5 -0
- package/lib/types/dsh-fusion.d.ts +13 -0
- package/lib/types/errors.d.ts +28 -0
- package/lib/types/evidence/gate.d.ts +23 -0
- package/lib/types/evidence/receipts.d.ts +65 -0
- package/lib/types/execution/write-lease.d.ts +42 -0
- package/lib/types/host/bindings.d.ts +30 -0
- package/lib/types/host/cache-defaults.d.ts +48 -0
- package/lib/types/host/cache-policy.d.ts +155 -0
- package/lib/types/host/change-evidence.d.ts +38 -0
- package/lib/types/host/compaction-routing.d.ts +15 -0
- package/lib/types/host/coordinator.d.ts +169 -0
- package/lib/types/host/evidence-page.d.ts +13 -0
- package/lib/types/host/history.d.ts +88 -0
- package/lib/types/host/http.d.ts +4 -0
- package/lib/types/host/model-output.d.ts +13 -0
- package/lib/types/host/native-activity.d.ts +26 -0
- package/lib/types/host/native-approvals.d.ts +29 -0
- package/lib/types/host/native-auxiliary.d.ts +17 -0
- package/lib/types/host/native-budget.d.ts +33 -0
- package/lib/types/host/native-checks.d.ts +104 -0
- package/lib/types/host/native-compaction.d.ts +8 -0
- package/lib/types/host/native-context.d.ts +36 -0
- package/lib/types/host/native-effects.d.ts +75 -0
- package/lib/types/host/native-evidence.d.ts +4 -0
- package/lib/types/host/native-exploration.d.ts +15 -0
- package/lib/types/host/native-keepalive.d.ts +82 -0
- package/lib/types/host/native-model-control.d.ts +99 -0
- package/lib/types/host/native-on-demand-context.d.ts +39 -0
- package/lib/types/host/native-request.d.ts +5 -0
- package/lib/types/host/native-review.d.ts +11 -0
- package/lib/types/host/native-role-sandbox.d.ts +49 -0
- package/lib/types/host/native-scopes.d.ts +49 -0
- package/lib/types/host/native-selection.d.ts +29 -0
- package/lib/types/host/native-task-context.d.ts +39 -0
- package/lib/types/host/native-usage.d.ts +46 -0
- package/lib/types/host/native-worker-brief.d.ts +74 -0
- package/lib/types/host/native-worker-notices.d.ts +15 -0
- package/lib/types/host/native-worker.d.ts +44 -0
- package/lib/types/host/native-workflow.d.ts +41 -0
- package/lib/types/host/route-transport.d.ts +22 -0
- package/lib/types/host/settings.d.ts +39 -0
- package/lib/types/host/shell.d.ts +11 -0
- package/lib/types/host/status.d.ts +7 -0
- package/lib/types/host/subagent-seam.d.ts +10 -0
- package/lib/types/host/tool-table.d.ts +35 -0
- package/lib/types/host/worker-terminal.d.ts +68 -0
- package/lib/types/host/workspace-guard.d.ts +13 -0
- package/lib/types/host/workspace.d.ts +52 -0
- package/lib/types/identity.d.ts +64 -0
- package/lib/types/index.d.ts +34 -0
- package/lib/types/message-source.d.ts +29 -0
- package/lib/types/profile/compactor.d.ts +5 -0
- package/lib/types/profile/keepalive.d.ts +3 -0
- package/lib/types/profile/resolve.d.ts +17 -0
- package/lib/types/prompts.d.ts +8 -0
- package/lib/types/recovery/reconcile.d.ts +28 -0
- package/lib/types/review/dispatch.d.ts +20 -0
- package/lib/types/review/protocol.d.ts +51 -0
- package/lib/types/review/ticket.d.ts +32 -0
- package/lib/types/status.d.ts +88 -0
- package/lib/types/store-migration.d.ts +29 -0
- package/lib/types/task/deletion.d.ts +64 -0
- package/lib/types/task/reducer.d.ts +6 -0
- package/lib/types/task/sqlite-store.d.ts +71 -0
- package/lib/types/task/state.d.ts +51 -0
- package/lib/types/task/status-index.d.ts +28 -0
- package/lib/types/task/store.d.ts +48 -0
- package/lib/types/usage/budget.d.ts +27 -0
- package/lib/types/usage/ledger.d.ts +66 -0
- package/lib/types/usage/normalize.d.ts +50 -0
- package/lib/types/usage/pricing.d.ts +18 -0
- package/package.json +178 -4
- package/profiles/fusion-auto.example.json +45 -0
- package/prompts/compact.md +7 -0
- package/prompts/lead.md +42 -0
- package/prompts/model-lead.md +13 -0
- package/prompts/model-worker.md +5 -0
- package/prompts/worker.md +21 -0
- package/src/activity.ts +23 -0
- package/src/approval/logical.ts +44 -0
- package/src/benchmark/naive.ts +216 -0
- package/src/benchmark/native-run.ts +215 -0
- package/src/benchmark/output-limits.ts +33 -0
- package/src/bilingual.ts +2 -0
- package/src/client/ActivityRows.tsx +59 -0
- package/src/client/FusionCache.tsx +130 -0
- package/src/client/FusionHint.tsx +39 -0
- package/src/client/FusionHistory.tsx +247 -0
- package/src/client/FusionPanelIcon.tsx +39 -0
- package/src/client/FusionRecovery.tsx +69 -0
- package/src/client/FusionSettings.tsx +205 -0
- package/src/client/FusionStatus.tsx +103 -0
- package/src/client/FusionTool.tsx +66 -0
- package/src/client/activity-model.ts +31 -0
- package/src/client/i18n.ts +30 -0
- package/src/client/index.ts +50 -0
- package/src/client/settings-events.ts +3 -0
- package/src/client/status-polling.ts +88 -0
- package/src/context/guard.ts +81 -0
- package/src/context/handoff.ts +28 -0
- package/src/contracts.ts +449 -0
- package/src/digest.ts +28 -0
- package/src/dsh-fusion.ts +321 -0
- package/src/errors.ts +34 -0
- package/src/evidence/gate.ts +290 -0
- package/src/evidence/receipts.ts +175 -0
- package/src/execution/write-lease.ts +187 -0
- package/src/host/bindings.ts +91 -0
- package/src/host/cache-defaults.ts +103 -0
- package/src/host/cache-policy.ts +168 -0
- package/src/host/change-evidence.ts +156 -0
- package/src/host/compaction-routing.ts +46 -0
- package/src/host/coordinator.ts +2160 -0
- package/src/host/evidence-page.ts +25 -0
- package/src/host/history.ts +114 -0
- package/src/host/http.ts +30 -0
- package/src/host/model-output.ts +22 -0
- package/src/host/native-activity.ts +199 -0
- package/src/host/native-approvals.ts +196 -0
- package/src/host/native-auxiliary.ts +19 -0
- package/src/host/native-budget.ts +112 -0
- package/src/host/native-checks.ts +474 -0
- package/src/host/native-compaction.ts +34 -0
- package/src/host/native-context.ts +204 -0
- package/src/host/native-effects.ts +260 -0
- package/src/host/native-evidence.ts +31 -0
- package/src/host/native-exploration.ts +114 -0
- package/src/host/native-keepalive.ts +292 -0
- package/src/host/native-model-control.ts +298 -0
- package/src/host/native-on-demand-context.ts +74 -0
- package/src/host/native-request.ts +12 -0
- package/src/host/native-review.ts +51 -0
- package/src/host/native-role-sandbox.ts +171 -0
- package/src/host/native-scopes.ts +203 -0
- package/src/host/native-selection.ts +110 -0
- package/src/host/native-task-context.ts +264 -0
- package/src/host/native-usage.ts +121 -0
- package/src/host/native-worker-brief.ts +122 -0
- package/src/host/native-worker-notices.ts +72 -0
- package/src/host/native-worker.ts +215 -0
- package/src/host/native-workflow.ts +103 -0
- package/src/host/route-transport.ts +54 -0
- package/src/host/settings.ts +93 -0
- package/src/host/shell.ts +13 -0
- package/src/host/status.ts +112 -0
- package/src/host/subagent-seam.ts +20 -0
- package/src/host/tool-table.ts +56 -0
- package/src/host/worker-terminal.ts +91 -0
- package/src/host/workspace-guard.ts +17 -0
- package/src/host/workspace.ts +184 -0
- package/src/identity.ts +76 -0
- package/src/index.ts +50 -0
- package/src/message-source.ts +31 -0
- package/src/profile/compactor.ts +26 -0
- package/src/profile/keepalive.ts +13 -0
- package/src/profile/resolve.ts +131 -0
- package/src/prompts.ts +32 -0
- package/src/recovery/reconcile.ts +38 -0
- package/src/review/dispatch.ts +48 -0
- package/src/review/protocol.ts +212 -0
- package/src/review/ticket.ts +166 -0
- package/src/status.ts +55 -0
- package/src/store-migration.ts +65 -0
- package/src/task/deletion.ts +105 -0
- package/src/task/reducer.ts +679 -0
- package/src/task/sqlite-store.ts +386 -0
- package/src/task/state.ts +72 -0
- package/src/task/status-index.ts +198 -0
- package/src/task/store.ts +287 -0
- package/src/usage/budget.ts +88 -0
- package/src/usage/ledger.ts +218 -0
- package/src/usage/normalize.ts +308 -0
- package/src/usage/pricing.ts +95 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { Digest, SnapshotId, TaskId, ToolEvidence, WorkOrder, WorkOrderId } from '../contracts.js'
|
|
2
|
+
import { digestOf } from '../digest.js'
|
|
3
|
+
import { EVENT_CONFLICT, FusionError } from '../errors.js'
|
|
4
|
+
|
|
5
|
+
export interface InvocationHeader {
|
|
6
|
+
taskId: ToolEvidence['taskId']
|
|
7
|
+
workOrderId?: WorkOrderId
|
|
8
|
+
revision?: number
|
|
9
|
+
operationId: ToolEvidence['operationId']
|
|
10
|
+
actor: ToolEvidence['actor']
|
|
11
|
+
nativeToolCallId: string
|
|
12
|
+
argvDigest: Digest
|
|
13
|
+
cwdDigest: Digest
|
|
14
|
+
inputSnapshot: SnapshotId
|
|
15
|
+
startedAt: string
|
|
16
|
+
verifierPlanDigest?: Digest
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CheckPlan {
|
|
20
|
+
id: string
|
|
21
|
+
version: number
|
|
22
|
+
taskId: TaskId
|
|
23
|
+
workOrderId: WorkOrderId
|
|
24
|
+
revision: number
|
|
25
|
+
criterionId: string
|
|
26
|
+
kind: 'test' | 'static-check' | 'human' | 'review'
|
|
27
|
+
snapshot: SnapshotId
|
|
28
|
+
/**
|
|
29
|
+
* Version 2 may freeze the verifier before editing. `snapshot` then names the
|
|
30
|
+
* WorkOrder base; native invocation receipts must still bind both input and
|
|
31
|
+
* output to the exact current candidate. Omission retains v1 exact semantics.
|
|
32
|
+
*/
|
|
33
|
+
snapshotBinding?: 'candidate'
|
|
34
|
+
cwdDigest: Digest
|
|
35
|
+
argvDigest: Digest
|
|
36
|
+
definitionDigest: Digest
|
|
37
|
+
policyDigest: Digest
|
|
38
|
+
minExecutedTests?: number
|
|
39
|
+
/**
|
|
40
|
+
* Baseline-relative test check: ids that already failed on the untouched workspace, frozen before the
|
|
41
|
+
* Worker started. Only these may fail on the candidate; every other executed test must pass.
|
|
42
|
+
*/
|
|
43
|
+
allowedFailures?: readonly string[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface PlannedReceipt {
|
|
47
|
+
id: string
|
|
48
|
+
planDigest: Digest
|
|
49
|
+
evidence: ToolEvidence
|
|
50
|
+
counts?: { executed: number; passed: number; failed: number
|
|
51
|
+
/** Failures frozen in the plan's baseline (not counted as executed). */
|
|
52
|
+
knownFailures?: readonly string[]
|
|
53
|
+
/** Failures absent from the baseline; counted in `failed`. */
|
|
54
|
+
newFailures?: readonly string[] }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface VerificationRegistry {
|
|
58
|
+
getPlan(planId: string): CheckPlan | undefined
|
|
59
|
+
getReceipt(receiptId: string): PlannedReceipt | undefined
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function memoryVerificationRegistry(
|
|
63
|
+
plans: readonly CheckPlan[],
|
|
64
|
+
receipts: readonly PlannedReceipt[],
|
|
65
|
+
): VerificationRegistry {
|
|
66
|
+
const planById = new Map(plans.map(plan => [plan.id, freezePlan(plan)]))
|
|
67
|
+
const receiptById = new Map(receipts.map(receipt => [receipt.id, structuredClone(receipt)]))
|
|
68
|
+
return {
|
|
69
|
+
getPlan: id => planById.get(id),
|
|
70
|
+
getReceipt: id => {
|
|
71
|
+
const receipt = receiptById.get(id)
|
|
72
|
+
return receipt === undefined ? undefined : structuredClone(receipt)
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function freezePlan(plan: CheckPlan): CheckPlan {
|
|
78
|
+
return Object.freeze(structuredClone(plan))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function requireThat(value: unknown, code: string): asserts value {
|
|
82
|
+
if (!value) throw new FusionError(EVENT_CONFLICT, code)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function assertPlannedCheck(
|
|
86
|
+
workOrder: WorkOrder,
|
|
87
|
+
criterionId: string,
|
|
88
|
+
plan: CheckPlan,
|
|
89
|
+
receipt: PlannedReceipt,
|
|
90
|
+
actualSnapshot: SnapshotId,
|
|
91
|
+
): void {
|
|
92
|
+
requireThat(plan.taskId === workOrder.taskId && plan.workOrderId === workOrder.id && plan.revision === workOrder.revision, 'PLAN_SCOPE_MISMATCH')
|
|
93
|
+
requireThat(plan.criterionId === criterionId, 'CRITERION_SCOPE_MISMATCH')
|
|
94
|
+
requireThat(receipt.planDigest === digestOf(plan), 'PLAN_DIGEST_MISMATCH')
|
|
95
|
+
if (plan.snapshotBinding === undefined) {
|
|
96
|
+
requireThat(plan.snapshot === actualSnapshot, 'PLAN_SNAPSHOT_MISMATCH')
|
|
97
|
+
} else {
|
|
98
|
+
requireThat(plan.snapshotBinding === 'candidate' && plan.version === 2, 'UNSUPPORTED_SNAPSHOT_BINDING')
|
|
99
|
+
requireThat(plan.snapshot === workOrder.baseSnapshot, 'PLAN_BASE_SNAPSHOT_MISMATCH')
|
|
100
|
+
}
|
|
101
|
+
const criterion = workOrder.acceptance.find(item => item.id === criterionId)
|
|
102
|
+
requireThat(criterion && criterion.verificationKind === plan.kind, 'VERIFIER_KIND_MISMATCH')
|
|
103
|
+
requireThat(criterion.planId === plan.id && criterion.planDigest === digestOf(plan), 'PLAN_NOT_FROZEN_IN_WORK_ORDER')
|
|
104
|
+
requireThat(plan.kind === 'test' || plan.kind === 'static-check', 'USE_TYPED_HUMAN_OR_REVIEW_RECEIPT')
|
|
105
|
+
requireThat(plan.cwdDigest && plan.argvDigest, 'EXACT_VERIFIER_CONTEXT_REQUIRED')
|
|
106
|
+
const evidence = receipt.evidence
|
|
107
|
+
requireThat(evidence.taskId === workOrder.taskId && evidence.cwdDigest === plan.cwdDigest && evidence.argvDigest === plan.argvDigest, 'INVOCATION_SCOPE_MISMATCH')
|
|
108
|
+
// A baseline-relative run exits non-zero while frozen pre-existing failures remain; its counts must then
|
|
109
|
+
// show that every failure was one of them.
|
|
110
|
+
const knownOnly = plan.kind === 'test' && plan.allowedFailures !== undefined && receipt.counts?.failed === 0
|
|
111
|
+
&& (receipt.counts.knownFailures ?? []).every(id => plan.allowedFailures!.includes(id))
|
|
112
|
+
requireThat(evidence.state === 'completed' && evidence.endedAt !== null
|
|
113
|
+
&& (evidence.exitCode === 0 || (knownOnly && evidence.exitCode !== null && (receipt.counts?.knownFailures?.length ?? 0) > 0)), 'CHECK_NOT_SUCCESSFUL')
|
|
114
|
+
requireThat(evidence.inputSnapshot === actualSnapshot && evidence.outputSnapshot === actualSnapshot, 'CHECK_SNAPSHOT_MISMATCH')
|
|
115
|
+
if (plan.kind === 'test') {
|
|
116
|
+
const counts = receipt.counts
|
|
117
|
+
requireThat(counts && [counts.executed, counts.passed, counts.failed].every(value => Number.isSafeInteger(value) && value >= 0), 'TEST_COUNTS_REQUIRED')
|
|
118
|
+
requireThat(counts.executed >= (plan.minExecutedTests ?? 1) && counts.failed === 0 && counts.passed === counts.executed, 'TEST_SUITE_NOT_PASSED')
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function invocationHeader(record: ToolEvidence, extras: Partial<InvocationHeader> = {}): InvocationHeader {
|
|
123
|
+
return {
|
|
124
|
+
taskId: record.taskId,
|
|
125
|
+
operationId: record.operationId,
|
|
126
|
+
actor: record.actor,
|
|
127
|
+
nativeToolCallId: record.nativeToolCallId,
|
|
128
|
+
argvDigest: record.argvDigest,
|
|
129
|
+
cwdDigest: record.cwdDigest,
|
|
130
|
+
inputSnapshot: record.inputSnapshot,
|
|
131
|
+
startedAt: record.startedAt,
|
|
132
|
+
...extras,
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function sameInvocationHeader(left: InvocationHeader, right: InvocationHeader): boolean {
|
|
137
|
+
return digestOf(left) === digestOf(right)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function artifactFingerprint(ref: ToolEvidence['stdout']): string {
|
|
141
|
+
return digestOf({
|
|
142
|
+
id: ref.id,
|
|
143
|
+
digest: ref.digest,
|
|
144
|
+
bytes: ref.bytes,
|
|
145
|
+
ownerTaskId: ref.ownerTaskId,
|
|
146
|
+
mediaType: ref.mediaType,
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function resolveInvocation(records: readonly ToolEvidence[]): ToolEvidence {
|
|
151
|
+
if (!records.length) throw new FusionError(EVENT_CONFLICT, 'NO_INVOCATION')
|
|
152
|
+
const header = invocationHeader(records[0]!)
|
|
153
|
+
for (const record of records) {
|
|
154
|
+
if (!sameInvocationHeader(header, invocationHeader(record))) {
|
|
155
|
+
throw new FusionError(EVENT_CONFLICT, 'IMMUTABLE_INVOCATION_CHANGED')
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const terminals = records.filter(record => record.state !== 'started')
|
|
159
|
+
if (!terminals.length) return records[0]!
|
|
160
|
+
const first = terminals[0]!
|
|
161
|
+
for (const terminal of terminals) {
|
|
162
|
+
if (digestOf({ ...first, id: undefined }) !== digestOf({ ...terminal, id: undefined })) {
|
|
163
|
+
throw new FusionError(EVENT_CONFLICT, 'EVIDENCE_TERMINAL_CONFLICT')
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (first.endedAt === null) throw new FusionError(EVENT_CONFLICT, 'TERMINAL_END_REQUIRED')
|
|
167
|
+
if (first.state === 'completed' && (first.exitCode === null || !Number.isSafeInteger(first.exitCode))) {
|
|
168
|
+
throw new FusionError(EVENT_CONFLICT, 'EXIT_STATUS_REQUIRED')
|
|
169
|
+
}
|
|
170
|
+
return first
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function checkPlanDigest(plan: CheckPlan): Digest {
|
|
174
|
+
return digestOf(plan)
|
|
175
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
2
|
+
import { DatabaseSync } from 'node:sqlite'
|
|
3
|
+
import * as fs from 'node:fs'
|
|
4
|
+
import * as os from 'node:os'
|
|
5
|
+
import * as path from 'node:path'
|
|
6
|
+
import type { OperationId, SessionId, TaskId, WriteLease } from '../contracts.js'
|
|
7
|
+
import { FusionError, LEASE_BUSY, LEASE_GENERATION } from '../errors.js'
|
|
8
|
+
|
|
9
|
+
export interface LeaseRecord extends WriteLease {
|
|
10
|
+
readonly owner: string
|
|
11
|
+
readonly pid: number
|
|
12
|
+
readonly acquiredAt: string
|
|
13
|
+
readonly liveProcessRefs: readonly number[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function canonicalWorkspace(cwd: string): string {
|
|
17
|
+
return fs.realpathSync.native(cwd)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DURABLE_LEASE_ROOT = path.join(os.homedir(), '.local', 'state', 'dsh-fusion', 'leases')
|
|
21
|
+
/** Where this plugin kept durable leases before it was renamed from `dsh-model-fusion`. */
|
|
22
|
+
const LEGACY_LEASE_ROOT = path.join(os.homedir(), '.local', 'state', 'dsh-model-fusion', 'leases')
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Move a pre-rename lease registry to its current name.
|
|
26
|
+
*
|
|
27
|
+
* The registry outlives a single process, so a rename must carry it: a fresh
|
|
28
|
+
* empty directory beside a lock nobody reads would report the workspace as
|
|
29
|
+
* free while a previous holder believes it still owns the write lease. A move
|
|
30
|
+
* keeps that holder visible to the next process; a cross-device install copies
|
|
31
|
+
* the directory instead, and a failed copy removes only what it created.
|
|
32
|
+
*
|
|
33
|
+
* @param next - registry directory the current release uses.
|
|
34
|
+
* @param legacy - registry directory the previous name used.
|
|
35
|
+
* @returns whether a legacy registry was adopted.
|
|
36
|
+
*/
|
|
37
|
+
export function adoptLegacyLeaseRoot(next: string, legacy = LEGACY_LEASE_ROOT): boolean {
|
|
38
|
+
if (fs.existsSync(next) || !fs.existsSync(legacy)) return false
|
|
39
|
+
try {
|
|
40
|
+
fs.renameSync(legacy, next)
|
|
41
|
+
return true
|
|
42
|
+
} catch {
|
|
43
|
+
// Cross-device or a locked file: fall back to a copy, then drop the source.
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
fs.cpSync(legacy, next, { recursive: true })
|
|
47
|
+
} catch {
|
|
48
|
+
fs.rmSync(next, { recursive: true, force: true })
|
|
49
|
+
return false
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
fs.rmSync(legacy, { recursive: true, force: true })
|
|
53
|
+
} catch {
|
|
54
|
+
// The new copy is complete; a leftover legacy directory is harmless.
|
|
55
|
+
}
|
|
56
|
+
return true
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function leasePath(workspaceId: string, root = DURABLE_LEASE_ROOT): string {
|
|
60
|
+
const key = createHash('sha256').update(workspaceId).digest('hex').slice(0, 24)
|
|
61
|
+
return path.join(root, `${key}.lock`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class WorkspaceWriteLease {
|
|
65
|
+
constructor(private readonly root = DURABLE_LEASE_ROOT) {
|
|
66
|
+
if (!path.isAbsolute(root)) throw new TypeError('lease registry directory must be absolute')
|
|
67
|
+
adoptLegacyLeaseRoot(root)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
acquire(input: {
|
|
71
|
+
cwd: string
|
|
72
|
+
holder: SessionId
|
|
73
|
+
taskId: TaskId
|
|
74
|
+
operationId: OperationId
|
|
75
|
+
liveProcessRefs?: readonly number[]
|
|
76
|
+
}): LeaseRecord {
|
|
77
|
+
const workspaceId = canonicalWorkspace(input.cwd)
|
|
78
|
+
return this.#transaction(workspaceId, db => {
|
|
79
|
+
const row = this.#row(db)
|
|
80
|
+
const prior = this.#record(row, workspaceId)
|
|
81
|
+
if (prior) throw new FusionError(LEASE_BUSY, `workspace is held by ${prior.holder}; reconcile before handoff`)
|
|
82
|
+
if (row.generation === Number.MAX_SAFE_INTEGER) throw new FusionError(LEASE_GENERATION, 'lease generation exhausted')
|
|
83
|
+
const refs = [...new Set([process.pid, ...(input.liveProcessRefs ?? [])])]
|
|
84
|
+
if (refs.some(pid => !Number.isSafeInteger(pid) || pid <= 0)) throw new FusionError(LEASE_BUSY, 'invalid process reference')
|
|
85
|
+
const record: LeaseRecord = {
|
|
86
|
+
workspaceId, holder: input.holder, taskId: input.taskId, generation: row.generation + 1,
|
|
87
|
+
activeOperationIds: [input.operationId], owner: `${process.pid}:${randomUUID()}`, pid: process.pid,
|
|
88
|
+
acquiredAt: new Date().toISOString(), liveProcessRefs: refs,
|
|
89
|
+
}
|
|
90
|
+
db.prepare('UPDATE lease SET generation=?, record=? WHERE id=1').run(record.generation, JSON.stringify(record))
|
|
91
|
+
return record
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
assertGeneration(lease: WriteLease, generation: number): void {
|
|
96
|
+
const current = this.current(lease.workspaceId)
|
|
97
|
+
if (lease.generation !== generation || current?.generation !== generation
|
|
98
|
+
|| current.holder !== lease.holder || current.taskId !== lease.taskId) {
|
|
99
|
+
throw new FusionError(LEASE_GENERATION, `stale write generation ${generation}`)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
stillHeld(lease: LeaseRecord): boolean {
|
|
104
|
+
const current = this.current(lease.workspaceId)
|
|
105
|
+
return current?.owner === lease.owner && current.generation === lease.generation
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Caller must prove native Worker/tool quiescence before releasing. */
|
|
109
|
+
release(lease: LeaseRecord): void {
|
|
110
|
+
this.#transaction(lease.workspaceId, db => {
|
|
111
|
+
const current = this.#record(this.#row(db), lease.workspaceId)
|
|
112
|
+
if (current?.owner === lease.owner && current.generation === lease.generation) {
|
|
113
|
+
db.prepare('UPDATE lease SET record=NULL WHERE id=1 AND generation=?').run(lease.generation)
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Trusted recovery inspection. A dead PID by itself never authorizes stealing. */
|
|
119
|
+
current(cwd: string): LeaseRecord | undefined {
|
|
120
|
+
const workspaceId = canonicalWorkspace(cwd)
|
|
121
|
+
return this.#transaction(workspaceId, db => this.#record(this.#row(db), workspaceId))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
#row(db: DatabaseSync): { generation: number; record: unknown } {
|
|
125
|
+
const row = db.prepare('SELECT generation, record FROM lease WHERE id=1').get()
|
|
126
|
+
if (!row || typeof row.generation !== 'number' || !Number.isSafeInteger(row.generation) || row.generation < 0) {
|
|
127
|
+
throw new FusionError(LEASE_BUSY, 'durable lease generation is corrupt')
|
|
128
|
+
}
|
|
129
|
+
return { generation: row.generation, record: row.record }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
#record(row: { generation: number; record: unknown }, workspaceId: string): LeaseRecord | undefined {
|
|
133
|
+
if (row.record === null) return undefined
|
|
134
|
+
if (typeof row.record !== 'string') throw new FusionError(LEASE_BUSY, 'invalid durable lease record')
|
|
135
|
+
const value = JSON.parse(row.record) as Partial<LeaseRecord>
|
|
136
|
+
if (value.workspaceId !== workspaceId || value.generation !== row.generation
|
|
137
|
+
|| typeof value.owner !== 'string' || typeof value.holder !== 'string' || typeof value.taskId !== 'string'
|
|
138
|
+
|| !Number.isSafeInteger(value.pid) || Number(value.pid) <= 0 || typeof value.acquiredAt !== 'string'
|
|
139
|
+
|| !Array.isArray(value.activeOperationIds) || !Array.isArray(value.liveProcessRefs)) {
|
|
140
|
+
throw new FusionError(LEASE_BUSY, 'durable lease requires reconciliation')
|
|
141
|
+
}
|
|
142
|
+
return value as LeaseRecord
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Atomic publication/CAS; generations survive release and process restart. */
|
|
146
|
+
#transaction<T>(workspaceId: string, body: (db: DatabaseSync) => T): T {
|
|
147
|
+
const legacy = leasePath(workspaceId, this.root)
|
|
148
|
+
fs.mkdirSync(path.dirname(legacy), { recursive: true, mode: 0o700 })
|
|
149
|
+
const oldTemporary = leasePath(workspaceId, path.join(fs.existsSync('/tmp') ? '/tmp' : os.tmpdir(), 'dsh-model-fusion-leases'))
|
|
150
|
+
// A lock written under the pre-rename registry, or by the pre-rename
|
|
151
|
+
// temporary one, still names a writer: refuse rather than take the
|
|
152
|
+
// workspace out from under it.
|
|
153
|
+
const oldDurable = leasePath(workspaceId, LEGACY_LEASE_ROOT)
|
|
154
|
+
for (const lock of new Set([legacy, oldDurable, oldTemporary])) {
|
|
155
|
+
try {
|
|
156
|
+
fs.lstatSync(lock)
|
|
157
|
+
throw new FusionError(LEASE_BUSY, 'legacy writer lock requires explicit reconciliation')
|
|
158
|
+
} catch (error) {
|
|
159
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const db = new DatabaseSync(`${legacy}.sqlite`)
|
|
163
|
+
let transaction = false
|
|
164
|
+
try {
|
|
165
|
+
db.exec('PRAGMA busy_timeout=5000; PRAGMA synchronous=FULL; BEGIN IMMEDIATE;')
|
|
166
|
+
transaction = true
|
|
167
|
+
const version = db.prepare('PRAGMA user_version').get()?.user_version
|
|
168
|
+
if (version !== 0 && version !== 1) throw new FusionError(LEASE_BUSY, 'unknown lease database version')
|
|
169
|
+
if (version === 0) {
|
|
170
|
+
const tables = db.prepare("SELECT count(*) AS n FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").get()?.n
|
|
171
|
+
if (tables !== 0) throw new FusionError(LEASE_BUSY, 'unversioned nonempty lease database')
|
|
172
|
+
db.exec('CREATE TABLE lease (id INTEGER PRIMARY KEY CHECK(id=1), generation INTEGER NOT NULL, record TEXT); INSERT INTO lease VALUES (1, 0, NULL); PRAGMA user_version=1;')
|
|
173
|
+
}
|
|
174
|
+
const result = body(db)
|
|
175
|
+
db.exec('COMMIT'); transaction = false
|
|
176
|
+
return result
|
|
177
|
+
} catch (error) {
|
|
178
|
+
if (transaction) db.exec('ROLLBACK')
|
|
179
|
+
throw error
|
|
180
|
+
} finally { db.close() }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function classifyEffect(kind: 'read-tool' | 'write-tool' | 'unknown-shell'): 'read' | 'write' {
|
|
185
|
+
if (kind === 'read-tool') return 'read'
|
|
186
|
+
return 'write'
|
|
187
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { PairProfile, TaskId } from '../contracts.js'
|
|
2
|
+
import { TaskId as taskId } from '../contracts.js'
|
|
3
|
+
import { digestOf } from '../digest.js'
|
|
4
|
+
import { completeProfile } from '../profile/resolve.js'
|
|
5
|
+
import type { PromptBundle, ResolvedProfile } from '../profile/resolve.js'
|
|
6
|
+
import type { SqliteFusionStore } from '../task/sqlite-store.js'
|
|
7
|
+
|
|
8
|
+
export interface SessionBinding {
|
|
9
|
+
readonly schemaVersion: 1
|
|
10
|
+
readonly sessionId: string
|
|
11
|
+
readonly taskId: TaskId
|
|
12
|
+
readonly selected: boolean
|
|
13
|
+
/** One native continuable Worker for this selection, across completed tasks. */
|
|
14
|
+
readonly workerId?: string
|
|
15
|
+
readonly profile: PairProfile
|
|
16
|
+
readonly prompts: PromptBundle
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function object(value: unknown): Record<string, unknown> {
|
|
20
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid Fusion session binding')
|
|
21
|
+
return value as Record<string, unknown>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function readBinding(value: unknown, sessionId: string): SessionBinding {
|
|
25
|
+
const row = object(value)
|
|
26
|
+
if (row.schemaVersion !== 1 || row.sessionId !== sessionId || typeof row.taskId !== 'string'
|
|
27
|
+
|| typeof row.selected !== 'boolean') throw new Error('Fusion binding migration or reconciliation required')
|
|
28
|
+
const prompt = object(row.prompts)
|
|
29
|
+
if (row.workerId !== undefined && (typeof row.workerId !== 'string' || !row.workerId || row.workerId.includes('\0'))) {
|
|
30
|
+
throw new Error('Invalid persistent Fusion Worker identity')
|
|
31
|
+
}
|
|
32
|
+
if (typeof prompt.lead !== 'string' || typeof prompt.worker !== 'string' || typeof prompt.compact !== 'string') {
|
|
33
|
+
throw new Error('Frozen Fusion prompts are missing')
|
|
34
|
+
}
|
|
35
|
+
const prompts: PromptBundle = { lead: prompt.lead, worker: prompt.worker, compact: prompt.compact }
|
|
36
|
+
const profile = completeProfile(object(row.profile), prompts)
|
|
37
|
+
if (digestOf(profile) !== digestOf(row.profile)) throw new Error('Frozen Fusion profile or prompt digest changed')
|
|
38
|
+
return { schemaVersion: 1, sessionId, taskId: taskId(row.taskId), selected: row.selected, profile, prompts,
|
|
39
|
+
...(row.workerId === undefined ? {} : { workerId: row.workerId as string }) }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Selection is durable and scoped by the native Lead identity, never a global default. */
|
|
43
|
+
export class BindingRepository {
|
|
44
|
+
constructor(private readonly store: SqliteFusionStore) {}
|
|
45
|
+
|
|
46
|
+
read(sessionId: string): { revision: number; binding: SessionBinding } | undefined {
|
|
47
|
+
const document = this.store.readDocument(`binding:${sessionId}`)
|
|
48
|
+
return document ? { revision: document.revision, binding: readBinding(document.value, sessionId) } : undefined
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
select(sessionId: string, id: TaskId, resolved: ResolvedProfile): SessionBinding {
|
|
52
|
+
const prior = this.read(sessionId)
|
|
53
|
+
if (prior?.binding.selected) throw new Error('Fusion is already selected for this session')
|
|
54
|
+
const binding: SessionBinding = {
|
|
55
|
+
schemaVersion: 1, sessionId, taskId: id, selected: true,
|
|
56
|
+
profile: structuredClone(resolved.profile), prompts: structuredClone(resolved.prompts),
|
|
57
|
+
}
|
|
58
|
+
readBinding(binding, sessionId)
|
|
59
|
+
this.store.writeDocument(`binding:${sessionId}`, prior?.revision ?? 0, binding)
|
|
60
|
+
return binding
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
clear(sessionId: string): void {
|
|
64
|
+
const prior = this.read(sessionId)
|
|
65
|
+
if (!prior || !prior.binding.selected) return
|
|
66
|
+
this.store.writeDocument(`binding:${sessionId}`, prior.revision, { ...prior.binding, selected: false })
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Reserve identity before native dispatch; an existing selection cannot swap workers. */
|
|
70
|
+
assignWorker(sessionId: string, expectedTask: TaskId, workerId: string): void {
|
|
71
|
+
const prior = this.read(sessionId)
|
|
72
|
+
if (!prior?.binding.selected || prior.binding.taskId !== expectedTask) throw new Error('Fusion binding changed before Worker dispatch')
|
|
73
|
+
if (prior.binding.workerId && prior.binding.workerId !== workerId) throw new Error('Persistent Worker identity cannot change within a Fusion selection')
|
|
74
|
+
const binding = { ...prior.binding, workerId }
|
|
75
|
+
readBinding(binding, sessionId)
|
|
76
|
+
this.store.writeDocument(`binding:${sessionId}`, prior.revision, binding)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A new native user turn starts a new task while preserving the frozen pair. */
|
|
80
|
+
rollover(sessionId: string, expectedTask: TaskId, nextTask: TaskId): SessionBinding {
|
|
81
|
+
const prior = this.read(sessionId)
|
|
82
|
+
if (!prior?.binding.selected || prior.binding.taskId !== expectedTask) throw new Error('Fusion binding changed during task rollover')
|
|
83
|
+
const binding = { ...prior.binding, taskId: nextTask }
|
|
84
|
+
this.store.writeDocument(`binding:${sessionId}`, prior.revision, binding)
|
|
85
|
+
return binding
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
selected(): readonly SessionBinding[] {
|
|
89
|
+
return this.store.listDocumentIds('binding:').map(id => this.read(id.slice('binding:'.length))!.binding).filter(row => row.selected)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default prompt-cache behaviour for the models most people pair in Fusion, from each provider's official
|
|
3
|
+
* documentation. Matching uses the model id (stable across DSH setups), never the route name, except for the
|
|
4
|
+
* routes of this project's own OAuth plugins, whose billing we know. Everything here is a default: the user's
|
|
5
|
+
* per-model setting always wins, and the runtime can shorten an interval it observes to be too long.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const CACHE_DEFAULTS_CHECKED = '2026-09-27'
|
|
9
|
+
/** Safe for every provider documented here (the shortest documented lifetime is 5 minutes). */
|
|
10
|
+
export const GENERIC_INTERVAL_SECONDS = 285
|
|
11
|
+
|
|
12
|
+
export type CacheMode = 'auto' | 'on' | 'off'
|
|
13
|
+
|
|
14
|
+
export interface CacheFamily {
|
|
15
|
+
id: string
|
|
16
|
+
label: string
|
|
17
|
+
match: RegExp
|
|
18
|
+
/** Official lifetime text as documented (not a guarantee; caches are best-effort). */
|
|
19
|
+
lifetime: { zh: string; en: string }
|
|
20
|
+
/** Cached input price relative to uncached input, as documented. */
|
|
21
|
+
discount: string
|
|
22
|
+
keepalive: CacheMode
|
|
23
|
+
intervalSeconds: number
|
|
24
|
+
docs: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const CACHE_FAMILIES: readonly CacheFamily[] = [
|
|
28
|
+
{ id: 'openai', label: 'OpenAI', match: /^(gpt-|o\d|codex|chatgpt)/i, discount: '1/10', keepalive: 'auto', intervalSeconds: 285,
|
|
29
|
+
lifetime: { zh: '空闲 5–10 分钟,最长 1 小时;部分新模型至少 30 分钟(ChatGPT 订阅通道实测:空闲 10 分钟仍命中,15 分钟已过期)', en: '5–10 min idle, up to 1 h; some newer models ≥ 30 min (ChatGPT subscription measured: still cached after 10 idle min, gone at 15)' },
|
|
30
|
+
docs: 'https://developers.openai.com/api/docs/guides/prompt-caching' },
|
|
31
|
+
{ id: 'anthropic', label: 'Anthropic', match: /^claude/i, discount: '1/10', keepalive: 'auto', intervalSeconds: 270,
|
|
32
|
+
lifetime: { zh: '5 分钟,每次命中重新计时(可选 1 小时)', en: '5 min, refreshed on each hit (1 h option)' },
|
|
33
|
+
docs: 'https://platform.claude.com/docs/en/build-with-claude/prompt-caching' },
|
|
34
|
+
{ id: 'google', label: 'Google', match: /^gemini/i, discount: '1/10', keepalive: 'auto', intervalSeconds: 285,
|
|
35
|
+
lifetime: { zh: '隐式缓存自动,时长未公布', en: 'implicit, lifetime not published' },
|
|
36
|
+
docs: 'https://ai.google.dev/gemini-api/docs/caching' },
|
|
37
|
+
{ id: 'deepseek', label: 'DeepSeek', match: /^deepseek/i, discount: '1/10–1/50', keepalive: 'off', intervalSeconds: 285,
|
|
38
|
+
lifetime: { zh: '硬盘缓存,通常保留几小时到几天(等待期间不会过期,无需保活)', en: 'disk cache, hours to days (no keepalive needed)' },
|
|
39
|
+
docs: 'https://api-docs.deepseek.com/guides/kv_cache/' },
|
|
40
|
+
{ id: 'xai', label: 'xAI', match: /^grok/i, discount: '≈1/6', keepalive: 'auto', intervalSeconds: 285,
|
|
41
|
+
lifetime: { zh: '自动,时长未公布,内存紧张时可能被清除', en: 'automatic, lifetime not published, evicted under memory pressure' },
|
|
42
|
+
docs: 'https://docs.x.ai/developers/advanced-api-usage/prompt-caching' },
|
|
43
|
+
{ id: 'zhipu', label: 'Z.AI / 智谱', match: /^glm/i, discount: '≈1/5', keepalive: 'auto', intervalSeconds: 285,
|
|
44
|
+
lifetime: { zh: '自动,时长未公布', en: 'automatic, lifetime not published' },
|
|
45
|
+
docs: 'https://docs.bigmodel.cn/cn/guide/capabilities/cache' },
|
|
46
|
+
{ id: 'moonshot', label: 'Moonshot / Kimi', match: /^kimi|^moonshot/i, discount: '1/10', keepalive: 'auto', intervalSeconds: 270,
|
|
47
|
+
lifetime: { zh: '默认 5 分钟,每次命中重新计时(可选 1 小时)', en: '5 min default, refreshed on each hit (1 h tier)' },
|
|
48
|
+
docs: 'https://platform.moonshot.ai/docs/pricing/chat-k3' },
|
|
49
|
+
{ id: 'alibaba', label: 'Alibaba / Qwen', match: /^qwen/i, discount: '1/5', keepalive: 'auto', intervalSeconds: 270,
|
|
50
|
+
lifetime: { zh: '隐式缓存由系统管理;显式缓存 5 分钟', en: 'implicit cache system-managed; explicit cache 5 min' },
|
|
51
|
+
docs: 'https://help.aliyun.com/zh/model-studio/context-cache' },
|
|
52
|
+
{ id: 'xiaomi', label: 'Xiaomi MiMo', match: /^mimo/i, discount: '≈1/120', keepalive: 'auto', intervalSeconds: 285,
|
|
53
|
+
lifetime: { zh: '自动,时长未公布', en: 'automatic, lifetime not published' },
|
|
54
|
+
docs: 'https://mimo.mi.com/docs/en-US/price/pay-as-you-go' },
|
|
55
|
+
{ id: 'stepfun', label: 'StepFun', match: /^step/i, discount: '1/5', keepalive: 'auto', intervalSeconds: 285,
|
|
56
|
+
lifetime: { zh: '自动,时长未公布', en: 'automatic, lifetime not published' },
|
|
57
|
+
docs: 'https://platform.stepfun.com/docs/guide/prompt_cache' },
|
|
58
|
+
{ id: 'meta', label: 'Meta Muse', match: /^muse/i, discount: '≈1/8', keepalive: 'auto', intervalSeconds: 285,
|
|
59
|
+
lifetime: { zh: '自动;可申请 24 小时保留但不保证', en: 'automatic; 24 h retention is a hint, not a guarantee' },
|
|
60
|
+
docs: 'https://dev.meta.ai/docs/pricing-rate-limits' },
|
|
61
|
+
{ id: 'minimax', label: 'MiniMax', match: /^minimax/i, discount: '1/5', keepalive: 'auto', intervalSeconds: 270,
|
|
62
|
+
lifetime: { zh: '5 分钟,每次命中重新计时', en: '5 min, refreshed on each hit' },
|
|
63
|
+
docs: 'https://platform.minimax.io/docs/api-reference/text-prompt-caching' },
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
/** Routes of this project's OAuth plugins (dsh-oauth-login, dsh-antigravity-oauth): billing we know. */
|
|
67
|
+
export interface RouteBilling { match: RegExp; label: string; keepalive: CacheMode; reason: { zh: string; en: string }
|
|
68
|
+
/** Interval measured on this route; overrides the model family default. */
|
|
69
|
+
intervalSeconds?: number }
|
|
70
|
+
export const OWN_ROUTES: readonly RouteBilling[] = [
|
|
71
|
+
{ match: /^pi-zai-coding/, label: 'GLM Coding Plan', keepalive: 'off',
|
|
72
|
+
reason: { zh: '按请求次数计额度,保活会占用次数', en: 'quota counts requests; keepalive pings would use it' } },
|
|
73
|
+
{ match: /^pi-kimi-coding/, label: 'Kimi Code', keepalive: 'off',
|
|
74
|
+
reason: { zh: '订阅按请求次数计额度', en: 'subscription quota counts requests' } },
|
|
75
|
+
{ match: /^pi-github-copilot/, label: 'GitHub Copilot', keepalive: 'off',
|
|
76
|
+
reason: { zh: '每次请求都算一次高级请求', en: 'every request counts as a premium request' } },
|
|
77
|
+
{ match: /^agy-/, label: 'Antigravity', keepalive: 'off',
|
|
78
|
+
reason: { zh: '按请求次数限额', en: 'request-count limits' } },
|
|
79
|
+
// Probed 2026-10-01 on gpt-6-astra: a 20.7k-token prefix still hit after 4, 7 and 10 idle minutes and was gone
|
|
80
|
+
// at 15 and 22. 480 s is 0.8 x the shortest confirmed lifetime; it halves the pings of the 285 s family default.
|
|
81
|
+
{ match: /^pi-openai-codex/, label: 'ChatGPT (Codex)', keepalive: 'auto', intervalSeconds: 480,
|
|
82
|
+
reason: { zh: '按用量计,缓存命中能省额度;实测缓存空闲 10 分钟仍在', en: 'usage-based; cache hits save quota; measured cache still warm after 10 idle min' } },
|
|
83
|
+
...([[/^pi-anthropic/, 'Claude'], [/^pi-xai/, 'xAI'], [/^pi-openrouter/, 'OpenRouter']] as const)
|
|
84
|
+
.map(([match, label]): RouteBilling => ({ match, label, keepalive: 'auto',
|
|
85
|
+
reason: { zh: '按用量计,缓存命中能省额度', en: 'usage-based; cache hits save quota' } })),
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
export interface CacheDefaults {
|
|
89
|
+
keepalive: CacheMode
|
|
90
|
+
intervalSeconds: number
|
|
91
|
+
source: 'route' | 'family' | 'generic'
|
|
92
|
+
family?: CacheFamily
|
|
93
|
+
route?: RouteBilling
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Defaults for one physical route; the model id selects the family, our own routes set the billing mode. */
|
|
97
|
+
export function cacheDefaults(provider: string, model: string): CacheDefaults {
|
|
98
|
+
const family = CACHE_FAMILIES.find(item => item.match.test(model))
|
|
99
|
+
const route = OWN_ROUTES.find(item => item.match.test(provider))
|
|
100
|
+
if (route) return { keepalive: route.keepalive, intervalSeconds: route.intervalSeconds ?? family?.intervalSeconds ?? GENERIC_INTERVAL_SECONDS, source: 'route', family, route }
|
|
101
|
+
if (family) return { keepalive: family.keepalive, intervalSeconds: family.intervalSeconds, source: 'family', family }
|
|
102
|
+
return { keepalive: 'auto', intervalSeconds: GENERIC_INTERVAL_SECONDS, source: 'generic' }
|
|
103
|
+
}
|