@lmzhen/dsh-evolution-feedback 0.1.0-rc.70 → 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 +146 -28
- package/lib/types/index.d.ts +4 -1
- package/package.json +6 -6
package/lib/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import z from "@deepseek-ai/schemastery";
|
|
2
|
-
import { join } from "node:path";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
//#region ../evolution-core/src/io.ts
|
|
@@ -41,6 +41,17 @@ function evolutionIoAdapter(provider) {
|
|
|
41
41
|
}
|
|
42
42
|
};
|
|
43
43
|
}
|
|
44
|
+
/** Active-log split point (rc.71): when the active log reaches this many events
|
|
45
|
+
* the older half is rotated into an archive; the active stays bounded so a
|
|
46
|
+
* single append stays O(active) instead of O(total-history). Tunable default —
|
|
47
|
+
* callers may override per append (the tests use small values). */
|
|
48
|
+
const EVENT_LOG_ROTATE_AT = 4e3;
|
|
49
|
+
/** Archive file prefix: `events-<lastArchivedSeq>.json`. The active file is
|
|
50
|
+
* `events.json` and never matches this glob. */
|
|
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$/;
|
|
44
55
|
function eventsFile(home) {
|
|
45
56
|
return join(home, "evolution", "events.json");
|
|
46
57
|
}
|
|
@@ -68,39 +79,108 @@ function parseEvolutionEvents(raw) {
|
|
|
68
79
|
}
|
|
69
80
|
}
|
|
70
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
|
+
/**
|
|
71
93
|
* Append one event under the write lock (rc.68): `seq` = current max + 1
|
|
72
94
|
* computed inside the transact, so two processes appending concurrently never
|
|
73
95
|
* collide. A malformed log is refused (bytes preserved) and the append fails.
|
|
74
96
|
* Returns the assigned seq.
|
|
97
|
+
*
|
|
98
|
+
* Rotation (rc.71, 007 design): when the active log reaches `rotateAt`, the
|
|
99
|
+
* older half is copied into an archive inside the SAME transact (the archive
|
|
100
|
+
* path has its own lock, so no recursion) and the active is replaced with the
|
|
101
|
+
* newer half + the new event. seqs stay globally monotonic; a crash between
|
|
102
|
+
* archive write and active write leaves both copies, which the timeline merge
|
|
103
|
+
* dedupes by seq. An archive-write failure aborts the append (active keeps the
|
|
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.
|
|
75
110
|
*/
|
|
76
|
-
async function appendEvolutionEvent(io, path, event) {
|
|
111
|
+
async function appendEvolutionEvent(io, path, event, rotateAt = EVENT_LOG_ROTATE_AT) {
|
|
77
112
|
let assigned = 0;
|
|
78
|
-
await transactIo(io, path, (current) => {
|
|
113
|
+
await transactIo(io, path, async (current) => {
|
|
79
114
|
if (current !== null && current.trim() !== "") try {
|
|
80
115
|
JSON.parse(current);
|
|
81
116
|
} catch {
|
|
82
|
-
return
|
|
117
|
+
return current;
|
|
83
118
|
}
|
|
84
|
-
const
|
|
85
|
-
|
|
119
|
+
const nextEvents = await rotateIfDue(io, path, parseEvolutionEvents(current), rotateAt);
|
|
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));
|
|
86
122
|
const record = {
|
|
87
123
|
...event,
|
|
88
124
|
seq: maxSeq + 1,
|
|
89
125
|
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
90
126
|
};
|
|
91
127
|
assigned = record.seq;
|
|
92
|
-
return
|
|
128
|
+
return JSON.stringify({
|
|
93
129
|
version: 1,
|
|
94
|
-
events: [...
|
|
95
|
-
}, null, 2)
|
|
130
|
+
events: [...nextEvents, record]
|
|
131
|
+
}, null, 2);
|
|
96
132
|
});
|
|
97
133
|
if (assigned === 0) throw new Error(`evolution event log is malformed and was not touched: ${path}`);
|
|
98
134
|
return assigned;
|
|
99
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* Split the active log at its midpoint when due: the older half is written to
|
|
138
|
+
* `events-<lastArchivedSeq>.json` (await — a failed archive write aborts the
|
|
139
|
+
* append so the active is never truncated without its copy), old archives are
|
|
140
|
+
* pruned, and the newer half is returned as the next active body. No-op when
|
|
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).
|
|
143
|
+
*/
|
|
144
|
+
async function rotateIfDue(io, path, events, rotateAt) {
|
|
145
|
+
if (rotateAt < 2 || events.length < rotateAt) return events;
|
|
146
|
+
const mid = Math.ceil(events.length / 2);
|
|
147
|
+
const head = events.slice(0, mid);
|
|
148
|
+
const tail = events.slice(mid);
|
|
149
|
+
if (tail.length === 0) return events;
|
|
150
|
+
const anchor = tail[0]?.seq ?? 0;
|
|
151
|
+
const archivePath = join(dirname(path), `${EVENT_ARCHIVE_PREFIX}${anchor - 1}.json`);
|
|
152
|
+
await io.writeText(archivePath, JSON.stringify({
|
|
153
|
+
version: 1,
|
|
154
|
+
events: head
|
|
155
|
+
}, null, 2));
|
|
156
|
+
await retainEventArchives(io, path);
|
|
157
|
+
return tail;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Prune old event archives (rc.71): keep the newest `EVENT_LOG_RETAIN_ARCHIVES`.
|
|
161
|
+
* The name's numeric part is the last archived seq, so ordering is NUMERIC —
|
|
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.
|
|
165
|
+
*/
|
|
166
|
+
async function retainEventArchives(io, path) {
|
|
167
|
+
const dir = dirname(path);
|
|
168
|
+
const names = await listEventArchives(io, path);
|
|
169
|
+
const excess = names.slice(0, Math.max(0, names.length - 10));
|
|
170
|
+
for (const name of excess) await io.remove(join(dir, name)).catch(() => {});
|
|
171
|
+
}
|
|
100
172
|
/** Read the event log; a missing/whitespace-only file reads as empty,
|
|
101
173
|
* corrupt content is flagged (and refused on append). */
|
|
102
174
|
async function readEvolutionEvents(io, path) {
|
|
103
|
-
|
|
175
|
+
let raw;
|
|
176
|
+
try {
|
|
177
|
+
raw = await io.readText(path);
|
|
178
|
+
} catch {
|
|
179
|
+
return {
|
|
180
|
+
events: [],
|
|
181
|
+
malformed: true
|
|
182
|
+
};
|
|
183
|
+
}
|
|
104
184
|
if (raw === null || raw.trim() === "") return {
|
|
105
185
|
events: [],
|
|
106
186
|
malformed: false
|
|
@@ -122,6 +202,30 @@ async function readEvolutionEvents(io, path) {
|
|
|
122
202
|
};
|
|
123
203
|
}
|
|
124
204
|
}
|
|
205
|
+
/**
|
|
206
|
+
* Read the full timeline (rc.71): active log + all archives, merged by seq
|
|
207
|
+
* (active copy wins, duplicates only arise from the rotation crash window),
|
|
208
|
+
* sorted ascending. Per-file malformed flag as in `readEvolutionEvents`; a
|
|
209
|
+
* malformed (or unreadable) ARCHIVE is skipped — it never bricks the boot and
|
|
210
|
+
* it is still flagged.
|
|
211
|
+
*/
|
|
212
|
+
async function readEvolutionTimeline(io, path) {
|
|
213
|
+
const dir = dirname(path);
|
|
214
|
+
let malformed = false;
|
|
215
|
+
const bySeq = /* @__PURE__ */ new Map();
|
|
216
|
+
for (const name of await listEventArchives(io, path)) {
|
|
217
|
+
const read = await readEvolutionEvents(io, join(dir, name));
|
|
218
|
+
if (read.malformed) malformed = true;
|
|
219
|
+
for (const event of read.events) bySeq.set(event.seq, event);
|
|
220
|
+
}
|
|
221
|
+
const active = await readEvolutionEvents(io, path);
|
|
222
|
+
if (active.malformed) malformed = true;
|
|
223
|
+
for (const event of active.events) bySeq.set(event.seq, event);
|
|
224
|
+
return {
|
|
225
|
+
events: [...bySeq.values()].sort((a, b) => a.seq - b.seq),
|
|
226
|
+
malformed
|
|
227
|
+
};
|
|
228
|
+
}
|
|
125
229
|
//#endregion
|
|
126
230
|
//#region ../evolution-core/src/prompts.ts
|
|
127
231
|
/**
|
|
@@ -366,6 +470,12 @@ new RegExp(String.raw`ignore\s+${FILLER}(?:previous|above|prior|all)\s+${FILLER}
|
|
|
366
470
|
* @module @lmzhen/dsh-evolution-feedback
|
|
367
471
|
*/
|
|
368
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;
|
|
369
479
|
var EvolutionFeedback = class {
|
|
370
480
|
state = {
|
|
371
481
|
skills: {},
|
|
@@ -393,16 +503,19 @@ var EvolutionFeedback = class {
|
|
|
393
503
|
if (!path || !eventsPath) return;
|
|
394
504
|
await this.mutate(async () => {
|
|
395
505
|
const rawEvents = await io.readText(eventsPath);
|
|
396
|
-
|
|
506
|
+
const archiveNames = await listEventArchives(io, eventsPath);
|
|
507
|
+
if ((rawEvents === null || rawEvents.trim() === "") && archiveNames.length === 0) {
|
|
397
508
|
const aggregate = parseAggregate(await io.readText(path));
|
|
398
509
|
if (aggregate) try {
|
|
399
510
|
await migrateFeedbackEvents(io, eventsPath, aggregate);
|
|
400
511
|
} catch {}
|
|
401
512
|
}
|
|
402
|
-
const { events } = await
|
|
513
|
+
const { events } = await readEvolutionTimeline(io, eventsPath);
|
|
403
514
|
const cache = parseCache(await io.readText(path));
|
|
404
515
|
const maxSeq = events.reduce((max, event) => Math.max(max, event.seq), 0);
|
|
405
|
-
const
|
|
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);
|
|
406
519
|
this.state = {
|
|
407
520
|
skills: {
|
|
408
521
|
...truth.skills,
|
|
@@ -437,13 +550,13 @@ var EvolutionFeedback = class {
|
|
|
437
550
|
if (!recordIo || !eventsPath) return;
|
|
438
551
|
this.mutate(async () => {
|
|
439
552
|
try {
|
|
440
|
-
await appendEvolutionEvent(recordIo, eventsPath, {
|
|
553
|
+
if (await appendEvolutionEvent(recordIo, eventsPath, {
|
|
441
554
|
type: "feedback",
|
|
442
555
|
target,
|
|
443
556
|
kind,
|
|
444
557
|
rating,
|
|
445
558
|
note
|
|
446
|
-
});
|
|
559
|
+
}) % CACHE_SNAP_EVERY === 0) await this.writeCacheNow();
|
|
447
560
|
} catch (error) {}
|
|
448
561
|
});
|
|
449
562
|
}
|
|
@@ -464,23 +577,28 @@ var EvolutionFeedback = class {
|
|
|
464
577
|
waitIdle() {
|
|
465
578
|
return this.chain;
|
|
466
579
|
}
|
|
467
|
-
/**
|
|
468
|
-
|
|
580
|
+
/** Snapshot the boot cache from the log truth (rc.68/rc.72); best-effort. */
|
|
581
|
+
async writeCacheNow() {
|
|
469
582
|
const path = this.path;
|
|
470
583
|
const eventsPath = this.eventsPath;
|
|
471
584
|
const recordIo = this.io;
|
|
472
|
-
if (!path || !eventsPath || !recordIo) return
|
|
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() {
|
|
473
600
|
return this.mutate(async () => {
|
|
474
|
-
|
|
475
|
-
const { events } = await readEvolutionEvents(recordIo, eventsPath);
|
|
476
|
-
const maxSeq = events.reduce((max, event) => Math.max(max, event.seq), 0);
|
|
477
|
-
if (maxSeq === 0) return;
|
|
478
|
-
await recordIo.writeText(path, JSON.stringify({
|
|
479
|
-
version: CACHE_VERSION,
|
|
480
|
-
lastSeq: maxSeq,
|
|
481
|
-
...foldFeedbackState(events)
|
|
482
|
-
}, null, 2));
|
|
483
|
-
} catch {}
|
|
601
|
+
await this.writeCacheNow();
|
|
484
602
|
});
|
|
485
603
|
}
|
|
486
604
|
};
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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.
|
|
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.
|
|
41
|
-
"@lmzhen/dsh-skill-usage": "^0.1.0-rc.
|
|
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.
|
|
46
|
-
"@lmzhen/dsh-evolution-io-node": "^0.1.0-rc.
|
|
47
|
-
"@lmzhen/dsh-skill-usage": "^0.1.0-rc.
|
|
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
|
}
|