dsh-lite-memory 0.18.19 → 0.18.26

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.
@@ -23,6 +23,11 @@ const test = (name, fn) => {
23
23
  };
24
24
 
25
25
  const dirs = [];
26
+ /** Windows 会把同一个临时目录报成 8.3 短名或长名;按目录的文件 ID 判断是不是同一处。 */
27
+ const pathId = (target) => {
28
+ const stat = fs.statSync(target, { bigint: true });
29
+ return `${stat.dev}:${stat.ino}`;
30
+ };
26
31
  const fresh = (label) => {
27
32
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), `lm-state-${label}-`));
28
33
  dirs.push(dir);
@@ -218,9 +223,7 @@ test('S01 仓库根与子目录共用一份记忆', () => {
218
223
  const atSub = scopeOf(sub);
219
224
  assert.equal(atSub.key, atRoot.key);
220
225
  assert.equal(atSub.dir, atRoot.dir);
221
- /* Windows reports the temp root as an 8.3 short name on one side and the long
222
- name on the other; compare what both of them resolve to. */
223
- assert.equal(fs.realpathSync(resolveRoot(sub).root), fs.realpathSync(repo));
226
+ assert.equal(pathId(resolveRoot(sub).root), pathId(repo));
224
227
  });
225
228
 
226
229
  test('S02 符号链接、非 Git 目录、嵌套仓库各归各的', () => {
@@ -251,7 +254,7 @@ test('S03 配置的项目根只覆盖自己的子树,不吞别的目录', () =
251
254
  fs.mkdirSync(path.dirname(config), { recursive: true });
252
255
  fs.writeFileSync(config, JSON.stringify({ projectRoot: root }));
253
256
  try {
254
- assert.equal(resolveRoot(sub).root, fs.realpathSync(root));
257
+ assert.equal(pathId(resolveRoot(sub).root), pathId(root));
255
258
  assert.equal(resolveRoot(sub).source, 'configured');
256
259
  assert.equal(scopeOf(sub).key, scopeOf(root).key);
257
260
  assert.notEqual(resolveRoot(outside).source, 'configured');
@@ -327,7 +330,7 @@ test('S10 旧库迁移:正文、归档、旧标题一起搬进状态', () => {
327
330
  const loaded = load(scope).state;
328
331
  assert.equal(loaded.entries.length, 4);
329
332
  assert.equal(loaded.migration.fromVersion, 1);
330
- assert.equal(bodyOf(scope, 'mem_111111111111'), '第一步跑测试\n第二步改版本号');
333
+ assert.equal(bodyOf(scope, 'mem_111111111111').replace(/\r\n/g, '\n'), '第一步跑测试\n第二步改版本号');
331
334
  const archived = loaded.entries.find((entry) => entry.id === 'mem_444444444444');
332
335
  assert.equal(archived.status, 'archived');
333
336
  assert.equal(archived.current.body, '归档正文');
@@ -142,6 +142,26 @@ test('same title with a different when is a second entry', () => {
142
142
  assert.equal(again.record.id, b.record.id);
143
143
  });
144
144
 
145
+ test('expired or superseded records do not block a new same-title save', () => {
146
+ const scope = fresh('stale-merge');
147
+ const first = upsert(scope, { title: '旧结论', summary: '这条已经过期', topicKey: 'old-topic', body: '旧' });
148
+ const expired = loadIndex(scope);
149
+ expired.entries.find((row) => row.id === first.record.id).validUntil = new Date(Date.now() - 1000).toISOString();
150
+ saveIndex(scope, expired);
151
+ const next = upsert(scope, { title: '旧结论', summary: '新结论应该另存', topicKey: 'old-topic', body: '新' });
152
+ assert.equal(next.needsResolution, false);
153
+ assert.equal(next.merged, false);
154
+ assert.notEqual(next.record.id, first.record.id);
155
+
156
+ const second = upsert(scope, { title: '被取代的结论', summary: '已经被新版本取代', body: '旧' });
157
+ const superseded = loadIndex(scope);
158
+ superseded.entries.find((row) => row.id === second.record.id).supersededBy = 'later';
159
+ saveIndex(scope, superseded);
160
+ const replacement = upsert(scope, { title: '被取代的结论', summary: '沿同名标题重写', body: '新' });
161
+ assert.equal(replacement.needsResolution, false);
162
+ assert.notEqual(replacement.record.id, second.record.id);
163
+ });
164
+
145
165
  test('oversized body is rejected, old data untouched', () => {
146
166
  const scope = fresh('oversize');
147
167
  const big = 'x'.repeat(LIMITS.entryBytes + 1);
@@ -1069,6 +1089,24 @@ test('归属路由:本机痕迹的「通用流程」留在项目层,干净
1069
1089
  assert.equal(evidencePath.chosenScope, 'project', '依据本身是本机文件不算普适证据');
1070
1090
  assert.equal(evidencePath.reasonCode, 'machine_bound_procedure');
1071
1091
 
1092
+ const scopedProcedure = store.routeScope({
1093
+ title: 'npm 操作走 dsh-npm', summary: '本机 npm 命令用包装脚本',
1094
+ body: '本机 npm 一律用 dsh-npm;裸 npm 报 ENEEDAUTH 不是缺凭据',
1095
+ memoryClass: 'portable_procedure',
1096
+ conditions: [{ key: 'machine', value: 'macOS 桌面机' }],
1097
+ }, { project });
1098
+ assert.equal(scopedProcedure.chosenScope, 'global', '声明了机器条件的本机流程可进全局层');
1099
+ assert.equal(scopedProcedure.reasonCode, 'machine_scoped_procedure');
1100
+
1101
+ const scopedExplicit = store.routeScope({
1102
+ title: 'npm 操作走 dsh-npm', summary: '本机 npm 命令用包装脚本',
1103
+ body: '本机 npm 一律用 dsh-npm;裸 npm 报 ENEEDAUTH 不是缺凭据',
1104
+ memoryClass: 'portable_procedure', scope: 'global',
1105
+ conditions: [{ key: 'machine', value: 'macOS 桌面机' }],
1106
+ }, { project });
1107
+ assert.equal(scopedExplicit.chosenScope, 'global');
1108
+ assert.equal(scopedExplicit.reasonCode, 'explicit');
1109
+
1072
1110
  const unconfirmed = store.routeScope({
1073
1111
  title: '部署脚本', summary: '本机部署脚本', body: 'bash /tmp/deploy.sh',
1074
1112
  memoryClass: 'portable_procedure', scope: 'global',
@@ -1091,12 +1129,21 @@ test('归属路由:本机痕迹的「通用流程」留在项目层,干净
1091
1129
  assert.equal(modelGlobalFact.chosenScope, 'project', '模型自填 global 不能把项目事实送进全局层');
1092
1130
  assert.equal(modelGlobalFact.reasonCode, 'model_global_project_fact');
1093
1131
 
1094
- const modelGlobalProcedure = store.routeScope({
1132
+ /** 显式 global 是用户要的那一层:没 evidenceRef 也只代表模型没补依据,不能把它降回项目层。 */
1133
+ const userGlobalProcedure = store.routeScope({
1095
1134
  title: '提交前跑测试', summary: '提交前跑测试', body: 'pnpm test',
1096
1135
  memoryClass: 'portable_procedure', scope: 'global',
1097
1136
  }, { project });
1098
- assert.equal(modelGlobalProcedure.chosenScope, 'project', '模型自填 global 少了依据也不能进全局层');
1099
- assert.equal(modelGlobalProcedure.reasonCode, 'unbound_evidence');
1137
+ assert.equal(userGlobalProcedure.chosenScope, 'global');
1138
+ assert.equal(userGlobalProcedure.reasonCode, 'explicit');
1139
+
1140
+ /** 没显式 scope 时,memoryClass 只是提案:缺可搬依据的流程仍留在项目层。 */
1141
+ const classOnlyProcedure = store.routeScope({
1142
+ title: '提交前跑测试', summary: '提交前跑测试', body: 'pnpm test',
1143
+ memoryClass: 'portable_procedure',
1144
+ }, { project });
1145
+ assert.equal(classOnlyProcedure.chosenScope, 'project', '没有显式 scope 又缺依据,不能自己搬去全局层');
1146
+ assert.equal(classOnlyProcedure.reasonCode, 'unbound_evidence');
1100
1147
  });
1101
1148
 
1102
1149
  test('几百条归档的正文不进热状态,下一次保存照样能过', () => {
package/view.js CHANGED
@@ -309,17 +309,37 @@ function wordsOf(text) {
309
309
  } catch {
310
310
  for (const word of plainText.split(/[^\p{L}\p{N}]+/u)) if (word && !STOPWORDS.has(word)) out.push(word);
311
311
  }
312
- /** 本机 ICU 缺中文词典时整句会被切成单字;补上二元组,别让「这」这种单字当整词。 */
313
- if (!/[a-z]/i.test(plainText) && out.some((word) => [...word].length === 1)) {
314
- const chars = plainText.replace(/[^\p{L}\p{N}]+/gu, '');
315
- for (let i = 0; i + 1 < chars.length; i += 1) {
316
- const gram = chars.slice(i, i + 2);
317
- if (!STOPWORDS.has(gram)) out.push(gram);
312
+ /** 本机 ICU 缺中文词典时中文会被切成单字;只在出现单字时才补中文二元组,别让二元组把查询的实词数量撑大。 */
313
+ if (out.some((word) => [...word].length === 1 && /[\u4e00-\u9fff]/.test(word))) {
314
+ for (const run of plainText.split(/[^\u4e00-\u9fff]+/)) {
315
+ for (let i = 0; i + 1 < run.length; i += 1) {
316
+ const gram = run.slice(i, i + 2);
317
+ if (!STOPWORDS.has(gram)) out.push(gram);
318
+ }
318
319
  }
319
320
  }
320
321
  return out.slice(0, 48);
321
322
  }
322
323
 
324
+ /** 只取切词给出的多字实词:本机 ICU 切成单字时返回空,说明这句子没有可靠词边界。 */
325
+ function contentWordsOf(text) {
326
+ const plainText = plain(text, 400).toLowerCase();
327
+ const out = [];
328
+ try {
329
+ const segmenter = new Intl.Segmenter('zh', { granularity: 'word' });
330
+ for (const part of segmenter.segment(plainText)) {
331
+ const word = part.segment.trim();
332
+ if ([...word].length < 2 || !/[\p{L}\p{N}]/u.test(word)) continue;
333
+ if (!STOPWORDS.has(word)) out.push(word);
334
+ }
335
+ } catch {
336
+ for (const word of plainText.split(/[^\p{L}\p{N}]+/u)) {
337
+ if ([...word].length >= 2 && !STOPWORDS.has(word)) out.push(word);
338
+ }
339
+ }
340
+ return out;
341
+ }
342
+
323
343
  /** 纯英文查询按整词判断:git 不该命中 digital。自动召回和手动搜索共用这一条。 */
324
344
  export const asciiWordsOf = (text) => plain(text, 400).toLowerCase().match(/[a-z][a-z0-9_-]{1,}/g) || [];
325
345
  export const wordIn = (pool, word) => new RegExp(`(^|[^a-z0-9])${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}($|[^a-z0-9])`).test(pool);
@@ -357,14 +377,22 @@ export function matchScore(record, query, corpus = null) {
357
377
  return { score: 1000, hints: ['id'] };
358
378
  }
359
379
  const grams = gramsOf(raw);
360
- const words = wordsOf(raw);
380
+ /** 本机切词会同时给出整词和补的二元组,去重后同一个词才只算一次。 */
381
+ const words = [...new Set(wordsOf(raw))];
361
382
  const hints = [];
362
383
  let score = 0;
384
+ /** 英文按整词比对:纯英文二元组只在该词整体出现时才计数,中英混排也一样。 */
385
+ const ascii = asciiWordsOf(raw);
386
+ const englishIn = (text) => ascii.some((word) => wordIn(plain(text, 400).toLowerCase(), word));
363
387
  const hit = (text, weight) => {
364
388
  if (!text) return 0;
365
389
  const pool = gramsOf(text);
390
+ const wholeEnglish = englishIn(text);
366
391
  let shared = 0;
367
- for (const gram of grams) if (pool.has(gram)) shared += 1;
392
+ for (const gram of grams) {
393
+ if (/^[a-z0-9]+$/.test(gram) && !wholeEnglish) continue;
394
+ if (pool.has(gram)) shared += 1;
395
+ }
368
396
  if (shared === 0) return 0;
369
397
  return (shared / Math.max(1, Math.min(grams.size, 12))) * weight;
370
398
  };
@@ -402,21 +430,38 @@ export function matchScore(record, query, corpus = null) {
402
430
  const df = corpus.df.get(text) || 0;
403
431
  return df > Math.max(3, corpus.total * 0.15);
404
432
  };
405
- let titleWordHit = false;
406
- let keywordWordHit = false;
407
- /** 标题里同时命中的不同实词:两个以上独立概念都落在标题,比只沾一个词更说明在说同一件事。 */
433
+ /**
434
+ * 查询里有两个以上实词时,只沾上一个不算在说同一件事。
435
+ * 「启动服务器」只碰到「启动失败」里的「启动」,不能把改包名的记忆带进自动提示。
436
+ * 单实词查询(「发版」「样式」)不设这个门槛。
437
+ */
438
+ const distinctContent = [...new Set(contentWordsOf(raw).filter((word) => /[\u4e00-\u9fff]/.test(word) && !common(word)))];
439
+ const keywordWords = new Set();
408
440
  const titleWords = new Set();
409
441
  for (const word of words) {
410
442
  if ([...word].length < 2) continue;
411
443
  const plainWord = word.toLowerCase();
412
444
  if (common(plainWord)) continue;
413
- if (keywordTexts.some((text) => plain(text, 400).toLowerCase().includes(plainWord))) { keywordWordHit = true; continue; }
445
+ if (keywordTexts.some((text) => plain(text, 400).toLowerCase().includes(plainWord))) {
446
+ keywordWords.add(plainWord);
447
+ continue;
448
+ }
414
449
  if (plain(record.title, 400).toLowerCase().includes(plainWord)
415
450
  || (record.aliases || []).some((name) => plain(name, 400).toLowerCase().includes(plainWord))) {
416
- titleWordHit = true;
417
451
  titleWords.add(plainWord);
418
452
  }
419
453
  }
454
+ const wholeText = [record.title, (record.aliases || []).join(' '), (record.tags || []).join(' '),
455
+ (record.triggers || []).join(' '), record.summary, record.when, record.body]
456
+ .map((text) => plain(text, 400).toLowerCase()).filter(Boolean).join('\n');
457
+ const matchedContent = distinctContent.filter((word) => wholeText.includes(word)).length;
458
+ const enoughContent = distinctContent.length < 2 || matchedContent >= 2;
459
+ if (!enoughContent) {
460
+ keywordWords.clear();
461
+ titleWords.clear();
462
+ }
463
+ const titleWordHit = titleWords.size > 0;
464
+ const keywordWordHit = keywordWords.size > 0;
420
465
  if (!keywordWordHit && titleWordHit) { score += WEIGHT.title; hints.push('title'); }
421
466
  if (keywordWordHit) { score += WEIGHT.keywords; hints.push('keyword'); }
422
467
  if (titleWords.size >= 2) { score += WEIGHT.coverage; hints.push('coverage'); }
@@ -437,7 +482,6 @@ export function matchScore(record, query, corpus = null) {
437
482
  const whenScore = hit(record.when, WEIGHT.when);
438
483
  if (whenScore > 0) score += whenScore;
439
484
  /** 纯英文查询必须整词命中,别让语料里的字面巧合把不相关的记忆带进自动提示。 */
440
- const ascii = asciiWordsOf(raw);
441
485
  if (ascii.length && !/[\u4e00-\u9fff]/.test(raw)) {
442
486
  const pool = [record.title, (record.aliases || []).join(' '), ...(record.tags || []), ...(record.triggers || []), record.summary, record.when]
443
487
  .filter(Boolean).join('\n').toLowerCase();
@@ -563,14 +607,14 @@ export function toModelView(record, purpose, extra = {}) {
563
607
  if (purpose === 'search') {
564
608
  const view = pick(record, ['id', 'title', 'summary', 'when', 'revision']);
565
609
  view.scope = scope;
566
- if (record.origin === 'project_evolution') view.origin = 'project_evolution';
610
+ if (record.origin === 'project_evolution' || record.origin === 'model_drafted') view.origin = record.origin;
567
611
  if (extra.status && extra.status !== 'ok') view.state = extra.status;
568
612
  return view;
569
613
  }
570
614
  if (purpose === 'read') {
571
615
  const view = pick(record, ['id', 'title', 'summary', 'when', 'kind', 'revision', 'body']);
572
616
  view.scope = scope;
573
- if (record.origin === 'project_evolution') view.origin = 'project_evolution';
617
+ if (record.origin === 'project_evolution' || record.origin === 'model_drafted') view.origin = record.origin;
574
618
  if (Array.isArray(record.conditions) && record.conditions.length) view.conditions = record.conditions;
575
619
  if (record.selection && record.selection.mode === 'complete') view.selection = record.selection.artifactKey;
576
620
  const related = linksOf(record).length;
@@ -649,6 +693,75 @@ export function textOfMessage(message) {
649
693
  }
650
694
 
651
695
  /** 最后一句真人说的话;goal 轮次、压缩摘要、时间快照、后台任务回执都不算。 */
696
+ /** 续说一句(「继续」「然后呢」)本身没有检索线索:沿用上一轮原话,别让自动提醒在这一轮断掉。 */
697
+ export function isContinuationQuery(text) {
698
+ const raw = plain(text, 40).trim().toLowerCase().replace(/[\s\u3002\uff01\uff1f?!\uff0c,\u3001\u2026~\uff5e]+/gu, '');
699
+ if (!raw || [...raw].length > 8) return false;
700
+ return /^(继续|接着|然后|然后呢|还有|还有呢|往下|说下去|goon|continue|next|more)$/.test(raw);
701
+ }
702
+
703
+ /**
704
+ * 动作侧检索词:用户这句话没提,但「刚才在动什么」往往就写在上一轮助手的话和工具名里。
705
+ * 预防型记忆(先重载再重启、改插件后别用 Cmd+R)约束的是下一步动作,
706
+ * 只拿用户原话查,词面永远在错的一侧。这里只取少量最近的实词和工具名,避免长文稀释命中。
707
+ */
708
+ const ACTION_NOISE = new Set(['const', 'function', 'return', 'import', 'export', 'from', 'async', 'await', 'true', 'false', 'null', 'undefined', 'string', 'number', 'object']);
709
+ export function actionTerms(messages, options = {}) {
710
+ if (!Array.isArray(messages)) return '';
711
+ const limit = Number.isInteger(options.limit) && options.limit > 0 ? options.limit : 16;
712
+ const terms = [];
713
+ const seen = new Set();
714
+ const push = (word) => {
715
+ const text = String(word || '').trim().toLowerCase();
716
+ if ([...text].length < 2 || ACTION_NOISE.has(text) || seen.has(text)) return;
717
+ seen.add(text);
718
+ terms.push(text);
719
+ };
720
+ const scan = (message) => {
721
+ const content = message?.content;
722
+ if (!Array.isArray(content)) return '';
723
+ let text = '';
724
+ for (const part of content) {
725
+ if (!part || typeof part !== 'object') continue;
726
+ if (part.type === 'tool-call' || part.type === 'tool_call') {
727
+ if (typeof part.name === 'string') push(part.name);
728
+ const input = part.input || part.arguments || part.args;
729
+ if (input && typeof input === 'object') {
730
+ for (const value of Object.values(input)) {
731
+ if (typeof value === 'string' && /[./_-]/.test(value)) {
732
+ for (const piece of value.split(/[^\p{L}\p{N}_.-]+/u)) push(piece);
733
+ }
734
+ }
735
+ }
736
+ continue;
737
+ }
738
+ if (part.type === 'text' && typeof part.text === 'string') text += `${part.text}\n`;
739
+ }
740
+ return text;
741
+ };
742
+ let assistants = 0;
743
+ let previousHuman = false;
744
+ for (let i = messages.length - 1; i >= 0 && terms.length < limit; i -= 1) {
745
+ const message = messages[i];
746
+ if (!message) continue;
747
+ if (message.role === 'assistant') {
748
+ const text = scan(message);
749
+ if (!text) continue;
750
+ assistants += 1;
751
+ for (const word of contentWordsOf(text)) push(word);
752
+ for (const word of asciiWordsOf(text)) push(word);
753
+ if (assistants >= 2) break;
754
+ } else if (message.role === 'user' && !previousHuman && message?.source?.kind !== 'goal') {
755
+ const text = scan(message);
756
+ if (!text) continue;
757
+ previousHuman = true;
758
+ for (const word of contentWordsOf(text)) push(word);
759
+ for (const word of asciiWordsOf(text)) push(word);
760
+ }
761
+ }
762
+ return terms.slice(0, limit).join(' ');
763
+ }
764
+
652
765
  export function newestUserQuery(messages) {
653
766
  if (!Array.isArray(messages)) return '';
654
767
  for (let i = messages.length - 1; i >= 0; i -= 1) {