@lmzhen/dsh-evolution-feedback 0.1.0-rc.67 → 0.1.0-rc.69

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
@@ -41,6 +41,82 @@ function evolutionIoAdapter(provider) {
41
41
  }
42
42
  };
43
43
  }
44
+ function eventsFile(home) {
45
+ return join(home, "evolution", "events.json");
46
+ }
47
+ function isEventRecord(event) {
48
+ return typeof event === "object" && event !== null && typeof event.seq === "number";
49
+ }
50
+ /**
51
+ * Parse an event log body. A missing file, a whitespace-only file (rc.69:
52
+ * rebuildable, NOT malformed) or a corrupt one reads as empty; corrupt content
53
+ * is still refused on append, never overwritten.
54
+ */
55
+ function parseEvolutionEvents(raw) {
56
+ if (raw === null || raw.trim() === "") return [];
57
+ try {
58
+ const parsed = JSON.parse(raw);
59
+ if (!Array.isArray(parsed.events)) return [];
60
+ return parsed.events.filter(isEventRecord);
61
+ } catch {
62
+ return [];
63
+ }
64
+ }
65
+ /**
66
+ * Append one event under the write lock (rc.68): `seq` = current max + 1
67
+ * computed inside the transact, so two processes appending concurrently never
68
+ * collide. A malformed log is refused (bytes preserved) and the append fails.
69
+ * Returns the assigned seq.
70
+ */
71
+ async function appendEvolutionEvent(io, path, event) {
72
+ let assigned = 0;
73
+ await transactIo(io, path, (current) => {
74
+ if (current !== null && current.trim() !== "") try {
75
+ JSON.parse(current);
76
+ } catch {
77
+ return Promise.resolve(current);
78
+ }
79
+ const events = parseEvolutionEvents(current);
80
+ const maxSeq = events.reduce((max, entry) => Math.max(max, entry.seq), 0);
81
+ const record = {
82
+ ...event,
83
+ seq: maxSeq + 1,
84
+ at: (/* @__PURE__ */ new Date()).toISOString()
85
+ };
86
+ assigned = record.seq;
87
+ return Promise.resolve(JSON.stringify({
88
+ version: 1,
89
+ events: [...events, record]
90
+ }, null, 2));
91
+ });
92
+ if (assigned === 0) throw new Error(`evolution event log is malformed and was not touched: ${path}`);
93
+ return assigned;
94
+ }
95
+ /** Read the event log; a missing/whitespace-only file reads as empty,
96
+ * corrupt content is flagged (and refused on append). */
97
+ async function readEvolutionEvents(io, path) {
98
+ const raw = await io.readText(path);
99
+ if (raw === null || raw.trim() === "") return {
100
+ events: [],
101
+ malformed: false
102
+ };
103
+ try {
104
+ const parsed = JSON.parse(raw);
105
+ if (!Array.isArray(parsed.events)) return {
106
+ events: [],
107
+ malformed: true
108
+ };
109
+ return {
110
+ events: parsed.events.filter(isEventRecord),
111
+ malformed: false
112
+ };
113
+ } catch {
114
+ return {
115
+ events: [],
116
+ malformed: true
117
+ };
118
+ }
119
+ }
44
120
  //#endregion
45
121
  //#region ../evolution-core/src/prompts.ts
46
122
  /**
@@ -276,8 +352,15 @@ new RegExp(String.raw`ignore\s+${FILLER}(?:previous|above|prior|all)\s+${FILLER}
276
352
  * Feedback is durable through `ctx.evolutionIo` (when mounted) and skill
277
353
  * feedback feeds `quality_score` / `quality_warn` on the usage record, so
278
354
  * curator decisions can consume it deterministically.
355
+ *
356
+ * Persistence (rc.68): the EVENTS LOG (`evolution/events.json`, via
357
+ * `evolution-core/evolution-events.ts`) is the single source of truth —
358
+ * every increment appends one event under the write lock. `feedback.json` is
359
+ * a rebuildable BOOT CACHE (`{ version: 2, lastSeq, skills, sessions }`),
360
+ * never the truth; the in-memory state is the optimistic aggregate.
279
361
  * @module @lmzhen/dsh-evolution-feedback
280
362
  */
