dsh-prime-memory 0.22.2 → 0.24.1
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 +421 -0
- package/CHANGELOG.ja.md +416 -0
- package/CHANGELOG.ko.md +416 -0
- package/CHANGELOG.md +120 -0
- package/dist/client.js +1266 -845
- package/dist/config.d.ts +131 -1341
- package/dist/config.js +89 -4
- package/dist/conflict-service.d.ts +7 -1
- package/dist/conflict-service.js +7 -1
- package/dist/contract.d.ts +211 -0
- package/dist/domain-gate.d.ts +11 -0
- package/dist/domain-gate.js +15 -0
- package/dist/hooks/obsolete-notice.d.ts +26 -0
- package/dist/hooks/obsolete-notice.js +64 -0
- package/dist/hooks/recall.d.ts +39 -0
- package/dist/hooks/recall.js +110 -30
- package/dist/index.d.ts +1 -1127
- package/dist/index.js +52 -2
- package/dist/memory-types.d.ts +69 -0
- package/dist/memory-types.js +165 -0
- package/dist/memport/format.d.ts +6 -1
- package/dist/memport/format.js +42 -2
- package/dist/pipeline/l1.d.ts +44 -2
- package/dist/pipeline/l1.js +163 -9
- package/dist/pipeline/md-sync.d.ts +65 -0
- package/dist/pipeline/md-sync.js +456 -0
- package/dist/pipeline/ruminate.d.ts +46 -1
- package/dist/pipeline/ruminate.js +109 -0
- package/dist/pipeline/runner.d.ts +10 -0
- package/dist/pipeline/runner.js +13 -2
- package/dist/pipeline/workspace-relink.d.ts +60 -0
- package/dist/pipeline/workspace-relink.js +73 -0
- package/dist/projection/recall.d.ts +51 -13
- package/dist/projection/recall.js +47 -12
- package/dist/prompts/examples.d.ts +73 -0
- package/dist/prompts/examples.js +178 -0
- package/dist/prompts/l1-dedup.d.ts +9 -3
- package/dist/prompts/l1-dedup.js +34 -13
- package/dist/prompts/l1-extraction.d.ts +5 -3
- package/dist/prompts/l1-extraction.js +49 -4
- package/dist/settings.d.ts +8 -0
- package/dist/settings.js +16 -1
- package/dist/stats.d.ts +12 -0
- package/dist/stats.js +352 -17
- package/dist/store/embedding-source.d.ts +8 -0
- package/dist/store/embedding-source.js +39 -2
- package/dist/store/file-versions.d.ts +2 -0
- package/dist/store/file-versions.js +2 -0
- package/dist/store/graph-store.d.ts +14 -0
- package/dist/store/graph-store.js +35 -0
- package/dist/store/l1-snapshot.d.ts +26 -1
- package/dist/store/l1-snapshot.js +53 -8
- package/dist/store/l1.d.ts +94 -3
- package/dist/store/l1.js +169 -4
- package/dist/store/memory-md-archive.d.ts +84 -0
- package/dist/store/memory-md-archive.js +155 -0
- package/dist/store/memory-md-disable.d.ts +74 -0
- package/dist/store/memory-md-disable.js +302 -0
- package/dist/store/memory-md-export.d.ts +50 -0
- package/dist/store/memory-md-export.js +227 -0
- package/dist/store/memory-md-ledger.d.ts +94 -0
- package/dist/store/memory-md-ledger.js +243 -0
- package/dist/store/memory-md-orphan.d.ts +27 -0
- package/dist/store/memory-md-orphan.js +153 -0
- package/dist/store/memory-md-parse.d.ts +35 -0
- package/dist/store/memory-md-parse.js +85 -0
- package/dist/store/memory-md-run.d.ts +48 -0
- package/dist/store/memory-md-run.js +151 -0
- package/dist/store/memory-md-skeleton.d.ts +18 -0
- package/dist/store/memory-md-skeleton.js +61 -0
- package/dist/store/memory-md-sync.d.ts +59 -0
- package/dist/store/memory-md-sync.js +152 -0
- package/dist/store/memory-md-types.d.ts +243 -0
- package/dist/store/memory-md-types.js +10 -0
- package/dist/store/memory-md.d.ts +117 -0
- package/dist/store/memory-md.js +230 -0
- package/dist/store/obsolete-notices.d.ts +39 -0
- package/dist/store/obsolete-notices.js +153 -0
- package/dist/store/recall-feedback.d.ts +75 -0
- package/dist/store/recall-feedback.js +213 -0
- package/dist/store/recall-usage.d.ts +11 -0
- package/dist/store/recall-usage.js +29 -0
- package/dist/store/session-recalled.d.ts +38 -3
- package/dist/store/session-recalled.js +156 -9
- package/dist/store/sqlite.d.ts +66 -0
- package/dist/store/sqlite.js +233 -15
- package/dist/store/supersede.d.ts +1 -1
- package/dist/store/supersede.js +1 -1
- package/dist/tools/index.d.ts +4 -0
- package/dist/tools/index.js +26 -7
- package/dist/tools/slots.d.ts +2 -1
- package/dist/tools/slots.js +2 -1
- package/dist/types.d.ts +24 -7
- package/dist/types.js +10 -2
- package/dist/util/maturity.d.ts +20 -3
- package/dist/util/maturity.js +18 -1
- package/dist/util/md-name.d.ts +129 -0
- package/dist/util/md-name.js +366 -0
- package/dist/util/md-normalize.d.ts +23 -0
- package/dist/util/md-normalize.js +131 -0
- package/dist/workspace-route.d.ts +22 -0
- package/dist/workspace-route.js +66 -0
- package/dsh.plugin.json +1 -1
- package/package.json +6 -5
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L1 记录 → markdown 投射(P1 / N1)。
|
|
3
|
+
*
|
|
4
|
+
* 三条设计约束,都是被"文件要能双向"逼出来的:
|
|
5
|
+
*
|
|
6
|
+
* 1. **渲染必须是纯函数**:同一个记录永远渲染出同一份字节。否则每次导出都会重写
|
|
7
|
+
* 每个文件,账本里的 `fileDigest` 每轮都变,"文件被人改过"就永远判不出来。
|
|
8
|
+
* 所以文件里**不写导出时刻**、不写任何非记录字段。
|
|
9
|
+
* 2. **只写内容层,不写检索轴**(N6 口径):`hall`/`tags`/`room*`/`cogHall` 留在库里
|
|
10
|
+
* ——写进文件就是一份会过期的副本,还会诱导人改它(而改了又不生效)。派生评价
|
|
11
|
+
* (`maturity`/`usage`/`rating`)同理:它们是算出来的,不是事实。
|
|
12
|
+
* 3. **文件名走 `sanitizeFilename` 那一套**(与 `util/md-name.ts` 同源),type 目录名
|
|
13
|
+
* 走**枚举白名单**(不 sanitize——不在白名单的 type 直接归 `unsorted`,而不是
|
|
14
|
+
* 洗出一个没人认识的新目录)。
|
|
15
|
+
*/
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
17
|
+
import { existsSync } from 'node:fs';
|
|
18
|
+
import { isAbsolute, normalize, resolve, sep } from 'node:path';
|
|
19
|
+
import { normText } from '../memport/format.js';
|
|
20
|
+
import { memoryTypeDirs, UNCLASSIFIED_DIR } from '../memory-types.js';
|
|
21
|
+
import { deriveName } from '../util/md-name.js';
|
|
22
|
+
import { normalizeContentMd } from '../util/md-normalize.js';
|
|
23
|
+
import { sanitizeFilename } from './scenes.js';
|
|
24
|
+
/**
|
|
25
|
+
* 允许当目录名的 type 值(pm-name-distill task_1.3:由词表单一真源派生,不再手抄)。
|
|
26
|
+
*
|
|
27
|
+
* 手抄的危害很具体:换词表不改这一处,新词表里的十余类会**静默落进兜底桶**——
|
|
28
|
+
* 不报错、不同步失败,只少目录树。
|
|
29
|
+
*/
|
|
30
|
+
export const MEMORY_TYPE_DIRS = memoryTypeDirs();
|
|
31
|
+
/**
|
|
32
|
+
* 不在词表的 type 落到这个目录(P4.1 的**默认类**)。
|
|
33
|
+
*
|
|
34
|
+
* 它是**目录层的投影**,不是库内事实:记录自己的 `type` 原值照旧写进 frontmatter,
|
|
35
|
+
* 也绝不回填到 `rowToRecord`/upsert/metadata(F5 红线)。之所以不直接归进某个 `work_*`:
|
|
36
|
+
* 那会把这批无类记录的 `family` 挑到 work 侧,而 family 决定共享面——等于静默改了可见性。
|
|
37
|
+
*/
|
|
38
|
+
export const UNSORTED_DIR = UNCLASSIFIED_DIR;
|
|
39
|
+
/** frontmatter 允许出现的键(用例断言键集**恰等于**它,防检索轴泄漏进文件)。 */
|
|
40
|
+
export const FRONTMATTER_KEYS = [
|
|
41
|
+
'name', 'type', 'priority', 'createdAt', 'updatedAt', 'usageScenario', 'extKey', 'id', 'hash', 'retired',
|
|
42
|
+
];
|
|
43
|
+
/** 内容指纹长度(12 hex ≈ 48 bit;账本只用来判"变没变",不当身份用)。 */
|
|
44
|
+
export const DIGEST_HEX = 12;
|
|
45
|
+
/** 失效后缀(N11):`x.memory.md` → `x.memory.md.disable`。用户定名,取代原设计的 `.outdated`。 */
|
|
46
|
+
export const DISABLE_SUFFIX = '.disable';
|
|
47
|
+
/** workspace 优先时的子目录(N10):`<workspace>/.agents/memory/…`。 */
|
|
48
|
+
export const WORKSPACE_SUBDIR = ['.agents', 'memory'];
|
|
49
|
+
/**
|
|
50
|
+
* 解析单根(无 workspace 归属的记录落这里)。
|
|
51
|
+
*
|
|
52
|
+
* 给了 `cfgRoot` 就用它(必须绝对路径且不含 `..`),否则默认 `<dataDir>/memories`。
|
|
53
|
+
* **不合法只回退默认并 warn,绝不抛错**:投射是旁路,旁路配置写错不能弄挂记忆本体。
|
|
54
|
+
*/
|
|
55
|
+
export function resolveMemoriesRoot(cfgRoot, dataDir, logger) {
|
|
56
|
+
const raw = (cfgRoot ?? '').trim();
|
|
57
|
+
if (raw.length === 0)
|
|
58
|
+
return resolve(dataDir, 'memories');
|
|
59
|
+
if (!isAbsolute(raw) || raw.includes('..')) {
|
|
60
|
+
logger?.warn(`[memory] memExport.root 不是无 .. 的绝对路径,回退默认根: ${raw}`);
|
|
61
|
+
return resolve(dataDir, 'memories');
|
|
62
|
+
}
|
|
63
|
+
return resolve(raw);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* 把渲染出的相对路径安全地拼到根下。
|
|
67
|
+
*
|
|
68
|
+
* 越界(绝对路径、`..` 跳出、盘符切换)一律返回 `undefined` 由调用方跳过并 warn——
|
|
69
|
+
* 记录内容是人/模型写的,它可能包含 `../../`,这里必须是结构性挡死而不是"应该不会有"。
|
|
70
|
+
*/
|
|
71
|
+
export function safeJoin(root, relPath) {
|
|
72
|
+
if (relPath.length === 0 || isAbsolute(relPath))
|
|
73
|
+
return undefined;
|
|
74
|
+
const parent = resolve(root);
|
|
75
|
+
const target = resolve(parent, relPath);
|
|
76
|
+
if (target === parent)
|
|
77
|
+
return undefined;
|
|
78
|
+
// 前缀判定必须带分隔符结尾,否则 `/a/memoriesX` 会被 `/a/memories` 误认包含。
|
|
79
|
+
const withSep = parent.endsWith('/') || parent.endsWith('\\') ? parent : `${parent}${sep}`;
|
|
80
|
+
return target.startsWith(withSep) ? target : undefined;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* 投射落点(N10):**workspace 优先**。
|
|
84
|
+
*
|
|
85
|
+
* 有 `workspaceId` ⇒ `<workspace>/.agents/memory/<type>/<stem>.memory.md`;
|
|
86
|
+
* 没有 ⇒ 单根(`cfg.memExport.root` 或 `<dataDir>/memories`)。
|
|
87
|
+
*
|
|
88
|
+
* ⚠️ **导出/对账/失效改名/归档四处必须共用本函数**。库里的 `workspace_id` 在 Windows 上是
|
|
89
|
+
* 小写形态(`normalizeWorkspacePath` 会 `.toLowerCase()`),任何一处自己拼路径、或者自己
|
|
90
|
+
* 转一下大小写,就会把“刚写进去的文件”算成另一个账本键 → 对账永远报“文件缺失”。
|
|
91
|
+
* 同理:**不**在这里做 `fs.realpath` 归一——那会让目录创建前后的键漂移,反而造出假缺失;
|
|
92
|
+
* Windows 路径大小写不敏感,同一逻辑路径的两种写法解析到同一个文件,键自洽即可。
|
|
93
|
+
*/
|
|
94
|
+
export function targetFor(r, cfgRoot, dataDir, logger) {
|
|
95
|
+
const { relPath } = renderRecord(r);
|
|
96
|
+
const ws = (r.workspaceId ?? '').trim();
|
|
97
|
+
if (ws.length > 0 && isAbsolute(ws) && !ws.includes('..')) {
|
|
98
|
+
// 护栏(默认开,不给开关):workspace 目录不存在时不往它下面写。worktree 被删、
|
|
99
|
+
// 盘符变了、或 `workspace_id` 是脏值时,写盘会凭空造出一棵
|
|
100
|
+
// `<已消失的项目>/.agents/memory/` 孤儿树——那比落回单根难查得多(单根里的文件
|
|
101
|
+
// 仍然可见、可导出、可同步;孤儿树没人知道它属于哪个项目)。
|
|
102
|
+
// 判据用 **workspace 根本身**而不是投射根:后者不存在是常态(首次导出会 mkdir)。
|
|
103
|
+
if (existsSync(ws)) {
|
|
104
|
+
const root = resolve(ws, ...WORKSPACE_SUBDIR);
|
|
105
|
+
return { root, relPath, absPath: safeJoin(root, relPath), inWorkspace: true };
|
|
106
|
+
}
|
|
107
|
+
warnMissingWorkspace(logger, ws);
|
|
108
|
+
}
|
|
109
|
+
const root = resolveMemoriesRoot(cfgRoot, dataDir, logger);
|
|
110
|
+
return { root, relPath, absPath: safeJoin(root, relPath), inWorkspace: false };
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* 已提醒过的缺失 workspace(同一个根只 warn 一次)。
|
|
114
|
+
*
|
|
115
|
+
* 不设上限就会在 1280 条记录上刷屏;集子封到 64 个就不再记录新条目(宁可漏提醒,
|
|
116
|
+
* 也不让一个诊断集无限吃内存)。放在 `targetFor` 之后:函数声明提升,`const` 集子在
|
|
117
|
+
* 模块求值后才被调用,所以顺序不影响可用性——影响的是文档块跟谁。
|
|
118
|
+
*/
|
|
119
|
+
const warnedMissingWs = new Set();
|
|
120
|
+
function warnMissingWorkspace(logger, ws) {
|
|
121
|
+
if (logger === undefined)
|
|
122
|
+
return;
|
|
123
|
+
if (warnedMissingWs.has(ws))
|
|
124
|
+
return;
|
|
125
|
+
if (warnedMissingWs.size < 64)
|
|
126
|
+
warnedMissingWs.add(ws);
|
|
127
|
+
logger.warn(`[memory] workspace 目录不存在,该条投射落点退回单根: ${ws}`);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* 退场判据:与库侧同口径(`sqlite.ts` 的“`valid_to` 非空即退场”,与
|
|
131
|
+
* `retiredL1Count`/`listRetiredL1` 一致)。
|
|
132
|
+
*
|
|
133
|
+
* 为什么住在本文件:导出(要不要把文件放回非退场路径)与失效联动(后缀该不该带)
|
|
134
|
+
* 两侧都得用同一个判据,否则“列表说它退了、文件却没后缀”会回来。放 `memory-md-disable.ts`
|
|
135
|
+
* 会让 `memory-md-export.ts` 反向 import 它形成循环(那边已经 import 这边)。
|
|
136
|
+
*/
|
|
137
|
+
export function isRetiredRecord(r) {
|
|
138
|
+
return typeof r.validTo === 'number' && r.validTo > 0;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* 投射文件的失效路径:`x.memory.md` → `x.memory.md.disable`。
|
|
142
|
+
* 已带后缀的原样返回(幂等靠这个性质,而不是靠调用方记住判重)。
|
|
143
|
+
*/
|
|
144
|
+
export function disablePathOf(absPath) {
|
|
145
|
+
return absPath.endsWith(DISABLE_SUFFIX) ? absPath : `${absPath}${DISABLE_SUFFIX}`;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* 内容指纹。
|
|
149
|
+
*
|
|
150
|
+
* 刻意**不复用** `memport/format.ts` 的 `contentHash`:那个口径被 TS↔Python 跨实现
|
|
151
|
+
* 金标准锁死(`tests/memport-ts-consistency`),改了就是让两边算出不同的包。这里只
|
|
152
|
+
* 借它的 `normText`(NFKC + 换行归一),口径独立、且明确标注为"投射账本专用"。
|
|
153
|
+
*/
|
|
154
|
+
export function digest(text) {
|
|
155
|
+
return createHash('sha1').update(normText(text), 'utf8').digest('hex').slice(0, DIGEST_HEX);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* 投射文件自带 `hash` 键的**规范口径**(导出写、读入核,两侧都必须走它)。
|
|
159
|
+
*
|
|
160
|
+
* 定义 = `digest(归一后的正文)`:与 `renderRecord` 写进 `hash` 的值、以及读侧拿解析出的
|
|
161
|
+
* `content` 实算的值三处同源。只覆盖正文、不含任何 frontmatter 字段——所以换 id、换文件名、
|
|
162
|
+
* 退场标记都不改变它,它只回答「正文有没有被改过」这一件事。
|
|
163
|
+
*
|
|
164
|
+
* 走 `digest`(内含 NFKC + 空白折叠)而非裸 sha1:CRLF/BOM/尾随空白这类搬运噪声不该触发
|
|
165
|
+
* 假不符(搬文件、换编辑器、git autocrlf 都会引入它们)。
|
|
166
|
+
*/
|
|
167
|
+
export function contentHash(content) {
|
|
168
|
+
return digest(normalizeContentMd(content));
|
|
169
|
+
}
|
|
170
|
+
/** 记录内容指纹:正文字段(不含 metadata——轴变了不算内容变)。 */
|
|
171
|
+
export function recordDigest(r) {
|
|
172
|
+
return digest(r.content);
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* 文件名干:有 `name` 用 `name`;没有则回落 `mem-<id 后 8 位>`。
|
|
176
|
+
*
|
|
177
|
+
* 回落是**必需**的:存量记录 name 恒空(零回填红线),不回落就没法投射第一批。
|
|
178
|
+
* id 每轮新铸,所以回落名不稳定——这正是 P0 要先做 `name` 的原因,注释留在这里
|
|
179
|
+
* 免得后来人以为"回落名也能双向"。
|
|
180
|
+
*/
|
|
181
|
+
export function fileStemFor(r) {
|
|
182
|
+
const named = (r.name ?? '').length > 0 ? r.name : deriveName(r.content);
|
|
183
|
+
const base = named.length > 0 ? named : `mem-${r.id.slice(-8)}`;
|
|
184
|
+
// 借 sanitizeFilename 的字符/保留名纪律,再剥掉它补的 .md(我们后面统一补)。
|
|
185
|
+
return sanitizeFilename(base).replace(/\.md$/i, '') || `mem-${r.id.slice(-8)}`;
|
|
186
|
+
}
|
|
187
|
+
/** 把值写成可解析回读的 frontmatter 标量/数组(JSON 编码,转义由它负责)。 */
|
|
188
|
+
function fmLine(key, value) {
|
|
189
|
+
return `${key}: ${JSON.stringify(value)}`;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* 渲染一条记录。
|
|
193
|
+
*
|
|
194
|
+
* @param r - L1 记录(含已退场的——退场也要有投影,否则人在文件树里看不到它)。
|
|
195
|
+
* @returns `relPath`(相对投射根,已归一分隔符)与 `text`(全文)。
|
|
196
|
+
*/
|
|
197
|
+
export function renderRecord(r) {
|
|
198
|
+
const dir = MEMORY_TYPE_DIRS.has(r.type) ? r.type : UNSORTED_DIR;
|
|
199
|
+
const stem = fileStemFor(r);
|
|
200
|
+
const relPath = normalize(`${dir}/${stem}.memory.md`).replace(/\\/g, '/');
|
|
201
|
+
const scenario = Array.isArray(r.metadata?.dsh_usage_scenario)
|
|
202
|
+
? (r.metadata?.dsh_usage_scenario).filter((s) => typeof s === 'string')
|
|
203
|
+
: [];
|
|
204
|
+
// 正文只归一一次:`hash` 键与正文行必须落在**同一份正文**上,否则读入侧的
|
|
205
|
+
// 「声明 vs 实算」核对会因两次归一是否完全一致而变得可疑(归一虽幂等,复用同一变量最稳)。
|
|
206
|
+
const body = normalizeContentMd(r.content);
|
|
207
|
+
const lines = ['---'];
|
|
208
|
+
lines.push(fmLine('name', stem));
|
|
209
|
+
lines.push(fmLine('type', r.type));
|
|
210
|
+
lines.push(fmLine('priority', r.priority));
|
|
211
|
+
lines.push(fmLine('createdAt', r.createdAt ? new Date(r.createdAt).toISOString() : ''));
|
|
212
|
+
lines.push(fmLine('updatedAt', r.updatedAt ? new Date(r.updatedAt).toISOString() : ''));
|
|
213
|
+
lines.push(fmLine('usageScenario', scenario));
|
|
214
|
+
lines.push(fmLine('extKey', r.extKey ?? ''));
|
|
215
|
+
lines.push(fmLine('id', r.id));
|
|
216
|
+
// 正文自校验指纹(N13):写在 frontmatter 里的 `hash`,只覆盖**渲染后的正文**(与下面那行
|
|
217
|
+
// 正文同源)。读入侧据此确认文件未被外部改动/损坏,且**不依赖账本**——文件搬到别的机器、
|
|
218
|
+
// 账本丢失时仍可自证。刻意不含 frontmatter:`id` 会随 merge 重铸(F1),把 id 算进去会让
|
|
219
|
+
// 「换了 id、正文没动」也误报被改。
|
|
220
|
+
lines.push(fmLine('hash', contentHash(r.content)));
|
|
221
|
+
// 退场标记只在真退场时写:不留 `retired: false` 这种噪声键(键集越稳,人越不容易改错)。
|
|
222
|
+
if (r.validTo !== undefined)
|
|
223
|
+
lines.push(fmLine('retired', true));
|
|
224
|
+
lines.push('---', '');
|
|
225
|
+
// 正文过一道版式归一(P4.2):无结构存量文本出口就是标准形状。归一是**幂等**的,
|
|
226
|
+
// 所以不会把账本 `fileDigest` 每轮推得变一下(不幂等 = 反向同步永远判“文件被改过”)。
|
|
227
|
+
lines.push(body);
|
|
228
|
+
lines.push('');
|
|
229
|
+
return { relPath, text: lines.join('\n') };
|
|
230
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { MemoryLogger } from '../types.js';
|
|
2
|
+
export declare const OBSOLETE_NOTICES_FILE_VERSION: 1;
|
|
3
|
+
/** 一条待播报的过时声明。 */
|
|
4
|
+
export interface ObsoleteNotice {
|
|
5
|
+
/** 被退场的 L1 记录 id。 */
|
|
6
|
+
id: string;
|
|
7
|
+
/** 正文首行(注入时供模型定位是哪条;截断至 TITLE_MAX)。 */
|
|
8
|
+
title: string;
|
|
9
|
+
/** 用户声明时刻(epoch ms)。 */
|
|
10
|
+
at: number;
|
|
11
|
+
}
|
|
12
|
+
export declare class ObsoleteNoticeStore {
|
|
13
|
+
private readonly logger?;
|
|
14
|
+
private readonly file;
|
|
15
|
+
private buckets;
|
|
16
|
+
private loaded;
|
|
17
|
+
private degraded;
|
|
18
|
+
private degradedLogged;
|
|
19
|
+
private writeChain;
|
|
20
|
+
private rev;
|
|
21
|
+
constructor(dataDir: string, logger?: MemoryLogger | undefined);
|
|
22
|
+
init(): Promise<void>;
|
|
23
|
+
private normalize;
|
|
24
|
+
private ensureLoaded;
|
|
25
|
+
/** 判脏序号(测试/观测用;注入侧靠 drain 的返回值判断有无)。 */
|
|
26
|
+
revision(): number;
|
|
27
|
+
/** 入队一条(幂等由调用方保证:同 id 重复入队只会多播一行,故这里去重)。 */
|
|
28
|
+
push(sessionKey: string, notice: ObsoleteNotice): void;
|
|
29
|
+
/** 出队并清空该会话的待播报(一次性语义:取走即离开 outbox)。 */
|
|
30
|
+
drain(sessionKey: string): ObsoleteNotice[];
|
|
31
|
+
/** 只看不取(测试/诊断)。 */
|
|
32
|
+
peek(sessionKey: string): ObsoleteNotice[];
|
|
33
|
+
size(): number;
|
|
34
|
+
private prune;
|
|
35
|
+
private schedulePersist;
|
|
36
|
+
private persist;
|
|
37
|
+
/** 等待在途写完成(测试/停机用)。 */
|
|
38
|
+
flush(): Promise<void>;
|
|
39
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「记忆已声明过时」待播报队列(outbox,`obsolete-notices.json`)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它:用户在召回卡上打 `-1` 之后,**记忆库那一侧当场就改了**(退场),
|
|
5
|
+
* 但模型此刻的上下文里那条记忆还在——它得被告知"这条已由用户判定过时、别再依据它
|
|
6
|
+
* 作答"。这个告知是一次性的:下一轮注入即出队,不常驻(常驻是槽位的语义,见
|
|
7
|
+
* `hooks/slot-recall.ts`)。
|
|
8
|
+
*
|
|
9
|
+
* 落盘而非纯内存:打 `-1` 到下一轮之间若进程重启,纯内存会**静默丢掉这一票**——
|
|
10
|
+
* 记忆已经退场了,而模型从没被告知。所以 outbox 必须持久化(与 session-recalled
|
|
11
|
+
* 同款纪律:写穿 + 版本 + 损坏只读降级,绝不抛进注入热路径)。
|
|
12
|
+
*
|
|
13
|
+
* 键 = sessionKey(`agent.session?.id ?? agent.id`),与活召回集、pill 的 sessionId
|
|
14
|
+
* 同源;三处不同键会直接导致"播报错会话",见 store/session-recalled.ts 头注。
|
|
15
|
+
*/
|
|
16
|
+
import * as path from 'node:path';
|
|
17
|
+
import { readJsonStrict, atomicWriteText, ensureDir } from '../util/io.js';
|
|
18
|
+
export const OBSOLETE_NOTICES_FILE_VERSION = 1;
|
|
19
|
+
/** 单会话待播报上限(超出丢最旧:模型只需知道"这些别再信",不需历史)。 */
|
|
20
|
+
const PER_SESSION_CAP = 8;
|
|
21
|
+
/** 会话数上限,按最近写入时间淘汰。 */
|
|
22
|
+
const SESSION_CAP = 200;
|
|
23
|
+
const TITLE_MAX = 120;
|
|
24
|
+
export class ObsoleteNoticeStore {
|
|
25
|
+
logger;
|
|
26
|
+
file;
|
|
27
|
+
buckets = new Map();
|
|
28
|
+
loaded = false;
|
|
29
|
+
degraded;
|
|
30
|
+
degradedLogged = false;
|
|
31
|
+
writeChain = Promise.resolve();
|
|
32
|
+
rev = 0;
|
|
33
|
+
constructor(dataDir, logger) {
|
|
34
|
+
this.logger = logger;
|
|
35
|
+
this.file = path.join(dataDir, 'obsolete-notices.json');
|
|
36
|
+
}
|
|
37
|
+
async init() {
|
|
38
|
+
await ensureDir(path.dirname(this.file));
|
|
39
|
+
const read = await readJsonStrict(this.file);
|
|
40
|
+
if (!read.ok) {
|
|
41
|
+
if (read.reason !== 'missing') {
|
|
42
|
+
this.degraded = `${read.reason}${'detail' in read && read.detail ? `: ${read.detail}` : ''}`;
|
|
43
|
+
this.logger?.warn(`[memory] 过时播报队列只读降级: ${this.degraded}`);
|
|
44
|
+
}
|
|
45
|
+
this.loaded = true;
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const raw = read.value.sessions ?? {};
|
|
49
|
+
for (const [sid, list] of Object.entries(raw)) {
|
|
50
|
+
const kept = this.normalize(list);
|
|
51
|
+
if (kept.length > 0)
|
|
52
|
+
this.buckets.set(sid, kept);
|
|
53
|
+
}
|
|
54
|
+
this.loaded = true;
|
|
55
|
+
}
|
|
56
|
+
normalize(list) {
|
|
57
|
+
if (!Array.isArray(list))
|
|
58
|
+
return [];
|
|
59
|
+
const out = [];
|
|
60
|
+
for (const raw of list) {
|
|
61
|
+
if (!raw || typeof raw !== 'object')
|
|
62
|
+
continue;
|
|
63
|
+
const e = raw;
|
|
64
|
+
if (typeof e.id !== 'string' || !e.id || typeof e.title !== 'string' || typeof e.at !== 'number')
|
|
65
|
+
continue;
|
|
66
|
+
out.push({ id: e.id, title: e.title, at: e.at });
|
|
67
|
+
if (out.length >= PER_SESSION_CAP)
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
ensureLoaded() {
|
|
73
|
+
if (!this.loaded)
|
|
74
|
+
throw new Error('过时播报队列未初始化(先 await init())');
|
|
75
|
+
}
|
|
76
|
+
/** 判脏序号(测试/观测用;注入侧靠 drain 的返回值判断有无)。 */
|
|
77
|
+
revision() {
|
|
78
|
+
return this.rev;
|
|
79
|
+
}
|
|
80
|
+
/** 入队一条(幂等由调用方保证:同 id 重复入队只会多播一行,故这里去重)。 */
|
|
81
|
+
push(sessionKey, notice) {
|
|
82
|
+
this.ensureLoaded();
|
|
83
|
+
if (!sessionKey)
|
|
84
|
+
return;
|
|
85
|
+
const list = this.buckets.get(sessionKey) ?? [];
|
|
86
|
+
if (list.some((n) => n.id === notice.id))
|
|
87
|
+
return;
|
|
88
|
+
const title = notice.title.length > TITLE_MAX ? `${notice.title.slice(0, TITLE_MAX)}…` : notice.title;
|
|
89
|
+
const next = [...list, { ...notice, title }];
|
|
90
|
+
this.buckets.set(sessionKey, next.slice(-PER_SESSION_CAP));
|
|
91
|
+
this.rev += 1;
|
|
92
|
+
this.prune();
|
|
93
|
+
this.schedulePersist();
|
|
94
|
+
}
|
|
95
|
+
/** 出队并清空该会话的待播报(一次性语义:取走即离开 outbox)。 */
|
|
96
|
+
drain(sessionKey) {
|
|
97
|
+
this.ensureLoaded();
|
|
98
|
+
const list = this.buckets.get(sessionKey);
|
|
99
|
+
if (list === undefined || list.length === 0)
|
|
100
|
+
return [];
|
|
101
|
+
this.buckets.delete(sessionKey);
|
|
102
|
+
this.rev += 1;
|
|
103
|
+
this.schedulePersist();
|
|
104
|
+
return list;
|
|
105
|
+
}
|
|
106
|
+
/** 只看不取(测试/诊断)。 */
|
|
107
|
+
peek(sessionKey) {
|
|
108
|
+
this.ensureLoaded();
|
|
109
|
+
return [...(this.buckets.get(sessionKey) ?? [])];
|
|
110
|
+
}
|
|
111
|
+
size() {
|
|
112
|
+
this.ensureLoaded();
|
|
113
|
+
let n = 0;
|
|
114
|
+
for (const list of this.buckets.values())
|
|
115
|
+
n += list.length;
|
|
116
|
+
return n;
|
|
117
|
+
}
|
|
118
|
+
prune() {
|
|
119
|
+
if (this.buckets.size <= SESSION_CAP)
|
|
120
|
+
return;
|
|
121
|
+
const newestAt = (list) => list.reduce((m, n) => Math.max(m, n.at), 0);
|
|
122
|
+
const byOldest = [...this.buckets.entries()].sort((a, b) => newestAt(a[1]) - newestAt(b[1]));
|
|
123
|
+
for (const [sid] of byOldest.slice(0, this.buckets.size - SESSION_CAP))
|
|
124
|
+
this.buckets.delete(sid);
|
|
125
|
+
}
|
|
126
|
+
schedulePersist() {
|
|
127
|
+
if (this.degraded)
|
|
128
|
+
return;
|
|
129
|
+
this.writeChain = this.writeChain.then(() => this.persist(), () => this.persist());
|
|
130
|
+
}
|
|
131
|
+
async persist() {
|
|
132
|
+
if (this.degraded)
|
|
133
|
+
return;
|
|
134
|
+
const body = {
|
|
135
|
+
version: OBSOLETE_NOTICES_FILE_VERSION,
|
|
136
|
+
sessions: Object.fromEntries(this.buckets),
|
|
137
|
+
};
|
|
138
|
+
try {
|
|
139
|
+
await atomicWriteText(this.file, JSON.stringify(body, null, 2));
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
this.degraded = err instanceof Error ? err.message : String(err);
|
|
143
|
+
if (!this.degradedLogged) {
|
|
144
|
+
this.degradedLogged = true;
|
|
145
|
+
this.logger?.warn(`[memory] 过时播报队列落盘失败,转只读降级: ${this.degraded}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/** 等待在途写完成(测试/停机用)。 */
|
|
150
|
+
flush() {
|
|
151
|
+
return this.writeChain;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { MemoryLogger } from '../types.js';
|
|
2
|
+
export declare const RECALL_FEEDBACK_FILE_VERSION: 1;
|
|
3
|
+
/** 合法打分:-1(声明过时)与 0..5(相关度)。 */
|
|
4
|
+
export declare const FEEDBACK_SCORES: readonly [-1, 0, 1, 2, 3, 4, 5];
|
|
5
|
+
export type FeedbackScore = (typeof FEEDBACK_SCORES)[number];
|
|
6
|
+
/** 单条记忆的反馈聚合。 */
|
|
7
|
+
export interface RecallFeedbackEntry {
|
|
8
|
+
/** 最近一次打分(-1..5)。 */
|
|
9
|
+
last: FeedbackScore;
|
|
10
|
+
/** 有效相关度评分次数(不含 -1)。 */
|
|
11
|
+
rated: number;
|
|
12
|
+
/** 0..5 评分均值(rated=0 时为 0)。 */
|
|
13
|
+
mean: number;
|
|
14
|
+
/** 被声明过时的次数(-1 计数)。 */
|
|
15
|
+
obsolete: number;
|
|
16
|
+
/** 最近一次打分时刻(epoch ms)。 */
|
|
17
|
+
updatedAt: number;
|
|
18
|
+
/** 最近若干条打分轨迹(旧→新,截断至 HISTORY_CAP)。 */
|
|
19
|
+
history: Array<{
|
|
20
|
+
s: FeedbackScore;
|
|
21
|
+
at: number;
|
|
22
|
+
}>;
|
|
23
|
+
}
|
|
24
|
+
/** 是否合法打分(供端点入参校验复用)。 */
|
|
25
|
+
export declare function isFeedbackScore(v: unknown): v is FeedbackScore;
|
|
26
|
+
export declare class RecallFeedbackStore {
|
|
27
|
+
private readonly logger?;
|
|
28
|
+
private readonly file;
|
|
29
|
+
private entries;
|
|
30
|
+
private loaded;
|
|
31
|
+
private degraded;
|
|
32
|
+
private degradedLogged;
|
|
33
|
+
private writeChain;
|
|
34
|
+
/** 判脏序号:每次有效写入 +1(投影 view 带 rating,靠它决定要不要重建)。 */
|
|
35
|
+
private rev;
|
|
36
|
+
constructor(dataDir: string, logger?: MemoryLogger | undefined);
|
|
37
|
+
init(): Promise<void>;
|
|
38
|
+
/** 读侧逐条收窄:形状不符的条目丢弃,不让一条坏数据拖垮整表。 */
|
|
39
|
+
private normalize;
|
|
40
|
+
private ensureLoaded;
|
|
41
|
+
/** 判脏序号(供投影 O(1) 决定要不要重建 view)。 */
|
|
42
|
+
revision(): number;
|
|
43
|
+
/**
|
|
44
|
+
* 记一次打分。返回聚合后的条目(供端点回执)。
|
|
45
|
+
* 只聚合不裁决:`-1` 进 obsolete 计数、不进均值(过时不是"不相关",两回事)。
|
|
46
|
+
*/
|
|
47
|
+
rate(id: string, score: FeedbackScore, at?: number): RecallFeedbackEntry;
|
|
48
|
+
get(id: string): RecallFeedbackEntry | undefined;
|
|
49
|
+
/** 均值(0..5);无有效评分 → undefined(缺席 ≠ 0 分)。 */
|
|
50
|
+
meanOf(id: string): number | undefined;
|
|
51
|
+
/** 最近一次打分(-1..5);未标注 → undefined。 */
|
|
52
|
+
lastOf(id: string): FeedbackScore | undefined;
|
|
53
|
+
/**
|
|
54
|
+
* 把一条记录的人工标注搬到新 id(merge 之后由管线调用,P0-d)。
|
|
55
|
+
*
|
|
56
|
+
* 不搬的后果比使用史更直接:成熟度只吃“人工最近一笔”,而 `last` 留在旧 id 上
|
|
57
|
+
* 就等于新记录**看起来从没被人打过**;更坏的是旧 id 上那一笔 `-1`(已判过时)
|
|
58
|
+
* 会跟着旧行一起沉进垃圾堆,新行反而以“未标注”的活状态回到召回里。
|
|
59
|
+
*
|
|
60
|
+
* 计数按相加、不按轨迹重算:`history` 截断到 12 条,拿它反推 `obsolete` 必然少算。
|
|
61
|
+
* 两侧都有条目时取较新一侧的 `last`,轨迹按时间归并。
|
|
62
|
+
*/
|
|
63
|
+
rekey(oldId: string, newId: string): boolean;
|
|
64
|
+
summary(): {
|
|
65
|
+
tracked: number;
|
|
66
|
+
rated: number;
|
|
67
|
+
obsolete: number;
|
|
68
|
+
};
|
|
69
|
+
private prune;
|
|
70
|
+
/** 立即落盘(串行链上排队,不与在途写交错)。 */
|
|
71
|
+
private schedulePersist;
|
|
72
|
+
private persist;
|
|
73
|
+
/** 等待在途写完成(测试/停机用)。 */
|
|
74
|
+
flush(): Promise<void>;
|
|
75
|
+
}
|