dsh-code 1.0.5 → 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 +338 -286
- package/README.md +68 -16
- package/bin/deepseek.mjs +204 -4
- package/cordis.patch.yml +105 -7
- package/lib/index.mjs +2148 -439
- package/lib/session-query.mjs +149 -0
- package/lib/types/app.d.ts +34 -8
- package/lib/types/attachments.d.ts +36 -4
- package/lib/types/index.d.ts +38 -2
- package/lib/types/kernel-panels.d.ts +23 -0
- package/lib/types/provider-settings.d.ts +6 -11
- package/lib/types/render/animations.d.ts +74 -7
- package/lib/types/render/editor.d.ts +4 -3
- package/lib/types/render/export.d.ts +0 -6
- package/lib/types/render/fuzzy.d.ts +21 -0
- package/lib/types/render/ime-cursor.d.ts +60 -0
- package/lib/types/render/projection.d.ts +80 -4
- package/lib/types/render/status.d.ts +1 -1
- package/lib/types/session-directory.d.ts +48 -13
- package/lib/types/session-query.d.ts +92 -0
- package/lib/types/store.d.ts +3 -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 +307 -162
- package/src/app.ts +730 -266
- package/src/attachments.ts +110 -11
- package/src/commands.ts +35 -5
- package/src/index.ts +1986 -1779
- package/src/internals.ts +66 -40
- package/src/kernel-panels.ts +89 -3
- package/src/provider-settings.ts +12 -12
- package/src/render/animations.ts +606 -403
- package/src/render/editor.ts +5 -4
- package/src/render/export.ts +13 -3
- package/src/render/fuzzy.ts +83 -0
- package/src/render/ime-cursor.ts +147 -0
- package/src/render/projection.ts +1974 -1621
- package/src/render/status.ts +18 -4
- package/src/session-directory.ts +94 -16
- package/src/session-query.ts +235 -0
- package/src/skills.ts +23 -9
- package/src/store.ts +39 -1
- package/src/subagents.ts +26 -3
- package/src/terminal-title.ts +173 -0
- package/src/update-panel.ts +246 -0
- package/src/update.ts +110 -0
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
|
|
package/src/session-directory.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { basename, dirname, resolve } from 'node:path'
|
|
4
4
|
import { realpathSync } from 'node:fs'
|
|
5
|
-
import type
|
|
5
|
+
import { SESSION_FORMAT_VERSION, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
|
|
6
6
|
|
|
7
7
|
export interface SessionRecord {
|
|
8
8
|
readonly header: SessionHeader
|
|
@@ -170,9 +170,8 @@ export function mergeSessionTitles(
|
|
|
170
170
|
|
|
171
171
|
/**
|
|
172
172
|
* Encode a session id the way the JSONL backend does for its on-disk layout
|
|
173
|
-
* (`encodeSegment`: safe units literal, everything else `~XXXX`). Used
|
|
174
|
-
* validate
|
|
175
|
-
* any deletion touches the filesystem — a local copy of the pure upstream
|
|
173
|
+
* (`encodeSegment`: safe units literal, everything else `~XXXX`). Used to
|
|
174
|
+
* validate and derive session directories — a local copy of the pure upstream
|
|
176
175
|
* contract, kept in sync with `session-persistence-jsonl/src/format.ts`.
|
|
177
176
|
*/
|
|
178
177
|
export function encodeSessionSegment(raw: string): string {
|
|
@@ -192,22 +191,101 @@ export function encodeSessionSegment(raw: string): string {
|
|
|
192
191
|
return out
|
|
193
192
|
}
|
|
194
193
|
|
|
195
|
-
/**
|
|
196
|
-
|
|
194
|
+
/**
|
|
195
|
+
* Encode a project cwd the way the JSONL backend groups sessions on disk
|
|
196
|
+
* (`projectKey`: separators collapse to one `-`, everything else mirrors
|
|
197
|
+
* `encodeSegment`, bounded to 251 chars). A local copy of the pure upstream
|
|
198
|
+
* contract, kept in sync with `session-persistence-jsonl/src/format.ts`.
|
|
199
|
+
*/
|
|
200
|
+
export function encodeProjectKey(cwd: string): string {
|
|
201
|
+
if (cwd.length === 0) throw new Error('cannot encode an empty project path')
|
|
202
|
+
let readable = ''
|
|
203
|
+
let separatorRun = false
|
|
204
|
+
for (let i = 0; i < cwd.length; i += 1) {
|
|
205
|
+
const code = cwd.charCodeAt(i)
|
|
206
|
+
const ch = String.fromCharCode(code)
|
|
207
|
+
if (ch === '/' || ch === '\\' || ch === ':') {
|
|
208
|
+
if (!separatorRun) readable += '-'
|
|
209
|
+
separatorRun = true
|
|
210
|
+
} else if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) {
|
|
211
|
+
readable += ch
|
|
212
|
+
separatorRun = false
|
|
213
|
+
} else {
|
|
214
|
+
readable += `~${code.toString(16).toUpperCase().padStart(4, '0')}`
|
|
215
|
+
separatorRun = false
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const slug = readable.replace(/^-+/, '') || 'root'
|
|
219
|
+
return `--${slug.slice(0, 251)}--`
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The project-level directory name the JSONL backend uses for a missing cwd. */
|
|
223
|
+
const NO_CWD_DIRECTORY = '_no-cwd'
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Derive one session's artifact directory under the JSONL backend root,
|
|
227
|
+
* mirroring the upstream `<root>/<projectKey(cwd)>/<encodeSegment(id)>/`
|
|
228
|
+
* layout (0.1.5 `sessionDir`/`projectDir`).
|
|
229
|
+
* @param root - the JSONL backend's configured session root.
|
|
230
|
+
* @param cwd - the session's pinned working directory, when the header has one.
|
|
231
|
+
* @param id - the session id.
|
|
232
|
+
* @returns the absolute session directory path.
|
|
233
|
+
*/
|
|
234
|
+
export function sessionDirectoryFor(root: string, cwd: string | undefined, id: string): string {
|
|
235
|
+
const project = cwd === undefined || cwd === '' ? NO_CWD_DIRECTORY : encodeProjectKey(cwd)
|
|
236
|
+
return resolve(root, project, encodeSessionSegment(id))
|
|
237
|
+
}
|
|
197
238
|
|
|
198
239
|
/**
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
240
|
+
* The canonical session-log artifact filenames the JSONL backend may create:
|
|
241
|
+
* format v0 writes the bare `session.jsonl` name; v1+ write
|
|
242
|
+
* `session.vN.jsonl`, each generation optionally zstd-compressed. Multiple
|
|
243
|
+
* immutable generations may coexist in one session directory (0.1.5). The
|
|
244
|
+
* range follows the installed session package's `SESSION_FORMAT_VERSION`, so
|
|
245
|
+
* a future generation joins the enumeration with the dependency bump.
|
|
205
246
|
*/
|
|
206
|
-
export function
|
|
207
|
-
|
|
208
|
-
|
|
247
|
+
export function sessionArtifactNames(): readonly string[] {
|
|
248
|
+
const names: string[] = ['session.jsonl', 'session.jsonl.zstd']
|
|
249
|
+
for (let version = 1; version <= SESSION_FORMAT_VERSION; version += 1) {
|
|
250
|
+
names.push(`session.v${version}.jsonl`, `session.v${version}.jsonl.zstd`)
|
|
251
|
+
}
|
|
252
|
+
return names
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Canonical generation-log filenames as a lookup set (bare v0 or `vN`-suffixed, ± zstd). */
|
|
256
|
+
const SESSION_ARTIFACT_NAME_SET: ReadonlySet<string> = new Set(sessionArtifactNames())
|
|
257
|
+
|
|
258
|
+
/** True for one canonical session-log artifact filename the backend may own. */
|
|
259
|
+
export function isSessionArtifactName(name: string): boolean {
|
|
260
|
+
return SESSION_ARTIFACT_NAME_SET.has(name)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Guard a derived session directory before deletion (codex's scoped-path
|
|
265
|
+
* check, adapted to the JSONL layout): the directory's base name must be
|
|
266
|
+
* exactly `encodeSegment(id)` beneath its project grouping.
|
|
267
|
+
* @param dir - the derived session artifact directory.
|
|
268
|
+
* @param id - the session id the directory claims to belong to.
|
|
269
|
+
* @returns the guarded directory, or undefined when the layout is unexpected.
|
|
270
|
+
*/
|
|
271
|
+
export function sessionArtifactDirectory(dir: string, id: string): string | undefined {
|
|
209
272
|
if (basename(dir) !== encodeSessionSegment(id)) return undefined
|
|
210
|
-
return dir
|
|
273
|
+
if (basename(dirname(dir)) === NO_CWD_DIRECTORY) return dir
|
|
274
|
+
return /^--.*--$|^~/.test(basename(dirname(dir))) ? dir : undefined
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The JSONL backend's configured session root, when the mounted backend
|
|
279
|
+
* exposes one. The upstream service contract dropped `locate()` in 0.1.5
|
|
280
|
+
* (artifact paths are backend-private; only refusal diagnostics carry them),
|
|
281
|
+
* so the TUI derives artifact paths from the backend's public plugin config.
|
|
282
|
+
* Backends without a JSONL-style config (or a foreign shape) yield undefined
|
|
283
|
+
* and callers degrade: mtime sorting falls back to createdAt and /delete
|
|
284
|
+
* refuses, exactly as before.
|
|
285
|
+
*/
|
|
286
|
+
export function jsonlSessionRoot(persistence: unknown): string | undefined {
|
|
287
|
+
const root = (persistence as { config?: { root?: unknown } } | undefined)?.config?.root
|
|
288
|
+
return typeof root === 'string' && root !== '' ? root : undefined
|
|
211
289
|
}
|
|
212
290
|
|
|
213
291
|
/**
|
|
@@ -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
|
package/src/skills.ts
CHANGED
|
@@ -74,11 +74,17 @@ export function watchSkills(ctx: Context, fallbackCwd?: string): SkillsWatch {
|
|
|
74
74
|
|
|
75
75
|
const reload = (): void => {
|
|
76
76
|
const target = agent
|
|
77
|
-
if (skills === undefined
|
|
78
|
-
Promise.resolve().then(() => skills.list(
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
77
|
+
if (skills === undefined) return
|
|
78
|
+
Promise.resolve().then(() => skills.list(target === undefined
|
|
79
|
+
// No session exists yet (a bare launch keeps the agent unset until the
|
|
80
|
+
// first message): read the global skill layer for the working directory.
|
|
81
|
+
// The upstream contract makes `scope` optional — omitted reads the
|
|
82
|
+
// global layer alone — so the menu offers skills before a session does.
|
|
83
|
+
? { cwd: fallbackCwd }
|
|
84
|
+
: {
|
|
85
|
+
cwd: target.session.header.cwd ?? fallbackCwd,
|
|
86
|
+
scope: target,
|
|
87
|
+
})).then((summaries: readonly SkillSummary[]) => {
|
|
82
88
|
// A retarget landed while this catalog was loading: the rows belong to
|
|
83
89
|
// another agent's workspace and must never overwrite the current view.
|
|
84
90
|
if (agent !== target) return
|
|
@@ -97,19 +103,27 @@ export function watchSkills(ctx: Context, fallbackCwd?: string): SkillsWatch {
|
|
|
97
103
|
for (const listener of listeners) listener()
|
|
98
104
|
}).catch((cause: unknown) => {
|
|
99
105
|
if (agent !== target) return
|
|
100
|
-
// Discovery failure keeps the last good rows for the SAME
|
|
106
|
+
// Discovery failure keeps the last good rows for the SAME target (the
|
|
101
107
|
// next skills/change notification is the retry surface, mirroring the
|
|
102
|
-
// web directory);
|
|
108
|
+
// web directory); a target that never loaded starts from empty rows —
|
|
103
109
|
// stale rows from a previous workspace must not keep completing here.
|
|
110
|
+
// The rows array keeps its identity unless the failure text itself
|
|
111
|
+
// changed: a repeated identical error on the 0.1.5 event storm must not
|
|
112
|
+
// churn fresh identities into React's update chain.
|
|
113
|
+
const nextError = cause instanceof Error ? cause.message : String(cause)
|
|
104
114
|
if (loadedFor !== target) rows = []
|
|
105
|
-
|
|
106
|
-
error =
|
|
115
|
+
const errorChanged = nextError !== error
|
|
116
|
+
error = nextError
|
|
117
|
+
if (!errorChanged) return
|
|
107
118
|
for (const listener of listeners) listener()
|
|
108
119
|
})
|
|
109
120
|
}
|
|
110
121
|
|
|
111
122
|
if (skills !== undefined) {
|
|
112
123
|
ctx.on('skills/change', reload)
|
|
124
|
+
// Read the global layer immediately: a bare launch has no agent yet, and
|
|
125
|
+
// waiting for the first skills/change would leave the menu empty.
|
|
126
|
+
reload()
|
|
113
127
|
}
|
|
114
128
|
|
|
115
129
|
const view: SkillsWatch = {
|
package/src/store.ts
CHANGED
|
@@ -30,8 +30,16 @@
|
|
|
30
30
|
* @module @deepseek-ai/dsh-tui/store
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
|
+
import type { AssistantStreamFrame } from '@deepseek-ai/dsh-agent'
|
|
33
34
|
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
34
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
applyAssistantStreamChunk,
|
|
37
|
+
clearAssistantStream,
|
|
38
|
+
createReplayAccumulator,
|
|
39
|
+
replayProjectEvent,
|
|
40
|
+
snapshotReplayView,
|
|
41
|
+
type TranscriptView,
|
|
42
|
+
} from './render/projection.ts'
|
|
35
43
|
|
|
36
44
|
/** Render frame budget: the notification cadence's upper bound. */
|
|
37
45
|
const NOTIFY_FRAME_MS = 16
|
|
@@ -44,6 +52,8 @@ export interface TranscriptStore {
|
|
|
44
52
|
subscribe(listener: () => void): () => void
|
|
45
53
|
/** Fold one session event; ignored events change nothing and notify nobody. */
|
|
46
54
|
apply(event: SessionEvent): void
|
|
55
|
+
/** Fold one live assistant-stream frame; frames without visible deltas stay silent. */
|
|
56
|
+
applyStreamFrame(frame: AssistantStreamFrame): void
|
|
47
57
|
/** Drop the folded view entirely (/clear): the next event starts a fresh one. */
|
|
48
58
|
reset(): void
|
|
49
59
|
}
|
|
@@ -66,6 +76,11 @@ export function createTranscriptStore(replay?: readonly SessionEvent[]): Transcr
|
|
|
66
76
|
const listeners = new Set<() => void>()
|
|
67
77
|
let scheduled = false
|
|
68
78
|
let lastNotifyAt = 0
|
|
79
|
+
// Live attempt → `turn:step` key: chunk frames name only their attempt, so
|
|
80
|
+
// the start frame's turn/step anchor is remembered until the end frame
|
|
81
|
+
// retires the attempt. A replacement attempt (new start frame) overwrites
|
|
82
|
+
// the entry; committed settlements already cleared the tails it replaces.
|
|
83
|
+
const attemptKeys = new Map<string, string>()
|
|
69
84
|
const notify = (): void => {
|
|
70
85
|
if (scheduled) return
|
|
71
86
|
scheduled = true
|
|
@@ -101,8 +116,31 @@ export function createTranscriptStore(replay?: readonly SessionEvent[]): Transcr
|
|
|
101
116
|
dirty = true
|
|
102
117
|
notify()
|
|
103
118
|
},
|
|
119
|
+
applyStreamFrame(frame: AssistantStreamFrame): void {
|
|
120
|
+
if (frame.type === 'start') {
|
|
121
|
+
attemptKeys.set(frame.attemptId, `${frame.turn}:${frame.step}`)
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
if (frame.type === 'chunk') {
|
|
125
|
+
const key = attemptKeys.get(frame.attemptId)
|
|
126
|
+
if (key === undefined) return
|
|
127
|
+
if (!applyAssistantStreamChunk(acc, key, frame.time, frame.chunk)) return
|
|
128
|
+
dirty = true
|
|
129
|
+
notify()
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
// End frame: committed settlements arrive as durable events before
|
|
133
|
+
// their end frame and already cleared the tails; an abandoned attempt
|
|
134
|
+
// has no settlement, so its partial tail is dropped here.
|
|
135
|
+
attemptKeys.delete(frame.attemptId)
|
|
136
|
+
if (frame.outcome.kind === 'abandoned' && clearAssistantStream(acc)) {
|
|
137
|
+
dirty = true
|
|
138
|
+
notify()
|
|
139
|
+
}
|
|
140
|
+
},
|
|
104
141
|
reset(): void {
|
|
105
142
|
acc = createReplayAccumulator()
|
|
143
|
+
attemptKeys.clear()
|
|
106
144
|
dirty = true
|
|
107
145
|
notify()
|
|
108
146
|
},
|
package/src/subagents.ts
CHANGED
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
25
|
+
// Type-only import merges the subagent package's SessionEventMap variant
|
|
26
|
+
// ('subagent/catalog') into the union this fold switches on.
|
|
27
|
+
import type {} from '@deepseek-ai/dsh-subagent'
|
|
25
28
|
|
|
26
29
|
/** Hard row cap: overflow evicts the oldest settled row; a fully busy feed waits. */
|
|
27
30
|
export const MAX_SUBAGENT_ROWS = 8
|
|
@@ -107,8 +110,27 @@ export function foldSubagentRow(previous: SubagentRow | undefined, sessionId: st
|
|
|
107
110
|
return { ...base, state: 'running', activity: 'working…', updatedAt: event.time }
|
|
108
111
|
case 'user/message':
|
|
109
112
|
return { ...base, state: 'running', activity: 'prompted', updatedAt: event.time }
|
|
110
|
-
case 'assistant/
|
|
113
|
+
case 'assistant/attempt':
|
|
114
|
+
// Durable logs are settlement-only since session-log v2; an attempt
|
|
115
|
+
// landing without a surface message means the model is retrying or
|
|
116
|
+
// recovered from a stream error, so the child stays running.
|
|
111
117
|
return { ...base, state: 'running', activity: 'thinking…', updatedAt: event.time }
|
|
118
|
+
case 'subagent/catalog': {
|
|
119
|
+
// Parent-owned durable discovery fact (0.1.5): the catalog names the
|
|
120
|
+
// child's mode (one-shot vs continuable) and its authored label — the
|
|
121
|
+
// most semantic label the row can carry. It is a discovery fact, not a
|
|
122
|
+
// lifecycle signal: a fresh row starts idle, but a late delivery never
|
|
123
|
+
// regresses a row that already ran or finished. An unchanged fact keeps
|
|
124
|
+
// the row's identity (the no-op discipline of the default branch), so
|
|
125
|
+
// repeated deliveries never churn the snapshot array.
|
|
126
|
+
const mode = event.data.mode === 'continuable' ? 'continuable' : 'one-shot'
|
|
127
|
+
const label = event.data.label !== undefined && event.data.label.trim() !== '' ? bound(event.data.label) : undefined
|
|
128
|
+
const nextLabel = label === undefined ? base.label : label
|
|
129
|
+
const activity = label === undefined ? `catalog · ${mode}` : `catalog · ${mode} · ${label}`
|
|
130
|
+
const state = previous === undefined ? 'idle' : base.state
|
|
131
|
+
if (nextLabel === base.label && state === base.state && activity === base.activity) return base
|
|
132
|
+
return { ...base, state, label: nextLabel, activity, updatedAt: event.time }
|
|
133
|
+
}
|
|
112
134
|
case 'assistant/message':
|
|
113
135
|
return { ...base, state: 'idle', activity: messagePreview(data['message'] === undefined ? undefined : (data['message'] as { content?: unknown }).content), updatedAt: event.time }
|
|
114
136
|
case 'tool/call': {
|
|
@@ -169,11 +191,12 @@ export function createSubagentFeed(): SubagentFeedView & {
|
|
|
169
191
|
}
|
|
170
192
|
// A child this feed has not shown yet: the honest total grows even
|
|
171
193
|
// when every row is busy; admission then prefers evicting the OLDEST
|
|
172
|
-
// settled row
|
|
194
|
+
// settled row (idle or done — both are non-running) so a new running
|
|
195
|
+
// agent never waits on one that already settled.
|
|
173
196
|
const counted = !seen.has(sessionId)
|
|
174
197
|
if (counted) seen.add(sessionId)
|
|
175
198
|
if (rows.length >= MAX_SUBAGENT_ROWS) {
|
|
176
|
-
const evict = rows.findIndex(row => row.state
|
|
199
|
+
const evict = rows.findIndex(row => row.state !== 'running')
|
|
177
200
|
if (evict === -1) {
|
|
178
201
|
if (counted) notify()
|
|
179
202
|
return
|