@lmzhen/dsh-evolution-feedback 0.1.0-rc.66 → 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
  /**
@@ -65,7 +135,7 @@ function evolutionIoAdapter(provider) {
65
135
  * changes semantically: the bundle digest is the fail-closed signal for
66
136
  * review workers, so a stale id across deployments must be distinguishable.
67
137
  */
68
- const PROMPT_BUNDLE_ID = "dsh-evolution@6";
138
+ const PROMPT_BUNDLE_ID = "dsh-evolution@7";
69
139
  const MEMORY_REVIEW_PROMPT = `[Auto-review — Memory]
70
140
  Review the conversation above and consider saving to memory if appropriate.
71
141
 
@@ -86,6 +156,8 @@ Signals to look for (any one of these warrants action):
86
156
  • Non-trivial technique, fix, workaround, debugging path, or tool-usage pattern emerged that a future session would benefit from. Capture it.
87
157
  • A skill that got loaded or consulted this session turned out to be wrong, missing a step, or outdated. Patch it NOW.
88
158
 
159
+ Read-before-write (enforced by this channel): update, patch, delete, or write support files ONLY into skills you loaded or read in THIS session — ops on unread skills are dropped; CREATE of a brand-new umbrella is the only exception.
160
+
89
161
  Preference order — prefer the earliest action that fits, but do pick one when a signal above fired:
90
162
  1. UPDATE A CURRENTLY-LOADED SKILL. Look back through the conversation for skills the user loaded or you read. If any of them covers the territory of the new learning, PATCH that one first. It is the skill that was in play, so it's the right one to extend.
91
163
  2. UPDATE AN EXISTING UMBRELLA. If no loaded skill fits but an existing class-level skill does, patch it. Add a subsection, a pitfall, or broaden a trigger.
@@ -133,6 +205,8 @@ Signals that warrant a skill update (any one is enough):
133
205
  • Non-trivial technique, fix, workaround, or debugging path emerged.
134
206
  • A skill that was loaded or consulted turned out wrong, missing, or outdated — patch it now.
135
207
 
208
+ Read-before-write (enforced by this channel): update, patch, delete, or write support files ONLY into skills you loaded or read in THIS session — ops on unread skills are dropped; CREATE of a brand-new umbrella is the only exception.
209
+
136
210
  Preference order for skills — pick the earliest that fits:
137
211
  1. UPDATE A CURRENTLY-LOADED SKILL. Check what skills were loaded or read in the conversation. If one of them covers the learning, PATCH it first. It was in play; it's the right place.
138
212
  2. UPDATE AN EXISTING UMBRELLA. Patch it.
@@ -240,12 +314,12 @@ function sha256(text) {
240
314
  function createPromptBundle(prompts) {
241
315
  const canonical = JSON.stringify({
242
316
  id: PROMPT_BUNDLE_ID,
243
- version: 6,
317
+ version: 7,
244
318
  prompts: Object.fromEntries(Object.entries(prompts).sort())
245
319
  });
246
320
  return Object.freeze({
247
321
  id: PROMPT_BUNDLE_ID,
248
- version: 6,
322
+ version: 7,
249
323
  prompts: Object.freeze({ ...prompts }),
250
324
  sha256: sha256(canonical)
251
325
  });
@@ -272,8 +346,15 @@ new RegExp(String.raw`ignore\s+${FILLER}(?:previous|above|prior|all)\s+${FILLER}
272
346
  * Feedback is durable through `ctx.evolutionIo` (when mounted) and skill
273
347
  * feedback feeds `quality_score` / `quality_warn` on the usage record, so
274
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.
275
355
  * @module @lmzhen/dsh-evolution-feedback
276
356
  */
357
+ const CACHE_VERSION = 2;
277
358
  var EvolutionFeedback = class {
278
359
  state = {
279
360
  skills: {},
@@ -281,9 +362,13 @@ var EvolutionFeedback = class {
281
362
  };
282
363
  chain = Promise.resolve();
283
364
  path;
365
+ eventsPath;
284
366
  io;
285
367
  constructor(io, home = process.env.DSH_HOME ?? join(homedir(), ".dsh"), pathOverride) {
286
- 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
+ }
287
372
  this.io = io;
288
373
  }
289
374
  mutate(task) {
@@ -293,26 +378,45 @@ var EvolutionFeedback = class {
293
378
  }
294
379
  async restore(io) {
295
380
  const path = this.path;
296
- if (!path) return;
381
+ const eventsPath = this.eventsPath;
382
+ if (!path || !eventsPath) return;
297
383
  await this.mutate(async () => {
298
- const raw = await io.readText(path);
299
- if (raw === null) return;
300
- try {
301
- const parsed = JSON.parse(raw);
302
- this.state = {
303
- skills: {
304
- ...parsed.skills,
305
- ...this.state.skills
306
- },
307
- sessions: {
308
- ...parsed.sessions,
309
- ...this.state.sessions
310
- }
311
- };
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));
312
416
  } catch {}
313
417
  });
314
418
  }
