dsh-prime-memory 0.18.2 → 0.18.4

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 (46) hide show
  1. package/CHANGELOG.en.md +14 -0
  2. package/CHANGELOG.ja.md +14 -0
  3. package/CHANGELOG.ko.md +14 -0
  4. package/CHANGELOG.md +14 -0
  5. package/README.en.md +4 -0
  6. package/README.ja.md +4 -0
  7. package/README.ko.md +4 -0
  8. package/README.md +4 -0
  9. package/cordis.patch.yml +6 -0
  10. package/dist/client.js +36 -9
  11. package/dist/config.d.ts +37 -0
  12. package/dist/config.js +7 -0
  13. package/dist/contract.d.ts +46 -0
  14. package/dist/hooks/capture-recovery.d.ts +59 -0
  15. package/dist/hooks/capture-recovery.js +88 -0
  16. package/dist/hooks/capture.js +110 -1
  17. package/dist/hooks/recall-ack.d.ts +48 -0
  18. package/dist/hooks/recall-ack.js +79 -0
  19. package/dist/hooks/recall.d.ts +19 -0
  20. package/dist/hooks/recall.js +114 -5
  21. package/dist/index.d.ts +22 -0
  22. package/dist/index.js +4 -0
  23. package/dist/pipeline/l1.d.ts +4 -0
  24. package/dist/pipeline/l1.js +9 -2
  25. package/dist/pipeline/reconcile.js +4 -0
  26. package/dist/pipeline/runner.js +62 -0
  27. package/dist/prompts/boundary.d.ts +17 -0
  28. package/dist/prompts/boundary.js +20 -0
  29. package/dist/prompts/graph-projection.js +4 -1
  30. package/dist/prompts/l1-dedup.d.ts +3 -3
  31. package/dist/prompts/l1-dedup.js +9 -1
  32. package/dist/prompts/l1-extraction.d.ts +3 -3
  33. package/dist/prompts/l1-extraction.js +12 -0
  34. package/dist/prompts/persona.js +6 -0
  35. package/dist/prompts/scene.js +7 -1
  36. package/dist/stats.js +9 -0
  37. package/dist/store/l0.d.ts +4 -0
  38. package/dist/store/l0.js +8 -0
  39. package/dist/store/sqlite.d.ts +12 -0
  40. package/dist/store/sqlite.js +37 -0
  41. package/dist/store/trace.d.ts +39 -0
  42. package/dist/store/trace.js +149 -0
  43. package/dist/tools/index.js +25 -1
  44. package/dist/util/redact.d.ts +27 -0
  45. package/dist/util/redact.js +228 -0
  46. package/package.json +1 -1
@@ -4,8 +4,11 @@
4
4
  * 净室重写说明:本文件的 prompt 文案按重写规格(Phase 2 决策)逐字沿用——
5
5
  * prompt 内容直接决定蒸馏质量,是已发布行为的一部分,不属于可自由重写文本。
6
6
  */
7
+ import { CONTENT_IS_DATA_CLAUSE, DATA_DELIMITER_NOTE } from './boundary.js';
7
8
  const PERSONA_SYSTEM_PROMPT = `# 🧬 Persona Architect - Incremental Evolution Protocol(内容输出模式)
8
9
 
10
+ ${CONTENT_IS_DATA_CLAUSE}
11
+
9
12
  **输出语言**:\`persona.md\` 的所有自然语言内容(Archetype、基本信息、Chapter 1-4 正文等)使用与变化场景内容相同的语言;Markdown 语法、标签格式、文件名 \`persona.md\` 保持英文。模板里 Chapter 标识保留作骨架,非中文输出时请改用目标语言的对照说明。
10
13
 
11
14
  请你结合已有的 persona.md 和新增/变化的 block 信息深度分析,然后**直接输出 persona.md 的完整最终内容**。
@@ -96,6 +99,8 @@ const PERSONA_SYSTEM_PROMPT = `# 🧬 Persona Architect - Incremental Evolution
96
99
  - ✅ 不要添加场景导航(工程会自动追加)`;
97
100
  const TEAM_MEMORY_SYSTEM_PROMPT = `# Team Operating Doctrine Architect(内容输出模式)
