dsh-layered-memory 0.5.3

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.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +186 -0
  3. package/assets/readme/hero.svg +58 -0
  4. package/cordis.patch.yml +16 -0
  5. package/dist/client.js +1066 -0
  6. package/dist/config.d.ts +263 -0
  7. package/dist/config.js +65 -0
  8. package/dist/hooks/capture.d.ts +16 -0
  9. package/dist/hooks/capture.js +160 -0
  10. package/dist/hooks/recall.d.ts +31 -0
  11. package/dist/hooks/recall.js +201 -0
  12. package/dist/index.d.ts +190 -0
  13. package/dist/index.js +234 -0
  14. package/dist/llm.d.ts +32 -0
  15. package/dist/llm.js +128 -0
  16. package/dist/pipeline/l1.d.ts +19 -0
  17. package/dist/pipeline/l1.js +226 -0
  18. package/dist/pipeline/l2.d.ts +13 -0
  19. package/dist/pipeline/l2.js +77 -0
  20. package/dist/pipeline/l3.d.ts +15 -0
  21. package/dist/pipeline/l3.js +72 -0
  22. package/dist/pipeline/runner.d.ts +44 -0
  23. package/dist/pipeline/runner.js +132 -0
  24. package/dist/prompts/l1-dedup.d.ts +20 -0
  25. package/dist/prompts/l1-dedup.js +244 -0
  26. package/dist/prompts/l1-extraction.d.ts +18 -0
  27. package/dist/prompts/l1-extraction.js +434 -0
  28. package/dist/prompts/persona.d.ts +23 -0
  29. package/dist/prompts/persona.js +234 -0
  30. package/dist/prompts/scene.d.ts +33 -0
  31. package/dist/prompts/scene.js +408 -0
  32. package/dist/settings.d.ts +27 -0
  33. package/dist/settings.js +73 -0
  34. package/dist/stats.d.ts +46 -0
  35. package/dist/stats.js +234 -0
  36. package/dist/store/bm25.d.ts +19 -0
  37. package/dist/store/bm25.js +62 -0
  38. package/dist/store/embedding.d.ts +68 -0
  39. package/dist/store/embedding.js +133 -0
  40. package/dist/store/io.d.ts +13 -0
  41. package/dist/store/io.js +75 -0
  42. package/dist/store/l0.d.ts +28 -0
  43. package/dist/store/l0.js +148 -0
  44. package/dist/store/l1.d.ts +70 -0
  45. package/dist/store/l1.js +239 -0
  46. package/dist/store/persona.d.ts +15 -0
  47. package/dist/store/persona.js +60 -0
  48. package/dist/store/scenes.d.ts +23 -0
  49. package/dist/store/scenes.js +150 -0
  50. package/dist/store/search-utils.d.ts +22 -0
  51. package/dist/store/search-utils.js +71 -0
  52. package/dist/store/session-modes.d.ts +24 -0
  53. package/dist/store/session-modes.js +102 -0
  54. package/dist/store/sqlite.d.ts +121 -0
  55. package/dist/store/sqlite.js +922 -0
  56. package/dist/store/state.d.ts +35 -0
  57. package/dist/store/state.js +62 -0
  58. package/dist/tools/index.d.ts +21 -0
  59. package/dist/tools/index.js +169 -0
  60. package/dist/types.d.ts +86 -0
  61. package/dist/types.js +7 -0
  62. package/dist/util/filelog.d.ts +4 -0
  63. package/dist/util/filelog.js +43 -0
  64. package/dist/util/sanitize.d.ts +10 -0
  65. package/dist/util/sanitize.js +66 -0
  66. package/dist/util/text.d.ts +11 -0
  67. package/dist/util/text.js +43 -0
  68. package/package.json +80 -0
