@lmzhen/dsh-evolution-feedback 0.1.0-rc.70 → 0.1.0-rc.72

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/lib/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import z from "@deepseek-ai/schemastery";
2
- import { join } from "node:path";
2
+ import { dirname, join } from "node:path";
3
3
  import { createHash } from "node:crypto";
4
4
  import { homedir } from "node:os";
5
5
  //#region ../evolution-core/src/io.ts
@@ -41,6 +41,17 @@ function evolutionIoAdapter(provider) {
41
41
  }
42
42
  };
43
43
  }
44
+ /** Active-log split point (rc.71): when the active log reaches this many events
45
+ * the older half is rotated into an archive; the active stays bounded so a
46
+ * single append stays O(active) instead of O(total-history). Tunable default —
47
+ * callers may override per append (the tests use small values). */
48
+ const EVENT_LOG_ROTATE_AT = 4e3;
49
+ /** Archive file prefix: `events-<lastArchivedSeq>.json`. The active file is
50
+ * `events.json` and never matches this glob. */
51
+ const EVENT_ARCHIVE_PREFIX = "events-";
52
+ /** Archive naming is strictly numeric: a user file such as `events-backup.json`
53
+ * under the same directory is neither read into the timeline nor pruned. */
54
+ const EVENT_ARCHIVE_RE = /^events-(\d+)\.json$/;
44
55
  function eventsFile(home) {
45
56
  return join(home, "evolution", "events.json");
46
57
  }
@@ -68,39 +79,108 @@ function parseEvolutionEvents(raw) {
68
79
  }
69
80
  }
70
81
  /**
82
+ * List the numeric archives under the log's directory, sorted ascending by
83
+ * their last-archived seq. Single glob predicate for the timeline, the
84
+ * retention pass and the feedback migration check (rc.72 H-3).
85
+ */
86
+ async function listEventArchives(io, path) {
87
+ const dir = dirname(path);
88
+ return (await io.list(dir)).filter((name) => EVENT_ARCHIVE_RE.test(name)).sort((a, b) => {
89
+ return Number.parseInt(a.slice(7, a.length - 5), 10) - Number.parseInt(b.slice(7, b.length - 5), 10);
90
+ });
91
+ }
92
+ /**
71
93
  * Append one event under the write lock (rc.68): `seq` = current max + 1
72
94
  * computed inside the transact, so two processes appending concurrently never
73
95
  * collide. A malformed log is refused (bytes preserved) and the append fails.
74
96
  * Returns the assigned seq.
97
+ *
98
+ * Rotation (rc.71, 007 design): when the active log reaches `rotateAt`, the
99
+ * older half is copied into an archive inside the SAME transact (the archive
100
+ * path has its own lock, so no recursion) and the active is replaced with the
101
+ * newer half + the new event. seqs stay globally monotonic; a crash between
102
+ * archive write and active write leaves both copies, which the timeline merge
103
+ * dedupes by seq. An archive-write failure aborts the append (active keeps the
104
+ * full old content — no loss) and the caller's best-effort handling applies.
105
+ *
106
+ * rc.72 G-1: when the ACTIVE is missing/whitespace but archives exist (a
107
+ * deleted active, or B-2 self-heal), seq derivation consults the archive names
108
+ * — the active restarts AFTER the highest archived seq, never at 1, so a new
109
+ * event can never shadow an archived one in the seq-deduped timeline.
75
110
  */
