dsh-prime-memory 0.22.2 → 0.24.1

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.
Files changed (104) hide show
  1. package/CHANGELOG.en.md +421 -0
  2. package/CHANGELOG.ja.md +416 -0
  3. package/CHANGELOG.ko.md +416 -0
  4. package/CHANGELOG.md +120 -0
  5. package/dist/client.js +1266 -845
  6. package/dist/config.d.ts +131 -1341
  7. package/dist/config.js +89 -4
  8. package/dist/conflict-service.d.ts +7 -1
  9. package/dist/conflict-service.js +7 -1
  10. package/dist/contract.d.ts +211 -0
  11. package/dist/domain-gate.d.ts +11 -0
  12. package/dist/domain-gate.js +15 -0
  13. package/dist/hooks/obsolete-notice.d.ts +26 -0
  14. package/dist/hooks/obsolete-notice.js +64 -0
  15. package/dist/hooks/recall.d.ts +39 -0
  16. package/dist/hooks/recall.js +110 -30
  17. package/dist/index.d.ts +1 -1127
  18. package/dist/index.js +52 -2
  19. package/dist/memory-types.d.ts +69 -0
  20. package/dist/memory-types.js +165 -0
  21. package/dist/memport/format.d.ts +6 -1
  22. package/dist/memport/format.js +42 -2
  23. package/dist/pipeline/l1.d.ts +44 -2
  24. package/dist/pipeline/l1.js +163 -9
  25. package/dist/pipeline/md-sync.d.ts +65 -0
  26. package/dist/pipeline/md-sync.js +456 -0
  27. package/dist/pipeline/ruminate.d.ts +46 -1
  28. package/dist/pipeline/ruminate.js +109 -0
  29. package/dist/pipeline/runner.d.ts +10 -0
  30. package/dist/pipeline/runner.js +13 -2
  31. package/dist/pipeline/workspace-relink.d.ts +60 -0
  32. package/dist/pipeline/workspace-relink.js +73 -0
  33. package/dist/projection/recall.d.ts +51 -13
  34. package/dist/projection/recall.js +47 -12
  35. package/dist/prompts/examples.d.ts +73 -0
  36. package/dist/prompts/examples.js +178 -0
  37. package/dist/prompts/l1-dedup.d.ts +9 -3
  38. package/dist/prompts/l1-dedup.js +34 -13
  39. package/dist/prompts/l1-extraction.d.ts +5 -3
  40. package/dist/prompts/l1-extraction.js +49 -4
  41. package/dist/settings.d.ts +8 -0
  42. package/dist/settings.js +16 -1
  43. package/dist/stats.d.ts +12 -0
  44. package/dist/stats.js +352 -17
  45. package/dist/store/embedding-source.d.ts +8 -0
  46. package/dist/store/embedding-source.js +39 -2
  47. package/dist/store/file-versions.d.ts +2 -0
  48. package/dist/store/file-versions.js +2 -0
  49. package/dist/store/graph-store.d.ts +14 -0
  50. package/dist/store/graph-store.js +35 -0
  51. package/dist/store/l1-snapshot.d.ts +26 -1
  52. package/dist/store/l1-snapshot.js +53 -8
  53. package/dist/store/l1.d.ts +94 -3
  54. package/dist/store/l1.js +169 -4
  55. package/dist/store/memory-md-archive.d.ts +84 -0
  56. package/dist/store/memory-md-archive.js +155 -0
  57. package/dist/store/memory-md-disable.d.ts +74 -0
  58. package/dist/store/memory-md-disable.js +302 -0
  59. package/dist/store/memory-md-export.d.ts +50 -0
  60. package/dist/store/memory-md-export.js +227 -0
  61. package/dist/store/memory-md-ledger.d.ts +94 -0
  62. package/dist/store/memory-md-ledger.js +243 -0
  63. package/dist/store/memory-md-orphan.d.ts +27 -0
  64. package/dist/store/memory-md-orphan.js +153 -0
  65. package/dist/store/memory-md-parse.d.ts +35 -0
  66. package/dist/store/memory-md-parse.js +85 -0
  67. package/dist/store/memory-md-run.d.ts +48 -0
  68. package/dist/store/memory-md-run.js +151 -0
  69. package/dist/store/memory-md-skeleton.d.ts +18 -0
  70. package/dist/store/memory-md-skeleton.js +61 -0
  71. package/dist/store/memory-md-sync.d.ts +59 -0
  72. package/dist/store/memory-md-sync.js +152 -0
  73. package/dist/store/memory-md-types.d.ts +243 -0
  74. package/dist/store/memory-md-types.js +10 -0
  75. package/dist/store/memory-md.d.ts +117 -0
  76. package/dist/store/memory-md.js +230 -0
  77. package/dist/store/obsolete-notices.d.ts +39 -0
  78. package/dist/store/obsolete-notices.js +153 -0
  79. package/dist/store/recall-feedback.d.ts +75 -0
  80. package/dist/store/recall-feedback.js +213 -0
  81. package/dist/store/recall-usage.d.ts +11 -0
  82. package/dist/store/recall-usage.js +29 -0
  83. package/dist/store/session-recalled.d.ts +38 -3
  84. package/dist/store/session-recalled.js +156 -9
  85. package/dist/store/sqlite.d.ts +66 -0
  86. package/dist/store/sqlite.js +233 -15
  87. package/dist/store/supersede.d.ts +1 -1
  88. package/dist/store/supersede.js +1 -1
  89. package/dist/tools/index.d.ts +4 -0
  90. package/dist/tools/index.js +26 -7
  91. package/dist/tools/slots.d.ts +2 -1
  92. package/dist/tools/slots.js +2 -1
  93. package/dist/types.d.ts +24 -7
  94. package/dist/types.js +10 -2
  95. package/dist/util/maturity.d.ts +20 -3
  96. package/dist/util/maturity.js +18 -1
  97. package/dist/util/md-name.d.ts +129 -0
  98. package/dist/util/md-name.js +366 -0
  99. package/dist/util/md-normalize.d.ts +23 -0
  100. package/dist/util/md-normalize.js +131 -0
  101. package/dist/workspace-route.d.ts +22 -0
  102. package/dist/workspace-route.js +66 -0
  103. package/dsh.plugin.json +1 -1
  104. package/package.json +6 -5
