@heixui/dsh-session-suite 0.0.0-stage → 0.3.1

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.
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Streaming sanitizer for the four internal step tags
3
+ * (`<发>` / `<执行>` / `<好>` / `<现在>`, U+003C + word + U+003E).
4
+ *
5
+ * These are prompt-side stepping markers, not user-facing content. Weak or
6
+ * mis-switched models occasionally emit them as a long run inside a normal
7
+ * assistant reply; once streamed they land in the durable assistant message,
8
+ * the live transcript, share exports, and every later request that replays the
9
+ * history. The only layer that can clean all of those at once is the
10
+ * `llm/stream` waterfall: filter the token deltas (live transcript) and the
11
+ * terminal `block-end` block text (what the assembler actually persists).
12
+ *
13
+ * The filter is deliberately conservative:
14
+ * - only the four exact literals are removed, never other angle-bracket text;
15
+ * - a possible partial tag at a delta boundary is held back until it can be
16
+ * classified, so `<执` + `行>` is removed across two deltas;
17
+ * - every other chunk passes through unchanged (same object), and a filter
18
+ * failure yields the original chunk instead of breaking the stream.
19
+ *
20
+ * @module dsh-session-suite/step-tag-filter
21
+ */
22
+
23
+ /** The four internal step tags, built from code points on purpose. */
24
+ export const STEP_TAGS = Object.freeze([
25
+ '\u003c发\u003e',
26
+ '\u003c执行\u003e',
27
+ '\u003c好\u003e',
28
+ '\u003c现在\u003e',
29
+ ])
30
+
31
+ /** Longest tag length, used to size the boundary carry. */
32
+ const MAX_TAG_LENGTH = STEP_TAGS.reduce((max, tag) => Math.max(max, tag.length), 0)
33
+
34
+ /**
35
+ * Remove every occurrence of the four step tags from a complete string.
36
+ * Other angle-bracket text (for example `\u003c我现在写\u003e`) is preserved.
37
+ *
38
+ * @param {string} text source text
39
+ * @returns {string} text without the four step tags
40
+ */
41
+ export function stripStepTags(text) {
42
+ if (typeof text !== 'string' || text.length === 0) return text
43
+ if (!text.includes('\u003c')) return text
44
+ let out = ''
45
+ let index = 0
46
+ while (index < text.length) {
47
+ if (text.charCodeAt(index) === 0x3c) {
48
+ let matched = false
49
+ for (const tag of STEP_TAGS) {
50
+ if (text.startsWith(tag, index)) {
51
+ index += tag.length
52
+ matched = true
53
+ break
54
+ }
55
+ }
56
+ if (matched) continue
57
+ }
58
+ out += text[index]
59
+ index += 1
60
+ }
61
+ return out
62
+ }
63
+
64
+ /**
65
+ * Per-(block kind, index) carry state for the streaming filter.
66
+ * Holds back the longest suffix that is still a proper prefix of a step tag, so
67
+ * a tag split across two deltas is removed as one unit instead of leaking a
68
+ * fragment.
69
+ */
70
+ class StepTagCarry {
71
+ /** @type {Map<string, string>} */
72
+ pending = new Map()
73
+
74
+ /**
75
+ * @param {'text'|'reasoning'} kind delta kind
76
+ * @param {number} index content-block index
77
+ * @param {string} text incoming delta
78
+ * @returns {string} text safe to emit now (may be empty)
79
+ */
80
+ push(kind, index, text) {
81
+ const key = `${kind}#${index}`
82
+ const buffer = (this.pending.get(key) ?? '') + text
83
+ const stripped = stripStepTags(buffer)
84
+ let hold = 0
85
+ const limit = Math.min(MAX_TAG_LENGTH - 1, stripped.length)
86
+ for (let length = limit; length > 0; length -= 1) {
87
+ const suffix = stripped.slice(-length)
88
+ let isPartialTag = false
89
+ for (const tag of STEP_TAGS) {
90
+ if (tag.length > length && tag.startsWith(suffix)) {
91
+ isPartialTag = true
92
+ break
93
+ }
94
+ }
95
+ if (isPartialTag) {
96
+ hold = length
97
+ break
98
+ }
99
+ }
100
+ const emit = hold === 0 ? stripped : stripped.slice(0, -hold)
101
+ this.pending.set(key, hold === 0 ? '' : stripped.slice(-hold))
102
+ return emit
103
+ }
104
+
105
+ /**
106
+ * Emit everything still carried for one key.
107
+ *
108
+ * @param {'text'|'reasoning'} kind delta kind
109
+ * @param {number} index content-block index
110
+ * @returns {Array<object>} zero or one synthetic delta chunk
111
+ */
112
+ flush(kind, index) {
113
+ const key = `${kind}#${index}`
114
+ const text = this.pending.get(key) ?? ''
115
+ this.pending.delete(key)
116
+ if (text.length === 0) return []
117
+ return [{ type: kind === 'text' ? 'text-delta' : 'reasoning-delta', index, text }]
118
+ }
119
+
120
+ /** @returns {Array<object>} one synthetic delta chunk per non-empty carry, in arrival order */
121
+ flushAll() {
122
+ const chunks = []
123
+ for (const key of [...this.pending.keys()]) {
124
+ const separator = key.indexOf('#')
125
+ const kind = key.slice(0, separator)
126
+ const index = Number(key.slice(separator + 1))
127
+ chunks.push(...this.flush(kind, index))
128
+ }
129
+ return chunks
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Wrap an LLM chunk stream, removing the four step tags from text/reasoning
135
+ * deltas and from the terminal `block-end` block text.
136
+ *
137
+ * @param {AsyncIterable<object>} stream adapter chunk stream
138
+ * @returns {AsyncGenerator<object>} sanitized chunk stream
139
+ */
140
+ export async function* stripStepTagsFromStream(stream) {
141
+ const iterator = stream[Symbol.asyncIterator]()
142
+ const carry = new StepTagCarry()
143
+ let completed = false
144
+ try {
145
+ while (true) {
146
+ const step = await iterator.next()
147
+ if (step.done === true) {
148
+ completed = true
149
+ for (const pending of carry.flushAll()) yield pending
150
+ return
151
+ }
152
+ const chunk = step.value
153
+ try {
154
+ if (
155
+ chunk !== null &&
156
+ typeof chunk === 'object' &&
157
+ (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') &&
158
+ typeof chunk.text === 'string'
159
+ ) {
160
+ const kind = chunk.type === 'text-delta' ? 'text' : 'reasoning'
161
+ const text = carry.push(kind, chunk.index, chunk.text)
162
+ if (text.length > 0) yield { ...chunk, text }
163
+ continue
164
+ }
165
+ if (chunk !== null && typeof chunk === 'object' && chunk.type === 'block-end') {
166
+ for (const pending of carry.flushAll()) yield pending
167
+ const block = chunk.block
168
+ if (
169
+ block !== null &&
170
+ typeof block === 'object' &&
171
+ (block.type === 'text' || block.type === 'reasoning') &&
172
+ typeof block.text === 'string'
173
+ ) {
174
+ const text = stripStepTags(block.text)
175
+ if (text !== block.text) {
176
+ yield { ...chunk, block: { ...block, text } }
177
+ continue
178
+ }
179
+ }
180
+ yield chunk
181
+ continue
182
+ }
183
+ for (const pending of carry.flushAll()) yield pending
184
+ yield chunk
185
+ } catch {
186
+ // A filter bug must never break the model stream: pass the chunk through.
187
+ yield chunk
188
+ }
189
+ }
190
+ } finally {
191
+ if (!completed) {
192
+ try {
193
+ await iterator.return?.()
194
+ } catch {
195
+ // Cleanup is best-effort; the consumer already abandoned the stream.
196
+ }
197
+ }
198
+ }
199
+ }
package/lib/tags.js ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Controlled vocabulary for memory tags.
3
+ *
4
+ * History: tags were whatever a caller passed, used verbatim. In practice that
5
+ * produced a library where the same domain appeared under different spellings
6
+ * (`dsh` and `DSH`), UI words leaked in as if they were domains (`快照`,
7
+ * `删除` — those are features, not subjects), and near-duplicates fragmented the
8
+ * list (`记忆` / `机制` / `项目经验`). The user's report was blunt: 标签打得有点乱.
9
+ *
10
+ * Two rules fix that without taking control away from the caller:
11
+ *
12
+ * 1. **Normalise before storing.** Case, surrounding space, full-width
13
+ * punctuation, and the half-dozen spellings a domain actually appears in
14
+ * all collapse to one canonical label.
15
+ * 2. **Refuse what is not a subject.** A tag must name a domain of knowledge.
16
+ * Words that describe the plugin's own UI, or that are pure scaffolding,
17
+ * are dropped rather than stored, because a tag nobody can filter by is
18
+ * worse than no tag.
19
+ *
20
+ * The vocabulary is a suggestion list, not a jail: an unknown tag that looks
21
+ * like a real subject is still accepted, after normalisation. Refusing all
22
+ * novelty would make the feature useless for new domains, and the user has been
23
+ * explicit that they want the system to decide, not to be asked.
24
+ */
25
+
26
+ /**
27
+ * Canonical labels, with the spellings that must fold into each.
28
+ *
29
+ * Keys are the stored form; values are recognised aliases. Chinese entries use
30
+ * the form the user's own notes use.
31
+ */
32
+ export const TAG_ALIASES = {
33
+ 错误教训: ['错误教训', '教训', '踩坑', '错误', 'bug', 'mistake', 'lesson'],
34
+ 技术方案: ['技术方案', '方案', '技术', '实现', 'design', 'technique'],
35
+ 迭代版本: ['迭代版本', '迭代', '版本', 'version', 'iteration'],
36
+ 偏差档案: ['偏差档案', '偏差', 'deviation'],
37
+ 调度: ['调度', 'scheduling', 'routing'],
38
+ 配置: ['配置', '设置', 'config', 'configuration', 'settings'],
39
+ 安全: ['安全', 'security', 'safety'],
40
+ 检索: ['检索', '搜索', 'search', 'retrieval'],
41
+ 记忆: ['记忆', 'memory'],
42
+ 模型: ['模型', 'model', 'provider'],
43
+ 工具: ['工具', 'tool', 'tools'],
44
+ 档案: ['档案', 'archive', 'profile'],
45
+ 事实: ['事实', 'fact', 'facts'],
46
+ 偏好: ['偏好', 'preference', 'preferences'],
47
+ 项目经验: ['项目经验', '经验', 'experience'],
48
+ 工作区: ['工作区', 'workspace'],
49
+ dsh: ['dsh', 'DSH', 'DeepSeek Harness', '深寻'],
50
+ }
51
+
52
+ /**
53
+ * Words that describe the plugin's own UI or are pure scaffolding. These were
54
+ * observed as tags and are not subjects: a user cannot sensibly filter a memory
55
+ * library by `全部标签`.
56
+ */
57
+ export const TAG_STOPWORDS = new Set([
58
+ '全部标签',
59
+ '快照',
60
+ '删除',
61
+ '回滚',
62
+ '重发',
63
+ '编辑',
64
+ '共装',
65
+ '降链',
66
+ '降级链',
67
+ '插件',
68
+ 'all',
69
+ 'all tags',
70
+ 'snapshot',
71
+ 'delete',
72
+ 'rollback',
73
+ 'resend',
74
+ 'edit',
75
+ 'plugin',
76
+ 'tag',
77
+ 'tags',
78
+ ])
79
+
80
+ /** @param {string} value raw tag @returns {string} comparable form */
81
+ function comparable(value) {
82
+ return String(value ?? '')
83
+ .replace(/[\u3000\s]+/g, ' ')
84
+ .trim()
85
+ .replace(/[,。、;:,.;:]+$/g, '')
86
+ .toLowerCase()
87
+ }
88
+
89
+ /** alias lookup, built once */
90
+ const ALIAS_INDEX = (() => {
91
+ const index = new Map()
92
+ for (const [canonical, aliases] of Object.entries(TAG_ALIASES)) {
93
+ for (const alias of aliases) index.set(comparable(alias), canonical)
94
+ }
95
+ return index
96
+ })()
97
+
98
+ /**
99
+ * Canonicalise one tag.
100
+ *
101
+ * @param {unknown} raw caller-supplied tag
102
+ * @returns {string | null} the canonical label, or null when it is not a subject
103
+ */
104
+ export function normalizeTag(raw) {
105
+ if (typeof raw !== 'string') return null
106
+ const key = comparable(raw)
107
+ if (!key) return null
108
+ if (TAG_STOPWORDS.has(key)) return null
109
+ const canonical = ALIAS_INDEX.get(key)
110
+ if (canonical !== undefined) return canonical
111
+
112
+ // Unknown but plausibly a subject: keep it, trimmed, with internal spacing
113
+ // collapsed. Reject one-character noise and anything absurdly long, which are
114
+ // the shapes auto-extraction produces when it splits a sentence badly.
115
+ const trimmed = String(raw).replace(/\s+/g, ' ').trim()
116
+ if (trimmed.length < 2 || trimmed.length > 24) return null
117
+ if (!/[\p{L}\p{N}]/u.test(trimmed)) return null
118
+ return trimmed
119
+ }
120
+
121
+ /**
122
+ * Canonicalise a tag list, dropping duplicates and non-subjects.
123
+ *
124
+ * @param {unknown} raw caller-supplied tags
125
+ * @returns {string[]} canonical tags, in first-seen order
126
+ */
127
+ export function normalizeTags(raw) {
128
+ if (!Array.isArray(raw)) return []
129
+ const out = []
130
+ const seen = new Set()
131
+ for (const item of raw) {
132
+ const tag = normalizeTag(item)
133
+ if (tag === null) continue
134
+ const key = tag.toLowerCase()
135
+ if (seen.has(key)) continue
136
+ seen.add(key)
137
+ out.push(tag)
138
+ }
139
+ return out
140
+ }
package/lib/util.js ADDED
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Small, dependency-free helpers used by both the snapshot engine and the
3
+ * HTTP surface of dsh-session-suite.
4
+ *
5
+ * Everything here is plain Node: the Host half of a DSH plugin is an ordinary
6
+ * ESM module running in the Electron main process, so `node:` builtins are the
7
+ * only imports allowed (no @deepseek-ai runtime packages are resolved for a
8
+ * profile-installed bundle).
9
+ */
10
+
11
+ import { createHash } from 'node:crypto'
12
+ import fs from 'node:fs/promises'
13
+ import path from 'node:path'
14
+
15
+ /** Directories and files a checkpoint never records. */
16
+ export const DEFAULT_IGNORES = [
17
+ 'node_modules',
18
+ '.git',
19
+ '.hg',
20
+ '.svn',
21
+ '.pnpm-store',
22
+ '.pnpm',
23
+ '.yarn',
24
+ '.cache',
25
+ '.next',
26
+ '.nuxt',
27
+ '.svelte-kit',
28
+ '.turbo',
29
+ '.parcel-cache',
30
+ '.venv',
31
+ 'venv',
32
+ '__pycache__',
33
+ 'dist',
34
+ 'build',
35
+ 'out',
36
+ 'coverage',
37
+ '.idea',
38
+ '.vscode',
39
+ '.DS_Store',
40
+ 'Thumbs.db',
41
+ '*.log',
42
+ '*.tmp',
43
+ '*.temp',
44
+ '*.lock',
45
+ 'package-lock.json',
46
+ 'pnpm-lock.yaml',
47
+ 'yarn.lock',
48
+ '.dsh-ignore',
49
+ ]
50
+
51
+ const LOCKFILE_NAMES = new Set(['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lockb'])
52
+
53
+ /**
54
+ * Glob-ish ignore matcher: exact segment, `*` prefix/suffix patterns, and
55
+ * bare `*.ext` suffixes. Matching is applied to every path segment.
56
+ *
57
+ * @param {readonly string[]} patterns ignore patterns
58
+ * @returns {(relPath: string, name: string) => boolean} predicate
59
+ */
60
+ export function createIgnoreMatcher(patterns) {
61
+ const segments = new Set()
62
+ const suffixes = []
63
+ for (const raw of patterns) {
64
+ const pattern = raw.trim()
65
+ if (!pattern) continue
66
+ if (pattern.startsWith('*.')) suffixes.push(pattern.slice(1).toLowerCase())
67
+ else if (pattern.startsWith('*')) suffixes.push(pattern.slice(1).toLowerCase())
68
+ else segments.add(pattern)
69
+ }
70
+ return (relPath, name) => {
71
+ if (segments.has(name)) return true
72
+ const lower = name.toLowerCase()
73
+ for (const suffix of suffixes) {
74
+ if (lower.endsWith(suffix)) return true
75
+ }
76
+ if (LOCKFILE_NAMES.has(name)) return true
77
+ // `.git` nested inside an allowed directory is still ignored.
78
+ return relPath.split('/').some(part => part === '.git' || part === 'node_modules')
79
+ }
80
+ }
81
+
82
+ /** @param {Buffer} data content */
83
+ export function sha256(data) {
84
+ return createHash('sha256').update(data).digest('hex')
85
+ }
86
+
87
+ /** @param {string} text content */
88
+ export function sha256Text(text) {
89
+ return createHash('sha256').update(text, 'utf8').digest('hex')
90
+ }
91
+
92
+ /**
93
+ * Deterministic short id, independent of `crypto.randomUUID` availability.
94
+ *
95
+ * @param {string} seed seed material
96
+ * @returns {string} 12-char id
97
+ */
98
+ export function shortId(seed) {
99
+ return sha256Text(`${seed}:${process.hrtime.bigint()}`).slice(0, 12)
100
+ }
101
+
102
+ /** @param {string} dir directory to create */
103
+ export async function ensureDir(dir) {
104
+ await fs.mkdir(dir, { recursive: true })
105
+ return dir
106
+ }
107
+
108
+ /**
109
+ * @param {string} file target file
110
+ * @returns {Promise<unknown | undefined>} parsed JSON, or undefined when absent/corrupt
111
+ */
112
+ export async function readJson(file) {
113
+ try {
114
+ const text = await fs.readFile(file, 'utf8')
115
+ return JSON.parse(text)
116
+ } catch {
117
+ return undefined
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Atomic JSON write (temp file + rename) so a crash never truncates state.
123
+ *
124
+ * @param {string} file target file
125
+ * @param {unknown} value JSON-serializable value
126
+ */
127
+ export async function writeJson(file, value) {
128
+ await ensureDir(path.dirname(file))
129
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`
130
+ await fs.writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
131
+ await fs.rename(tmp, file)
132
+ }
133
+
134
+ /** @param {number} bytes raw size */
135
+ export function formatBytes(bytes) {
136
+ if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
137
+ const units = ['B', 'KB', 'MB', 'GB']
138
+ let value = bytes
139
+ let index = 0
140
+ while (value >= 1024 && index < units.length - 1) {
141
+ value /= 1024
142
+ index += 1
143
+ }
144
+ return `${value >= 10 || index === 0 ? Math.round(value) : value.toFixed(1)} ${units[index]}`
145
+ }
146
+
147
+ /**
148
+ * Look like a binary blob? A NUL byte within the first 8 KiB is the classic
149
+ * signal, and we also refuse files the design explicitly excludes.
150
+ *
151
+ * @param {Buffer} head first bytes of the file
152
+ * @returns {boolean} true when the file must be skipped
153
+ */
154
+ export function looksBinary(head) {
155
+ return head.includes(0)
156
+ }
157
+
158
+ /**
159
+ * Remove a directory tree without throwing when it is already gone.
160
+ *
161
+ * @param {string} dir directory
162
+ */
163
+ export async function removeDir(dir) {
164
+ await fs.rm(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 })
165
+ }
166
+
167
+ /**
168
+ * @param {string} file file
169
+ * @returns {Promise<{ size: number, mtimeMs: number } | undefined>} stat or undefined
170
+ */
171
+ export async function statOrUndefined(file) {
172
+ try {
173
+ return await fs.stat(file)
174
+ } catch {
175
+ return undefined
176
+ }
177
+ }
178
+
179
+ /** @param {number} ms delay */
180
+ export function sleep(ms) {
181
+ return new Promise(resolve => setTimeout(resolve, ms))
182
+ }
183
+
184
+ /**
185
+ * Bound concurrency without pulling in a dependency.
186
+ *
187
+ * @template T, R
188
+ * @param {readonly T[]} items inputs
189
+ * @param {number} limit parallelism
190
+ * @param {(item: T, index: number) => Promise<R>} worker per-item work
191
+ * @returns {Promise<R[]>} results in input order
192
+ */
193
+ export async function mapLimit(items, limit, worker) {
194
+ const results = new Array(items.length)
195
+ let cursor = 0
196
+ const runners = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
197
+ while (cursor < items.length) {
198
+ const index = cursor
199
+ cursor += 1
200
+ results[index] = await worker(items[index], index)
201
+ }
202
+ })
203
+ await Promise.all(runners)
204
+ return results
205
+ }
206
+
207
+ /**
208
+ * Normalize a workspace-relative path to forward slashes.
209
+ *
210
+ * @param {string} rel relative path
211
+ * @returns {string} normalized
212
+ */
213
+ export function toPosix(rel) {
214
+ return rel.split(path.sep).join('/')
215
+ }
216
+
217
+ /** @param {string} value unknown value */
218
+ export function asString(value) {
219
+ return typeof value === 'string' ? value : undefined
220
+ }
221
+
222
+ /**
223
+ * Is this step message something the user actually typed?
224
+ *
225
+ * `role === 'user'` is not enough. The harness injects its own pipeline
226
+ * messages with that role — `runtime-context`, `time-context`, `skill-catalog`,
227
+ * `agent-message` — and compaction adds a synthetic `user/message` with
228
+ * `source.kind === 'compact-checkpoint'`. A plugin that reads instructions out
229
+ * of "user" messages will therefore read its own prompt text back, because
230
+ * whatever it injects is delivered inside a `runtime-context` message.
231
+ *
232
+ * That is not hypothetical: scanning runtime-context messages made this bundle
233
+ * parse the `【记住:…】` example in its own instruction footer as a user command,
234
+ * 154 times, and store a memory whose entire body was the ellipsis placeholder.
235
+ *
236
+ * @param {any} message step message
237
+ * @returns {boolean} whether the message is user-authored
238
+ */
239
+ export function isUserAuthored(message) {
240
+ if (message?.role !== 'user') return false
241
+ const source = typeof message.source === 'object' && message.source ? message.source : undefined
242
+ return source?.kind === undefined || source.kind === 'user'
243
+ }
244
+
245
+ /**
246
+ * Is a captured value real content rather than a placeholder?
247
+ *
248
+ * A placeholder is worth rejecting explicitly because the value came out of a
249
+ * regex over text that may include our own examples. `【记住:…】` yields `…`,
250
+ * which is a non-empty string and would otherwise sail straight into the store.
251
+ *
252
+ * @param {unknown} value captured value
253
+ * @returns {boolean} whether it is worth storing
254
+ */
255
+ export function isMeaningfulText(value) {
256
+ const text = String(value ?? '').trim()
257
+ // One character carries no recoverable meaning, and punctuation/symbol-only
258
+ // values are placeholders by construction.
259
+ if (text.length < 2) return false
260
+ return /[\p{L}\p{N}]/u.test(text)
261
+ }