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,72 @@
1
+ /** Token counts owned by the standalone compression runtime. */
2
+
3
+ /** Exact count of one canonical value under a pinned tokenizer artifact. */
4
+ export interface ExactTokenizerTokenCount {
5
+ readonly kind: 'exact-tokenizer'
6
+ readonly tokens: number
7
+ readonly tokenizerId: string
8
+ readonly tokenizerRevision: string
9
+ }
10
+
11
+ /** A value that the bundled tokenizer cannot safely count. */
12
+ export interface UnavailableTokenCount {
13
+ readonly kind: 'unavailable'
14
+ readonly reason: string
15
+ }
16
+
17
+ /** Best-effort estimate carrying a conservative upper value when available. */
18
+ export interface TokenizerEstimateTokenCount {
19
+ readonly kind: 'tokenizer-estimate'
20
+ readonly tokens: number
21
+ readonly upperBoundTokens: number
22
+ readonly estimatorId: string
23
+ readonly estimatorRevision: string
24
+ readonly calibration?: Readonly<{
25
+ readonly sampleCount: number
26
+ readonly conservativeMarginTokens: number
27
+ }>
28
+ }
29
+
30
+ /** Exact, conservative request estimate, or an explicit refusal. */
31
+ export type TokenCount = ExactTokenizerTokenCount | TokenizerEstimateTokenCount | UnavailableTokenCount
32
+
33
+ /** Bound exact text counter for one durable provider/model request target. */
34
+ export type CanonicalTextTokenCounter = (text: string) => TokenCount
35
+
36
+ /** Build an explicit unavailable result without inventing an estimate. */
37
+ export function unavailableTokenCount(reason: string): UnavailableTokenCount {
38
+ if (reason.length === 0) throw new TypeError('unavailable token count requires a reason')
39
+ return Object.freeze({ kind: 'unavailable', reason })
40
+ }
41
+
42
+ /** Sum independent canonical fields only when every count has one identity. */
43
+ export function countExactCanonicalTextFields(
44
+ fields: readonly string[],
45
+ counter: CanonicalTextTokenCounter,
46
+ subject: string,
47
+ ): TokenCount {
48
+ if (subject.length === 0) throw new TypeError('canonical text field count requires a subject')
49
+ const values = fields.length === 0 ? [''] : fields
50
+ let identity: ExactTokenizerTokenCount | undefined
51
+ let tokens = 0
52
+ for (const value of values) {
53
+ const count = counter(value)
54
+ if (count.kind !== 'exact-tokenizer') {
55
+ return unavailableTokenCount(`${subject}: ${count.kind === 'unavailable'
56
+ ? count.reason
57
+ : 'canonical content requires an exact tokenizer count'}`)
58
+ }
59
+ if (identity !== undefined
60
+ && (identity.tokenizerId !== count.tokenizerId
61
+ || identity.tokenizerRevision !== count.tokenizerRevision)) {
62
+ return unavailableTokenCount(`${subject}: tokenizer identity changed within one measurement`)
63
+ }
64
+ identity ??= count
65
+ tokens += count.tokens
66
+ if (!Number.isSafeInteger(tokens) || tokens < 0) {
67
+ return unavailableTokenCount(`${subject}: token sum is outside the safe integer range`)
68
+ }
69
+ }
70
+ if (identity === undefined) return unavailableTokenCount(`${subject}: no tokenizer identity`)
71
+ return Object.freeze({ ...identity, tokens })
72
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * TokenPilot-inspired A1: byte-identical repeated tool-result dedup.
3
+ *
4
+ * Pure helpers behind the ToolResultPruner fresh pass. The per-session table
5
+ * maps a canonical-content SHA-256 to the first surface seq that produced it;
6
+ * later identical results may be replaced with a pointer placeholder that the
7
+ * recovery tool can resolve back to the original full text via the append-only
8
+ * session log. Only hash+seq metadata is stored — never content.
9
+ */
10
+ import { createHash } from 'node:crypto'
11
+ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
12
+
13
+ /** Canonicalization modes for content hashing. */
14
+ export type DedupeNormalization = 'trim-eol' | 'exact'
15
+
16
+ /** Entry stored per first-seen content hash. */
17
+ export interface DedupeTableEntry {
18
+ /** First surface seq carrying this canonical content. */
19
+ readonly seq: number
20
+ /** Session source reference of the first occurrence. */
21
+ readonly sourceRef: string
22
+ /** Tool name of the first occurrence. */
23
+ readonly toolName: string
24
+ /** Original full-text code points of the first occurrence. */
25
+ readonly originalChars: number
26
+ }
27
+
28
+ /** Per-session dedup index with insertion-order eviction. */
29
+ export class DedupeTable {
30
+ private readonly entries = new Map<string, DedupeTableEntry>()
31
+
32
+ constructor(private readonly maxEntries = 2_048) {}
33
+
34
+ /** Look up the first occurrence for one canonical hash, if any. */
35
+ get(hash: string): DedupeTableEntry | undefined {
36
+ return this.entries.get(hash)
37
+ }
38
+
39
+ /** Record a first occurrence; existing hashes only refresh insertion order. */
40
+ record(hash: string, entry: DedupeTableEntry): void {
41
+ if (this.entries.has(hash)) return
42
+ while (this.entries.size >= this.maxEntries) {
43
+ const oldest = this.entries.keys().next().value
44
+ if (oldest === undefined) break
45
+ this.entries.delete(oldest)
46
+ }
47
+ this.entries.set(hash, entry)
48
+ }
49
+ }
50
+
51
+ /** Canonicalize tool-result text for hashing. */
52
+ export function canonicalizeForDedupe(text: string, mode: DedupeNormalization): string {
53
+ if (mode === 'exact') return text
54
+ return text
55
+ .replace(/[ \t]+\r?\n/g, '\n')
56
+ .replace(/(^\s+)|(\s+$)/g, '')
57
+ }
58
+
59
+ /** SHA-256 hex of the canonicalized text. */
60
+ export function dedupeHash(text: string, mode: DedupeNormalization): string {
61
+ return createHash('sha256').update(canonicalizeForDedupe(text, mode), 'utf8').digest('hex')
62
+ }
63
+
64
+ /** Concatenated text of an all-text content block list; null when rich. */
65
+ export function flattenPlainText(content: readonly ContentBlock[]): string | undefined {
66
+ let text = ''
67
+ for (const block of content) {
68
+ if (block.type !== 'text') return undefined
69
+ text += block.text
70
+ }
71
+ return text
72
+ }
73
+
74
+ /** Pointer placeholder pointing at the first occurrence's original event. */
75
+ export function dedupePlaceholder(entry: DedupeTableEntry, originalChars: number): string {
76
+ return [
77
+ `[... identical to the earlier ${entry.toolName} result; first seen at ${entry.sourceRef};`,
78
+ `original_chars=${String(originalChars)};`,
79
+ 'use context_compression_retrieve with this source if the omitted evidence is necessary.]',
80
+ ].join(' ')
81
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * TokenPilot-inspired E1: lightweight zero-shot estimator channel.
3
+ *
4
+ * A small auxiliary model (TokenPilot uses a Qwen3.5-35B-A3B-class estimator)
5
+ * judges residual utility of oversized historical reads: "will the model still
6
+ * reference this file state?" Verdicts are advisory — they only extend the
7
+ * rule-only superseded classification and never block or roll back a landed
8
+ * rewrite. Any failure is fail-open.
9
+ *
10
+ * Channels: `''` (off — every consumer stays on rule-only fallbacks), `host`
11
+ * (the Harness `llm` service with the user's configured providers), `direct`
12
+ * (native OpenAI-compatible HTTP endpoint). The API key lives in session
13
+ * settings only and is never logged or audited.
14
+ */
15
+ import type { Context } from '@deepseek-ai/cordis'
16
+ import type { PresetOptionsSettings } from '../types.ts'
17
+
18
+ /** The minimal face of the Harness `llm` service this module consumes. */
19
+ export interface HostLlmLike {
20
+ stream(request: {
21
+ provider: string
22
+ model: string
23
+ messages: readonly { readonly role: 'user', readonly content: readonly { readonly type: 'text', readonly text: string }[] }[]
24
+ system?: string
25
+ temperature?: number
26
+ reasoningEffort?: string
27
+ maxTokens?: number
28
+ signal?: AbortSignal
29
+ }): AsyncIterable<{ readonly type: string, readonly text?: string }>
30
+ }
31
+
32
+ /** One sampled historical read offered to the estimator. */
33
+ export interface EstimatorSample {
34
+ readonly seq: number
35
+ readonly path: string
36
+ readonly turn: number
37
+ }
38
+
39
+ /** One estimator verdict for a sampled read. */
40
+ export interface EstimatorVerdict {
41
+ readonly seq: number
42
+ readonly expired: boolean
43
+ }
44
+
45
+ /** Per-session estimator failure bookkeeping for exponential backoff. */
46
+ export interface EstimatorFailures {
47
+ failures: number
48
+ cooldownUntil: number
49
+ }
50
+
51
+ /** Exponential backoff with a 5-minute cap: 1s, 2s, 4s, … */
52
+ export function backoffCooldownMs(failures: number): number {
53
+ return Math.min(5 * 60_000, 1_000 * 2 ** Math.max(0, failures - 1))
54
+ }
55
+
56
+ export function isCoolingDown(state: EstimatorFailures | undefined, now: number): boolean {
57
+ return state !== undefined && state.cooldownUntil > now
58
+ }
59
+
60
+ export function buildEstimatorSystemPrompt(): string {
61
+ return [
62
+ 'You are a session residual-utility estimator.',
63
+ 'For each numbered historical file read, decide whether the live agent is likely to',
64
+ 'reference that exact file state again later in the session. Reads whose file was',
65
+ 'already rewritten, or whose task has visibly moved on, are expired.',
66
+ 'Answer with ONLY a JSON array: [{"seq":<number>,"expired":<boolean>}].',
67
+ ].join(' ')
68
+ }
69
+
70
+ export function buildEstimatorUserPrompt(samples: readonly EstimatorSample[]): string {
71
+ const lines = samples.map(sample =>
72
+ `{"seq":${String(sample.seq)},"path":${JSON.stringify(sample.path)},"turn":${String(sample.turn)}}`)
73
+ return lines.join('\n')
74
+ }
75
+
76
+ /** Parse the estimator answer; anything malformed yields no verdicts. */
77
+ export function parseEstimatorAnswer(text: string): EstimatorVerdict[] {
78
+ const start = text.indexOf('[')
79
+ const end = text.lastIndexOf(']')
80
+ if (start < 0 || end <= start) return []
81
+ try {
82
+ const parsed: unknown = JSON.parse(text.slice(start, end + 1))
83
+ if (!Array.isArray(parsed)) return []
84
+ const verdicts: EstimatorVerdict[] = []
85
+ for (const entry of parsed) {
86
+ if (typeof entry !== 'object' || entry === null) continue
87
+ const record = entry as { seq?: unknown, expired?: unknown }
88
+ if (typeof record.seq !== 'number' || typeof record.expired !== 'boolean') continue
89
+ verdicts.push({ seq: record.seq, expired: record.expired })
90
+ }
91
+ return verdicts
92
+ } catch {
93
+ return []
94
+ }
95
+ }
96
+
97
+ /** One channel-bound estimator. `ask` resolves undefined on any failure. */
98
+ export class Estimator {
99
+ constructor(
100
+ private readonly ctx: Context,
101
+ private readonly options: PresetOptionsSettings,
102
+ ) {}
103
+
104
+ get enabled(): boolean {
105
+ return this.options.estimatorMode === 'host' || this.options.estimatorMode === 'direct'
106
+ }
107
+
108
+ async ask(system: string, user: string, signal: AbortSignal): Promise<string | undefined> {
109
+ const timeoutMs = this.options.estimatorTimeoutMs ?? 3_000
110
+ const timeout = AbortSignal.timeout(timeoutMs)
111
+ const signal2 = typeof AbortSignal.any === 'function' ? AbortSignal.any([signal, timeout]) : timeout
112
+ try {
113
+ if (this.options.estimatorMode === 'host') return await this.askHost(system, user, signal2)
114
+ if (this.options.estimatorMode === 'direct') return await this.askDirect(system, user, signal2)
115
+ return undefined
116
+ } catch {
117
+ return undefined
118
+ }
119
+ }
120
+
121
+ private async askHost(system: string, user: string, signal: AbortSignal): Promise<string | undefined> {
122
+ let llm: HostLlmLike | undefined
123
+ try {
124
+ llm = this.ctx.get('llm' as never) as HostLlmLike | undefined
125
+ } catch {
126
+ return undefined
127
+ }
128
+ if (llm?.stream === undefined) return undefined
129
+ const provider = this.options.estimatorProvider ?? ''
130
+ const model = this.options.estimatorModel ?? ''
131
+ if (provider.length === 0 || model.length === 0) return undefined
132
+ let text = ''
133
+ const stream = llm.stream({
134
+ provider,
135
+ model,
136
+ messages: [{ role: 'user', content: [{ type: 'text', text: user }] }],
137
+ system,
138
+ temperature: 0,
139
+ reasoningEffort: 'off',
140
+ maxTokens: 256,
141
+ signal,
142
+ })
143
+ for await (const chunk of stream) {
144
+ if ((chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') && typeof chunk.text === 'string') {
145
+ text += chunk.text
146
+ } else if (chunk.type === 'finish' && chunk.text === undefined) {
147
+ break
148
+ }
149
+ }
150
+ return text.trim().length > 0 ? text : undefined
151
+ }
152
+
153
+ private async askDirect(system: string, user: string, signal: AbortSignal): Promise<string | undefined> {
154
+ const baseUrl = this.options.estimatorBaseUrl
155
+ if (baseUrl === undefined || baseUrl.length === 0) return undefined
156
+ const headers: Record<string, string> = { 'content-type': 'application/json' }
157
+ if (this.options.estimatorApiKey !== undefined && this.options.estimatorApiKey.length > 0) {
158
+ headers.authorization = `Bearer ${this.options.estimatorApiKey}`
159
+ }
160
+ const model = this.options.estimatorModel ?? ''
161
+ if (model.length === 0) return undefined
162
+ const response = await fetch(`${baseUrl.replace(/\/+$/, '')}/chat/completions`, {
163
+ method: 'POST',
164
+ headers,
165
+ body: JSON.stringify({
166
+ model,
167
+ messages: [
168
+ { role: 'system', content: system },
169
+ { role: 'user', content: user },
170
+ ],
171
+ temperature: 0,
172
+ max_tokens: 256,
173
+ }),
174
+ signal,
175
+ })
176
+ if (!response.ok) return undefined
177
+ const payload = (await response.json()) as {
178
+ choices?: { message?: { content?: string } }[]
179
+ }
180
+ const text = payload.choices?.[0]?.message?.content
181
+ return typeof text === 'string' && text.trim().length > 0 ? text : undefined
182
+ }
183
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * TokenPilot-inspired A2: Exact Sources locator block appended to the Auto
3
+ * Compact summary checkpoint AFTER compaction/end.
4
+ *
5
+ * The block carries three kinds of locators over the shadowed range — the seq
6
+ * range itself, spill files named by the Harness spill notices, and files
7
+ * touched by read/grep-style tool calls — so details removed by the LLM
8
+ * summary remain recoverable through ordinary file reads, the recovery tool,
9
+ * or session event references. The block is only a few hundred bytes and is
10
+ * skipped entirely when it would locate nothing concrete.
11
+ */
12
+ import type { SessionEvent } from '@deepseek-ai/dsh-session'
13
+
14
+ /** One resolved compaction transaction to annotate. */
15
+ export interface CompactionTrace {
16
+ readonly compactionId: string
17
+ readonly summarySeq: number
18
+ readonly summaryShadowedRange: { readonly start: number, readonly end: number }
19
+ }
20
+
21
+ /** Files touched by read/grep-style tool calls inside the range. */
22
+ const TOUCHED_FILE_TOOL = /(?:^|[-_])?(?:read|write|edit|glob|grep|view|str_replace_editor)(?:$|[-_])/i
23
+ /** Spill notice paths emitted by the Harness output-retention policy. */
24
+ const SPILL_PATH = /stored at:\s*([^\s)\]]+)/g
25
+ /** Tool call arguments keys that commonly carry a file path. */
26
+ const PATH_KEYS = ['path', 'file_path'] as const
27
+
28
+ /**
29
+ * Find the latest compaction/summary event matching the compaction id of a
30
+ * compaction/end event. Returns undefined when the transaction cannot be
31
+ * identified — the caller must skip rather than guess.
32
+ */
33
+ export function findCompactionTrace(
34
+ events: readonly SessionEvent[],
35
+ compactionId: string,
36
+ ): CompactionTrace | undefined {
37
+ let trace: CompactionTrace | undefined
38
+ for (const event of events) {
39
+ if (event.type === 'compaction/summary' && event.data.compactionId === compactionId) {
40
+ trace = {
41
+ compactionId,
42
+ summarySeq: event.seq,
43
+ summaryShadowedRange: event.data.shadowedRange,
44
+ }
45
+ }
46
+ }
47
+ return trace
48
+ }
49
+
50
+ /** Extract spill file paths from one text chunk. */
51
+ export function extractSpillPaths(text: string): string[] {
52
+ const paths: string[] = []
53
+ for (const match of text.matchAll(SPILL_PATH)) {
54
+ const path = match[1]?.replace(/[.,;]+$/, '')
55
+ if (path !== undefined && path.length > 0) paths.push(path)
56
+ }
57
+ return paths
58
+ }
59
+
60
+ /** Extract touched file paths from one tool/call event's arguments. */
61
+ export function extractTouchedPath(name: string, argumentsText: string): string | undefined {
62
+ if (!TOUCHED_FILE_TOOL.test(name)) return undefined
63
+ let parsed: unknown
64
+ try {
65
+ parsed = JSON.parse(argumentsText)
66
+ } catch {
67
+ return undefined
68
+ }
69
+ if (typeof parsed !== 'object' || parsed === null) return undefined
70
+ const record = parsed as Record<string, unknown>
71
+ for (const key of PATH_KEYS) {
72
+ const value = record[key]
73
+ if (typeof value === 'string' && value.length > 0) return value
74
+ }
75
+ return undefined
76
+ }
77
+
78
+ /** Result of one locator block build: text plus locator census for auditing. */
79
+ export interface LocatorBlock {
80
+ readonly text: string
81
+ readonly spillFiles: number
82
+ readonly touchedFiles: number
83
+ }
84
+
85
+ /**
86
+ * Build the Exact Sources block for one shadowed range, or null when the
87
+ * range locates nothing concrete (no spill files and no touched files).
88
+ */
89
+ export function buildLocatorBlock(
90
+ events: readonly SessionEvent[],
91
+ shadowedRange: { readonly start: number, readonly end: number },
92
+ ): LocatorBlock | null {
93
+ const spillFiles = new Set<string>()
94
+ const touchedFiles = new Set<string>()
95
+ for (let seq = shadowedRange.start; seq <= shadowedRange.end && seq < events.length; seq += 1) {
96
+ const event = events[seq]
97
+ if (event === undefined) continue
98
+ if (event.type === 'tool/call') {
99
+ const path = extractTouchedPath(event.data.name, event.data.arguments)
100
+ if (path !== undefined) touchedFiles.add(path)
101
+ continue
102
+ }
103
+ if (event.type === 'tool/result' || event.type === 'user/message') {
104
+ // tool/result nests content under data.message; user/message carries it directly.
105
+ const data = event.data as { content?: unknown, message?: { content?: unknown } }
106
+ const content = Array.isArray(data.content) ? data.content : data.message?.content
107
+ if (!Array.isArray(content)) continue
108
+ for (const block of content) {
109
+ if (block.type === 'text') {
110
+ for (const path of extractSpillPaths(block.text)) spillFiles.add(path)
111
+ }
112
+ }
113
+ }
114
+ }
115
+ if (spillFiles.size === 0 && touchedFiles.size === 0) return null
116
+ const lines = [
117
+ '## Exact Sources (locators)',
118
+ `- seq range: ${String(shadowedRange.start)}-${String(shadowedRange.end)}`,
119
+ ...[...spillFiles].map(path => `- spill file: ${path}`),
120
+ ...[...touchedFiles].map(path => `- file touched: ${path}`),
121
+ '(Use `read <spill file>` or `context_compression_retrieve` with a `session://` source to restore exact text.)',
122
+ ]
123
+ return {
124
+ text: lines.join('\n'),
125
+ spillFiles: spillFiles.size,
126
+ touchedFiles: touchedFiles.size,
127
+ }
128
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * TokenPilot-inspired R2/R3: read-state semantics and clustered omission
3
+ * markers.
4
+ *
5
+ * A read result is `superseded` when a later write-style tool call mutated the
6
+ * same file: its full text can no longer match the file the model would read
7
+ * again, so aggressive aging is safe. Superseded reads may compress to a small
8
+ * aggregate placeholder instead of the ordinary historical placeholder.
9
+ */
10
+ import type { SessionEvent } from '@deepseek-ai/dsh-session'
11
+
12
+ /** Write-style tool names whose success supersedes earlier reads. */
13
+ const WRITE_TOOLS = /(?:^|[-_])?(?:write|edit|apply_patch|file_write|file_edit|str_replace|replace|multiedit)(?:$|[-_])/i
14
+ const PATH_KEYS = ['path', 'file_path'] as const
15
+
16
+ /** Parse one path out of a tool-call arguments JSON blob. */
17
+ export function toolCallPath(argumentsText: string): string | undefined {
18
+ let parsed: unknown
19
+ try {
20
+ parsed = JSON.parse(argumentsText)
21
+ } catch {
22
+ return undefined
23
+ }
24
+ if (typeof parsed !== 'object' || parsed === null) return undefined
25
+ const record = parsed as Record<string, unknown>
26
+ for (const key of PATH_KEYS) {
27
+ const value = record[key]
28
+ if (typeof value === 'string' && value.length > 0) return value
29
+ }
30
+ return undefined
31
+ }
32
+
33
+ /**
34
+ * Decide whether an oversized read result was superseded by a later mutation
35
+ * of the same file. `readPath` is the read call's target path; events after
36
+ * `readSeq` are scanned for a write-style call on it.
37
+ */
38
+ export function isSupersededRead(
39
+ events: readonly SessionEvent[],
40
+ readSeq: number,
41
+ readPath: string | undefined,
42
+ ): boolean {
43
+ if (readPath === undefined) return false
44
+ for (let seq = readSeq + 1; seq < events.length; seq += 1) {
45
+ const event = events[seq]
46
+ if (event?.type !== 'tool/call') continue
47
+ if (!WRITE_TOOLS.test(event.data.name)) continue
48
+ if (toolCallPath(event.data.arguments) === readPath) return true
49
+ }
50
+ return false
51
+ }
52
+
53
+ /** Error/warning/info line classifiers used by the omission summary. */
54
+ const ERROR_LINE = /\b(error|failed|failure|fatal|exception|traceback|cannot|unable|denied)\b/i
55
+ const WARN_LINE = /\b(warn|warning|deprecated)\b/i
56
+
57
+ /**
58
+ * Cluster one omitted line-count into an error/warn/info census appended to a
59
+ * placeholder marker, giving the model meta-knowledge about what was dropped.
60
+ */
61
+ export function clusterOmittedLines(text: string, omittedLines: number): string | undefined {
62
+ if (omittedLines <= 0) return undefined
63
+ let errors = 0
64
+ let warns = 0
65
+ let infos = 0
66
+ for (const line of text.split('\n')) {
67
+ if (ERROR_LINE.test(line)) errors += 1
68
+ else if (WARN_LINE.test(line)) warns += 1
69
+ else infos += 1
70
+ }
71
+ const parts: string[] = []
72
+ if (errors > 0) parts.push(`${String(errors)} error`)
73
+ if (warns > 0) parts.push(`${String(warns)} warn`)
74
+ if (infos > 0) parts.push(`${String(infos)} info`)
75
+ if (parts.length === 0) return undefined
76
+ return `${String(omittedLines)} lines omitted (${parts.join(', ')})`
77
+ }