@@ -1,13 +1,19 @@
1
1
  /**
2
2
  * 成熟度(maturity)合成——G3,单一事实源的纯函数。
3
3
  *
4
- * 把三个**已存在**的信号合成一个可解释的置信档,供面板显示(后续可作召回排序/矛盾旁注):
4
+ * 把四个**已存在**的信号合成一个可解释的置信档,供面板显示与召回排序:
5
5
  * - `priority`(L1 真实列,0-100):写入时的重要性先验;
6
6
  * - `usage.used`(recall-usage,进 topN 被注入才计):被反复用到 = 越"确立";
7
- * - `agingWeight`(读路径衰减系数 0.5~1,null=关):新鲜度。
7
+ * - `agingWeight`(读路径衰减系数 0.5~1,null=关):新鲜度;
8
+ * - `mark`(人在召回卡上打的 -1..5,**只看最近一笔**):显式的人工裁决。
9
+ *
10
+ * 人工标记是**加性**项(±W_MARK),不是权重再分配:未打分时贡献恒为 0,
11
+ * 与三输入时代的分值**逐字相等**(旧回归断言不会因新增一项而漂)。
12
+ * 档位设计(用户口径:0/1/2 拉低、4/5 抬高):0/1/2 为负、3 中性、4/5 为正;
13
+ * `-1`(声明过时)**不在此计分**——它走退场,若再计一次罚就是同一件事罚两遍。
8
14
  *
9
15
  * 刻意**读时派生、不落库**(Agent B/C 结论):落库会在每轮 recordAttempt 触发双写 + 值随时间陈旧,
10
- * 且顶层列不进快照哈希=隐形改写。三输入即时可得,纯函数零 IO。
16
+ * 且顶层列不进快照哈希=隐形改写。各输入即时可得,纯函数零 IO。
11
17
  *
12
18
  * 权重/阈值集中为常量,便于回归与调参;缺 usage(未装配)按 0 强化处理(不虚构成熟度)。
13
19
  */
@@ -20,7 +26,18 @@ export interface MaturityInput {
20
26
  usage?: MaturityUsage | null;
21
27
  /** 读路径老化权重(0.5~1);null = 衰减关闭 → 视为 1(不惩罚)。 */
22
28
  agingWeight?: number | null;
29
+ /**
30
+ * 人给的最近一笔打分(-1..5);未打分 = undefined/null → 贡献 0(逐字旧行为)。
31
+ * `-1` 也按 0 处理:那条记录已经退场,不该在成熟度上再罚一次。
32
+ */
33
+ mark?: number | null;
23
34
  }
35
+ /**
36
+ * 打分 → 归一化人工信号(-1..1)。3 分与未打分同为中性。
37
+ * 正侧只分两档(4 半、5 满);负侧分三档渐次加重(2 轻罚 -0.4、1 中罚 -0.7、0 重罚 -1)——
38
+ * “不相关”是个需要区分程度的否定,而“相关”不必分那么细。
39
+ */
40
+ export declare function markSignal(mark: number | null | undefined): number;
24
41
  /** 合成分值(0~1),导出供测试与未来排序复用。 */
25
42
  export declare function maturityScore(i: MaturityInput): number;
26
43
  /** 三档成熟度:high ≥0.66、mid ≥0.40、否则 low。 */
@@ -1,10 +1,27 @@
1
1
  const W_PRIORITY = 0.45;
2
2
  const W_USAGE = 0.3;
3
3
  const W_AGING = 0.25;
4
+ /** 人工标记的加性幅度(±0.3):够让一档在强先验上被推翻,又不足以一票定生死。 */
5
+ const W_MARK = 0.3;
4
6
  /** used 达到该数即满强化(再高不加分)。 */
5
7
  const USAGE_SATURATE = 5;
6
8
  const HIGH_THRESHOLD = 0.66;
7
9
  const MID_THRESHOLD = 0.4;
10
+ /**
11
+ * 打分 → 归一化人工信号(-1..1)。3 分与未打分同为中性。
12
+ * 正侧只分两档(4 半、5 满);负侧分三档渐次加重(2 轻罚 -0.4、1 中罚 -0.7、0 重罚 -1)——
13
+ * “不相关”是个需要区分程度的否定,而“相关”不必分那么细。
14
+ */
15
+ export function markSignal(mark) {
16
+ switch (mark) {
17
+ case 5: return 1;
18
+ case 4: return 0.5;
19
+ case 2: return -0.4;
20
+ case 1: return -0.7;
21
+ case 0: return -1;
22
+ default: return 0; // 3 / -1 / 未打分 / 越界值
23
+ }
24
+ }
8
25
  function clamp01(x) {
9
26
  return Math.max(0, Math.min(1, x));
10
27
  }
@@ -13,7 +30,7 @@ export function maturityScore(i) {
13
30
  const p = clamp01(i.priority / 100);
14
31
  const u = i.usage ? clamp01(i.usage.used / USAGE_SATURATE) : 0;
15
32
  const a = i.agingWeight == null ? 1 : clamp01(i.agingWeight);
16
- return W_PRIORITY * p + W_USAGE * u + W_AGING * a;
33
+ return clamp01(W_PRIORITY * p + W_USAGE * u + W_AGING * a + W_MARK * markSignal(i.mark));
17
34
  }
