@max-null/dsh-memory 0.5.0 → 0.5.2
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 +31 -12
- package/client.js +23 -15
- package/dist/bm25.d.ts +25 -1
- package/dist/bm25.js +99 -20
- package/dist/engine.d.ts +47 -3
- package/dist/engine.js +192 -11
- package/dist/index.d.ts +1 -1
- package/dist/index.js +45 -12
- package/dist/injection.d.ts +34 -0
- package/dist/injection.js +52 -0
- package/dist/remote.d.ts +9 -5
- package/dist/remote.js +11 -4
- package/dist/routes.js +12 -2
- package/dist/self.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# dsh-memory
|
|
2
2
|
|
|
3
|
+
本插件属于 **`@max-null/*` 插件系列**——这一系列共同构成 **[SSID(思灵 · Seek Soul in Darkness)](https://github.com/Max-Null/seek-soul-in-darkness)** 桌面体验。SSID 是整合它们的盒:`dsh-capture` · `dsh-chat-rail` · `dsh-chinese-thinking` · `dsh-draft-polish` · `dsh-guardian` · `dsh-habit` · `dsh-header-unify` · `dsh-memory` · `dsh-node-appearance` · `dsh-plugin-center` · `dsh-skill-mcp-center` · `dsh-ssid-panels` · `dsh-ssid-zh-ui`。
|
|
4
|
+
|
|
5
|
+
This plugin belongs to the **`@max-null/*` family** — a set of plugins that together form the **[SSID (思灵 · Seek Soul in Darkness)](https://github.com/Max-Null/seek-soul-in-darkness)** desktop experience.
|
|
6
|
+
|
|
3
7
|
一个面向 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 的**跨会话明文记忆插件**。遵循「一切皆插件」——它不修改 DSH 源码,声明 `name`/`inject`/`apply`,由 Loader 从 `cordis.yml` 加载。
|
|
4
8
|
|
|
5
9
|
## 设计原则
|
|
@@ -7,7 +11,7 @@
|
|
|
7
11
|
1. **人是所有者**:模型只能写入 `suggested` 状态的记忆,绝不自我提升;只有人工确认(`setStatus`)才能让记忆生效。
|
|
8
12
|
2. **可观测先于精准**:每条记忆是明文,`memory_list` 随时可见、`memory_forget` 随时删除——不存在"静默暗礁"。
|
|
9
13
|
3. **明文是人机共享的审计窗口**:记忆是可读文本,模型可自检其是否过期或出错(规划中的 v2)。
|
|
10
|
-
4. **确定性且缓存安全**:BM25 关键词检索是存储的纯函数、无 LLM 调用;固定指引进 system-prompt section,`
|
|
14
|
+
4. **确定性且缓存安全**:BM25 关键词检索是存储的纯函数、无 LLM 调用;固定指引进 system-prompt section,`approved + injected` 记忆进 recall context(global 全量 + 当前会话工作区),逐条为单行摘要并按注入预算截断(超预算按最近使用优先,省略数在面板可见)。
|
|
11
15
|
|
|
12
16
|
## 用法
|
|
13
17
|
|
|
@@ -25,8 +29,9 @@ npm install @max-null/dsh-memory
|
|
|
25
29
|
## 提供的服务与工具
|
|
26
30
|
|
|
27
31
|
- **服务** `ctx.memory`:`remember` / `list` / `search` / `forget` / `setStatus`
|
|
28
|
-
- **工具**:`memory_save`、`memory_list`、`memory_search`、`memory_confirm`、`memory_forget`
|
|
29
|
-
- **注入**:`tool:memory` 指引 section + `memory:recall` 召回 context
|
|
32
|
+
- **工具**:`memory_save`、`memory_list`、`memory_search`、`memory_confirm`、`memory_forget`、`memory_update`
|
|
33
|
+
- **注入**:`tool:memory` 指引 section + `memory:recall` 召回 context(global 的 `approved + injected` + 当前会话工作区的 `approved + injected`,带 `[memory:<id>:<namespace>]` 来源标记;摘要化 + 预算截断)
|
|
34
|
+
- **检索**:BM25(CJK 单字 + 2-gram,content 与 keywords 字段分离加权;中文多字查询精度显著优于单字切分);可选语义融合(见「可选配置」)
|
|
30
35
|
|
|
31
36
|
## 两层存储(global / project)
|
|
32
37
|
|
|
@@ -35,16 +40,17 @@ npm install @max-null/dsh-memory
|
|
|
35
40
|
| namespace | 默认位置 | 用途 |
|
|
36
41
|
|---|---|---|
|
|
37
42
|
| `global` | `$DSH_HOME/storages/memory.json` | 跨项目的个人偏好 |
|
|
38
|
-
| `project` | `<cwd>/.dsh/storages/
|
|
43
|
+
| `project` | `<cwd>/.dsh/storages/memory_project_<hash>.json` | 跟随仓库的项目共识,可 git 分享 |
|
|
39
44
|
|
|
40
|
-
两个根都可用 config 覆盖(`globalRoot` / `projectRoot`)。`memory_list` / `memory_search` 不带 `namespace`
|
|
45
|
+
两个根都可用 config 覆盖(`globalRoot` / `projectRoot`)。`memory_list` / `memory_search` 不带 `namespace` 过滤时会同时查两层。旧版双重前缀文件名(`memory_project_memory_project_<hash>.json`)在打开时自动迁移为规范名。
|
|
41
46
|
|
|
42
47
|
## 使用流程(人工确认闸门)
|
|
43
48
|
|
|
44
49
|
```
|
|
45
50
|
模型 memory_save → status: suggested(只是建议,未生效)
|
|
46
|
-
人 memory_confirm → status:
|
|
47
|
-
|
|
51
|
+
人 memory_confirm → status: approved(已审核;是否常驻注入由独立开关 injected 决定)
|
|
52
|
+
人(面板/开关) → injected: true(每轮注入:global + 当前会话工作区,摘要化 + 预算截断)
|
|
53
|
+
memory_search → 关键词/语义召回任意状态的记忆(命中标记 lastUsedAt 冷热追踪)
|
|
48
54
|
memory_forget → 随时删除
|
|
49
55
|
```
|
|
50
56
|
|
|
@@ -52,15 +58,28 @@ memory_forget → 随时删除
|
|
|
52
58
|
|
|
53
59
|
## 为什么明文 + BM25,而不是向量检索
|
|
54
60
|
|
|
55
|
-
向量检索的记忆本体是一串不可读的数字,过期信息会成为**无法观测、无法修复的静默暗礁**;BM25 +
|
|
61
|
+
向量检索的记忆本体是一串不可读的数字,过期信息会成为**无法观测、无法修复的静默暗礁**;BM25 + 明文让每一次召回都可解释、每一条记忆都可见可删。语义(向量)检索作为**可插拔的可选项**(0.5.2,见「可选配置」)——记忆本体仍是明文,向量仅作为检索辅助字段(`vector`,明文可读),且以"可观测 + 可修复"为门槛。
|
|
56
62
|
|
|
57
|
-
明文还有一层**跟随仓库分享**的好处:`project` 命名空间的记忆落在项目文件夹内(`<cwd>/.dsh/storages/
|
|
63
|
+
明文还有一层**跟随仓库分享**的好处:`project` 命名空间的记忆落在项目文件夹内(`<cwd>/.dsh/storages/memory_project_<hash>.json`),随 `git` 提交、分享给所有协作者;`global` 命名空间的记忆留在本地 `$DSH_HOME`。团队的共识("本项目统一用 Vue3 `<script setup>`")能沉淀进仓库,而不是散落在每个人的本地。FTS5 的 SQLite 二进制无法这样"跟着仓库走"。
|
|
58
64
|
|
|
59
|
-
##
|
|
65
|
+
## 可选配置
|
|
60
66
|
|
|
61
|
-
|
|
67
|
+
在 `cordis.yml` 的 `config` 里传给插件(均可省略):
|
|
62
68
|
|
|
63
|
-
|
|
69
|
+
```yaml
|
|
70
|
+
- id: memory
|
|
71
|
+
name: '@max-null/dsh-memory'
|
|
72
|
+
config:
|
|
73
|
+
injectionBudget: 1500 # 常驻注入预算(字符;null = 不限制)
|
|
74
|
+
summaryChars: 80 # 单条注入摘要截断上限(字符)
|
|
75
|
+
semanticTopK: 5 # 语义侧参与融合的 topK(仅配置 embeddings 时生效)
|
|
76
|
+
# 混合语义检索(可插拔;缺省 = 纯 BM25)
|
|
77
|
+
# embeddings: { embed: '...' 提供 embed(texts) 的宿主函数 或 插件名 ... }
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`embeddings` 接受 `{ embed(texts): Promise<number[][]>, similarity? }` 对象——由宿主装配层提供嵌入实现(如 DeepSeek 嵌入端点);配置后 `memory_search` 以 BM25 + 语义 RRF 融合,向量增量生成并持久化,嵌入调用失败自动降级为纯 BM25。
|
|
81
|
+
|
|
82
|
+
## SSID 系列
|
|
64
83
|
|
|
65
84
|
## 开发
|
|
66
85
|
|
package/client.js
CHANGED
|
@@ -173,7 +173,8 @@ var STRINGS = {
|
|
|
173
173
|
keywordsLabel: "\u5173\u952E\u8BCD",
|
|
174
174
|
suggested: "\u5F85\u5BA1\u6838",
|
|
175
175
|
approved: "\u5DF2\u5BA1\u6838",
|
|
176
|
-
refresh: "\u5237\u65B0"
|
|
176
|
+
refresh: "\u5237\u65B0",
|
|
177
|
+
coldHint: "\u51B7\u6570\u636E \xB7 \u5EFA\u8BAE\u6309\u9700\u5316"
|
|
177
178
|
},
|
|
178
179
|
en: {
|
|
179
180
|
tabMemory: "Memory",
|
|
@@ -197,7 +198,8 @@ var STRINGS = {
|
|
|
197
198
|
keywordsLabel: "Keywords",
|
|
198
199
|
suggested: "Suggested",
|
|
199
200
|
approved: "Approved",
|
|
200
|
-
refresh: "Refresh"
|
|
201
|
+
refresh: "Refresh",
|
|
202
|
+
coldHint: "Cold \xB7 consider on-demand"
|
|
201
203
|
}
|
|
202
204
|
};
|
|
203
205
|
var localeId = "zh";
|
|
@@ -294,11 +296,20 @@ var memoryApi = {
|
|
|
294
296
|
setInjected(id, injected, cwd) {
|
|
295
297
|
return api("setInjected", { id, injected, ...cwd === void 0 ? {} : { cwd } });
|
|
296
298
|
},
|
|
297
|
-
injectionPreview() {
|
|
298
|
-
return api("injectionPreview");
|
|
299
|
+
injectionPreview(cwd) {
|
|
300
|
+
return api("injectionPreview", { ...cwd === void 0 ? {} : { cwd } });
|
|
299
301
|
}
|
|
300
302
|
};
|
|
301
303
|
var ORGANIZE_PROMPT = "\u8BF7\u6574\u7406\u6211\u7684\u8BB0\u5FC6\u5E93\uFF1A\u7528 memory_list \u67E5\u770B\u5168\u90E8\u8BB0\u5FC6\u3002\u6574\u7406\u89C4\u5219\uFF08\u5FC5\u987B\u6267\u884C\uFF09\uFF1A\u2460\u540C\u7C7B\u6761\u76EE\u5408\u5E76\u2014\u2014\u5DE5\u4F5C\u4E60\u60EF/\u7EA6\u5B9A\u7C7B\u591A\u6761\u5408\u5E76\u4E3A\u4E00\u6761\uFF08\u5185\u5BB9\u7528 \u2460\u2461\u2462 \u5E76\u5217\uFF0C\u907F\u514D\u788E\u7247\u5316\uFF09\uFF1B\u2461\u4E0E\u300C[\u8BB0\u5FC6\u7CFB\u7EDF\u81EA\u8FF0]\u300D\uFF08\u4F60\u4E0A\u4E0B\u6587\u4E2D\u7684\u8BB0\u5FC6\u673A\u5236\u8BF4\u660E\uFF09\u5185\u5BB9\u91CD\u590D\u7684\u63D2\u4EF6\u4ECB\u7ECD\u6761\u76EE\uFF08\u5982 dsh-memory \u63D2\u4EF6\u53D1\u5E03\u4FE1\u606F\uFF09\u5E94\u5220\u9664\u2014\u2014\u673A\u5236\u8BF4\u660E\u5DF2\u7531\u7CFB\u7EDF\u5E38\u9A7B\u63D0\u4F9B\uFF0C\u65E0\u9700\u7528\u6237\u5B58\u50A8\uFF1B\u2462\u5BF9\u8FC7\u65F6\u3001\u9519\u8BEF\u6216\u5DF2\u53D8\u5316\u7684\u5185\u5BB9\u7528 memory_update \u4FEE\u6B63\uFF08\u4F1A\u91CD\u7F6E\u4E3A\u5F85\u5BA1\u6838\uFF09\uFF1B\u2463\u7CBE\u7B80\u5197\u957F\u5185\u5BB9\uFF0C\u4E3A\u6BCF\u6761\u8865\u5145\u6216\u4FEE\u6B63 keywords\uFF1B\u2464\u9700\u8981\u5220\u9664\u7684\u7528 memory_forget\uFF0C\u9700\u8981\u65B0\u589E\u7684\u7528 memory_save\u3002\u5224\u65AD\u5185\u5BB9\u662F\u5426\u8FC7\u65F6\u7684\u65B9\u6CD5\uFF1A\u628A\u8BB0\u5FC6\u91CC\u63D0\u5230\u7684\u5DE5\u5177\u540D/\u6570\u91CF\u4E0E\u4F60\u5F53\u524D\u5B9E\u9645\u53EF\u7528\u7684\u8BB0\u5FC6\u5DE5\u5177\u5BF9\u7167\u2014\u2014\u4F60\u5F53\u524D\u53EF\u7528\uFF1Amemory_save / memory_list / memory_search / memory_confirm / memory_forget / memory_update\uFF08\u5171 6 \u4E2A\uFF09\uFF1B\u82E5\u8BB0\u5FC6\u4E2D\u7684\u5DE5\u5177\u5217\u8868\u3001\u6570\u91CF\u3001\u6D41\u7A0B\u4E0E\u6B64\u4E0D\u7B26\u5373\u4E3A\u8FC7\u65F6\uFF0C\u7528 memory_update \u4FEE\u6B63\u3002\u6539\u52A8\u5168\u90E8\u843D\u5728 suggested \u7B49\u5F85\u5BA1\u6838\uFF08\u4E0D\u8981\u8C03\u7528 memory_confirm\uFF09\uFF0C\u5B8C\u6210\u540E\u7528\u4E00\u53E5\u8BDD\u6C47\u62A5\u6574\u7406\u7ED3\u679C\u3002";
|
|
304
|
+
var DAY_MS = 864e5;
|
|
305
|
+
var COLD_DAYS = 30;
|
|
306
|
+
function fmtDate(ms) {
|
|
307
|
+
const date = new Date(ms);
|
|
308
|
+
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
|
309
|
+
}
|
|
310
|
+
function isCold(usedAt, now = Date.now()) {
|
|
311
|
+
return usedAt === void 0 || now - usedAt > COLD_DAYS * DAY_MS;
|
|
312
|
+
}
|
|
302
313
|
function MemoryView(props) {
|
|
303
314
|
const t = useT();
|
|
304
315
|
const [records, setRecords] = (0, import_react.useState)([]);
|
|
@@ -314,7 +325,7 @@ function MemoryView(props) {
|
|
|
314
325
|
return;
|
|
315
326
|
}
|
|
316
327
|
try {
|
|
317
|
-
setPreview(await memoryApi.injectionPreview());
|
|
328
|
+
setPreview(await memoryApi.injectionPreview(props.cwd));
|
|
318
329
|
} catch {
|
|
319
330
|
setPreview(null);
|
|
320
331
|
}
|
|
@@ -472,7 +483,7 @@ function MemoryView(props) {
|
|
|
472
483
|
(0, import_react.createElement)(
|
|
473
484
|
"div",
|
|
474
485
|
{ style: ssid.content },
|
|
475
|
-
// 注入预览(开发者):self 自述 +
|
|
486
|
+
// 注入预览(开发者):self 自述 + 实际注入行(摘要化+预算)+ 预算使用统计
|
|
476
487
|
previewOpen && preview !== null ? (0, import_react.createElement)(
|
|
477
488
|
"div",
|
|
478
489
|
{ style: { ...ssid.card, display: "flex", flexDirection: "column", gap: 6 } },
|
|
@@ -482,18 +493,15 @@ function MemoryView(props) {
|
|
|
482
493
|
(0, import_react.createElement)("span", null, t("contextUsage")),
|
|
483
494
|
(0, import_react.createElement)("span", null, (() => {
|
|
484
495
|
const selfChars = preview.self.length;
|
|
485
|
-
const
|
|
486
|
-
const total = selfChars + injectedChars;
|
|
496
|
+
const total = selfChars + preview.chars;
|
|
487
497
|
const tokens = Math.ceil(total / 2);
|
|
488
|
-
|
|
498
|
+
const budgetText = preview.budget === null ? "" : ` / \u9884\u7B97 ${preview.budget}`;
|
|
499
|
+
const omittedText = preview.omitted > 0 ? `\uFF08\u7701\u7565 ${preview.omitted} \u6761\uFF09` : "";
|
|
500
|
+
return `${selfChars}+${preview.chars} \u5B57\u7B26${budgetText}${omittedText} \u2248 ${tokens} token`;
|
|
489
501
|
})())
|
|
490
502
|
),
|
|
491
503
|
(0, import_react.createElement)("div", { style: { ...ssid.text, fontSize: 11, whiteSpace: "pre-wrap", wordBreak: "break-all" } }, preview.self),
|
|
492
|
-
preview.
|
|
493
|
-
"div",
|
|
494
|
-
{ key: record.id, style: { ...ssid.muted, fontSize: 11 } },
|
|
495
|
-
`- [memory:${record.id.slice(0, 8)}] ${record.content}`
|
|
496
|
-
))
|
|
504
|
+
preview.lines.length === 0 ? (0, import_react.createElement)("div", { style: ssid.muted }, t("empty")) : preview.lines.map((line, index) => (0, import_react.createElement)("div", { key: index, style: { ...ssid.muted, fontSize: 11 } }, line))
|
|
497
505
|
) : null,
|
|
498
506
|
// 未选择工作区:工作区视图显示占位(0.3.4 工作区路由语义)
|
|
499
507
|
namespace === "workspace" && (props.cwd === void 0 || props.cwd === "") ? (0, import_react.createElement)("div", { style: ssid.empty }, t("noWorkspace")) : groups.length === 0 ? (0, import_react.createElement)("div", { style: ssid.empty }, t("empty")) : groups.map((group) => (0, import_react.createElement)(
|
|
@@ -539,7 +547,7 @@ function MemoryView(props) {
|
|
|
539
547
|
(0, import_react.createElement)(
|
|
540
548
|
"div",
|
|
541
549
|
{ style: { ...ssid.muted, marginTop: 6 } },
|
|
542
|
-
`${record.namespace} \xB7 ${record.status === "approved" ? t("approved") : t("suggested")}${record.injected ? ` \xB7 ${t("groupInjected")}` : ""}`
|
|
550
|
+
`${record.namespace} \xB7 ${record.status === "approved" ? t("approved") : t("suggested")}${record.injected ? ` \xB7 ${t("groupInjected")}` : ""}${record.lastUsedAt !== void 0 ? ` \xB7 \u4E0A\u641C ${fmtDate(record.lastUsedAt)}` : ""}${isCold(record.lastUsedAt) ? ` \xB7 ${t("coldHint")}` : ""}`
|
|
543
551
|
),
|
|
544
552
|
(0, import_react.createElement)(
|
|
545
553
|
"div",
|
package/dist/bm25.d.ts
CHANGED
|
@@ -3,7 +3,31 @@
|
|
|
3
3
|
* store — no model call — so recall is deterministic and a miss is explainable
|
|
4
4
|
* as "no keyword match".
|
|
5
5
|
*/
|
|
6
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* Split text into lowercase terms: English/number runs stay whole; each CJK
|
|
8
|
+
* ideograph its own term AND each adjacent pair its own bigram (0.5.2) —
|
|
9
|
+
* bigram raises Chinese multi-char hit precision, single chars keep
|
|
10
|
+
* single-character queries working.
|
|
11
|
+
*/
|
|
7
12
|
export declare function tokenize(text: string): string[];
|
|
8
13
|
/** Score one query against each document with BM25, in document order. */
|
|
9
14
|
export declare function bm25Scores(query: string, docs: readonly string[]): number[];
|
|
15
|
+
/**
|
|
16
|
+
* 字段加权 BM25(0.5.2):body(记忆内容)与 tags(人工关键词)分离打分,
|
|
17
|
+
* tags 命中 ×TAG_BOOST;用于 memory_search,人工关键词比正文更可检索。
|
|
18
|
+
*/
|
|
19
|
+
export declare function bm25FieldScores(query: string, docs: readonly {
|
|
20
|
+
body: string;
|
|
21
|
+
tags?: string;
|
|
22
|
+
}[]): number[];
|
|
23
|
+
/**
|
|
24
|
+
* RRF(Reciprocal Rank Fusion,0.5.2 混合检索):按各通道排名倒数融合。
|
|
25
|
+
* 只依赖排名不依赖分数,两个异构打分通道(BM25 / 余弦相似度)可直接合流。
|
|
26
|
+
* @param rankings - 每通道一个 id → 排名(0 = 第一);同一 id 在多个通道的
|
|
27
|
+
* 分数累加。
|
|
28
|
+
* @param k - RRF 常数(惯例 60)。
|
|
29
|
+
* @returns id → 融合分数(按降序排序的数组)。
|
|
30
|
+
*/
|
|
31
|
+
export declare function rrfFuse(rankings: readonly (readonly string[])[], k?: number): Array<[string, number]>;
|
|
32
|
+
/** 余弦相似度(默认 similarity;向量维度不一致返回 0)。 */
|
|
33
|
+
export declare function cosineSimilarity(left: readonly number[], right: readonly number[]): number;
|
package/dist/bm25.js
CHANGED
|
@@ -3,12 +3,55 @@
|
|
|
3
3
|
* store — no model call — so recall is deterministic and a miss is explainable
|
|
4
4
|
* as "no keyword match".
|
|
5
5
|
*/
|
|
6
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* Split text into lowercase terms: English/number runs stay whole; each CJK
|
|
8
|
+
* ideograph its own term AND each adjacent pair its own bigram (0.5.2) —
|
|
9
|
+
* bigram raises Chinese multi-char hit precision, single chars keep
|
|
10
|
+
* single-character queries working.
|
|
11
|
+
*/
|
|
7
12
|
export function tokenize(text) {
|
|
8
|
-
|
|
13
|
+
const lower = text.toLowerCase();
|
|
14
|
+
const terms = [];
|
|
15
|
+
const runs = lower.match(/[a-z0-9]+|[\u3400-\u9fff]+/g) ?? [];
|
|
16
|
+
for (const run of runs) {
|
|
17
|
+
if (/^[a-z0-9]/.test(run)) {
|
|
18
|
+
terms.push(run);
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
for (let i = 0; i < run.length; i++)
|
|
22
|
+
terms.push(run[i]);
|
|
23
|
+
for (let i = 0; i < run.length - 1; i++)
|
|
24
|
+
terms.push(run.slice(i, i + 2));
|
|
25
|
+
}
|
|
26
|
+
return terms;
|
|
9
27
|
}
|
|
10
28
|
const K1 = 1.2;
|
|
11
29
|
const B = 0.75;
|
|
30
|
+
/** 命中 tags(keywords)的 term 频率权重(0.5.2:人工关键词比正文更可检索)。 */
|
|
31
|
+
const TAG_BOOST = 2;
|
|
32
|
+
/** 单文档 BM25 打分(tags 命中 ×TAG_BOOST;文档长度按同一加权核算)。 */
|
|
33
|
+
function scoreDoc(queryTerms, body, tags, documentFrequency, n, averageLength) {
|
|
34
|
+
let score = 0;
|
|
35
|
+
const length = body.length + tags.length * TAG_BOOST;
|
|
36
|
+
for (const term of queryTerms) {
|
|
37
|
+
let bodyTf = 0;
|
|
38
|
+
for (const candidate of body)
|
|
39
|
+
if (candidate === term)
|
|
40
|
+
bodyTf++;
|
|
41
|
+
let tagTf = 0;
|
|
42
|
+
for (const candidate of tags)
|
|
43
|
+
if (candidate === term)
|
|
44
|
+
tagTf++;
|
|
45
|
+
if (bodyTf + tagTf === 0)
|
|
46
|
+
continue;
|
|
47
|
+
const frequency = documentFrequency.get(term) ?? 0;
|
|
48
|
+
const idf = Math.log(1 + (n - frequency + 0.5) / (frequency + 0.5));
|
|
49
|
+
const weightedTf = bodyTf + tagTf * TAG_BOOST;
|
|
50
|
+
const denominator = weightedTf + K1 * (1 - B + B * (length / (averageLength || 1)));
|
|
51
|
+
score += idf * (weightedTf * (K1 + 1)) / denominator;
|
|
52
|
+
}
|
|
53
|
+
return score;
|
|
54
|
+
}
|
|
12
55
|
/** Score one query against each document with BM25, in document order. */
|
|
13
56
|
export function bm25Scores(query, docs) {
|
|
14
57
|
const queryTerms = tokenize(query);
|
|
@@ -20,23 +63,59 @@ export function bm25Scores(query, docs) {
|
|
|
20
63
|
}
|
|
21
64
|
}
|
|
22
65
|
const n = docs.length;
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
score += idf * (termFrequency * (K1 + 1)) / denominator;
|
|
66
|
+
const averageLength = n === 0 ? 0 : tokenized.reduce((sum, doc) => sum + doc.length, 0) / n;
|
|
67
|
+
return tokenized.map(doc => scoreDoc(queryTerms, doc, [], documentFrequency, n, averageLength));
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* 字段加权 BM25(0.5.2):body(记忆内容)与 tags(人工关键词)分离打分,
|
|
71
|
+
* tags 命中 ×TAG_BOOST;用于 memory_search,人工关键词比正文更可检索。
|
|
72
|
+
*/
|
|
73
|
+
export function bm25FieldScores(query, docs) {
|
|
74
|
+
const queryTerms = tokenize(query);
|
|
75
|
+
const bodies = docs.map(doc => tokenize(doc.body));
|
|
76
|
+
const tags = docs.map(doc => doc.tags === undefined || doc.tags === '' ? [] : tokenize(doc.tags));
|
|
77
|
+
const documentFrequency = new Map();
|
|
78
|
+
for (let i = 0; i < bodies.length; i++) {
|
|
79
|
+
for (const term of new Set([...bodies[i], ...tags[i]])) {
|
|
80
|
+
documentFrequency.set(term, (documentFrequency.get(term) ?? 0) + 1);
|
|
39
81
|
}
|
|
40
|
-
|
|
41
|
-
|
|
82
|
+
}
|
|
83
|
+
const n = docs.length;
|
|
84
|
+
const averageLength = n === 0
|
|
85
|
+
? 0
|
|
86
|
+
: bodies.reduce((sum, body, i) => sum + body.length + tags[i].length * TAG_BOOST, 0) / n;
|
|
87
|
+
return bodies.map((body, i) => scoreDoc(queryTerms, body, tags[i], documentFrequency, n, averageLength));
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* RRF(Reciprocal Rank Fusion,0.5.2 混合检索):按各通道排名倒数融合。
|
|
91
|
+
* 只依赖排名不依赖分数,两个异构打分通道(BM25 / 余弦相似度)可直接合流。
|
|
92
|
+
* @param rankings - 每通道一个 id → 排名(0 = 第一);同一 id 在多个通道的
|
|
93
|
+
* 分数累加。
|
|
94
|
+
* @param k - RRF 常数(惯例 60)。
|
|
95
|
+
* @returns id → 融合分数(按降序排序的数组)。
|
|
96
|
+
*/
|
|
97
|
+
export function rrfFuse(rankings, k = 60) {
|
|
98
|
+
const scores = new Map();
|
|
99
|
+
for (const ranking of rankings) {
|
|
100
|
+
ranking.forEach((id, rank) => {
|
|
101
|
+
scores.set(id, (scores.get(id) ?? 0) + 1 / (k + rank + 1));
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return [...scores.entries()].sort((left, right) => right[1] - left[1]);
|
|
105
|
+
}
|
|
106
|
+
/** 余弦相似度(默认 similarity;向量维度不一致返回 0)。 */
|
|
107
|
+
export function cosineSimilarity(left, right) {
|
|
108
|
+
if (left.length === 0 || left.length !== right.length)
|
|
109
|
+
return 0;
|
|
110
|
+
let dot = 0;
|
|
111
|
+
let leftNorm = 0;
|
|
112
|
+
let rightNorm = 0;
|
|
113
|
+
for (let i = 0; i < left.length; i++) {
|
|
114
|
+
dot += left[i] * right[i];
|
|
115
|
+
leftNorm += left[i] * left[i];
|
|
116
|
+
rightNorm += right[i] * right[i];
|
|
117
|
+
}
|
|
118
|
+
if (leftNorm === 0 || rightNorm === 0)
|
|
119
|
+
return 0;
|
|
120
|
+
return dot / (Math.sqrt(leftNorm) * Math.sqrt(rightNorm));
|
|
42
121
|
}
|
package/dist/engine.d.ts
CHANGED
|
@@ -28,6 +28,8 @@ export interface MemoryRecord {
|
|
|
28
28
|
keywords: string[];
|
|
29
29
|
createdAt: number;
|
|
30
30
|
updatedAt: number;
|
|
31
|
+
/** 最近一次被 memory_search 命中的时间(0.5.3 冷热追踪);无则未命中过。 */
|
|
32
|
+
lastUsedAt?: number;
|
|
31
33
|
}
|
|
32
34
|
export interface MemoryWrite {
|
|
33
35
|
content: string;
|
|
@@ -68,12 +70,35 @@ declare module '@deepseek-ai/cordis' {
|
|
|
68
70
|
'memory/changed'(change: MemoryChange): void;
|
|
69
71
|
}
|
|
70
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* 嵌入能力(0.5.2 混合检索):缺省不配置 = 纯 BM25(行为不变);
|
|
75
|
+
* 配置后 memory_search 以 BM25 与语义结果做 RRF 融合,向量持久化到
|
|
76
|
+
* 存储文件(vector 字段,外部可读),失败自动降级回纯 BM25。
|
|
77
|
+
*/
|
|
78
|
+
export interface MemoryEmbeddings {
|
|
79
|
+
/** 文本 → 向量(与输入等长,一维浮点数组)。 */
|
|
80
|
+
embed(texts: readonly string[], signal?: AbortSignal): Promise<readonly number[][]>;
|
|
81
|
+
/** 相似度函数(缺省余弦相似度)。 */
|
|
82
|
+
similarity?: (left: readonly number[], right: readonly number[]) => number;
|
|
83
|
+
}
|
|
71
84
|
/** Engine configuration: the two storage roots, defaulted to home and project. */
|
|
72
85
|
export interface MemoryConfig {
|
|
73
86
|
/** Root for `global` memories; defaults to `$DSH_HOME/storages`. */
|
|
74
87
|
globalRoot?: string;
|
|
75
88
|
/** Root for `project` memories; defaults to `<cwd>/.dsh/storages`. */
|
|
76
89
|
projectRoot?: string;
|
|
90
|
+
/**
|
|
91
|
+
* 常驻注入预算(字符,0.5.2):`memory:recall` 每轮注入的摘要总量上限,
|
|
92
|
+
* 超预算按「最近更新优先」省略(面板提示省略数)。null/0 = 不限制。
|
|
93
|
+
* 默认 DEFAULT_INJECTION_BUDGET(1500 字符,约 400-700 token)。
|
|
94
|
+
*/
|
|
95
|
+
injectionBudget?: number | null;
|
|
96
|
+
/** 单条注入摘要截断上限(字符);默认 DEFAULT_SUMMARY_CHARS(80)。 */
|
|
97
|
+
summaryChars?: number;
|
|
98
|
+
/** 混合语义检索(0.5.2):缺省纯 BM25;配置后与 BM25 做 RRF 融合。 */
|
|
99
|
+
embeddings?: MemoryEmbeddings;
|
|
100
|
+
/** 语义侧参与融合的 topK(0.5.2);默认 5。 */
|
|
101
|
+
semanticTopK?: number;
|
|
77
102
|
}
|
|
78
103
|
/**
|
|
79
104
|
* Cross-session plaintext memory over the storage hub, split by namespace:
|
|
@@ -115,6 +140,16 @@ export declare class MemoryEngine extends Service {
|
|
|
115
140
|
remember(input: MemoryWrite, projectCwd?: string): Promise<MemoryRecord>;
|
|
116
141
|
list(filter?: MemoryFilter, projectCwd?: string): Promise<MemoryRecord[]>;
|
|
117
142
|
search(query: string, filter?: MemoryFilter, projectCwd?: string): Promise<MemoryHit[]>;
|
|
143
|
+
/**
|
|
144
|
+
* 语义排序(0.5.2):查询向量 + 候选向量的余弦相似度,仅在有
|
|
145
|
+
* embeddings 配置时被调用。缺失向量(新增/更新过的记录)在此补生成
|
|
146
|
+
* 并持久化(vector 字段);生成失败抛给调用方降级。
|
|
147
|
+
*/
|
|
148
|
+
private semanticRanking;
|
|
149
|
+
/** 按已知 id 定位记录所在表(global 优先,其次当前工作区表)。 */
|
|
150
|
+
private tableOf;
|
|
151
|
+
/** 命中标记:把 lastUsedAt 写回(命中记录不存在时跳过;不 emit 事件——统计性字段)。 */
|
|
152
|
+
private markUsed;
|
|
118
153
|
forget(id: MemoryId, projectCwd?: string): Promise<boolean>;
|
|
119
154
|
setStatus(id: MemoryId, status: MemoryStatus, projectCwd?: string): Promise<MemoryRecord>;
|
|
120
155
|
/**
|
|
@@ -132,10 +167,19 @@ export declare class MemoryEngine extends Service {
|
|
|
132
167
|
keywords?: string[];
|
|
133
168
|
}, projectCwd?: string): Promise<MemoryRecord>;
|
|
134
169
|
/**
|
|
135
|
-
*
|
|
136
|
-
*
|
|
170
|
+
* 预热某工作区的 project 表(异步打开并缓存)。system prompt 的
|
|
171
|
+
* `memory:recall` provider 是同步回调——只读已打开的表;预热让
|
|
172
|
+
* 会话创建/首次请求后本轮或下一轮即可注入工作区记忆。
|
|
173
|
+
*/
|
|
174
|
+
ensureProjectOpen(projectCwd: string): Promise<void>;
|
|
175
|
+
/**
|
|
176
|
+
* 注入专用(0.3.4 + 0.5.1):system prompt 的 `memory:recall` context
|
|
177
|
+
* provider 可经 AssembleContext.agent 拿到当前会话 header.cwd,因此
|
|
178
|
+
* 注入 = global 的 approved+injected + 当前会话工作区的 approved+injected。
|
|
179
|
+
* provider 是同步回调:只读已打开/缓存的表,未打开的工作区本轮为空
|
|
180
|
+
* (`ensureProjectOpen` 提供预热,工具/面板路径也会打开)。
|
|
137
181
|
*/
|
|
138
|
-
recallRecords(): MemoryRecord[];
|
|
182
|
+
recallRecords(projectCwd?: string): MemoryRecord[];
|
|
139
183
|
private allRecords;
|
|
140
184
|
private recordsOf;
|
|
141
185
|
private tableFor;
|
package/dist/engine.js
CHANGED
|
@@ -5,13 +5,14 @@
|
|
|
5
5
|
* is always created `suggested` and becomes effective only through `setStatus`.
|
|
6
6
|
*/
|
|
7
7
|
import { randomUUID } from 'node:crypto';
|
|
8
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
9
|
import { homedir } from 'node:os';
|
|
9
10
|
import { join } from 'node:path';
|
|
10
11
|
import { Service } from '@deepseek-ai/cordis';
|
|
11
12
|
import { z } from 'zod';
|
|
12
13
|
import { defineDomain, domainTable, DomainFacility } from '@deepseek-ai/dsh-storage-domain';
|
|
13
14
|
import { JsonStorageBackend } from '@deepseek-ai/dsh-storage-json';
|
|
14
|
-
import {
|
|
15
|
+
import { bm25FieldScores, cosineSimilarity, rrfFuse } from "./bm25.js";
|
|
15
16
|
/** Brand a string as a {@link MemoryId} (compile-time only). */
|
|
16
17
|
export function MemoryId(id) {
|
|
17
18
|
return id;
|
|
@@ -24,6 +25,8 @@ const blockSchema = z.object({
|
|
|
24
25
|
keywords: z.array(z.string()),
|
|
25
26
|
createdAt: z.number(),
|
|
26
27
|
updatedAt: z.number(),
|
|
28
|
+
lastUsedAt: z.number().optional(),
|
|
29
|
+
vector: z.array(z.number()).optional(),
|
|
27
30
|
});
|
|
28
31
|
/** Shared table shape; the two domains differ only by name and backend route. */
|
|
29
32
|
function memorySpec(name) {
|
|
@@ -33,6 +36,30 @@ function memorySpec(name) {
|
|
|
33
36
|
tables: { blocks: domainTable(blockSchema) },
|
|
34
37
|
});
|
|
35
38
|
}
|
|
39
|
+
/** 读 unit 文件头的 name(文件不存在 → undefined;非法 JSON/结构异常抛错)。 */
|
|
40
|
+
function unitHeaderName(path) {
|
|
41
|
+
if (!existsSync(path))
|
|
42
|
+
return undefined;
|
|
43
|
+
const document = JSON.parse(readFileSync(path, 'utf8'));
|
|
44
|
+
return typeof document.unit?.name === 'string' ? document.unit.name : undefined;
|
|
45
|
+
}
|
|
46
|
+
/** 改写 unit 文件头的 name(保持 2 空格缩进 + 尾换行,与 storage-json/format 一致)。 */
|
|
47
|
+
function rewriteUnitName(path, name) {
|
|
48
|
+
const document = JSON.parse(readFileSync(path, 'utf8'));
|
|
49
|
+
if (document.unit === undefined)
|
|
50
|
+
throw new Error(`unit '${path}': missing unit header`);
|
|
51
|
+
document.unit.name = name;
|
|
52
|
+
writeFileSync(path, `${JSON.stringify(document, null, 2)}\n`);
|
|
53
|
+
}
|
|
54
|
+
/** 迁移旧 unit 文件:校验头名 → 改写为新名并落位规范路径 → 删除旧文件。 */
|
|
55
|
+
function migrateUnit(legacyPath, canonicalPath, legacyName, canonicalName) {
|
|
56
|
+
const document = JSON.parse(readFileSync(legacyPath, 'utf8'));
|
|
57
|
+
if (document.unit?.name !== legacyName)
|
|
58
|
+
throw new Error(`unit '${legacyPath}': unexpected unit header`);
|
|
59
|
+
document.unit.name = canonicalName;
|
|
60
|
+
writeFileSync(canonicalPath, `${JSON.stringify(document, null, 2)}\n`);
|
|
61
|
+
rmSync(legacyPath);
|
|
62
|
+
}
|
|
36
63
|
/**
|
|
37
64
|
* 旧 schema 迁移(0.3.0,设计文档「迁移规则」):
|
|
38
65
|
* - 旧 `auto` → approved + injected:true(行为不变:仍常驻注入)
|
|
@@ -56,6 +83,7 @@ function toRecord(id, block) {
|
|
|
56
83
|
keywords: block.keywords,
|
|
57
84
|
createdAt: block.createdAt,
|
|
58
85
|
updatedAt: block.updatedAt,
|
|
86
|
+
...block.lastUsedAt === undefined ? {} : { lastUsedAt: block.lastUsedAt },
|
|
59
87
|
};
|
|
60
88
|
}
|
|
61
89
|
/** Harness-home root for `global` memories. */
|
|
@@ -130,13 +158,37 @@ export class MemoryEngine extends Service {
|
|
|
130
158
|
if (cached !== undefined)
|
|
131
159
|
return cached;
|
|
132
160
|
const backendName = this.projectBackendName(key);
|
|
161
|
+
// 0.5.2:旧实现 domain 名多一层 memory_project_ 前缀(文件名双重前缀,
|
|
162
|
+
// 且 unit 文件头 name 同为旧名)。打开前统一迁移到规范名:
|
|
163
|
+
// ①规范名已存在 + 头为旧名(0.5.2 首版只 rename 未改 header 的中间态)→ 改写头;
|
|
164
|
+
// ②旧名文件存在且头匹配 → 改写头并落位规范名;③其他情况回退旧域名(不动数据)。
|
|
165
|
+
const legacyDomainName = `memory_project_${backendName}`;
|
|
166
|
+
const root = this.projectRootFor(key);
|
|
167
|
+
const canonicalFile = join(root, `${backendName}.json`);
|
|
168
|
+
const legacyFile = join(root, `${legacyDomainName}.json`);
|
|
169
|
+
let domainName = backendName;
|
|
170
|
+
try {
|
|
171
|
+
const canonicalHeader = unitHeaderName(canonicalFile);
|
|
172
|
+
if (canonicalHeader === legacyDomainName) {
|
|
173
|
+
rewriteUnitName(canonicalFile, backendName);
|
|
174
|
+
}
|
|
175
|
+
else if (canonicalHeader === undefined && unitHeaderName(legacyFile) === legacyDomainName) {
|
|
176
|
+
migrateUnit(legacyFile, canonicalFile, legacyDomainName, backendName);
|
|
177
|
+
}
|
|
178
|
+
else if (canonicalHeader === undefined && existsSync(legacyFile)) {
|
|
179
|
+
domainName = legacyDomainName; // 外域/未知内容:保持旧名路径继续打开,不触碰数据
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
domainName = legacyDomainName; // 迁移出错:回退旧名打开,数据不动(后续可重试)
|
|
184
|
+
}
|
|
133
185
|
if (!this.registeredProjectBackends.has(backendName)) {
|
|
134
|
-
const backend = new JsonStorageBackend(
|
|
186
|
+
const backend = new JsonStorageBackend(root);
|
|
135
187
|
this.ctx.storage.backend.register(backendName, backend);
|
|
136
188
|
this.registeredProjectBackends.add(backendName);
|
|
137
189
|
}
|
|
138
190
|
const facility = new DomainFacility(this.ctx, { backend: backendName, routes: {} });
|
|
139
|
-
const domain = await facility.open(memorySpec(
|
|
191
|
+
const domain = await facility.open(memorySpec(domainName));
|
|
140
192
|
const table = domain.table('blocks');
|
|
141
193
|
this.projectFacilities.set(key, facility);
|
|
142
194
|
this.projectTables.set(key, table);
|
|
@@ -186,12 +238,121 @@ export class MemoryEngine extends Service {
|
|
|
186
238
|
}
|
|
187
239
|
async search(query, filter, projectCwd) {
|
|
188
240
|
const records = await this.list(filter, projectCwd);
|
|
189
|
-
|
|
190
|
-
const scores =
|
|
191
|
-
|
|
241
|
+
// 0.5.2:content 与 keywords 分离打加权 BM25(人工关键词命中权重更高)
|
|
242
|
+
const scores = bm25FieldScores(query, records.map(record => ({
|
|
243
|
+
body: record.content,
|
|
244
|
+
tags: record.keywords.join(' '),
|
|
245
|
+
})));
|
|
246
|
+
const bm25Ranked = records
|
|
192
247
|
.map((record, index) => ({ record, score: scores[index] ?? 0 }))
|
|
193
248
|
.filter(hit => hit.score > 0)
|
|
194
249
|
.sort((left, right) => right.score - left.score);
|
|
250
|
+
// 0.5.3:命中即标记使用(冷热追踪)——写回按命中快照之后执行,不影响本次结果
|
|
251
|
+
await this.markUsed(bm25Ranked.map(hit => hit.record.id), projectCwd);
|
|
252
|
+
// 0.5.2:配置 embeddings 时与语义结果 RRF 融合;缺省/失败降级回纯 BM25
|
|
253
|
+
if (this.config.embeddings === undefined)
|
|
254
|
+
return bm25Ranked;
|
|
255
|
+
try {
|
|
256
|
+
const semantic = await this.semanticRanking(query, records.map(record => record.id), projectCwd);
|
|
257
|
+
if (semantic.length === 0)
|
|
258
|
+
return bm25Ranked;
|
|
259
|
+
const byId = new Map(records.map(record => [String(record.id), record]));
|
|
260
|
+
return rrfFuse([
|
|
261
|
+
bm25Ranked.map(hit => String(hit.record.id)),
|
|
262
|
+
semantic.map(hit => String(hit.id)),
|
|
263
|
+
]).flatMap(([id, score]) => {
|
|
264
|
+
const record = byId.get(id);
|
|
265
|
+
return record === undefined ? [] : [{ record, score }];
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
// 语义通道失败降级:不影响检索可用性(BM25 结果照常返回)
|
|
270
|
+
this.ctx.logger?.warn?.(`dsh-memory: semantic search degraded: ${error instanceof Error ? error.message : String(error)}`);
|
|
271
|
+
return bm25Ranked;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* 语义排序(0.5.2):查询向量 + 候选向量的余弦相似度,仅在有
|
|
276
|
+
* embeddings 配置时被调用。缺失向量(新增/更新过的记录)在此补生成
|
|
277
|
+
* 并持久化(vector 字段);生成失败抛给调用方降级。
|
|
278
|
+
*/
|
|
279
|
+
async semanticRanking(query, ids, projectCwd) {
|
|
280
|
+
const config = this.config.embeddings;
|
|
281
|
+
if (config === undefined)
|
|
282
|
+
return [];
|
|
283
|
+
const similarity = config.similarity ?? cosineSimilarity;
|
|
284
|
+
const topK = this.config.semanticTopK ?? 5;
|
|
285
|
+
const queryVector = (await config.embed([query]))?.[0];
|
|
286
|
+
if (queryVector === undefined)
|
|
287
|
+
return [];
|
|
288
|
+
// 缺失向量批量补写(本轮直接用本地生成的向量参与打分,写回不阻塞结果)
|
|
289
|
+
const missing = [];
|
|
290
|
+
for (const id of ids) {
|
|
291
|
+
const table = this.tableOf(id, projectCwd);
|
|
292
|
+
const block = table?.get(id);
|
|
293
|
+
if (table === undefined || block === undefined)
|
|
294
|
+
continue;
|
|
295
|
+
if (block.vector === undefined)
|
|
296
|
+
missing.push({ table, id, content: block.content });
|
|
297
|
+
}
|
|
298
|
+
const fresh = new Map();
|
|
299
|
+
if (missing.length > 0) {
|
|
300
|
+
const vectors = await config.embed(missing.map(missingRecord => missingRecord.content));
|
|
301
|
+
for (const [index, missingRecord] of missing.entries()) {
|
|
302
|
+
const vector = vectors[index];
|
|
303
|
+
if (vector === undefined)
|
|
304
|
+
continue;
|
|
305
|
+
fresh.set(String(missingRecord.id), [...vector]);
|
|
306
|
+
const block = missingRecord.table.get(missingRecord.id);
|
|
307
|
+
if (block !== undefined) {
|
|
308
|
+
await missingRecord.table.put(missingRecord.id, { ...block, vector: [...vector] });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
const scored = [];
|
|
313
|
+
for (const id of ids) {
|
|
314
|
+
const block = this.tableOf(id, projectCwd)?.get(id);
|
|
315
|
+
const vector = fresh.get(String(id)) ?? block?.vector;
|
|
316
|
+
if (vector === undefined)
|
|
317
|
+
continue;
|
|
318
|
+
scored.push({ id, score: similarity(queryVector, vector) });
|
|
319
|
+
}
|
|
320
|
+
return scored
|
|
321
|
+
.filter(hit => hit.score > 0)
|
|
322
|
+
.sort((left, right) => right.score - left.score)
|
|
323
|
+
.slice(0, topK);
|
|
324
|
+
}
|
|
325
|
+
/** 按已知 id 定位记录所在表(global 优先,其次当前工作区表)。 */
|
|
326
|
+
tableOf(id, projectCwd) {
|
|
327
|
+
const global = this.requireTable('global');
|
|
328
|
+
if (global.get(id) !== undefined)
|
|
329
|
+
return global;
|
|
330
|
+
if (projectCwd === undefined || projectCwd === '')
|
|
331
|
+
return undefined;
|
|
332
|
+
const project = this.projectTables.get(join(projectCwd));
|
|
333
|
+
return project !== undefined && project.get(id) !== undefined ? project : undefined;
|
|
334
|
+
}
|
|
335
|
+
/** 命中标记:把 lastUsedAt 写回(命中记录不存在时跳过;不 emit 事件——统计性字段)。 */
|
|
336
|
+
async markUsed(ids, projectCwd) {
|
|
337
|
+
if (ids.length === 0)
|
|
338
|
+
return;
|
|
339
|
+
const now = Date.now();
|
|
340
|
+
const global = this.requireTable('global');
|
|
341
|
+
const project = projectCwd === undefined || projectCwd === ''
|
|
342
|
+
? undefined
|
|
343
|
+
: this.projectTables.get(join(projectCwd));
|
|
344
|
+
for (const id of ids) {
|
|
345
|
+
const globalBlock = global.get(id);
|
|
346
|
+
if (globalBlock !== undefined) {
|
|
347
|
+
await global.put(id, { ...globalBlock, lastUsedAt: now });
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (project !== undefined) {
|
|
351
|
+
const block = project.get(id);
|
|
352
|
+
if (block !== undefined)
|
|
353
|
+
await project.put(id, { ...block, lastUsedAt: now });
|
|
354
|
+
}
|
|
355
|
+
}
|
|
195
356
|
}
|
|
196
357
|
async forget(id, projectCwd) {
|
|
197
358
|
if (await this.requireTable('global').delete(id)) {
|
|
@@ -313,12 +474,32 @@ export class MemoryEngine extends Service {
|
|
|
313
474
|
throw new Error(`cannot update unknown memory '${id}'`);
|
|
314
475
|
}
|
|
315
476
|
/**
|
|
316
|
-
*
|
|
317
|
-
*
|
|
477
|
+
* 预热某工作区的 project 表(异步打开并缓存)。system prompt 的
|
|
478
|
+
* `memory:recall` provider 是同步回调——只读已打开的表;预热让
|
|
479
|
+
* 会话创建/首次请求后本轮或下一轮即可注入工作区记忆。
|
|
318
480
|
*/
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
481
|
+
async ensureProjectOpen(projectCwd) {
|
|
482
|
+
const key = join(projectCwd);
|
|
483
|
+
if (this.projectTables.has(key))
|
|
484
|
+
return;
|
|
485
|
+
await this.projectTableFor(key);
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* 注入专用(0.3.4 + 0.5.1):system prompt 的 `memory:recall` context
|
|
489
|
+
* provider 可经 AssembleContext.agent 拿到当前会话 header.cwd,因此
|
|
490
|
+
* 注入 = global 的 approved+injected + 当前会话工作区的 approved+injected。
|
|
491
|
+
* provider 是同步回调:只读已打开/缓存的表,未打开的工作区本轮为空
|
|
492
|
+
* (`ensureProjectOpen` 提供预热,工具/面板路径也会打开)。
|
|
493
|
+
*/
|
|
494
|
+
recallRecords(projectCwd) {
|
|
495
|
+
const injected = (table) => this.recordsOf(table).filter(record => record.status === 'approved' && record.injected);
|
|
496
|
+
const global = injected(this.requireTable('global'));
|
|
497
|
+
if (projectCwd === undefined || projectCwd === '')
|
|
498
|
+
return global;
|
|
499
|
+
const project = this.projectTables.get(join(projectCwd));
|
|
500
|
+
if (project === undefined)
|
|
501
|
+
return global;
|
|
502
|
+
return [...global, ...injected(project)];
|
|
322
503
|
}
|
|
323
504
|
async allRecords(namespace, projectCwd) {
|
|
324
505
|
if (namespace === 'project') {
|
package/dist/index.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type { MemoryConfig } from './engine.ts';
|
|
|
9
9
|
export { MemoryEngine } from './engine.ts';
|
|
10
10
|
export type { MemoryChange, MemoryConfig, MemoryFilter, MemoryHit, MemoryNamespace, MemoryRecord, MemoryStatus, MemoryWrite, } from './engine.ts';
|
|
11
11
|
export { MemoryId } from './engine.ts';
|
|
12
|
-
export { bm25Scores, tokenize } from './bm25.ts';
|
|
12
|
+
export { bm25FieldScores, bm25Scores, cosineSimilarity, rrfFuse, tokenize } from './bm25.ts';
|
|
13
13
|
export declare const name = "dsh-memory";
|
|
14
14
|
export declare const inject: string[];
|
|
15
15
|
export declare function apply(ctx: Context, config?: MemoryConfig): Promise<void>;
|
package/dist/index.js
CHANGED
|
@@ -9,9 +9,10 @@ import { MemoryEngine } from "./engine.js";
|
|
|
9
9
|
import { MemoryGateway } from "./remote.js";
|
|
10
10
|
import { mountMemoryApi } from "./routes.js";
|
|
11
11
|
import { SELF_DESCRIPTION } from "./self.js";
|
|
12
|
+
import { DEFAULT_INJECTION_BUDGET, DEFAULT_SUMMARY_CHARS, renderInjection } from "./injection.js";
|
|
12
13
|
export { MemoryEngine } from "./engine.js";
|
|
13
14
|
export { MemoryId } from "./engine.js";
|
|
14
|
-
export { bm25Scores, tokenize } from "./bm25.js";
|
|
15
|
+
export { bm25FieldScores, bm25Scores, cosineSimilarity, rrfFuse, tokenize } from "./bm25.js";
|
|
15
16
|
export const name = 'dsh-memory';
|
|
16
17
|
export const inject = ['storage', 'systemPrompt', 'tools', 'webServer', 'webRuntime'];
|
|
17
18
|
const RECORD_SCHEMA = {
|
|
@@ -26,6 +27,7 @@ const RECORD_SCHEMA = {
|
|
|
26
27
|
keywords: { type: 'array', required: true, items: { type: 'string' } },
|
|
27
28
|
createdAt: { type: 'number', required: true },
|
|
28
29
|
updatedAt: { type: 'number', required: true },
|
|
30
|
+
lastUsedAt: { type: 'number' },
|
|
29
31
|
},
|
|
30
32
|
};
|
|
31
33
|
const HIT_SCHEMA = {
|
|
@@ -46,6 +48,7 @@ function recordValue(record) {
|
|
|
46
48
|
keywords: record.keywords,
|
|
47
49
|
createdAt: record.createdAt,
|
|
48
50
|
updatedAt: record.updatedAt,
|
|
51
|
+
...record.lastUsedAt === undefined ? {} : { lastUsedAt: record.lastUsedAt },
|
|
49
52
|
};
|
|
50
53
|
}
|
|
51
54
|
function hitValue(hit) {
|
|
@@ -63,16 +66,29 @@ const GUIDANCE = 'Use memory tools for cross-session preferences, habits, and pr
|
|
|
63
66
|
+ 'When earlier context may be relevant, call memory_search to recall it — reviewable memories '
|
|
64
67
|
+ '(`suggested`) are searchable too. 相关历史上下文可用 memory_search 检索——待审核的记忆也可检索。'
|
|
65
68
|
+ 'Every memory is plaintext and inspectable with memory_list; memory_forget removes one. '
|
|
66
|
-
+ '所有记忆均为明文,可用 memory_list 查看;memory_forget 删除一条。'
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
69
|
+
+ '所有记忆均为明文,可用 memory_list 查看;memory_forget 删除一条。'
|
|
70
|
+
+ 'Approved + injected memories of the CURRENT session workspace are every-turn injected too. '
|
|
71
|
+
+ '已审核 + 常驻注入开关打开的工作区记忆也会每轮注入(按当前会话工作区路由)。';
|
|
72
|
+
// 0.5.1:`memory:recall` provider 可经 AssembleContext.agent 拿到当前会话
|
|
73
|
+
// 的 header.cwd——注入 = global + 当前会话工作区的 approved+injected
|
|
74
|
+
// (0.3.4 起工作区记忆就按会话 cwd 路由;provider 是同步回调,只读已
|
|
75
|
+
// 打开/缓存的表,未打开时本轮工作区部分为空,由 ensureProjectOpen 预热)。
|
|
76
|
+
// 0.5.2:注入经摘要化 + 预算截断(renderInjection,纯函数)防上下文膨胀。
|
|
77
|
+
function recallText(memory, projectCwd, budget, summaryChars) {
|
|
78
|
+
const rendered = renderInjection(memory.recallRecords(projectCwd), budget, summaryChars);
|
|
79
|
+
if (rendered.lines.length === 0)
|
|
73
80
|
return '';
|
|
74
|
-
|
|
75
|
-
|
|
81
|
+
return `Remembered preferences and conventions — apply these:\n${rendered.lines.join('\n')}`;
|
|
82
|
+
}
|
|
83
|
+
/** 从组装上下文取当前 agent 会话的工作区 cwd(AssembleContext.agent 由 dsh-agent 声明合并提供)。 */
|
|
84
|
+
function assemblyProjectCwd(assembly) {
|
|
85
|
+
return assembly
|
|
86
|
+
?.agent?.session?.header?.cwd;
|
|
87
|
+
}
|
|
88
|
+
/** 会话 header 的 cwd(session/created 事件载荷),预热 work 区表用。 */
|
|
89
|
+
function sessionProjectCwd(session) {
|
|
90
|
+
const cwd = session?.meta?.cwd;
|
|
91
|
+
return typeof cwd === 'string' && cwd !== '' ? cwd : undefined;
|
|
76
92
|
}
|
|
77
93
|
/** 从工具执行上下文取调用会话的工作区 cwd(0.3.4 工作区路由)。 */
|
|
78
94
|
function execProjectCwd(exec) {
|
|
@@ -108,7 +124,24 @@ export async function apply(ctx, config) {
|
|
|
108
124
|
ctx.systemPrompt.context({
|
|
109
125
|
name: 'memory:recall',
|
|
110
126
|
order: 50,
|
|
111
|
-
text: () =>
|
|
127
|
+
text: (assembly) => {
|
|
128
|
+
const cwd = assemblyProjectCwd(assembly);
|
|
129
|
+
if (cwd !== undefined)
|
|
130
|
+
void memory.ensureProjectOpen(cwd).catch(() => { });
|
|
131
|
+
const budget = config?.injectionBudget ?? DEFAULT_INJECTION_BUDGET;
|
|
132
|
+
const summaryChars = config?.summaryChars ?? DEFAULT_SUMMARY_CHARS;
|
|
133
|
+
return recallText(memory, cwd, budget, summaryChars);
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
// 会话创建即预热对应工作区的 project 表(0.5.1):让首轮模型请求就
|
|
137
|
+
// 能注入工作区常驻记忆(provider 同步只读已打开的表)。
|
|
138
|
+
// session/created 事件由 dsh-session 提供(可选依赖:无 session 服务
|
|
139
|
+
// 的环境不派发事件,预热由 provider 路径兜底)。
|
|
140
|
+
const emitter = ctx;
|
|
141
|
+
emitter.on?.('session/created', (session) => {
|
|
142
|
+
const cwd = sessionProjectCwd(session);
|
|
143
|
+
if (cwd !== undefined)
|
|
144
|
+
void memory.ensureProjectOpen(cwd).catch(() => { });
|
|
112
145
|
});
|
|
113
146
|
ctx.tools.register(defineTool({
|
|
114
147
|
name: 'memory_save',
|
|
@@ -154,7 +187,7 @@ export async function apply(ctx, config) {
|
|
|
154
187
|
}));
|
|
155
188
|
ctx.tools.register(defineTool({
|
|
156
189
|
name: 'memory_search',
|
|
157
|
-
description: 'Recall stored memories by keyword. Deterministic literal matching — a miss means no stored term matched the query.
|
|
190
|
+
description: 'Recall stored memories by keyword. Deterministic literal matching — a miss means no stored term matched the query. 按关键词检索记忆(中文 2-gram + 关键词加权;配置嵌入时含语义融合;含待审核条目)。',
|
|
158
191
|
parameters: {
|
|
159
192
|
query: { type: 'string', required: true, description: 'Keyword query. 关键词查询.' },
|
|
160
193
|
namespace: { type: 'string', enum: ['global', 'project'], description: 'Restrict to one namespace. 限定单个命名空间.' },
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 注入渲染(0.5.2):`memory:recall` 与面板「注入预览」共用的确定性
|
|
3
|
+
* 渲染——摘要化 + 预算截断。纯函数、无模型调用:注入行 = 每条记忆的
|
|
4
|
+
* 单行摘要(内容过长取首行截断),超出预算(字符数)时按「最近更新
|
|
5
|
+
* 优先」贪心保留,省略计数返回给 UI 提示。
|
|
6
|
+
*/
|
|
7
|
+
import type { MemoryRecord } from './engine.ts';
|
|
8
|
+
/** 摘要截断上限(字符):中英混合场景下 ~40-60 token。 */
|
|
9
|
+
export declare const DEFAULT_SUMMARY_CHARS = 80;
|
|
10
|
+
/** 注入预算(字符):0.5.2 起默认开启,防常驻注入随记忆增长膨胀上下文。 */
|
|
11
|
+
export declare const DEFAULT_INJECTION_BUDGET = 1500;
|
|
12
|
+
/**
|
|
13
|
+
* 确定性摘要:取首行,超过 maxChars 截断加省略号。
|
|
14
|
+
* 空内容/空首行回退为去掉换行后的前 maxChars(保证 any 内容有摘要)。
|
|
15
|
+
*/
|
|
16
|
+
export declare function deriveSummary(content: string, maxChars: number): string;
|
|
17
|
+
/** 一条注入行的渲染文本(与面板预览完全一致)。 */
|
|
18
|
+
export declare function injectionLine(record: MemoryRecord, summaryChars: number): string;
|
|
19
|
+
export interface InjectionRender {
|
|
20
|
+
/** 实际注入行(已按预算截断、按更新时间降序)。 */
|
|
21
|
+
lines: string[];
|
|
22
|
+
/** 生效的预算(字符;null = 无限制)。 */
|
|
23
|
+
budget: number | null;
|
|
24
|
+
/** 因预算被省略的候选条数(0 = 全部注入)。 */
|
|
25
|
+
omitted: number;
|
|
26
|
+
/** 实际注入的字符数(摘要 + 标记开销之和)。 */
|
|
27
|
+
chars: number;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* 由候选记录渲染注入行:按使用序降序(lastUsedAt 优先、缺省回退
|
|
31
|
+
* updatedAt——最近使用/更新的记忆最先),逐条累加开销直到预算用尽;
|
|
32
|
+
* 预算为 null 或非正数时全量注入(不过滤)。
|
|
33
|
+
*/
|
|
34
|
+
export declare function renderInjection(records: readonly MemoryRecord[], budget: number | null, summaryChars: number): InjectionRender;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 注入渲染(0.5.2):`memory:recall` 与面板「注入预览」共用的确定性
|
|
3
|
+
* 渲染——摘要化 + 预算截断。纯函数、无模型调用:注入行 = 每条记忆的
|
|
4
|
+
* 单行摘要(内容过长取首行截断),超出预算(字符数)时按「最近更新
|
|
5
|
+
* 优先」贪心保留,省略计数返回给 UI 提示。
|
|
6
|
+
*/
|
|
7
|
+
/** 摘要截断上限(字符):中英混合场景下 ~40-60 token。 */
|
|
8
|
+
export const DEFAULT_SUMMARY_CHARS = 80;
|
|
9
|
+
/** 注入预算(字符):0.5.2 起默认开启,防常驻注入随记忆增长膨胀上下文。 */
|
|
10
|
+
export const DEFAULT_INJECTION_BUDGET = 1500;
|
|
11
|
+
/** 单条注入行的固定标记开销(不含摘要本体)。 */
|
|
12
|
+
const LINE_OVERHEAD = 27;
|
|
13
|
+
/**
|
|
14
|
+
* 确定性摘要:取首行,超过 maxChars 截断加省略号。
|
|
15
|
+
* 空内容/空首行回退为去掉换行后的前 maxChars(保证 any 内容有摘要)。
|
|
16
|
+
*/
|
|
17
|
+
export function deriveSummary(content, maxChars) {
|
|
18
|
+
const firstLine = content.split('\n')[0].trim();
|
|
19
|
+
if (firstLine !== '') {
|
|
20
|
+
return firstLine.length <= maxChars ? firstLine : `${firstLine.slice(0, maxChars)}…`;
|
|
21
|
+
}
|
|
22
|
+
const collapsed = content.replace(/\s+/g, ' ').trim();
|
|
23
|
+
if (collapsed === '')
|
|
24
|
+
return content;
|
|
25
|
+
return collapsed.length <= maxChars ? collapsed : `${collapsed.slice(0, maxChars)}…`;
|
|
26
|
+
}
|
|
27
|
+
/** 一条注入行的渲染文本(与面板预览完全一致)。 */
|
|
28
|
+
export function injectionLine(record, summaryChars) {
|
|
29
|
+
return `- [memory:${String(record.id).slice(0, 8)}:${record.namespace}] ${deriveSummary(record.content, summaryChars)}`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* 由候选记录渲染注入行:按使用序降序(lastUsedAt 优先、缺省回退
|
|
33
|
+
* updatedAt——最近使用/更新的记忆最先),逐条累加开销直到预算用尽;
|
|
34
|
+
* 预算为 null 或非正数时全量注入(不过滤)。
|
|
35
|
+
*/
|
|
36
|
+
export function renderInjection(records, budget, summaryChars) {
|
|
37
|
+
const ordered = [...records].sort((left, right) => (right.lastUsedAt ?? right.updatedAt) - (left.lastUsedAt ?? left.updatedAt));
|
|
38
|
+
const lines = [];
|
|
39
|
+
let chars = 0;
|
|
40
|
+
let omitted = 0;
|
|
41
|
+
for (const record of ordered) {
|
|
42
|
+
const text = injectionLine(record, summaryChars);
|
|
43
|
+
const cost = text.length + LINE_OVERHEAD;
|
|
44
|
+
if (budget !== null && budget > 0 && chars + cost > budget) {
|
|
45
|
+
omitted++;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
lines.push(text);
|
|
49
|
+
chars += cost;
|
|
50
|
+
}
|
|
51
|
+
return { lines, budget: budget ?? null, omitted, chars };
|
|
52
|
+
}
|
package/dist/remote.d.ts
CHANGED
|
@@ -29,12 +29,16 @@ export declare class MemoryGateway extends TypertRemoteService {
|
|
|
29
29
|
/** Force-reload storage files (external edits; JsonStorageBackend has no watch). */
|
|
30
30
|
reload(cwd?: string): Promise<MemoryRecord[]>;
|
|
31
31
|
/**
|
|
32
|
-
* 注入预览(0.3.5):开发者查看当前注入到 system prompt
|
|
33
|
-
*
|
|
32
|
+
* 注入预览(0.3.5 + 0.5.2):开发者查看当前注入到 system prompt 的记忆
|
|
33
|
+
* 内容——self 自述 + 当前会话工作区(cwd)的 approved+injected 记忆,
|
|
34
|
+
* 经摘要化 + 预算截断(与 memory:recall 同源渲染)。
|
|
34
35
|
*/
|
|
35
|
-
injectionPreview(): {
|
|
36
|
+
injectionPreview(cwd?: string, budget?: number | null, summaryChars?: number): Promise<{
|
|
36
37
|
self: string;
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
lines: string[];
|
|
39
|
+
budget: number | null;
|
|
40
|
+
omitted: number;
|
|
41
|
+
chars: number;
|
|
42
|
+
}>;
|
|
39
43
|
}
|
|
40
44
|
export default MemoryGateway;
|
package/dist/remote.js
CHANGED
|
@@ -43,6 +43,7 @@ var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn,
|
|
|
43
43
|
import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol';
|
|
44
44
|
import { MemoryId } from "./engine.js";
|
|
45
45
|
import { SELF_DESCRIPTION } from "./self.js";
|
|
46
|
+
import { DEFAULT_INJECTION_BUDGET, DEFAULT_SUMMARY_CHARS, renderInjection } from "./injection.js";
|
|
46
47
|
/** Client-callable memory surface; registered as the `memory` Typert namespace. */
|
|
47
48
|
let MemoryGateway = (() => {
|
|
48
49
|
let _classSuper = TypertRemoteService;
|
|
@@ -109,11 +110,17 @@ let MemoryGateway = (() => {
|
|
|
109
110
|
return this.ctx.memory.list({}, cwd);
|
|
110
111
|
}
|
|
111
112
|
/**
|
|
112
|
-
* 注入预览(0.3.5):开发者查看当前注入到 system prompt
|
|
113
|
-
*
|
|
113
|
+
* 注入预览(0.3.5 + 0.5.2):开发者查看当前注入到 system prompt 的记忆
|
|
114
|
+
* 内容——self 自述 + 当前会话工作区(cwd)的 approved+injected 记忆,
|
|
115
|
+
* 经摘要化 + 预算截断(与 memory:recall 同源渲染)。
|
|
114
116
|
*/
|
|
115
|
-
injectionPreview() {
|
|
116
|
-
|
|
117
|
+
async injectionPreview(cwd, budget, summaryChars) {
|
|
118
|
+
if (cwd !== undefined)
|
|
119
|
+
await this.ctx.memory.ensureProjectOpen(cwd);
|
|
120
|
+
return {
|
|
121
|
+
self: SELF_DESCRIPTION,
|
|
122
|
+
...renderInjection(this.ctx.memory.recallRecords(cwd), budget ?? DEFAULT_INJECTION_BUDGET, summaryChars ?? DEFAULT_SUMMARY_CHARS),
|
|
123
|
+
};
|
|
117
124
|
}
|
|
118
125
|
};
|
|
119
126
|
})();
|
package/dist/routes.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createRequire } from 'node:module';
|
|
2
2
|
import { SELF_DESCRIPTION } from "./self.js";
|
|
3
|
+
import { DEFAULT_INJECTION_BUDGET, DEFAULT_SUMMARY_CHARS, renderInjection } from "./injection.js";
|
|
3
4
|
const require = createRequire(import.meta.url);
|
|
4
5
|
/** Body size bound of one JSON request. */
|
|
5
6
|
const MAX_BODY_BYTES = 1 << 20;
|
|
@@ -168,11 +169,20 @@ export function mountMemoryApi(ctx) {
|
|
|
168
169
|
...record.keywords === undefined ? {} : { keywords: record.keywords },
|
|
169
170
|
}, cwdOf(record));
|
|
170
171
|
},
|
|
171
|
-
'injectionPreview': () => {
|
|
172
|
+
'injectionPreview': async (payload) => {
|
|
172
173
|
const memory = ctx.get('memory');
|
|
173
174
|
if (memory === undefined)
|
|
174
175
|
throw new MemoryApiError('service-unavailable', 'memory service unavailable', 503);
|
|
175
|
-
|
|
176
|
+
const record = payload;
|
|
177
|
+
const cwd = cwdOf(record);
|
|
178
|
+
if (cwd !== undefined)
|
|
179
|
+
await memory.ensureProjectOpen(cwd);
|
|
180
|
+
// 0.5.2:预览与真实注入同源(renderInjection)——摘要化 + 预算截断一致
|
|
181
|
+
const budget = typeof record?.budget === 'number' && record.budget > 0
|
|
182
|
+
? record.budget
|
|
183
|
+
: record?.budget === null ? null : DEFAULT_INJECTION_BUDGET;
|
|
184
|
+
const summaryChars = typeof record?.summaryChars === 'number' && record.summaryChars > 0 ? record.summaryChars : DEFAULT_SUMMARY_CHARS;
|
|
185
|
+
return { self: SELF_DESCRIPTION, ...renderInjection(memory.recallRecords(cwd), budget, summaryChars) };
|
|
176
186
|
},
|
|
177
187
|
};
|
|
178
188
|
const fence = (req) => isTrusted(req, webRuntime.trustedHosts);
|
package/dist/self.js
CHANGED
|
@@ -18,8 +18,8 @@ const SELF_VERSION = (() => {
|
|
|
18
18
|
export const SELF_DESCRIPTION = `[记忆系统自述] 当前环境内置跨会话记忆服务(dsh-memory v${SELF_VERSION})。`
|
|
19
19
|
+ '工作机制:①记忆以明文 JSON 存储(global→$DSH_HOME/storages、工作区→会话工作区 .dsh/storages 随 git 分享);'
|
|
20
20
|
+ '②模型写入即 suggested(待审核),仅人工审核(memory_confirm)后才 approved;'
|
|
21
|
-
+ '③每轮注入 system prompt
|
|
22
|
-
+ '
|
|
21
|
+
+ '③每轮注入 system prompt 的 = 「global 的 approved + injected」+「当前会话工作区的 approved + injected」,逐条为单行摘要(内容全文靠 memory_search 检索),并受注入预算限制(超预算按最近更新优先,省略条数在面板「注入预览」可见;常驻注入由用户控制,审核≠注入);'
|
|
22
|
+
+ '④工作区记忆按会话工作区路由(存储跟随仓库),memory_search 为 BM25 检索(中文 2-gram + 关键词加权,可配置语义融合),可检索任意状态(含待审核条目,可先评估再引用);'
|
|
23
23
|
+ '⑤修正过时内容用 memory_update(改动重置待审核,注入开关保留)。'
|
|
24
24
|
+ `可用记忆工具共 6 个:memory_save / memory_list / memory_search / memory_confirm / memory_forget / memory_update。`
|
|
25
25
|
+ '使用建议:相关历史上下文先 memory_search 检索再引用;新习惯/约定用 memory_save 写入(落在待审核)。'
|
package/package.json
CHANGED