dsh-vscode-mode 0.20.0 → 0.20.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.
@@ -20,7 +20,7 @@ function useMcpView({ sessionId, store }: McpRowProps): SessionMcpView {
20
20
 
21
21
  /** @public @author ddj 2026年10月20号 @param view Current observation. @returns Accessible row label; steady states deliberately do not occupy the leading slot. */
22
22
  export function mcpRowLabel(view: SessionMcpView): string | undefined {
23
- if (view.stale) return '项目 MCP 状态暂不可用(上次状态已过期)'
23
+ if (view.stale) return undefined
24
24
  if (!view.status || (!busy.has(view.status.phase) && view.status.phase !== 'error')) return undefined
25
25
  return '项目 MCP:' + labels[view.status.phase]
26
26
  }
@@ -30,7 +30,7 @@ export function McpSessionIcon(props: McpRowProps): React.ReactElement | null {
30
30
  const view = useMcpView(props)
31
31
  const label = mcpRowLabel(view)
32
32
  if (!label) return null
33
- const warning = view.stale || view.status?.phase === 'error'
33
+ const warning = view.status?.phase === 'error'
34
34
  return React.createElement('span', {
35
35
  role: 'img', 'aria-label': label, title: label,
36
36
  style: { display: 'inline-flex', width: 16, height: 16, alignItems: 'center', justifyContent: 'center',
@@ -41,6 +41,13 @@ export function McpSessionIcon(props: McpRowProps): React.ReactElement | null {
41
41
  /** @private @author ddj 2026年10月20号 @param start Stage start. @param end Stage finish or local clock. @returns Human-readable elapsed time, without claiming a timeout. */
42
42
  function elapsed(start: number, end: number): string { return Math.max(0, (end - start) / 1000).toFixed(1) + ' 秒' }
43
43
 
44
+ /** @private @author ddj 2026年10月27号 @param view Query observation. @returns Query-only explanation, separate from initialization health. */
45
+ function queryMessage(view: SessionMcpView): string {
46
+ if (view.reason === 'paused') return '状态查询已暂停'
47
+ if (view.reason === 'pending') return '正在查询项目 MCP 状态'
48
+ return '状态查询暂不可用,不代表 MCP 初始化失败'
49
+ }
50
+
44
51
  /** @public @author ddj 2026年10月20号 @param props Official hover sessionId and poller. @returns Project-only diagnostic detail, never transport credentials. */
45
52
  export function McpSessionDetail(props: McpRowProps): React.ReactElement {
46
53
  const view = useMcpView(props)
@@ -53,10 +60,18 @@ export function McpSessionDetail(props: McpRowProps): React.ReactElement {
53
60
  }, [status, view.stale])
54
61
 
55
62
  const children: React.ReactNode[] = [React.createElement('strong', { key: 'title' }, '项目 MCP')]
56
- if (view.stale) children.push(React.createElement('div', { key: 'stale', role: 'status' }, '状态暂不可用;以下如有记录,为上次观测结果'))
57
- if (!status) children.push(React.createElement('div', { key: 'empty' }, view.stale ? '等待状态查询' : '当前无活跃 MCP 实例'))
58
- else {
59
- const end = status.finishedAt ?? now
63
+ if (view.stale) {
64
+ children.push(React.createElement('div', { key: 'stale', role: 'status' }, queryMessage(view)))
65
+ if (status) children.push(React.createElement('div', { key: 'history' }, '以下为上次观测结果,不代表当前状态'))
66
+ if (view.observedAt !== undefined) children.push(React.createElement('div', { key: 'observed' },
67
+ '上次观测时间:' + new Date(view.observedAt).toLocaleString()))
68
+ }
69
+ if (!status && !view.stale) children.push(React.createElement('div', { key: 'empty' }, '当前无活跃 MCP 实例'))
70
+ if (status) {
71
+ // Unobserved time must not look like continued initialization, including per-server timing.
72
+ let observedNow = now
73
+ if (view.stale) observedNow = view.observedAt ?? status.phaseAt
74
+ const end = status.finishedAt ?? observedNow
60
75
  const completed = status.servers.filter((server) => server.phase === 'ready').length
61
76
  const slow = !view.stale && busy.has(status.phase) && now - status.phaseAt > 10000
62
77
  children.push(React.createElement('div', { key: 'phase' }, labels[status.phase], slow ? '(耗时较长,不代表失败)' : ''))
@@ -64,7 +79,7 @@ export function McpSessionDetail(props: McpRowProps): React.ReactElement {
64
79
  if (status.configMs !== undefined) children.push(React.createElement('div', { key: 'config' }, `配置准备 ${elapsed(0, status.configMs)}`))
65
80
  if (status.error) children.push(React.createElement('div', { key: 'error' }, status.error))
66
81
  for (const server of status.servers) children.push(React.createElement('div', { key: 'server:' + server.serverName },
67
- `${server.serverName}:${labels[server.phase]}${server.reused ? ' · 复用已有连接' : ''}${server.phase === 'idle' ? '' : ' · ' + elapsed(server.startedAt, server.finishedAt ?? now)}`,
82
+ `${server.serverName}:${labels[server.phase]}${server.reused ? ' · 复用已有连接' : ''}${server.phase === 'idle' ? '' : ' · ' + elapsed(server.startedAt, server.finishedAt ?? observedNow)}`,
68
83
  server.queuedMs === undefined ? '' : ' · 排队 ' + elapsed(0, server.queuedMs),
69
84
  server.connectMs === undefined ? '' : ' · 初始化 ' + elapsed(0, server.connectMs),
70
85
  server.firstCallMs === undefined ? '' : ' · 首次工具执行 ' + elapsed(0, server.firstCallMs),
@@ -0,0 +1,112 @@
1
+ import React from 'react'
2
+ import { Button, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
3
+ import type { ReviewDetail, ReviewItem, ReviewPage } from '../../shared/review.js'
4
+ import { ReviewLedger } from './OfficialReview.js'
5
+
6
+ export interface OfficialBarProps {
7
+ official: { sessionId: string; path: string; pending: number; detail?: ReviewDetail; records?: ReviewItem[]; page: ReviewPage; onSelect: (id: string) => void }
8
+ activePath: string
9
+ pendingRegions?: unknown[]
10
+ staleRegions?: unknown[]
11
+ diffIdx?: number
12
+ fileTotal?: number
13
+ fileIdx?: number
14
+ allPendingCount?: number
15
+ reviewBatchBusy?: boolean
16
+ batchUnavailable?: string
17
+ onAcceptAllFiles?: () => void
18
+ onUndoAllFiles?: () => void
19
+ onPrevFile: () => void
20
+ onNextFile: () => void
21
+ onPrevDiff: () => void
22
+ onNextDiff: () => void
23
+ onAcceptFile: () => void
24
+ onUndoFile: () => void
25
+ onOpenLauncher: (tab: string) => void
26
+ reviewError?: string
27
+ }
28
+
29
+ type Mark = 'left' | 'right' | 'up' | 'down' | 'check' | 'undo' | 'more' | 'file'
30
+ const marks: Record<Mark, string> = {
31
+ left: 'm10 4-5 4 5 4', right: 'm6 4 5 4-5 4', up: 'm4 10 4-5 4 5', down: 'm4 6 4 5 4-5',
32
+ check: 'm3 8 3 3 7-7', undo: 'M5 3 2 6l3 3M2 6h7a4 4 0 0 1 0 8',
33
+ more: 'M3 8h.01M8 8h.01M13 8h.01', file: 'M9 2H4v12h8V5L9 2Zm0 0v4h3M6 9h4M6 11h3',
34
+ }
35
+
36
+ /** @private @author ddj 2026年10月27号 @param mark Toolbar action. @returns Decorative consistent-stroke icon; labels live on buttons. */
37
+ function barIcon(mark: Mark): React.ReactElement {
38
+ return <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={marks[mark]} /></svg>
39
+ }
40
+
41
+ /** @private @author ddj 2026年10月27号 @param props Global workspace counts and bulk handlers. @returns Persistent global review controls, distinct from current-file actions. */
42
+ function ReviewAllRow(props: OfficialBarProps): React.ReactElement {
43
+ const total = props.fileTotal ?? 0
44
+ const current = total && (props.fileIdx ?? -1) >= 0 ? (props.fileIdx ?? 0) + 1 : 0
45
+ const disabled = props.reviewBatchBusy || !!props.batchUnavailable || !props.allPendingCount
46
+ return <div className="edrv-review-global" role="group" aria-label="全部待审文件">
47
+ <Button size="sm" variant="ghost" className="edrv-review-total" onClick={() => props.onOpenLauncher('pending')} title="打开当前工作区全部待审差异文件">差异文件 <strong>{current} / {total}</strong></Button>
48
+ <span className="edrv-review-all-actions">
49
+ <Button size="sm" variant="ghost" onClick={props.onAcceptAllFiles} disabled={disabled || !props.onAcceptAllFiles} title={props.batchUnavailable || 'Keep 当前工作区全部待审文件(含其他轮次),不改写磁盘'}>Keep All</Button>
50
+ <Button size="sm" variant="ghost" className="edrv-review-undo" onClick={props.onUndoAllFiles} disabled={disabled || !props.onUndoAllFiles} title={props.batchUnavailable || '检查全部待审记录后逐项 Undo;缺少证据或遇到冲突停止,不强制覆盖'}>Undo All</Button>
51
+ </span>
52
+ </div>
53
+ }
54
+
55
+ /** @public @author ddj 2026年10月27号 @param props Existing review callbacks and immutable record metadata. @returns Responsive review toolbar; no disk or decision side effects until an explicit action. */
56
+ export function OfficialDiffBar(props: OfficialBarProps): React.ReactElement {
57
+ const { official, pendingRegions = [], staleRegions = [], diffIdx = 0 } = props
58
+ const [expanded, setExpanded] = React.useState(false)
59
+ const [menu, setMenu] = React.useState<'records' | 'more' | null>(null)
60
+ const name = String(props.activePath || '').split(/[\\/]/).pop() || '当前文件'
61
+ const detail = official.detail
62
+ const records = official.records ?? []
63
+ const count = pendingRegions.length
64
+ const position = count ? `${Math.min(diffIdx + 1, count)} / ${count}` : '—'
65
+
66
+ /** @private @author ddj 2026年10月27号 @returns Close current popup without changing selection. */
67
+ function closeMenu(): void { setMenu(null) }
68
+ /** @private @author ddj 2026年10月27号 @param id Immutable record ID. @returns Select record and dismiss popup. */
69
+ function selectRecord(id: string): void { official.onSelect(id); closeMenu() }
70
+ /** @private @author ddj 2026年10月27号 @param tab Existing launcher tab. @returns Open existing overview, never create another review entry. */
71
+ function openOverview(tab: string): void { props.onOpenLauncher(tab); closeMenu() }
72
+ /** @private @author ddj 2026年10月27号 @param mark Navigation icon. @param label Accessible action. @param action Existing callback. @param disabled Whether navigation is unavailable. @returns Compact keyboard-focusable navigation control. */
73
+ function navButton(mark: Mark, label: string, action: () => void, disabled: boolean): React.ReactElement {
74
+ return <Button size="sm" variant="ghost" className="edrv-review-icon" aria-label={label} title={label} disabled={disabled} onClick={action}>{barIcon(mark)}</Button>
75
+ }
76
+
77
+ const recordTrigger = <Button size="sm" variant="ghost" className="edrv-review-record" aria-label="选择官方差异记录" aria-haspopup="menu" aria-expanded={menu === 'records'} disabled={!records.length} onClick={() => setMenu(menu === 'records' ? null : 'records')}>
78
+ {detail ? `轮次 ${detail.item.turn}` : '选择记录'}{barIcon('down')}
79
+ </Button>
80
+ const moreTrigger = <Button size="sm" variant="ghost" className="edrv-review-icon" aria-label="更多审查操作" title="更多审查操作" aria-haspopup="menu" aria-expanded={menu === 'more'} onClick={() => setMenu(menu === 'more' ? null : 'more')}>{barIcon('more')}</Button>
81
+
82
+ return <div className="edrv-diffbar edrv-diffbar-dock edrv-reviewbar" data-edrv-diffbox="1" data-edrv-diff-source="official" role="region" aria-label="文件差异审查">
83
+ <ReviewAllRow {...props} />
84
+ {props.reviewBatchBusy ? <div className="edrv-review-notice" role="status">正在处理全部待审记录…</div> : null}
85
+ <div className="edrv-review-heading">
86
+ <span className="edrv-review-filemark">{barIcon('file')}</span>
87
+ <span className="edrv-review-filename" title={props.activePath}>{name}</span>
88
+ <span className="edrv-review-pending" title={`${official.pending} 条官方待审记录`}>待审 <strong>{official.pending}</strong></span>
89
+ <Menu open={menu === 'records'} anchor={recordTrigger} items={records.map((item) => ({ id: item.id, label: `轮次 ${item.turn} / 事件 ${item.seq}` }))} selectedId={detail?.item.id} onSelect={selectRecord} onClose={closeMenu} side="top" align="end" portal compact autoFocus />
90
+ </div>
91
+ <div className="edrv-review-tools">
92
+ <div className="edrv-review-nav" role="group" aria-label="文件导航">
93
+ {navButton('left', '上一个差异文件', props.onPrevFile, !props.fileTotal)}
94
+ {navButton('right', '下一个差异文件', props.onNextFile, !props.fileTotal)}
95
+ </div>
96
+ <div className="edrv-review-nav edrv-review-blocknav" role="group" aria-label="差异导航">
97
+ {navButton('up', '上一个差异', props.onPrevDiff, !count)}
98
+ <span className="edrv-review-position" aria-live="polite">{position}</span>
99
+ {navButton('down', '下一个差异', props.onNextDiff, !count)}
100
+ </div>
101
+ <div className="edrv-review-actions">
102
+ <Button size="sm" variant="ghost" className="edrv-review-undo" icon={barIcon('undo')} onClick={props.onUndoFile} disabled={props.reviewBatchBusy || !detail?.capability.undo} title={detail?.capability.reason}>Undo</Button>
103
+ <Button size="sm" variant="primary" className="edrv-review-keep" icon={barIcon('check')} onClick={props.onAcceptFile} disabled={props.reviewBatchBusy || !detail?.capability.keep} title="保留当前记录的未处理修改">Keep</Button>
104
+ <Button size="sm" variant="ghost" className="edrv-review-details" onClick={() => setExpanded(!expanded)} aria-expanded={expanded} aria-label={expanded ? '收起差异' : '展开差异'} title={expanded ? '收起差异' : '展开差异'}>详情{barIcon(expanded ? 'up' : 'down')}</Button>
105
+ <Menu open={menu === 'more'} anchor={moreTrigger} items={[{ id: 'pending', label: '差异总览' }, { id: 'archive', label: '归档 / 历史' }]} onSelect={openOverview} onClose={closeMenu} side="top" align="end" portal compact autoFocus />
106
+ </div>
107
+ </div>
108
+ {detail && staleRegions.length > 0 ? <div className="edrv-review-notice" role="status">{staleRegions.length} 处历史差异无法定位,展开详情查看原始增删。</div> : null}
109
+ {props.reviewError ? <div className="edrv-review-notice edrv-review-error" role="alert">{props.reviewError}</div> : null}
110
+ {expanded ? <div className="edrv-review-body"><ReviewLedger key={`${official.sessionId}:${official.path}`} sessionId={official.sessionId} first={official.page} path={official.path} /></div> : null}
111
+ </div>
112
+ }
@@ -0,0 +1,35 @@
1
+ import React from 'react'
2
+ import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
3
+
4
+ interface ReviewAllProps {
5
+ reject: boolean
6
+ count: number
7
+ onCancel: () => void
8
+ onConfirm: () => void
9
+ }
10
+
11
+ /**
12
+ * @public
13
+ * @author ddj 2026年10月27号
14
+ * @description Confirm workspace-wide review through the DSH modal; dismissing never applies a decision.
15
+ * @param props Pending file count, action and explicit confirmation callbacks.
16
+ * @returns DSH-themed confirmation with distinct Keep and Undo safety descriptions.
17
+ */
18
+ export function ReviewAllConfirm(props: ReviewAllProps): React.ReactElement {
19
+ const action = props.reject ? 'Undo All' : 'Keep All'
20
+ const warning = props.reject
21
+ ? '撤销会改写磁盘;逐项安全校验,遇到冲突停止,已完成项不会自动恢复。'
22
+ : '只标记待审修改为保留,不改写文件。'
23
+ const footer = React.createElement(React.Fragment, null,
24
+ React.createElement(Button, { variant: 'ghost', onClick: props.onCancel, autoFocus: true }, '取消'),
25
+ React.createElement(Button, { variant: 'primary', onClick: props.onConfirm }, '确认 ' + action))
26
+
27
+ return React.createElement(Modal, {
28
+ open: true,
29
+ title: action + ' 确认',
30
+ closeLabel: '取消',
31
+ onClose: props.onCancel,
32
+ description: `处理当前工作区全部 ${props.count} 个待审差异文件(含其他轮次)?`,
33
+ footer,
34
+ }, React.createElement('p', null, warning))
35
+ }
@@ -7,6 +7,40 @@
7
7
  * 作者 ddj 2026-09-10
8
8
  */
9
9
  declare module '@deepseek-ai/dsh-client-ui-primitives' {
10
+ /** Compact theme-backed button; mirrors official Button.d.ts. @author ddj 2026年10月27号 */
11
+ export const Button: import('react').ComponentType<import('react').ButtonHTMLAttributes<HTMLButtonElement> & {
12
+ variant?: 'primary' | 'ghost' | 'outline' | 'toolbar'
13
+ size?: 'md' | 'sm'
14
+ icon?: import('react').ReactNode
15
+ }>
16
+
17
+ /** Centered body-portaled dialog; mirrors official Modal.d.ts. @author ddj 2026年10月27号 */
18
+ export const Modal: import('react').ComponentType<{
19
+ open: boolean
20
+ onClose: () => void
21
+ title: string
22
+ description?: string
23
+ children?: import('react').ReactNode
24
+ footer?: import('react').ReactNode
25
+ className?: string
26
+ contentClassName?: string
27
+ } & ({ headless: true; closeLabel?: never } | { headless?: false; closeLabel: string })>
28
+
29
+ /** Anchored selectable menu; mirrors the subset used from official Menu.d.ts. @author ddj 2026年10月27号 */
30
+ export const Menu: import('react').ComponentType<{
31
+ open: boolean
32
+ anchor: import('react').ReactNode
33
+ items: readonly { id: string; label: import('react').ReactNode; disabled?: boolean }[]
34
+ selectedId?: string
35
+ onSelect: (id: string) => void
36
+ onClose: () => void
37
+ side?: 'top' | 'bottom' | 'right'
38
+ align?: 'start' | 'end'
39
+ portal?: boolean
40
+ compact?: boolean
41
+ autoFocus?: boolean
42
+ }>
43
+
10
44
  /** 图标组件通用 props(官方 Icon* 组件与 FileTypeIcon 共用形状)。 */
11
45
  export interface EdrvIconProps {
12
46
  /** 图标边长(px);省略时用图标自带尺寸。 */
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  import { registerRoutes } from './routes.js'
3
3
  import { captureToolResult } from './capture.js'
4
4
  import { installCapture } from './officialCapture.js'
5
+ import { installEvidence } from './reviewCapture.js'
5
6
  import { closeArchiveDbs } from './archiveDb.js'
6
7
  import { handleRpc, integrationBaseUrlOf } from './rpc.js'
7
8
  import { newSearcher } from './search/orchestrator.js'
@@ -68,7 +69,8 @@ function createRuntime(ctx: Ctx, config: unknown) {
68
69
  * @param runtime Managers created by this instance.
69
70
  */
70
71
  function attachRuntime(ctx: Ctx, runtime: Runtime): void {
71
- const stopCapture = installCapture(ctx)
72
+ const evidence = installEvidence(ctx)
73
+ const stopCapture = installCapture(ctx, (cwd, snapshot) => { evidence.persist(cwd, snapshot); return true })
72
74
  /** @author ddj 2026年09月28号 @param exec Tool execution. @param result Tool result. */
73
75
  function onResult(exec: unknown, result: unknown): void {
74
76
  void captureToolResult(ctx, runtime.registry, exec, result)
@@ -87,6 +89,7 @@ function attachRuntime(ctx: Ctx, runtime: Runtime): void {
87
89
  /** @author ddj 2026年09月28号 Release every manager even if another cleanup rejects. */
88
90
  async function cleanup(): Promise<void> {
89
91
  await stopCapture()
92
+ evidence.dispose()
90
93
  runtime.fileVersions.dispose()
91
94
  closeArchiveDbs()
92
95
  disposeAllServers()
@@ -1,6 +1,6 @@
1
1
  /** Deferred official-event collection; no private snapshot access. @author ddj 2026年10月26号 */
2
2
  import { captureState, repairPaths, saveSnapshot } from './reviewDb.js'
3
- import type { OfficialDiff, OfficialSummary } from './shared/review.js'
3
+ import type { OfficialDiff, OfficialSummary, ReviewSnapshot } from './shared/review.js'
4
4
  import type { Ctx } from './store.js'
5
5
  import { loadBucket } from './store.js'
6
6
  import { log } from './log.js'
@@ -148,12 +148,16 @@ async function readDiff(source: SourceService, task: CaptureTask, index: number,
148
148
  }
149
149
 
150
150
  /** @public @author ddj 2026年10月26号 @param ctx Host lifecycle owner; services remain optional for older Hosts. */
151
- export function installCapture(ctx: Ctx): () => Promise<void> {
151
+ export function installCapture(ctx: Ctx, onSnapshot?: (cwd: string, snapshot: ReviewSnapshot) => void): () => Promise<void> {
152
152
  const collector = createCollector({
153
153
  source: () => ctx.get('workspaceChanges'),
154
154
  prepare: async (cwd) => { await loadBucket(ctx, cwd); await repairPaths(cwd, resolvePath) },
155
155
  identity: resolvePath,
156
- persist: saveSnapshot, state: captureState, report: (message) => log.warn(message),
156
+ persist: (cwd, snapshot) => {
157
+ const saved = saveSnapshot(cwd, snapshot)
158
+ onSnapshot?.(cwd, snapshot)
159
+ return saved
160
+ }, state: captureState, report: (message) => log.warn(message),
157
161
  })
158
162
  // Subscribe first: seed reads must not create a live-event race window.
159
163
  ctx.on('session/event', collector.enqueue)
@@ -0,0 +1,112 @@
1
+ /** Independent byte evidence; official diff remains the only display authority. @author ddj 2026年10月27号 */
2
+ import { rawReviewFile, evidenceBlocks } from './reviewEvidence.js'
3
+ import type { RawReviewFile } from './reviewEvidence.js'
4
+ import type { ReviewSnapshot } from './shared/review.js'
5
+ import { reviewDb } from './reviewDb.js'
6
+ import { resolveTarget } from './store.js'
7
+ import type { Ctx } from './store.js'
8
+ import { log } from './log.js'
9
+
10
+ interface ByteChain { before: RawReviewFile; after: RawReviewFile; busy: boolean; invalid: boolean }
11
+ interface EvidenceCapture {
12
+ persist(cwd: string, snapshot: ReviewSnapshot): void
13
+ dispose(): void
14
+ }
15
+
16
+ /** @public @author ddj 2026年10月27号 @param ctx Host lifecycle owner. @returns Scoped byte recorder; failures disable undo evidence only, never tool execution. */
17
+ export function installEvidence(ctx: Ctx): EvidenceCapture {
18
+ const chains = new Map<string, Map<string, ByteChain>>()
19
+ const staged = new Map<string, Map<string, ByteChain>>()
20
+ let stopped = false
21
+ ctx.on('tools/execute', aroundTool)
22
+ ctx.on('session/event', onEvent)
23
+ ctx.on('session/disposed', onDisposed)
24
+
25
+ /** @private @author ddj 2026年10月27号 @param session Disposed owner. Drops optional in-memory evidence, never persisted official facts. */
26
+ function onDisposed(session: { id: string }): void { chains.delete(session.id) }
27
+
28
+ /** @private @author ddj 2026年10月27号 @returns Current bounded capture footprint, excluding transient filesystem reads. */
29
+ function footprint(): number {
30
+ let bytes = 0
31
+ for (const group of [chains, staged]) {
32
+ for (const files of group.values()) {
33
+ for (const chain of files.values()) bytes += chain.before.bytes.length + chain.after.bytes.length
34
+ }
35
+ }
36
+ return bytes
37
+ }
38
+
39
+ /** @private @author ddj 2026年10月27号 @param exec Allowed tool dispatch. @param next Original dispatch. @returns Original result unchanged, regardless of capture failure. */
40
+ async function aroundTool(exec: any, next: () => Promise<any>): Promise<any> {
41
+ const session = exec.agent?.session
42
+ const path = exec.arguments?.file_path
43
+ if (stopped || !session?.id || !['edit', 'write'].includes(exec.name) || typeof path !== 'string') return next()
44
+ let fs: any
45
+ let chain: ByteChain | undefined
46
+ let target: any
47
+ try {
48
+ if ((chains.size >= 16 && !chains.has(session.id)) || footprint() >= 32 * 1024 * 1024) return next()
49
+ fs = ctx.get('fs')
50
+ target = await resolveTarget(ctx, session, path)
51
+ const identity = fs.processPath(target)
52
+ const before = await readRaw(fs, target, exec.signal)
53
+ let files = chains.get(session.id)
54
+ if (!files) { files = new Map(); chains.set(session.id, files) }
55
+ if (files.size >= 64 && !files.has(identity)) return next()
56
+ chain = files.get(identity)
57
+ if (!chain) { chain = { before, after: before, busy: false, invalid: false }; files.set(identity, chain) }
58
+ if (chain.busy || chain.after.hash !== before.hash) chain.invalid = true
59
+ chain.busy = true
60
+ } catch { /* No trustworthy read means no undo evidence, not a failed user tool. */ }
61
+ try { return await next() }
62
+ finally {
63
+ if (chain) {
64
+ try { chain.after = await readRaw(fs, target, exec.signal) } catch { chain.invalid = true }
65
+ chain.busy = false
66
+ }
67
+ }
68
+ }
69
+
70
+ /** @private @author ddj 2026年10月27号 @param session Source session. @param event Published official turn. Freezes the chain before the next turn can mutate it. */
71
+ function onEvent(session: any, event: any): void {
72
+ if (stopped || event.type !== 'workspace/changes') return
73
+ const files = chains.get(session.id)
74
+ chains.delete(session.id)
75
+ if (!files || staged.size >= 128) return
76
+ staged.set(JSON.stringify([session.id, event.seq]), structuredClone(files))
77
+ }
78
+
79
+ /** @public @author ddj 2026年10月27号 @param cwd Official workspace. @param snapshot Committed official facts. Persists only independently validated, exact-correspondence byte chains. */
80
+ function persist(cwd: string, snapshot: ReviewSnapshot): void {
81
+ const key = JSON.stringify([snapshot.sessionId, snapshot.seq])
82
+ const files = staged.get(key)
83
+ staged.delete(key)
84
+ if (!files || stopped) return
85
+ const db = reviewDb(cwd)
86
+ for (const file of snapshot.files) {
87
+ const chain = files.get(file.identity)
88
+ if (!chain || chain.invalid || chain.busy) continue
89
+ try {
90
+ const evidence = { before: chain.before, after: chain.after }
91
+ evidenceBlocks(file.diff, evidence)
92
+ db.prepare(`INSERT OR IGNORE INTO review_evidence(file_id,payload)
93
+ SELECT id,? FROM review_files WHERE session_id=? AND turn=? AND seq=? AND identity=?`)
94
+ .run(JSON.stringify(evidence), snapshot.sessionId, snapshot.turn, snapshot.seq, file.identity)
95
+ } catch (error) { log.warn('官方回滚证据不可用:' + String(error)) }
96
+ }
97
+ }
98
+
99
+ /** @public @author ddj 2026年10月27号 Release bounded in-memory images after capture has drained. */
100
+ function dispose(): void { stopped = true; chains.clear(); staged.clear() }
101
+ return { persist, dispose }
102
+ }
103
+
104
+ /** @private @author ddj 2026年10月27号 @param fs Public filesystem. @param target Backend identity. @returns Raw bounded file or confirmed missing. @throws Any non-missing read error. */
105
+ async function readRaw(fs: any, target: any, signal?: AbortSignal): Promise<RawReviewFile> {
106
+ try { return rawReviewFile(await fs.readBytes(target, signal, 1024 * 1024)) }
107
+ catch (error) {
108
+ const code = (error as { code?: string }).code
109
+ if (code === 'ENOENT' || code === 'FS_NOT_FOUND') return rawReviewFile(null)
110
+ throw error
111
+ }
112
+ }
package/src/reviewDb.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  /** Official history shares the archive connection, but never the legacy callId namespace. @author ddj 2026年10月26号 */
2
2
  import { createHash } from 'node:crypto'
3
+ import { existsSync } from 'node:fs'
4
+ import { archiveDbFile } from './paths.js'
5
+ import { reviewBlocks } from './shared/reviewRegions.js'
6
+ import type { ReviewBlockState } from './shared/reviewRegions.js'
3
7
  import type { DatabaseSync } from 'node:sqlite'
4
8
  import { activeReady, dbFor } from './archiveDb.js'
5
9
  import type { CaptureGap, DecideReview, OfficialDiff, ReviewDetail, ReviewItem, ReviewPage, ReviewSnapshot } from './shared/review.js'
@@ -11,7 +15,7 @@ export function reviewDb(cwd: string): DatabaseSync {
11
15
  if (!activeReady(cwd)) throw new Error('官方审查要求先完成活跃库迁移')
12
16
  const db = dbFor(cwd)
13
17
  const version = db.prepare("SELECT value FROM metadata WHERE name='schema'").get() as Row
14
- if (version.value === '4') return db
18
+ if (version.value === '4') { migrateBlocks(cwd, db); return db }
15
19
  db.exec('BEGIN IMMEDIATE')
16
20
  try {
17
21
  db.exec(`CREATE TABLE review_snapshots (
@@ -37,9 +41,37 @@ export function reviewDb(cwd: string): DatabaseSync {
37
41
  if (db.isTransaction) db.exec('ROLLBACK')
38
42
  throw error
39
43
  }
44
+ migrateBlocks(cwd, db)
40
45
  return db
41
46
  }
42
47
 
48
+ /** @private @author ddj 2026年10月27号 @param cwd Workspace backup identity. @param db Open schema4 database. Extends the official-review sub-schema without changing legacy archive schema. @throws Migration failure, leaving prior decisions intact. */
49
+ function migrateBlocks(cwd: string, db: DatabaseSync): void {
50
+ if (db.prepare("SELECT value FROM metadata WHERE name='review_schema'").get()) return
51
+ const backup = archiveDbFile(cwd) + '.before-review-blocks.bak'
52
+ if (!existsSync(backup)) db.exec(`VACUUM INTO '${backup.replace(/'/g, "''")}'`)
53
+
54
+ db.exec('BEGIN IMMEDIATE')
55
+ try {
56
+ db.exec(`CREATE TABLE review_blocks (file_id TEXT NOT NULL, block_id TEXT NOT NULL,
57
+ decision TEXT NOT NULL, PRIMARY KEY(file_id,block_id));
58
+ CREATE TABLE review_evidence (file_id TEXT PRIMARY KEY, payload TEXT NOT NULL);
59
+ INSERT INTO metadata(name,value) VALUES('review_schema','2');`)
60
+ const files = db.prepare('SELECT id,diff,decision FROM review_files').all() as Row[]
61
+ for (const file of files) seedBlocks(db, String(file.id), JSON.parse(String(file.diff)), String(file.decision))
62
+ db.exec('COMMIT')
63
+ } catch (error) {
64
+ if (db.isTransaction) db.exec('ROLLBACK')
65
+ throw error
66
+ }
67
+ }
68
+
69
+ /** @private @author ddj 2026年10月27号 @param db Transaction connection. @param id File record. @param diff Immutable official source. @param decision Prior file decision. Seeds deterministic blocks without changing source facts. */
70
+ function seedBlocks(db: DatabaseSync, id: string, diff: OfficialDiff, decision: string): void {
71
+ const insert = db.prepare('INSERT INTO review_blocks VALUES(?,?,?)')
72
+ for (const block of reviewBlocks(diff)) insert.run(id, block.id, decision)
73
+ }
74
+
43
75
  /** @private @author ddj 2026年10月26号 @param text Canonical serialized input. @returns Collision-resistant identity. */
44
76
  function digest(text: string): string { return createHash('sha256').update(text).digest('hex') }
45
77
 
@@ -107,7 +139,7 @@ export async function repairPaths(cwd: string, resolvePath: (cwd: string, path:
107
139
  /** @private @author ddj 2026年10月26号 @param db Transaction. @param snapshot Source payload. @param hash Source digest. */
108
140
  function insertSnapshot(db: DatabaseSync, snapshot: ReviewSnapshot, hash: string): void {
109
141
  const { sessionId, turn, seq } = snapshot
110
- const previous = db.prepare('SELECT identity,diff,decision FROM review_files WHERE session_id=? AND turn=? AND current=1').all(sessionId, turn) as Row[]
142
+ const previous = db.prepare('SELECT id,identity,diff,decision FROM review_files WHERE session_id=? AND turn=? AND current=1').all(sessionId, turn) as Row[]
111
143
  db.prepare('UPDATE review_snapshots SET current=0 WHERE session_id=? AND turn=?').run(sessionId, turn)
112
144
  db.prepare('UPDATE review_files SET current=0 WHERE session_id=? AND turn=?').run(sessionId, turn)
113
145
  db.prepare('INSERT INTO review_snapshots VALUES(?,?,?,?,?,1)').run(sessionId, turn, seq, JSON.stringify(snapshot), hash)
@@ -116,7 +148,17 @@ function insertSnapshot(db: DatabaseSync, snapshot: ReviewSnapshot, hash: string
116
148
  const encoded = JSON.stringify(file.diff)
117
149
  const unchanged = previous.find((item) => item.identity === file.identity && item.diff === encoded)
118
150
  const id = digest(JSON.stringify([sessionId, turn, seq, file.identity]))
119
- insert.run(id, sessionId, turn, seq, file.identity, JSON.stringify(snapshot.summary.files[index]), encoded, unchanged?.decision ?? 'pending')
151
+ const decision = String(unchanged?.decision ?? 'pending')
152
+ insert.run(id, sessionId, turn, seq, file.identity, JSON.stringify(snapshot.summary.files[index]), encoded, decision)
153
+ seedBlocks(db, id, file.diff, decision)
154
+ if (unchanged) {
155
+ // A same-turn republish must preserve mixed per-block decisions, not seed all blocks as mixed.
156
+ db.prepare(`UPDATE review_blocks SET decision=(SELECT p.decision FROM review_blocks p
157
+ WHERE p.file_id=? AND p.block_id=review_blocks.block_id)
158
+ WHERE file_id=? AND EXISTS(SELECT 1 FROM review_blocks p WHERE p.file_id=? AND p.block_id=review_blocks.block_id)`)
159
+ .run(unchanged.id, id, unchanged.id)
160
+ db.prepare('INSERT INTO review_evidence SELECT ?,payload FROM review_evidence WHERE file_id=?').run(id, unchanged.id)
161
+ }
120
162
  }
121
163
  }
122
164
 
@@ -181,9 +223,33 @@ export function reviewDetail(cwd: string, id: string): ReviewDetail | null {
181
223
  const row = db.prepare(`SELECT f.*,json_extract(s.payload,'$.capturedAt') AS captured_at FROM review_files f JOIN review_snapshots s
182
224
  USING(session_id,turn,seq) WHERE f.id=?`).get(id) as Row | undefined
183
225
  if (!row) return null
184
- return { item: itemOf(row), diff: JSON.parse(String(row.diff)), capturedAt: String(row.captured_at),
185
- capability: { keep: row.current === 1 && row.decision === 'pending', undo: false,
186
- reason: '官方仅提供差异块,缺少可验证的历史全文及原始字节;拒绝写回已禁用。可标记已审或打开当前文件继续编辑。' } }
226
+ const blocks = db.prepare('SELECT block_id AS id,decision FROM review_blocks WHERE file_id=? ORDER BY rowid').all(id) as unknown as ReviewBlockState[]
227
+ const diff = JSON.parse(String(row.diff)) as OfficialDiff
228
+ const evidence = db.prepare('SELECT 1 FROM review_evidence WHERE file_id=?').get(id)
229
+ const pending = row.current === 1 && row.decision === 'pending'
230
+ const undo = pending && !!evidence && diff.kind === 'text' && diff.before && diff.after && !diff.coarse
231
+ return { item: itemOf(row), diff, capturedAt: String(row.captured_at), blocks,
232
+ capability: { keep: pending, undo,
233
+ reason: undo ? 'Undo 将校验原始字节、当前文件版本及已处理差异块;文件变化会安全拒绝。'
234
+ : '该记录缺少可验证的原始字节或受控写回能力;仍可显示差异和 Keep,不能猜测历史全文执行 Undo。' } }
235
+ }
236
+
237
+ /** @public @author ddj 2026年10月27号 @param db Active decision transaction. @param request File or block action. Updates aggregate without losing mixed decisions. @throws Invalid or already-decided block. */
238
+ export function changeBlocks(db: DatabaseSync, request: DecideReview): void {
239
+ if (request.scope === 'hunk') {
240
+ const changed = db.prepare("UPDATE review_blocks SET decision=? WHERE file_id=? AND block_id=? AND decision='pending'")
241
+ .run(request.decision, request.id, request.blockId ?? '')
242
+ if (changed.changes !== 1) throw new Error('差异块不存在或已处理')
243
+ } else {
244
+ db.prepare("UPDATE review_blocks SET decision=? WHERE file_id=? AND decision='pending'").run(request.decision, request.id)
245
+ }
246
+ const states = db.prepare('SELECT DISTINCT decision FROM review_blocks WHERE file_id=?').all(request.id) as Row[]
247
+ const decisions = states.map((row) => String(row.decision))
248
+ let aggregate = request.decision as string
249
+ if (decisions.includes('pending')) aggregate = 'pending'
250
+ else if (decisions.length > 1) aggregate = 'mixed'
251
+ else if (decisions.length === 1) aggregate = decisions[0]
252
+ db.prepare('UPDATE review_files SET decision=? WHERE id=?').run(aggregate, request.id)
187
253
  }
188
254
 
189
255
  /** @public @author ddj 2026年10月26号 @param cwd Workspace. @param request Frozen identity/revision. @returns Idempotent decision; rejection never guesses a target. */
@@ -196,13 +262,18 @@ export function decideReview(cwd: string, request: DecideReview): { revision: nu
196
262
  const previous = db.prepare('SELECT request,result FROM review_ops WHERE operation_id=?').get(request.operationId) as Row | undefined
197
263
  if (previous) {
198
264
  if (previous.request !== encoded) throw new Error('operationId 已用于其他请求')
265
+ if (!previous.result) throw new Error('Undo 尚待写后核对,请重试原操作')
199
266
  db.exec('COMMIT')
200
267
  return JSON.parse(String(previous.result))
201
268
  }
269
+ if (db.prepare("SELECT 1 FROM review_ops WHERE state='prepared' AND json_extract(request,'$.id')=?").get(request.id)) {
270
+ throw new Error('该文件有尚待核对的 Undo,暂不能 Keep')
271
+ }
202
272
  if (request.decision !== 'accepted') throw new Error('缺少可信全文证据,禁止拒绝写回')
203
273
  const change = db.prepare(`UPDATE review_files SET decision='accepted',revision=revision+1
204
274
  WHERE id=? AND seq=? AND revision=? AND current=1 AND decision='pending'`).run(request.id, request.seq, request.revision)
205
275
  if (change.changes !== 1) throw new Error('审查版本冲突,请刷新后重试')
276
+ changeBlocks(db, request)
206
277
  const result = { revision: request.revision + 1 }
207
278
  db.prepare("INSERT INTO review_ops VALUES(?,?,'committed',?,NULL)").run(request.operationId, encoded, JSON.stringify(result))
208
279
  db.exec('COMMIT')
@@ -0,0 +1,70 @@
1
+ /** Raw UTF-8 evidence and patch correspondence; never reconstructs a historical file from disk. @author ddj 2026年10月27号 */
2
+ import { createHash } from 'node:crypto'
3
+ import { reviewBlocks } from './shared/reviewRegions.js'
4
+ import type { OfficialDiff } from './shared/review.js'
5
+
6
+ export interface RawReviewFile { present: boolean; bytes: string; hash: string }
7
+ export interface ReviewEvidence { before: RawReviewFile; after: RawReviewFile }
8
+ export interface RawReviewBlock { id: string; before: string; after: string; offset: number }
9
+
10
+ /** @public @author ddj 2026年10月27号 @param bytes File bytes, or null for confirmed absence. @returns Existence-sensitive immutable digest and base64. */
11
+ export function rawReviewFile(bytes: Uint8Array | null): RawReviewFile {
12
+ const payload = bytes === null ? Buffer.alloc(0) : Buffer.from(bytes)
13
+ return { present: bytes !== null, bytes: payload.toString('base64'), hash: createHash('sha256')
14
+ .update(bytes === null ? 'absent:' : 'present:').update(payload).digest('hex') }
15
+ }
16
+
17
+ /** @private @author ddj 2026年10月27号 @param file Persisted raw image. @returns UTF-8 storage text preserving BOM and terminators. @throws Binary, invalid encoding or corrupt hash. */
18
+ function rawText(file: RawReviewFile): string {
19
+ const bytes = Buffer.from(file.bytes, 'base64')
20
+ if (bytes.length > 8 * 1024 * 1024 || bytes.includes(0)) throw new Error('回滚证据不是受支持的 UTF-8 文本')
21
+ if (rawReviewFile(file.present ? bytes : null).hash !== file.hash) throw new Error('回滚证据哈希不匹配')
22
+ // ignoreBOM=true retains the BOM codepoint, allowing byte-identical writeText.
23
+ const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes)
24
+ if (!Buffer.from(text, 'utf8').equals(bytes)) throw new Error('回滚证据无法字节往返')
25
+ return text
26
+ }
27
+
28
+ /** @private @author ddj 2026年10月27号 @param text Storage text. @returns Physical lines, retaining each original line ending and excluding BOM. */
29
+ function rawLines(text: string): string[] {
30
+ const body = text.replace(/^\uFEFF/, '')
31
+ return body.match(/[^\r\n]*(?:\r\n|\n|\r|$)/g)?.filter((line, index, lines) => line !== '' || index !== lines.length - 1) ?? []
32
+ }
33
+
34
+ /** @private @author ddj 2026年10月27号 @param lines Physical storage lines. @returns Official logical-line comparison basis. */
35
+ function logical(lines: string[]): string[] { return lines.map((line) => line.replace(/(?:\r\n|\r|\n)$/, '')) }
36
+
37
+ /** @public @author ddj 2026年10月27号 @param diff Official source. @param evidence Independent raw captures. @returns Exact raw segments only when every official hunk reproduces the entire captured result. @throws Incomplete chain, coarse patch or mismatched context/content. */
38
+ export function evidenceBlocks(diff: OfficialDiff, evidence: ReviewEvidence): RawReviewBlock[] {
39
+ if (diff.kind !== 'text' || diff.coarse) throw new Error('该官方差异没有可靠文本基线')
40
+ if (diff.before !== evidence.before.present || diff.after !== evidence.after.present) throw new Error('文件存在性证据不一致')
41
+ const beforeText = rawText(evidence.before), afterText = rawText(evidence.after)
42
+ const before = rawLines(beforeText), after = rawLines(afterText)
43
+ const expected = logical(before)
44
+ let delta = 0
45
+ for (const hunk of diff.hunks) {
46
+ const old = hunk.lines.filter((line) => line[0] === ' ' || line[0] === '-').map((line) => line.slice(1))
47
+ const next = hunk.lines.filter((line) => line[0] === ' ' || line[0] === '+').map((line) => line.slice(1))
48
+ if (old.length !== hunk.oldLines || next.length !== hunk.newLines) throw new Error('官方差异行数不一致')
49
+ const position = Math.max(0, hunk.oldStart - 1) + delta
50
+ if (JSON.stringify(expected.slice(position, position + old.length)) !== JSON.stringify(old)) throw new Error('原始基线与官方上下文不一致')
51
+ expected.splice(position, old.length, ...next)
52
+ delta += next.length - old.length
53
+ }
54
+ if (JSON.stringify(expected) !== JSON.stringify(logical(after))) throw new Error('采集链未覆盖全部官方变更')
55
+ if (!diff.hunks.length && beforeText !== afterText) throw new Error('官方没有提供可撤销的变更块')
56
+ const blocks = reviewBlocks(diff)
57
+ const segments = blocks.map((block) => ({ id: block.id,
58
+ before: before.slice(block.oldStart - 1, block.oldStart - 1 + block.oldLines.length).join(''),
59
+ after: after.slice(block.newStart - 1, block.newStart - 1 + block.newLines.length).join(''),
60
+ offset: (afterText.startsWith('\uFEFF') ? 1 : 0) + after.slice(0, block.newStart - 1).join('').length }))
61
+ let reversed = afterText
62
+ for (const segment of [...segments].reverse()) reversed = reversed.slice(0, segment.offset) + segment.before + reversed.slice(segment.offset + segment.after.length)
63
+ if (reversed !== beforeText) throw new Error('官方变更外的 BOM、行尾或末尾换行发生变化,证据不足')
64
+ return segments
65
+ }
66
+
67
+ /** @public @author ddj 2026年10月27号 @param evidence Raw original images. @returns Exact before and after UTF-8 storage text for guarded writes. */
68
+ export function evidenceText(evidence: ReviewEvidence): { before: string; after: string } {
69
+ return { before: rawText(evidence.before), after: rawText(evidence.after) }
70
+ }