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