@lmzhen/dsh-evolution-feedback 0.1.0-rc.71 → 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
@@ -49,6 +49,9 @@ const EVENT_LOG_ROTATE_AT = 4e3;
49
49
  /** Archive file prefix: `events-<lastArchivedSeq>.json`. The active file is
50
50
  * `events.json` and never matches this glob. */
51
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$/;
52
55
  function eventsFile(home) {
53
56
  return join(home, "evolution", "events.json");
54
57
  }
@@ -76,6 +79,17 @@ function parseEvolutionEvents(raw) {
76
79
  }
77
80
  }
78
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
+ /**
79
93
  * Append one event under the write lock (rc.68): `seq` = current max + 1
80
94
  * computed inside the transact, so two processes appending concurrently never
81
95
  * collide. A malformed log is refused (bytes preserved) and the append fails.
@@ -88,6 +102,11 @@ function parseEvolutionEvents(raw) {
88
102
  * archive write and active write leaves both copies, which the timeline merge
89
103
  * dedupes by seq. An archive-write failure aborts the append (active keeps the
90
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.
91
110
  */
92
111
  async function appendEvolutionEvent(io, path, event, rotateAt = EVENT_LOG_ROTATE_AT) {
93
112
  let assigned = 0;
@@ -98,7 +117,8 @@ async function appendEvolutionEvent(io, path, event, rotateAt = EVENT_LOG_ROTATE
98
117
  return current;
99
118
  }
100
119
  const nextEvents = await rotateIfDue(io, path, parseEvolutionEvents(current), rotateAt);
101
- const maxSeq = nextEvents.reduce((max, entry) => Math.max(max, entry.seq), 0);
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));
102
122
  const record = {
103
123
  ...event,
104
124
  seq: maxSeq + 1,
@@ -118,14 +138,16 @@ async function appendEvolutionEvent(io, path, event, rotateAt = EVENT_LOG_ROTATE
118
138
  * `events-<lastArchivedSeq>.json` (await — a failed archive write aborts the
119
139
  * append so the active is never truncated without its copy), old archives are
120
140
  * pruned, and the newer half is returned as the next active body. No-op when
121
- * under the threshold.
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).
122
143
  */
123
144
  async function rotateIfDue(io, path, events, rotateAt) {
124
- if (events.length < rotateAt) return events;
145
+ if (rotateAt < 2 || events.length < rotateAt) return events;
125
146
  const mid = Math.ceil(events.length / 2);
126
147
  const head = events.slice(0, mid);
127
148
  const tail = events.slice(mid);
128
- const anchor = tail[0]?.seq ?? events[events.length - 1]?.seq ?? 0;
149
+ if (tail.length === 0) return events;
150
+ const anchor = tail[0]?.seq ?? 0;
129
151
  const archivePath = join(dirname(path), `${EVENT_ARCHIVE_PREFIX}${anchor - 1}.json`);
130
152
  await io.writeText(archivePath, JSON.stringify({
131
153
  version: 1,
@@ -137,21 +159,28 @@ async function rotateIfDue(io, path, events, rotateAt) {
137
159
  /**
138
160
  * Prune old event archives (rc.71): keep the newest `EVENT_LOG_RETAIN_ARCHIVES`.
139
161
  * The name's numeric part is the last archived seq, so ordering is NUMERIC —
140
- * lexicographic would rank `events-10` before `events-2`. Best-effort per
141
- * removal; exported for the retention test.
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.
142
165
  */
143
166
  async function retainEventArchives(io, path) {
144
167
  const dir = dirname(path);
145
- const names = (await io.list(dir)).filter((name) => name.startsWith("events-") && name.endsWith(".json"));
146
- const archiveSeq = (name) => Number.parseInt(name.slice(7, name.length - 5), 10) || 0;
147
- names.sort((a, b) => archiveSeq(a) - archiveSeq(b));
168
+ const names = await listEventArchives(io, path);
148
169
  const excess = names.slice(0, Math.max(0, names.length - 10));
149
170
  for (const name of excess) await io.remove(join(dir, name)).catch(() => {});
150
171
  }
151
172
  /** Read the event log; a missing/whitespace-only file reads as empty,
152
173
  * corrupt content is flagged (and refused on append). */
153
174
  async function readEvolutionEvents(io, path) {
154
- 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
+ }
155
184
  if (raw === null || raw.trim() === "") return {
156
185
  events: [],
157
186
  malformed: false
@@ -177,14 +206,14 @@ async function readEvolutionEvents(io, path) {
177
206
  * Read the full timeline (rc.71): active log + all archives, merged by seq
178
207
  * (active copy wins, duplicates only arise from the rotation crash window),
179
208
  * sorted ascending. Per-file malformed flag as in `readEvolutionEvents`; a
180
- * malformed ARCHIVE is skipped (never bricks the boot) and still flagged.
209
+ * malformed (or unreadable) ARCHIVE is skipped — it never bricks the boot and
210
+ * it is still flagged.
181
211
  */
182
212
  async function readEvolutionTimeline(io, path) {
183
213
  const dir = dirname(path);
184
- const names = (await io.list(dir)).filter((name) => name.startsWith("events-") && name.endsWith(".json")).sort();
185
214
  let malformed = false;
186
215
  const bySeq = /* @__PURE__ */ new Map();
187
- for (const name of names) {
216
+ for (const name of await listEventArchives(io, path)) {
188
217
  const read = await readEvolutionEvents(io, join(dir, name));
189
218
  if (read.malformed) malformed = true;
190
219
  for (const event of read.events) bySeq.set(event.seq, event);
@@ -441,6 +470,12 @@ new RegExp(String.raw`ignore\s+${FILLER}(?:previous|above|prior|all)\s+${FILLER}
441
470
  * @module @lmzhen/dsh-evolution-feedback
442
471
  */
443
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;
444
479
  var EvolutionFeedback = class {
445
480
  state = {
446
481
  skills: {},
@@ -468,7 +503,7 @@ var EvolutionFeedback = class {
468
503
  if (!path || !eventsPath) return;
469
504
  await this.mutate(async () => {
470
505
  const rawEvents = await io.readText(eventsPath);
471
- const archiveNames = (await io.list(dirname(eventsPath))).filter((name) => name.startsWith("events-") && name.endsWith(".json"));
506
+ const archiveNames = await listEventArchives(io, eventsPath);
472
507
  if ((rawEvents === null || rawEvents.trim() === "") && archiveNames.length === 0) {
473
508
  const aggregate = parseAggregate(await io.readText(path));
474
509
  if (aggregate) try {
@@ -478,7 +513,9 @@ var EvolutionFeedback = class {
478
513
  const { events } = await readEvolutionTimeline(io, eventsPath);
479
514
  const cache = parseCache(await io.readText(path));
480
515
  const maxSeq = events.reduce((max, event) => Math.max(max, event.seq), 0);
481
- 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);
482
519
  this.state = {
483
520
  skills: {
484
521
  ...truth.skills,
@@ -513,13 +550,13 @@ var EvolutionFeedback = class {
513
550
  if (!recordIo || !eventsPath) return;
514
551
  this.mutate(async () => {
515
552
  try {
516
- await appendEvolutionEvent(recordIo, eventsPath, {
553
+ if (await appendEvolutionEvent(recordIo, eventsPath, {
517
554
  type: "feedback",
518
555
  target,
519
556
  kind,
520
557
  rating,
521
558
  note
522
- });
559
+ }) % CACHE_SNAP_EVERY === 0) await this.writeCacheNow();
523
560
  } catch (error) {}
524
561
  });
525
562
  }
@@ -540,23 +577,28 @@ var EvolutionFeedback = class {
540
577
  waitIdle() {
541
578
  return this.chain;
542
579
  }
543
- /** Rebuild the boot cache from the log truth (rc.68); best-effort. */
544
- persistCache() {
580
+ /** Snapshot the boot cache from the log truth (rc.68/rc.72); best-effort. */
581
+ async writeCacheNow() {
545
582
  const path = this.path;
546
583
  const eventsPath = this.eventsPath;
547
584
  const recordIo = this.io;
548
- 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() {
549
600
  return this.mutate(async () => {
550
- try {
551
- const { events } = await readEvolutionTimeline(recordIo, eventsPath);
552
- const maxSeq = events.reduce((max, event) => Math.max(max, event.seq), 0);
553
- if (maxSeq === 0) return;
554
- await recordIo.writeText(path, JSON.stringify({
555
- version: CACHE_VERSION,
556
- lastSeq: maxSeq,
557
- ...foldFeedbackState(events)
558
- }, null, 2));
559
- } catch {}
601
+ await this.writeCacheNow();
560
602
  });
561
603
  }
562
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.71",
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.71",
41
- "@lmzhen/dsh-skill-usage": "^0.1.0-rc.71"
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.71",
46
- "@lmzhen/dsh-evolution-io-node": "^0.1.0-rc.71",
47
- "@lmzhen/dsh-skill-usage": "^0.1.0-rc.71"
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
  }