dsh-context-compression-improved 0.1.1 → 0.2.0

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 (148) hide show
  1. package/.gitattributes +1 -0
  2. package/.github/workflows/ci.yml +39 -0
  3. package/CHANGELOG.ja.md +39 -0
  4. package/CHANGELOG.ko.md +39 -0
  5. package/CHANGELOG.md +135 -0
  6. package/CHANGELOG.zh.md +39 -0
  7. package/CONTRIBUTING.md +22 -0
  8. package/README.ja.md +104 -0
  9. package/README.ko.md +103 -0
  10. package/README.md +89 -12
  11. package/README.zh.md +87 -12
  12. package/SECURITY.md +18 -0
  13. package/THIRD_PARTY_NOTICES.md +7 -31
  14. package/docs/installation.ja.md +76 -0
  15. package/docs/installation.ko.md +76 -0
  16. package/docs/installation.md +76 -0
  17. package/docs/installation.zh.md +76 -0
  18. package/docs/repair-log.md +582 -0
  19. package/eslint.config.js +30 -0
  20. package/package.json +84 -81
  21. package/packages/selector/LICENSE +21 -0
  22. package/packages/selector/README.md +26 -0
  23. package/packages/selector/README.zh.md +26 -0
  24. package/packages/selector/THIRD_PARTY_NOTICES.md +38 -0
  25. package/packages/selector/docs/history-tool-call-working-set-spec.md +112 -0
  26. package/packages/selector/docs/native-tool-result-selector-spec.md +34 -0
  27. package/packages/selector/docs/subagent-cache-reuse-spec.md +46 -0
  28. package/packages/selector/lib/style.css +308 -0
  29. package/packages/selector/package.json +115 -0
  30. package/{screenshots.json → packages/selector/screenshots.json} +6 -6
  31. package/packages/selector/src/client/CompressionProfileControls.tsx +229 -0
  32. package/packages/selector/src/client/CompressionProfileSelector.module.css +170 -0
  33. package/packages/selector/src/client/CompressionProfileSelector.tsx +79 -0
  34. package/packages/selector/src/client/CustomPolicyEditor.tsx +216 -0
  35. package/packages/selector/src/client/EstimatorControls.tsx +281 -0
  36. package/packages/selector/src/client/decode.ts +49 -0
  37. package/packages/selector/src/client/index.ts +111 -0
  38. package/packages/selector/src/client/locales.ts +198 -0
  39. package/packages/selector/src/client/preset-options.ts +70 -0
  40. package/packages/selector/src/client/settings-section.tsx +126 -0
  41. package/packages/selector/src/css-modules.d.ts +6 -0
  42. package/packages/selector/src/deepseek-v4-tokenizer.ts +210 -0
  43. package/packages/selector/src/estimator-catalog.ts +104 -0
  44. package/packages/selector/src/index.ts +327 -0
  45. package/packages/selector/src/invariant.ts +113 -0
  46. package/packages/selector/src/preset-overlay.ts +567 -0
  47. package/packages/selector/src/profiles.ts +342 -0
  48. package/packages/selector/src/pruner/content.ts +188 -0
  49. package/packages/selector/src/pruner/session.ts +94 -0
  50. package/packages/selector/src/pruner/state.ts +43 -0
  51. package/packages/selector/src/pruner/tuning.ts +23 -0
  52. package/packages/selector/src/pruner/types.ts +60 -0
  53. package/packages/selector/src/pruner.ts +2144 -0
  54. package/packages/selector/src/runtime/adaptive-cost.ts +194 -0
  55. package/packages/selector/src/runtime/audit.ts +215 -0
  56. package/packages/selector/src/runtime/config.ts +613 -0
  57. package/packages/selector/src/runtime/custom-policy.ts +278 -0
  58. package/packages/selector/src/runtime/deepseek-official-pricing.ts +298 -0
  59. package/packages/selector/src/runtime/deepseek-v4-vision-tokens.ts +254 -0
  60. package/packages/selector/src/runtime/measurement.ts +403 -0
  61. package/packages/selector/src/runtime/reducers.ts +656 -0
  62. package/packages/selector/src/runtime/retrieve.ts +457 -0
  63. package/packages/selector/src/runtime/session-events.ts +17 -0
  64. package/packages/selector/src/runtime/tail-trim.ts +166 -0
  65. package/packages/selector/src/runtime/token-count.ts +72 -0
  66. package/packages/selector/src/runtime/tokenpilot/dedup.ts +81 -0
  67. package/packages/selector/src/runtime/tokenpilot/estimator.ts +183 -0
  68. package/packages/selector/src/runtime/tokenpilot/locator.ts +128 -0
  69. package/packages/selector/src/runtime/tokenpilot/read-state.ts +77 -0
  70. package/packages/selector/src/runtime/types.ts +309 -0
  71. package/packages/selector/src/runtime/value.ts +48 -0
  72. package/packages/selector/tests/auto-compact.client.spec.tsx +226 -0
  73. package/packages/selector/tests/built/client-artifact.spec.ts +51 -0
  74. package/packages/selector/tests/cache-prefix-audit.spec.ts +123 -0
  75. package/packages/selector/tests/code-skeleton.client.spec.ts +88 -0
  76. package/packages/selector/tests/custom-contract.client.spec.ts +202 -0
  77. package/packages/selector/tests/estimator-catalog.spec.ts +70 -0
  78. package/packages/selector/tests/estimator-channel.client.spec.tsx +247 -0
  79. package/packages/selector/tests/estimator-route-registration.host.spec.ts +176 -0
  80. package/packages/selector/tests/host-preset-overlay.host.spec.ts +204 -0
  81. package/packages/selector/tests/preset-options-write.client.spec.ts +181 -0
  82. package/packages/selector/tests/preset-overlay-loader.e2e.host.spec.ts +196 -0
  83. package/packages/selector/tests/preset-overlay.host.spec.ts +243 -0
  84. package/packages/selector/tests/profiles.client.spec.tsx +434 -0
  85. package/packages/selector/tests/public/package-contract.client.spec.ts +33 -0
  86. package/packages/selector/tests/runtime/adaptive-cost.spec.ts +167 -0
  87. package/packages/selector/tests/runtime/audit.spec.ts +129 -0
  88. package/packages/selector/tests/runtime/auto-compact-config.spec.ts +523 -0
  89. package/packages/selector/tests/runtime/code-skeleton.spec.ts +141 -0
  90. package/packages/selector/tests/runtime/deepseek-official-pricing.spec.ts +186 -0
  91. package/packages/selector/tests/runtime/deepseek-v4-tokenizer.spec.ts +122 -0
  92. package/packages/selector/tests/runtime/deepseek-v4-vision-tokens.spec.ts +122 -0
  93. package/packages/selector/tests/runtime/fixtures/profile-baseline.json +273 -0
  94. package/packages/selector/tests/runtime/fixtures/tokenizer-golden.json +106 -0
  95. package/packages/selector/tests/runtime/fixtures/vision-golden.json +459 -0
  96. package/packages/selector/tests/runtime/public/public-runtime.spec.ts +2531 -0
  97. package/packages/selector/tests/runtime/session-events.spec.ts +27 -0
  98. package/packages/selector/tests/runtime/tokenizer-golden.spec.ts +53 -0
  99. package/packages/selector/tests/runtime/tokenpilot/dedup.spec.ts +52 -0
  100. package/packages/selector/tests/runtime/tokenpilot/estimator.spec.ts +56 -0
  101. package/packages/selector/tests/runtime/tokenpilot/locator.spec.ts +76 -0
  102. package/packages/selector/tests/runtime/tokenpilot/profile-baseline.spec.ts +100 -0
  103. package/packages/selector/tests/runtime/tokenpilot/read-state.spec.ts +58 -0
  104. package/packages/selector/tests/runtime/value.spec.ts +23 -0
  105. package/packages/selector/tests/standing-generation.host.spec.ts +631 -0
  106. package/packages/selector/tests/subagent-cache-reuse.host.spec.ts +250 -0
  107. package/packages/selector/tests/support/cache-prefix-audit.ts +105 -0
  108. package/packages/selector/tests/support/mock-adapter.ts +37 -0
  109. package/packages/selector/tests/support/ui-primitives.tsx +34 -0
  110. package/packages/selector/tsconfig.json +11 -0
  111. package/packages/selector/tsdown.client.config.ts +102 -0
  112. package/packages/selector/tsdown.config.ts +20 -0
  113. package/pnpm-workspace.yaml +19 -0
  114. package/scripts/capture-profile-baseline.ts +80 -0
  115. package/scripts/generate-tokenizer-fixtures.py +81 -0
  116. package/scripts/generate-vision-fixtures.py +208 -0
  117. package/scripts/packed-components-smoke.ts +713 -0
  118. package/scripts/packed-install-e2e.ts +1072 -0
  119. package/scripts/verify-release.ts +300 -0
  120. package/tests/TEST_INVENTORY.md +42 -0
  121. package/tsconfig.base.json +18 -0
  122. package/tsconfig.json +7 -0
  123. package/tsconfig.scripts.json +13 -0
  124. package/tsconfig.tests.json +15 -0
  125. package/vitest.built.config.ts +9 -0
  126. package/vitest.config.ts +43 -0
  127. /package/{assets → packages/selector/assets}/deepseek-v4/LICENSE.DeepSeek-V4-Pro.txt +0 -0
  128. /package/{assets → packages/selector/assets}/deepseek-v4/manifest.json +0 -0
  129. /package/{assets → packages/selector/assets}/deepseek-v4/tokenizer.json +0 -0
  130. /package/{assets → packages/selector/assets}/deepseek-v4/tokenizer_config.json +0 -0
  131. /package/{assets → packages/selector/assets}/deepseek-v4-vision-exp/LICENSE.DeepSeek-V4-Flash-Vision-Exp.txt +0 -0
  132. /package/{assets → packages/selector/assets}/deepseek-v4-vision-exp/manifest.json +0 -0
  133. /package/{assets → packages/selector/assets}/deepseek-v4-vision-exp/tokenizer.json +0 -0
  134. /package/{assets → packages/selector/assets}/deepseek-v4-vision-exp/tokenizer_config.json +0 -0
  135. /package/{assets → packages/selector/assets}/screenshots/context-compression-selector-profiles.jpg +0 -0
  136. /package/{assets → packages/selector/assets}/screenshots/context-compression-selector-settings.png +0 -0
  137. /package/{cordis.patch.yml → packages/selector/cordis.patch.yml} +0 -0
  138. /package/{dsh.plugin.json → packages/selector/dsh.plugin.json} +0 -0
  139. /package/{lib → packages/selector/lib}/client.d.ts +0 -0
  140. /package/{lib → packages/selector/lib}/client.js +0 -0
  141. /package/{lib → packages/selector/lib}/config.js +0 -0
  142. /package/{lib → packages/selector/lib}/index.d.ts +0 -0
  143. /package/{lib → packages/selector/lib}/index.js +0 -0
  144. /package/{lib → packages/selector/lib}/invariant.d.ts +0 -0
  145. /package/{lib → packages/selector/lib}/invariant.js +0 -0
  146. /package/{lib → packages/selector/lib}/pruner.d.ts +0 -0
  147. /package/{lib → packages/selector/lib}/pruner.js +0 -0
  148. /package/{lib → packages/selector/lib}/tail-trim.js +0 -0
