@lmzhen/dsh-evolution-feedback 0.1.0-rc.7 → 0.1.0-rc.71
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 +661 -27
- package/lib/types/index.d.ts +28 -7
- package/package.json +8 -8
package/lib/index.js
CHANGED
|
@@ -1,6 +1,430 @@
|
|
|
1
1
|
import z from "@deepseek-ai/schemastery";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
2
4
|
import { homedir } from "node:os";
|
|
3
|
-
|
|
5
|
+
//#region ../evolution-core/src/io.ts
|
|
6
|
+
/**
|
|
7
|
+
* Run `task` inside `io.transact` when the backend provides it; otherwise fall
|
|
8
|
+
* back to a plain read → task → write/remove sequence (no cross-process lock —
|
|
9
|
+
* callers keep their single-process serialize chain as the second layer).
|
|
10
|
+
*/
|
|
11
|
+
async function transactIo(io, path, task) {
|
|
12
|
+
if (io.transact) {
|
|
13
|
+
await io.transact(path, task);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const next = await task(await io.readText(path));
|
|
17
|
+
if (next === null) await io.remove(path);
|
|
18
|
+
else await io.writeText(path, next);
|
|
19
|
+
}
|
|
20
|
+
/** Lazy adapter over an IO provider registry, shared by every evolution consumer. */
|
|
21
|
+
function evolutionIoAdapter(provider) {
|
|
22
|
+
return {
|
|
23
|
+
readText: (path) => provider().readText(path),
|
|
24
|
+
writeText: (path, content) => provider().writeText(path, content),
|
|
25
|
+
remove: (path) => provider().remove(path),
|
|
26
|
+
list: (path) => provider().list(path),
|
|
27
|
+
exists: (path) => provider().exists(path),
|
|
28
|
+
rename: (path, destination) => provider().rename(path, destination),
|
|
29
|
+
copy: (path, destination) => provider().copy(path, destination),
|
|
30
|
+
size: (path) => {
|
|
31
|
+
const io = provider();
|
|
32
|
+
return io.size ? io.size(path) : Promise.resolve(null);
|
|
33
|
+
},
|
|
34
|
+
transact: (path, task) => {
|
|
35
|
+
const io = provider();
|
|
36
|
+
return io.transact ? io.transact(path, task) : transactIo(io, path, task);
|
|
37
|
+
},
|
|
38
|
+
isSymlink: (path) => {
|
|
39
|
+
const io = provider();
|
|
40
|
+
return io.isSymlink ? io.isSymlink(path) : Promise.resolve(null);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
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
|
+
function eventsFile(home) {
|
|
53
|
+
return join(home, "evolution", "events.json");
|
|
54
|
+
}
|
|
55
|
+
function isEventRecord(event) {
|
|
56
|
+
return typeof event === "object" && event !== null && typeof event.seq === "number";
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Parse an event log body. A missing file, a whitespace-only file (rc.69:
|
|
60
|
+
* rebuildable, NOT malformed) or a corrupt one reads as empty; corrupt content
|
|
61
|
+
* is still refused on append, never overwritten.
|
|
62
|
+
*
|
|
63
|
+
* Per-entry normalization (rc.70 F-1): entries without a numeric `seq` are
|
|
64
|
+
* skipped here and dropped at the next append — valid entries survive, the
|
|
65
|
+
* damaged record is the only loss (self-heal semantics, matching the usage
|
|
66
|
+
* sidecar's per-field normalization on read).
|
|
67
|
+
*/
|
|
68
|
+
function parseEvolutionEvents(raw) {
|
|
69
|
+
if (raw === null || raw.trim() === "") return [];
|
|
70
|
+
try {
|
|
71
|
+
const parsed = JSON.parse(raw);
|
|
72
|
+
if (!Array.isArray(parsed.events)) return [];
|
|
73
|
+
return parsed.events.filter(isEventRecord);
|
|
74
|
+
} catch {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Append one event under the write lock (rc.68): `seq` = current max + 1
|
|
80
|
+
* computed inside the transact, so two processes appending concurrently never
|
|
81
|
+
* collide. A malformed log is refused (bytes preserved) and the append fails.
|
|
82
|
+
* Returns the assigned seq.
|
|
83
|
+
*
|
|
84
|
+
* Rotation (rc.71, 007 design): when the active log reaches `rotateAt`, the
|
|
85
|
+
* older half is copied into an archive inside the SAME transact (the archive
|
|
86
|
+
* path has its own lock, so no recursion) and the active is replaced with the
|
|
87
|
+
* newer half + the new event. seqs stay globally monotonic; a crash between
|
|
88
|
+
* archive write and active write leaves both copies, which the timeline merge
|
|
89
|
+
* dedupes by seq. An archive-write failure aborts the append (active keeps the
|
|
90
|
+
* full old content — no loss) and the caller's best-effort handling applies.
|
|
91
|
+
*/
|
|
92
|
+
async function appendEvolutionEvent(io, path, event, rotateAt = EVENT_LOG_ROTATE_AT) {
|
|
93
|
+
let assigned = 0;
|
|
94
|
+
await transactIo(io, path, async (current) => {
|
|
95
|
+
if (current !== null && current.trim() !== "") try {
|
|
96
|
+
JSON.parse(current);
|
|
97
|
+
} catch {
|
|
98
|
+
return current;
|
|
99
|
+
}
|
|
100
|
+
const nextEvents = await rotateIfDue(io, path, parseEvolutionEvents(current), rotateAt);
|
|
101
|
+
const maxSeq = nextEvents.reduce((max, entry) => Math.max(max, entry.seq), 0);
|
|
102
|
+
const record = {
|
|
103
|
+
...event,
|
|
104
|
+
seq: maxSeq + 1,
|
|
105
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
106
|
+
};
|
|
107
|
+
assigned = record.seq;
|
|
108
|
+
return JSON.stringify({
|
|
109
|
+
version: 1,
|
|
110
|
+
events: [...nextEvents, record]
|
|
111
|
+
}, null, 2);
|
|
112
|
+
});
|
|
113
|
+
if (assigned === 0) throw new Error(`evolution event log is malformed and was not touched: ${path}`);
|
|
114
|
+
return assigned;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Split the active log at its midpoint when due: the older half is written to
|
|
118
|
+
* `events-<lastArchivedSeq>.json` (await — a failed archive write aborts the
|
|
119
|
+
* append so the active is never truncated without its copy), old archives are
|
|
120
|
+
* pruned, and the newer half is returned as the next active body. No-op when
|
|
121
|
+
* under the threshold.
|
|
122
|
+
*/
|
|
123
|
+
async function rotateIfDue(io, path, events, rotateAt) {
|
|
124
|
+
if (events.length < rotateAt) return events;
|
|
125
|
+
const mid = Math.ceil(events.length / 2);
|
|
126
|
+
const head = events.slice(0, mid);
|
|
127
|
+
const tail = events.slice(mid);
|
|
128
|
+
const anchor = tail[0]?.seq ?? events[events.length - 1]?.seq ?? 0;
|
|
129
|
+
const archivePath = join(dirname(path), `${EVENT_ARCHIVE_PREFIX}${anchor - 1}.json`);
|
|
130
|
+
await io.writeText(archivePath, JSON.stringify({
|
|
131
|
+
version: 1,
|
|
132
|
+
events: head
|
|
133
|
+
}, null, 2));
|
|
134
|
+
await retainEventArchives(io, path);
|
|
135
|
+
return tail;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Prune old event archives (rc.71): keep the newest `EVENT_LOG_RETAIN_ARCHIVES`.
|
|
139
|
+
* The name's numeric part is the last archived seq, so ordering is NUMERIC —
|
|
140
|
+
* lexicographic would rank `events-10` before `events-2`. Best-effort per
|
|
141
|
+
* removal; exported for the retention test.
|
|
142
|
+
*/
|
|
143
|
+
async function retainEventArchives(io, path) {
|
|
144
|
+
const dir = dirname(path);
|
|
145
|
+
const names = (await io.list(dir)).filter((name) => name.startsWith("events-") && name.endsWith(".json"));
|
|
146
|
+
const archiveSeq = (name) => Number.parseInt(name.slice(7, name.length - 5), 10) || 0;
|
|
147
|
+
names.sort((a, b) => archiveSeq(a) - archiveSeq(b));
|
|
148
|
+
const excess = names.slice(0, Math.max(0, names.length - 10));
|
|
149
|
+
for (const name of excess) await io.remove(join(dir, name)).catch(() => {});
|
|
150
|
+
}
|
|
151
|
+
/** Read the event log; a missing/whitespace-only file reads as empty,
|
|
152
|
+
* corrupt content is flagged (and refused on append). */
|
|
153
|
+
async function readEvolutionEvents(io, path) {
|
|
154
|
+
const raw = await io.readText(path);
|
|
155
|
+
if (raw === null || raw.trim() === "") return {
|
|
156
|
+
events: [],
|
|
157
|
+
malformed: false
|
|
158
|
+
};
|
|
159
|
+
try {
|
|
160
|
+
const parsed = JSON.parse(raw);
|
|
161
|
+
if (!Array.isArray(parsed.events)) return {
|
|
162
|
+
events: [],
|
|
163
|
+
malformed: false
|
|
164
|
+
};
|
|
165
|
+
return {
|
|
166
|
+
events: parsed.events.filter(isEventRecord),
|
|
167
|
+
malformed: false
|
|
168
|
+
};
|
|
169
|
+
} catch {
|
|
170
|
+
return {
|
|
171
|
+
events: [],
|
|
172
|
+
malformed: true
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Read the full timeline (rc.71): active log + all archives, merged by seq
|
|
178
|
+
* (active copy wins, duplicates only arise from the rotation crash window),
|
|
179
|
+
* sorted ascending. Per-file malformed flag as in `readEvolutionEvents`; a
|
|
180
|
+
* malformed ARCHIVE is skipped (never bricks the boot) and still flagged.
|
|
181
|
+
*/
|
|
182
|
+
async function readEvolutionTimeline(io, path) {
|
|
183
|
+
const dir = dirname(path);
|
|
184
|
+
const names = (await io.list(dir)).filter((name) => name.startsWith("events-") && name.endsWith(".json")).sort();
|
|
185
|
+
let malformed = false;
|
|
186
|
+
const bySeq = /* @__PURE__ */ new Map();
|
|
187
|
+
for (const name of names) {
|
|
188
|
+
const read = await readEvolutionEvents(io, join(dir, name));
|
|
189
|
+
if (read.malformed) malformed = true;
|
|
190
|
+
for (const event of read.events) bySeq.set(event.seq, event);
|
|
191
|
+
}
|
|
192
|
+
const active = await readEvolutionEvents(io, path);
|
|
193
|
+
if (active.malformed) malformed = true;
|
|
194
|
+
for (const event of active.events) bySeq.set(event.seq, event);
|
|
195
|
+
return {
|
|
196
|
+
events: [...bySeq.values()].sort((a, b) => a.seq - b.seq),
|
|
197
|
+
malformed
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
//#endregion
|
|
201
|
+
//#region ../evolution-core/src/prompts.ts
|
|
202
|
+
/**
|
|
203
|
+
* Review and curation prompts adapted from Hermes Agent
|
|
204
|
+
* `agent/background_review.py`, `agent/curator.py`, and
|
|
205
|
+
* `agent/learn_prompt.py`, with tool names translated to the DSH-native
|
|
206
|
+
* catalog (`memory`, `skill_manage`, `skill`, `bash`, `str_replace_editor`).
|
|
207
|
+
*
|
|
208
|
+
* Alignment policy (2026-08-29): the OPERATIONAL steps and instructions the
|
|
209
|
+
* model follows mirror the Hermes originals structurally (signal list,
|
|
210
|
+
* preference order, support-file taxonomy, curator package integrity,
|
|
211
|
+
* consolidated/pruned reporting block). Tool and platform differences are
|
|
212
|
+
* DSH-adapted (native tool names, pinned-within-review semantics, this
|
|
213
|
+
* platform's index cap), and DSH-only additions are marked as such.
|
|
214
|
+
*
|
|
215
|
+
* Every prompt is pinned in a versioned bundle. Review workers verify the
|
|
216
|
+
* bundle digest before spending a model call, so a partially-patched
|
|
217
|
+
* deployment fails closed instead of silently running a truncated prompt.
|
|
218
|
+
*/
|
|
219
|
+
/**
|
|
220
|
+
* Prompt bundle identity. Bump both id and version whenever a prompt's text
|
|
221
|
+
* changes semantically: the bundle digest is the fail-closed signal for
|
|
222
|
+
* review workers, so a stale id across deployments must be distinguishable.
|
|
223
|
+
*/
|
|
224
|
+
const PROMPT_BUNDLE_ID = "dsh-evolution@7";
|
|
225
|
+
const MEMORY_REVIEW_PROMPT = `[Auto-review — Memory]
|
|
226
|
+
Review the conversation above and consider saving to memory if appropriate.
|
|
227
|
+
|
|
228
|
+
Focus on:
|
|
229
|
+
1. Has the user revealed things about themselves — persona, desires, preferences, or personal details worth remembering?
|
|
230
|
+
2. Has the user expressed expectations about how you should behave, their work style, or ways they want you to operate?
|
|
231
|
+
|
|
232
|
+
If something stands out, save it using the memory tool.
|
|
233
|
+
If nothing is worth saving, just say "Nothing to save." and stop.`;
|
|
234
|
+
const SKILL_REVIEW_PROMPT = `[Auto-review — Skills]
|
|
235
|
+
Review the conversation above and update the skill library. Be ACTIVE — most sessions produce at least one skill update, even if small. A pass that does nothing is a missed learning opportunity, not a neutral outcome.
|
|
236
|
+
|
|
237
|
+
Target shape of the library: CLASS-LEVEL skills, each with a rich SKILL.md and a references/ directory for session-specific detail. Not a long flat list of narrow one-session-one-skill entries. This shapes HOW you update, not WHETHER you update.
|
|
238
|
+
|
|
239
|
+
Signals to look for (any one of these warrants action):
|
|
240
|
+
• User corrected your style, tone, format, legibility, or verbosity. Frustration signals like 'stop doing X', 'this is too verbose', 'don't format like this', 'why are you explaining', 'just give me the answer', 'you always do Y and I hate it', or an explicit 'remember this' are FIRST-CLASS skill signals, not just memory signals. Update the relevant skill(s) to embed the preference so the next session starts already knowing.
|
|
241
|
+
• User corrected your workflow, approach, or sequence of steps. Encode the correction as a pitfall or explicit step in the skill that governs that class of task.
|
|
242
|
+
• Non-trivial technique, fix, workaround, debugging path, or tool-usage pattern emerged that a future session would benefit from. Capture it.
|
|
243
|
+
• A skill that got loaded or consulted this session turned out to be wrong, missing a step, or outdated. Patch it NOW.
|
|
244
|
+
|
|
245
|
+
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.
|
|
246
|
+
|
|
247
|
+
Preference order — prefer the earliest action that fits, but do pick one when a signal above fired:
|
|
248
|
+
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.
|
|
249
|
+
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.
|
|
250
|
+
3. ADD A SUPPORT FILE under an existing umbrella. Skills can be packaged with three kinds of support files — use the right directory per kind:
|
|
251
|
+
• references/<topic>.md — session-specific detail (error transcripts, reproduction recipes, provider quirks) AND condensed knowledge banks: quoted research, API docs, external authoritative excerpts, or domain notes you found while working on the problem. Write it concise and for the value of the task, not as a full mirror of upstream docs.
|
|
252
|
+
• templates/<name>.<ext> — starter files meant to be copied and modified (boilerplate configs, scaffolding, a known-good example the agent can reproduce with modifications).
|
|
253
|
+
• scripts/<name>.<ext> — statically re-runnable actions the skill can invoke directly (verification scripts, fixture generators, deterministic probes, anything the agent should run rather than hand-type each time).
|
|
254
|
+
Add support files via skill_manage action=write_file with file_path starting 'references/', 'templates/', or 'scripts/'. The umbrella's SKILL.md should gain a one-line pointer to any new support file so future agents know it exists.
|
|
255
|
+
4. CREATE A NEW CLASS-LEVEL UMBRELLA SKILL when no existing skill covers the class. The name MUST be at the class level. The name MUST NOT be a specific PR number, error string, feature codename, library-alone name, or 'fix-X / debug-Y / audit-Z-today' session artifact. If the proposed name only makes sense for today's task, it's wrong — fall back to (1), (2), or (3).
|
|
256
|
+
|
|
257
|
+
User-preference embedding (important): when the user expressed a style/format/workflow preference, the update belongs in the SKILL.md body, not just in memory. Memory captures 'who the user is and what the current situation and state of your operations are'; skills capture 'how to do this class of task for this user'. When they complain about how you handled a task, the skill that governs that task needs to carry the lesson.
|
|
258
|
+
|
|
259
|
+
If you notice two existing skills that overlap, note it in your reply — the background curator handles consolidation at scale.
|
|
260
|
+
|
|
261
|
+
Two-tier deposition discipline (DSH addition, same spirit as the umbrella rule): before writing, classify the knowledge:
|
|
262
|
+
• PATTERN (reusable — symptom → mechanism → fix → verification, still valuable next session) belongs in the SKILL.md body.
|
|
263
|
+
• LOG (one-off — commit SHAs, npm/profile states, what this release changed, this session's process narrative) belongs in a references/ file, never the body. Body density IS reuse rate. Keep new entries tight: a pattern fits in 2-8 physical lines; prefer changing the current-state pointer over appending history.
|
|
264
|
+
|
|
265
|
+
Protected skills (DO NOT edit these):
|
|
266
|
+
• Bundled skills (shipped with the platform).
|
|
267
|
+
• Hub-installed skills (installed from a hub).
|
|
268
|
+
Pinned skills are read-only to THIS background review pass — the pinned write guard refuses background changes, so only the foreground may update or archive them. Foreground and delegated-subagent writes to pinned skills remain allowed.
|
|
269
|
+
If the only skills that need updating are protected, say 'Nothing to save.' and stop.
|
|
270
|
+
|
|
271
|
+
Do NOT capture (these become persistent self-imposed constraints that bite you later when the environment changes):
|
|
272
|
+
• Environment-dependent failures: missing binaries, fresh-install errors, post-migration path mismatches, 'command not found', unconfigured credentials, uninstalled packages. The user can fix these — they are not durable rules.
|
|
273
|
+
• Negative claims about tools or features ('browser tools do not work', 'X tool is broken', 'cannot use Y'). These harden into refusals the agent cites against itself for months after the actual problem was fixed.
|
|
274
|
+
• Session-specific transient errors that resolved before the conversation ended. If retrying worked, the lesson is the retry pattern, not the original failure.
|
|
275
|
+
• One-off task narratives. A user asking 'summarize today's market' or 'analyze this PR' is not a class of work that warrants a skill.
|
|
276
|
+
|
|
277
|
+
If a tool failed because of setup state, capture the FIX (install command, config step, env var to set) under an existing setup or troubleshooting skill — never 'this tool does not work' as a standalone constraint.
|
|
278
|
+
|
|
279
|
+
'Nothing to save.' is a real option but should NOT be the default. If the session ran smoothly with no corrections and produced no new technique, just say 'Nothing to save.' and stop. Otherwise, act.`;
|
|
280
|
+
const COMBINED_REVIEW_PROMPT = `[Auto-review]
|
|
281
|
+
Review the conversation above and update two things:
|
|
282
|
+
|
|
283
|
+
**Memory**: who the user is. Did the user reveal persona, desires, preferences, personal details, or expectations about how you should behave? Save facts about the user and durable preferences with the memory tool.
|
|
284
|
+
|
|
285
|
+
**Skills**: how to do this class of task. Be ACTIVE — most sessions produce at least one skill update. A pass that does nothing is a missed learning opportunity, not a neutral outcome.
|
|
286
|
+
|
|
287
|
+
Target shape of the skill library: CLASS-LEVEL skills with a rich SKILL.md and a references/ directory for session-specific detail. Not a long flat list of narrow one-session-one-skill entries.
|
|
288
|
+
|
|
289
|
+
Signals that warrant a skill update (any one is enough):
|
|
290
|
+
• User corrected your style, tone, format, legibility, verbosity, or approach. Frustration is a FIRST-CLASS skill signal, not just a memory signal. 'stop doing X', 'don't format like this', 'I hate when you Y' — embed the lesson in the skill that governs that task so the next session starts fixed.
|
|
291
|
+
• Non-trivial technique, fix, workaround, or debugging path emerged.
|
|
292
|
+
• A skill that was loaded or consulted turned out wrong, missing, or outdated — patch it now.
|
|
293
|
+
|
|
294
|
+
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.
|
|
295
|
+
|
|
296
|
+
Preference order for skills — pick the earliest that fits:
|
|
297
|
+
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.
|
|
298
|
+
2. UPDATE AN EXISTING UMBRELLA. Patch it.
|
|
299
|
+
3. ADD A SUPPORT FILE under an existing umbrella via skill_manage action=write_file. Three kinds: references/<topic>.md for session-specific detail OR condensed knowledge banks (quoted research, API docs excerpts, domain notes) written concise and task-focused; templates/<name>.<ext> for starter files meant to be copied and modified; scripts/<name>.<ext> for statically re-runnable actions (verification, fixture generators, probes). Add a one-line pointer in SKILL.md so future agents find them.
|
|
300
|
+
4. CREATE A NEW CLASS-LEVEL UMBRELLA when nothing exists. Name at the class level — NOT a PR number, error string, codename, library-alone name, or 'fix-X / debug-Y' session artifact. If the name only fits today's task, fall back to (1), (2), or (3).
|
|
301
|
+
|
|
302
|
+
Two-tier deposition discipline (DSH addition): classify before writing — PATTERN (symptom → mechanism → fix → verification) goes in the SKILL.md body; LOG (commit SHAs, npm/profile states, this release's change list, this session's narrative) goes in a references/ file. Body density IS reuse rate; a pattern fits in 2-8 physical lines.
|
|
303
|
+
|
|
304
|
+
User-preference embedding: when the user complains about how you handled a task, update the skill that governs that task — memory alone isn't enough. Memory says 'who the user is and what the current situation and state of your operations are'; skills say 'how to do this class of task for this user'. Both should carry user-preference lessons when relevant.
|
|
305
|
+
|
|
306
|
+
If you notice overlapping existing skills, mention it — the background curator handles consolidation.
|
|
307
|
+
|
|
308
|
+
Protected skills (DO NOT edit these):
|
|
309
|
+
• Bundled skills (shipped with the platform).
|
|
310
|
+
• Hub-installed skills (installed from a hub).
|
|
311
|
+
Pinned skills are read-only to THIS background review pass — the pinned write guard refuses background changes, so only the foreground may update or archive them. Foreground and delegated-subagent writes to pinned skills remain allowed.
|
|
312
|
+
If the only skills that need updating are protected, say 'Nothing to save.' and stop.
|
|
313
|
+
|
|
314
|
+
Do NOT capture as skills (these become persistent self-imposed constraints that bite you later when the environment changes):
|
|
315
|
+
• Environment-dependent failures: missing binaries, fresh-install errors, post-migration path mismatches, 'command not found', unconfigured credentials, uninstalled packages. The user can fix these — they are not durable rules.
|
|
316
|
+
• Negative claims about tools or features ('browser tools do not work', 'X tool is broken', 'cannot use Y'). These harden into refusals the agent cites against itself for months after the actual problem was fixed.
|
|
317
|
+
• Session-specific transient errors that resolved before the conversation ended. If retrying worked, the lesson is the retry pattern, not the original failure.
|
|
318
|
+
• One-off task narratives. A user asking 'summarize today's market' or 'analyze this PR' is not a class of work that warrants a skill.
|
|
319
|
+
|
|
320
|
+
If a tool failed because of setup state, capture the FIX (install command, config step, env var to set) under an existing setup or troubleshooting skill — never 'this tool does not work' as a standalone constraint.
|
|
321
|
+
|
|
322
|
+
Act on whichever of the two dimensions has real signal. If genuinely nothing stands out on either, say 'Nothing to save.' and stop — but don't reach for that conclusion as a default.`;
|
|
323
|
+
const CURATOR_PROMPT = `You are the skill curator. Maintain a healthy, class-level skill library, not a flat pile of narrow one-session skills.
|
|
324
|
+
|
|
325
|
+
This is an UMBRELLA-BUILDING consolidation pass, not a passive audit and not a duplicate-finder.
|
|
326
|
+
|
|
327
|
+
The goal is a LIBRARY OF CLASS-LEVEL INSTRUCTIONS. A skill collection of many narrow skills where each captures one session's specific bug is a FAILURE of the library. An agent searching skills matches on descriptions, not exact names; one broad umbrella with labeled subsections beats five narrow siblings for discoverability.
|
|
328
|
+
|
|
329
|
+
Right target shape: class-level skills with rich SKILL.md + references/, templates/, scripts/ support files for session-specific detail.
|
|
330
|
+
|
|
331
|
+
Hard rules:
|
|
332
|
+
1. NEVER hard-delete a skill. Archive (moving to .archive/) is the maximum destructive action; archives are recoverable, deletion is not.
|
|
333
|
+
2. Do not touch bundled, hub-installed, pinned, or scheduled-task-referenced (referenced) skills. Referenced skills are fully protected — never consolidated, never pruned (there is no scheduled-task reference-rewriting pass; a referenced skill stays in place by design).
|
|
334
|
+
3. Do not archive recently-created or never-used skills without strong evidence. "use=0" is NOT evidence either way — it only means the trigger has not come up yet. Never archive a never-used skill unless it is at least 30 days old AND its content is genuinely obsolete or fully absorbed elsewhere.
|
|
335
|
+
4. Do NOT reject consolidation on the grounds that "each skill has a distinct trigger". The right bar is: would a human maintainer write this as N separate skills, or one skill with N labeled subsections? When the answer is the latter, merge.
|
|
336
|
+
5. Judge overlap on CONTENT, not on usage counters.
|
|
337
|
+
6. Before archiving a merged skill, ensure its unique content was preserved in the umbrella.
|
|
338
|
+
|
|
339
|
+
How to work:
|
|
340
|
+
1. Scan the candidate list. Identify PREFIX CLUSTERS — skills sharing a first word or domain keyword. Expected cluster count scales with the library: a large collection may show 10-25 prefix clusters, a small one often has none — a clean "nothing to consolidate" summary is the correct small-library outcome, not a shortage of ambition.
|
|
341
|
+
2. For each cluster with 2+ members, ask "what is the UMBRELLA CLASS these skills serve?" and consolidate:
|
|
342
|
+
a. MERGE INTO AN EXISTING UMBRELLA (patch a labeled section for each sibling's unique insight, then archive the siblings).
|
|
343
|
+
b. CREATE A NEW UMBRELLA SKILL.md covering the shared workflow with short labeled subsections, then archive the absorbed siblings.
|
|
344
|
+
c. DEMOTE session-specific detail to references/, templates/, or scripts/ under the umbrella. Use the right directory per kind:
|
|
345
|
+
• references/<topic>.md — session-specific detail OR condensed knowledge banks (quoted research, API docs excerpts, domain notes, provider quirks, reproduction recipes) written concise and task-focused.
|
|
346
|
+
• templates/<name>.<ext> — starter files meant to be copied and modified.
|
|
347
|
+
• scripts/<name>.<ext> — statically re-runnable actions (verification scripts, fixture generators, probes).
|
|
348
|
+
3. Package integrity — not optional: inspect each skill as a COMPLETE directory package, not just SKILL.md. A skill root may include references/, templates/, scripts/, and assets/. If the source skill has support files OR its SKILL.md contains relative links to them, DO NOT flatten only SKILL.md into <umbrella>/references/<old>.md. Choose one safe path instead: keep it as a standalone skill, OR fully merge by re-homing every needed support file into the umbrella's canonical directories AND rewriting the destination instructions to the new paths, OR archive the entire original skill package unchanged. Never leave demoted instructions pointing at files left behind under the old skill directory.
|
|
349
|
+
4. Flag skills whose NAME is too narrow (contains a PR number, a feature codename, a specific error string, an 'audit'/'diagnosis'/'salvage' session artifact) — they almost always belong as a subsection or support file under a class-level umbrella.
|
|
350
|
+
5. Iterate. After one consolidation round, scan the remaining set and look for the NEXT umbrella opportunity. Don't stop after 3 merges.
|
|
351
|
+
|
|
352
|
+
You are a NOMINATOR, not an executor: this channel has NO tools. Your single deliverable is the structured YAML block below. Never narrate actions you did not take ("merged", "patched", "archived") — you are proposing, and the deterministic engine executes only names from the candidate pool it gave you. (A future execution view would expose skill_manage; today it does not.)
|
|
353
|
+
|
|
354
|
+
'keep' is a legitimate decision ONLY when the skill is already a class-level umbrella and none of the proposed merges would improve discoverability. 'This is narrow but distinct from its siblings' is NOT a reason to keep — it's a reason to move it under an umbrella as a subsection or support file.
|
|
355
|
+
|
|
356
|
+
Expected output: real umbrella-ification. Process every obvious cluster. If you end the pass with obvious clusters still untouched, you stopped too early — go back and look at the clusters you left alone.
|
|
357
|
+
|
|
358
|
+
Keep the umbrella body tight and scannable: exact commands, verbatim paths, ~100-200 lines; never invent flags or APIs.
|
|
359
|
+
|
|
360
|
+
When done, write a human summary THEN the structured machine-readable block. The block is the contract: every skill you would move to .archive/ MUST appear in exactly one of the two lists. Return ONLY the YAML block after the summary — no post-block prose. Format EXACTLY:
|
|
361
|
+
|
|
362
|
+
## Structured summary (required)
|
|
363
|
+
\`\`\`yaml
|
|
364
|
+
consolidations:
|
|
365
|
+
- from: <old-skill-name>
|
|
366
|
+
into: <umbrella-skill-name>
|
|
367
|
+
reason: <one short sentence — why merged, not just 'similar'>
|
|
368
|
+
prunings:
|
|
369
|
+
- name: <skill-name>
|
|
370
|
+
reason: <one short sentence — why archived with no merge target>
|
|
371
|
+
\`\`\`
|
|
372
|
+
|
|
373
|
+
Every skill you would move to .archive/ MUST appear in exactly one of the two lists. If you consolidated X into umbrella Y (patched Y, wrote a references file to Y, or created Y with X's content absorbed), X goes under consolidations with into: Y. If you archived X with no absorption — truly stale, irrelevant, or obsolete — X goes under prunings. Leave a list empty (consolidations: []) if none. Do not omit the block. The block comes AFTER your human-readable summary of clusters processed, patches made, and decisions left alone.`;
|
|
374
|
+
const COMPLETION_SKILL_REVIEW_PROMPT = `[Auto-review — Skills · task complete]
|
|
375
|
+
Your current task now appears complete. Before wrapping up, review the approach and update the skill library via skill_manage.
|
|
376
|
+
|
|
377
|
+
Follow the skills review policy: be ACTIVE, prefer class-level umbrellas, patch ONLY skills loaded or read this session, and capture non-trivial techniques and user corrections. Do NOT capture environment-dependent failures, negative claims about tools, or one-off task narratives.
|
|
378
|
+
|
|
379
|
+
Do NOT modify output files or re-run the task. If you are still mid-task, ignore this.`;
|
|
380
|
+
/**
|
|
381
|
+
* System-prompt guidance section (Hermes `SKILLS_GUIDANCE`, DSH-adapted).
|
|
382
|
+
* Registered as a system-prompt section by tool-skill-manage (it mounts
|
|
383
|
+
* exactly when `skill_manage` is available — the DSH analogue of Hermes'
|
|
384
|
+
* `if "skill_manage" in agent.valid_tool_names` condition). Instructs the
|
|
385
|
+
* model to save/repair skills on its own initiative.
|
|
386
|
+
*/
|
|
387
|
+
const SKILLS_GUIDANCE = `Skills guidance:
|
|
388
|
+
• After completing a complex task (5+ tool calls), fixing a tricky error, or discovering a non-trivial workflow, save the approach as a skill with skill_manage so you can reuse it next time.
|
|
389
|
+
• When using a skill and finding it outdated, incomplete, or wrong, patch it immediately with skill_manage (action='patch') — don't wait to be asked. Skills that aren't maintained become liabilities.`;
|
|
390
|
+
const PLAN_CHANNEL_NOTE = `
|
|
391
|
+
|
|
392
|
+
CHANNEL (subagent): this review channel mounts only the read-only \`skill\` tool — you have NO \`skill_manage\`, NO \`memory\`. Your deliverable is the structured JSON plan below (outputSchema). Describe the patches/creates you RECOMMEND in the plan; never narrate actions you took.`;
|
|
393
|
+
/** Subagent-channel variant: same review policy, channel-limited deliverable (M-2). */
|
|
394
|
+
const SKILL_REVIEW_PLAN_PROMPT = `${SKILL_REVIEW_PROMPT}${PLAN_CHANNEL_NOTE}`;
|
|
395
|
+
/** Subagent-channel variant of the combined review (M-2). */
|
|
396
|
+
const COMBINED_REVIEW_PLAN_PROMPT = `${COMBINED_REVIEW_PROMPT}${PLAN_CHANNEL_NOTE}`;
|
|
397
|
+
function sha256(text) {
|
|
398
|
+
return createHash("sha256").update(text).digest("hex");
|
|
399
|
+
}
|
|
400
|
+
function createPromptBundle(prompts) {
|
|
401
|
+
const canonical = JSON.stringify({
|
|
402
|
+
id: PROMPT_BUNDLE_ID,
|
|
403
|
+
version: 7,
|
|
404
|
+
prompts: Object.fromEntries(Object.entries(prompts).sort())
|
|
405
|
+
});
|
|
406
|
+
return Object.freeze({
|
|
407
|
+
id: PROMPT_BUNDLE_ID,
|
|
408
|
+
version: 7,
|
|
409
|
+
prompts: Object.freeze({ ...prompts }),
|
|
410
|
+
sha256: sha256(canonical)
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
createPromptBundle({
|
|
414
|
+
memory: MEMORY_REVIEW_PROMPT,
|
|
415
|
+
skill: SKILL_REVIEW_PROMPT,
|
|
416
|
+
combined: COMBINED_REVIEW_PROMPT,
|
|
417
|
+
skillPlan: SKILL_REVIEW_PLAN_PROMPT,
|
|
418
|
+
combinedPlan: COMBINED_REVIEW_PLAN_PROMPT,
|
|
419
|
+
curator: CURATOR_PROMPT,
|
|
420
|
+
completion: COMPLETION_SKILL_REVIEW_PROMPT,
|
|
421
|
+
skillsGuidance: SKILLS_GUIDANCE
|
|
422
|
+
});
|
|
423
|
+
//#endregion
|
|
424
|
+
//#region ../evolution-core/src/threats.ts
|
|
425
|
+
const FILLER = String.raw`(?:\w+\s+){0,8}`;
|
|
426
|
+
new RegExp(String.raw`ignore\s+${FILLER}(?:previous|above|prior|all)\s+${FILLER}instructions`, "i"), new RegExp(String.raw`new\s+${FILLER}system\s+${FILLER}prompt`, "i"), new RegExp(String.raw`forget\s+${FILLER}(?:everything|all)\s+${FILLER}(?:discussed|you\s+know)`, "i"), new RegExp(String.raw`you\s+have\s+been\s+${FILLER}(?:updated|upgraded|patched)\s+to`, "i"), new RegExp(String.raw`do\s+not\s+${FILLER}tell\s+${FILLER}the\s+user`, "i"), new RegExp(String.raw`output\s+${FILLER}(?:system|initial)\s+prompt`, "i");
|
|
427
|
+
//#endregion
|
|
4
428
|
//#region lib/types/index.js
|
|
5
429
|
/**
|
|
6
430
|
* Feedback-to-quality scoring for self-evolution.
|
|
@@ -8,8 +432,15 @@ import { join } from "node:path";
|
|
|
8
432
|
* Feedback is durable through `ctx.evolutionIo` (when mounted) and skill
|
|
9
433
|
* feedback feeds `quality_score` / `quality_warn` on the usage record, so
|
|
10
434
|
* curator decisions can consume it deterministically.
|
|
435
|
+
*
|
|
436
|
+
* Persistence (rc.68): the EVENTS LOG (`evolution/events.json`, via
|
|
437
|
+
* `evolution-core/evolution-events.ts`) is the single source of truth —
|
|
438
|
+
* every increment appends one event under the write lock. `feedback.json` is
|
|
439
|
+
* a rebuildable BOOT CACHE (`{ version: 2, lastSeq, skills, sessions }`),
|
|
440
|
+
* never the truth; the in-memory state is the optimistic aggregate.
|
|
11
441
|
* @module @lmzhen/dsh-evolution-feedback
|
|
12
442
|
*/
|
|
443
|
+
const CACHE_VERSION = 2;
|
|
13
444
|
var EvolutionFeedback = class {
|
|
14
445
|
state = {
|
|
15
446
|
skills: {},
|
|
@@ -17,8 +448,14 @@ var EvolutionFeedback = class {
|
|
|
17
448
|
};
|
|
18
449
|
chain = Promise.resolve();
|
|
19
450
|
path;
|
|
451
|
+
eventsPath;
|
|
452
|
+
io;
|
|
20
453
|
constructor(io, home = process.env.DSH_HOME ?? join(homedir(), ".dsh"), pathOverride) {
|
|
21
|
-
if (io)
|
|
454
|
+
if (io) {
|
|
455
|
+
this.path = pathOverride ?? join(home, "evolution", "feedback.json");
|
|
456
|
+
this.eventsPath = eventsFile(home);
|
|
457
|
+
}
|
|
458
|
+
this.io = io;
|
|
22
459
|
}
|
|
23
460
|
mutate(task) {
|
|
24
461
|
const run = this.chain.then(task, task);
|
|
@@ -27,21 +464,43 @@ var EvolutionFeedback = class {
|
|
|
27
464
|
}
|
|
28
465
|
async restore(io) {
|
|
29
466
|
const path = this.path;
|
|
30
|
-
|
|
467
|
+
const eventsPath = this.eventsPath;
|
|
468
|
+
if (!path || !eventsPath) return;
|
|
31
469
|
await this.mutate(async () => {
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
470
|
+
const rawEvents = await io.readText(eventsPath);
|
|
471
|
+
const archiveNames = (await io.list(dirname(eventsPath))).filter((name) => name.startsWith("events-") && name.endsWith(".json"));
|
|
472
|
+
if ((rawEvents === null || rawEvents.trim() === "") && archiveNames.length === 0) {
|
|
473
|
+
const aggregate = parseAggregate(await io.readText(path));
|
|
474
|
+
if (aggregate) try {
|
|
475
|
+
await migrateFeedbackEvents(io, eventsPath, aggregate);
|
|
476
|
+
} catch {}
|
|
477
|
+
}
|
|
478
|
+
const { events } = await readEvolutionTimeline(io, eventsPath);
|
|
479
|
+
const cache = parseCache(await io.readText(path));
|
|
480
|
+
const maxSeq = events.reduce((max, event) => Math.max(max, event.seq), 0);
|
|
481
|
+
const truth = cache ? foldWithDelta(cache, events) : foldFeedbackState(events);
|
|
482
|
+
this.state = {
|
|
483
|
+
skills: {
|
|
484
|
+
...truth.skills,
|
|
485
|
+
...this.state.skills
|
|
486
|
+
},
|
|
487
|
+
sessions: {
|
|
488
|
+
...truth.sessions,
|
|
489
|
+
...this.state.sessions
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
if (maxSeq > 0 && (!cache || cache.lastSeq < maxSeq)) try {
|
|
493
|
+
await io.writeText(path, JSON.stringify({
|
|
494
|
+
version: CACHE_VERSION,
|
|
495
|
+
lastSeq: maxSeq,
|
|
496
|
+
...truth
|
|
497
|
+
}, null, 2));
|
|
40
498
|
} catch {}
|
|
41
499
|
});
|
|
42
500
|
}
|
|
43
|
-
record(target, rating, note, kind = "session"
|
|
44
|
-
const
|
|
501
|
+
record(target, rating, note, kind = "session") {
|
|
502
|
+
const mode = kind === "skill" ? "skills" : "sessions";
|
|
503
|
+
const table = this.state[mode];
|
|
45
504
|
const current = table[target] ?? {
|
|
46
505
|
positive: 0,
|
|
47
506
|
negative: 0
|
|
@@ -49,7 +508,20 @@ var EvolutionFeedback = class {
|
|
|
49
508
|
current[rating] += 1;
|
|
50
509
|
if (note !== void 0) current.lastNote = note;
|
|
51
510
|
table[target] = current;
|
|
52
|
-
|
|
511
|
+
const recordIo = this.io;
|
|
512
|
+
const eventsPath = this.eventsPath;
|
|
513
|
+
if (!recordIo || !eventsPath) return;
|
|
514
|
+
this.mutate(async () => {
|
|
515
|
+
try {
|
|
516
|
+
await appendEvolutionEvent(recordIo, eventsPath, {
|
|
517
|
+
type: "feedback",
|
|
518
|
+
target,
|
|
519
|
+
kind,
|
|
520
|
+
rating,
|
|
521
|
+
note
|
|
522
|
+
});
|
|
523
|
+
} catch (error) {}
|
|
524
|
+
});
|
|
53
525
|
}
|
|
54
526
|
score(target, kind = "session") {
|
|
55
527
|
const record = (kind === "skill" ? this.state.skills : this.state.sessions)[target];
|
|
@@ -64,14 +536,179 @@ var EvolutionFeedback = class {
|
|
|
64
536
|
sessions: { ...this.state.sessions }
|
|
65
537
|
};
|
|
66
538
|
}
|
|
67
|
-
|
|
539
|
+
/** Await the pending record-task chain (unload safety; rc.66). */
|
|
540
|
+
waitIdle() {
|
|
541
|
+
return this.chain;
|
|
542
|
+
}
|
|
543
|
+
/** Rebuild the boot cache from the log truth (rc.68); best-effort. */
|
|
544
|
+
persistCache() {
|
|
68
545
|
const path = this.path;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
546
|
+
const eventsPath = this.eventsPath;
|
|
547
|
+
const recordIo = this.io;
|
|
548
|
+
if (!path || !eventsPath || !recordIo) return Promise.resolve();
|
|
549
|
+
return this.mutate(async () => {
|
|
550
|
+
try {
|
|
551
|
+
const { events } = await readEvolutionTimeline(recordIo, eventsPath);
|
|
552
|
+
const maxSeq = events.reduce((max, event) => Math.max(max, event.seq), 0);
|
|
553
|
+
if (maxSeq === 0) return;
|
|
554
|
+
await recordIo.writeText(path, JSON.stringify({
|
|
555
|
+
version: CACHE_VERSION,
|
|
556
|
+
lastSeq: maxSeq,
|
|
557
|
+
...foldFeedbackState(events)
|
|
558
|
+
}, null, 2));
|
|
559
|
+
} catch {}
|
|
72
560
|
});
|
|
73
561
|
}
|
|
74
562
|
};
|
|
563
|
+
/** True when `existing` contains the legacy sequence as a contiguous run on
|
|
564
|
+
* its semantic fields (skip case). `seq` and `at` are excluded: after a merge
|
|
565
|
+
* the legacy events carry shifted seqs, and a re-synthesis stamps a different
|
|
566
|
+
* `at` — the semantic identity is type/kind/target/rating/note. A coincidental
|
|
567
|
+
* semantic match of an already-appended user sequence yields the identical
|
|
568
|
+
* aggregation, so the skip is harmless for counts and notes. */
|
|
569
|
+
function containsLegacySequence(existing, expected) {
|
|
570
|
+
if (expected.length === 0) return true;
|
|
571
|
+
for (let start = 0; start <= existing.length - expected.length; start += 1) {
|
|
572
|
+
let match = true;
|
|
573
|
+
for (let offset = 0; offset < expected.length; offset += 1) {
|
|
574
|
+
const a = expected[offset];
|
|
575
|
+
const b = existing[start + offset];
|
|
576
|
+
if (!a || !b || a.type !== b.type || a.kind !== b.kind || a.target !== b.target || a.rating !== b.rating || a.note !== b.note) {
|
|
577
|
+
match = false;
|
|
578
|
+
break;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
if (match) return true;
|
|
582
|
+
}
|
|
583
|
+
return false;
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Merge a legacy aggregate into the event log (rc.69): the expected synthetic
|
|
587
|
+
* sequence is APPENDED (seq-shifted) when the log does not already contain it
|
|
588
|
+
* — so a concurrent first writer's events AND the legacy history both
|
|
589
|
+
* survive; when the sequence is already present the migration was completed
|
|
590
|
+
* (by a first writer or by this path) and nothing is re-appended. Idempotent
|
|
591
|
+
* and race-safe (the search runs inside the same transact). Exported for the
|
|
592
|
+
* migration-race regression test.
|
|
593
|
+
*/
|
|
594
|
+
async function migrateFeedbackEvents(io, eventsPath, aggregate) {
|
|
595
|
+
const expected = synthesizeFeedbackEvents(aggregate);
|
|
596
|
+
await transactIo(io, eventsPath, (current) => {
|
|
597
|
+
const existing = parseEvolutionEvents(current);
|
|
598
|
+
if (containsLegacySequence(existing, expected)) return Promise.resolve(current);
|
|
599
|
+
const maxSeq = existing.reduce((max, event) => Math.max(max, event.seq), 0);
|
|
600
|
+
const merged = [...existing, ...expected.map((event, index) => ({
|
|
601
|
+
...event,
|
|
602
|
+
seq: maxSeq + index + 1
|
|
603
|
+
}))];
|
|
604
|
+
return Promise.resolve(JSON.stringify({
|
|
605
|
+
version: 1,
|
|
606
|
+
events: merged
|
|
607
|
+
}, null, 2));
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
/** Parse a legacy aggregate (v1) or a v2 cache into a plain aggregate state. */
|
|
611
|
+
function parseAggregate(raw) {
|
|
612
|
+
if (raw === null) return null;
|
|
613
|
+
try {
|
|
614
|
+
const parsed = JSON.parse(raw);
|
|
615
|
+
const skills = isRecord(parsed.skills) ? parsed.skills : void 0;
|
|
616
|
+
const sessions = isRecord(parsed.sessions) ? parsed.sessions : void 0;
|
|
617
|
+
if (!skills && !sessions) return null;
|
|
618
|
+
return {
|
|
619
|
+
skills: skills ?? {},
|
|
620
|
+
sessions: sessions ?? {}
|
|
621
|
+
};
|
|
622
|
+
} catch {
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
function parseCache(raw) {
|
|
627
|
+
if (raw === null) return null;
|
|
628
|
+
try {
|
|
629
|
+
const parsed = JSON.parse(raw);
|
|
630
|
+
if (parsed.version !== CACHE_VERSION || typeof parsed.lastSeq !== "number") return null;
|
|
631
|
+
if (!isRecord(parsed.skills) || !isRecord(parsed.sessions)) return null;
|
|
632
|
+
return {
|
|
633
|
+
lastSeq: parsed.lastSeq,
|
|
634
|
+
state: {
|
|
635
|
+
skills: parsed.skills,
|
|
636
|
+
sessions: parsed.sessions
|
|
637
|
+
}
|
|
638
|
+
};
|
|
639
|
+
} catch {
|
|
640
|
+
return null;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
function isRecord(value) {
|
|
644
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
645
|
+
}
|
|
646
|
+
/** Fold all feedback events from zero (the truth view). */
|
|
647
|
+
function foldFeedbackState(events) {
|
|
648
|
+
const state = {
|
|
649
|
+
skills: {},
|
|
650
|
+
sessions: {}
|
|
651
|
+
};
|
|
652
|
+
for (const event of events) applyFeedbackEvent(state, event);
|
|
653
|
+
return state;
|
|
654
|
+
}
|
|
655
|
+
/** Fold events after the cache's lastSeq onto the cached aggregates. */
|
|
656
|
+
function foldWithDelta(cache, events) {
|
|
657
|
+
const state = {
|
|
658
|
+
skills: { ...cache.state.skills },
|
|
659
|
+
sessions: { ...cache.state.sessions }
|
|
660
|
+
};
|
|
661
|
+
for (const event of events) if (event.seq > cache.lastSeq) applyFeedbackEvent(state, event);
|
|
662
|
+
return state;
|
|
663
|
+
}
|
|
664
|
+
function applyFeedbackEvent(state, event) {
|
|
665
|
+
if (event.type !== "feedback") return;
|
|
666
|
+
const target = event.target;
|
|
667
|
+
if (target === void 0 || event.rating === void 0) return;
|
|
668
|
+
const table = event.kind === "skill" ? state.skills : state.sessions;
|
|
669
|
+
const record = table[target] ?? {
|
|
670
|
+
positive: 0,
|
|
671
|
+
negative: 0
|
|
672
|
+
};
|
|
673
|
+
record[event.rating] += 1;
|
|
674
|
+
if (event.note !== void 0) record.lastNote = event.note;
|
|
675
|
+
table[target] = record;
|
|
676
|
+
}
|
|
677
|
+
/** Synthesize one event per aggregate count unit, lastNote on the final event (migration). */
|
|
678
|
+
function synthesizeFeedbackEvents(aggregate) {
|
|
679
|
+
const events = [];
|
|
680
|
+
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
681
|
+
const emitTarget = (kind, target, record) => {
|
|
682
|
+
const first = events.length + 1;
|
|
683
|
+
for (let index = 0; index < record.positive; index += 1) events.push({
|
|
684
|
+
seq: events.length + 1,
|
|
685
|
+
at,
|
|
686
|
+
type: "feedback",
|
|
687
|
+
kind,
|
|
688
|
+
target,
|
|
689
|
+
rating: "positive"
|
|
690
|
+
});
|
|
691
|
+
for (let index = 0; index < record.negative; index += 1) events.push({
|
|
692
|
+
seq: events.length + 1,
|
|
693
|
+
at,
|
|
694
|
+
type: "feedback",
|
|
695
|
+
kind,
|
|
696
|
+
target,
|
|
697
|
+
rating: "negative"
|
|
698
|
+
});
|
|
699
|
+
if (record.lastNote !== void 0 && events.length >= first) {
|
|
700
|
+
const last = events.length - 1;
|
|
701
|
+
const final = events[last];
|
|
702
|
+
if (final) events[last] = {
|
|
703
|
+
...final,
|
|
704
|
+
note: record.lastNote
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
for (const [target, record] of Object.entries(aggregate.skills)) emitTarget("skill", target, record);
|
|
709
|
+
for (const [target, record] of Object.entries(aggregate.sessions)) emitTarget("session", target, record);
|
|
710
|
+
return events;
|
|
711
|
+
}
|
|
75
712
|
const name = "evolution-feedback";
|
|
76
713
|
const Config = z.object({
|
|
77
714
|
qualityWarnThreshold: z.number().default(-.25),
|
|
@@ -79,10 +716,7 @@ const Config = z.object({
|
|
|
79
716
|
});
|
|
80
717
|
function apply(ctx, rawConfig = {}) {
|
|
81
718
|
const ioRegistry = ctx.get("evolutionIo");
|
|
82
|
-
const io = ioRegistry ?
|
|
83
|
-
readText: (path) => ioRegistry.provider().readText(path),
|
|
84
|
-
writeText: (path, content) => ioRegistry.provider().writeText(path, content)
|
|
85
|
-
} : void 0;
|
|
719
|
+
const io = ioRegistry ? evolutionIoAdapter(() => ioRegistry.provider()) : void 0;
|
|
86
720
|
const feedback = new EvolutionFeedback(io, process.env.DSH_HOME ?? join(homedir(), ".dsh"), rawConfig.path || void 0);
|
|
87
721
|
if (io) feedback.restore(io).catch((error) => {
|
|
88
722
|
ctx.logger.warn(error);
|
|
@@ -91,8 +725,8 @@ function apply(ctx, rawConfig = {}) {
|
|
|
91
725
|
const skillUsage = ctx.get("skillUsage");
|
|
92
726
|
if (skillUsage) {
|
|
93
727
|
const original = feedback.record.bind(feedback);
|
|
94
|
-
feedback.record = (target, rating, note, kind
|
|
95
|
-
original(target, rating, note, kind ?? "session"
|
|
728
|
+
feedback.record = (target, rating, note, kind) => {
|
|
729
|
+
original(target, rating, note, kind ?? "session");
|
|
96
730
|
if (kind === "skill") {
|
|
97
731
|
const score = feedback.score(target, "skill");
|
|
98
732
|
const warn = score < (rawConfig.qualityWarnThreshold ?? -.25);
|
|
@@ -103,8 +737,8 @@ function apply(ctx, rawConfig = {}) {
|
|
|
103
737
|
};
|
|
104
738
|
}
|
|
105
739
|
ctx.effect(() => () => {
|
|
106
|
-
|
|
107
|
-
}, "evolution-feedback.
|
|
740
|
+
return Promise.all([feedback.persistCache(), feedback.waitIdle()]);
|
|
741
|
+
}, "evolution-feedback.records");
|
|
108
742
|
}
|
|
109
743
|
//#endregion
|
|
110
|
-
export { Config, EvolutionFeedback, apply, name };
|
|
744
|
+
export { Config, EvolutionFeedback, apply, migrateFeedbackEvents, name };
|
package/lib/types/index.d.ts
CHANGED
|
@@ -4,10 +4,17 @@
|
|
|
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';
|
|
10
16
|
import z from '@deepseek-ai/schemastery';
|
|
17
|
+
import { type EvolutionIoLike } from '@deepseek-ai/dsh-evolution-core';
|
|
11
18
|
declare module '@deepseek-ai/cordis' {
|
|
12
19
|
interface Context {
|
|
13
20
|
evolutionFeedback: EvolutionFeedback;
|
|
@@ -22,27 +29,41 @@ export interface FeedbackState {
|
|
|
22
29
|
skills: Record<string, FeedbackRecord>;
|
|
23
30
|
sessions: Record<string, FeedbackRecord>;
|
|
24
31
|
}
|
|
25
|
-
|
|
26
|
-
readText(path: string): Promise<string | null>;
|
|
27
|
-
writeText(path: string, content: string): Promise<void>;
|
|
28
|
-
}
|
|
32
|
+
type IoLike = EvolutionIoLike;
|
|
29
33
|
export declare class EvolutionFeedback {
|
|
30
34
|
private state;
|
|
31
35
|
private chain;
|
|
32
36
|
private readonly path?;
|
|
37
|
+
private readonly eventsPath?;
|
|
38
|
+
private readonly io;
|
|
33
39
|
constructor(io?: IoLike, home?: string, pathOverride?: string);
|
|
34
40
|
private mutate;
|
|
35
41
|
restore(io: IoLike): Promise<void>;
|
|
36
|
-
record(target: string, rating: 'positive' | 'negative', note?: string, kind?: 'skill' | 'session'
|
|
42
|
+
record(target: string, rating: 'positive' | 'negative', note?: string, kind?: 'skill' | 'session'): void;
|
|
37
43
|
score(target: string, kind?: 'skill' | 'session'): number;
|
|
38
44
|
snapshot(): FeedbackState;
|
|
39
|
-
|
|
45
|
+
/** Await the pending record-task chain (unload safety; rc.66). */
|
|
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
|
|
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.
|
|
4
|
+
"version": "0.1.0-rc.71",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
@@ -35,15 +35,15 @@
|
|
|
35
35
|
"@deepseek-ai/schemastery": "^3.18.1"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
|
-
"@deepseek-ai/dsh-invariants": "^0.1.
|
|
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.71",
|
|
41
|
+
"@lmzhen/dsh-skill-usage": "^0.1.0-rc.71"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
-
"@deepseek-ai/dsh-invariants": "^0.1.
|
|
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.
|
|
44
|
+
"@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
|
|
45
|
+
"@lmzhen/dsh-evolution-io": "^0.1.0-rc.71",
|
|
46
|
+
"@lmzhen/dsh-evolution-io-node": "^0.1.0-rc.71",
|
|
47
|
+
"@lmzhen/dsh-skill-usage": "^0.1.0-rc.71"
|
|
48
48
|
}
|
|
49
49
|
}
|