dsh-vscode-mode 0.14.1 → 0.14.3

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/src/rpc.ts CHANGED
@@ -14,22 +14,22 @@ import {
14
14
  appendArchiveEntries,
15
15
  archiveRecords,
16
16
  autoArchiveStale,
17
- parseArchive,
18
17
  policyOf,
19
- readArchiveText,
20
18
  resolveTarget,
21
19
  saveBucket,
22
20
  } from './store.js'
23
21
  import { cp, mkdir, readFile, rename, rm, stat, writeFile, readdir } from 'node:fs/promises'
24
22
  import { basename, dirname, join } from 'node:path'
25
23
  import { archiveEntryFor, markDecision, recordResolved, reconstructOriginal } from './model.js'
24
+ import { clearRevertState, listArchives, pageArchives, readArchives, revertState, setRevertState, startReverts } from './archiveDb.js'
25
+ import { log } from './log.js'
26
26
  import type { Registry } from './registry.js'
27
27
  import { bucketOf, cwdOf, sessionOf } from './registry.js'
28
28
  import type { SearchOrchestrator } from './search/orchestrator.js'
29
29
  import { newSearcher } from './search/orchestrator.js'
30
30
  import type { ContentSearcher } from './search/content.js'
31
31
  import { newContentSearcher } from './search/content.js'
32
- import { restoreFile, revertCall, revertHunk } from './revert.js'
32
+ import { restoreFile, revertCall, revertHunk, revertHunks, type Result } from './revert.js'
33
33
  import { rulesList, rulesRead, rulesRemove, rulesSave, rulesToggle } from './rules.js'
34
34
  import { isSnippetFilePath, snippetsEntries, snippetsList, snippetsRead, snippetsRemove, snippetsSave } from './snippets.js'
35
35
  import { listMcp, refreshMcp, removeMcp, saveMcp, toggleMcp } from './mcp.js'
@@ -58,6 +58,10 @@ import { settingsDocPath } from './settingsDocPath.js'
58
58
  const staleCheckedAt = new Map<string, number>()
59
59
  /** stale 自动清理最小间隔。 */
60
60
  const STALE_CHECK_MIN_MS = 10_000
