@stardustlc/dsh-dream 0.8.1 → 0.9.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.
@@ -1,4 +1,11 @@
1
1
  import { type Evidence, type EvidenceInput, type Lesson, type LessonInput, type LessonReviewResolution, type LessonState, type ReviewDecision } from './knowledge.js';
2
+ type MemorySnapshot = {
3
+ sourceKey: string;
4
+ lessons: Lesson[];
5
+ evidence: Evidence[];
6
+ badLines: number;
7
+ complete: boolean;
8
+ };
2
9
  /** events.jsonl 默认最多回放的行数(有界读取)。 */
3
10
  export declare const MAX_REPLAY_EVENTS = 200000;
4
11
  /** KnowledgeStore 构造选项(都可省略,保持 FREEZE 的 constructor(knowledgeDir) 可用)。 */
@@ -37,6 +44,9 @@ export declare class KnowledgeStore {
37
44
  listLessons(): Lesson[];
38
45
  /** 全部证据,按 observedAt 升序(同刻按 id 稳定排序)。 */
39
46
  listEvidence(): Evidence[];
47
+ /** Content-addressed, immutable snapshot. Hashes also detect same-size, same-mtime writes. */
48
+ readSnapshot(): MemorySnapshot;
49
+ private sourceFingerprint;
40
50
  /** 按 id 取经验;不存在返回 undefined。 */
41
51
  getLesson(id: string): Lesson | undefined;
42
52
  /** 追加证据;同 sessionId+recordSeq 或同 sourceHash 视为重复,返回已有记录且不写。 */
@@ -71,7 +81,7 @@ export declare class KnowledgeStore {
71
81
  id: string;
72
82
  patch: Partial<Lesson>;
73
83
  expectedRevision: number;
74
- }>, idempotencyKey: string): Lesson[];
84
+ }>, idempotencyKey: string, candidateReview?: 'accepted' | 'rejected'): Lesson[];
75
85
  /** 状态迁移;不允许的迁移抛 KnowledgeError('invalid') 且不写。 */
76
86
  applyTransition(id: string, to: LessonState, expectedRevision: number, idempotencyKey: string): Lesson;
77
87
  /** 从事件完整重建派生索引与 checkpoint;不删除、不改写 events.jsonl / evidence.jsonl。 */
@@ -188,3 +198,4 @@ export declare class KnowledgeStore {
188
198
  private writeIndex;
189
199
  private writeIndexStrict;
190
200
  }
201
+ export {};
@@ -22,6 +22,16 @@ import { join } from 'node:path';
22
22
  import { StringDecoder } from 'node:string_decoder';
23
23
  import { KnowledgeError, canTransition, evidenceNaturalKey, evidenceSessionKey, lessonFingerprint, mergeEvidenceSupport, validateEvidence, validateLesson, } from './knowledge.js';
24
24
  import { maskSecrets } from './mask.js';
25
+ import { memoryDirectory } from './memory.js';
26
+ const snapshots = new Map();
27
+ function freezeTree(value) {
28
+ if (value && typeof value === 'object' && !Object.isFrozen(value)) {
29
+ for (const child of Object.values(value))
30
+ freezeTree(child);
31
+ Object.freeze(value);
32
+ }
33
+ return value;
34
+ }
25
35
  /** events.jsonl 默认最多回放的行数(有界读取)。 */
26
36
  export const MAX_REPLAY_EVENTS = 200000;
27
37
  const PREFIX_HASH_BYTES = 64 * 1024;
@@ -336,6 +346,53 @@ export class KnowledgeStore {
336
346
  .map((item) => clone(item))
337
347
  .sort((a, b) => (a.observedAt === b.observedAt ? a.id.localeCompare(b.id) : a.observedAt.localeCompare(b.observedAt)));
338
348
  }
