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.
- package/.gitattributes +1 -0
- package/.github/workflows/ci.yml +39 -0
- package/CHANGELOG.ja.md +39 -0
- package/CHANGELOG.ko.md +39 -0
- package/CHANGELOG.md +135 -0
- package/CHANGELOG.zh.md +39 -0
- package/CONTRIBUTING.md +22 -0
- package/README.ja.md +104 -0
- package/README.ko.md +103 -0
- package/README.md +89 -12
- package/README.zh.md +87 -12
- package/SECURITY.md +18 -0
- package/THIRD_PARTY_NOTICES.md +7 -31
- package/docs/installation.ja.md +76 -0
- package/docs/installation.ko.md +76 -0
- package/docs/installation.md +76 -0
- package/docs/installation.zh.md +76 -0
- package/docs/repair-log.md +582 -0
- package/eslint.config.js +30 -0
- package/package.json +84 -81
- package/packages/selector/LICENSE +21 -0
- package/packages/selector/README.md +26 -0
- package/packages/selector/README.zh.md +26 -0
- package/packages/selector/THIRD_PARTY_NOTICES.md +38 -0
- package/packages/selector/docs/history-tool-call-working-set-spec.md +112 -0
- package/packages/selector/docs/native-tool-result-selector-spec.md +34 -0
- package/packages/selector/docs/subagent-cache-reuse-spec.md +46 -0
- package/packages/selector/lib/style.css +308 -0
- package/packages/selector/package.json +115 -0
- package/{screenshots.json → packages/selector/screenshots.json} +6 -6
- package/packages/selector/src/client/CompressionProfileControls.tsx +229 -0
- package/packages/selector/src/client/CompressionProfileSelector.module.css +170 -0
- package/packages/selector/src/client/CompressionProfileSelector.tsx +79 -0
- package/packages/selector/src/client/CustomPolicyEditor.tsx +216 -0
- package/packages/selector/src/client/EstimatorControls.tsx +281 -0
- package/packages/selector/src/client/decode.ts +49 -0
- package/packages/selector/src/client/index.ts +111 -0
- package/packages/selector/src/client/locales.ts +198 -0
- package/packages/selector/src/client/preset-options.ts +70 -0
- package/packages/selector/src/client/settings-section.tsx +126 -0
- package/packages/selector/src/css-modules.d.ts +6 -0
- package/packages/selector/src/deepseek-v4-tokenizer.ts +210 -0
- package/packages/selector/src/estimator-catalog.ts +104 -0
- package/packages/selector/src/index.ts +327 -0
- package/packages/selector/src/invariant.ts +113 -0
- package/packages/selector/src/preset-overlay.ts +567 -0
- package/packages/selector/src/profiles.ts +342 -0
- package/packages/selector/src/pruner/content.ts +188 -0
- package/packages/selector/src/pruner/session.ts +94 -0
- package/packages/selector/src/pruner/state.ts +43 -0
- package/packages/selector/src/pruner/tuning.ts +23 -0
- package/packages/selector/src/pruner/types.ts +60 -0
- package/packages/selector/src/pruner.ts +2144 -0
- package/packages/selector/src/runtime/adaptive-cost.ts +194 -0
- package/packages/selector/src/runtime/audit.ts +215 -0
- package/packages/selector/src/runtime/config.ts +613 -0
- package/packages/selector/src/runtime/custom-policy.ts +278 -0
- package/packages/selector/src/runtime/deepseek-official-pricing.ts +298 -0
- package/packages/selector/src/runtime/deepseek-v4-vision-tokens.ts +254 -0
- package/packages/selector/src/runtime/measurement.ts +403 -0
- package/packages/selector/src/runtime/reducers.ts +656 -0
- package/packages/selector/src/runtime/retrieve.ts +457 -0
- package/packages/selector/src/runtime/session-events.ts +17 -0
- package/packages/selector/src/runtime/tail-trim.ts +166 -0
- package/packages/selector/src/runtime/token-count.ts +72 -0
- package/packages/selector/src/runtime/tokenpilot/dedup.ts +81 -0
- package/packages/selector/src/runtime/tokenpilot/estimator.ts +183 -0
- package/packages/selector/src/runtime/tokenpilot/locator.ts +128 -0
- package/packages/selector/src/runtime/tokenpilot/read-state.ts +77 -0
- package/packages/selector/src/runtime/types.ts +309 -0
- package/packages/selector/src/runtime/value.ts +48 -0
- package/packages/selector/tests/auto-compact.client.spec.tsx +226 -0
- package/packages/selector/tests/built/client-artifact.spec.ts +51 -0
- package/packages/selector/tests/cache-prefix-audit.spec.ts +123 -0
- package/packages/selector/tests/code-skeleton.client.spec.ts +88 -0
- package/packages/selector/tests/custom-contract.client.spec.ts +202 -0
- package/packages/selector/tests/estimator-catalog.spec.ts +70 -0
- package/packages/selector/tests/estimator-channel.client.spec.tsx +247 -0
- package/packages/selector/tests/estimator-route-registration.host.spec.ts +176 -0
- package/packages/selector/tests/host-preset-overlay.host.spec.ts +204 -0
- package/packages/selector/tests/preset-options-write.client.spec.ts +181 -0
- package/packages/selector/tests/preset-overlay-loader.e2e.host.spec.ts +196 -0
- package/packages/selector/tests/preset-overlay.host.spec.ts +243 -0
- package/packages/selector/tests/profiles.client.spec.tsx +434 -0
- package/packages/selector/tests/public/package-contract.client.spec.ts +33 -0
- package/packages/selector/tests/runtime/adaptive-cost.spec.ts +167 -0
- package/packages/selector/tests/runtime/audit.spec.ts +129 -0
- package/packages/selector/tests/runtime/auto-compact-config.spec.ts +523 -0
- package/packages/selector/tests/runtime/code-skeleton.spec.ts +141 -0
- package/packages/selector/tests/runtime/deepseek-official-pricing.spec.ts +186 -0
- package/packages/selector/tests/runtime/deepseek-v4-tokenizer.spec.ts +122 -0
- package/packages/selector/tests/runtime/deepseek-v4-vision-tokens.spec.ts +122 -0
- package/packages/selector/tests/runtime/fixtures/profile-baseline.json +273 -0
- package/packages/selector/tests/runtime/fixtures/tokenizer-golden.json +106 -0
- package/packages/selector/tests/runtime/fixtures/vision-golden.json +459 -0
- package/packages/selector/tests/runtime/public/public-runtime.spec.ts +2531 -0
- package/packages/selector/tests/runtime/session-events.spec.ts +27 -0
- package/packages/selector/tests/runtime/tokenizer-golden.spec.ts +53 -0
- package/packages/selector/tests/runtime/tokenpilot/dedup.spec.ts +52 -0
- package/packages/selector/tests/runtime/tokenpilot/estimator.spec.ts +56 -0
- package/packages/selector/tests/runtime/tokenpilot/locator.spec.ts +76 -0
- package/packages/selector/tests/runtime/tokenpilot/profile-baseline.spec.ts +100 -0
- package/packages/selector/tests/runtime/tokenpilot/read-state.spec.ts +58 -0
- package/packages/selector/tests/runtime/value.spec.ts +23 -0
- package/packages/selector/tests/standing-generation.host.spec.ts +631 -0
- package/packages/selector/tests/subagent-cache-reuse.host.spec.ts +250 -0
- package/packages/selector/tests/support/cache-prefix-audit.ts +105 -0
- package/packages/selector/tests/support/mock-adapter.ts +37 -0
- package/packages/selector/tests/support/ui-primitives.tsx +34 -0
- package/packages/selector/tsconfig.json +11 -0
- package/packages/selector/tsdown.client.config.ts +102 -0
- package/packages/selector/tsdown.config.ts +20 -0
- package/pnpm-workspace.yaml +19 -0
- package/scripts/capture-profile-baseline.ts +80 -0
- package/scripts/generate-tokenizer-fixtures.py +81 -0
- package/scripts/generate-vision-fixtures.py +208 -0
- package/scripts/packed-components-smoke.ts +713 -0
- package/scripts/packed-install-e2e.ts +1072 -0
- package/scripts/verify-release.ts +300 -0
- package/tests/TEST_INVENTORY.md +42 -0
- package/tsconfig.base.json +18 -0
- package/tsconfig.json +7 -0
- package/tsconfig.scripts.json +13 -0
- package/tsconfig.tests.json +15 -0
- package/vitest.built.config.ts +9 -0
- package/vitest.config.ts +43 -0
- /package/{assets → packages/selector/assets}/deepseek-v4/LICENSE.DeepSeek-V4-Pro.txt +0 -0
- /package/{assets → packages/selector/assets}/deepseek-v4/manifest.json +0 -0
- /package/{assets → packages/selector/assets}/deepseek-v4/tokenizer.json +0 -0
- /package/{assets → packages/selector/assets}/deepseek-v4/tokenizer_config.json +0 -0
- /package/{assets → packages/selector/assets}/deepseek-v4-vision-exp/LICENSE.DeepSeek-V4-Flash-Vision-Exp.txt +0 -0
- /package/{assets → packages/selector/assets}/deepseek-v4-vision-exp/manifest.json +0 -0
- /package/{assets → packages/selector/assets}/deepseek-v4-vision-exp/tokenizer.json +0 -0
- /package/{assets → packages/selector/assets}/deepseek-v4-vision-exp/tokenizer_config.json +0 -0
- /package/{assets → packages/selector/assets}/screenshots/context-compression-selector-profiles.jpg +0 -0
- /package/{assets → packages/selector/assets}/screenshots/context-compression-selector-settings.png +0 -0
- /package/{cordis.patch.yml → packages/selector/cordis.patch.yml} +0 -0
- /package/{dsh.plugin.json → packages/selector/dsh.plugin.json} +0 -0
- /package/{lib → packages/selector/lib}/client.d.ts +0 -0
- /package/{lib → packages/selector/lib}/client.js +0 -0
- /package/{lib → packages/selector/lib}/config.js +0 -0
- /package/{lib → packages/selector/lib}/index.d.ts +0 -0
- /package/{lib → packages/selector/lib}/index.js +0 -0
- /package/{lib → packages/selector/lib}/invariant.d.ts +0 -0
- /package/{lib → packages/selector/lib}/invariant.js +0 -0
- /package/{lib → packages/selector/lib}/pruner.d.ts +0 -0
- /package/{lib → packages/selector/lib}/pruner.js +0 -0
- /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
|
+
}
|