315
- record(target, rating, note, kind = "session", io) {
419
+ record(target, rating, note, kind = "session") {
316
420
  const mode = kind === "skill" ? "skills" : "sessions";
317
421
  const table = this.state[mode];
318
422
  const current = table[target] ?? {
@@ -322,28 +426,17 @@ var EvolutionFeedback = class {
322
426
  current[rating] += 1;
323
427
  if (note !== void 0) current.lastNote = note;
324
428
  table[target] = current;
325
- const recordIo = io ?? this.io;
326
- const path = this.path;
327
- if (!recordIo || !path) return;
429
+ const recordIo = this.io;
430
+ const eventsPath = this.eventsPath;
431
+ if (!recordIo || !eventsPath) return;
328
432
  this.mutate(async () => {
329
433
  try {
330
- await transactIo(recordIo, path, (raw) => {
331
- if (raw !== null) try {
332
- JSON.parse(raw);
333
- } catch {
334
- return Promise.resolve(raw);
335
- }
336
- const diskState = parseState(raw);
337
- const diskTable = diskState[mode];
338
- const diskRec = diskTable[target] ?? {
339
- positive: 0,
340
- negative: 0
341
- };
342
- diskRec[rating] += 1;
343
- if (note !== void 0) diskRec.lastNote = note;
344
- diskTable[target] = diskRec;
345
- this.state = diskState;
346
- return Promise.resolve(JSON.stringify(diskState, null, 2));
434
+ await appendEvolutionEvent(recordIo, eventsPath, {
435
+ type: "feedback",
436
+ target,
437
+ kind,
438
+ rating,
439
+ note
347
440
  });
348
441
  } catch (error) {}
349
442
  });
@@ -365,26 +458,128 @@ var EvolutionFeedback = class {
365
458
  waitIdle() {
366
459
  return this.chain;
367
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
+ }
368
480
  };
369
- /** Parse a raw feedback sidecar; malformed reads as empty (best-effort). */
370
- function parseState(raw) {
371
- if (raw === null) return {
372
- skills: {},
373
- sessions: {}
374
- };
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;
375
484
  try {
376
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;
377
489
  return {
378
- skills: typeof parsed.skills === "object" && !Array.isArray(parsed.skills) ? parsed.skills : {},
379
- sessions: typeof parsed.sessions === "object" && !Array.isArray(parsed.sessions) ? parsed.sessions : {}
490
+ skills: skills ?? {},
491
+ sessions: sessions ?? {}
380
492
  };
381
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;
382
503
  return {
383
- skills: {},
384
- sessions: {}
504
+ lastSeq: parsed.lastSeq,
505
+ state: {
506
+ skills: parsed.skills,
507
+ sessions: parsed.sessions
508
+ }
385
509
  };
510
+ } catch {
511
+ return null;
386
512
  }
387
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
+ }
388
583
  const name = "evolution-feedback";
389
584
  const Config = z.object({
390
585
  qualityWarnThreshold: z.number().default(-.25),
@@ -401,8 +596,8 @@ function apply(ctx, rawConfig = {}) {
401
596
  const skillUsage = ctx.get("skillUsage");
402
597
  if (skillUsage) {
403
598
  const original = feedback.record.bind(feedback);
404
- feedback.record = (target, rating, note, kind, recordIo) => {
405
- original(target, rating, note, kind ?? "session", recordIo ?? io);
599
+ feedback.record = (target, rating, note, kind) => {
600
+ original(target, rating, note, kind ?? "session");
406
601
  if (kind === "skill") {
407
602
  const score = feedback.score(target, "skill");
408
603
  const warn = score < (rawConfig.qualityWarnThreshold ?? -.25);
@@ -413,7 +608,7 @@ function apply(ctx, rawConfig = {}) {
413
608
  };
414
609
  }
415
610
  ctx.effect(() => () => {
416
- return feedback.waitIdle();
611
+ return Promise.all([feedback.persistCache(), feedback.waitIdle()]);
417
612
  }, "evolution-feedback.records");
418
613
  }
419
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.66",
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.66",
41
- "@lmzhen/dsh-skill-usage": "^0.1.0-rc.66"
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.66",
46
- "@lmzhen/dsh-evolution-io-node": "^0.1.0-rc.66",
47
- "@lmzhen/dsh-skill-usage": "^0.1.0-rc.66"
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
  }