dsh-lite-memory 0.2.7 → 0.3.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 +6 -2
- package/TRIGGERS.md +25 -5
- package/index.js +5 -3
- package/package.json +1 -1
- package/store.js +91 -38
- package/test/store.test.mjs +39 -0
package/README.md
CHANGED
|
@@ -19,7 +19,9 @@ Lightweight memory for the DeepSeek Harness. Nothing in the system prompt, nothi
|
|
|
19
19
|
| **L1** 它发现有跨会话价值的经验 | 一行灰字 `memory · keep? "X" — 要记的内容` + 一个 `keep` 按钮;标题加一句话,看得懂才点,不点就永远不写,48 小时内不重复 |
|
|
20
20
|
| **L2** 常识、一次性参数、凭据的值 | 永不写入 |
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
这些行只属于产生它的那一轮:重进会话就没了,历史里只留正文。同一件事再记一次会合并进原条目(旧要点保留,同键的新值覆盖);前置条件不同的同一主题是两条,不合并。
|
|
23
|
+
|
|
24
|
+
写入时分两层:默认只在这个项目生效;用户说「以后所有项目都这样」才进全局层(上限 40 条),检索时一起查。同一主题在不同前提下用 `when` 区分,各存一条。
|
|
23
25
|
|
|
24
26
|
触发词:`记住`、`记一下`、`上次`、`规范`、`约定`、`路径`、`凭据怎么取`。
|
|
25
27
|
|
|
@@ -29,7 +31,9 @@ Lightweight memory for the DeepSeek Harness. Nothing in the system prompt, nothi
|
|
|
29
31
|
| **L1** it spots something worth keeping | one muted line `memory · keep? "X" — what would be remembered` with a single `keep` button — readable before you click, nothing written unless you click, at most once per two days |
|
|
30
32
|
| **L2** common sense, one-off parameters, credential values | never written |
|
|
31
33
|
|
|
32
|
-
Those lines belong to the turn that made them: reopening the session drops them, leaving only the prose in history. Recording the same subject again merges into the existing entry
|
|
34
|
+
Those lines belong to the turn that made them: reopening the session drops them, leaving only the prose in history. Recording the same subject again merges into the existing entry — earlier points are kept and only conflicting keys are updated. The same subject under different preconditions stays two entries.
|
|
35
|
+
|
|
36
|
+
Writes land in one of two layers: the current project by default, or the global layer (max 40 entries) when the user says the rule holds for every project. Both layers are searched together, and a `when` field separates the same subject under different preconditions.
|
|
33
37
|
|
|
34
38
|
## 点一下就存 / One click, no turn
|
|
35
39
|
|
package/TRIGGERS.md
CHANGED
|
@@ -34,14 +34,34 @@
|
|
|
34
34
|
|
|
35
35
|
这些行只属于产生它的那一轮:重进会话时不再显示,历史里只留正文。行出现在调用本身的位置——一轮正在跑时,那就是对话的最下面,不会悬浮在输入框上方。
|
|
36
36
|
|
|
37
|
+
## 通用还是项目
|
|
38
|
+
|
|
39
|
+
写入时先定层,三层各管一件事,别混着写:
|
|
40
|
+
|
|
41
|
+
- **全局**(`scope: 'global'`):跨项目成立的习惯与约定——回复语言、提交信息风格、凭据放在哪。只放「所有项目都这样」的;沾一个仓库的就不放。上限 40 条。
|
|
42
|
+
- **项目**(默认):这个仓库的事实、路径、流程、踩坑。换项目就查不到。
|
|
43
|
+
- **场景**(`when`):同一件事在不同前提下的不同做法。带 `when` 的条目只在该条件下成立,检索时显示成「标题(场景)」。
|
|
44
|
+
|
|
45
|
+
检索一次查两层,带「[全局]」前缀的是跨项目那层;两层同名条目各存各的,不互相合并。归档、读正文按 id 走,全局条目在别的项目里也能归档。
|
|
46
|
+
|
|
47
|
+
为什么是这三层:[Claude Code](https://code.claude.com/docs/en/memory) 分 user / project / local,[Codex 的 AGENTS.md](https://agents.md/) 分 `~/.codex` 与仓库根、子目录,都是「按作用域分层、就近优先」,不做语义去重。我们照这个骨架,多的一层是 `when`:同一主题在不同前提下必须能并存,否则合并就是拿一种情况覆盖另一种。[Graphiti](https://github.com/getzep/graphiti) 的做法是每条事实带有效期、冲突只让旧事实失效而不删除,我们收敛成「同键覆盖、旧要点保留」。
|
|
48
|
+
|
|
37
49
|
## 什么时候合并,什么时候新建
|
|
38
50
|
|
|
39
|
-
|
|
51
|
+
一条约束只留一条,但**前置条件不一样就是两条**。同一条记忆挂在「什么情况下」上:换机器、换环境、换前提,内容往往互相矛盾,硬合并等于丢掉其中一种情况。
|
|
52
|
+
|
|
53
|
+
判断顺序:
|
|
54
|
+
|
|
55
|
+
1. 前置条件不同(含一边写了自己没写)→ 新建,标题后面跟场景,如「构建耗时长|CI 容器」。合并只在同一层内发生:全局跟项目不合并。
|
|
56
|
+
2. 前置条件一致,且标题相同、标题字片重合 ≥ 0.6、或摘要重合 ≥ 0.75 → 合并。
|
|
57
|
+
3. 只共用项目名不算合并(「插件市场条目字段」对「插件市场踩坑清单」重合约 0.4)。
|
|
58
|
+
4. 判不准先 `memory_search`:命中且场景一致,就按那条的标题和场景写;不一致就新建。
|
|
59
|
+
|
|
60
|
+
合并只做增添,绝不整篇覆盖:
|
|
40
61
|
|
|
41
|
-
-
|
|
42
|
-
-
|
|
43
|
-
-
|
|
44
|
-
- 判不准先 `memory_search`:命中就是同一件事,按命中那条的标题写;没命中再新建。
|
|
62
|
+
- 旧正文全留下;这次只写新增的要点。
|
|
63
|
+
- `- 键:值` 形式的行,键相同则以新的为准(端口改了、路径改了)。
|
|
64
|
+
- 真要把一条记忆作废重写(不是补充),先 `memory_forget` 再 `memory_save`,别用合并去覆盖。
|
|
45
65
|
|
|
46
66
|
## 频率与退让
|
|
47
67
|
|
package/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
scopeOf,
|
|
3
|
+
globalScope,
|
|
3
4
|
upsert,
|
|
4
5
|
search,
|
|
5
6
|
readEntry,
|
|
@@ -114,6 +115,7 @@ const SAVE_PARAMS = {
|
|
|
114
115
|
title: { type: 'string', description: `标题:名词短语,用户一眼能认出是哪件事,不超过 ${LIMITS.titleChars} 字` },
|
|
115
116
|
summary: { type: 'string', description: `一句话说清要记的内容本身——用户只看这一行决定留不留,名词短语,不超过 ${LIMITS.summaryChars} 字` },
|
|
116
117
|
body: { type: 'string', description: '正文 markdown。只写指针与要点,不抄录长文档' },
|
|
118
|
+
when: { type: 'string', description: `前置条件或适用场景(「在哪台机器 / 哪个环境 / 什么情况下」),不超过 ${LIMITS.whenChars} 字。同一主题但前置条件不同,会存成两条,不合并` },
|
|
117
119
|
kind: { type: 'string', enum: ['preference', 'convention', 'procedure', 'pointer', 'pitfall', 'credential_hint', 'note'], description: '条目类型' },
|
|
118
120
|
tags: { type: 'array', items: { type: 'string' }, description: '最多 5 个标签' },
|
|
119
121
|
},
|
|
@@ -156,7 +158,7 @@ export function apply(ctx) {
|
|
|
156
158
|
|
|
157
159
|
ctx.effect(() => tools.register({
|
|
158
160
|
name: 'memory_save',
|
|
159
|
-
description: `保存一条记忆。用户明确说「记住」时直接调用;用户自己的话已经是长期规则(「以后都这样」「别再 X」)时也直接调用,不要先问。用户点了「保留」或发「保留记忆「X」」时,用那条建议的内容调用。更新已知主题时先 memory_read
|
|
161
|
+
description: `保存一条记忆。用户明确说「记住」时直接调用;用户自己的话已经是长期规则(「以后都这样」「别再 X」)时也直接调用,不要先问。用户点了「保留」或发「保留记忆「X」」时,用那条建议的内容调用。更新已知主题时先 memory_read 旧正文再写:标题相同、或标题与摘要高度相近、且前置条件一致时,会合并进原来那条(merges+1),旧要点保留,同键的新值覆盖。前置条件不同的同一主题是两条,不要硬合并。正文只写这次新增的要点。${NOTES}`,
|
|
160
162
|
parameters: SAVE_PARAMS,
|
|
161
163
|
output: {
|
|
162
164
|
schema: {
|
|
@@ -176,7 +178,7 @@ export function apply(ctx) {
|
|
|
176
178
|
}],
|
|
177
179
|
},
|
|
178
180
|
execute: async (args, execution) => {
|
|
179
|
-
const scope = scopeOf(cwdOf(execution));
|
|
181
|
+
const scope = args.scope === 'global' ? globalScope() : scopeOf(cwdOf(execution));
|
|
180
182
|
const saved = upsert(scope, args);
|
|
181
183
|
return { id: saved.record.id, title: saved.record.title, summary: saved.record.summary, merged: saved.merged };
|
|
182
184
|
},
|
|
@@ -217,7 +219,7 @@ export function apply(ctx) {
|
|
|
217
219
|
|
|
218
220
|
ctx.effect(() => tools.register({
|
|
219
221
|
name: 'memory_search',
|
|
220
|
-
description: `按关键词检索已有记忆,返回标题与一句话摘要(最多 4
|
|
222
|
+
description: `按关键词检索已有记忆,返回标题与一句话摘要(最多 4 条),项目库与全局库一起查,「[全局]」前缀表示跨项目通用。带(场景)的条目只在该条件下成立。用户提到「记住过 / 上次 / 规范 / 约定 / 路径 / 凭据怎么取」时先查这里。命中后再用 memory_read 取正文。${NOTES}`,
|
|
221
223
|
parameters: SEARCH_PARAMS,
|
|
222
224
|
output: {
|
|
223
225
|
schema: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-lite-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "On-demand memory for the DeepSeek Harness: nothing in the system prompt, one muted line only when something is saved, hit or suggested.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
package/store.js
CHANGED
|
@@ -9,9 +9,11 @@ import crypto from 'node:crypto';
|
|
|
9
9
|
*/
|
|
10
10
|
export const LIMITS = {
|
|
11
11
|
entries: 200,
|
|
12
|
+
globalEntries: 40,
|
|
12
13
|
entryBytes: 4096,
|
|
13
14
|
totalBytes: 512 * 1024,
|
|
14
15
|
titleChars: 40,
|
|
16
|
+
whenChars: 60,
|
|
15
17
|
summaryChars: 60,
|
|
16
18
|
suggestionWindowMs: 48 * 60 * 60 * 1000,
|
|
17
19
|
};
|
|
@@ -42,6 +44,21 @@ export function scopeOf(cwd) {
|
|
|
42
44
|
return { key: `${label}-${hash}`, dir: path.join(memoryRoot(), `${label}-${hash}`), cwd: abs };
|
|
43
45
|
}
|
|
44
46
|
|
|
47
|
+
/** 跨项目通用的那层:用户说「以后所有项目都这样」才写这里。 */
|
|
48
|
+
export function globalScope() {
|
|
49
|
+
return { key: 'global', dir: path.join(memoryRoot(), 'global'), cwd: '' };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 一次写入只落在一个库里:项目库优先,全局库兜底。 */
|
|
53
|
+
function locate(scope, id) {
|
|
54
|
+
for (const source of [scope, globalScope()]) {
|
|
55
|
+
const index = loadIndex(source);
|
|
56
|
+
const record = index.entries.find((item) => item.id === id);
|
|
57
|
+
if (record) return { source, index, record };
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
45
62
|
function readJson(file, fallback) {
|
|
46
63
|
try {
|
|
47
64
|
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
@@ -115,7 +132,8 @@ export function trimIndex(scope, index) {
|
|
|
115
132
|
const rank = (item) => (item.lastUsed || item.updated || item.created || '');
|
|
116
133
|
return rank(a).localeCompare(rank(b));
|
|
117
134
|
});
|
|
118
|
-
|
|
135
|
+
const cap = scope.key === 'global' ? LIMITS.globalEntries : LIMITS.entries;
|
|
136
|
+
while (index.entries.length > cap) archiveEntry(scope, index, byStaleness.shift());
|
|
119
137
|
let total = index.entries.reduce((sum, item) => sum + (item.bytes || 0), 0);
|
|
120
138
|
while (total > LIMITS.totalBytes && byStaleness.length > 0) {
|
|
121
139
|
const victim = byStaleness.shift();
|
|
@@ -147,11 +165,15 @@ function similarity(a, b) {
|
|
|
147
165
|
return shared / (left.size + right.size - shared);
|
|
148
166
|
}
|
|
149
167
|
|
|
150
|
-
/**
|
|
151
|
-
|
|
168
|
+
/**
|
|
169
|
+
* 同一主题的判定。前置条件必须一致:同一件事在不同场景下是两条记忆,
|
|
170
|
+
* 哪怕正文互相看不上眼,也不能合并成一条。
|
|
171
|
+
*/
|
|
172
|
+
function mergeCandidate(entries, title, summary, when) {
|
|
152
173
|
let best = null;
|
|
153
174
|
let bestScore = 0;
|
|
154
175
|
for (const item of entries) {
|
|
176
|
+
if ((item.when || '') !== when) continue;
|
|
155
177
|
if (item.title === title) return item;
|
|
156
178
|
const titleScore = similarity(item.title, title);
|
|
157
179
|
const summaryScore = similarity(item.summary, summary);
|
|
@@ -165,10 +187,29 @@ function mergeCandidate(entries, title, summary) {
|
|
|
165
187
|
return best;
|
|
166
188
|
}
|
|
167
189
|
|
|
168
|
-
/**
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
190
|
+
/** 合并只做增添:旧要点全留下;同一个键(`- 键:值`)的新值覆盖旧值。 */
|
|
191
|
+
export function mergeBody(oldBody, incoming) {
|
|
192
|
+
const keyOf = (line) => {
|
|
193
|
+
const match = /^\s*[-*]\s*([^::]{1,20})[::]/.exec(line);
|
|
194
|
+
return match ? match[1].trim() : null;
|
|
195
|
+
};
|
|
196
|
+
const flat = (line) => line.replace(/\s+/g, ' ').trim();
|
|
197
|
+
const out = String(oldBody || '').split('\n').filter((line) => line.trim());
|
|
198
|
+
for (const raw of String(incoming || '').split('\n')) {
|
|
199
|
+
const line = raw.trim();
|
|
200
|
+
if (!line) continue;
|
|
201
|
+
const key = keyOf(line);
|
|
202
|
+
if (key) {
|
|
203
|
+
const at = out.findIndex((item) => keyOf(item) === key);
|
|
204
|
+
if (at >= 0) out[at] = line;
|
|
205
|
+
else out.push(line);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (!out.some((item) => flat(item) === flat(line))) out.push(line);
|
|
209
|
+
}
|
|
210
|
+
return out.join('\n');
|
|
211
|
+
}
|
|
212
|
+
|
|
172
213
|
export function upsert(scope, draft) {
|
|
173
214
|
const title = normalizeText(draft.title, LIMITS.titleChars);
|
|
174
215
|
if (!title) throw new Error('标题为空');
|
|
@@ -179,13 +220,18 @@ export function upsert(scope, draft) {
|
|
|
179
220
|
const asSecret = detectSecret(`${title}\n${summary.text}\n${body}`);
|
|
180
221
|
if (asSecret) throw new Error(`检出疑似凭据,未保存(${asSecret})。凭据只记位置与调用方式`);
|
|
181
222
|
|
|
223
|
+
const when = normalizeText(draft.when ?? '', LIMITS.whenChars);
|
|
182
224
|
const index = loadIndex(scope);
|
|
183
225
|
const now = new Date().toISOString();
|
|
184
|
-
const existing = mergeCandidate(index.entries, title, summary.text);
|
|
226
|
+
const existing = mergeCandidate(index.entries, title, summary.text, when);
|
|
185
227
|
const id = existing ? existing.id : `mem_${Date.now().toString(36)}${crypto.randomBytes(2).toString('hex')}`;
|
|
228
|
+
const stated = existing ? fs.readFileSync(entryFile(scope, id), 'utf8') : '';
|
|
229
|
+
const mergedBody = existing ? mergeBody(stated, body) : body;
|
|
230
|
+
if (Buffer.byteLength(mergedBody, 'utf8') > LIMITS.entryBytes) throw new Error(`合并后超过 ${LIMITS.entryBytes} 字:只写这次新增的要点,或先归档旧条目`);
|
|
186
231
|
const record = {
|
|
187
232
|
id,
|
|
188
|
-
title,
|
|
233
|
+
title: existing ? existing.title : when ? `${title}|${when}`.slice(0, LIMITS.titleChars) : title,
|
|
234
|
+
when,
|
|
189
235
|
summary: summary.text,
|
|
190
236
|
kind: draft.kind || 'note',
|
|
191
237
|
tags: Array.isArray(draft.tags) ? draft.tags.map((tag) => normalizeText(tag, 20)).filter(Boolean).slice(0, 5) : [],
|
|
@@ -194,9 +240,9 @@ export function upsert(scope, draft) {
|
|
|
194
240
|
lastUsed: now,
|
|
195
241
|
hits: existing ? existing.hits || 0 : 0,
|
|
196
242
|
merges: existing ? (existing.merges || 0) + 1 : 0,
|
|
197
|
-
bytes: Buffer.byteLength(
|
|
243
|
+
bytes: Buffer.byteLength(mergedBody, 'utf8'),
|
|
198
244
|
};
|
|
199
|
-
writeAtomic(entryFile(scope, id),
|
|
245
|
+
writeAtomic(entryFile(scope, id), mergedBody);
|
|
200
246
|
if (existing) index.entries = index.entries.map((item) => (item.id === id ? record : item));
|
|
201
247
|
else index.entries.push(record);
|
|
202
248
|
index.suggestions = (index.suggestions || []).filter((item) => item.title !== title).slice(-19);
|
|
@@ -206,47 +252,53 @@ export function upsert(scope, draft) {
|
|
|
206
252
|
}
|
|
207
253
|
|
|
208
254
|
export function search(scope, query, limit = 4) {
|
|
209
|
-
const index = loadIndex(scope);
|
|
210
255
|
const terms = normalizeText(query, 200)
|
|
211
256
|
.toLowerCase()
|
|
212
257
|
.split(/[^\p{L}\p{N}]+/u)
|
|
213
258
|
.filter((word) => word.length > 1);
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
hit.item.lastUsed = now;
|
|
259
|
+
const sources = scope.key === 'global' ? [scope] : [scope, globalScope()];
|
|
260
|
+
const seen = new Set();
|
|
261
|
+
const hits = [];
|
|
262
|
+
for (const source of sources) {
|
|
263
|
+
const index = loadIndex(source);
|
|
264
|
+
for (const item of index.entries) {
|
|
265
|
+
if (seen.has(item.id)) continue;
|
|
266
|
+
const hay = `${item.title} ${item.summary} ${item.when || ''} ${(item.tags || []).join(' ')}`.toLowerCase();
|
|
267
|
+
let score = 0;
|
|
268
|
+
for (const term of terms) if (hay.includes(term)) score += term.length > 2 ? 2 : 1;
|
|
269
|
+
if (terms.length > 0 && score === 0) score = similarity(item.title, query) > 0.5 ? 1 : 0;
|
|
270
|
+
if (score > 0) hits.push({ item, score, source, index });
|
|
227
271
|
}
|
|
228
|
-
saveIndex(scope, index);
|
|
229
272
|
}
|
|
230
|
-
|
|
273
|
+
const top = hits.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
274
|
+
const now = new Date().toISOString();
|
|
275
|
+
const dirty = new Map();
|
|
276
|
+
for (const hit of top) {
|
|
277
|
+
hit.item.hits = (hit.item.hits || 0) + 1;
|
|
278
|
+
hit.item.lastUsed = now;
|
|
279
|
+
dirty.set(hit.source.key, { source: hit.source, index: hit.index });
|
|
280
|
+
}
|
|
281
|
+
for (const entry of dirty.values()) saveIndex(entry.source, entry.index);
|
|
282
|
+
return top.map((hit) => ({ ...hit.item, scope: hit.source.key === 'global' ? 'global' : 'project' }));
|
|
231
283
|
}
|
|
232
284
|
|
|
233
285
|
export function readEntry(scope, id) {
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
const body = fs.readFileSync(entryFile(
|
|
286
|
+
const found = locate(scope, id);
|
|
287
|
+
if (!found) throw new Error(`没有这条记忆:${id}`);
|
|
288
|
+
const { source, index, record } = found;
|
|
289
|
+
const body = fs.readFileSync(entryFile(source, record.id), 'utf8');
|
|
238
290
|
record.lastUsed = new Date().toISOString();
|
|
239
291
|
record.hits = (record.hits || 0) + 1;
|
|
240
|
-
saveIndex(
|
|
292
|
+
saveIndex(source, index);
|
|
241
293
|
return { record, body };
|
|
242
294
|
}
|
|
243
295
|
|
|
244
296
|
export function forget(scope, id) {
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
archiveEntry(
|
|
249
|
-
saveIndex(
|
|
297
|
+
const found = locate(scope, id);
|
|
298
|
+
if (!found) throw new Error(`没有这条记忆:${id}`);
|
|
299
|
+
const { source, index, record } = found;
|
|
300
|
+
archiveEntry(source, index, record);
|
|
301
|
+
saveIndex(source, index);
|
|
250
302
|
return record;
|
|
251
303
|
}
|
|
252
304
|
|
|
@@ -297,7 +349,8 @@ export function renderIndex(entries, budget = 400) {
|
|
|
297
349
|
for (const item of entries) {
|
|
298
350
|
const last = Date.parse(item.lastUsed || item.updated || item.created || '') || now;
|
|
299
351
|
const stale = now - last > staleMs;
|
|
300
|
-
const
|
|
352
|
+
const head = `${item.scope === 'global' ? '[全局] ' : ''}${item.title}${item.when ? `(${item.when})` : ''}`;
|
|
353
|
+
const line = stale ? `- ${item.id} ${head}` : `- ${item.id} ${head}|${item.summary}`;
|
|
301
354
|
if (used + line.length > budget) break;
|
|
302
355
|
lines.push(line);
|
|
303
356
|
used += line.length + 1;
|
package/test/store.test.mjs
CHANGED
|
@@ -78,3 +78,42 @@ assert.equal(sibling.merged, false);
|
|
|
78
78
|
assert.equal(store.loadIndex(mergeScope).entries.length, 2);
|
|
79
79
|
|
|
80
80
|
console.log('store: merge rules ok');
|
|
81
|
+
|
|
82
|
+
// 前置条件不同 → 两条,哪怕标题一样。
|
|
83
|
+
const mac = store.upsert(mergeScope, { title: '构建耗时长', summary: '本机构建要几分钟', when: 'macOS 本地', body: '- 原因:磁盘加密' });
|
|
84
|
+
const ci = store.upsert(mergeScope, { title: '构建耗时长', summary: '本机构建要几分钟', when: 'CI 容器', body: '- 原因:冷缓存' });
|
|
85
|
+
assert.equal(mac.merged, false);
|
|
86
|
+
assert.equal(ci.merged, false);
|
|
87
|
+
assert.notEqual(ci.record.id, mac.record.id);
|
|
88
|
+
assert.equal(store.loadIndex(mergeScope).entries.filter((item) => item.title.startsWith('构建耗时长')).length, 2);
|
|
89
|
+
|
|
90
|
+
// 同一前置条件下合并:旧要点保留,同键覆盖。
|
|
91
|
+
const plus = store.upsert(mergeScope, { title: '构建耗时长', summary: '本机构建要几分钟', when: 'macOS 本地', body: '- 绕过:先跑 cache\n- 原因:磁盘加密拖慢' });
|
|
92
|
+
assert.equal(plus.merged, true);
|
|
93
|
+
assert.equal(plus.record.id, mac.record.id);
|
|
94
|
+
const body = store.readEntry(mergeScope, mac.record.id).body;
|
|
95
|
+
assert.match(body, /绕过/);
|
|
96
|
+
assert.match(body, /磁盘加密拖慢/);
|
|
97
|
+
assert.equal(body.includes('磁盘加密\n'), false);
|
|
98
|
+
|
|
99
|
+
// 前置条件参与检索。
|
|
100
|
+
const hit = store.search(mergeScope, 'CI 容器');
|
|
101
|
+
assert.ok(hit.some((item) => item.id === ci.record.id));
|
|
102
|
+
|
|
103
|
+
console.log('store: precondition rules ok');
|
|
104
|
+
|
|
105
|
+
// 全局层:一处写入,别的项目查得到;两层同名不合并,全局 id 跨作用域可读可归档。
|
|
106
|
+
const projA = store.scopeOf('/tmp/global-a');
|
|
107
|
+
const projB = store.scopeOf('/tmp/global-b');
|
|
108
|
+
const global = store.globalScope();
|
|
109
|
+
store.upsert(global, { title: '回复用中文', summary: '所有项目的中文回复约定', body: '- 回复:中文' });
|
|
110
|
+
const seen = store.search(projB, '中文回复');
|
|
111
|
+
assert.equal(seen.some((item) => item.scope === 'global'), true);
|
|
112
|
+
const local = store.upsert(projA, { title: '回复用中文', summary: '所有项目的中文回复约定', body: '- 回复:中文' });
|
|
113
|
+
assert.equal(local.merged, false);
|
|
114
|
+
assert.equal(store.loadIndex(global).entries.length, 1);
|
|
115
|
+
assert.equal(store.loadIndex(projA).entries.length, 1);
|
|
116
|
+
assert.match(store.readEntry(projB, seen[0].id).body, /中文/);
|
|
117
|
+
store.forget(projB, seen[0].id);
|
|
118
|
+
assert.equal(store.loadIndex(global).entries.length, 0);
|
|
119
|
+
console.log('store: global scope ok');
|