dsh-prime-memory 0.21.0 → 0.22.2

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 (63) hide show
  1. package/dist/client.js +1581 -1150
  2. package/dist/config.d.ts +149 -0
  3. package/dist/config.js +35 -0
  4. package/dist/conflict-service.d.ts +8 -0
  5. package/dist/conflict-service.js +19 -0
  6. package/dist/contract.d.ts +131 -0
  7. package/dist/domain-gate.d.ts +8 -0
  8. package/dist/domain-gate.js +30 -0
  9. package/dist/graph/constraints.d.ts +4 -2
  10. package/dist/graph/constraints.js +4 -2
  11. package/dist/hooks/recall.d.ts +2 -0
  12. package/dist/hooks/recall.js +21 -2
  13. package/dist/hooks/skill-recall.d.ts +25 -0
  14. package/dist/hooks/skill-recall.js +72 -0
  15. package/dist/index.d.ts +98 -0
  16. package/dist/index.js +26 -1
  17. package/dist/memport/format.d.ts +70 -0
  18. package/dist/memport/format.js +305 -0
  19. package/dist/memport/prefilter.d.ts +62 -0
  20. package/dist/memport/prefilter.js +132 -0
  21. package/dist/memport/scan.d.ts +13 -0
  22. package/dist/memport/scan.js +100 -0
  23. package/dist/metadata-validators.d.ts +22 -0
  24. package/dist/metadata-validators.js +45 -0
  25. package/dist/pipeline/l1.js +16 -2
  26. package/dist/pipeline/ruminate.d.ts +3 -0
  27. package/dist/pipeline/ruminate.js +50 -1
  28. package/dist/pipeline/slot-migrate.d.ts +45 -0
  29. package/dist/pipeline/slot-migrate.js +45 -0
  30. package/dist/projection/recall.d.ts +74 -0
  31. package/dist/projection/recall.js +148 -0
  32. package/dist/projection/slots.d.ts +5 -0
  33. package/dist/projection/slots.js +9 -2
  34. package/dist/prompts/graph-projection.js +3 -0
  35. package/dist/prompts/l1-extraction.d.ts +4 -1
  36. package/dist/prompts/l1-extraction.js +27 -4
  37. package/dist/settings.js +106 -11
  38. package/dist/skills/crystallize.d.ts +41 -0
  39. package/dist/skills/crystallize.js +75 -0
  40. package/dist/stats.d.ts +18 -1
  41. package/dist/stats.js +184 -5
  42. package/dist/store/file-versions.d.ts +2 -0
  43. package/dist/store/file-versions.js +2 -0
  44. package/dist/store/l1.d.ts +2 -0
  45. package/dist/store/l1.js +4 -0
  46. package/dist/store/memory-sources.d.ts +59 -0
  47. package/dist/store/memory-sources.js +214 -0
  48. package/dist/store/session-recalled.d.ts +25 -0
  49. package/dist/store/session-recalled.js +163 -0
  50. package/dist/store/skill-store.d.ts +99 -0
  51. package/dist/store/skill-store.js +330 -0
  52. package/dist/store/sqlite.d.ts +5 -0
  53. package/dist/store/sqlite.js +30 -4
  54. package/dist/tools/index.d.ts +0 -13
  55. package/dist/tools/index.js +58 -7
  56. package/dist/tools/skills.d.ts +13 -0
  57. package/dist/tools/skills.js +130 -0
  58. package/dist/types.d.ts +6 -0
  59. package/dist/util/maturity.d.ts +27 -0
  60. package/dist/util/maturity.js +26 -0
  61. package/dist/wing-backfill.js +7 -4
  62. package/dsh.plugin.json +2 -2
  63. package/package.json +14 -14
