dsh-prime-memory 0.22.2 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.en.md +401 -0
- package/CHANGELOG.ja.md +401 -0
- package/CHANGELOG.ko.md +401 -0
- package/CHANGELOG.md +100 -0
- package/dist/client.js +1180 -845
- package/dist/config.d.ts +128 -1341
- package/dist/config.js +80 -4
- package/dist/conflict-service.d.ts +4 -0
- package/dist/conflict-service.js +6 -1
- package/dist/contract.d.ts +196 -0
- package/dist/domain-gate.d.ts +11 -0
- package/dist/domain-gate.js +15 -0
- package/dist/hooks/obsolete-notice.d.ts +26 -0
- package/dist/hooks/obsolete-notice.js +64 -0
- package/dist/hooks/recall.d.ts +39 -0
- package/dist/hooks/recall.js +110 -30
- package/dist/index.d.ts +1 -1127
- package/dist/index.js +30 -2
- package/dist/memory-types.d.ts +69 -0
- package/dist/memory-types.js +165 -0
- package/dist/memport/format.d.ts +6 -1
- package/dist/memport/format.js +42 -2
- package/dist/pipeline/l1.d.ts +44 -2
- package/dist/pipeline/l1.js +163 -9
- package/dist/pipeline/md-sync.d.ts +65 -0
- package/dist/pipeline/md-sync.js +456 -0
- package/dist/pipeline/ruminate.d.ts +43 -1
- package/dist/pipeline/ruminate.js +100 -0
- package/dist/pipeline/runner.d.ts +10 -0
- package/dist/pipeline/runner.js +7 -1
- package/dist/pipeline/workspace-relink.d.ts +60 -0
- package/dist/pipeline/workspace-relink.js +73 -0
- package/dist/projection/recall.d.ts +51 -13
- package/dist/projection/recall.js +47 -12
- package/dist/prompts/examples.d.ts +73 -0
- package/dist/prompts/examples.js +178 -0
- package/dist/prompts/l1-dedup.d.ts +9 -3
- package/dist/prompts/l1-dedup.js +34 -13
- package/dist/prompts/l1-extraction.d.ts +5 -3
- package/dist/prompts/l1-extraction.js +49 -4
- package/dist/settings.d.ts +8 -0
- package/dist/settings.js +12 -1
- package/dist/stats.d.ts +12 -0
- package/dist/stats.js +327 -15
- package/dist/store/embedding-source.d.ts +8 -0
- package/dist/store/embedding-source.js +39 -2
- package/dist/store/file-versions.d.ts +2 -0
- package/dist/store/file-versions.js +2 -0
- package/dist/store/graph-store.d.ts +14 -0
- package/dist/store/graph-store.js +35 -0
- package/dist/store/l1-snapshot.d.ts +26 -1
- package/dist/store/l1-snapshot.js +53 -8
- package/dist/store/l1.d.ts +75 -3
- package/dist/store/l1.js +124 -3
- package/dist/store/memory-md-archive.d.ts +84 -0
- package/dist/store/memory-md-archive.js +155 -0
- package/dist/store/memory-md-disable.d.ts +23 -0
- package/dist/store/memory-md-disable.js +106 -0
- package/dist/store/memory-md-export.d.ts +50 -0
- package/dist/store/memory-md-export.js +200 -0
- package/dist/store/memory-md-ledger.d.ts +94 -0
- package/dist/store/memory-md-ledger.js +243 -0
- package/dist/store/memory-md-orphan.d.ts +27 -0
- package/dist/store/memory-md-orphan.js +153 -0
- package/dist/store/memory-md-parse.d.ts +35 -0
- package/dist/store/memory-md-parse.js +85 -0
- package/dist/store/memory-md-skeleton.d.ts +18 -0
- package/dist/store/memory-md-skeleton.js +61 -0
- package/dist/store/memory-md-types.d.ts +174 -0
- package/dist/store/memory-md-types.js +10 -0
- package/dist/store/memory-md.d.ts +108 -0
- package/dist/store/memory-md.js +219 -0
- package/dist/store/obsolete-notices.d.ts +39 -0
- package/dist/store/obsolete-notices.js +153 -0
- package/dist/store/recall-feedback.d.ts +75 -0
- package/dist/store/recall-feedback.js +213 -0
- package/dist/store/recall-usage.d.ts +11 -0
- package/dist/store/recall-usage.js +29 -0
- package/dist/store/session-recalled.d.ts +38 -3
- package/dist/store/session-recalled.js +156 -9
- package/dist/store/sqlite.d.ts +66 -0
- package/dist/store/sqlite.js +233 -15
- package/dist/store/supersede.d.ts +1 -1
- package/dist/store/supersede.js +1 -1
- package/dist/tools/index.d.ts +4 -0
- package/dist/tools/index.js +23 -6
- package/dist/types.d.ts +24 -7
- package/dist/types.js +10 -2
- package/dist/util/maturity.d.ts +20 -3
- package/dist/util/maturity.js +18 -1
- package/dist/util/md-name.d.ts +129 -0
- package/dist/util/md-name.js +366 -0
- package/dist/util/md-normalize.d.ts +23 -0
- package/dist/util/md-normalize.js +131 -0
- package/dist/workspace-route.d.ts +22 -0
- package/dist/workspace-route.js +66 -0
- package/dsh.plugin.json +1 -1
- package/package.json +6 -5
|
@@ -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;
|
|
@@ -0,0 +1,131 @@
|
|
|
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
|
+
const NL = String.fromCharCode(10);
|
|
17
|
+
const TAB = String.fromCharCode(9);
|
|
18
|
+
const FENCE_BACKTICK = String.fromCharCode(96, 96, 96);
|
|
19
|
+
const FENCE_TILDE = String.fromCharCode(126, 126, 126);
|
|
20
|
+
/** 无序列表的等价符号:统一成 `- `(有序列表**不动**,`1. 2. 3.` 自带语义顺序)。 */
|
|
21
|
+
const BULLETS = ['*', '+', '·', '•'];
|
|
22
|
+
/** 行首缩进宽度(只数空格;tab 缩进按 1 计,不做宽度换算——换算就不是幂等的了)。 */
|
|
23
|
+
function indentWidth(line) {
|
|
24
|
+
let i = 0;
|
|
25
|
+
while (i < line.length && (line.charAt(i) === ' ' || line.charAt(i) === TAB))
|
|
26
|
+
i += 1;
|
|
27
|
+
return i;
|
|
28
|
+
}
|
|
29
|
+
/** 行尾收口:尾部空白里空格 ≥2 视为 markdown 硬换行,压成恰好两个;单个尾随空格与所有尾随 tab 去掉。 */
|
|
30
|
+
function settleEol(line) {
|
|
31
|
+
let i = line.length;
|
|
32
|
+
while (i > 0 && (line.charAt(i - 1) === ' ' || line.charAt(i - 1) === TAB))
|
|
33
|
+
i -= 1;
|
|
34
|
+
const core = line.slice(0, i);
|
|
35
|
+
const trail = line.slice(i);
|
|
36
|
+
let spaces = 0;
|
|
37
|
+
for (const c of trail)
|
|
38
|
+
if (c === ' ')
|
|
39
|
+
spaces += 1;
|
|
40
|
+
return spaces >= 2 ? `${core} ` : core;
|
|
41
|
+
}
|
|
42
|
+
/** 无序列表符号归一(只认"符号 + 一个空格"的规范写法,避免把 `*` 强调语法当列表改了)。 */
|
|
43
|
+
function settleBullet(line) {
|
|
44
|
+
const w = indentWidth(line);
|
|
45
|
+
const head = line.slice(0, w);
|
|
46
|
+
const rest = line.slice(w);
|
|
47
|
+
for (const b of BULLETS) {
|
|
48
|
+
if (rest.startsWith(`${b} `) || rest.startsWith(b + TAB))
|
|
49
|
+
return `${head}-${rest.slice(1)}`;
|
|
50
|
+
}
|
|
51
|
+
return line;
|
|
52
|
+
}
|
|
53
|
+
/** 标题行的前导空白去掉(` # 标题` → `# 标题`);列表项内的 `#` 不算标题(那多半是内容)。 */
|
|
54
|
+
function settleHeading(line) {
|
|
55
|
+
if (indentWidth(line) === 0)
|
|
56
|
+
return line;
|
|
57
|
+
const rest = line.slice(indentWidth(line));
|
|
58
|
+
return rest.startsWith('#') ? rest : line;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* 归一一段记忆正文。
|
|
62
|
+
*
|
|
63
|
+
* @param text - 原始正文(可含 CRLF、BOM、任意尾随空白)。
|
|
64
|
+
* @returns 版式归一后的正文:**无**前导/尾随空行、段落间恰好一个空行、围栏内(含 delimiter)
|
|
65
|
+
* 逐字保留、无末尾换行(渲染器自己控制)。
|
|
66
|
+
*/
|
|
67
|
+
export function normalizeContentMd(text) {
|
|
68
|
+
// ① 换行统一:BOM 去掉,CRLF/CR → LF。这一步之后再按 LF 切。
|
|
69
|
+
let src = text;
|
|
70
|
+
// BOM
|
|
71
|
+
if (src.charCodeAt(0) === 0xfeff)
|
|
72
|
+
src = src.slice(1);
|
|
73
|
+
// CRLF → LF(逐字符扫,避开转义写法)
|
|
74
|
+
let flat = '';
|
|
75
|
+
for (let i = 0; i < src.length; i += 1) {
|
|
76
|
+
const c = src.charAt(i);
|
|
77
|
+
if (c === String.fromCharCode(13)) {
|
|
78
|
+
// CR 无论后面是不是 LF 都必须落一个 LF:只进 `i += 1` 不写 `flat` 会把换行直接吞掉
|
|
79
|
+
// (自家幂等用例抓到的第一个真 bug:`甲\r\n乙` 变成 `甲乙`,段落边界就这么没了)。
|
|
80
|
+
if (src.charAt(i + 1) === NL)
|
|
81
|
+
i += 1; // CR LF → 吃掉 CR,只留下面那个 LF
|
|
82
|
+
flat += NL;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
flat += c;
|
|
86
|
+
}
|
|
87
|
+
const lines = flat.split(NL);
|
|
88
|
+
const out = [];
|
|
89
|
+
let inFence = false;
|
|
90
|
+
let fenceMark = '';
|
|
91
|
+
let blanks = 0;
|
|
92
|
+
for (const raw of lines) {
|
|
93
|
+
const pre = raw.slice(indentWidth(raw));
|
|
94
|
+
const isFence = pre.startsWith(FENCE_BACKTICK) || pre.startsWith(FENCE_TILDE);
|
|
95
|
+
if (isFence) {
|
|
96
|
+
const mark = pre.startsWith(FENCE_BACKTICK) ? FENCE_BACKTICK : FENCE_TILDE;
|
|
97
|
+
if (!inFence) {
|
|
98
|
+
inFence = true;
|
|
99
|
+
fenceMark = mark;
|
|
100
|
+
}
|
|
101
|
+
else if (mark === fenceMark) {
|
|
102
|
+
inFence = false;
|
|
103
|
+
fenceMark = '';
|
|
104
|
+
}
|
|
105
|
+
// 围栏 delimiter 行就是版式的一部分:原样落盘,连行尾收口也不做
|
|
106
|
+
blanks = 0;
|
|
107
|
+
out.push(raw);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (inFence) {
|
|
111
|
+
// 围栏内的行逐字保留:代码里的缩进、`*` 号、行尾双空格都可能是有意为之
|
|
112
|
+
blanks = 0;
|
|
113
|
+
out.push(raw);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
let line = settleBullet(settleHeading(settleEol(raw)));
|
|
117
|
+
if (line.trim().length === 0) {
|
|
118
|
+
blanks += 1;
|
|
119
|
+
// 空行只在已有内容时才可能保留,且最多一个
|
|
120
|
+
if (out.length > 0 && blanks === 1)
|
|
121
|
+
out.push('');
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
blanks = 0;
|
|
125
|
+
out.push(line);
|
|
126
|
+
}
|
|
127
|
+
// ③ 末尾无换行:渲染器负责补,正文本体不带(否则 digest 会随写法抖动)。
|
|
128
|
+
while (out.length > 0 && out[out.length - 1] === '')
|
|
129
|
+
out.pop();
|
|
130
|
+
return out.join(NL);
|
|
131
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** 一条路由规则。 */
|
|
2
|
+
export interface WorkspaceRoute {
|
|
3
|
+
/** 命中后归到的 workspace(绝对路径;返回值已归一)。 */
|
|
4
|
+
path: string;
|
|
5
|
+
/** 命中任一关键词即归此路(大小写不敏感、已 NFKC 归一的子串匹配)。 */
|
|
6
|
+
anyOf: readonly string[];
|
|
7
|
+
/** 出现任一排除词则**此路**不命中(继续往下看下一条规则)。 */
|
|
8
|
+
noneOf?: readonly string[];
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* 按序取首个命中;全不命中返回 `undefined`(**宁缺勿猜**——归错 workspace 会让记忆在
|
|
12
|
+
* 错误的工作区可见/不可见,比不归更难查)。
|
|
13
|
+
*
|
|
14
|
+
* 排序即优先级:调用方给的表里 dsh 侧在前,故"同一段内容既谈 Hanako 又谈 dsh"会归 dsh
|
|
15
|
+
* 一侧(用户口径);非 dsh 那条同时带 `noneOf: ['dsh']` 做二次保险。
|
|
16
|
+
*/
|
|
17
|
+
export declare function routeWorkspace(text: string, routes: readonly WorkspaceRoute[]): string | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* 路由结论与来源链结论**矛盾**时返回 true(来源链仅作交叉验证)。
|
|
20
|
+
* 单独抽出是为了把"冲突要可观测"这条判据变成可测函数,而不是散在调用点的 if 里。
|
|
21
|
+
*/
|
|
22
|
+
export declare function routeConflictsWithProvenance(routed: string | undefined, provenance: string | undefined): boolean;
|