@heixui/dsh-session-suite 0.0.0-stage → 0.3.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/LICENSE +21 -0
- package/README.md +381 -2
- package/README.zh.md +178 -0
- package/THIRD-PARTY.md +64 -0
- package/client.js +2974 -0
- package/cordis.patch.yml +77 -0
- package/icon.svg +19 -0
- package/index.js +211 -0
- package/lib/api.js +387 -0
- package/lib/checkpoints.js +774 -0
- package/lib/data/jieba-dict.txt +109750 -0
- package/lib/factkeys.js +212 -0
- package/lib/markdown.js +252 -0
- package/lib/memory-engine.js +1323 -0
- package/lib/memory-host.js +640 -0
- package/lib/memory.js +224 -0
- package/lib/retrieval.js +217 -0
- package/lib/service.js +1273 -0
- package/lib/share.js +641 -0
- package/lib/step-tag-filter.js +199 -0
- package/lib/tags.js +140 -0
- package/lib/util.js +261 -0
- package/lib/wordlist.js +183 -0
- package/locale/en.json +87 -0
- package/locale/zh.json +87 -0
- package/package.json +75 -4
|
@@ -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
|
+
}
|