dsh-lite-memory 0.18.36 → 0.18.38

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/client.js CHANGED
@@ -2516,6 +2516,45 @@ window.__ModuleLoader__.load({
2516
2516
  });
2517
2517
  };
2518
2518
 
2519
+ /** 跨层搬移是人的决定:模型只能提案,面板这一下才真的换层。 */
2520
+ const moveMemory = (record) => {
2521
+ if (busy || !record || !record.id) return;
2522
+ const target = selectedKey === 'global' ? 'project' : 'global';
2523
+ setBusy(true);
2524
+ setNote('');
2525
+ const operationId = newOperationId();
2526
+ let attempts = 0;
2527
+ const step = (resume) => {
2528
+ attempts += 1;
2529
+ const payload = resume ? { sessionId, operationId } : { sessionId, id: record.id, target, operationId };
2530
+ post(resume ? '/lite-memory/resume-move' : '/lite-memory/move', payload).then((result) => {
2531
+ if (!alive.current) return;
2532
+ if (result.status === 202) {
2533
+ if (attempts < 3) {
2534
+ step(true);
2535
+ return;
2536
+ }
2537
+ setBusy(false);
2538
+ setNote('搬移还没收尾,再点一次继续');
2539
+ load(selectedKey);
2540
+ return;
2541
+ }
2542
+ if (!alive.current || (selectedKey && scopeRef.current !== selectedKey)) return;
2543
+ setBusy(false);
2544
+ if (succeeded(result)) {
2545
+ setSelectedMemory('');
2546
+ setDetail(null);
2547
+ setEditingMemory(false);
2548
+ setNote(target === 'global' ? '已移到全局,各项目都能用' : '已移回当前项目');
2549
+ load(selectedKey);
2550
+ return;
2551
+ }
2552
+ setNote(failure(result, '没有移动'));
2553
+ });
2554
+ };
2555
+ step(false);
2556
+ };
2557
+
2519
2558
  const setControlMode = (mode) => {
2520
2559
  if (busy || !canToggle || mode === memoryMode) return;
2521
2560
  setBusy(true);
@@ -2627,7 +2666,7 @@ window.__ModuleLoader__.load({
2627
2666
  style: { display: 'flex', flexDirection: 'column', gap: '3px', padding: '7px 18px 8px', borderBottom: '1px solid var(--dsw-alias-border-l1)', flex: '0 0 auto' },
2628
2667
  },
2629
2668
  globalView
2630
- ? h('div', { style: { ...PANEL_MUTED, lineHeight: '20px' } }, '全局记忆只读')
2669
+ ? h('div', { style: { ...PANEL_MUTED, lineHeight: '20px' } }, '全局记忆只读,可移回项目')
2631
2670
  : h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(190px, 1fr))', gap: '4px 18px' } },
2632
2671
  modeChoices(),
2633
2672
  switchItem('自进化', evolutionOn ? '实验功能,会增加 token 消耗' : '从反馈里整理经验', evolutionOn, busy || !evolution || !canToggle || !memoryOn, toggleEvolution), // ui-copy-ignore 用户指定文案
@@ -2733,6 +2772,9 @@ window.__ModuleLoader__.load({
2733
2772
  }, '修改'),
2734
2773
  )
2735
2774
  : null,
2775
+ !editingMemory && detailRecord && memoryOn && (globalView || canManage)
2776
+ ? h(Action, { disabled: busy, onClick: () => moveMemory(detailRecord) }, selectedKey === 'global' ? '移回项目' : '移到全局')
2777
+ : null,
2736
2778
  )
2737
2779
  : h('div', { style: PANEL_MUTED }, detailNote || '读取中…'),
2738
2780
  )
package/docs/BENCH.md CHANGED
@@ -25,22 +25,22 @@ npm run bench:evolution # 实验规则/样例/检查点/回执的存储与投
25
25
 
26
26
  ## 固定数据集性能
27
27
 
28
- 2026-10-29,macOS arm64,Node v24.16.0,本地 SSD。数据:项目 200 条 + 全局 200 条,全部带标题、摘要、`when`、正文;另加一条正面场景,确认全局卡片真的进得来且不超预算。
28
+ 2026-10-29,macOS arm64,Node v24.16.0,本地 SSD。数据:项目 200 条 + 全局 200 条,全部带标题、摘要、`when`、正文;另加一条正面场景,确认全局卡片真的进得来且不超预算。0.18.38 版含跨层搬移后的复测。
29
29
 
30
30
  0.18.0 起每次保存都走「写临时文件 → fsync → rename → fsync 目录」并保留上一版,代价是单次保存多两次落盘;下面这组数是带这套持久化保证后测的。
31
31
 
32
32
  | 指标 | 数值 |
33
33
  | --- | --- |
