@heixui/dsh-session-suite 0.3.4 → 0.4.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.
- package/README.md +39 -0
- package/README.zh.md +39 -3
- package/client.js +633 -30
- package/cordis.patch.yml +27 -0
- package/lib/api.js +5 -0
- package/lib/embeddings.js +239 -0
- package/lib/memory-engine.js +525 -17
- package/lib/memory-host.js +199 -43
- package/lib/retrieval.js +75 -0
- package/lib/service.js +99 -20
- package/package.json +1 -1
package/cordis.patch.yml
CHANGED
|
@@ -69,6 +69,33 @@
|
|
|
69
69
|
bm25K1: 1.5
|
|
70
70
|
bm25B: 0.75
|
|
71
71
|
rrfK: 60
|
|
72
|
+
# ---------- optional dense reranking (stage 2) ----------
|
|
73
|
+
# Lexical retrieval cannot bridge a paraphrase that shares no character
|
|
74
|
+
# (checkpoint vs 快照). A dense model can, so it reorders the candidates
|
|
75
|
+
# the lexical stage already found. It is OPTIONAL and OFF by default:
|
|
76
|
+
# nothing is downloaded, and recall works exactly as before when disabled.
|
|
77
|
+
#
|
|
78
|
+
# To enable, install a model deliberately and point modelDir at it:
|
|
79
|
+
#
|
|
80
|
+
# embedding:
|
|
81
|
+
# enabled: true
|
|
82
|
+
# provider: local # local | remote
|
|
83
|
+
# modelDir: D:\AI agent cache\models\bge-base-zh-v1.5
|
|
84
|
+
#
|
|
85
|
+
# Or use an OpenAI-compatible endpoint instead of local files:
|
|
86
|
+
#
|
|
87
|
+
# embedding:
|
|
88
|
+
# enabled: true
|
|
89
|
+
# provider: remote
|
|
90
|
+
# baseUrl: https://api.siliconflow.cn/v1
|
|
91
|
+
# apiKey: sk-...
|
|
92
|
+
# model: BAAI/bge-m3
|
|
93
|
+
#
|
|
94
|
+
# Either way, a failure degrades to lexical ranking; it never breaks recall.
|
|
95
|
+
embedding:
|
|
96
|
+
enabled: true
|
|
97
|
+
provider: local
|
|
98
|
+
modelDir: D:\AI agent cache\dsh\models\bge-base-zh-v1.5
|
|
72
99
|
# Similarity above which a new memory reinforces an existing one.
|
|
73
100
|
duplicateSimilarity: 0.82
|
|
74
101
|
# Similarity above which two memories may join one cluster.
|
package/lib/api.js
CHANGED
|
@@ -144,6 +144,11 @@ async function respond(service, deps, action, input) {
|
|
|
144
144
|
{ cascade: input.cascade !== false },
|
|
145
145
|
),
|
|
146
146
|
})
|
|
147
|
+
case 'restoreSession':
|
|
148
|
+
// The inverse of archiving. Kept separate from any delete path on purpose:
|
|
149
|
+
// un-hiding a session and destroying one are different intents and must not
|
|
150
|
+
// share a control.
|
|
151
|
+
return json({ ok: true, data: await service.restoreSession(requireSession(input)) })
|
|
147
152
|
case 'deleteSession':
|
|
148
153
|
return json({
|
|
149
154
|
ok: true,
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional dense embeddings for memory reranking.
|
|
3
|
+
*
|
|
4
|
+
* Design constraints, in the order they shaped this file:
|
|
5
|
+
*
|
|
6
|
+
* 1. **Never download anything implicitly.** A memory plugin that silently pulls
|
|
7
|
+
* several hundred megabytes onto someone's disk is not acceptable; the model
|
|
8
|
+
* is installed deliberately, and this module only loads what is already there.
|
|
9
|
+
* 2. **Never break recall.** Embedding is an optimisation. Any failure — no model,
|
|
10
|
+
* unreadable file, out-of-memory, a provider that changed shape — must degrade
|
|
11
|
+
* to "no reranking", never to an exception on the recall path.
|
|
12
|
+
* 3. **The model is swappable.** Two providers are supported without changing the
|
|
13
|
+
* engine: a local transformers.js pipeline, and any OpenAI-compatible
|
|
14
|
+
* /embeddings endpoint (Zhipu, SiliconFlow, and friends). The engine only ever
|
|
15
|
+
* sees `{ vector }` or `undefined`.
|
|
16
|
+
*
|
|
17
|
+
* The default is the local pipeline, because recall runs on the prompt path and
|
|
18
|
+
* must not depend on a network round trip: a busy or rate-limited provider would
|
|
19
|
+
* turn every step into a slow one.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import fs from 'node:fs'
|
|
23
|
+
import path from 'node:path'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A provider that never produces vectors.
|
|
27
|
+
*
|
|
28
|
+
* This is the default. Reranking then costs nothing and changes nothing, which
|
|
29
|
+
* is the correct behaviour for a library with no model installed.
|
|
30
|
+
*/
|
|
31
|
+
export const DISABLED_PROVIDER = {
|
|
32
|
+
name: 'disabled',
|
|
33
|
+
available: false,
|
|
34
|
+
async embed() {
|
|
35
|
+
return undefined
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A local transformers.js pipeline.
|
|
41
|
+
*
|
|
42
|
+
* The model directory is supplied by the caller; nothing is downloaded here. If
|
|
43
|
+
* the directory or the runtime is missing, `available` stays false and the
|
|
44
|
+
* provider behaves like the disabled one.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} input provider input
|
|
47
|
+
* @param {string} input.modelDir absolute directory holding the model files
|
|
48
|
+
* @param {string} [input.pipeline] transformers.js pipeline name
|
|
49
|
+
* @param {number} [input.batchSize] texts per inference call
|
|
50
|
+
* @returns {Promise<object>} provider
|
|
51
|
+
*/
|
|
52
|
+
export async function createLocalProvider({ modelDir, pipeline = 'feature-extraction', batchSize = 16, runtime }) {
|
|
53
|
+
if (typeof modelDir !== 'string' || !modelDir) return DISABLED_PROVIDER
|
|
54
|
+
// Point at the model directory itself; transformers.js accepts a path and will
|
|
55
|
+
// not reach the network when the files are present.
|
|
56
|
+
if (!fs.existsSync(modelDir)) return DISABLED_PROVIDER
|
|
57
|
+
|
|
58
|
+
let transformers = runtime
|
|
59
|
+
if (!transformers) {
|
|
60
|
+
try {
|
|
61
|
+
// Resolved at runtime so a profile without the package still loads the bundle.
|
|
62
|
+
transformers = await import('@xenova/transformers')
|
|
63
|
+
} catch {
|
|
64
|
+
try {
|
|
65
|
+
transformers = await import('@huggingface/transformers')
|
|
66
|
+
} catch {
|
|
67
|
+
return DISABLED_PROVIDER
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Turn OFF remote fetching before creating the pipeline.
|
|
73
|
+
//
|
|
74
|
+
// Passing a directory to pipeline() is NOT enough: when any expected file is
|
|
75
|
+
// missing, transformers.js silently falls back to downloading the model from
|
|
76
|
+
// Hugging Face. On a network where that host is unreachable the failure surfaces
|
|
77
|
+
// as `fetch failed` / CONNECT_TIMEOUT, which looks like a network problem rather
|
|
78
|
+
// than a missing-file problem — and worse, on a reachable network the plugin would
|
|
79
|
+
// pull hundreds of megabytes onto a user's disk without asking. allowRemoteModels
|
|
80
|
+
// = false turns a missing file into a clear, immediate error instead.
|
|
81
|
+
//
|
|
82
|
+
// transformers.js resolves a model by joining localModelPath with the model id, so
|
|
83
|
+
// localModelPath must be the PARENT directory and the id the folder name. An
|
|
84
|
+
// absolute path passed as the id defeats that join and triggers the remote lookup.
|
|
85
|
+
let modelId = modelDir
|
|
86
|
+
try {
|
|
87
|
+
if (transformers.env) {
|
|
88
|
+
transformers.env.allowRemoteModels = false
|
|
89
|
+
transformers.env.allowLocalModels = true
|
|
90
|
+
const parent = path.dirname(modelDir)
|
|
91
|
+
transformers.env.localModelPath = parent
|
|
92
|
+
modelId = path.basename(modelDir)
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
/* an older runtime without env is still usable with an explicit path */
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const extractor = await transformers.pipeline(pipeline, modelId)
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
name: 'local',
|
|
102
|
+
available: true,
|
|
103
|
+
modelDir,
|
|
104
|
+
/**
|
|
105
|
+
* @param {string} text input
|
|
106
|
+
* @returns {Promise<number[] | undefined>} embedding
|
|
107
|
+
*/
|
|
108
|
+
async embed(text) {
|
|
109
|
+
const value = String(text ?? '').trim()
|
|
110
|
+
if (!value) return undefined
|
|
111
|
+
try {
|
|
112
|
+
const output = await extractor(value, { pooling: 'mean', normalize: true })
|
|
113
|
+
// transformers.js returns a Tensor; `data` is the flat Float32Array.
|
|
114
|
+
const data = output?.data ?? output?.[0]?.data ?? output
|
|
115
|
+
if (!data) return undefined
|
|
116
|
+
return Array.from(data, Number)
|
|
117
|
+
} catch {
|
|
118
|
+
return undefined
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
/**
|
|
122
|
+
* Embed many texts in one call. Falls back to per-text calls when the
|
|
123
|
+
* runtime does not support batches, which is slower but still correct.
|
|
124
|
+
*
|
|
125
|
+
* @param {string[]} texts inputs
|
|
126
|
+
* @returns {Promise<Array<number[] | undefined>>} embeddings
|
|
127
|
+
*/
|
|
128
|
+
async embedMany(texts) {
|
|
129
|
+
const list = Array.isArray(texts) ? texts : []
|
|
130
|
+
if (list.length === 0) return []
|
|
131
|
+
try {
|
|
132
|
+
const output = await extractor(list, { pooling: 'mean', normalize: true })
|
|
133
|
+
const rows = output?.tolist?.() ?? output?.data ?? undefined
|
|
134
|
+
if (Array.isArray(rows) && rows.length === list.length && Array.isArray(rows[0])) {
|
|
135
|
+
return rows.map(row => Array.from(row, Number))
|
|
136
|
+
}
|
|
137
|
+
if (rows && rows.length === list.length * (rows[0]?.length ?? 0)) {
|
|
138
|
+
// Flat buffer: split into equal rows.
|
|
139
|
+
const width = rows.length / list.length
|
|
140
|
+
const out = []
|
|
141
|
+
for (let index = 0; index < list.length; index += 1) {
|
|
142
|
+
out.push(Array.from(rows.slice(index * width, (index + 1) * width), Number))
|
|
143
|
+
}
|
|
144
|
+
return out
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
/* fall through to per-item calls */
|
|
148
|
+
}
|
|
149
|
+
const out = []
|
|
150
|
+
for (const text of list.slice(0, batchSize * 8)) out.push(await this.embed(text))
|
|
151
|
+
return out
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* An OpenAI-compatible /embeddings endpoint.
|
|
158
|
+
*
|
|
159
|
+
* Used when the user prefers no local files. Every failure returns undefined, so
|
|
160
|
+
* a flaky endpoint degrades to lexical ranking rather than failing recall.
|
|
161
|
+
*
|
|
162
|
+
* @param {object} input provider input
|
|
163
|
+
* @param {string} input.baseUrl endpoint base, e.g. https://api.siliconflow.cn/v1
|
|
164
|
+
* @param {string} input.apiKey bearer token
|
|
165
|
+
* @param {string} input.model model id
|
|
166
|
+
* @param {number} [input.timeoutMs] request timeout
|
|
167
|
+
* @returns {object} provider
|
|
168
|
+
*/
|
|
169
|
+
export function createRemoteProvider({ baseUrl, apiKey, model, timeoutMs = 5000 }) {
|
|
170
|
+
if (typeof baseUrl !== 'string' || !baseUrl || !apiKey || !model) return DISABLED_PROVIDER
|
|
171
|
+
const endpoint = `${baseUrl.replace(/\/+$/, '')}/embeddings`
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
name: 'remote',
|
|
175
|
+
available: true,
|
|
176
|
+
model,
|
|
177
|
+
/**
|
|
178
|
+
* @param {string} text input
|
|
179
|
+
* @returns {Promise<number[] | undefined>} embedding
|
|
180
|
+
*/
|
|
181
|
+
async embed(text) {
|
|
182
|
+
const value = String(text ?? '').trim()
|
|
183
|
+
if (!value) return undefined
|
|
184
|
+
const controller = new AbortController()
|
|
185
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
186
|
+
try {
|
|
187
|
+
const response = await fetch(endpoint, {
|
|
188
|
+
method: 'POST',
|
|
189
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
|
|
190
|
+
body: JSON.stringify({ model, input: value }),
|
|
191
|
+
signal: controller.signal,
|
|
192
|
+
})
|
|
193
|
+
if (!response.ok) return undefined
|
|
194
|
+
const payload = await response.json()
|
|
195
|
+
const vector = payload?.data?.[0]?.embedding
|
|
196
|
+
return Array.isArray(vector) ? vector.map(Number) : undefined
|
|
197
|
+
} catch {
|
|
198
|
+
return undefined
|
|
199
|
+
} finally {
|
|
200
|
+
clearTimeout(timer)
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Build the provider described by plugin config, or the disabled one.
|
|
208
|
+
*
|
|
209
|
+
* Configuration is read defensively: a half-written block yields the disabled
|
|
210
|
+
* provider rather than a crash, because this runs during plugin installation.
|
|
211
|
+
*
|
|
212
|
+
* @param {any} config plugin config
|
|
213
|
+
* @returns {Promise<object>} provider
|
|
214
|
+
*/
|
|
215
|
+
export async function createEmbeddingProvider(config) {
|
|
216
|
+
const block = config?.embedding
|
|
217
|
+
if (!block || typeof block !== 'object' || block.enabled === false) return DISABLED_PROVIDER
|
|
218
|
+
|
|
219
|
+
if (block.provider === 'remote') {
|
|
220
|
+
return createRemoteProvider({
|
|
221
|
+
baseUrl: block.baseUrl,
|
|
222
|
+
apiKey: block.apiKey,
|
|
223
|
+
model: block.model,
|
|
224
|
+
timeoutMs: Number(block.timeoutMs) || 5000,
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const modelDir = typeof block.modelDir === 'string' && block.modelDir
|
|
229
|
+
? block.modelDir
|
|
230
|
+
: path.join(config?.root ?? '', 'models', String(block.model ?? 'bge-base-zh-v1.5'))
|
|
231
|
+
try {
|
|
232
|
+
return await createLocalProvider({
|
|
233
|
+
modelDir,
|
|
234
|
+
pipeline: typeof block.pipeline === 'string' ? block.pipeline : 'feature-extraction',
|
|
235
|
+
})
|
|
236
|
+
} catch {
|
|
237
|
+
return DISABLED_PROVIDER
|
|
238
|
+
}
|
|
239
|
+
}
|