@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/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
+ }