@lmzhen/dsh-evolution-feedback 0.3.16 → 0.3.18

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
@@ -38,6 +38,10 @@ function evolutionIoAdapter(provider) {
38
38
  isSymlink: (path) => {
39
39
  const io = provider();
40
40
  return io.isSymlink ? io.isSymlink(path) : Promise.resolve(null);
41
+ },
42
+ mtime: (path) => {
43
+ const io = provider();
44
+ return io.mtime ? io.mtime(path) : Promise.resolve(null);
41
45
  }
42
46
  };
43
47
  }
@@ -2870,11 +2874,15 @@ var EvolutionFeedback = class {
2870
2874
  path;
2871
2875
  eventsPath;
2872
2876
  io;
2873
- constructor(io, home = process.env.DSH_HOME ?? join(homedir(), ".dsh"), pathOverride) {
2874
- if (io) {
2875
- this.path = pathOverride ?? join(home, "evolution", "feedback.json");
2876
- this.eventsPath = eventsFile(home);
2877
- }
2877
+ warn;
2878
+ constructor(io, home = process.env.DSH_HOME ?? join(homedir(), ".dsh"), pathOverride, warn = () => {}) {
2879
+ this.path = pathOverride ?? join(home, "evolution", "feedback.json");
2880
+ this.eventsPath = eventsFile(home);
2881
+ this.io = io;
2882
+ this.warn = warn;
2883
+ }
2884
+ /** Bind the evolution IO backend after construction (S6.4 deferred binding). */
2885
+ attachIo(io) {
2878
2886
  this.io = io;
2879
2887
  }
2880
2888
  mutate(task) {
@@ -2896,11 +2904,11 @@ var EvolutionFeedback = class {
2896
2904
  } catch {}
2897
2905
  }
2898
2906
  const { events } = await readEvolutionTimeline(io, eventsPath);
2899
- const cache = parseCache(await io.readText(path));
2907
+ const cache = parseCache(await io.readText(path), this.warn);
2900
2908
  const maxSeq = events.reduce((max, event) => Math.max(max, event.seq), 0);
2901
2909
  const floor = events[0]?.seq ?? 0;
2902
2910
  const usableCache = cache && cache.lastSeq >= floor - 1 ? cache : null;
2903
- const truth = usableCache ? foldWithDelta(usableCache, events) : foldFeedbackState(events);
2911
+ const truth = usableCache ? foldWithDelta(usableCache, events, this.warn) : foldFeedbackState(events, this.warn);
2904
2912
  this.state = {
2905
2913
  skills: {
2906
2914
  ...truth.skills,
@@ -2927,6 +2935,7 @@ var EvolutionFeedback = class {
2927
2935
  positive: 0,
2928
2936
  negative: 0
2929
2937
  };
2938
+ const previousNote = current.lastNote;
2930
2939
  current[rating] += 1;
2931
2940
  if (note !== void 0) current.lastNote = note;
2932
2941
  table[target] = current;
@@ -2942,7 +2951,14 @@ var EvolutionFeedback = class {
2942
2951
  rating,
2943
2952
  note
2944
2953
  }) % CACHE_SNAP_EVERY === 0) await this.writeCacheNow();
2945
- } catch (error) {}
2954
+ } catch (error) {
2955
+ const rollback = table[target];
2956
+ if (rollback) {
2957
+ rollback[rating] -= 1;
2958
+ if (note !== void 0) if (previousNote === void 0) delete rollback.lastNote;
2959
+ else rollback.lastNote = previousNote;
2960
+ }
2961
+ }
2946
2962
  });
2947
2963
  }
