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
package/dist/stats.js ADDED
@@ -0,0 +1,234 @@
1
+ /**
2
+ * 状态面板数据通道:Host 侧注册 /rpc 通道的 dsh-memory/stats 端点,
3
+ * Client 设置页通过 ctx.connection.rpc.call('/rpc', 'dsh-memory/stats') 拉取。
4
+ *
5
+ * connection 是可选服务且可能晚于本插件就绪:先探测一次,未就绪则监听
6
+ * internal/service,服务上线时再注册(服务消失又回来时也能重注册)。
7
+ */
8
+ import { createRequire } from 'node:module';
9
+ import { readFileSync, statSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { resolveDataDir } from './config.js';
12
+ const require = createRequire(import.meta.url);
13
+ export const PLUGIN_VERSION = require('../package.json').version;
14
+ /** 注册状态 RPC(web 侧 connection 服务可选,缺失时跳过,不影响插件主体)。 */
15
+ export function registerMemoryRpc(ctx, cfg, stores, logger, status, live, modes, dataDir) {
16
+ /** 当前是否持有一段有效注册(dispose 完成后清空,允许服务重上线时重注册)。 */
17
+ let holding = false;
18
+ const tryRegister = () => {
19
+ if (holding)
20
+ return;
21
+ const connection = ctx.get('connection');
22
+ if (!connection)
23
+ return;
24
+ holding = true;
25
+ let active = true;
26
+ // handle() 同步注册并返回异步 disposer(() => Promise<void>)。
27
+ const dispose = connection.rpc.handle('/rpc', async (endpoint, payload) => {
28
+ try {
29
+ const value = await handleEndpoint(endpoint, payload, {
30
+ cfg,
31
+ stores,
32
+ status,
33
+ live,
34
+ modes,
35
+ dataDir: dataDir ?? resolveDataDir(cfg),
36
+ logger,
37
+ });
38
+ return { ok: true, value };
39
+ }
40
+ catch (err) {
41
+ return {
42
+ ok: false,
43
+ error: { code: 'internal', message: err instanceof Error ? err.message : String(err), details: {} },
44
+ };
45
+ }
46
+ }, { authority: 'loopback' });
47
+ if (!active) {
48
+ void dispose();
49
+ return;
50
+ }
51
+ logger.debug?.('[memory] 状态 RPC 已注册(/rpc → dsh-memory/*)');
52
+ disposers.push(() => {
53
+ active = false;
54
+ holding = false;
55
+ void dispose();
56
+ });
57
+ };
58
+ const disposers = [];
59
+ ctx.effect(() => {
60
+ tryRegister();
61
+ const off = ctx.on('internal/service', (name) => {
62
+ if (name === 'connection')
63
+ tryRegister();
64
+ });
65
+ return () => {
66
+ off();
67
+ for (const dispose of disposers.splice(0))
68
+ dispose();
69
+ };
70
+ });
71
+ }
72
+ async function buildStats(cfg, stores, status) {
73
+ // 两族 checkpoint 聚合(浏览器混合视图:总量求和、时间取最新)
74
+ const chat = stores.state.forFamily('chat');
75
+ const work = stores.state.forFamily('work');
76
+ const [chatScenes, workScenes, chatPersona, workPersona] = await Promise.all([
77
+ stores.scenes.chat.list(),
78
+ stores.scenes.work.list(),
79
+ stores.persona.chat.read(),
80
+ stores.persona.work.read(),
81
+ ]);
82
+ const degraded = status?.degraded() ?? false;
83
+ const personaChars = (chatPersona?.length ?? 0) + (workPersona?.length ?? 0);
84
+ const max = (a, b) => Math.max(a, b);
85
+ const iso = (t) => (t ? new Date(t).toISOString() : null);
86
+ return {
87
+ ok: true,
88
+ dataDir: resolveDataDir(cfg),
89
+ family: cfg.family,
90
+ version: PLUGIN_VERSION,
91
+ l0Today: await stores.l0.countToday(),
92
+ l1Count: stores.l1.size,
93
+ l1TotalExtracted: chat.totalExtracted + work.totalExtracted,
94
+ sceneCount: chatScenes.length + workScenes.length,
95
+ personaChars,
96
+ hasPersona: chat.hasPersona || work.hasPersona,
97
+ lastExtractAt: iso(max(chat.lastExtractAt, work.lastExtractAt)),
98
+ lastL2At: iso(max(chat.lastL2At, work.lastL2At)),
99
+ lastL3At: iso(max(chat.lastL3At, work.lastL3At)),
100
+ memoriesSinceL2: chat.newMemoriesSinceL2 + work.newMemoriesSinceL2,
101
+ memoriesSinceL3: chat.memoriesSinceL3 + work.memoriesSinceL3,
102
+ pendingExtract: status?.pending() ?? 0,
103
+ message: degraded ? 'degraded:存储不可用,记忆功能已停用' : 'running',
104
+ };
105
+ }
106
+ async function handleEndpoint(endpoint, payload, deps) {
107
+ const { cfg, stores, status, live, modes, dataDir } = deps;
108
+ switch (endpoint) {
109
+ case 'dsh-memory/stats':
110
+ return buildStats(cfg, stores, status);
111
+ case 'dsh-memory/session-mode-get': {
112
+ if (!modes)
113
+ throw new Error('档位存储未初始化');
114
+ const p = (payload ?? {});
115
+ if (typeof p.sessionId !== 'string' || !p.sessionId)
116
+ throw new Error('sessionId 缺失');
117
+ return { sessionId: p.sessionId, mode: modes.get(p.sessionId), defaultMode: modes.default };
118
+ }
119
+ case 'dsh-memory/session-mode-set': {
120
+ if (!modes)
121
+ throw new Error('档位存储未初始化');
122
+ const p = (payload ?? {});
123
+ if (typeof p.sessionId !== 'string' || !p.sessionId)
124
+ throw new Error('sessionId 缺失');
125
+ const allowed = ['auto', 'chat', 'work', 'off'];
126
+ if (typeof p.mode !== 'string' || !allowed.includes(p.mode)) {
127
+ throw new Error(`非法档位: ${String(p.mode)}(允许 ${allowed.join('/')})`);
128
+ }
129
+ modes.set(p.sessionId, p.mode);
130
+ deps.logger.info(`[memory] 会话档位设置 session=${p.sessionId} mode=${p.mode}`);
131
+ return { sessionId: p.sessionId, mode: p.mode };
132
+ }
133
+ case 'dsh-memory/settings-get':
134
+ return {
135
+ supported: live?.supported ?? false,
136
+ settings: live?.get() ?? { enabled: true, capture: true, distill: true, recall: true },
137
+ // 静态部署上限(cordis.patch.yml):运行时开关与它取 AND
138
+ ceilings: { capture: cfg.capture.enabled, distill: cfg.extract.enabled, recall: cfg.recall.enabled },
139
+ };
140
+ case 'dsh-memory/settings-set': {
141
+ if (!live)
142
+ throw new Error('开关通道未初始化');
143
+ const patch = (payload ?? {});
144
+ const allowed = ['enabled', 'capture', 'distill', 'recall'];
145
+ const clean = {};
146
+ for (const key of allowed) {
147
+ if (typeof patch[key] === 'boolean')
148
+ clean[key] = patch[key];
149
+ }
150
+ if (Object.keys(clean).length === 0)
151
+ throw new Error('开关更新载荷为空');
152
+ await live.update(clean);
153
+ return { ok: true, settings: live.get() };
154
+ }
155
+ case 'dsh-memory/list-records': {
156
+ const p = (payload ?? {});
157
+ const limit = Math.min(Math.max(Number(p.limit) || 50, 1), 200);
158
+ const offset = Math.max(Number(p.offset) || 0, 0);
159
+ // 关键词路径:复用检索唯一缝(与召回同源),取回后做场景过滤 + 手工分页
160
+ if (p.query && p.query.trim()) {
161
+ const hits = await stores.l1.search(p.query, Math.min(offset + limit + 1, 200), { type: p.type || undefined });
162
+ const filtered = p.scene ? hits.filter((h) => h.scene_name === p.scene) : hits;
163
+ return {
164
+ items: filtered.slice(offset, offset + limit).map(hitToUiRecord),
165
+ hasMore: filtered.length > offset + limit,
166
+ total: null,
167
+ scenes: offset === 0 ? stores.l1.distinctScenes() : undefined,
168
+ };
169
+ }
170
+ const { items, total } = stores.l1.list({ type: p.type || undefined, scene: p.scene || undefined, limit, offset });
171
+ return {
172
+ items: items.map(hitToUiRecord),
173
+ hasMore: offset + items.length < total,
174
+ total,
175
+ scenes: offset === 0 ? stores.l1.distinctScenes() : undefined,
176
+ };
177
+ }
178
+ case 'dsh-memory/scenes': {
179
+ // 两族拼接展示(浏览器保持混合视图;路径冲突时后写入的族覆盖显示名,读取仍各自独立)
180
+ const items = [];
181
+ for (const family of ['chat', 'work']) {
182
+ const summaries = await stores.scenes[family].list();
183
+ for (const s of summaries) {
184
+ items.push({ path: s.path, family, summary: s.summary, updated: s.updated, heat: s.heat, content: (await stores.scenes[family].read(s.path)) ?? '' });
185
+ }
186
+ }
187
+ items.sort((a, b) => (a.updated < b.updated ? 1 : -1));
188
+ return { items };
189
+ }
190
+ case 'dsh-memory/persona': {
191
+ const [chat, work] = await Promise.all([stores.persona.chat.read(), stores.persona.work.read()]);
192
+ const parts = [];
193
+ if (chat)
194
+ parts.push(`<!-- family: chat -->\n${chat}`);
195
+ if (work)
196
+ parts.push(`<!-- family: work -->\n${work}`);
197
+ return { content: parts.join('\n\n---\n\n') };
198
+ }
199
+ case 'dsh-memory/log-tail': {
200
+ const p = (payload ?? {});
201
+ return { lines: readLogTail(join(dataDir, 'memory.log'), Math.min(Math.max(Number(p.lines) || 200, 1), 1000)) };
202
+ }
203
+ default:
204
+ throw new Error(`unknown endpoint: ${endpoint}`);
205
+ }
206
+ }
207
+ /** 浏览器卡片字段(比 MemoryRecord 精简,去掉大 metadata)。 */
208
+ function hitToUiRecord(r) {
209
+ return {
210
+ id: r.id,
211
+ content: r.content,
212
+ type: r.type,
213
+ priority: r.priority ?? 60,
214
+ scene: r.scene_name,
215
+ family: r.family ?? null,
216
+ timestamps: (r.timestamps ?? []).map((t) => new Date(t).toISOString()),
217
+ createdAt: r.createdAt ? new Date(r.createdAt).toISOString() : null,
218
+ updatedAt: r.updatedAt ? new Date(r.updatedAt).toISOString() : null,
219
+ version: r.version ?? 0,
220
+ sourceMessageIds: r.source_message_ids ?? [],
221
+ score: r.score ?? null,
222
+ };
223
+ }
224
+ /** 读日志尾部(文件有 2MB 轮转上限,全量读后取尾部即可)。 */
225
+ function readLogTail(logPath, maxLines) {
226
+ try {
227
+ void statSync(logPath);
228
+ const lines = readFileSync(logPath, 'utf8').split('\n').filter((l) => l.length > 0);
229
+ return lines.slice(-maxLines);
230
+ }
231
+ catch {
232
+ return [];
233
+ }
234
+ }
@@ -0,0 +1,19 @@
1
+ export interface Bm25Doc {
2
+ id: string;
3
+ text: string;
4
+ }
5
+ export interface Bm25Hit {
6
+ id: string;
7
+ score: number;
8
+ }
9
+ export declare class Bm25Index {
10
+ private docs;
11
+ private termFreq;
12
+ private docLen;
13
+ private df;
14
+ private totalLen;
15
+ get size(): number;
16
+ /** 全量重建。 */
17
+ rebuild(docs: Bm25Doc[]): void;
18
+ search(query: string, topK: number, filter?: (id: string) => boolean): Bm25Hit[];
19
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * 轻量 BM25 检索(无外部依赖):拉丁词 + CJK 二元组分词。
3
+ * 仅用于 L2 场景摘要的上下文选取(小语料);L0/L1 检索走 SQLite FTS5/向量。
4
+ */
5
+ import { tokenize } from '../util/text.js';
6
+ const K1 = 1.5;
7
+ const B = 0.75;
8
+ export class Bm25Index {
9
+ docs = [];
10
+ termFreq = [];
11
+ docLen = [];
12
+ df = new Map();
13
+ totalLen = 0;
14
+ get size() {
15
+ return this.docs.length;
16
+ }
17
+ /** 全量重建。 */
18
+ rebuild(docs) {
19
+ this.docs = docs;
20
+ this.termFreq = [];
21
+ this.docLen = [];
22
+ this.df = new Map();
23
+ this.totalLen = 0;
24
+ for (let i = 0; i < docs.length; i++) {
25
+ const terms = tokenize(docs[i].text);
26
+ const tf = new Map();
27
+ for (const t of terms)
28
+ tf.set(t, (tf.get(t) ?? 0) + 1);
29
+ this.termFreq.push(tf);
30
+ this.docLen.push(terms.length);
31
+ this.totalLen += terms.length;
32
+ for (const t of tf.keys())
33
+ this.df.set(t, (this.df.get(t) ?? 0) + 1);
34
+ }
35
+ }
36
+ search(query, topK, filter) {
37
+ const terms = tokenize(query);
38
+ if (terms.length === 0 || this.docs.length === 0)
39
+ return [];
40
+ const avgdl = this.totalLen / this.docs.length || 1;
41
+ const n = this.docs.length;
42
+ const scores = [];
43
+ for (let i = 0; i < n; i++) {
44
+ if (filter && !filter(this.docs[i].id))
45
+ continue;
46
+ const tf = this.termFreq[i];
47
+ const len = this.docLen[i];
48
+ let score = 0;
49
+ for (const t of new Set(terms)) {
50
+ const f = tf.get(t) ?? 0;
51
+ if (f === 0)
52
+ continue;
53
+ const idf = Math.log(1 + (n - (this.df.get(t) ?? 0) + 0.5) / ((this.df.get(t) ?? 0) + 0.5));
54
+ score += idf * ((f * (K1 + 1)) / (f + K1 * (1 - B + B * (len / avgdl))));
55
+ }
56
+ if (score > 0)
57
+ scores.push({ i, s: score });
58
+ }
59
+ scores.sort((a, b) => b.s - a.s);
60
+ return scores.slice(0, topK).map(({ i, s }) => ({ id: this.docs[i].id, score: s }));
61
+ }
62
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Embedding 服务(移植 MemoryCore embedding.ts 的裁剪版,仅保留远程 OpenAI 兼容实现)。
3
+ * dsh 的 ctx.llm 只有 chat 流式接口、无 embeddings 端点,因此向量能力
4
+ * 需要用户自备任意 OpenAI 兼容 /embeddings 服务(OpenAI/SiliconFlow/Jina/自托管 BGE 等)。
5
+ * 默认关闭(Noop)——纯 FTS 模式即可运行,这是官方 provider="none" 的同款语义。
6
+ */
7
+ import type { MemoryLogger } from '../types.js';
8
+ export interface EmbeddingProviderInfo {
9
+ provider: string;
10
+ model: string;
11
+ dimensions: number;
12
+ }
13
+ export interface EmbeddingService {
14
+ embed(text: string): Promise<Float32Array>;
15
+ embedBatch(texts: string[]): Promise<Float32Array[]>;
16
+ getDimensions(): number;
17
+ getProviderInfo(): EmbeddingProviderInfo;
18
+ isReady(): boolean;
19
+ close?(): void;
20
+ }
21
+ /** 空实现:向量能力关闭/不可用时使用,一切调用安全返回。 */
22
+ export declare class NoopEmbeddingService implements EmbeddingService {
23
+ getDimensions(): number;
24
+ getProviderInfo(): EmbeddingProviderInfo;
25
+ isReady(): boolean;
26
+ embed(): Promise<Float32Array>;
27
+ embedBatch(texts: string[]): Promise<Float32Array[]>;
28
+ }
29
+ export interface RemoteEmbeddingOptions {
30
+ baseUrl: string;
31
+ apiKey: string;
32
+ model: string;
33
+ /** 必填且须与模型输出一致(vec0 建表需要固定维度)。 */
34
+ dimensions: number;
35
+ maxInputChars?: number;
36
+ timeoutMs?: number;
37
+ logger?: MemoryLogger;
38
+ }
39
+ /**
40
+ * OpenAI 兼容远程 embedding:POST {baseUrl}/embeddings。
41
+ * 向量在客户端做 L2 归一化(与余弦度量配套,照搬官方 sanitizeAndNormalize)。
42
+ */
43
+ export declare class RemoteEmbeddingService implements EmbeddingService {
44
+ private readonly opts;
45
+ constructor(opts: RemoteEmbeddingOptions);
46
+ getDimensions(): number;
47
+ getProviderInfo(): EmbeddingProviderInfo;
48
+ isReady(): boolean;
49
+ embed(text: string): Promise<Float32Array>;
50
+ embedBatch(texts: string[]): Promise<Float32Array[]>;
51
+ }
52
+ /**
53
+ * 嵌入调用降级助手(L0/L1 Store 共用):查询向量失败 → undefined(调用方降级 FTS);
54
+ * 批量嵌入失败 → 全 undefined(跳过向量写入,由周期性 backfill 补齐)。
55
+ * 同类失败只告警一次,避免刷屏。
56
+ */
57
+ export declare class EmbedHelper {
58
+ private readonly embed;
59
+ private readonly logger?;
60
+ private warned;
61
+ constructor(embed: EmbeddingService, logger?: MemoryLogger | undefined);
62
+ vectorReady(): boolean;
63
+ /** 查询向量;失败或空向量返回 undefined(调用方降级 FTS)。 */
64
+ query(text: string): Promise<Float32Array | undefined>;
65
+ /** 批量嵌入;服务未就绪或失败时返回全 undefined(不阻断元数据/FTS 写入)。 */
66
+ batch(texts: string[]): Promise<Array<Float32Array | undefined>>;
67
+ private warn;
68
+ }
@@ -0,0 +1,133 @@
1
+ /** 空实现:向量能力关闭/不可用时使用,一切调用安全返回。 */
2
+ export class NoopEmbeddingService {
3
+ getDimensions() {
4
+ return 0;
5
+ }
6
+ getProviderInfo() {
7
+ return { provider: 'noop', model: 'disabled', dimensions: 0 };
8
+ }
9
+ isReady() {
10
+ return false;
11
+ }
12
+ async embed() {
13
+ return new Float32Array(0);
14
+ }
15
+ async embedBatch(texts) {
16
+ return texts.map(() => new Float32Array(0));
17
+ }
18
+ }
19
+ /**
20
+ * OpenAI 兼容远程 embedding:POST {baseUrl}/embeddings。
21
+ * 向量在客户端做 L2 归一化(与余弦度量配套,照搬官方 sanitizeAndNormalize)。
22
+ */
23
+ export class RemoteEmbeddingService {
24
+ opts;
25
+ constructor(opts) {
26
+ this.opts = { maxInputChars: 5000, timeoutMs: 10_000, ...opts };
27
+ }
28
+ getDimensions() {
29
+ return this.opts.dimensions;
30
+ }
31
+ getProviderInfo() {
32
+ return { provider: 'remote', model: this.opts.model, dimensions: this.opts.dimensions };
33
+ }
34
+ isReady() {
35
+ return true;
36
+ }
37
+ async embed(text) {
38
+ const [vec] = await this.embedBatch([text]);
39
+ return vec;
40
+ }
41
+ async embedBatch(texts) {
42
+ if (texts.length === 0)
43
+ return [];
44
+ const input = texts.map((t) => t.slice(0, this.opts.maxInputChars));
45
+ const base = this.opts.baseUrl.replace(/\/+$/, '');
46
+ const res = await fetch(`${base}/embeddings`, {
47
+ method: 'POST',
48
+ headers: {
49
+ 'content-type': 'application/json',
50
+ authorization: `Bearer ${this.opts.apiKey}`,
51
+ },
52
+ body: JSON.stringify({ model: this.opts.model, input }),
53
+ signal: AbortSignal.timeout(this.opts.timeoutMs),
54
+ });
55
+ if (!res.ok) {
56
+ const body = await res.text().catch(() => '');
57
+ throw new Error(`embeddings HTTP ${res.status}: ${body.slice(0, 200)}`);
58
+ }
59
+ const json = (await res.json());
60
+ const data = json.data ?? [];
61
+ if (data.length !== texts.length) {
62
+ throw new Error(`embeddings 返回数量不匹配:期望 ${texts.length},得到 ${data.length}`);
63
+ }
64
+ // 按 index 还原顺序(部分服务商不保序)
65
+ const byIndex = new Map();
66
+ for (const d of data)
67
+ byIndex.set(d.index, d.embedding);
68
+ return input.map((_, i) => {
69
+ const raw = byIndex.get(i);
70
+ if (!raw || raw.length !== this.opts.dimensions) {
71
+ throw new Error(`embedding 维度不匹配:期望 ${this.opts.dimensions},得到 ${raw?.length ?? 0}`);
72
+ }
73
+ return sanitizeAndNormalize(raw);
74
+ });
75
+ }
76
+ }
77
+ /** 清洗 NaN/Inf 并 L2 归一化(零向量原样返回,写入侧会跳过)。 */
78
+ function sanitizeAndNormalize(vec) {
79
+ const arr = Array.from(vec).map((v) => (Number.isFinite(v) ? v : 0));
80
+ const magnitude = Math.sqrt(arr.reduce((sum, v) => sum + v * v, 0));
81
+ if (magnitude < 1e-10)
82
+ return new Float32Array(arr);
83
+ return new Float32Array(arr.map((v) => v / magnitude));
84
+ }
85
+ /**
86
+ * 嵌入调用降级助手(L0/L1 Store 共用):查询向量失败 → undefined(调用方降级 FTS);
87
+ * 批量嵌入失败 → 全 undefined(跳过向量写入,由周期性 backfill 补齐)。
88
+ * 同类失败只告警一次,避免刷屏。
89
+ */
90
+ export class EmbedHelper {
91
+ embed;
92
+ logger;
93
+ warned = false;
94
+ constructor(embed, logger) {
95
+ this.embed = embed;
96
+ this.logger = logger;
97
+ }
98
+ vectorReady() {
99
+ return this.embed.isReady();
100
+ }
101
+ /** 查询向量;失败或空向量返回 undefined(调用方降级 FTS)。 */
102
+ async query(text) {
103
+ try {
104
+ const vec = await this.embed.embed(text);
105
+ return vec.length > 0 ? vec : undefined;
106
+ }
107
+ catch (err) {
108
+ this.warn(`查询向量计算失败,降级 FTS: ${errMsg(err)}`);
109
+ return undefined;
110
+ }
111
+ }
112
+ /** 批量嵌入;服务未就绪或失败时返回全 undefined(不阻断元数据/FTS 写入)。 */
113
+ async batch(texts) {
114
+ if (!this.embed.isReady())
115
+ return texts.map(() => undefined);
116
+ try {
117
+ return await this.embed.embedBatch(texts);
118
+ }
119
+ catch (err) {
120
+ this.warn(`批量嵌入失败,本批暂不写向量(后台 backfill 会补齐): ${errMsg(err)}`);
121
+ return texts.map(() => undefined);
122
+ }
123
+ }
124
+ warn(msg) {
125
+ if (this.warned)
126
+ return;
127
+ this.warned = true;
128
+ this.logger?.warn(`[memory] ${msg}`);
129
+ }
130
+ }
131
+ function errMsg(err) {
132
+ return err instanceof Error ? err.message : String(err);
133
+ }
@@ -0,0 +1,13 @@
1
+ export declare function ensureDir(dir: string): Promise<void>;
2
+ /** 原子写文本文件。 */
3
+ export declare function atomicWriteText(file: string, content: string): Promise<void>;
4
+ /** 原子写 JSON。 */
5
+ export declare function atomicWriteJson(file: string, value: unknown): Promise<void>;
6
+ export declare function readJsonIfExists<T>(file: string): Promise<T | undefined>;
7
+ export declare function readTextIfExists(file: string): Promise<string | undefined>;
8
+ /** 追加 JSONL 行(存在则追加,否则创建)。 */
9
+ export declare function appendJsonl(file: string, lines: unknown[]): Promise<void>;
10
+ /** 读取 JSONL 全部行。 */
11
+ export declare function readJsonl<T>(file: string): Promise<T[]>;
12
+ export declare function nowIso(): string;
13
+ export declare function dayKey(ts: number): string;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * 持久化工具:原子写(tmp + rename)、JSON 状态。
3
+ */
4
+ import { promises as fs } from 'node:fs';
5
+ import * as path from 'node:path';
6
+ export async function ensureDir(dir) {
7
+ await fs.mkdir(dir, { recursive: true });
8
+ }
9
+ /** 原子写文本文件。 */
10
+ export async function atomicWriteText(file, content) {
11
+ await ensureDir(path.dirname(file));
12
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
13
+ await fs.writeFile(tmp, content, 'utf-8');
14
+ await fs.rename(tmp, file);
15
+ }
16
+ /** 原子写 JSON。 */
17
+ export async function atomicWriteJson(file, value) {
18
+ await atomicWriteText(file, JSON.stringify(value, null, 2));
19
+ }
20
+ export async function readJsonIfExists(file) {
21
+ try {
22
+ const raw = await fs.readFile(file, 'utf-8');
23
+ return JSON.parse(raw);
24
+ }
25
+ catch {
26
+ return undefined;
27
+ }
28
+ }
29
+ export async function readTextIfExists(file) {
30
+ try {
31
+ return await fs.readFile(file, 'utf-8');
32
+ }
33
+ catch {
34
+ return undefined;
35
+ }
36
+ }
37
+ /** 追加 JSONL 行(存在则追加,否则创建)。 */
38
+ export async function appendJsonl(file, lines) {
39
+ if (lines.length === 0)
40
+ return;
41
+ await ensureDir(path.dirname(file));
42
+ const payload = lines.map((l) => JSON.stringify(l)).join('\n') + '\n';
43
+ await fs.appendFile(file, payload, 'utf-8');
44
+ }
45
+ /** 读取 JSONL 全部行。 */
46
+ export async function readJsonl(file) {
47
+ try {
48
+ const raw = await fs.readFile(file, 'utf-8');
49
+ const out = [];
50
+ for (const line of raw.split('\n')) {
51
+ const t = line.trim();
52
+ if (!t)
53
+ continue;
54
+ try {
55
+ out.push(JSON.parse(t));
56
+ }
57
+ catch {
58
+ // 跳过坏行
59
+ }
60
+ }
61
+ return out;
62
+ }
63
+ catch {
64
+ return [];
65
+ }
66
+ }
67
+ export function nowIso() {
68
+ return new Date().toISOString();
69
+ }
70
+ export function dayKey(ts) {
71
+ const d = new Date(ts);
72
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
73
+ const dd = String(d.getDate()).padStart(2, '0');
74
+ return `${d.getFullYear()}-${mm}-${dd}`;
75
+ }
@@ -0,0 +1,28 @@
1
+ import type { ConversationMessage, L0MessageRecord, MemoryLogger } from '../types.js';
2
+ import { type EmbeddingService } from './embedding.js';
3
+ import type { MemoryDb } from './sqlite.js';
4
+ export declare class L0Store {
5
+ private readonly db;
6
+ private readonly dir;
7
+ private readonly legacyDir;
8
+ private readonly helper;
9
+ private readonly embedSvc;
10
+ private readonly logger?;
11
+ constructor(dataDir: string, db: MemoryDb, embed?: EmbeddingService, logger?: MemoryLogger);
12
+ init(): Promise<void>;
13
+ /** 旧版 l0/*.jsonl 一次性导入检索库,成功后目录改名 l0.imported/。 */
14
+ private importLegacy;
15
+ append(sessionId: string, messages: ConversationMessage[]): Promise<void>;
16
+ /** 今日已捕获消息数(SQL 计数,不再读整文件)。 */
17
+ countToday(): Promise<number>;
18
+ /** 检索:FTS + 向量 hybrid(RRF 融合),返回按相关性排序的消息。 */
19
+ search(query: string, limit: number): Promise<L0MessageRecord[]>;
20
+ /**
21
+ * 全量重嵌入(embedding 启用 / 周期性补齐用)。
22
+ * 返回写入数与失败数——failed > 0 时调用方不应标记 meta 同步完成。
23
+ */
24
+ reindex(): Promise<{
25
+ written: number;
26
+ failed: number;
27
+ }>;
28
+ }