61
+ // #region debug log
62
+ /** 诊断由 client 的 edrv.debug 激活,过期后不再产生 Host 耗时日志。 */
63
+ const diffTraceUntil = new Map<string, { until: number; id: string }>()
64
+ // #endregion
61
65
  /**
62
66
  * 无会话期诊断的伪 cwd:冷启动早期本插件客户端就会上报界面状态镜像事件
63
67
  * (此时 `sessions` 可能尚未就绪,`requireSession` 会失败),退回本固定值落盘
@@ -237,7 +241,7 @@ async function afterManualSave(
237
241
  const done: DiffRecord[] = []
238
242
  for (const rec of bucket.values()) if (rec.path === path && rec.superseded) done.push(rec)
239
243
  if (done.length) await archiveRecords(ctx, sc.session, sc.cwd, bucket, done, '被手动编辑覆盖')
240
- else await saveBucket(ctx, sc.cwd, bucket, sc.session)
244
+ else await saveBucket(ctx, sc.cwd, bucket, sc.session, { upserts: [...bucket.values()].filter((rec) => rec.path === path), removed: [] })
241
245
  }
242
246
  }
243
247
 
@@ -259,18 +263,110 @@ async function applyDecisions(
259
263
  ): Promise<DecideResult[]> {
260
264
  const results: DecideResult[] = []
261
265
  const resolved: DiffRecord[] = []
266
+ const touched = new Map<string, DiffRecord>()
262
267
  const ws = findRemoteWs(cwd)
263
268
  let changed = false
264
- for (const item of items) {
269
+ const grouped = new Map<number, Result>()
270
+ // #region debug log
271
+ const trace = diffTraceUntil.get(cwd)
272
+ const traceId = trace && trace.until > Date.now() ? trace.id : null
273
+ const started = traceId ? performance.now() : 0
274
+ let revertMs = 0
275
+ let revertGroups = 0
276
+ // #endregion
277
+ for (let index = 0; index < items.length; index++) {
278
+ const item = items[index]
265
279
  const record = bucket.get(item.callId)
266
280
  if (!record) {
267
281
  results.push({ callId: item.callId, ok: false, error: '记录不存在' })
268
282
  continue
269
283
  }
284
+ if (record.archived) {
285
+ results.push({ callId: item.callId, ok: false, error: '记录已归档,无法重复决策' })
286
+ continue
287
+ }
288
+ // 重试已执行的决定只补持久化,不能再次修改磁盘上的同一个 hunk。
289
+ const previous = item.scope === 'hunk'
290
+ ? record.decisions.perHunk[item.hunkIndex ?? -1] : record.decisions.call
291
+ const alreadyDone = previous === item.decision
292
+ if (alreadyDone) {
293
+ if (recordResolved(record) && !record.archived) resolved.push(record)
294
+ changed = true
295
+ touched.set(record.callId, record)
296
+ results.push({ callId: item.callId, ok: true, record: recView(record, ws) })
297
+ continue
298
+ }
270
299
  let revertedStale = false
271
- if (item.decision === 'rejected') {
300
+ if (item.decision === 'rejected' && !alreadyDone) {
272
301
  const scope: RpcScope = item.scope ?? 'call'
273
- const outcome = scope === 'hunk' ? await revertHunk(ctx, session, record, item.hunkIndex ?? -1) : await revertCall(ctx, session, record)
302
+ const hunkIndex = scope === 'hunk' ? item.hunkIndex ?? -1 : -1
303
+ let existingIntent: ReturnType<typeof revertState>
304
+ try { existingIntent = grouped.has(index) ? null : revertState(cwd, record.callId, scope, hunkIndex) }
305
+ catch (error) {
306
+ results.push({ callId: item.callId, ok: false, error: '回滚状态读取失败,未修改文件:' + String(error) })
307
+ continue
308
+ }
309
+ if (existingIntent === 'started') {
310
+ results.push({ callId: item.callId, ok: false, error: '回滚结果不确定,请人工核对文件后处理;已阻止再次修改文件' })
311
+ continue
312
+ }
313
+ if (existingIntent === 'applied') {
314
+ markDecision(record, scope, item.hunkIndex, item.decision)
315
+ changed = true
316
+ touched.set(record.callId, record)
317
+ if (recordResolved(record)) resolved.push(record)
318
+ results.push({ callId: item.callId, ok: true, record: recView(record, ws) })
319
+ continue
320
+ }
321
+ if (!grouped.has(index)) {
322
+ let last = index + 1
323
+ if (scope === 'hunk' && !ws && record.toolName !== 'remote_ssh_write') {
324
+ while (last < items.length && items[last].callId === item.callId
325
+ && items[last].decision === 'rejected' && items[last].scope === 'hunk') last++
326
+ }
327
+ let groupReady = false
328
+ try {
329
+ groupReady = last > index + 1 && items.slice(index + 1, last).every((next) =>
330
+ revertState(cwd, record.callId, 'hunk', next.hunkIndex ?? -1) === null)
331
+ if (groupReady) startReverts(cwd, record.callId, items.slice(index, last).map((next) => next.hunkIndex ?? -1))
332
+ else setRevertState(cwd, record.callId, scope, hunkIndex, 'started')
333
+ } catch (error) {
334
+ results.push({ callId: item.callId, ok: false, error: '回滚状态无法登记,未修改文件:' + String(error) })
335
+ continue
336
+ }
337
+ if (groupReady) {
338
+ // #region debug log
339
+ const revertStart = traceId ? performance.now() : 0
340
+ // #endregion
341
+ const outcomes = await revertHunks(ctx, session, record, items.slice(index, last).map((next) => next.hunkIndex ?? -1), traceId ?? undefined)
342
+ // #region debug log
343
+ if (traceId) revertMs += performance.now() - revertStart
344
+ revertGroups++
345
+ // #endregion
346
+ outcomes.forEach((outcome, offset) => grouped.set(index + offset, outcome))
347
+ }
348
+ }
349
+ // #region debug log
350
+ const revertStart = traceId ? performance.now() : 0
351
+ // #endregion
352
+ const outcome = grouped.get(index) ?? (scope === 'hunk'
353
+ ? await revertHunk(ctx, session, record, item.hunkIndex ?? -1, traceId ?? undefined)
354
+ : await revertCall(ctx, session, record))
355
+ // #region debug log
356
+ if (traceId && !grouped.has(index)) revertMs += performance.now() - revertStart
357
+ // #endregion
358
+ grouped.delete(index)
359
+ try {
360
+ if (outcome.ok || outcome.stale === true) setRevertState(cwd, record.callId, scope, hunkIndex, 'applied')
361
+ else if (outcome.error === '找不到该差异块' || outcome.error === '回滚失败:文件不存在'
362
+ || outcome.error === '回滚失败:文件过大,无法安全定位') {
363
+ clearRevertState(cwd, record.callId, scope, hunkIndex)
364
+ }
365
+ // 其余失败可能发生在写盘途中:保留 started,阻止不确定状态下重复回滚。
366
+ } catch (error) {
367
+ results.push({ callId: item.callId, ok: false, error: '回滚状态保存失败,请人工核对文件:' + String(error) })
368
+ continue
369
+ }
274
370
  if (!outcome.ok) {
275
371
  if (scope !== 'hunk' || outcome.stale !== true) {
276
372
  results.push({ callId: item.callId, ok: false, error: outcome.error })
@@ -282,16 +378,62 @@ async function applyDecisions(
282
378
  const wasResolved = recordResolved(record)
283
379
  markDecision(record, item.scope ?? 'call', item.hunkIndex, item.decision)
284
380
  changed = true
381
+ touched.set(record.callId, record)
285
382
  if (!wasResolved && recordResolved(record)) resolved.push(record)
286
383
  const result: DecideResult = { callId: item.callId, ok: true, record: recView(record, ws) }
287
384
  if (revertedStale) result.stale = true
288
385
  results.push(result)
289
386
  }
387
+ // #region debug log
388
+ const decisionMs = traceId ? performance.now() - started : 0
389
+ const archiveStart = traceId ? performance.now() : 0
390
+ // #endregion
290
391
  if (resolved.length) {
291
392
  const reason = items.some((item) => item.decision === 'rejected') ? '已处理(回滚)' : '已处理'
292
- await archiveRecords(ctx, session, cwd, bucket, resolved, reason, { deferSave: true })
393
+ try {
394
+ await archiveRecords(ctx, session, cwd, bucket, resolved, reason, { deferSave: true, traceId: traceId ?? undefined, changed: [...touched.values()] })
395
+ } catch (error) {
396
+ const failed = new Set(resolved.map((record) => record.callId))
397
+ for (const result of results) {
398
+ if (!failed.has(result.callId) || !result.ok) continue
399
+ result.ok = false
400
+ result.error = '归档失败:' + String(error)
401
+ delete result.record
402
+ }
403
+ // 决策保留于内存并尝试写回 sidecar,Undo 不再重复反替换。
404
+ if (changed) {
405
+ try { await saveBucket(ctx, cwd, bucket, session, { upserts: [...touched.values()], removed: [] }) }
406
+ catch (saveError) { log.error('归档失败后的 SQLite 补写失败:' + String(saveError)) }
407
+ }
408
+ return results
409
+ }
410
+ }
411
+ // #region debug log
412
+ const archiveMs = traceId ? performance.now() - archiveStart : 0
413
+ const saveStart = traceId ? performance.now() : 0
414
+ // #endregion
415
+ if (changed && !resolved.length) {
416
+ try {
417
+ await saveBucket(ctx, cwd, bucket, session, { upserts: [...touched.values()], removed: [] })
418
+ } catch (error) {
419
+ for (const result of results) {
420
+ if (!result.ok) continue
421
+ result.ok = false
422
+ result.error = '决策记录保存失败:' + String(error)
423
+ delete result.record
424
+ }
425
+ return results
426
+ }
293
427
  }
294
- if (changed) await saveBucket(ctx, cwd, bucket, session)
428
+ // #region debug log
429
+ try {
430
+ if (traceId) {
431
+ const saveMs = performance.now() - saveStart
432
+ const totalMs = performance.now() - started
433
+ debugRecord(ctx, cwd, `[DEBUG diffDecision] trace=${traceId} items=${items.length} rejected=${items.filter((item) => item.decision === 'rejected').length} ok=${results.filter((result) => result.ok).length} stale=${results.filter((result) => result.stale === true).length} grouped=${revertGroups} loopMs=${decisionMs.toFixed(1)} revertMs=${revertMs.toFixed(1)} archiveMs=${archiveMs.toFixed(1)} saveMs=${saveMs.toFixed(1)} totalMs=${totalMs.toFixed(1)}`)
434
+ }
435
+ } catch { /* 诊断不可影响决策 */ }
436
+ // #endregion
295
437
  return results