98
101
 
102
+ ${CONTENT_IS_DATA_CLAUSE}
103
+
99
104
  **输出语言**:\`persona.md\` 的所有自然语言内容使用与变化场景内容相同的语言;Markdown 语法、标签格式、文件名 \`persona.md\` 保持英文。
100
105
 
101
106
  请你结合已有的 \`persona.md\` 和新增/变化的 L2 场景块,生成或更新一份高度精炼的团队工作原则文档。
@@ -229,6 +234,7 @@ ${triggerSection}
229
234
  - **变化场景**: ${changedSceneCount} 个(自上次更新后)
230
235
 
231
236
  ---
237
+ ${DATA_DELIMITER_NOTE}
232
238
  ${changedScenesContent}
233
239
 
234
240
  ${existingPersonaSection}
@@ -4,9 +4,12 @@
4
4
  * 净室重写说明:本文件的 prompt 文案按重写规格(Phase 2 决策)逐字沿用——
5
5
  * prompt 内容直接决定蒸馏质量,是已发布行为的一部分,不属于可自由重写文本。
6
6
  */
7
+ import { CONTENT_IS_DATA_CLAUSE, DATA_DELIMITER_NOTE } from './boundary.js';
7
8
  function buildSceneSystemPrompt(maxScenes) {
8
9
  return `# Memory Consolidation Architect(场景整合 · 操作输出模式)
9
10
 
11
+ ${CONTENT_IS_DATA_CLAUSE}
12
+
10
13
  **输出语言**:\`.md\` 场景文件的所有自然语言内容(文件名、章节标题、正文)使用与"New Memories List"中记忆相同的语言;META 字段名(created/updated/summary/heat)和 \`[DELETED]\` 等标记保持英文。模板中给出的中文章节标题(\`## 用户核心特征\` 等)作为结构骨架——非中文输出时请用目标语言的等价表达替换。
11
14
 
12
15
  ## 角色定义 (Role Definition)
@@ -168,6 +171,8 @@ reason: 具体原因描述
168
171
  function buildWorkSceneSystemPrompt(maxScenes) {
169
172
  return `# Team Work Method Memory Consolidation Architect(场景整合 · 操作输出模式)
170
173
 
174
+ ${CONTENT_IS_DATA_CLAUSE}
175
+
171
176
  **输出语言**:\`.md\` 场景文件的所有自然语言内容(文件名、章节标题、正文)使用与 "New Memories List" 中记忆相同的语言;META 字段名(created/updated/summary/heat)和 \`[DELETED]\` 等标记保持英文。模板中的中文章节标题仅作为结构骨架,非中文输出时请用目标语言的等价表达替换。
172
177
 
173
178
  ## 角色定义 (Role Definition)
@@ -379,7 +384,7 @@ function getSceneSystemPrompt(maxScenes, family) {
379
384
  export function buildScenePrompt(params) {
380
385
  const { memoriesJson, sceneSummaries, sceneContents, currentTimestamp, existingSceneFiles, maxScenes, family, } = params;
381
386
  const contentsSection = sceneContents
382
- ? `### 2.5 相关场景完整内容(仅供参考,write 时必须整体重写,不要只改局部)\n${sceneContents}\n`
387
+ ? `### 2.5 相关场景完整内容(仅供参考,write 时必须整体重写,不要只改局部)\n${DATA_DELIMITER_NOTE}\n${sceneContents}\n`
383
388
  : '';
384
389
  const fileListSection = existingSceneFiles.length > 0
