dsh-prime-memory 0.11.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/CHANGELOG.en.md +28 -0
- package/CHANGELOG.ja.md +30 -0
- package/CHANGELOG.ko.md +30 -0
- package/CHANGELOG.md +1220 -0
- package/ENGINEERING-NOTES.md +452 -0
- package/INSTALL.en.md +92 -0
- package/INSTALL.ja.md +92 -0
- package/INSTALL.ko.md +92 -0
- package/INSTALL.md +92 -0
- package/LICENSE +21 -0
- package/README.en.md +458 -0
- package/README.ja.md +306 -0
- package/README.ko.md +306 -0
- package/README.md +425 -0
- package/assets/changelog/0.8.10/01-write-only-pill.png +0 -0
- package/assets/changelog/0.8.9/01-panel.png +0 -0
- package/assets/changelog/0.8.9/02-halo.png +0 -0
- package/assets/changelog/0.8.9/03-layer-segmented-panel.png +0 -0
- package/assets/changelog/0.8.9/04-layer-l1-panel.png +0 -0
- package/assets/img/EmbeddingSource.png +0 -0
- package/assets/img/Hero.png +0 -0
- package/assets/img/Layers.png +0 -0
- package/assets/img/MemoryTools.png +0 -0
- package/assets/img/Modes.png +0 -0
- package/assets/img/ToolTrajectory.png +0 -0
- package/assets/img/ui-dark.jpg +0 -0
- package/assets/img/ui-light.jpg +0 -0
- package/assets/readme/bench-dialog.svg +70 -0
- package/assets/readme/bench-workflow.svg +79 -0
- package/assets/readme/flow.svg +189 -0
- package/assets/readme/storage.svg +115 -0
- package/cordis.patch.yml +16 -0
- package/dist/bench-control.d.ts +34 -0
- package/dist/bench-control.js +16 -0
- package/dist/client.js +4293 -0
- package/dist/config.d.ts +683 -0
- package/dist/config.js +129 -0
- package/dist/contract.d.ts +820 -0
- package/dist/contract.js +1 -0
- package/dist/embedding-worker.cjs +176 -0
- package/dist/graph/apply.d.ts +37 -0
- package/dist/graph/apply.js +270 -0
- package/dist/graph/constraints.d.ts +47 -0
- package/dist/graph/constraints.js +38 -0
- package/dist/graph/search.d.ts +16 -0
- package/dist/graph/search.js +115 -0
- package/dist/graph/types.d.ts +142 -0
- package/dist/graph/types.js +14 -0
- package/dist/hooks/capture.d.ts +32 -0
- package/dist/hooks/capture.js +194 -0
- package/dist/hooks/recall.d.ts +63 -0
- package/dist/hooks/recall.js +429 -0
- package/dist/index.d.ts +534 -0
- package/dist/index.js +344 -0
- package/dist/llm-usage.d.ts +26 -0
- package/dist/llm-usage.js +37 -0
- package/dist/llm.d.ts +153 -0
- package/dist/llm.js +530 -0
- package/dist/pipeline/graph.d.ts +35 -0
- package/dist/pipeline/graph.js +104 -0
- package/dist/pipeline/l1.d.ts +19 -0
- package/dist/pipeline/l1.js +271 -0
- package/dist/pipeline/l2.d.ts +13 -0
- package/dist/pipeline/l2.js +83 -0
- package/dist/pipeline/l3.d.ts +15 -0
- package/dist/pipeline/l3.js +78 -0
- package/dist/pipeline/rebuild.d.ts +61 -0
- package/dist/pipeline/rebuild.js +307 -0
- package/dist/pipeline/ruminate.d.ts +89 -0
- package/dist/pipeline/ruminate.js +298 -0
- package/dist/pipeline/runner.d.ts +167 -0
- package/dist/pipeline/runner.js +638 -0
- package/dist/pipeline/trigger.d.ts +40 -0
- package/dist/pipeline/trigger.js +75 -0
- package/dist/prompts/graph-projection.d.ts +70 -0
- package/dist/prompts/graph-projection.js +167 -0
- package/dist/prompts/l1-dedup.d.ts +22 -0
- package/dist/prompts/l1-dedup.js +251 -0
- package/dist/prompts/l1-extraction.d.ts +22 -0
- package/dist/prompts/l1-extraction.js +457 -0
- package/dist/prompts/persona.d.ts +23 -0
- package/dist/prompts/persona.js +240 -0
- package/dist/prompts/scene.d.ts +32 -0
- package/dist/prompts/scene.js +414 -0
- package/dist/runtime-package-lock.json +982 -0
- package/dist/settings.d.ts +50 -0
- package/dist/settings.js +355 -0
- package/dist/stats.d.ts +109 -0
- package/dist/stats.js +929 -0
- package/dist/store/bm25.d.ts +19 -0
- package/dist/store/bm25.js +63 -0
- package/dist/store/cost-ledger.d.ts +75 -0
- package/dist/store/cost-ledger.js +171 -0
- package/dist/store/download-queue.d.ts +79 -0
- package/dist/store/download-queue.js +424 -0
- package/dist/store/embedding-source.d.ts +118 -0
- package/dist/store/embedding-source.js +443 -0
- package/dist/store/embedding.d.ts +90 -0
- package/dist/store/embedding.js +206 -0
- package/dist/store/graph-store.d.ts +94 -0
- package/dist/store/graph-store.js +641 -0
- package/dist/store/l0.d.ts +40 -0
- package/dist/store/l0.js +197 -0
- package/dist/store/l1.d.ts +93 -0
- package/dist/store/l1.js +297 -0
- package/dist/store/local-embedding.d.ts +89 -0
- package/dist/store/local-embedding.js +227 -0
- package/dist/store/model-catalog.d.ts +48 -0
- package/dist/store/model-catalog.js +81 -0
- package/dist/store/occupancy.d.ts +30 -0
- package/dist/store/occupancy.js +134 -0
- package/dist/store/pending.d.ts +36 -0
- package/dist/store/pending.js +103 -0
- package/dist/store/persona.d.ts +15 -0
- package/dist/store/persona.js +60 -0
- package/dist/store/recall-dedupe.d.ts +26 -0
- package/dist/store/recall-dedupe.js +138 -0
- package/dist/store/runtime-installer.d.ts +59 -0
- package/dist/store/runtime-installer.js +243 -0
- package/dist/store/scenes.d.ts +24 -0
- package/dist/store/scenes.js +160 -0
- package/dist/store/search-utils.d.ts +38 -0
- package/dist/store/search-utils.js +100 -0
- package/dist/store/session-modes.d.ts +35 -0
- package/dist/store/session-modes.js +144 -0
- package/dist/store/sqlite.d.ts +246 -0
- package/dist/store/sqlite.js +1491 -0
- package/dist/store/state.d.ts +41 -0
- package/dist/store/state.js +72 -0
- package/dist/token-cost.d.ts +23 -0
- package/dist/token-cost.js +185 -0
- package/dist/tools/index.d.ts +34 -0
- package/dist/tools/index.js +758 -0
- package/dist/types.d.ts +139 -0
- package/dist/types.js +38 -0
- package/dist/util/context-occupancy.d.ts +68 -0
- package/dist/util/context-occupancy.js +92 -0
- package/dist/util/filelog.d.ts +6 -0
- package/dist/util/filelog.js +108 -0
- package/dist/util/io.d.ts +18 -0
- package/dist/util/io.js +97 -0
- package/dist/util/recall-budget.d.ts +31 -0
- package/dist/util/recall-budget.js +84 -0
- package/dist/util/sanitize.d.ts +11 -0
- package/dist/util/sanitize.js +67 -0
- package/dist/util/text.d.ts +16 -0
- package/dist/util/text.js +61 -0
- package/dist/util/tokenizer.d.ts +9 -0
- package/dist/util/tokenizer.js +50 -0
- package/dsh.plugin.json +22 -0
- package/package.json +118 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L2 场景块存储:Markdown 文件(含 META 块)+ 场景导航。
|
|
3
|
+
* LLM 输出 [DELETED] 时由工程侧删除文件(硬删除),list() 对仍含该标记的
|
|
4
|
+
* 遗留文件容错跳过。
|
|
5
|
+
*
|
|
6
|
+
* 分族隔离:目录为 scenes/<family>/;init() 把旧布局(scenes/ 根下散文件)
|
|
7
|
+
* 一次性迁入 scenes/chat/(历史数据由 chat 档蒸馏产出)。
|
|
8
|
+
* META 定界符与 [DELETED] 标记是磁盘契约,不可更动。
|
|
9
|
+
*/
|
|
10
|
+
import { promises as fs } from 'node:fs';
|
|
11
|
+
import * as path from 'node:path';
|
|
12
|
+
import { atomicWriteText, ensureDir, readTextIfExists } from '../util/io.js';
|
|
13
|
+
import { NAV_HEADER } from './persona.js';
|
|
14
|
+
const META_START = '-----META-START-----';
|
|
15
|
+
const META_END = '-----META-END-----';
|
|
16
|
+
const DELETED_MARKER = '[DELETED]';
|
|
17
|
+
export class SceneStore {
|
|
18
|
+
logger;
|
|
19
|
+
dir;
|
|
20
|
+
family;
|
|
21
|
+
constructor(dataDir, family, logger) {
|
|
22
|
+
this.logger = logger;
|
|
23
|
+
this.family = family;
|
|
24
|
+
this.dir = path.join(dataDir, 'scenes', family);
|
|
25
|
+
}
|
|
26
|
+
async init() {
|
|
27
|
+
await ensureDir(this.dir);
|
|
28
|
+
await this.migrateLegacyLayout();
|
|
29
|
+
}
|
|
30
|
+
/** 旧布局迁移:scenes/ 根下的 .md 移入本族目录。仅 chat 族执行(历史数据归属 chat)。 */
|
|
31
|
+
async migrateLegacyLayout() {
|
|
32
|
+
if (this.family !== 'chat')
|
|
33
|
+
return;
|
|
34
|
+
const legacyDir = path.dirname(this.dir);
|
|
35
|
+
let files;
|
|
36
|
+
try {
|
|
37
|
+
files = await fs.readdir(legacyDir);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
let moved = 0;
|
|
43
|
+
for (const f of files) {
|
|
44
|
+
if (!f.endsWith('.md'))
|
|
45
|
+
continue;
|
|
46
|
+
const from = path.join(legacyDir, f);
|
|
47
|
+
const to = path.join(this.dir, f);
|
|
48
|
+
try {
|
|
49
|
+
await fs.rename(from, to);
|
|
50
|
+
moved++;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
/* 单文件失败跳过(可能被占用),下次启动重试 */
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (moved > 0)
|
|
57
|
+
this.logger?.info(`[memory] 场景目录迁移:${moved} 个文件 scenes/ → scenes/chat/`);
|
|
58
|
+
}
|
|
59
|
+
listFiles() {
|
|
60
|
+
return fs.readdir(this.dir).catch(() => []);
|
|
61
|
+
}
|
|
62
|
+
/** 列出场景摘要(解析 META 块)。 */
|
|
63
|
+
async list() {
|
|
64
|
+
const files = await this.listFiles();
|
|
65
|
+
const out = [];
|
|
66
|
+
for (const f of files.sort()) {
|
|
67
|
+
if (!f.endsWith('.md'))
|
|
68
|
+
continue;
|
|
69
|
+
const content = await readTextIfExists(path.join(this.dir, f));
|
|
70
|
+
if (!content || content.trim() === DELETED_MARKER)
|
|
71
|
+
continue;
|
|
72
|
+
out.push(parseMeta(content, f));
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
async read(name) {
|
|
77
|
+
const safe = sanitizeFilename(name);
|
|
78
|
+
if (!safe)
|
|
79
|
+
return undefined;
|
|
80
|
+
return readTextIfExists(path.join(this.dir, safe));
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* 写入/重写场景文件。content 为 [DELETED] 时删除该文件(LLM 的 delete 操作)。
|
|
84
|
+
* 文件名自动归一化(空格→短横线、剔除非法字符),非法则抛错。
|
|
85
|
+
*/
|
|
86
|
+
async write(name, content) {
|
|
87
|
+
const safe = sanitizeFilename(name);
|
|
88
|
+
if (!safe)
|
|
89
|
+
throw new Error(`非法的场景文件名: ${name}`);
|
|
90
|
+
const file = path.join(this.dir, safe);
|
|
91
|
+
if (content.trim() === DELETED_MARKER) {
|
|
92
|
+
await fs.unlink(file).catch(() => undefined);
|
|
93
|
+
return safe;
|
|
94
|
+
}
|
|
95
|
+
await atomicWriteText(file, content);
|
|
96
|
+
return safe;
|
|
97
|
+
}
|
|
98
|
+
/** 场景导航索引(召回注入用)。 */
|
|
99
|
+
async navigation() {
|
|
100
|
+
const scenes = await this.list();
|
|
101
|
+
if (scenes.length === 0)
|
|
102
|
+
return '';
|
|
103
|
+
const lines = [
|
|
104
|
+
NAV_HEADER,
|
|
105
|
+
'*以下是当前场景记忆索引,可使用 memory_read_scene 读取详细内容。*',
|
|
106
|
+
'',
|
|
107
|
+
];
|
|
108
|
+
for (const s of scenes) {
|
|
109
|
+
lines.push(`- \`${s.path}\` — ${s.summary || '(无摘要)'}`);
|
|
110
|
+
}
|
|
111
|
+
return lines.join('\n');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function parseMeta(content, name) {
|
|
115
|
+
const s = { path: name, created: '', updated: '', summary: '', heat: 0 };
|
|
116
|
+
const start = content.indexOf(META_START);
|
|
117
|
+
const end = content.indexOf(META_END);
|
|
118
|
+
if (start !== -1 && end !== -1) {
|
|
119
|
+
const meta = content.slice(start + META_START.length, end);
|
|
120
|
+
for (const line of meta.split('\n')) {
|
|
121
|
+
const m = /^\s*([a-zA-Z_]+)\s*:\s*(.*)$/.exec(line);
|
|
122
|
+
if (!m)
|
|
123
|
+
continue;
|
|
124
|
+
const key = m[1].toLowerCase();
|
|
125
|
+
const value = m[2].trim();
|
|
126
|
+
if (key === 'created')
|
|
127
|
+
s.created = value;
|
|
128
|
+
else if (key === 'updated')
|
|
129
|
+
s.updated = value;
|
|
130
|
+
else if (key === 'summary')
|
|
131
|
+
s.summary = value;
|
|
132
|
+
else if (key === 'heat')
|
|
133
|
+
s.heat = Number.parseInt(value, 10) || 0;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return s;
|
|
137
|
+
}
|
|
138
|
+
/** Windows 保留设备名(CON.md 等带扩展形态同样命中设备语义,须整体避开)。 */
|
|
139
|
+
const RESERVED_NAME_RE = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
|
|
140
|
+
/** 文件名归一化:只允许字母数字 CJK - _ .,以 .md 结尾,去空格/标点。
|
|
141
|
+
* 超长名截断到 120 字符(ENAMETOOLONG 防御);Windows 保留设备名加前缀 _ 避让。 */
|
|
142
|
+
export function sanitizeFilename(name) {
|
|
143
|
+
let n = name.trim();
|
|
144
|
+
if (!n.toLowerCase().endsWith('.md'))
|
|
145
|
+
n = `${n}.md`;
|
|
146
|
+
n = n
|
|
147
|
+
.replace(/[^\w\u3400-\u9fff\uf900-\ufaff.\-_]/g, '-')
|
|
148
|
+
.replace(/-{2,}/g, '-')
|
|
149
|
+
.replace(/-+\.md$/i, '.md')
|
|
150
|
+
.replace(/^-+|-+$/g, '');
|
|
151
|
+
let stem = n.slice(0, -3);
|
|
152
|
+
if (stem.length > 120)
|
|
153
|
+
stem = stem.slice(0, 120).replace(/[-._]+$/, '');
|
|
154
|
+
if (RESERVED_NAME_RE.test(stem))
|
|
155
|
+
stem = `_${stem}`;
|
|
156
|
+
n = `${stem}.md`;
|
|
157
|
+
if (!n || !/^[\w\u3400-\u9fff\uf900-\ufaff.\-_]+\.md$/i.test(n))
|
|
158
|
+
return '';
|
|
159
|
+
return n;
|
|
160
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** 标准 RRF 常数(原论文值);k 越大越偏向低排名项(分布更平滑)。 */
|
|
2
|
+
export declare const RRF_K = 60;
|
|
3
|
+
/** 衰减地板(#29 时效加权的安全边界):老记忆最多损失一半排序分,永不沉底。
|
|
4
|
+
* 内部常量不进配置——它是安全机制不是调参旋钮。 */
|
|
5
|
+
export declare const DECAY_FLOOR = 0.5;
|
|
6
|
+
/**
|
|
7
|
+
* 时效衰减加权(#29,读路径专用):score × max(FLOOR, 0.5^(Δ天/半衰期)) 后重排序。
|
|
8
|
+
*
|
|
9
|
+
* - Δ 按 updated_at(内容版本时间)起算,缺失/非法按最老 → 地板接管(零特判分支);
|
|
10
|
+
* - 乘法保相关性主导:只在相关度相近的候选之间轮转名次,不淘汰不硬过滤;
|
|
11
|
+
* hit 的原 score 字段不被改写(排序用加权分,展示仍反映检索相关度);
|
|
12
|
+
* - halfLifeDays ≤ 0 直接原样返回(开关关闭);
|
|
13
|
+
* - 仅用于召回/工具检索;searchCandidates(去重候选)不得应用——写路径找同语义
|
|
14
|
+
* 旧记录要无视新旧,衰减会让去重漏检(同事实双记录)。
|
|
15
|
+
*/
|
|
16
|
+
export declare function applyDecayWeight<T extends {
|
|
17
|
+
score: number;
|
|
18
|
+
}>(hits: T[], halfLifeDays: number, updatedAtOf: (hit: T) => number | undefined, now?: number): T[];
|
|
19
|
+
/**
|
|
20
|
+
* RRF 融合多个已排序列表:每项得分 = 各列表 1/(k + rank + 1) 之和。
|
|
21
|
+
* 出现在多个列表的项得分累加,按得分降序返回(附 rrfScore)。
|
|
22
|
+
*/
|
|
23
|
+
export declare function rrfMerge<T>(lists: T[][], getId: (item: T) => string, k?: number): Array<T & {
|
|
24
|
+
rrfScore: number;
|
|
25
|
+
}>;
|
|
26
|
+
/** FTS5 bm25 rank(负值=更相关)转 0~1 分数。 */
|
|
27
|
+
export declare function bm25RankToScore(rank: number): number;
|
|
28
|
+
/**
|
|
29
|
+
* 把自然语言查询构造成 FTS5 MATCH 表达式:token 引号化后 OR 连接,
|
|
30
|
+
* 命中任一 token 即返回,BM25 自然把命中多 token 的文档排前——
|
|
31
|
+
* 长查询与纯 FTS 模式(无向量)下召回率显著优于整句匹配。
|
|
32
|
+
*/
|
|
33
|
+
export declare function buildFtsQuery(raw: string): string | null;
|
|
34
|
+
/**
|
|
35
|
+
* 写入侧分词:tokenize 后空格连接,交给 FTS5 unicode61 切词建索引。
|
|
36
|
+
* 与 buildFtsQuery 用同一分词器,保证查询 token 在索引中可命中。
|
|
37
|
+
*/
|
|
38
|
+
export declare function tokenizeForFts(raw: string): string;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 检索工具:
|
|
3
|
+
* - rrfMerge:RRF(Reciprocal Rank Fusion,k=60)多路结果融合,hybrid 检索用;
|
|
4
|
+
* - bm25RankToScore:FTS5 bm25 rank(负值=更相关)转 0~1 分数;
|
|
5
|
+
* - applyDecayWeight:#29 时效衰减加权(读路径专用);
|
|
6
|
+
* - buildFtsQuery / tokenizeForFts:FTS5 查询构造与写入侧分词。
|
|
7
|
+
* 分词走 util/text.ts 的 tokenize,读写两侧共用同一分词器,保证查询 token
|
|
8
|
+
* 与索引 token 对齐;FTS 索引按分词器版本戳自动重建(sqlite.ts)。
|
|
9
|
+
*/
|
|
10
|
+
import { tokenize } from '../util/text.js';
|
|
11
|
+
/** 标准 RRF 常数(原论文值);k 越大越偏向低排名项(分布更平滑)。 */
|
|
12
|
+
export const RRF_K = 60;
|
|
13
|
+
/** 衰减地板(#29 时效加权的安全边界):老记忆最多损失一半排序分,永不沉底。
|
|
14
|
+
* 内部常量不进配置——它是安全机制不是调参旋钮。 */
|
|
15
|
+
export const DECAY_FLOOR = 0.5;
|
|
16
|
+
/**
|
|
17
|
+
* 时效衰减加权(#29,读路径专用):score × max(FLOOR, 0.5^(Δ天/半衰期)) 后重排序。
|
|
18
|
+
*
|
|
19
|
+
* - Δ 按 updated_at(内容版本时间)起算,缺失/非法按最老 → 地板接管(零特判分支);
|
|
20
|
+
* - 乘法保相关性主导:只在相关度相近的候选之间轮转名次,不淘汰不硬过滤;
|
|
21
|
+
* hit 的原 score 字段不被改写(排序用加权分,展示仍反映检索相关度);
|
|
22
|
+
* - halfLifeDays ≤ 0 直接原样返回(开关关闭);
|
|
23
|
+
* - 仅用于召回/工具检索;searchCandidates(去重候选)不得应用——写路径找同语义
|
|
24
|
+
* 旧记录要无视新旧,衰减会让去重漏检(同事实双记录)。
|
|
25
|
+
*/
|
|
26
|
+
export function applyDecayWeight(hits, halfLifeDays, updatedAtOf, now = Date.now()) {
|
|
27
|
+
if (!(halfLifeDays > 0) || hits.length === 0)
|
|
28
|
+
return hits;
|
|
29
|
+
const weight = (h) => {
|
|
30
|
+
const t = updatedAtOf(h);
|
|
31
|
+
if (t == null || !Number.isFinite(t))
|
|
32
|
+
return DECAY_FLOOR;
|
|
33
|
+
const days = Math.max(0, (now - t) / 86_400_000);
|
|
34
|
+
return Math.max(DECAY_FLOOR, 0.5 ** (days / halfLifeDays));
|
|
35
|
+
};
|
|
36
|
+
return hits
|
|
37
|
+
.map((h) => ({ h, weighted: h.score * weight(h) }))
|
|
38
|
+
.sort((a, b) => b.weighted - a.weighted)
|
|
39
|
+
.map((x) => x.h);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* RRF 融合多个已排序列表:每项得分 = 各列表 1/(k + rank + 1) 之和。
|
|
43
|
+
* 出现在多个列表的项得分累加,按得分降序返回(附 rrfScore)。
|
|
44
|
+
*/
|
|
45
|
+
export function rrfMerge(lists, getId, k = RRF_K) {
|
|
46
|
+
const map = new Map();
|
|
47
|
+
for (const list of lists) {
|
|
48
|
+
for (let rank = 0; rank < list.length; rank++) {
|
|
49
|
+
const item = list[rank];
|
|
50
|
+
const id = getId(item);
|
|
51
|
+
const score = 1 / (k + rank + 1);
|
|
52
|
+
const existing = map.get(id);
|
|
53
|
+
if (existing) {
|
|
54
|
+
existing.rrfScore += score;
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
map.set(id, { item, rrfScore: score });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return [...map.values()]
|
|
62
|
+
.sort((a, b) => b.rrfScore - a.rrfScore)
|
|
63
|
+
.map(({ item, rrfScore }) => ({ ...item, rrfScore }));
|
|
64
|
+
}
|
|
65
|
+
/** FTS5 bm25 rank(负值=更相关)转 0~1 分数。 */
|
|
66
|
+
export function bm25RankToScore(rank) {
|
|
67
|
+
if (!Number.isFinite(rank))
|
|
68
|
+
return 1 / (1 + 999);
|
|
69
|
+
if (rank < 0) {
|
|
70
|
+
const relevance = -rank;
|
|
71
|
+
return relevance / (1 + relevance);
|
|
72
|
+
}
|
|
73
|
+
return 1 / (1 + rank);
|
|
74
|
+
}
|
|
75
|
+
/** 高频中文虚词,进 FTS 查询只添噪声。 */
|
|
76
|
+
const ZH_STOP_WORDS = new Set([
|
|
77
|
+
'的', '了', '在', '是', '我', '有', '和', '就', '不', '人', '都', '一',
|
|
78
|
+
'一个', '上', '也', '很', '到', '说', '要', '去', '你', '会', '着',
|
|
79
|
+
'没有', '看', '好', '自己', '这', '他', '她', '它', '们', '那',
|
|
80
|
+
'吗', '吧', '呢', '啊', '呀', '哦', '嗯',
|
|
81
|
+
]);
|
|
82
|
+
/**
|
|
83
|
+
* 把自然语言查询构造成 FTS5 MATCH 表达式:token 引号化后 OR 连接,
|
|
84
|
+
* 命中任一 token 即返回,BM25 自然把命中多 token 的文档排前——
|
|
85
|
+
* 长查询与纯 FTS 模式(无向量)下召回率显著优于整句匹配。
|
|
86
|
+
*/
|
|
87
|
+
export function buildFtsQuery(raw) {
|
|
88
|
+
const tokens = [...new Set(tokenize(raw).filter((t) => !ZH_STOP_WORDS.has(t)))];
|
|
89
|
+
if (tokens.length === 0)
|
|
90
|
+
return null;
|
|
91
|
+
const quoted = tokens.map((t) => `"${t.replaceAll('"', '')}"`);
|
|
92
|
+
return quoted.join(' OR ');
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* 写入侧分词:tokenize 后空格连接,交给 FTS5 unicode61 切词建索引。
|
|
96
|
+
* 与 buildFtsQuery 用同一分词器,保证查询 token 在索引中可命中。
|
|
97
|
+
*/
|
|
98
|
+
export function tokenizeForFts(raw) {
|
|
99
|
+
return tokenize(raw).join(' ');
|
|
100
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { MemoryLogger, MemoryMode } from '../types.js';
|
|
2
|
+
export declare function isMemoryMode(v: unknown): v is MemoryMode;
|
|
3
|
+
export declare class SessionModeStore {
|
|
4
|
+
private readonly defaultMode;
|
|
5
|
+
private readonly logger?;
|
|
6
|
+
private readonly file;
|
|
7
|
+
private readonly entries;
|
|
8
|
+
private readonly loaded;
|
|
9
|
+
private persistFailed;
|
|
10
|
+
/** 档位切换回调(index.ts 装配 runner 的同步动作:切片落袋/挂起,ADR-0003)。 */
|
|
11
|
+
private onModeChange?;
|
|
12
|
+
/** 串行化持久化写(避免并发原子写撞临时文件名)。 */
|
|
13
|
+
private writeChain;
|
|
14
|
+
constructor(dataDir: string, defaultMode: Extract<MemoryMode, 'auto' | 'chat' | 'work'>, logger?: MemoryLogger | undefined);
|
|
15
|
+
/** 载入持久化映射(index.ts 启动时 await;失败降级内存态)。 */
|
|
16
|
+
init(): Promise<void>;
|
|
17
|
+
get default(): MemoryMode;
|
|
18
|
+
/** 同步读取:未设置过的会话返回默认档。 */
|
|
19
|
+
get(sessionId: string): MemoryMode;
|
|
20
|
+
/** 会话级注入覆盖原始值:undefined = 未覆盖,跟随全局。 */
|
|
21
|
+
getRecall(sessionId: string): boolean | undefined;
|
|
22
|
+
/** 解析后的注入开关:会话覆盖 ?? 全局运行时开关(部署级 cfg.recall.enabled
|
|
23
|
+
* 与主闸 s.enabled 不经此处,仍按既有硬门生效——覆盖打不穿部署上限)。 */
|
|
24
|
+
resolvedRecall(sessionId: string, globalRecall: boolean): boolean;
|
|
25
|
+
/** 设置会话级注入覆盖(undefined = 清除覆盖跟随全局。写穿持久化)。 */
|
|
26
|
+
setRecall(sessionId: string, recall: boolean | undefined): void;
|
|
27
|
+
/** 注册档位切换回调(同步调用;回调异常只记日志不阻断写穿)。 */
|
|
28
|
+
setModeChangeHandler(cb: (sessionId: string, oldMode: MemoryMode, newMode: MemoryMode) => void): void;
|
|
29
|
+
/** 设置会话档位(写穿持久化;持久化失败保持内存态生效)。 */
|
|
30
|
+
set(sessionId: string, mode: MemoryMode): void;
|
|
31
|
+
/** 等待在途持久化写完成(测试/停机用)。 */
|
|
32
|
+
flush(): Promise<void>;
|
|
33
|
+
private persist;
|
|
34
|
+
private serialize;
|
|
35
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话记忆档位存储:sessionId → MemoryMode 的持久化映射。
|
|
3
|
+
* 热路径(捕获 turn/end、召回 pre-step、工具 execute)需要同步读取,
|
|
4
|
+
* 因此 init() 一次性载入内存 Map,set() 写穿。
|
|
5
|
+
* 存储失败只降级为内存态(warn 不崩),与插件的存储降级不变量一致。
|
|
6
|
+
*/
|
|
7
|
+
import * as path from 'node:path';
|
|
8
|
+
import { errDetail } from '../util/filelog.js';
|
|
9
|
+
import { atomicWriteJson, ensureDir, readJsonIfExists } from '../util/io.js';
|
|
10
|
+
const MODES = ['auto', 'chat', 'work', 'off'];
|
|
11
|
+
const PRUNE_MS = 90 * 24 * 3600_000;
|
|
12
|
+
const MAX_ENTRIES = 500;
|
|
13
|
+
export function isMemoryMode(v) {
|
|
14
|
+
return typeof v === 'string' && MODES.includes(v);
|
|
15
|
+
}
|
|
16
|
+
export class SessionModeStore {
|
|
17
|
+
defaultMode;
|
|
18
|
+
logger;
|
|
19
|
+
file;
|
|
20
|
+
entries = new Map();
|
|
21
|
+
loaded;
|
|
22
|
+
persistFailed = false;
|
|
23
|
+
/** 档位切换回调(index.ts 装配 runner 的同步动作:切片落袋/挂起,ADR-0003)。 */
|
|
24
|
+
onModeChange;
|
|
25
|
+
/** 串行化持久化写(避免并发原子写撞临时文件名)。 */
|
|
26
|
+
writeChain = Promise.resolve();
|
|
27
|
+
constructor(dataDir, defaultMode, logger) {
|
|
28
|
+
this.defaultMode = defaultMode;
|
|
29
|
+
this.logger = logger;
|
|
30
|
+
this.file = path.join(dataDir, 'session-modes.json');
|
|
31
|
+
this.loaded = defaultMode;
|
|
32
|
+
}
|
|
33
|
+
/** 载入持久化映射(index.ts 启动时 await;失败降级内存态)。 */
|
|
34
|
+
async init() {
|
|
35
|
+
const data = await readJsonIfExists(this.file);
|
|
36
|
+
if (!data?.sessions || typeof data.sessions !== 'object')
|
|
37
|
+
return;
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
let count = 0;
|
|
40
|
+
for (const [sid, entry] of Object.entries(data.sessions)) {
|
|
41
|
+
if (!isMemoryMode(entry?.mode))
|
|
42
|
+
continue;
|
|
43
|
+
if (now - (entry.updatedAt ?? 0) > PRUNE_MS)
|
|
44
|
+
continue;
|
|
45
|
+
this.entries.set(sid, {
|
|
46
|
+
mode: entry.mode,
|
|
47
|
+
// 非布尔视为损坏丢弃(= 跟随全局);旧文件无此键同款兼容
|
|
48
|
+
recall: typeof entry.recall === 'boolean' ? entry.recall : undefined,
|
|
49
|
+
updatedAt: entry.updatedAt ?? now,
|
|
50
|
+
});
|
|
51
|
+
count++;
|
|
52
|
+
}
|
|
53
|
+
if (count > 0)
|
|
54
|
+
this.logger?.info(`[memory] 会话档位载入 ${count} 条(默认档=${this.defaultMode})`);
|
|
55
|
+
}
|
|
56
|
+
get default() {
|
|
57
|
+
return this.loaded;
|
|
58
|
+
}
|
|
59
|
+
/** 同步读取:未设置过的会话返回默认档。 */
|
|
60
|
+
get(sessionId) {
|
|
61
|
+
return this.entries.get(sessionId)?.mode ?? this.loaded;
|
|
62
|
+
}
|
|
63
|
+
/** 会话级注入覆盖原始值:undefined = 未覆盖,跟随全局。 */
|
|
64
|
+
getRecall(sessionId) {
|
|
65
|
+
return this.entries.get(sessionId)?.recall;
|
|
66
|
+
}
|
|
67
|
+
/** 解析后的注入开关:会话覆盖 ?? 全局运行时开关(部署级 cfg.recall.enabled
|
|
68
|
+
* 与主闸 s.enabled 不经此处,仍按既有硬门生效——覆盖打不穿部署上限)。 */
|
|
69
|
+
resolvedRecall(sessionId, globalRecall) {
|
|
70
|
+
return this.entries.get(sessionId)?.recall ?? globalRecall;
|
|
71
|
+
}
|
|
72
|
+
/** 设置会话级注入覆盖(undefined = 清除覆盖跟随全局。写穿持久化)。 */
|
|
73
|
+
setRecall(sessionId, recall) {
|
|
74
|
+
const entry = this.entries.get(sessionId);
|
|
75
|
+
this.entries.set(sessionId, {
|
|
76
|
+
mode: entry?.mode ?? this.loaded,
|
|
77
|
+
recall,
|
|
78
|
+
updatedAt: Date.now(),
|
|
79
|
+
});
|
|
80
|
+
this.writeChain = this.writeChain.then(() => this.persist());
|
|
81
|
+
}
|
|
82
|
+
/** 注册档位切换回调(同步调用;回调异常只记日志不阻断写穿)。 */
|
|
83
|
+
setModeChangeHandler(cb) {
|
|
84
|
+
this.onModeChange = cb;
|
|
85
|
+
}
|
|
86
|
+
/** 设置会话档位(写穿持久化;持久化失败保持内存态生效)。 */
|
|
87
|
+
set(sessionId, mode) {
|
|
88
|
+
const old = this.get(sessionId);
|
|
89
|
+
// 已有注入覆盖跨切档保留(档位与注入正交,切档不动覆盖)
|
|
90
|
+
const recall = this.entries.get(sessionId)?.recall;
|
|
91
|
+
this.entries.set(sessionId, { mode, recall, updatedAt: Date.now() });
|
|
92
|
+
this.writeChain = this.writeChain.then(() => this.persist());
|
|
93
|
+
if (old !== mode && this.onModeChange) {
|
|
94
|
+
try {
|
|
95
|
+
this.onModeChange(sessionId, old, mode);
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
this.logger?.warn(`[memory] 档位切换回调失败: ${errDetail(err)}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** 等待在途持久化写完成(测试/停机用)。 */
|
|
103
|
+
flush() {
|
|
104
|
+
return this.writeChain;
|
|
105
|
+
}
|
|
106
|
+
async persist() {
|
|
107
|
+
try {
|
|
108
|
+
await ensureDir(path.dirname(this.file));
|
|
109
|
+
await atomicWriteJson(this.file, this.serialize());
|
|
110
|
+
this.persistFailed = false;
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
if (!this.persistFailed) {
|
|
114
|
+
this.persistFailed = true;
|
|
115
|
+
this.logger?.warn(`[memory] 会话档位持久化失败(降级内存态): ${errDetail(err)}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
serialize() {
|
|
120
|
+
const now = Date.now();
|
|
121
|
+
// 超期清理 + 条数上限(按 updatedAt 淘汰最旧)
|
|
122
|
+
for (const [sid, e] of this.entries) {
|
|
123
|
+
if (now - e.updatedAt > PRUNE_MS)
|
|
124
|
+
this.entries.delete(sid);
|
|
125
|
+
}
|
|
126
|
+
while (this.entries.size > MAX_ENTRIES) {
|
|
127
|
+
let oldest;
|
|
128
|
+
let oldestAt = Infinity;
|
|
129
|
+
for (const [sid, e] of this.entries) {
|
|
130
|
+
if (e.updatedAt < oldestAt) {
|
|
131
|
+
oldest = sid;
|
|
132
|
+
oldestAt = e.updatedAt;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (oldest === undefined)
|
|
136
|
+
break;
|
|
137
|
+
this.entries.delete(oldest);
|
|
138
|
+
}
|
|
139
|
+
const sessions = {};
|
|
140
|
+
for (const [sid, e] of this.entries)
|
|
141
|
+
sessions[sid] = e;
|
|
142
|
+
return { version: 1, sessions };
|
|
143
|
+
}
|
|
144
|
+
}
|