@max-null/dsh-memory 0.4.1 → 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/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
- /** Split text into lowercase terms: English/number runs stay whole, each CJK ideograph its own term. */
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
- return text.toLowerCase().match(/[a-z0-9]+|[\u3400-\u9fff]/g) ?? [];
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 totalLength = tokenized.reduce((sum, doc) => sum + doc.length, 0);
24
- const averageLength = n === 0 ? 0 : totalLength / n;
25
- return tokenized.map((doc) => {
26
- let score = 0;
27
- for (const term of queryTerms) {
28
- let termFrequency = 0;
29
- for (const candidate of doc) {
30
- if (candidate === term)
31
- termFrequency++;
32
- }
33
- if (termFrequency === 0)
34
- continue;
35
- const frequency = documentFrequency.get(term) ?? 0;
36
- const idf = Math.log(1 + (n - frequency + 0.5) / (frequency + 0.5));
37
- const denominator = termFrequency + K1 * (1 - B + B * (doc.length / (averageLength || 1)));
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
- return score;
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
- * 注入专用(0.3.4):systemPrompt context 是同步回调、无会话 cwd 可及,
136
- * 只返回 global 的 approved+injected(工作区记忆走检索/面板)。
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 { bm25Scores } from "./bm25.js";
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(this.projectRootFor(key));
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(`memory_project_${backendName}`));
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
- const docs = records.map(record => `${record.content} ${record.keywords.join(' ')}`);
190
- const scores = bm25Scores(query, docs);
191
- return records
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
- * 注入专用(0.3.4):systemPrompt context 是同步回调、无会话 cwd 可及,
317
- * 只返回 global 的 approved+injected(工作区记忆走检索/面板)。
477
+ * 预热某工作区的 project 表(异步打开并缓存)。system prompt 的
478
+ * `memory:recall` provider 是同步回调——只读已打开的表;预热让
479
+ * 会话创建/首次请求后本轮或下一轮即可注入工作区记忆。
318
480
  */
319
- recallRecords() {
320
- return this.recordsOf(this.requireTable('global'))
321
- .filter(record => record.status === 'approved' && record.injected);
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
- // 0.3.4:注入只注入 global 的 approved+injected(systemPrompt context 是
68
- // 同步回调、无会话 cwd 可及);工作区(project)记忆按会话 cwd 路由,靠
69
- // memory_search / 面板访问。
70
- function recallText(memory) {
71
- const auto = memory.recallRecords();
72
- if (auto.length === 0)
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
- const lines = auto.map(record => `- [memory:${String(record.id)}] ${record.content}`);
75
- return `Remembered preferences and conventions — apply these:\n${lines.join('\n')}`;
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: () => recallText(memory),
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;