385
390
  ? `### 📁 已有场景文件清单(仅以下文件可 write/delete)\n${existingSceneFiles.map((f) => `- \`${f}\``).join('\n')}\n`
@@ -387,6 +392,7 @@ export function buildScenePrompt(params) {
387
392
  const userPrompt = `**输出语言**:场景文件内容使用下方 New Memories List 中记忆的主导语言。
388
393
 
389
394
  ### 1️⃣ New Memories List
395
+ ${DATA_DELIMITER_NOTE}
390
396
  ${memoriesJson}
391
397
 
392
398
  ### 2️⃣ Existing Scene Blocks Summary
package/dist/stats.js CHANGED
@@ -20,6 +20,7 @@ import { emptyRecallStats } from './hooks/recall.js';
20
20
  import { buildRouteChain, decideSendableEffort, LAYER_DEFAULT_BUDGETS, layerChainOrNull, resolveModelContextWindow, resolveModelEfforts, resolveModelRoute } from './llm.js';
21
21
  import { projectDistillChain, validateDistillChain } from './settings.js';
22
22
  import { RECEIPTS_QUERY_LIMIT_MAX, dimensionOf, toReceiptView } from './store/receipts.js';
23
+ import { getTraceStore } from './store/trace.js';
23
24
  import { resolveConflictPair, listConflictPairs } from './conflict-service.js';
24
25
  import { sourceAnchorLabels } from './pipeline/anchors.js';
25
26
  import { readSupersedeMarker } from './store/supersede.js';
