@heixui/dsh-session-suite 0.3.4 → 0.4.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/README.md CHANGED
@@ -345,6 +345,28 @@ anything, and it never deletes real content.
345
345
  as symptom → root cause → fix. Read it before changing the snapshot, compaction,
346
346
  memory, or export paths.
347
347
 
348
+ `docs/memory-architecture.md` maps the memory engine against the standard
349
+ two-layer agent-memory design, with measured numbers rather than claims — including
350
+ the parts that are deliberately not built and why.
351
+
352
+ ## What changed in 0.4.0
353
+
354
+ The memory engine was reorganised; the session features are unchanged.
355
+
356
+ | Area | Before | Now |
357
+ |---|---|---|
358
+ | Duplicate memories | One topic occupied 7 of 46 entries | Maintenance is idempotent (verified across 6 passes); existing duplicates merged into cold storage |
359
+ | Resident layer | 2 flat entries mixed into the "relevant" block | Up to 5 topic cards, in their own labelled section |
360
+ | Episodic memory | Absent | `kind: 'episode'` with time, context, and outcome; decays twice as fast |
361
+ | Dense reranking | Implemented but off, weight unverified | **Optional, off by default**, weight calibrated by measurement (MRR 0.535 → 0.760 on 8 paraphrased queries, recall unchanged) |
362
+ | Privacy | Scope only | `public` / `internal` / `secret`; credentials auto-detected; `secret` never injected or exported |
363
+ | Implicit queue | Keyword gate dropped 7 of 10 valid candidates | Gate removed; an empty queue now states why it is empty |
364
+
365
+ **Embedding is optional and adds no download by default.** Lexical retrieval alone is
366
+ the default behaviour; installing a model adds cross-lingual and paraphrase recall.
367
+ See `docs/memory-architecture.md` for the measured effect and
368
+ `tools/download-embedding.mjs` for the deliberate, explicit install path.
369
+
348
370
  ## Limits
349
371
 
350
372
  * Rewinding does not rewrite the durable log; it forks at the target message and archives the old session. That
package/README.zh.md CHANGED
@@ -157,13 +157,34 @@ tools/ 自测脚本与预清理脚本(不随插件打包)
157
157
  docs/ 需求与 API 笔记
158
158
  ```
159
159
 
160
+ ## 0.4.0 改了什么
161
+
162
+ 本次重构的是记忆引擎,会话相关功能未变。
163
+
164
+ | 方面 | 改前 | 改后 |
165
+ |---|---|---|
166
+ | 重复记忆 | 46 条里同一主题占 7 条 | 维护幂等(连续 6 轮验证);存量重复已合并进冷库 |
167
+ | 常驻层 | 2 条扁平记忆,混在「相关」区块里 | 最多 5 张主题卡片,独立分区标注 |
168
+ | 情景记忆 | 没有 | `kind: 'episode'`,带发生时间、情境、结果;衰减加倍 |
169
+ | dense 重排 | 实现了但默认关,权重未验证 | **可选,默认关**;权重经实测校准(8 条改写查询 MRR 0.535→0.760,recall 不变) |
170
+ | 隐私分级 | 只有 scope | `public`/`internal`/`secret`;凭据自动识别;secret 永不注入、永不导出 |
171
+ | 隐式队列 | 关键词闸门误杀 7/10 合法候选 | 闸门已移除;空队列会说明原因 |
172
+
173
+ **embedding 是可选增强,默认不下载任何东西。** 不装模型就是纯词法检索,功能完整;装了才有跨语言与改写召回。
174
+ 实测效果与安装方式见 `docs/memory-architecture.md` 和 `tools/download-embedding.mjs`。
175
+
160
176
  ## 测试
161
177
 
162
- 两个自测脚本都在临时目录里跑,不会碰真实的 DSH 数据。
178
+ 全部自测脚本都在临时目录里跑,不会碰真实的 DSH 数据。
163
179
 
164
180
  ```bash