349
+ /** Content-addressed, immutable snapshot. Hashes also detect same-size, same-mtime writes. */
350
+ readSnapshot() {
351
+ const key = this.sourceFingerprint();
352
+ const cacheKey = this.dir + ':' + this.maxReplayEvents;
353
+ const cached = snapshots.get(cacheKey);
354
+ if (cached?.sourceKey === key)
355
+ return cached;
356
+ snapshots.delete(cacheKey);
357
+ // Cold reads verify the authority, including edits in the middle of a
358
+ // legacy checkpoint's prefix/tail guards. Warm reads only hash the files.
359
+ const state = this.mergeEvidenceFile(this.loadEventsBounded(0));
360
+ if (this.sourceFingerprint() !== key)
361
+ throw new KnowledgeError('io', '记忆正在更新,请稍后重试');
362
+ const snapshot = freezeTree({ sourceKey: key, lessons: [...state.lessons.values()], evidence: [...state.evidence.values()], badLines: state.badLines,
363
+ complete: state.idempotencyComplete && state.orphanEvents === 0 && state.unsupportedVersions === 0 && state.skippedEvents === 0 });
364
+ if (snapshots.size >= 16)
365
+ snapshots.delete(snapshots.keys().next().value);
366
+ snapshots.set(cacheKey, snapshot);
367
+ return snapshot;
368
+ }
369
+ sourceFingerprint() {
370
+ const digest = createHash('sha256'), buffer = Buffer.allocUnsafe(64 * 1024);
371
+ for (const file of [this.eventsPath, this.evidencePath, this.indexPath]) {
372
+ digest.update(file);
373
+ let fd;
374
+ try {
375
+ fd = openSync(file, 'r');
376
+ }
377
+ catch (error) {
378
+ if (error.code === 'ENOENT') {
379
+ digest.update(':missing');
380
+ continue;
381
+ }
382
+ throw error;
383
+ }
384
+ try {
385
+ let count;
386
+ while ((count = readSync(fd, buffer, 0, buffer.length, null)) > 0)
387
+ digest.update(buffer.subarray(0, count));
388
+ digest.update(':end');
389
+ }
390
+ finally {
391
+ closeSync(fd);
392
+ }
393
+ }
394
+ return digest.digest('hex');
395
+ }
339
396
  /** 按 id 取经验;不存在返回 undefined。 */
