@deepseek-ai/dsh-session 0.2.2-rc.1 → 0.2.2-rc.3

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 (41) hide show
  1. package/README.i18n.yaml +4 -4
  2. package/README.md +3 -1
  3. package/README.zh.md +3 -1
  4. package/lib/index.js +151 -79
  5. package/lib/types/fork.d.ts.map +1 -0
  6. package/lib/types/fork.js.map +1 -0
  7. package/lib/types/index.d.ts +17 -1
  8. package/lib/types/index.d.ts.map +1 -0
  9. package/lib/types/index.js +82 -63
  10. package/lib/types/index.js.map +1 -0
  11. package/lib/types/known-event-types.d.ts.map +1 -0
  12. package/lib/types/known-event-types.js +1 -0
  13. package/lib/types/known-event-types.js.map +1 -0
  14. package/lib/types/preparation.d.ts.map +1 -0
  15. package/lib/types/preparation.js.map +1 -0
  16. package/lib/types/repair.d.ts.map +1 -0
  17. package/lib/types/repair.js.map +1 -0
  18. package/lib/types/request-header.d.ts.map +1 -0
  19. package/lib/types/request-header.js.map +1 -0
  20. package/lib/types/seq-ranges.d.ts.map +1 -0
  21. package/lib/types/seq-ranges.js.map +1 -0
  22. package/lib/types/surface.d.ts +6 -0
  23. package/lib/types/surface.d.ts.map +1 -0
  24. package/lib/types/surface.js +57 -11
  25. package/lib/types/surface.js.map +1 -0
  26. package/lib/types/tool-history.d.ts.map +1 -0
  27. package/lib/types/tool-history.js.map +1 -0
  28. package/lib/types/types.d.ts +4 -0
  29. package/lib/types/types.d.ts.map +1 -0
  30. package/lib/types/types.js.map +1 -0
  31. package/package.json +12 -9
  32. package/src/fork.ts +30 -0
  33. package/src/index.ts +1555 -0
  34. package/src/known-event-types.ts +91 -0
  35. package/src/preparation.ts +49 -0
  36. package/src/repair.ts +211 -0
  37. package/src/request-header.ts +69 -0
  38. package/src/seq-ranges.ts +73 -0
  39. package/src/surface.ts +758 -0
  40. package/src/tool-history.ts +75 -0
  41. package/src/types.ts +574 -0