2948
2964
  score(target, kind = "session") {
@@ -2972,11 +2988,12 @@ var EvolutionFeedback = class {
2972
2988
  const { events } = await readEvolutionTimeline(recordIo, eventsPath);
2973
2989
  const maxSeq = events.reduce((max, event) => Math.max(max, event.seq), 0);
2974
2990
  if (maxSeq === 0) return;
2975
- await recordIo.writeText(path, JSON.stringify({
2991
+ const body = JSON.stringify({
2976
2992
  version: CACHE_VERSION,
2977
2993
  lastSeq: maxSeq,
2978
- ...foldFeedbackState(events)
2979
- }, null, 2));
2994
+ ...foldFeedbackState(events, this.warn)
2995
+ }, null, 2);
2996
+ await recordIo.writeText(path, body);
2980
2997
  } catch {}
2981
2998
  }
2982
2999
  /** Rebuild the boot cache from the log truth (rc.68); best-effort, queued
@@ -3050,48 +3067,87 @@ function parseAggregate(raw) {
3050
3067
  return null;
3051
3068
  }
3052
3069
  }
3053
- function parseCache(raw) {
3070
+ function parseCache(raw, warn = () => {}) {
3054
3071
  if (raw === null) return null;
3055
3072
  try {
3056
3073
  const parsed = JSON.parse(raw);
3057
- if (parsed.version !== CACHE_VERSION || typeof parsed.lastSeq !== "number") return null;
3074
+ if (parsed.version !== CACHE_VERSION || typeof parsed.lastSeq !== "number" || !Number.isFinite(parsed.lastSeq)) return null;
3058
3075
  if (!isRecord(parsed.skills) || !isRecord(parsed.sessions)) return null;
3059
3076
  return {
3060
3077
  lastSeq: parsed.lastSeq,
3061
3078
  state: {
3062
- skills: parsed.skills,
3063
- sessions: parsed.sessions
3079
+ skills: sanitizeCacheRecords(parsed.skills, "skill", warn),
3080
+ sessions: sanitizeCacheRecords(parsed.sessions, "session", warn)
3064
3081
  }
3065
3082
  };
3066
3083
  } catch {
3067
3084
  return null;
3068
3085
  }
3069
3086
  }
3087
+ /** Per-record numeric-domain validation (S6.4): a record whose `positive` or
3088
+ * `negative` is not a finite number >= 0, or whose `lastNote` is not a string,
3089
+ * would fold as NaN into the usage aggregate — skip it with a warn instead of
3090
+ * corrupting the state. A record with no valid count is dropped entirely. */
3091
+ function sanitizeCacheRecords(input, kind, warn) {
3092
+ const out = {};
3093
+ for (const [target, value] of Object.entries(input)) {
3094
+ const record = sanitizeFeedbackRecord(value, target, kind, warn);
3095
+ if (record) out[target] = record;
3096
+ }
3097
+ return out;
3098
+ }
3099
+ function sanitizeFeedbackRecord(value, target, kind, warn) {
3100
+ if (!isRecord(value)) {
3101
+ warn(`evolution-feedback: skipping cache record for ${kind} "${target}": not a record`);
3102
+ return null;
3103
+ }
3104
+ const positive = value.positive;
3105
+ const negative = value.negative;
3106
+ const note = value.lastNote;
3107
+ const validCount = (n) => typeof n === "number" && Number.isFinite(n) && n >= 0;
3108
+ if (!validCount(positive) || !validCount(negative)) {
3109
+ warn(`evolution-feedback: skipping cache record for ${kind} "${target}": positive/negative must be finite numbers >= 0`);
3110
+ return null;
3111
+ }
3112
+ if (note !== void 0 && typeof note !== "string") {
3113
+ warn(`evolution-feedback: skipping cache record for ${kind} "${target}": lastNote must be a string`);
3114
+ return null;
3115
+ }
3116
+ return {
3117
+ positive,
3118
+ negative,
3119
+ ...note !== void 0 ? { lastNote: note } : {}
3120
+ };
3121
+ }
3070
3122
  function isRecord(value) {
3071
3123
  return typeof value === "object" && value !== null && !Array.isArray(value);
3072
3124
  }
3073
3125
  /** Fold all feedback events from zero (the truth view). */
