@modusensus/dsh-mneme 0.8.16 → 0.9.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/README.md +16 -3
- package/assets/icon.svg +2 -2
- package/docs/CONFIGURATION.md +2 -1
- package/docs/CONTINUITY.md +18 -3
- package/docs/WORKFLOWS.md +299 -0
- package/lib/api.js +124 -8
- package/lib/candidates.js +454 -0
- package/lib/capabilities.js +819 -0
- package/lib/client.js +1703 -182
- package/lib/config.js +31 -5
- package/lib/continuity.js +35 -6
- package/lib/dream/decisions.js +17 -1
- package/lib/dream/sleep.js +10 -2
- package/lib/dream.js +5 -1
- package/lib/entities/extractor.js +4 -0
- package/lib/index.js +49 -12
- package/lib/inject.js +6 -1
- package/lib/pipeline.js +534 -0
- package/lib/scope.js +16 -4
- package/lib/serve.js +1 -1
- package/lib/service.js +59 -18
- package/lib/session-usage.js +350 -0
- package/lib/settings.js +7 -1
- package/lib/store.js +55 -3
- package/lib/summarize.js +31 -0
- package/lib/tools.js +11 -2
- package/package.json +1 -1
- package/scripts/publish-gate.mjs +197 -0
- package/src/api.js +124 -8
- package/src/candidates.js +454 -0
- package/src/capabilities.js +819 -0
- package/src/config.js +31 -5
- package/src/continuity.js +35 -6
- package/src/dream/decisions.js +17 -1
- package/src/dream/sleep.js +10 -2
- package/src/dream.js +5 -1
- package/src/entities/extractor.js +4 -0
- package/src/index.js +49 -12
- package/src/inject.js +6 -1
- package/src/pipeline.js +534 -0
- package/src/scope.js +16 -4
- package/src/serve.js +1 -1
- package/src/service.js +59 -18
- package/src/session-usage.js +350 -0
- package/src/settings.js +7 -1
- package/src/store.js +55 -3
- package/src/summarize.js +31 -0
- package/src/tools.js +11 -2
- package/test/api.test.js +147 -7
- package/test/candidates.test.js +345 -0
- package/test/capabilities.test.js +577 -0
- package/test/client.test.js +361 -2
- package/test/continuity.test.js +84 -0
- package/test/embed-ready-poll.test.js +94 -0
- package/test/entities.test.js +13 -2
- package/test/failure-query.test.js +147 -0
- package/test/failure-retention.test.js +105 -0
- package/test/graph-api.test.js +36 -2
- package/test/inject-recall.test.js +32 -4
- package/test/llm-audit.test.js +92 -1
- package/test/mint-source.test.js +202 -0
- package/test/pipeline.test.js +505 -0
- package/test/placement.test.js +1 -1
- package/test/publish-gate.test.js +272 -0
- package/test/recall-retention.test.js +128 -0
- package/test/release-workflow.test.js +77 -0
- package/test/scope-distill-stamp.test.js +135 -0
- package/test/serve.test.js +1 -1
- package/test/session-usage.test.js +308 -0
- package/test/store-write-path-columns.test.js +191 -0
- package/test/summarize.test.js +176 -0
- package/test/supersede-state-tracking.test.js +4 -4
- package/test/tools.test.js +16 -1
package/src/service.js
CHANGED
|
@@ -18,9 +18,14 @@ import { anchorSeeds, normalizePath, cascadeDepths, HOP_QUOTA } from "./graph/an
|
|
|
18
18
|
// 的 recall_runs `auto` 模式里被按需检索过 328 次:不是垃圾数据,只是进不了注入池。
|
|
19
19
|
// 收编后候选池 1362 → 1713(+25.8%),按 top_k=5 粗算约多占 1 席;重要度闸门对
|
|
20
20
|
// history 一视同仁(见 injectCandidates 的 importance >= threshold),低于闸门照旧出局。
|
|
21
|
-
// pattern
|
|
22
|
-
//
|
|
23
|
-
//
|
|
21
|
+
// pattern 一并收编:它一度被当成「只由手工写入(实测 40 条全 source=manual)」——那个
|
|
22
|
+
// 判据是错的。机器一直在 mint:2026-10-14 按 dream_runs 的 patterns.applied 逐轮求和,
|
|
23
|
+
// 得 144,与表内 pattern 行数 144 相等(72 轮 sleep 里 48 轮 ok,auto 轮 0),来源是
|
|
24
|
+
// src/dream/sleep.js 的 phasePatterns;当时的 source=manual 只是 saveWithDedupe 没拿到
|
|
25
|
+
// source 时的兜底值,不是「人写的」的意思(现在 mint 会带 source=sleep/dream)。
|
|
26
|
+
// 但它确实已被 dreamMergeGuard 列为长保留受保护类型(docs/CONFIGURATION.md)——巩固层
|
|
27
|
+
// 当资产护住、注入面一次不给看,是同一条口径的两头不一致。这一点与作者是谁无关,
|
|
28
|
+
// 收编的结论不变,错的只是当初用来支持它的那句前提。
|
|
24
29
|
const INJECT_TYPES = new Set(["preference", "project", "decision", "summary", "rejected_solution", "pitfall", "constraint", "history", "pattern"]);
|
|
25
30
|
|
|
26
31
|
// document 摘要行(#230):仅 documentMemoryEnabled 开启时进注入候选。独立
|
|
@@ -1293,7 +1298,10 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
1293
1298
|
let quality = null;
|
|
1294
1299
|
if (qf?.enabled === true) {
|
|
1295
1300
|
try {
|
|
1296
|
-
|
|
1301
|
+
// #430:近重复判定只要最近 20 条正文,别再为了 slice(0,20) 先物化整库
|
|
1302
|
+
// (all() + 逐行 toRow,5k 行实测 100ms 级;本查询 0.06ms 级)。口径与
|
|
1303
|
+
// all().slice(0, 20) 一致:不加 WHERE、updated_at DESC、同刻顺序未定义。
|
|
1304
|
+
const recentContents = store.recentContents(20);
|
|
1297
1305
|
quality = evaluateMemoryQuality(memory, {
|
|
1298
1306
|
minContentLength: qf.minContentLength ?? 10,
|
|
1299
1307
|
recentContents
|
|
@@ -1837,9 +1845,15 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
1837
1845
|
if ((config?.recallRecordDefault ?? true) && recallRecorder && injected.length > 0) {
|
|
1838
1846
|
try {
|
|
1839
1847
|
recallRecorder({
|
|
1840
|
-
// recall_runs.query 是 NOT NULL(store.js
|
|
1841
|
-
//
|
|
1842
|
-
|
|
1848
|
+
// recall_runs.query 是 NOT NULL(store.js 的 recall_runs 建表语句,
|
|
1849
|
+
// 2026-10-14 在 :75)—— 注入是主动曝光,
|
|
1850
|
+
// 没有查询词时用空串而非 null——SQLite 改列约束需重建表,不值得。
|
|
1851
|
+
// v0.9.0:调用方(inject.js 的 lastUserQuery,2026-10-14 在 :343)本来就带着本轮的
|
|
1852
|
+
// 用户原话传进来,这里丢掉等于把「当时想干什么」从审计里抹掉
|
|
1853
|
+
// (2026-10-13 快照:18,231/18,231 注入行 query 全空,全空比例
|
|
1854
|
+
// 100%——这个比率才是要记的,绝对行数随表长,引用要带时刻)。
|
|
1855
|
+
// 有就记,真没有才空串。
|
|
1856
|
+
query: q,
|
|
1843
1857
|
mode: "inject",
|
|
1844
1858
|
topK: maxItems,
|
|
1845
1859
|
threshold: null,
|
|
@@ -1944,6 +1958,9 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
1944
1958
|
setArchived: (mid, v) => archiveMemory(mid, v),
|
|
1945
1959
|
transaction: (fn) => transaction(fn)
|
|
1946
1960
|
};
|
|
1961
|
+
// 不传 mintSource——本路径由人在面板上点确认,即便日后有人在这里加
|
|
1962
|
+
// create,落 saveWithDedupe 的 manual 兜底恰好就是对的语义(人触发的写入)。
|
|
1963
|
+
// 这是刻意的,不是漏传。
|
|
1947
1964
|
const { committed, failures } = applyDecisions(
|
|
1948
1965
|
[{ action: "conflict", winner: winnerId, loser: loserId, reason: row.reason ?? "" }],
|
|
1949
1966
|
applyService,
|
|
@@ -2150,18 +2167,22 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
2150
2167
|
try {
|
|
2151
2168
|
// 预先取本次要覆盖的全部活跃行(只读一次)。这里曾经是
|
|
2152
2169
|
// store.list({ limit: 500 })——活跃集超过 500 时镜像会静默少掉尾部记忆,
|
|
2153
|
-
// 而文件本身没有任何提示(#278
|
|
2154
|
-
//
|
|
2155
|
-
//
|
|
2156
|
-
// includeArchived:false
|
|
2157
|
-
//
|
|
2158
|
-
//
|
|
2159
|
-
//
|
|
2160
|
-
|
|
2170
|
+
// 而文件本身没有任何提示(#278 第一批)。在这里退回任何截断都不行——
|
|
2171
|
+
// 「宣称覆盖活跃集」与静默截断不能共存(这一条不变)。
|
|
2172
|
+
// #430:改成 list 的无界档(limit: null)+ SQL 侧过滤,替掉 all() + 逐行
|
|
2173
|
+
// filter——list 的默认 includeForgotten:false / includeArchived:false 与旧
|
|
2174
|
+
// filter 的 !forgotten && !archived 完全同口径(store.js 里就是这两条
|
|
2175
|
+
// clause),document 闸改由 excludeTypes 在 LIMIT 之前排除。旧写法先物化
|
|
2176
|
+
// 整库(embedding 列 + 逐行 27 字段 toRow)再逐行丢弃:被丢掉的行连带它们
|
|
2177
|
+
// 的物化一起省下了。要再压一层得换按 type 分页取,属另一批的事。
|
|
2178
|
+
const list = store.list({
|
|
2179
|
+
limit: null,
|
|
2180
|
+
order: "chrono",
|
|
2161
2181
|
// #296 第二批:镜像里的 documents.md 与 index.md 共用 documentMemoryEnabled
|
|
2162
2182
|
// 闸。关掉时把 document 行也从渲染集里去掉——sync 对「空 type」的既有处理
|
|
2163
2183
|
// 会把陈旧的 documents.md 删掉,不留一个「看着还在、其实已关」的视图。
|
|
2164
|
-
|
|
2184
|
+
excludeTypes: config?.documentMemoryEnabled === true ? null : ["document"]
|
|
2185
|
+
});
|
|
2165
2186
|
for (const memory of list) {
|
|
2166
2187
|
if (memory?.type && TYPE_FILE[memory.type]) {
|
|
2167
2188
|
coveredTypes.add(memory.type);
|
|
@@ -2524,11 +2545,31 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
2524
2545
|
// Async-initializing embedder: poll `ready` until it flips, then flush.
|
|
2525
2546
|
if ("ready" in emb && embedReadyTimer === null) {
|
|
2526
2547
|
let attempts = 0;
|
|
2548
|
+
let readyReadFailed = false;
|
|
2549
|
+
// 读 `ready` 必须先兜住异常再算天花板,否则天花板形同不存在:`ready` 是
|
|
2550
|
+
// getter(embedding.js 的 OpenAI embedder 直接读库),getter 一抛错,整条
|
|
2551
|
+
// `||` 的求值当场中断,右侧的 `attempts >= EMBED_READY_POLL_LIMIT` 永远轮
|
|
2552
|
+
// 不到 —— stopEmbedReadyPolling() 不执行,定时器就活过 3s 上限,并以未捕获
|
|
2553
|
+
// 异常的形式每 100ms 抛一次。实测(2026-10-14):卸载路径 store.close() 之后
|
|
2554
|
+
// 每次读 ready 都抛 "database is not open",5s 内读 46 次,天花板 31 被直接
|
|
2555
|
+
// 打穿,宿主事件循环因此永不排空。读不出来按「没就绪」处理,但上限照算。
|
|
2556
|
+
const readReady = () => {
|
|
2557
|
+
try {
|
|
2558
|
+
return emb.ready === true;
|
|
2559
|
+
} catch (err) {
|
|
2560
|
+
if (!readyReadFailed) {
|
|
2561
|
+
readyReadFailed = true; // 只报一次,别让 3s 上限变成 30 条刷屏
|
|
2562
|
+
logger?.warn?.("embedder ready probe failed, treating it as not-ready:", err);
|
|
2563
|
+
}
|
|
2564
|
+
return false;
|
|
2565
|
+
}
|
|
2566
|
+
};
|
|
2527
2567
|
embedReadyTimer = setInterval(() => {
|
|
2528
2568
|
attempts++;
|
|
2529
|
-
|
|
2569
|
+
const ready = readReady();
|
|
2570
|
+
if (ready || attempts >= EMBED_READY_POLL_LIMIT) {
|
|
2530
2571
|
stopEmbedReadyPolling();
|
|
2531
|
-
if (
|
|
2572
|
+
if (ready) flushEmbedPending();
|
|
2532
2573
|
else embedPending = []; // init never landed: drop the queue
|
|
2533
2574
|
}
|
|
2534
2575
|
}, EMBED_READY_POLL_MS);
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 能力使用证据的**读侧**:从 DSH 会话日志里解析「哪个能力在什么时候被调用过」。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独一个文件:这份解析有两个已经踩过的坑,都属「静默少一大截」型——
|
|
5
|
+
* · **zstd 是多帧的**:`zlib.zstdDecompressSync(buf)` 只返回**第一帧**,不报错、不警告。
|
|
6
|
+
* 实测一个 1.5 MB 的会话文件只解出 291 字节,99% 的内容没被扫到,于是得出
|
|
7
|
+
* 「零命中」的假结论。必须自己按 magic `28 b5 2f fd` 切帧逐帧解。
|
|
8
|
+
* · **tool-call 的参数在 `arguments`,值是 JSON 字符串**(不是对象,也不是
|
|
9
|
+
* `parameters`):形如 `{"type":"tool-call","name":"skill","arguments":"{\"name\":\"agent-reach\"}"}`。
|
|
10
|
+
* 取错字段会得到 null,看着像「调用了但没记参数」。
|
|
11
|
+
* 把它们关在一个模块里,是为了让 `capabilities.js` 只负责「调用 + 投影」,不碰这些细节。
|
|
12
|
+
*
|
|
13
|
+
* **只读**:不解压到磁盘、不写任何文件、不改库。
|
|
14
|
+
*
|
|
15
|
+
* 与 `src/pipeline.js` 的 `deriveState` 同一套语汇:使用状态由**运行记录**推出来,
|
|
16
|
+
* 不由人写死——没记录就是 `candidate`(实话:不知道用过没有),有记录是 `trusted`,
|
|
17
|
+
* 久未用是 `stale`。
|
|
18
|
+
*/
|
|
19
|
+
import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import zlib from "node:zlib";
|
|
23
|
+
|
|
24
|
+
/** zstd 帧头 magic。多帧文件里每帧都以此开头,切帧就靠它。 */
|
|
25
|
+
const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 会话日志文件名前缀。
|
|
29
|
+
*
|
|
30
|
+
* **不是只认 `session.v4`** —— 本机实测同一会话树下并存三种格式:
|
|
31
|
+
* · `session.v4.jsonl.zstd`(219 个)
|
|
32
|
+
* · `session.v3.jsonl.zstd`(218 个)
|
|
33
|
+
* · `session.jsonl.zstd`(72 个,无版本号的最早期形态)
|
|
34
|
+
* 只认 v4 会**静默漏掉一半以上的历史**(实测漏掉 50 次技能调用,其中 `euthyna` 44 次;
|
|
35
|
+
* `brainstorming` / `writing-plans` / `subagent-driven-development` 三个技能明明用过
|
|
36
|
+
* 却被报成 `candidate`)。所以这里只要求「文件名以 `session` 开头」——宁可多读几个
|
|
37
|
+
* 非会话文件(解析时自然会被 `tool-call` 过滤掉),也不要漏掉真实记录。
|
|
38
|
+
*/
|
|
39
|
+
const SESSION_FILE_PREFIX = "session";
|
|
40
|
+
|
|
41
|
+
/** 单次扫描最多读多少个文件——目录膨胀时宁可报错,也不要把一次面板刷新变成全盘扫描。 */
|
|
42
|
+
const MAX_SESSION_FILES = 2000;
|
|
43
|
+
|
|
44
|
+
/** 判定 `stale` 的默认天数。与 pipeline.js 的 90 天同源:都是「久未使用」的经验阈值。 */
|
|
45
|
+
export const STALE_AFTER_DAYS = 90;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 使用状态**三值**。
|
|
49
|
+
*
|
|
50
|
+
* **这是对 `pipeline.js` 四态(`candidate`/`trusted`/`stale`/`refuted`)的有意收敛,
|
|
51
|
+
* 不是遗漏**:`refuted` 在工作流里的含义是「这一步被运行记录判定为不成立」,而能力
|
|
52
|
+
* 的「被使用」记录**推不出**任何等价结论——一个能力被调用过,只能说「用过」,不能
|
|
53
|
+
* 因此说它「已被推翻」。能力的弃用要另立判据(比如「用它之后任务失败了」),那是
|
|
54
|
+
* 另一件事,**不能从调用次数硬凑**。所以这里只保留能从运行记录直接推出的三态;
|
|
55
|
+
* 少了的那一态宁可不做,也不拿 `candidate` 或 `stale` 冒充。
|
|
56
|
+
*/
|
|
57
|
+
export const USAGE_STATES = ["candidate", "trusted", "stale"];
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 会话根目录。默认由 `DSH_HOME` 推导,**不写死本机路径**——仓库里出现盘符是红线。
|
|
61
|
+
* 传 `dshHome` 可覆盖(测试用临时目录,生产用环境变量)。
|
|
62
|
+
*/
|
|
63
|
+
export function resolveSessionDir({ dshHome = process.env.DSH_HOME || join(homedir(), ".dsh") } = {}) {
|
|
64
|
+
return join(String(dshHome), "sessions");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 逐帧解一个多帧 zstd buffer,**边解边回调**,不把全文拼成一个字符串。
|
|
69
|
+
*
|
|
70
|
+
* 用回调而不是返回字符串:实测一次读上百个会话文件会 OOM(`FATAL ERROR: Reached
|
|
71
|
+
* heap limit`)。逐帧交给回调,内存只保留一帧。
|
|
72
|
+
*/
|
|
73
|
+
/**
|
|
74
|
+
* 多帧 zstd 逐帧解压,**边解边回调**。
|
|
75
|
+
*
|
|
76
|
+
* 返回 `{ frames, failed, bytes }`——`failed` 是**解不出来**的帧数。
|
|
77
|
+
*
|
|
78
|
+
* **关于「残缺帧」的一个实测结论**:zstd 是流式格式,**尾部被截断的帧会解出「部分
|
|
79
|
+
* 文本」而不抛错**(实测截断到一半的帧解出 `"这一帧会被截"`)。我试过两种完整性判据
|
|
80
|
+
* 都不可靠:①「截掉末字节再解、比对是否相同」——对短截断失效;②流式 API 的
|
|
81
|
+
* `end` 事件——截断帧照样正常结束、不报错。Node 的 zstd 绑定没有暴露「还有多少输入
|
|
82
|
+
* 未被消费」。
|
|
83
|
+
*
|
|
84
|
+
* 所以**不猜帧的完整性**,改在**行这一层**兜底:残缺帧解出来的尾部一定是半行,而
|
|
85
|
+
* jsonl 的解析器本来就要求整行是合法 JSON —— 半行会 `JSON.parse` 失败被丢掉。
|
|
86
|
+
* 代价是:**一条调用记录若正好被截在中间,会被丢弃**(而不是被当成完整记录误用)。
|
|
87
|
+
* 这个取舍是明确的:**宁可少一条,不要错一条**。
|
|
88
|
+
*
|
|
89
|
+
* 另外,**只有最后一帧可能被截断**(中间帧的边界由下一个 magic 保证),所以
|
|
90
|
+
* `failed` 只在真的解不出来时增长,不把「末帧可能是半行」计进去——那样会天天报警。
|
|
91
|
+
*/
|
|
92
|
+
export function forEachFrame(buf, onText) {
|
|
93
|
+
let offset = 0;
|
|
94
|
+
let frames = 0;
|
|
95
|
+
let failed = 0;
|
|
96
|
+
while (offset < buf.length) {
|
|
97
|
+
const start = buf.indexOf(ZSTD_MAGIC, offset);
|
|
98
|
+
if (start === -1) break;
|
|
99
|
+
let end = buf.indexOf(ZSTD_MAGIC, start + 4);
|
|
100
|
+
if (end === -1) end = buf.length;
|
|
101
|
+
try {
|
|
102
|
+
onText(zlib.zstdDecompressSync(buf.subarray(start, end)).toString("utf8"));
|
|
103
|
+
frames += 1;
|
|
104
|
+
} catch {
|
|
105
|
+
// 帧头都坏了:计入 failed,继续后面的帧。
|
|
106
|
+
failed += 1;
|
|
107
|
+
}
|
|
108
|
+
offset = end;
|
|
109
|
+
}
|
|
110
|
+
return { frames, failed };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 递归找出所有会话日志文件。
|
|
115
|
+
*
|
|
116
|
+
* 返回 `{ files, unreadable, truncated }`——**目录读不动时必须留痕**。
|
|
117
|
+
*
|
|
118
|
+
* 为什么这是关键:早期版本用 `existsSync` 定 `present`、`readdirSync` 失败直接 `return`,
|
|
119
|
+
* 于是「目录存在但不可读(ACL 拒绝、权限不足)」与「目录存在且为空」产出**逐字段相同**
|
|
120
|
+
* 的读数(`present=true, scannedFiles=0, unreadable=[]`),清单上 175 条技能全 `candidate`。
|
|
121
|
+
* 那正是本仓库反复防的那类错:**读不到被显示成「读到但没有」**。所以 readdir 失败要
|
|
122
|
+
* 记进 `unreadable`,让上层能把它与「空目录」分辨开。
|
|
123
|
+
*
|
|
124
|
+
* `limit` 可注入(默认 `MAX_SESSION_FILES`)。**早期版本把上限写死在遍历里**,于是
|
|
125
|
+
* 调用方传 `maxFiles: 5000` 也只拿到 2000 个——上限在内层生效、外层无从知道,
|
|
126
|
+
* 又是「静默截断」。现在上限只在这里生效,且**超限时 `truncated` 为 true**。
|
|
127
|
+
*/
|
|
128
|
+
export function findSessionFiles(dir, { limit = MAX_SESSION_FILES } = {}) {
|
|
129
|
+
const cap = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : MAX_SESSION_FILES;
|
|
130
|
+
const result = { files: [], unreadable: [], present: false, truncated: false };
|
|
131
|
+
if (!existsSync(dir)) return result;
|
|
132
|
+
result.present = true;
|
|
133
|
+
const walk = (current) => {
|
|
134
|
+
if (result.files.length >= cap) {
|
|
135
|
+
result.truncated = true;
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
let entries;
|
|
139
|
+
try {
|
|
140
|
+
entries = readdirSync(current, { withFileTypes: true });
|
|
141
|
+
} catch (error) {
|
|
142
|
+
// 读不动就说出来——不静默 return(那会与「空目录」同形)。
|
|
143
|
+
result.unreadable.push({ dir: current, detail: `目录读失败:${error.message}` });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
for (const entry of entries) {
|
|
147
|
+
if (result.files.length >= cap) {
|
|
148
|
+
result.truncated = true;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const full = join(current, entry.name);
|
|
152
|
+
// 符号链接要单独认:Dirent 对 symlink 的 isDirectory() 返回 false(踩过一次)。
|
|
153
|
+
let isDir = entry.isDirectory();
|
|
154
|
+
if (entry.isSymbolicLink()) {
|
|
155
|
+
try {
|
|
156
|
+
isDir = statSync(full).isDirectory();
|
|
157
|
+
} catch {
|
|
158
|
+
// 悬空链接:计入 unreadable(目标不可达也是「读不到」的一种)。
|
|
159
|
+
result.unreadable.push({ dir: full, detail: "符号链接目标不可达" });
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (isDir) walk(full);
|
|
164
|
+
else if (entry.name.startsWith(SESSION_FILE_PREFIX)) result.files.push(full);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
walk(dir);
|
|
168
|
+
result.files.sort();
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* 从一行 jsonl 里抽出所有 tool-call。
|
|
174
|
+
*
|
|
175
|
+
* 返回 `{ name, args, turn, time }`——`args` 是**二次解析后的对象**,取不到就是 null
|
|
176
|
+
* (调用记录本身仍保留,只是参数缺失,这种要能看出来而不是丢掉整条)。
|
|
177
|
+
*/
|
|
178
|
+
export function toolCallsInLine(line) {
|
|
179
|
+
if (typeof line !== "string" || !line.includes('"tool-call"')) return [];
|
|
180
|
+
let obj;
|
|
181
|
+
try {
|
|
182
|
+
obj = JSON.parse(line);
|
|
183
|
+
} catch {
|
|
184
|
+
return [];
|
|
185
|
+
}
|
|
186
|
+
const content = obj?.data?.message?.content;
|
|
187
|
+
if (!Array.isArray(content)) return [];
|
|
188
|
+
const out = [];
|
|
189
|
+
for (const item of content) {
|
|
190
|
+
if (item?.type !== "tool-call" || typeof item.name !== "string") continue;
|
|
191
|
+
let args = null;
|
|
192
|
+
if (typeof item.arguments === "string") {
|
|
193
|
+
try {
|
|
194
|
+
args = JSON.parse(item.arguments);
|
|
195
|
+
} catch {
|
|
196
|
+
args = null;
|
|
197
|
+
}
|
|
198
|
+
} else if (item.arguments && typeof item.arguments === "object") {
|
|
199
|
+
args = item.arguments;
|
|
200
|
+
}
|
|
201
|
+
out.push({
|
|
202
|
+
name: item.name,
|
|
203
|
+
args,
|
|
204
|
+
turn: obj?.data?.turn ?? null,
|
|
205
|
+
time: Number.isFinite(obj?.time) ? obj.time : null
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* 一次调用对应哪个能力。三类能力的判定方式**故意不同**:
|
|
213
|
+
* · `skill` 工具 → 参数里的 `name` 就是技能名(照宿主 `skill` 工具的契约);
|
|
214
|
+
* · MCP 工具 → 公共名形如 `mcp__<serverName>__<rawName>`,取中间那段为 server;
|
|
215
|
+
* · 其余内置工具(read / pwsh 等)**不是能力清单里的东西**,返回 null 丢掉。
|
|
216
|
+
*
|
|
217
|
+
* 拿不到名字时返回 `{ kind, name: null }`,让上层能统计「对不上的条数」——
|
|
218
|
+
* 对不上是要报出来的事实,不是可以静默丢弃的噪音。
|
|
219
|
+
*/
|
|
220
|
+
export function identifyCapability(call) {
|
|
221
|
+
if (!call || typeof call.name !== "string") return null;
|
|
222
|
+
if (call.name === "skill") {
|
|
223
|
+
const name = call.args && typeof call.args.name === "string" ? call.args.name.trim() : "";
|
|
224
|
+
return { kind: "skill", name: name === "" ? null : name };
|
|
225
|
+
}
|
|
226
|
+
if (call.name.startsWith("mcp__")) {
|
|
227
|
+
const parts = call.name.split("__");
|
|
228
|
+
// mcp__<server>__<tool>:server 为空也算 MCP 调用,只是名字不可用。
|
|
229
|
+
const server = typeof parts[1] === "string" ? parts[1].trim() : "";
|
|
230
|
+
return { kind: "mcp", name: server === "" ? null : server, tool: parts.slice(2).join("__") || null };
|
|
231
|
+
}
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* 扫全部会话日志,产出「按能力聚合的使用记录」。
|
|
237
|
+
*
|
|
238
|
+
* 返回结构里的 `skills` / `mcp` 是 Map(能力名 → { uses, lastUsedAt, sessions }),
|
|
239
|
+
* 由上层投影成清单字段。`unmatched` 记对不上名字的调用数——如实报,不硬凑。
|
|
240
|
+
*
|
|
241
|
+
* `maxFiles` 现在**真的管用**(透传给 `findSessionFiles` 的 `limit`):早期版本把上限
|
|
242
|
+
* 写死在遍历里,传 5000 也只拿到 2000 个且不报截断——调用方以为扫全了。
|
|
243
|
+
*/
|
|
244
|
+
export function collectUsage({ sessionDir = resolveSessionDir(), maxFiles = MAX_SESSION_FILES } = {}) {
|
|
245
|
+
const found = findSessionFiles(sessionDir, { limit: maxFiles });
|
|
246
|
+
// 透传后 found.files 已按上限截好,slice 只是防御(limit 非法时)。
|
|
247
|
+
const files = found.files.slice(0, maxFiles);
|
|
248
|
+
const result = {
|
|
249
|
+
dir: sessionDir,
|
|
250
|
+
// `present` = 目录在;`readable` = 目录真能列出来。**两者必须分开报**,
|
|
251
|
+
// 否则「不可读」与「空目录」在读数上同形(那正是本仓库反复防的那类错)。
|
|
252
|
+
present: found.present,
|
|
253
|
+
readable: found.present && found.unreadable.length === 0,
|
|
254
|
+
// 文件数被上限截断时要说出来,否则「只扫了一部分」看不出来。
|
|
255
|
+
// 两个来源:内层遍历到上限(found.truncated),或调用方给的 maxFiles 更小。
|
|
256
|
+
truncated: found.truncated === true || found.files.length > files.length,
|
|
257
|
+
totalFiles: found.files.length,
|
|
258
|
+
scannedFiles: 0,
|
|
259
|
+
frames: 0,
|
|
260
|
+
failedFrames: 0,
|
|
261
|
+
skillCalls: 0,
|
|
262
|
+
mcpCalls: 0,
|
|
263
|
+
unmatched: 0,
|
|
264
|
+
skills: new Map(),
|
|
265
|
+
mcp: new Map(),
|
|
266
|
+
// 目录读不动、悬空链接、单文件读失败都记在这里——**读不到要有痕**。
|
|
267
|
+
unreadable: [...found.unreadable]
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const record = (map, name, time, sessionId) => {
|
|
271
|
+
const entry = map.get(name) || { uses: 0, lastUsedAt: null, sessions: new Set() };
|
|
272
|
+
entry.uses += 1;
|
|
273
|
+
if (Number.isFinite(time) && (entry.lastUsedAt === null || time > entry.lastUsedAt)) entry.lastUsedAt = time;
|
|
274
|
+
if (sessionId) entry.sessions.add(sessionId);
|
|
275
|
+
map.set(name, entry);
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
for (const file of files) {
|
|
279
|
+
let buf;
|
|
280
|
+
try {
|
|
281
|
+
buf = readFileSync(file);
|
|
282
|
+
} catch (error) {
|
|
283
|
+
result.unreadable.push({ file, detail: `读失败:${error.message}` });
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
result.scannedFiles += 1;
|
|
287
|
+
const sessionId = file.split(/[\\/]/).slice(-2, -1)[0] || null;
|
|
288
|
+
const stat = forEachFrame(buf, (text) => {
|
|
289
|
+
for (const line of text.split("\n")) {
|
|
290
|
+
if (!line.trim() || !line.includes('"tool-call"')) continue;
|
|
291
|
+
// time 在行的外层,toolCallsInLine 已带上;这里从整行再取一次 session 级信息。
|
|
292
|
+
for (const call of toolCallsInLine(line)) {
|
|
293
|
+
const identified = identifyCapability(call);
|
|
294
|
+
if (!identified) continue;
|
|
295
|
+
if (identified.name === null) {
|
|
296
|
+
result.unmatched += 1;
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if (identified.kind === "skill") {
|
|
300
|
+
result.skillCalls += 1;
|
|
301
|
+
record(result.skills, identified.name, call.time, sessionId);
|
|
302
|
+
} else {
|
|
303
|
+
result.mcpCalls += 1;
|
|
304
|
+
// MCP 按 **server** 聚合(清单里的 MCP 条目是 server 粒度),不是按工具。
|
|
305
|
+
record(result.mcp, identified.name, call.time, sessionId);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
result.frames += stat.frames;
|
|
311
|
+
result.failedFrames += stat.failed;
|
|
312
|
+
buf.fill(0);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return result;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* 由使用记录推使用状态。**照 `pipeline.js` 的 `deriveState` 写**:
|
|
320
|
+
* · 没有记录 → `candidate`(不知道用过没有,这是实话);
|
|
321
|
+
* · 有记录、最近一次在 `staleAfterDays` 天内 → `trusted`;
|
|
322
|
+
* · 有记录但最近一次早于阈值 → `stale`。
|
|
323
|
+
*
|
|
324
|
+
* 保留期会限制这个判定的可靠度:会话日志被清理后,久未用的能力会**退化成 candidate**
|
|
325
|
+
* (看起来像从没用过)。所以「candidate」的含义是「本机现存日志里没有记录」,
|
|
326
|
+
* 而不是「从未使用」——这句话必须由上层写进输出,否则会被读错。
|
|
327
|
+
*/
|
|
328
|
+
export function deriveUsageState(entry, { now = Date.now(), staleAfterDays = STALE_AFTER_DAYS } = {}) {
|
|
329
|
+
if (!entry || !Number.isFinite(entry.uses) || entry.uses === 0) return "candidate";
|
|
330
|
+
if (Number.isFinite(entry.lastUsedAt)) {
|
|
331
|
+
const ageDays = (now - entry.lastUsedAt) / 86400000;
|
|
332
|
+
if (Number.isFinite(ageDays) && ageDays > staleAfterDays) return "stale";
|
|
333
|
+
}
|
|
334
|
+
return "trusted";
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** 把 Map 里的聚合记录投影成清单友好的纯数组(含状态),按使用次数降序。 */
|
|
338
|
+
export function summarizeUsage(collection, { now = Date.now(), staleAfterDays = STALE_AFTER_DAYS } = {}) {
|
|
339
|
+
const toRows = (map) =>
|
|
340
|
+
[...map.entries()]
|
|
341
|
+
.map(([name, entry]) => ({
|
|
342
|
+
name,
|
|
343
|
+
uses: entry.uses,
|
|
344
|
+
lastUsedAt: Number.isFinite(entry.lastUsedAt) ? new Date(entry.lastUsedAt).toISOString() : null,
|
|
345
|
+
sessions: entry.sessions.size,
|
|
346
|
+
state: deriveUsageState(entry, { now, staleAfterDays })
|
|
347
|
+
}))
|
|
348
|
+
.sort((a, b) => b.uses - a.uses || a.name.localeCompare(b.name));
|
|
349
|
+
return { skills: toRows(collection.skills), mcp: toRows(collection.mcp) };
|
|
350
|
+
}
|
package/src/settings.js
CHANGED
|
@@ -140,7 +140,13 @@ const FEATURE_FLAG_BOOLEANS = [
|
|
|
140
140
|
// 落盘(distill_records)并给记忆挂窗口链接(memory_provenance);默认关。
|
|
141
141
|
// 点号键平铺存、合并时展开回 distillRecords 对象(同 llmAudit.enabled)。
|
|
142
142
|
// retentionDays 是 int,走下面的 INT_RANGES 表,不进开关白名单。
|
|
143
|
-
"distillRecords.enabled"
|
|
143
|
+
"distillRecords.enabled",
|
|
144
|
+
// v0.9.0:M.O.(Modus Operandi · 手法,beta)客户端视图开关。客户端读 /features
|
|
145
|
+
// 的 effective
|
|
146
|
+
// 决定是否注册座位(会话头图标 / 浮层 / 右栏 / 对话行卡片),服务端只负责把
|
|
147
|
+
// 这个键稳定暴露出来——键若不在 effective 里,客户端静默少键、一个座位都不注册。
|
|
148
|
+
// 默认关(beta、opt-in);顶层扁平键,因此不需要 NESTED_FLAG_PATHS 条目。
|
|
149
|
+
"actionPipelineEnabled"
|
|
144
150
|
];
|
|
145
151
|
// 整数开关的闭区间,与 config.js 里 z.natural().min().max() 对齐。
|
|
146
152
|
const FEATURE_FLAG_INT_RANGES = {
|
package/src/store.js
CHANGED
|
@@ -1565,11 +1565,24 @@ export function createStore(path) {
|
|
|
1565
1565
|
// "chrono" is pure newest-first — the stable order paged browsing (month
|
|
1566
1566
|
// tree, infinite scroll) needs; importance ordering would interleave
|
|
1567
1567
|
// months across pages.
|
|
1568
|
-
|
|
1568
|
+
// #430:chrono 的 **无界档** 不再追加 id 平局键。updated_at 上有 #202 建的
|
|
1569
|
+
// 索引,而带 id 的第二项排序键用不上它——5k 行实测同一份 SQL 只差这一项:
|
|
1570
|
+
// 100.6ms(带 id,EXPLAIN 多一个 USE TEMP B-TREE)对 23.4ms(纯 updated_at,
|
|
1571
|
+
// 直接走 idx_memories_updated)。分页档位必须留平局键(跨页拼接要求稳定,
|
|
1572
|
+
// 同刻两行在新旧页之间可能漏一条或重复一条);无界档没有跨页拼接,今天唯一
|
|
1573
|
+
// 的调用方 syncMirror 只用它算覆盖集,与顺序无关。
|
|
1574
|
+
const orderBy = order === "chrono"
|
|
1575
|
+
? (unbounded ? "updated_at DESC" : "updated_at DESC, id DESC")
|
|
1576
|
+
: "importance DESC, updated_at DESC, id";
|
|
1577
|
+
// Issue #430:与 all() 共用同一份显式列清单(memoryColumns 由 PRAGMA 派生,
|
|
1578
|
+
// 排除 embedding)。toRow 本就不输出向量,SELECT * 只是把全表最重的列拖进
|
|
1579
|
+
// 内存再丢弃——无界档实测 5k 行 263ms → 117ms,窗口档 50ms → 41ms,本函数
|
|
1580
|
+
// 的全部调用方(分页浏览、document 索引、sleep 池、注册器全量扫描)一起受益。
|
|
1581
|
+
const projected = `SELECT ${memoryColumnList} FROM memories`;
|
|
1569
1582
|
const rows = unbounded
|
|
1570
|
-
? db.prepare(
|
|
1583
|
+
? db.prepare(`${projected} ${where} ORDER BY ${orderBy}`).all(...params)
|
|
1571
1584
|
: db.prepare(
|
|
1572
|
-
|
|
1585
|
+
`${projected} ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`
|
|
1573
1586
|
).all(...params, lim, off);
|
|
1574
1587
|
return rows.map(toRow);
|
|
1575
1588
|
}
|
|
@@ -1581,6 +1594,18 @@ export function createStore(path) {
|
|
|
1581
1594
|
return rows.map(toRow);
|
|
1582
1595
|
}
|
|
1583
1596
|
|
|
1597
|
+
// Issue #430:写路径要的「最近若干条正文」。质量过滤的近重复判定只读 content
|
|
1598
|
+
// 这一列 + 前 20 行,此前写成 all().slice(0, 20) —— 全表逐行 toRow(27 字段、
|
|
1599
|
+
// 三次 JSON 解析)之后只截前 20 条正文;5k 行实测 100ms 级 → 0.06ms 级。
|
|
1600
|
+
// 口径与 all() 的前 N 条严格一致:不加 WHERE(archived/forgotten 仍参与近重复
|
|
1601
|
+
// 判定)、updated_at DESC、同刻顺序未定义。limit 走 sanitizePage,非正整数
|
|
1602
|
+
// 落回 20,调用方不必自己防 0/undefined。
|
|
1603
|
+
function recentContents(limit = 20) {
|
|
1604
|
+
const { limit: lim } = sanitizePage(limit, 0, 20);
|
|
1605
|
+
const rows = db.prepare("SELECT content FROM memories ORDER BY updated_at DESC LIMIT ?").all(lim);
|
|
1606
|
+
return rows.map((row) => row.content ?? "");
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1584
1609
|
/** Set (or clear with null) the embedding vector of a memory. */
|
|
1585
1610
|
function setEmbedding(id, vector) {
|
|
1586
1611
|
const json = Array.isArray(vector) && vector.length ? JSON.stringify(vector) : null;
|
|
@@ -1968,6 +1993,30 @@ export function createStore(path) {
|
|
|
1968
1993
|
|
|
1969
1994
|
// --- recall evaluation trail (方案 B: separate from the production audit) -
|
|
1970
1995
|
|
|
1996
|
+
/**
|
|
1997
|
+
* Rolling purge of the recall_runs production audit (v0.7.0): with recordRecall
|
|
1998
|
+
* defaulting ON, the table would otherwise grow unboundedly on a busy store.
|
|
1999
|
+
* Drop rows older than `days` (config.recallRetentionDays, default 90).
|
|
2000
|
+
* created_at is an ISO TEXT string, so the ISO bound compares lexicographically.
|
|
2001
|
+
* Returns the number of rows deleted (bookkeeping — never throws on empty).
|
|
2002
|
+
*
|
|
2003
|
+
* 2026-10-14 找回:这一对(本函数 + index.js 的启动调用)在 dd0ed78「release:
|
|
2004
|
+
* v0.7.12 + 编码记忆(codingRetrospect)蒸馏/…(合并 #73 与 #74)」里被整块弄丢。
|
|
2005
|
+
* 该提交不是 merge(单亲 cb5ec63),store.js 是从一个没有这段的版本重建的,
|
|
2006
|
+
* 于是函数与调用一起蒸发,而配置键 recallRetentionDays、README.md 与
|
|
2007
|
+
* docs/CONFIGURATION.md 的「recall_runs 滚动清理保留天数」一直留到今天。
|
|
2008
|
+
* 实测后果(2026-10-14 快照):本机 recall_runs 18,442 行、跨度 2026-09-08 →
|
|
2009
|
+
* 2026-10-13,即从那一天起只增不减。注意这张表是**活的、只增的**:同一个 10-13
|
|
2010
|
+
* 当天 14:43Z 是 18,442 行、20:47Z 已是 20,029 行,几小时就差一千五——所以任何
|
|
2011
|
+
* 行数只在测它的那一刻成立,引用时必须带时刻,否则日后对不上会被误读成出了错。
|
|
2012
|
+
* 这份审计里现在装的是用户原话,不是无信息的空串。
|
|
2013
|
+
*/
|
|
2014
|
+
function purgeRecallRunsOlderThan(days) {
|
|
2015
|
+
const cutoff = new Date(Date.now() - days * 86400000).toISOString();
|
|
2016
|
+
const result = db.prepare("DELETE FROM recall_runs WHERE created_at < ?").run(cutoff);
|
|
2017
|
+
return result.changes;
|
|
2018
|
+
}
|
|
2019
|
+
|
|
1971
2020
|
/**
|
|
1972
2021
|
* Persist one retrieval-evaluation snapshot into recall_evals — the test/eval
|
|
1973
2022
|
* sibling of recall_runs, deliberately stored apart so eval snapshots never
|
|
@@ -3102,6 +3151,8 @@ export function createStore(path) {
|
|
|
3102
3151
|
getUnrecalledSince,
|
|
3103
3152
|
list,
|
|
3104
3153
|
all,
|
|
3154
|
+
// #430:有界单列读,写路径(质量过滤)用。
|
|
3155
|
+
recentContents,
|
|
3105
3156
|
search,
|
|
3106
3157
|
setEmbedding,
|
|
3107
3158
|
getEmbeddings,
|
|
@@ -3122,6 +3173,7 @@ export function createStore(path) {
|
|
|
3122
3173
|
getRecallRun,
|
|
3123
3174
|
listRecallRuns,
|
|
3124
3175
|
listRecallRunsSince,
|
|
3176
|
+
purgeRecallRunsOlderThan,
|
|
3125
3177
|
saveRecallEval,
|
|
3126
3178
|
getRecallEval,
|
|
3127
3179
|
listRecallEvals,
|
package/src/summarize.js
CHANGED
|
@@ -4,6 +4,8 @@ import { STR, langOf } from "./lang.js";
|
|
|
4
4
|
// 重试一次不带 effort 字段)与 describeStreamFailure(流失败原因归一)——
|
|
5
5
|
// 三条链路同一降级语义,不另造第二份实现。
|
|
6
6
|
import { withEffortFallback, describeStreamFailure, EFFORT_REJECT_RE } from "./dream.js";
|
|
7
|
+
// v0.9.0(M.O. 项目记忆):蒸馏写入复用工具路径那把作用域解析器,见 createSummarizer。
|
|
8
|
+
import { createScopeResolver } from "./scope.js";
|
|
7
9
|
|
|
8
10
|
// 编码记忆蒸馏 prompt(codingRetrospect 开启时启用):在通用记忆之外,额外提取
|
|
9
11
|
// 三类编码专属记忆,专治重复踩坑 / 遗忘被否决方案 / 丢失工程约束。字段仍沿用
|
|
@@ -511,6 +513,16 @@ export function createSummarizer(ctx, service, config, deps = {}) {
|
|
|
511
513
|
const setTimer = typeof deps.setTimeoutFn === "function" ? deps.setTimeoutFn : setTimeout;
|
|
512
514
|
const clearTimer = typeof deps.clearTimeoutFn === "function" ? deps.clearTimeoutFn : clearTimeout;
|
|
513
515
|
|
|
516
|
+
// v0.9.0(M.O. 项目记忆):蒸馏写入也要盖作用域章。此前只有 memory_save 那条工具
|
|
517
|
+
// 路径盖章(tools.js 的 memory_save 里 resolveSessionScope 到 workspaceLabel 那一段,
|
|
518
|
+
// 2026-10-13 在 :173-193),而机器提炼是写入量的绝大多数——于是「项目记忆」
|
|
519
|
+
// 在库里根本不存在(全库带 scope 的行不足 3%,且带 scope 的全部来自工具路径)。
|
|
520
|
+
// 这里不另造口径:同一把解析器(scope.js 的 createScopeResolver,2026-10-13 在 :24)、同一把闸门
|
|
521
|
+
// (config.scopeEnabled === true)、同一对列(*_scope + *_scope_source='auto'),
|
|
522
|
+
// 与工具路径逐字对齐。解析器的入参形状是「工具 exec」(内部读 exec.agent.session),
|
|
523
|
+
// 蒸馏路径手里直接有会话对象,所以包一层壳喂进去——不改解析器的公共契约。
|
|
524
|
+
const resolveSessionScope = createScopeResolver({ ctx, config, logger: ctx.logger });
|
|
525
|
+
|
|
514
526
|
const inFlight = new Map();
|
|
515
527
|
// Issue #127:per-session 上一次实际开跑时刻(最小间隔闸门用)。与 inFlight
|
|
516
528
|
// 同生命周期,dispose 时一并清空。
|
|
@@ -873,6 +885,13 @@ export function createSummarizer(ctx, service, config, deps = {}) {
|
|
|
873
885
|
const dedupeMode = config.summarizeDedupeMode ?? "off";
|
|
874
886
|
let deduped = 0;
|
|
875
887
|
let dedupeMaxSim = 0;
|
|
888
|
+
// v0.9.0(M.O. 项目记忆):一次窗口解析一次——同一蒸馏窗口里所有条目同属一个
|
|
889
|
+
// 工作区、一个 agent 身份,逐条解析是白花钱。解析不到就落 NULL(NULL = 未标注 =
|
|
890
|
+
// 全局可见),且解析器绝不抛错(scope.js 的硬约束:记忆写入不该被作用域拖挂)。
|
|
891
|
+
// 闸门与 tools.js 的 autoStamping(2026-10-13 在 :180)同一把:关着就一个字段都不加,老行为不变。
|
|
892
|
+
const writeScope = config?.scopeEnabled === true
|
|
893
|
+
? resolveSessionScope({ agent: { session } })
|
|
894
|
+
: null;
|
|
876
895
|
const writes = [];
|
|
877
896
|
for (const entry of entries) {
|
|
878
897
|
// Provenance: the summarizer runs on a real session (turn/end hook), so
|
|
@@ -917,6 +936,18 @@ export function createSummarizer(ctx, service, config, deps = {}) {
|
|
|
917
936
|
const outcome = service.saveWithDedupe({
|
|
918
937
|
...entry,
|
|
919
938
|
source,
|
|
939
|
+
// 合并进既有行时不盖章,但这件事由 saveWithDedupe 的合并分支自己保证
|
|
940
|
+
// (它只认 explicit 来源升级,从不采纳这里传的 auto 字段)——调用方不能
|
|
941
|
+
// 靠「省略字段」来表达这个意图,因为省略会反向把合并也挡掉:
|
|
942
|
+
// findSessionDuplicate 只按 type+source+时间窗找候选,不看 scope;找到
|
|
943
|
+
// 候选后由 saveWithDedupe 的 scopeMatches 门决定并或不并。这里一省略,
|
|
944
|
+
// 新条目的 scope 就是 null,与已盖过章的候选不等,门必拒 → 回落新建,
|
|
945
|
+
// 而那一行同样是新行、同样该带章,却没有章可带。实测两轮蒸馏正是如此:
|
|
946
|
+
// 第二轮命中第一轮、门拒后新建,留下同题两行一行 auto 一行 NULL——
|
|
947
|
+
// 作用域隔离没做成,重复倒留下了。传字段、让合并分支按自己的口径处理,
|
|
948
|
+
// 才是「不重贴」与「新行必贴」两个要求的唯一相容解。
|
|
949
|
+
...(writeScope ? { agent_scope: writeScope.agent_scope, agent_scope_source: "auto" } : {}),
|
|
950
|
+
...(writeScope ? { workspace_scope: writeScope.workspace_scope, workspace_scope_source: "auto" } : {}),
|
|
920
951
|
...(dup ? { _mergeInto: dup.memory.id } : {})
|
|
921
952
|
});
|
|
922
953
|
// created 与 merged 两条成功分支都返回 memory(含 id);merged 时 id 是
|