cs-bots 0.1.1 → 0.7.5
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.md +539 -0
- package/README.md +65 -10
- package/docs/01-/346/236/266/346/236/204/346/226/207/346/241/243.md +364 -0
- package/docs/02-/351/234/200/346/261/202/350/247/204/346/240/274/344/271/246.md +284 -0
- package/docs/03-/350/257/246/347/273/206/350/256/276/350/256/241.md +567 -0
- package/docs/04-/346/223/215/344/275/234/346/211/213/345/206/214.md +569 -0
- package/docs/ARCHITECTURE.md +277 -0
- package/docs/adr/ADR-001-/351/241/265/347/255/276/347/273/223/346/236/204/345/233/236/345/210/260/344/270/203/351/241/265.md +70 -0
- package/docs/steps/step-01-/345/216/237/346/236/266/346/236/204/345/244/215/345/216/237/344/270/216IA/350/257/212/346/226/255.md +80 -0
- package/docs/steps/step-02-/351/241/265/347/255/276/346/224/266/346/225/233/344/270/216/345/215/207/347/272/247/351/200/232/351/201/223.md +51 -0
- package/docs/steps/step-03-/346/236/266/346/236/204/350/265/204/346/226/231/346/210/220/346/226/207.md +16 -0
- package/docs/steps/step-04-/344/270/212/347/272/277/344/270/216/351/252/214/350/257/201.md +46 -0
- package/docs//344/272/244/344/273/230/350/257/264/346/230/216-v0.2.0.md +95 -0
- package/docs//346/223/215/344/275/234/346/211/213/345/206/214-v1.md +509 -0
- package/docs//350/277/201/347/247/273/346/226/271/346/241/210-v1.md +64 -0
- package/docs//351/225/277/346/234/237/350/256/260/345/277/206/346/226/271/346/241/210-v1.md +85 -0
- package/package.json +4 -2
- package/src/admission.js +365 -0
- package/src/api.js +4097 -420
- package/src/board.js +883 -14
- package/src/botprofile.js +190 -3
- package/src/client.js +6596 -1082
- package/src/cycle.js +516 -0
- package/src/delivery-watch.js +255 -0
- package/src/dirbrowse.js +20 -0
- package/src/discipline.js +1188 -0
- package/src/fence.js +142 -0
- package/src/index.js +387 -4
- package/src/kernel.js +636 -24
- package/src/library.js +1224 -11
- package/src/memory.js +617 -37
- package/src/metrics.js +339 -0
- package/src/prompts.js +1318 -0
- package/src/reviewer.js +709 -0
- package/src/room.js +655 -14
- package/src/roster.js +346 -26
- package/src/routing/complexity.js +320 -0
- package/src/routing/hints.js +707 -0
- package/src/scratch.js +477 -0
- package/src/settings.js +97 -0
- package/src/stage.js +263 -0
- package/src/store.js +133 -1
- package/src/subscription.js +571 -0
- package/src/suggest.js +1203 -0
- package/src/teams.js +527 -0
- package/src/todo.js +1301 -0
- package/src/tools.js +734 -10
- package/src/verify.js +356 -0
package/src/memory.js
CHANGED
|
@@ -35,6 +35,18 @@
|
|
|
35
35
|
* ——「超上限」这件事**看得见**,不是静默截断。`update()` 无法切分(它是单条 CAS),
|
|
36
36
|
* 故超长直接抛 `MEMORY_ENTRY_TOO_LONG`。
|
|
37
37
|
*
|
|
38
|
+
* ── cwd 作用域(按工作目录隔离,2026-11-04 对齐记忆进化 #2)──
|
|
39
|
+
* 问题:改造前所有条目是**全局单库**,多个项目的记忆互相串味
|
|
40
|
+
* (A 项目的「密钥在 .env.local」会出现在 B 项目的上下文里)。证据:`ANALYSIS-memory-evolve-parity.md` #2。
|
|
41
|
+
* 方案:
|
|
42
|
+
* - 只有 **L3 项目 key / L4 任务笔记 / L5 日札**带 `cwd` 字段(归一化:`resolve()` 后去尾斜杠,只收绝对路径);
|
|
43
|
+
* - **L0/L1/L2 是全局层**(人的身份 / 偏好 / 团队规矩属于「这个人 / 这个团队」,不属于某个项目),
|
|
44
|
+
* 传了 cwd 也**不记**(记下来会让全局条目被某个项目锁住,别的项目反而看不见 —— 反直觉);
|
|
45
|
+
* - `list()` / `search()` / `contextFor()` 新增可选 `cwd`:**缺省不过滤**(保持旧行为,兼容既有调用方与 33 个用例),
|
|
46
|
+
* `'*'` = 显式跨项目视图,具体路径 = 「全局条目 + 该 cwd 的 L3/L4/L5」;
|
|
47
|
+
* - `migrateCwd()` 是把「未归属项目」的老条目批量归属到某个项目的迁移入口。
|
|
48
|
+
* 老条目兼容取舍见 `cwdVisible()` 的注释(**故意**不让它们出现在具体项目视图里)。
|
|
49
|
+
*
|
|
38
50
|
* ── 存储 ──
|
|
39
51
|
* `memory.jsonl` 事件流(**真相源**,append-only)
|
|
40
52
|
* `memory.snap.json` 原子快照(加速启动;事件流为空时才作为回落)
|
|
@@ -52,11 +64,12 @@
|
|
|
52
64
|
* 本仓库是**裸检出**(无 node_modules),静态 import 会让本文件连带 kernel.js 加载失败。
|
|
53
65
|
* 拿不到 kernel 且调用方也没给 `config.stateDir` → **抛可读错误**(不静默兜底)。
|
|
54
66
|
*
|
|
55
|
-
* 依赖面:node 内置(`node:crypto` / `node:sqlite`)+ `./store.js` + `./kernel.js`。
|
|
67
|
+
* 依赖面:node 内置(`node:crypto` / `node:sqlite` / `node:path`)+ `./store.js` + `./kernel.js`。
|
|
56
68
|
* **不 import 任何 `@deepseek-ai/*`**(`test/ci-zero-dep.sh` 检查 1 机械强制)。
|
|
57
69
|
*/
|
|
58
70
|
|
|
59
71
|
import { randomBytes } from 'node:crypto'
|
|
72
|
+
import { isAbsolute, resolve, sep } from 'node:path'
|
|
60
73
|
|
|
61
74
|
import {
|
|
62
75
|
appendJsonl,
|
|
@@ -67,6 +80,19 @@ import {
|
|
|
67
80
|
writeJsonAtomic,
|
|
68
81
|
} from './store.js'
|
|
69
82
|
|
|
83
|
+
// ── store.appendAudit 软导入(P1-D §4)───────────────────────────────────────
|
|
84
|
+
// 软用:拿不到就只 warn 一次 + 计数,绝不阻断主流程,也绝不自己另造审计格式。
|
|
85
|
+
// 用顶层 await 一次性判定(memory.js 已是 ESM + 顶层 await),commit() 里同步判定。
|
|
86
|
+
let storeAppendAudit = /** @type {null | ((dir: string, entry: object) => unknown)} */ (null)
|
|
87
|
+
let storeAuditMissing = false
|
|
88
|
+
try {
|
|
89
|
+
const storeMod = await import('./store.js')
|
|
90
|
+
if (typeof storeMod.appendAudit === 'function') storeAppendAudit = storeMod.appendAudit
|
|
91
|
+
else storeAuditMissing = true
|
|
92
|
+
} catch {
|
|
93
|
+
storeAuditMissing = true
|
|
94
|
+
}
|
|
95
|
+
|
|
70
96
|
// ── kernel 惰性桥(见文件头「为什么是动态导入」)──────────────────────────────
|
|
71
97
|
|
|
72
98
|
/** @type {object | null} */
|
|
@@ -116,7 +142,11 @@ const ID_LEN = 8
|
|
|
116
142
|
/** 切分时的软边界回溯窗口占 max 的比例(在窗口内找换行/空格,找不到就硬切)。 */
|
|
117
143
|
const SOFT_BOUNDARY_RATIO = 0.5
|
|
118
144
|
/** 事件动作集合。 */
|
|
119
|
-
const ACTIONS = ['add', 'update', 'remove', 'archive', 'restore', 'review', 'consolidate']
|
|
145
|
+
const ACTIONS = ['add', 'update', 'remove', 'archive', 'restore', 'review', 'consolidate', 'migrate', 'purge']
|
|
146
|
+
/** 按 cwd 隔离的层(见文件头「cwd 作用域」):只有 L3/L4/L5 归属某个项目。 */
|
|
147
|
+
export const CWD_LAYERS = ['L3', 'L4', 'L5']
|
|
148
|
+
/** `cwd:'*'` = 显式要求跨项目视图(UI 的「全部项目」按钮)。 */
|
|
149
|
+
export const CWD_ALL = '*'
|
|
120
150
|
/** 否定极性标记(用于「冲突 vs 重复」判定,见 `polarityOf`)。 */
|
|
121
151
|
const NEG_MARKERS = ['不', '禁止', '不要', '别', 'never', 'must not', 'mustn\'t', '避免', '不可', '无需', '不应']
|
|
122
152
|
/** 肯定极性标记(存在即视为「有极性主张」,用于与否定对照)。 */
|
|
@@ -231,6 +261,99 @@ function normalizeNow(value, where) {
|
|
|
231
261
|
)
|
|
232
262
|
}
|
|
233
263
|
|
|
264
|
+
// ── cwd 作用域(按工作目录隔离)───────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
/** 层是否按 cwd 隔离(L0/L1/L2 是全局层,见文件头「cwd 作用域」)。 */
|
|
267
|
+
function cwdScopedLayer(layer) {
|
|
268
|
+
return CWD_LAYERS.includes(layer)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* cwd 归一化:`resolve()` 后去掉结尾斜杠。
|
|
273
|
+
*
|
|
274
|
+
* 两条硬规定:
|
|
275
|
+
* 1. **只收绝对路径**。相对路径要按「某个进程的当前目录」解释,而记忆是跨会话、跨 Bot
|
|
276
|
+
* 共享的 —— 同一个相对路径在不同进程里指向不同项目,会把记忆静默挂错项目。
|
|
277
|
+
* 所以直接拒收(`MEMORY_BAD_FIELD`),不做「猜一个 cwd」的兜底。
|
|
278
|
+
* 2. **去尾斜杠**(根 `/` 除外)。`/a/b` 与 `/a/b/` 必须算同一个项目,
|
|
279
|
+
* 否则同一项目会分裂成两个视图、迁移看起来「一半成功」。
|
|
280
|
+
*
|
|
281
|
+
* @param {unknown} value
|
|
282
|
+
* @param {string} field
|
|
283
|
+
* @param {string} where
|
|
284
|
+
* @returns {string}
|
|
285
|
+
*/
|
|
286
|
+
function normalizeCwd(value, field, where) {
|
|
287
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
288
|
+
throw new MemoryError(
|
|
289
|
+
`memory.${where}:字段 ${field} 必须是非空字符串(绝对路径),收到 ${JSON.stringify(value)}`,
|
|
290
|
+
'MEMORY_BAD_FIELD',
|
|
291
|
+
{ field, where },
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
const raw = value.trim()
|
|
295
|
+
if (!isAbsolute(raw)) {
|
|
296
|
+
throw new MemoryError(
|
|
297
|
+
`memory.${where}:字段 ${field} 必须是绝对路径(收到 ${JSON.stringify(raw)})—— `
|
|
298
|
+
+ '相对路径要按某个进程的当前目录解释,跨会话会指向别的项目,故拒收',
|
|
299
|
+
'MEMORY_BAD_FIELD',
|
|
300
|
+
{ field, where, value: raw },
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
let abs = resolve(raw)
|
|
304
|
+
while (abs.length > 1 && (abs.endsWith('/') || abs.endsWith(sep))) abs = abs.slice(0, -1)
|
|
305
|
+
return abs
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* 读侧过滤参数归一。
|
|
310
|
+
*
|
|
311
|
+
* **`undefined` / `null` → 不过滤**:这是兼容前提 —— `api.js` / `tools.js` 与既有
|
|
312
|
+
* 33 个用例都按「不传 cwd = 看全部」消费;若把缺省改成「只看当前项目」,
|
|
313
|
+
* 那些调用方会突然少一半记忆(静默行为变更)。
|
|
314
|
+
* `''`(空串)也按「未提供」处理:HTTP 查询串里 `?cwd=` 与「没有这个参数」在语义上
|
|
315
|
+
* 无法区分(UI 的「全部项目」很可能就发空串),读侧宽容比让 UI 报 400 更合理;
|
|
316
|
+
* 注意**写侧**相反 —— `add({cwd:''})` 是「要归属但没给路径」,必须报错(见 cwdForWrite)。
|
|
317
|
+
* `'*'` → 不过滤(语义上不同:调用方**显式**要求跨项目视图,如 UI 的「全部项目」)。
|
|
318
|
+
*
|
|
319
|
+
* @param {unknown} value
|
|
320
|
+
* @param {string} where
|
|
321
|
+
* @returns {string | undefined} undefined = 不过滤
|
|
322
|
+
*/
|
|
323
|
+
function normalizeCwdFilter(value, where) {
|
|
324
|
+
if (value === undefined || value === null) return undefined
|
|
325
|
+
if (typeof value === 'string' && (value.trim() === '' || value.trim() === CWD_ALL)) return undefined
|
|
326
|
+
return normalizeCwd(value, 'cwd', where)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* 条目在某个 cwd 视图里是否可见(`targetCwd === undefined` 表示不过滤)。
|
|
331
|
+
*
|
|
332
|
+
* ── 老数据(无 cwd 字段)的处置:**故意如此,不是 bug** ──
|
|
333
|
+
* 改造前写入的 L3/L4/L5 条目没有 `cwd` 字段 = 「未归属项目」。按 cwd 过滤时
|
|
334
|
+
* **它们不出现在任何具体 cwd 视图里**(只在不传 cwd / `cwd:'*'` 时可见)。
|
|
335
|
+
*
|
|
336
|
+
* 为什么故意这样(取舍理由):
|
|
337
|
+
* - 老条目来自全局单库,**没人知道它属于哪个项目** —— 让它们出现在每个项目视图
|
|
338
|
+
* 就等于把「A 项目的密钥在 .env.local」原封不动搬进 B 项目,而那正是本次改造要消灭的串味。
|
|
339
|
+
* - **宁可少看见,不可看错**:多看见一条不属于本项目的记忆,会让 Bot 做出错误决定
|
|
340
|
+
* (且这种错误静默、难追溯);少看见一条只是信息不全,用户有明确的补救入口 —— `migrateCwd()`。
|
|
341
|
+
* - 恢复可见性是**一次显式、可审计的动作**(人点「把历史记忆归到当前项目」),
|
|
342
|
+
* 而不是靠启发式猜测归属(猜错的代价比不猜高)。
|
|
343
|
+
*
|
|
344
|
+
* 注意「全局层永远可见」的判据是 **layer**,不是「有没有 cwd 字段」:
|
|
345
|
+
* L0/L1/L2 即使记录里意外带了 cwd(手改 jsonl 等)也仍然全局可见 —— 层语义优先。
|
|
346
|
+
*
|
|
347
|
+
* @param {object} entry
|
|
348
|
+
* @param {string | undefined} targetCwd
|
|
349
|
+
* @returns {boolean}
|
|
350
|
+
*/
|
|
351
|
+
function cwdVisible(entry, targetCwd) {
|
|
352
|
+
if (targetCwd === undefined) return true
|
|
353
|
+
if (!cwdScopedLayer(entry?.layer)) return true
|
|
354
|
+
return entry.cwd === targetCwd
|
|
355
|
+
}
|
|
356
|
+
|
|
234
357
|
// ── 极性 / 签名(规定 3 的判定基础)────────────────────────────────────────────
|
|
235
358
|
|
|
236
359
|
/**
|
|
@@ -312,6 +435,26 @@ function partitionContent(content, max) {
|
|
|
312
435
|
return parts
|
|
313
436
|
}
|
|
314
437
|
|
|
438
|
+
/**
|
|
439
|
+
* 粗略估 token(调研驱动:cs-myfree 用 4 字符/英文 + 1.5/中文)。
|
|
440
|
+
* 不追求精确(精确要 tokenizer,代价大于收益),只用于预算判断。
|
|
441
|
+
* @param {string} text
|
|
442
|
+
* @returns {number}
|
|
443
|
+
*/
|
|
444
|
+
function estimateTokens(text) {
|
|
445
|
+
const s = String(text ?? '')
|
|
446
|
+
if (s === '') return 0
|
|
447
|
+
let wide = 0
|
|
448
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
449
|
+
if (s.charCodeAt(i) > 0x2e80) wide += 1
|
|
450
|
+
}
|
|
451
|
+
const narrow = s.length - wide
|
|
452
|
+
return Math.ceil(narrow / 4 + wide / 1.5)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** 注入层的保留优先级(高者在预算不足时优先保留):L3 项目 key 离当前任务最近。 */
|
|
456
|
+
const INJECT_PRIORITY = { L3: 4, L2: 3, L1: 2, L0: 1 }
|
|
457
|
+
|
|
315
458
|
// ── 写入优先级(规定 4)───────────────────────────────────────────────────────
|
|
316
459
|
|
|
317
460
|
/** 写者优先级:数值越大越优先(规定 4:显式工具 > review > consolidate)。 */
|
|
@@ -451,11 +594,16 @@ export function createMemory(ctx, config = {}) {
|
|
|
451
594
|
replayedEvents: 0,
|
|
452
595
|
badLines: 0,
|
|
453
596
|
snapshotWriteFailures: 0,
|
|
597
|
+
auditWriteFailures: 0,
|
|
598
|
+
auditWarnedOnce: false,
|
|
454
599
|
searchBackend: /** @type {'fts5'|'includes'} */ ('includes'),
|
|
455
600
|
searchBackendReason: /** @type {string} */ ('未初始化'),
|
|
456
601
|
ftsTokenize: /** @type {string|null} */ (null),
|
|
457
602
|
lastSearchAt: /** @type {number|undefined} */ (undefined),
|
|
458
603
|
lastReplayAt: /** @type {number|undefined} */ (undefined),
|
|
604
|
+
// cwd 作用域诊断:上层(UI/调度)可据此判断「上次注入是按项目裁剪的,还是全局的」
|
|
605
|
+
lastContextCwd: /** @type {string|null|undefined} */ (undefined),
|
|
606
|
+
lastContextCwdScoped: /** @type {boolean} */ (false),
|
|
459
607
|
}
|
|
460
608
|
|
|
461
609
|
// ── FTS5 索引(派生缓存;坏了不影响真相源)──────────────────────────────────
|
|
@@ -596,6 +744,38 @@ export function createMemory(ctx, config = {}) {
|
|
|
596
744
|
diagnostics.snapshotWriteFailures += 1
|
|
597
745
|
warn(`[cs-bots/memory] 快照写入失败(${snapPath})——事件流已落盘,replay() 可完整恢复`)
|
|
598
746
|
}
|
|
747
|
+
// ── 审计接线(P1-D §4)────────────────────────────────────────────────
|
|
748
|
+
// 软用 store.appendAudit:拿不到/写失败 → 只 warn 一次 + 计数,绝不阻断主流程。
|
|
749
|
+
if (storeAuditMissing) {
|
|
750
|
+
diagnostics.auditWriteFailures += 1
|
|
751
|
+
if (!diagnostics.auditWarnedOnce) {
|
|
752
|
+
diagnostics.auditWarnedOnce = true
|
|
753
|
+
warn('[cs-bots/memory] store.appendAudit 尚不可用 → 审计接线跳过;主流程不受影响')
|
|
754
|
+
}
|
|
755
|
+
} else {
|
|
756
|
+
try {
|
|
757
|
+
const digest = (e) => (e
|
|
758
|
+
? { id: e.id, layer: e.layer ?? null, rev: e.rev, source: e.source ?? null }
|
|
759
|
+
: null)
|
|
760
|
+
const result = storeAppendAudit(stateDir, {
|
|
761
|
+
ts: at, actor: byId, action, target: id, before: digest(before), after: digest(after),
|
|
762
|
+
})
|
|
763
|
+
const failed = result === false || Boolean(result && typeof result === 'object' && result.ok === false)
|
|
764
|
+
if (failed) {
|
|
765
|
+
diagnostics.auditWriteFailures += 1
|
|
766
|
+
if (!diagnostics.auditWarnedOnce) {
|
|
767
|
+
diagnostics.auditWarnedOnce = true
|
|
768
|
+
warn('[cs-bots/memory] store.appendAudit 返回失败——主流程不受影响')
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
} catch (error) {
|
|
772
|
+
diagnostics.auditWriteFailures += 1
|
|
773
|
+
if (!diagnostics.auditWarnedOnce) {
|
|
774
|
+
diagnostics.auditWarnedOnce = true
|
|
775
|
+
warn(`[cs-bots/memory] store.appendAudit 抛错:${error?.message ?? error}——主流程不受影响`)
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
599
779
|
return clone(after)
|
|
600
780
|
}
|
|
601
781
|
|
|
@@ -638,7 +818,24 @@ export function createMemory(ctx, config = {}) {
|
|
|
638
818
|
// 事件流是真相源:只接受 rev 递增的事件(同 rev 重放幂等,容忍重复投递)
|
|
639
819
|
let skipped = 0
|
|
640
820
|
for (const ev of rows) {
|
|
641
|
-
if (!ev || typeof ev.id !== 'string'
|
|
821
|
+
if (!ev || typeof ev.id !== 'string') {
|
|
822
|
+
skipped += 1
|
|
823
|
+
continue
|
|
824
|
+
}
|
|
825
|
+
// `after === null` = **彻底删除墓碑**(purge):从内存移除,但事件本身
|
|
826
|
+
// 仍进 events 供审计/历史查询。为什么不在文件里抹掉这一行:
|
|
827
|
+
// 事件流是只追加的真相源,改写历史会让「谁在什么时候删了什么」永久丢失。
|
|
828
|
+
if (ev.after === null) {
|
|
829
|
+
const beforePurge = entries.get(ev.id)
|
|
830
|
+
if (beforePurge && Number.isFinite(ev.rev) && ev.rev <= beforePurge.rev) {
|
|
831
|
+
skipped += 1
|
|
832
|
+
continue
|
|
833
|
+
}
|
|
834
|
+
entries.delete(ev.id)
|
|
835
|
+
events.push(clone(ev))
|
|
836
|
+
continue
|
|
837
|
+
}
|
|
838
|
+
if (!ev.after || typeof ev.after.id !== 'string') {
|
|
642
839
|
skipped += 1
|
|
643
840
|
continue
|
|
644
841
|
}
|
|
@@ -724,6 +921,40 @@ export function createMemory(ctx, config = {}) {
|
|
|
724
921
|
return branches.length === 0 || branches.includes(branch)
|
|
725
922
|
}
|
|
726
923
|
|
|
924
|
+
/**
|
|
925
|
+
* 写侧 cwd 归属计算(add/update 共用)。
|
|
926
|
+
*
|
|
927
|
+
* - 隔离层(L3/L4/L5):归一化后**返回要落盘的 cwd**;
|
|
928
|
+
* - 全局层(L0/L1/L2):**忽略**(返回 undefined,不落字段)。为什么不报错:
|
|
929
|
+
* 调用方(工具 / UI)常把「当前工作目录」无脑透传,对 L1/L2 报错会让
|
|
930
|
+
* 「我在这条路径下写的偏好」整条写不进去 —— 那是把一次便利调用变成硬失败;
|
|
931
|
+
* 但**记下来**更糟(全局条目被某个项目锁住,别的项目反而看不见它,反直觉)。
|
|
932
|
+
* 故:忽略 + `warn` 留痕(绝不静默),层语义不受影响。
|
|
933
|
+
* - `'*'` 只能用于**过滤**,不能当作写入归属(会写出一个谁都看不见的 cwd)→ 拒收。
|
|
934
|
+
*
|
|
935
|
+
* @param {string} layer
|
|
936
|
+
* @param {unknown} rawCwd
|
|
937
|
+
* @param {string} where
|
|
938
|
+
* @returns {string | undefined}
|
|
939
|
+
*/
|
|
940
|
+
function cwdForWrite(layer, rawCwd, where) {
|
|
941
|
+
if (rawCwd === undefined || rawCwd === null) return undefined
|
|
942
|
+
// 全局层先判:规格是「传了也不记」,故连非法值也一并忽略(不因一个与它无关的字段报错)
|
|
943
|
+
if (!cwdScopedLayer(layer)) {
|
|
944
|
+
warn(`[cs-bots/memory] ${where}:${layer} 是全局层,忽略传入的 cwd=${JSON.stringify(rawCwd)}(L0/L1/L2 不按项目隔离)`)
|
|
945
|
+
return undefined
|
|
946
|
+
}
|
|
947
|
+
if (typeof rawCwd === 'string' && rawCwd.trim() === CWD_ALL) {
|
|
948
|
+
throw new MemoryError(
|
|
949
|
+
`memory.${where}:cwd='*' 是**读侧**的跨项目视图标记,不能作为写入归属 —— `
|
|
950
|
+
+ '请传具体项目目录的绝对路径',
|
|
951
|
+
'MEMORY_BAD_FIELD',
|
|
952
|
+
{ field: 'cwd', where },
|
|
953
|
+
)
|
|
954
|
+
}
|
|
955
|
+
return normalizeCwd(rawCwd, 'cwd', where)
|
|
956
|
+
}
|
|
957
|
+
|
|
727
958
|
// ── API:写 ───────────────────────────────────────────────────────────────
|
|
728
959
|
|
|
729
960
|
/**
|
|
@@ -735,11 +966,11 @@ export function createMemory(ctx, config = {}) {
|
|
|
735
966
|
*
|
|
736
967
|
* @param {{ layer: string, content: string, by: string, explicit?: boolean,
|
|
737
968
|
* source?: 'explicit'|'review'|'consolidate', branch?: string|string[],
|
|
738
|
-
* tags?: string[], now?: number }} input
|
|
969
|
+
* tags?: string[], cwd?: string, now?: number }} input
|
|
739
970
|
* @returns {object} Entry(含 id + rev)
|
|
740
971
|
*/
|
|
741
972
|
function add(input = {}) {
|
|
742
|
-
const { layer, content, by, explicit, source, branch, tags, botId, now } = input ?? {}
|
|
973
|
+
const { layer, content, by, explicit, source, branch, tags, botId, now, cwd, taskId, phase } = input ?? {}
|
|
743
974
|
const targetLayer = requireLayer(layer, 'add')
|
|
744
975
|
assertNotPersonaLayer(targetLayer, 'add')
|
|
745
976
|
const author = requireString(by, 'by', 'add')
|
|
@@ -758,6 +989,8 @@ export function createMemory(ctx, config = {}) {
|
|
|
758
989
|
// Bot 维度:不传 = 全局记忆;传了 = 只在那个 Bot 的 contextFor 里出现
|
|
759
990
|
const ownerBot = optionalString(botId, 'botId', 'add')
|
|
760
991
|
const tagList = stringList(tags, 'tags', 'add')
|
|
992
|
+
// cwd 维度:只有 L3/L4/L5 记录(全局层忽略,见 cwdForWrite 注释)
|
|
993
|
+
const ownerCwd = cwdForWrite(targetLayer, cwd, 'add')
|
|
761
994
|
const at = normalizeNow(now, 'add')
|
|
762
995
|
|
|
763
996
|
const parts = cpLength(text) > entryMax ? partitionContent(text, entryMax) : [text]
|
|
@@ -771,8 +1004,15 @@ export function createMemory(ctx, config = {}) {
|
|
|
771
1004
|
|
|
772
1005
|
/** @type {object[]} */
|
|
773
1006
|
const created = []
|
|
774
|
-
|
|
775
|
-
|
|
1007
|
+
// ⚠️ 修复(2026-11-04 测试,P1-2):原来 `split`/`parts` 只挂在返回给调用方的
|
|
1008
|
+
// 克隆上(commit 已落盘之后才挂),⇒ 读回来(list/search/replay)全部丢失,
|
|
1009
|
+
// 切分状态"不可持久、不可审计",UI 会显示成"单条完整"。
|
|
1010
|
+
// 现在:先算出全量 id,把 `splitGroup`(同一原文的组标识)+ `splitIndex` +
|
|
1011
|
+
// `splitTotal` **写进每条 entry 本身**,然后一起落盘。
|
|
1012
|
+
const groupId = parts.length > 1 ? newId() : null
|
|
1013
|
+
const ids = parts.map(() => newId())
|
|
1014
|
+
for (const [i, part] of parts.entries()) {
|
|
1015
|
+
const id = ids[i]
|
|
776
1016
|
/** @type {Record<string, unknown>} */
|
|
777
1017
|
const entry = {
|
|
778
1018
|
id,
|
|
@@ -783,15 +1023,26 @@ export function createMemory(ctx, config = {}) {
|
|
|
783
1023
|
explicit: explicit === true,
|
|
784
1024
|
branches,
|
|
785
1025
|
...(ownerBot !== undefined ? { botId: ownerBot } : {}),
|
|
1026
|
+
// 任务笔记绑定(长期记忆方案 §2.1):taskId 把这条 L4 笔记挂到某个 board 任务上,
|
|
1027
|
+
// notesForTask(taskId) 据此拉取该任务的全部历史笔记 —— 跨会话交接靠它,不依赖旧消息。
|
|
1028
|
+
...(typeof taskId === 'string' && taskId !== '' ? { taskId } : {}),
|
|
1029
|
+
// 任务笔记阶段(§2.2):plan / doing / block / done,便于筛选与归档优先级
|
|
1030
|
+
...(typeof phase === 'string' && phase !== '' ? { phase } : {}),
|
|
1031
|
+
// cwd 必须写进**条目本身**(随 commit → 事件流 → 落盘);
|
|
1032
|
+
// 只挂在返回克隆上会重演 split 标记的老坑:读回来就丢(P1-2 教训)。
|
|
1033
|
+
...(ownerCwd !== undefined ? { cwd: ownerCwd } : {}),
|
|
786
1034
|
tags: tagList.slice(),
|
|
787
1035
|
createdAt: at,
|
|
788
1036
|
createdBy: author,
|
|
789
1037
|
updatedAt: at,
|
|
790
1038
|
updatedBy: author,
|
|
791
1039
|
}
|
|
792
|
-
if (
|
|
793
|
-
|
|
1040
|
+
if (groupId !== null) {
|
|
1041
|
+
// 持久化的切分元数据:组标识 + 序号 + 总数 + 组内全部 id(便于无损还原原文)
|
|
1042
|
+
entry.splitGroup = groupId
|
|
1043
|
+
entry.splitIndex = i
|
|
794
1044
|
entry.splitTotal = parts.length
|
|
1045
|
+
entry.splitParts = ids.slice()
|
|
795
1046
|
}
|
|
796
1047
|
try {
|
|
797
1048
|
created.push(commit(id, 'add', author, at, null, entry))
|
|
@@ -822,11 +1073,11 @@ export function createMemory(ctx, config = {}) {
|
|
|
822
1073
|
* 抛 `MEMORY_EXPLICIT_PROTECTED`(并指向 `review()`)。
|
|
823
1074
|
*
|
|
824
1075
|
* @param {{ layer: string, id: string, expectedRev: number, content?: string, tags?: string[],
|
|
825
|
-
* explicit?: boolean, by: string, now?: number }} input
|
|
1076
|
+
* cwd?: string, explicit?: boolean, by: string, now?: number }} input
|
|
826
1077
|
* @returns {object} 变更后的 Entry
|
|
827
1078
|
*/
|
|
828
1079
|
function update(input = {}) {
|
|
829
|
-
const { layer, id, expectedRev, content, tags, explicit, by, now } = input ?? {}
|
|
1080
|
+
const { layer, id, expectedRev, content, tags, explicit, by, now, cwd } = input ?? {}
|
|
830
1081
|
const targetLayer = requireLayer(layer, 'update')
|
|
831
1082
|
assertNotPersonaLayer(targetLayer, 'update')
|
|
832
1083
|
const author = requireString(by, 'by', 'update')
|
|
@@ -867,12 +1118,17 @@ export function createMemory(ctx, config = {}) {
|
|
|
867
1118
|
assertWriteAllowed(current, 'explicit', 'update')
|
|
868
1119
|
|
|
869
1120
|
const at = normalizeNow(now, 'update')
|
|
1121
|
+
// cwd 重新归属:不传 = 保持原归属(clone 已带上);传了 = 归一化后覆盖。
|
|
1122
|
+
// 注意这里**不清理**旧 cwd 字段 —— 显式「清空归属」没有对应语义(要改归属就传新路径,
|
|
1123
|
+
// 回到「未归属」状态只能靠手改数据,避免一次误传把条目打回谁都看不见的状态)。
|
|
1124
|
+
const nextCwd = cwdForWrite(targetLayer, cwd, 'update')
|
|
870
1125
|
const before = clone(current)
|
|
871
1126
|
const next = clone(current)
|
|
872
1127
|
next.rev = current.rev + 1
|
|
873
1128
|
next.updatedAt = at
|
|
874
1129
|
next.updatedBy = author
|
|
875
1130
|
if (explicit === true) next.explicit = true
|
|
1131
|
+
if (nextCwd !== undefined) next.cwd = nextCwd
|
|
876
1132
|
if (content !== undefined && content !== null) {
|
|
877
1133
|
const text = requireString(content, 'content', 'update')
|
|
878
1134
|
if (cpLength(text) > entryMax) {
|
|
@@ -958,6 +1214,74 @@ export function createMemory(ctx, config = {}) {
|
|
|
958
1214
|
return commit(current.id, action, author, at, before, next)
|
|
959
1215
|
}
|
|
960
1216
|
|
|
1217
|
+
/**
|
|
1218
|
+
* **彻底删除**(purge)——与 `remove`(软删除,进归档区可恢复)的区别:
|
|
1219
|
+
* - `remove` = 归档,`archived.reason='removed'`,列表不可见但**可恢复**,可追溯;
|
|
1220
|
+
* - `purge` = 真删,条目从内存与快照中移除,**不可恢复**;事件流只留一条
|
|
1221
|
+
* `after:null` 的墓碑,记录「谁在什么时候删了什么」,审计不丢、内容不留。
|
|
1222
|
+
*
|
|
1223
|
+
* 为什么两者都要有:`remove` 是对用户的保护(误删可救),`purge` 是对用户的
|
|
1224
|
+
* 承诺(说删就真的删 —— 比如密钥/口令误写进记忆时必须能彻底清掉)。
|
|
1225
|
+
* 只有 `remove` 时用户会遇到「删了还在归档区躺着」;只有 `purge` 时误删无从挽回。
|
|
1226
|
+
*
|
|
1227
|
+
* 仍然禁止 L0:人格层只允许人改,删除同样要人显式发起(by 必填)。
|
|
1228
|
+
*
|
|
1229
|
+
* @param {{ layer: string, id: string, by: string, expectedRev?: number, now?: number }} input
|
|
1230
|
+
* @returns {object} 被删除的条目(删除前的样子,供 UI 回显)
|
|
1231
|
+
*/
|
|
1232
|
+
function purge(input = {}) {
|
|
1233
|
+
const { layer, id, by, expectedRev, now } = input ?? {}
|
|
1234
|
+
const targetLayer = requireLayer(layer, 'purge')
|
|
1235
|
+
assertNotPersonaLayer(targetLayer, 'purge')
|
|
1236
|
+
const author = requireString(by, 'by', 'purge')
|
|
1237
|
+
const current = requireEntry(id, targetLayer, 'purge')
|
|
1238
|
+
|
|
1239
|
+
if (expectedRev !== undefined && expectedRev !== null) {
|
|
1240
|
+
if (typeof expectedRev !== 'number' || !Number.isInteger(expectedRev)) {
|
|
1241
|
+
throw new MemoryError(
|
|
1242
|
+
`memory.purge:expectedRev 必须是整数(收到 ${JSON.stringify(expectedRev)})`,
|
|
1243
|
+
'MEMORY_BAD_FIELD',
|
|
1244
|
+
{ field: 'expectedRev', id: current.id },
|
|
1245
|
+
)
|
|
1246
|
+
}
|
|
1247
|
+
if (current.rev !== expectedRev) {
|
|
1248
|
+
throw new MemoryError(
|
|
1249
|
+
`memory.purge CAS 冲突:条目 [id:${current.id}] 期望 rev=${expectedRev},实际 rev=${current.rev} —— 拒绝误删`,
|
|
1250
|
+
'MEMORY_CAS_CONFLICT',
|
|
1251
|
+
{ id: current.id, expectedRev, rev: current.rev },
|
|
1252
|
+
)
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
const at = normalizeNow(now, 'purge')
|
|
1257
|
+
const event = {
|
|
1258
|
+
id: current.id,
|
|
1259
|
+
layer: targetLayer,
|
|
1260
|
+
rev: current.rev + 1,
|
|
1261
|
+
action: 'purge',
|
|
1262
|
+
byId: author,
|
|
1263
|
+
at,
|
|
1264
|
+
before: clone(current),
|
|
1265
|
+
after: null,
|
|
1266
|
+
}
|
|
1267
|
+
if (!appendJsonl(jsonlPath, event)) {
|
|
1268
|
+
throw new MemoryError(
|
|
1269
|
+
`memory.purge:事件追加失败(${jsonlPath})——拒绝「只改内存不落盘」`,
|
|
1270
|
+
'MEMORY_APPEND_FAILED',
|
|
1271
|
+
{ id: current.id, action: 'purge' },
|
|
1272
|
+
)
|
|
1273
|
+
}
|
|
1274
|
+
entries.delete(current.id)
|
|
1275
|
+
events.push(event)
|
|
1276
|
+
// 全文索引重建:purge 是低频操作,整体重建比增量删除更不容易出错
|
|
1277
|
+
try { indexRebuild() } catch { /* 索引失败不影响正确性,检索会退化 */ }
|
|
1278
|
+
if (!writeJsonAtomic(snapPath, snapshot())) {
|
|
1279
|
+
diagnostics.snapshotWriteFailures += 1
|
|
1280
|
+
warn(`[cs-bots/memory] 快照写入失败(${snapPath})——事件流已落盘,replay() 可完整恢复`)
|
|
1281
|
+
}
|
|
1282
|
+
return clone(current)
|
|
1283
|
+
}
|
|
1284
|
+
|
|
961
1285
|
/**
|
|
962
1286
|
* 从归档恢复(与 `remove`/`archive` 构成往返一致)。归档不是删除,恢复后原文照旧。
|
|
963
1287
|
* @param {{ layer: string, id: string, by?: string, now?: number }} input
|
|
@@ -1036,7 +1360,19 @@ export function createMemory(ctx, config = {}) {
|
|
|
1036
1360
|
}
|
|
1037
1361
|
|
|
1038
1362
|
if (!target) {
|
|
1039
|
-
|
|
1363
|
+
// cwd 透传:review 若带着项目上下文来(`item.cwd`),新增条目就归属该项目;
|
|
1364
|
+
// 不带则保持「未归属」——**故意不猜**(猜错归属比不归属更糟,见 cwdVisible 注释)。
|
|
1365
|
+
// 注意:更新既有条目时**不**透传 cwd —— review 按内容签名匹配,命中可能是别的项目的
|
|
1366
|
+
// 同文条目,跟着当前 cwd 改写会把记忆静默搬到另一个项目去。
|
|
1367
|
+
const created = add({
|
|
1368
|
+
layer: targetLayer,
|
|
1369
|
+
content,
|
|
1370
|
+
by: author,
|
|
1371
|
+
source: 'review',
|
|
1372
|
+
tags: item.tags,
|
|
1373
|
+
cwd: item.cwd,
|
|
1374
|
+
now: at,
|
|
1375
|
+
})
|
|
1040
1376
|
const row = { action: 'added', id: created.id, rev: created.rev }
|
|
1041
1377
|
results.push(row)
|
|
1042
1378
|
applied.push(row)
|
|
@@ -1638,17 +1974,39 @@ export function createMemory(ctx, config = {}) {
|
|
|
1638
1974
|
|
|
1639
1975
|
/**
|
|
1640
1976
|
* 列出条目。
|
|
1977
|
+
*
|
|
1978
|
+
* `cwd` 语义(见文件头「cwd 作用域」):
|
|
1979
|
+
* - **不传 / `null`** → 与改造前完全一致(返回全部,含未归属与他项目条目);
|
|
1980
|
+
* - **传具体路径** → 全局条目(L0/L1/L2)+ 该 cwd 的 L3/L4/L5;其他 cwd 与
|
|
1981
|
+
* **未归属项目的**老条目都不返回(故意,见 `cwdVisible`);
|
|
1982
|
+
* - **传 `'*'`** → 全部(UI「全部项目」视图)。
|
|
1983
|
+
*
|
|
1641
1984
|
* @param {{ layer?: string, branch?: string, limit?: number, query?: string,
|
|
1642
|
-
* includeArchived?: boolean, onlyArchived?: boolean
|
|
1985
|
+
* includeArchived?: boolean, onlyArchived?: boolean, botId?: string,
|
|
1986
|
+
* cwd?: string }} [options]
|
|
1643
1987
|
* @returns {object[]}
|
|
1644
1988
|
*/
|
|
1645
1989
|
function list(options = {}) {
|
|
1646
|
-
|
|
1990
|
+
// ⚠️ 修复(2026-11-04 测试,P1-3):list/search 之前不按 botId 过滤,
|
|
1991
|
+
// Bot 私有记忆对任何 API 调用者可见(与「只在那个 Bot 的 contextFor 里出现」
|
|
1992
|
+
// 的注释自相矛盾)。现在与 contextFor 口径一致:
|
|
1993
|
+
// ① 传 botId → 只见该 Bot 的私有记忆(可见的可见性,不静默泄露);
|
|
1994
|
+
// ② 不传 botId → 只见非私有(无 botId)的记忆。
|
|
1995
|
+
const { layer, branch, limit, query, includeArchived, onlyArchived, botId, cwd } = options ?? {}
|
|
1647
1996
|
const targetLayer = layer === undefined || layer === null ? undefined : requireLayer(layer, 'list')
|
|
1648
1997
|
const lim = optionalLimit(limit, 'list')
|
|
1649
1998
|
const q = optionalString(query, 'query', 'list')
|
|
1999
|
+
// 先归一化(非法 cwd 早失败,避免「查了半天才报错」)
|
|
2000
|
+
const targetCwd = normalizeCwdFilter(cwd, 'list')
|
|
1650
2001
|
|
|
1651
2002
|
let rows = [...entries.values()]
|
|
2003
|
+
if (botId !== undefined && botId !== null && botId !== '') {
|
|
2004
|
+
const bid = requireString(botId, 'botId', 'list')
|
|
2005
|
+
rows = rows.filter((e) => e.botId === bid)
|
|
2006
|
+
} else {
|
|
2007
|
+
rows = rows.filter((e) => !e.botId)
|
|
2008
|
+
}
|
|
2009
|
+
if (targetCwd !== undefined) rows = rows.filter((e) => cwdVisible(e, targetCwd))
|
|
1652
2010
|
if (targetLayer) rows = rows.filter((e) => e.layer === targetLayer)
|
|
1653
2011
|
if (onlyArchived === true) rows = rows.filter((e) => e.archived)
|
|
1654
2012
|
else if (includeArchived !== true) rows = rows.filter((e) => !e.archived)
|
|
@@ -1675,12 +2033,14 @@ export function createMemory(ctx, config = {}) {
|
|
|
1675
2033
|
* 检索(规定:**用 node:sqlite FTS5**,不可用则退化 `String.includes`)。
|
|
1676
2034
|
*
|
|
1677
2035
|
* @param {{ query: string, layers?: string[], branch?: string, limit?: number,
|
|
1678
|
-
* includeArchived?: boolean }} input
|
|
2036
|
+
* includeArchived?: boolean, botId?: string, cwd?: string }} input
|
|
1679
2037
|
* @returns {object[]} 命中条目(按 FTS5 rank / 稳定顺序)
|
|
1680
2038
|
*/
|
|
1681
2039
|
function search(input = {}) {
|
|
1682
|
-
|
|
2040
|
+
// ⚠️ 修复(2026-11-04 测试,P1-3):与 list 口径一致,见 list() 顶部注释。
|
|
2041
|
+
const { query, layers, branch, limit, includeArchived, botId, cwd } = input ?? {}
|
|
1683
2042
|
const q = requireString(query, 'query', 'search')
|
|
2043
|
+
const targetCwd = normalizeCwdFilter(cwd, 'search')
|
|
1684
2044
|
const layerFilter = layers === undefined || layers === null ? undefined : stringList(layers, 'layers', 'search')
|
|
1685
2045
|
if (layerFilter) {
|
|
1686
2046
|
for (const l of layerFilter) {
|
|
@@ -1745,10 +2105,35 @@ export function createMemory(ctx, config = {}) {
|
|
|
1745
2105
|
.filter((e) => (includeArchived === true ? true : !e.archived))
|
|
1746
2106
|
.filter((e) => (layerFilter ? layerFilter.includes(e.layer) : true))
|
|
1747
2107
|
.filter((e) => branchVisible(e, b))
|
|
2108
|
+
.filter((e) => cwdVisible(e, targetCwd))
|
|
2109
|
+
.filter((e) => visibleToBot(e, botId))
|
|
1748
2110
|
if (lim !== undefined) hits = hits.slice(0, lim)
|
|
1749
2111
|
return clone(hits)
|
|
1750
2112
|
}
|
|
1751
2113
|
|
|
2114
|
+
/**
|
|
2115
|
+
* 拉取某个 board 任务的全部 L4 任务笔记(倒序,最新在前)。
|
|
2116
|
+
*
|
|
2117
|
+
* 为什么需要:长期记忆方案 §2.3 —— 任务细节不进常驻注入(L4 按需 recall),
|
|
2118
|
+
* 跨会话交接时先 `bot_task_get` 看任务,再 `notesForTask(taskId)` 看历史笔记,
|
|
2119
|
+
* 就能恢复上下文,不依赖旧会话消息。
|
|
2120
|
+
*
|
|
2121
|
+
* @param {{ taskId: string, cwd?: string, limit?: number }} input
|
|
2122
|
+
* @returns {object[]} 该任务的 L4 笔记(倒序)
|
|
2123
|
+
*/
|
|
2124
|
+
function notesForTask(input = {}) {
|
|
2125
|
+
const { taskId, cwd, limit } = input ?? {}
|
|
2126
|
+
const id = requireString(taskId, 'taskId', 'notesForTask')
|
|
2127
|
+
const targetCwd = normalizeCwdFilter(cwd, 'notesForTask')
|
|
2128
|
+
const lim = optionalLimit(limit, 'notesForTask')
|
|
2129
|
+
const rows = [...entries.values()]
|
|
2130
|
+
.filter((e) => e.layer === 'L4' && e.taskId === id)
|
|
2131
|
+
.filter((e) => cwdVisible(e, targetCwd))
|
|
2132
|
+
.sort((x, y) => (x.createdAt > y.createdAt ? -1 : x.createdAt < y.createdAt ? 1 : (x.id < y.id ? -1 : 1)))
|
|
2133
|
+
const out = rows.slice(0, lim ?? rows.length)
|
|
2134
|
+
return clone(out)
|
|
2135
|
+
}
|
|
2136
|
+
|
|
1752
2137
|
// ── API:常驻注入 ──────────────────────────────────────────────────────────
|
|
1753
2138
|
|
|
1754
2139
|
/** 渲染一行:`[id:xxxxxxxx] [rev:N] [conflict] 内容`。 */
|
|
@@ -1764,11 +2149,30 @@ export function createMemory(ctx, config = {}) {
|
|
|
1764
2149
|
* 常驻注入文本(**L0–L3 冻结快照**):值一旦返回即不受后续变更影响。
|
|
1765
2150
|
* L0 来自 `config.persona`(人改),其余来自条目;L4/L5 永不进入常驻注入。
|
|
1766
2151
|
*
|
|
1767
|
-
*
|
|
1768
|
-
*
|
|
1769
|
-
|
|
2152
|
+
* ⚠️ 2026-11-04 增强(调研驱动):新增 **tokenBudget** —— 按 contextWindow
|
|
2153
|
+
* (cs-myfree 上报的模型窗口)动态裁剪注入量。小窗模型(8k–32k)上
|
|
2154
|
+
* L0–L3 全量注入可能挤占工具/用户空间(调研:MemOS/Mem0 分级预算)。
|
|
2155
|
+
* 预算耗尽时按「L3 项目 key > L2 团队 > L1 用户 > L0 人格」的**保留优先级**
|
|
2156
|
+
* 截断(L3 离当前任务最近,优先保留)。
|
|
2157
|
+
*
|
|
2158
|
+
* ── cwd 作用域(2026-11-04 对齐记忆进化 #2)──
|
|
2159
|
+
* 传 `cwd` 时只注入「全局条目(L0/L1/L2)+ 当前 cwd 的 L3」(L4/L5 本来就不常驻);
|
|
2160
|
+
* 不传时保持旧行为(全部可见),并在**诊断**里标 `cwdScoped:false`。
|
|
2161
|
+
*
|
|
2162
|
+
* 为什么默认仍返回**字符串**、`cwdScoped` 走 `withMeta`:`api.js` 的 `/memory/context`
|
|
2163
|
+
* 与 `tools.js` 的 `bot_memory_context` 都按字符串消费 `contextFor()`,33 个既有用例也用
|
|
2164
|
+
* `assert.match(text, ...)` 断言;把返回值直接改成对象是破坏性变更(且这两个文件不在
|
|
2165
|
+
* 本次改造的写入范围内)。故用显式开关:`withMeta:true` → 返回
|
|
2166
|
+
* `{ text, cwd, cwdScoped, layers }`;缺省 → 返回字符串。同样的诊断信息另见 `diagnostics()`。
|
|
2167
|
+
*
|
|
2168
|
+
* @param {{ layers?: string[], branch?: string, title?: string, botId?: string,
|
|
2169
|
+
* tokenBudget?: number, cwd?: string, withMeta?: boolean }} [options]
|
|
2170
|
+
* @returns {string | { text: string, cwd: string|null, cwdScoped: boolean, layers: string[] }}
|
|
2171
|
+
*/
|
|
1770
2172
|
function contextFor(options = {}) {
|
|
1771
|
-
const { layers, branch, title, botId: forBot } = options ?? {}
|
|
2173
|
+
const { layers, branch, title, botId: forBot, tokenBudget, cwd, withMeta } = options ?? {}
|
|
2174
|
+
const rawCwd = cwd
|
|
2175
|
+
const targetCwd = normalizeCwdFilter(cwd, 'contextFor')
|
|
1772
2176
|
const want = layers === undefined || layers === null
|
|
1773
2177
|
? INJECTED_LAYERS.slice()
|
|
1774
2178
|
: stringList(layers, 'layers', 'contextFor')
|
|
@@ -1786,26 +2190,91 @@ export function createMemory(ctx, config = {}) {
|
|
|
1786
2190
|
throw new MemoryError('memory.contextFor:branch 过滤只对 L3 有意义', 'MEMORY_BAD_FIELD', { field: 'branch' })
|
|
1787
2191
|
}
|
|
1788
2192
|
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
2193
|
+
// ⚠️ tokenBudget 计算(2026-11-04 增强):
|
|
2194
|
+
// 先把所有层渲染成行数组,再按预算裁剪。预算不足时从低优先级层开始整层丢弃,
|
|
2195
|
+
// 并在输出尾部**可见地**标记被丢弃的层(不静默)。
|
|
2196
|
+
const budget = Number.isFinite(tokenBudget) && tokenBudget > 0 ? Math.floor(tokenBudget) : 0
|
|
2197
|
+
|
|
2198
|
+
const header = [
|
|
2199
|
+
`# cs-bots 分层记忆${title ? ` · ${title}` : ''}`,
|
|
2200
|
+
`# branch=${b ?? '(all)'} · layers=${want.join(',')} · entries=${entries.size}`
|
|
2201
|
+
+ ` · search=${diagnostics.searchBackend}${forBot ? ` · bot=${forBot}` : ''}`
|
|
2202
|
+
// 只有真按项目裁剪时才在头部标 cwd:不传 cwd 时输出与改造前保持逐字一致(兼容前提)
|
|
2203
|
+
+ (targetCwd !== undefined ? ` · cwd=${targetCwd}` : ''),
|
|
2204
|
+
]
|
|
2205
|
+
|
|
2206
|
+
/** 逐层收集渲染行 */
|
|
2207
|
+
const layersOut = []
|
|
1794
2208
|
for (const layer of want) {
|
|
1795
|
-
|
|
1796
|
-
out.push(`## ${layer} ${LAYER_NAMES[layer] ?? ''}`.trim())
|
|
2209
|
+
const lines = [`## ${layer} ${LAYER_NAMES[layer] ?? ''}`.trim()]
|
|
1797
2210
|
if (layer === PERSONA_LAYER) {
|
|
1798
|
-
if (persona.length === 0)
|
|
1799
|
-
else for (const line of persona)
|
|
1800
|
-
|
|
2211
|
+
if (persona.length === 0) lines.push('(未配置——人格层只能由人改配置,模型不可写)')
|
|
2212
|
+
else for (const line of persona) lines.push(`- ${line}`)
|
|
2213
|
+
} else {
|
|
2214
|
+
const rows = [...entries.values()]
|
|
2215
|
+
.filter((e) => e.layer === layer && !e.archived && branchVisible(e, b)
|
|
2216
|
+
&& cwdVisible(e, targetCwd) && visibleToBot(e, forBot))
|
|
2217
|
+
.sort((x, y) => (x.id < y.id ? -1 : 1))
|
|
2218
|
+
if (rows.length === 0) lines.push('(空)')
|
|
2219
|
+
else for (const e of rows) lines.push(renderLine(e))
|
|
2220
|
+
}
|
|
2221
|
+
layersOut.push({
|
|
2222
|
+
layer,
|
|
2223
|
+
lines,
|
|
2224
|
+
tokens: estimateTokens(lines.join('\n')),
|
|
2225
|
+
priority: INJECT_PRIORITY[layer] ?? 0,
|
|
2226
|
+
})
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
/**
|
|
2230
|
+
* 统一出口:默认返回字符串(向后兼容),`withMeta:true` 返回带 `cwdScoped` 的结构。
|
|
2231
|
+
* 同时把「本次注入是否按项目裁剪」写进 diagnostics —— 即使调用方只要字符串,
|
|
2232
|
+
* 上层诊断(UI / 调度器)也能查到。
|
|
2233
|
+
* @param {string} text
|
|
2234
|
+
*/
|
|
2235
|
+
function finish(text) {
|
|
2236
|
+
diagnostics.lastContextCwd = rawCwd ?? null
|
|
2237
|
+
diagnostics.lastContextCwdScoped = targetCwd !== undefined
|
|
2238
|
+
if (withMeta === true) {
|
|
2239
|
+
return { text, cwd: targetCwd ?? null, cwdScoped: targetCwd !== undefined, layers: want.slice() }
|
|
1801
2240
|
}
|
|
1802
|
-
|
|
1803
|
-
.filter((e) => e.layer === layer && !e.archived && branchVisible(e, b) && visibleToBot(e, forBot))
|
|
1804
|
-
.sort((x, y) => (x.id < y.id ? -1 : 1))
|
|
1805
|
-
if (rows.length === 0) out.push('(空)')
|
|
1806
|
-
else for (const e of rows) out.push(renderLine(e))
|
|
2241
|
+
return text
|
|
1807
2242
|
}
|
|
1808
|
-
|
|
2243
|
+
|
|
2244
|
+
if (budget <= 0) {
|
|
2245
|
+
const out = header.slice()
|
|
2246
|
+
for (const ly of layersOut) out.push('', ...ly.lines)
|
|
2247
|
+
return finish(out.join('\n'))
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
// 按**优先级**(L3 > L2 > L1 > L0)从高到低决定保留顺序,而不是按渲染顺序
|
|
2251
|
+
// (渲染顺序是 L0→L3,直接遍历会让"最先渲染的层"占满预算,与注释说的
|
|
2252
|
+
// 「L3 离当前任务最近、优先保留」相反 —— 2026-11-04 实测抓到并修)。
|
|
2253
|
+
// 例外:L0 人格是身份,即使优先级最低也**永远保留**(丢了身份比丢几条偏好严重)。
|
|
2254
|
+
const headerTokens = estimateTokens(header.join('\n'))
|
|
2255
|
+
const byPriority = [...layersOut].sort((x, y) => (y.priority - x.priority) || (x.layer < y.layer ? -1 : 1))
|
|
2256
|
+
let used = headerTokens
|
|
2257
|
+
const kept = []
|
|
2258
|
+
// 先无条件保留 L0
|
|
2259
|
+
for (const ly of byPriority) {
|
|
2260
|
+
if (ly.layer === PERSONA_LAYER) { kept.push(ly); used += ly.tokens }
|
|
2261
|
+
}
|
|
2262
|
+
for (const ly of byPriority) {
|
|
2263
|
+
if (ly.layer === PERSONA_LAYER) continue
|
|
2264
|
+
if (used + ly.tokens <= budget) { kept.push(ly); used += ly.tokens }
|
|
2265
|
+
}
|
|
2266
|
+
// 渲染仍按 L0→L3 的自然阅读顺序
|
|
2267
|
+
const out2 = header.slice()
|
|
2268
|
+
for (const layer of want) {
|
|
2269
|
+
const ly = kept.find((k) => k.layer === layer)
|
|
2270
|
+
if (ly) out2.push('', ...ly.lines)
|
|
2271
|
+
}
|
|
2272
|
+
if (layersOut.length !== kept.length) {
|
|
2273
|
+
const dropped = layersOut.filter((l) => !kept.includes(l)).map((l) => l.layer).join(',')
|
|
2274
|
+
out2.push(`\n# ⚠️ tokenBudget 生效(预算 ${budget} token):已丢弃 ${dropped} 层`
|
|
2275
|
+
+ '(按 L3>L2>L1>L0 优先级保留,L0 人格永不丢弃)')
|
|
2276
|
+
}
|
|
2277
|
+
return finish(out2.join('\n'))
|
|
1809
2278
|
}
|
|
1810
2279
|
|
|
1811
2280
|
// ── API:回放与统计 ────────────────────────────────────────────────────────
|
|
@@ -1826,6 +2295,80 @@ export function createMemory(ctx, config = {}) {
|
|
|
1826
2295
|
}
|
|
1827
2296
|
}
|
|
1828
2297
|
|
|
2298
|
+
/**
|
|
2299
|
+
* 迁移入口:把「未归属项目」的 L3/L4/L5 老条目**批量**打上指定 `cwd`。
|
|
2300
|
+
*
|
|
2301
|
+
* 为什么需要它:改造前写入的老条目没有 `cwd` 字段,按 cwd 过滤时它们在任何具体项目
|
|
2302
|
+
* 视图里都不可见(见 `cwdVisible` 的取舍)。用户需要一个「把历史记忆归到当前项目」的
|
|
2303
|
+
* **显式、可审计**入口 —— 本函数就是那个入口:一次性归属、返回计数与明细,不猜归属。
|
|
2304
|
+
*
|
|
2305
|
+
* 语义细节(都有明确理由,不是随手写的):
|
|
2306
|
+
* - 只处理 **L3/L4/L5**:L0/L1/L2 是全局层,没有「归属项目」这回事。
|
|
2307
|
+
* - 只处理 **`cwd` 尚未设置**的条目:已归属其他项目的**不动**(避免把 B 项目的记忆
|
|
2308
|
+
* 偷到 A 项目),它们计入 `alreadyScoped`,不算 migrated 也不算 skipped。
|
|
2309
|
+
* - **已归档的未归属条目不动**(计入 `skipped`,code=`MEMORY_ENTRY_ARCHIVED`):
|
|
2310
|
+
* 归档是审计证据,迁移只作用于活跃条目;`restore()` 之后可以再迁一次。
|
|
2311
|
+
* - 迁移只改 `cwd` 元数据、**不动正文**,因此按 `explicit` 写者身份执行
|
|
2312
|
+
* (这是人发起的显式动作,不违反规定 2 的「review/consolidate 不得改 explicit」)。
|
|
2313
|
+
* - 每条一次 `commit`(rev+1 + 事件流 `action:'migrate'`),所以迁移本身也可回放/可审计;
|
|
2314
|
+
* 单条失败不中断整批,失败条目进 `skippedEntries`(可见,不静默)。
|
|
2315
|
+
*
|
|
2316
|
+
* @param {{ cwd: string, by?: string, now?: number }} input
|
|
2317
|
+
* @returns {{ cwd: string, migrated: number, skipped: number, alreadyScoped: number,
|
|
2318
|
+
* migratedIds: string[], skippedEntries: object[] }}
|
|
2319
|
+
*/
|
|
2320
|
+
function migrateCwd(input = {}) {
|
|
2321
|
+
const { cwd, by, now } = input ?? {}
|
|
2322
|
+
const target = normalizeCwd(cwd, 'cwd', 'migrateCwd')
|
|
2323
|
+
const author = by === undefined || by === null ? 'human' : requireString(by, 'by', 'migrateCwd')
|
|
2324
|
+
const at = normalizeNow(now, 'migrateCwd')
|
|
2325
|
+
|
|
2326
|
+
const migratedIds = []
|
|
2327
|
+
const skippedEntries = []
|
|
2328
|
+
let alreadyScoped = 0
|
|
2329
|
+
|
|
2330
|
+
for (const entry of [...entries.values()]) {
|
|
2331
|
+
if (!cwdScopedLayer(entry.layer)) continue
|
|
2332
|
+
if (typeof entry.cwd === 'string' && entry.cwd !== '') { alreadyScoped += 1; continue }
|
|
2333
|
+
if (entry.archived) {
|
|
2334
|
+
skippedEntries.push({
|
|
2335
|
+
id: entry.id,
|
|
2336
|
+
layer: entry.layer,
|
|
2337
|
+
code: 'MEMORY_ENTRY_ARCHIVED',
|
|
2338
|
+
reason: '已归档条目不改(归档是证据;restore() 后可再迁移一次)',
|
|
2339
|
+
})
|
|
2340
|
+
continue
|
|
2341
|
+
}
|
|
2342
|
+
// cwd 未归属且活跃 → 归属到目标项目(只改 cwd 元数据,正文一字不动)
|
|
2343
|
+
const before = clone(entry)
|
|
2344
|
+
const next = clone(entry)
|
|
2345
|
+
next.rev = entry.rev + 1
|
|
2346
|
+
next.updatedAt = at
|
|
2347
|
+
next.updatedBy = author
|
|
2348
|
+
next.cwd = target
|
|
2349
|
+
try {
|
|
2350
|
+
migratedIds.push(commit(entry.id, 'migrate', author, at, before, next).id)
|
|
2351
|
+
} catch (error) {
|
|
2352
|
+
if (!(error instanceof MemoryError)) throw error
|
|
2353
|
+
skippedEntries.push({ id: entry.id, layer: entry.layer, code: error.code, reason: error.message })
|
|
2354
|
+
warn(`[cs-bots/memory] migrateCwd 跳过 ${entry.id}:${error.code} —— ${error.message}`)
|
|
2355
|
+
}
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
if (migratedIds.length > 0) {
|
|
2359
|
+
info(`[cs-bots/memory] migrateCwd:${migratedIds.length} 条未归属条目 → cwd=${target}`
|
|
2360
|
+
+ `(跳过 ${skippedEntries.length} 条,已归属他处 ${alreadyScoped} 条)`)
|
|
2361
|
+
}
|
|
2362
|
+
return {
|
|
2363
|
+
cwd: target,
|
|
2364
|
+
migrated: migratedIds.length,
|
|
2365
|
+
skipped: skippedEntries.length,
|
|
2366
|
+
alreadyScoped,
|
|
2367
|
+
migratedIds,
|
|
2368
|
+
skippedEntries,
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
|
|
1829
2372
|
/** 统计(给记忆中心页面与诊断用)。 */
|
|
1830
2373
|
function stats() {
|
|
1831
2374
|
const byLayer = {}
|
|
@@ -1833,12 +2376,28 @@ export function createMemory(ctx, config = {}) {
|
|
|
1833
2376
|
let archived = 0
|
|
1834
2377
|
let conflict = 0
|
|
1835
2378
|
const bySource = {}
|
|
2379
|
+
// 2026-11-04 增强(调研驱动):按层估 token(token 计量),
|
|
2380
|
+
// 让 UI/诊断能看到"记忆注入花了多少 token"(MemOS/Mem0 统一 token 计量的落地)。
|
|
2381
|
+
const layerTokens = {}
|
|
2382
|
+
let injectedTokens = 0
|
|
2383
|
+
// cwd 聚合(2026-11-04 对齐记忆进化 #2):byCwd 只统计**有 cwd 的条目**(含归档 ——
|
|
2384
|
+
// 这是库分布,不是注入量);未归属的隔离层条目单独计数,供 UI 提示「去迁移」。
|
|
2385
|
+
const byCwd = {}
|
|
2386
|
+
let cwdUnassignedActive = 0
|
|
2387
|
+
let cwdUnassignedArchived = 0
|
|
1836
2388
|
for (const e of entries.values()) {
|
|
1837
2389
|
const slot = byLayer[e.layer] ?? (byLayer[e.layer] = { active: 0, archived: 0, conflict: 0 })
|
|
1838
|
-
if (e.archived) { slot.archived += 1; archived += 1 } else slot.active += 1
|
|
2390
|
+
if (e.archived) { slot.archived += 1; archived += 1 } else { slot.active += 1; injectedTokens += estimateTokens(renderLine(e)) }
|
|
2391
|
+
layerTokens[e.layer] = (layerTokens[e.layer] ?? 0) + estimateTokens(renderLine(e))
|
|
1839
2392
|
if (e.conflict === true) { slot.conflict += 1; conflict += 1 }
|
|
1840
2393
|
const src = e.source ?? 'unknown'
|
|
1841
2394
|
bySource[src] = (bySource[src] ?? 0) + 1
|
|
2395
|
+
if (typeof e.cwd === 'string' && e.cwd !== '') {
|
|
2396
|
+
byCwd[e.cwd] = (byCwd[e.cwd] ?? 0) + 1
|
|
2397
|
+
} else if (cwdScopedLayer(e.layer)) {
|
|
2398
|
+
if (e.archived) cwdUnassignedArchived += 1
|
|
2399
|
+
else cwdUnassignedActive += 1
|
|
2400
|
+
}
|
|
1842
2401
|
}
|
|
1843
2402
|
return {
|
|
1844
2403
|
stateDir,
|
|
@@ -1846,6 +2405,8 @@ export function createMemory(ctx, config = {}) {
|
|
|
1846
2405
|
layers: LAYERS,
|
|
1847
2406
|
injectedLayers: INJECTED_LAYERS,
|
|
1848
2407
|
entryMax,
|
|
2408
|
+
// ⚠️ 修复(2026-11-04 测试,P2-2):UI 概览读 `stats.total`,此处之前没有 ⇒ 恒显示 `—`
|
|
2409
|
+
total: entries.size,
|
|
1849
2410
|
persona: persona.length,
|
|
1850
2411
|
entries: entries.size,
|
|
1851
2412
|
active: entries.size - archived,
|
|
@@ -1853,6 +2414,21 @@ export function createMemory(ctx, config = {}) {
|
|
|
1853
2414
|
conflicts: conflict,
|
|
1854
2415
|
byLayer,
|
|
1855
2416
|
bySource,
|
|
2417
|
+
// cwd 聚合:`byCwd` 是需求要求的形态(cwd → 条数,只含有 cwd 的条目);
|
|
2418
|
+
// `cwd` 段是给 UI 的完整视图(隔离层清单 + 未归属计数,用于提示「历史记忆待归项目」)。
|
|
2419
|
+
byCwd,
|
|
2420
|
+
cwd: {
|
|
2421
|
+
layers: CWD_LAYERS,
|
|
2422
|
+
all: CWD_ALL,
|
|
2423
|
+
byCwd,
|
|
2424
|
+
unassignedActive: cwdUnassignedActive,
|
|
2425
|
+
unassignedArchived: cwdUnassignedArchived,
|
|
2426
|
+
},
|
|
2427
|
+
tokens: {
|
|
2428
|
+
byLayer: layerTokens,
|
|
2429
|
+
injected: INJECTED_LAYERS,
|
|
2430
|
+
injectedEstimate: injectedTokens,
|
|
2431
|
+
},
|
|
1856
2432
|
events: events.length,
|
|
1857
2433
|
search: { backend: diagnostics.searchBackend, reason: diagnostics.searchBackendReason, tokenize: diagnostics.ftsTokenize },
|
|
1858
2434
|
hydration: {
|
|
@@ -1881,19 +2457,23 @@ export function createMemory(ctx, config = {}) {
|
|
|
1881
2457
|
paths: Object.freeze({ stateDir, jsonl: jsonlPath, snapshot: snapPath, fts: ftsPath }),
|
|
1882
2458
|
layers: LAYERS,
|
|
1883
2459
|
injectedLayers: INJECTED_LAYERS,
|
|
2460
|
+
cwdLayers: CWD_LAYERS,
|
|
1884
2461
|
entryMax,
|
|
1885
2462
|
add,
|
|
1886
2463
|
get,
|
|
1887
2464
|
list,
|
|
1888
2465
|
update,
|
|
1889
2466
|
remove,
|
|
2467
|
+
purge,
|
|
1890
2468
|
archive,
|
|
1891
2469
|
restore,
|
|
1892
2470
|
review,
|
|
1893
2471
|
consolidate,
|
|
1894
2472
|
search,
|
|
1895
2473
|
contextFor,
|
|
2474
|
+
migrateCwd,
|
|
1896
2475
|
history,
|
|
2476
|
+
notesForTask,
|
|
1897
2477
|
replay,
|
|
1898
2478
|
stats,
|
|
1899
2479
|
dispose,
|