dsh-lite-memory 0.2.8 → 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 CHANGED
@@ -21,6 +21,8 @@ Lightweight memory for the DeepSeek Harness. Nothing in the system prompt, nothi
21
21
 
22
22
  这些行只属于产生它的那一轮:重进会话就没了,历史里只留正文。同一件事再记一次会合并进原条目(旧要点保留,同键的新值覆盖);前置条件不同的同一主题是两条,不合并。
23
23
 
24
+ 写入时分两层:默认只在这个项目生效;用户说「以后所有项目都这样」才进全局层(上限 40 条),检索时一起查。同一主题在不同前提下用 `when` 区分,各存一条。
25
+
24
26
  触发词:`记住`、`记一下`、`上次`、`规范`、`约定`、`路径`、`凭据怎么取`。
25
27
 
26
28
  | Case | Behaviour |
@@ -31,6 +33,8 @@ Lightweight memory for the DeepSeek Harness. Nothing in the system prompt, nothi
31
33
 
32
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.
33
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.
37
+
34
38
  ## 点一下就存 / One click, no turn
35
39
 
36
40
  `keep` 是同源直写:不产生消息、不动输入框、不打断当前回复。按钮变 `kept` 就结束了。自动写入的行尾有个 `✕`,点它就把刚才那条移入归档。
package/TRIGGERS.md CHANGED
@@ -34,13 +34,25 @@
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
  一条约束只留一条,但**前置条件不一样就是两条**。同一条记忆挂在「什么情况下」上:换机器、换环境、换前提,内容往往互相矛盾,硬合并等于丢掉其中一种情况。
40
52
 
41
53
  判断顺序:
42
54
 
43
- 1. 前置条件不同(含一边写了自己没写)→ 新建,标题后面跟场景,如「构建耗时长|CI 容器」。
55
+ 1. 前置条件不同(含一边写了自己没写)→ 新建,标题后面跟场景,如「构建耗时长|CI 容器」。合并只在同一层内发生:全局跟项目不合并。
44
56
  2. 前置条件一致,且标题相同、标题字片重合 ≥ 0.6、或摘要重合 ≥ 0.75 → 合并。
45
57
  3. 只共用项目名不算合并(「插件市场条目字段」对「插件市场踩坑清单」重合约 0.4)。
46
58
  4. 判不准先 `memory_search`:命中且场景一致,就按那条的标题和场景写;不一致就新建。
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,
@@ -177,7 +178,7 @@ export function apply(ctx) {
177
178
  }],
178
179
  },
179
180
  execute: async (args, execution) => {
180
- const scope = scopeOf(cwdOf(execution));
181
+ const scope = args.scope === 'global' ? globalScope() : scopeOf(cwdOf(execution));
181
182
  const saved = upsert(scope, args);
182
183
  return { id: saved.record.id, title: saved.record.title, summary: saved.record.summary, merged: saved.merged };
183
184
  },