76
- async function appendEvolutionEvent(io, path, event) {
111
+ async function appendEvolutionEvent(io, path, event, rotateAt = EVENT_LOG_ROTATE_AT) {
77
112
  let assigned = 0;
78
- await transactIo(io, path, (current) => {
113
+ await transactIo(io, path, async (current) => {
79
114
  if (current !== null && current.trim() !== "") try {
80
115
  JSON.parse(current);
81
116
  } catch {
82
- return Promise.resolve(current);
117
+ return current;
83
118
  }
84
- const events = parseEvolutionEvents(current);
85
- const maxSeq = events.reduce((max, entry) => Math.max(max, entry.seq), 0);
119
+ const nextEvents = await rotateIfDue(io, path, parseEvolutionEvents(current), rotateAt);
120
+ let maxSeq = nextEvents.reduce((max, entry) => Math.max(max, entry.seq), 0);
121
+ if (maxSeq === 0) for (const name of await listEventArchives(io, path)) maxSeq = Math.max(maxSeq, Number.parseInt(name.slice(7, name.length - 5), 10));
86
122
  const record = {
87
123
  ...event,
88
124
  seq: maxSeq + 1,
89
125
  at: (/* @__PURE__ */ new Date()).toISOString()
90
126
  };
91
127
  assigned = record.seq;
92
- return Promise.resolve(JSON.stringify({
128
+ return JSON.stringify({
93
129
  version: 1,
94
- events: [...events, record]
95
- }, null, 2));
130
+ events: [...nextEvents, record]
131
+ }, null, 2);
96
132
  });
97
133
  if (assigned === 0) throw new Error(`evolution event log is malformed and was not touched: ${path}`);
98
134
  return assigned;
99
135
  }
136
+ /**
137
+ * Split the active log at its midpoint when due: the older half is written to
138
+ * `events-<lastArchivedSeq>.json` (await — a failed archive write aborts the
139
+ * append so the active is never truncated without its copy), old archives are
140
+ * pruned, and the newer half is returned as the next active body. No-op when
141
+ * under the threshold; `rotateAt < 2` is a guarded no-op (rc.72 G-1: a
142
+ * one-event rotate would archive everything and restart seqs at 1).
143
+ */
144
+ async function rotateIfDue(io, path, events, rotateAt) {
145
+ if (rotateAt < 2 || events.length < rotateAt) return events;
146
+ const mid = Math.ceil(events.length / 2);
147
+ const head = events.slice(0, mid);
148
+ const tail = events.slice(mid);
149
+ if (tail.length === 0) return events;
150
+ const anchor = tail[0]?.seq ?? 0;
151
+ const archivePath = join(dirname(path), `${EVENT_ARCHIVE_PREFIX}${anchor - 1}.json`);
152
+ await io.writeText(archivePath, JSON.stringify({
153
+ version: 1,
154
+ events: head
155
+ }, null, 2));
156
+ await retainEventArchives(io, path);
157
+ return tail;
158
+ }
159
+ /**
160
+ * Prune old event archives (rc.71): keep the newest `EVENT_LOG_RETAIN_ARCHIVES`.
161
+ * The name's numeric part is the last archived seq, so ordering is NUMERIC —
162
+ * lexicographic would rank `events-10` before `events-2`. Only strictly
163
+ * numeric names participate (rc.72 G-2: user files are never deleted).
164
+ * Best-effort per removal; exported for the retention test.
165
+ */
166
+ async function retainEventArchives(io, path) {
167
+ const dir = dirname(path);
168
+ const names = await listEventArchives(io, path);
169
+ const excess = names.slice(0, Math.max(0, names.length - 10));
170
+ for (const name of excess) await io.remove(join(dir, name)).catch(() => {});
171
+ }
100
172
  /** Read the event log; a missing/whitespace-only file reads as empty,
101
173
  * corrupt content is flagged (and refused on append). */
102
174
  async function readEvolutionEvents(io, path) {
103
- const raw = await io.readText(path);
175
+ let raw;
176
+ try {
177
+ raw = await io.readText(path);
178
+ } catch {
179
+ return {
180
+ events: [],
181
+ malformed: true
182
+ };
183
+ }
104
184
  if (raw === null || raw.trim() === "") return {
105
185
  events: [],
106
186
  malformed: false
@@ -122,6 +202,30 @@ async function readEvolutionEvents(io, path) {
122
202
  };
123
203
  }
124
204
  }
205
+ /**
206
+ * Read the full timeline (rc.71): active log + all archives, merged by seq
207
+ * (active copy wins, duplicates only arise from the rotation crash window),
208
+ * sorted ascending. Per-file malformed flag as in `readEvolutionEvents`; a
209
+ * malformed (or unreadable) ARCHIVE is skipped — it never bricks the boot and
210
+ * it is still flagged.
211
+ */
212
+ async function readEvolutionTimeline(io, path) {
213
+ const dir = dirname(path);
214
+ let malformed = false;
215
+ const bySeq = /* @__PURE__ */ new Map();
216
+ for (const name of await listEventArchives(io, path)) {
217
+ const read = await readEvolutionEvents(io, join(dir, name));
218
+ if (read.malformed) malformed = true;
219
+ for (const event of read.events) bySeq.set(event.seq, event);
220
+ }
221
+ const active = await readEvolutionEvents(io, path);
222
+ if (active.malformed) malformed = true;
223
+ for (const event of active.events) bySeq.set(event.seq, event);
224
+ return {
225
+ events: [...bySeq.values()].sort((a, b) => a.seq - b.seq),
226
+ malformed
227
+ };
228
+ }
125
229
  //#endregion
126
230
  //#region ../evolution-core/src/prompts.ts
127
231
  /**
@@ -366,6 +470,12 @@ new RegExp(String.raw`ignore\s+${FILLER}(?:previous|above|prior|all)\s+${FILLER}
366
470
  * @module @lmzhen/dsh-evolution-feedback
367
471
  */
368
472
  const CACHE_VERSION = 2;
473
+ /** Cache snapshot cadence (rc.72 G-3): every N-th appended event refreshes the
474
+ * boot cache, so `cache.lastSeq` always stays inside the retention window —
475
+ * a hard crash between snapshots loses at most N events, all of which still
476
+ * live in the ACTIVE log (bounded by `EVENT_LOG_ROTATE_AT`), so the next fold
477
+ * is complete. Package-private tunable, not a config surface. */
478
+ const CACHE_SNAP_EVERY = 1024;
369
479
  var EvolutionFeedback = class {
370
480
  state = {
371
481
  skills: {},
@@ -393,16 +503,19 @@ var EvolutionFeedback = class {
393
503
  if (!path || !eventsPath) return;
394
504
  await this.mutate(async () => {
395
505
  const rawEvents = await io.readText(eventsPath);
396
- if (rawEvents === null || rawEvents.trim() === "") {
506
+ const archiveNames = await listEventArchives(io, eventsPath);
507
+ if ((rawEvents === null || rawEvents.trim() === "") && archiveNames.length === 0) {
397
508
  const aggregate = parseAggregate(await io.readText(path));
398
509
  if (aggregate) try {
399
510
  await migrateFeedbackEvents(io, eventsPath, aggregate);
400
511
  } catch {}
401
512
  }
402
- const { events } = await readEvolutionEvents(io, eventsPath);
513
+ const { events } = await readEvolutionTimeline(io, eventsPath);
403
514
  const cache = parseCache(await io.readText(path));
404
515
  const maxSeq = events.reduce((max, event) => Math.max(max, event.seq), 0);
405
- const truth = cache ? foldWithDelta(cache, events) : foldFeedbackState(events);
516
+ const floor = events[0]?.seq ?? 0;
517
+ const usableCache = cache && cache.lastSeq >= floor - 1 ? cache : null;
518
+ const truth = usableCache ? foldWithDelta(usableCache, events) : foldFeedbackState(events);
406
519
  this.state = {
407
520
  skills: {
408
521
  ...truth.skills,
@@ -437,13 +550,13 @@ var EvolutionFeedback = class {
437
550
  if (!recordIo || !eventsPath) return;
438
551
  this.mutate(async () => {
439
552
  try {
440
- await appendEvolutionEvent(recordIo, eventsPath, {
553
+ if (await appendEvolutionEvent(recordIo, eventsPath, {
441
554
  type: "feedback",
442
555
  target,
443
556
  kind,
444
557
  rating,
445
558
  note
446
- });
559
+ }) % CACHE_SNAP_EVERY === 0) await this.writeCacheNow();
447
560
  } catch (error) {}
448
561
  });
449
562
  }
@@ -464,23 +577,28 @@ var EvolutionFeedback = class {
464
577
  waitIdle() {
465
578
  return this.chain;
466
579
  }
467
- /** Rebuild the boot cache from the log truth (rc.68); best-effort. */
468
- persistCache() {
580
+ /** Snapshot the boot cache from the log truth (rc.68/rc.72); best-effort. */
581
+ async writeCacheNow() {
469
582
  const path = this.path;
470
583
  const eventsPath = this.eventsPath;
471
584
  const recordIo = this.io;
472
- if (!path || !eventsPath || !recordIo) return Promise.resolve();
585
+ if (!path || !eventsPath || !recordIo) return;
586
+ try {
587
+ const { events } = await readEvolutionTimeline(recordIo, eventsPath);
588
+ const maxSeq = events.reduce((max, event) => Math.max(max, event.seq), 0);
589
+ if (maxSeq === 0) return;
590
+ await recordIo.writeText(path, JSON.stringify({
591
+ version: CACHE_VERSION,
592
+ lastSeq: maxSeq,
593
+ ...foldFeedbackState(events)
594
+ }, null, 2));
595
+ } catch {}
596
+ }
597
+ /** Rebuild the boot cache from the log truth (rc.68); best-effort, queued
598
+ * on the record chain so it runs after the pending appends. */
599
+ persistCache() {
473
600
  return this.mutate(async () => {
474
- try {
475
- const { events } = await readEvolutionEvents(recordIo, eventsPath);
476
- const maxSeq = events.reduce((max, event) => Math.max(max, event.seq), 0);
477
- if (maxSeq === 0) return;
478
- await recordIo.writeText(path, JSON.stringify({
479
- version: CACHE_VERSION,
480
- lastSeq: maxSeq,
481
- ...foldFeedbackState(events)
482
- }, null, 2));
483
- } catch {}
601
+ await this.writeCacheNow();
484
602
  });
485
603
  }
486
604
  };
@@ -44,7 +44,10 @@ export declare class EvolutionFeedback {
44
44
  snapshot(): FeedbackState;
45
45
  /** Await the pending record-task chain (unload safety; rc.66). */
46
46
  waitIdle(): Promise<unknown>;
47
- /** Rebuild the boot cache from the log truth (rc.68); best-effort. */
47
+ /** Snapshot the boot cache from the log truth (rc.68/rc.72); best-effort. */
48
+ private writeCacheNow;
49
+ /** Rebuild the boot cache from the log truth (rc.68); best-effort, queued
50
+ * on the record chain so it runs after the pending appends. */
48
51
  persistCache(): Promise<void>;
49
52
  }
50
53
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lmzhen/dsh-evolution-feedback",
3
3
  "description": "Feedback-to-quality scoring for self-evolution (community build)",
4
- "version": "0.1.0-rc.70",
4
+ "version": "0.1.0-rc.72",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -37,13 +37,13 @@
37
37
  "peerDependencies": {
38
38
  "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
39
39
  "@deepseek-ai/cordis": "^4.0.1",
40
- "@lmzhen/dsh-evolution-io": "^0.1.0-rc.70",
41
- "@lmzhen/dsh-skill-usage": "^0.1.0-rc.70"
40
+ "@lmzhen/dsh-evolution-io": "^0.1.0-rc.72",
41
+ "@lmzhen/dsh-skill-usage": "^0.1.0-rc.72"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
45
- "@lmzhen/dsh-evolution-io": "^0.1.0-rc.70",
46
- "@lmzhen/dsh-evolution-io-node": "^0.1.0-rc.70",
47
- "@lmzhen/dsh-skill-usage": "^0.1.0-rc.70"
45
+ "@lmzhen/dsh-evolution-io": "^0.1.0-rc.72",
46
+ "@lmzhen/dsh-evolution-io-node": "^0.1.0-rc.72",
47
+ "@lmzhen/dsh-skill-usage": "^0.1.0-rc.72"
48
48
  }
49
49
  }