3074
- function foldFeedbackState(events) {
3126
+ function foldFeedbackState(events, warn = () => {}) {
3075
3127
  const state = {
3076
3128
  skills: {},
3077
3129
  sessions: {}
3078
3130
  };
3079
- for (const event of events) applyFeedbackEvent(state, event);
3131
+ for (const event of events) applyFeedbackEvent(state, event, warn);
3080
3132
  return state;
3081
3133
  }
3082
3134
  /** Fold events after the cache's lastSeq onto the cached aggregates. */
3083
- function foldWithDelta(cache, events) {
3135
+ function foldWithDelta(cache, events, warn = () => {}) {
3084
3136
  const state = {
3085
3137
  skills: { ...cache.state.skills },
3086
3138
  sessions: { ...cache.state.sessions }
3087
3139
  };
3088
- for (const event of events) if (event.seq > cache.lastSeq) applyFeedbackEvent(state, event);
3140
+ for (const event of events) if (event.seq > cache.lastSeq) applyFeedbackEvent(state, event, warn);
3089
3141
  return state;
3090
3142
  }
3091
- function applyFeedbackEvent(state, event) {
3143
+ function applyFeedbackEvent(state, event, warn = () => {}) {
3092
3144
  if (event.type !== "feedback") return;
3145
+ if (event.rating !== "positive" && event.rating !== "negative") {
3146
+ warn(`evolution-feedback: skipping feedback event with invalid rating: ${String(event.rating)}`);
3147
+ return;
3148
+ }
3093
3149
  const target = event.target;
3094
- if (target === void 0 || event.rating === void 0) return;
3150
+ if (target === void 0) return;
3095
3151
  const table = event.kind === "skill" ? state.skills : state.sessions;
3096
3152
  const record = table[target] ?? {
3097
3153
  positive: 0,
@@ -3101,7 +3157,9 @@ function applyFeedbackEvent(state, event) {
3101
3157
  if (event.note !== void 0) record.lastNote = event.note;
3102
3158
  table[target] = record;
3103
3159
  }
3104
- /** Synthesize one event per aggregate count unit, lastNote on the final event (migration). */
3160
+ /** Synthesize one event per aggregate count unit, lastNote on the final event
3161
+ * (migration). A zero-count record still carrying a lastNote would otherwise
3162
+ * drop the note — emit a dedicated note event (S6.4) so it survives. */
3105
3163
  function synthesizeFeedbackEvents(aggregate) {
3106
3164
  const events = [];
3107
3165
  const at = (/* @__PURE__ */ new Date()).toISOString();
@@ -3123,14 +3181,22 @@ function synthesizeFeedbackEvents(aggregate) {
3123
3181
  target,
3124
3182
  rating: "negative"
3125
3183
  });
3126
- if (record.lastNote !== void 0 && events.length >= first) {
3184
+ if (record.lastNote !== void 0) if (events.length >= first) {
3127
3185
  const last = events.length - 1;
3128
3186
  const final = events[last];
3129
3187
  if (final) events[last] = {
3130
3188
  ...final,
3131
3189
  note: record.lastNote
3132
3190
  };
3133
- }
3191
+ } else events.push({
3192
+ seq: events.length + 1,
3193
+ at,
3194
+ type: "feedback",
3195
+ kind,
3196
+ target,
3197
+ rating: "positive",
3198
+ note: record.lastNote
3199
+ });
3134
3200
  };
3135
3201
  for (const [target, record] of Object.entries(aggregate.skills)) emitTarget("skill", target, record);
3136
3202
  for (const [target, record] of Object.entries(aggregate.sessions)) emitTarget("session", target, record);
@@ -3142,27 +3208,35 @@ const Config = z.object({
3142
3208
  path: z.string().default("")
3143
3209
  });
3144
3210
  function apply(ctx, rawConfig = {}) {
3145
- const ioRegistry = ctx.get("evolutionIo");
3146
- const io = ioRegistry ? evolutionIoAdapter(() => ioRegistry.provider()) : void 0;
3147
- const feedback = new EvolutionFeedback(io, process.env.DSH_HOME ?? join(homedir(), ".dsh"), rawConfig.path || void 0);
3148
- if (io) feedback.restore(io).catch((error) => {
3149
- ctx.logger.warn(error);
3211
+ const feedback = new EvolutionFeedback(void 0, process.env.DSH_HOME ?? join(homedir(), ".dsh"), rawConfig.path || void 0, (message) => {
3212
+ ctx.logger.warn(message);
3150
3213
  });
3151
3214
  ctx.provide("evolutionFeedback", feedback);
3152
- const skillUsage = ctx.get("skillUsage");
3153
- if (skillUsage) {
3154
- const original = feedback.record.bind(feedback);
3215
+ ctx.inject(["evolutionIo"], (ioCtx) => {
3216
+ const ioRegistry = ioCtx.evolutionIo;
3217
+ const io = evolutionIoAdapter(() => ioRegistry.provider());
3218
+ feedback.attachIo(io);
3219
+ feedback.restore(io).catch((error) => {
3220
+ ioCtx.logger.warn(error);
3221
+ });
3222
+ });
3223
+ const baseRecord = feedback.record.bind(feedback);
3224
+ let qualityWired = false;
3225
+ ctx.inject(["skillUsage"], (skillCtx) => {
3226
+ if (qualityWired) return;
3227
+ qualityWired = true;
3228
+ const skillUsage = skillCtx.skillUsage;
3155
3229
  feedback.record = (target, rating, note, kind) => {
3156
- original(target, rating, note, kind ?? "session");
3230
+ baseRecord(target, rating, note, kind ?? "session");
3157
3231
  if (kind === "skill") {
3158
3232
  const score = feedback.score(target, "skill");
3159
3233
  const warn = score < (rawConfig.qualityWarnThreshold ?? -.25);
3160
3234
  skillUsage.setQuality(target, score, warn).catch((error) => {
3161
- ctx.logger.warn(error);
3235
+ skillCtx.logger.warn(error);
3162
3236
  });
3163
3237
  }
3164
3238
  };
3165
- }
3239
+ });
3166
3240
  ctx.effect(() => () => {
3167
3241
  return Promise.all([feedback.persistCache(), feedback.waitIdle()]);
3168
3242
  }, "evolution-feedback.records");
@@ -35,8 +35,11 @@ export declare class EvolutionFeedback {
35
35
  private chain;
36
36
  private readonly path?;
37
37
  private readonly eventsPath?;
38
- private readonly io;
39
- constructor(io?: IoLike, home?: string, pathOverride?: string);
38
+ private io;
39
+ private warn;
40
+ constructor(io?: IoLike, home?: string, pathOverride?: string, warn?: (message: string) => void);
41
+ /** Bind the evolution IO backend after construction (S6.4 deferred binding). */
42
+ attachIo(io: IoLike): void;
40
43
  private mutate;
41
44
  restore(io: IoLike): Promise<void>;
42
45
  record(target: string, rating: 'positive' | 'negative', note?: string, kind?: 'skill' | 'session'): void;
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.3.16",
4
+ "version": "0.3.18",
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.3.16",
41
- "@lmzhen/dsh-skill-usage": "^0.3.16"
40
+ "@lmzhen/dsh-evolution-io": "^0.3.18",
41
+ "@lmzhen/dsh-skill-usage": "^0.3.18"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
45
- "@lmzhen/dsh-evolution-io": "^0.3.16",
46
- "@lmzhen/dsh-evolution-io-node": "^0.3.16",
47
- "@lmzhen/dsh-skill-usage": "^0.3.16"
45
+ "@lmzhen/dsh-evolution-io": "^0.3.18",
46
+ "@lmzhen/dsh-evolution-io-node": "^0.3.18",
47
+ "@lmzhen/dsh-skill-usage": "^0.3.18"
48
48
  }
49
49
  }