dsh-code 1.0.6 → 1.0.7
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/README.en.md +60 -9
- package/README.md +60 -9
- package/bin/deepseek.mjs +86 -1
- package/cordis.patch.yml +88 -0
- package/lib/index.mjs +1002 -94
- package/lib/session-query.mjs +149 -0
- package/lib/types/app.d.ts +9 -2
- package/lib/types/index.d.ts +27 -0
- package/lib/types/kernel-panels.d.ts +23 -0
- package/lib/types/render/editor.d.ts +4 -3
- package/lib/types/render/ime-cursor.d.ts +60 -0
- package/lib/types/render/projection.d.ts +35 -0
- package/lib/types/render/status.d.ts +1 -1
- package/lib/types/session-query.d.ts +92 -0
- package/lib/types/terminal-title.d.ts +58 -0
- package/lib/types/update-panel.d.ts +49 -0
- package/lib/types/update.d.ts +66 -0
- package/package.json +228 -89
- package/src/app.ts +252 -69
- package/src/index.ts +129 -11
- package/src/internals.ts +5 -0
- package/src/kernel-panels.ts +89 -3
- package/src/render/editor.ts +5 -4
- package/src/render/ime-cursor.ts +147 -0
- package/src/render/projection.ts +144 -3
- package/src/render/status.ts +18 -4
- package/src/session-query.ts +235 -0
- package/src/terminal-title.ts +173 -0
- package/src/update-panel.ts +246 -0
- package/src/update.ts +110 -0
package/src/render/projection.ts
CHANGED
|
@@ -23,6 +23,7 @@ import type {} from '@deepseek-ai/dsh-goal'
|
|
|
23
23
|
import type {} from '@deepseek-ai/dsh-llm-retry'
|
|
24
24
|
import type {} from '@deepseek-ai/dsh-plan-mode'
|
|
25
25
|
import type {} from '@deepseek-ai/dsh-permission-presets'
|
|
26
|
+
import type {} from '@deepseek-ai/dsh-schedule'
|
|
26
27
|
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
|
27
28
|
import type {} from '@deepseek-ai/dsh-session-title'
|
|
28
29
|
// The subagent package's durable catalog event joins the union the same way
|
|
@@ -273,6 +274,86 @@ export interface TranscriptStats {
|
|
|
273
274
|
reasoningEffort: string
|
|
274
275
|
}
|
|
275
276
|
|
|
277
|
+
/** One active reminder folded from durable `schedule/change` events. */
|
|
278
|
+
export interface ScheduleRow {
|
|
279
|
+
readonly id: string
|
|
280
|
+
readonly kind: 'after' | 'at' | 'every'
|
|
281
|
+
readonly prompt: string
|
|
282
|
+
/** Next due time (epoch ms); the /schedule panel derives overdue/relative labels. */
|
|
283
|
+
readonly targetAt: number
|
|
284
|
+
/** Recurrence seconds for 'every' rows, undefined otherwise. */
|
|
285
|
+
readonly everySeconds?: number
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The durable `schedule/change` payload shape this fold consumes. Upstream
|
|
290
|
+
* strict-decodes the whole transition stream before appending, so unknown
|
|
291
|
+
* ids here are corrupt-input edges that degrade to a no-op.
|
|
292
|
+
*/
|
|
293
|
+
export interface ScheduleChangeLike {
|
|
294
|
+
readonly operation: 'create' | 'delete' | 'dispatch'
|
|
295
|
+
readonly schedule?: {
|
|
296
|
+
readonly id: string
|
|
297
|
+
readonly kind: 'after' | 'at' | 'every'
|
|
298
|
+
readonly prompt: string
|
|
299
|
+
readonly afterSeconds?: number
|
|
300
|
+
readonly everySeconds?: number
|
|
301
|
+
readonly scheduledAt: string
|
|
302
|
+
}
|
|
303
|
+
readonly id?: string
|
|
304
|
+
readonly acceptedAt?: string
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/*
|
|
308
|
+
* Upstream record semantics (dsh-schedule types): `scheduledAt` is ALREADY
|
|
309
|
+
* the due instant — AfterScheduleRecord carries the RFC 3339 UTC target
|
|
310
|
+
* (delay included), EveryScheduleRecord carries the earliest anchor-aligned
|
|
311
|
+
* occurrence not yet dispatched. No kind ever adds its own interval on top.
|
|
312
|
+
*/
|
|
313
|
+
|
|
314
|
+
/** Fold one `schedule/change` into the active-reminder list (create/delete/dispatch). */
|
|
315
|
+
export function applyScheduleChange(rows: readonly ScheduleRow[], data: ScheduleChangeLike): readonly ScheduleRow[] {
|
|
316
|
+
if (data.operation === 'create' && data.schedule !== undefined) {
|
|
317
|
+
const schedule = data.schedule
|
|
318
|
+
const row: ScheduleRow = {
|
|
319
|
+
id: schedule.id,
|
|
320
|
+
kind: schedule.kind,
|
|
321
|
+
prompt: schedule.prompt,
|
|
322
|
+
targetAt: Date.parse(schedule.scheduledAt),
|
|
323
|
+
...(schedule.kind === 'every' ? { everySeconds: schedule.everySeconds ?? 0 } : {}),
|
|
324
|
+
}
|
|
325
|
+
return [...rows.filter(existing => existing.id !== schedule.id), row]
|
|
326
|
+
}
|
|
327
|
+
if (data.operation === 'delete' && data.id !== undefined) {
|
|
328
|
+
return rows.filter(existing => existing.id !== data.id)
|
|
329
|
+
}
|
|
330
|
+
if (data.operation === 'dispatch' && data.id !== undefined) {
|
|
331
|
+
// A dispatched one-shot reminder is finished. An 'every' reminder
|
|
332
|
+
// advances PAST every missed occurrence in one step: the next target is
|
|
333
|
+
// the first anchor-aligned instant strictly after acceptedAt, stepping
|
|
334
|
+
// from the previous aligned target (upstream advances the same way).
|
|
335
|
+
if (data.acceptedAt === undefined) return rows.filter(existing => existing.id !== data.id)
|
|
336
|
+
const accepted = Date.parse(data.acceptedAt)
|
|
337
|
+
return rows.map(existing => existing.id === data.id
|
|
338
|
+
? { ...existing, targetAt: nextEveryTarget(existing.targetAt, accepted, existing.everySeconds ?? 0) }
|
|
339
|
+
: existing)
|
|
340
|
+
}
|
|
341
|
+
return rows
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** First anchor-aligned target after `acceptedAt`, stepping from the previous aligned target. */
|
|
345
|
+
export function nextEveryTarget(previousTarget: number, acceptedAt: number, everySeconds: number): number {
|
|
346
|
+
const interval = Math.max(1, everySeconds) * 1000
|
|
347
|
+
if (acceptedAt <= previousTarget) return previousTarget + interval
|
|
348
|
+
const missed = Math.ceil((acceptedAt - previousTarget + 1) / interval)
|
|
349
|
+
return previousTarget + missed * interval
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Plugin snapshot sources folded into token stats but never rendered as rows. */
|
|
353
|
+
const HIDDEN_SNAPSHOT_PLUGINS = new Set(['time-context', 'tmux-context'])
|
|
354
|
+
/** Plugin prompt sources rendered as full user rows (they ARE the conversation). */
|
|
355
|
+
const REMINDER_PLUGINS = new Set(['schedule'])
|
|
356
|
+
|
|
276
357
|
/** The complete TUI transcript view for one session. */
|
|
277
358
|
export interface TranscriptView {
|
|
278
359
|
/** Settled entries in log order. */
|
|
@@ -319,6 +400,8 @@ export interface TranscriptView {
|
|
|
319
400
|
sandbox: string
|
|
320
401
|
/** Current long-running goal folded from the last `goal/change`, undefined when cleared. */
|
|
321
402
|
goal: GoalFold | undefined
|
|
403
|
+
/** Active reminders folded from `schedule/change` events, oldest target first at render. */
|
|
404
|
+
schedules: readonly ScheduleRow[]
|
|
322
405
|
/**
|
|
323
406
|
* Ordered live message ids per inbox target, mirrored from
|
|
324
407
|
* `agent/inbox/spliced` exactly like the upstream Inbox projection — the
|
|
@@ -491,6 +574,7 @@ export function createTranscriptView(): TranscriptView {
|
|
|
491
574
|
systemPrompt: '',
|
|
492
575
|
sandbox: '',
|
|
493
576
|
goal: undefined,
|
|
577
|
+
schedules: [],
|
|
494
578
|
pending: { 'next-turn': [], 'next-step': [] },
|
|
495
579
|
stats: { turns: 0, steps: 0, llmMs: 0, toolMs: 0, usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 }, lastPromptTokens: 0, contextWindow: 0, contextSegments: { system: 0, prompt: 0, assistant: 0, thinking: 0, tools: 0 }, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0, reasoningEffort: '' },
|
|
496
580
|
anchors: { stepStart: new Map(), toolStart: new Map(), firstChunkAt: new Map(), compactionTokens: new Map(), lastPruneTokens: 0, turnFiles: new Map(), turnSteps: new Map(), turnTools: new Map(), systemNodes: new Map() },
|
|
@@ -558,9 +642,42 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
|
|
|
558
642
|
},
|
|
559
643
|
}
|
|
560
644
|
}
|
|
645
|
+
// Snapshot injections (time/tmux context) still spend model context but
|
|
646
|
+
// render nothing; the schedule reminder is a real prompt and renders in
|
|
647
|
+
// full — the model acts on it, so the transcript must show it.
|
|
648
|
+
if (message.source.kind === 'plugin' && HIDDEN_SNAPSHOT_PLUGINS.has(message.source.plugin)) {
|
|
649
|
+
return {
|
|
650
|
+
...view,
|
|
651
|
+
pending,
|
|
652
|
+
entries,
|
|
653
|
+
stats: {
|
|
654
|
+
...view.stats,
|
|
655
|
+
contextSegments: {
|
|
656
|
+
...view.stats.contextSegments,
|
|
657
|
+
system: view.stats.contextSegments.system + estimateTokens(text),
|
|
658
|
+
},
|
|
659
|
+
},
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
if (message.source.kind === 'plugin' && REMINDER_PLUGINS.has(message.source.plugin)) {
|
|
663
|
+
return {
|
|
664
|
+
...view,
|
|
665
|
+
pending,
|
|
666
|
+
entries: [...entries, { kind: 'user', text, notice: false, ...(images.length === 0 ? {} : { images }), ...(files.length === 0 ? {} : { files }) }],
|
|
667
|
+
stats: {
|
|
668
|
+
...view.stats,
|
|
669
|
+
contextSegments: {
|
|
670
|
+
...view.stats.contextSegments,
|
|
671
|
+
prompt: view.stats.contextSegments.prompt + estimateTokens(text),
|
|
672
|
+
},
|
|
673
|
+
},
|
|
674
|
+
}
|
|
675
|
+
}
|
|
561
676
|
const notice = message.source.kind === 'plugin' && message.source.form === 'notice'
|
|
562
677
|
? message.source.summary
|
|
563
|
-
: message.source.kind
|
|
678
|
+
: message.source.kind === 'plugin'
|
|
679
|
+
? message.source.plugin
|
|
680
|
+
: message.source.kind
|
|
564
681
|
const summary = boundContextSummary(notice)
|
|
565
682
|
return {
|
|
566
683
|
...view,
|
|
@@ -963,6 +1080,11 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
|
|
|
963
1080
|
entries: line === undefined ? view.entries : [...view.entries, { kind: 'turn-marker', text: line }],
|
|
964
1081
|
}
|
|
965
1082
|
}
|
|
1083
|
+
case 'schedule/change':
|
|
1084
|
+
// Non-conversational catalog state: the /schedule panel renders the
|
|
1085
|
+
// active list, the transcript shows only the reminder prompts
|
|
1086
|
+
// (handled at user/message above).
|
|
1087
|
+
return { ...view, schedules: applyScheduleChange(view.schedules, event.data) }
|
|
966
1088
|
case 'session/title':
|
|
967
1089
|
// Latest-wins title snapshot, log-only; the status line prefers it.
|
|
968
1090
|
return { ...view, title: event.data.title }
|
|
@@ -1098,6 +1220,7 @@ export interface ReplayAccumulator {
|
|
|
1098
1220
|
systemPrompt: string
|
|
1099
1221
|
sandbox: string
|
|
1100
1222
|
goal: GoalFold | undefined
|
|
1223
|
+
schedules: readonly ScheduleRow[]
|
|
1101
1224
|
stats: TranscriptStats
|
|
1102
1225
|
stepStart: Map<string, number>
|
|
1103
1226
|
toolStart: Map<string, number>
|
|
@@ -1137,6 +1260,7 @@ export function createReplayAccumulator(): ReplayAccumulator {
|
|
|
1137
1260
|
systemPrompt: '',
|
|
1138
1261
|
sandbox: '',
|
|
1139
1262
|
goal: undefined,
|
|
1263
|
+
schedules: [],
|
|
1140
1264
|
stats: { turns: 0, steps: 0, llmMs: 0, toolMs: 0, usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 }, lastPromptTokens: 0, contextWindow: 0, contextSegments: { system: 0, prompt: 0, assistant: 0, thinking: 0, tools: 0 }, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0, reasoningEffort: '' },
|
|
1141
1265
|
stepStart: new Map(),
|
|
1142
1266
|
toolStart: new Map(),
|
|
@@ -1270,7 +1394,7 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
|
|
|
1270
1394
|
const text = textOf(message.content)
|
|
1271
1395
|
const images = imagesOf(message.content)
|
|
1272
1396
|
const files = filesOf(message.content)
|
|
1273
|
-
if (message.source.kind === 'user') {
|
|
1397
|
+
if (message.source.kind === 'user' || (message.source.kind === 'plugin' && REMINDER_PLUGINS.has(message.source.plugin))) {
|
|
1274
1398
|
appendReplayEntry(acc, { kind: 'user', text, notice: false, ...(images.length === 0 ? {} : { images }), ...(files.length === 0 ? {} : { files }) })
|
|
1275
1399
|
acc.stats = {
|
|
1276
1400
|
...acc.stats,
|
|
@@ -1281,9 +1405,21 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
|
|
|
1281
1405
|
}
|
|
1282
1406
|
return true
|
|
1283
1407
|
}
|
|
1408
|
+
if (message.source.kind === 'plugin' && HIDDEN_SNAPSHOT_PLUGINS.has(message.source.plugin)) {
|
|
1409
|
+
acc.stats = {
|
|
1410
|
+
...acc.stats,
|
|
1411
|
+
contextSegments: {
|
|
1412
|
+
...acc.stats.contextSegments,
|
|
1413
|
+
system: acc.stats.contextSegments.system + estimateTokens(text),
|
|
1414
|
+
},
|
|
1415
|
+
}
|
|
1416
|
+
return true
|
|
1417
|
+
}
|
|
1284
1418
|
const notice = message.source.kind === 'plugin' && message.source.form === 'notice'
|
|
1285
1419
|
? message.source.summary
|
|
1286
|
-
: message.source.kind
|
|
1420
|
+
: message.source.kind === 'plugin'
|
|
1421
|
+
? message.source.plugin
|
|
1422
|
+
: message.source.kind
|
|
1287
1423
|
const summary = boundContextSummary(notice)
|
|
1288
1424
|
appendReplayEntry(acc, { kind: 'user', text: summary, notice: true })
|
|
1289
1425
|
acc.stats = {
|
|
@@ -1603,6 +1739,10 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
|
|
|
1603
1739
|
if (line !== undefined) appendReplayEntry(acc, { kind: 'turn-marker', text: line })
|
|
1604
1740
|
return true
|
|
1605
1741
|
}
|
|
1742
|
+
case 'schedule/change':
|
|
1743
|
+
acc.schedules = applyScheduleChange(acc.schedules, event.data)
|
|
1744
|
+
acc.ops += 1
|
|
1745
|
+
return true
|
|
1606
1746
|
case 'session/title':
|
|
1607
1747
|
acc.title = event.data.title
|
|
1608
1748
|
return true
|
|
@@ -1715,6 +1855,7 @@ function materializeReplayView(acc: ReplayAccumulator, copy: boolean): Transcrip
|
|
|
1715
1855
|
systemPrompt: acc.systemPrompt,
|
|
1716
1856
|
sandbox: acc.sandbox,
|
|
1717
1857
|
goal: acc.goal,
|
|
1858
|
+
schedules: acc.schedules,
|
|
1718
1859
|
pending: { 'next-turn': [...acc.pendingTurn], 'next-step': [...acc.pendingStep] },
|
|
1719
1860
|
stats: acc.stats,
|
|
1720
1861
|
// Handed-out views get their own anchors snapshot: the accumulator keeps
|
package/src/render/status.ts
CHANGED
|
@@ -77,6 +77,7 @@ export type StatusTone =
|
|
|
77
77
|
| 'meta'
|
|
78
78
|
| 'accent'
|
|
79
79
|
| 'success'
|
|
80
|
+
| 'plan'
|
|
80
81
|
| 'warn'
|
|
81
82
|
| 'error'
|
|
82
83
|
// Context-bar fill: one DeepSeek blue for the whole occupied run (the free
|
|
@@ -419,9 +420,7 @@ function buildCandidates(
|
|
|
419
420
|
const right: { span: StatusSpan; rank: number; id: string }[] = []
|
|
420
421
|
const row2: { group: StatusGroup; rank: number; id: string }[] = []
|
|
421
422
|
|
|
422
|
-
|
|
423
|
-
row2.push({ group: { spans: [{ text: '⧉ plan', tone: 'accent' }] }, rank: RANK2_PLAN, id: 'plan' })
|
|
424
|
-
}
|
|
423
|
+
|
|
425
424
|
|
|
426
425
|
if (stats.turns > 0 || stats.steps > 0) {
|
|
427
426
|
if (enabled.has('turns')) {
|
|
@@ -522,10 +521,25 @@ function buildCandidates(
|
|
|
522
521
|
}
|
|
523
522
|
const permission = safe(facts.permission)
|
|
524
523
|
let badge = -1
|
|
524
|
+
// The plan STATION names itself in the permission badge: with the most
|
|
525
|
+
// restrictive preset active, plan mode reads as the green fourth cycle
|
|
526
|
+
// station 'plan' (that preset IS the station's permission layer). Plan on
|
|
527
|
+
// any other preset (a typed /plan mid-session) stays orthogonal: the badge
|
|
528
|
+
// keeps naming the preset and row 2 carries the green plan marker.
|
|
529
|
+
const planStation = facts.plan && permissionTone(permission) === 'success'
|
|
525
530
|
if (permission !== '' && enabled.has('permission')) {
|
|
526
|
-
right.push({
|
|
531
|
+
right.push({
|
|
532
|
+
span: planStation
|
|
533
|
+
? { text: 'plan on', tone: 'plan' }
|
|
534
|
+
: { text: permission, tone: permissionTone(permission) },
|
|
535
|
+
rank: RANK_BADGE,
|
|
536
|
+
id: 'permission',
|
|
537
|
+
})
|
|
527
538
|
badge = right.length - 1
|
|
528
539
|
}
|
|
540
|
+
if (facts.plan && enabled.has('plan')) {
|
|
541
|
+
row2.push({ group: { spans: [{ text: '⧉ plan', tone: 'accent' }] }, rank: RANK2_PLAN, id: 'plan' })
|
|
542
|
+
}
|
|
529
543
|
return { left, right, badge, row2 }
|
|
530
544
|
}
|
|
531
545
|
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skip-tolerant session-query engine for this terminal.
|
|
3
|
+
*
|
|
4
|
+
* The upstream SqliteSessionQueryEngine reconciliation observes EVERY
|
|
5
|
+
* persisted session before each search, and ONE unreadable source (for
|
|
6
|
+
* example a pre-release session artifact the frozen format codecs reject)
|
|
7
|
+
* fails the whole pass with SESSION_QUERY_PERSISTENCE_FAILED — every
|
|
8
|
+
* cross-session search dies because of a single old file nobody opened
|
|
9
|
+
* otherwise. This subclass overrides only the observation loop so an
|
|
10
|
+
* unreadable source is skipped with a warning and the rest of the corpus
|
|
11
|
+
* indexes normally; skipped sessions are retried on later reconciliations
|
|
12
|
+
* and rejoin automatically once a host that can read them is installed.
|
|
13
|
+
*
|
|
14
|
+
* Vendored surface note: `_observeStable` and its module-local helpers are
|
|
15
|
+
* private upstream; this file re-declares the observation loop against the
|
|
16
|
+
* pinned @deepseek-ai line (see package.json peers) and must be re-checked
|
|
17
|
+
* whenever that line moves. The engine class and the tool boundary also
|
|
18
|
+
* share ONE physical parent-package instance through this bundle, which
|
|
19
|
+
* restores instanceof-based typed error messages on the search path.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { createHash } from 'node:crypto'
|
|
23
|
+
import SqliteSessionQueryEngine, { type Config } from '@deepseek-ai/dsh-session-query-sqlite'
|
|
24
|
+
import {
|
|
25
|
+
assertSessionHeadersCompatible,
|
|
26
|
+
buildSessionEventSearchDocuments,
|
|
27
|
+
readColdSessionLog,
|
|
28
|
+
SessionQueryError,
|
|
29
|
+
} from '@deepseek-ai/dsh-session-query'
|
|
30
|
+
import type { SessionEvent, SessionHeader, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session'
|
|
31
|
+
import type { SessionPersistenceRevision, SessionPersistenceSnapshot } from '@deepseek-ai/dsh-session-persistence'
|
|
32
|
+
|
|
33
|
+
/** One observed session: detached header plus its derived search documents. */
|
|
34
|
+
interface ObservedSession {
|
|
35
|
+
header: SessionHeader
|
|
36
|
+
inheritedEventCount: SessionLogOffset
|
|
37
|
+
documents: readonly ReturnType<typeof buildSessionEventSearchDocuments>[number][]
|
|
38
|
+
fingerprint: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One persisted snapshot as the reconciliation sees it (loaded once readable). */
|
|
42
|
+
interface ObservedPersistedSession {
|
|
43
|
+
header: SessionHeader
|
|
44
|
+
revision: SessionPersistenceRevision
|
|
45
|
+
loaded?: ObservedSession
|
|
46
|
+
/** Diagnosis for a cold read that failed; the session stays unindexed. */
|
|
47
|
+
unreadable?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The engine-internal state the observation loop touches. */
|
|
51
|
+
export interface EngineSurface {
|
|
52
|
+
readonly ctx: {
|
|
53
|
+
sessions: {
|
|
54
|
+
list(): readonly { header: SessionHeader; inheritedEventCount: SessionLogOffset; snapshotEvents(): readonly SessionEvent[]; id: SessionId }[]
|
|
55
|
+
get(id: SessionId): unknown
|
|
56
|
+
}
|
|
57
|
+
logger?: { warn(format: string, ...args: readonly unknown[]): void }
|
|
58
|
+
}
|
|
59
|
+
readonly _persistenceBinding: {
|
|
60
|
+
readonly identity: symbol
|
|
61
|
+
readonly service?: {
|
|
62
|
+
list(options?: { readonly signal?: AbortSignal }): Promise<readonly SessionPersistenceSnapshot[]>
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
_lastPersistenceIdentity: symbol | undefined
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type ColdRead = (persistence: NonNullable<EngineSurface['_persistenceBinding']['service']>, id: SessionId, signal: AbortSignal | undefined) => Promise<{ header: SessionHeader; inheritedEventCount: SessionLogOffset; events: readonly SessionEvent[] }>
|
|
69
|
+
|
|
70
|
+
const STABLE_OBSERVATION_ATTEMPTS = 2
|
|
71
|
+
|
|
72
|
+
function assertNotAborted(signal: AbortSignal | undefined): void {
|
|
73
|
+
if (signal?.aborted) {
|
|
74
|
+
throw new SessionQueryError('session-search aborted', 'SESSION_QUERY_ABORTED')
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isAbort(error: unknown): boolean {
|
|
79
|
+
return error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function errorMessage(error: unknown): string {
|
|
83
|
+
return error instanceof Error ? error.message : 'unknown error'
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function observeSession(header: SessionHeader, inheritedEventCount: SessionLogOffset, events: readonly SessionEvent[]): ObservedSession {
|
|
87
|
+
const detachedHeader = structuredClone(header)
|
|
88
|
+
const detachedEvents = events.map(event => structuredClone(event))
|
|
89
|
+
return {
|
|
90
|
+
header: detachedHeader,
|
|
91
|
+
inheritedEventCount,
|
|
92
|
+
documents: buildSessionEventSearchDocuments(detachedHeader.id, detachedEvents),
|
|
93
|
+
fingerprint: createHash('sha256')
|
|
94
|
+
.update(JSON.stringify({ header: detachedHeader, inheritedEventCount, events: detachedEvents }))
|
|
95
|
+
.digest('base64url'),
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function sameHeader(a: SessionHeader, b: SessionHeader): boolean {
|
|
100
|
+
return a.id === b.id
|
|
101
|
+
&& a.createdAt === b.createdAt
|
|
102
|
+
&& a.cwd === b.cwd
|
|
103
|
+
&& a.parentSession === b.parentSession
|
|
104
|
+
&& a.isSeeded === b.isSeeded
|
|
105
|
+
&& (a.delegationDepth ?? 0) === (b.delegationDepth ?? 0)
|
|
106
|
+
&& a.agentPreset === b.agentPreset
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function materializePersistenceSnapshots(snapshots: readonly SessionPersistenceSnapshot[]): Map<SessionId, ObservedPersistedSession> {
|
|
110
|
+
if (!Array.isArray(snapshots)) throw new Error('persistence snapshots must be an array')
|
|
111
|
+
const result = new Map<SessionId, ObservedPersistedSession>()
|
|
112
|
+
for (const snapshot of snapshots) {
|
|
113
|
+
if (typeof snapshot.revision !== 'string') {
|
|
114
|
+
throw new Error('persistence snapshot revision must be a string')
|
|
115
|
+
}
|
|
116
|
+
const header = structuredClone(snapshot.header)
|
|
117
|
+
if (result.has(header.id)) {
|
|
118
|
+
throw new Error(`persistence listed duplicate session "${header.id}"`)
|
|
119
|
+
}
|
|
120
|
+
result.set(header.id, { header, revision: snapshot.revision })
|
|
121
|
+
}
|
|
122
|
+
return result
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function samePersistenceSnapshots(before: ReadonlyMap<SessionId, ObservedPersistedSession>, after: ReadonlyMap<SessionId, ObservedPersistedSession>): boolean {
|
|
126
|
+
if (before.size !== after.size) return false
|
|
127
|
+
for (const [id, first] of before) {
|
|
128
|
+
const second = after.get(id)
|
|
129
|
+
if (second === undefined || first.revision !== second.revision || !sameHeader(first.header, second.header)) return false
|
|
130
|
+
}
|
|
131
|
+
return true
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The skip-tolerant observation pass: structurally the upstream loop, with
|
|
136
|
+
* the per-source cold read wrapped so one unreadable session degrades to a
|
|
137
|
+
* warning instead of failing every search. Exported for unit tests with an
|
|
138
|
+
* injectable cold reader.
|
|
139
|
+
*/
|
|
140
|
+
export async function observeStableWithSkip(
|
|
141
|
+
engine: EngineSurface,
|
|
142
|
+
indexed: ReadonlyMap<SessionId, { revision: SessionPersistenceRevision }>,
|
|
143
|
+
signal: AbortSignal | undefined,
|
|
144
|
+
readCold: ColdRead = readColdSessionLog as unknown as ColdRead,
|
|
145
|
+
): Promise<{ persistenceBinding: EngineSurface['_persistenceBinding']; persisted: Map<SessionId, ObservedPersistedSession>; live: Map<SessionId, ObservedSession> }> {
|
|
146
|
+
for (let attempt = 0; attempt < STABLE_OBSERVATION_ATTEMPTS; attempt += 1) {
|
|
147
|
+
assertNotAborted(signal)
|
|
148
|
+
const persistenceBinding = engine._persistenceBinding
|
|
149
|
+
const persistence = persistenceBinding.service
|
|
150
|
+
const initiallyLive = new Set(engine.ctx.sessions.list().map(session => session.id))
|
|
151
|
+
let persisted = new Map<SessionId, ObservedPersistedSession>()
|
|
152
|
+
if (persistence !== undefined) {
|
|
153
|
+
try {
|
|
154
|
+
const canReuseIndexed = engine._lastPersistenceIdentity === undefined
|
|
155
|
+
|| engine._lastPersistenceIdentity === persistenceBinding.identity
|
|
156
|
+
const listOptions = signal === undefined ? undefined : { signal }
|
|
157
|
+
const before = await persistence.list(listOptions)
|
|
158
|
+
assertNotAborted(signal)
|
|
159
|
+
persisted = materializePersistenceSnapshots(before)
|
|
160
|
+
for (const entry of persisted.values()) {
|
|
161
|
+
if (canReuseIndexed && indexed.get(entry.header.id)?.revision === entry.revision) continue
|
|
162
|
+
if (initiallyLive.has(entry.header.id) || engine.ctx.sessions.get(entry.header.id) !== undefined) continue
|
|
163
|
+
assertNotAborted(signal)
|
|
164
|
+
// The skip: a session whose cold log fails to migrate or decode
|
|
165
|
+
// stays OUT of the index with one warning; the remaining corpus
|
|
166
|
+
// indexes normally. `loaded` stays undefined exactly like a
|
|
167
|
+
// not-yet-read entry, so the stable-snapshot comparison and the
|
|
168
|
+
// live-preferred merge below are unaffected.
|
|
169
|
+
try {
|
|
170
|
+
const loaded = await readCold(persistence, entry.header.id, signal)
|
|
171
|
+
assertNotAborted(signal)
|
|
172
|
+
assertSessionHeadersCompatible(entry.header, loaded.header)
|
|
173
|
+
entry.loaded = observeSession(loaded.header, loaded.inheritedEventCount, loaded.events)
|
|
174
|
+
} catch (error: unknown) {
|
|
175
|
+
if (isAbort(error) || signal?.aborted) throw error
|
|
176
|
+
if (engine._persistenceBinding !== persistenceBinding) break
|
|
177
|
+
entry.unreadable = errorMessage(error)
|
|
178
|
+
engine.ctx.logger?.warn(
|
|
179
|
+
'session-search skipped unreadable session %s: %s',
|
|
180
|
+
entry.header.id,
|
|
181
|
+
entry.unreadable,
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
assertNotAborted(signal)
|
|
186
|
+
const afterSnapshots = await persistence.list(listOptions)
|
|
187
|
+
assertNotAborted(signal)
|
|
188
|
+
const after = materializePersistenceSnapshots(afterSnapshots)
|
|
189
|
+
if (!samePersistenceSnapshots(persisted, after)) continue
|
|
190
|
+
if (engine._persistenceBinding !== persistenceBinding) continue
|
|
191
|
+
} catch (error: unknown) {
|
|
192
|
+
if (isAbort(error) || signal?.aborted) {
|
|
193
|
+
throw new SessionQueryError('session-search aborted', 'SESSION_QUERY_ABORTED', { cause: error })
|
|
194
|
+
}
|
|
195
|
+
if (engine._persistenceBinding !== persistenceBinding) continue
|
|
196
|
+
if (error instanceof SessionQueryError) throw error
|
|
197
|
+
throw new SessionQueryError(
|
|
198
|
+
`session-search persistence observation failed: ${errorMessage(error)}`,
|
|
199
|
+
'SESSION_QUERY_PERSISTENCE_FAILED',
|
|
200
|
+
{ cause: error },
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const live = new Map<SessionId, ObservedSession>()
|
|
205
|
+
for (const session of engine.ctx.sessions.list()) {
|
|
206
|
+
const observed = observeSession(session.header, session.inheritedEventCount, session.snapshotEvents())
|
|
207
|
+
const durable = persisted.get(session.id)
|
|
208
|
+
if (durable !== undefined && durable.loaded === undefined) {
|
|
209
|
+
// A live owner always wins over a skipped durable copy.
|
|
210
|
+
live.set(session.id, observed)
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
if (durable !== undefined) assertSessionHeadersCompatible(observed.header, durable.header as SessionHeader)
|
|
214
|
+
live.set(session.id, observed)
|
|
215
|
+
}
|
|
216
|
+
const sameLive = initiallyLive.size === live.size && [...initiallyLive].every(id => live.has(id))
|
|
217
|
+
if (!sameLive) continue
|
|
218
|
+
return { persistenceBinding, persisted, live }
|
|
219
|
+
}
|
|
220
|
+
throw new SessionQueryError('session-search persistence observation did not stabilize after one retry', 'SESSION_QUERY_PERSISTENCE_FAILED')
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// The opaque-base cast keeps the private `_observeStable` override legal in
|
|
224
|
+
// TypeScript while inheriting every runtime static (inject, Config, Service
|
|
225
|
+
// metadata) from the real engine class.
|
|
226
|
+
const EngineBase = SqliteSessionQueryEngine as unknown as abstract new (ctx: never, config: Config) => EngineSurface & object
|
|
227
|
+
|
|
228
|
+
/** The engine this bundle mounts in place of the base `session-query-sqlite` row. */
|
|
229
|
+
export class SkipTolerantSessionQueryEngine extends EngineBase {
|
|
230
|
+
async _observeStable(indexed: ReadonlyMap<SessionId, { revision: SessionPersistenceRevision }>, signal: AbortSignal | undefined): Promise<unknown> {
|
|
231
|
+
return await observeStableWithSkip(this as unknown as EngineSurface, indexed, signal)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export default SkipTolerantSessionQueryEngine as unknown as typeof SqliteSessionQueryEngine
|