296
438
  }
297
439
 
@@ -447,9 +589,17 @@ export function buildHandlers(
447
589
  // edrv.dap.* 由 createDapRpc 提供(调试会话单例),这里并入。
448
590
  ...((dapHandlers ?? {}) as RpcHandlerMap),
449
591
  'edrv.list': async (args) => {
592
+ // #region debug log
593
+ const start = performance.now()
594
+ let staleMs = 0
595
+ let staleChecked = false
596
+ // #endregion
450
597
  const sc = await requireSession(ctx, args.sessionId)
451
598
  if ('err' in sc) return { ok: false, error: sc.err }
452
599
  const bucket = await bucketOf(registry, ctx, sc.cwd)
600
+ // #region debug log
601
+ const loadMs = performance.now() - start
602
+ // #endregion
453
603
  const want = Array.isArray(args.callIds) ? new Set(args.callIds) : null
454
604
  // 全量轮询时自动清理 stale 幽灵差异(批量决策后的即时刷新可 skipStale 跳过);
455
605
  // 三个组件各自 5s 全量 list,stale 检查节流到 STALE_CHECK_MIN_MS 一次,
@@ -458,7 +608,14 @@ export function buildHandlers(
458
608
  const now = Date.now()
459
609
  if (now - (staleCheckedAt.get(sc.cwd) ?? 0) > STALE_CHECK_MIN_MS) {
460
610
  staleCheckedAt.set(sc.cwd, now)
611
+ // #region debug log
612
+ staleChecked = true
613
+ const staleStart = performance.now()
614
+ // #endregion
461
615
  await autoArchiveStale(ctx, sc.session, sc.cwd, bucket)
616
+ // #region debug log
617
+ staleMs = performance.now() - staleStart
618
+ // #endregion
462
619
  }
463
620
  }
464
621
  const out: RecordView[] = []
@@ -470,6 +627,13 @@ export function buildHandlers(
470
627
  out.push(recView(rec, ws))
471
628
  }
472
629
  out.sort((a, b) => (a.at < b.at ? -1 : 1))
630
+ // #region debug log
631
+ try {
632
+ const totalMs = performance.now() - start
633
+ const trace = diffTraceUntil.get(sc.cwd)
634
+ if (trace && trace.until > Date.now() && (totalMs >= 20 || staleMs >= 10)) debugRecord(ctx, sc.cwd, `[DEBUG diffListHost] trace=${trace.id} count=${out.length} bucket=${bucket.size} checked=${staleChecked} loadMs=${loadMs.toFixed(1)} staleMs=${staleMs.toFixed(1)} totalMs=${totalMs.toFixed(1)}`)
635
+ } catch { /* 诊断不可影响列表 */ }
636
+ // #endregion
473
637
  return { ok: true, records: out }
474
638
  },
475
639
  /**
@@ -715,38 +879,38 @@ export function buildHandlers(
715
879
  'edrv.archiveList': async (args) => {
716
880
  const sc = await requireSession(ctx, args.sessionId)
717
881
  if ('err' in sc) return { ok: false, error: sc.err }
718
- const batches = parseArchive(await readArchiveText(ctx, sc.cwd)).filter((b) => b.cwd === sc.cwd)
719
- const ws = findRemoteWs(sc.cwd)
720
- const entries = batches.map((b) => {
721
- const recs = Array.isArray(b.records) ? b.records : []
722
- const sum = recs.reduce((s, r) => {
723
- const sm = r.summary || { accepted: 0, rejected: 0, pending: 0, superseded: false }
724
- s.accepted += sm.accepted || 0
725
- s.rejected += sm.rejected || 0
726
- s.pending += sm.pending || 0
727
- if (sm.superseded) s.superseded++
728
- return s
729
- }, { accepted: 0, rejected: 0, pending: 0, superseded: 0 })
730
- return {
731
- at: b.at,
732
- lastAt: b.lastAt || b.at,
733
- path: b.path,
734
- ...(ws ? { displayPath: remoteDisplayOf(b.path, ws) ?? undefined } : {}),
735
- batch: b.batch ?? null,
736
- reason: b.reason ?? null,
737
- nRecords: recs.length,
738
- summary: sum,
739
- }
740
- })
741
- entries.sort((a, b) => (Number(b.batch ?? -1) - Number(a.batch ?? -1)) || (a.at < b.at ? 1 : -1))
742
- return { ok: true, entries }
882
+ try {
883
+ const ws = findRemoteWs(sc.cwd)
884
+ const entries = listArchives(sc.cwd).map((item) => ({
885
+ ...item, ...(ws ? { displayPath: remoteDisplayOf(item.path, ws) ?? undefined } : {}),
886
+ }))
887
+ return { ok: true, entries }
888
+ } catch (error) {
889
+ return { ok: false, error: '归档列表读取失败:' + String(error) }
890
+ }
891
+ },
892
+ 'edrv.archivePage': async (args) => {
893
+ const sc = await requireSession(ctx, args.sessionId)
894
+ if ('err' in sc) return { ok: false, error: sc.err }
895
+ try {
896
+ const ws = findRemoteWs(sc.cwd)
897
+ const page = pageArchives(sc.cwd, args.cursor, args.limit)
898
+ return { ok: true, entries: page.entries.map((item) => ({
899
+ ...item, ...(ws ? { displayPath: remoteDisplayOf(item.path, ws) ?? undefined } : {}),
900
+ })), nextCursor: page.nextCursor }
901
+ } catch (error) {
902
+ return { ok: false, error: '归档分页读取失败:' + String(error) }
903
+ }
743
904
  },
744
905
  'edrv.archiveRead': async (args) => {
745
906
  const sc = await requireSession(ctx, args.sessionId)
746
907
  if ('err' in sc) return { ok: false, error: sc.err }
747
- const batchPath = args.path
748
- const batches = parseArchive(await readArchiveText(ctx, sc.cwd)).filter((b) => b.cwd === sc.cwd && (!batchPath || b.path === batchPath))
749
- return { ok: true, batches }
908
+ try {
909
+ if (args.batch !== undefined && (!args.path || (args.batch !== null && !Number.isInteger(args.batch)))) return { ok: false, error: '归档批次参数无效' }
910
+ return { ok: true, batches: readArchives(sc.cwd, args.path, args.batch) }
911
+ } catch (error) {
912
+ return { ok: false, error: '归档详情读取失败:' + String(error) }
913
+ }
750
914
  },
751
915
  'edrv.rollback': async (args) => {
752
916
  const sc = await requireSession(ctx, args.sessionId)
@@ -755,37 +919,41 @@ export function buildHandlers(
755
919
  if (!fs) return { ok: false, error: '缺少 fs' }
756
920
  const batch = args.batch
757
921
  const bucket = await bucketOf(registry, ctx, sc.cwd)
758
- let affected: DiffRecord[] = []
759
- let beforeRec: DiffRecord | null = null
760
- if (batch !== undefined) {
761
- const all = parseArchive(await readArchiveText(ctx, sc.cwd)).filter((b) => b.cwd === sc.cwd && b.path === args.path)
762
- const recs: DiffRecord[] = []
763
- for (const b of all) if (b.batch === batch) for (const r of (b.records || [])) recs.push(r as unknown as DiffRecord)
764
- if (!recs.length) return { ok: false, error: '归档中找不到该批次' }
765
- recs.sort((a, b2) => (a.at < b2.at ? -1 : 1))
766
- beforeRec = recs[0]
767
- affected = recs
768
- } else {
769
- for (const r of bucket.values()) if (r.path === args.path && !r.archived) affected.push(r)
770
- if (!affected.length) return { ok: false, error: '该文件没有可回滚的差异' }
771
- affected.sort((a, b2) => (a.at < b2.at ? -1 : 1))
772
- beforeRec = affected[0]
773
- }
774
- const rerr = await restoreFile(ctx, sc.session, beforeRec)
775
- if (rerr) return { ok: false, error: rerr }
776
- if (batch === undefined) {
777
- await archiveRecords(ctx, sc.session, sc.cwd, bucket, affected, '已回滚')
778
- } else {
779
- const all = parseArchive(await readArchiveText(ctx, sc.cwd)).filter((b) => b.cwd === sc.cwd && b.path === args.path && b.batch === batch)
780
- const sumRecs = all.reduce((s, b) => s + ((b.records || []).length), 0)
781
- const logRec = Object.assign({}, affected[0], { note: (affected[0].note ? affected[0].note + ';' : '') + '回滚至本批次前(批次 ' + batch + ')' })
782
- await appendArchiveEntries(ctx, sc.cwd, [archiveEntryFor([logRec], sc.cwd, '已回滚(批次 ' + batch + ',' + sumRecs + ' 条)')], sc.session)
783
- // 批次回滚恢复的是旧内容,文件当前活跃差异已失效,一并归档
784
- const activeRecs: DiffRecord[] = []
785
- for (const r of bucket.values()) if (r.path === args.path && !r.archived) activeRecs.push(r)
786
- if (activeRecs.length) await archiveRecords(ctx, sc.session, sc.cwd, bucket, activeRecs, '已回滚(批次回滚覆盖)')
922
+ try {
923
+ let affected: DiffRecord[] = []
924
+ let beforeRec: DiffRecord | null = null
925
+ if (batch !== undefined) {
926
+ const all = readArchives(sc.cwd, args.path, batch)
927
+ const recs: DiffRecord[] = []
928
+ for (const b of all) if (b.batch === batch) for (const r of (b.records || [])) recs.push(r as unknown as DiffRecord)
929
+ if (!recs.length) return { ok: false, error: '归档中找不到该批次' }
930
+ recs.sort((a, b2) => (a.at < b2.at ? -1 : 1))
931
+ beforeRec = recs[0]
932
+ affected = recs
933
+ } else {
934
+ for (const r of bucket.values()) if (r.path === args.path && !r.archived) affected.push(r)
935
+ if (!affected.length) return { ok: false, error: '该文件没有可回滚的差异' }
936
+ affected.sort((a, b2) => (a.at < b2.at ? -1 : 1))
937
+ beforeRec = affected[0]
938
+ }
939
+ const rerr = await restoreFile(ctx, sc.session, beforeRec)
940
+ if (rerr) return { ok: false, error: rerr }
941
+ if (batch === undefined) {
942
+ await archiveRecords(ctx, sc.session, sc.cwd, bucket, affected, '已回滚')
943
+ } else {
944
+ const all = readArchives(sc.cwd, args.path, batch)
945
+ const sumRecs = all.reduce((s, b) => s + ((b.records || []).length), 0)
946
+ const logRec = Object.assign({}, affected[0], { note: (affected[0].note ? affected[0].note + ';' : '') + '回滚至本批次前(批次 ' + batch + ')' })
947
+ await appendArchiveEntries(ctx, sc.cwd, [archiveEntryFor([logRec], sc.cwd, '已回滚(批次 ' + batch + ',' + sumRecs + ' 条)')], sc.session)
948
+ // 批次回滚恢复的是旧内容,文件当前活跃差异已失效,一并归档
949
+ const activeRecs: DiffRecord[] = []
950
+ for (const r of bucket.values()) if (r.path === args.path && !r.archived) activeRecs.push(r)
951
+ if (activeRecs.length) await archiveRecords(ctx, sc.session, sc.cwd, bucket, activeRecs, '已回滚(批次回滚覆盖)')
952
+ }
953
+ return { ok: true, path: args.path, batch: batch ?? null }
954
+ } catch (error) {
955
+ return { ok: false, error: '回滚/归档失败:' + String(error) }
787
956
  }
788
- return { ok: true, path: args.path, batch: batch ?? null }
789
957
  },
790
958
  'edrv.debug': async (args) => {
791
959
  // 诊断日志:client 上报 → debugLog 模块缓冲批量落盘 ~/.dsh/dsh-vscode-mode/logs/
@@ -798,6 +966,12 @@ export function buildHandlers(
798
966
  debugRecord(ctx, UI_AUDIT_CWD, text, args.level)
799
967
  return { ok: true }
800
968
  }
969
+ // #region debug log
970
+ try {
971
+ const match = /^\[DEBUG diffAction\] stage=start trace=(\d+-\d+) /.exec(text)
972
+ if (match) diffTraceUntil.set(sc.cwd, { until: Date.now() + 15_000, id: match[1] })
973
+ } catch { /* 诊断不可影响日志路由 */ }
974
+ // #endregion
801
975
  debugRecord(ctx, sc.cwd, text, args.level)
