dsh-continual-evolve 0.3.0 → 0.4.0
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/README.md +83 -409
- package/README.zh.md +84 -272
- package/lib/apply.js +7 -1
- package/lib/auto.d.ts +17 -0
- package/lib/auto.js +5 -2
- package/lib/benchmark-command.js +2 -0
- package/lib/command.d.ts +3 -0
- package/lib/command.js +40 -2
- package/lib/fate.d.ts +2 -1
- package/lib/fate.js +5 -4
- package/lib/index.d.ts +22 -0
- package/lib/index.js +19 -1
- package/lib/inject.d.ts +16 -1
- package/lib/inject.js +55 -14
- package/lib/promotion.d.ts +62 -0
- package/lib/promotion.js +102 -0
- package/lib/service.js +2 -1
- package/lib/skill-render.d.ts +9 -1
- package/lib/skill-render.js +40 -2
- package/lib/state.js +6 -1
- package/lib/usage.d.ts +17 -4
- package/lib/usage.js +41 -10
- package/lib/wrapup-command.d.ts +2 -1
- package/lib/wrapup-command.js +4 -3
- package/lib/wrapup.d.ts +15 -6
- package/lib/wrapup.js +45 -6
- package/package.json +9 -7
package/lib/fate.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
2
|
+
import { DEFAULT_PROMOTION_POLICY } from "./promotion.js";
|
|
2
3
|
import { questionServiceOf } from "./approval.js";
|
|
3
4
|
import { assessLocalEntries, candidateKey, filterPromotable, listLocalCandidates, splitArchiveGuards, splitPromoteBlocked, splitPromoteProposals, wholePromoteProposals, } from "./wrapup.js";
|
|
4
5
|
/** Turns a declined local-fate proposal stays silent before being offered again. */
|
|
@@ -9,9 +10,9 @@ export const FATE_CONSULT_COOLDOWN_TURNS = 10;
|
|
|
9
10
|
* may have changed while the LLM call was in flight). Pure and unit-tested;
|
|
10
11
|
* mirrors the partition step of the wrap-up command.
|
|
11
12
|
*/
|
|
12
|
-
export function planLocalFates(items, candidates, globalState) {
|
|
13
|
+
export function planLocalFates(items, candidates, globalState, policy = DEFAULT_PROMOTION_POLICY) {
|
|
13
14
|
const byKey = new Map(candidates.map((candidate) => [candidateKey(candidate.kind, candidate.id), candidate]));
|
|
14
|
-
const { promotable, skipped } = filterPromotable(items, globalState, candidates);
|
|
15
|
+
const { promotable, skipped } = filterPromotable(items, globalState, candidates, policy);
|
|
15
16
|
const promoteItems = promotable.filter((item) => item.verdict === "promote");
|
|
16
17
|
const archiveItems = items.filter((item) => item.verdict === "archive");
|
|
17
18
|
const splits = [];
|
|
@@ -24,7 +25,7 @@ export function planLocalFates(items, candidates, globalState) {
|
|
|
24
25
|
splitSkipped.push({ key: item.key, reason: "not in the audited candidate list" });
|
|
25
26
|
continue;
|
|
26
27
|
}
|
|
27
|
-
const blocked = splitPromoteBlocked(item, globalState, candidate.kind);
|
|
28
|
+
const blocked = splitPromoteBlocked(item, globalState, candidate.kind, policy);
|
|
28
29
|
if (blocked) {
|
|
29
30
|
splitSkipped.push({ key: item.key, reason: blocked });
|
|
30
31
|
continue;
|
|
@@ -230,7 +231,7 @@ export async function runLocalFatePhase(ctx, engine, agent, config, state, reaso
|
|
|
230
231
|
});
|
|
231
232
|
return;
|
|
232
233
|
}
|
|
233
|
-
const plan = planLocalFates(assessment.items, candidates, globalState);
|
|
234
|
+
const plan = planLocalFates(assessment.items, candidates, globalState, config.promotionPolicy);
|
|
234
235
|
const needsDialog = plan.promotable.length + plan.splits.length + plan.reviewArchives.length > 0;
|
|
235
236
|
let consent = { approved: false, asked: false, reason: "nothing-to-ask" };
|
|
236
237
|
if (reason !== "compact" && needsDialog) {
|
package/lib/index.d.ts
CHANGED
|
@@ -57,6 +57,17 @@ export declare const Config: z<Schemastery.ObjectS<{
|
|
|
57
57
|
* assessment so the encounter is distilled. 0 disables.
|
|
58
58
|
*/
|
|
59
59
|
goalBlockedWrapupTurns: z<number, number>;
|
|
60
|
+
/**
|
|
61
|
+
* Promotion policy (2026-08-22): regex sources whose match in a
|
|
62
|
+
* candidate's title/content marks it project-scoped — such entries are
|
|
63
|
+
* never promoted to the cross-session global store. Replaces the built-in
|
|
64
|
+
* defaults when set.
|
|
65
|
+
*/
|
|
66
|
+
promotionBlockPatterns: z<string[], string[]>;
|
|
67
|
+
/** Whole promotions below this content length stay local (chars). */
|
|
68
|
+
promotionMinChars: z<number, number>;
|
|
69
|
+
/** Entry-directory lines injected per build before folding into a counter. */
|
|
70
|
+
injectionDirectoryLines: z<number, number>;
|
|
60
71
|
}>, Schemastery.ObjectT<{
|
|
61
72
|
/** Root for evolution stores; defaults to the resolved DSH home. */
|
|
62
73
|
baseDir: z<string, string>;
|
|
@@ -109,6 +120,17 @@ export declare const Config: z<Schemastery.ObjectS<{
|
|
|
109
120
|
* assessment so the encounter is distilled. 0 disables.
|
|
110
121
|
*/
|
|
111
122
|
goalBlockedWrapupTurns: z<number, number>;
|
|
123
|
+
/**
|
|
124
|
+
* Promotion policy (2026-08-22): regex sources whose match in a
|
|
125
|
+
* candidate's title/content marks it project-scoped — such entries are
|
|
126
|
+
* never promoted to the cross-session global store. Replaces the built-in
|
|
127
|
+
* defaults when set.
|
|
128
|
+
*/
|
|
129
|
+
promotionBlockPatterns: z<string[], string[]>;
|
|
130
|
+
/** Whole promotions below this content length stay local (chars). */
|
|
131
|
+
promotionMinChars: z<number, number>;
|
|
132
|
+
/** Entry-directory lines injected per build before folding into a counter. */
|
|
133
|
+
injectionDirectoryLines: z<number, number>;
|
|
112
134
|
}>>;
|
|
113
135
|
/**
|
|
114
136
|
* Structurally typed resolved config (loader passes the validated object).
|
package/lib/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import { entriesSectionText } from "./inject.js";
|
|
|
19
19
|
import { resolveRubricKey } from "./rubric.js";
|
|
20
20
|
import { restoreMounted } from "./mount.js";
|
|
21
21
|
import { registerFileLogger } from "./logfile.js";
|
|
22
|
+
import { resolvePromotionPolicy } from "./promotion.js";
|
|
22
23
|
export const name = "continual-evolve";
|
|
23
24
|
/** Service key under which the evolution engine is published. */
|
|
24
25
|
export const EVOLUTION_SERVICE = "evolution";
|
|
@@ -75,6 +76,17 @@ export const Config = z.object({
|
|
|
75
76
|
* assessment so the encounter is distilled. 0 disables.
|
|
76
77
|
*/
|
|
77
78
|
goalBlockedWrapupTurns: z.natural().min(0).default(3),
|
|
79
|
+
/**
|
|
80
|
+
* Promotion policy (2026-08-22): regex sources whose match in a
|
|
81
|
+
* candidate's title/content marks it project-scoped — such entries are
|
|
82
|
+
* never promoted to the cross-session global store. Replaces the built-in
|
|
83
|
+
* defaults when set.
|
|
84
|
+
*/
|
|
85
|
+
promotionBlockPatterns: z.array(z.string()),
|
|
86
|
+
/** Whole promotions below this content length stay local (chars). */
|
|
87
|
+
promotionMinChars: z.natural().default(100),
|
|
88
|
+
/** Entry-directory lines injected per build before folding into a counter. */
|
|
89
|
+
injectionDirectoryLines: z.natural().default(15),
|
|
78
90
|
});
|
|
79
91
|
export function apply(ctx, config) {
|
|
80
92
|
const baseDir = resolveDshHome(config.baseDir);
|
|
@@ -107,13 +119,18 @@ export function apply(ctx, config) {
|
|
|
107
119
|
ctx.systemPrompt.section({
|
|
108
120
|
name: "tool:continual-evolve:entries",
|
|
109
121
|
order: (config.sectionOrder ?? 118) + 1,
|
|
110
|
-
text: (context) => entriesSectionText(engine, context.agent),
|
|
122
|
+
text: (context) => entriesSectionText(engine, context.agent, undefined, { directoryLines: config.injectionDirectoryLines ?? 15 }),
|
|
111
123
|
});
|
|
112
124
|
const gate = { requireGlobalApproval: config.requireGlobalApproval ?? true };
|
|
125
|
+
const promotionPolicy = resolvePromotionPolicy({
|
|
126
|
+
blockPatterns: config.promotionBlockPatterns,
|
|
127
|
+
minPromoteChars: config.promotionMinChars,
|
|
128
|
+
});
|
|
113
129
|
registerEvolveTools(ctx, engine, gate);
|
|
114
130
|
registerEvolveCommand(ctx, engine, gate, {
|
|
115
131
|
rubricKey: resolveRubricKey(baseDir, config.rubricKey, process.env, (m) => ctx.logger("continual-evolve").warn(m)),
|
|
116
132
|
autoRollbackOnReject: config.autoRollbackOnReject ?? true,
|
|
133
|
+
promotionPolicy,
|
|
117
134
|
});
|
|
118
135
|
// Plugin-owned file logging: every cordis log message lands in
|
|
119
136
|
// <baseDir>/evolve/plugin.log regardless of how dsh web was launched —
|
|
@@ -137,6 +154,7 @@ export function apply(ctx, config) {
|
|
|
137
154
|
localFate: config.localFate ?? true,
|
|
138
155
|
fateIntervalTurns: config.fateIntervalTurns ?? config.reviewIntervalTurns ?? 6,
|
|
139
156
|
goalBlockedWrapupTurns: config.goalBlockedWrapupTurns ?? 3,
|
|
157
|
+
promotionPolicy,
|
|
140
158
|
...(config.reviewModel ? { reviewModel: config.reviewModel } : {}),
|
|
141
159
|
});
|
|
142
160
|
ctx.logger("continual-evolve").info(`continual-evolve auto-review enabled (every ${config.reviewIntervalTurns ?? 6} turns; local-fate ${config.localFate ?? true ? "on" : "off"} every ${config.fateIntervalTurns ?? config.reviewIntervalTurns ?? 6} turns)`);
|
package/lib/inject.d.ts
CHANGED
|
@@ -109,8 +109,16 @@ export declare function formatSubagentSpecsSection(entries: readonly HarnessEntr
|
|
|
109
109
|
* the model a zero-cost overview of what exists so it can ask for full text
|
|
110
110
|
* via `evolve_list` or `/evolve list`. The directory is appended after the
|
|
111
111
|
* curated top-N injection sections and adds minimal tokens.
|
|
112
|
+
*
|
|
113
|
+
* 2026-08-22 throttle: the directory is CAPPED at {@link DEFAULT_DIRECTORY_LINES}
|
|
114
|
+
* lines (oldest-sorted stable order) with the remainder folded into a single
|
|
115
|
+
* counter line — an uncapped directory across a polluted global store was
|
|
116
|
+
* measured at ~2K chars of every build in every project.
|
|
112
117
|
*/
|
|
118
|
+
export declare const DEFAULT_DIRECTORY_LINES = 15;
|
|
113
119
|
export declare function formatEntriesDirectory(...kindEntries: readonly HarnessEntry[][]): string;
|
|
120
|
+
/** {@link formatEntriesDirectory} with an explicit cap (configurable). */
|
|
121
|
+
export declare function formatEntriesDirectoryCapped(maxLines: number, ...kindEntries: readonly HarnessEntry[][]): string;
|
|
114
122
|
/**
|
|
115
123
|
* Walk the parent-session chain from `agent` upward and return the nearest
|
|
116
124
|
* session whose local store is non-empty, if any. Children inherit their
|
|
@@ -127,6 +135,13 @@ export declare function nearestLocalStateWithEntries(engine: EvolutionEngine, ag
|
|
|
127
135
|
* (relevance first, then recency; see {@link rankEntries}). Returns "" when
|
|
128
136
|
* nothing is injectable — the prompt renderer then drops the section, so an
|
|
129
137
|
* empty store adds zero tokens to every assembly.
|
|
138
|
+
*
|
|
139
|
+
* `opts.directoryLines` caps the entry-directory index (2026-08-22 throttle).
|
|
140
|
+
* Usage recording covers ALL kinds — memories and skills appear as directory
|
|
141
|
+
* lines, prompts/subagents as content — and is deduped per session so the
|
|
142
|
+
* counts read "how many sessions saw this", not "how many prompt builds".
|
|
130
143
|
*/
|
|
131
|
-
export declare function entriesSectionText(engine: EvolutionEngine, agent: AgentLike | undefined, query?: string
|
|
144
|
+
export declare function entriesSectionText(engine: EvolutionEngine, agent: AgentLike | undefined, query?: string, opts?: {
|
|
145
|
+
directoryLines?: number;
|
|
146
|
+
}): string;
|
|
132
147
|
//# sourceMappingURL=inject.d.ts.map
|
package/lib/inject.js
CHANGED
|
@@ -190,22 +190,45 @@ export function formatSubagentSpecsSection(entries, query) {
|
|
|
190
190
|
* the model a zero-cost overview of what exists so it can ask for full text
|
|
191
191
|
* via `evolve_list` or `/evolve list`. The directory is appended after the
|
|
192
192
|
* curated top-N injection sections and adds minimal tokens.
|
|
193
|
+
*
|
|
194
|
+
* 2026-08-22 throttle: the directory is CAPPED at {@link DEFAULT_DIRECTORY_LINES}
|
|
195
|
+
* lines (oldest-sorted stable order) with the remainder folded into a single
|
|
196
|
+
* counter line — an uncapped directory across a polluted global store was
|
|
197
|
+
* measured at ~2K chars of every build in every project.
|
|
193
198
|
*/
|
|
199
|
+
export const DEFAULT_DIRECTORY_LINES = 15;
|
|
200
|
+
/** How many of the variadic arrays are content-section kinds (prompt, subagent). */
|
|
201
|
+
const CONTENT_SECTION_KINDS = 2;
|
|
194
202
|
export function formatEntriesDirectory(...kindEntries) {
|
|
203
|
+
return formatEntriesDirectoryCapped(DEFAULT_DIRECTORY_LINES, ...kindEntries);
|
|
204
|
+
}
|
|
205
|
+
/** {@link formatEntriesDirectory} with an explicit cap (configurable). */
|
|
206
|
+
export function formatEntriesDirectoryCapped(maxLines, ...kindEntries) {
|
|
195
207
|
const allEntries = kindEntries.flat().filter((e) => !isArchived(e));
|
|
196
208
|
if (allEntries.length === 0) {
|
|
197
209
|
return "";
|
|
198
210
|
}
|
|
199
|
-
// Skip the directory when
|
|
200
|
-
//
|
|
201
|
-
|
|
202
|
-
|
|
211
|
+
// Skip the directory only when EVERY entry is already content-visible.
|
|
212
|
+
// Only the first two arrays (prompt, subagent) have curated sections —
|
|
213
|
+
// memories and skills have NO content injection, so they are invisible
|
|
214
|
+
// unless the directory lists them (pre-2026-08-22 the redundancy check
|
|
215
|
+
// wrongly counted them as "already shown", hiding small stores entirely).
|
|
216
|
+
const contentVisible = kindEntries
|
|
217
|
+
.slice(0, CONTENT_SECTION_KINDS)
|
|
218
|
+
.reduce((sum, entries) => sum + Math.min(entries.filter((e) => !isArchived(e)).length, MAX_INJECTED_ENTRIES_PER_KIND), 0);
|
|
219
|
+
if (allEntries.length <= contentVisible) {
|
|
203
220
|
return "";
|
|
204
221
|
}
|
|
222
|
+
const sorted = [...allEntries].sort((a, b) => `${a.kind}:${a.id}`.localeCompare(`${b.kind}:${b.id}`));
|
|
205
223
|
const lines = ["# Continual Harness — Entry Directory", "All entries (use evolve_list for full text of any entry):"];
|
|
206
|
-
|
|
224
|
+
const shown = sorted.slice(0, Math.max(maxLines, 1));
|
|
225
|
+
for (const entry of shown) {
|
|
207
226
|
lines.push(`- [${entry.kind}:${entry.id}] ${entry.title}`);
|
|
208
227
|
}
|
|
228
|
+
const hidden = sorted.length - shown.length;
|
|
229
|
+
if (hidden > 0) {
|
|
230
|
+
lines.push(`- …and ${hidden} more entries (evolve_list for the full index)`);
|
|
231
|
+
}
|
|
209
232
|
return lines.join("\n");
|
|
210
233
|
}
|
|
211
234
|
/**
|
|
@@ -236,8 +259,13 @@ export function nearestLocalStateWithEntries(engine, agent) {
|
|
|
236
259
|
* (relevance first, then recency; see {@link rankEntries}). Returns "" when
|
|
237
260
|
* nothing is injectable — the prompt renderer then drops the section, so an
|
|
238
261
|
* empty store adds zero tokens to every assembly.
|
|
262
|
+
*
|
|
263
|
+
* `opts.directoryLines` caps the entry-directory index (2026-08-22 throttle).
|
|
264
|
+
* Usage recording covers ALL kinds — memories and skills appear as directory
|
|
265
|
+
* lines, prompts/subagents as content — and is deduped per session so the
|
|
266
|
+
* counts read "how many sessions saw this", not "how many prompt builds".
|
|
239
267
|
*/
|
|
240
|
-
export function entriesSectionText(engine, agent, query) {
|
|
268
|
+
export function entriesSectionText(engine, agent, query, opts) {
|
|
241
269
|
if (!agent) {
|
|
242
270
|
return "";
|
|
243
271
|
}
|
|
@@ -250,28 +278,41 @@ export function entriesSectionText(engine, agent, query) {
|
|
|
250
278
|
// Build injected text and collect which entries were included (gap B1).
|
|
251
279
|
const promptText = formatPromptEntriesSection(promptEntries, relevanceQuery);
|
|
252
280
|
const subagentText = formatSubagentSpecsSection(subagentEntries, relevanceQuery);
|
|
253
|
-
const injectedKeys =
|
|
281
|
+
const injectedKeys = new Set();
|
|
254
282
|
// Collect keys from the visible (ranked, capped) entries that actually appear.
|
|
255
283
|
const visiblePrompt = promptEntries.filter((e) => !isArchived(e));
|
|
256
284
|
const visibleSubagent = subagentEntries.filter((e) => !isArchived(e));
|
|
257
285
|
for (const entry of rankEntries(visiblePrompt, relevanceQuery).slice(0, MAX_INJECTED_ENTRIES_PER_KIND)) {
|
|
258
|
-
injectedKeys.
|
|
286
|
+
injectedKeys.add(`prompt:${entry.id}`);
|
|
259
287
|
}
|
|
260
288
|
for (const entry of rankEntries(visibleSubagent, relevanceQuery).slice(0, MAX_INJECTED_ENTRIES_PER_KIND)) {
|
|
261
|
-
injectedKeys.
|
|
289
|
+
injectedKeys.add(`subagent:${entry.id}`);
|
|
290
|
+
}
|
|
291
|
+
// Gap B3: lightweight directory of ALL entries (id+title, one line each).
|
|
292
|
+
// Zero-cost index so the model knows what exists and can ask for full text.
|
|
293
|
+
const directoryText = formatEntriesDirectoryCapped(opts?.directoryLines ?? DEFAULT_DIRECTORY_LINES, Object.values(merged.entries.prompt), Object.values(merged.entries.memory), Object.values(merged.entries.skill), Object.values(merged.entries.subagent));
|
|
294
|
+
// Directory-visible keys count too: a memory's injection IS its directory
|
|
295
|
+
// line. Set semantics keep content-injected entries single-counted.
|
|
296
|
+
for (const kind of ["prompt", "memory", "skill", "subagent"]) {
|
|
297
|
+
for (const entry of Object.values(merged.entries[kind])) {
|
|
298
|
+
if (isArchived(entry))
|
|
299
|
+
continue;
|
|
300
|
+
const key = `${kind}:${entry.id}`;
|
|
301
|
+
if (injectedKeys.has(key) || directoryText.includes(`[${key}]`)) {
|
|
302
|
+
injectedKeys.add(key);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
262
305
|
}
|
|
263
306
|
// Record usage durably (best-effort: failure never blocks injection).
|
|
264
|
-
|
|
307
|
+
// Deduped per session — see recordInjection.
|
|
308
|
+
if (injectedKeys.size > 0) {
|
|
265
309
|
try {
|
|
266
|
-
recordInjection(engine.baseDir, injectedKeys);
|
|
310
|
+
recordInjection(engine.baseDir, [...injectedKeys], agent.id);
|
|
267
311
|
}
|
|
268
312
|
catch {
|
|
269
313
|
// Usage recording is diagnostic; never interrupt the injection path.
|
|
270
314
|
}
|
|
271
315
|
}
|
|
272
|
-
// Gap B3: lightweight directory of ALL entries (id+title, one line each).
|
|
273
|
-
// Zero-cost index so the model knows what exists and can ask for full text.
|
|
274
|
-
const directoryText = formatEntriesDirectory(Object.values(merged.entries.prompt), Object.values(merged.entries.memory), Object.values(merged.entries.skill), Object.values(merged.entries.subagent));
|
|
275
316
|
const parts = [promptText, subagentText, directoryText].filter((part) => part.length > 0);
|
|
276
317
|
return parts.join("\n\n");
|
|
277
318
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Promotion policy: mechanical, code-enforced gates that decide whether a
|
|
3
|
+
* local entry MAY be promoted to the cross-session global store ("模型提议,
|
|
4
|
+
* 代码保证"). The LLM classification proposes; these guards dispose.
|
|
5
|
+
*
|
|
6
|
+
* Rationale (2026-08-22 store audit): the global store is shared by every
|
|
7
|
+
* project on one profile, so promoting project-scoped knowledge taxes every
|
|
8
|
+
* future session in every project. Measured failure modes:
|
|
9
|
+
* - absolute paths / session ids in promoted content (project-scoped),
|
|
10
|
+
* - near-duplicates of existing global entries re-promoted from later
|
|
11
|
+
* sessions (title matching alone missed them),
|
|
12
|
+
* - one-line facts whose framing costs more than their content.
|
|
13
|
+
*
|
|
14
|
+
* Pure functions only — the callers (wrapup command, gate local-fate phase)
|
|
15
|
+
* supply the resolved {@link PromotionPolicy}.
|
|
16
|
+
*/
|
|
17
|
+
import type { HarnessState, RefinementKind } from "./types.js";
|
|
18
|
+
export interface PromotionPolicy {
|
|
19
|
+
/** Content matching any pattern is project-scoped and stays local. */
|
|
20
|
+
blockPatterns: RegExp[];
|
|
21
|
+
/** Whole promotions below this content length stay local (chars). */
|
|
22
|
+
minPromoteChars: number;
|
|
23
|
+
/** Content overlap above this against a global entry = duplicate. */
|
|
24
|
+
maxContentOverlap: number;
|
|
25
|
+
}
|
|
26
|
+
export declare const DEFAULT_PROMOTION_POLICY: PromotionPolicy;
|
|
27
|
+
/**
|
|
28
|
+
* Build a policy from config values (schemastery strings compiled here so
|
|
29
|
+
* the config layer never touches RegExp). Invalid patterns are skipped —
|
|
30
|
+
* a broken user pattern must not disable the remaining guards.
|
|
31
|
+
*/
|
|
32
|
+
export declare function resolvePromotionPolicy(options: {
|
|
33
|
+
blockPatterns?: readonly string[] | undefined;
|
|
34
|
+
minPromoteChars?: number | undefined;
|
|
35
|
+
maxContentOverlap?: number | undefined;
|
|
36
|
+
}): PromotionPolicy;
|
|
37
|
+
/**
|
|
38
|
+
* First reason the content reads as project-scoped, or undefined when it
|
|
39
|
+
* looks portable. Returns the matched pattern source so skip reports stay
|
|
40
|
+
* explainable in reviews.jsonl rationales.
|
|
41
|
+
*/
|
|
42
|
+
export declare function projectScopedReason(content: string, policy: PromotionPolicy): string | undefined;
|
|
43
|
+
/**
|
|
44
|
+
* Tokenize for cheap similarity: ASCII words plus CJK character bigrams
|
|
45
|
+
* (single CJK chars are too ambiguous; bigrams survive segmentation-free
|
|
46
|
+
* Chinese text). Lowercased; single-char ASCII tokens dropped as noise.
|
|
47
|
+
*/
|
|
48
|
+
export declare function normalizedTokens(text: string): Set<string>;
|
|
49
|
+
/** Jaccard similarity of two texts' normalized token sets (0..1). */
|
|
50
|
+
export declare function contentOverlap(a: string, b: string): number;
|
|
51
|
+
export interface SimilarEntryHit {
|
|
52
|
+
id: string;
|
|
53
|
+
title: string;
|
|
54
|
+
score: number;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The most similar non-archived global entry of the same kind, above the
|
|
58
|
+
* policy threshold. Title and content both feed the comparison (titles are
|
|
59
|
+
* short; content carries the real signal).
|
|
60
|
+
*/
|
|
61
|
+
export declare function mostSimilarGlobalEntry(globalState: HarnessState, kind: RefinementKind, title: string, content: string, policy: PromotionPolicy): SimilarEntryHit | undefined;
|
|
62
|
+
//# sourceMappingURL=promotion.d.ts.map
|
package/lib/promotion.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { isArchived } from "./types.js";
|
|
2
|
+
/** Regex sources that mark content as project-scoped (never global). */
|
|
3
|
+
const DEFAULT_BLOCK_PATTERNS = [
|
|
4
|
+
String.raw `\/(?:mnt|home|Users)\/`, // absolute POSIX paths: "/home/…", "/mnt/…", "/Users/…"
|
|
5
|
+
String.raw `\bsession-[0-9a-f]{8}\b`, // session-scoped identifiers
|
|
6
|
+
String.raw `~/\.dsh\b`, // user harness home references
|
|
7
|
+
];
|
|
8
|
+
export const DEFAULT_PROMOTION_POLICY = {
|
|
9
|
+
blockPatterns: DEFAULT_BLOCK_PATTERNS.map((source) => new RegExp(source, "i")),
|
|
10
|
+
minPromoteChars: 100,
|
|
11
|
+
maxContentOverlap: 0.6,
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Build a policy from config values (schemastery strings compiled here so
|
|
15
|
+
* the config layer never touches RegExp). Invalid patterns are skipped —
|
|
16
|
+
* a broken user pattern must not disable the remaining guards.
|
|
17
|
+
*/
|
|
18
|
+
export function resolvePromotionPolicy(options) {
|
|
19
|
+
const patterns = [...(options.blockPatterns ?? [])]
|
|
20
|
+
.map((source) => {
|
|
21
|
+
try {
|
|
22
|
+
return new RegExp(source, "i");
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
.filter((pattern) => pattern !== undefined);
|
|
29
|
+
return {
|
|
30
|
+
blockPatterns: patterns.length > 0 ? patterns : DEFAULT_PROMOTION_POLICY.blockPatterns,
|
|
31
|
+
minPromoteChars: options.minPromoteChars ?? DEFAULT_PROMOTION_POLICY.minPromoteChars,
|
|
32
|
+
maxContentOverlap: options.maxContentOverlap ?? DEFAULT_PROMOTION_POLICY.maxContentOverlap,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* First reason the content reads as project-scoped, or undefined when it
|
|
37
|
+
* looks portable. Returns the matched pattern source so skip reports stay
|
|
38
|
+
* explainable in reviews.jsonl rationales.
|
|
39
|
+
*/
|
|
40
|
+
export function projectScopedReason(content, policy) {
|
|
41
|
+
for (const pattern of policy.blockPatterns) {
|
|
42
|
+
if (pattern.test(content)) {
|
|
43
|
+
return `project-scoped content (matches /${pattern.source}/)`;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Tokenize for cheap similarity: ASCII words plus CJK character bigrams
|
|
50
|
+
* (single CJK chars are too ambiguous; bigrams survive segmentation-free
|
|
51
|
+
* Chinese text). Lowercased; single-char ASCII tokens dropped as noise.
|
|
52
|
+
*/
|
|
53
|
+
export function normalizedTokens(text) {
|
|
54
|
+
const lowered = text.toLowerCase();
|
|
55
|
+
const tokens = new Set();
|
|
56
|
+
for (const match of lowered.matchAll(/[a-z0-9_]{2,}/g)) {
|
|
57
|
+
tokens.add(match[0] ?? "");
|
|
58
|
+
}
|
|
59
|
+
let previous;
|
|
60
|
+
for (const match of lowered.matchAll(/[\u3400-\u9fff]/g)) {
|
|
61
|
+
const char = match[0] ?? "";
|
|
62
|
+
if (previous !== undefined) {
|
|
63
|
+
tokens.add(`${previous}${char}`);
|
|
64
|
+
}
|
|
65
|
+
previous = char;
|
|
66
|
+
}
|
|
67
|
+
tokens.delete("");
|
|
68
|
+
return tokens;
|
|
69
|
+
}
|
|
70
|
+
/** Jaccard similarity of two texts' normalized token sets (0..1). */
|
|
71
|
+
export function contentOverlap(a, b) {
|
|
72
|
+
const left = normalizedTokens(a);
|
|
73
|
+
const right = normalizedTokens(b);
|
|
74
|
+
if (left.size === 0 || right.size === 0) {
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
let intersection = 0;
|
|
78
|
+
for (const token of left) {
|
|
79
|
+
if (right.has(token)) {
|
|
80
|
+
intersection += 1;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return intersection / (left.size + right.size - intersection);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The most similar non-archived global entry of the same kind, above the
|
|
87
|
+
* policy threshold. Title and content both feed the comparison (titles are
|
|
88
|
+
* short; content carries the real signal).
|
|
89
|
+
*/
|
|
90
|
+
export function mostSimilarGlobalEntry(globalState, kind, title, content, policy) {
|
|
91
|
+
let best;
|
|
92
|
+
for (const other of Object.values(globalState.entries[kind])) {
|
|
93
|
+
if (isArchived(other))
|
|
94
|
+
continue;
|
|
95
|
+
const score = Math.max(contentOverlap(title, other.title), contentOverlap(content, other.content));
|
|
96
|
+
if (score >= policy.maxContentOverlap && (best === undefined || score > best.score)) {
|
|
97
|
+
best = { id: other.id, title: other.title, score };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return best;
|
|
101
|
+
}
|
|
102
|
+
//# sourceMappingURL=promotion.js.map
|
package/lib/service.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { applyRefinementProposal } from "./apply.js";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { rollbackProposal } from "./rollback.js";
|
|
3
4
|
import { loadHarnessState, saveHarnessState } from "./state.js";
|
|
4
5
|
import { appendResult, loadResults, snapshotBefore, storePaths } from "./store.js";
|
|
@@ -9,7 +10,7 @@ export function createEvolutionEngine(baseDir, hooks = {}) {
|
|
|
9
10
|
function apply(scope, sessionId, proposal, context) {
|
|
10
11
|
const paths = storePaths(baseDir, scope, sessionId);
|
|
11
12
|
const state = context?.baselineState ?? load(scope, sessionId);
|
|
12
|
-
const id = `evolve_${Date.now().toString(36)}_${
|
|
13
|
+
const id = `evolve_${Date.now().toString(36)}_${randomUUID().slice(0, 8)}`;
|
|
13
14
|
// Code-enforced snapshot: runs before any mutation, cannot be skipped by the model.
|
|
14
15
|
snapshotBefore(paths, id);
|
|
15
16
|
const result = applyRefinementProposal(state, proposal, {
|
package/lib/skill-render.d.ts
CHANGED
|
@@ -10,6 +10,14 @@
|
|
|
10
10
|
import type { HarnessEntry } from "./types.js";
|
|
11
11
|
/** Convert a harness entry id (underscore slug) to a kebab-case skill name. */
|
|
12
12
|
export declare function skillNameOf(id: string): string;
|
|
13
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* Render a harness skill entry as a discoverable SKILL.md document.
|
|
15
|
+
*
|
|
16
|
+
* 2026-08-22: the frontmatter description now carries a ROUTING HINT —
|
|
17
|
+
* title plus the first meaningful content line — instead of the bare title.
|
|
18
|
+
* The skill catalog matches on description; a title-only description gave
|
|
19
|
+
* loaders nothing to route on (observed: materialized skills were 7-line
|
|
20
|
+
* stubs with a one-line description and no use-when signal).
|
|
21
|
+
*/
|
|
14
22
|
export declare function renderSkillMarkdown(entry: HarnessEntry): string;
|
|
15
23
|
//# sourceMappingURL=skill-render.d.ts.map
|
package/lib/skill-render.js
CHANGED
|
@@ -2,12 +2,50 @@
|
|
|
2
2
|
export function skillNameOf(id) {
|
|
3
3
|
return id.toLowerCase().replace(/_/g, "-");
|
|
4
4
|
}
|
|
5
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* First content line usable as a routing hint: non-empty, not a Markdown
|
|
7
|
+
* heading, not a list marker, not frontmatter. Undefined when the body is
|
|
8
|
+
* effectively empty.
|
|
9
|
+
*/
|
|
10
|
+
function routingHint(content) {
|
|
11
|
+
for (const rawLine of content.split("\n")) {
|
|
12
|
+
const line = rawLine.trim();
|
|
13
|
+
if (line.length === 0)
|
|
14
|
+
continue;
|
|
15
|
+
if (line.startsWith("#") || line.startsWith("---") || line.startsWith("-") || line.startsWith("*"))
|
|
16
|
+
continue;
|
|
17
|
+
return oneLine(line);
|
|
18
|
+
}
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
/** Max rendered frontmatter description length (loaders truncate anyway). */
|
|
22
|
+
const MAX_DESCRIPTION_LENGTH = 240;
|
|
23
|
+
/**
|
|
24
|
+
* Render a harness skill entry as a discoverable SKILL.md document.
|
|
25
|
+
*
|
|
26
|
+
* 2026-08-22: the frontmatter description now carries a ROUTING HINT —
|
|
27
|
+
* title plus the first meaningful content line — instead of the bare title.
|
|
28
|
+
* The skill catalog matches on description; a title-only description gave
|
|
29
|
+
* loaders nothing to route on (observed: materialized skills were 7-line
|
|
30
|
+
* stubs with a one-line description and no use-when signal).
|
|
31
|
+
*/
|
|
6
32
|
export function renderSkillMarkdown(entry) {
|
|
33
|
+
const hint = routingHint(entry.content);
|
|
34
|
+
const base = oneLine(entry.title);
|
|
35
|
+
let description;
|
|
36
|
+
if (base.length === 0) {
|
|
37
|
+
description = (hint ?? "").slice(0, MAX_DESCRIPTION_LENGTH);
|
|
38
|
+
}
|
|
39
|
+
else if (hint !== undefined && !base.toLowerCase().includes(hint.toLowerCase())) {
|
|
40
|
+
description = `${base} — use when: ${hint}`.slice(0, MAX_DESCRIPTION_LENGTH);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
description = base.slice(0, MAX_DESCRIPTION_LENGTH);
|
|
44
|
+
}
|
|
7
45
|
const lines = [
|
|
8
46
|
"---",
|
|
9
47
|
`name: ${skillNameOf(entry.id)}`,
|
|
10
|
-
`description: ${
|
|
48
|
+
`description: ${description}`,
|
|
11
49
|
"---",
|
|
12
50
|
"",
|
|
13
51
|
entry.content.trim(),
|
package/lib/state.js
CHANGED
|
@@ -80,8 +80,13 @@ export function loadHarnessState(stateDir, scope = "global") {
|
|
|
80
80
|
scope: normalizeScope(entry["scope"], scope),
|
|
81
81
|
reference: objectRecord(entry["reference"]) ?? {},
|
|
82
82
|
arguments: objectRecord(entry["arguments"]) ?? {},
|
|
83
|
+
// skill_kind must survive persistence: /evolve mount and the
|
|
84
|
+
// listing render branch on it (guidance vs executable).
|
|
85
|
+
...(entry["skill_kind"] === "guidance" || entry["skill_kind"] === "executable"
|
|
86
|
+
? { skill_kind: entry["skill_kind"] }
|
|
87
|
+
: {}),
|
|
83
88
|
metadata: objectRecord(entry["metadata"]) ?? {},
|
|
84
|
-
source:
|
|
89
|
+
source: "evolve",
|
|
85
90
|
created_at: typeof entry["created_at"] === "string" ? entry["created_at"] : new Date(0).toISOString(),
|
|
86
91
|
updated_at: typeof entry["updated_at"] === "string" ? entry["updated_at"] : new Date(0).toISOString(),
|
|
87
92
|
version: typeof entry["version"] === "number" ? entry["version"] : 1,
|
package/lib/usage.d.ts
CHANGED
|
@@ -2,19 +2,32 @@ import type { HarnessState, RefinementKind } from "./types.js";
|
|
|
2
2
|
export interface UsageStore {
|
|
3
3
|
/** Injection count per entry key (`kind:id`). */
|
|
4
4
|
counts: Record<string, number>;
|
|
5
|
+
/** Session dedup marker: the last session id each key was counted in (v2). */
|
|
6
|
+
lastSession?: Record<string, string>;
|
|
5
7
|
}
|
|
6
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* Load the usage store from disk; returns an empty store when absent or
|
|
10
|
+
* corrupt. Accepts BOTH on-disk shapes:
|
|
11
|
+
* - legacy (≤0.3.x): a flat `{ "kind:id": count }` map,
|
|
12
|
+
* - v2: `{ version: 2, counts, lastSession }` with per-session dedup.
|
|
13
|
+
*/
|
|
7
14
|
export declare function loadUsage(baseDir: string): UsageStore;
|
|
8
|
-
/** Persist the usage store atomically. */
|
|
15
|
+
/** Persist the usage store atomically (always the v2 shape). */
|
|
9
16
|
export declare function saveUsage(baseDir: string, store: UsageStore): void;
|
|
10
17
|
/** Build the usage key for an entry. */
|
|
11
18
|
export declare function usageKey(kind: RefinementKind, id: string): string;
|
|
12
19
|
/**
|
|
13
20
|
* Increment injection counts for the entries that were actually injected.
|
|
14
21
|
* Called after `entriesSectionText` renders the injected block. Keys not
|
|
15
|
-
* present in the store are initialized to 1
|
|
22
|
+
* present in the store are initialized to 1.
|
|
23
|
+
*
|
|
24
|
+
* Session dedup (2026-08-22): with a sessionId, each key counts AT MOST
|
|
25
|
+
* ONCE per session — the old per-build counting produced meaningless
|
|
26
|
+
* numbers (one entry hit 2311× in a week) and hid the real "how many
|
|
27
|
+
* sessions found this useful" signal that staleness decay needs. Without
|
|
28
|
+
* a sessionId the call degrades to legacy always-increment behavior.
|
|
16
29
|
*/
|
|
17
|
-
export declare function recordInjection(baseDir: string, injectedKeys: string[]): void;
|
|
30
|
+
export declare function recordInjection(baseDir: string, injectedKeys: readonly string[], sessionId?: string): void;
|
|
18
31
|
/**
|
|
19
32
|
* Get the injection count for a specific entry. Returns 0 when the entry
|
|
20
33
|
* has never been injected (absent from the store).
|