@max-null/dsh-memory 0.5.0 → 0.6.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/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
@@ -5,6 +5,7 @@
5
5
  * is always created `suggested` and becomes effective only through `setStatus`.
6
6
  */
7
7
  import { Service } from '@deepseek-ai/cordis';
8
+ import { type PromptFile } from './prompt-files.ts';
8
9
  declare const memoryIdBrand: unique symbol;
9
10
  /** Opaque identity of one stored memory record. */
10
11
  export type MemoryId = string & {
@@ -18,6 +19,27 @@ export type MemoryNamespace = 'global' | 'project';
18
19
  * 审核通过。注入与否由独立维度 `injected` 控制(见 MemoryRecord)。
19
20
  */
20
21
  export type MemoryStatus = 'suggested' | 'approved';
22
+ /**
23
+ * 记录类别(0.6.0 模板库):'fact' = 常规记忆(缺省,旧数据即此);
24
+ * 'prompt' = 提示词模板索引记录(文件是事实源,见 prompt-files.ts)。
25
+ */
26
+ export type MemoryKind = 'fact' | 'prompt';
27
+ /** prompt 索引记录的附加元数据(与 md 文件 frontmatter 对齐,工具/UI 直接取用)。 */
28
+ export interface PromptMetaIndex {
29
+ seq?: number;
30
+ name: string;
31
+ dimension?: string;
32
+ difficulty?: string;
33
+ tags: string[];
34
+ /** 索引摘要(正文前 200 字)。 */
35
+ summary: string;
36
+ /** md 文件绝对路径(引擎内部使用;工具输出不暴露)。 */
37
+ path: string;
38
+ /** 索引时的文件 mtime(惰性刷新比对)。 */
39
+ mtime: number;
40
+ /** user=用户创建;agent=模型新增(UI 角标)。 */
41
+ source: 'user' | 'agent';
42
+ }
21
43
  export interface MemoryRecord {
22
44
  id: MemoryId;
23
45
  namespace: MemoryNamespace;
@@ -28,6 +50,12 @@ export interface MemoryRecord {
28
50
  keywords: string[];
29
51
  createdAt: number;
30
52
  updatedAt: number;
53
+ /** 最近一次被 memory_search 命中的时间(0.5.3 冷热追踪);无则未命中过。 */
54
+ lastUsedAt?: number;
55
+ /** 记录类别(缺省 'fact')。 */
56
+ kind?: MemoryKind;
57
+ /** prompt 记录元数据(仅 kind==='prompt')。 */
58
+ meta?: PromptMetaIndex;
31
59
  }
32
60
  export interface MemoryWrite {
33
61
  content: string;
@@ -38,6 +66,8 @@ export interface MemoryFilter {
38
66
  namespace?: MemoryNamespace;
39
67
  status?: MemoryStatus;
40
68
  injected?: boolean;
69
+ /** 记录类别过滤(0.6.0);缺省不过滤(工具层显式传 kind='fact' 保持旧行为)。 */
70
+ kind?: MemoryKind;
41
71
  }
42
72
  export interface MemoryHit {
43
73
  record: MemoryRecord;
@@ -68,12 +98,38 @@ declare module '@deepseek-ai/cordis' {
68
98
  'memory/changed'(change: MemoryChange): void;
69
99
  }
70
100
  }
101
+ /**
102
+ * 嵌入能力(0.5.2 混合检索):缺省不配置 = 纯 BM25(行为不变);
103
+ * 配置后 memory_search 以 BM25 与语义结果做 RRF 融合,向量持久化到
104
+ * 存储文件(vector 字段,外部可读),失败自动降级回纯 BM25。
105
+ */
106
+ export interface MemoryEmbeddings {
107
+ /** 文本 → 向量(与输入等长,一维浮点数组)。 */
108
+ embed(texts: readonly string[], signal?: AbortSignal): Promise<readonly number[][]>;
109
+ /** 相似度函数(缺省余弦相似度)。 */
110
+ similarity?: (left: readonly number[], right: readonly number[]) => number;
111
+ }
71
112
  /** Engine configuration: the two storage roots, defaulted to home and project. */
72
113
  export interface MemoryConfig {
73
114
  /** Root for `global` memories; defaults to `$DSH_HOME/storages`. */
74
115
  globalRoot?: string;
75
116
  /** Root for `project` memories; defaults to `<cwd>/.dsh/storages`. */
76
117
  projectRoot?: string;
118
+ /**
119
+ * 常驻注入预算(字符,0.5.2):`memory:recall` 每轮注入的摘要总量上限,
120
+ * 超预算按「最近更新优先」省略(面板提示省略数)。null/0 = 不限制。
121
+ * 默认 DEFAULT_INJECTION_BUDGET(1500 字符,约 400-700 token)。
122
+ */
123
+ injectionBudget?: number | null;
124
+ /** 单条注入摘要截断上限(字符);默认 DEFAULT_SUMMARY_CHARS(80)。 */
125
+ summaryChars?: number;
126
+ /** 混合语义检索(0.5.2):缺省纯 BM25;配置后与 BM25 做 RRF 融合。 */
127
+ embeddings?: MemoryEmbeddings;
128
+ /** 语义侧参与融合的 topK(0.5.2);默认 5。 */
129
+ semanticTopK?: number;
130
+ /** 全局提示词模板根目录(0.6.0);默认 `$DSH_HOME/prompt-library`。
131
+ * project 模板随工作区(`<workspace>/.dsh/prompt-library`,与记忆同构)。 */
132
+ promptGlobalRoot?: string;
77
133
  }
78
134
  /**
79
135
  * Cross-session plaintext memory over the storage hub, split by namespace:
@@ -115,6 +171,16 @@ export declare class MemoryEngine extends Service {
115
171
  remember(input: MemoryWrite, projectCwd?: string): Promise<MemoryRecord>;
116
172
  list(filter?: MemoryFilter, projectCwd?: string): Promise<MemoryRecord[]>;
117
173
  search(query: string, filter?: MemoryFilter, projectCwd?: string): Promise<MemoryHit[]>;
174
+ /**
175
+ * 语义排序(0.5.2):查询向量 + 候选向量的余弦相似度,仅在有
176
+ * embeddings 配置时被调用。缺失向量(新增/更新过的记录)在此补生成
177
+ * 并持久化(vector 字段);生成失败抛给调用方降级。
178
+ */
179
+ private semanticRanking;
180
+ /** 按已知 id 定位记录所在表(global 优先,其次当前工作区表)。 */
181
+ private tableOf;
182
+ /** 命中标记:把 lastUsedAt 写回(命中记录不存在时跳过;不 emit 事件——统计性字段)。 */
183
+ private markUsed;
118
184
  forget(id: MemoryId, projectCwd?: string): Promise<boolean>;
119
185
  setStatus(id: MemoryId, status: MemoryStatus, projectCwd?: string): Promise<MemoryRecord>;
120
186
  /**
@@ -132,13 +198,59 @@ export declare class MemoryEngine extends Service {
132
198
  keywords?: string[];
133
199
  }, projectCwd?: string): Promise<MemoryRecord>;
134
200
  /**
135
- * 注入专用(0.3.4):systemPrompt context 是同步回调、无会话 cwd 可及,
136
- * 只返回 global 的 approved+injected(工作区记忆走检索/面板)。
201
+ * 预热某工作区的 project 表(异步打开并缓存)。system prompt 的
202
+ * `memory:recall` provider 是同步回调——只读已打开的表;预热让
203
+ * 会话创建/首次请求后本轮或下一轮即可注入工作区记忆。
137
204
  */
138
- recallRecords(): MemoryRecord[];
205
+ ensureProjectOpen(projectCwd: string): Promise<void>;
206
+ /**
207
+ * 注入专用(0.3.4 + 0.5.1):system prompt 的 `memory:recall` context
208
+ * provider 可经 AssembleContext.agent 拿到当前会话 header.cwd,因此
209
+ * 注入 = global 的 approved+injected + 当前会话工作区的 approved+injected。
210
+ * provider 是同步回调:只读已打开/缓存的表,未打开的工作区本轮为空
211
+ * (`ensureProjectOpen` 提供预热,工具/面板路径也会打开)。
212
+ */
213
+ recallRecords(projectCwd?: string): MemoryRecord[];
139
214
  private allRecords;
140
215
  private recordsOf;
141
216
  private tableFor;
142
217
  private requireTable;
218
+ /** 全局模板根(默认 `$DSH_HOME/prompt-library`,可配置覆盖)。 */
219
+ private promptGlobalDir;
220
+ /** project 模板根(随工作区 `<workspace>/.dsh/prompt-library`,与记忆同构)。 */
221
+ private promptProjectDir;
222
+ /** 稳定索引 id(按路径 hash;文件删除后随刷新清理)。 */
223
+ private promptIdOf;
224
+ /**
225
+ * 扫描模板目录并同步索引(mtime 惰性:未变条目零写;目录缺失 = 空库)。
226
+ * @returns 统计(扫描数/变更数/清理数 + 坏文件警告)。
227
+ */
228
+ refreshPromptIndex(projectCwd?: string): Promise<{
229
+ scan: number;
230
+ changed: number;
231
+ removed: number;
232
+ warnings: string[];
233
+ }>;
234
+ /**
235
+ * 取模板(id 或名称/路径片段定位),返回 md 全文(直读文件,忽略索引缓存)。
236
+ * @throws 未命中或文件解析失败(错误信息可读)。
237
+ */
238
+ promptGet(nameOrId: string, projectCwd?: string): Promise<PromptFile>;
239
+ /**
240
+ * 新增模板:写 md 文件(序号自动分配/文件名安全化/同名防覆盖)+ 刷新索引。
241
+ * 返回索引记录(模型新增 source='agent',UI 角标提示)。
242
+ */
243
+ promptAdd(input: {
244
+ name: string;
245
+ dimension?: string;
246
+ difficulty?: string;
247
+ tags?: string[];
248
+ content: string;
249
+ fallback?: string;
250
+ source: 'user' | 'agent';
251
+ namespace?: MemoryNamespace;
252
+ }, projectCwd?: string): Promise<MemoryRecord>;
253
+ /** 删除模板:md 文件与索引一并删除;未命中返回 false。 */
254
+ promptRemove(idOrName: string, projectCwd?: string): Promise<boolean>;
143
255
  }
144
256
  export {};