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

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,76 @@ 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
+ function parseEventList(raw) {
51
+ if (raw === null) return [];
52
+ try {
53
+ const parsed = JSON.parse(raw);
54
+ if (!Array.isArray(parsed.events)) return [];
55
+ return parsed.events.filter(isEventRecord);
56
+ } catch {
57
+ return [];
58
+ }
59
+ }
60
+ /**
61
+ * Append one event under the write lock (rc.68): `seq` = current max + 1
62
+ * computed inside the transact, so two processes appending concurrently never
63
+ * collide. A malformed log is refused (bytes preserved) and the append fails.
64
+ * Returns the assigned seq.
65
+ */
66
+ async function appendEvolutionEvent(io, path, event) {
67
+ let assigned = 0;
68
+ await transactIo(io, path, (current) => {
69
+ if (current !== null) try {
70
+ JSON.parse(current);
71
+ } catch {
72
+ return Promise.resolve(current);
73
+ }
74
+ const events = parseEventList(current);
75
+ const maxSeq = events.reduce((max, entry) => Math.max(max, entry.seq), 0);
76
+ const record = {
77
+ ...event,
78
+ seq: maxSeq + 1,
79
+ at: (/* @__PURE__ */ new Date()).toISOString()
80
+ };
81
+ assigned = record.seq;
82
+ return Promise.resolve(JSON.stringify({
83
+ version: 1,
84
+ events: [...events, record]
85
+ }, null, 2));
86
+ });
87
+ if (assigned === 0) throw new Error(`evolution event log is malformed and was not touched: ${path}`);
88
+ return assigned;
89
+ }
90
+ /** Read the event log; a missing file reads as empty, malformed is flagged. */
91
+ async function readEvolutionEvents(io, path) {
92
+ const raw = await io.readText(path);
93
+ if (raw === null) return {
94
+ events: [],
95
+ malformed: false
96
+ };
97
+ try {
98
+ const parsed = JSON.parse(raw);
99
+ if (!Array.isArray(parsed.events)) return {
100
+ events: [],
101
+ malformed: true
102
+ };
103
+ return {
104
+ events: parsed.events.filter(isEventRecord),
105
+ malformed: false
106
+ };
107
+ } catch {
108
+ return {
109
+ events: [],
110
+ malformed: true
111
+ };
112
+ }
113
+ }
44
114
  //#endregion
45
115
  //#region ../evolution-core/src/prompts.ts
46
116
  /**
@@ -276,8 +346,15 @@ new RegExp(String.raw`ignore\s+${FILLER}(?:previous|above|prior|all)\s+${FILLER}
276
346
  * Feedback is durable through `ctx.evolutionIo` (when mounted) and skill
277
347
  * feedback feeds `quality_score` / `quality_warn` on the usage record, so
278
348
  * curator decisions can consume it deterministically.
349
+ *
350
+ * Persistence (rc.68): the EVENTS LOG (`evolution/events.json`, via
351
+ * `evolution-core/evolution-events.ts`) is the single source of truth —
352
+ * every increment appends one event under the write lock. `feedback.json` is
353
+ * a rebuildable BOOT CACHE (`{ version: 2, lastSeq, skills, sessions }`),
354
+ * never the truth; the in-memory state is the optimistic aggregate.
279
355
  * @module @lmzhen/dsh-evolution-feedback
280
356
  */
357
+ const CACHE_VERSION = 2;
281
358
  var EvolutionFeedback = class {
282
359
  state = {
283
360
  skills: {},
@@ -285,9 +362,13 @@ var EvolutionFeedback = class {
285
362
  };
286
363
  chain = Promise.resolve();
287
364
  path;
365
+ eventsPath;
288
366
  io;
289
367
  constructor(io, home = process.env.DSH_HOME ?? join(homedir(), ".dsh"), pathOverride) {
290
- if (io) this.path = pathOverride ?? join(home, "evolution", "feedback.json");
368
+ if (io) {
369
+ this.path = pathOverride ?? join(home, "evolution", "feedback.json");
370
+ this.eventsPath = eventsFile(home);
371
+ }
291
372
  this.io = io;
292
373
  }
293
374
  mutate(task) {
@@ -297,26 +378,45 @@ var EvolutionFeedback = class {
297
378
  }
298
379
  async restore(io) {
299
380
  const path = this.path;
300
- if (!path) return;
381
+ const eventsPath = this.eventsPath;
382
+ if (!path || !eventsPath) return;
301
383
  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
- };
384
+ if (await io.readText(eventsPath) === null) {
385
+ const aggregate = parseAggregate(await io.readText(path));
386
+ if (aggregate) try {
387
+ await transactIo(io, eventsPath, (current) => {
388
+ if (current !== null) return Promise.resolve(current);
389
+ return Promise.resolve(JSON.stringify({
390
+ version: 1,
391
+ events: synthesizeFeedbackEvents(aggregate)
392
+ }, null, 2));
393
+ });
394
+ } catch {}
395
+ }
396
+ const { events } = await readEvolutionEvents(io, eventsPath);
397
+ const cache = parseCache(await io.readText(path));
398
+ const maxSeq = events.reduce((max, event) => Math.max(max, event.seq), 0);
399
+ const truth = cache ? foldWithDelta(cache, events) : foldFeedbackState(events);
400
+ this.state = {
401
+ skills: {
402
+ ...truth.skills,
403
+ ...this.state.skills
404
+ },
405
+ sessions: {
406
+ ...truth.sessions,
407
+ ...this.state.sessions
408
+ }
409
+ };
410
+ if (maxSeq > 0 && (!cache || cache.lastSeq < maxSeq)) try {
411
+ await io.writeText(path, JSON.stringify({
412
+ version: CACHE_VERSION,
413
+ lastSeq: maxSeq,
414
+ ...truth
415
+ }, null, 2));
316
416
  } catch {}
317
417
  });
318
418
  }
