@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,25 @@
|
|
|
1
|
+
/** Lossless text decoding; binary artifacts stay available as explicit base64. */
|
|
2
|
+
export function evidenceText(bytes: Uint8Array): string | undefined {
|
|
3
|
+
if (bytes.includes(0)) return undefined
|
|
4
|
+
try { return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes) }
|
|
5
|
+
catch { return undefined }
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Offsets count UTF-16 code units of text, or characters of base64. */
|
|
9
|
+
export function evidencePage(bytes: Uint8Array, offset = 0, limit = 24_000) {
|
|
10
|
+
if (!Number.isSafeInteger(offset) || offset < 0) throw new Error('Evidence offset must be a nonnegative safe integer')
|
|
11
|
+
if (!Number.isSafeInteger(limit) || limit < 2 || limit > 24_000) throw new Error('Evidence limit must be an integer from 2 to 24000')
|
|
12
|
+
const decoded = evidenceText(bytes)
|
|
13
|
+
const encoding = decoded === undefined ? 'base64' : 'utf-8'
|
|
14
|
+
const content = decoded ?? Buffer.from(bytes).toString('base64')
|
|
15
|
+
if (offset > content.length) throw new Error('Evidence offset exceeds the artifact text length')
|
|
16
|
+
const splitsPair = (at: number) => at > 0 && at < content.length
|
|
17
|
+
&& content.charCodeAt(at - 1) >= 0xd800 && content.charCodeAt(at - 1) <= 0xdbff
|
|
18
|
+
&& content.charCodeAt(at) >= 0xdc00 && content.charCodeAt(at) <= 0xdfff
|
|
19
|
+
if (splitsPair(offset)) throw new Error('Evidence offset splits a Unicode character; use the returned nextOffset')
|
|
20
|
+
let end = Math.min(content.length, offset + limit)
|
|
21
|
+
if (splitsPair(end)) end--
|
|
22
|
+
const truncated = end < content.length
|
|
23
|
+
return { bytes: bytes.byteLength, encoding, offsetUnit: 'utf-16-code-units', offset, totalCharacters: content.length,
|
|
24
|
+
text: content.slice(offset, end), truncated, nextOffset: truncated ? end : null }
|
|
25
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { SqliteFusionStore } from '../task/sqlite-store.js'
|
|
2
|
+
import type { TaskState } from '../task/state.js'
|
|
3
|
+
import type { NativeUsageRecord } from './native-usage.js'
|
|
4
|
+
import { projectLedger, restoreLedger, type Count } from '../usage/ledger.js'
|
|
5
|
+
|
|
6
|
+
export interface TaskSummary {
|
|
7
|
+
id: string; sessionId: string; seq: number; createdAt: string; updatedAt: string
|
|
8
|
+
title: string; phase: string; verification: string; mode: string
|
|
9
|
+
}
|
|
10
|
+
export function taskSummary(state: TaskState, createdAt: string, updatedAt: string): TaskSummary {
|
|
11
|
+
return { id: state.taskId, sessionId: state.parent, seq: state.seq, createdAt, updatedAt,
|
|
12
|
+
title: state.currentWorkOrder?.goal?.slice(0, 160) || '对话任务', phase: state.phase, verification: state.verification, mode: state.control.mode }
|
|
13
|
+
}
|
|
14
|
+
export interface UsageSummary {
|
|
15
|
+
id: string; taskId: string; role: string; provider: string; model: string; purpose: string; startedAt: string
|
|
16
|
+
outcome: string; authority: 'none' | 'provisional' | 'final'; input: Count; output: Count
|
|
17
|
+
reasoning: Count; totalOutput: Count; cacheRead: Count
|
|
18
|
+
}
|
|
19
|
+
export function usageSummary(row: NativeUsageRecord): UsageSummary {
|
|
20
|
+
const projection = projectLedger(restoreLedger(row.ledger))
|
|
21
|
+
const bill = projection.bill
|
|
22
|
+
const input = bill?.uncachedInput ?? { state: 'unknown' }
|
|
23
|
+
const output = bill?.output ?? { state: 'unknown' }
|
|
24
|
+
const cacheRead = bill?.cacheRead ?? { state: 'unknown' }
|
|
25
|
+
const reasoning = bill?.reasoning?.tokens ?? { state: 'unknown' }
|
|
26
|
+
|
|
27
|
+
let totalOutput: Count = { state: 'unknown' }
|
|
28
|
+
if (output.state === 'known') {
|
|
29
|
+
if (bill?.reasoning?.kind === 'included') {
|
|
30
|
+
totalOutput = { state: 'known', tokens: output.tokens }
|
|
31
|
+
} else if (reasoning.state === 'known') {
|
|
32
|
+
totalOutput = { state: 'known', tokens: output.tokens + reasoning.tokens }
|
|
33
|
+
} else {
|
|
34
|
+
totalOutput = { state: 'unknown' }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { id: row.nativeInvocationId, taskId: row.taskId, role: row.role, provider: row.ledger.key.provider, model: row.ledger.key.model,
|
|
39
|
+
purpose: row.purpose ?? 'conversation', startedAt: row.startedAt, outcome: row.outcome, authority: projection.authority,
|
|
40
|
+
input, output, reasoning, totalOutput, cacheRead }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Once at plugin startup; GET never writes, starts agents, or scans full task/usage snapshots. */
|
|
44
|
+
export function backfillHistory(store: SqliteFusionStore) {
|
|
45
|
+
for (const id of store.listTaskIds()) {
|
|
46
|
+
if (store.readDocument(`task-index:${id}`)) continue
|
|
47
|
+
const state = store.load(id)!, events = store.events(id)
|
|
48
|
+
store.writeDocument(`task-index:${id}`, 0, taskSummary(state, events[0]!.createdAt, events.at(-1)!.createdAt))
|
|
49
|
+
}
|
|
50
|
+
for (const id of store.listDocumentIds('usage:')) {
|
|
51
|
+
const projectionId = `usage-index:${id.slice(6)}`
|
|
52
|
+
const existing = store.readDocument(projectionId)
|
|
53
|
+
if (existing && (existing.value as UsageSummary).totalOutput !== undefined) continue
|
|
54
|
+
const usageDoc = store.readDocument(id)
|
|
55
|
+
if (usageDoc) {
|
|
56
|
+
store.writeDocument(projectionId, existing?.revision ?? 0, usageSummary(usageDoc.value as NativeUsageRecord))
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export interface HistoryTotals { calls: number; final: number; provisional: number; unreported: number;
|
|
61
|
+
input: { known: number; reported: number }; output: { known: number; reported: number }
|
|
62
|
+
reasoning: { known: number; reported: number }; totalOutput: { known: number; reported: number }; cacheRead: { known: number; reported: number } }
|
|
63
|
+
const empty = (): HistoryTotals => ({ calls: 0, final: 0, provisional: 0, unreported: 0,
|
|
64
|
+
input: { known: 0, reported: 0 }, output: { known: 0, reported: 0 },
|
|
65
|
+
reasoning: { known: 0, reported: 0 }, totalOutput: { known: 0, reported: 0 }, cacheRead: { known: 0, reported: 0 } })
|
|
66
|
+
function add(total: HistoryTotals, row: UsageSummary) {
|
|
67
|
+
total.calls++; total[row.authority === 'none' ? 'unreported' : row.authority]++
|
|
68
|
+
for (const key of ['input', 'output', 'reasoning', 'totalOutput', 'cacheRead'] as const) {
|
|
69
|
+
const count = row[key]
|
|
70
|
+
if (count && count.state === 'known') { total[key].known += count.tokens; total[key].reported++ }
|
|
71
|
+
}
|
|
72
|
+
if (!row.totalOutput && row.output && row.output.state === 'known') {
|
|
73
|
+
if (row.reasoning && row.reasoning.state === 'known') {
|
|
74
|
+
total.totalOutput.known += row.output.tokens + row.reasoning.tokens
|
|
75
|
+
total.totalOutput.reported++
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** How often the Host unlocked Lead takeover (enforced-v3), by reason. */
|
|
80
|
+
export interface TakeoverTotals { total: number; tasks: number; reasons: Record<string, number> }
|
|
81
|
+
export interface HistoryView {
|
|
82
|
+
observedAt: string; totalTasks: number; tasks: (TaskSummary & { usage: HistoryTotals; takeovers: number })[]; nextCursor: string | null
|
|
83
|
+
takeovers: TakeoverTotals
|
|
84
|
+
totals: HistoryTotals; groups: ({ role: string; provider: string; model: string; purpose: string } & HistoryTotals)[]
|
|
85
|
+
actualBilledUsd: null; savingsPercent: null; upstreamHttpCalls: null
|
|
86
|
+
}
|
|
87
|
+
export function readHistory(store: SqliteFusionStore, cursor = '', limit = 20): HistoryView {
|
|
88
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 50) throw new Error('History page size must be 1–50')
|
|
89
|
+
const tasks = store.listDocumentIds('task-index:').map(id => store.readDocument(id)!.value as TaskSummary)
|
|
90
|
+
.sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id))
|
|
91
|
+
const all = store.listDocumentIds('usage-index:').map(id => store.readDocument(id)!.value as UsageSummary)
|
|
92
|
+
const groups = new Map<string, HistoryView['groups'][number]>(), byTask = new Map<string, HistoryTotals>(), totals = empty()
|
|
93
|
+
for (const row of all) {
|
|
94
|
+
add(totals, row)
|
|
95
|
+
const own = byTask.get(row.taskId) ?? empty(); add(own, row); byTask.set(row.taskId, own)
|
|
96
|
+
const key = JSON.stringify([row.role, row.provider, row.model, row.purpose])
|
|
97
|
+
const group = groups.get(key) ?? { role: row.role, provider: row.provider, model: row.model, purpose: row.purpose, ...empty() }
|
|
98
|
+
add(group, row); groups.set(key, group)
|
|
99
|
+
}
|
|
100
|
+
const takeovers: TakeoverTotals = { total: 0, tasks: 0, reasons: {} }, takeoversByTask = new Map<string, number>()
|
|
101
|
+
for (const id of store.listDocumentIds('lead-takeover:')) {
|
|
102
|
+
const row = store.readDocument(id)!.value as { taskId: string; reason: string }
|
|
103
|
+
takeovers.total++; takeovers.reasons[row.reason] = (takeovers.reasons[row.reason] ?? 0) + 1
|
|
104
|
+
takeoversByTask.set(row.taskId, (takeoversByTask.get(row.taskId) ?? 0) + 1)
|
|
105
|
+
}
|
|
106
|
+
takeovers.tasks = takeoversByTask.size
|
|
107
|
+
const start = cursor ? tasks.findIndex(task => task.id === cursor) + 1 : 0
|
|
108
|
+
if (cursor && start === 0) throw new Error('History cursor is no longer available')
|
|
109
|
+
const page = tasks.slice(start, start + limit)
|
|
110
|
+
return { observedAt: new Date().toISOString(), totalTasks: tasks.length,
|
|
111
|
+
tasks: page.map(task => ({ ...task, usage: byTask.get(task.id) ?? empty(), takeovers: takeoversByTask.get(task.id) ?? 0 })),
|
|
112
|
+
nextCursor: start + limit < tasks.length ? page.at(-1)!.id : null, takeovers,
|
|
113
|
+
totals, groups: [...groups.values()].sort((a, b) => b.calls - a.calls), actualBilledUsd: null, savingsPercent: null, upstreamHttpCalls: null }
|
|
114
|
+
}
|
package/src/host/http.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
2
|
+
|
|
3
|
+
export function trustedRequest(req: IncomingMessage): boolean {
|
|
4
|
+
if (!['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(req.socket.remoteAddress ?? '')) return false
|
|
5
|
+
if (req.headers['sec-fetch-site'] === 'cross-site' || !req.headers.host) return false
|
|
6
|
+
try {
|
|
7
|
+
const target = new URL(`http://${req.headers.host}`)
|
|
8
|
+
if (!['localhost', '127.0.0.1', '[::1]'].includes(target.hostname)) return false
|
|
9
|
+
return !req.headers.origin || new URL(req.headers.origin).host === target.host
|
|
10
|
+
} catch { return false }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function json(res: ServerResponse, status: number, body: unknown): void {
|
|
14
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' })
|
|
15
|
+
res.end(JSON.stringify(body))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function readJson(req: IncomingMessage): Promise<Record<string, unknown>> {
|
|
19
|
+
if (!req.headers['content-type']?.toLowerCase().startsWith('application/json')) throw new Error('JSON content type required')
|
|
20
|
+
const chunks: Buffer[] = []
|
|
21
|
+
let size = 0
|
|
22
|
+
for await (const chunk of req) {
|
|
23
|
+
size += chunk.length
|
|
24
|
+
if (size > 256 * 1024) throw new Error('Request body exceeds 256 KiB')
|
|
25
|
+
chunks.push(Buffer.from(chunk))
|
|
26
|
+
}
|
|
27
|
+
const value: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
28
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('JSON object required')
|
|
29
|
+
return value as Record<string, unknown>
|
|
30
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
2
|
+
import { fittedOutputReservation } from '../context/guard.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Output limits come only from the public model metadata DSH discloses for the route (context window and
|
|
6
|
+
* the adapter's default max tokens). No route or model names are special-cased: route names differ between
|
|
7
|
+
* installations, and a guessed cap would be wrong for someone else's setup.
|
|
8
|
+
*/
|
|
9
|
+
export interface ModelOutputLimits {
|
|
10
|
+
contextWindow: number
|
|
11
|
+
defaultMaxTokens?: number
|
|
12
|
+
testedOutputCap?: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Public model metadata only; resolving limits does not start a model turn. */
|
|
16
|
+
export async function resolveModelOutputLimits(ctx: Context, provider: string, model: string): Promise<ModelOutputLimits> {
|
|
17
|
+
const info = await ctx.llm.resolveModelInfo(provider, model)
|
|
18
|
+
const contextWindow = info.context?.contextWindow
|
|
19
|
+
if (!Number.isSafeInteger(contextWindow) || Number(contextWindow) <= 0) throw new Error('The configured model does not disclose a usable context window')
|
|
20
|
+
fittedOutputReservation(Number(contextWindow))
|
|
21
|
+
return { contextWindow: Number(contextWindow), defaultMaxTokens: info.defaultMaxTokens }
|
|
22
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
2
|
+
import { SessionId } from '@deepseek-ai/dsh-session'
|
|
3
|
+
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
|
4
|
+
import type {} from '@deepseek-ai/dsh-session-query'
|
|
5
|
+
import type { FusionActivity, FusionActivityPage } from '../activity.js'
|
|
6
|
+
import type { SqliteFusionStore } from '../task/sqlite-store.js'
|
|
7
|
+
|
|
8
|
+
interface ActivityLink {
|
|
9
|
+
parentId: string
|
|
10
|
+
childId: string
|
|
11
|
+
taskId: string
|
|
12
|
+
callId: string
|
|
13
|
+
turn: number
|
|
14
|
+
step: number
|
|
15
|
+
anchorSeq: number
|
|
16
|
+
fromSeq: number | null
|
|
17
|
+
done?: boolean
|
|
18
|
+
}
|
|
19
|
+
const prefix = 'activity-link:'
|
|
20
|
+
const visibleTools = new Set(['bash', 'pwsh', 'read', 'write', 'edit', 'glob', 'grep', 'job_output', 'job_kill', 'str_replace_editor'])
|
|
21
|
+
const parentPrefix = (parentId: string) => `activity:${encodeURIComponent(parentId)}:`
|
|
22
|
+
const callPrefix = (parentId: string, callId: string) => `${parentPrefix(parentId)}${encodeURIComponent(callId)}:`
|
|
23
|
+
|
|
24
|
+
/** Paginated read-only data for a plugin-owned tool view; no runtime activation. */
|
|
25
|
+
export function readFusionActivity(store: SqliteFusionStore, parentId: string, callId: string, after = -1): FusionActivityPage {
|
|
26
|
+
if (!Number.isSafeInteger(after) || after < -1) throw new Error('Invalid activity cursor')
|
|
27
|
+
const rows = store.listDocumentIds(callPrefix(parentId, callId)).map(id => store.readDocument(id)!.value as FusionActivity)
|
|
28
|
+
.filter(row => row.source.seq > after).sort((a, b) => a.source.seq - b.source.seq)
|
|
29
|
+
const events = rows.slice(0, 100)
|
|
30
|
+
const links = store.listDocumentIds(prefix).map(id => store.readDocument(id)!.value as ActivityLink)
|
|
31
|
+
const link = links.find(row => row.parentId === parentId && row.callId === callId)
|
|
32
|
+
return { events, cursor: events.at(-1)?.source.seq ?? after, more: rows.length > events.length, done: !link || link.done === true }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Old conversations remain readable through the public, non-activating history
|
|
36
|
+
* API. Correlate accepted message ids, never timing guesses or model narration.
|
|
37
|
+
* This fallback neither rewrites the old log nor creates a replacement Worker.
|
|
38
|
+
*/
|
|
39
|
+
export async function readHistoricalFusionActivity(ctx: Context, store: SqliteFusionStore,
|
|
40
|
+
parentId: string, callId: string, after = -1): Promise<FusionActivityPage> {
|
|
41
|
+
const cached = readFusionActivity(store, parentId, callId, after)
|
|
42
|
+
const linked = store.listDocumentIds(prefix).some(id => {
|
|
43
|
+
const row = store.readDocument(id)!.value as ActivityLink
|
|
44
|
+
return row.parentId === parentId && row.callId === callId
|
|
45
|
+
})
|
|
46
|
+
if (linked || !parentId || !callId) return cached
|
|
47
|
+
const tasks = store.listTaskIds().map(id => store.load(id)!).filter(task => task.parent === parentId)
|
|
48
|
+
for (const task of tasks) {
|
|
49
|
+
if (!task.acceptedChild) continue
|
|
50
|
+
const history = store.events(task.taskId)
|
|
51
|
+
const prepared = history.find(event => event.type === 'work-order/prepared' && event.causeId === callId)
|
|
52
|
+
const delivery = store.listDocumentIds(`worker-delivery:${task.taskId}:`).map(id => store.readDocument(id)!.value as
|
|
53
|
+
{ causeId: string; messageId?: string; state: string }).find(row => row.causeId === callId && row.state === 'accepted')
|
|
54
|
+
const accepted = prepared && history.find(event => event.type === 'child/accepted' && event.seq > prepared.seq)
|
|
55
|
+
const messageId = delivery?.messageId ?? (accepted?.type === 'child/accepted' ? accepted.payload.messageId : undefined)
|
|
56
|
+
if (!messageId) continue
|
|
57
|
+
const [parent, child] = await Promise.all([ctx.sessionQuery.readSession(SessionId(parentId)), ctx.sessionQuery.readSession(SessionId(task.acceptedChild))])
|
|
58
|
+
if (child.session.parentSession !== parentId) throw new Error('Fusion activity child ownership mismatch')
|
|
59
|
+
const call = parent.events.find(event => event.type === 'tool/call' && event.data.callId === callId)
|
|
60
|
+
if (!call || call.type !== 'tool/call' || !['fusion_explore', 'fusion_delegate', 'fusion_rework'].includes(call.data.name)) return cached
|
|
61
|
+
const start = child.events.find(event => event.type === 'user/message' && event.data.id === messageId)
|
|
62
|
+
if (!start) return cached
|
|
63
|
+
const acceptedIds = new Set(tasks.flatMap(item => store.events(item.taskId).flatMap(event =>
|
|
64
|
+
event.type === 'child/accepted' && event.payload.child === task.acceptedChild ? [event.payload.messageId] : [])))
|
|
65
|
+
const end = child.events.find(event => event.seq > start.seq && event.type === 'user/message' && acceptedIds.has(event.data.id))?.seq ?? Infinity
|
|
66
|
+
const calls = new Set<string>()
|
|
67
|
+
const all: FusionActivity[] = []
|
|
68
|
+
for (const source of child.events) {
|
|
69
|
+
if (source.seq <= start.seq) continue
|
|
70
|
+
if (source.type === 'tool/call' && source.seq < end && visibleTools.has(source.data.name)) calls.add(source.data.callId)
|
|
71
|
+
else if (source.type !== 'tool/result' || !calls.has(source.data.message.source.callId)) continue
|
|
72
|
+
if (source.type !== 'tool/call' && source.type !== 'tool/result') continue
|
|
73
|
+
all.push({ schemaVersion: 1, taskId: task.taskId, childSessionId: task.acceptedChild, parentCallId: callId,
|
|
74
|
+
turn: call.data.turn, step: call.data.step, anchorSeq: call.seq, source })
|
|
75
|
+
}
|
|
76
|
+
const remaining = all.filter(event => event.source.seq > after), events = remaining.slice(0, 100)
|
|
77
|
+
return { events, cursor: events.at(-1)?.source.seq ?? after, more: remaining.length > events.length,
|
|
78
|
+
done: task.control.mode === 'completed' || child.events.at(-1)?.type === 'turn/end' }
|
|
79
|
+
}
|
|
80
|
+
return cached
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Copies actual Worker tool events into plugin-owned presentation storage only.
|
|
84
|
+
* Model history still contains solely the Lead's native messages and reports.
|
|
85
|
+
* Stored source coordinates make replay/HMR idempotent without executing tools.
|
|
86
|
+
*/
|
|
87
|
+
export class NativeFusionActivity {
|
|
88
|
+
readonly #dispose: (() => void)[] = []
|
|
89
|
+
#pending: Promise<void> = Promise.resolve()
|
|
90
|
+
readonly #seen = new WeakMap<Session, Set<string>>()
|
|
91
|
+
readonly #calls = new WeakMap<Session, Map<string, FusionActivity>>()
|
|
92
|
+
readonly errors = new Map<string, string>()
|
|
93
|
+
|
|
94
|
+
constructor(readonly ctx: Context, readonly store: SqliteFusionStore) {
|
|
95
|
+
this.#dispose.push(ctx.on('session/event', (session, event) => {
|
|
96
|
+
if (!session.header.parentSession) return
|
|
97
|
+
if (event.type === 'turn/end') { this.#enqueue(() => this.#ended(session)); return }
|
|
98
|
+
if (event.type !== 'tool/call' && event.type !== 'tool/result') return
|
|
99
|
+
this.#enqueue(() => this.#mirror(session, event, false))
|
|
100
|
+
}))
|
|
101
|
+
this.#dispose.push(ctx.on('agent/created', ({ agent }) => {
|
|
102
|
+
this.#replay(agent.session)
|
|
103
|
+
for (const child of ctx.sessions.list()) if (child.header.parentSession === agent.id) this.#replay(child)
|
|
104
|
+
return undefined
|
|
105
|
+
}))
|
|
106
|
+
for (const session of ctx.sessions.list()) this.#replay(session)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Called before each native delivery. No inference, dispatch or approval. */
|
|
110
|
+
link(parent: Session, childId: string, taskId: string, callId: string): void {
|
|
111
|
+
const call = parent.snapshotEvents().find(event => event.type === 'tool/call' && event.data.callId === callId)
|
|
112
|
+
if (!call || call.type !== 'tool/call') return // Direct programmatic control has no native UI call to own a row.
|
|
113
|
+
const id = `${prefix}${childId}:${call.seq}`
|
|
114
|
+
if (this.store.readDocument(id)) return
|
|
115
|
+
this.store.writeDocument(id, 0, {
|
|
116
|
+
parentId: parent.id, childId, taskId, callId, turn: call.data.turn, step: call.data.step,
|
|
117
|
+
anchorSeq: call.seq, fromSeq: this.ctx.sessions.get(SessionId(childId))?.seq ?? null,
|
|
118
|
+
} satisfies ActivityLink)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
#enqueue(action: () => void): void {
|
|
122
|
+
// Session.append forbids reentry from event observers, including same-session
|
|
123
|
+
// parent callbacks. Awaited microtasks run after the native append boundary.
|
|
124
|
+
this.#pending = this.#pending.then(action).catch(error => {
|
|
125
|
+
this.errors.set('presentation', String(error))
|
|
126
|
+
this.ctx.logger.warn('Fusion activity presentation failed: %s', String(error))
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
#replay(session: Session): void {
|
|
131
|
+
if (!session.header.parentSession) return
|
|
132
|
+
this.#enqueue(() => {
|
|
133
|
+
for (const event of session.snapshotEvents()) {
|
|
134
|
+
if (event.type === 'tool/call' || event.type === 'tool/result') this.#mirror(session, event, true)
|
|
135
|
+
if (event.type === 'turn/end') this.#ended(session, event.seq, false)
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
#ended(child: Session, throughSeq: number = child.seq, live = true): void {
|
|
141
|
+
for (const id of this.store.listDocumentIds(`${prefix}${child.id}:`)) {
|
|
142
|
+
const row = this.store.readDocument(id)!, link = row.value as ActivityLink
|
|
143
|
+
if ((link.fromSeq === null ? live : link.fromSeq <= throughSeq) && !link.done) this.store.writeDocument(id, row.revision, { ...link, done: true })
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
#mirror(child: Session, event: SessionEvent<'tool/call'> | SessionEvent<'tool/result'>, replay: boolean): void {
|
|
148
|
+
const parent = child.header.parentSession && this.ctx.sessions.get(child.header.parentSession)
|
|
149
|
+
if (!parent) return
|
|
150
|
+
let seen = this.#seen.get(parent), calls = this.#calls.get(parent)
|
|
151
|
+
if (!seen || !calls) {
|
|
152
|
+
seen = new Set(); calls = new Map()
|
|
153
|
+
for (const id of this.store.listDocumentIds(parentPrefix(parent.id))) {
|
|
154
|
+
const activity = this.store.readDocument(id)!.value as FusionActivity
|
|
155
|
+
seen.add(`${activity.childSessionId}:${activity.source.seq}`)
|
|
156
|
+
if (activity.source.type === 'tool/call') calls.set(`${activity.childSessionId}:${activity.source.data.callId}`, activity)
|
|
157
|
+
}
|
|
158
|
+
this.#seen.set(parent, seen); this.#calls.set(parent, calls)
|
|
159
|
+
}
|
|
160
|
+
const key = `${child.id}:${event.seq}`
|
|
161
|
+
if (seen.has(key)) return
|
|
162
|
+
const sourceCallId = event.type === 'tool/call' ? event.data.callId : event.data.message.source.callId
|
|
163
|
+
const callKey = `${child.id}:${sourceCallId}`
|
|
164
|
+
let origin: Omit<FusionActivity, 'source'> | undefined = calls.get(callKey)
|
|
165
|
+
if (event.type === 'tool/call') {
|
|
166
|
+
if (!visibleTools.has(event.data.name)) return
|
|
167
|
+
const links = this.store.listDocumentIds(`${prefix}${child.id}:`).map(id => ({ id, ...this.store.readDocument(id)! }))
|
|
168
|
+
.filter(row => (row.value as ActivityLink).parentId === parent.id)
|
|
169
|
+
.sort((a, b) => (b.value as ActivityLink).anchorSeq - (a.value as ActivityLink).anchorSeq)
|
|
170
|
+
const row = links.find(row => {
|
|
171
|
+
const link = row.value as ActivityLink
|
|
172
|
+
return link.fromSeq === null ? !replay : event.seq >= link.fromSeq
|
|
173
|
+
})
|
|
174
|
+
if (!row) return // Historical, pre-feature calls are not fabricated.
|
|
175
|
+
let link = row.value as ActivityLink
|
|
176
|
+
if (link.fromSeq === null) {
|
|
177
|
+
link = { ...link, fromSeq: event.seq }
|
|
178
|
+
this.store.writeDocument(row.id, row.revision, link)
|
|
179
|
+
}
|
|
180
|
+
origin = { schemaVersion: 1, taskId: link.taskId, childSessionId: child.id,
|
|
181
|
+
parentCallId: link.callId, turn: link.turn, step: link.step,
|
|
182
|
+
// Place all calls below the owning delivery, in original source order.
|
|
183
|
+
anchorSeq: link.anchorSeq + 0.5 - 0.5 / (event.seq + 2) }
|
|
184
|
+
}
|
|
185
|
+
if (!origin) return // Results never acquire an invented call head.
|
|
186
|
+
const activity = { ...origin, source: event } satisfies FusionActivity
|
|
187
|
+
const id = `${callPrefix(parent.id, origin.parentCallId)}${String(event.seq).padStart(12, '0')}`
|
|
188
|
+
if (!this.store.readDocument(id)) this.store.writeDocument(id, 0, activity)
|
|
189
|
+
seen.add(key)
|
|
190
|
+
if (event.type === 'tool/call') calls.set(callKey, activity)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async flush(): Promise<void> { await this.#pending }
|
|
194
|
+
|
|
195
|
+
async close(): Promise<void> {
|
|
196
|
+
for (const dispose of this.#dispose.splice(0).reverse()) dispose()
|
|
197
|
+
await this.flush()
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
2
|
+
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
3
|
+
import { SessionId as NativeSessionId } from '@deepseek-ai/dsh-session'
|
|
4
|
+
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
|
5
|
+
import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval'
|
|
6
|
+
import type { ArtifactRef, LogicalApproval, Role, TaskId } from '../contracts.js'
|
|
7
|
+
import { OperationId, SessionId } from '../contracts.js'
|
|
8
|
+
import { digestOf } from '../digest.js'
|
|
9
|
+
import type { SqliteFusionStore } from '../task/sqlite-store.js'
|
|
10
|
+
import type { TaskState } from '../task/state.js'
|
|
11
|
+
import type { SessionBinding } from './bindings.js'
|
|
12
|
+
import { snapshotWorkspace } from './workspace.js'
|
|
13
|
+
|
|
14
|
+
type Owner = { binding: SessionBinding; role: Role }
|
|
15
|
+
interface Callbacks {
|
|
16
|
+
owner(agent: Agent): Owner | undefined
|
|
17
|
+
state(id: TaskId): TaskState
|
|
18
|
+
pending(id: TaskId, approval: LogicalApproval): void
|
|
19
|
+
answered(id: TaskId, approvalId: string, state: 'approved' | 'rejected' | 'withdrawn'): void
|
|
20
|
+
failed(agent: Agent, error: unknown): void
|
|
21
|
+
}
|
|
22
|
+
interface ApprovalRecord {
|
|
23
|
+
schemaVersion: 1
|
|
24
|
+
logical: LogicalApproval
|
|
25
|
+
agentId: string
|
|
26
|
+
role: Role
|
|
27
|
+
toolName: string
|
|
28
|
+
callId: string
|
|
29
|
+
rootCallId: string
|
|
30
|
+
askedSeq: number
|
|
31
|
+
askedAt: string
|
|
32
|
+
processId: number
|
|
33
|
+
scopeDigest: string
|
|
34
|
+
evidence: ArtifactRef
|
|
35
|
+
state: 'pending' | 'decided' | 'withdrawn-after-inspection'
|
|
36
|
+
nativeOutcome?: ApprovalOutcome
|
|
37
|
+
decidedSeq?: number
|
|
38
|
+
scopeRejection?: string
|
|
39
|
+
effect?: 'settled' | 'failed'
|
|
40
|
+
inspectionEvidence?: string
|
|
41
|
+
reissuedAs?: string
|
|
42
|
+
previousRequest?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Observe native decisions; never answer on the user's behalf or replay a grant. */
|
|
46
|
+
export class NativeApprovals {
|
|
47
|
+
readonly #calls = new Map<Agent, Map<string, Readonly<ToolExecution>>>()
|
|
48
|
+
readonly #requests = new Map<string, string>()
|
|
49
|
+
constructor(readonly ctx: Context, readonly store: SqliteFusionStore, readonly callbacks: Callbacks) {}
|
|
50
|
+
|
|
51
|
+
install(): () => void {
|
|
52
|
+
const disposers = [
|
|
53
|
+
this.ctx.on('tools/pre-execute', async (exec, next) => {
|
|
54
|
+
if (exec.agent && this.callbacks.owner(exec.agent)) {
|
|
55
|
+
let calls = this.#calls.get(exec.agent)
|
|
56
|
+
if (!calls) this.#calls.set(exec.agent, calls = new Map())
|
|
57
|
+
calls.set(exec.callId, exec)
|
|
58
|
+
}
|
|
59
|
+
return next()
|
|
60
|
+
}, { prepend: true }),
|
|
61
|
+
this.ctx.on('session/event', (session, event) => {
|
|
62
|
+
if (event.type !== 'approval/asked' && event.type !== 'approval/decided') return
|
|
63
|
+
const agent = this.ctx.agents.get(NativeSessionId(session.id)), owner = agent && this.callbacks.owner(agent)
|
|
64
|
+
if (!agent || !owner) return
|
|
65
|
+
try {
|
|
66
|
+
if (event.type === 'approval/asked') this.#asked(agent, owner, event.data, event.seq)
|
|
67
|
+
else this.#decided(owner, event.data.id, event.data.outcome, event.seq)
|
|
68
|
+
} catch (error) { this.callbacks.failed(agent, error) }
|
|
69
|
+
}),
|
|
70
|
+
this.ctx.on('approval/request', async (request, next) => {
|
|
71
|
+
const owner = this.callbacks.owner(request.agent)
|
|
72
|
+
if (!owner) return next()
|
|
73
|
+
// This is deny-only middleware around the native answerer. The audit
|
|
74
|
+
// event already exists; a missing projection must not permit execution.
|
|
75
|
+
const key = this.#requests.get(this.#callKey(request.agent.id, request.callId ?? ''))
|
|
76
|
+
if (!key) return 'unavailable'
|
|
77
|
+
const outcome = await next() // deliberately no timer around human input
|
|
78
|
+
if (outcome !== 'allowed-once') return outcome
|
|
79
|
+
try {
|
|
80
|
+
const row = this.#read(key), exec = this.#calls.get(request.agent)?.get(row.callId)
|
|
81
|
+
const current = this.callbacks.state(owner.binding.taskId)
|
|
82
|
+
const control = current.control
|
|
83
|
+
if (!exec || request.signal?.aborted || control.mode !== 'running' || control.recovering
|
|
84
|
+
|| control.outcomeUnknown || control.budgetBlocked
|
|
85
|
+
|| row.scopeDigest !== this.#scope(request.agent, owner, exec).digest) {
|
|
86
|
+
this.#write(key, { ...row, scopeRejection: 'The action, workspace, policy or task control changed while approval was pending' })
|
|
87
|
+
return 'rejected'
|
|
88
|
+
}
|
|
89
|
+
return outcome
|
|
90
|
+
} catch (error) { this.callbacks.failed(request.agent, error); return 'unavailable' }
|
|
91
|
+
}, { prepend: true }),
|
|
92
|
+
this.ctx.on('tools/result', (exec, result) => {
|
|
93
|
+
if (!exec.agent) return
|
|
94
|
+
const key = this.#requests.get(this.#callKey(exec.agent.id, exec.callId))
|
|
95
|
+
if (key) {
|
|
96
|
+
try {
|
|
97
|
+
const row = this.#read(key)
|
|
98
|
+
this.#write(key, { ...row,
|
|
99
|
+
logical: row.nativeOutcome === 'allowed-once' ? { ...row.logical, state: 'consumed' } : row.logical,
|
|
100
|
+
effect: result.isError ? 'failed' : 'settled' })
|
|
101
|
+
}
|
|
102
|
+
catch (error) { this.callbacks.failed(exec.agent, error) }
|
|
103
|
+
this.#requests.delete(this.#callKey(exec.agent.id, exec.callId))
|
|
104
|
+
}
|
|
105
|
+
this.#calls.get(exec.agent)?.delete(exec.callId)
|
|
106
|
+
}),
|
|
107
|
+
]
|
|
108
|
+
return () => { for (const dispose of disposers.reverse()) dispose(); this.#calls.clear(); this.#requests.clear() }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Caller must first stop/release both native Agents and inspect uncertain effects. */
|
|
112
|
+
reconcile(taskId: TaskId, evidence: string): void {
|
|
113
|
+
if (!evidence) throw new Error('Approval recovery requires inspection evidence')
|
|
114
|
+
for (const key of this.store.listDocumentIds(`native-approval:${taskId}:`)) {
|
|
115
|
+
const row = this.#read(key)
|
|
116
|
+
if (row.state !== 'pending') continue
|
|
117
|
+
const agent = this.ctx.agents.get(NativeSessionId(row.agentId))
|
|
118
|
+
if (agent && agent.status !== 'idle' || [...this.#requests.values()].includes(key)) {
|
|
119
|
+
throw new Error('The native approval is still live; settle it before recovery')
|
|
120
|
+
}
|
|
121
|
+
if (row.processId !== process.pid) {
|
|
122
|
+
try { process.kill(row.processId, 0); throw new Error('The recorded approval process is still live') }
|
|
123
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error }
|
|
124
|
+
}
|
|
125
|
+
// This withdraws the plugin's logical wait after explicit inspection.
|
|
126
|
+
// It does not fabricate a native decision or execute the interrupted call.
|
|
127
|
+
if (this.callbacks.state(taskId).pendingApprovalIds.includes(row.logical.id)) {
|
|
128
|
+
this.callbacks.answered(taskId, row.logical.id, 'withdrawn')
|
|
129
|
+
}
|
|
130
|
+
this.#write(key, { ...row, logical: { ...row.logical, state: 'withdrawn' }, state: 'withdrawn-after-inspection', inspectionEvidence: evidence })
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
#asked(agent: Agent, owner: Owner, asked: { id: string; toolName: string; callId?: string; reason?: string }, seq: number): void {
|
|
135
|
+
const exec = asked.callId && this.#calls.get(agent)?.get(asked.callId)
|
|
136
|
+
if (!exec || exec.name !== asked.toolName) throw new Error('Native approval has no matching immutable tool execution')
|
|
137
|
+
const key = `native-approval:${owner.binding.taskId}:${asked.id}`
|
|
138
|
+
if (this.store.readDocument(key)) throw new Error('Native approval identity was reused')
|
|
139
|
+
const scope = this.#scope(agent, owner, exec), state = this.callbacks.state(owner.binding.taskId)
|
|
140
|
+
const previousRequest = this.store.listDocumentIds(`native-approval:${owner.binding.taskId}:`).find(id => {
|
|
141
|
+
const prior = this.#read(id)
|
|
142
|
+
return prior.state === 'withdrawn-after-inspection' && !prior.reissuedAs && prior.scopeDigest === scope.digest
|
|
143
|
+
})
|
|
144
|
+
const prior = previousRequest && this.#read(previousRequest)
|
|
145
|
+
const logical: LogicalApproval = {
|
|
146
|
+
id: prior ? prior.logical.id : `native:${asked.id}`, taskId: owner.binding.taskId, revision: state.revision,
|
|
147
|
+
operationId: prior ? prior.logical.operationId : state.currentWorkOrder?.operationId ?? OperationId(`native-tool:${agent.id}:${exec.callId}`),
|
|
148
|
+
nativeRequestId: asked.id, argsDigest: scope.argsDigest, snapshot: scope.snapshot.id,
|
|
149
|
+
permissionPolicyDigest: scope.policyDigest, state: 'pending',
|
|
150
|
+
requestingAgent: SessionId(agent.id), toolName: exec.name, callId: exec.callId, role: owner.role,
|
|
151
|
+
...(asked.reason ? { reason: asked.reason } : {}),
|
|
152
|
+
}
|
|
153
|
+
const evidence = this.store.putArtifact(owner.binding.taskId, Buffer.from(JSON.stringify({
|
|
154
|
+
nativeRequestId: asked.id, toolName: exec.name, arguments: exec.arguments,
|
|
155
|
+
snapshot: scope.snapshot, policy: scope.policy,
|
|
156
|
+
})), 'application/vnd.dsh-fusion.approval-scope+json')
|
|
157
|
+
this.store.writeDocument(key, 0, { schemaVersion: 1, logical, agentId: agent.id, role: owner.role,
|
|
158
|
+
toolName: exec.name, callId: exec.callId, rootCallId: exec.rootCallId, askedSeq: seq,
|
|
159
|
+
askedAt: new Date().toISOString(), processId: process.pid, scopeDigest: scope.digest,
|
|
160
|
+
evidence, state: 'pending', ...(previousRequest ? { previousRequest } : {}) } satisfies ApprovalRecord)
|
|
161
|
+
this.callbacks.pending(owner.binding.taskId, logical)
|
|
162
|
+
if (prior && previousRequest) this.#write(previousRequest, { ...prior, reissuedAs: key })
|
|
163
|
+
this.#requests.set(this.#callKey(agent.id, exec.callId), key)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
#decided(owner: Owner, nativeId: string, outcome: ApprovalOutcome, seq: number): void {
|
|
167
|
+
const key = `native-approval:${owner.binding.taskId}:${nativeId}`, row = this.#read(key)
|
|
168
|
+
if (row.state !== 'pending') throw new Error('Native approval already settled')
|
|
169
|
+
// Audit outcomes do not identify a human vs. automated answerer. Leave
|
|
170
|
+
// decisionBy unknown rather than attributing an auto-review to the user.
|
|
171
|
+
const state = outcome === 'allowed-once' ? 'approved' : outcome === 'cancelled' ? 'withdrawn' : 'rejected'
|
|
172
|
+
this.callbacks.answered(owner.binding.taskId, row.logical.id, state)
|
|
173
|
+
this.#write(key, { ...row, logical: { ...row.logical, state }, state: 'decided', nativeOutcome: outcome, decidedSeq: seq })
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
#scope(agent: Agent, owner: Owner, exec: Readonly<ToolExecution>) {
|
|
177
|
+
const state = this.callbacks.state(owner.binding.taskId), service = agent.ctx.get('approval') ?? this.ctx.get('approval')
|
|
178
|
+
if (!service || !agent.session.header.cwd) throw new Error('Native approval policy or workspace is unavailable')
|
|
179
|
+
const snapshot = snapshotWorkspace(agent.session.header.cwd)
|
|
180
|
+
const policy = { scope: 'native-approval-and-frozen-fusion-policy',
|
|
181
|
+
nativeApproval: service.overrideOf(agent.session) ?? service.config.policy ?? 'ask',
|
|
182
|
+
profileDigest: owner.binding.profile.digest, allowedPaths: state.currentWorkOrder?.allowedPaths ?? null }
|
|
183
|
+
const argsDigest = digestOf({ tool: exec.name, arguments: exec.arguments }), policyDigest = digestOf(policy)
|
|
184
|
+
return { snapshot, policy, argsDigest, policyDigest,
|
|
185
|
+
digest: digestOf({ taskId: state.taskId, revision: state.revision, agentId: agent.id,
|
|
186
|
+
role: owner.role, argsDigest, snapshot: snapshot.id, policyDigest }) }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
#callKey(agent: string, call: string): string { return JSON.stringify([agent, call]) }
|
|
190
|
+
#read(key: string): ApprovalRecord {
|
|
191
|
+
const row = this.store.readDocument(key)?.value as ApprovalRecord | undefined
|
|
192
|
+
if (!row || row.schemaVersion !== 1 || !row.logical || !row.scopeDigest) throw new Error('Native approval journal requires reconciliation')
|
|
193
|
+
return row
|
|
194
|
+
}
|
|
195
|
+
#write(key: string, row: ApprovalRecord): void { this.store.writeDocument(key, this.store.readDocument(key)!.revision, row) }
|
|
196
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
|
|
2
|
+
import type { Digest, Role, TaskId } from '../contracts.js'
|
|
3
|
+
|
|
4
|
+
export interface AuxiliaryRequest {
|
|
5
|
+
purpose: 'cache-keepalive'
|
|
6
|
+
taskId: TaskId
|
|
7
|
+
sessionId: string
|
|
8
|
+
profileDigest: Digest
|
|
9
|
+
role: Role
|
|
10
|
+
seriesId: string
|
|
11
|
+
iteration: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Plugin-local identity: do not widen or misuse the Host's purpose enum. */
|
|
15
|
+
export class NativeAuxiliaryRequests {
|
|
16
|
+
readonly #requests = new WeakMap<GenerateOptions, Readonly<AuxiliaryRequest>>()
|
|
17
|
+
tag(request: GenerateOptions, metadata: AuxiliaryRequest): void { this.#requests.set(request, Object.freeze(metadata)) }
|
|
18
|
+
get(request: GenerateOptions): Readonly<AuxiliaryRequest> | undefined { return this.#requests.get(request) }
|
|
19
|
+
}
|