34
- | 准备时延 P50 | 62.3 ms |
35
- | 准备时延 P95 | 112.4 ms |
36
- | 冷启动首次准备 | 83.9 ms |
37
- | 最慢一次 | 136.1 ms |
34
+ | 准备时延 P50 | 51.4 ms |
35
+ | 准备时延 P95 | 59.4 ms |
36
+ | 冷启动首次准备 | 76.5 ms |
37
+ | 最慢一次 | 76.5 ms |
38
38
  | 每次准备上下文 | 368 B |
39
39
  | 正面场景上下文 | 335 B,含 1 张全局卡片,未超 3 条 / 1200 字节 |
40
40
  | 每次准备文件读取 | 0.2 |
41
41
  | 本次数据规模 | 项目 200 条 + 全局 200 条 |
42
42
 
43
- 数据翻倍到两层各 200 条后,准备时延比 0.18.0 的 200 条单层基线高;deadline 150 ms 不变,当前 P95 112.4 ms 仍在限内。模型 provider 在准备阶段无 I/O、无网络。
43
+ 数据翻倍到两层各 200 条后仍在 deadline 150 ms 内:0.18.38 复测 P95 59.4 ms。模型 provider 在准备阶段无 I/O、无网络。
44
44
 
45
45
  ## 长期序列
46
46
 
@@ -52,8 +52,8 @@ npm run bench:evolution # 实验规则/样例/检查点/回执的存储与投
52
52
  | 全局活跃条目 | 200 |
53
53
  | `state.json` | 396 KB(上限 2 MiB) |
54
54
  | 层内文件 | `state.json`、`state.prev.json` 两个,不随轮数增长 |
55
- | RSS | 190 MB 量级(含进程内缓存与测试数据) |
56
- | 序列耗时 | 7.3 s(150 轮全落盘) |
55
+ | RSS | 219 MB(含进程内缓存与测试数据) |
56
+ | 序列耗时 | 5.8 s(150 轮全落盘) |
57
57
 
58
58
  ## 实验整理成本(0.11.0)
59
59
 
@@ -0,0 +1,8 @@
1
+ # 0.18.37:npm 提醒先看当前项目层
2
+
3
+ - 动作前的 npm 提醒不再只查全局层:先查当前项目层的现行记忆,再查全局层,项目规则优先。
4
+ - 本机流程按记忆分层规矩留在项目层,也能在 `npm whoami` / `npm publish` 前拦一次,而不是因为没进全局层就失效。
5
+ - 同一条提醒仍然每轮最多一次,已交付过的条目不会再提示;项目记忆关闭时行为不变。
6
+ - 补回归:项目层与全局层各放一条 npm 规则,验证项目层优先且正文送达。
7
+
8
+ 验证:`npm test` 全绿;`npm run bench` / `npm run bench:evolution` 均 exit 0;`style-lint` 通过。
@@ -0,0 +1,9 @@
1
+ # 0.18.38:面板可跨层搬移记忆
2
+
3
+ - 明细里新增搬移动作:项目记忆可移到全局,全局记忆可移回当前项目;全局视图只读,搬回由当前会话的项目层承接。
4
+ - 同一操作重复提交按 `operationId` 幂等;目标层有同名、近义或主题槽位冲突时拒绝并说明原因,不覆盖。
5
+ - 服务端只认会话身份:面板带 `sessionId`,项目目录由服务端查,客户端不用抄路径;未知会话回 `请求不完整`。
6
+ - 搬移后 id 换成新层身份,旧 id 留原层归档;出链在目标层不可见时拒绝,避免搬过去就断链。
7
+ - 补回归:保存探针、移到全局、再移回项目,校验层、id、正文与旧 id 归档读取。
8
+
9
+ 验证:`npm test` 全绿;`npm run bench` exit 0(P50 51.4 ms / P95 59.4 ms,上下文 368 B);`style-lint` 通过。
package/index.js CHANGED
@@ -739,7 +739,10 @@ function keepHandler(webServer, cwdForSession) {
739
739
  return sendJson(res, 200, { ok: true, ...listCitations(sessionId) });
740
740
  }
741
741
 
742
- const cwd = typeof body.cwd === 'string' ? body.cwd : '';
742
+ /** 面板只带会话:工作目录由会话身份查,客户端不用抄路径。 */
743
+ const cwd = typeof body.cwd === 'string' && body.cwd
744
+ ? body.cwd
745
+ : (typeof body.sessionId === 'string' ? cwdForSession(body.sessionId) || '' : '');
743
746
  if (cwd.length === 0) return sendJson(res, 400, { ok: false, reason: '请求不完整' });
744
747
  const scope = scopeOf(cwd);
745
748
  const id = typeof body.id === 'string' ? body.id : '';