319
- record(target, rating, note, kind = "session", io) {
419
+ record(target, rating, note, kind = "session") {
320
420
  const mode = kind === "skill" ? "skills" : "sessions";
321
421
  const table = this.state[mode];
322
422
  const current = table[target] ?? {
@@ -326,28 +426,17 @@ var EvolutionFeedback = class {
326
426
  current[rating] += 1;
327
427
  if (note !== void 0) current.lastNote = note;
328
428
  table[target] = current;
329
- const recordIo = io ?? this.io;
330
- const path = this.path;
331
- if (!recordIo || !path) return;
429
+ const recordIo = this.io;
430
+ const eventsPath = this.eventsPath;
431
+ if (!recordIo || !eventsPath) return;
332
432
  this.mutate(async () => {
333
433
  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));
434
+ await appendEvolutionEvent(recordIo, eventsPath, {
435
+ type: "feedback",
436
+ target,
437
+ kind,
438
+ rating,
439
+ note
351
440
  });
352
441
  } catch (error) {}
353
442
  });
@@ -369,26 +458,128 @@ var EvolutionFeedback = class {
369
458
  waitIdle() {
370
459
  return this.chain;
371
460
  }
461
+ /** Rebuild the boot cache from the log truth (rc.68); best-effort. */
462
+ persistCache() {
463
+ const path = this.path;
464
+ const eventsPath = this.eventsPath;
465
+ const recordIo = this.io;
466
+ if (!path || !eventsPath || !recordIo) return Promise.resolve();
467
+ return this.mutate(async () => {
468
+ try {
469
+ const { events } = await readEvolutionEvents(recordIo, eventsPath);
470
+ const maxSeq = events.reduce((max, event) => Math.max(max, event.seq), 0);
471
+ if (maxSeq === 0) return;
472
+ await recordIo.writeText(path, JSON.stringify({
473
+ version: CACHE_VERSION,
474
+ lastSeq: maxSeq,
475
+ ...foldFeedbackState(events)
476
+ }, null, 2));
477
+ } catch {}
478
+ });
479
+ }
372
480
  };
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
- };
481
+ /** Parse a legacy aggregate (v1) or a v2 cache into a plain aggregate state. */
482
+ function parseAggregate(raw) {
483
+ if (raw === null) return null;
379
484
  try {
380
485
  const parsed = JSON.parse(raw);
486
+ const skills = isRecord(parsed.skills) ? parsed.skills : void 0;
487
+ const sessions = isRecord(parsed.sessions) ? parsed.sessions : void 0;
488
+ if (!skills && !sessions) return null;
381
489
  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 : {}
490
+ skills: skills ?? {},
491
+ sessions: sessions ?? {}
384
492
  };
385
493
  } catch {
494
+ return null;
495
+ }
496
+ }
497
+ function parseCache(raw) {
498
+ if (raw === null) return null;
499
+ try {
500
+ const parsed = JSON.parse(raw);
501
+ if (parsed.version !== CACHE_VERSION || typeof parsed.lastSeq !== "number") return null;
502
+ if (!isRecord(parsed.skills) || !isRecord(parsed.sessions)) return null;
386
503
  return {
387
- skills: {},
388
- sessions: {}
504
+ lastSeq: parsed.lastSeq,
505
+ state: {
506
+ skills: parsed.skills,
507
+ sessions: parsed.sessions
508
+ }
389
509
  };
510
+ } catch {
511
+ return null;
390
512
  }
391
513
  }
