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/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): 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 it would be redundant (all entries already shown
200
- // in the curated sections above 6/kind cap means ≤6 entries total).
201
- const totalCapped = kindEntries.reduce((sum, entries) => sum + Math.min(entries.filter((e) => !isArchived(e)).length, MAX_INJECTED_ENTRIES_PER_KIND), 0);
202
- if (allEntries.length <= totalCapped) {
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
- for (const entry of allEntries.sort((a, b) => `${a.kind}:${a.id}`.localeCompare(`${b.kind}:${b.id}`))) {
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.push(`prompt:${entry.id}`);
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.push(`subagent:${entry.id}`);
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
- if (injectedKeys.length > 0) {
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
@@ -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)}_${Math.random().toString(36).slice(2, 8)}`;
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, {
@@ -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
- /** Render a harness skill entry as a discoverable SKILL.md document. */
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
@@ -2,12 +2,50 @@
2
2
  export function skillNameOf(id) {
3
3
  return id.toLowerCase().replace(/_/g, "-");
4
4
  }
5
- /** Render a harness skill entry as a discoverable SKILL.md document. */
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: ${oneLine(entry.title)}`,
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: entry["source"] === "evolve" ? "evolve" : "evolve",
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
- /** Load the usage store from disk; returns an empty store when absent or corrupt. */
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; existing keys are incremented.
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).