@deepseek-ai/dsh-session 0.0.1-rc.1

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.
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Crash-recovery repair for an interrupted session log. It preserves a fully
3
+ * written final turn and supplies the missing tool, step, and turn boundaries
4
+ * needed to resume with a provider-valid transcript, plus the activity-time
5
+ * read that must skip the end-seed boundary — which this module does
6
+ * not write (`Session`'s constructor does) but whose synthetic closers can
7
+ * inherit that boundary's timestamp, the one real coupling between the two.
8
+ * @module @deepseek-ai/dsh-session/repair
9
+ */
10
+ import { MessageId, freezeMessage } from '@deepseek-ai/dsh-llm';
11
+ /**
12
+ * The `time` of the log's last event representing actual work, skipping the
13
+ * `session/end-seed` boundary — picking a session up is not activity, so
14
+ * activity ordering must exclude it.
15
+ *
16
+ * Excluded by type, so a pickup time still leaks when a boundary is the last
17
+ * event of an open turn: {@link interruptedTurnClosers} copies it onto the
18
+ * synthetic `turn/end`, which this counts as work. Reachable only by seeding an
19
+ * unbalanced log directly — `load()` balances first.
20
+ * @param events - the log to scan, in seq order.
21
+ * @returns the latest non-boundary event's `time`, or undefined when there is none.
22
+ */
23
+ export function lastActivityTime(events) {
24
+ return events.findLast(event => event.type !== 'session/end-seed')?.time;
25
+ }
26
+ /** Recovery code for an assistant tool request that never reached a recorded call start. */
27
+ export const TOOL_NOT_STARTED = 'TOOL_NOT_STARTED';
28
+ /** Recovery code for a recorded tool call whose completed outcome was not durably recorded. */
29
+ export const TOOL_OUTCOME_UNKNOWN = 'TOOL_OUTCOME_UNKNOWN';
30
+ /**
31
+ * Return deterministic synthetic events that close an open tail turn. Unmatched
32
+ * calls receive error results first, followed by an open `step/end` and an
33
+ * interrupted `turn/end`; sequences continue the log and timestamps reuse the
34
+ * last real event. A balanced or empty log returns no events.
35
+ *
36
+ * @param events - the loaded durable log to scan (a valid committed prefix, possibly with a crash tail).
37
+ * @returns the synthetic closer events to append after `events`, in order; empty when the log is already balanced.
38
+ */
39
+ export function interruptedTurnClosers(events) {
40
+ let openTurn = null;
41
+ let openStep = null;
42
+ // Reset at each turn boundary so earlier calls cannot leak into tail repair.
43
+ // Assistant blocks register calls; later `tool/call` events add their seqs to `sourceEventSeqs`.
44
+ const pendingCalls = new Map();
45
+ for (const event of events) {
46
+ switch (event.type) {
47
+ case 'turn/start':
48
+ openTurn = event.data.turn;
49
+ openStep = null;
50
+ pendingCalls.clear();
51
+ break;
52
+ case 'turn/end':
53
+ openTurn = null;
54
+ openStep = null;
55
+ pendingCalls.clear();
56
+ break;
57
+ case 'step/start':
58
+ openStep = event.data.step;
59
+ break;
60
+ case 'step/end':
61
+ pendingCalls.clear();
62
+ openStep = null;
63
+ break;
64
+ case 'assistant/message':
65
+ // The assistant message carries the tool-call blocks; each is pending
66
+ // until a tool/result event with the same callId is logged.
67
+ for (const block of event.data.message.content) {
68
+ if (block.type === 'tool-call')
69
+ pendingCalls.set(block.id, { step: event.data.step });
70
+ }
71
+ break;
72
+ case 'tool/call':
73
+ // Cite the `tool/call` seq from the synthetic result.
74
+ {
75
+ const entry = pendingCalls.get(event.data.callId);
76
+ if (entry) {
77
+ entry.callSeq = event.seq;
78
+ }
79
+ }
80
+ break;
81
+ case 'tool/result':
82
+ pendingCalls.delete(event.data.message.source.callId);
83
+ break;
84
+ // Other event types do not move the turn/step boundary cursor.
85
+ default:
86
+ break;
87
+ }
88
+ }
89
+ // Balanced log (no crash mid-turn): nothing to close. An open turn implies
90
+ // `events` is non-empty (its turn/start was logged), so `last` exists.
91
+ const last = events.at(-1);
92
+ if (openTurn === null || last === undefined)
93
+ return [];
94
+ // The last real event supplies the seq base and the timestamp for the
95
+ // synthetic closers (reusing the last timestamp keeps them deterministic and
96
+ // never invents a "future" time).
97
+ let seq = last.seq + 1;
98
+ const time = last.time;
99
+ const closers = [];
100
+ // Close calls before their step: providers reject dangling assistant calls,
101
+ // and Map insertion order preserves their transcript order.
102
+ for (const [callId, { step, callSeq }] of pendingCalls) {
103
+ const started = callSeq !== undefined;
104
+ const message = freezeMessage({
105
+ id: MessageId(`interrupted-tool-result-${callId}-${seq}`),
106
+ role: 'user',
107
+ source: { kind: 'tool', callId },
108
+ content: [{
109
+ type: 'tool-result',
110
+ toolCallId: callId,
111
+ isError: true,
112
+ content: [{
113
+ type: 'text',
114
+ text: started
115
+ ? 'The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.'
116
+ : 'The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.',
117
+ }],
118
+ }],
119
+ });
120
+ closers.push({
121
+ type: 'tool/result',
122
+ seq: seq++,
123
+ time,
124
+ data: {
125
+ turn: openTurn,
126
+ step,
127
+ message,
128
+ error: started
129
+ ? { name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN }
130
+ : { name: 'ToolNotStartedError', code: TOOL_NOT_STARTED },
131
+ },
132
+ surfaceOp: 'append',
133
+ ...started ? { sourceEventSeqs: [callSeq] } : {},
134
+ });
135
+ }
136
+ // Close an open step next — a turn/end while a step is open is an invariant
137
+ // violation, so the step's boundary must be synthesized before the turn's.
138
+ if (openStep !== null) {
139
+ closers.push({ type: 'step/end', seq: seq++, time, data: { turn: openTurn, step: openStep } });
140
+ }
141
+ closers.push({ type: 'turn/end', seq: seq++, time, data: { turn: openTurn, reason: { kind: 'interrupted' } } });
142
+ return closers;
143
+ }
144
+ //# sourceMappingURL=repair.js.map
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Request-header reconstruction utilities over full `request/header` session
3
+ * events. Anyone holding a session log reconstructs the {@link EpochHeader}
4
+ * any request was built under by taking the latest canonical snapshot; the
5
+ * loop uses the same equality helper to avoid logging unchanged headers.
6
+ *
7
+ * @module dsh-session/request-header
8
+ */
9
+ import type { EpochHeader, SessionEvent } from './types.ts';
10
+ /**
11
+ * Normalize a header to canonical form: an empty system prompt and empty tool
12
+ * list become absent fields, matching how requests are built. Logging, folding,
13
+ * and comparison use this one representation.
14
+ * @param header - the header to normalize (not mutated).
15
+ * @returns the canonical header.
16
+ */
17
+ export declare function canonicalHeader(header: EpochHeader): EpochHeader;
18
+ /**
19
+ * Field-wise equality over canonical headers. Tool schemas compare in order.
20
+ * @param a - one canonical header.
21
+ * @param b - the other.
22
+ * @returns whether config, system, and tools all match.
23
+ */
24
+ export declare function headerEquals(a: EpochHeader, b: EpochHeader): boolean;
25
+ /**
26
+ * Fold the header events of a log (or any prefix) into the
27
+ * {@link EpochHeader} in force after the last snapshot. Non-header events are
28
+ * skipped. This is the pure offline reconstruction path; the live session
29
+ * tracks the same fold incrementally.
30
+ * @param events - session events in log order.
31
+ * @param from - a previously folded state to continue from.
32
+ * @returns the latest canonical header, or undefined when none exists yet.
33
+ */
34
+ export declare function foldRequestHeader(events: readonly SessionEvent[], from?: EpochHeader): EpochHeader | undefined;
35
+ //# sourceMappingURL=request-header.d.ts.map
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Request-header reconstruction utilities over full `request/header` session
3
+ * events. Anyone holding a session log reconstructs the {@link EpochHeader}
4
+ * any request was built under by taking the latest canonical snapshot; the
5
+ * loop uses the same equality helper to avoid logging unchanged headers.
6
+ *
7
+ * @module dsh-session/request-header
8
+ */
9
+ import { callConfigEquals } from '@deepseek-ai/dsh-llm';
10
+ /**
11
+ * Normalize a header to canonical form: an empty system prompt and empty tool
12
+ * list become absent fields, matching how requests are built. Logging, folding,
13
+ * and comparison use this one representation.
14
+ * @param header - the header to normalize (not mutated).
15
+ * @returns the canonical header.
16
+ */
17
+ export function canonicalHeader(header) {
18
+ const adapterDefaults = header.adapterDefaults;
19
+ return {
20
+ config: header.config,
21
+ ...adapterDefaults?.reasoningEffort === true || adapterDefaults?.maxTokens === true
22
+ ? { adapterDefaults }
23
+ : {},
24
+ ...header.system !== undefined && header.system.length > 0 ? { system: header.system } : {},
25
+ ...header.tools !== undefined && header.tools.length > 0 ? { tools: header.tools } : {},
26
+ };
27
+ }
28
+ /** Canonical JSON equality for tool schemas assembled through the same path. */
29
+ function sameSchema(a, b) {
30
+ return JSON.stringify(a) === JSON.stringify(b);
31
+ }
32
+ /**
33
+ * Field-wise equality over canonical headers. Tool schemas compare in order.
34
+ * @param a - one canonical header.
35
+ * @param b - the other.
36
+ * @returns whether config, system, and tools all match.
37
+ */
38
+ export function headerEquals(a, b) {
39
+ if (!callConfigEquals(a.config, b.config)
40
+ || a.adapterDefaults?.reasoningEffort !== b.adapterDefaults?.reasoningEffort
41
+ || a.adapterDefaults?.maxTokens !== b.adapterDefaults?.maxTokens
42
+ || a.system !== b.system)
43
+ return false;
44
+ const at = a.tools ?? [];
45
+ const bt = b.tools ?? [];
46
+ return at.length === bt.length && at.every((tool, i) => sameSchema(tool, bt[i]));
47
+ }
48
+ /**
49
+ * Fold the header events of a log (or any prefix) into the
50
+ * {@link EpochHeader} in force after the last snapshot. Non-header events are
51
+ * skipped. This is the pure offline reconstruction path; the live session
52
+ * tracks the same fold incrementally.
53
+ * @param events - session events in log order.
54
+ * @param from - a previously folded state to continue from.
55
+ * @returns the latest canonical header, or undefined when none exists yet.
56
+ */
57
+ export function foldRequestHeader(events, from) {
58
+ let state = from;
59
+ for (const event of events) {
60
+ if (event.type === 'request/header')
61
+ state = canonicalHeader(event.data.header);
62
+ }
63
+ return state;
64
+ }
65
+ //# sourceMappingURL=request-header.js.map
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Surface layer on top of the session event log: an ordered view of events
3
+ * that produce LLM messages. The append-only log remains the source of truth.
4
+ *
5
+ * Browser-safe: web clients consume this subpath export, so it must stay free
6
+ * of `node:` imports (they break the vite bundle).
7
+ *
8
+ * @module @deepseek-ai/dsh-session/surface
9
+ */
10
+ import type { Message } from '@deepseek-ai/dsh-llm';
11
+ import type { SessionEvent, SurfaceEvent, SurfaceOp } from './types.ts';
12
+ /**
13
+ * Whether an event type can join the model-visible surface.
14
+ * @param type - event type to test.
15
+ * @returns true for one of the three message-producing event types.
16
+ */
17
+ export declare function isSurfaceEligibleType(type: string): boolean;
18
+ /**
19
+ * Narrow an event to a surface-eligible event carrying its required marker.
20
+ * @param event - event to test.
21
+ * @returns true when both the type and marker identify a surface event.
22
+ */
23
+ export declare function isSurfaceEvent(event: SessionEvent): event is SurfaceEvent;
24
+ /**
25
+ * Narrow an event to an append-origin surface event: one that entered the
26
+ * surface at its own log position and was never itself a replacement copy.
27
+ *
28
+ * The model-visible surface deliberately shadows replaced ranges, so it is the
29
+ * wrong source for a human transcript — a landed replacement would erase
30
+ * conversation the user already saw. Append-origin events are that transcript's
31
+ * durable source material; replacement copies stay model-only.
32
+ * @param event - event to test.
33
+ * @returns true when the event appended to the surface tail.
34
+ */
35
+ export declare function isAppendSurfaceEvent(event: SessionEvent): event is SurfaceEvent & {
36
+ surfaceOp: 'append';
37
+ };
38
+ /**
39
+ * Narrow an event to a surface replacement: a node that shadowed an existing
40
+ * surface range instead of appending to the tail. The counterpart of
41
+ * {@link isAppendSurfaceEvent} over the two {@link SurfaceOp} variants.
42
+ * @param event - event to test.
43
+ * @returns true when the event replaced a surface range.
44
+ */
45
+ export declare function isReplacementSurfaceEvent(event: SessionEvent): event is SurfaceEvent & {
46
+ surfaceOp: Extract<SurfaceOp, {
47
+ op: 'replace';
48
+ }>;
49
+ };
50
+ /**
51
+ * Project a single event into the LLM message it derives to, or null when it
52
+ * produces none — a non-surface event (chunk, boundary, log-only record) or an
53
+ * empty-content assistant/message (which exists only to host usage). This is
54
+ * THE per-node projection rule: `Session.deriveMessages` folds it over the
55
+ * live surface, external reconstructors and pure projections fold the same
56
+ * function over a log prefix's surface to rebuild the exact messages any
57
+ * request was built from. The returned message is the already frozen message
58
+ * nested in the event wrapper and shared by delivery, durable history, and
59
+ * model requests.
60
+ * @param event - the event to project.
61
+ * @returns the derived message, or null when the event produces none.
62
+ */
63
+ export declare function deriveEventMessage(event: SessionEvent): Message | null;
64
+ /** One replacement operation observed while folding a session surface. */
65
+ export interface SurfaceFoldReplacement {
66
+ /** Seq of the event that replaced the prior surface range. */
67
+ seq: number;
68
+ /** Declared inclusive start seq of the replaced surface range. */
69
+ start: number;
70
+ /** Declared inclusive end seq of the replaced surface range. */
71
+ end: number;
72
+ /** Actual surface entries removed by the operation, in surface order. */
73
+ shadowedSeqs: number[];
74
+ }
75
+ /** Complete result of replaying the surface operations in a session log. */
76
+ export interface SurfaceFoldResult {
77
+ /** Current surface event sequences in model-visible order. */
78
+ nodes: number[];
79
+ /** Replacement operations in event order. */
80
+ replacements: SurfaceFoldReplacement[];
81
+ }
82
+ /** Readonly live projection of the message-producing session events. */
83
+ export interface SessionSurface {
84
+ /** Current surface event sequences in model-visible order. */
85
+ readonly nodes: readonly number[];
86
+ /** Monotonic count of committed positional replacements. */
87
+ readonly replaceGeneration: number;
88
+ }
89
+ /**
90
+ * Replay a complete session log through the canonical surface fold.
91
+ * @param events - session events in contiguous seq order.
92
+ * @returns detached current sequences and replacement history.
93
+ * @throws when an event violates surface metadata, source-event references, range, or tool-result rewrite rules.
94
+ */
95
+ export declare function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult;
96
+ /** Incremental ordered surface view and append-boundary validator. */
97
+ export declare class SurfaceManager implements SessionSurface {
98
+ private log;
99
+ private readonly baseSeq;
100
+ /** Shared transition state; replacement history is not retained. */
101
+ private _state;
102
+ /** Last processed absolute seq. */
103
+ private _lastProcessedSeq;
104
+ /** Candidate already validated by `validateNext`, pending exact log admission. */
105
+ private _pendingPlan;
106
+ /**
107
+ * @param log - Contiguous complete log or loaded event window.
108
+ * @param baseSeq - Absolute sequence of the window's first event.
109
+ */
110
+ constructor(log: readonly SessionEvent[], baseSeq?: number);
111
+ /**
112
+ * Validate the next candidate without mutating the committed surface.
113
+ * @param event - candidate event that has not entered the log yet.
114
+ */
115
+ validateNext(event: SessionEvent): void;
116
+ /** Monotonic count of folded positional replacements. */
117
+ get replaceGeneration(): number;
118
+ /** Surface event sequences in model-visible order. */
119
+ get nodes(): readonly number[];
120
+ /** Fold events appended since the previous access. */
121
+ private _processDelta;
122
+ }
123
+ //# sourceMappingURL=surface.d.ts.map