18
35
  /** 三档成熟度:high ≥0.66、mid ≥0.40、否则 low。 */
19
36
  export function computeMaturity(i) {
@@ -0,0 +1,129 @@
1
+ /**
2
+ * 入库闸门的长度上限(字符)。人/模型**有意给**的名字允许到这里。
3
+ *
4
+ * 历史:24 → 28 → **40**。放宽的理由不是“名字长点好看”,是**计数把 ASCII 与汉字同价**:
5
+ * 本项目记忆标题里 `dsh-agent-teams`、`Python-3.13`、`NeatDownloader` 这类标识符很常见,
6
+ * 一个词就吃掉 13–16 个额度,于是语义上只有一两个词的正当名在字符上“超长”
7
+ * (真机 13 条 fallback 里 3 条属此类)。28 的代价不是难看,是把已经拿到的语义丢掉重算。
8
+ * 仍钉在 40 而不是更松:投射路径要留在 Windows `MAX_PATH` 里,而且超过 40 的名
9
+ * 几乎必然是句子而不是标题。
10
+ */
11
+ export declare const NAME_MAX = 40;
12
+ /**
13
+ * 规则兜底名({@link deriveName})的截断上限,**故意比闸门紧**。
14
+ *
15
+ * 两者不能合为一个数(实测过:统一放 40 后,`buildTurnCompactSystemPrompt` 会拖出
16
+ * 「负责组装每一轮的系统提示」这种句子尾巴):`deriveName` 吃的是**叙述句**,多给的额度
17
+ * 只会被句子吃掉;而闸门吃的是**人/模型写的标题**,那里长度是语义而不是噪声。
18
+ * 28 仍是兜底名不至于变成句子的上限。
19
+ */
20
+ export declare const NAME_CLIP_MAX = 28;
21
+ /** 目标区间下界(推荐名短于此偏空),仅作注释性常量,不作硬闸。 */
22
+ export declare const NAME_TARGET_MIN = 6;
23
+ /** 目标区间上界(推荐名长于此偏啰嗦),仅作注释性常量,不作硬闸。 */
24
+ export declare const NAME_TARGET_MAX = 14;
25
+ /**
26
+ * 叙述引子表:正文是"谁干了什么"的完整叙述句时,这些前缀只是话头,
27
+ * 占了名字名额却不含信息量(真机坏味「用户要求将-...」「团队已完成-...」即此)。
28
+ * 导出供 `validateTitle` 与 `deriveName` **共用同一份**,两处判据不许漂移。
29
+ */
30
+ export declare const NARRATIVE_LEAD_INS: readonly string[];
31
+ /**
32
+ * 正文 → 名字干(分段流水线)。
33
+ *
34
+ * 保持签名 `(content) => string` 与"洗不出可用文字返回空串"的承诺。步骤:
35
+ * 1. 标题行优先:正文有 `^#{1,3} ` 行用它,否则取首个非空行;
36
+ * 2. 剥 markdown 前缀;
37
+ * 3. 剥叙述引子({@link NARRATIVE_LEAD_INS})连同其后标点/残留话头;
38
+ * 4. 结构边界截断(第一个句读 / 括号 / 反引号 / 路径 / session / uuid 处断开,
39
+ * 括号内整块丢弃——除非主句因此被丢空);
40
+ * 5. 词元边界收尾:CJK 逐字、ASCII 逐词,绝不切半单词;
41
+ * 6. 截断纪律:过 `sanitizeFilename`,剥尾随 `.` `-` 空格。
42
+ *
43
+ * @param content - 记忆正文。
44
+ * @returns 文件名安全的名字干;无可用文字时 `''`。
45
+ */
46
+ export declare function deriveName(content: string): string;
47
+ /**
48
+ * 步骤 5:截到 maxLen,且绝不切半 ASCII 单词。
49
+ * 若刚好落在 ASCII 词元中间(末字符与被丢字符都是 `[A-Za-z0-9]`),回删整个残词。
50
+ * CJK 逐字,无需回退。
51
+ *
52
+ * 导出是因为**改名侧也要同一个收梢算法**:模型给的 title 只是超长(`too-long`)时,
53
+ * 按词元边界收一刀仍是好名字,直接回落 `deriveName` 等于把已经拿到的语义扔掉重算。
54
+ * 两处共用一份,截断形状才不会分头演化。
55
+ */
56
+ export declare function clipAtTokenBoundary(raw: string, maxLen: number): string;
57
+ /**
58
+ * 校验一个**人给的或模型给的**名字是否可用(形状,不含唯一性)。
59
+ *
60
+ * Phase 3 的 few-shot 判据会拿它当门禁,因此签名与 reason 取值必须稳定。
61
+ * 命中任一硬规则即拒;全部通过返回 `{ ok: true, name }`(name 为过字符白名单后的归一值)。
62
+ *
63
+ * @param raw - 候选名字。
64
+ * @returns `{ ok: true, name }` 或 `{ ok: false, reason }`。
65
+ */
66
+ export declare function validateTitle(raw: string): {
67
+ ok: true;
68
+ name: string;
69
+ } | {
70
+ ok: false;
71
+ reason: string;
72
+ };
73
+ /**
74
+ * 剥一个**模型给的** title 的形状噪声:空白(含全角空格)→ `-`、连字折叠、首尾点/横线剔掉。
75
+ *
76
+ * 为什么需要它:不是语义不对,只是字符形状不对——直接拿原文过形状闸会把
77
+ * “尾部多一个点”这种一律打进 fallback(真机旧行为:16/20 条白归规则名)。
78
+ * 导出是为了**只有一个整形函数**:管线与临时改名脚本共用,两套迟早漂。
79
+ */
80
+ export declare function reshapeTitle(raw: string): string;
81
+ /**
82
+ * 收下模型产出的 title:先整形再入闸,**只超长**时按词元边界再收一刀。
83
+ *
84
+ * 返回落名或 `undefined`(不合格)。刻意**不回落规则名**:那是调用方的决定,
85
+ * 因为两条路的可观测要求不同(管线要“没名字就交给起名器”,脚本要“记 fallback+reason”)。
86
+ * 闸仍是 {@link validateTitle} 那一个,不开第二套判据。
87
+ *
88
+ * @param raw - 模型给的原文(故意收 `unknown`:模型输出是不可信输入)。
89
+ * @returns 可用的落名,或 `undefined`。
90
+ */
91
+ export declare function acceptTitle(raw: unknown): string | undefined;
92
+ /**
93
+ * 校验一个**人给的**名字是否可用(形状,不含唯一性)。
94
+ *
95
+ * 人从面板或 md 文件里改名字时,先过这道形状闸再谈唯一性:形状不合就拒,
96
+ * 比"收下然后写不出文件"好。返回归一后的名字(可能与入参不同:洗掉了非法字符)。
97
+ *
98
+ * @param raw - 用户给的名字。
99
+ * @returns `{ ok: true, name }` 或 `{ ok: false, reason }`。
100
+ */
101
+ export declare function normalizeName(raw: string): {
102
+ ok: true;
103
+ name: string;
104
+ } | {
105
+ ok: false;
106
+ reason: string;
107
+ };
108
+ /** 名字没洗出可用字符时的回落干:`mem-<id 后 8 位>`。 */
109
+ export declare function fallbackStem(id: string): string;
110
+ /**
111
+ * 给一个名字求**全库唯一**的落名:基底 → `基底-2` → `基底-3` → … → 回落名。
112
+ *
113
+ * 为什么必须在这里消歧,而不是"谁先到谁写":同一段话的多条记录必然洗出同一个名字。
114
+ * 真机实测 1166 条里 42 条挤进 17 个同名文件(最狠一个路径 6 条),后果是导出每轮都把
115
+ * 被覆盖的那几条重写一遍(面板上那个永远不归零的「已写 9」)、而反向同步会把一份内容
116
+ * 回灌给六条记录。
117
+ *
118
+ * 分配本身只取决于"基底 + 已占用集",因此**调用方必须按 id 排序后再分配**:
119
+ * 同一个基底碰上同一次分配时,谁先谁拿到裸名——顺序不钉死的话,两次导出会给同一条
120
+ * 记录分到不同名字,文件在盘上跳来跳去。后缀要留位置:总长不超过 {@link NAME_MAX},
121
+ * 宁可截基底也不让它超长。
122
+ *
123
+ * @param base - 基底名(通常是 `deriveName(content)`,可以为空)。
124
+ * @param id - 记录 id,基底不可用时落回 `mem-<id8>`。
125
+ * @param isFree - 库侧查询:这个名字没被**其它**记录占着。
126
+ * @param reserved - 本轮已分配出去的名字(调用方逐次加入,函数也会自己加)。
127
+ * @returns 可用的名字;实在找不到就把 `id` 的回落名加上数字尾巴交出去(仍进 reserved)。
128
+ */
129
+ export declare function allocUniqueName(base: string, id: string, isFree: (name: string) => boolean, reserved: Set<string>): string;
@@ -0,0 +1,366 @@
1
+ /**
2
+ * 文件身份名(N1):把一条 L1 记忆的正文折成一个**人可读、可编辑、全库唯一**的短名,
3
+ * 直接当 `<root>/<type>/<name>.memory.md` 的文件名干。
4
+ *
5
+ * 为什么不用现成的键:
6
+ * - `id` 每轮新铸、从不复用(merge = 旧行软删 + 新记录追加),拿它当文件名等于每天换名;
7
+ * - `scene_name` 实测基数大于记录数,当不了唯一名;
8
+ * - `ext_key` 是**导入幂等键**(机器算的、只在来源内唯一),语义与"人给这条记忆起的名字"不同。
9
+ *
10
+ * 名字纪律**复用** `sanitizeFilename`(`store/scenes.ts`)而不是另写一套:
11
+ * 那里已经处理过非法字符、Windows 保留设备名(`CON`/`LPT1`…)、stem 超长截断。
12
+ * 自己拼一份正则迟早和它漂移,而漂移的后果是"库里存的名字写不出文件"。
13
+ *
14
+ * Phase 2 重写:旧 `deriveName` 取首行前 24 字,正本是完整叙述句时天然是残句
15
+ * (真机 1166 条里 96.7% 是句子残片)。现改为**分段流水线**:标题行优先 → 剥 markdown
16
+ * 前缀 → 剥叙述引子 → 结构边界截断 → 词元边界收尾 → 截断纪律,每一步都可单测。
17
+ */
18
+ import { sanitizeFilename } from '../store/scenes.js';
19
+ /**
20
+ * 入库闸门的长度上限(字符)。人/模型**有意给**的名字允许到这里。
21
+ *
22
+ * 历史:24 → 28 → **40**。放宽的理由不是“名字长点好看”,是**计数把 ASCII 与汉字同价**:
23
+ * 本项目记忆标题里 `dsh-agent-teams`、`Python-3.13`、`NeatDownloader` 这类标识符很常见,
24
+ * 一个词就吃掉 13–16 个额度,于是语义上只有一两个词的正当名在字符上“超长”
25
+ * (真机 13 条 fallback 里 3 条属此类)。28 的代价不是难看,是把已经拿到的语义丢掉重算。
26
+ * 仍钉在 40 而不是更松:投射路径要留在 Windows `MAX_PATH` 里,而且超过 40 的名
27
+ * 几乎必然是句子而不是标题。
28
+ */
29
+ export const NAME_MAX = 40;
30
+ /**
31
+ * 规则兜底名({@link deriveName})的截断上限,**故意比闸门紧**。
32
+ *
33
+ * 两者不能合为一个数(实测过:统一放 40 后,`buildTurnCompactSystemPrompt` 会拖出
34
+ * 「负责组装每一轮的系统提示」这种句子尾巴):`deriveName` 吃的是**叙述句**,多给的额度
35
+ * 只会被句子吃掉;而闸门吃的是**人/模型写的标题**,那里长度是语义而不是噪声。
36
+ * 28 仍是兜底名不至于变成句子的上限。
37
+ */
38
+ export const NAME_CLIP_MAX = 28;
39
+ /** 目标区间下界(推荐名短于此偏空),仅作注释性常量,不作硬闸。 */
40
+ export const NAME_TARGET_MIN = 6;
41
+ /** 目标区间上界(推荐名长于此偏啰嗦),仅作注释性常量,不作硬闸。 */
42
+ export const NAME_TARGET_MAX = 14;
43
+ /**
44
+ * 叙述引子表:正文是"谁干了什么"的完整叙述句时,这些前缀只是话头,
45
+ * 占了名字名额却不含信息量(真机坏味「用户要求将-...」「团队已完成-...」即此)。
46
+ * 导出供 `validateTitle` 与 `deriveName` **共用同一份**,两处判据不许漂移。
47
+ */
48
+ export const NARRATIVE_LEAD_INS = [
49
+ '用户要求', '用户规定', '用户需要', '用户确立', '用户',
50
+ '团队已', '团队将', '团队完', '团队',
51
+ '针对', '已生成', '生成了', '已为', '本次', '目前', '结论', '要点', '待推进',
52
+ ];
53
+ /** 由长到短排序,保证「用户要求」优先于「用户」命中(前缀匹配不会被短项截胡)。 */
54
+ const LEAD_IN_SORTED = [...NARRATIVE_LEAD_INS].sort((a, b) => b.length - a.length);
55
+ /**
56
+ * 闸门用的引子表 = 剥引子表**去掉裸主语**(`用户` / `团队`)。
57
+ *
58
+ * 两张表本来就该是两个用途,之前共用一份是误杀源:
59
+ * - `deriveName` 吃的是**完整叙述句**,句首裸「用户」只是话头、不含信息量,该剥;
60
+ * - `validateTitle` 吃的是**人/模型给的名词短语**,「用户npm账号用户名」「团队profile配置」里的
61
+ * 「用户/团队」是正当的所属主语,当成叙述引子拒掉就是误杀(真机改名计划 13 条 fallback 里 10 条栽在这两个词)。
62
+ * 所以这不是把闸门调松,是把「剥话头」与「拒名字」分开——短语类引子(用户要求/团队已/针对/本次…)照旧拒。
63
+ */
64
+ const GATE_LEAD_IN_SORTED = LEAD_IN_SORTED.filter((lead) => lead !== '用户' && lead !== '团队');
65
+ /** 引子之后可能残留的单个话头连接词(「用户要求将…」的「将」),一并丢弃。 */
66
+ const LEAD_IN_CONNECTOR = '(?:将|把|要|需|做了|做|规定|需要|的)?';
67
+ /** 只吃引子后的空白与读点,不吃正文用字。 */
68
+ const LEAD_IN_PUNCT = '[\\s,,、::]*';
69
+ /** 结构边界截断点:第一个命中的分隔标记处断开(',' '、' 不在此列,只走长度收尾)。 */
70
+ const STRUCTURAL_BOUNDARIES = [
71
+ /[:。;]/, // 全角句读
72
+ /[((]/, // 括号:整块丢弃括号内容
73
+ /`/, // 反引号:内联代码前后都算边界
74
+ /[A-Za-z]:[\\/]/, // Windows 盘符路径
75
+ /session-[0-9a-f-]{8,}/i, // 会话 id
76
+ /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}/i, // uuid 片段
77
+ ];
78
+ /**
79
+ * 正文 → 名字干(分段流水线)。
80
+ *
81
+ * 保持签名 `(content) => string` 与"洗不出可用文字返回空串"的承诺。步骤:
82
+ * 1. 标题行优先:正文有 `^#{1,3} ` 行用它,否则取首个非空行;
83
+ * 2. 剥 markdown 前缀;
84
+ * 3. 剥叙述引子({@link NARRATIVE_LEAD_INS})连同其后标点/残留话头;
85
+ * 4. 结构边界截断(第一个句读 / 括号 / 反引号 / 路径 / session / uuid 处断开,
86
+ * 括号内整块丢弃——除非主句因此被丢空);
87
+ * 5. 词元边界收尾:CJK 逐字、ASCII 逐词,绝不切半单词;
88
+ * 6. 截断纪律:过 `sanitizeFilename`,剥尾随 `.` `-` 空格。
89
+ *
90
+ * @param content - 记忆正文。
91
+ * @returns 文件名安全的名字干;无可用文字时 `''`。
92
+ */
93
+ export function deriveName(content) {
94
+ let s = pickTitleLine(content);
95
+ if (s.length === 0)
96
+ return '';
97
+ s = stripLeadIn(s);
98
+ s = structuralTruncate(s);
99
+ s = clipAtTokenBoundary(s, NAME_CLIP_MAX);
100
+ if (s.length === 0)
101
+ return '';
102
+ return applyTrailingDiscipline(s);
103
+ }
104
+ /**
105
+ * 步骤 1+2:选出标题行并剥掉 markdown 前缀。
106
+ * markdown 标题(`#`/`##`/`###` + 空格)优先;否则首个非空行去掉 `#>*-+` 与空白前缀。
107
+ */
108
+ function pickTitleLine(content) {
109
+ const lines = (content ?? '').split('\n');
110
+ const heading = lines.find((l) => /^#{1,3} \S/.test(l.trim()));
111
+ if (heading)
112
+ return heading.trim().replace(/^#{1,3}\s+/, '').trim();
113
+ const line = lines.find((l) => l.trim().length > 0) ?? '';
114
+ return line.trim().replace(/^[#>\-*+\s]+/, '').trim();
115
+ }
116
+ /**
117
+ * 步骤 3:剥叙述引子。命中引子就把引子 + 其后标点 + 单个残留话头一起丢,
118
+ * 直到剩不下引子为止(只剥一次最外层,避免把正文首词也吃掉)。
119
+ */
120
+ function stripLeadIn(raw) {
121
+ let s = raw.trim();
122
+ for (const lead of LEAD_IN_SORTED) {
123
+ const re = new RegExp(`^${escapeRe(lead)}${LEAD_IN_CONNECTOR}${LEAD_IN_PUNCT}`);
124
+ const m = re.exec(s);
125
+ if (m && m[0].length > lead.length - 1) {
126
+ s = s.slice(m[0].length).trim();
127
+ break;
128
+ }
129
+ }
130
+ return s;
131
+ }
132
+ /**
133
+ * 步骤 4:结构边界截断。取所有标记里**最靠前**的断点;若断点是括号,
134
+ * 括号内整块丢弃(保留括号前的主句),仅当主句被丢空时才跳过这个括号继续找下一断点。
135
+ */
136
+ function structuralTruncate(raw) {
137
+ let s = raw.trim();
138
+ // 反复处理:命中括号且主句非空 → 截到括号前;命中其它标记 → 截到标记前。
139
+ for (let guard = 0; guard < 8; guard++) {
140
+ let cut = -1;
141
+ for (const re of STRUCTURAL_BOUNDARIES) {
142
+ const m = re.exec(s);
143
+ if (m && (cut === -1 || m.index < cut))
144
+ cut = m.index;
145
+ }
146
+ if (cut === -1)
147
+ return s;
148
+ const head = s.slice(0, cut).trim();
149
+ const marker = s[cut];
150
+ const isParen = marker === '(' || marker === '(';
151
+ if (head.length === 0 && isParen) {
152
+ // 主句被丢空:丢掉这个左括号,保留其后内容继续处理。
153
+ s = s.slice(cut + 1).trim();
154
+ continue;
155
+ }
156
+ return head;
157
+ }
158
+ return s;
159
+ }
160
+ /**
161
+ * 步骤 5:截到 maxLen,且绝不切半 ASCII 单词。
162
+ * 若刚好落在 ASCII 词元中间(末字符与被丢字符都是 `[A-Za-z0-9]`),回删整个残词。
163
+ * CJK 逐字,无需回退。
164
+ *
165
+ * 导出是因为**改名侧也要同一个收梢算法**:模型给的 title 只是超长(`too-long`)时,
166
+ * 按词元边界收一刀仍是好名字,直接回落 `deriveName` 等于把已经拿到的语义扔掉重算。
167
+ * 两处共用一份,截断形状才不会分头演化。
168
+ */
169
+ export function clipAtTokenBoundary(raw, maxLen) {
170
+ if (raw.length <= maxLen)
171
+ return raw;
172
+ let clipped = raw.slice(0, maxLen);
173
+ const lastKept = clipped.slice(-1);
174
+ const firstDropped = raw[maxLen] ?? '';
175
+ if (/[A-Za-z0-9]/.test(lastKept) && /[A-Za-z0-9]/.test(firstDropped)) {
176
+ clipped = clipped.replace(/[A-Za-z0-9]+$/, '');
177
+ }
178
+ return clipped;
179
+ }
180
+ /**
181
+ * 步骤 6:截断纪律。过 `sanitizeFilename` 的字符白名单 → 剥它补的 `.md`
182
+ * → 剥尾随 `.` `-` `_` 空格(尾点是真机 12 条坏味的直接来源)。
183
+ */
184
+ function applyTrailingDiscipline(raw) {
185
+ const safe = sanitizeFilename(raw).replace(/\.md$/i, '');
186
+ return safe.replace(/[.\-_\s]+$/, '');
187
+ }
188
+ /**
189
+ * 校验一个**人给的或模型给的**名字是否可用(形状,不含唯一性)。
190
+ *
191
+ * Phase 3 的 few-shot 判据会拿它当门禁,因此签名与 reason 取值必须稳定。
192
+ * 命中任一硬规则即拒;全部通过返回 `{ ok: true, name }`(name 为过字符白名单后的归一值)。
193
+ *
194
+ * @param raw - 候选名字。
195
+ * @returns `{ ok: true, name }` 或 `{ ok: false, reason }`。
196
+ */
197
+ export function validateTitle(raw) {
198
+ const s = (raw ?? '').trim();
199
+ if (s.length === 0)
200
+ return { ok: false, reason: 'empty' };
201
+ if (/[\r\n]/.test(s))
202
+ return { ok: false, reason: 'has-newline' };
203
+ if (s.length < 4)
204
+ return { ok: false, reason: 'too-short' };
205
+ if (s.length > NAME_MAX)
206
+ return { ok: false, reason: 'too-long' };
207
+ if (/[。!?!?.]$/.test(s))
208
+ return { ok: false, reason: 'ends-with-punctuation' };
209
+ if (/[A-Za-z]:[\\/]/.test(s))
210
+ return { ok: false, reason: 'has-drive-path' };
211
+ if (s.includes('/'))
212
+ return { ok: false, reason: 'has-slash' };
213
+ if (/http/i.test(s))
214
+ return { ok: false, reason: 'has-url' };
215
+ // 只拒**真 id 形状**(session-44332549…):宽判 `session-` 子串会把
216
+ // `dsh-session-steward` 这类正当词组名误拒(真机改名计划实测 4/20 的误杀源)。
217
+ if (/session-[0-9a-f]{6,}/i.test(s))
218
+ return { ok: false, reason: 'has-session-id' };
219
+ if (/[0-9a-fA-F]{32,36}/.test(s))
220
+ return { ok: false, reason: 'has-hex-id' };
221
+ // 数字尾巴**只在它不属于某个标识符名字**时才算残片:`记录-12345-号` 是截断产物(数字前是 CJK),
222
+ // 而 `ADR-0006`、`Han-1413141` 里数字前面是 ASCII 字母——那是名字本体(`[^ -~]` = 非可打印 ASCII)。
223
+ // 旧判据一律拒 `-\d{4}`,真机把 4 条 ADR 记录的正当 LLM 标题全打进 fallback(反而掉回 `ADR-0006` 这种半截名)。
224
+ if (/[^ -~]-\d{4}/.test(s))
225
+ return { ok: false, reason: 'has-id-fragment' };
226
+ if (startsNarrativeLeadIn(s))
227
+ return { ok: false, reason: 'starts-with-lead-in' };
228
+ if (isBareCodeIdentifier(s))
229
+ return { ok: false, reason: 'code-identifier' };
230
+ const safe = sanitizeFilename(s).replace(/\.md$/i, '');
231
+ if (safe.length === 0)
232
+ return { ok: false, reason: 'no-usable-char' };
233
+ return { ok: true, name: safe };
234
+ }
235
+ /** 是否以叙述引子开头(用 {@link GATE_LEAD_IN_SORTED}:剥引子与拒名字两张表用途不同,见其注释)。 */
236
+ function startsNarrativeLeadIn(s) {
237
+ return GATE_LEAD_IN_SORTED.some((lead) => s.startsWith(lead));
238
+ }
239
+ /**
240
+ * 是否为"裸代码标识符"——纯 ASCII、无任何分隔符(空格/-/_)、且含词中大小写翻转
241
+ * (camelCase 边界),读起来像被原样抓来的函数/变量名,而非人给短标题。
242
+ * 真机坏味 `buildTurnCompactSystemPr`(ASCII 函数名硬切腰斩)即此类。
243
+ */
244
+ function isBareCodeIdentifier(s) {
245
+ if (/[^\x00-\x7F]/.test(s))
246
+ return false; // 含非 ASCII(CJK 等)不算
247
+ if (/[\s\-_]/.test(s))
248
+ return false; // 有分隔符即为短语,放行
249
+ return /[a-z][A-Z]/.test(s); // 词中大写翻转 = 驼峰标识符
250
+ }
251
+ /**
252
+ * 剥一个**模型给的** title 的形状噪声:空白(含全角空格)→ `-`、连字折叠、首尾点/横线剔掉。
253
+ *
254
+ * 为什么需要它:不是语义不对,只是字符形状不对——直接拿原文过形状闸会把
255
+ * “尾部多一个点”这种一律打进 fallback(真机旧行为:16/20 条白归规则名)。
256
+ * 导出是为了**只有一个整形函数**:管线与临时改名脚本共用,两套迟早漂。
257
+ */
258
+ export function reshapeTitle(raw) {
259
+ return raw.replace(/[\s\u3000]+/g, '-').replace(/-{2,}/g, '-').replace(/^[-.]+|[-.]+$/g, '');
260
+ }
261
+ /**
262
+ * 收下模型产出的 title:先整形再入闸,**只超长**时按词元边界再收一刀。
263
+ *
264
+ * 返回落名或 `undefined`(不合格)。刻意**不回落规则名**:那是调用方的决定,
265
+ * 因为两条路的可观测要求不同(管线要“没名字就交给起名器”,脚本要“记 fallback+reason”)。
266
+ * 闸仍是 {@link validateTitle} 那一个,不开第二套判据。
267
+ *
268
+ * @param raw - 模型给的原文(故意收 `unknown`:模型输出是不可信输入)。
269
+ * @returns 可用的落名,或 `undefined`。
270
+ */
271
+ export function acceptTitle(raw) {
272
+ if (typeof raw !== 'string')
273
+ return undefined;
274
+ const shaped = reshapeTitle(raw.trim());
275
+ if (shaped.length === 0)
276
+ return undefined;
277
+ // 两轮:原形状→若仅因超长被拒则按词元边界收到 NAME_MAX 再试。其他拒因(句子、路径、
278
+ // 叙述引子开头)收短也不会变合格,重试只会把垃圾洗成“看起来干净”的垃圾。
279
+ for (const cand of [shaped, clipAtTokenBoundary(shaped, NAME_MAX)]) {
280
+ const g = validateTitle(cand);
281
+ if (g.ok)
282
+ return g.name;
283
+ }
284
+ return undefined;
285
+ }
286
+ /**
287
+ * 校验一个**人给的**名字是否可用(形状,不含唯一性)。
288
+ *
289
+ * 人从面板或 md 文件里改名字时,先过这道形状闸再谈唯一性:形状不合就拒,
290
+ * 比"收下然后写不出文件"好。返回归一后的名字(可能与入参不同:洗掉了非法字符)。
291
+ *
292
+ * @param raw - 用户给的名字。
293
+ * @returns `{ ok: true, name }` 或 `{ ok: false, reason }`。
294
+ */
295
+ export function normalizeName(raw) {
296
+ const trimmed = (raw ?? '').trim();
297
+ if (trimmed.length === 0)
298
+ return { ok: false, reason: 'empty' };
299
+ if (trimmed.length > NAME_MAX * 2)
300
+ return { ok: false, reason: 'too-long' };
301
+ const safe = sanitizeFilename(trimmed).replace(/\.md$/i, '');
302
+ if (safe.length === 0)
303
+ return { ok: false, reason: 'no-usable-char' };
304
+ if (safe !== trimmed)
305
+ return { ok: false, reason: 'illegal-chars' };
306
+ return { ok: true, name: safe };
307
+ }
308
+ /** 名字没洗出可用字符时的回落干:`mem-<id 后 8 位>`。 */
309
+ export function fallbackStem(id) {
310
+ return `mem-${(id ?? '').slice(-8)}`;
311
+ }
312
+ /**
313
+ * 给一个名字求**全库唯一**的落名:基底 → `基底-2` → `基底-3` → … → 回落名。
314
+ *
315
+ * 为什么必须在这里消歧,而不是"谁先到谁写":同一段话的多条记录必然洗出同一个名字。
316
+ * 真机实测 1166 条里 42 条挤进 17 个同名文件(最狠一个路径 6 条),后果是导出每轮都把
317
+ * 被覆盖的那几条重写一遍(面板上那个永远不归零的「已写 9」)、而反向同步会把一份内容
318
+ * 回灌给六条记录。
319
+ *
320
+ * 分配本身只取决于"基底 + 已占用集",因此**调用方必须按 id 排序后再分配**:
321
+ * 同一个基底碰上同一次分配时,谁先谁拿到裸名——顺序不钉死的话,两次导出会给同一条
322
+ * 记录分到不同名字,文件在盘上跳来跳去。后缀要留位置:总长不超过 {@link NAME_MAX},
323
+ * 宁可截基底也不让它超长。
324
+ *
325
+ * @param base - 基底名(通常是 `deriveName(content)`,可以为空)。
326
+ * @param id - 记录 id,基底不可用时落回 `mem-<id8>`。
327
+ * @param isFree - 库侧查询:这个名字没被**其它**记录占着。
328
+ * @param reserved - 本轮已分配出去的名字(调用方逐次加入,函数也会自己加)。
329
+ * @returns 可用的名字;实在找不到就把 `id` 的回落名加上数字尾巴交出去(仍进 reserved)。
330
+ */
331
+ export function allocUniqueName(base, id, isFree, reserved) {
332
+ const usable = (n) => n.length > 0 && !reserved.has(n) && isFree(n);
333
+ const fallback = fallbackStem(id);
334
+ // 基底没洗出名字时,回落名就是基底(先试裸名,再试 `-N`)。
335
+ const stem = base.length > 0 ? base : fallback;
336
+ if (usable(stem)) {
337
+ reserved.add(stem);
338
+ return stem;
339
+ }
340
+ // 后缀从 2 起(-1 留给裸名,读起来不像"第二个")。
341
+ for (let n = 2; n <= 999; n++) {
342
+ const tail = `-${n}`;
343
+ const room = Math.max(1, NAME_MAX - tail.length);
344
+ const candidate = `${stem.slice(0, room)}${tail}`;
345
+ if (usable(candidate)) {
346
+ reserved.add(candidate);
347
+ return candidate;
348
+ }
349
+ }
350
+ // 理论上到不了这里(999 个尾巴不够用 = 同一个 28 字基底已经有 1000 条记录);
351
+ // 真到了就用 id 尾巴加数字,至少能保证写得出文件。
352
+ for (let n = 1; n <= 999; n++) {
353
+ const candidate = n === 1 ? fallback : `${fallback}-${n}`;
354
+ if (usable(candidate)) {
355
+ reserved.add(candidate);
356
+ return candidate;
357
+ }
358
+ }
359
+ const last = `${fallback}-${Date.now().toString(36)}`;
360
+ reserved.add(last);
361
+ return last;
362
+ }
363
+ /** 正则元字符转义,供动态构造引子匹配。 */
364
+ function escapeRe(str) {
365
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
366
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * 记忆正文的 markdown 版式归一(P4.2「格式需要标准化」)。
3
+ *
4
+ * 只做**版式**,不做**语义**:不 bullet 化散文、不插 H1、不往正文里塞元数据(元数据一律在
5
+ * frontmatter)。三条理由:
6
+ * - 散文转列表会把"因为/所以/但是"这类连接关系拆散,且拆完不可逆;
7
+ * - 正文一旦带骨架,`parseMdFile` 的往返一致性(P2)就得反向剪枝,两套逻辑必然漂移;
8
+ * - 归一结果要进 `renderRecord`(导出)与回灌(入库)两头,任何有损改写都会被写进库。
9
+ *
10
+ * **幂等是硬要求**:`f(f(x)) === f(x)`。不幂等的话账本 `fileDigest` 每轮都变,反向同步
11
+ * 永远判"文件被改过",写回环打不完——那正是 P2 用例钉死的那条性质。
12
+ *
13
+ * 刻意不用正则与转义写法:同仓产物里出现过 `\n` 被展开成真实换行、把正则字面量截断的事,
14
+ * 所以这里全部走字符码与字符串切片,形状只依赖文件真实结构。
15
+ */
16
+ /**
17
+ * 归一一段记忆正文。
18
+ *
19
+ * @param text - 原始正文(可含 CRLF、BOM、任意尾随空白)。
20
+ * @returns 版式归一后的正文:**无**前导/尾随空行、段落间恰好一个空行、围栏内(含 delimiter)
21
+ * 逐字保留、无末尾换行(渲染器自己控制)。
22
+ */
23
+ export declare function normalizeContentMd(text: string): string;