@@ -0,0 +1,305 @@
1
+ /**
2
+ * MMF v1.1 记忆包解析 / 序列化(轨3 宿主侧纯 TS 实现,零 IO)。
3
+ *
4
+ * 与 `skills/memport/scripts/memport.py` 是**同一格式的两份实现**:Python 侧供无 DSH
5
+ * 环境下导出用,本模块供 DSH 宿主侧导入 pre-check / 预筛用。二者必须产出一致的
6
+ * 结构校验结论与哈希——`tests/memport-ts-consistency.test.ts` 用 `templates/example.mem`
7
+ * 头部声明的 sha256(`af1247329ce5`) 做**跨实现金标准**钉死这一点。
8
+ *
9
+ * 刻意零 IO:解析入参是字符串(调用方自己读文件/请求体),便于单测且不碰文件系统。
10
+ * 逐字段语义见 `references/format.md`;四轴时间(created/updated/vf/vt)、持续性 st
11
+ * 的判定不可互相顶替(见 SKILL §四)。
12
+ */
13
+ import { createHash } from 'node:crypto';
14
+ /** MMF v1.1 默认列序(缺 `#cols=` 时按此解析;v1 包缺 vf/vt/st 列按 -/-/? 补齐)。 */
15
+ export const DEFAULT_COLS = ['type', 'created', 'updated', 'vf', 'vt', 'st', 'cf', 'rw', 'tags', 'content'];
16
+ /** 类型码 → 运行时 type(与 memport.py TYPE_CODES 一字不差)。 */
17
+ export const TYPE_CODES = {
18
+ pe: 'persona',
19
+ ep: 'episodic',
20
+ in: 'instruction',
21
+ wf: 'work_fact',
22
+ wt: 'work_task',
23
+ wm: 'work_method',
24
+ wa: 'work_artifact',
25
+ };
26
+ const TYPE_FULL = Object.fromEntries(Object.entries(TYPE_CODES).map(([k, v]) => [v, k]));
27
+ /** 持续性取值域:p 时点 / s 已结束区间 / o 仍在持续 / t 无时间性 / ? 未判定。 */
28
+ export const PERSISTENCE = ['p', 's', 'o', 't', '?'];
29
+ /** 支持的包版本(其余报错)。 */
30
+ const KNOWN_VERSIONS = new Set(['1', 'v1', '1.1', 'v1.1']);
31
+ /** NFKC + 空白折叠 + 去首尾(用于哈希与相似度,不改原文)。 */
32
+ export function normText(s) {
33
+ return s.normalize('NFKC').replace(/\s+/g, ' ').trim();
34
+ }
35
+ /**
36
+ * 内容哈希:sha256(normText + 小写)[:16]。
37
+ * 注意:Python 用 casefold(激进大小写折叠),JS 用 toLowerCase;
38
+ * 对 CJK/ASCII 记忆正文等价,仅极端 Unicode 有别——实际记忆集不含此类,
39
+ * 跨实现金标准走包级 sha256(不逐条折叠),不受影响。
40
+ */
41
+ export function contentHash(s) {
42
+ return createHash('sha256').update(normText(s).toLowerCase(), 'utf8').digest('hex').slice(0, 16);
43
+ }
44
+ /** 记录稳定 id:`mp_` + sha256(src|chash)[:16]。 */
45
+ export function recordId(src, chash) {
46
+ return 'mp_' + createHash('sha256').update(`${src}|${chash}`, 'utf8').digest('hex').slice(0, 16);
47
+ }
48
+ /** 解码内容列:反斜杠+反斜杠 → 单反斜杠;反斜杠+n → 换行(其余反斜杠原样)。 */
49
+ export function unescapeContent(s) {
50
+ let out = '';
51
+ for (let i = 0; i < s.length; i++) {
52
+ const c = s[i];
53
+ if (c === '\\' && i + 1 < s.length) {
54
+ const nxt = s[i + 1];
55
+ if (nxt === '\\') {
56
+ out += '\\';
57
+ i++;
58
+ continue;
59
+ }
60
+ if (nxt === 'n') {
61
+ out += '\n';
62
+ i++;
63
+ continue;
64
+ }
65
+ }
66
+ out += c;
67
+ }
68
+ return out;
69
+ }
70
+ /**
71
+ * 编码内容列:反斜杠 → 双反斜杠、CRLF → LF、LF → 反斜杠+n。
72
+ * 用 charCode 常量而非控制字符字面量,避免跨工具写盘时 CR/LF 被吞进正则/字符串字面量。
73
+ */
74
+ export function escapeContent(s) {
75
+ const CR = String.fromCharCode(13);
76
+ const LF = String.fromCharCode(10);
77
+ const BSL = String.fromCharCode(92);
78
+ return s.split(BSL).join(BSL + BSL).split(CR + LF).join(LF).split(LF).join(BSL + 'n');
79
+ }
80
+ /** `k:v;k:v` → 对象(缺冒号的段忽略)。 */
81
+ export function parseTags(raw) {
82
+ const out = {};
83
+ for (const part of (raw || '').split(';')) {
84
+ const p = part.trim();
85
+ if (!p || !p.includes(':'))
86
+ continue;
87
+ const idx = p.indexOf(':');
88
+ out[p.slice(0, idx).trim()] = p.slice(idx + 1).trim();
89
+ }
90
+ return out;
91
+ }
92
+ /** 时间串 → { ms|null, precision }(镜像 Python parse_time;naive 视为本地)。 */
93
+ export function parseTime(raw) {
94
+ const s = (raw || '').trim();
95
+ if (s === '' || s === '-' || s === '?' || s === 'null')
96
+ return { ms: null, precision: 'unknown' };
97
+ const patterns = [
98
+ [/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$/, 'exact'],
99
+ [/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?:Z|[+-]\d{2}:?\d{2})?$/, 'minute'],
100
+ [/^\d{4}-\d{2}-\d{2}[ ]\d{2}:\d{2}:\d{2}$/, 'exact'],
101
+ [/^\d{4}-\d{2}-\d{2}[ ]\d{2}:\d{2}$/, 'minute'],
102
+ [/^\d{4}-\d{2}-\d{2}$/, 'day'],
103
+ ];
104
+ for (const [re, prec] of patterns) {
105
+ if (re.test(s)) {
106
+ const iso = s.includes('T') ? s : s.replace(' ', 'T');
107
+ const t = Date.parse(iso);
108
+ if (Number.isFinite(t))
109
+ return { ms: t, precision: prec };
110
+ }
111
+ }
112
+ return { ms: null, precision: 'unknown' };
113
+ }
114
+ /** 按 Python str.split(sep, maxsplit) 语义切列:前 maxsplit 个分隔符切,剩余归最后一列。 */
115
+ function splitMax(line, maxSplit) {
116
+ const out = [];
117
+ let start = 0;
118
+ for (let i = 0; i < maxSplit; i++) {
119
+ const idx = line.indexOf('|', start);
120
+ if (idx === -1)
121
+ break;
122
+ out.push(line.slice(start, idx));
123
+ start = idx + 1;
124
+ }
125
+ out.push(line.slice(start));
126
+ return out;
127
+ }
128
+ /**
129
+ * 解析整包文本 → Bundle(含结构校验)。镜像 memport.py parse_bundle。
130
+ * 入参是字符串(调用方读文件时自带编码处理);此处容错剥离 BOM。
131
+ */
132
+ export function parseBundle(text) {
133
+ const b = {
134
+ header: {},
135
+ cols: [],
136
+ records: [],
137
+ errors: [],
138
+ warnings: [],
139
+ declaredCount: null,
140
+ declaredSha: null,
141
+ bodyLines: [],
142
+ };
143
+ const clean = text.replace(/^/, '');
144
+ const rawLines = clean.split(/\r?\n/);
145
+ const body = [];
146
+ let seqLine = 0;
147
+ for (const line of rawLines) {
148
+ seqLine++;
149
+ if (line.startsWith('#')) {
150
+ const head = line.slice(1).trim();
151
+ if (head.startsWith('memport')) {
152
+ const rest = head.slice('memport'.length).trim();
153
+ b.header.version = rest.split(';')[0].trim() || '1';
154
+ continue;
155
+ }
156
+ for (const part of head.split(';')) {
157
+ const p = part.trim();
158
+ if (!p || !p.includes('='))
159
+ continue;
160
+ const idx = p.indexOf('=');
161
+ const k = p.slice(0, idx).trim();
162
+ const v = p.slice(idx + 1).trim();
163
+ if (k === 'cols')
164
+ b.cols = v.split('|').map((c) => c.trim());
165
+ else if (k === 'count') {
166
+ const n = Number(v);
167
+ if (Number.isInteger(n))
168
+ b.declaredCount = n;
169
+ else
170
+ b.errors.push(`L${seqLine}: count 不是整数:${v}`);
171
+ }
172
+ else if (k === 'sha256')
173
+ b.declaredSha = v;
174
+ else
175
+ b.header[k] = v;
176
+ }
177
+ continue;
178
+ }
179
+ if (line.trim() === '')
180
+ continue;
181
+ body.push(line);
182
+ }
183
+ b.bodyLines = body;
184
+ if (b.cols.length === 0) {
185
+ b.cols = [...DEFAULT_COLS];
186
+ b.warnings.push('包未声明 #cols=,按 MMF 默认列序解析');
187
+ }
188
+ if (!b.cols.includes('content')) {
189
+ b.errors.push('列声明缺少 content 列');
190
+ return b;
191
+ }
192
+ const missingCols = ['vf', 'vt', 'st'].filter((c) => !b.cols.includes(c));
193
+ if (missingCols.length) {
194
+ b.warnings.push(`包缺 ${missingCols.join('/')} 列(MMF v1 包);有效期与持续性将按 -/-/? 落库,无法参与时间定位,建议导出侧升级到 v1.1`);
195
+ }
196
+ const ci = {};
197
+ b.cols.forEach((name, i) => (ci[name] = i));
198
+ const contentIdx = ci.content;
199
+ if (contentIdx !== b.cols.length - 1 && b.errors.length === 0) {
200
+ b.warnings.push('content 不是最后一列;含 | 的内容可能被误切');
201
+ }
202
+ const col = (parts, name, def = '') => {
203
+ const i = ci[name];
204
+ return i === undefined ? def : (parts[i] ?? '').trim();
205
+ };
206
+ body.forEach((line, i) => {
207
+ const seq = i + 1;
208
+ const parts = splitMax(line, b.cols.length - 1);
209
+ if (parts.length !== b.cols.length) {
210
+ b.errors.push(`记录 ${seq}: 列数 ${parts.length} != 声明 ${b.cols.length}`);
211
+ return;
212
+ }
213
+ const content = unescapeContent(parts[contentIdx] ?? '').trim();
214
+ const typeRaw = col(parts, 'type');
215
+ const rec = {
216
+ seq,
217
+ type: typeRaw,
218
+ typeFull: TYPE_CODES[typeRaw] ?? typeRaw,
219
+ created: col(parts, 'created'),
220
+ updated: col(parts, 'updated'),
221
+ vf: col(parts, 'vf', '-'),
222
+ vt: col(parts, 'vt', '-'),
223
+ st: col(parts, 'st', '?'),
224
+ cf: col(parts, 'cf'),
225
+ rw: col(parts, 'rw'),
226
+ tags: 'tags' in ci ? parseTags(col(parts, 'tags')) : {},
227
+ content,
228
+ hash: contentHash(content),
229
+ createdMs: null,
230
+ createdPrec: 'unknown',
231
+ updatedMs: null,
232
+ updatedPrec: 'unknown',
233
+ vfMs: null,
234
+ vfPrec: 'unknown',
235
+ vtMs: null,
236
+ vtPrec: 'unknown',
237
+ };
238
+ const c = parseTime(rec.created);
239
+ rec.createdMs = c.ms;
240
+ rec.createdPrec = c.precision;
241
+ const u = parseTime(rec.updated);
242
+ rec.updatedMs = u.ms;
243
+ rec.updatedPrec = u.precision;
244
+ const vf = parseTime(rec.vf);
245
+ rec.vfMs = vf.ms;
246
+ rec.vfPrec = vf.precision;
247
+ const vt = parseTime(rec.vt);
248
+ rec.vtMs = vt.ms;
249
+ rec.vtPrec = vt.precision;
250
+ b.records.push(rec);
251
+ });
252
+ // ── 结构校验 ──
253
+ const ver = b.header.version;
254
+ if (ver !== undefined && !KNOWN_VERSIONS.has(ver)) {
255
+ b.errors.push(`未知包版本:${ver}(本工具支持 1 / 1.1)`);
256
+ }
257
+ if (b.declaredCount !== null && b.declaredCount !== body.length) {
258
+ b.errors.push(`条数不符:声明 ${b.declaredCount},实际记录行 ${body.length}`);
259
+ }
260
+ if (b.declaredSha) {
261
+ const digest = createHash('sha256').update(body.join('\n'), 'utf8').digest('hex');
262
+ if (!digest.startsWith(b.declaredSha.slice(0, 12))) {
263
+ b.errors.push(`校验和不符:声明 ${b.declaredSha.slice(0, 12)},实算 ${digest.slice(0, 12)}`);
264
+ }
265
+ }
266
+ const seen = {};
267
+ for (const rec of b.records) {
268
+ const p = `记录 ${rec.seq}`;
269
+ if (!(rec.type in TYPE_CODES) && !(rec.type in TYPE_FULL)) {
270
+ if (rec.type === '' || rec.type === '?')
271
+ b.warnings.push(`${p}: 类型缺失,导入时将回落 episodic`);
272
+ else
273
+ b.warnings.push(`${p}: 非标准类型码 ${rec.type},将按原样透传`);
274
+ }
275
+ for (const [fld, val] of [
276
+ ['cf', rec.cf],
277
+ ['rw', rec.rw],
278
+ ]) {
279
+ if (val !== '0' && val !== '1' && val !== '?')
280
+ b.errors.push(`${p}: ${fld} 取值非法 ${val}(仅 0/1/?)`);
281
+ }
282
+ if (!PERSISTENCE.includes(rec.st)) {
283
+ b.errors.push(`${p}: st 取值非法 ${rec.st}(仅 p/s/o/t/?)`);
284
+ }
285
+ if (rec.vfMs && rec.vtMs && rec.vtMs < rec.vfMs) {
286
+ b.warnings.push(`${p}: vt 早于 vf,已按 vf 处理`);
287
+ rec.vtMs = rec.vfMs;
288
+ }
289
+ if (rec.content.length < 4)
290
+ b.errors.push(`${p}: content 过短,疑似空记录`);
291
+ if (rec.content.length > 600)
292
+ b.warnings.push(`${p}: content 超长(${rec.content.length} 字符),违反节省规则(≤100 字)`);
293
+ if (rec.createdMs && rec.updatedMs && rec.updatedMs < rec.createdMs) {
294
+ b.warnings.push(`${p}: updated 早于 created,已按 created 视为 updated`);
295
+ rec.updatedMs = rec.createdMs;
296
+ }
297
+ if (rec.hash in seen)
298
+ b.errors.push(`${p}: 与本包第 ${seen[rec.hash]} 条内容重复(应先在包内去重)`);
299
+ else
300
+ seen[rec.hash] = rec.seq;
301
+ }
302
+ if (b.records.length === 0)
303
+ b.errors.push('包内没有任何记录');
304
+ return b;
305
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * 外部导入预筛(轨3 宿主侧纯 TS):把 memport.py 的确定性相似度判据下沉到宿主。
3
+ *
4
+ * 0 LLM / 0 token / 0 网络——纯本地 CPU。判据与阈值经真实语料标定,与
5
+ * `skills/memport/scripts/memport.py` 一字对齐(金标准测试见
6
+ * `tests/memport-ts-consistency.test.ts`):score = max(3-gram Jaccard, 2-gram Dice),
7
+ * 真改写对 0.49~0.51 / 主题相关但不同 0.16~0.28,故
8
+ * score ≥ 0.62 → 同一事实的改写(near-dup)
9
+ * score ≥ 0.30 → 主题相关,交上层判定互补还是矛盾(conflict-candidate)
10
+ * 宁可多判(代价低),不可漏判(漏判 = 冲突静默入库)。
11
+ *
12
+ * 刻意**不接嵌入做去重**:一旦引语义模型,就从"0-token 纯 CPU"变成"装运行时 + 逐条
13
+ * 嵌入"的持续开销(对齐计划轨3 性能红线)。
14
+ */
15
+ import { type MemRecord } from './format.js';
16
+ /** 相似度阈值(与 memport.py DEFAULT_* 一致)。 */
17
+ export declare const DEFAULT_DUP_T = 0.62;
18
+ export declare const DEFAULT_REL_T = 0.3;
19
+ /** n-gram 集合(按码点滑窗,unicode 安全;len≤n 时整体作单 gram)。镜像 gram_set。 */
20
+ export declare function gramSet(s: string, n?: number): Set<string>;
21
+ export declare function jaccard(a: Set<string>, b: Set<string>): number;
22
+ /** Dice 系数:对长度差异比 Jaccard 宽容,中文改写场景召回更好。 */
23
+ export declare function dice(a: Set<string>, b: Set<string>): number;
24
+ /** 一对(已预计算 gram)的相似度:max(3g Jaccard, 2g Dice),round 4。镜像 score_pair。 */
25
+ export declare function scorePair(g3: Set<string>, g2: Set<string>, h3: Set<string>, h2: Set<string>): number;
26
+ /** 已有记忆的最小形状(来自 snapshot:content + hash + id)。 */
27
+ export interface ExistingLike {
28
+ id: string;
29
+ content: string;
30
+ hash: string;
31
+ }
32
+ export type Verdict = 'new' | 'dup' | 'near-dup' | 'conflict-candidate';
33
+ export interface MatchedRef {
34
+ id: string;
35
+ score: number;
36
+ hash: string;
37
+ content: string;
38
+ }
39
+ export interface PlanRow {
40
+ seq: number;
41
+ id: string;
42
+ hash: string;
43
+ typeFull: string;
44
+ content: string;
45
+ verdict: Verdict;
46
+ matched: MatchedRef[];
47
+ reason: string;
48
+ }
49
+ export interface PlanResult {
50
+ rows: PlanRow[];
51
+ counts: Record<Verdict, number>;
52
+ }
53
+ /**
54
+ * 四桶预筛:对每条待导入记录,先按内容哈希查 dup,再对基线取 top-3 相似按阈值分档;
55
+ * 基线未命中时兜底比对本包内前序条目(导出侧应已合并,这里拦包内近重复)。
56
+ * 镜像 cmd_plan 的分类段(不含 temporal_diff——那属上层裁决增强,非本层职责)。
57
+ */
58
+ export declare function plan(records: MemRecord[], existing: ExistingLike[], opts?: {
59
+ dupThreshold?: number;
60
+ relThreshold?: number;
61
+ srcTag?: string;
62
+ }): PlanResult;
@@ -0,0 +1,132 @@
1
+ /**
2
+ * 外部导入预筛(轨3 宿主侧纯 TS):把 memport.py 的确定性相似度判据下沉到宿主。
3
+ *
4
+ * 0 LLM / 0 token / 0 网络——纯本地 CPU。判据与阈值经真实语料标定,与
5
+ * `skills/memport/scripts/memport.py` 一字对齐(金标准测试见
6
+ * `tests/memport-ts-consistency.test.ts`):score = max(3-gram Jaccard, 2-gram Dice),
7
+ * 真改写对 0.49~0.51 / 主题相关但不同 0.16~0.28,故
8
+ * score ≥ 0.62 → 同一事实的改写(near-dup)
9
+ * score ≥ 0.30 → 主题相关,交上层判定互补还是矛盾(conflict-candidate)
10
+ * 宁可多判(代价低),不可漏判(漏判 = 冲突静默入库)。
11
+ *
12
+ * 刻意**不接嵌入做去重**:一旦引语义模型,就从"0-token 纯 CPU"变成"装运行时 + 逐条
13
+ * 嵌入"的持续开销(对齐计划轨3 性能红线)。
14
+ */
15
+ import { normText, recordId } from './format.js';
16
+ /** 相似度阈值(与 memport.py DEFAULT_* 一致)。 */
17
+ export const DEFAULT_DUP_T = 0.62;
18
+ export const DEFAULT_REL_T = 0.30;
19
+ /** n-gram 集合(按码点滑窗,unicode 安全;len≤n 时整体作单 gram)。镜像 gram_set。 */
20
+ export function gramSet(s, n = 3) {
21
+ const t = Array.from(normText(s).toLowerCase());
22
+ if (t.length <= n)
23
+ return t.length ? [t.join('')].reduce((acc, x) => acc.add(x), new Set()) : new Set();
24
+ const out = new Set();
25
+ for (let i = 0; i + n <= t.length; i++)
26
+ out.add(t.slice(i, i + n).join(''));
27
+ return out;
28
+ }
29
+ export function jaccard(a, b) {
30
+ if (a.size === 0 || b.size === 0)
31
+ return 0;
32
+ let inter = 0;
33
+ for (const x of a)
34
+ if (b.has(x))
35
+ inter++;
36
+ const union = a.size + b.size - inter;
37
+ return union === 0 ? 0 : inter / union;
38
+ }
39
+ /** Dice 系数:对长度差异比 Jaccard 宽容,中文改写场景召回更好。 */
40
+ export function dice(a, b) {
41
+ if (a.size === 0 || b.size === 0)
42
+ return 0;
43
+ let inter = 0;
44
+ for (const x of a)
45
+ if (b.has(x))
46
+ inter++;
47
+ return (2 * inter) / (a.size + b.size);
48
+ }
49
+ /** 一对(已预计算 gram)的相似度:max(3g Jaccard, 2g Dice),round 4。镜像 score_pair。 */
50
+ export function scorePair(g3, g2, h3, h2) {
51
+ return Math.round(Math.max(jaccard(g3, h3), dice(g2, h2)) * 1e4) / 1e4;
52
+ }
53
+ /**
54
+ * 四桶预筛:对每条待导入记录,先按内容哈希查 dup,再对基线取 top-3 相似按阈值分档;
55
+ * 基线未命中时兜底比对本包内前序条目(导出侧应已合并,这里拦包内近重复)。
56
+ * 镜像 cmd_plan 的分类段(不含 temporal_diff——那属上层裁决增强,非本层职责)。
57
+ */
58
+ export function plan(records, existing, opts = {}) {
59
+ const dupT = opts.dupThreshold ?? DEFAULT_DUP_T;
60
+ const relT = opts.relThreshold ?? DEFAULT_REL_T;
61
+ const srcTag = opts.srcTag ?? '';
62
+ const byHash = new Map();
63
+ for (const r of existing)
64
+ if (!byHash.has(r.hash))
65
+ byHash.set(r.hash, r);
66
+ const exGrams = existing.map((r) => ({ r, g3: gramSet(r.content, 3), g2: gramSet(r.content, 2) }));
67
+ const rows = [];
68
+ const counts = { new: 0, dup: 0, 'near-dup': 0, 'conflict-candidate': 0 };
69
+ const bundleGrams = [];
70
+ for (const rec of records) {
71
+ const rid = recordId(srcTag, rec.hash);
72
+ const hit = byHash.get(rec.hash);
73
+ let verdict = 'new';
74
+ let matched = [];
75
+ let reason = '基线中无相关内容';
76
+ if (hit) {
77
+ verdict = 'dup';
78
+ matched = [{ id: hit.id, score: 1, hash: hit.hash, content: hit.content }];
79
+ reason = `内容哈希与现存 ${hit.id} 完全一致`;
80
+ }
81
+ else {
82
+ const c3 = gramSet(rec.content, 3);
83
+ const c2 = gramSet(rec.content, 2);
84
+ const scored = exGrams
85
+ .map(({ r, g3, g2 }) => ({ id: r.id, score: scorePair(c3, c2, g3, g2), hash: r.hash, content: r.content }))
86
+ .sort((a, b) => b.score - a.score)
87
+ .slice(0, 3);
88
+ const best = scored[0];
89
+ if (best && best.score >= dupT) {
90
+ verdict = 'near-dup';
91
+ matched = scored.filter((s) => s.score >= relT);
92
+ reason = `与现存 ${best.id} 相似度 ${best.score.toFixed(2)},疑似同一事实的另一种表述`;
93
+ }
94
+ else if (best && best.score >= relT) {
95
+ verdict = 'conflict-candidate';
96
+ matched = scored.filter((s) => s.score >= relT);
97
+ reason = `与现存 ${best.id} 主题相关(相似度 ${best.score.toFixed(2)}),需判定互补还是矛盾`;
98
+ }
99
+ // 包内近重复兜底
100
+ if (verdict === 'new' && bundleGrams.length) {
101
+ let ibest = null;
102
+ let iscore = 0;
103
+ for (const prev of bundleGrams) {
104
+ const sc = scorePair(c3, c2, prev.g3, prev.g2);
105
+ if (sc > iscore) {
106
+ iscore = sc;
107
+ ibest = prev.rec;
108
+ }
109
+ }
110
+ if (ibest && iscore >= relT) {
111
+ const near = iscore >= dupT;
112
+ verdict = near ? 'near-dup' : 'conflict-candidate';
113
+ matched = [{ id: `bundle#${ibest.seq}`, score: iscore, hash: ibest.hash, content: ibest.content }];
114
+ reason = `与本包第 ${ibest.seq} 条相似度 ${iscore.toFixed(2)}` + (near ? '(包内近重复,导出侧应先合并)' : '(包内相关条目,需判定)');
115
+ }
116
+ }
117
+ bundleGrams.push({ rec, g3: gramSet(rec.content, 3), g2: gramSet(rec.content, 2) });
118
+ }
119
+ counts[verdict]++;
120
+ rows.push({
121
+ seq: rec.seq,
122
+ id: rid,
123
+ hash: rec.hash,
124
+ typeFull: rec.typeFull,
125
+ content: rec.content,
126
+ verdict,
127
+ matched,
128
+ reason,
129
+ });
130
+ }
131
+ return { rows, counts };
132
+ }
@@ -0,0 +1,13 @@
1
+ import type { MemorySourceEntry } from '../store/memory-sources.js';
2
+ import type { MemoryRecord as L1Record } from '../types.js';
3
+ import type { MemportScanSourceResult } from '../contract.js';
4
+ /** l1 的最小读取面(解耦具体 store,便于测试注入)。 */
5
+ export interface L1Reader {
6
+ all(): L1Record[];
7
+ }
8
+ /** 扫描一批来源(只读)。返回每个来源的四桶预览(含失败原因)。 */
9
+ export declare function scanSources(opts: {
10
+ sources: MemorySourceEntry[];
11
+ l1: L1Reader;
12
+ maxRecords: number;
13
+ }): Promise<MemportScanSourceResult[]>;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * 外部来源只读扫描(轨3 Connect,3.4)。
3
+ *
4
+ * **只读预览,绝不写库**:遍历已登记来源 → 找 `.mem` 包 → `parseBundle` → `prefilter.plan`
5
+ * 与现存基线比对 → 产出四桶计数 + 新记录样本。真正的落库由上层(agent 调 `memory_import`,
6
+ * 携 source_id 走 P0 的 ext_key 幂等 + 脱敏)完成——扫描本身不碰写入面,
7
+ * 因此不重复 memory_import 的 `__manual__`/supersede/redact 门禁(那些在写侧强制)。
8
+ *
9
+ * 卫生边界:
10
+ * - 单源/单包读失败**只记 error 不抛**,不阻断其余来源(对齐 sidecar 降级铁律);
11
+ * - `.mem` 才是 interchange 载体;非 .mem 路径记一条提示,引导先跑 `memport 导出`;
12
+ * - 记录数受 `maxRecords` 上限(防极端大包在 RPC 事件循环里阻塞);
13
+ * - 预览正文**截断**,且不外传整文件内容——完整脱敏留给写侧 memory_import。
14
+ */
15
+ import { readdir, readFile, stat } from 'node:fs/promises';
16
+ import { isAbsolute, join } from 'node:path';
17
+ import { contentHash, parseBundle } from './format.js';
18
+ import { plan } from './prefilter.js';
19
+ const MEM_EXT = '.mem';
20
+ const MAX_FILES_PER_SOURCE = 50;
21
+ const SAMPLE_CAP = 8;
22
+ const SAMPLE_CHARS = 90;
23
+ async function collectMemFiles(root) {
24
+ const st = await stat(root);
25
+ if (st.isFile())
26
+ return root.toLowerCase().endsWith(MEM_EXT) ? [root] : [];
27
+ if (!st.isDirectory())
28
+ return [];
29
+ const names = await readdir(root);
30
+ return names.filter((n) => n.toLowerCase().endsWith(MEM_EXT)).slice(0, MAX_FILES_PER_SOURCE).map((n) => join(root, n));
31
+ }
32
+ /** 扫描一批来源(只读)。返回每个来源的四桶预览(含失败原因)。 */
33
+ export async function scanSources(opts) {
34
+ const { sources, l1, maxRecords } = opts;
35
+ // 现存基线(id + content + 规范化哈希)——供 plan 做 dup/near-dup 比对。
36
+ const existing = l1.all().map((r) => ({ id: r.id, content: r.content, hash: contentHash(r.content ?? '') }));
37
+ const out = [];
38
+ for (const src of sources) {
39
+ const result = {
40
+ sourceId: src.id,
41
+ label: src.label,
42
+ files: 0,
43
+ total: 0,
44
+ errors: [],
45
+ counts: { dup: 0, 'near-dup': 0, 'conflict-candidate': 0, new: 0 },
46
+ sampleNew: [],
47
+ };
48
+ out.push(result);
49
+ if (!isAbsolute(src.path)) {
50
+ result.errors.push(`来源 path 非绝对路径,已跳过:${src.path}`);
51
+ continue;
52
+ }
53
+ let files;
54
+ try {
55
+ files = await collectMemFiles(src.path);
56
+ }
57
+ catch (err) {
58
+ result.errors.push(`读取失败:${src.path}(${err instanceof Error ? err.message : String(err)})`);
59
+ continue;
60
+ }
61
+ if (files.length === 0) {
62
+ result.errors.push(`${src.path} 下未发现 .mem 包(先用「memport 导出」在该工具侧生成,或把 .mem 落到此路径)`);
63
+ continue;
64
+ }
65
+ const acc = [];
66
+ for (const f of files) {
67
+ if (acc.length >= maxRecords)
68
+ break;
69
+ let text;
70
+ try {
71
+ text = await readFile(f, 'utf8');
72
+ }
73
+ catch (err) {
74
+ result.errors.push(`${f}: 读取失败(${err instanceof Error ? err.message : String(err)})`);
75
+ continue;
76
+ }
77
+ const b = parseBundle(text);
78
+ if (b.errors.length) {
79
+ result.errors.push(`${f}: 校验失败——${b.errors[0]}${b.errors.length > 1 ? `(共 ${b.errors.length} 项)` : ''}`);
80
+ continue;
81
+ }
82
+ result.files++;
83
+ for (const rec of b.records) {
84
+ if (acc.length >= maxRecords)
85
+ break;
86
+ acc.push(rec);
87
+ }
88
+ }
89
+ result.total = acc.length;
90
+ if (acc.length === 0)
91
+ continue;
92
+ const { rows, counts } = plan(acc, existing, { srcTag: src.id });
93
+ result.counts = counts;
94
+ result.sampleNew = rows
95
+ .filter((r) => r.verdict === 'new')
96
+ .slice(0, SAMPLE_CAP)
97
+ .map((r) => ({ type: r.typeFull, content: r.content.length > SAMPLE_CHARS ? r.content.slice(0, SAMPLE_CHARS) + '…' : r.content }));
98
+ }
99
+ return out;
100
+ }
@@ -1,3 +1,4 @@
1
+ import { type CognitiveHall } from './cognitive-hall.js';
1
2
  /**
2
3
  * Wing 严格校验:只认 8 域 + general 兜底。
3
4
  *
@@ -23,3 +24,24 @@ export declare function isTag(v: unknown): boolean;
23
24
  */
24
25
  export declare function normTags(v: unknown, max?: number): string[];
25
26
  export { isCognitiveHall, COGNITIVE_HALLS, type CognitiveHall } from './cognitive-hall.js';
27
+ /**
28
+ * usageScenario 归一 + 去重 + 上限。**所有写回点必须过这里**。
29
+ *
30
+ * 与 normTags 的区别:usageScenario 是自然语言短语(如“修改 profile 配置时”),
31
+ * 不是 slug,所以不做字符集校验,只做 trim/去重/长度钳制/上限。
32
+ * 非数组或空→ `[]`(缺字段中性,不抛——防单条抽取失败波及整切片重试)。
33
+ */
34
+ export declare function normUsageScenario(v: unknown, max?: number): string[];
35
+ /**
36
+ * 读 **Wing(生活域)**。⚠️ 磁盘键是 `metadata.hall`——历史别名,存的其实是 **wing 值**。
37
+ * 只认词表内的合法 wing(8 角 + general),否则 undefined。
38
+ *
39
+ * 用它取代裸读 `metadata.hall`:从入口上把“wing 轴”与“认知 hall 轴”分开,
40
+ * 防止把认知 hall 值(facts/events/…)误当 wing 读——两轴不串。
41
+ */
42
+ export declare function readWing(metadata: Record<string, unknown> | undefined | null): string | undefined;
43
+ /**
44
+ * 读 **认知 hall(大类)**。磁盘键 `metadata.cogHall`,与 wing 的磁盘键 `hall` 正交。
45
+ * 只认五个枚举值,否则 undefined(wing 值不当认知 hall 读)。
46
+ */
47
+ export declare function readCognitiveHall(metadata: Record<string, unknown> | undefined | null): CognitiveHall | undefined;