1
/**2
* Cross-session snapshot preparation. Hosts adapt mentions into structured3
* references; this service owns exact reads, projection, budgets, and durable context.4
*5
* @module @deepseek-ai/dsh-session-reference6
*/8
import { Context } from '@deepseek-ai/cordis'9
import z from '@deepseek-ai/schemastery'10
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'11
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'12
import { createUserMessage, freezeMessage, LlmError } from '@deepseek-ai/dsh-llm'13
import type { ContentBlock, LlmResolvedModelInfo, UserMessage } from '@deepseek-ai/dsh-llm'14
import type { SessionId } from '@deepseek-ai/dsh-session'15
// Type-only: the `title` projection key plus the live registry and durable16
// cache Context merges — the two projection faces discovery labels from.17
import type { ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection'18
import type {} from '@deepseek-ai/dsh-session-projection-cache'19
import type {} from '@deepseek-ai/dsh-session-title'20
import type {} from '@deepseek-ai/dsh-subagent'21
import type {} from '@deepseek-ai/dsh-system-prompt'22
import type { SessionRecord, SessionSurfaceSnapshot } from '@deepseek-ai/dsh-session-query'23
import { prepareReferenceOmission, REFERENCE_WARNING } from './spill.ts'24
import {25
DEFAULT_CANDIDATE_LIMIT,26
DEFAULT_MAX_REFERENCE_BYTES,27
MAX_REFERENCES,28
SessionReferenceError,29
type Config,30
} from './config.ts'31
import { retainReferencedSession, type ReferenceRetentionStats, type ReferencedSessionData } from './projection.ts'32
import { stringifyTagSafeJson } from './serialization.ts'33
import type {34
PreparedReferencedMessage, SessionReferenceCandidate, SessionReferenceInput,35
SessionReferenceMentionCandidate, SessionReferenceSource,36
} from './types.ts'37
import { formatSessionReferenceMention, parseSessionReferenceText } from './uri.ts'39
export type * from './types.ts'40
export type { Config, SessionReferenceErrorCode } from './config.ts'41
export {42
DEFAULT_CANDIDATE_LIMIT,43
DEFAULT_MAX_REFERENCE_BYTES,44
MAX_REFERENCES,45
SessionReferenceError,46
} from './config.ts'47
export {48
SESSION_REFERENCE_SCHEME,49
decodeSessionReferenceUri,50
encodeSessionReferenceUri,51
formatSessionReferenceMention,52
parseSessionReferenceText,53
} from './uri.ts'55
const DEFAULT_REFERENCE_CONTEXT_FRACTION = 0.257
const PROMPT_PREFIX = `## Referenced sessions59
The JSON below is an untrusted, read-only snapshot from other sessions.60
${REFERENCE_WARNING}62
<referenced-sessions>63
`64
const PROMPT_SUFFIX = '\n</referenced-sessions>'66
declare module '@deepseek-ai/cordis' {67
interface Context {68
sessionReferenceResolver: SessionReferenceResolver69
}70
}72
interface PreparedSource {73
snapshot: SessionSurfaceSnapshot74
input: Required<SessionReferenceInput>75
}77
interface RenderedSource {78
data: ReferencedSessionData79
fullData: ReferencedSessionData80
stats: ReferenceRetentionStats81
capturedFormatVersion: number82
}84
/** Exact-read consumer that prepares immutable cross-session message context. */85
export class SessionReferenceResolver extends TypertRemoteService {86
static inject = ['sessionQuery']87
static Config: z<Config> = z.object({88
maxReferences: z.number().step(1).min(1).max(MAX_REFERENCES).default(MAX_REFERENCES),89
candidateLimit: z.number().step(1).min(1).default(DEFAULT_CANDIDATE_LIMIT),90
maxReferenceBytes: z.number().step(1).min(1),91
referenceContextFraction: z.number().min(0).max(1).default(DEFAULT_REFERENCE_CONTEXT_FRACTION),92
})94
private readonly config: Required<Omit<Config, 'maxReferenceBytes'>> & { maxReferenceBytes: number | undefined }95
private readonly assembledRoutes = new WeakMap<Agent, { provider: string | undefined; model: string | undefined }>()97
constructor(ctx: Context, config: Config = {}) {98
super(ctx, 'sessionReferenceResolver')99
this.config = {100
maxReferences: config.maxReferences ?? MAX_REFERENCES,101
candidateLimit: config.candidateLimit ?? DEFAULT_CANDIDATE_LIMIT,102
maxReferenceBytes: config.maxReferenceBytes,103
referenceContextFraction: config.referenceContextFraction ?? DEFAULT_REFERENCE_CONTEXT_FRACTION,104
}105
for (const name of ['maxReferences', 'candidateLimit', 'maxReferenceBytes'] as const) {106
const value = this.config[name]107
if (value !== undefined && (!Number.isSafeInteger(value) || value <= 0)) {108
throw new SessionReferenceError(109
`session-reference: ${name} must be a positive safe integer`,110
'SESSION_REFERENCE_INVALID_CONFIG',111
)112
}113
}114
if (this.config.maxReferences > MAX_REFERENCES) {115
throw new SessionReferenceError(116
`session-reference: maxReferences must not exceed ${MAX_REFERENCES}`,117
'SESSION_REFERENCE_INVALID_CONFIG',118
)119
}120
if (!(this.config.referenceContextFraction >= 0 && this.config.referenceContextFraction <= 1)) {121
throw new SessionReferenceError(122
'session-reference: referenceContextFraction must be between zero and one',123
'SESSION_REFERENCE_INVALID_CONFIG',124
)125
}126
// Prepend observes model-selection overrides after downstream assembly completes.127
ctx.on('system-prompt/assemble', async (_assembly, context, next) => {128
const assembly = await next()129
if (context.agent !== undefined) {130
const { provider, model } = assembly.variables131
this.assembledRoutes.set(context.agent, { provider, model })132
}133
return assembly134
}, { prepend: true })135
ctx.on('agent/pre-step', async ({ agent, signal }, next): Promise<PreStepDecision> => {136
const decision = await next()137
if (decision.kind === 'reject') return decision138
return {139
...decision,140
messages: await this.prepareDirectMessages(agent, decision.messages, signal),141
}142
}, { prepend: true })143
}145
/**146
* Replace canonical mentions in direct user messages and place each prepared147
* snapshot immediately after the message that cited it.148
* @param agent - agent entering the model step.149
* @param messages - messages accepted by downstream pre-step listeners.150
* @param signal - active turn cancellation.151
* @returns direct messages followed by their session-reference context in citation order.152
*/153
private async prepareDirectMessages(154
agent: Agent,155
messages: readonly UserMessage[],156
signal: AbortSignal,157
): Promise<UserMessage[]> {158
const prepared = await Promise.all(messages.map(async (message): Promise<UserMessage[]> => {159
if (message.source.kind !== 'user') return [message]160
const references: SessionReferenceInput[] = []161
const content = message.content.map((block): ContentBlock => {162
if (block.type !== 'text') return block163
const parsed = parseSessionReferenceText(block.text)164
references.push(...parsed.references)165
return { type: 'text', text: parsed.text }166
})167
if (references.length === 0) return [message]168
const resolved = await this.prepare(agent, content, references, signal)169
const direct = freezeMessage({ ...message, content: resolved.content })170
/* v8 ignore if -- a parsed canonical mention always leaves one normalized reference */171
if (resolved.additionalContext === undefined) {172
throw new Error('session-reference preparation omitted context for a canonical mention')173
}174
return [direct, resolved.additionalContext]175
}))176
return prepared.flat()177
}179
/**180
* List reference candidates, ranked by working-directory affinity.181
*182
* Discovery runs at keystroke rate, so titles and subagent labels only ever183
* come from projection reads; sessions without either fall back to their id.184
* @param agent - target agent; self is excluded and its cwd drives ranking.185
* @param query - optional case-insensitive session-id/cwd/title/display-title substring.186
* @param limit - optional positive result cap.187
* @param signal - optional cancellation boundary for host autocomplete teardown.188
* @returns candidates with canonical mention labels and presentation titles.189
*/190
async listCandidates(191
agent: Agent,192
query: string = '',193
limit: number = this.config.candidateLimit,194
signal?: AbortSignal,195
): Promise<SessionReferenceCandidate[]> {196
if (!Number.isSafeInteger(limit) || limit <= 0) {197
throw new SessionReferenceError('candidate limit must be a positive safe integer', 'SESSION_REFERENCE_INVALID_REFERENCE')198
}199
const needle = query.toLocaleLowerCase()200
const targetCwd = agent.session.header.cwd201
assertNotCancelled(signal)202
const records = (await settleWithCancellation(this.ctx.sessionQuery.listSessions(signal), signal))203
.filter(record => record.header.id !== agent.id)204
.map((record, index) => ({ record, index }))205
const labelled = records.map(({ record, index }) => ({ record, index, ...this.projectedLabels(record) }))206
return labelled.filter(({ record, label, displayTitle }) => {207
if (needle === '') return true208
return record.header.id.toLocaleLowerCase().includes(needle)209
|| record.header.cwd?.toLocaleLowerCase().includes(needle) === true210
|| label.toLocaleLowerCase().includes(needle)211
|| displayTitle.toLocaleLowerCase().includes(needle)212
}).sort((a, b) => candidateRank(a.record.header.cwd, targetCwd) - candidateRank(b.record.header.cwd, targetCwd)213
|| a.index - b.index)214
.slice(0, limit)215
.map(({ record, label, displayTitle }) => ({216
sessionId: record.header.id,217
label,218
displayTitle,219
...record.header.cwd === undefined ? {} : { cwd: record.header.cwd },220
sameWorkspace: record.header.cwd !== undefined && record.header.cwd === targetCwd,221
createdAt: record.header.createdAt,222
}))223
}225
/**226
* The mention label and display title a Session's projections can answer without reading its log.227
*228
* Attachment is decided by the store at read time, not by the listing:229
* a session that attached in between would otherwise be answered from a230
* checkpoint its live log has already moved past.231
*232
* An attached session answers from its live registry cut, which advances233
* with every committed event, so a rename or a just-generated title is234
* visible immediately; its events are already in memory, so the lazy fold235
* costs no I/O. A cold session answers from the durable checkpoint the236
* projection cache wrote when it went cold.237
*238
* Nothing else is attempted. Folding a title from a log costs the whole239
* log, and this call sits under every keystroke of `@` completion. A240
* session that no projection can answer for — one persisted before the241
* cache was composed — is labeled by its id and cannot be found by its242
* title until it is opened once, which checkpoints it.243
* @param record - the listed session, live or cold.244
* @returns the title-backed mention label and the subagent-label-first display title.245
*/246
private projectedLabels(record: SessionRecord): { label: string; displayTitle: string } {247
const attached = this.ctx.get('sessions')?.get(record.header.id)248
const projections = this.ctx.get('sessionProjections')249
const snapshot = attached !== undefined && projections !== undefined250
? projections.snapshot(attached, ['title', 'subagent'])251
: this.ctx.get('sessionProjectionCache')?.cachedSnapshot(record.header, ['title', 'subagent'])252
const label = titleOf(snapshot) ?? record.header.id253
const subagent = snapshot?.values.subagent254
return {255
label,256
displayTitle: subagent === undefined || subagent === null257
? label258
: subagent.label ?? label,259
}260
}262
/**263
* Remote face of {@link listCandidates}: the configured candidate limit264
* applies, and every candidate carries the canonical mention a host inserts265
* into the prompt draft.266
* @param agent - target agent; self is excluded and its cwd drives ranking.267
* @param query - optional case-insensitive session-id/cwd/title substring.268
* @param signal - caller cancellation.269
* @returns mention-carrying candidates in rank order.270
*/271
@Remote('candidates')272
async remoteExportCandidates(273
agent: Agent,274
query: string,275
signal: AbortSignal,276
): Promise<SessionReferenceMentionCandidate[]> {277
const candidates = await this.listCandidates(agent, query, this.config.candidateLimit, signal)278
return candidates.map(candidate => ({279
...candidate,280
mention: formatSessionReferenceMention({281
sessionId: candidate.sessionId,282
label: candidate.displayTitle ?? candidate.label,283
}),284
}))285
}287
/**288
* Snapshot all references for one accepted direct message and return one aggregated durable context.289
* Automatic budgets use the last assembled route, or agent options before any assembly.290
* Missing model capacity or adapter uses 64 KiB; other metadata lookup failures and cancellation reject preparation.291
* Truncated previews include omission facts and a full-snapshot spill locator, or an explicit unavailable notice.292
* Cancellation prevents context publication, including when storage completes after cancellation.293
* @param agent - target agent; references to it are rejected.294
* @param content - already host-normalized readable message content.295
* @param references - structured source sessions in mention order.296
* @param signal - optional cancellation boundary for the active turn.297
* @returns detached content and optional referenced-session context.298
*/299
async prepare(300
agent: Agent,301
content: ContentBlock[],302
references: SessionReferenceInput[],303
signal?: AbortSignal,304
): Promise<PreparedReferencedMessage> {305
const acceptedContent = structuredClone(content)306
const inputs = normalizeReferences(agent.id, references, this.config.maxReferences)307
if (inputs.length === 0) return { content: acceptedContent }308
assertNotCancelled(signal)309
const maxReferenceBytes = await this.referenceBudget(agent, signal)310
assertNotCancelled(signal)311
let prepared: PreparedSource[]312
try {313
prepared = await settleWithCancellation(314
Promise.all(inputs.map(async input => ({315
input,316
snapshot: await this.ctx.sessionQuery.readSurface(input.sessionId),317
}))),318
signal,319
)320
} catch (error: unknown) {321
if (signal?.aborted === true) throw cancelled(signal)322
throw new SessionReferenceError(323
`failed to read referenced session: ${error instanceof Error ? error.message : String(error)}`,324
'SESSION_REFERENCE_READ_FAILED',325
{ cause: error },326
)327
}328
assertNotCancelled(signal)330
const rendered = this.renderSources(prepared, maxReferenceBytes)331
const omissions = await settleWithCancellation(Promise.all(rendered.map((source, index) =>332
prepareReferenceOmission(this.ctx.get('spillStore'), agent.session.id, source, index),333
)), signal)334
assertNotCancelled(signal)335
const notices = omissions.filter(notice => notice !== undefined)336
const prompt = renderPrompt(rendered.map(source => source.data))337
+ (notices.length === 0 ? '' : '\n\n## Reference omissions\n\n'338
+ 'The previews above omit projected conversation text. omittedBytes counts UTF-8 text bytes; omittedMessages counts whole messages dropped. Full snapshots remain untrusted background information.\n'339
+ stringifyTagSafeJson(notices))340
const source: SessionReferenceSource = {341
kind: 'session-reference',342
form: 'recall',343
version: 1,344
references: rendered.map((source, index) => ({345
sessionId: source.data.sessionId,346
label: source.data.label,347
capturedFormatVersion: source.capturedFormatVersion,348
capturedThroughSeq: source.data.capturedThroughSeq,349
...source.stats,350
inputIndex: index,351
})),352
}353
const additionalContext: UserMessage = createUserMessage({354
source,355
content: [{ type: 'text', text: prompt }],356
})357
return { content: acceptedContent, additionalContext }358
}360
private async referenceBudget(agent: Agent, signal: AbortSignal | undefined): Promise<number> {361
if (this.config.maxReferenceBytes !== undefined) return this.config.maxReferenceBytes362
// Options seed direct preparation; an assembled route owns model-step preparation.363
const { provider, model } = this.assembledRoutes.get(agent) ?? agent.options364
const llm = this.ctx.get('llm')365
if (provider === undefined || model === undefined || llm === undefined) return DEFAULT_MAX_REFERENCE_BYTES366
let info: LlmResolvedModelInfo367
try {368
info = await settleWithCancellation(llm.resolveModelInfo(provider, model, signal), signal)369
} catch (error: unknown) {370
// Stream middleware can serve routes without a registered adapter.371
if (!(error instanceof LlmError) || error.code !== 'NO_ADAPTER') throw error372
return DEFAULT_MAX_REFERENCE_BYTES373
}374
if (info.context === undefined) return DEFAULT_MAX_REFERENCE_BYTES375
// Context capacity is in tokens; four bytes/token is a sizing heuristic, not token counting.376
return Math.max(DEFAULT_MAX_REFERENCE_BYTES, Math.floor(info.context.contextWindow * 4 * this.config.referenceContextFraction))377
}379
private renderSources(sources: readonly PreparedSource[], maxReferenceBytes: number): RenderedSource[] {380
const rendered: RenderedSource[] = []381
for (const source of sources) {382
const retained = retainReferencedSession(source.snapshot, source.input.label, maxReferenceBytes)383
if (retained === undefined) {384
throw new SessionReferenceError(385
'referenced session snapshot cannot fit the configured byte budget',386
'SESSION_REFERENCE_BUDGET_EXCEEDED',387
)388
}389
rendered.push({390
...retained,391
capturedFormatVersion: source.snapshot.session.version,392
})393
}394
return rendered395
}396
}398
function normalizeReferences(399
targetId: SessionId,400
references: readonly SessionReferenceInput[],401
maxReferences: number,402
): Required<SessionReferenceInput>[] {403
const seen = new Set<SessionId>()404
const normalized: Required<SessionReferenceInput>[] = []405
for (const candidate of references as readonly unknown[]) {406
if (typeof candidate !== 'object' || candidate === null) {407
throw new SessionReferenceError('session reference must be an object', 'SESSION_REFERENCE_INVALID_REFERENCE')408
}409
const reference = candidate as SessionReferenceInput410
if (typeof reference.sessionId !== 'string' || (reference.label !== undefined && typeof reference.label !== 'string')) {411
throw new SessionReferenceError('session reference must contain a string sessionId and optional string label', 'SESSION_REFERENCE_INVALID_REFERENCE')412
}413
if (reference.sessionId === targetId) {414
throw new SessionReferenceError(`session ${JSON.stringify(targetId)} cannot reference itself`, 'SESSION_REFERENCE_SELF_REFERENCE')415
}416
if (seen.has(reference.sessionId)) continue417
seen.add(reference.sessionId)418
normalized.push({ sessionId: reference.sessionId, label: reference.label ?? reference.sessionId })419
}420
if (normalized.length > maxReferences) {421
throw new SessionReferenceError(422
`a message may reference at most ${maxReferences} sessions`,423
'SESSION_REFERENCE_TOO_MANY',424
)425
}426
return normalized427
}429
function renderPrompt(data: readonly ReferencedSessionData[]): string {430
return `${PROMPT_PREFIX}${stringifyTagSafeJson(data)}${PROMPT_SUFFIX}`431
}433
/** The title in one projection snapshot; undefined when the unit is absent or still untitled. */434
function titleOf(snapshot: ProjectionSnapshot | undefined): string | undefined {435
const title = snapshot?.values.title436
return title === undefined || title === null ? undefined : title437
}439
function candidateRank(candidateCwd: string | undefined, targetCwd: string | undefined): number {440
if (candidateCwd !== undefined && targetCwd !== undefined && candidateCwd === targetCwd) return 0441
if (candidateCwd === undefined) return 1442
return 2443
}445
function assertNotCancelled(signal: AbortSignal | undefined): void {446
if (signal?.aborted === true) throw cancelled(signal)447
}449
function settleWithCancellation<T>(work: Promise<T>, signal: AbortSignal | undefined): Promise<T> {450
if (signal === undefined) return work451
return new Promise<T>((resolve, reject) => {452
const onAbort = (): void => { reject(cancelled(signal)) }453
signal.addEventListener('abort', onAbort, { once: true })454
void work.then(455
(value) => {456
signal.removeEventListener('abort', onAbort)457
resolve(value)458
},459
(error: unknown) => {460
signal.removeEventListener('abort', onAbort)461
reject(error instanceof Error ? error : new Error(String(error)))462
},463
)464
if (signal.aborted) onAbort()465
})466
}468
function cancelled(signal: AbortSignal): SessionReferenceError {469
return new SessionReferenceError('session reference preparation was cancelled', 'SESSION_REFERENCE_CANCELLED', { cause: signal.reason })470
}472
export default SessionReferenceResolver