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,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
|
+
}
|