165
- node tools/selftest.mjs # 快照 / 忽略规则 / 恢复 / 淘汰 / 记忆
166
- node tools/selftest-rollback.mjs # 回滚 / 撤销 / 删除消息 / 分享 / 永久删除
181
+ node tools/selftest.mjs # 快照 / 忽略规则 / 恢复 / 淘汰 / 记忆
182
+ node tools/selftest-memory-dedupe.mjs # 维护幂等,重复不再生成
183
+ node tools/selftest-resident-cards.mjs # 常驻卡片分组与预算
184
+ node tools/selftest-episode-memory.mjs # 情景记忆类型与衰减
185
+ node tools/selftest-memory-privacy.mjs # 隐私分级与导出过滤
186
+ node tools/selftest-implicit-queue.mjs # 隐式候选不再被误杀
187
+ node tools/probe-dense-rerank.mjs # dense 重排的实测对比(需要模型)
167
188
  ```
168
189
 
169
190
  ## 已知边界
package/client.js CHANGED
@@ -178,6 +178,7 @@ window.__ModuleLoader__.load({
178
178
  'memory.workspacePicker': 'Workspace',
179
179
  'memory.workspaceNone': 'No workspace detected',
180
180
  'memory.rule': 'Rule',
181
+ 'memory.episode': 'Episode',
181
182
  'memory.summary': 'Summary',
182
183
  'memory.frozen': 'Frozen',
183
184
  'memory.archived': 'Archived',
@@ -210,6 +211,11 @@ window.__ModuleLoader__.load({
210
211
  'memory.remove': 'Delete',
211
212
  'memory.pendingTitle': 'Suggested memories',
212
213
  'memory.pendingHint': 'Extracted from the last turns. Nothing is stored until you accept it.',
214
+ 'memory.extractionIdle': 'Nothing is waiting. Last check: {reason}',
215
+ 'memory.extractionUnknown': 'no attempt recorded since this Host started',
216
+ 'memory.extractionTried': 'tried {n} memory model(s)',
217
+ 'memory.extractionQueued': '{n} queued',
218
+ 'memory.extractionRejected': '{n} rejected as unusable',
213
219
  'memory.accept': 'Keep',
214
220
  'memory.reject': 'Discard',
215
221
  'memory.accepted': 'Kept',
@@ -357,6 +363,7 @@ window.__ModuleLoader__.load({
357
363
  'memory.workspacePicker': '工作区',
358
364
  'memory.workspaceNone': '未检测到工作区',
359
365
  'memory.rule': '规则',
366
+ 'memory.episode': '经历',
360
367
  'memory.summary': '摘要',
361
368
  'memory.frozen': '已冻结',
362
369
  'memory.archived': '已归档',
@@ -389,6 +396,11 @@ window.__ModuleLoader__.load({
389
396
  'memory.remove': '删除',
390
397
  'memory.pendingTitle': '待确认的记忆',
391
398
  'memory.pendingHint': '从最近几轮里识别出来的,确认前不会入库。',
399
+ 'memory.extractionIdle': '当前没有待确认条目。上次检查结果:{reason}',
400
+ 'memory.extractionUnknown': '本次运行还没有执行过提取',
401
+ 'memory.extractionTried': '试过 {n} 个记忆模型',
402
+ 'memory.extractionQueued': '入队 {n} 条',
403
+ 'memory.extractionRejected': '剔除 {n} 条',
392
404
  'memory.accept': '保留',
393
405
  'memory.reject': '丢弃',
394
406
  'memory.accepted': '已保留',
@@ -2571,10 +2583,14 @@ window.__ModuleLoader__.load({
2571
2583
  h('span', { className: 'dss-row-meta' }, t('memory.filterCount', { n: (items ?? []).length })),
2572
2584
  )
2573
2585
 
2586
+ // An empty queue used to render nothing at all, which is indistinguishable
2587
+ // from a broken feature: the user cannot tell "the model judged this turn
2588
+ // unremarkable" from "every memory model failed" or "a filter ate the
2589
+ // result". When there is nothing pending, say why.
2590
+ const extraction = status?.extraction
2574
2591
  const pendingBlock =
2575
- pending.length === 0
2576
- ? null
2577
- : h(
2592
+ pending.length > 0
2593
+ ? h(
2578
2594
  'div',
2579
2595
  { className: 'dss-mem-pending' },
2580
2596
  h('div', { className: 'dss-group-title' }, t('memory.pendingTitle')),
@@ -2616,6 +2632,33 @@ window.__ModuleLoader__.load({
2616
2632
  ),
2617
2633
  ),
2618
2634
  )
2635
+ : extraction
2636
+ ? h(
2637
+ 'div',
2638
+ { className: 'dss-mem-pending' },
2639
+ h('div', { className: 'dss-group-title' }, t('memory.pendingTitle')),
2640
+ h(
2641
+ 'div',
2642
+ { className: 'dss-mem-pending-hint' },
2643
+ t('memory.extractionIdle', {
2644
+ reason: extraction.reason || t('memory.extractionUnknown'),
2645
+ }),
2646
+ ),
2647
+ extraction.candidates?.length
2648
+ ? h(
2649
+ 'div',
2650
+ { className: 'dss-row-meta' },
2651
+ h('span', null, t('memory.extractionTried', { n: extraction.candidates.length })),
2652
+ extraction.queued !== undefined
2653
+ ? h('span', null, t('memory.extractionQueued', { n: extraction.queued }))
2654
+ : null,
2655
+ extraction.rejected !== undefined
2656
+ ? h('span', null, t('memory.extractionRejected', { n: extraction.rejected }))
2657
+ : null,
2658
+ )
2659
+ : null,
2660
+ )
2661
+ : null
2619
2662
 
2620
2663
  const listBlock =
2621
2664
  visible.length === 0
@@ -2636,6 +2679,10 @@ window.__ModuleLoader__.load({
2636
2679
  { className: 'dss-row-meta' },
2637
2680
  h('span', { className: 'dss-badge' + (entry.scope === 'global' ? ' dss-locked' : '') }, entry.scope === 'global' ? t('memory.global') : t('memory.workspace')),
2638
2681
  entry.kind === 'rule' ? h('span', { className: 'dss-badge dss-temp' }, t('memory.rule')) : null,
2682
+ // An episode is a dated event, not a truth. Labelling it
2683
+ // separately is what lets the user tell "this happened once"
2684
+ // from "this is how things are".
2685
+ entry.kind === 'episode' ? h('span', { className: 'dss-badge' }, t('memory.episode')) : null,
2639
2686
  entry.summary ? h('span', { className: 'dss-badge' }, t('memory.summary')) : null,
2640
2687
  entry.frozen ? h('span', { className: 'dss-badge dss-locked' }, t('memory.frozen')) : null,
2641
2688
  entry.archived ? h('span', { className: 'dss-badge dss-temp' }, t('memory.archived')) : null,
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.
@@ -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
+ }