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,70 @@
1
+ /**
2
+ * Field-wise writes for the `presetOptions` section of the
3
+ * context-compression settings namespace.
4
+ *
5
+ * `settingsScope.set(field, value)` replaces the value AT that field, so
6
+ * writing the section root with a patch deleted every sibling: choosing a
7
+ * provider after choosing a channel erased `estimatorMode` and silently turned
8
+ * the estimator back off, and the stored document kept exactly the last field
9
+ * the user touched. The client reads the complete section (this namespace
10
+ * declares no secret fields), so a patch is applied over the current document
11
+ * and the merged result is what gets written.
12
+ *
13
+ * A patch member set to `undefined` means "clear this field" (it then
14
+ * re-inherits the preset default); absent members stay untouched.
15
+ */
16
+
17
+ import type { PresetOptionsSettings } from '../profiles.ts'
18
+
19
+ /** Every field a patch may address, in the schema's own order. */
20
+ const PRESET_OPTION_KEYS = [
21
+ 'dedupeToolResults', 'summaryLocator', 'prefixStabilizer', 'readState', 'estimatorMode',
22
+ 'estimatorProvider', 'estimatorModel', 'estimatorBaseUrl', 'estimatorApiKey', 'estimatorTimeoutMs',
23
+ ] as const
24
+
25
+ /** One partial edit of `presetOptions`; `undefined` clears the named field. */
26
+ export type PresetOptionsPatch = {
27
+ readonly [K in keyof PresetOptionsSettings]?: PresetOptionsSettings[K] | undefined
28
+ }
29
+
30
+ /**
31
+ * Apply one patch over the stored section.
32
+ *
33
+ * @param current - the decoded `presetOptions` section, when one is stored.
34
+ * @param patch - the fields to write or clear.
35
+ * @returns the complete section to store.
36
+ */
37
+ export function mergePresetOptionsPatch(
38
+ current: PresetOptionsSettings | undefined,
39
+ patch: PresetOptionsPatch,
40
+ ): PresetOptionsSettings {
41
+ const source = current as Record<string, unknown> | undefined
42
+ const merged: Record<string, unknown> = {}
43
+ for (const key of PRESET_OPTION_KEYS) {
44
+ const stored = source?.[key]
45
+ if (stored !== undefined) merged[key] = stored
46
+ }
47
+ for (const key of Object.keys(patch)) {
48
+ const value = (patch as Record<string, unknown>)[key]
49
+ if (value === undefined) delete merged[key]
50
+ else merged[key] = value
51
+ }
52
+ return merged as PresetOptionsSettings
53
+ }
54
+
55
+ /**
56
+ * Compare two sections field by field, so an unchanged patch neither rewrites
57
+ * the document nor reports a save the Host never committed.
58
+ *
59
+ * @param left - one section (or none).
60
+ * @param right - the other section.
61
+ * @returns whether every known field holds the same value.
62
+ */
63
+ export function presetOptionsEqual(
64
+ left: PresetOptionsSettings | undefined,
65
+ right: PresetOptionsSettings | undefined,
66
+ ): boolean {
67
+ const a = left as Record<string, unknown> | undefined
68
+ const b = right as Record<string, unknown> | undefined
69
+ return PRESET_OPTION_KEYS.every(key => a?.[key] === b?.[key])
70
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Full-page Settings surface backed by the same durable selector state.
3
+ *
4
+ * Extracted from CompressionProfileSelector.tsx to reduce god-module size.
5
+ *
6
+ * @module dsh-context-compression-improved/client/settings-section
7
+ */
8
+
9
+ import { useEffect, useState } from 'react'
10
+ import css from './CompressionProfileSelector.module.css'
11
+ import {
12
+ AUTO_COMPACT_THRESHOLD_LIMITS,
13
+ COMPRESSION_PROFILES,
14
+ type CompressionProfile,
15
+ type CustomCompressionPolicyV3,
16
+ } from '../profiles.ts'
17
+ import type { CompressionProfileSelectorProps } from './CompressionProfileSelector.tsx'
18
+ import { AutoCompactThresholdControls, CodeSkeletonControls } from './CompressionProfileControls.tsx'
19
+ import { EstimatorControls, EstimatorInactiveNotice } from './EstimatorControls.tsx'
20
+ import { CustomPolicyEditor, editableCustom } from './CustomPolicyEditor.tsx'
21
+
22
+ /** Full-page Settings surface backed by the same durable selector state. */
23
+ export function ContextCompressionSettingsSection(props: CompressionProfileSelectorProps) {
24
+ return <SettingsCompressionProfileControls {...props} />
25
+ }
26
+
27
+ export function SettingsCompressionProfileControls({
28
+ useCompression, useSessions, select, saveCustom, resetCustom, saveAutoCompact, saveCodeSkeleton,
29
+ savePresetOptions, t,
30
+ }: CompressionProfileSelectorProps) {
31
+ const state = useCompression(snapshot => snapshot)
32
+ const currentPreset = useSessions((sessions) => {
33
+ const current = sessions.current
34
+ return current === undefined ? undefined : sessions.byId[current]?.agentPreset
35
+ })
36
+ const selectorAvailable = currentPreset !== 'minimal'
37
+ const [saving, setSaving] = useState(false)
38
+ const [saveError, setSaveError] = useState<string | null>(null)
39
+ const [draft, setDraft] = useState<CustomCompressionPolicyV3 | null>(null)
40
+ const current = state.value?.profile ?? 'balanced'
41
+ useEffect(() => {
42
+ const custom = state.value?.custom
43
+ setDraft(current === 'custom' && custom !== undefined ? editableCustom(custom) : null)
44
+ }, [current, state.value?.custom])
45
+ if (state.status === 'unavailable') return null
46
+
47
+ const busy = state.status === 'loading' || saving
48
+ const selectProfile = (profile: CompressionProfile): void => {
49
+ if (!selectorAvailable || !state.writable || profile === current) return
50
+ setSaveError(null)
51
+ setSaving(true)
52
+ void select(profile).then(
53
+ () => { setSaving(false) },
54
+ (error: unknown) => {
55
+ setSaving(false)
56
+ setSaveError(error instanceof Error && error.message !== '' ? error.message : t('status.saveFailed'))
57
+ },
58
+ )
59
+ }
60
+ const settle = (operation: () => Promise<void>): void => {
61
+ setSaveError(null)
62
+ setSaving(true)
63
+ void operation().then(
64
+ () => { setSaving(false) },
65
+ (error: unknown) => {
66
+ setSaving(false)
67
+ setSaveError(error instanceof Error && error.message !== '' ? error.message : t('status.saveFailed'))
68
+ },
69
+ )
70
+ }
71
+
72
+ return (
73
+ <section className={css.settingsSection}>
74
+ <h2 className={css.settingsTitle}>{t('settings.title')}</h2>
75
+ <p className={css.settingsDescription}>{t('settings.description')}</p>
76
+ {selectorAvailable ? (
77
+ <div className={css.profileGrid} aria-label={t('label')}>
78
+ {COMPRESSION_PROFILES.map((profile) => {
79
+ const selected = profile === current
80
+ return (
81
+ <button key={profile} type="button" className={css.profileCard} aria-pressed={selected}
82
+ disabled={busy || !state.writable} onClick={() => { selectProfile(profile) }}>
83
+ <span className={css.profileCardTop}>
84
+ <span className={css.profileCardTitle}>{t(`profile.${profile}`)}</span>
85
+ {selected ? <span className={css.profileCurrent}>{t('profile.current')}</span> : null}
86
+ </span>
87
+ <span className={css.profileCardDetail}>{t(`detail.${profile}`)}</span>
88
+ </button>
89
+ )
90
+ })}
91
+ </div>
92
+ ) : <div className={css.unavailable} role="status">{t('status.minimalUnavailable')}</div>}
93
+ <AutoCompactThresholdControls
94
+ value={state.value?.autoCompact?.thresholdPercent ?? AUTO_COMPACT_THRESHOLD_LIMITS.default}
95
+ disabled={busy || !state.writable || !selectorAvailable}
96
+ save={saveAutoCompact}
97
+ settle={settle}
98
+ t={t}
99
+ />
100
+ <CodeSkeletonControls
101
+ value={state.value?.codeSkeleton?.enabled ?? false}
102
+ disabled={busy || !state.writable || !selectorAvailable}
103
+ save={saveCodeSkeleton}
104
+ settle={settle}
105
+ t={t}
106
+ />
107
+ {current !== 'tokenpilot-inspired' ? (
108
+ <EstimatorInactiveNotice profile={t(`profile.${current}`)} t={t} />
109
+ ) : (
110
+ <EstimatorControls
111
+ options={state.value?.presetOptions ?? {}}
112
+ disabled={busy || !state.writable || !selectorAvailable}
113
+ save={savePresetOptions}
114
+ settle={settle}
115
+ t={t}
116
+ />
117
+ )}
118
+ <div className={css.pricing}>{t('pricing.disclosure')}</div>
119
+ {current !== 'custom' || draft === null || !selectorAvailable ? null : (
120
+ <CustomPolicyEditor value={draft} disabled={busy || !state.writable} setValue={setDraft}
121
+ save={() => saveCustom(structuredClone(draft))} reset={resetCustom} settle={settle} t={t} />
122
+ )}
123
+ {saveError === null ? null : <div className={css.error} role="alert">{saveError}</div>}
124
+ </section>
125
+ )
126
+ }
@@ -0,0 +1,6 @@
1
+ declare module '*.module.css' {
2
+ const classes: Record<string, string>
3
+ export default classes
4
+ }
5
+
6
+ declare module '*.css'
@@ -0,0 +1,210 @@
1
+ /** Offline DeepSeek tokenizers backed by pinned official Hugging Face assets. */
2
+
3
+ import { createHash } from 'node:crypto'
4
+ import { readFileSync } from 'node:fs'
5
+ import { Tokenizer } from '@huggingface/tokenizers'
6
+ import type { ExactTokenizerTokenCount } from './runtime/token-count.ts'
7
+
8
+ const TOKENIZER_ID = 'deepseek-ai/DeepSeek-V4-Pro'
9
+ const TOKENIZER_REVISION = '0e1a0e5e52aea73055f50fef6f2423db370265b6'
10
+ const TOKENIZER_SHA256 = '8f9f37ca37fdc4f5fd36d5cf4d3b0e8392edb4e894fd10cc0d70b4957c8633cf'
11
+ const CONFIG_SHA256 = '6ac8c8dc065ed118161d02dd532749ae3f52c243deac27872134fae2f50d8547'
12
+ const VISION_TOKENIZER_ID = 'deepseek-ai/DeepSeek-V4-Flash-Vision-Exp'
13
+ const VISION_TOKENIZER_REVISION = '6821d6ad3681a4b137b066b76094fa82ebd0a380'
14
+ const VISION_TOKENIZER_SHA256 = 'c90dfa01249db1be4245780a052ede752e1361c612ac6d08e2bdada7d599476b'
15
+ const VISION_CONFIG_SHA256 = '6ac8c8dc065ed118161d02dd532749ae3f52c243deac27872134fae2f50d8547'
16
+
17
+ /** Auditable origin and compatibility mapping for one bundled tokenizer. */
18
+ export interface DeepSeekTokenizerArtifactOrigin {
19
+ /** Hugging Face repository the assets were pinned from. */
20
+ readonly repository: string
21
+ /** Immutable commit revision the assets were pinned from. */
22
+ readonly revision: string
23
+ /** Upstream license of the repository. */
24
+ readonly license: string
25
+ /** Exact API wire model ids served by this artifact. */
26
+ readonly modelIds: readonly string[]
27
+ readonly tokenizerSha256: string
28
+ readonly tokenizerConfigSha256: string
29
+ }
30
+
31
+ /** Auditable origin and compatibility mapping for the bundled V4 Pro tokenizer. */
32
+ export const DEEPSEEK_V4_TOKENIZER_ARTIFACT: DeepSeekTokenizerArtifactOrigin = Object.freeze({
33
+ repository: TOKENIZER_ID,
34
+ revision: TOKENIZER_REVISION,
35
+ license: 'MIT',
36
+ tokenizerSha256: TOKENIZER_SHA256,
37
+ tokenizerConfigSha256: CONFIG_SHA256,
38
+ modelIds: Object.freeze(['deepseek-v4-flash', 'deepseek-v4-pro']),
39
+ })
40
+
41
+ /**
42
+ * Auditable origin and compatibility mapping for the bundled V4 Flash Vision
43
+ * tokenizer. The vision repository ships a distinct `tokenizer.json` (it adds
44
+ * the `<|deepseek_image|>` special token), so the vision model must never be
45
+ * mapped onto the V4 Pro tokenizer as an alias.
46
+ */
47
+ export const DEEPSEEK_VISION_TOKENIZER_ARTIFACT: DeepSeekTokenizerArtifactOrigin = Object.freeze({
48
+ repository: VISION_TOKENIZER_ID,
49
+ revision: VISION_TOKENIZER_REVISION,
50
+ license: 'MIT',
51
+ tokenizerSha256: VISION_TOKENIZER_SHA256,
52
+ tokenizerConfigSha256: VISION_CONFIG_SHA256,
53
+ modelIds: Object.freeze(['deepseek-v4-flash-vision-exp']),
54
+ })
55
+
56
+ /** Synchronous exact counter backed only by the verified local V4 artifacts. */
57
+ export interface DeepSeekV4TextTokenizer {
58
+ /** Count one fully determined text value with the official tokenizer. */
59
+ countText(text: string): ExactTokenizerTokenCount
60
+ }
61
+
62
+ /** Integrity tuple for one tokenizer JSON asset. @internal */
63
+ export interface DeepSeekV4TokenizerAssetDescriptor {
64
+ readonly bytes: number
65
+ readonly sha256: string
66
+ }
67
+
68
+ /** Injectable integrity manifest used by negative loader tests. @internal */
69
+ export interface DeepSeekV4TokenizerAssetIntegrity {
70
+ readonly tokenizer: DeepSeekV4TokenizerAssetDescriptor
71
+ readonly config: DeepSeekV4TokenizerAssetDescriptor
72
+ }
73
+
74
+ interface TokenizerArtifact {
75
+ readonly origin: DeepSeekTokenizerArtifactOrigin
76
+ readonly assetRoot: URL
77
+ readonly integrity: DeepSeekV4TokenizerAssetIntegrity
78
+ }
79
+
80
+ /**
81
+ * Every bundled artifact is registered with independent asset roots, integrity
82
+ * manifests, and cache entries: one corrupted artifact must never disable the
83
+ * tokenizer serving the other model family.
84
+ *
85
+ * This module deliberately sits at the package's `src/` root rather than in
86
+ * `src/runtime/`: the build flattens the runtime into `lib/*.js`, so only a
87
+ * module one level below the package root resolves `../assets/` identically
88
+ * from source and from the published artifact.
89
+ */
90
+ const ARTIFACTS: readonly TokenizerArtifact[] = Object.freeze([
91
+ Object.freeze({
92
+ origin: DEEPSEEK_V4_TOKENIZER_ARTIFACT,
93
+ assetRoot: new URL('../assets/deepseek-v4/', import.meta.url),
94
+ integrity: Object.freeze({
95
+ tokenizer: Object.freeze({ bytes: 6_367_146, sha256: TOKENIZER_SHA256 }),
96
+ config: Object.freeze({ bytes: 801, sha256: CONFIG_SHA256 }),
97
+ }),
98
+ }),
99
+ Object.freeze({
100
+ origin: DEEPSEEK_VISION_TOKENIZER_ARTIFACT,
101
+ assetRoot: new URL('../assets/deepseek-v4-vision-exp/', import.meta.url),
102
+ integrity: Object.freeze({
103
+ tokenizer: Object.freeze({ bytes: 6_367_257, sha256: VISION_TOKENIZER_SHA256 }),
104
+ config: Object.freeze({ bytes: 801, sha256: VISION_CONFIG_SHA256 }),
105
+ }),
106
+ }),
107
+ ])
108
+
109
+ interface ArtifactCacheEntry {
110
+ readonly tokenizer?: DeepSeekV4TextTokenizer
111
+ readonly failure?: string
112
+ }
113
+
114
+ const registryCache = new Map<DeepSeekTokenizerArtifactOrigin, ArtifactCacheEntry>()
115
+
116
+ function artifactForModel(modelId: string): TokenizerArtifact | undefined {
117
+ return ARTIFACTS.find(artifact => (artifact.origin.modelIds as readonly string[]).includes(modelId))
118
+ }
119
+
120
+ /**
121
+ * Resolve the shared offline tokenizer for one compatible API model.
122
+ * Unknown models and a cached asset/runtime failure return `undefined`; callers
123
+ * must report unavailable instead of manufacturing a character estimate.
124
+ * @param modelId - exact DeepSeek API wire model id.
125
+ * @returns the shared verified tokenizer, or undefined when unsupported/unavailable.
126
+ */
127
+ export function deepSeekV4TokenizerForModel(modelId: string): DeepSeekV4TextTokenizer | undefined {
128
+ const artifact = artifactForModel(modelId)
129
+ if (artifact === undefined) return undefined
130
+ const cached = registryCache.get(artifact.origin)
131
+ if (cached !== undefined) return cached.tokenizer
132
+ let entry: ArtifactCacheEntry
133
+ try {
134
+ const tokenizer = createDeepSeekV4TokenizerFromAssets(artifact.assetRoot, artifact.integrity, artifact.origin)
135
+ entry = { tokenizer }
136
+ } catch (error: unknown) {
137
+ entry = { failure: error instanceof Error ? error.message : String(error) }
138
+ }
139
+ registryCache.set(artifact.origin, entry)
140
+ return entry.tokenizer
141
+ }
142
+
143
+ /**
144
+ * Read the cached initialization diagnostic for provider registration.
145
+ * @param modelId - optional exact model id; defaults to the V4 Pro artifact.
146
+ * @returns the first initialization failure for that artifact, or undefined.
147
+ */
148
+ export function deepSeekV4TokenizerFailureReason(modelId?: string): string | undefined {
149
+ const artifact = modelId === undefined ? ARTIFACTS[0] : artifactForModel(modelId)
150
+ if (artifact === undefined) return undefined
151
+ return registryCache.get(artifact.origin)?.failure
152
+ }
153
+
154
+ /** Complete pinned artifact inventory, in stable registration order. */
155
+ export function deepSeekTokenizerArtifacts(): readonly DeepSeekTokenizerArtifactOrigin[] {
156
+ return ARTIFACTS.map(artifact => artifact.origin)
157
+ }
158
+
159
+ /**
160
+ * Build a tokenizer from one local asset directory after byte/hash validation.
161
+ * This provider-private seam exists so tests can prove every failure branch
162
+ * without mutating the committed artifact.
163
+ * @param assetRoot - local URL containing tokenizer.json and tokenizer_config.json.
164
+ * @param integrity - expected byte length and SHA-256 for both files.
165
+ * @param origin - auditable identity recorded on every returned count.
166
+ * @returns a synchronous exact text counter.
167
+ * @internal
168
+ */
169
+ export function createDeepSeekV4TokenizerFromAssets(
170
+ assetRoot: URL,
171
+ integrity: DeepSeekV4TokenizerAssetIntegrity,
172
+ origin: DeepSeekTokenizerArtifactOrigin = DEEPSEEK_V4_TOKENIZER_ARTIFACT,
173
+ ): DeepSeekV4TextTokenizer {
174
+ const tokenizerJson = readVerifiedJson(assetRoot, 'tokenizer.json', integrity.tokenizer)
175
+ const tokenizerConfig = readVerifiedJson(assetRoot, 'tokenizer_config.json', integrity.config)
176
+ const runtime = new Tokenizer(tokenizerJson, tokenizerConfig)
177
+ return Object.freeze({
178
+ countText(text: string): ExactTokenizerTokenCount {
179
+ const tokens = runtime.encode(text, { add_special_tokens: false }).ids.length
180
+ return Object.freeze({
181
+ kind: 'exact-tokenizer',
182
+ tokens,
183
+ tokenizerId: origin.repository,
184
+ tokenizerRevision: origin.revision,
185
+ })
186
+ },
187
+ })
188
+ }
189
+
190
+ function readVerifiedJson(
191
+ assetRoot: URL,
192
+ name: string,
193
+ descriptor: DeepSeekV4TokenizerAssetDescriptor,
194
+ ): Record<string, unknown> {
195
+ const bytes = readFileSync(new URL(name, assetRoot))
196
+ if (bytes.byteLength !== descriptor.bytes) {
197
+ throw new Error(
198
+ `DeepSeek tokenizer asset ${name} has ${String(bytes.byteLength)} bytes; expected ${String(descriptor.bytes)}`,
199
+ )
200
+ }
201
+ const actualSha256 = createHash('sha256').update(bytes).digest('hex')
202
+ if (actualSha256 !== descriptor.sha256) {
203
+ throw new Error(`DeepSeek tokenizer asset ${name} failed SHA-256 verification`)
204
+ }
205
+ const parsed: unknown = JSON.parse(bytes.toString('utf8'))
206
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
207
+ throw new Error(`DeepSeek tokenizer asset ${name} must contain a JSON object`)
208
+ }
209
+ return parsed as Record<string, unknown>
210
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Estimator model-catalog projection for the settings card: the live
3
+ * provider/model-group catalog from the DSH `llm` service — including
4
+ * user-configured custom groups — plus the effective host selection
5
+ * (override → agentDefaultModel.currentSelection). The same projection seam
6
+ * dsh-perm-gate's receiver-info and dsh-prime-memory's llm-providers view use.
7
+ *
8
+ * Pure projection: never throws; per-provider failures are reported inline so
9
+ * one broken group never blanks the whole list.
10
+ */
11
+
12
+ /** One model entry as offered by a provider group. */
13
+ export interface CatalogModel {
14
+ readonly id: string
15
+ readonly name: string
16
+ }
17
+
18
+ /** One provider group and its models (or the reason it failed to enumerate). */
19
+ export interface CatalogProvider {
20
+ readonly id: string
21
+ readonly name: string
22
+ readonly models: readonly CatalogModel[]
23
+ readonly error?: string
24
+ }
25
+
26
+ /** What the settings card needs to render the host-route dropdowns. */
27
+ export interface EstimatorCatalog {
28
+ /** Live provider groups; empty when the `llm` service is unavailable. */
29
+ readonly providers: readonly CatalogProvider[]
30
+ /** The provider/model the host channel would use right now (undefined when undetermined). */
31
+ readonly selection?: { readonly provider: string, readonly model: string }
32
+ }
33
+
34
+ /** The minimal faces of the host services this projection reads. */
35
+ export interface EstimatorCatalogDeps {
36
+ llm?: {
37
+ listProviders?: () => readonly { id: string, name: string }[]
38
+ listModels?: (providerId: string) => Promise<readonly { id: string, name: string }[]>
39
+ /** Fallback enumeration source for builds whose `listModels` is absent or dormant. */
40
+ listConfigurableProviders?: () => readonly { provider: string, settingsNs: string }[]
41
+ /** Discovery for a configured route answers from the adapter's own knowledge — no network call. */
42
+ discoverModels?: (settingsNs: string, request: { provider?: string }) => Promise<readonly { id: string, name?: string }[]>
43
+ }
44
+ /** Live default-model selection (optional `agentDefaultModel` service). */
45
+ currentSelection?: () => { provider?: unknown, model?: unknown } | undefined
46
+ /** Explicit estimator overrides (empty string = follow the host selection). */
47
+ overrideProvider?: string
48
+ overrideModel?: string
49
+ }
50
+
51
+ function str(value: unknown): string {
52
+ return typeof value === 'string' ? value : ''
53
+ }
54
+
55
+ /** Resolve the effective host model route (override → session default). */
56
+ export function resolveHostRoute(deps: EstimatorCatalogDeps): { provider: string, model: string } | undefined {
57
+ const overrideProvider = str(deps.overrideProvider)
58
+ const overrideModel = str(deps.overrideModel)
59
+ const selected = deps.currentSelection?.()
60
+ const selectedProvider = str(selected?.provider)
61
+ const selectedModel = str(selected?.model)
62
+ const provider = overrideProvider !== '' ? overrideProvider : selectedProvider
63
+ const model = overrideModel !== '' ? overrideModel : selectedModel
64
+ if (provider === '' || model === '') return undefined
65
+ return { provider, model }
66
+ }
67
+
68
+ /** Build the catalog projection. Never throws. */
69
+ export async function buildEstimatorCatalog(deps: EstimatorCatalogDeps): Promise<EstimatorCatalog> {
70
+ if (deps.llm?.listProviders === undefined) {
71
+ const selection = resolveHostRoute(deps)
72
+ return selection === undefined ? { providers: [] } : { providers: [], selection }
73
+ }
74
+ const selection = resolveHostRoute(deps)
75
+ const raw = deps.llm.listProviders()
76
+ const providers = await Promise.all(raw.map(async (p): Promise<CatalogProvider> => {
77
+ let models: CatalogModel[] = []
78
+ let error: string | undefined
79
+ try {
80
+ models = (await deps.llm?.listModels?.(p.id) ?? []).map(m => ({ id: m.id, name: m.name }))
81
+ } catch (e) {
82
+ error = String((e as Error)?.message ?? e)
83
+ }
84
+ if (models.length === 0) {
85
+ // Fallback: route discovery. For an already-configured route the
86
+ // adapter answers from its own stored knowledge (no network call).
87
+ try {
88
+ const entry = deps.llm?.listConfigurableProviders?.().find(c => c.provider === p.id)
89
+ if (entry !== undefined && deps.llm?.discoverModels !== undefined) {
90
+ const discovered = await deps.llm.discoverModels(entry.settingsNs, { provider: p.id })
91
+ if (discovered.length > 0) {
92
+ models = discovered.map(m => ({ id: m.id, name: m.name ?? m.id }))
93
+ error = undefined
94
+ }
95
+ }
96
+ } catch {
97
+ // keep the primary error / empty state
98
+ }
99
+ }
100
+ if (models.length === 0 && error === undefined) error = 'no models advertised'
101
+ return { id: p.id, name: p.name, models, ...(error === undefined ? {} : { error }) }
102
+ }))
103
+ return { providers, ...(selection === undefined ? {} : { selection }) }
104
+ }