514
+ function isRecord(value) {
515
+ return typeof value === "object" && value !== null && !Array.isArray(value);
516
+ }
517
+ /** Fold all feedback events from zero (the truth view). */
518
+ function foldFeedbackState(events) {
519
+ const state = {
520
+ skills: {},
521
+ sessions: {}
522
+ };
523
+ for (const event of events) applyFeedbackEvent(state, event);
524
+ return state;
525
+ }
526
+ /** Fold events after the cache's lastSeq onto the cached aggregates. */
527
+ function foldWithDelta(cache, events) {
528
+ const state = {
529
+ skills: { ...cache.state.skills },
530
+ sessions: { ...cache.state.sessions }
531
+ };
532
+ for (const event of events) if (event.seq > cache.lastSeq) applyFeedbackEvent(state, event);
533
+ return state;
534
+ }
535
+ function applyFeedbackEvent(state, event) {
536
+ if (event.type !== "feedback") return;
537
+ const target = event.target;
538
+ if (target === void 0 || event.rating === void 0) return;
539
+ const table = event.kind === "skill" ? state.skills : state.sessions;
540
+ const record = table[target] ?? {
541
+ positive: 0,
542
+ negative: 0
543
+ };
544
+ record[event.rating] += 1;
545
+ if (event.note !== void 0) record.lastNote = event.note;
546
+ table[target] = record;
547
+ }
548
+ /** Synthesize one event per aggregate count unit, lastNote on the final event (migration). */
549
+ function synthesizeFeedbackEvents(aggregate) {
550
+ const events = [];
551
+ const at = (/* @__PURE__ */ new Date()).toISOString();
552
+ const emitTarget = (kind, target, record) => {
553
+ const first = events.length + 1;
554
+ for (let index = 0; index < record.positive; index += 1) events.push({
555
+ seq: events.length + 1,
556
+ at,
557
+ type: "feedback",
558
+ kind,
559
+ target,
560
+ rating: "positive"
561
+ });
562
+ for (let index = 0; index < record.negative; index += 1) events.push({
563
+ seq: events.length + 1,
564
+ at,
565
+ type: "feedback",
566
+ kind,
567
+ target,
568
+ rating: "negative"
569
+ });
570
+ if (record.lastNote !== void 0 && events.length >= first) {
571
+ const last = events.length - 1;
572
+ const final = events[last];
573
+ if (final) events[last] = {
574
+ ...final,
575
+ note: record.lastNote
576
+ };
577
+ }
578
+ };
579
+ for (const [target, record] of Object.entries(aggregate.skills)) emitTarget("skill", target, record);
580
+ for (const [target, record] of Object.entries(aggregate.sessions)) emitTarget("session", target, record);
581
+ return events;
582
+ }
392
583
  const name = "evolution-feedback";
393
584
  const Config = z.object({
394
585
  qualityWarnThreshold: z.number().default(-.25),
@@ -405,8 +596,8 @@ function apply(ctx, rawConfig = {}) {
405
596
  const skillUsage = ctx.get("skillUsage");
406
597
  if (skillUsage) {
407
598
  const original = feedback.record.bind(feedback);
408
- feedback.record = (target, rating, note, kind, recordIo) => {
409
- original(target, rating, note, kind ?? "session", recordIo ?? io);
599
+ feedback.record = (target, rating, note, kind) => {
600
+ original(target, rating, note, kind ?? "session");
410
601
  if (kind === "skill") {
411
602
  const score = feedback.score(target, "skill");
412
603
  const warn = score < (rawConfig.qualityWarnThreshold ?? -.25);
@@ -417,7 +608,7 @@ function apply(ctx, rawConfig = {}) {
417
608
  };
418
609
  }
419
610
  ctx.effect(() => () => {
420
- return feedback.waitIdle();
611
+ return Promise.all([feedback.persistCache(), feedback.waitIdle()]);
421
612
  }, "evolution-feedback.records");
422
613
  }
423
614
  //#endregion
@@ -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,25 @@ 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
  }
41
50
  export declare const name = "evolution-feedback";
42
51
  export interface Config {
43
52
  /** Score below which curator receives quality_warn for a skill. */
44
53
  qualityWarnThreshold?: number;
45
- /** Explicit feedback file path; empty derives $DSH_HOME/evolution/feedback.json. */
54
+ /** Explicit boot-cache file path; empty derives $DSH_HOME/evolution/feedback.json
55
+ * (the event log is its sibling `events.json`). */
46
56
  path?: string;
47
57
  }
48
58
  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.68",
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.68",
41
+ "@lmzhen/dsh-skill-usage": "^0.1.0-rc.68"
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.68",
46
+ "@lmzhen/dsh-evolution-io-node": "^0.1.0-rc.68",
47
+ "@lmzhen/dsh-skill-usage": "^0.1.0-rc.68"
48
48
  }
49
49
  }