802
976
  return { ok: true }
803
977
  },
@@ -157,7 +157,7 @@ export function applyCaps(matches: ContentMatch[], maxMatches: number, maxFiles:
157
157
  */
158
158
  export function contentArgv(binary: string, root: string, input: ContentSearchInput): string[] {
159
159
  const argv = [binary, '--no-config', '--json', '--line-number', '--no-heading', '--color', 'never', '--hidden', '--no-ignore']
160
- if (input.matchCase === true) argv.push('--case-sensitive')
160
+ argv.push(input.matchCase === true ? '--case-sensitive' : '--ignore-case')
161
161
  if (input.wholeWord === true) argv.push('--word-regexp')
162
162
  if (input.regex !== true) argv.push('--fixed-strings')
163
163
  for (const excluded of EXCLUDES) argv.push('--glob', '!**/' + excluded + '/**')
@@ -127,18 +127,43 @@ export function lineBefore(text: string, index: number): number {
127
127
  return n
128
128
  }
129
129
 
130
- /** 找出文本的全部非重叠出现位置。 */
131
- function allMatches(content: string, needle: string): TextRange[] {
132
- if (!needle.length) return []
133
- const out: TextRange[] = []
134
- let from = 0
135
- while (from <= content.length - needle.length) {
136
- const at = content.indexOf(needle, from)
137
- if (at < 0) break
138
- out.push({ start: at, end: at + needle.length })
139
- from = at + Math.max(1, needle.length)
130
+ /** 每种搜索串只推进一次,保持原来从头开始的非重叠匹配顺序。 */
131
+ interface MatchState {
132
+ ranges: TextRange[]
133
+ next: number
134
+ done: boolean
135
+ }
136
+
137
+ /**
138
+ * 懒扫描候选:先找 cursor 后未占用项,找不到再回退至首个未占用项。
139
+ * @author ddj 2026年09月29号
140
+ * @param content 当前内容
141
+ * @param needle 搜索串
142
+ * @param cursor 前一块的结束位置
143
+ * @param occupied 已选区间
144
+ * @param state 此搜索串已扫描的候选
145
+ * @returns 与完整候选列表双轮查找相同的匹配
146
+ */
147
+ function findCandidate(content: string, needle: string, cursor: number, occupied: TextRange[], state: MatchState): TextRange | undefined {
148
+ if (!needle.length) return undefined
149
+ let fallback: TextRange | undefined
150
+ for (let i = 0; i < state.ranges.length; i++) {
151
+ const range = state.ranges[i]
152
+ if (occupied.some((used) => overlaps(range, used))) continue
153
+ if (range.start >= cursor) return range
154
+ if (!fallback) fallback = range
155
+ }
156
+ while (!state.done) {
157
+ const at = content.indexOf(needle, state.next)
158
+ if (at < 0) { state.done = true; break }
159
+ const range = { start: at, end: at + needle.length }
160
+ state.ranges.push(range)
161
+ state.next = range.end
162
+ if (occupied.some((used) => overlaps(range, used))) continue
163
+ if (range.start >= cursor) return range
164
+ if (!fallback) fallback = range
140
165
  }
141
- return out
166
+ return fallback
142
167
  }
143
168
 
144
169
  /**
@@ -153,6 +178,7 @@ export function locateHunks(content: string, hunks: Hunk[]): HunkLocation[] {
153
178
  const locations: HunkLocation[] = []
154
179
  let cursor = 0
155
180
  const occupied: TextRange[] = []
181
+ const matches = new Map<string, MatchState>()
156
182
  for (let idx = 0; idx < hunks.length; idx++) {
157
183
  const hunk = hunks[idx]
158
184
  const newText = hunk.newText
@@ -163,9 +189,14 @@ export function locateHunks(content: string, hunks: Hunk[]): HunkLocation[] {
163
189
  && (newText.length === 0 || content.slice(snapshot.start, snapshot.end) === newText)
164
190
  ? snapshot
165
191
  : null
166
- const candidates = snapshotMatches ? [snapshotMatches] : allMatches(content, newText)
167
- const candidate = candidates.find((range) => range.start >= cursor && !occupied.some((used) => overlaps(range, used)))
168
- ?? candidates.find((range) => !occupied.some((used) => overlaps(range, used)))
192
+ let candidate: TextRange | undefined
193
+ if (snapshotMatches) {
194
+ if (!occupied.some((used) => overlaps(snapshotMatches, used))) candidate = snapshotMatches
195
+ } else if (newText.length) {
196
+ let state = matches.get(newText)
197
+ if (!state) { state = { ranges: [], next: 0, done: false }; matches.set(newText, state) }
198
+ candidate = findCandidate(content, newText, cursor, occupied, state)
199
+ }
169
200
  if (candidate) {
170
201
  locations.push({ idx, hunk, ...candidate, matched: true })
171
202
  occupied.push(candidate)
@@ -205,15 +236,33 @@ export function replaceRange(content: string, location: HunkLocation, reverse =
205
236
  return content.slice(0, location.start) + replacement + content.slice(location.end)
206
237
  }
207
238
 
208
- /** 按当前内容中出现的位置从后向前应用 hunk,避免前块长度变化影响后块。 */
239
+ /**
240
+ * 按位置倒序收集片段,最后一次拼接;非互斥区间退回原替换语义。
241
+ * @author ddj 2026年09月29号
242
+ * @param content 当前文件内容
243
+ * @param locations 已定位区间
244
+ * @param reverse 是否反向应用
245
+ * @returns 合成内容及失效块索引
246
+ */
209
247
  export function applyLocations(content: string, locations: HunkLocation[], reverse = false): { content: string; stale: number[] } {
210
248
  const stale = locations.filter((location) => !location.matched).map((location) => location.idx)
211
- let out = content
212
249
  const matched = locations.filter((location) => location.matched).sort((a, b) => b.start - a.start)
250
+ const parts: string[] = []
251
+ let end = content.length
213
252
  for (const location of matched) {
214
- const next = replaceRange(out, location, reverse)
215
- if (next === null) stale.push(location.idx)
216
- else out = next
253
+ if (location.start < 0 || location.end > end) {
254
+ let out = content
255
+ for (const item of matched) {
256
+ const next = replaceRange(out, item, reverse)
257
+ if (next === null) stale.push(item.idx)
258
+ else out = next
259
+ }
260
+ return { content: out, stale: stale.sort((a, b) => a - b) }
261
+ }
262
+ parts.push(content.slice(location.end, end), reverse ? (location.hunk.oldText ?? '') : location.hunk.newText)
263
+ end = location.start
217
264
  }
218
- return { content: out, stale: stale.sort((a, b) => a - b) }
265
+ parts.push(content.slice(0, end))
266
+ parts.reverse()
267
+ return { content: parts.join(''), stale: stale.sort((a, b) => a - b) }
219
268
  }
package/src/shared/rpc.ts CHANGED
@@ -275,7 +275,12 @@ export interface PerfMoveFailure {
275
275
  export interface SidecarPerfSummary {
276
276
  active: number
277
277
  pendingByFile: { path: string; pending: number }[]
278
+ /** 新版 SQLite 主库 + WAL 文件字节数。 */
278
279
  archiveBytes: number
280
+ /** 旧 JSON 历史体积;不计入新归档。 */
281
+ legacyArchiveBytes: number
282
+ /** 旧活跃 JSON 只读快照体积。 */
283
+ legacyActiveBytes: number
279
284
  }
280
285
  // --endregion
281
286
 
@@ -362,7 +367,8 @@ export interface RpcRequestMap {
362
367
  'edrv.save': { sessionId?: string; path: string; content: string; rev?: string }
363
368
  'edrv.saveBinary': { sessionId?: string; path: string; content: string; encoding: 'base64' }
364
369
  'edrv.archiveList': { sessionId?: string }
365
- 'edrv.archiveRead': { sessionId?: string; path?: string }
370
+ 'edrv.archivePage': { sessionId?: string; cursor?: string; limit?: number }
371
+ 'edrv.archiveRead': { sessionId?: string; path?: string; batch?: number | null }
366
372
  'edrv.rollback': { sessionId?: string; path: string; batch?: number }
367
373
  'edrv.debug': { sessionId?: string; text: string; level?: LoggerLevel }
368
374
  'edrv.dlog.list': { sessionId?: string }
@@ -520,6 +526,7 @@ export interface RpcOkMap {
520
526
  'edrv.save': { rev?: string; conflict?: boolean }
521
527
  'edrv.saveBinary': object
522
528
  'edrv.archiveList': { entries: ArchiveEntry[] }
529
+ 'edrv.archivePage': { entries: ArchiveEntry[]; nextCursor: string | null }
523
530
  'edrv.archiveRead': { batches: ArchiveBatch[] }
524
531
  'edrv.rollback': { path: string; batch: number | null }
525
532
  'edrv.debug': object