@@ -0,0 +1,457 @@
1
+ /**
2
+ * Current-session recovery tool for context-compression placeholders.
3
+ *
4
+ * It reads the immutable append-only event cited by a `session://.../event/N`
5
+ * reference. The tool never reads the replacement surface as the source of
6
+ * truth and never crosses into another Session.
7
+ *
8
+ * @module dsh-context-compression-improved-runtime/retrieve
9
+ */
10
+
11
+ import type { Context } from '@deepseek-ai/cordis'
12
+ import z from '@deepseek-ai/schemastery'
13
+ import { defineTool } from '@deepseek-ai/dsh-tools'
14
+ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
15
+ import type {} from '@deepseek-ai/dsh-system-prompt'
16
+ import {
17
+ parseTailTrimRef,
18
+ validatePublishedTailTrim,
19
+ type PublishedTailTrim,
20
+ } from './tail-trim.ts'
21
+ import { sessionEvents } from './session-events.ts'
22
+
23
+ export const name = 'context-compression-retrieve'
24
+ export const inject = ['tools', 'systemPrompt']
25
+
26
+ /** Maximum text returned by one recovery call. */
27
+ export interface Config {
28
+ /** Hard response bound in Unicode code points. Defaults to 50000. */
29
+ maxChars?: number
30
+ /** Maximum source text inspected by one call. Defaults to 250000. */
31
+ maxScanChars?: number
32
+ /** Maximum query length in Unicode code points. Defaults to 256. */
33
+ maxQueryChars?: number
34
+ }
35
+
36
+ /** Loader schema for the recovery tool's bounded runtime configuration. */
37
+ export const Config: z<Config> = z.object({
38
+ maxChars: z.number().step(1).min(1).default(50_000),
39
+ maxScanChars: z.number().step(1).min(1).default(250_000),
40
+ maxQueryChars: z.number().step(1).min(1).default(256),
41
+ })
42
+
43
+ const REF_PATTERN = /^session:\/\/([^/]+)\/event\/(\d+)$/
44
+ const MAX_LINES = 1_000
45
+ const DEFAULT_MAX_CHARS = 50_000
46
+ const DEFAULT_MAX_SCAN_CHARS = 250_000
47
+ const DEFAULT_MAX_QUERY_CHARS = 256
48
+ const TRUNCATION_MARKER = '\n[context_compression_retrieve output truncated; reported lines describe the selected source range]\n'
49
+ const PROMPT = 'When a compacted tool result contains a session://<session-id>/event/<seq> reference, '
50
+ + 'or TailTrim contains a session://<session-id>/tailtrim/<seq> reference, use context_compression_retrieve with that exact ref '
51
+ + 'and a narrow line range or query if the omitted evidence is necessary. '
52
+ + 'The returned event content comes from the append-only session log, which is the source of truth.'
53
+
54
+ const OUTPUT = {
55
+ schema: { type: 'string' as const },
56
+ render: (_args: unknown, value: string) => [{ type: 'text' as const, text: value }],
57
+ }
58
+
59
+ /**
60
+ * Register the current-session recovery tool and its stable guidance.
61
+ *
62
+ * @param ctx Plugin context providing the tool registry and system prompt.
63
+ * @param config Optional response, scan, and query bounds.
64
+ */
65
+ export function installContextCompressionRetrieve(ctx: Context, config: Config = {}): void {
66
+ const maxChars = resolvePositiveInteger('maxChars', config.maxChars, DEFAULT_MAX_CHARS)
67
+ const maxScanChars = resolvePositiveInteger(
68
+ 'maxScanChars', config.maxScanChars, DEFAULT_MAX_SCAN_CHARS,
69
+ )
70
+ const maxQueryChars = resolvePositiveInteger(
71
+ 'maxQueryChars', config.maxQueryChars, DEFAULT_MAX_QUERY_CHARS,
72
+ )
73
+ ctx.systemPrompt.section({ name: 'tool:context-compression-retrieve', order: 114, text: PROMPT })
74
+ ctx.tools.register(defineTool({
75
+ name: 'context_compression_retrieve',
76
+ description: 'Recover exact content from one compacted tool result or TailTrim group using its current-session session:// reference.',
77
+ parameters: {
78
+ ref: { type: 'string', required: true, description: 'Exact session://<current-session-id>/event/<seq> or /tailtrim/<seq> reference from a placeholder.' },
79
+ query: { type: 'string', description: 'Optional case-insensitive text to search for inside the original result.' },
80
+ start_line: { type: 'integer', description: 'Optional 1-based first line for a direct slice. Defaults to 1.' },
81
+ max_lines: { type: 'integer', description: 'Maximum lines to return. Defaults to 200; maximum 1000.' },
82
+ },
83
+ output: OUTPUT,
84
+ isConcurrencySafe: () => true,
85
+ execute(args, exec) {
86
+ if (exec.agent === undefined) throw new Error('context_compression_retrieve requires an agent session')
87
+ const match = REF_PATTERN.exec(args.ref)
88
+ const tailTrimRef = parseTailTrimRef(args.ref)
89
+ if (match === null && tailTrimRef === null) {
90
+ throw new Error('context_compression_retrieve: ref must be session://<session-id>/(event|tailtrim)/<seq>')
91
+ }
92
+ const sessionId = match?.[1] ?? tailTrimRef?.sessionId
93
+ if (sessionId !== String(exec.agent.id)) {
94
+ throw new Error('context_compression_retrieve: a compression reference may only read the caller\'s current session')
95
+ }
96
+ if (tailTrimRef !== null) {
97
+ return Promise.resolve(recoverTailTrim(
98
+ exec.agent.session,
99
+ args.ref,
100
+ tailTrimRef.manifestSeq,
101
+ args.query,
102
+ args.start_line,
103
+ args.max_lines,
104
+ { maxChars, maxScanChars, maxQueryChars },
105
+ ))
106
+ }
107
+ const seq = Number(match?.[2])
108
+ const event = sessionEvents(exec.agent.session)[seq]
109
+ if (event?.type !== 'tool/result') {
110
+ throw new Error(`context_compression_retrieve: event ${String(seq)} is not a tool/result in the current session`)
111
+ }
112
+ const maxLines = resolveMaxLines(args.max_lines)
113
+ const scan = scanBlocks(event.data.message.content[0].content, maxScanChars)
114
+ const scannedLines = splitScannedLines(scan)
115
+ const lines = scannedLines.lines
116
+ const query = args.query
117
+ if (query !== undefined && exceedsCodePointLimit(query, maxQueryChars)) {
118
+ throw new Error(
119
+ `context_compression_retrieve: query must be at most ${String(maxQueryChars)} Unicode code points`,
120
+ )
121
+ }
122
+ const selected = query === undefined || query === ''
123
+ ? directSlice(lines, args.start_line ?? 1, maxLines, scan.complete, scannedLines.partialTail)
124
+ : querySlice(lines, query, maxLines, scan.complete, scannedLines.partialTail)
125
+ const total = scan.complete ? String(lines.length) : `at least ${String(lines.length)}`
126
+ const header = [
127
+ `source: ${args.ref}`,
128
+ `tool_call_id: ${event.data.message.source.callId}`,
129
+ `status: ${event.data.message.content[0].isError === true ? 'error' : 'completed'}`,
130
+ `lines: ${String(selected.start)}-${String(selected.end)} of ${total}`,
131
+ scan.complete ? '' : 'note: source scan limit reached; later lines were not inspected',
132
+ selected.partialLine === undefined
133
+ ? ''
134
+ : `note: line ${String(selected.partialLine)} is a partial prefix ending at the source scan limit`,
135
+ selected.omitted
136
+ ? query === undefined || query === ''
137
+ ? 'note: additional source lines were omitted'
138
+ : 'note: additional matching or neighboring lines were omitted'
139
+ : '',
140
+ '--- original tool result ---',
141
+ ].filter(Boolean).join('\n')
142
+ const output = `${header}\n${selected.text}`
143
+ return Promise.resolve(boundCodePoints(output, maxChars))
144
+ },
145
+ }))
146
+ }
147
+
148
+ function recoverTailTrim(
149
+ session: Parameters<typeof validatePublishedTailTrim>[0],
150
+ ref: string,
151
+ manifestSeq: number,
152
+ query: string | undefined,
153
+ startLine: number | undefined,
154
+ requestedMaxLines: number | undefined,
155
+ bounds: { readonly maxChars: number; readonly maxScanChars: number; readonly maxQueryChars: number },
156
+ ): string {
157
+ const published = validatePublishedTailTrim(session, manifestSeq)
158
+ if (published === null || published.ref !== ref) {
159
+ throw new Error('context_compression_retrieve: ref is not a valid published TailTrim group')
160
+ }
161
+ if (query !== undefined && exceedsCodePointLimit(query, bounds.maxQueryChars)) {
162
+ throw new Error(
163
+ `context_compression_retrieve: query must be at most ${String(bounds.maxQueryChars)} Unicode code points`,
164
+ )
165
+ }
166
+ const fixedHeader = [
167
+ `source: ${ref}`,
168
+ 'kind: tailtrim-group',
169
+ `records: ${String(published.roots.length)}`,
170
+ '--- original tool group (jsonl) ---',
171
+ ].join('\n')
172
+ const scanBudget = Math.max(0, bounds.maxScanChars - codePointLength(`${fixedHeader}\n`))
173
+ const scan = consumeChunks(renderGroupRecordChunks(published.roots), scanBudget)
174
+ const scannedLines = splitScannedLines(scan)
175
+ const maxLines = resolveMaxLines(requestedMaxLines)
176
+ const selected = query === undefined || query === ''
177
+ ? directSlice(
178
+ scannedLines.lines,
179
+ startLine ?? 1,
180
+ maxLines,
181
+ scan.complete,
182
+ scannedLines.partialTail,
183
+ )
184
+ : querySlice(
185
+ scannedLines.lines,
186
+ query,
187
+ maxLines,
188
+ scan.complete,
189
+ scannedLines.partialTail,
190
+ )
191
+ const header = [
192
+ fixedHeader.split('\n').slice(0, 3).join('\n'),
193
+ scan.complete ? '' : 'note: source scan limit reached; later records were not inspected',
194
+ selected.omitted ? 'note: additional group records were omitted' : '',
195
+ '--- original tool group (jsonl) ---',
196
+ ].filter(Boolean).join('\n')
197
+ return boundCodePoints(`${header}\n${selected.text}`, bounds.maxChars)
198
+ }
199
+
200
+ interface ScanResult {
201
+ readonly text: string
202
+ readonly complete: boolean
203
+ }
204
+
205
+ interface ScannedLines {
206
+ readonly lines: readonly string[]
207
+ readonly partialTail: boolean
208
+ }
209
+
210
+ interface Selection {
211
+ readonly text: string
212
+ readonly start: number
213
+ readonly end: number
214
+ readonly omitted: boolean
215
+ readonly partialLine?: number
216
+ }
217
+
218
+ function resolvePositiveInteger(name: string, value: number | undefined, fallback: number): number {
219
+ const resolved = value ?? fallback
220
+ if (!Number.isSafeInteger(resolved) || resolved <= 0) {
221
+ throw new TypeError(`tool-context-retrieve: ${name} must be a positive safe integer`)
222
+ }
223
+ return resolved
224
+ }
225
+
226
+ function resolveMaxLines(value: number | undefined): number {
227
+ const resolved = value ?? 200
228
+ if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > MAX_LINES) {
229
+ throw new Error(`context_compression_retrieve: max_lines must be an integer from 1 to ${String(MAX_LINES)}`)
230
+ }
231
+ return resolved
232
+ }
233
+
234
+ function scanBlocks(blocks: readonly ContentBlock[], maxChars: number): ScanResult {
235
+ return consumeChunks(renderBlockChunks(blocks), maxChars)
236
+ }
237
+
238
+ function* renderBlockChunks(blocks: readonly ContentBlock[]): Generator<string> {
239
+ let first = true
240
+ for (const block of blocks) {
241
+ if (!first) yield '\n'
242
+ first = false
243
+ if (block.type === 'text') yield block.text
244
+ else yield* jsonTokens(block)
245
+ }
246
+ }
247
+
248
+ function* renderGroupRecordChunks(roots: PublishedTailTrim['roots']): Generator<string> {
249
+ let first = true
250
+ for (const root of roots) {
251
+ if (!first) yield '\n'
252
+ first = false
253
+ const message = root.data.message
254
+ yield* jsonTokens({
255
+ seq: root.seq,
256
+ type: root.type,
257
+ message: {
258
+ id: message.id,
259
+ role: message.role,
260
+ content: message.content,
261
+ source: message.source,
262
+ },
263
+ })
264
+ }
265
+ }
266
+
267
+ function* jsonTokens(value: unknown): Generator<string> {
268
+ if (value === null) {
269
+ yield 'null'
270
+ return
271
+ }
272
+ switch (typeof value) {
273
+ case 'string':
274
+ yield '"'
275
+ for (const point of value) {
276
+ const quoted = jsonScalar(point)
277
+ yield quoted.slice(1, -1)
278
+ }
279
+ yield '"'
280
+ return
281
+ case 'number':
282
+ case 'boolean':
283
+ yield jsonScalar(value)
284
+ return
285
+ case 'object': {
286
+ if (Array.isArray(value)) {
287
+ yield '['
288
+ for (let index = 0; index < value.length; index++) {
289
+ if (index > 0) yield ','
290
+ yield* jsonTokens(value[index])
291
+ }
292
+ yield ']'
293
+ return
294
+ }
295
+ yield '{'
296
+ let first = true
297
+ for (const key in value) {
298
+ if (!Object.hasOwn(value, key)) continue
299
+ if (!first) yield ','
300
+ first = false
301
+ yield* jsonTokens(key)
302
+ yield ':'
303
+ yield* jsonTokens((value as Record<string, unknown>)[key])
304
+ }
305
+ yield '}'
306
+ return
307
+ }
308
+ default:
309
+ throw new TypeError('context_compression_retrieve: source content is not JSON-serializable')
310
+ }
311
+ }
312
+
313
+ function jsonScalar(value: string | number | boolean): string {
314
+ return JSON.stringify(value)
315
+ }
316
+
317
+ function consumeChunks(chunks: Iterable<string>, maxChars: number): ScanResult {
318
+ const output: string[] = []
319
+ let remaining = maxChars
320
+ for (const chunk of chunks) {
321
+ const prefix = codePointPrefix(chunk, remaining)
322
+ output.push(prefix.text)
323
+ remaining -= prefix.count
324
+ if (!prefix.complete) return { text: output.join(''), complete: false }
325
+ }
326
+ return { text: output.join(''), complete: true }
327
+ }
328
+
329
+ function splitScannedLines(scan: ScanResult): ScannedLines {
330
+ const lines = scan.text.split('\n')
331
+ const partialTail = !scan.complete && !scan.text.endsWith('\n')
332
+ if (scan.text.endsWith('\n')) lines.pop()
333
+ return { lines, partialTail }
334
+ }
335
+
336
+ function directSlice(
337
+ lines: readonly string[],
338
+ startLine: number,
339
+ maxLines: number,
340
+ scanComplete: boolean,
341
+ partialTail: boolean,
342
+ ): Selection {
343
+ if (!Number.isSafeInteger(startLine) || startLine < 1) {
344
+ throw new Error('context_compression_retrieve: start_line must be a positive safe integer')
345
+ }
346
+ if (startLine > lines.length) {
347
+ const reason = scanComplete ? 'outside the source line range' : 'beyond the source scan limit'
348
+ throw new Error(`context_compression_retrieve: start_line ${String(startLine)} is ${reason}`)
349
+ }
350
+ const startIndex = startLine - 1
351
+ const selected = lines.slice(startIndex, startIndex + maxLines)
352
+ return {
353
+ text: selected.join('\n'),
354
+ start: startIndex + 1,
355
+ end: startIndex + selected.length,
356
+ omitted: startIndex > 0 || startIndex + selected.length < lines.length || !scanComplete,
357
+ ...partialTail && startIndex + selected.length === lines.length
358
+ ? { partialLine: lines.length }
359
+ : {},
360
+ }
361
+ }
362
+
363
+ function querySlice(
364
+ lines: readonly string[],
365
+ query: string,
366
+ maxLines: number,
367
+ scanComplete: boolean,
368
+ partialTail: boolean,
369
+ ): Selection {
370
+ const needle = query.toLowerCase()
371
+ const chosen = new Set<number>()
372
+ let matched = false
373
+ let omitted = !scanComplete
374
+ for (let index = 0; index < lines.length; index++) {
375
+ const line = lines[index]
376
+ if (line === undefined || !line.toLowerCase().includes(needle)) continue
377
+ matched = true
378
+ for (let row = Math.max(0, index - 2); row <= Math.min(lines.length - 1, index + 2); row++) {
379
+ if (chosen.has(row)) continue
380
+ if (chosen.size >= maxLines) {
381
+ omitted = true
382
+ continue
383
+ }
384
+ chosen.add(row)
385
+ }
386
+ }
387
+ if (!matched) {
388
+ return {
389
+ text: scanComplete ? '[no matches]' : '[no matches within source scan limit]',
390
+ start: 0,
391
+ end: 0,
392
+ omitted,
393
+ }
394
+ }
395
+ const ordered = [...chosen].sort((a, b) => a - b)
396
+ const rendered: string[] = []
397
+ let previous = -2
398
+ let start = 0
399
+ let end = 0
400
+ for (const index of ordered) {
401
+ const line = lines[index]
402
+ if (line === undefined) continue
403
+ if (index > previous + 1) rendered.push('...')
404
+ rendered.push(`${String(index + 1)}: ${line}`)
405
+ if (start === 0) start = index + 1
406
+ end = index + 1
407
+ previous = index
408
+ }
409
+ return {
410
+ text: rendered.join('\n'),
411
+ start,
412
+ end,
413
+ omitted,
414
+ ...partialTail && ordered.includes(lines.length - 1)
415
+ ? { partialLine: lines.length }
416
+ : {},
417
+ }
418
+ }
419
+
420
+ function boundCodePoints(text: string, maxChars: number): string {
421
+ const bounded = codePointPrefix(text, maxChars)
422
+ if (bounded.complete) return text
423
+ const marker = codePointPrefix(TRUNCATION_MARKER, maxChars)
424
+ if (!marker.complete) return marker.text
425
+ const body = codePointPrefix(text, maxChars - marker.count)
426
+ return body.text + marker.text
427
+ }
428
+
429
+ function codePointPrefix(text: string, maxChars: number): {
430
+ readonly text: string
431
+ readonly count: number
432
+ readonly complete: boolean
433
+ } {
434
+ const output: string[] = []
435
+ let count = 0
436
+ for (const point of text) {
437
+ if (count >= maxChars) return { text: output.join(''), count, complete: false }
438
+ output.push(point)
439
+ count++
440
+ }
441
+ return { text: output.join(''), count, complete: true }
442
+ }
443
+
444
+ function exceedsCodePointLimit(text: string, limit: number): boolean {
445
+ let count = 0
446
+ for (const _point of text) {
447
+ count++
448
+ if (count > limit) return true
449
+ }
450
+ return false
451
+ }
452
+
453
+ function codePointLength(text: string): number {
454
+ let count = 0
455
+ for (const _point of text) count++
456
+ return count
457
+ }
@@ -0,0 +1,17 @@
1
+ /** Public Session event-log compatibility across Harness 0.1.x releases. */
2
+
3
+ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
4
+
5
+ /**
6
+ * Read one immutable Session event snapshot.
7
+ *
8
+ * Harness rc.2 exposed `events`; Alpha 5 replaced it with the public
9
+ * `snapshotEvents()` method. Prefer the new API when present and retain the
10
+ * old accessor as the compatibility fallback for already-supported hosts.
11
+ */
12
+ export function sessionEvents(session: Session): readonly SessionEvent[] {
13
+ const snapshot = (session as unknown as {
14
+ snapshotEvents?: () => readonly SessionEvent[]
15
+ }).snapshotEvents
16
+ return typeof snapshot === 'function' ? snapshot.call(session) : session.events
17
+ }
@@ -0,0 +1,166 @@
1
+ /** Plugin-owned TailTrim publication protocol over official Session events. */
2
+
3
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
4
+ import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm'
5
+ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
6
+ import { sessionEvents } from './session-events.ts'
7
+
8
+ const TAIL_TRIM_REF_PATTERN = /^session:\/\/([^/]+)\/tailtrim\/(\d+)$/
9
+ const MAX_ROOTS = 64
10
+ const MAX_STUB_CODE_POINTS = 1_024
11
+
12
+ /** A standard prune plus adjacent user-message replacement that validates. */
13
+ export interface PublishedTailTrim {
14
+ readonly manifest: SessionEvent<'compaction/prune'>
15
+ readonly replacement: SessionEvent<'user/message'>
16
+ readonly roots: readonly (
17
+ SessionEvent<'assistant/message'> | SessionEvent<'tool/result'>
18
+ )[]
19
+ readonly toolNames: readonly string[]
20
+ readonly ref: string
21
+ readonly stub: string
22
+ }
23
+
24
+ /** Build a same-session reference keyed by the standard prune event. */
25
+ export function tailTrimRef(sessionId: string, manifestSeq: number): string {
26
+ return `session://${sessionId}/tailtrim/${String(manifestSeq)}`
27
+ }
28
+
29
+ /** Parse one exact TailTrim reference. */
30
+ export function parseTailTrimRef(ref: string): { sessionId: string; manifestSeq: number } | null {
31
+ const match = TAIL_TRIM_REF_PATTERN.exec(ref)
32
+ if (match === null) return null
33
+ const manifestSeq = Number(match[2])
34
+ if (!Number.isSafeInteger(manifestSeq) || manifestSeq < 0) return null
35
+ return { sessionId: match[1] ?? '', manifestSeq }
36
+ }
37
+
38
+ /** Build the fixed bounded model-visible TailTrim stub. */
39
+ export function tailTrimStub(
40
+ ref: string,
41
+ toolNames: readonly string[],
42
+ sourceEventSeqs: readonly number[],
43
+ ): string | null {
44
+ const stub = [
45
+ '[TailTrim: completed tool-call group]',
46
+ `ref: ${ref}`,
47
+ `tools: ${toolNames.join(', ')}`,
48
+ `source_event_seqs: ${sourceEventSeqs.join(', ')}`,
49
+ 'use context_compression_retrieve with this TailTrim ref if needed',
50
+ ].join('\n')
51
+ return Array.from(stub).length <= MAX_STUB_CODE_POINTS ? stub : null
52
+ }
53
+
54
+ /** Wrap a TailTrim stub in the one user message used for range replacement. */
55
+ export function tailTrimMessage(stub: string): UserMessage {
56
+ return createUserMessage({
57
+ content: [{ type: 'text', text: stub }],
58
+ source: { kind: 'plugin', plugin: 'dsh-context-compression-improved-runtime' },
59
+ })
60
+ }
61
+
62
+ /** Validate the standard prune, adjacent replacement, append roots and stub. */
63
+ export function validatePublishedTailTrim(
64
+ session: Session,
65
+ manifestSeq: number,
66
+ ): PublishedTailTrim | null {
67
+ const events = sessionEvents(session)
68
+ const manifest = events[manifestSeq]
69
+ if (manifest?.type !== 'compaction/prune'
70
+ || manifest.data.shadowedSeqs.length < 2
71
+ || manifest.data.shadowedSeqs.length > MAX_ROOTS
72
+ || manifest.data.shadowedSeqs[0] !== manifest.data.shadowedRange.start
73
+ || manifest.data.shadowedSeqs.at(-1) !== manifest.data.shadowedRange.end
74
+ || new Set(manifest.data.shadowedSeqs).size !== manifest.data.shadowedSeqs.length) return null
75
+ const replacement = events[manifestSeq + 1]
76
+ if (replacement?.type !== 'user/message'
77
+ || replacement.seq !== manifest.seq + 1
78
+ || replacement.data.source.kind !== 'plugin'
79
+ || replacement.data.source.plugin !== 'dsh-context-compression-improved-runtime'
80
+ || replacement.surfaceOp === undefined
81
+ || replacement.surfaceOp === 'append'
82
+ || replacement.surfaceOp.start !== manifest.data.shadowedRange.start
83
+ || replacement.surfaceOp.end !== manifest.data.shadowedRange.end
84
+ || !sameNumbers(
85
+ replacement.sourceEventSeqs,
86
+ [manifest.seq, ...manifest.data.shadowedSeqs],
87
+ )
88
+ || replacement.data.content.length !== 1
89
+ || replacement.data.content[0]?.type !== 'text') return null
90
+
91
+ const tracedRoots = manifest.data.shadowedSeqs.map(seq => uniqueAppendRoot(session, seq, manifestSeq))
92
+ if (tracedRoots.some(root => root === null)) return null
93
+ const sourceEventSeqs = tracedRoots as number[]
94
+ if (new Set(sourceEventSeqs).size !== sourceEventSeqs.length) return null
95
+ const roots = sourceEventSeqs.map(seq => events[seq])
96
+ if (roots.some((event): event is undefined => event === undefined)) return null
97
+ const typedRoots = roots as SessionEvent[]
98
+ if (!validRootGroup(typedRoots, manifestSeq)) return null
99
+ const assistant = typedRoots[0]
100
+ if (assistant?.type !== 'assistant/message') return null
101
+ const toolNames = assistant.data.message.content.map((block) => {
102
+ if (block.type !== 'tool-call') throw new Error('unreachable')
103
+ return block.name
104
+ })
105
+ const ref = tailTrimRef(String(session.id), manifestSeq)
106
+ const stub = tailTrimStub(ref, toolNames, sourceEventSeqs)
107
+ if (stub === null || replacement.data.content[0].text !== stub) return null
108
+ return {
109
+ manifest,
110
+ replacement,
111
+ roots: typedRoots as PublishedTailTrim['roots'],
112
+ toolNames,
113
+ ref,
114
+ stub,
115
+ }
116
+ }
117
+
118
+ function uniqueAppendRoot(session: Session, seq: number, beforeSeq: number): number | null {
119
+ const events = sessionEvents(session)
120
+ const pending: Array<{ readonly seq: number; readonly depth: number }> = [{ seq, depth: 0 }]
121
+ const visited = new Set<number>()
122
+ const roots = new Set<number>()
123
+ while (pending.length > 0) {
124
+ const next = pending.pop()
125
+ if (next === undefined || next.depth > MAX_ROOTS || visited.has(next.seq)) continue
126
+ if (!Number.isSafeInteger(next.seq) || next.seq < 0 || next.seq >= beforeSeq) return null
127
+ visited.add(next.seq)
128
+ if (visited.size > MAX_ROOTS) return null
129
+ const event = events[next.seq]
130
+ if (event === undefined || (event.type !== 'assistant/message' && event.type !== 'tool/result')) return null
131
+ if (event.surfaceOp === 'append') roots.add(event.seq)
132
+ else if (typeof event.surfaceOp === 'object') {
133
+ const sources = event.sourceEventSeqs
134
+ if (sources === undefined || sources.length === 0) return null
135
+ for (const source of sources) pending.push({ seq: source, depth: next.depth + 1 })
136
+ } else return null
137
+ if (roots.size > 1) return null
138
+ }
139
+ return roots.size === 1 ? [...roots][0] ?? null : null
140
+ }
141
+
142
+ function validRootGroup(roots: readonly SessionEvent[], manifestSeq: number): boolean {
143
+ const assistant = roots[0]
144
+ if (assistant?.type !== 'assistant/message' || assistant.seq >= manifestSeq
145
+ || assistant.surfaceOp !== 'append' || assistant.data.interrupted === true
146
+ || assistant.data.message.content.length === 0
147
+ || assistant.data.message.content.some(block => block.type !== 'tool-call')) return false
148
+ const calls = assistant.data.message.content as Extract<ContentBlock, { type: 'tool-call' }>[]
149
+ const callIds = calls.map(call => call.id)
150
+ if (new Set(callIds).size !== callIds.length || roots.length !== callIds.length + 1) return false
151
+ for (const [index, root] of roots.slice(1).entries()) {
152
+ if (root.type !== 'tool/result' || root.seq >= manifestSeq || root.surfaceOp !== 'append') return false
153
+ const result = root.data.message.content[0]
154
+ if (result.isError === true
155
+ || root.data.error !== undefined
156
+ || root.data.turn !== assistant.data.turn
157
+ || root.data.step !== assistant.data.step
158
+ || String(root.data.message.source.callId) !== String(callIds[index])) return false
159
+ }
160
+ return true
161
+ }
162
+
163
+ function sameNumbers(left: readonly number[] | undefined, right: readonly number[]): boolean {
164
+ return left !== undefined && left.length === right.length
165
+ && left.every((value, index) => value === right[index])
166
+ }