@stardustlc/dsh-dream 0.5.0 → 0.6.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.
@@ -0,0 +1,15 @@
1
+ import type { EvidenceInput } from './knowledge.js';
2
+ export interface EvidenceClaim extends EvidenceInput {
3
+ /** 摘要为转述时,另给原文片段以便核验;不持久化原文。 */
4
+ quote?: string;
5
+ artifactPath?: string;
6
+ }
7
+ export interface EvidenceContext {
8
+ sessionsRoot: string;
9
+ projectId?: string;
10
+ workspaceRoot?: string;
11
+ /** 本地文件只在宿主执行上下文的工作区内读取。 */
12
+ cwd?: string;
13
+ }
14
+ /** 核验失败保留 claimed 和原因;永远不以模型提供的 hash、verification 或用户角色为准。 */
15
+ export declare function verifyEvidence(claim: EvidenceClaim, context: EvidenceContext): EvidenceInput;
@@ -0,0 +1,123 @@
1
+ /** 工具参数只是来源声明;只有本模块读到匹配的可见记录后才产生 read。 */
2
+ import { readFileSync, realpathSync, statSync } from 'node:fs';
3
+ import { basename, dirname, isAbsolute, relative, resolve } from 'node:path';
4
+ import { createHash } from 'node:crypto';
5
+ import { decompressAll, listSessionFiles } from './sessions.js';
6
+ function record(value) {
7
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
8
+ }
9
+ function visibleText(value) {
10
+ if (typeof value === 'string')
11
+ return value;
12
+ if (Array.isArray(value))
13
+ return value.map(visibleText).join('');
14
+ const item = record(value);
15
+ if (item.type !== undefined && item.type !== 'text')
16
+ return '';
17
+ return visibleText(item.text ?? item.content ?? '');
18
+ }
19
+ function normalized(text) { return text.normalize('NFC').replace(/\s+/g, ' ').trim(); }
20
+ function inside(root, file) {
21
+ const rel = relative(root, file);
22
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
23
+ }
24
+ function samePath(a, b) {
25
+ const canonical = (p) => { try {
26
+ return realpathSync(p);
27
+ }
28
+ catch {
29
+ return resolve(p);
30
+ } };
31
+ const left = canonical(a), right = canonical(b);
32
+ return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
33
+ }
34
+ function readBounded(file, maxBytes) {
35
+ const stat = statSync(file);
36
+ if (!stat.isFile() || stat.size > maxBytes)
37
+ throw new Error('source-unreadable');
38
+ const bytes = readFileSync(file);
39
+ if (bytes.length > maxBytes)
40
+ throw new Error('source-unreadable');
41
+ return bytes;
42
+ }
43
+ /** 核验失败保留 claimed 和原因;永远不以模型提供的 hash、verification 或用户角色为准。 */
44
+ export function verifyEvidence(claim, context) {
45
+ const base = {
46
+ kind: claim.kind, summary: claim.summary, verification: 'claimed',
47
+ ...(claim.sessionId !== undefined ? { sessionId: claim.sessionId } : {}),
48
+ ...(claim.recordSeq !== undefined ? { recordSeq: claim.recordSeq } : {}),
49
+ ...(context.projectId !== undefined ? { projectId: context.projectId } : {}),
50
+ };
51
+ const held = (reason) => ({ ...base, verificationReason: reason });
52
+ if (claim.verification !== 'read')
53
+ return held('model-claimed');
54
+ const quote = normalized(claim.quote ?? claim.summary);
55
+ if (quote === '')
56
+ return held('quote-missing');
57
+ try {
58
+ let text;
59
+ if (claim.kind === 'local-artifact') {
60
+ if (!context.cwd || !claim.artifactPath)
61
+ return held('artifact-context-missing');
62
+ const root = realpathSync(context.cwd);
63
+ if (context.workspaceRoot && !samePath(root, context.workspaceRoot))
64
+ return held('workspace-mismatch');
65
+ const file = realpathSync(resolve(root, claim.artifactPath));
66
+ if (!inside(root, file))
67
+ return held('artifact-outside-workspace');
68
+ const bytes = readBounded(file, 1024 * 1024);
69
+ text = bytes.toString('utf8');
70
+ if (bytes.includes(0) || !Buffer.from(text, 'utf8').equals(bytes))
71
+ return held('artifact-not-text');
72
+ }
73
+ else {
74
+ if (!claim.sessionId || claim.recordSeq === undefined)
75
+ return held('session-reference-missing');
76
+ const root = realpathSync(context.sessionsRoot);
77
+ const candidates = listSessionFiles(root, 10000).filter(file => basename(dirname(file)) === claim.sessionId);
78
+ if (candidates.length !== 1)
79
+ return held('session-not-found-or-ambiguous');
80
+ const file = realpathSync(candidates[0]);
81
+ if (!inside(root, file))
82
+ return held('session-outside-root');
83
+ if (context.projectId && basename(dirname(dirname(file))) !== context.projectId)
84
+ return held('project-mismatch');
85
+ const bytes = readBounded(file, 64 * 1024 * 1024);
86
+ const content = file.endsWith('.zstd') ? decompressAll(bytes) : bytes.toString('utf8');
87
+ if (Buffer.byteLength(content) > 64 * 1024 * 1024)
88
+ return held('source-unreadable');
89
+ const lines = content.split('\n').filter(line => line.trim() !== '');
90
+ const header = record(JSON.parse(lines[0] ?? 'null'));
91
+ if (header.type !== 'session' || header.id !== claim.sessionId || (header.version !== undefined && ![0, 1, 2, 3, 4].includes(header.version)))
92
+ return held('session-header-mismatch');
93
+ if (context.workspaceRoot && (typeof header.cwd !== 'string' || !samePath(header.cwd, context.workspaceRoot)))
94
+ return held('workspace-mismatch');
95
+ const found = [];
96
+ for (const line of lines.slice(1)) {
97
+ try {
98
+ const item = record(JSON.parse(line));
99
+ if (item.seq !== undefined && String(item.seq) === String(claim.recordSeq))
100
+ found.push(item);
101
+ }
102
+ catch { /* 坏行不是证据 */ }
103
+ }
104
+ if (found.length !== 1)
105
+ return held('record-not-found-or-ambiguous');
106
+ const entry = found[0], data = record(entry.data);
107
+ if (claim.kind === 'user-correction' && (entry.type !== 'user/message' || (header.version >= 4 && record(data.source).kind !== 'user')))
108
+ return held('not-human-message');
109
+ if (entry.type !== 'user/message' && entry.type !== 'assistant/message' && entry.type !== 'tool/result')
110
+ return held('record-not-visible');
111
+ text = visibleText(data.content ?? data.text ?? data.message ?? entry.data);
112
+ }
113
+ if (!normalized(text).includes(quote))
114
+ return held('quote-mismatch');
115
+ const hash = createHash('sha256').update(text, 'utf8').digest('hex');
116
+ if (claim.sourceHash !== undefined && claim.sourceHash !== hash)
117
+ return held('source-hash-mismatch');
118
+ return { ...base, verification: 'read', sourceHash: hash, verificationReason: 'source-verified' };
119
+ }
120
+ catch {
121
+ return held('source-unreadable');
122
+ }
123
+ }
package/lib/journal.d.ts CHANGED
@@ -9,6 +9,19 @@ export interface DreamEntry {
9
9
  export declare function journalFile(journalDir: string): string;
10
10
  /** 追加一条梦;返回落盘后的条目。 */
11
11
  export declare function saveDream(journalDir: string, reflection: string, lessons: string[], mood: string): DreamEntry;
12
+ /** 读取结果:梦境 + 被跳过的行数(坏 JSON 或不是梦境记录)。 */
13
+ export interface DreamReadResult {
14
+ dreams: DreamEntry[];
15
+ skippedLines: number;
16
+ }
17
+ /**
18
+ * 倒序读取梦境并**报告**跳过了多少行。
19
+ *
20
+ * 两个健壮性要求(评审实测发现):
21
+ * - 文件开头的 UTF-8 BOM 必须剥离,否则第一行解析失败会让整份日记读空;
22
+ * - 坏行跳过要可计数,不能静默——调用方(dream_health / 只读路由)可以透出。
23
+ */
24
+ export declare function readDreamsDetailed(journalDir: string, limit: number): DreamReadResult;
12
25
  /** 倒序读取梦境(新梦在前);损坏行跳过。 */
13
26
  export declare function readDreams(journalDir: string, limit: number): DreamEntry[];
14
27
  /** 教训榜的一行:次数与最近一次出现时间(lastAt 缺失时是空串,绝不为 null)。 */
@@ -22,6 +35,8 @@ export interface DreamStats {
22
35
  total: number;
23
36
  moods: Record<string, number>;
24
37
  topLessons: LessonStat[];
38
+ /** 解析时跳过的行数(坏 JSON 或不是梦境记录);0 表示全部可读。 */
39
+ skippedLines: number;
25
40
  }
26
41
  /**
27
42
  * 统计梦境(基于全量日记)。
package/lib/journal.js CHANGED
@@ -26,18 +26,27 @@ export function saveDream(journalDir, reflection, lessons, mood) {
26
26
  }
27
27
  /** 需要读全量日记时的上限:检索与统计共用,避免两处上限漂移。 */
28
28
  const FULL_SCAN_LIMIT = 100000;
29
- /** 倒序读取梦境(新梦在前);损坏行跳过。 */
30
- export function readDreams(journalDir, limit) {
29
+ /**
30
+ * 倒序读取梦境并**报告**跳过了多少行。
31
+ *
32
+ * 两个健壮性要求(评审实测发现):
33
+ * - 文件开头的 UTF-8 BOM 必须剥离,否则第一行解析失败会让整份日记读空;
34
+ * - 坏行跳过要可计数,不能静默——调用方(dream_health / 只读路由)可以透出。
35
+ */
36
+ export function readDreamsDetailed(journalDir, limit) {
31
37
  const file = journalFile(journalDir);
32
38
  if (!existsSync(file))
33
- return [];
34
- const lines = readFileSync(file, 'utf8').split('\n').filter((line) => line.trim() !== '');
35
- const out = [];
39
+ return { dreams: [], skippedLines: 0 };
40
+ // 开头 BOM 不参与解析(Windows 记事本等工具会写入)。
41
+ const text = readFileSync(file, 'utf8').replace(/^\uFEFF/, '');
42
+ const lines = text.split('\n').filter((line) => line.trim() !== '');
43
+ const dreams = [];
44
+ let skippedLines = 0;
36
45
  for (const line of lines) {
37
46
  try {
38
47
  const rec = JSON.parse(line);
39
48
  if (typeof rec.reflection === 'string') {
40
- out.push({
49
+ dreams.push({
41
50
  id: String(rec.id ?? ''),
42
51
  at: String(rec.at ?? ''),
43
52
  reflection: rec.reflection,
@@ -45,10 +54,18 @@ export function readDreams(journalDir, limit) {
45
54
  mood: typeof rec.mood === 'string' ? rec.mood : '',
46
55
  });
47
56
  }
57
+ else
58
+ skippedLines += 1;
59
+ }
60
+ catch {
61
+ skippedLines += 1;
48
62
  }
49
- catch { /* 损坏行跳过 */ }
50
63
  }
51
- return out.reverse().slice(0, limit);
64
+ return { dreams: dreams.reverse().slice(0, limit), skippedLines };
65
+ }
66
+ /** 倒序读取梦境(新梦在前);损坏行跳过。 */
67
+ export function readDreams(journalDir, limit) {
68
+ return readDreamsDetailed(journalDir, limit).dreams;
52
69
  }
53
70
  /**
54
71
  * 统计梦境(基于全量日记)。
@@ -58,8 +75,10 @@ export function readDreams(journalDir, limit) {
58
75
  * 大小写,不再二次 flatMap(否则同一条教训的三个数值会来自不同的扫描)。
59
76
  */
60
77
  export function dreamStats(journalDir) {
61
- const dreams = readDreams(journalDir, FULL_SCAN_LIMIT);
62
- const moods = {};
78
+ const { dreams, skippedLines } = readDreamsDetailed(journalDir, FULL_SCAN_LIMIT);
79
+ // 无原型对象:心境是用户可控文本,'__proto__' / 'constructor' 这类键
80
+ // 在普通对象上会被原型吞掉或把计数变成字符串(评审实测)。
81
+ const moods = Object.create(null);
63
82
  const lessons = new Map();
64
83
  for (const dream of dreams) {
65
84
  const mood = dream.mood !== '' ? dream.mood : '平静';
@@ -78,7 +97,9 @@ export function dreamStats(journalDir) {
78
97
  const topLessons = [...lessons.values()]
79
98
  .sort((a, b) => b.count - a.count || b.lastAt.localeCompare(a.lastAt))
80
99
  .slice(0, 10);
81
- return { total: dreams.length, moods, topLessons };
100
+ // 对外给普通对象副本:内部用无原型对象防键污染,但消费方(deepStrictEqual / 原型判断)
101
+ // 期望的是普通对象。展开赋值走 CreateDataProperty,不会触发 __proto__ setter。
102
+ return { total: dreams.length, moods: { ...moods }, topLessons, skippedLines };
82
103
  }
83
104
  /** 关键词检索梦境(不区分大小写,命中 reflection/lessons;扫描全量,上限见 FULL_SCAN_LIMIT)。 */
84
105
  export function searchDreams(journalDir, query, limit) {
@@ -1,4 +1,4 @@
1
- import { type Evidence, type EvidenceInput, type Lesson, type LessonInput, type LessonState, type ReviewDecision } from './knowledge.js';
1
+ import { type Evidence, type EvidenceInput, type Lesson, type LessonInput, type LessonReviewResolution, type LessonState, type ReviewDecision } from './knowledge.js';
2
2
  /** events.jsonl 默认最多回放的行数(有界读取)。 */
3
3
  export declare const MAX_REPLAY_EVENTS = 200000;
4
4
  /** KnowledgeStore 构造选项(都可省略,保持 FREEZE 的 constructor(knowledgeDir) 可用)。 */
@@ -11,6 +11,10 @@ export interface KnowledgeStoreOptions {
11
11
  lockTimeoutMs?: number;
12
12
  /** 锁年龄超过该毫秒数且 pid 不存活才允许接管;默认 30000。 */
13
13
  lockStaleMs?: number;
14
+ /** 锁硬上限:超过且心跳停滞时给可操作错误、不静默抢占;默认 600000(10 分钟)。 */
15
+ lockHardLimitMs?: number;
16
+ /** 心跳刷新间隔毫秒;默认 5000。长回放/压缩也会按行数主动 touch。 */
17
+ lockHeartbeatMs?: number;
14
18
  }
15
19
  /**
16
20
  * 经验知识存储。无文件时所有读取方法安全返回空,写入方法在首次写入时自建目录。
@@ -25,6 +29,9 @@ export declare class KnowledgeStore {
25
29
  private readonly maxReplayEvents;
26
30
  private readonly lockTimeoutMs;
27
31
  private readonly lockStaleMs;
32
+ private readonly lockHardLimitMs;
33
+ private readonly lockHeartbeatMs;
34
+ private lastIndexError?;
28
35
  constructor(knowledgeDir: string, options?: KnowledgeStoreOptions);
29
36
  /** 全部经验,按 updatedAt 降序(同刻按 id 稳定排序)。 */
30
37
  listLessons(): Lesson[];
@@ -38,22 +45,36 @@ export declare class KnowledgeStore {
38
45
  created: boolean;
39
46
  };
40
47
  /**
41
- * 创建候选经验。同 idempotencyKey 幂等;同指纹(文本 + 范围相同)确定性合并,
48
+ * 创建候选经验。同 idempotencyKey + 同 requestHash 幂等;同指纹(文本 + 范围相同)确定性合并,
42
49
  * 只把新证据并入已有经验,不新增经验。
50
+ * 经调用方核验的 read 来源可用于项目经验;来源读取不代表用户采纳,review 始终 unreviewed。
43
51
  */
44
- createLesson(input: LessonInput, idempotencyKey: string): {
52
+ createLesson(input: LessonInput, idempotencyKey: string, options?: {
53
+ requireReview?: boolean;
54
+ }): {
45
55
  lesson: Lesson;
46
56
  created: boolean;
47
57
  };
48
- /** 按 patch 更新经验;revision 不符抛 KnowledgeError('revision') 且不写。 */
58
+ /** 追加证据与更新引用共用一把锁;先检查 revision 与全部输入,不改变审阅状态。 */
59
+ attachEvidence(id: string, inputs: EvidenceInput[], expectedRevision: number, idempotencyKey: string): Lesson;
60
+ /** 按 patch 更新经验;revision 不符抛 KnowledgeError('revision')(details 带 currentRevision)且不写。 */
49
61
  updateLesson(id: string, patch: Partial<Lesson>, expectedRevision: number, idempotencyKey: string): Lesson;
50
62
  /** 审阅经验:accepted → usable,rejected → rejected,unreviewed → candidate。 */
51
- reviewLesson(id: string, decision: ReviewDecision, expectedRevision: number, idempotencyKey: string, actor?: string): Lesson;
63
+ reviewLesson(id: string, decision: ReviewDecision, expectedRevision: number, idempotencyKey: string, actor?: string, note?: string, resolution?: LessonReviewResolution): Lesson;
64
+ /**
65
+ * 批量原子更新(M2-②):一个事件承载全部变更;先统一校验,
66
+ * 任一条不通过 → 零写入并抛 KnowledgeError(details.failedId)。
67
+ */
68
+ updateLessonsBatch(updates: Array<{
69
+ id: string;
70
+ patch: Partial<Lesson>;
71
+ expectedRevision: number;
72
+ }>, idempotencyKey: string): Lesson[];
52
73
  /** 状态迁移;不允许的迁移抛 KnowledgeError('invalid') 且不写。 */
53
74
  applyTransition(id: string, to: LessonState, expectedRevision: number, idempotencyKey: string): Lesson;
54
- /** 从事件重建派生索引;不删除、不改写 events.jsonl / evidence.jsonl。 */
75
+ /** 从事件完整重建派生索引与 checkpoint;不删除、不改写 events.jsonl / evidence.jsonl。 */
55
76
  rebuildIndex(): void;
56
- /** 统计。truncated=true 表示回放被 MAX_REPLAY_EVENTS 截断(不静默)。 */
77
+ /** 统计。truncated=true 表示事件总量超过有界回放上限(不静默);快照模式下实体已折叠进 checkpoint。 */
57
78
  stats(): {
58
79
  lessons: number;
59
80
  evidence: number;
@@ -61,15 +82,91 @@ export declare class KnowledgeStore {
61
82
  byState: Record<LessonState, number>;
62
83
  truncated: boolean;
63
84
  badLines: number;
85
+ orphanEvents: number;
86
+ unsupportedVersions: number;
87
+ skippedEvents: number;
88
+ firstReplayedEventId?: string;
89
+ replayMode: 'full' | 'snapshot+tail';
90
+ replayedEvents: number;
91
+ unreplayedEvents: number;
92
+ };
93
+ /** 只读诊断:锁、文件、计数与最近一次派生索引写失败;绝不获取锁、绝不写盘。 */
94
+ diagnose(): {
95
+ writable: boolean;
96
+ lock: {
97
+ present: boolean;
98
+ ageMs?: number;
99
+ pid?: number;
100
+ alive?: boolean;
101
+ bootId?: string;
102
+ stale: boolean;
103
+ };
104
+ files: {
105
+ dir: string;
106
+ events: {
107
+ path: string;
108
+ exists: boolean;
109
+ bytes: number;
110
+ };
111
+ evidence: {
112
+ path: string;
113
+ exists: boolean;
114
+ bytes: number;
115
+ };
116
+ index: {
117
+ path: string;
118
+ exists: boolean;
119
+ bytes: number;
120
+ generatedAt?: string;
121
+ };
122
+ };
123
+ counts: {
124
+ lessons: number;
125
+ evidence: number;
126
+ events: number;
127
+ badLines: number;
128
+ orphanEvents: number;
129
+ unsupportedVersions: number;
130
+ skippedEvents: number;
131
+ truncated: boolean;
132
+ byState: Record<LessonState, number>;
133
+ };
134
+ lastIndexError?: {
135
+ message: string;
136
+ at: string;
137
+ };
64
138
  };
65
139
  private ensureDir;
66
140
  private withLock;
67
- /** 有界读取 JSONL:只保留最后 maxReplayEvents 条非空行,并报告总行数与截断。 */
141
+ private fileInfo;
142
+ private hashRequest;
143
+ private hashablePatch;
144
+ private cleanPatch;
145
+ private assertSameRequest;
146
+ /**
147
+ * 恢复"首次结果":
148
+ * - 尾部回放产生的 entry 自带快照,直接返回;
149
+ * - checkpoint 里的 entry 只有 lessonId/requestHash,则做一次有针对性的完整扫描,
150
+ * 在命中该幂等键的那一刻取快照;失败再退回当前实体(best effort)。
151
+ */
152
+ private resolveIdempotentLesson;
153
+ private resolveIdempotentBatch;
154
+ private replayUntilIdempotencyKey;
155
+ /** 顺序读取 JSONL 的非空行;每 TOUCH_EVERY_LINES 行调用一次 touch(长回放锁心跳)。 */
156
+ private forEachLine;
157
+ /** 有界读取:只保留最后 maxReplayEvents 条非空行,并报告总行数与是否截断。 */
68
158
  private readBoundedLines;
69
- /** 只回放事件流。 */
159
+ /** 快照 + 尾部增量(checkpoint 有效时);否则有界全量回放。 */
70
160
  private loadEvents;
161
+ private loadEventsBounded;
162
+ /** 无界完整回放(显式压缩 / 写路径发现截断或状态不完整时使用)。 */
163
+ private loadAllUnbounded;
71
164
  /** 事件流 + evidence.jsonl(两条路径都留痕,按 id 合并去重)。 */
72
165
  private loadAll;
166
+ /** 写路径:状态不完整、证据疑似缺失或截断未折叠时,自动做一次完整回放。 */
167
+ private loadCompleteState;
168
+ private hasMissingEvidenceRefs;
169
+ private mergeEvidenceFile;
73
170
  private applyEventLine;
74
171
  private appendEvent;
75
172
  private appendLine;
@@ -80,8 +177,12 @@ export declare class KnowledgeStore {
80
177
  private appendLessonUpdate;
81
178
  private maskLesson;
82
179
  private maskEvidence;
180
+ private hashPrefix;
181
+ private readLastLineId;
182
+ private tryLoadCheckpoint;
183
+ private buildCheckpoint;
83
184
  private buildIndexPayload;
84
- /** 自动更新派生索引:失败降级(权威事件已落盘),不推翻调用结果。 */
185
+ /** 自动更新派生索引:失败降级(权威事件已落盘),记录 lastIndexError 供 diagnose()。 */
85
186
  private writeIndex;
86
187
  private writeIndexStrict;
87
188
  }