@@ -218,7 +219,7 @@ export function apply(ctx) {
218
219
 
219
220
  ctx.effect(() => tools.register({
220
221
  name: 'memory_search',
221
- description: `按关键词检索已有记忆,返回标题与一句话摘要(最多 4 条)。用户提到「记住过 / 上次 / 规范 / 约定 / 路径 / 凭据怎么取」时先查这里。命中后再用 memory_read 取正文。${NOTES}`,
222
+ description: `按关键词检索已有记忆,返回标题与一句话摘要(最多 4 条),项目库与全局库一起查,「[全局]」前缀表示跨项目通用。带(场景)的条目只在该条件下成立。用户提到「记住过 / 上次 / 规范 / 约定 / 路径 / 凭据怎么取」时先查这里。命中后再用 memory_read 取正文。${NOTES}`,
222
223
  parameters: SEARCH_PARAMS,
223
224
  output: {
224
225
  schema: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-lite-memory",
3
- "version": "0.2.8",
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,6 +9,7 @@ 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,
@@ -43,6 +44,21 @@ export function scopeOf(cwd) {
43
44
  return { key: `${label}-${hash}`, dir: path.join(memoryRoot(), `${label}-${hash}`), cwd: abs };
44
45
  }
45
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
+
46
62
  function readJson(file, fallback) {
47
63
  try {
48
64
  return JSON.parse(fs.readFileSync(file, 'utf8'));
@@ -116,7 +132,8 @@ export function trimIndex(scope, index) {
116
132
  const rank = (item) => (item.lastUsed || item.updated || item.created || '');
117
133
  return rank(a).localeCompare(rank(b));
118
134
  });
119
- while (index.entries.length > LIMITS.entries) archiveEntry(scope, index, byStaleness.shift());
135
+ const cap = scope.key === 'global' ? LIMITS.globalEntries : LIMITS.entries;
136
+ while (index.entries.length > cap) archiveEntry(scope, index, byStaleness.shift());
120
137
  let total = index.entries.reduce((sum, item) => sum + (item.bytes || 0), 0);
121
138
  while (total > LIMITS.totalBytes && byStaleness.length > 0) {
122
139
  const victim = byStaleness.shift();
@@ -235,47 +252,53 @@ export function upsert(scope, draft) {
235
252
  }
236
253
 
237
254
  export function search(scope, query, limit = 4) {
238
- const index = loadIndex(scope);
239
255
  const terms = normalizeText(query, 200)
240
256
  .toLowerCase()
241
257
  .split(/[^\p{L}\p{N}]+/u)
242
258
  .filter((word) => word.length > 1);
243
- const scored = index.entries.map((item) => {
244
- const hay = `${item.title} ${item.summary} ${item.when || ''} ${(item.tags || []).join(' ')}`.toLowerCase();
245
- let score = 0;
246
- for (const term of terms) if (hay.includes(term)) score += term.length > 2 ? 2 : 1;
247
- if (terms.length > 0 && score === 0) score = similarity(item.title, query) > 0.5 ? 1 : 0;
248
- return { item, score };
249
- });
250
- const hits = scored.filter((row) => row.score > 0).sort((a, b) => b.score - a.score).slice(0, limit);
251
- if (hits.length > 0) {
252
- const now = new Date().toISOString();
253
- for (const hit of hits) {
254
- hit.item.hits = (hit.item.hits || 0) + 1;
255
- 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 });
256
271
  }
257
- saveIndex(scope, index);
258
272
  }
259
- return hits.map((row) => row.item);
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' }));
260
283
  }
261
284
 
262
285
  export function readEntry(scope, id) {
263
- const index = loadIndex(scope);
264
- const record = index.entries.find((item) => item.id === id);
265
- if (!record) throw new Error(`没有这条记忆:${id}`);
266
- const body = fs.readFileSync(entryFile(scope, record.id), 'utf8');
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');
267
290
  record.lastUsed = new Date().toISOString();
268
291
  record.hits = (record.hits || 0) + 1;
269
- saveIndex(scope, index);
292
+ saveIndex(source, index);
270
293
  return { record, body };
271
294
  }
272
295
 
273
296
  export function forget(scope, id) {
274
- const index = loadIndex(scope);
275
- const record = index.entries.find((item) => item.id === id);
276
- if (!record) throw new Error(`没有这条记忆:${id}`);
277
- archiveEntry(scope, index, record);
278
- saveIndex(scope, index);
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);
279
302
  return record;
280
303
  }
281
304
 
@@ -326,7 +349,7 @@ export function renderIndex(entries, budget = 400) {
326
349
  for (const item of entries) {
327
350
  const last = Date.parse(item.lastUsed || item.updated || item.created || '') || now;
328
351
  const stale = now - last > staleMs;
329
- const head = item.when ? `${item.title}(${item.when})` : item.title;
352
+ const head = `${item.scope === 'global' ? '[全局] ' : ''}${item.title}${item.when ? `(${item.when})` : ''}`;
330
353
  const line = stale ? `- ${item.id} ${head}` : `- ${item.id} ${head}|${item.summary}`;
331
354
  if (used + line.length > budget) break;
332
355
  lines.push(line);
@@ -101,3 +101,19 @@ const hit = store.search(mergeScope, 'CI 容器');
101
101
  assert.ok(hit.some((item) => item.id === ci.record.id));
102
102
 
103
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');