@@ -0,0 +1,91 @@
1
+ /**
2
+ * GENERATED by `scripts/gen-persistence-catalog.ts` — do not edit by hand; run
3
+ * `pnpm run gen-persistence-catalog` to regenerate (verified fresh by
4
+ * `pnpm run verify-persistence-catalog`, part of `doc-sync`).
5
+ * @module @deepseek-ai/dsh-session/known-event-types
6
+ */
7
+
8
+ /**
9
+ * Every `SessionEventMap` member declared in this repository — the event
10
+ * vocabulary this build understands. The persistence read path refuses to
11
+ * interpret a log containing a type outside this set unless the event
12
+ * carries the envelope's `ignorable` marker (see `SessionEvent.ignorable`
13
+ * in `./types.ts`): such a log was likely written by a newer harness, and
14
+ * silently skipping a required event would reconstruct a wrong session.
15
+ * Downstream (out-of-repo) plugin events are outside this list by
16
+ * construction. The persisted `SessionEvent.ignorable` marker is the
17
+ * compatibility mechanism; event-name registration was rejected because
18
+ * it does not classify omission safety and would make reads
19
+ * composition-dependent. The rationale is in
20
+ * `.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md`.
21
+ */
22
+ export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet<string> = new Set([
23
+ 'agent-preset/selected',
24
+ 'agent/inbox/spliced',
25
+ 'approval/asked',
26
+ 'approval/decided',
27
+ 'approval/policy',
28
+ 'assistant/attempt',
29
+ 'assistant/message',
30
+ 'command/done',
31
+ 'command/run',
32
+ 'compaction/end',
33
+ 'compaction/prune',
34
+ 'compaction/start',
35
+ 'compaction/summary',
36
+ 'deliverables/presented',
37
+ 'developer/message',
38
+ 'feedback/message-delete',
39
+ 'feedback/message-put',
40
+ 'feedback/record',
41
+ 'goal/change',
42
+ 'hook/invoked',
43
+ 'hook/result',
44
+ 'image/offload',
45
+ 'job/registered',
46
+ 'job/settled',
47
+ 'llm/retry',
48
+ 'llm/retry-started',
49
+ 'model/selection',
50
+ 'permission/preset',
51
+ 'plan/mode',
52
+ 'request/context',
53
+ 'request/header',
54
+ 'sandbox/mode',
55
+ 'schedule/change',
56
+ 'session-log-deepseek/delivery-accepted',
57
+ 'session/end-seed',
58
+ 'session/title',
59
+ 'session/title-llm-request',
60
+ 'step/end',
61
+ 'step/start',
62
+ 'subagent/catalog',
63
+ 'subagent/descriptor',
64
+ 'subagent/model-selection-policy',
65
+ 'system/message',
66
+ 'team/member',
67
+ 'team/message/delivered',
68
+ 'team/message/queued',
69
+ 'team/mode',
70
+ 'team/task',
71
+ 'todo/write',
72
+ 'tool-workflow/agent-end',
73
+ 'tool-workflow/agent-start',
74
+ 'tool-workflow/run-end',
75
+ 'tool-workflow/run-start',
76
+ 'tool/call',
77
+ 'tool/ptc-dispatch',
78
+ 'tool/ptc-dispatch-start',
79
+ 'tool/result',
80
+ 'turn/end',
81
+ 'turn/start',
82
+ 'user/message',
83
+ 'web/deepseek-search-llm-request',
84
+ 'working-directory/change',
85
+ 'workspace/changes',
86
+ ])
87
+
88
+ /** Event types whose model-visible effects require an explicit pure interpreter. */
89
+ export const MESSAGE_PROJECTION_EVENT_TYPES: ReadonlySet<string> = new Set([
90
+ 'image/offload',
91
+ ])
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Ownership of one unpublished Session before registry publication.
3
+ * @module @deepseek-ai/dsh-session/preparation
4
+ */
5
+
6
+ import type { Session } from './index.ts'
7
+
8
+ /** Options for a preparation whose provider retains unpublished state. */
9
+ export interface SessionPreparationOptions {
10
+ /** Release provider-owned state when the Session was not published. */
11
+ readonly release?: () => void
12
+ }
13
+
14
+ /**
15
+ * One exact unpublished Session and the provider state that keeps it usable.
16
+ * Disposal is synchronous and idempotent. Providers decide whether release
17
+ * returns the Session to a cache or discards it; publication may consume that
18
+ * state before disposal, making the callback a no-op.
19
+ */
20
+ export class SessionPreparation implements Disposable {
21
+ private released = false
22
+
23
+ /** The exact Session to use for setup and publication. */
24
+ readonly session: Session
25
+
26
+ private constructor(
27
+ session: Session,
28
+ private readonly options: SessionPreparationOptions,
29
+ ) {
30
+ this.session = session
31
+ }
32
+
33
+ /**
34
+ * Wrap an unpublished Session in one preparation lifetime.
35
+ * @param session - exact unpublished Session.
36
+ * @param options - optional provider release behavior.
37
+ * @returns a preparation disposed after publication or rollback.
38
+ */
39
+ static create(session: Session, options?: SessionPreparationOptions): SessionPreparation {
40
+ return new SessionPreparation(session, options ?? {})
41
+ }
42
+
43
+ /** Release provider state once when this preparation leaves its caller. */
44
+ [Symbol.dispose](): void {
45
+ if (this.released) return
46
+ this.released = true
47
+ this.options.release?.()
48
+ }
49
+ }
package/src/repair.ts ADDED
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Pending tool-result recovery shared by failed live steps, interrupted logs,
3
+ * and fork seeds. Tail repair preserves closed steps and supplies only missing
4
+ * tool results and lifecycle boundaries, with cause-specific retry guidance.
5
+ * @module @deepseek-ai/dsh-session/repair
6
+ */
7
+
8
+ import { brandString } from '@deepseek-ai/dsh-brand'
9
+ import type { MessageId, ToolCallId, ToolResultMessage } from '@deepseek-ai/dsh-llm'
10
+ import { deepFreeze } from '@deepseek-ai/dsh-util-values'
11
+ import { SessionSeq } from './types.ts'
12
+ import type { SessionEvent, SessionSeq as SessionSeqType } from './types.ts'
13
+
14
+ /** Recovery code for an assistant tool request that never reached a recorded call start. */
15
+ export const TOOL_NOT_STARTED = 'TOOL_NOT_STARTED'
16
+
17
+ /** Recovery code for a recorded tool call whose completed outcome was not durably recorded. */
18
+ export const TOOL_OUTCOME_UNKNOWN = 'TOOL_OUTCOME_UNKNOWN'
19
+
20
+ /**
21
+ * Why an open tail turn is closed with synthetic events: `interrupted` is
22
+ * crash recovery over a persisted log; `forked` is a fork seed cut inside the
23
+ * source's open turn. The cause selects the synthetic `turn/end` reason, the
24
+ * model-visible wording of synthetic error tool results, and the
25
+ * deterministic synthetic message-id prefix. The error codes
26
+ * ({@link TOOL_NOT_STARTED} / {@link TOOL_OUTCOME_UNKNOWN}) are shared: both
27
+ * causes state the same fact about the call's recorded lifecycle.
28
+ */
29
+ export type OpenTurnCloseCause = { readonly kind: 'interrupted' } | { readonly kind: 'forked' }
30
+
31
+ /** Model-visible wording of the synthetic error tool results, keyed by cause. */
32
+ const CLOSER_TEXT = {
33
+ interrupted: {
34
+ started: '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.',
35
+ notStarted: 'The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.',
36
+ },
37
+ forked: {
38
+ started: 'The history inherited by this branch records this tool call starting but does not include its result. The parent session may have completed it after the fork point. 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.',
39
+ notStarted: 'The history inherited by this branch has no record of this tool call starting. The parent session may have executed it after the fork point. 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.',
40
+ },
41
+ } as const
42
+
43
+ /**
44
+ * Return deterministic synthetic events that close an open tail turn. Unmatched
45
+ * calls receive error results first, followed by an open `step/end` and a
46
+ * cause-specific `turn/end`; sequences continue the log and timestamps reuse the
47
+ * last real event. A balanced or empty log returns no events.
48
+ *
49
+ * @param events - the loaded durable log to scan (a valid committed prefix, possibly with a crash tail).
50
+ * @param cause - the owning close operation: selects result wording and the turn-ending reason.
51
+ * @returns the synthetic closer events to append after `events`, in order; empty when the log is already balanced.
52
+ */
53
+ export function openTurnClosers(events: readonly SessionEvent[], cause: OpenTurnCloseCause): SessionEvent[] {
54
+ let openTurn: number | null = null
55
+ let openStep: number | null = null
56
+ const recovery = new ToolCallRecovery(cause)
57
+ for (const event of events) {
58
+ recovery.observe(event)
59
+ switch (event.type) {
60
+ case 'turn/start':
61
+ openTurn = event.data.turn
62
+ openStep = null
63
+ break
64
+ case 'turn/end':
65
+ openTurn = null
66
+ openStep = null
67
+ break
68
+ case 'step/start':
69
+ openStep = event.data.step
70
+ break
71
+ case 'step/end':
72
+ openStep = null
73
+ break
74
+ // Other event types do not move the turn/step boundary cursor.
75
+ default:
76
+ break
77
+ }
78
+ }
79
+
80
+ // Balanced log (no open tail turn): nothing to close. An open turn implies
81
+ // `events` is non-empty (its turn/start was logged), so `last` exists.
82
+ const last = events.at(-1)
83
+ if (openTurn === null || last === undefined) return []
84
+
85
+ // The last real event supplies the seq base and the timestamp for the
86
+ // synthetic closers (reusing the last timestamp keeps them deterministic and
87
+ // never invents a "future" time).
88
+ const closers: SessionEvent[] = recovery.results()
89
+ let seq = last.seq + closers.length + 1
90
+ const time = last.time
91
+
92
+ // Close an open step before its turn.
93
+ if (openStep !== null) {
94
+ closers.push({ type: 'step/end', seq: SessionSeq(seq++), time, data: { turn: openTurn, step: openStep } })
95
+ }
96
+ closers.push({ type: 'turn/end', seq: SessionSeq(seq++), time, data: { turn: openTurn, reason: { kind: cause.kind } } })
97
+ return closers
98
+ }
99
+
100
+ /**
101
+ * Track unanswered assistant tool requests from one Session's committed events.
102
+ * Observe from the start of the owned step or replay prefix, and recover before
103
+ * its step closes. This state retains pending identities, not event history.
104
+ */
105
+ export class ToolCallRecovery {
106
+ private readonly pendingCalls = new Map<ToolCallId, { turn: number; step: number; callSeq?: SessionSeqType }>()
107
+ private last: Pick<SessionEvent, 'seq' | 'time'> | undefined
108
+
109
+ /** @param cause - defaults to interrupted live/crash recovery; fork-seed construction supplies its own cause. */
110
+ constructor(private readonly cause: OpenTurnCloseCause = { kind: 'interrupted' }) {}
111
+
112
+ /**
113
+ * Consume the next committed event; closed steps and turn boundaries discard pending requests.
114
+ * @param event - the next event from the same Session, in sequence order.
115
+ */
116
+ observe(event: SessionEvent): void {
117
+ this.last = { seq: event.seq, time: event.time }
118
+ switch (event.type) {
119
+ case 'turn/start':
120
+ case 'turn/end':
121
+ case 'step/end':
122
+ this.pendingCalls.clear()
123
+ break
124
+ case 'assistant/message':
125
+ for (const block of event.data.message.content) {
126
+ if (block.type === 'tool-call') {
127
+ this.pendingCalls.set(block.id, { turn: event.data.turn, step: event.data.step })
128
+ }
129
+ }
130
+ break
131
+ case 'tool/call': {
132
+ const entry = this.pendingCalls.get(event.data.callId)
133
+ if (entry) entry.callSeq = event.seq
134
+ break
135
+ }
136
+ case 'tool/result': {
137
+ const callId = event.data.message.source.callId
138
+ const entry = this.pendingCalls.get(callId)
139
+ if (event.surfaceOp === 'append' && entry !== undefined
140
+ && entry.turn === event.data.turn && entry.step === event.data.step) {
141
+ this.pendingCalls.delete(callId)
142
+ }
143
+ break
144
+ }
145
+ // SessionEvent is merge-extensible; unrelated events retain pending requests.
146
+ default:
147
+ break
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Build conservative error results in assistant order without changing tracked state.
153
+ * Sequences follow the latest observed event and timestamps reuse its time.
154
+ * Callers commit the results and observe those commits before recovering again.
155
+ * @returns pending tool-result events, empty when no request remains unanswered.
156
+ */
157
+ results(): SessionEvent<'tool/result'>[] {
158
+ if (this.last === undefined) return []
159
+ let seq = this.last.seq + 1
160
+ const time = this.last.time
161
+ const results: SessionEvent<'tool/result'>[] = []
162
+
163
+ const text = CLOSER_TEXT[this.cause.kind]
164
+ // Close calls before their step: providers reject dangling assistant calls,
165
+ // and Map insertion order preserves their transcript order.
166
+ for (const [callId, { turn, step, callSeq }] of this.pendingCalls) {
167
+ const started = callSeq !== undefined
168
+ const message: ToolResultMessage = deepFreeze({
169
+ id: brandString<MessageId>(`${this.cause.kind}-tool-result-${callId}-${seq}`),
170
+ role: 'tool',
171
+ toolCallId: callId,
172
+ isError: true,
173
+ source: { kind: 'tool', callId },
174
+ content: [{
175
+ type: 'text',
176
+ text: started ? text.started : text.notStarted,
177
+ }],
178
+ })
179
+ results.push({
180
+ type: 'tool/result',
181
+ seq: SessionSeq(seq++),
182
+ time,
183
+ data: {
184
+ turn,
185
+ step,
186
+ message,
187
+ error: started
188
+ ? { name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN }
189
+ : { name: 'ToolNotStartedError', code: TOOL_NOT_STARTED },
190
+ },
191
+ surfaceOp: 'append',
192
+ ...started ? { sourceEventSeqs: [callSeq] } : {},
193
+ })
194
+ }
195
+
196
+ return results
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Crash-recovery entry point: synthetic closers that balance a persisted log
202
+ * whose tail turn was interrupted. Used by crash-recovery callers; fork
203
+ * seeds receive their `forked`-cause closers through `buildForkSeed` in
204
+ * `./fork.ts`.
205
+ *
206
+ * @param events - the persisted log to scan, possibly ending inside an open turn.
207
+ * @returns the synthetic `interrupted` closer events to append after `events`; empty when the log is already balanced.
208
+ */
209
+ export function interruptedTurnClosers(events: readonly SessionEvent[]): SessionEvent[] {
210
+ return openTurnClosers(events, { kind: 'interrupted' })
211
+ }
@@ -0,0 +1,69 @@
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
+
10
+ import { callConfigEquals } from '@deepseek-ai/dsh-llm'
11
+ import type { ToolSchema } from '@deepseek-ai/dsh-llm'
12
+ import type { EpochHeader, SessionEvent } from './types.ts'
13
+
14
+ /**
15
+ * Normalize a header to canonical form: an empty tool list becomes an absent
16
+ * field, matching how requests are built. Logging, folding, and comparison use
17
+ * this one representation.
18
+ * @param header - the header to normalize (not mutated).
19
+ * @returns the canonical header.
20
+ */
21
+ export function canonicalHeader(header: EpochHeader): EpochHeader {
22
+ const adapterDefaults = header.adapterDefaults
23
+ return {
24
+ config: header.config,
25
+ ...adapterDefaults?.reasoningEffort === true || adapterDefaults?.maxTokens === true
26
+ ? { adapterDefaults }
27
+ : {},
28
+ ...header.tools !== undefined && header.tools.length > 0 ? { tools: header.tools } : {},
29
+ }
30
+ }
31
+
32
+ /** Canonical JSON equality for tool schemas assembled through the same path. */
33
+ function sameSchema(a: ToolSchema, b: ToolSchema): boolean {
34
+ return JSON.stringify(a) === JSON.stringify(b)
35
+ }
36
+
37
+ /**
38
+ * Field-wise equality over canonical headers. Tool schemas compare in order.
39
+ * @param a - one canonical header.
40
+ * @param b - the other.
41
+ * @returns whether config, adapter defaults, and tools all match.
42
+ */
43
+ export function headerEquals(a: EpochHeader, b: EpochHeader): boolean {
44
+ if (
45
+ !callConfigEquals(a.config, b.config)
46
+ || a.adapterDefaults?.reasoningEffort !== b.adapterDefaults?.reasoningEffort
47
+ || a.adapterDefaults?.maxTokens !== b.adapterDefaults?.maxTokens
48
+ ) return false
49
+ const at = a.tools ?? []
50
+ const bt = b.tools ?? []
51
+ return at.length === bt.length && at.every((tool, i) => sameSchema(tool, bt[i] as ToolSchema))
52
+ }
53
+
54
+ /**
55
+ * Fold the header events of a log (or any prefix) into the
56
+ * {@link EpochHeader} in force after the last snapshot. Non-header events are
57
+ * skipped. This is the pure offline reconstruction path; the live session
58
+ * tracks the same fold incrementally.
59
+ * @param events - session events in log order.
60
+ * @param from - a previously folded state to continue from.
61
+ * @returns the latest canonical header, or undefined when none exists yet.
62
+ */
63
+ export function foldRequestHeader(events: readonly SessionEvent[], from?: EpochHeader): EpochHeader | undefined {
64
+ let state = from
65
+ for (const event of events) {
66
+ if (event.type === 'request/header') state = canonicalHeader(event.data.header)
67
+ }
68
+ return state
69
+ }
@@ -0,0 +1,73 @@
1
+ /** Lossless range encoding for JSONL `sourceEventSeqs` arrays. */
2
+
3
+ import { SessionSeq } from './types.ts'
4
+ import type { SessionSeq as SessionSeqType } from './types.ts'
5
+
6
+ /** A stored source sequence or inclusive consecutive range. */
7
+ export type EncodedSeq = number | [number, number]
8
+
9
+ function isStrictlyIncreasing(values: readonly SessionSeqType[]): boolean {
10
+ return values.every((value, index) => index === 0 || value > (values[index - 1] as SessionSeqType))
11
+ }
12
+
13
+ /**
14
+ * Replace profitable consecutive runs with inclusive pairs.
15
+ * @param values - validated in-memory source sequences.
16
+ * @returns a lossless JSON storage form.
17
+ */
18
+ export function encodeSeqRanges(values: readonly SessionSeqType[]): EncodedSeq[] {
19
+ if (!isStrictlyIncreasing(values)) return [...values]
20
+ const encoded: EncodedSeq[] = []
21
+ for (let start = 0; start < values.length;) {
22
+ let end = start
23
+ while (end + 1 < values.length && values[end + 1] === (values[end] as number) + 1) end += 1
24
+ if (end - start >= 2) encoded.push([values[start] as number, values[end] as number])
25
+ else for (let index = start; index <= end; index += 1) encoded.push(values[index] as number)
26
+ start = end + 1
27
+ }
28
+ return encoded
29
+ }
30
+
31
+ /**
32
+ * Expand a JSON storage-form source sequence array.
33
+ * @param value - parsed storage value.
34
+ * @param maxEntries - largest list permitted by the owning event.
35
+ * @returns the in-memory source sequences.
36
+ */
37
+ export function decodeSeqRanges(value: unknown, maxEntries = Number.MAX_SAFE_INTEGER): SessionSeqType[] {
38
+ if (!Array.isArray(value)) throw new TypeError('sourceEventSeqs must be an array')
39
+ const decoded: SessionSeqType[] = []
40
+ let hasRange = false
41
+ for (const entry of value) {
42
+ if (typeof entry === 'number') {
43
+ assertSeq(entry)
44
+ if (decoded.length >= maxEntries) throw new TypeError('sourceEventSeqs exceeds its event sequence')
45
+ decoded.push(SessionSeq(entry))
46
+ continue
47
+ }
48
+ if (!Array.isArray(entry) || entry.length !== 2) {
49
+ throw new TypeError('sourceEventSeqs range entries must be [start, end] pairs')
50
+ }
51
+ const start: unknown = entry[0]
52
+ const end: unknown = entry[1]
53
+ assertSeq(start)
54
+ assertSeq(end)
55
+ if (end < start) throw new TypeError('sourceEventSeqs ranges require start <= end')
56
+ const length = end - start + 1
57
+ if (length > maxEntries - decoded.length) {
58
+ throw new TypeError('sourceEventSeqs range exceeds its event sequence')
59
+ }
60
+ for (let seq = start; seq <= end; seq += 1) decoded.push(SessionSeq(seq))
61
+ hasRange = true
62
+ }
63
+ if (hasRange && !isStrictlyIncreasing(decoded)) {
64
+ throw new TypeError('sourceEventSeqs ranges must be strictly increasing')
65
+ }
66
+ return decoded
67
+ }
68
+
69
+ function assertSeq(value: unknown): asserts value is number {
70
+ if (!Number.isSafeInteger(value) || (value as number) < 0) {
71
+ throw new TypeError('sourceEventSeqs must contain non-negative safe integers')
72
+ }
73
+ }