@@ -0,0 +1,148 @@
1
+ /**
2
+ * L0 原始对话存储(双写架构,目录布局对齐 MemoryCore l0-recorder):
3
+ * - conversations/YYYY-MM-DD.jsonl:追加式事实源;
4
+ * - MemoryDb(SQLite):主检索引擎——检索不再按天扫文件、现建内存索引。
5
+ */
6
+ import { existsSync, promises as fs } from 'node:fs';
7
+ import * as path from 'node:path';
8
+ import { EmbedHelper, NoopEmbeddingService } from './embedding.js';
9
+ import { appendJsonl, dayKey, ensureDir, nowIso, readJsonl } from './io.js';
10
+ import { rrfMerge } from './search-utils.js';
11
+ /** 官方过度召回倍数(conversation-search:limit × 3)。 */
12
+ const CANDIDATE_MULTIPLIER = 3;
13
+ export class L0Store {
14
+ db;
15
+ dir;
16
+ legacyDir;
17
+ helper;
18
+ embedSvc;
19
+ logger;
20
+ constructor(dataDir, db, embed = new NoopEmbeddingService(), logger) {
21
+ this.db = db;
22
+ this.dir = path.join(dataDir, 'conversations');
23
+ this.legacyDir = path.join(dataDir, 'l0');
24
+ this.embedSvc = embed;
25
+ this.helper = new EmbedHelper(embed, logger);
26
+ this.logger = logger;
27
+ }
28
+ async init() {
29
+ await ensureDir(this.dir);
30
+ await this.importLegacy();
31
+ }
32
+ /** 旧版 l0/*.jsonl 一次性导入检索库,成功后目录改名 l0.imported/。 */
33
+ async importLegacy() {
34
+ if (!existsSync(this.legacyDir))
35
+ return;
36
+ try {
37
+ const files = await fs.readdir(this.legacyDir).catch(() => []);
38
+ let imported = 0;
39
+ let total = 0;
40
+ for (const f of files.sort()) {
41
+ if (!f.endsWith('.jsonl'))
42
+ continue;
43
+ const records = await readJsonl(path.join(this.legacyDir, f));
44
+ if (records.length > 0) {
45
+ total += records.length;
46
+ if (this.db.upsertL0Batch(records))
47
+ imported += records.length;
48
+ }
49
+ }
50
+ // 只有全部批次入库成功(或目录为空)才改名,避免数据被改名带走
51
+ if (imported === total) {
52
+ const renamed = await fs
53
+ .rename(this.legacyDir, `${this.legacyDir}.imported`)
54
+ .then(() => true, () => false);
55
+ if (renamed) {
56
+ this.logger?.info(`[memory] 旧版 L0 数据已导入检索库 ${imported} 条(l0/ → l0.imported/)`);
57
+ }
58
+ else {
59
+ this.logger?.warn('[memory] 旧版 L0 导入完成但改名失败(l0.imported/ 已存在?),下次启动会重复导入(幂等,无害)');
60
+ }
61
+ }
62
+ else {
63
+ this.logger?.warn(`[memory] 旧版 L0 导入不完整(${imported}/${total}),保留原目录下次重试`);
64
+ }
65
+ }
66
+ catch (err) {
67
+ this.logger?.warn(`[memory] 旧版 L0 数据导入失败: ${err instanceof Error ? err.message : String(err)}`);
68
+ }
69
+ }
70
+ async append(sessionId, messages) {
71
+ if (messages.length === 0)
72
+ return;
73
+ const records = messages.map((m) => ({
74
+ sessionId,
75
+ recordedAt: nowIso(),
76
+ id: m.id,
77
+ role: m.role,
78
+ content: m.content,
79
+ timestamp: m.timestamp,
80
+ }));
81
+ // 事实源:按天追加
82
+ const byDay = new Map();
83
+ for (const r of records) {
84
+ const k = dayKey(r.timestamp);
85
+ const arr = byDay.get(k) ?? [];
86
+ arr.push(r);
87
+ byDay.set(k, arr);
88
+ }
89
+ for (const [day, list] of byDay) {
90
+ await appendJsonl(path.join(this.dir, `${day}.jsonl`), list);
91
+ }
92
+ // 检索引擎:DB + 向量(嵌入失败只跳过向量,不影响元数据/FTS,backfill 补齐)
93
+ const vecs = await this.helper.batch(records.map((r) => r.content));
94
+ this.db.upsertL0Batch(records, vecs);
95
+ }
96
+ /** 今日已捕获消息数(SQL 计数,不再读整文件)。 */
97
+ async countToday() {
98
+ const d = new Date();
99
+ return this.db.countL0Since(new Date(d.getFullYear(), d.getMonth(), d.getDate()).toISOString());
100
+ }
101
+ /** 检索:FTS + 向量 hybrid(RRF 融合),返回按相关性排序的消息。 */
102
+ async search(query, limit) {
103
+ const caps = this.db.getCapabilities();
104
+ if (!caps.ftsSearch && !caps.vectorSearch)
105
+ return [];
106
+ const candidateK = limit * CANDIDATE_MULTIPLIER;
107
+ if (caps.vectorSearch && this.helper.vectorReady()) {
108
+ const [ftsRaw, vec] = await Promise.all([
109
+ Promise.resolve(this.db.searchL0Fts(query, candidateK)),
110
+ this.helper.query(query),
111
+ ]);
112
+ const vecList = vec ? this.db.searchL0Vector(vec, candidateK) : [];
113
+ const merged = rrfMerge([ftsRaw, vecList], (h) => h.id);
114
+ return merged.map(({ rrfScore: _rrf, ...r }) => r).slice(0, limit);
115
+ }
116
+ return this.db.searchL0Fts(query, limit).map(({ score: _score, ...r }) => r);
117
+ }
118
+ /**
119
+ * 全量重嵌入(embedding 启用 / 周期性补齐用)。
120
+ * 返回写入数与失败数——failed > 0 时调用方不应标记 meta 同步完成。
121
+ */
122
+ async reindex() {
123
+ if (!this.helper.vectorReady())
124
+ return { written: 0, failed: 0 };
125
+ const items = this.db.getL0ForReindex();
126
+ let written = 0;
127
+ let failed = 0;
128
+ const CHUNK = 32;
129
+ for (let i = 0; i < items.length; i += CHUNK) {
130
+ const chunk = items.slice(i, i + CHUNK);
131
+ let vecs;
132
+ try {
133
+ vecs = await this.embedSvc.embedBatch(chunk.map((c) => c.text));
134
+ }
135
+ catch {
136
+ failed += chunk.length;
137
+ continue;
138
+ }
139
+ chunk.forEach((c, j) => {
140
+ if (this.db.updateL0Vec(c.id, vecs[j], ''))
141
+ written++;
142
+ else
143
+ failed++;
144
+ });
145
+ }
146
+ return { written, failed };
147
+ }
148
+ }
@@ -0,0 +1,70 @@
1
+ import type { L1Hit, MemoryFamily, MemoryLogger, MemoryRecord } from '../types.js';
2
+ import { type EmbeddingService } from './embedding.js';
3
+ import type { MemoryDb } from './sqlite.js';
4
+ export type RecallStrategy = 'keyword' | 'embedding' | 'hybrid';
5
+ export interface L1SearchOptions {
6
+ /** 按记忆类型精确过滤(后置过滤,官方做法)。 */
7
+ type?: string;
8
+ /** 按族过滤(undefined = 不过滤,即 auto 档与浏览路径;检索唯一缝的族语义)。 */
9
+ family?: MemoryFamily;
10
+ /** 分数阈值(仅召回路径传;keyword/embedding 策略生效,FTS 含小语料例外;
11
+ * hybrid 按官方语义在 RRF 融合前不过滤)。 */
12
+ scoreThreshold?: number;
13
+ }
14
+ export declare class L1Store {
15
+ private readonly db;
16
+ private readonly strategy;
17
+ private readonly recordsDir;
18
+ private readonly legacyFile;
19
+ private readonly helper;
20
+ private readonly embedSvc;
21
+ private readonly logger?;
22
+ constructor(dataDir: string, db: MemoryDb, embed?: EmbeddingService, strategy?: RecallStrategy, logger?: MemoryLogger);
23
+ init(): Promise<void>;
24
+ /** 旧版单文件 records.jsonl 一次性导入检索库,成功后改名 .imported。 */
25
+ private importLegacy;
26
+ get size(): number;
27
+ /** 全量读取(调试/迁移用;检索请走 search)。 */
28
+ all(): MemoryRecord[];
29
+ /** 按 id 精确取记录(去重决策的版本号查询用,避免全表扫描)。 */
30
+ getByIds(ids: string[]): MemoryRecord[];
31
+ /** 新记忆落盘:JSONL 按天追加(事实源)+ 检索库 upsert + 向量。 */
32
+ appendNew(records: MemoryRecord[]): Promise<void>;
33
+ /** 去重 update/merge 产出的记录:只更新检索库(JSONL 事实源不改写,官方语义)。 */
34
+ upsert(record: MemoryRecord): Promise<void>;
35
+ deleteBatch(ids: string[]): Promise<void>;
36
+ /**
37
+ * 三策略检索(自动召回与 memory_search 工具共用接缝)。
38
+ * embedding 不可用时自动降级 keyword;type 后置过滤;
39
+ * scoreThreshold 仅对 keyword/embedding 单路策略生效——hybrid 按官方语义
40
+ * 融合完整列表(融合分已归一化 0~1,可直接用于展示/过滤)。
41
+ */
42
+ search(query: string, limit: number, opts?: L1SearchOptions): Promise<L1Hit[]>;
43
+ /** 浏览列表(UI 用):无关键词时按更新时间倒序分页。 */
44
+ list(opts: {
45
+ type?: string;
46
+ scene?: string;
47
+ family?: string;
48
+ limit: number;
49
+ offset: number;
50
+ }): {
51
+ items: MemoryRecord[];
52
+ total: number;
53
+ };
54
+ /** 场景名去重列表(UI 筛选器数据源)。 */
55
+ distinctScenes(): string[];
56
+ /**
57
+ * 去重候选召回(官方 3 级):空库跳过 → 向量优先 → FTS 兜底。
58
+ * 传入 family 时只在同族记录里召回(去重永不跨族)。
59
+ */
60
+ searchCandidates(query: string, limit: number, family?: MemoryFamily): Promise<MemoryRecord[]>;
61
+ /**
62
+ * 全量重嵌入(embedding 配置变化 / 周期性补齐用)。
63
+ * 返回写入数与失败数——failed > 0 时调用方不应标记 meta 同步完成。
64
+ */
65
+ reindex(): Promise<{
66
+ written: number;
67
+ failed: number;
68
+ }>;
69
+ private postProcess;
70
+ }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * L1 原子记忆存储(双写架构,移植 MemoryCore l1-writer 语义):
3
+ * - records/YYYY-MM-DD.jsonl:追加式事实源(只增不改,备份/恢复用);
4
+ * - MemoryDb(SQLite):主检索引擎,upsert/delete 只动这里;
5
+ * - 检索三策略:keyword(FTS5 BM25)/ embedding(vec0 余弦)/ hybrid(双路 + RRF k=60)。
6
+ *
7
+ * 去重/合并的更新记录走 upsert(新 record id + 版本递增),不再全量重写文件。
8
+ */
9
+ import { existsSync, promises as fs } from 'node:fs';
10
+ import * as path from 'node:path';
11
+ import { familyForType } from '../types.js';
12
+ import { EmbedHelper, NoopEmbeddingService } from './embedding.js';
13
+ import { appendJsonl, dayKey, ensureDir, readJsonl } from './io.js';
14
+ import { RRF_K, rrfMerge } from './search-utils.js';
15
+ /** 官方过度召回倍数:候选池 = limit × 3(官方 tool 路径同款)。 */
16
+ const CANDIDATE_MULTIPLIER = 3;
17
+ export class L1Store {
18
+ db;
19
+ strategy;
20
+ recordsDir;
21
+ legacyFile;
22
+ helper;
23
+ embedSvc;
24
+ logger;
25
+ constructor(dataDir, db, embed = new NoopEmbeddingService(), strategy = 'hybrid', logger) {
26
+ this.db = db;
27
+ this.strategy = strategy;
28
+ this.recordsDir = path.join(dataDir, 'records');
29
+ this.legacyFile = path.join(dataDir, 'l1', 'records.jsonl');
30
+ this.embedSvc = embed;
31
+ this.helper = new EmbedHelper(embed, logger);
32
+ this.logger = logger;
33
+ }
34
+ async init() {
35
+ await ensureDir(this.recordsDir);
36
+ await this.importLegacy();
37
+ }
38
+ /** 旧版单文件 records.jsonl 一次性导入检索库,成功后改名 .imported。 */
39
+ async importLegacy() {
40
+ if (!existsSync(this.legacyFile))
41
+ return;
42
+ try {
43
+ const records = await readJsonl(this.legacyFile);
44
+ let n = 0;
45
+ for (const r of records) {
46
+ if (r && typeof r.id === 'string' && r.content) {
47
+ if (this.db.upsertL1(r))
48
+ n++;
49
+ }
50
+ }
51
+ // 只有确实导入成功(或文件为空)才改名,避免把未入库的数据改名带走
52
+ if (n === records.length) {
53
+ const renamed = await fs
54
+ .rename(this.legacyFile, `${this.legacyFile}.imported`)
55
+ .then(() => true, () => false);
56
+ if (renamed) {
57
+ this.logger?.info(`[memory] 旧版 L1 数据已导入检索库 ${n} 条(l1/records.jsonl → .imported)`);
58
+ }
59
+ else {
60
+ this.logger?.warn('[memory] 旧版 L1 导入完成但改名失败,下次启动会重复导入(upsert 幂等,无害)');
61
+ }
62
+ }
63
+ else {
64
+ this.logger?.warn(`[memory] 旧版 L1 导入不完整(${n}/${records.length}),保留原文件下次重试`);
65
+ }
66
+ }
67
+ catch (err) {
68
+ this.logger?.warn(`[memory] 旧版 L1 数据导入失败: ${err instanceof Error ? err.message : String(err)}`);
69
+ }
70
+ }
71
+ get size() {
72
+ return this.db.countL1();
73
+ }
74
+ /** 全量读取(调试/迁移用;检索请走 search)。 */
75
+ all() {
76
+ return this.db.getAllL1();
77
+ }
78
+ /** 按 id 精确取记录(去重决策的版本号查询用,避免全表扫描)。 */
79
+ getByIds(ids) {
80
+ return this.db.getL1ByIds(ids);
81
+ }
82
+ /** 新记忆落盘:JSONL 按天追加(事实源)+ 检索库 upsert + 向量。 */
83
+ async appendNew(records) {
84
+ if (records.length === 0)
85
+ return;
86
+ for (const r of records) {
87
+ if (!r.family)
88
+ r.family = familyForType(r.type);
89
+ }
90
+ const byDay = new Map();
91
+ for (const r of records) {
92
+ const k = dayKey(r.createdAt || Date.now());
93
+ const arr = byDay.get(k) ?? [];
94
+ arr.push(r);
95
+ byDay.set(k, arr);
96
+ }
97
+ for (const [day, list] of byDay) {
98
+ await appendJsonl(path.join(this.recordsDir, `${day}.jsonl`), list);
99
+ }
100
+ const vecs = await this.helper.batch(records.map((r) => r.content));
101
+ for (let i = 0; i < records.length; i++) {
102
+ this.db.upsertL1(records[i], vecs[i]);
103
+ }
104
+ }
105
+ /** 去重 update/merge 产出的记录:只更新检索库(JSONL 事实源不改写,官方语义)。 */
106
+ async upsert(record) {
107
+ if (!record.family)
108
+ record.family = familyForType(record.type);
109
+ const vec = (await this.helper.batch([record.content]))[0];
110
+ this.db.upsertL1(record, vec);
111
+ }
112
+ async deleteBatch(ids) {
113
+ this.db.deleteL1Batch(ids);
114
+ }
115
+ /**
116
+ * 三策略检索(自动召回与 memory_search 工具共用接缝)。
117
+ * embedding 不可用时自动降级 keyword;type 后置过滤;
118
+ * scoreThreshold 仅对 keyword/embedding 单路策略生效——hybrid 按官方语义
119
+ * 融合完整列表(融合分已归一化 0~1,可直接用于展示/过滤)。
120
+ */
121
+ async search(query, limit, opts) {
122
+ const caps = this.db.getCapabilities();
123
+ const canVec = caps.vectorSearch && this.helper.vectorReady();
124
+ let strategy = this.strategy;
125
+ if (strategy !== 'keyword' && !canVec)
126
+ strategy = caps.ftsSearch ? 'keyword' : 'none';
127
+ const candidateK = limit * CANDIDATE_MULTIPLIER;
128
+ const threshold = opts?.scoreThreshold ?? 0;
129
+ if (strategy === 'none')
130
+ return [];
131
+ if (strategy === 'keyword') {
132
+ const fts = this.db.searchL1Fts(query, candidateK, opts?.family);
133
+ return this.postProcess(applyFtsThreshold(fts, threshold, limit), opts?.type, limit);
134
+ }
135
+ if (strategy === 'embedding') {
136
+ const vec = await this.helper.query(query);
137
+ if (!vec) {
138
+ // embedding 调用失败:降级 FTS,不阻断
139
+ const fts = this.db.searchL1Fts(query, candidateK, opts?.family);
140
+ return this.postProcess(applyFtsThreshold(fts, threshold, limit), opts?.type, limit);
141
+ }
142
+ const vecHits = this.db.searchL1Vector(vec, candidateK, opts?.family);
143
+ return this.postProcess(filterScore(vecHits, threshold), opts?.type, limit);
144
+ }
145
+ // hybrid(官方语义):双路并行 → 完整列表 RRF 融合(融合前不过滤阈值)
146
+ // → 融合分归一化:rank1 双列表命中 = 1.0,单列表命中 ≤ 0.5,保持 0~1 语义
147
+ const [ftsList, vecRaw] = await Promise.all([
148
+ Promise.resolve(this.db.searchL1Fts(query, candidateK, opts?.family)),
149
+ this.helper.query(query),
150
+ ]);
151
+ const vecList = vecRaw ? this.db.searchL1Vector(vecRaw, candidateK, opts?.family) : [];
152
+ const merged = rrfMerge([ftsList, vecList], (h) => h.id);
153
+ return this.postProcess(merged.map(({ rrfScore, ...h }) => ({ ...h, score: normalizeRrf(rrfScore) })), opts?.type, limit);
154
+ }
155
+ /** 浏览列表(UI 用):无关键词时按更新时间倒序分页。 */
156
+ list(opts) {
157
+ return this.db.listL1(opts);
158
+ }
159
+ /** 场景名去重列表(UI 筛选器数据源)。 */
160
+ distinctScenes() {
161
+ return this.db.distinctL1Scenes();
162
+ }
163
+ /**
164
+ * 去重候选召回(官方 3 级):空库跳过 → 向量优先 → FTS 兜底。
165
+ * 传入 family 时只在同族记录里召回(去重永不跨族)。
166
+ */
167
+ async searchCandidates(query, limit, family) {
168
+ if (this.db.countL1() === 0)
169
+ return [];
170
+ const caps = this.db.getCapabilities();
171
+ if (caps.vectorSearch && this.helper.vectorReady()) {
172
+ try {
173
+ const vec = await this.helper.query(query);
174
+ if (vec) {
175
+ const hits = this.db.searchL1Vector(vec, limit, family);
176
+ if (hits.length > 0)
177
+ return this.db.getL1ByIds(hits.map((h) => h.id));
178
+ }
179
+ }
180
+ catch (err) {
181
+ this.logger?.warn(`[memory] 向量候选召回失败,降级 FTS: ${err instanceof Error ? err.message : String(err)}`);
182
+ }
183
+ }
184
+ const fts = this.db.searchL1Fts(query, limit * 2, family);
185
+ return this.db.getL1ByIds(fts.map((h) => h.id));
186
+ }
187
+ /**
188
+ * 全量重嵌入(embedding 配置变化 / 周期性补齐用)。
189
+ * 返回写入数与失败数——failed > 0 时调用方不应标记 meta 同步完成。
190
+ */
191
+ async reindex() {
192
+ if (!this.helper.vectorReady())
193
+ return { written: 0, failed: 0 };
194
+ const items = this.db.getL1ForReindex();
195
+ let written = 0;
196
+ let failed = 0;
197
+ const CHUNK = 16;
198
+ for (let i = 0; i < items.length; i += CHUNK) {
199
+ const chunk = items.slice(i, i + CHUNK);
200
+ let vecs;
201
+ try {
202
+ vecs = await this.embedSvc.embedBatch(chunk.map((c) => c.content));
203
+ }
204
+ catch {
205
+ failed += chunk.length;
206
+ continue;
207
+ }
208
+ chunk.forEach((c, j) => {
209
+ if (this.db.updateL1Vec(c.id, vecs[j]))
210
+ written++;
211
+ else
212
+ failed++;
213
+ });
214
+ }
215
+ return { written, failed };
216
+ }
217
+ postProcess(hits, type, limit) {
218
+ const filtered = type ? hits.filter((h) => h.type === type) : hits;
219
+ return filtered.slice(0, limit);
220
+ }
221
+ }
222
+ /** RRF 原始分归一化到 0~1:双列表 rank1 命中 = 2/(k+1) → 1.0。 */
223
+ function normalizeRrf(rrfScore) {
224
+ return (rrfScore * (RRF_K + 1)) / 2;
225
+ }
226
+ /** FTS 阈值过滤(含官方小语料例外:全部低于阈值但结果数 ≤ maxResults 时保留)。 */
227
+ function applyFtsThreshold(hits, threshold, maxResults) {
228
+ if (threshold <= 0)
229
+ return hits;
230
+ const filtered = hits.filter((h) => h.score >= threshold);
231
+ if (filtered.length === 0 && hits.length > 0 && hits.length <= maxResults)
232
+ return hits;
233
+ return filtered;
234
+ }
235
+ function filterScore(hits, threshold) {
236
+ if (threshold <= 0)
237
+ return hits;
238
+ return hits.filter((h) => h.score >= threshold);
239
+ }
@@ -0,0 +1,15 @@
1
+ import type { MemoryFamily, MemoryLogger } from '../types.js';
2
+ export declare const NAV_HEADER = "## \uD83D\uDDFA\uFE0F Scene Navigation";
3
+ export declare class PersonaStore {
4
+ private readonly logger?;
5
+ private readonly file;
6
+ private readonly family;
7
+ constructor(dataDir: string, family: MemoryFamily, logger?: MemoryLogger | undefined);
8
+ /** 旧布局迁移:persona.md → persona-chat.md(幂等,仅 chat 族执行)。 */
9
+ init(): Promise<void>;
10
+ /** 读取正文(剥离场景导航部分)。 */
11
+ read(): Promise<string | undefined>;
12
+ /** 写入正文(保留已有导航段则拼回尾部)。 */
13
+ write(body: string): Promise<void>;
14
+ }
15
+ export declare function stripSceneNavigation(content: string): string;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * L3 画像存储:persona-<family>.md(chat 族=用户画像 / work 族=Team Operating Doctrine)。
3
+ * 场景导航由工程侧自动追加/剥离(移植 MemoryCore stripSceneNavigation 语义)。
4
+ * init() 把旧布局的 persona.md 一次性改名为 persona-chat.md(历史数据归属 chat 档)。
5
+ */
6
+ import { promises as fs } from 'node:fs';
7
+ import * as path from 'node:path';
8
+ import { atomicWriteText, readTextIfExists } from './io.js';
9
+ export const NAV_HEADER = '## 🗺️ Scene Navigation';
10
+ export class PersonaStore {
11
+ logger;
12
+ file;
13
+ family;
14
+ constructor(dataDir, family, logger) {
15
+ this.logger = logger;
16
+ this.family = family;
17
+ this.file = path.join(dataDir, `persona-${family}.md`);
18
+ }
19
+ /** 旧布局迁移:persona.md → persona-chat.md(幂等,仅 chat 族执行)。 */
20
+ async init() {
21
+ if (this.family !== 'chat')
22
+ return;
23
+ const dataDir = path.dirname(this.file);
24
+ const legacy = path.join(dataDir, 'persona.md');
25
+ try {
26
+ await fs.access(legacy);
27
+ await fs.rename(legacy, this.file);
28
+ this.logger?.info('[memory] 画像文件迁移:persona.md → persona-chat.md');
29
+ }
30
+ catch {
31
+ /* 无旧文件或已迁移 */
32
+ }
33
+ }
34
+ /** 读取正文(剥离场景导航部分)。 */
35
+ async read() {
36
+ const raw = await readTextIfExists(this.file);
37
+ if (!raw)
38
+ return undefined;
39
+ return stripSceneNavigation(raw).trim() || undefined;
40
+ }
41
+ /** 写入正文(保留已有导航段则拼回尾部)。 */
42
+ async write(body) {
43
+ const raw = await readTextIfExists(this.file);
44
+ const nav = raw ? extractSceneNavigation(raw) : undefined;
45
+ const content = nav ? `${body.trim()}\n\n${nav}\n` : `${body.trim()}\n`;
46
+ await atomicWriteText(this.file, content);
47
+ }
48
+ }
49
+ export function stripSceneNavigation(content) {
50
+ const idx = content.indexOf(NAV_HEADER);
51
+ if (idx === -1)
52
+ return content;
53
+ return content.slice(0, idx).trimEnd();
54
+ }
55
+ function extractSceneNavigation(content) {
56
+ const idx = content.indexOf(NAV_HEADER);
57
+ if (idx === -1)
58
+ return undefined;
59
+ return content.slice(idx).trim();
60
+ }
@@ -0,0 +1,23 @@
1
+ import type { MemoryFamily, MemoryLogger, SceneSummary } from '../types.js';
2
+ export declare class SceneStore {
3
+ private readonly logger?;
4
+ private readonly dir;
5
+ private readonly family;
6
+ constructor(dataDir: string, family: MemoryFamily, logger?: MemoryLogger | undefined);
7
+ init(): Promise<void>;
8
+ /** 旧布局迁移:scenes/ 根下的 .md 移入本族目录。仅 chat 族执行(历史数据归属 chat)。 */
9
+ private migrateLegacyLayout;
10
+ listFiles(): Promise<string[]>;
11
+ /** 列出场景摘要(解析 META 块)。 */
12
+ list(): Promise<SceneSummary[]>;
13
+ read(name: string): Promise<string | undefined>;
14
+ /**
15
+ * 写入/重写场景文件。content 为 [DELETED] 时删除该文件(LLM 的 delete 操作)。
16
+ * 文件名自动归一化(空格→短横线、剔除非法字符),非法则抛错。
17
+ */
18
+ write(name: string, content: string): Promise<string>;
19
+ /** 场景导航索引(召回注入用)。 */
20
+ navigation(): Promise<string>;
21
+ }
22
+ /** 文件名归一化:只允许字母数字 CJK - _ .,以 .md 结尾,去空格/标点。 */
23
+ export declare function sanitizeFilename(name: string): string;