@@ -66,6 +67,7 @@ export const MEMORY_ENDPOINTS = [
66
67
  'dsh-memory/scenes',
67
68
  'dsh-memory/persona',
68
69
  'dsh-memory/log-tail',
70
+ 'dsh-memory/trace-tail',
69
71
  'dsh-memory/rebuild-status',
70
72
  'dsh-memory/rebuild-start',
71
73
  'dsh-memory/rebuild-cancel',
@@ -1152,6 +1154,13 @@ export async function handleEndpoint(endpoint, payload, deps) {
1152
1154
  const p = (payload ?? {});
1153
1155
  return { lines: readLogTail(join(dataDir, 'memory.log'), Math.min(Math.max(Number(p.lines) || 200, 1), 1000)) };
1154
1156
  }
1157
+ case 'dsh-memory/trace-tail': {
1158
+ // §F 追踪尾读:未初始化(trace.enabled=false)回空集;坏行已在 store 侧跳过
1159
+ const p = (payload ?? {});
1160
+ const store = getTraceStore();
1161
+ const events = store ? await store.tail(Math.min(Math.max(Number(p.lines) || 100, 1), 500), p.kind) : [];
1162
+ return { events };
1163
+ }
1155
1164
  case 'dsh-memory/rebuild-status': {
1156
1165
  if (!rebuild) {
1157
1166
  const v = { supported: false, running: false, phase: 'idle' };
@@ -19,6 +19,10 @@ export declare class L0Store {
19
19
  countBySession(sessionId: string): Promise<number>;
20
20
  /** 该会话最近 n 条消息(时间升序;蒸馏背景参考用,按会话现查——ADR-0003)。 */
21
21
  recentBySession(sessionId: string, limit: number): Promise<ConversationMessage[]>;
22
+ /** §A 水位线:该会话 L0 已落盘的最大 turn(空会话 undefined;委托 MemoryDb)。 */
23
+ maxCapturedTurn(sessionId: string): number | undefined;
24
+ /** §A 幂等检查:该 (session, turn) 是否已有任意 L0 行(委托 MemoryDb)。 */
25
+ hasAnyL0Message(sessionId: string, turn: number): boolean;
22
26
  /** 检索:FTS + 向量 hybrid(RRF 融合),返回按相关性排序的消息。 */
23
27
  search(query: string, limit: number): Promise<L0MessageRecord[]>;
24
28
  /** 活切换嵌入源:同步换底层服务(嵌入源三态切换用)。 */
package/dist/store/l0.js CHANGED
@@ -128,6 +128,14 @@ export class L0Store {
128
128
  async recentBySession(sessionId, limit) {
129
129
  return this.db.recentL0BySession(sessionId, limit);
130
130
  }
131
+ /** §A 水位线:该会话 L0 已落盘的最大 turn(空会话 undefined;委托 MemoryDb)。 */
132
+ maxCapturedTurn(sessionId) {
133
+ return this.db.maxCapturedTurn(sessionId);
134
+ }
135
+ /** §A 幂等检查:该 (session, turn) 是否已有任意 L0 行(委托 MemoryDb)。 */
136
+ hasAnyL0Message(sessionId, turn) {
137
+ return this.db.hasAnyL0Message(sessionId, turn);
138
+ }
131
139
  /** 检索:FTS + 向量 hybrid(RRF 融合),返回按相关性排序的消息。 */
132
140
  async search(query, limit) {
133
141
  const caps = this.db.getCapabilities();
@@ -460,6 +460,18 @@ export declare class MemoryDb {
460
460
  countL0Since(iso: string): number;
461
461
  /** 统计某会话已捕获消息数(session-stats 数据源;idx_l0_session_id 索引点查)。 */
462
462
  countL0BySession(sessionId: string): number;
463
+ /**
464
+ * §A 崩溃恢复水位线(memorax-absorb):该会话 L0 已落盘的最大 turn。
465
+ * L0 为追加式事实源、从不裁剪 → 水位线崩溃安全;turn 空洞是常态
466
+ * (off 档/无消息轮),MAX 语义不受影响。空会话返回 undefined。
467
+ */
468
+ maxCapturedTurn(sessionId: string): number | undefined;
469
+ /**
470
+ * §A 幂等检查:该 (session, turn) 是否已有任意 L0 行。
471
+ * 水位线 MAX(turn) 只判"整轮无"、判不了"部分有"——恢复窗口内逐 turn
472
+ * 做存在性检查,命中即整轮跳过(重放 record id 非确定,upsert 不能去重)。
473
+ */
474
+ hasAnyL0Message(sessionId: string, turn: number): boolean;
463
475
  /** 按会话取最近消息(时间升序返回;走 idx_l0_session_id 索引)。
464
476
  * 蒸馏背景参考专用——按会话现查替代全局内存数组(ADR-0003)。 */
465
477
  recentL0BySession(sessionId: string, limit: number): L0MessageRecord[];
@@ -1797,6 +1797,43 @@ export class MemoryDb {
1797
1797
  return 0;
1798
1798
  }
1799
1799
  }
1800
+ /**
1801
+ * §A 崩溃恢复水位线(memorax-absorb):该会话 L0 已落盘的最大 turn。
1802
+ * L0 为追加式事实源、从不裁剪 → 水位线崩溃安全;turn 空洞是常态
1803
+ * (off 档/无消息轮),MAX 语义不受影响。空会话返回 undefined。
1804
+ */
1805
+ maxCapturedTurn(sessionId) {
1806
+ if (this.degraded)
1807
+ return undefined;
1808
+ try {
1809
+ const row = this.db
1810
+ .prepare('SELECT MAX(turn) AS max_turn FROM l0_conversations WHERE session_id = ?')
1811
+ .get(sessionId);
1812
+ return typeof row?.max_turn === 'number' ? row.max_turn : undefined;
1813
+ }
1814
+ catch (err) {
1815
+ this.logger?.warn(`[memory] L0 水位线查询失败(按空处理): ${err instanceof Error ? err.message : String(err)}`);
1816
+ return undefined;
1817
+ }
1818
+ }
1819
+ /**
1820
+ * §A 幂等检查:该 (session, turn) 是否已有任意 L0 行。
1821
+ * 水位线 MAX(turn) 只判"整轮无"、判不了"部分有"——恢复窗口内逐 turn
1822
+ * 做存在性检查,命中即整轮跳过(重放 record id 非确定,upsert 不能去重)。
1823
+ */
1824
+ hasAnyL0Message(sessionId, turn) {
1825
+ if (this.degraded || !Number.isFinite(turn))
1826
+ return false;
1827
+ try {
1828
+ const row = this.db
1829
+ .prepare('SELECT 1 FROM l0_conversations WHERE session_id = ? AND turn = ? LIMIT 1')
1830
+ .get(sessionId, turn);
1831
+ return row !== undefined;
1832
+ }
1833
+ catch {
1834
+ return false;
1835
+ }
1836
+ }
1800
1837
  /** 按会话取最近消息(时间升序返回;走 idx_l0_session_id 索引)。
1801
1838
  * 蒸馏背景参考专用——按会话现查替代全局内存数组(ADR-0003)。 */
1802
1839
  recentL0BySession(sessionId, limit) {
@@ -0,0 +1,39 @@
1
+ import type { TraceEvent } from '../contract.js';
2
+ export interface TraceStoreOptions {
3
+ /** 保留天数;0 = 永久保留。 */
4
+ retentionDays: number;
5
+ /** 单文件字节上限(超过停写 + marker)。 */
6
+ maxFileBytes?: number;
7
+ }
8
+ export declare class TraceStore {
9
+ private readonly dataDir;
10
+ private readonly opts;
11
+ /** 已停写的日期(5MB 上限触发;当日内后续事件丢弃,次日自动恢复)。 */
12
+ private readonly truncatedDays;
13
+ private lastPruneDay;
14
+ private chain;
15
+ constructor(dataDir: string, opts: TraceStoreOptions);
16
+ /** 追加事件(异步串行链,失败静默)。 */
17
+ append(event: TraceEvent): void;
18
+ /** 等待挂起的写入排空(dispose 序用)。 */
19
+ flush(): Promise<void>;
20
+ /**
21
+ * 尾部读取:从最近的日期文件倒序收集,按 kind 过滤,返回至多 `lines` 条
22
+ * (按写入顺序 = 时间升序)。坏行跳过(readJsonl 同款)。
23
+ */
24
+ tail(lines: number, kind?: TraceEvent['kind']): Promise<TraceEvent[]>;
25
+ private dir;
26
+ private fileFor;
27
+ private recentFiles;
28
+ private writeOne;
29
+ /** 删除超过保留期的旧日文件(retentionDays 0 = 永久)。 */
30
+ private pruneOldDays;
31
+ }
32
+ /** 插件启动时注入(index.ts 调用);enabled=false 或 0 天保留不影响初始化本身。 */
33
+ export declare function initTraceStore(dataDir: string, opts: TraceStoreOptions): void;
34
+ /** 测试/卸载:清空单例。 */
35
+ export declare function resetTraceStore(): void;
36
+ /** 埋点入口:未初始化 = no-op。fire-and-forget,永不抛。 */
37
+ export declare function trace(event: TraceEvent): void;
38
+ /** RPC tail 数据源;未初始化返回 undefined(端点回空集)。 */
39
+ export declare function getTraceStore(): TraceStore | null;
@@ -0,0 +1,149 @@
1
+ /**
2
+ * §F 结构化追踪(memorax-absorb Wave 4 / task_28)——recall/distill 事件流。
3
+ *
4
+ * 模块级单例(token-cost.ts 同款):trace() 埋点拿不到 store 实例(store 在
5
+ * index.ts 运行时才有 dataDir),initTraceStore 启动时注入;未初始化或
6
+ * trace.enabled=false 时 trace() 为零副作用 no-op。
7
+ *
8
+ * 存储:按天 JSONL(`dataDir/trace/trace-YYYY-MM-DD.jsonl`,appendJsonl 原语),
9
+ * 文件名即日期 → 保留期清理在切日时触发(删除超 retentionDays 的旧文件);
10
+ * 单文件 >5MB 停写 + 写入一行 trace_truncated marker(防无限增长);
11
+ * 写失败静默(filelog 同款纪律——追踪绝不影响主链路)。
12
+ *
13
+ * 隐私默认 metadata-only:recall 事件只带 queryChars+querySha,
14
+ * captureContent=true 才存 queryText(截断 200)——由埋点方决定字段,本模块不感知。
15
+ */
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { appendJsonl, dayKey, readJsonl } from '../util/io.js';
19
+ const TRACE_DIR = 'trace';
20
+ const DEFAULT_MAX_FILE_BYTES = 5 * 1024 * 1024;
21
+ export class TraceStore {
22
+ dataDir;
23
+ opts;
24
+ /** 已停写的日期(5MB 上限触发;当日内后续事件丢弃,次日自动恢复)。 */
25
+ truncatedDays = new Set();
26
+ lastPruneDay = '';
27
+ chain = Promise.resolve();
28
+ constructor(dataDir, opts) {
29
+ this.dataDir = dataDir;
30
+ this.opts = opts;
31
+ }
32
+ /** 追加事件(异步串行链,失败静默)。 */
33
+ append(event) {
34
+ const day = dayKey(event.ts);
35
+ if (this.truncatedDays.has(day))
36
+ return;
37
+ this.chain = this.chain
38
+ .then(() => this.writeOne(day, event))
39
+ .catch(() => {
40
+ /* 静默:追踪失败不影响主链路 */
41
+ });
42
+ }
43
+ /** 等待挂起的写入排空(dispose 序用)。 */
44
+ flush() {
45
+ return this.chain;
46
+ }
47
+ /**
48
+ * 尾部读取:从最近的日期文件倒序收集,按 kind 过滤,返回至多 `lines` 条
49
+ * (按写入顺序 = 时间升序)。坏行跳过(readJsonl 同款)。
50
+ */
51
+ async tail(lines, kind) {
52
+ const limit = Math.max(1, Math.min(500, Math.round(lines) || 100));
53
+ const files = this.recentFiles();
54
+ const collected = [];
55
+ for (let i = files.length - 1; i >= 0 && collected.length < limit; i--) {
56
+ const rows = await readJsonl(files[i]);
57
+ for (let j = rows.length - 1; j >= 0 && collected.length < limit; j--) {
58
+ const row = rows[j];
59
+ if (!row || typeof row !== 'object' || !row.kind)
60
+ continue;
61
+ if (kind && row.kind !== kind)
62
+ continue;
63
+ collected.push(row);
64
+ }
65
+ }
66
+ return collected.reverse();
67
+ }
68
+ dir() {
69
+ return path.join(this.dataDir, TRACE_DIR);
70
+ }
71
+ fileFor(day) {
72
+ return path.join(this.dir(), `trace-${day}.jsonl`);
73
+ }
74
+ recentFiles() {
75
+ try {
76
+ return fs
77
+ .readdirSync(this.dir())
78
+ .filter((f) => f.startsWith('trace-') && f.endsWith('.jsonl'))
79
+ .sort()
80
+ .map((f) => path.join(this.dir(), f));
81
+ }
82
+ catch {
83
+ return [];
84
+ }
85
+ }
86
+ async writeOne(day, event) {
87
+ if (day !== this.lastPruneDay) {
88
+ this.lastPruneDay = day;
89
+ this.pruneOldDays(day);
90
+ }
91
+ const file = this.fileFor(day);
92
+ try {
93
+ const stat = await fs.promises.stat(file);
94
+ if (stat.size > (this.opts.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES)) {
95
+ this.truncatedDays.add(day);
96
+ await appendJsonl(file, [
97
+ { kind: 'trace_truncated', ts: event.ts, note: '单日文件超上限,当日停写(次日自动恢复)' },
98
+ ]);
99
+ return;
100
+ }
101
+ }
102
+ catch {
103
+ /* 文件不存在 = 首写,继续 */
104
+ }
105
+ await appendJsonl(file, [event]);
106
+ }
107
+ /** 删除超过保留期的旧日文件(retentionDays 0 = 永久)。 */
108
+ pruneOldDays(today) {
109
+ if (this.opts.retentionDays <= 0)
110
+ return;
111
+ let files;
112
+ try {
113
+ files = fs.readdirSync(this.dir());
114
+ }
115
+ catch {
116
+ return;
117
+ }
118
+ const todayMs = Date.parse(`${today}T00:00:00`);
119
+ for (const f of files) {
120
+ const m = /^trace-(\d{4}-\d{2}-\d{2})\.jsonl$/.exec(f);
121
+ if (!m)
122
+ continue;
123
+ const dayMs = Date.parse(`${m[1]}T00:00:00`);
124
+ if (!Number.isFinite(dayMs) || !Number.isFinite(todayMs))
125
+ continue;
126
+ if (todayMs - dayMs > this.opts.retentionDays * 86_400_000) {
127
+ fs.promises.rm(path.join(this.dir(), f), { force: true }).catch(() => { });
128
+ }
129
+ }
130
+ }
131
+ }
132
+ // ── 模块级单例(token-cost.ts 同款) ──
133
+ let store = null;
134
+ /** 插件启动时注入(index.ts 调用);enabled=false 或 0 天保留不影响初始化本身。 */
135
+ export function initTraceStore(dataDir, opts) {
136
+ store = new TraceStore(dataDir, opts);
137
+ }
138
+ /** 测试/卸载:清空单例。 */
139
+ export function resetTraceStore() {
140
+ store = null;
141
+ }
142
+ /** 埋点入口:未初始化 = no-op。fire-and-forget,永不抛。 */
143
+ export function trace(event) {
144
+ store?.append(event);
145
+ }
146
+ /** RPC tail 数据源;未初始化返回 undefined(端点回空集)。 */
147
+ export function getTraceStore() {
148
+ return store;
149
+ }
@@ -4,9 +4,30 @@ import { listConflictPairs, renderConflictResolution, renderConflicts, resolveCo
4
4
  import { WING_CATALOG, WING_FALLBACK, normPersistence, normScope, resolveRecordScope } from '../types.js';
5
5
  import { scopeFilterOf, workspaceIdOf } from '../workspace.js';
6
6
  import { GRAPH_STATUS_LABELS } from '../prompts/graph-projection.js';
7
+ import { redactSecrets } from '../util/redact.js';
7
8
  const OFF_NOTICE = '本会话的记忆档位为"关闭":该会话对记忆系统完全隐身,不读取也不写入记忆。';
8
9
  const WRITE_ONLY_NOTICE = '本会话为只写模式:记忆照常沉淀,但不读取。';
9
10
  const GLOBAL_OFF_NOTICE = '记忆注入已全局停用:本会话不读取记忆(沉淀照常)。';
11
+ /** §C 一次性告警标记:手动写入路径的脱敏失败只提示一次(不阻断写入)。 */
12
+ let manualRedactWarnedOnce = false;
13
+ /**
14
+ * §C 手动写入路径脱敏(memorax-absorb / task_18):memory_add / memory_import
15
+ * 与捕获路径走同一 8 类词表,两条 L1 写入边界语义一致。
16
+ * 红线:脱敏失败放行原文 + 一次性 warn(不阻断写入)。
17
+ */
18
+ function applyManualRedaction(record, cfg, logger) {
19
+ if (cfg.capture?.redactSecrets === false)
20
+ return;
21
+ try {
22
+ record.content = redactSecrets(record.content).text;
23
+ }
24
+ catch (err) {
25
+ if (!manualRedactWarnedOnce) {
26
+ manualRedactWarnedOnce = true;
27
+ logger.warn(`[memory] 手动写入脱敏失败,放行原文(一次性提示): ${err instanceof Error ? err.message : String(err)}`);
28
+ }
29
+ }
30
+ }
10
31
  export function registerMemoryTools(ctx, cfg, stores, logger, modes, live,
11
32
  /** 反刍控制器(可选:未装配时 ruminate 工具返回未启用提示)。 */
12
33
  ruminate) {
@@ -362,6 +383,7 @@ ruminate) {
362
383
  return { notice: 'content 为空,未写入' };
363
384
  const scene = typeof args.scene === 'string' && args.scene.trim() ? args.scene.trim().slice(0, 120) : '__manual__';
364
385
  const record = buildRecord(args, scene, Date.now(), workspaceIdOf(exec));
386
+ applyManualRedaction(record, cfg, logger);
365
387
  await stores.l1.appendNew([record]);
366
388
  logger.info(`[memory] 高权限写入记忆(${record.type}${record.metadata?.hall ? '/' + String(record.metadata.hall) : ''},时间轴 ${record.persistence ?? '?'}):${record.content.slice(0, 120)}`);
367
389
  return { id: record.id };
@@ -455,7 +477,9 @@ ruminate) {
455
477
  return;
456
478
  }
457
479
  seen.add(key);
458
- records.push(buildRecord(item, scene, now, workspaceIdOf(exec)));
480
+ const record = buildRecord(item, scene, now, workspaceIdOf(exec));
481
+ applyManualRedaction(record, cfg, logger);
482
+ records.push(record);
459
483
  });
460
484
  if (records.length > 0)
461
485
  await stores.l1.appendNew(records);
@@ -0,0 +1,27 @@
1
+ /**
2
+ * §C 载荷脱敏(memorax-absorb Wave 2 / task_14)——捕获边界的密钥擦除。
3
+ *
4
+ * 用户在对话里贴入的密钥(PEM/Bearer/JWT/Cookie/厂商 API key/邮箱/长号/高熵 ID)
5
+ * 若原样进 L0/L1,会被逐轮召回注入上下文并在 UI 展示。本模块在捕获单点把它们
6
+ * 替换为类型化占位符 `[REDACTED:<KIND>]`——占位符保留类别语义,关键词检索仍可命中。
7
+ *
8
+ * 词表/正则/allowlist/span 合并算法以 memorax `payload-redaction.ts` 为基准逐条移植
9
+ * (全文快照见计划 evidence/payload-redaction-snapshot.ts;findings.md 有等价内联)。
10
+ *
11
+ * 契约(sanitize 同款,tests/redact.test.ts 钉住):
12
+ * - 纯函数,零 I/O 零时钟零随机;输入原样返回(未命中)= 引用不变无关紧要,字符串不可变;
13
+ * - **幂等**:已是 `[REDACTED:KIND]` 的值不会被二次匹配;
14
+ * - 安全值 allowlist 命中不脱(`example`/`change-me`/`$VAR`/`${{…}}` 等);
15
+ * - 本函数**永不抛错**的保证由调用方 try/catch 兜底(capture 接线层,§C 红线:失败放行原文)。
16
+ */
17
+ export type RedactionKind = 'PRIVATE_KEY' | 'AUTH_TOKEN' | 'COOKIE' | 'API_KEY' | 'CREDENTIAL' | 'EMAIL' | 'LONG_NUMBER' | 'OPAQUE_ID';
18
+ export interface RedactionResult {
19
+ text: string;
20
+ counts: Partial<Record<RedactionKind, number>>;
21
+ redacted: boolean;
22
+ }
23
+ /**
24
+ * 对文本执行 8 类脱敏。规则各自扫描 → 收集值级 span → 合并重叠 → 重拼插入占位符。
25
+ * 同一位置被多条规则命中时取更长/更高优先级的 span。
26
+ */
27
+ export declare function redactSecrets(text: string): RedactionResult;