@sparkelf/dsh-plus 0.2.1-rc.2.4 → 0.2.2-alpha.2.10

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.
Files changed (37) hide show
  1. package/README.md +7 -5
  2. package/README.zh.md +7 -5
  3. package/lib/apply-08huHfLG.js +665 -0
  4. package/lib/apply-DmICvIGh.js +653 -0
  5. package/lib/apply.js +1 -1
  6. package/lib/bin.js +229 -47
  7. package/lib/types/apply.d.ts +1 -1
  8. package/lib/types/apply.js +56 -117
  9. package/lib/types/patch-set.d.ts +59 -0
  10. package/lib/types/patch-set.js +132 -0
  11. package/lib/types/standalone-cli.js +93 -2
  12. package/lib/types/standalone-profile.d.ts +8 -1
  13. package/lib/types/standalone-profile.js +191 -53
  14. package/package.json +603 -175
  15. package/patches/approval-allow-policy.patch +108 -0
  16. package/patches/atomic-write-zombie-lock-holder.patch +53 -0
  17. package/patches/bootstrap-exempt-names.patch +32 -0
  18. package/patches/browser-auth-mode.patch +127 -0
  19. package/patches/composer-popover-boundaries.patch +337 -0
  20. package/patches/diagram-preview.patch +562 -0
  21. package/patches/image-offload-hoist-replay.patch +533 -0
  22. package/patches/legacy-code-preset.patch +148 -0
  23. package/patches/llm-pi-ai-transport-truncation.patch +33 -0
  24. package/patches/mobile-journal-generation.patch +76 -0
  25. package/patches/office-preview-stable-load.patch +34 -0
  26. package/patches/officecli-deliverables.patch +42 -0
  27. package/patches/ptc-mcp-schema-types.patch +156 -0
  28. package/patches/responses-reasoning-status.patch +289 -0
  29. package/patches/session-envelope-ignorable.patch +63 -0
  30. package/patches/session-export-chinese.patch +85 -0
  31. package/patches/session-format-legacy-restart.patch +35 -0
  32. package/patches/session-log-trajectory-toolbar.patch +352 -0
  33. package/patches/state-dot-brand-blue.patch +185 -0
  34. package/patches/ui-settings-compact-phone.patch +76 -0
  35. package/patches/web-runtime-compat.patch +83 -0
  36. package/patches/workspace-storage-restore.patch +186 -0
  37. package/patches/wsl-native-open.patch +265 -0