@@ -842,29 +845,29 @@ function refsOf(hits) {
842
845
  const SAVE_PARAMS = {
843
846
  type: 'object',
844
847
  properties: {
845
- updateTitle: { type: 'string', description: '要更新的那条记忆的标题(原样抄搜索结果里的标题)。同一件事更新时用它,别另建一条' },
846
- title: { type: 'string', description: `标题:名词短语,用户一眼能认出是哪件事,不超过 ${LIMITS.titleChars} 字。它就是这条记忆的身份` },
847
- summary: { type: 'string', description: `一句话说清要记的内容本身——用户只看这一行决定留不留,名词短语最好,超长会自动截到这个长度` },
848
- body: { type: 'string', description: `正文 markdown,整段替换旧正文,不超过 ${LIMITS.entryBytes} 字节。只写原则、判断规则、触发条件和边界;不写一次性数字或代码片段,这些查当前代码或文档。` },
849
- when: { type: 'string', description: `前置条件或适用场景(「在哪台机器 / 哪个环境 / 什么情况下」),不超过 ${LIMITS.whenChars} 字。同一标题但前置条件不同,会存成两条,不合并;更新时不给就保留原值` },
850
- kind: { type: 'string', enum: ['preference', 'convention', 'procedure', 'pointer', 'pitfall', 'credential_hint', 'note'], description: '条目类型;更新时不给就保留原值' },
851
- tags: { type: 'array', items: { type: 'string' }, description: '最多 5 个标签;更新时不给就保留原值' },
848
+ updateTitle: { type: 'string', description: '要更新的已有条目标题,原样抄搜索结果;同一件事更新时用,不新建' },
849
+ title: { type: 'string', description: `标题,名词短语,≤${LIMITS.titleChars} 字;就是条目身份` },
850
+ summary: { type: 'string', description: '一句话说明记的是什么,名词短语;超长自动截断' },
851
+ body: { type: 'string', description: `正文 markdown,≤${LIMITS.entryBytes} 字节;只写原则、判断规则、触发条件和边界` },
852
+ when: { type: 'string', description: `适用场景(哪台机器/什么环境/什么情况),≤${LIMITS.whenChars} 字;同标题不同 when 存两条;更新不给则保留` },
853
+ kind: { type: 'string', enum: ['preference', 'convention', 'procedure', 'pointer', 'pitfall', 'credential_hint', 'note'], description: '条目类型;更新不给则保留' },
854
+ tags: { type: 'array', items: { type: 'string' }, description: '最多 5 个标签;更新不给则保留' },
852
855
  supersedes: {
853
856
  type: 'string',
854
- description: '要取代的那条记忆的标题(原样抄搜索结果里的标题):新内容写成新条目,旧条目标为已被取代并保留在历史里;同层才有效,已被取代过的不能再取代',
857
+ description: '被取代的旧条目标题,原样抄搜索结果;同层有效,旧条目保留在历史',
855
858
  },
856
859
  resolution: {
857
860
  type: 'string',
858
861
  enum: ['distinct'],
859
- description: '明确表示「这确实是另一条」。返回 needsResolution 时,只有确认不是同一件事才带上它再存一次',
862
+ description: '确认「不是同一条」;仅当返回 needsResolution 且核对后确实不同时带上',
860
863
  },
861
- scope: { type: 'string', enum: ['auto', 'project', 'global'], description: '存哪一层:auto 按记忆类别判定(默认);只有用户这一次明确说「以后所有项目都这样」才用 global,模型自己判断通用不是理由;明确说只在这个项目用 project。本机路径/端口和项目事实即使写 global 也会留在项目层,降级原因见 scopeNote;只在本机成立的命令/流程声明 machine 或 runtime 条件后可进全局层' },
862
- memoryClass: { type: 'string', enum: ['personal_preference', 'project_fact', 'environment_fact', 'portable_procedure'], description: '归属提案:个人偏好 / 项目事实 / 设备事实 / 可搬流程。服务端据此判定存哪层;设备事实必须同时给设备条件' },
863
- durability: { type: 'string', enum: ['durable', 'task'], description: '一般认为 durable;只是一次性任务参数用 task,服务端会拒绝落盘' },
864
- evidenceRef: { type: 'string', description: '归属判定的依据(哪条消息、哪个文件、哪次观察);用户没说范围时用来判断能不能存' },
864
+ scope: { type: 'string', enum: ['auto', 'project', 'global'], description: '存哪层:auto(默认,按类别判定)/ project / global。只有用户明确说「所有项目」才 global;本机路径、端口、项目事实会降为项目层;仅本机成立的流程声明 machine/runtime 条件后可进全局' },
865
+ memoryClass: { type: 'string', enum: ['personal_preference', 'project_fact', 'environment_fact', 'portable_procedure'], description: '归属提案:个人偏好/项目事实/设备事实/可搬流程;设备事实须带条件' },
866
+ durability: { type: 'string', enum: ['durable', 'task'], description: 'durable(默认)或 task(一次性参数,拒绝落盘)' },
867
+ evidenceRef: { type: 'string', description: '归属依据:哪条消息/文件/观察' },
865
868
  sameTopicAs: {
866
869
  type: 'object',
867
- description: '显式绑定到已有主题槽位:{scope, title}。标题对不上就报错,不写',
870
+ description: '绑定已有主题槽位;标题对不上则报错不写',
868
871
  properties: {
869
872
  scope: { type: 'string', enum: ['project', 'global'] },
870
873
  title: { type: 'string' },
@@ -875,16 +878,16 @@ const SAVE_PARAMS = {
875
878
  mode: {
876
879
  type: 'string',
877
880
  enum: ['patch', 'replace_current'],
878
- description: '默认 patch:没给的字段保持原值。要整条换掉时用 replace_current,并把 title/summary/when/body/kind/tags 全部显式给出(空值也要写),不继承旧值',
881
+ description: 'patch(默认,未给字段保留)或 replace_current(整条替换,须显式给 title/summary/when/body/kind/tags)',
879
882
  },
880
883
  applicability: {
881
884
  type: 'string',
882
885
  enum: view.APPLICABILITY,
883
- description: '适用范围:all_in_scope 普遍适用;conditional 条件适用(必须给 conditions);unresolved 还没定(有 when 但没结构化条件时默认这个)。不给就按字段推断',
886
+ description: 'all_in_scope 普遍适用;conditional 须给 conditions;unresolved 还没定(默认)',
884
887
  },
885
888
  conditions: {
886
889
  type: 'array',
887
- description: '条件适用的具体条件,最多 8 条;给了空数组就是清空',
890
+ description: '条件,最多 8 条;空数组表示清空',
888
891
  items: {
889
892
  type: 'object',
890
893
  properties: {
@@ -897,23 +900,23 @@ const SAVE_PARAMS = {
897
900
  },
898
901
  selection: {
899
902
  type: ['object', 'null'],
900
- description: '只对某个产物成立的规则:complete 表示清单完整(必须给 artifactKey),examples 只是举例。null 表示清空',
903
+ description: '只对某产物成立:complete 清单完整(须 artifactKey),examples 仅举例;null 清空',
901
904
  properties: {
902
905
  mode: { type: 'string', enum: view.SELECTION_MODES },
903
906
  artifactKey: { type: 'string' },
904
- items: { type: 'array', items: { type: 'string' }, description: 'examples 模式下的举例清单' },
907
+ items: { type: 'array', items: { type: 'string' }, description: 'examples 的举例清单' },
905
908
  },
906
909
  required: ['mode'],
907
910
  additionalProperties: false,
908
911
  },
909
912
  links: {
910
913
  type: 'array',
911
- description: '相关记忆,最多 3 条,只表示同类相关;目标必须当前存在。给空数组就是清空',
914
+ description: '相关条目,最多 3 条;目标须存在;空数组清空',
912
915
  items: {
913
916
  type: 'object',
914
917
  properties: {
915
918
  type: { type: 'string', enum: view.LINK_TYPES, description: '默认 related' },
916
- title: { type: 'string', description: '被关联那条记忆的标题(原样抄搜索结果里的标题)' },
919
+ title: { type: 'string', description: '被关联条目标题,原样抄搜索结果' },
917
920
  },
918
921
  required: ['title'],
919
922
  additionalProperties: false,
@@ -922,9 +925,9 @@ const SAVE_PARAMS = {
922
925
  triggers: {
923
926
  type: 'array',
924
927
  items: { type: 'string' },
925
- description: '自动召回用的固定触发词,最多 4 个;给空数组就是清空',
928
+ description: '自动召回触发词,最多 4 个;空数组清空',
926
929
  },
927
- topicKey: { type: ['string', 'null'], description: '主题槽位,用来判断同一件事避免重复;不给就保持原值,null 表示清空' },
930
+ topicKey: { type: ['string', 'null'], description: '主题槽位,去重用;null 清空' },
928
931
  },
929
932
  required: ['title', 'summary'],
930
933
  additionalProperties: false,
@@ -932,7 +935,7 @@ const SAVE_PARAMS = {
932
935
 
933
936
  const SUGGEST_PARAMS = {
934
937
  ...SAVE_PARAMS,
935
- properties: Object.fromEntries(Object.entries(SAVE_PARAMS.properties).filter(([key]) => !['scope', 'id', 'updateTitle', 'resolution', 'memoryClass', 'durability', 'evidenceRef', 'sameTopicAs'].includes(key))),
938
+ properties: Object.fromEntries(Object.entries(SAVE_PARAMS.properties).filter(([key]) => !['scope', 'id', 'updateTitle', 'resolution', 'memoryClass', 'durability', 'evidenceRef', 'sameTopicAs', 'supersedes', 'mode'].includes(key))),
936
939
  };
937
940
 
938
941
  const SEARCH_PARAMS = {
@@ -940,9 +943,9 @@ const SEARCH_PARAMS = {
940
943
  properties: {
941
944
  query: { type: 'string', description: '要查的主题;用用户原话里的词,长句也行' },
942
945
  limit: { type: 'integer', minimum: 1, maximum: 4, description: '最多返回几条,默认 3' },
943
- history: { type: 'boolean', description: '连已归档的旧记录一起找;默认只看当前有效的,旧修正不混进正常检索' },
944
- contextId: { type: 'string', description: '本请求已经给出的任务上下文 id;只有上一轮自动索引或工具结果里出现过才填' },
945
- bindForRequest: { type: 'boolean', description: '把这次选择绑定为本请求的任务;有 contextId 就带上。默认 false,只做普通检索' },
946
+ history: { type: 'boolean', description: '连已归档的旧记录一起找;默认只看当前有效' },
947
+ contextId: { type: 'string', description: '上一轮索引或工具结果给的任务上下文 id' },
948
+ bindForRequest: { type: 'boolean', description: '把这次选择绑定为本请求的任务;有 contextId 时带上' },
946
949
  },
947
950
  required: ['query'],
948
951
  additionalProperties: false,
@@ -1605,11 +1608,11 @@ export function apply(ctx, config) {
1605
1608
  continue;
1606
1609
  }
1607
1610
  /**
1608
- * 实验规则的任务条件本身就是相关度:栏目/阶段都对上,说明这条规则就属于当前这一集。
1609
- * 没有声明条件的规则仍要过词面门,避免一条宽泛规则在所有请求里都出现。
1611
+ * 只有任务范围条件(栏目/阶段/任务类型)才算相关度:环境条件(系统/机器/路径)人人都过,
1612
+ * 拿它当绑定条件会让一条宽泛规则出现在每个请求里。其余规则照旧过词面门。
1610
1613
  */
1611
1614
  const conditions = Array.isArray(record.conditions) ? record.conditions.filter((row) => row && row.key) : [];
1612
- const bound = conditions.length > 0;
1615
+ const bound = conditions.some((row) => view.TASK_CONDITION_KEYS.includes(row.key));
1613
1616
  if (!bound && score < view.MATCH_FLOOR) continue;
1614
1617
  const topic = record.topicKey || record.id;
1615
1618
  if (byTopic.has(topic) && byTopic.get(topic).body !== record.body) conflict = true;
@@ -1954,7 +1957,7 @@ export function apply(ctx, config) {
1954
1957
 
1955
1958
  ctx.effect(() => tools.register({
1956
1959
  name: 'memory_save',
1957
- description: `保存一条记忆。只在会改变以后判断时才存;先搜已有条目,能更新就不要新建。保存前先蒸馏:标题是结论,正文只写原则与触发条件,通常 2–4 句;路径、字段表、完整命令、版本号、尺寸数值不要抄进正文,改成文档或 skill 的指针。用户明确说「记住」时直接调用;用户自己的话已经是长期规则(「以后都这样」「别再 X」)时也直接调用,不要先问。更新同一件事时用同一个标题:身份只看标题、旧标题和前置条件,像不像不授权覆盖。文本相近但标题不同时先不写,返回 needsResolution 与 1–2 条相近条目:确实是同一条就改成那个标题重存,确实是另一条才带 resolution="distinct" 再存。按标题更新时没给的字段保持原值,正文整段替换,旧正文只留作内部撤回,不展示也不参与检索。条数到硬上限时把同层最久没被读的一条移进归档,结果里写明动了哪条,不拒绝新记忆;单条正文超限只报错、不截断。前置条件不同的同一标题是两条。${NOTES}`,
1960
+ description: `保存一条跨会话有用的记忆。只在会改变以后判断时存;先搜,能更新就不新建。用户说「记住」或话已是长期规则(「以后都这样」「别再 X」)时直接调用,不要先问。文本相近但标题不同时先不写,返回 needsResolution 后核对:是同一件事就改成那个标题重存,确实是另一条才带 resolution="distinct"。按标题更新时未给字段保留,正文整段替换;旧正文只留作内部撤回,不展示不检索。前置条件不同同一标题可存两条。${NOTES}`,
1958
1961
  parameters: SAVE_PARAMS,
1959
1962
  output: {
1960
1963
  presentationMeta: (_args, value) => ({
@@ -2176,7 +2179,7 @@ export function apply(ctx, config) {
2176
2179
 
2177
2180
  ctx.effect(() => tools.register({
2178
2181
  name: 'memory_suggest',
2179
- description: `对一条用户没说、但跨会话还有用的经验提出保存建议。调用后对话里出现一个「保留」按钮,用户点了才进正式记忆。不要用问句征询「要不要记」,那要用户打字。一次对话最多建议一条,48 小时内每个项目一条。建议草稿与正式保存走同一套校验,凭据不会落盘;待确认草稿 48 小时过期,确认前就落盘所以撤回/过期都查得到。已有同一条时不再建议;相近的另一条会记成「更新候选」,保留后改的是那条,不会另建。${NOTES}`,
2182
+ description: `对用户没说、但跨会话有用的经验提出保存建议。调用后出现「保留」按钮,用户点了才进正式记忆;不要用问句征询。一次对话最多一条,48 小时内每项目一条;相近条目记为「更新候选」,保留后更新那条,不新建。${NOTES}`,
2180
2183
  parameters: SUGGEST_PARAMS,
2181
2184
  output: {
2182
2185
  presentationMeta: (_args, value) => ({ kind: 'memory-suggest', id: value.id || '', asked: value.asked }),
@@ -2216,7 +2219,7 @@ export function apply(ctx, config) {
2216
2219
 
2217
2220
  ctx.effect(() => tools.register({
2218
2221
  name: 'memory_search',
2219
- description: `按需检索已有记忆:先搜,命中再 memory_read 读正文。用户提到「记住了什么 / 上次 / 规范 / 约定 / 路径 / 凭据怎么取」,或者眼前反复出现的报错、弹窗、异常时,先查这里有没有记过解法。跨项目的通用条目以 [global] 开头,项目条目以 [项目名] 开头。中文长句可以直接当查询。每行给标题与适用范围;要更新就用那个标题重存。默认只列当前有效的;已经归档或被更新替换的旧说法不混进来,要查历史就在参数里显式要 history。`,
2222
+ description: `检索已有记忆;命中后 memory_read 带标题读正文。用户提到「记住了什么/上次/规范/约定/路径/凭据怎么取」,或眼前反复出现报错、弹窗、异常时先查这里。跨项目条目以 [global] 开头。要更新就按返回标题重存。默认只列当前有效的;查历史传 history。`,
2220
2223
  parameters: SEARCH_PARAMS,
2221
2224
  output: {
2222
2225
  presentationMeta: (_args, value) => ({
@@ -2671,8 +2674,16 @@ export function apply(ctx, config) {
2671
2674
  const held = prepared.get(agent);
2672
2675
  if (!held || held.npmHinted) return undefined;
2673
2676
  if (!memoryOnForAgent(agent)) return undefined;
2674
- const index = loadIndex(globalScope());
2675
- const row = (index.entries || []).find((entry) => {
2677
+ /** 本机流程按规矩留在项目层;全局层是兜底,先看当前项目再看全局。 */
2678
+ const scopes = [];
2679
+ try {
2680
+ const project = scopeOf(cwdOf({ agent }));
2681
+ if (project && project.confirmed !== false && project.key !== 'global') scopes.push(project);
2682
+ } catch { /* 项目根还没确认时只看全局 */ }
2683
+ scopes.push(globalScope());
2684
+ const row = scopes.flatMap((scope) => {
2685
+ try { return loadIndex(scope).entries || []; } catch { return []; }
2686
+ }).find((entry) => {
2676
2687
  if (!entry || entry.archived || entry.origin === 'project_evolution') return false;
2677
2688
  /** 过期/被取代的规则不能再在动作前拦人。 */
2678
2689
  if (view.lifecycleStatusOf(entry)) return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-lite-memory",
3
- "version": "0.18.36",
3
+ "version": "0.18.38",
4
4
  "description": "On-demand memory for the DeepSeek Harness: nothing resident, at most three small cards on a real hit, bodies read on demand.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -8,7 +8,7 @@
8
8
  "dsh": ">=0.1.5"
9
9
  },
10
10
  "scripts": {
11
- "test": "node test/store.test.mjs && node test/state.test.mjs && node test/view.test.mjs && node test/anchor.test.mjs && node test/recall.test.mjs && node test/evolution-read.test.mjs && node test/lexical.test.mjs && node test/layer-read.test.mjs && node test/control.test.mjs && node test/learning.test.mjs && node test/scenario.test.mjs && node test/crossing.test.mjs && node test/runtime-context.test.mjs && node test/client.test.mjs && node test/palace.test.mjs && node test/contract.test.mjs && node test/safety.test.mjs && node test/ha.test.mjs && node test/record.test.mjs && node test/eval-seed.test.mjs",
11
+ "test": "node test/store.test.mjs && node test/state.test.mjs && node test/view.test.mjs && node test/anchor.test.mjs && node test/recall.test.mjs && node test/evolution-read.test.mjs && node test/lexical.test.mjs && node test/layer-read.test.mjs && node test/control.test.mjs && node test/learning.test.mjs && node test/scenario.test.mjs && node test/crossing.test.mjs && node test/runtime-context.test.mjs && node test/resident.test.mjs && node test/client.test.mjs && node test/palace.test.mjs && node test/contract.test.mjs && node test/safety.test.mjs && node test/ha.test.mjs && node test/record.test.mjs && node test/eval-seed.test.mjs",
12
12
  "bench": "node bench/bench.mjs",
13
13
  "bench:evolution": "node bench/evolution.mjs"
14
14
  },
@@ -1299,4 +1299,29 @@ const cfgKeep = await call('/lite-memory/conflict', {
1299
1299
  assert.equal(cfgKeep.status, 200);
1300
1300
  assert.equal(store.conflictsOf(store.globalScope()).length, 0);
1301
1301
 
1302
+ // ── 面板搬移:只带会话就能换层,身份按 id 换但正文与条件跟着走 ─────────────
1303
+ const moveCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-contract-move-'));
1304
+ sessionCwds.set('session-move', moveCwd);
1305
+ store.saveEntry(store.scopeOf(moveCwd), {
1306
+ title: '搬移探针', summary: '只在项目层', body: '搬移正文。', tags: ['搬移'], writeMode: 'patch',
1307
+ });
1308
+ const movedOut = await call('/lite-memory/move', {
1309
+ sessionId: 'session-move', target: 'global',
1310
+ id: store.loadIndex(store.scopeOf(moveCwd)).entries.find((row) => row.title === '搬移探针').id,
1311
+ });
1312
+ assert.equal(movedOut.status, 200);
1313
+ assert.equal(movedOut.body.ok, true);
1314
+ assert.equal(movedOut.body.status, 'moved');
1315
+ assert.equal(movedOut.body.to, 'global');
1316
+ assert.ok(movedOut.body.id && movedOut.body.id !== movedOut.body.previousId, '换层后身份换新 id');
1317
+ assert.equal(store.loadIndex(store.scopeOf(moveCwd)).entries.some((row) => row.title === '搬移探针'), false, '项目层不再有这条');
1318
+ assert.equal(store.loadIndex(store.globalScope()).entries.some((row) => row.id === movedOut.body.id), true, '全局层拿到这条');
1319
+ const movedBack = await call('/lite-memory/move', {
1320
+ sessionId: 'session-move', target: 'project', id: movedOut.body.id,
1321
+ });
1322
+ assert.equal(movedBack.status, 200);
1323
+ assert.equal(movedBack.body.to, 'project');
1324
+ assert.equal(store.loadIndex(store.scopeOf(moveCwd)).entries.some((row) => row.id === movedBack.body.id), true, '搬回当前项目');
1325
+ assert.equal(store.loadIndex(store.globalScope()).entries.some((row) => row.id === movedOut.body.id), false, '全局层不再有这条');
1326
+
1302
1327
  console.log('contract.test.mjs: all passed');
@@ -8,6 +8,7 @@ const { apply } = await import('../index.js');
8
8
  const store = await import('../store.js');
9
9
  const state = await import('../state.js');
10
10
  const anchor = await import('../anchor.js');
11
+ const view = await import('../view.js');
11
12
 
12
13
  const effect = (fn) => {
13
14
  const dispose = fn();
@@ -637,3 +638,21 @@ console.log('evolution-read.test.mjs: all passed');
637
638
  await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('这期封面标题怎么写')));
638
639
  assert.equal(runtime.text(agent, sig).includes('导出统一用 H.264'), false, '无关问题不注入宽泛规则');
639
640
  }
641
+
642
+ /* L25:只声明环境条件(系统/路径)的规则不算任务绑定,问题不相关时不注入。 */
643
+ {
644
+ const dir = mkdir('env-only');
645
+ const scope = scopeOf(dir);
646
+ const host = view.hostTargetOs();
647
+ assert.ok(host, '本机系统可识别');
648
+ seedRule(scope, { title: '导出参数', body: '导出统一用 H.264。', conditions: [{ key: 'targetOs', value: host }] });
649
+ enable(scope, { seriesId: '口播' });
650
+ const runtime = boot();
651
+ const agent = makeAgent('a-env-only', dir);
652
+ const sig = signal();
653
+ await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('这期封面标题怎么写')));
654
+ assert.equal(runtime.text(agent, sig).includes('导出统一用 H.264'), false, '只有环境条件也要过词面门');
655
+ const sig2 = signal();
656
+ await runtime.preStep({ agent, signal: sig2, turn: 't2' }, enter(human('这期口播导出参数怎么配')));
657
+ assert.equal(runtime.text(agent, sig2).includes('导出统一用 H.264'), true, '相关时环境条件规则照常注入');
658
+ }
@@ -846,6 +846,40 @@ console.log('recall.test.mjs: ok');
846
846
  assert.ok(scopeNpm.key, '项目层不受影响');
847
847
  }
848
848
 
849
+ // A27b:npm 提醒先看当前项目层,再看全局层;本机流程留在项目层也能在动作前拦人。
850
+ {
851
+ const cwdNpmProject = mkdir('npm-guard-project');
852
+ const scopeNpmProject = store.scopeOf(cwdNpmProject);
853
+ store.setRecallMode(scopeNpmProject, 'adaptive', { operationId: 'npm-guard-project-mode' });
854
+ assert.ok(store.upsert(global, {
855
+ title: 'npm 全局登录提醒',
856
+ summary: '全局层的 npm 登录说明',
857
+ body: '全局层:npm login 会误导,改用 dsh-npm。',
858
+ tags: ['npm'],
859
+ applicability: 'all_in_scope',
860
+ }).record);
861
+ assert.ok(store.upsert(scopeNpmProject, {
862
+ title: 'npm 项目发布前先看凭据',
863
+ summary: '这个项目的 npm 发布步骤',
864
+ body: '项目层:先读 Keychain 位置,再用 dsh-npm publish,不要 npm whoami。',
865
+ tags: ['npm', '发布'],
866
+ applicability: 'all_in_scope',
867
+ }).record);
868
+ const guards = [];
869
+ const agent = makeAgent('agent-npm-project', cwdNpmProject);
870
+ agent.ctx = {
871
+ tools: { guard: (fn) => { guards.push(fn); return () => {}; } },
872
+ effect: (fn) => fn(),
873
+ };
874
+ const runtime = boot();
875
+ runtime.handlers.get('agent/created').at(-1)({ agent });
876
+ const sig = signal();
877
+ await runtime.preStep({ agent, signal: sig, turn: 'npm-project-1' }, enter(human('先看下环境状态')));
878
+ const blocked = guards[0]({ name: 'bash', callId: 'call-npm-project', arguments: { command: 'npm publish' } });
879
+ assert.match(String(blocked), /项目发布前先看凭据/, '项目层规则优先于全局层');
880
+ assert.match(String(blocked), /Keychain/, '给项目层正文');
881
+ }
882
+
849
883
  // A28:零命中的同一轮里,关键动作发生后补查一次;补到后不再重复注入。
850
884
  {
851
885
  const cwdActionKey = mkdir('action-key');
@@ -0,0 +1,46 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ process.env.DSH_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-resident-'));
7
+ const { apply } = await import('../index.js');
8
+
9
+ const effect = (fn) => {
10
+ const dispose = fn();
11
+ return typeof dispose === 'function' ? dispose : () => {};
12
+ };
13
+
14
+ const defs = new Map();
15
+ const ctx = {
16
+ tools: { register: (def) => { defs.set(def.name, def); return () => {}; } },
17
+ effect,
18
+ inject: () => () => {},
19
+ get: () => undefined,
20
+ on: () => () => {},
21
+ };
22
+ apply(ctx);
23
+
24
+ const bytes = (value) => Buffer.byteLength(JSON.stringify(value ?? null));
25
+ const resident = (def) => bytes(def.description) + bytes(def.parameters) + bytes(def.output?.schema ?? null);
26
+
27
+ /* 常驻提示每轮都要发一遍:工具定义变大就是每次请求都变贵。 */
28
+ const caps = {
29
+ memory_save: 6500,
30
+ memory_suggest: 3900,
31
+ memory_search: 1800,
32
+ memory_read: 1400,
33
+ memory_forget: 1100,
34
+ };
35
+ let total = 0;
36
+ for (const [name, cap] of Object.entries(caps)) {
37
+ const def = defs.get(name);
38
+ assert.ok(def, `${name} registered`);
39
+ const size = resident(def);
40
+ total += size;
41
+ assert.ok(size <= cap, `${name} resident ${size}B exceeds ${cap}B`);
42
+ }
43
+ assert.ok(total <= 13600, `resident tool text ${total}B exceeds 13600B`);
44
+ assert.ok(defs.get('memory_suggest').parameters.properties.supersedes === undefined, 'suggest does not accept supersedes');
45
+ assert.ok(defs.get('memory_suggest').parameters.properties.mode === undefined, 'suggest does not accept mode');
46
+ console.log('resident tool text', total, 'bytes');
package/view.js CHANGED
@@ -9,6 +9,8 @@ export const APPLICABILITY = ['all_in_scope', 'conditional', 'unresolved'];
9
9
  export const SELECTION_MODES = ['complete', 'examples'];
10
10
  export const LINK_TYPES = ['related'];
11
11
  export const CONDITION_KEYS = ['targetOs', 'shell', 'runtime', 'machine', 'projectRoot', 'pathPrefix', 'artifactKey', 'taskKind', 'seriesId', 'phase'];
12
+ /** 真正把规则绑到「这一集任务」上的条件;其余条件只描述环境,不构成相关性。 */
13
+ export const TASK_CONDITION_KEYS = ['taskKind', 'seriesId', 'phase'];
12
14
 
13
15
  /** 卡片与单次请求的硬上限:整张卡要么进来,要么省略,不截半句前提。 */
14
16
  export const CARD = { max: 3, bytes: 1200 };