340
397
  getLesson(id) {
341
398
  const state = this.loadEvents();
@@ -693,7 +750,7 @@ export class KnowledgeStore {
693
750
  * 批量原子更新(M2-②):一个事件承载全部变更;先统一校验,
694
751
  * 任一条不通过 → 零写入并抛 KnowledgeError(details.failedId)。
695
752
  */
696
- updateLessonsBatch(updates, idempotencyKey) {
753
+ updateLessonsBatch(updates, idempotencyKey, candidateReview) {
697
754
  assertIdempotencyKey(idempotencyKey);
698
755
  if (!Array.isArray(updates) || updates.length === 0)
699
756
  throw new KnowledgeError('invalid', 'updates 必须是非空数组');
@@ -707,6 +764,7 @@ export class KnowledgeStore {
707
764
  }
708
765
  const requestHash = this.hashRequest({
709
766
  operation: 'lesson.batch',
767
+ ...(candidateReview === undefined ? {} : { candidateReview }),
710
768
  updates: updates.map((update) => ({ id: update.id, expectedRevision: update.expectedRevision, patch: this.hashablePatch(update.patch) })),
711
769
  });
712
770
  return this.withLock((touch) => {
@@ -721,6 +779,7 @@ export class KnowledgeStore {
721
779
  }
722
780
  const now = new Date().toISOString();
723
781
  const planned = [];
782
+ let reviewGroup;
724
783
  for (const update of updates) {
725
784
  const previous = state.lessons.get(update.id);
726
785
  if (previous === undefined)
@@ -733,6 +792,18 @@ export class KnowledgeStore {
733
792
  });
734
793
  }
735
794
  const clean = this.cleanPatch(update.patch);
795
+ if (candidateReview !== undefined) {
796
+ if (previous.state !== 'candidate' || previous.conflictIds.length)
797
+ throw new KnowledgeError('invalid', '批量审阅只接受没有冲突的候选,请逐条核对其他记录');
798
+ const group = JSON.stringify([previous.kind, previous.scope.global === true, previous.scope.projectId, previous.scope.workspaceRoot]);
799
+ reviewGroup ??= group;
800
+ if (group !== reviewGroup)
801
+ throw new KnowledgeError('invalid', '批量审阅需要相同项目、范围和主题');
802
+ if (candidateReview === 'accepted' && !previous.evidenceIds.some(id => state.evidence.get(id)?.verification === 'read'))
803
+ throw new KnowledgeError('invalid', '请先核对候选的实际来源');
804
+ clean.state = candidateReview === 'accepted' ? 'usable' : 'rejected';
805
+ clean.review = { decision: candidateReview, actor: 'human', at: now };
806
+ }
736
807
  if (clean.state !== undefined && clean.state !== previous.state && !canTransition(previous.state, clean.state)) {
737
808
  throw new KnowledgeError('invalid', '不允许的状态迁移:' + previous.state + ' → ' + clean.state, { failedId: update.id });
738
809
  }
@@ -1678,6 +1749,7 @@ export class KnowledgeStore {
1678
1749
  lessons,
1679
1750
  stateCounts,
1680
1751
  byState: stateCounts,
1752
+ memory: memoryDirectory([...state.lessons.values()], [...state.evidence.values()]),
1681
1753
  };
1682
1754
  const checkpoint = this.buildCheckpoint(state);
1683
1755
  if (checkpoint !== undefined)
@@ -0,0 +1,36 @@
1
+ import type { Evidence, Lesson } from './knowledge.js';
2
+ /** A retrieval deadline is not a verification result; feedback never renews it. */
3
+ export declare function reviewDeadline(lesson: Lesson): number | undefined;
4
+ export declare function needsMemoryReview(lesson: Lesson, now?: number): boolean;
5
+ /** Derived layers contain pointers; authoritative lessons and evidence remain untouched. */
6
+ export declare function memoryDirectory(lessons: Lesson[], evidence: Evidence[], now?: number): {
7
+ summary: {
8
+ lessonId: string;
9
+ title: string;
10
+ scope: {
11
+ projectId?: string;
12
+ workspaceRoot?: string;
13
+ global: boolean;
14
+ };
15
+ }[];
16
+ entries: {
17
+ lessonId: string;
18
+ revision: number;
19
+ title: string;
20
+ topic: import("./knowledge.js").LessonKind;
21
+ scope: {
22
+ projectId?: string;
23
+ workspaceRoot?: string;
24
+ global: boolean;
25
+ };
26
+ state: import("./knowledge.js").LessonState;
27
+ reviewDue: boolean;
28
+ reviewAfter: string | null;
29
+ sources: {
30
+ evidenceId: string;
31
+ sessionId: string | undefined;
32
+ recordSeq: string | number | undefined;
33
+ verification: "read" | "claimed";
34
+ }[];
35
+ }[];
36
+ };
package/lib/memory.js ADDED
@@ -0,0 +1,35 @@
1
+ /** A retrieval deadline is not a verification result; feedback never renews it. */
2
+ export function reviewDeadline(lesson) {
3
+ const explicit = lesson.reviewAfter ? Date.parse(lesson.reviewAfter) : NaN;
4
+ const reviewed = Date.parse(lesson.review.at ?? lesson.lastValidatedAt ?? lesson.createdAt);
5
+ const renewed = lesson.review.at ? Date.parse(lesson.review.at) : NaN;
6
+ if (Number.isFinite(explicit) && (!Number.isFinite(renewed) || renewed <= explicit))
7
+ return explicit;
8
+ if (lesson.kind !== 'fact')
9
+ return undefined;
10
+ return Number.isFinite(reviewed) ? reviewed + 30 * 86400000 : undefined;
11
+ }
12
+ export function needsMemoryReview(lesson, now = Date.now()) {
13
+ const deadline = reviewDeadline(lesson);
14
+ return lesson.state === 'usable' && deadline !== undefined && deadline <= now;
15
+ }
16
+ /** Derived layers contain pointers; authoritative lessons and evidence remain untouched. */
17
+ export function memoryDirectory(lessons, evidence, now = Date.now()) {
18
+ const byId = new Map(evidence.map(item => [item.id, item]));
19
+ const accepted = new Set(lessons.filter(item => item.review.decision === 'accepted').map(item => item.id));
20
+ const entries = lessons.filter(item => item.state !== 'rejected').map(item => ({
21
+ lessonId: item.id, revision: item.revision, title: item.title, topic: item.kind, scope: item.scope,
22
+ state: item.state, reviewDue: needsMemoryReview(item, now),
23
+ reviewAfter: reviewDeadline(item) === undefined ? null : new Date(reviewDeadline(item)).toISOString(),
24
+ sources: item.evidenceIds.slice(0, 3).flatMap(id => {
25
+ const source = byId.get(id);
26
+ return source ? [{ evidenceId: id, sessionId: source.sessionId, recordSeq: source.recordSeq, verification: source.verification }] : [];
27
+ }),
28
+ }));
29
+ let chars = 0;
30
+ const summary = entries.filter(item => item.state === 'usable' && !item.reviewDue
31
+ && accepted.has(item.lessonId))
32
+ .slice(0, 12).filter(item => { chars += item.title.length; return chars <= 2400; })
33
+ .map(({ lessonId, title, scope }) => ({ lessonId, title, scope }));
34
+ return { summary, entries };
35
+ }
@@ -28,6 +28,7 @@ export interface RetrievalQuery {
28
28
  includeCandidates?: boolean;
29
29
  /** 是否保留 independentSupportCount===0 且非 usable 的条目;默认 true。dream_context 传 false(R1)。 */
30
30
  includeNoEvidence?: boolean;
31
+ now?: number;
31
32
  }
32
33
  /** 检索结果中的单条经验(when/exceptions 永不截断)。 */
33
34
  export interface RetrievedLesson {
package/lib/retrieval.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { normalizeLessonText } from './knowledge.js';
2
+ import { needsMemoryReview } from './memory.js';
2
3
  /** 默认条数。 */
3
4
  export const DEFAULT_RETRIEVAL_LIMIT = 5;
4
5
  /** 条数硬上限。 */
@@ -150,6 +151,7 @@ const SKIPPED_REASON_LABELS = {
150
151
  'no-evidence': '无独立证据',
151
152
  'version-mismatch': '版本条件不匹配',
152
153
  'platform-mismatch': '平台条件不匹配',
154
+ 'review-due': '已到复核时间',
153
155
  limit: '超出条数上限',
154
156
  'budget-stop': '预算放不下',
155
157
  budget: '预算放不下(旧值)',
@@ -478,6 +480,10 @@ export function retrieveLessons(lessons, evidence, q) {
478
480
  for (const lesson of lessons) {
479
481
  if (!scopeMatches(lesson, q))
480
482
  continue;
483
+ if (needsMemoryReview(lesson, q.now ?? Date.now())) {
484
+ skipped.push({ lessonId: lesson.id, reason: 'review-due' });
485
+ continue;
486
+ }
481
487
  const excludedReason = EXCLUDED_STATE_REASONS[lesson.state];
482
488
  if (excludedReason !== undefined) {
483
489
  skipped.push({ lessonId: lesson.id, reason: excludedReason });
@@ -104,6 +104,36 @@ export function createDreamActionHandler(options = {}) {
104
104
  if (request.signal.aborted)
105
105
  return fail(409, 'cancelled', '操作已取消');
106
106
  const operation = text(body, 'operation');
107
+ if (operation === 'automatic-chat' || operation === 'automatic-flush') {
108
+ const runtime = automaticRuntime(cfg);
109
+ if (!runtime)
110
+ return fail(503, 'unavailable', '自动功能尚未就绪');
111
+ if (operation === 'automatic-flush')
112
+ runtime.flush();
113
+ else {
114
+ if (['use', 'contribute'].some(key => body[key] !== undefined && body[key] !== null && typeof body[key] !== 'boolean')
115
+ || (body.use === undefined && body.contribute === undefined))
116
+ throw new KnowledgeError('invalid', '聊天记忆开关需要布尔值或跟随全局');
117
+ runtime.controlSession(text(body, 'sessionId'), { use: body.use, contribute: body.contribute });
118
+ }
119
+ return json(200, { ok: true, automatic: runtime.status() });
120
+ }
121
+ if (operation === 'review-batch') {
122
+ if (!['accept', 'reject'].includes(String(body.action)) || !Array.isArray(body.items) || body.items.length < 1 || body.items.length > 20)
123
+ throw new KnowledgeError('invalid', '批量审阅每次选择 1 到 20 条候选');
124
+ const key = text(body, 'requestId');
125
+ if (!/^[a-zA-Z0-9_-]{8,100}$/.test(key))
126
+ throw new KnowledgeError('invalid', '审阅请求标识无效');
127
+ const seen = new Set();
128
+ const updates = body.items.map(item => {
129
+ if (!item || typeof item.lessonId !== 'string' || seen.has(item.lessonId) || !Number.isSafeInteger(item.expectedRevision) || item.expectedRevision < 1)
130
+ throw new KnowledgeError('invalid', '候选及版本无效或重复');
131
+ seen.add(item.lessonId);
132
+ return { id: item.lessonId, expectedRevision: item.expectedRevision, patch: {} };
133
+ });
134
+ const lessons = store.updateLessonsBatch(updates, 'web-review-batch:' + key, body.action === 'accept' ? 'accepted' : 'rejected');
135
+ return json(200, maskDeep({ ok: true, lessons, message: '已完成所选候选的审阅' }));
136
+ }
107
137
  if (operation === 'automatic') {
108
138
  if ((body.enabled !== undefined && typeof body.enabled !== 'boolean') || (body.retrievalEnabled !== undefined && typeof body.retrievalEnabled !== 'boolean') || (body.enabled === undefined && body.retrievalEnabled === undefined))
109
139
  throw new KnowledgeError('invalid', '自动功能开关必须是布尔值');
@@ -169,6 +199,8 @@ export function createDreamActionHandler(options = {}) {
169
199
  throw new KnowledgeError('invalid', '不支持此操作');
170
200
  }
171
201
  catch (error) {
202
+ if (error instanceof Error && error.message.startsWith('automatic-'))
203
+ return fail(409, 'invalid', error.message === 'automatic-main-task-active' ? '主任务进行中,请等待完成后整理' : '聊天状态已变化或正在写入,请刷新后重试');
172
204
  if (error instanceof KnowledgeError)
173
205
  return fail(error.code === 'revision' || error.code === 'duplicate' ? 409 : error.code === 'io' ? 500 : 400, error.code, error.message);
174
206
  return fail(500, 'io', '操作失败:' + (error instanceof Error ? error.message : String(error)));
package/lib/web.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type ResolvedDreamConfig } from './config.js';
2
2
  import { type DreamEntry, type DreamStats } from './journal.js';
3
3
  import type { Evidence, Lesson } from './knowledge.js';
4
+ import { memoryDirectory } from './memory.js';
4
5
  /** 面板与浏览器说话的同源路由。 */
5
6
  export declare const DREAM_ROUTE = "/_dsh/dsh-dream/journal";
6
7
  /** M1 经验面板的只读路由。 */
@@ -43,7 +44,11 @@ export interface KnowledgeWebPayload {
43
44
  lessons: Array<Lesson & {
44
45
  scopeLabel: string;
45
46
  evidenceSummary: string;
47
+ reviewDue?: boolean;
48
+ nextReviewAt?: string;
49
+ lastRetrievedAt?: string;
46
50
  }>;
51
+ memory?: ReturnType<typeof memoryDirectory>;
47
52
  /** 脱敏后的证据记录。 */
48
53
  evidence: Evidence[];
49
54
  /** 全量统计(不受条数影响)。 */
package/lib/web.js CHANGED
@@ -22,6 +22,8 @@ import { dreamStats, readDreams, searchDreams } from './journal.js';
22
22
  import { KnowledgeStore } from './knowledge-store.js';
23
23
  import { isDreamReference, maskSecrets } from './mask.js';
24
24
  import { buildEvidenceSummary, lessonScopeLabel } from './retrieval.js';
25
+ import { memoryDirectory, needsMemoryReview, reviewDeadline } from './memory.js';
26
+ import { readAutomaticState } from './automatic-state.js';
25
27
  /** 面板与浏览器说话的同源路由。 */
26
28
  export const DREAM_ROUTE = '/_dsh/dsh-dream/journal';
27
29
  /** M1 经验面板的只读路由。 */
@@ -343,14 +345,23 @@ export function createKnowledgeWebHandler(options = {}) {
343
345
  const lessons = reader.listLessons();
344
346
  const evidence = reader.listEvidence();
345
347
  const stats = normalizeKnowledgeStats(reader.stats());
348
+ let retrieved = {};
349
+ try {
350
+ retrieved = readAutomaticState({ ...resolveConfig(options.config), journalDir: journalDirOf(options) }).retrieved ?? {};
351
+ }
352
+ catch { /* read-only panel still reports knowledge */ }
346
353
  const payload = {
347
354
  lessons: lessons.map((lesson) => maskDeep({
348
355
  ...lesson,
349
356
  scopeLabel: lessonScopeLabel(lesson.scope),
350
357
  evidenceSummary: buildEvidenceSummary(lesson, evidence),
358
+ reviewDue: needsMemoryReview(lesson),
359
+ ...(reviewDeadline(lesson) === undefined ? {} : { nextReviewAt: new Date(reviewDeadline(lesson)).toISOString() }),
360
+ ...(retrieved[lesson.id] ? { lastRetrievedAt: new Date(retrieved[lesson.id].at).toISOString() } : {}),
351
361
  })),
352
362
  evidence: evidence.map((item) => maskDeep(item)),
353
363
  stats,
364
+ memory: maskDeep(memoryDirectory(lessons, evidence)),
354
365
  };
355
366
  responseJson(res, 200, payload);
356
367
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stardustlc/dsh-dream",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "description": "回顾会话、保存反思,并把经过核验和审阅的经验用于后续任务。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",