@@ -0,0 +1,533 @@
1
+ diff --git a/packages/compaction/compaction-image-offload/src/project-message.ts b/packages/compaction/compaction-image-offload/src/project-message.ts
2
+ index 17116ffb..70913316 100644
3
+ --- a/packages/compaction/compaction-image-offload/src/project-message.ts
4
+ +++ b/packages/compaction/compaction-image-offload/src/project-message.ts
5
+ @@ -5,23 +5,51 @@ import { deepFreeze } from '@deepseek-ai/dsh-util-values'
6
+
7
+ /**
8
+ * Project selected image occurrences to immutable offloaded blocks.
9
+ + *
10
+ + * Occurrence indexes are recorded against the message the deciding request saw,
11
+ + * which is the *projected* message. Two things make a durable message's image
12
+ + * list differ from that one:
13
+ + *
14
+ + * - `image/hoist` moves tool-result images onto an earlier user carrier, so a
15
+ + * reader that does not mount that projection sees the images at their original
16
+ + * positions; the recorded indexes then resolve to different images or to none.
17
+ + * - The same projection may run again and move images further.
18
+ + *
19
+ + * `positionsAreAuthoritative` therefore separates the two situations. A log with
20
+ + * no hoist decision is folded identically everywhere, and a selection that does
21
+ + * not resolve there is a writer bug that must fail loudly. A log that does carry
22
+ + * hoist decisions has projection-dependent positions, and refusing to replay it
23
+ + * would make the session permanently unreadable to every host that does not mount
24
+ + * the plugin that wrote those rows — strictly worse than leaving an image
25
+ + * unsuppressed, and the reason those rows are recorded as `ignorable`.
26
+ * @param message - message projected before this decision.
27
+ * @param indexes - nonempty, strictly increasing depth-first image indexes.
28
+ - * @returns an immutable message with the same identity and selected images marked.
29
+ - * @throws when a selected occurrence is missing or already offloaded.
30
+ + * @param positionsAreAuthoritative - whether every recorded index must resolve.
31
+ + * @returns an immutable message with the same identity and every resolvable selected image marked.
32
+ + * @throws when positions are authoritative and a selected occurrence is missing or already offloaded.
33
+ */
34
+ -export function offloadMessageImages(message: Message, indexes: readonly number[]): Message {
35
+ +export function offloadMessageImages(
36
+ + message: Message,
37
+ + indexes: readonly number[],
38
+ + positionsAreAuthoritative = true,
39
+ +): Message {
40
+ + const selected = new Set(indexes)
41
+ let imageIndex = 0
42
+ - let selected = 0
43
+ + let applied = 0
44
+ const visit = (blocks: readonly ContentBlock[]): ContentBlock[] => {
45
+ let next: ContentBlock[] | undefined
46
+ for (const [index, block] of blocks.entries()) {
47
+ let projected = block
48
+ if (block.type === 'image') {
49
+ - if (imageIndex === indexes[selected]) {
50
+ - if (block.offloaded === true) throw new Error(`image/offload: image index ${imageIndex} is already offloaded`)
51
+ - projected = { ...block, offloaded: true }
52
+ - selected += 1
53
+ + if (selected.has(imageIndex)) {
54
+ + if (block.offloaded === true) {
55
+ + if (positionsAreAuthoritative) {
56
+ + throw new Error(`image/offload: image index ${imageIndex} is already offloaded`)
57
+ + }
58
+ + } else {
59
+ + projected = { ...block, offloaded: true }
60
+ + }
61
+ + applied += 1
62
+ }
63
+ imageIndex += 1
64
+ }
65
+ @@ -31,6 +59,9 @@ export function offloadMessageImages(message: Message, indexes: readonly number[
66
+ return next ?? blocks as ContentBlock[]
67
+ }
68
+ const content = visit(message.content)
69
+ - if (selected !== indexes.length) throw new Error(`image/offload: image index ${indexes[selected]} does not exist`)
70
+ + if (positionsAreAuthoritative && applied !== indexes.length) {
71
+ + throw new Error(`image/offload: image index ${indexes[applied]} does not exist`)
72
+ + }
73
+ + if (applied === 0) return message
74
+ return deepFreeze({ ...message, content })
75
+ }
76
+ diff --git a/packages/compaction/compaction-image-offload/src/projection.ts b/packages/compaction/compaction-image-offload/src/projection.ts
77
+ index 1dd0e81b..ce88e9a4 100644
78
+ --- a/packages/compaction/compaction-image-offload/src/projection.ts
79
+ +++ b/packages/compaction/compaction-image-offload/src/projection.ts
80
+ @@ -36,6 +36,22 @@ function isIndex(value: unknown): value is SessionSeq {
81
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && !Object.is(value, -0)
82
+ }
83
+
84
+ +/**
85
+ + * Whether this log carries `image/hoist` decisions.
86
+ + *
87
+ + * Those decisions move tool-result images onto an earlier user carrier at request
88
+ + * time without rewriting the durable message, so a recorded `image/offload`
89
+ + * selection counts occurrences in a shape that only exists while that projection
90
+ + * is mounted. Reading such a log without it must skip unresolvable positions
91
+ + * instead of refusing the session; a log with no hoist decision folds identically
92
+ + * everywhere, and there an unresolvable selection stays a hard error.
93
+ + * @param events - contiguous event window supplied to the fold.
94
+ + * @returns whether any event in the window is an image/hoist decision.
95
+ + */
96
+ +function carriesHoistDecisions(events: readonly { type: string }[]): boolean {
97
+ + return events.some(event => event.type === 'image/hoist')
98
+ +}
99
+ +
100
+ /** Atomic validation and reconstruction shared by live sessions and detached replay. */
101
+ export const imageOffloadProjection: SessionMessageProjection<'image/offload'> = {
102
+ type: 'image/offload',
103
+ @@ -46,6 +62,7 @@ export const imageOffloadProjection: SessionMessageProjection<'image/offload'> =
104
+ }
105
+ const messages = new Map<SessionSeq, Message>()
106
+ const nodes = new Set(context.nodes)
107
+ + const positionsAreAuthoritative = !carriesHoistDecisions(context.events)
108
+ for (const target of data['targets'] as unknown[]) {
109
+ if (!isRecord(target) || Object.keys(target).length !== 2 || !isIndex(target['seq'])
110
+ || !Array.isArray(target['imageIndexes']) || target['imageIndexes'].length === 0) {
111
+ @@ -67,7 +84,7 @@ export const imageOffloadProjection: SessionMessageProjection<'image/offload'> =
112
+ }
113
+ const message = context.messages.get(seq)
114
+ ?? (source.type === 'user/message' ? source.data : source.data.message)
115
+ - messages.set(seq, offloadMessageImages(message, target['imageIndexes'] as number[]))
116
+ + messages.set(seq, offloadMessageImages(message, target['imageIndexes'] as number[], positionsAreAuthoritative))
117
+ }
118
+ return messages
119
+ },
120
+ diff --git a/packages/compaction/compaction-image-offload/tests/projection.spec.ts b/packages/compaction/compaction-image-offload/tests/projection.spec.ts
121
+ index 0c82575d..e3217782 100644
122
+ --- a/packages/compaction/compaction-image-offload/tests/projection.spec.ts
123
+ +++ b/packages/compaction/compaction-image-offload/tests/projection.spec.ts
124
+ @@ -137,4 +137,49 @@ describe('durable image selections', () => {
125
+ }, { surfaceOp: 'append' })
126
+ expect(() => session.append('image/offload', { targets: [{ seq: assistant.seq, imageIndexes: [0] }] })).toThrow(/must be user\/message/)
127
+ })
128
+ +
129
+ + // A hoist decision moves tool-result images onto an earlier user carrier at request time
130
+ + // without rewriting the durable message, so the positions a later offload counted only
131
+ + // exist while that projection is mounted. A reader without it must still open the log.
132
+ + const hoistRow = (seq: number): SessionEvent => ({
133
+ + type: 'image/hoist',
134
+ + seq: SessionSeq(seq),
135
+ + time: 0,
136
+ + data: { targets: [{ targetSeq: 0, userSeq: 0 }] },
137
+ + ignorable: true,
138
+ + } as unknown as SessionEvent)
139
+ +
140
+ + it('skips unresolvable positions when the log carries hoist decisions', () => {
141
+ + const session = createSession(SessionId('hoisted'))
142
+ + const source = input(session)
143
+ + const events = session.snapshotEvents()
144
+ + // The recorded selection indexes the hoisted shape: the durable message has two
145
+ + // images and the selection names only index 0, which still resolves, while index 5
146
+ + // (the shape the hoisted carrier had) does not.
147
+ + const recorded = {
148
+ + type: 'image/offload',
149
+ + seq: SessionSeq(events.length + 1),
150
+ + time: 0,
151
+ + data: { targets: [{ seq: source.seq, imageIndexes: [0, 5] }] },
152
+ + } as unknown as SessionEvent
153
+ + const withHoist = [...events, hoistRow(events.length), recorded]
154
+ + // Catalog-only fold: index 5 is unresolvable, so it is skipped rather than fatal.
155
+ + const fold = foldSurface(withHoist, [imageOffloadProjection])
156
+ + const projected = deriveEventMessage(source, fold.projectedMessages)
157
+ + expect(projected?.content.filter(block => block.type === 'image').map(block => block.type === 'image' && block.offloaded === true))
158
+ + .toEqual([true, false])
159
+ + })
160
+ +
161
+ + it('keeps an unresolvable selection fatal without hoist decisions', () => {
162
+ + const session = createSession(SessionId('no-hoist'))
163
+ + input(session)
164
+ + const events = session.snapshotEvents()
165
+ + const recorded = {
166
+ + type: 'image/offload',
167
+ + seq: SessionSeq(events.length),
168
+ + time: 0,
169
+ + data: { targets: [{ seq: SessionSeq(0), imageIndexes: [0, 5] }] },
170
+ + } as unknown as SessionEvent
171
+ + expect(() => foldSurface([...events, recorded], [imageOffloadProjection])).toThrow(/does not exist/)
172
+ + })
173
+ })
174
+ diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts
175
+ index 7c95afa2..97d1763e 100644
176
+ --- a/packages/session-query/session-query-sqlite/src/index.ts
177
+ +++ b/packages/session-query/session-query-sqlite/src/index.ts
178
+ @@ -9,11 +9,12 @@ import { SESSION_FORMAT_VERSION, SessionSeq } from '@deepseek-ai/dsh-session'
179
+ import type { DatabaseSync } from 'node:sqlite'
180
+ import { Context, Service, type Fiber } from '@deepseek-ai/cordis'
181
+ import z from '@deepseek-ai/schemastery'
182
+ -import type { Session, SessionEvent, SessionHeader, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session'
183
+ +import type { Session, SessionEvent, SessionHeader, SessionId, SessionLogOffset, SessionMessageProjection } from '@deepseek-ai/dsh-session'
184
+ import type SessionPersistence from '@deepseek-ai/dsh-session-persistence'
185
+ -import type {
186
+ - SessionPersistenceRevision,
187
+ - SessionPersistenceSnapshot,
188
+ +import {
189
+ + SessionFormatUnsupportedError,
190
+ + type SessionPersistenceRevision,
191
+ + type SessionPersistenceSnapshot,
192
+ } from '@deepseek-ai/dsh-session-persistence'
193
+ import SessionQueryEngine, {
194
+ SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY,
195
+ @@ -24,6 +25,7 @@ import SessionQueryEngine, {
196
+ assertSessionHeadersCompatible,
197
+ buildSessionEventSearchDocuments,
198
+ readColdSessionLog,
199
+ + type ColdSessionLog,
200
+ } from '@deepseek-ai/dsh-session-query'
201
+ import type {
202
+ Config as SessionQueryConfig,
203
+ @@ -519,10 +521,27 @@ export class SqliteSessionQueryEngine extends SessionQueryEngine {
204
+ // live-preferred.
205
+ if (initiallyLive.has(entry.header.id) || this.ctx.sessions.get(entry.header.id) !== undefined) continue
206
+ assertNotAborted(signal)
207
+ - const loaded = await readColdSessionLog(persistence, entry.header.id, signal)
208
+ + let loaded: ColdSessionLog
209
+ + try {
210
+ + loaded = await readColdSessionLog(persistence, entry.header.id, signal)
211
+ + } catch (readError: unknown) {
212
+ + // A session whose stored log cannot be migrated is not indexable,
213
+ + // but it must not stop every other session from being indexed: one
214
+ + // unreadable log would otherwise fail content search on every
215
+ + // attempt, forever. Leave it out of this pass.
216
+ + if (!(readError instanceof SessionFormatUnsupportedError)) throw readError
217
+ + continue
218
+ + }
219
+ assertNotAborted(signal)
220
+ assertSessionHeadersCompatible(entry.header, loaded.header)
221
+ - entry.loaded = observeSession(loaded.header, loaded.inheritedEventCount, loaded.events)
222
+ + entry.loaded = observeSession(loaded.header, loaded.inheritedEventCount, loaded.events, this.replayMessageProjections)
223
+ + // Publish this session before reading the next one. The first pass
224
+ + // over a large history reads every log, so deferring the write until
225
+ + // the whole corpus is observed means an interrupt discards all of it
226
+ + // and the next search repeats the same work forever. Committing per
227
+ + // session keeps earlier progress, and the revision check above skips
228
+ + // whatever is already indexed on the next attempt.
229
+ + this._persistObservedSession(entry.loaded, entry.revision)
230
+ }
231
+ assertNotAborted(signal)
232
+ const afterSnapshots = await persistence.list(listOptions)
233
+ @@ -547,7 +566,7 @@ export class SqliteSessionQueryEngine extends SessionQueryEngine {
234
+ }
235
+ const live = new Map<SessionId, ObservedSession>()
236
+ for (const session of this.ctx.sessions.list()) {
237
+ - const observed = observeLive(session)
238
+ + const observed = observeLive(session, this.replayMessageProjections)
239
+ const durable = persisted.get(session.id)
240
+ if (durable !== undefined) assertSessionHeadersCompatible(observed.header, durable.header)
241
+ live.set(session.id, observed)
242
+ @@ -613,6 +632,34 @@ export class SqliteSessionQueryEngine extends SessionQueryEngine {
243
+ }
244
+ }
245
+
246
+ + /**
247
+ + * Index one observed session in its own transaction.
248
+ + *
249
+ + * Called while the corpus is being read rather than after it, so a first pass
250
+ + * that is interrupted keeps every session it already indexed. The next pass
251
+ + * skips those by revision and continues where this one stopped.
252
+ + * @param entry - the observed session and its parsed documents.
253
+ + * @param revision - the persistence revision the entry was read at.
254
+ + */
255
+ + private _persistObservedSession(entry: ObservedSession, revision: SessionPersistenceRevision): void {
256
+ + const db = this._requireDb()
257
+ + const generation = this._mainGeneration() + 1
258
+ + db.exec('BEGIN IMMEDIATE')
259
+ + try {
260
+ + this._replacePersistedSession(entry, revision, generation)
261
+ + db.prepare('UPDATE search_state SET global_generation = ? WHERE singleton = 1').run(generation)
262
+ + db.exec('COMMIT')
263
+ + } catch (error: unknown) {
264
+ + try {
265
+ + db.exec('ROLLBACK')
266
+ + } catch {
267
+ + // The write failure is the actionable cause; a rollback failure on the
268
+ + // same broken transaction adds nothing.
269
+ + }
270
+ + throw error
271
+ + }
272
+ + }
273
+ +
274
+ private _replaceLiveSession(entry: ObservedSession, generation: number, persisted: boolean): void {
275
+ this._deleteSession('live', entry.header.id)
276
+ const db = this._requireDb()
277
+ @@ -868,22 +915,23 @@ function selectedDocumentsParams(query: string, persistenceVisible: boolean): Ar
278
+ ]
279
+ }
280
+
281
+ -function observeLive(session: Session): ObservedSession {
282
+ +function observeLive(session: Session, projections: readonly SessionMessageProjection[]): ObservedSession {
283
+ // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
284
+ - return observeSession(session.header, session.inheritedEventCount, session.snapshotEvents())
285
+ + return observeSession(session.header, session.inheritedEventCount, session.snapshotEvents(), projections)
286
+ }
287
+
288
+ function observeSession(
289
+ header: SessionHeader,
290
+ inheritedEventCount: SessionLogOffset,
291
+ events: readonly SessionEvent[],
292
+ + projections: readonly SessionMessageProjection[],
293
+ ): ObservedSession {
294
+ const detachedHeader = structuredClone(header)
295
+ const detachedEvents = events.map(event => structuredClone(event))
296
+ return {
297
+ header: detachedHeader,
298
+ inheritedEventCount,
299
+ - documents: buildSessionEventSearchDocuments(detachedHeader.id, detachedEvents),
300
+ + documents: buildSessionEventSearchDocuments(detachedHeader.id, detachedEvents, projections),
301
+ fingerprint: createHash('sha256')
302
+ .update(JSON.stringify({ header: detachedHeader, inheritedEventCount, events: detachedEvents }))
303
+ .digest('base64url'),
304
+ diff --git a/packages/session-query/session-query/src/documents.ts b/packages/session-query/session-query/src/documents.ts
305
+ index 8788c8ea..d191b9cc 100644
306
+ --- a/packages/session-query/session-query/src/documents.ts
307
+ +++ b/packages/session-query/session-query/src/documents.ts
308
+ @@ -2,7 +2,7 @@
309
+
310
+ import { currentSessionMessageProjections } from '@deepseek-ai/dsh-session-format-catalog/message-projections'
311
+ import { foldSurface } from '@deepseek-ai/dsh-session'
312
+ -import type { SessionEvent, SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
313
+ +import type { SessionEvent, SessionId, SessionMessageProjection, SessionSeq } from '@deepseek-ai/dsh-session'
314
+ import type { SessionEventRecord, SessionEventSearchDocument, SessionEventSurface } from './types.ts'
315
+ import { SessionQueryError } from './config.ts'
316
+ import { extractSessionEventText } from './extraction.ts'
317
+ @@ -11,13 +11,15 @@ import { extractSessionEventText } from './extraction.ts'
318
+ * Project a raw log into lightweight surface-aware event records.
319
+ * @param sessionId - session that owns the log.
320
+ * @param events - complete contiguous raw event log.
321
+ + * @param projections - replay definitions this log was written under; catalog-only when omitted.
322
+ * @returns one record per event in ascending seq order.
323
+ */
324
+ export function buildSessionEventRecords(
325
+ sessionId: SessionId,
326
+ events: readonly SessionEvent[],
327
+ + projections: readonly SessionMessageProjection[] = currentSessionMessageProjections,
328
+ ): SessionEventRecord[] {
329
+ - const surfaceBySeq = classifySurface(events)
330
+ + const surfaceBySeq = classifySurface(events, projections)
331
+ return events.map(event => ({
332
+ sessionId,
333
+ seq: event.seq,
334
+ @@ -31,13 +33,15 @@ export function buildSessionEventRecords(
335
+ * Build first-party semantic documents for one complete raw event log.
336
+ * @param sessionId - session that owns the log.
337
+ * @param events - complete contiguous raw event log.
338
+ + * @param projections - replay definitions this log was written under; catalog-only when omitted.
339
+ * @returns searchable documents in ascending seq order; structural events are omitted.
340
+ */
341
+ export function buildSessionEventSearchDocuments(
342
+ sessionId: SessionId,
343
+ events: readonly SessionEvent[],
344
+ + projections: readonly SessionMessageProjection[] = currentSessionMessageProjections,
345
+ ): SessionEventSearchDocument[] {
346
+ - const surfaceBySeq = classifySurface(events)
347
+ + const surfaceBySeq = classifySurface(events, projections)
348
+ const documents: SessionEventSearchDocument[] = []
349
+ for (const event of events) {
350
+ const text = extractSessionEventText(event)
351
+ @@ -54,10 +58,13 @@ export function buildSessionEventSearchDocuments(
352
+ return documents
353
+ }
354
+
355
+ -function classifySurface(events: readonly SessionEvent[]): Map<SessionSeq, SessionEventSurface> {
356
+ +function classifySurface(
357
+ + events: readonly SessionEvent[],
358
+ + projections: readonly SessionMessageProjection[],
359
+ +): Map<SessionSeq, SessionEventSurface> {
360
+ let folded: ReturnType<typeof foldSurface>
361
+ try {
362
+ - folded = foldSurface(events, currentSessionMessageProjections)
363
+ + folded = foldSurface(events, projections)
364
+ } catch (error: unknown) {
365
+ throw new SessionQueryError(
366
+ /* v8 ignore next -- foldSurface throws Error instances */
367
+ diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts
368
+ index 838ce4c7..83b105fe 100644
369
+ --- a/packages/session-query/session-query/src/index.ts
370
+ +++ b/packages/session-query/session-query/src/index.ts
371
+ @@ -11,6 +11,7 @@ import {
372
+ SessionSeq,
373
+ snapshotSessionEvent,
374
+ type SessionId,
375
+ + type SessionMessageProjection,
376
+ type SessionSeq as SessionSeqType,
377
+ } from '@deepseek-ai/dsh-session'
378
+ import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
379
+ @@ -131,6 +132,27 @@ export abstract class SessionQueryEngine extends Service {
380
+ this._observations = new SessionObservationReader(ctx, preparedSessionCacheSize)
381
+ }
382
+
383
+ + /**
384
+ + * Resolve the message projections a detached read must replay.
385
+ + *
386
+ + * A log is interpreted by whichever projections were mounted when its durable
387
+ + * decisions were written, so a cold read must use the mounted set rather than
388
+ + * the first-party catalog alone. Replaying with only the catalog is what makes
389
+ + * an `image/offload` decision recorded against an already-hoisted message
390
+ + * throw on a later read: `image/hoist` moved those image occurrences onto an
391
+ + * earlier carrier, so the recorded depth-first indexes no longer resolve.
392
+ + *
393
+ + * Catalog definitions are kept first; a live contribution of the same event
394
+ + * type is dropped because the fold rejects duplicate ownership anyway.
395
+ + * @returns borrowed catalog definitions followed by live plugin contributions.
396
+ + */
397
+ + protected get replayMessageProjections(): readonly SessionMessageProjection[] {
398
+ + const live = this.ctx.sessions.messageProjections
399
+ + if (live.length === 0) return currentSessionMessageProjections
400
+ + const seen = new Set(currentSessionMessageProjections.map(projection => projection.type))
401
+ + return [...currentSessionMessageProjections, ...live.filter(projection => !seen.has(projection.type))]
402
+ + }
403
+ +
404
+ /**
405
+ * Observe one exact live or prepared Session without a persistence listing preflight.
406
+ * @param sessionId - logical Session identity.
407
+ @@ -188,7 +210,7 @@ export abstract class SessionQueryEngine extends Service {
408
+ loaded.events,
409
+ loaded.header,
410
+ loaded.inheritedEventCount,
411
+ - currentSessionMessageProjections,
412
+ + this.replayMessageProjections,
413
+ )
414
+ return {
415
+ session: structuredClone(loaded.header),
416
+ @@ -268,7 +290,7 @@ export abstract class SessionQueryEngine extends Service {
417
+ */
418
+ async listEvents(sessionId: SessionId): Promise<SessionEventRecord[]> {
419
+ const loaded = await this._corpus.load(sessionId)
420
+ - return tracing.eventRecords(sessionId, loaded.events)
421
+ + return tracing.eventRecords(sessionId, loaded.events, this.replayMessageProjections)
422
+ }
423
+
424
+ /**
425
+ @@ -297,7 +319,7 @@ export abstract class SessionQueryEngine extends Service {
426
+ filters: readonly SessionEventResultFilter[],
427
+ ): Promise<SessionEventSearchDocument[]> {
428
+ const loaded = await this._corpus.load(sessionId)
429
+ - const documents = buildSessionEventSearchDocuments(sessionId, loaded.events)
430
+ + const documents = buildSessionEventSearchDocuments(sessionId, loaded.events, this.replayMessageProjections)
431
+ return filterSessionEventDocuments(documents, filters)
432
+ }
433
+
434
+ @@ -313,7 +335,7 @@ export abstract class SessionQueryEngine extends Service {
435
+ session: structuredClone(loaded.header),
436
+ inheritedEventCount: loaded.inheritedEventCount,
437
+ capturedThroughSeq: loaded.events.at(-1)?.seq ?? null,
438
+ - events: tracing.currentSurfaceEvents(sessionId, loaded.events),
439
+ + events: tracing.currentSurfaceEvents(sessionId, loaded.events, this.replayMessageProjections),
440
+ }
441
+ }
442
+
443
+ @@ -342,7 +364,7 @@ export abstract class SessionQueryEngine extends Service {
444
+ signal?.throwIfAborted()
445
+ return {
446
+ session: loaded.header,
447
+ - ...tracing.traceEvent(request.sessionId, loaded.events, request.seq),
448
+ + ...tracing.traceEvent(request.sessionId, loaded.events, request.seq, this.replayMessageProjections),
449
+ }
450
+ }
451
+
452
+ diff --git a/packages/session-query/session-query/src/tracing.ts b/packages/session-query/session-query/src/tracing.ts
453
+ index 0cd3fccf..3388e22a 100644
454
+ --- a/packages/session-query/session-query/src/tracing.ts
455
+ +++ b/packages/session-query/session-query/src/tracing.ts
456
+ @@ -5,6 +5,7 @@ import { foldSurface, isSurfaceEvent, snapshotSessionEvent } from '@deepseek-ai/
457
+ import type {
458
+ SessionEvent,
459
+ SessionId,
460
+ + SessionMessageProjection,
461
+ SessionSeq,
462
+ SurfaceEvent,
463
+ } from '@deepseek-ai/dsh-session'
464
+ @@ -28,26 +29,30 @@ interface EventLogAnalysis {
465
+ * Classify a raw event log with one canonical surface fold.
466
+ * @param sessionId - owner of the event log.
467
+ * @param events - detached raw event log.
468
+ + * @param projections - replay definitions this log was written under; catalog-only when omitted.
469
+ * @returns lightweight records in ascending log order.
470
+ */
471
+ export function eventRecords(
472
+ sessionId: SessionId,
473
+ events: readonly SessionEvent[],
474
+ + projections: readonly SessionMessageProjection[] = currentSessionMessageProjections,
475
+ ): SessionEventRecord[] {
476
+ - return analyzeEventLog(sessionId, events).records
477
+ + return analyzeEventLog(sessionId, events, projections).records
478
+ }
479
+
480
+ /**
481
+ * Fold and return the current model surface after validating the whole log.
482
+ * @param sessionId - owner used in query diagnostics.
483
+ * @param events - detached raw event log from one corpus observation.
484
+ + * @param projections - replay definitions this log was written under; catalog-only when omitted.
485
+ * @returns detached current surface events in folded order.
486
+ */
487
+ export function currentSurfaceEvents(
488
+ sessionId: SessionId,
489
+ events: readonly SessionEvent[],
490
+ + projections: readonly SessionMessageProjection[] = currentSessionMessageProjections,
491
+ ): SurfaceEvent[] {
492
+ - const analysis = analyzeEventLog(sessionId, events)
493
+ + const analysis = analyzeEventLog(sessionId, events, projections)
494
+ return analysis.currentSeqs.map((seq) => {
495
+ const event = events[seq]
496
+ /* v8 ignore next 6 -- analyzeEventLog validated contiguous seqs and foldSurface returned only surface-event seqs. */
497
+ @@ -66,12 +71,14 @@ export function currentSurfaceEvents(
498
+ * @param sessionId - owner of the event log.
499
+ * @param events - detached raw event log.
500
+ * @param seq - target event seq.
501
+ + * @param projections - replay definitions this log was written under; catalog-only when omitted.
502
+ * @returns direct surface replacements and relationships to cited source events.
503
+ */
504
+ export function traceEvent(
505
+ sessionId: SessionId,
506
+ events: readonly SessionEvent[],
507
+ seq: SessionSeq,
508
+ + projections: readonly SessionMessageProjection[] = currentSessionMessageProjections,
509
+ ): SessionEventTrace {
510
+ const target = events[seq]
511
+ if (target === undefined || target.seq !== seq) {
512
+ @@ -81,7 +88,7 @@ export function traceEvent(
513
+ )
514
+ }
515
+
516
+ - const analysis = analyzeEventLog(sessionId, events)
517
+ + const analysis = analyzeEventLog(sessionId, events, projections)
518
+
519
+ const replacementChain: SessionSeq[] = []
520
+ let replacement = analysis.replacedBy.get(seq)
521
+ @@ -181,10 +188,11 @@ export function traceSession(
522
+ function analyzeEventLog(
523
+ sessionId: SessionId,
524
+ events: readonly SessionEvent[],
525
+ + projections: readonly SessionMessageProjection[],
526
+ ): EventLogAnalysis {
527
+ let folded: ReturnType<typeof foldSurface>
528
+ try {
529
+ - folded = foldSurface(events, currentSessionMessageProjections)
530
+ + folded = foldSurface(events, projections)
531
+ } catch (error: unknown) {
532
+ throw new SessionQueryError(
533
+ /* v8 ignore next -- foldSurface throws Error instances */
@@ -0,0 +1,148 @@
1
+ diff --git a/packages/api/session-controller/src/agent.ts b/packages/api/session-controller/src/agent.ts
2
+ index d16e041773..41b6b4a0d1 100644
3
+ --- a/packages/api/session-controller/src/agent.ts
4
+ +++ b/packages/api/session-controller/src/agent.ts
5
+ @@ -13,6 +13,9 @@ import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-ses
6
+ import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
7
+ import type {} from '@deepseek-ai/dsh-typert-registry'
8
+
9
+ +const LEGACY_PTC_PRESET = 'code'
10
+ +const PTC_PRESET = 'ptc'
11
+ +
12
+ /** Cold Session identity absent from persistence. */
13
+ export class ApiSessionNotFound extends Error {}
14
+
15
+ @@ -273,7 +276,15 @@ export class ApiSessionAgentController {
16
+ if (presets === undefined) {
17
+ return { setup: (agentCtx) => { installModelSelection(agentCtx) } }
18
+ }
19
+ - const resolvedId = (await presets.resolve(presetId)).id
20
+ + let resolved
21
+ + try {
22
+ + resolved = await presets.resolve(presetId)
23
+ + } catch (error: unknown) {
24
+ + if (presetId !== LEGACY_PTC_PRESET
25
+ + || remoteErrorOf(error)?.code !== 'agent-preset/not-found') throw error
26
+ + resolved = await presets.resolve(PTC_PRESET)
27
+ + }
28
+ + const resolvedId = resolved.id
29
+ return {
30
+ agentPreset: resolvedId,
31
+ setup: async (agentCtx) => {
32
+ diff --git a/packages/api/session-controller/tests/agent.host.spec.ts b/packages/api/session-controller/tests/agent.host.spec.ts
33
+ index aea96d5e04..3a37fcbbc0 100644
34
+ --- a/packages/api/session-controller/tests/agent.host.spec.ts
35
+ +++ b/packages/api/session-controller/tests/agent.host.spec.ts
36
+ @@ -10,6 +10,7 @@ import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/ds
37
+ import type { SessionHeader } from '@deepseek-ai/dsh-session'
38
+ import { SessionAlreadyOwnedError } from '@deepseek-ai/dsh-session-persistence'
39
+ import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
40
+ +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
41
+ import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
42
+ import { afterEach, describe, expect, it, vi } from 'vitest'
43
+ import {
44
+ @@ -262,4 +263,61 @@ describe('ApiSession creation', () => {
45
+ await expect(agents.createSession({ cwd: join(file, 'child'), reusable: () => true }))
46
+ .rejects.toThrow('failed to ensure project directory')
47
+ })
48
+ + it('resumes a persisted legacy code session through the ptc composition', async () => {
49
+ + const { ctx, agents } = await harness()
50
+ + const meta = { ...header('stored-legacy-code'), agentPreset: 'code' }
51
+ + providePersistence(ctx, {
52
+ + list: () => Promise.resolve([meta]),
53
+ + inspect: () => Promise.resolve({ meta, events: [] }),
54
+ + })
55
+ + const resolve = vi.fn((id?: string) => id === 'code'
56
+ + ? Promise.reject(new RemoteError(
57
+ + 'agent-preset/not-found',
58
+ + 'code is no longer shipped',
59
+ + { agentPreset: 'code', available: ['ptc'] },
60
+ + ))
61
+ + : Promise.resolve({ id: id ?? 'ptc' }))
62
+ + ctx.provide('agentPresets', { resolve, mount: () => Promise.resolve() } as never)
63
+ + const resumed = {
64
+ + id: meta.id,
65
+ + session: { id: meta.id, header: meta, events: [] },
66
+ + status: 'idle',
67
+ + ctx,
68
+ + } as unknown as Agent
69
+ + vi.spyOn(ctx.agents, 'resume').mockResolvedValue({
70
+ + agent: resumed,
71
+ + dispose: () => Promise.resolve(),
72
+ + })
73
+ +
74
+ + await expect(agents.resolveAgent(meta.id)).resolves.toEqual({ agent: resumed })
75
+ + expect(resolve).toHaveBeenNthCalledWith(1, 'code')
76
+ + expect(resolve).toHaveBeenNthCalledWith(2, 'ptc')
77
+ + expect(resumed.session.header.agentPreset).toBe('code')
78
+ + })
79
+ +
80
+ +
81
+ + it('falls back from a missing legacy code preset to ptc', async () => {
82
+ + const { ctx, agents } = await harness()
83
+ + const resolve = vi.fn((id?: string) => id === 'code'
84
+ + ? Promise.reject(new RemoteError(
85
+ + 'agent-preset/not-found',
86
+ + 'code is no longer shipped',
87
+ + { agentPreset: 'code', available: ['ptc'] },
88
+ + ))
89
+ + : Promise.resolve({ id: id ?? 'ptc' }))
90
+ + ctx.provide('agentPresets', { resolve, mount: () => Promise.resolve() } as never)
91
+ +
92
+ + await expect(agents.composeAgent('code')).resolves.toMatchObject({ agentPreset: 'ptc' })
93
+ + expect(resolve).toHaveBeenNthCalledWith(1, 'code')
94
+ + expect(resolve).toHaveBeenNthCalledWith(2, 'ptc')
95
+ + })
96
+ +
97
+ + it('prefers an explicitly available code preset over the ptc fallback', async () => {
98
+ + const { ctx, agents } = await harness()
99
+ + const resolve = vi.fn((id?: string) => Promise.resolve({ id: id ?? 'ptc' }))
100
+ + ctx.provide('agentPresets', { resolve, mount: () => Promise.resolve() } as never)
101
+ +
102
+ + await expect(agents.composeAgent('code')).resolves.toMatchObject({ agentPreset: 'code' })
103
+ + expect(resolve).toHaveBeenCalledExactlyOnceWith('code')
104
+ + })
105
+ })
106
+ diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx
107
+ index 26fdcfcbf6..25dd5a95bc 100644
108
+ --- a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx
109
+ +++ b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx
110
+ @@ -19,6 +19,8 @@ import type { AgentPresetSettingsState } from './settings-store.ts'
111
+ import { presetDisplayText } from './locales.ts'
112
+ import css from './AgentPresetLabel.module.css'
113
+
114
+ +const LEGACY_PTC_PRESET = 'code'
115
+ +
116
+ /** Registration-side business face for the header label. */
117
+ export interface AgentPresetLabelInjected {
118
+ hooks: {
119
+ @@ -59,10 +61,11 @@ export function AgentPresetLabel({
120
+
121
+ const option = options.find(entry => entry.id === preset)
122
+ const text = option === undefined ? undefined : presetDisplayText(option, t)
123
+ + const name = text?.name ?? (preset === LEGACY_PTC_PRESET ? t('presetPtcName') : preset)
124
+ return (
125
+ <span className={css.label} title={text?.description ?? t('headerHint')}>
126
+ <IconAgentPresetOutlineRegular size={14} className={css.icon} />
127
+ - {text?.name ?? preset}
128
+ + {name}
129
+ </span>
130
+ )
131
+ }
132
+ diff --git a/packages/client/ui-agent-preset/tests/components.client.spec.tsx b/packages/client/ui-agent-preset/tests/components.client.spec.tsx
133
+ index 79cc930e01..1e9c5f25d2 100644
134
+ --- a/packages/client/ui-agent-preset/tests/components.client.spec.tsx
135
+ +++ b/packages/client/ui-agent-preset/tests/components.client.spec.tsx
136
+ @@ -400,6 +400,12 @@ describe('the session-header label', () => {
137
+ expect(screen.getByTitle(en.headerHint).textContent).toBe('mine')
138
+ })
139
+
140
+ + it('labels a persisted legacy code preset as PTC mode', () => {
141
+ + renderLabel({ blank: false, projectionValues: { agentPreset: 'code' } })
142
+ +
143
+ + expect(screen.getByTitle(en.headerHint).textContent).toBe(en.presetPtcName)
144
+ + })
145
+ +
146
+ it('shows the id until the roster resolves it', () => {
147
+ renderLabel({
148
+ blank: false,