dsh-lite-memory 0.16.2 → 0.17.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.
@@ -10,7 +10,7 @@ const state = await import('../state.js');
10
10
  const { upsert, search, readEntry, forget, undoChange, acceptCandidate, rejectCandidate,
11
11
  suggestionGate, noteSuggestion, findSuggestion, renderIndex, normalizeDraft, loadIndex,
12
12
  scopeOf, globalScope, LIMITS, recordCitations, listCitations, readCitationBody,
13
- evolutionStatus, setEvolution } = store;
13
+ evolutionStatus, setEvolution, saveIndex } = store;
14
14
 
15
15
  let passed = 0;
16
16
  const test = (name, fn) => {
@@ -153,14 +153,18 @@ test('oversized body is rejected, old data untouched', () => {
153
153
  assert.equal(loadIndex(scope).entries[0].revision, 1);
154
154
  });
155
155
 
156
- test('over-long title is an error; over-long summary is truncated but the full text is saved', () => {
156
+ test('over-long title is an error; over-long summary keeps its full text outside the body', () => {
157
157
  const scope = fresh('length');
158
158
  assert.throws(() => upsert(scope, { title: 't'.repeat(LIMITS.titleChars + 1), summary: '写得短一点' }), /标题超过/);
159
159
  const long = '长'.repeat(LIMITS.summaryChars + 1);
160
160
  const saved = upsert(scope, { title: '长摘要', summary: long });
161
161
  assert.equal(saved.record.summary.length, LIMITS.summaryChars);
162
- assert.equal(readEntry(scope, saved.record.id).body, long);
163
- assert.equal(saved.draft.fullTextInBody, true);
162
+ /** 完整原句进维护区,正文保持空:不再往正文里补「摘要原文」尾巴。 */
163
+ assert.equal(readEntry(scope, saved.record.id).body, '');
164
+ assert.equal(saved.record.summaryFull, long);
165
+ assert.equal(readEntry(scope, saved.record.id).maintenance.summaryFull, long);
166
+ assert.equal(saved.draft.fullTextSaved, true);
167
+ assert.equal(saved.draft.fullTextInBody, false);
164
168
  assert.equal(saved.draft.summaryShortened, true);
165
169
  });
166
170
 
@@ -269,7 +273,8 @@ test('forget archives both body and record; undo restores the previous version',
269
273
  assert.equal(countOf(scope), 0);
270
274
  const archived = loadIndex(scope).archived.find((item) => item.id === first.record.id);
271
275
  assert.equal(archived.status, 'archived');
272
- assert.equal(archived.body, '第一版正文');
276
+ assert.equal(archived.body, '', '列表只拿归档元数据,不读冷存正文');
277
+ assert.equal(readEntry(scope, first.record.id).body, '第一版正文', '真要读这一条时才从冷存取');
273
278
  const againArchive = forget(scope, first.record.id, { mode: 'archive' });
274
279
  assert.equal(againArchive.mode, 'archive');
275
280
  assert.equal(againArchive.already, true);
@@ -860,29 +865,463 @@ test('R24 省略正文的更新不覆盖已有正文(按 id 与同名都成立
860
865
  assert.equal(bodyOf(scope, id), '旧正文:先看连接数。', '同名更新省略正文时保留原正文');
861
866
  });
862
867
 
863
- test('R25 长摘要改动即使只差第 61 个字符也算改过,全文仍保留', () => {
868
+ test('R25 长摘要改动即使只差第 61 个字符也算改过,完整原句另存', () => {
864
869
  const scope = fresh('long-summary-change');
865
870
  const head = '摘要主体'.repeat(15);
866
871
  const first = upsert(scope, { title: '长摘要规则', summary: `${head}甲` });
867
872
  const id = first.record.id;
868
873
  assert.equal(first.record.summary.length, LIMITS.summaryChars);
869
- assert.match(bodyOf(scope, id), /甲$/);
874
+ assert.equal(bodyOf(scope, id), '', '正文不塞摘要原文');
875
+ assert.equal(first.record.summaryFull, `${head}甲`);
870
876
  const again = upsert(scope, { id, title: '长摘要规则', summary: `${head}乙` });
871
877
  assert.equal(again.noop, false, '第 61 个字符变化不能当作没改');
872
- const body = bodyOf(scope, id);
873
- assert.match(body, /甲/);
874
- assert.match(body, /乙/, '新的完整摘要也落盘');
878
+ assert.equal(bodyOf(scope, id), '');
879
+ assert.equal(again.record.summaryFull, `${head}乙`, '新的完整摘要也落盘');
880
+ assert.equal(again.record.maintenance.summaryFull, `${head}乙`);
875
881
  assert.equal(again.record.summary.length, LIMITS.summaryChars);
876
882
  });
877
883
 
878
- test('R26 显式长正文加短摘要时补上摘要原文,不把两者混成一条', () => {
884
+ test('R26 显式长正文加短摘要时完整原句另存,不混进正文', () => {
879
885
  const scope = fresh('body-plus-summary');
880
886
  const long = '现场记录'.repeat(20);
881
887
  const saved = upsert(scope, { title: '带正文的长摘要', summary: long, body: '显式正文:先复现再改。' });
882
- const body = bodyOf(scope, saved.record.id);
883
- assert.match(body, /^显式正文:先复现再改。/);
884
- assert.match(body, /摘要原文:/);
885
- assert.match(body, /现场记录/);
888
+ const record = readEntry(scope, saved.record.id);
889
+ assert.equal(record.body, '显式正文:先复现再改。');
890
+ assert.doesNotMatch(record.body, /摘要原文:/);
891
+ assert.equal(record.summaryFull, long);
892
+ assert.equal(record.maintenance.summaryFull, long);
893
+ assert.equal(saved.draft.fullTextSaved, true);
894
+ assert.equal(saved.draft.fullTextInBody, false);
895
+ });
896
+
897
+ // ── 长摘要、用户正文里的「摘要原文:」、凭据、冷存删除 ─────────────────
898
+ test('用户正文里自带「摘要原文:」时原样保留,保存与再次更新都不改写', () => {
899
+ const scope = fresh('user-tail-text');
900
+ const body = '用户原话:\n摘要原文:这里本来就是正文的一部分,别动它。';
901
+ const saved = upsert(scope, { title: '用户写的正文', summary: '用户正文原样保留', body });
902
+ const id = saved.record.id;
903
+ assert.equal(bodyOf(scope, id), body);
904
+ upsert(scope, { id, title: '用户写的正文', summary: '再更新一次也不动正文', body });
905
+ assert.equal(bodyOf(scope, id), body, '第二次更新也不能剥离用户正文');
906
+ assert.equal(readEntry(scope, id).maintenance.legacySummaryTail, true, '旧标记只做待整理提示');
907
+ });
908
+
909
+ test('长摘要改短后清掉旧的完整原句,不留托管尾巴', () => {
910
+ const scope = fresh('summary-cleared');
911
+ const long = '完整摘要'.repeat(20);
912
+ const saved = upsert(scope, { title: '先长后短', summary: long });
913
+ const id = saved.record.id;
914
+ assert.equal(readEntry(scope, id).summaryFull, long);
915
+ upsert(scope, { id, title: '先长后短', summary: '改成一句短的' });
916
+ const record = readEntry(scope, id);
917
+ assert.equal(record.summaryFull, '');
918
+ assert.equal(record.maintenance.summaryFull, undefined);
919
+ assert.equal(record.body, '');
920
+ assert.equal(stateOf(scope).entries[0].maintenance.summaryFull, undefined, '热状态里也不留旧原文');
921
+ });
922
+
923
+ test('冷存共享原文片段时只删目标条目,另一条的正文与历史完整', () => {
924
+ const scope = fresh('cold-shared');
925
+ const shared = '这段原文两条都用到';
926
+ const a = upsert(scope, { title: '共享片段 A', summary: '第一条', body: `${shared},A 的细节` });
927
+ const b = upsert(scope, { title: '共享片段 B', summary: '第二条', body: `${shared},B 的细节`, resolution: 'distinct' });
928
+ upsert(scope, { id: b.record.id, title: '共享片段 B', summary: '第二条改过', body: `${shared},B 的第二版` });
929
+ forget(scope, a.record.id, { mode: 'archive' });
930
+ forget(scope, b.record.id, { mode: 'archive' });
931
+ const coldA = path.join(scope.dir, 'cold', `${a.record.id}.json`);
932
+ const coldB = path.join(scope.dir, 'cold', `${b.record.id}.json`);
933
+ assert.equal(fs.existsSync(coldA), true);
934
+ assert.equal(fs.existsSync(coldB), true);
935
+ forget(scope, a.record.id, { mode: 'purge' });
936
+ assert.equal(fs.existsSync(coldA), false, 'A 的冷存按 id 删除');
937
+ assert.equal(fs.existsSync(coldB), true, 'B 的冷存一个字都不能动');
938
+ const left = readEntry(scope, b.record.id);
939
+ assert.equal(left.body, `${shared},B 的第二版`);
940
+ assert.ok(left.versions.length >= 1, 'B 的历史版本还在');
941
+ assert.match(JSON.stringify(fs.readFileSync(coldB, 'utf8')), /B 的第一版|B 的细节|B 的第二版/);
942
+ assert.equal(fs.existsSync(path.join(scope.dir, 'cold')), true);
943
+ });
944
+
945
+ test('冷存缺失、损坏或身份不符时报明确错误,不拿空正文顶上', () => {
946
+ const scope = fresh('cold-broken');
947
+ const saved = upsert(scope, { title: '冷存出错', summary: '读回要报错', body: '冷存正文:读回要报错' });
948
+ const id = saved.record.id;
949
+ forget(scope, id, { mode: 'archive' });
950
+ const file = path.join(scope.dir, 'cold', `${id}.json`);
951
+ const good = fs.readFileSync(file, 'utf8');
952
+ fs.writeFileSync(file, '{ 不是 json');
953
+ assert.doesNotThrow(() => loadIndex(scope), '列表不读归档正文,冷存坏了也不能整层失败');
954
+ assert.throws(() => readEntry(scope, id), /冷存|cold_corrupt/);
955
+ fs.writeFileSync(file, JSON.stringify({ id: 'other', scope: scope.key, revision: 1, body: '别人的正文' }));
956
+ assert.throws(() => readEntry(scope, id), /冷存|cold_corrupt/);
957
+ fs.rmSync(file);
958
+ assert.throws(() => readEntry(scope, id), /冷存|cold_missing/);
959
+ fs.writeFileSync(file, good);
960
+ assert.equal(readEntry(scope, id).body, '冷存正文:读回要报错');
961
+ });
962
+
963
+ test('归档正文懒加载:列表与 snapshot 不读冷存,只有读到该条正文才报 cold_missing', () => {
964
+ const scope = fresh('cold-lazy');
965
+ const saved = upsert(scope, { title: '归档懒读', summary: '列表不读正文', body: '懒加载正文:只有读这一条才取' });
966
+ const id = saved.record.id;
967
+ forget(scope, id, { mode: 'archive' });
968
+ const file = path.join(scope.dir, 'cold', `${id}.json`);
969
+ assert.equal(fs.existsSync(file), true);
970
+ fs.rmSync(file);
971
+ const index = loadIndex(scope);
972
+ const archived = index.archived.find((row) => row.id === id);
973
+ assert.ok(archived, '归档元数据照常加载');
974
+ assert.equal(archived.body, '');
975
+ assert.equal(archived.coldBody, '');
976
+ assert.doesNotThrow(() => store.learningSnapshot(scope));
977
+ assert.equal(search(scope, '归档懒读').length, 0, '列表/搜索只认真在用(active)的记录');
978
+ assert.throws(() => readEntry(scope, id), /冷存|cold_missing/, '真要读这一条正文时缺冷存就报明确错误');
979
+ });
980
+
981
+ test('被截掉的摘尾巴里藏凭据也要拒绝', () => {
982
+ const scope = fresh('summary-tail-secret');
983
+ const mask = '这条摘要的前半段可以短一点'.repeat(5);
984
+ const secret = ' token = sk-abcdefghijklmnopqrst';
985
+ assert.throws(() => upsert(scope, {
986
+ title: '凭据藏尾巴', summary: `${mask}${secret}`, body: '显式正文',
987
+ }), /凭据/);
988
+ assert.equal(fs.existsSync(path.join(scope.dir, 'state.json')), false);
989
+ });
990
+
991
+ test('归属路由:本机痕迹的「通用流程」留在项目层,干净流程带依据才进全局', () => {
992
+ const project = { confirmed: true };
993
+ const bound = store.routeScope({
994
+ title: '部署脚本', summary: '本机部署脚本的位置', body: 'bash /tmp/deploy.sh,再看 127.0.0.1:8080',
995
+ memoryClass: 'portable_procedure', scope: 'global',
996
+ }, { project });
997
+ assert.equal(bound.chosenScope, 'project');
998
+ assert.equal(bound.reasonCode, 'machine_bound_scope');
999
+
1000
+ const boundNoScope = store.routeScope({
1001
+ title: '本机路径', summary: '部署脚本在 /tmp 下', body: 'bash /tmp/deploy.sh',
1002
+ memoryClass: 'portable_procedure',
1003
+ }, { project });
1004
+ assert.equal(boundNoScope.chosenScope, 'project');
1005
+ assert.equal(boundNoScope.reasonCode, 'machine_bound_procedure');
1006
+
1007
+ const noEvidence = store.routeScope({
1008
+ title: '提交前跑测试', summary: '提交前跑测试', body: 'pnpm test',
1009
+ memoryClass: 'portable_procedure',
1010
+ }, { project });
1011
+ assert.equal(noEvidence.chosenScope, 'project');
1012
+ assert.equal(noEvidence.reasonCode, 'unbound_evidence');
1013
+
1014
+ const withEvidence = store.routeScope({
1015
+ title: '提交前跑测试', summary: '提交前跑测试', body: 'pnpm test',
1016
+ memoryClass: 'portable_procedure', evidenceRef: '团队 wiki 的提交流程页',
1017
+ }, { project });
1018
+ assert.equal(withEvidence.chosenScope, 'global');
1019
+ assert.equal(withEvidence.reasonCode, 'class_portable_procedure');
1020
+
1021
+ const evidencePath = store.routeScope({
1022
+ title: '提交前跑测试', summary: '提交前跑测试', body: 'pnpm test',
1023
+ memoryClass: 'portable_procedure', evidenceRef: '/tmp/notes/deploy.md',
1024
+ }, { project });
1025
+ assert.equal(evidencePath.chosenScope, 'project', '依据本身是本机文件不算普适证据');
1026
+ assert.equal(evidencePath.reasonCode, 'machine_bound_procedure');
1027
+
1028
+ const unconfirmed = store.routeScope({
1029
+ title: '部署脚本', summary: '本机部署脚本', body: 'bash /tmp/deploy.sh',
1030
+ memoryClass: 'portable_procedure', scope: 'global',
1031
+ }, { project: { confirmed: false } });
1032
+ assert.equal(unconfirmed.needsScope, true);
1033
+ assert.equal(unconfirmed.reasonCode, 'needs_environment');
1034
+ assert.deepEqual(unconfirmed.requiredConditions.map((row) => row.key), ['machine', 'runtime']);
1035
+
1036
+ const humanGlobal = store.routeScope({
1037
+ title: '部署脚本', summary: '本机部署脚本', body: 'bash /tmp/deploy.sh',
1038
+ memoryClass: 'portable_procedure', scope: 'global',
1039
+ }, { project, humanScope: true });
1040
+ assert.equal(humanGlobal.chosenScope, 'global');
1041
+ assert.equal(humanGlobal.reasonCode, 'explicit');
1042
+
1043
+ const modelGlobalFact = store.routeScope({
1044
+ title: '发布分支', summary: '本仓库发布分支是 main', body: 'main',
1045
+ memoryClass: 'project_fact', scope: 'global',
1046
+ }, { project });
1047
+ assert.equal(modelGlobalFact.chosenScope, 'project', '模型自填 global 不能把项目事实送进全局层');
1048
+ assert.equal(modelGlobalFact.reasonCode, 'model_global_project_fact');
1049
+
1050
+ const modelGlobalProcedure = store.routeScope({
1051
+ title: '提交前跑测试', summary: '提交前跑测试', body: 'pnpm test',
1052
+ memoryClass: 'portable_procedure', scope: 'global',
1053
+ }, { project });
1054
+ assert.equal(modelGlobalProcedure.chosenScope, 'project', '模型自填 global 少了依据也不能进全局层');
1055
+ assert.equal(modelGlobalProcedure.reasonCode, 'unbound_evidence');
1056
+ });
1057
+
1058
+ test('几百条归档的正文不进热状态,下一次保存照样能过', () => {
1059
+ const scope = fresh('cold-archives');
1060
+ fs.mkdirSync(scope.dir, { recursive: true });
1061
+ const at = new Date(Date.now() - 86_400_000).toISOString();
1062
+ const row = (id, status, title, body) => ({
1063
+ id,
1064
+ revision: 1,
1065
+ status,
1066
+ origin: 'user_explicit',
1067
+ createdAt: at,
1068
+ updatedAt: at,
1069
+ current: {
1070
+ title, summary: title, body, kind: 'note', when: '', keywords: [], topicKey: '',
1071
+ triggers: [], applicability: 'all_in_scope', conditions: [], selection: null, links: [], aliases: [],
1072
+ },
1073
+ maintenance: { reviewState: 'current', sourceRefs: [], retiredNames: [], versions: [], checks: [], hits: 0, merges: 0, lastUsed: at },
1074
+ });
1075
+ const build = (chars) => {
1076
+ const big = state.emptyState(scope);
1077
+ big.entries.push(row('mem_cold00000001', 'active', '现役条目', '现役正文'));
1078
+ const body = '归档正文示例'.repeat(1).padEnd(1) + '归'.repeat(chars);
1079
+ for (let i = 0; i < 480; i += 1) {
1080
+ big.entries.push(row(`mem_cold${String(i + 2).padStart(8, '0')}`, 'archived', `旧条目 ${i}`, body));
1081
+ }
1082
+ return state.encodeState(big);
1083
+ };
1084
+ let chars = 1400;
1085
+ let encoded = build(chars);
1086
+ for (let attempt = 0; attempt < 30 && Buffer.byteLength(encoded) > LIMITS.stateBytes - 1200; attempt += 1) {
1087
+ const over = Buffer.byteLength(encoded) - (LIMITS.stateBytes - 1200);
1088
+ chars -= Math.max(1, Math.ceil(over / (480 * 3)));
1089
+ encoded = build(chars);
1090
+ }
1091
+ const seeded = Buffer.byteLength(encoded);
1092
+ assert.ok(seeded <= LIMITS.stateBytes, '造出的热状态本身还得读得动');
1093
+ assert.ok(seeded > LIMITS.stateBytes - 3200, '离上限只差一条新记忆');
1094
+ fs.writeFileSync(path.join(scope.dir, 'state.json'), encoded);
1095
+
1096
+ const saved = upsert(scope, { title: '新的一条', summary: '归档再多也要能写', body: '新正文'.repeat(400) });
1097
+ assert.equal(saved.record.title, '新的一条');
1098
+ assert.ok(Buffer.byteLength(rawState(scope)) < LIMITS.stateBytes, '热状态回到上限内');
1099
+ assert.equal(fs.existsSync(path.join(scope.dir, 'cold')), true);
1100
+ assert.equal(fs.existsSync(path.join(scope.dir, 'archive')), false);
1101
+ const archivedId = 'mem_cold00000002';
1102
+ assert.match(readEntry(scope, archivedId).body, /归档正文示例/, '归档正文还能读到');
1103
+ assert.match(search(scope, '新的一条')[0].title, /新的一条/);
1104
+ });
1105
+
1106
+ test('lastUsed 只在真读过且够旧时回写,刚存下的条目不动', () => {
1107
+ const scope = fresh('last-used-touch');
1108
+ const created = upsert(scope, { title: '会被读的条目', summary: '读一次要记下用过', body: '正文' });
1109
+ const id = created.record.id;
1110
+ const freshUsed = loadIndex(scope).entries.find((entry) => entry.id === id).lastUsed;
1111
+ readEntry(scope, id);
1112
+ assert.equal(loadIndex(scope).entries.find((entry) => entry.id === id).lastUsed, freshUsed, '十分钟内不写盘');
1113
+ search(scope, '会被读的条目');
1114
+ assert.equal(loadIndex(scope).entries.find((entry) => entry.id === id).lastUsed, freshUsed);
1115
+
1116
+ const stale = new Date(Date.now() - 11 * 60 * 1000).toISOString();
1117
+ const index = loadIndex(scope);
1118
+ index.entries.find((entry) => entry.id === id).lastUsed = stale;
1119
+ store.saveIndex(scope, index);
1120
+ assert.equal(stateOf(scope).entries.find((entry) => entry.id === id).maintenance.lastUsed, stale);
1121
+ readEntry(scope, id);
1122
+ const touched = loadIndex(scope).entries.find((entry) => entry.id === id).lastUsed;
1123
+ assert.notEqual(touched, stale);
1124
+ assert.ok(Date.now() - Date.parse(touched) < 60_000, '过旧时读一次就回写');
1125
+
1126
+ const other = upsert(scope, { title: '葡萄柚检索目标', summary: '葡萄柚专用的检索记录', body: '葡萄柚正文' });
1127
+ assert.ok(other.record, '第二条要真的新建出来');
1128
+ const staleSearch = new Date(Date.now() - 11 * 60 * 1000).toISOString();
1129
+ const again = loadIndex(scope);
1130
+ again.entries.find((entry) => entry.id === other.record.id).lastUsed = staleSearch;
1131
+ store.saveIndex(scope, again);
1132
+ search(scope, '葡萄柚检索目标');
1133
+ assert.notEqual(loadIndex(scope).entries.find((entry) => entry.id === other.record.id).lastUsed, staleSearch);
1134
+ });
1135
+
1136
+ test('项目层未绑作品的候选也能开始整理并提交成规则', () => {
1137
+ const scope = fresh('learning-unbound');
1138
+ const on = setEvolution(scope, true, { operationId: 'evo-unbound-on' });
1139
+ assert.equal(on.ok, true);
1140
+ const started = store.beginLearningAttempt(scope, { episodeId: '', operationId: 'unbound-start' });
1141
+ assert.equal(started.ok, true, started.reason);
1142
+ assert.equal(started.episodeId, '', '不编造作品 id');
1143
+ const change = {
1144
+ target: 'new', feedbackType: 'spec_change', topicKey: '口播开头',
1145
+ currentText: '开头三句内给结论。', applicability: 'conditional',
1146
+ conditions: [{ key: 'seriesId', value: '口播' }], evidence: ['u0'], persistence: 'project_candidate',
1147
+ };
1148
+ const committed = store.commitLearning(scope, {
1149
+ operationId: 'unbound-commit', epoch: started.epoch, episodeId: '', projectId: scope.key,
1150
+ sessionId: 's-unbound', messageIds: ['m1'], changes: [change], preparedTargets: [],
1151
+ });
1152
+ assert.equal(committed.ok, true, committed.reason);
1153
+ const rules = stateOf(scope).entries.filter((entry) => entry.origin === 'project_evolution');
1154
+ assert.equal(rules.length, 1);
1155
+ assert.match(rules[0].current.title, /开头|口播/);
1156
+
1157
+ const foreign = store.commitLearning(scope, {
1158
+ operationId: 'unbound-foreign', epoch: started.epoch, episodeId: '', projectId: 'other-project',
1159
+ sessionId: 's-unbound', messageIds: ['m1'], changes: [change], preparedTargets: [],
1160
+ });
1161
+ assert.equal(foreign.ok, false);
1162
+ assert.match(foreign.reason, /缺少作品标识/);
1163
+ });
1164
+
1165
+ test('整理回执的 evidence 带上用户原句,优先本次改动其次整批', () => {
1166
+ const changeFor = (extra = {}) => ({
1167
+ target: 'new', feedbackType: 'spec_change', topicKey: '口播开头',
1168
+ currentText: '开头三句内给结论。', applicability: 'conditional',
1169
+ conditions: [{ key: 'seriesId', value: '口播' }], evidence: ['u0'], persistence: 'project_candidate',
1170
+ ...extra,
1171
+ });
1172
+ const commitOnce = (scope, batchExcerpt, changeExcerpt) => {
1173
+ assert.equal(setEvolution(scope, true, { operationId: `evo-evidence-${scope.key}` }).ok, true);
1174
+ const started = store.beginLearningAttempt(scope, { episodeId: '', operationId: `start-evidence-${scope.key}` });
1175
+ assert.equal(started.ok, true, started.reason);
1176
+ const committed = store.commitLearning(scope, {
1177
+ operationId: `commit-evidence-${scope.key}`, epoch: started.epoch, episodeId: '', projectId: scope.key,
1178
+ sessionId: 's-evidence', messageIds: ['m1', 'm2'],
1179
+ sourceExcerpt: batchExcerpt,
1180
+ changes: [changeFor(changeExcerpt ? { sourceExcerpt: changeExcerpt } : {})], preparedTargets: [],
1181
+ });
1182
+ assert.equal(committed.ok, true, committed.reason);
1183
+ return loadIndex(scope).entries.find((entry) => entry.origin === 'project_evolution');
1184
+ };
1185
+ const fromBatch = commitOnce(fresh('evidence-batch'), '用户原话:开头别绕,三句内给结论。');
1186
+ assert.equal(fromBatch.learning.sourceExcerpt, '用户原话:开头别绕,三句内给结论。');
1187
+ assert.deepEqual(fromBatch.learning.sourceRefs.at(-1).messageIds, ['m1', 'm2'], 'messageIds 仍是真实消息');
1188
+ const fromChange = commitOnce(fresh('evidence-change'), '整批摘录不该优先', '用户原话:开头三句内给结论,别铺垫。');
1189
+ assert.equal(fromChange.learning.sourceExcerpt, '用户原话:开头三句内给结论,别铺垫。', '本次改动摘录优先');
1190
+ });
1191
+
1192
+ test('整理器规则顺序与开始整理时一致,按状态/最近使用排而不是插入顺序', () => {
1193
+ const scope = fresh('organizer-order');
1194
+ const on = setEvolution(scope, true, { operationId: 'order-evo-on' });
1195
+ assert.equal(on.ok, true, on.reason);
1196
+ const started = store.beginLearningAttempt(scope, { episodeId: '', operationId: 'order-start' });
1197
+ assert.equal(started.ok, true, started.reason);
1198
+ const changes = ['先写的旧规则', '后写的新规则'].map((topic) => ({
1199
+ target: 'new', feedbackType: 'spec_change', topicKey: topic,
1200
+ currentText: `${topic}:按规则走。`, applicability: 'all_in_scope',
1201
+ evidence: ['u0'], persistence: 'project_candidate',
1202
+ }));
1203
+ const committed = store.commitLearning(scope, {
1204
+ operationId: 'order-commit', epoch: started.epoch, projectId: scope.key,
1205
+ sessionId: 's-order', messageIds: ['m1'], changes, preparedTargets: [],
1206
+ });
1207
+ assert.equal(committed.ok, true, committed.reason);
1208
+ const organizer = store.learningRulesForOrganizer(scope).map((row) => row.id);
1209
+ assert.equal(organizer.length, 2);
1210
+ assert.deepEqual(store.learningRulesForOrganizer(scope).map((row) => row.id), organizer, '顺序确定,重复调用不变');
1211
+ const again = store.beginLearningAttempt(scope, { episodeId: '', operationId: 'order-start-2' });
1212
+ assert.equal(again.ok, true, again.reason);
1213
+ assert.deepEqual(again.rules.map((row) => row.id), organizer, '两处必须同一个顺序');
1214
+ });
1215
+
1216
+ /** 冷存是归档正文唯一的家:写入必须和 state.json 一样 fsync 文件 + 目录,失败要整次保存失败。 */
1217
+ const traceDurability = () => {
1218
+ const opened = new Map();
1219
+ const synced = [];
1220
+ const realOpen = fs.openSync;
1221
+ const realFsync = fs.fsyncSync;
1222
+ fs.openSync = (file, flags, ...rest) => {
1223
+ const fd = realOpen.call(fs, file, flags, ...rest);
1224
+ opened.set(fd, String(file));
1225
+ return fd;
1226
+ };
1227
+ fs.fsyncSync = (fd) => {
1228
+ synced.push(opened.get(fd) || '');
1229
+ return realFsync.call(fs, fd);
1230
+ };
1231
+ return {
1232
+ opened,
1233
+ synced,
1234
+ restore() {
1235
+ fs.openSync = realOpen;
1236
+ fs.fsyncSync = realFsync;
1237
+ },
1238
+ };
1239
+ };
1240
+
1241
+ test('归档正文写冷存时 fsync 文件和目录(与 state.json 同一落盘序列)', () => {
1242
+ const scope = fresh('cold-fsync-trace');
1243
+ const created = upsert(scope, { title: '落盘冷存', summary: '冷存落盘序列', kind: 'note' }, {});
1244
+ const trace = traceDurability();
1245
+ try {
1246
+ const result = forget(scope, created.record.id, { mode: 'archive' });
1247
+ assert.equal(result.mode, 'archive');
1248
+ } finally {
1249
+ trace.restore();
1250
+ }
1251
+ const coldDir = path.join(scope.dir, 'cold');
1252
+ assert.ok(trace.synced.some((file) => file === coldDir), `冷存目录要 fsync:${JSON.stringify(trace.synced)}`);
1253
+ assert.ok(trace.synced.some((file) => file.startsWith(path.join(coldDir, '.')) && file.endsWith('.tmp')),
1254
+ `冷存临时文件要先 fsync 再 rename:${JSON.stringify(trace.synced)}`);
1255
+ const file = path.join(coldDir, `${created.record.id}.json`);
1256
+ assert.equal(fs.existsSync(file), true);
1257
+ assert.equal(fs.existsSync(`${file}.tmp`), false);
1258
+ });
1259
+
1260
+ test('冷存 fsync 失败时保存失败、不发布状态、不留半个冷存文件', () => {
1261
+ const scope = fresh('cold-fsync-fail');
1262
+ const body = '归档正文必须能读回来';
1263
+ const created = upsert(scope, { title: '写不进去的冷存', summary: '冷存 fsync 失败', body, kind: 'note' }, {});
1264
+ const trace = traceDurability();
1265
+ const coldDir = path.join(scope.dir, 'cold');
1266
+ try {
1267
+ const opened = trace.opened;
1268
+ const real = fs.fsyncSync;
1269
+ fs.fsyncSync = (fd) => {
1270
+ const file = opened.get(fd) || '';
1271
+ if (file.startsWith(path.join(coldDir, '.')) && file.endsWith('.tmp')) {
1272
+ const error = new Error('injected fsync failure');
1273
+ error.code = 'EIO';
1274
+ throw error;
1275
+ }
1276
+ return real.call(fs, fd);
1277
+ };
1278
+ assert.throws(() => forget(scope, created.record.id, { mode: 'archive' }),
1279
+ (error) => error.code === 'unavailable' && /归档正文没写进冷存/.test(error.message));
1280
+ } finally {
1281
+ trace.restore();
1282
+ }
1283
+ assert.equal(fs.existsSync(path.join(coldDir, `${created.record.id}.json`)), false, 'fsync 没成功就不能有权威冷存文件');
1284
+ const tmpLeft = fs.existsSync(coldDir) ? fs.readdirSync(coldDir).filter((name) => name.endsWith('.tmp')) : [];
1285
+ assert.deepEqual(tmpLeft, [], '失败的临时文件要清掉');
1286
+ assert.equal(bodyOf(scope, created.record.id), body, '发布失败时热状态原样保留正文');
1287
+ const retried = forget(scope, created.record.id, { mode: 'archive' });
1288
+ assert.equal(retried.mode, 'archive');
1289
+ const healed = readEntry(scope, created.record.id);
1290
+ assert.equal(healed.status, 'archived');
1291
+ assert.equal(healed.body, body);
1292
+ });
1293
+
1294
+
1295
+ test('一条冷存文件损坏不再拖死整层写入:退回热正文并重写好文件', () => {
1296
+ const scope = fresh('cold-corrupt-heal');
1297
+ const body = '归档正文还能从热状态找回来';
1298
+ const created = upsert(scope, { title: '冷存坏了', summary: '损坏自愈', body, kind: 'note' }, {});
1299
+ const coldFile = path.join(scope.dir, 'cold', `${created.record.id}.json`);
1300
+ fs.mkdirSync(path.dirname(coldFile), { recursive: true });
1301
+ fs.writeFileSync(coldFile, '{"broken":');
1302
+ const index = loadIndex(scope);
1303
+ /** 模拟预算腾位:正文还在热状态,索引里只留归档占位(没有 coldBody,必须去读冷存)。 */
1304
+ index.archived = [{ id: created.record.id, title: '冷存坏了', status: 'archived' }];
1305
+ index.entries = index.entries.filter((row) => row.id !== created.record.id);
1306
+ saveIndex(scope, index);
1307
+ const healed = readEntry(scope, created.record.id);
1308
+ assert.equal(healed.status, 'archived');
1309
+ assert.equal(healed.body, body, '自愈后正文要读得回来');
1310
+ assert.equal(JSON.parse(fs.readFileSync(coldFile, 'utf8')).body, body, '坏文件要被重写成好文件');
1311
+ });
1312
+
1313
+ test('冷存坏了且热状态也没有正文:仍然明确报 cold_corrupt,不冒充空正文', () => {
1314
+ const scope = fresh('cold-corrupt-fail');
1315
+ upsert(scope, { title: '占位', summary: '保持状态存在', body: '正常正文', kind: 'note' });
1316
+ const orphan = 'mem_999999999999';
1317
+ const coldFile = path.join(scope.dir, 'cold', `${orphan}.json`);
1318
+ fs.mkdirSync(path.dirname(coldFile), { recursive: true });
1319
+ fs.writeFileSync(coldFile, 'not json');
1320
+ const index = loadIndex(scope);
1321
+ index.archived = [{ id: orphan, title: '孤儿归档', status: 'archived' }];
1322
+ assert.throws(() => saveIndex(scope, index), (error) => error.code === 'cold_corrupt');
1323
+ assert.equal(fs.readFileSync(coldFile, 'utf8'), 'not json', '救不回来时不动那个坏文件');
1324
+ assert.equal(index.entries.length, 1, '失败时不能悄悄丢掉别的条目');
886
1325
  });
887
1326
 
888
1327
  console.log(`store.test.mjs: ${passed} passed`);
package/view.js CHANGED
@@ -216,6 +216,48 @@ export function statusOf(record, context) {
216
216
  return 'not_applicable';
217
217
  }
218
218
 
219
+ /**
220
+ * 所有读入口只走这一处:项目归属、实验开关、学习状态、试用到期、暂停/冲突、复核状态、适用性。
221
+ * auto 只放行完全匹配;search 允许条件未知(只给状态);read 允许条件未知但要正文由调用方决定。
222
+ */
223
+ /** 条件上下文与请求任务合并:普通记忆也按当前任务判适用,不要求实验先开。 */
224
+ export function contextWithTask(context = {}, task = {}) {
225
+ const out = { ...(context || {}) };
226
+ if (!task || typeof task !== 'object') return out;
227
+ if (task.kind && !out.taskKind) out.taskKind = task.kind;
228
+ if (task.seriesId && !out.seriesId) out.seriesId = task.seriesId;
229
+ if (task.artifactKey && !out.artifactKey) out.artifactKey = task.artifactKey;
230
+ if (task.phase && !out.phase) out.phase = task.phase;
231
+ return out;
232
+ }
233
+
234
+ export function eligibility(record, runtime = {}, purpose = 'read', now = Date.now()) {
235
+ if (!record) return { ok: false, status: 'gone', reason: 'gone' };
236
+ if (record.status === 'archived') return { ok: false, status: 'gone', reason: 'archived' };
237
+ const pid = runtime && runtime.projectId;
238
+ if (pid && record.ownerProjectId && record.ownerProjectId !== pid) {
239
+ return { ok: false, status: 'not_applicable', reason: 'owner_mismatch' };
240
+ }
241
+ const experimental = record.origin === 'project_evolution';
242
+ if (experimental && runtime && runtime.experimental === false) {
243
+ return { ok: false, status: 'needs_context', reason: 'experimental_disabled' };
244
+ }
245
+ const learning = record.learning && typeof record.learning === 'object' ? record.learning : null;
246
+ if (experimental) {
247
+ if (!experimentalUsable(record, now)) return { ok: false, status: 'needs_context', reason: 'learning_inactive' };
248
+ if (learning && (learning.state === 'paused' || learning.blockedReason)) {
249
+ return { ok: false, status: 'needs_context', reason: 'learning_paused' };
250
+ }
251
+ }
252
+ if (purpose === 'auto' && record.reviewState === 'legacy_unreviewed') {
253
+ return { ok: false, status: 'needs_context', reason: 'legacy_unreviewed' };
254
+ }
255
+ const status = statusOf(record, contextWithTask((runtime && runtime.context) || {}, (runtime && runtime.task) || {}));
256
+ if (status === 'not_applicable') return { ok: false, status, reason: 'condition_mismatch' };
257
+ if (purpose === 'auto' && status !== 'ok') return { ok: false, status, reason: 'needs_context' };
258
+ return { ok: true, status, reason: '' };
259
+ }
260
+
219
261
  /** 旧库迁过来的条目没复核过,先让人看见再决定,不自动注入。 */
220
262
  export function canAutoRecall(record, context) {
221
263
  if (!record || record.reviewState === 'legacy_unreviewed') return false;
@@ -344,11 +386,26 @@ export function rank(records, query, options = {}) {
344
386
  for (const record of records || []) {
345
387
  if (!record || record.status === 'archived') continue;
346
388
  if (options.requireApplicable && record.reviewState === 'legacy_unreviewed') continue;
347
- const status = options.requireApplicable ? statusOf(record, context) : 'ok';
348
- if (options.requireApplicable && status !== 'ok') continue;
389
+ let status;
390
+ if (options.runtime) {
391
+ const gate = eligibility(record, options.runtime, options.requireApplicable ? 'auto' : 'search', options.now || Date.now());
392
+ if (!gate.ok) continue;
393
+ status = gate.status;
394
+ } else {
395
+ status = options.requireApplicable ? statusOf(record, context) : 'ok';
396
+ if (options.requireApplicable && status !== 'ok') continue;
397
+ }
349
398
  const matched = matchScore(record, query);
350
399
  if (matched.score < MATCH_FLOOR) continue;
351
- rows.push({ record, score: matched.score, hints: matched.hints, status });
400
+ /** 当前任务明确的话题/工作类型优先,但不覆盖查询本身的字面命中。 */
401
+ const task = options.task || {};
402
+ const sameTask = (record.conditions || []).some((condition) => {
403
+ if (condition.key === 'taskKind') return task.kind && sameText(condition.value, task.kind);
404
+ if (condition.key === 'seriesId') return task.seriesId && sameText(condition.value, task.seriesId);
405
+ if (condition.key === 'artifactKey') return task.artifactKey && sameText(condition.value, task.artifactKey);
406
+ return false;
407
+ });
408
+ rows.push({ record, score: matched.score + (sameTask ? 1.5 : 0), hints: matched.hints, status });
352
409
  }
353
410
  rows.sort((a, b) => b.score - a.score
354
411
  || String(b.record.lastUsed || '').localeCompare(String(a.record.lastUsed || ''))