@modusensus/dsh-mneme 0.11.0 → 0.13.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 +54 -21
- package/docs/CONFIGURATION.md +45 -4
- package/docs/DIRECTORIES.md +114 -0
- package/docs/DISTILL.md +64 -0
- package/docs/DSH-GO.md +97 -0
- package/docs/ENTITIES.md +5 -3
- package/docs/FORGET.md +77 -0
- package/docs/KB.md +110 -0
- package/docs/SLEEP.md +28 -3
- package/docs/STORAGE.md +23 -0
- package/lib/api-standalone.js +4 -1
- package/lib/api.js +744 -26
- package/lib/attachment-text.js +122 -0
- package/lib/choro-command.js +9 -2
- package/lib/client.js +3752 -819
- package/lib/config.js +171 -2
- package/lib/continuity.js +7 -6
- package/lib/diary/auto.js +190 -0
- package/lib/diary/compose.js +440 -0
- package/lib/diary/dir.js +59 -0
- package/lib/diary/export.js +97 -0
- package/lib/diary/index.js +26 -0
- package/lib/diary/library.js +189 -0
- package/lib/diary/qq-inbox.js +142 -0
- package/lib/diary/rollover.js +61 -0
- package/lib/diary/session-provider.js +122 -0
- package/lib/document.js +9 -3
- package/lib/dream/clustering.js +6 -1
- package/lib/dream/decisions.js +56 -5
- package/lib/dream/sleep.js +616 -15
- package/lib/dream.js +45 -13
- package/lib/dsh-go/host.js +162 -0
- package/lib/dsh-go/panel.js +2091 -0
- package/lib/dsh-go/text.js +342 -0
- package/lib/dsh-go/views/common.js +68 -0
- package/lib/dsh-go/views/entity.js +48 -0
- package/lib/dsh-go/views/memory.js +198 -0
- package/lib/dsh-go/views/root.js +30 -0
- package/lib/dsh-go/views/settings.js +398 -0
- package/lib/dsh-go/views/status.js +51 -0
- package/lib/embedding.js +42 -8
- package/lib/entities/extractor.js +36 -7
- package/lib/hot-memory.js +8 -9
- package/lib/index.js +131 -5
- package/lib/inject.js +192 -19
- package/lib/kb/index.js +309 -0
- package/lib/kb/routes.js +220 -0
- package/lib/kb/tools.js +148 -0
- package/lib/lang.js +115 -4
- package/lib/peak-hours.js +57 -2
- package/lib/profile-facts.js +356 -0
- package/lib/semantic.js +347 -60
- package/lib/service.js +302 -70
- package/lib/settings.js +117 -14
- package/lib/store.js +830 -55
- package/lib/summarize.js +38 -11
- package/lib/tools.js +173 -25
- package/lib/user-settings.js +175 -23
- package/lib/vector-index.js +76 -6
- package/lib/wiki/cli-hint.js +90 -0
- package/lib/wiki/queries.js +264 -10
- package/lib/wiki/t1-jobs.js +232 -0
- package/lib/wiki/t1.js +7 -1
- package/package.json +25 -1
- package/scripts/sync-test-badge.mjs +1 -1
- package/scripts/test-count-sync.mjs +9 -5
- package/src/api-standalone.js +4 -1
- package/src/api.js +744 -26
- package/src/attachment-text.js +122 -0
- package/src/choro-command.js +9 -2
- package/src/config.js +171 -2
- package/src/continuity.js +7 -6
- package/src/diary/auto.js +190 -0
- package/src/diary/compose.js +440 -0
- package/src/diary/dir.js +59 -0
- package/src/diary/export.js +97 -0
- package/src/diary/index.js +26 -0
- package/src/diary/library.js +189 -0
- package/src/diary/qq-inbox.js +142 -0
- package/src/diary/rollover.js +61 -0
- package/src/diary/session-provider.js +122 -0
- package/src/document.js +9 -3
- package/src/dream/clustering.js +6 -1
- package/src/dream/decisions.js +56 -5
- package/src/dream/sleep.js +616 -15
- package/src/dream.js +45 -13
- package/src/dsh-go/host.js +162 -0
- package/src/dsh-go/panel.js +2091 -0
- package/src/dsh-go/text.js +342 -0
- package/src/dsh-go/views/common.js +68 -0
- package/src/dsh-go/views/entity.js +48 -0
- package/src/dsh-go/views/memory.js +198 -0
- package/src/dsh-go/views/root.js +30 -0
- package/src/dsh-go/views/settings.js +398 -0
- package/src/dsh-go/views/status.js +51 -0
- package/src/embedding.js +42 -8
- package/src/entities/extractor.js +36 -7
- package/src/hot-memory.js +8 -9
- package/src/index.js +131 -5
- package/src/inject.js +192 -19
- package/src/kb/index.js +309 -0
- package/src/kb/routes.js +220 -0
- package/src/kb/tools.js +148 -0
- package/src/lang.js +115 -4
- package/src/peak-hours.js +57 -2
- package/src/profile-facts.js +356 -0
- package/src/semantic.js +347 -60
- package/src/service.js +302 -70
- package/src/settings.js +117 -14
- package/src/store.js +830 -55
- package/src/summarize.js +38 -11
- package/src/tools.js +173 -25
- package/src/user-settings.js +175 -23
- package/src/vector-index.js +76 -6
- package/src/wiki/cli-hint.js +90 -0
- package/src/wiki/queries.js +264 -10
- package/src/wiki/t1-jobs.js +232 -0
- package/src/wiki/t1.js +7 -1
- package/test/api-routes-gaps.test.js +63 -0
- package/test/api.test.js +377 -4
- package/test/attachment-text.test.js +140 -0
- package/test/bm25-corpus-cache.test.js +145 -0
- package/test/capacity-warning.test.js +262 -0
- package/test/client.test.js +908 -138
- package/test/config.test.js +79 -0
- package/test/conflict-freeze.test.js +45 -1
- package/test/continuity.test.js +8 -1
- package/test/diary-library.test.js +187 -0
- package/test/diary.test.js +576 -0
- package/test/document.test.js +32 -0
- package/test/dream-maxtokens-fallback.test.js +3 -3
- package/test/dream.test.js +62 -1
- package/test/dsh-go-host.test.js +134 -0
- package/test/dsh-go-panel-actions.test.js +854 -0
- package/test/dsh-go-panel.test.js +251 -0
- package/test/embedding-cache-f32.test.js +276 -0
- package/test/embedding-ready.test.js +33 -3
- package/test/entities.test.js +73 -0
- package/test/forget-corrections.test.js +70 -0
- package/test/image-memory.test.js +171 -0
- package/test/inject-pin-explicit.test.js +300 -0
- package/test/inject-pin-vector-crowd.test.js +225 -0
- package/test/issue-617-step1-query-and-fusion.test.js +361 -0
- package/test/kb-api-wiring.test.js +128 -0
- package/test/kb-collections.test.js +272 -0
- package/test/kb-routes.test.js +379 -0
- package/test/mint-source.test.js +26 -0
- package/test/peak-hours-observability.test.js +70 -0
- package/test/persona-api.test.js +3 -3
- package/test/persona-by-session.test.js +568 -0
- package/test/persona-settings.test.js +38 -7
- package/test/profile-facts.test.js +492 -0
- package/test/reflection-update-gate.test.js +129 -0
- package/test/reindex-backfill.test.js +334 -0
- package/test/reindex-scheduler.test.js +369 -0
- package/test/scope-candidates.test.js +249 -0
- package/test/session-gate.test.js +1 -1
- package/test/sleep-action-set.test.js +14 -0
- package/test/sleep-heat.test.js +49 -0
- package/test/sleep.test.js +598 -0
- package/test/standalone-api.test.js +24 -0
- package/test/store.test.js +20 -0
- package/test/summarize.test.js +26 -0
- package/test/test-count-sync.test.js +14 -3
- package/test/tool-state-ignore.test.js +65 -0
- package/test/tools-dto-schema-matrix.test.js +23 -2
- package/test/tools.test.js +144 -7
- package/test/user-settings-segments.test.js +528 -0
- package/test/vector-index.test.js +120 -6
- package/test/wiki-api.test.js +476 -1
- package/test/wiki-cli-hint.test.js +118 -0
- package/test/wiki-queries.test.js +91 -1
- package/test/wiki-t1-jobs.test.js +125 -0
- package/test/wiki-tools.test.js +131 -76
package/test/config.test.js
CHANGED
|
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { Config } from "../src/config.js";
|
|
6
|
+
import { isCodingTask } from "../src/service.js";
|
|
6
7
|
|
|
7
8
|
test("session incognito is opt-in, with a default-on subordinate selector", () => {
|
|
8
9
|
const cfg = Config({});
|
|
@@ -72,3 +73,81 @@ test("startup probe: the reranker module never statically imports transformers/o
|
|
|
72
73
|
"loader 不得静态导入 transformers,否则惰性加载就失效了"
|
|
73
74
|
);
|
|
74
75
|
});
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------- codingKeywords 工程协作词(2026-11-06)
|
|
78
|
+
// 生产日志里「合并 / 开 PR / 看 CI / 发版 / 验收 / 冲突 / 分支 / issue」这类
|
|
79
|
+
// 工程协作话在原 56 项词表里一个都不命中,被判成非编码任务,于是
|
|
80
|
+
// rejected_solution / pitfall / constraint 三类编码记忆被 codingGate 整类滤掉。
|
|
81
|
+
// 实测这类 query 占非编码调用的 17.1~17.8%。本组用例锁住补词后的词表与行为。
|
|
82
|
+
|
|
83
|
+
test("codingKeywords 覆盖工程协作动作,且不含歧义词", () => {
|
|
84
|
+
const { codingKeywords } = Config({});
|
|
85
|
+
|
|
86
|
+
// 这 34 项就是补进去的工程协作词,一个都不能少(少了就等于白改)。
|
|
87
|
+
// 不另加「长度必须是 90」的断言:那是把「逐词覆盖」和「不多不少」两个语义
|
|
88
|
+
// 绑在一处,删一个词会同时触发两条断言,看报告的人会误以为两条用例各自
|
|
89
|
+
// 独立兜底。逐词断言已经足够——多出来的词由下面的「不得含」三条反向兜住。
|
|
90
|
+
//
|
|
91
|
+
// 「审评」必须与「评审」并列:她真实说的是「等审评」「看CI和审评」(8 条 /
|
|
92
|
+
// 779 次),「评审」只有 4 条 / 420 次。同义不同形,子串匹配两套都得分开放。
|
|
93
|
+
const engineering = [
|
|
94
|
+
"提交", "合并", "分支", "冲突", "验收", "审查", "评审", "审评", "发版",
|
|
95
|
+
"仓库", "回滚", "推送", "拉取", "变基", "上线", "打包", "迁移",
|
|
96
|
+
"字段", "鉴权", "超时", "并发", "闪退", "白屏", "端口", "进程",
|
|
97
|
+
"守护", "定时任务", "索引", "缓存", "死锁", "内存泄漏", "日志",
|
|
98
|
+
"版本号", "环境变量"
|
|
99
|
+
];
|
|
100
|
+
for (const kw of engineering) {
|
|
101
|
+
assert.ok(codingKeywords.includes(kw), `缺少工程协作词「${kw}」`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 刻意不加的两个:
|
|
105
|
+
// 「事务」唯一命中是政治学简答「公共事务委托给企业和社会组织」,加进去
|
|
106
|
+
// 会把考研答题场景判成编码任务,方向正好相反。
|
|
107
|
+
// 「依赖包」纯冗余——「依赖」已在表内,它永远不会单独生效。
|
|
108
|
+
assert.ok(!codingKeywords.includes("事务"), "不得加「事务」:会把政治学简答判成编码任务");
|
|
109
|
+
assert.ok(!codingKeywords.includes("依赖包"), "不得加「依赖包」:「依赖」已在表内,纯冗余");
|
|
110
|
+
|
|
111
|
+
// 无重复
|
|
112
|
+
assert.equal(new Set(codingKeywords).size, codingKeywords.length, "词表不得有重复项");
|
|
113
|
+
|
|
114
|
+
// 英文词一律不加:isCodingTask 是纯子串匹配,无词边界的短英文词会误命中。
|
|
115
|
+
// 实测 pr 命中 69 条/6002 次,其中无词边界 30 条/3477 次(57.9%)是
|
|
116
|
+
// C:/Program File/GitHub CLI、pnpm 路径、prompt 这类文本。
|
|
117
|
+
for (const kw of ["pr", "ci", "push", "pull", "rebase", "revert", "diff"]) {
|
|
118
|
+
assert.ok(!codingKeywords.includes(kw), `不得加英文短词「${kw}」:无词边界会误命中`);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("工程协作话被判成编码任务,考研答题话仍判成非编码", () => {
|
|
123
|
+
// 走生产的 isCodingTask,不在这里另写一份判断逻辑。
|
|
124
|
+
// 此前这条用例自己写 const isCoding = (t) => codingKeywords.some(...),
|
|
125
|
+
// 测的是「词表内容对不对」,不是「生产函数怎么用它」——实测把
|
|
126
|
+
// src/service.js 的 isCodingTask 改成永远 return false(整个编码门废掉),
|
|
127
|
+
// 全仓 177 个相关用例仍然全绿。所以这里必须 import 真函数。
|
|
128
|
+
const { codingKeywords } = Config({});
|
|
129
|
+
|
|
130
|
+
// 这些是她真实说过的工程协作话,补词前全部被判成非编码
|
|
131
|
+
for (const q of [
|
|
132
|
+
"提交这个issue然后你在主分支的基础上开分支修",
|
|
133
|
+
"先回帖再开分支。",
|
|
134
|
+
"合并",
|
|
135
|
+
"看看#536,解决冲突",
|
|
136
|
+
"看看ci和评审",
|
|
137
|
+
"A+B,今天就能发版",
|
|
138
|
+
"等审评。你看看#35那个pr。",
|
|
139
|
+
"按照你说的,先合并完前面的再开#493"
|
|
140
|
+
]) {
|
|
141
|
+
assert.equal(isCodingTask(q, codingKeywords), true, `「${q}」应判成编码任务`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 考研答题话:补词后仍然不能被判成编码任务,否则她的答题场景会注入编码踩坑
|
|
145
|
+
for (const q of [
|
|
146
|
+
"1.B,2.这个就是……政府职能转变,公共事务委托给企业和社会组织",
|
|
147
|
+
"背95单词",
|
|
148
|
+
"英语阅读 1 篇(2016 起正序)",
|
|
149
|
+
"631 专业课块 · 15:30–17:00"
|
|
150
|
+
]) {
|
|
151
|
+
assert.equal(isCodingTask(q, codingKeywords), false, `「${q}」应判成非编码任务`);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
@@ -82,6 +82,50 @@ test("store: 不同对不去重", () => {
|
|
|
82
82
|
store.close();
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
+
// Issue #603 第 2 层:排序键从 created_at 换成相似度。原键在同一次 sleep 轮次里
|
|
86
|
+
// 全同(实测 60 条落在 60ms 内),队列在面板上等于随机——而「先看哪对」的唯一
|
|
87
|
+
// 客观信号就是相似度。这组用例锁三件事:相似度真的落库、排序真的按它、以及
|
|
88
|
+
// NULL(没记过)排在最后而不是被当成 0。
|
|
89
|
+
test("#603 store: similarity is persisted and read back", () => {
|
|
90
|
+
const store = openStore();
|
|
91
|
+
const row = store.saveConflictPending({ memory_a: "a", memory_b: "b", reason: "r", similarity: 0.87 });
|
|
92
|
+
assert.equal(row.similarity, 0.87, "fresh row carries the similarity it was parked with");
|
|
93
|
+
const [read] = store.listConflictPending();
|
|
94
|
+
assert.equal(read.similarity, 0.87, "listConflictPending reads the column back");
|
|
95
|
+
store.close();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("#603 store: the queue is ordered by similarity, newest only as a tiebreak", () => {
|
|
99
|
+
const store = openStore();
|
|
100
|
+
// 写入顺序与相似度顺序相反:如果仍按 created_at 排,结果会是 low → high。
|
|
101
|
+
const low = store.saveConflictPending({ memory_a: "a", memory_b: "b", reason: "low", similarity: 0.61 });
|
|
102
|
+
const high = store.saveConflictPending({ memory_a: "c", memory_b: "d", reason: "high", similarity: 0.94 });
|
|
103
|
+
const mid = store.saveConflictPending({ memory_a: "e", memory_b: "f", reason: "mid", similarity: 0.78 });
|
|
104
|
+
const order = store.listConflictPending().map((r) => r.id);
|
|
105
|
+
assert.deepEqual(order, [high.id, mid.id, low.id], "most similar first, regardless of insertion order");
|
|
106
|
+
store.close();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("#603 store: rows without a similarity sort last, they are not 'zero similar'", () => {
|
|
110
|
+
const store = openStore();
|
|
111
|
+
// 存量行(加列之前入队的)与 LLM 裁决路径(只有理由文本)都没有相似度。
|
|
112
|
+
const unknown = store.saveConflictPending({ memory_a: "a", memory_b: "b", reason: "no number" });
|
|
113
|
+
const known = store.saveConflictPending({ memory_a: "c", memory_b: "d", reason: "r", similarity: 0.7 });
|
|
114
|
+
assert.equal(unknown.similarity, undefined, "an unmeasured row must not report a number");
|
|
115
|
+
const order = store.listConflictPending().map((r) => r.id);
|
|
116
|
+
assert.deepEqual(order, [known.id, unknown.id], "unknown similarity goes after every measured row");
|
|
117
|
+
store.close();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("#603 store: an out-of-range or non-numeric similarity is stored as unknown", () => {
|
|
121
|
+
const store = openStore();
|
|
122
|
+
for (const bad of [1.5, -0.2, Number.NaN, "0.9", null]) {
|
|
123
|
+
const row = store.saveConflictPending({ memory_a: `a${String(bad)}`, memory_b: `b${String(bad)}`, reason: "r", similarity: bad });
|
|
124
|
+
assert.equal(row.similarity, undefined, `similarity ${String(bad)} must not be stored as a number`);
|
|
125
|
+
}
|
|
126
|
+
store.close();
|
|
127
|
+
});
|
|
128
|
+
|
|
85
129
|
test("store: resolveConflictPending 标 resolved + winner,默认列表不再返回", () => {
|
|
86
130
|
const store = openStore();
|
|
87
131
|
const pending = store.saveConflictPending({ memory_a: "a", memory_b: "b", reason: "r" });
|
|
@@ -294,7 +338,7 @@ test("dream: freeze=true 同一对跨 run 去重 —— 只保留一条 pending"
|
|
|
294
338
|
const ctx2 = freezeCtx({ conflicts: [{ action: "conflict", winner: w.id, loser: l.id, reason: "日期矛盾" }] });
|
|
295
339
|
const r2 = await dream.runDream(ctx2, service, { ...BASE_CONFIG, conflictFreezeEnabled: true });
|
|
296
340
|
assert.equal(r1.frozen, 1);
|
|
297
|
-
assert.equal(r2.frozen,
|
|
341
|
+
assert.equal(r2.frozen, 0, "第二次检出的是已在队列里的对,不算新停车(#603 第 1 层)");
|
|
298
342
|
assert.equal(store.countConflictPending(), 1, "但队列只有一条 pending");
|
|
299
343
|
store.close();
|
|
300
344
|
});
|
package/test/continuity.test.js
CHANGED
|
@@ -267,8 +267,15 @@ test("#249 N3:队列满的丢弃必须留痕(§8 触发率口径)", async
|
|
|
267
267
|
test("#249 N3:门控不调模型(静态锁)与挂载点的父开关在前", () => {
|
|
268
268
|
// §11「门控不调 LLM」:本模块只 import 消息工厂。静态锁而不是运行时断言——真去调模型的
|
|
269
269
|
// 回归不会从返回值露出来,只会让这一步变慢、变贵。
|
|
270
|
+
//
|
|
271
|
+
// #611 起多了 src/attachment-text.js:它同样**不调模型**(纯字符串函数、自己不
|
|
272
|
+
// import 任何东西),只是把图片块读成占位文本。所以锁的口径从「恰好一行 import」
|
|
273
|
+
// 改成「import 清单恰是这两项」——新增一行仍然转红,仍然要人来看一眼它是不是纯的。
|
|
270
274
|
const src = readFileSync(new URL("../src/continuity.js", import.meta.url), "utf8");
|
|
271
|
-
assert.deepEqual(src.match(/^import .*$/gm), [
|
|
275
|
+
assert.deepEqual(src.match(/^import .*$/gm), [
|
|
276
|
+
'import { createUserMessage } from "@deepseek-ai/dsh-llm";',
|
|
277
|
+
'import { contentToText } from "./attachment-text.js";'
|
|
278
|
+
]);
|
|
272
279
|
// 挂载点(`src/index.js`)是本功能唯一的生产入口:父开关必须在前、子项在后,否则「默认关」
|
|
273
280
|
// 只剩 Config 里的默认值撑着,接线漏一次没人发现。
|
|
274
281
|
const index = readFileSync(new URL("../src/index.js", import.meta.url), "utf8");
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// 左侧「ta 的日记」面板读的是**跨桶**清单——它长在 `main` 座位上,宿主不给它
|
|
2
|
+
// sessionId,所以算不出「当前会话的项目根」。这套用例锁的就是那条路径:
|
|
3
|
+
// 谁被算成一个桶、谁被跳过、谁的名字从 frontmatter 来而不是从目录名猜。
|
|
4
|
+
//
|
|
5
|
+
// 反面教材是 #613 的双桶:同一个项目因为历史原因落了两个目录键
|
|
6
|
+
// (`QQ对话-4f061790` 与 `QQ---4f061790`),面板若按目录名认人就会把同一本日记
|
|
7
|
+
// 挂成两个人。所以这里有一条用例专门断言「名字取自 frontmatter 的 persona」。
|
|
8
|
+
import test from "node:test";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { listDiaryLibrary, readLibraryEntry, isBucketName } from "../src/diary/index.js";
|
|
14
|
+
|
|
15
|
+
/** 造一个临时 memoryDir,里面的 diaries/ 按给定的桶与日期铺开。 */
|
|
16
|
+
function fixture(buckets) {
|
|
17
|
+
const memoryDir = mkdtempSync(join(tmpdir(), "mneme-diary-lib-"));
|
|
18
|
+
const root = join(memoryDir, "diaries");
|
|
19
|
+
mkdirSync(root, { recursive: true });
|
|
20
|
+
for (const [bucket, files] of Object.entries(buckets)) {
|
|
21
|
+
const dir = join(root, bucket);
|
|
22
|
+
mkdirSync(dir, { recursive: true });
|
|
23
|
+
for (const [date, text] of Object.entries(files)) {
|
|
24
|
+
writeFileSync(join(dir, `${date}.md`), text, "utf8");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return { memoryDir, root };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function entry({ persona = "", project = "p", body = "正文" } = {}) {
|
|
31
|
+
const lines = ["---", "date: 2026-11-08"];
|
|
32
|
+
if (project) lines.push(`project: ${project}`);
|
|
33
|
+
if (persona) lines.push(`persona: ${persona}`);
|
|
34
|
+
lines.push("---", "", body, "");
|
|
35
|
+
return lines.join("\n");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test("按桶列出日记,名字取自 frontmatter 的 persona 而不是目录名", (t) => {
|
|
39
|
+
// 两个桶是同一个项目(#613 的双桶现场):目录名一个保留中文、一个被洗成 -。
|
|
40
|
+
// 面板要显示的是「amarre」与「ptc」,不是那两个目录键。
|
|
41
|
+
const { memoryDir } = fixture({
|
|
42
|
+
"QQ对话-4f061790": { "2026-11-07": entry({ persona: "settings (amarre)", project: "QQ对话-4f061790" }) },
|
|
43
|
+
"QQ---4f061790": { "2026-11-08": entry({ persona: "default (ptc)", project: "QQ---4f061790" }) }
|
|
44
|
+
});
|
|
45
|
+
t.after(() => rmSync(memoryDir, { recursive: true, force: true }));
|
|
46
|
+
|
|
47
|
+
const { buckets } = listDiaryLibrary({ memoryDir });
|
|
48
|
+
assert.equal(buckets.length, 2);
|
|
49
|
+
const byKey = Object.fromEntries(buckets.map((b) => [b.key, b]));
|
|
50
|
+
assert.equal(byKey["QQ对话-4f061790"].persona, "settings (amarre)");
|
|
51
|
+
assert.equal(byKey["QQ---4f061790"].persona, "default (ptc)");
|
|
52
|
+
// 桶键原样保留(面板要用它拼路由),没被改写。
|
|
53
|
+
assert.ok(byKey["QQ---4f061790"]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("新的在前;每桶带 latest / count / entries", (t) => {
|
|
57
|
+
const { memoryDir } = fixture({
|
|
58
|
+
a: {
|
|
59
|
+
"2026-11-05": entry({ persona: "default (x)" }),
|
|
60
|
+
"2026-11-08": entry({ persona: "default (x)" }),
|
|
61
|
+
"2026-11-06": entry({ persona: "default (x)" })
|
|
62
|
+
},
|
|
63
|
+
b: { "2026-11-01": entry({ persona: "default (y)" }) }
|
|
64
|
+
});
|
|
65
|
+
t.after(() => rmSync(memoryDir, { recursive: true, force: true }));
|
|
66
|
+
|
|
67
|
+
const { buckets } = listDiaryLibrary({ memoryDir });
|
|
68
|
+
assert.deepEqual(buckets.map((x) => x.key), ["a", "b"], "按 latest 倒序");
|
|
69
|
+
assert.equal(buckets[0].latest, "2026-11-08");
|
|
70
|
+
assert.equal(buckets[0].count, 3);
|
|
71
|
+
assert.deepEqual(buckets[0].entries.map((e) => e.date), ["2026-11-08", "2026-11-06", "2026-11-05"]);
|
|
72
|
+
assert.equal(buckets[1].latest, "2026-11-01");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("空桶、回收站、非日记文件都不算一本", (t) => {
|
|
76
|
+
const { memoryDir, root } = fixture({
|
|
77
|
+
real: { "2026-11-08": entry({ persona: "default (x)" }) }
|
|
78
|
+
});
|
|
79
|
+
t.after(() => rmSync(memoryDir, { recursive: true, force: true }));
|
|
80
|
+
// 只有 runs.jsonl 的桶:文件被删光之后的样子。
|
|
81
|
+
mkdirSync(join(root, "hollow"), { recursive: true });
|
|
82
|
+
writeFileSync(join(root, "hollow", "runs.jsonl"), "{}\n", "utf8");
|
|
83
|
+
// 回收站:以点开头。
|
|
84
|
+
mkdirSync(join(root, ".deleted"), { recursive: true });
|
|
85
|
+
writeFileSync(join(root, ".deleted", "2026-11-08.md"), entry(), "utf8");
|
|
86
|
+
// 根目录下的散落文件不是桶。
|
|
87
|
+
writeFileSync(join(root, "stray.md"), "x", "utf8");
|
|
88
|
+
|
|
89
|
+
const { buckets } = listDiaryLibrary({ memoryDir });
|
|
90
|
+
assert.deepEqual(buckets.map((b) => b.key), ["real"],
|
|
91
|
+
"只有 runs.jsonl 的桶、.deleted 回收站、根下的散文件都不该出现在导航栏里");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("读不到 diaries 根目录时返回空清单,不抛", () => {
|
|
95
|
+
const { buckets } = listDiaryLibrary({ memoryDir: join(tmpdir(), "mneme-no-such-dir-xyz") });
|
|
96
|
+
assert.deepEqual(buckets, []);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("第一篇没 persona 时往下找:同一桶里先后住过两个人才是 #613 要拆的形状", (t) => {
|
|
100
|
+
const { memoryDir } = fixture({
|
|
101
|
+
shared: {
|
|
102
|
+
"2026-11-06": entry({ persona: "" }), // 手工写的,没有 frontmatter persona
|
|
103
|
+
"2026-11-07": entry({ persona: "settings (amarre)" })
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
t.after(() => rmSync(memoryDir, { recursive: true, force: true }));
|
|
107
|
+
|
|
108
|
+
const { buckets } = listDiaryLibrary({ memoryDir });
|
|
109
|
+
assert.equal(buckets[0].persona, "settings (amarre)", "跳过没 persona 的那篇,取第一篇带它的");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("读一篇:桶名与日期都是路径成分,越界一律读不到", (t) => {
|
|
113
|
+
const { memoryDir, root } = fixture({ ok: { "2026-11-08": entry({ body: "今天很好" }) } });
|
|
114
|
+
t.after(() => rmSync(memoryDir, { recursive: true, force: true }));
|
|
115
|
+
// **诱饵**:root 外面放一个真能被读到的文件。只断言「返回 null」是不够的
|
|
116
|
+
// ——桶名那一层挡不住时,`join` 会真的读到它;没有这个诱饵,校验坏了用例也绿。
|
|
117
|
+
const leak = join(memoryDir, "leak");
|
|
118
|
+
mkdirSync(leak, { recursive: true });
|
|
119
|
+
writeFileSync(join(leak, "2026-11-08.md"), "根外面的东西", "utf8");
|
|
120
|
+
mkdirSync(join(root, "ok"), { recursive: true });
|
|
121
|
+
|
|
122
|
+
assert.match(readLibraryEntry({ memoryDir, bucket: "ok", date: "2026-11-08" }), /今天很好/);
|
|
123
|
+
// 这三种都是「不能让它们拼进路径」的形状;命中诱饵就说明校验漏了。
|
|
124
|
+
assert.equal(readLibraryEntry({ memoryDir, bucket: "..", date: "2026-11-08" }), null);
|
|
125
|
+
assert.equal(readLibraryEntry({ memoryDir, bucket: "../leak", date: "2026-11-08" }), null);
|
|
126
|
+
assert.equal(readLibraryEntry({ memoryDir, bucket: "ok/../../leak", date: "2026-11-08" }), null);
|
|
127
|
+
// 合法但不存在 → null(面板据此显示「读不出」而不是空框)。
|
|
128
|
+
assert.equal(readLibraryEntry({ memoryDir, bucket: "ok", date: "2026-01-01" }), null);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("桶名判据挡的是路径语义,不是字符集", () => {
|
|
132
|
+
// 挡:分隔符、上跳、控制字符、首尾空白、`.` 与 `..`。
|
|
133
|
+
for (const bad of ["..", "../x", "a/b", "a\\b", "", ".", " a", "a ", "a\u0000b"]) {
|
|
134
|
+
assert.equal(isBucketName(bad), false, `不该接受桶名 ${JSON.stringify(bad)}`);
|
|
135
|
+
}
|
|
136
|
+
// 不挡:合法的单层目录名。**中文不在这道判据里**——历史上真有一个保留中文的
|
|
137
|
+
// 桶(`QQ对话-4f061790`),按字符集白名单会把它列出来却读不到。
|
|
138
|
+
for (const ok of ["QQ---4f061790", "amarre-c836db6a", "QQ对话-4f061790", "a b"]) {
|
|
139
|
+
assert.equal(isBucketName(ok), true, `该接受桶名 ${JSON.stringify(ok)}`);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("历史桶名带中文也要能读(#613 的双桶就有一个是那样)", (t) => {
|
|
144
|
+
// 上一版 `projectKey` 只留 ASCII,但现场确实存在一个保留中文的桶
|
|
145
|
+
// (用户那次让另一个会话手工写的)。清单与读取必须用同一道判据,否则就是
|
|
146
|
+
// 「列得出来、点进去读不到」——用户看到的是点了没反应。
|
|
147
|
+
const { memoryDir } = fixture({ "QQ对话-4f061790": { "2026-11-07": entry({ body: "旧的那篇" }) } });
|
|
148
|
+
t.after(() => rmSync(memoryDir, { recursive: true, force: true }));
|
|
149
|
+
|
|
150
|
+
const { buckets } = listDiaryLibrary({ memoryDir });
|
|
151
|
+
assert.deepEqual(buckets.map((b) => b.key), ["QQ对话-4f061790"],
|
|
152
|
+
"带中文的桶名必须出现在清单里——它是真实存在的历史桶,跳过它等于把用户写过的日记藏起来");
|
|
153
|
+
assert.match(readLibraryEntry({ memoryDir, bucket: "QQ对话-4f061790", date: "2026-11-07" }), /旧的那篇/,
|
|
154
|
+
"清单里列得出来就必须读得到,不能只做一半");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("清单带上桶的 projectRoot / agentId,面板靠它决定能不能写", (t) => {
|
|
158
|
+
// 桶名(目录名 + 8 位哈希)反推不回绝对路径,而「写今天」必须给 project。
|
|
159
|
+
// 所以 listDiaryLibrary 从 diaries/state.json 里取账本;账本里没有就留空,
|
|
160
|
+
// 面板据此禁用按钮——绝不猜路径,猜错就把日记写到别的项目去了。
|
|
161
|
+
const { memoryDir, root } = fixture({
|
|
162
|
+
"amarre-c836db6a": { "2026-11-08": entry({ persona: "default (ptc)" }) },
|
|
163
|
+
"lone-11111111": { "2026-11-08": entry({ persona: "default (ptc)" }) }
|
|
164
|
+
});
|
|
165
|
+
t.after(() => rmSync(memoryDir, { recursive: true, force: true }));
|
|
166
|
+
writeFileSync(join(root, "state.json"), JSON.stringify({
|
|
167
|
+
"amarre-c836db6a": { projectRoot: "D:\\amarre", lastDate: "2026-11-08", chatId: "c1", agentId: "ptc" }
|
|
168
|
+
}), "utf8");
|
|
169
|
+
|
|
170
|
+
const byKey = Object.fromEntries(listDiaryLibrary({ memoryDir }).buckets.map((b) => [b.key, b]));
|
|
171
|
+
assert.equal(byKey["amarre-c836db6a"].projectRoot, "D:\\amarre", "有账本的桶要带出绝对路径");
|
|
172
|
+
assert.equal(byKey["amarre-c836db6a"].agentId, "ptc");
|
|
173
|
+
assert.equal(byKey["lone-11111111"].projectRoot, "", "没账本就留空,绝不猜");
|
|
174
|
+
assert.equal(byKey["lone-11111111"].agentId, "");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("state.json 坏掉时清单照常出,不因账本读不出就整页空白", (t) => {
|
|
178
|
+
// 账本只是附加信息。它坏了最坏是「不知道该写哪」,不该连日记都看不到。
|
|
179
|
+
const { memoryDir, root } = fixture({ "amarre-c836db6a": { "2026-11-08": entry({ persona: "default (ptc)" }) } });
|
|
180
|
+
t.after(() => rmSync(memoryDir, { recursive: true, force: true }));
|
|
181
|
+
writeFileSync(join(root, "state.json"), "{ not json", "utf8");
|
|
182
|
+
|
|
183
|
+
const { buckets } = listDiaryLibrary({ memoryDir });
|
|
184
|
+
assert.equal(buckets.length, 1);
|
|
185
|
+
assert.equal(buckets[0].projectRoot, "");
|
|
186
|
+
assert.equal(buckets[0].persona, "default (ptc)");
|
|
187
|
+
});
|