363
+ const CACHE_VERSION = 2;
281
364
  var EvolutionFeedback = class {
282
365
  state = {
283
366
  skills: {},
@@ -285,9 +368,13 @@ var EvolutionFeedback = class {
285
368
  };
286
369
  chain = Promise.resolve();
287
370
  path;
371
+ eventsPath;
288
372
  io;
289
373
  constructor(io, home = process.env.DSH_HOME ?? join(homedir(), ".dsh"), pathOverride) {
290
- if (io) this.path = pathOverride ?? join(home, "evolution", "feedback.json");
374
+ if (io) {
375
+ this.path = pathOverride ?? join(home, "evolution", "feedback.json");
376
+ this.eventsPath = eventsFile(home);
377
+ }
291
378
  this.io = io;
292
379
  }
293
380
  mutate(task) {
@@ -297,26 +384,40 @@ var EvolutionFeedback = class {
297
384
  }
298
385
  async restore(io) {
299
386
  const path = this.path;
300
- if (!path) return;
387
+ const eventsPath = this.eventsPath;
388
+ if (!path || !eventsPath) return;
301
389
  await this.mutate(async () => {
302
- const raw = await io.readText(path);
303
- if (raw === null) return;
304
- try {
305
- const parsed = JSON.parse(raw);
306
- this.state = {
307
- skills: {
308
- ...parsed.skills,
309
- ...this.state.skills
310
- },
311
- sessions: {
312
- ...parsed.sessions,
313
- ...this.state.sessions
314
- }
315
- };
390
+ const rawEvents = await io.readText(eventsPath);
391
+ if (rawEvents === null || rawEvents.trim() === "") {
392
+ const aggregate = parseAggregate(await io.readText(path));
393
+ if (aggregate) try {
394
+ await migrateFeedbackEvents(io, eventsPath, aggregate);
395
+ } catch {}
396
+ }
397
+ const { events } = await readEvolutionEvents(io, eventsPath);
398
+ const cache = parseCache(await io.readText(path));
399
+ const maxSeq = events.reduce((max, event) => Math.max(max, event.seq), 0);
400
+ const truth = cache ? foldWithDelta(cache, events) : foldFeedbackState(events);
401
+ this.state = {
402
+ skills: {
403
+ ...truth.skills,
404
+ ...this.state.skills
405
+ },
406
+ sessions: {
407
+ ...truth.sessions,
408
+ ...this.state.sessions
409
+ }
410
+ };
411
+ if (maxSeq > 0 && (!cache || cache.lastSeq < maxSeq)) try {
412
+ await io.writeText(path, JSON.stringify({
413
+ version: CACHE_VERSION,
414
+ lastSeq: maxSeq,
415
+ ...truth
416
+ }, null, 2));
316
417
  } catch {}
317
418
  });
318
419
  }
319
- record(target, rating, note, kind = "session", io) {
420
+ record(target, rating, note, kind = "session") {
320
421
  const mode = kind === "skill" ? "skills" : "sessions";
321
422
  const table = this.state[mode];
322
423
  const current = table[target] ?? {
@@ -326,28 +427,17 @@ var EvolutionFeedback = class {
326
427
  current[rating] += 1;
327
428
  if (note !== void 0) current.lastNote = note;
328
429
  table[target] = current;
329
- const recordIo = io ?? this.io;
330
- const path = this.path;
331
- if (!recordIo || !path) return;
430
+ const recordIo = this.io;
431
+ const eventsPath = this.eventsPath;
432
+ if (!recordIo || !eventsPath) return;
332
433
  this.mutate(async () => {
333
434
  try {
334
- await transactIo(recordIo, path, (raw) => {
335
- if (raw !== null) try {
336
- JSON.parse(raw);
337
- } catch {
338
- return Promise.resolve(raw);
339
- }
340
- const diskState = parseState(raw);
341
- const diskTable = diskState[mode];
342
- const diskRec = diskTable[target] ?? {
343
- positive: 0,
344
- negative: 0
345
- };
346
- diskRec[rating] += 1;
347
- if (note !== void 0) diskRec.lastNote = note;
348
- diskTable[target] = diskRec;
349
- this.state = diskState;
350
- return Promise.resolve(JSON.stringify(diskState, null, 2));
435
+ await appendEvolutionEvent(recordIo, eventsPath, {
436
+ type: "feedback",
437
+ target,
438
+ kind,
439
+ rating,
440
+ note
351
441
  });
352
442
  } catch (error) {}
353
443
  });
@@ -369,26 +459,175 @@ var EvolutionFeedback = class {
369
459
  waitIdle() {
370
460
  return this.chain;
371
461
  }
462
+ /** Rebuild the boot cache from the log truth (rc.68); best-effort. */
463
+ persistCache() {
464
+ const path = this.path;
465
+ const eventsPath = this.eventsPath;
466
+ const recordIo = this.io;
467
+ if (!path || !eventsPath || !recordIo) return Promise.resolve();
468
+ return this.mutate(async () => {
469
+ try {
470
+ const { events } = await readEvolutionEvents(recordIo, eventsPath);
471
+ const maxSeq = events.reduce((max, event) => Math.max(max, event.seq), 0);
472
+ if (maxSeq === 0) return;
473
+ await recordIo.writeText(path, JSON.stringify({
474
+ version: CACHE_VERSION,
475
+ lastSeq: maxSeq,
476
+ ...foldFeedbackState(events)
477
+ }, null, 2));
478
+ } catch {}
479
+ });
480
+ }
372
481
  };
373
- /** Parse a raw feedback sidecar; malformed reads as empty (best-effort). */
374
- function parseState(raw) {
375
- if (raw === null) return {
376
- skills: {},
377
- sessions: {}
378
- };
482
+ /** True when `existing` contains the legacy sequence as a contiguous run on
483
+ * its semantic fields (skip case). `seq` and `at` are excluded: after a merge
484
+ * the legacy events carry shifted seqs, and a re-synthesis stamps a different
485
+ * `at` — the semantic identity is type/kind/target/rating/note. A coincidental
486
+ * semantic match of an already-appended user sequence yields the identical
487
+ * aggregation, so the skip is harmless for counts and notes. */
488
+ function containsLegacySequence(existing, expected) {
489
+ if (expected.length === 0) return true;
490
+ for (let start = 0; start <= existing.length - expected.length; start += 1) {
491
+ let match = true;
492
+ for (let offset = 0; offset < expected.length; offset += 1) {
493
+ const a = expected[offset];
494
+ const b = existing[start + offset];
495
+ if (!a || !b || a.type !== b.type || a.kind !== b.kind || a.target !== b.target || a.rating !== b.rating || a.note !== b.note) {
496
+ match = false;
497
+ break;
498
+ }
499
+ }
500
+ if (match) return true;
501
+ }
502
+ return false;
503
+ }
504
+ /**
505
+ * Merge a legacy aggregate into the event log (rc.69): the expected synthetic
506
+ * sequence is APPENDED (seq-shifted) when the log does not already contain it
507
+ * — so a concurrent first writer's events AND the legacy history both
508
+ * survive; when the sequence is already present the migration was completed
509
+ * (by a first writer or by this path) and nothing is re-appended. Idempotent
510
+ * and race-safe (the search runs inside the same transact). Exported for the
511
+ * migration-race regression test.
512
+ */
513
+ async function migrateFeedbackEvents(io, eventsPath, aggregate) {
514
+ const expected = synthesizeFeedbackEvents(aggregate);
515
+ await transactIo(io, eventsPath, (current) => {
516
+ const existing = parseEvolutionEvents(current);
517
+ if (containsLegacySequence(existing, expected)) return Promise.resolve(current ?? "");
518
+ const maxSeq = existing.reduce((max, event) => Math.max(max, event.seq), 0);
519
+ const merged = [...existing, ...expected.map((event, index) => ({
520
+ ...event,
521
+ seq: maxSeq + index + 1
522
+ }))];
523
+ return Promise.resolve(JSON.stringify({
524
+ version: 1,
525
+ events: merged
526
+ }, null, 2));
527
+ });
528
+ }
529
+ /** Parse a legacy aggregate (v1) or a v2 cache into a plain aggregate state. */
530
+ function parseAggregate(raw) {
531
+ if (raw === null) return null;
379
532
  try {
380
533
  const parsed = JSON.parse(raw);
534
+ const skills = isRecord(parsed.skills) ? parsed.skills : void 0;
535
+ const sessions = isRecord(parsed.sessions) ? parsed.sessions : void 0;
536
+ if (!skills && !sessions) return null;
381
537
  return {
382
- skills: typeof parsed.skills === "object" && !Array.isArray(parsed.skills) ? parsed.skills : {},
383
- sessions: typeof parsed.sessions === "object" && !Array.isArray(parsed.sessions) ? parsed.sessions : {}
538
+ skills: skills ?? {},
539
+ sessions: sessions ?? {}
384
540
  };
385
541
  } catch {
542
+ return null;
543
+ }
544
+ }
545
+ function parseCache(raw) {
546
+ if (raw === null) return null;
547
+ try {
548
+ const parsed = JSON.parse(raw);
549
+ if (parsed.version !== CACHE_VERSION || typeof parsed.lastSeq !== "number") return null;
550
+ if (!isRecord(parsed.skills) || !isRecord(parsed.sessions)) return null;
386
551
  return {
387
- skills: {},
388
- sessions: {}
552
+ lastSeq: parsed.lastSeq,
553
+ state: {
554
+ skills: parsed.skills,
555
+ sessions: parsed.sessions
556
+ }
389
557
  };
558
+ } catch {
559
+ return null;
390
560
  }
391
561
  }
562
+ function isRecord(value) {
563
+ return typeof value === "object" && value !== null && !Array.isArray(value);
564
+ }
565
+ /** Fold all feedback events from zero (the truth view). */
566
+ function foldFeedbackState(events) {
567
+ const state = {
568
+ skills: {},
569
+ sessions: {}
570
+ };
571
+ for (const event of events) applyFeedbackEvent(state, event);
572
+ return state;
573
+ }
574
+ /** Fold events after the cache's lastSeq onto the cached aggregates. */
575
+ function foldWithDelta(cache, events) {
576
+ const state = {
577
+ skills: { ...cache.state.skills },
578
+ sessions: { ...cache.state.sessions }
579
+ };
580
+ for (const event of events) if (event.seq > cache.lastSeq) applyFeedbackEvent(state, event);
581
+ return state;
582
+ }
583
+ function applyFeedbackEvent(state, event) {
584
+ if (event.type !== "feedback") return;
585
+ const target = event.target;
586
+ if (target === void 0 || event.rating === void 0) return;
587
+ const table = event.kind === "skill" ? state.skills : state.sessions;
588
+ const record = table[target] ?? {
589
+ positive: 0,
590
+ negative: 0
591
+ };
592
+ record[event.rating] += 1;
593
+ if (event.note !== void 0) record.lastNote = event.note;
594
+ table[target] = record;
595
+ }
596
+ /** Synthesize one event per aggregate count unit, lastNote on the final event (migration). */
597
+ function synthesizeFeedbackEvents(aggregate) {
598
+ const events = [];
599
+ const at = (/* @__PURE__ */ new Date()).toISOString();
600
+ const emitTarget = (kind, target, record) => {
601
+ const first = events.length + 1;
602
+ for (let index = 0; index < record.positive; index += 1) events.push({
603
+ seq: events.length + 1,
604
+ at,
605
+ type: "feedback",
606
+ kind,
607
+ target,
608
+ rating: "positive"
609
+ });
610
+ for (let index = 0; index < record.negative; index += 1) events.push({
611
+ seq: events.length + 1,
612
+ at,
613
+ type: "feedback",
614
+ kind,
615
+ target,
616
+ rating: "negative"
617
+ });
618
+ if (record.lastNote !== void 0 && events.length >= first) {
619
+ const last = events.length - 1;
620
+ const final = events[last];
621
+ if (final) events[last] = {
622
+ ...final,
623
+ note: record.lastNote
624
+ };
625
+ }
626
+ };
627
+ for (const [target, record] of Object.entries(aggregate.skills)) emitTarget("skill", target, record);
628
+ for (const [target, record] of Object.entries(aggregate.sessions)) emitTarget("session", target, record);
629
+ return events;
630
+ }
392
631
  const name = "evolution-feedback";
393
632
  const Config = z.object({
394
633
  qualityWarnThreshold: z.number().default(-.25),
@@ -405,8 +644,8 @@ function apply(ctx, rawConfig = {}) {
405
644
  const skillUsage = ctx.get("skillUsage");
406
645
  if (skillUsage) {
407
646
  const original = feedback.record.bind(feedback);
408
- feedback.record = (target, rating, note, kind, recordIo) => {
409
- original(target, rating, note, kind ?? "session", recordIo ?? io);
647
+ feedback.record = (target, rating, note, kind) => {
648
+ original(target, rating, note, kind ?? "session");
410
649
  if (kind === "skill") {
411
650
  const score = feedback.score(target, "skill");
412
651
  const warn = score < (rawConfig.qualityWarnThreshold ?? -.25);
@@ -417,8 +656,8 @@ function apply(ctx, rawConfig = {}) {
417
656
  };
418
657
  }
419
658
  ctx.effect(() => () => {
420
- return feedback.waitIdle();
659
+ return Promise.all([feedback.persistCache(), feedback.waitIdle()]);
421
660
  }, "evolution-feedback.records");
422
661
  }
423
662
  //#endregion
424
- export { Config, EvolutionFeedback, apply, name };
663
+ export { Config, EvolutionFeedback, apply, migrateFeedbackEvents, name };
@@ -4,6 +4,12 @@
4
4
  * Feedback is durable through `ctx.evolutionIo` (when mounted) and skill
5
5
  * feedback feeds `quality_score` / `quality_warn` on the usage record, so
6
6
  * curator decisions can consume it deterministically.
7
+ *
8
+ * Persistence (rc.68): the EVENTS LOG (`evolution/events.json`, via
9
+ * `evolution-core/evolution-events.ts`) is the single source of truth —
10
+ * every increment appends one event under the write lock. `feedback.json` is
11
+ * a rebuildable BOOT CACHE (`{ version: 2, lastSeq, skills, sessions }`),
12
+ * never the truth; the in-memory state is the optimistic aggregate.
7
13
  * @module @deepseek-ai/dsh-evolution-feedback
8
14
  */
9
15
  import type { Context } from '@deepseek-ai/cordis';
@@ -28,21 +34,36 @@ export declare class EvolutionFeedback {
28
34
  private state;
29
35
  private chain;
30
36
  private readonly path?;
37
+ private readonly eventsPath?;
31
38
  private readonly io;
32
39
  constructor(io?: IoLike, home?: string, pathOverride?: string);
33
40
  private mutate;
34
41
  restore(io: IoLike): Promise<void>;
35
- record(target: string, rating: 'positive' | 'negative', note?: string, kind?: 'skill' | 'session', io?: IoLike): void;
42
+ record(target: string, rating: 'positive' | 'negative', note?: string, kind?: 'skill' | 'session'): void;
36
43
  score(target: string, kind?: 'skill' | 'session'): number;
37
44
  snapshot(): FeedbackState;
38
45
  /** Await the pending record-task chain (unload safety; rc.66). */
39
46
  waitIdle(): Promise<unknown>;
47
+ /** Rebuild the boot cache from the log truth (rc.68); best-effort. */
48
+ persistCache(): Promise<void>;
40
49
  }
50
+ /**
51
+ * Merge a legacy aggregate into the event log (rc.69): the expected synthetic
52
+ * sequence is APPENDED (seq-shifted) when the log does not already contain it
53
+ * — so a concurrent first writer's events AND the legacy history both
54
+ * survive; when the sequence is already present the migration was completed
55
+ * (by a first writer or by this path) and nothing is re-appended. Idempotent
56
+ * and race-safe (the search runs inside the same transact). Exported for the
57
+ * migration-race regression test.
58
+ */
59
+ export declare function migrateFeedbackEvents(io: IoLike, eventsPath: string, aggregate: FeedbackState): Promise<void>;
41
60
  export declare const name = "evolution-feedback";
42
61
  export interface Config {
43
62
  /** Score below which curator receives quality_warn for a skill. */
44
63
  qualityWarnThreshold?: number;
45
- /** Explicit feedback file path; empty derives $DSH_HOME/evolution/feedback.json. */
64
+ /** Explicit boot-cache file path; empty derives $DSH_HOME/evolution/feedback.json.
65
+ * The event log always stays at $DSH_HOME/evolution/events.json (derived from
66
+ * home, never from this override). */
46
67
  path?: string;
47
68
  }
48
69
  export declare const Config: z<Config>;
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.67",
4
+ "version": "0.1.0-rc.69",
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.67",
41
- "@lmzhen/dsh-skill-usage": "^0.1.0-rc.67"
40
+ "@lmzhen/dsh-evolution-io": "^0.1.0-rc.69",
41
+ "@lmzhen/dsh-skill-usage": "^0.1.0-rc.69"
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.67",
46
- "@lmzhen/dsh-evolution-io-node": "^0.1.0-rc.67",
47
- "@lmzhen/dsh-skill-usage": "^0.1.0-rc.67"
45
+ "@lmzhen/dsh-evolution-io": "^0.1.0-rc.69",
46
+ "@lmzhen/dsh-evolution-io-node": "^0.1.0-rc.69",
47
+ "@lmzhen/dsh-skill-usage": "^0.1.0-rc.69"
48
48
  }
49
49
  }