dsh-continual-evolve 0.3.0 → 0.5.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 +85 -409
- package/README.zh.md +86 -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 +66 -2
- package/lib/consolidate.d.ts +65 -0
- package/lib/consolidate.js +113 -0
- 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 +26 -17
- package/lib/inject.js +82 -49
- package/lib/promotion.d.ts +83 -0
- package/lib/promotion.js +127 -0
- package/lib/search.d.ts +83 -0
- package/lib/search.js +136 -0
- package/lib/service.js +41 -1
- package/lib/skill-render.d.ts +9 -1
- package/lib/skill-render.js +40 -2
- package/lib/state.js +6 -1
- package/lib/tool.js +8 -1
- package/lib/types.d.ts +8 -0
- package/lib/types.js +8 -0
- 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/command.js
CHANGED
|
@@ -13,6 +13,8 @@ import { executeGoalCommand } from "./goal-command.js";
|
|
|
13
13
|
import { executeMountCommand, executeUnmountCommand } from "./mount-command.js";
|
|
14
14
|
import { executeBenchmarkCommand } from "./benchmark-command.js";
|
|
15
15
|
import { executeWrapupCommand } from "./wrapup-command.js";
|
|
16
|
+
import { loadUsage } from "./usage.js";
|
|
17
|
+
import { planConsolidation } from "./consolidate.js";
|
|
16
18
|
const USAGE = `Usage:
|
|
17
19
|
/evolve show this help and the current local store
|
|
18
20
|
/evolve list [global] list entries (add "global" for the cross-session store)
|
|
@@ -23,6 +25,9 @@ const USAGE = `Usage:
|
|
|
23
25
|
to the global store (approval required), archive one-offs
|
|
24
26
|
/evolve archive <id> [global] hide an entry from injection (data kept, restorable)
|
|
25
27
|
/evolve unarchive <id> [global] restore an archived entry
|
|
28
|
+
/evolve demote <id> hide a (global) entry from injection, keep data
|
|
29
|
+
/evolve consolidate [apply] report (or apply) a batch archive of conflict-hinted
|
|
30
|
+
and stale zero-use global entries
|
|
26
31
|
/evolve log [tail N] show the recent plugin log (default 50 lines)
|
|
27
32
|
/evolve failures aggregated failure counts (gate + benchmark, by class)
|
|
28
33
|
/evolve export [global] <path> backup a store to a JSON file
|
|
@@ -139,12 +144,16 @@ async function executeEvolveCommand(ctx, engine, invocation, opts, runtime) {
|
|
|
139
144
|
return success(renderResult(result));
|
|
140
145
|
}
|
|
141
146
|
case "archive":
|
|
142
|
-
case "unarchive":
|
|
147
|
+
case "unarchive":
|
|
148
|
+
case "demote": {
|
|
143
149
|
const { scope, rest: after } = scopeArg(rest);
|
|
144
150
|
const id = stripAngleBrackets(after[0] ?? "");
|
|
145
151
|
if (!id) {
|
|
146
152
|
return error(`${sub} requires an entry id.\n${USAGE}`);
|
|
147
153
|
}
|
|
154
|
+
if (sub === "demote") {
|
|
155
|
+
return demoteEntry(engine, id, sessionId);
|
|
156
|
+
}
|
|
148
157
|
const state = engine.load(scope, sessionId);
|
|
149
158
|
const found = findEntryById(state, id);
|
|
150
159
|
if (!found) {
|
|
@@ -167,6 +176,28 @@ async function executeEvolveCommand(ctx, engine, invocation, opts, runtime) {
|
|
|
167
176
|
}, { scope });
|
|
168
177
|
return success(renderResult(result));
|
|
169
178
|
}
|
|
179
|
+
case "consolidate": {
|
|
180
|
+
// R3: deterministic global-store hygiene. Report by default;
|
|
181
|
+
// `apply` re-scans fresh state and lands the whole batch as ONE
|
|
182
|
+
// refinement (single snapshot + audit record, fully rollback-able).
|
|
183
|
+
const apply = rest[0] === "apply";
|
|
184
|
+
const state = engine.load("global", undefined);
|
|
185
|
+
const { candidates, edits } = planConsolidation(state, loadUsage(engine.baseDir));
|
|
186
|
+
if (candidates.length === 0) {
|
|
187
|
+
return success("global store is already consolidated — no conflict-hinted or stale zero-use entries.");
|
|
188
|
+
}
|
|
189
|
+
const report = candidates.map((candidate, index) => `${index + 1}. [${candidate.kind}:${candidate.id}] ${candidate.title}\n ${candidate.reason}`).join("\n");
|
|
190
|
+
if (!apply) {
|
|
191
|
+
return success(`consolidation plan — ${candidates.length} archive candidate(s):\n${report}\n(run "/evolve consolidate apply" to archive all of them in one refinement)`);
|
|
192
|
+
}
|
|
193
|
+
const result = engine.apply("global", undefined, {
|
|
194
|
+
summary: `Consolidate global store: archive ${candidates.length} entries`,
|
|
195
|
+
rationale: "Human-invoked batch consolidation via /evolve consolidate apply.",
|
|
196
|
+
expectedOutcome: "Candidate entries are hidden from injection (data kept; restorable via /evolve unarchive).",
|
|
197
|
+
edits,
|
|
198
|
+
}, { scope: "global" });
|
|
199
|
+
return success(`${report}\n\napplied:\n${renderResult(result)}`);
|
|
200
|
+
}
|
|
170
201
|
case "failures": {
|
|
171
202
|
// /evolve failures — failure-signature aggregation (D1 observation):
|
|
172
203
|
// failed review-gate records + failed benchmark cells, counted by class.
|
|
@@ -289,7 +320,7 @@ async function executeEvolveCommand(ctx, engine, invocation, opts, runtime) {
|
|
|
289
320
|
return success(renderResult(result));
|
|
290
321
|
}
|
|
291
322
|
case "wrapup": {
|
|
292
|
-
return await executeWrapupCommand(ctx, engine, invocation);
|
|
323
|
+
return await executeWrapupCommand(ctx, engine, invocation, runtime.promotionPolicy);
|
|
293
324
|
}
|
|
294
325
|
case "goal": {
|
|
295
326
|
return executeGoalCommand(ctx, invocation, rest);
|
|
@@ -311,6 +342,39 @@ async function executeEvolveCommand(ctx, engine, invocation, opts, runtime) {
|
|
|
311
342
|
return error(cause instanceof Error ? cause.message : String(cause));
|
|
312
343
|
}
|
|
313
344
|
}
|
|
345
|
+
/**
|
|
346
|
+
* Demote (2026-08-22): hide an entry from injection WITHOUT deleting it —
|
|
347
|
+
* the one-command remedy for global-store pollution. Searches the global
|
|
348
|
+
* store first (the primary target: cross-project noise), then the session's
|
|
349
|
+
* local store. The data stays; `/evolve unarchive` restores it.
|
|
350
|
+
*/
|
|
351
|
+
function demoteEntry(engine, id, sessionId) {
|
|
352
|
+
for (const scope of ["global", "local"]) {
|
|
353
|
+
const state = engine.load(scope, sessionId);
|
|
354
|
+
const found = findEntryById(state, id);
|
|
355
|
+
if (!found)
|
|
356
|
+
continue;
|
|
357
|
+
const [kind, entry] = found;
|
|
358
|
+
const result = engine.apply(scope, sessionId, {
|
|
359
|
+
summary: `demote: archive ${kind}:${id} from the ${scope} store`,
|
|
360
|
+
rationale: "Human-invoked demote via the /evolve command.",
|
|
361
|
+
expectedOutcome: "The entry is hidden from injection in every scope it touched; data is kept and restorable.",
|
|
362
|
+
edits: [
|
|
363
|
+
{
|
|
364
|
+
action: "update",
|
|
365
|
+
kind,
|
|
366
|
+
id,
|
|
367
|
+
title: entry.title,
|
|
368
|
+
content: entry.content,
|
|
369
|
+
metadata: { ...entry.metadata, [ARCHIVED_AT_KEY]: new Date().toISOString() },
|
|
370
|
+
},
|
|
371
|
+
],
|
|
372
|
+
}, { scope });
|
|
373
|
+
const restoreScope = scope === "global" ? " global" : "";
|
|
374
|
+
return success(`demoted ${kind}:${id} from the ${scope} store (archived — restore with /evolve unarchive ${id}${restoreScope})\n${renderResult(result)}`);
|
|
375
|
+
}
|
|
376
|
+
return error(`entry ${id} not found in the global or local store`);
|
|
377
|
+
}
|
|
314
378
|
function renderResult(result) {
|
|
315
379
|
const applied = result.appliedEdits.filter((e) => e.applied);
|
|
316
380
|
const failed = result.appliedEdits.filter((e) => !e.applied);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global-store consolidation (R3): turn the two hygiene signals — R2's
|
|
3
|
+
* `conflictHint` stamps and usage v2's zero-injection staleness — into one
|
|
4
|
+
* deterministic, human-approved batch of archive edits (`/evolve consolidate`).
|
|
5
|
+
*
|
|
6
|
+
* Design (ADR implemented/feature/2026-08-24-consolidation-command.md):
|
|
7
|
+
* - pure functions only; the command layer owns loading, reporting, and the
|
|
8
|
+
* apply phase, and ALWAYS re-scans fresh state before applying (the report
|
|
9
|
+
* and the apply are two separate invocations — prefer under-archiving over
|
|
10
|
+
* mis-archiving when state moved in between);
|
|
11
|
+
* - archiving keeps data (ARCHIVED_AT_KEY stamp, restorable via
|
|
12
|
+
* `/evolve unarchive`) and preserves every existing metadata key including
|
|
13
|
+
* the conflictHint itself, so unarchived entries keep their trail;
|
|
14
|
+
* - no LLM call anywhere: code proposes, the human disposes.
|
|
15
|
+
*/
|
|
16
|
+
import type { HarnessState, RefinementEdit, RefinementKind } from "./types.js";
|
|
17
|
+
import { type UsageStore } from "./usage.js";
|
|
18
|
+
/** Zero-use entries at least this old are stale candidates (30d, matching the injection recency half-life scale). */
|
|
19
|
+
export declare const STALE_MIN_AGE_MS: number;
|
|
20
|
+
/** Parsed `<kind>:<id>:<score>` value of a {@link CONFLICT_HINT_KEY} stamp. */
|
|
21
|
+
export interface ConflictHint {
|
|
22
|
+
kind: RefinementKind;
|
|
23
|
+
id: string;
|
|
24
|
+
score: number;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Parse one conflictHint metadata value. Returns undefined for anything that
|
|
28
|
+
* is not exactly `<kind>:<id>:<score>` with a known kind and a score in
|
|
29
|
+
* [0, 1] — foreign or legacy junk must never crash the scan.
|
|
30
|
+
*/
|
|
31
|
+
export declare function parseConflictHint(value: unknown): ConflictHint | undefined;
|
|
32
|
+
/** One planned archive with its human-readable justification. */
|
|
33
|
+
export interface ConsolidationCandidate {
|
|
34
|
+
kind: RefinementKind;
|
|
35
|
+
id: string;
|
|
36
|
+
title: string;
|
|
37
|
+
reason: string;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Entries stamped by the write-time conflict guard whose target still exists
|
|
41
|
+
* and is still active. The hinted (newer) entry is the archive candidate —
|
|
42
|
+
* the pointed-to original stays as the live copy.
|
|
43
|
+
*/
|
|
44
|
+
export declare function findConflictPairs(state: HarnessState): ConsolidationCandidate[];
|
|
45
|
+
/**
|
|
46
|
+
* Active global entries never injected in any session and untouched for at
|
|
47
|
+
* least `minAgeMs`: prime staleness candidates. Operates on whatever state
|
|
48
|
+
* it is handed (callers pass the global store).
|
|
49
|
+
*/
|
|
50
|
+
export declare function findStaleEntries(state: HarnessState, store: UsageStore, now: number, minAgeMs?: number): ConsolidationCandidate[];
|
|
51
|
+
/**
|
|
52
|
+
* Merge both scans (conflict reason wins on overlap), deduped by kind:id,
|
|
53
|
+
* and build the batch archive edits. Each edit preserves the entry's full
|
|
54
|
+
* content and existing metadata — only ARCHIVED_AT_KEY is added — so
|
|
55
|
+
* `/evolve unarchive` restores everything intact.
|
|
56
|
+
*
|
|
57
|
+
* @param now Epoch ms used for the archivedAt stamp (injected for tests).
|
|
58
|
+
*/
|
|
59
|
+
export declare function planConsolidation(state: HarnessState, store: UsageStore, now?: number, opts?: {
|
|
60
|
+
minAgeMs?: number;
|
|
61
|
+
}): {
|
|
62
|
+
candidates: ConsolidationCandidate[];
|
|
63
|
+
edits: RefinementEdit[];
|
|
64
|
+
};
|
|
65
|
+
//# sourceMappingURL=consolidate.d.ts.map
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { ARCHIVED_AT_KEY, CONFLICT_HINT_KEY, isArchived } from "./types.js";
|
|
2
|
+
import { getUsageCount } from "./usage.js";
|
|
3
|
+
/** Zero-use entries at least this old are stale candidates (30d, matching the injection recency half-life scale). */
|
|
4
|
+
export const STALE_MIN_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
5
|
+
const KINDS = ["prompt", "memory", "skill", "subagent"];
|
|
6
|
+
/**
|
|
7
|
+
* Parse one conflictHint metadata value. Returns undefined for anything that
|
|
8
|
+
* is not exactly `<kind>:<id>:<score>` with a known kind and a score in
|
|
9
|
+
* [0, 1] — foreign or legacy junk must never crash the scan.
|
|
10
|
+
*/
|
|
11
|
+
export function parseConflictHint(value) {
|
|
12
|
+
if (typeof value !== "string") {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
const parts = value.split(":");
|
|
16
|
+
if (parts.length !== 3) {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
const [kind, id, rawScore] = parts;
|
|
20
|
+
if (!kind || !id || !rawScore || !KINDS.includes(kind)) {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
const score = Number(rawScore);
|
|
24
|
+
if (!Number.isFinite(score) || score < 0 || score > 1) {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
return { kind: kind, id, score };
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Entries stamped by the write-time conflict guard whose target still exists
|
|
31
|
+
* and is still active. The hinted (newer) entry is the archive candidate —
|
|
32
|
+
* the pointed-to original stays as the live copy.
|
|
33
|
+
*/
|
|
34
|
+
export function findConflictPairs(state) {
|
|
35
|
+
const candidates = [];
|
|
36
|
+
for (const kind of KINDS) {
|
|
37
|
+
for (const entry of Object.values(state.entries[kind])) {
|
|
38
|
+
if (isArchived(entry))
|
|
39
|
+
continue;
|
|
40
|
+
const hint = parseConflictHint(entry.metadata[CONFLICT_HINT_KEY]);
|
|
41
|
+
if (!hint || hint.kind !== kind)
|
|
42
|
+
continue;
|
|
43
|
+
const target = state.entries[hint.kind]?.[hint.id];
|
|
44
|
+
if (!target || isArchived(target))
|
|
45
|
+
continue;
|
|
46
|
+
candidates.push({
|
|
47
|
+
kind,
|
|
48
|
+
id: entry.id,
|
|
49
|
+
title: entry.title,
|
|
50
|
+
reason: `near-duplicate of ${hint.id} 「${target.title}」 (${Math.round(hint.score * 100)}%) — keep the original`,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return candidates;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Active global entries never injected in any session and untouched for at
|
|
58
|
+
* least `minAgeMs`: prime staleness candidates. Operates on whatever state
|
|
59
|
+
* it is handed (callers pass the global store).
|
|
60
|
+
*/
|
|
61
|
+
export function findStaleEntries(state, store, now, minAgeMs = STALE_MIN_AGE_MS) {
|
|
62
|
+
const candidates = [];
|
|
63
|
+
for (const kind of KINDS) {
|
|
64
|
+
for (const entry of Object.values(state.entries[kind])) {
|
|
65
|
+
if (isArchived(entry))
|
|
66
|
+
continue;
|
|
67
|
+
if (getUsageCount(store, kind, entry.id) !== 0)
|
|
68
|
+
continue;
|
|
69
|
+
const updatedAt = Date.parse(entry.updated_at);
|
|
70
|
+
if (Number.isNaN(updatedAt) || now - updatedAt < minAgeMs)
|
|
71
|
+
continue;
|
|
72
|
+
candidates.push({
|
|
73
|
+
kind,
|
|
74
|
+
id: entry.id,
|
|
75
|
+
title: entry.title,
|
|
76
|
+
reason: `0 injections since ${entry.updated_at.slice(0, 10)} (stale)`,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return candidates;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Merge both scans (conflict reason wins on overlap), deduped by kind:id,
|
|
84
|
+
* and build the batch archive edits. Each edit preserves the entry's full
|
|
85
|
+
* content and existing metadata — only ARCHIVED_AT_KEY is added — so
|
|
86
|
+
* `/evolve unarchive` restores everything intact.
|
|
87
|
+
*
|
|
88
|
+
* @param now Epoch ms used for the archivedAt stamp (injected for tests).
|
|
89
|
+
*/
|
|
90
|
+
export function planConsolidation(state, store, now = Date.now(), opts) {
|
|
91
|
+
const byKey = new Map();
|
|
92
|
+
for (const candidate of [...findConflictPairs(state), ...findStaleEntries(state, store, now, opts?.minAgeMs)]) {
|
|
93
|
+
const key = `${candidate.kind}:${candidate.id}`;
|
|
94
|
+
if (!byKey.has(key)) {
|
|
95
|
+
byKey.set(key, candidate);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const candidates = [...byKey.values()];
|
|
99
|
+
const edits = candidates.map((candidate) => {
|
|
100
|
+
const entry = state.entries[candidate.kind][candidate.id];
|
|
101
|
+
const metadata = { ...entry?.metadata, [ARCHIVED_AT_KEY]: new Date(now).toISOString() };
|
|
102
|
+
return {
|
|
103
|
+
action: "update",
|
|
104
|
+
kind: candidate.kind,
|
|
105
|
+
id: candidate.id,
|
|
106
|
+
title: candidate.title,
|
|
107
|
+
content: entry?.content ?? "",
|
|
108
|
+
metadata,
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
return { candidates, edits };
|
|
112
|
+
}
|
|
113
|
+
//# sourceMappingURL=consolidate.js.map
|
package/lib/fate.d.ts
CHANGED
|
@@ -34,6 +34,7 @@ import type { HarnessState, RefinementResult } from "./types.js";
|
|
|
34
34
|
import type { EvolutionEngine } from "./service.js";
|
|
35
35
|
import type { AutoRefineReason } from "./review.js";
|
|
36
36
|
import type { AutoReviewConfig, GateState, ReviewRecord } from "./auto.js";
|
|
37
|
+
import { type PromotionPolicy } from "./promotion.js";
|
|
37
38
|
import { type WrapupCandidate, type WrapupItem } from "./wrapup.js";
|
|
38
39
|
/** Turns a declined local-fate proposal stays silent before being offered again. */
|
|
39
40
|
export declare const FATE_CONSULT_COOLDOWN_TURNS = 10;
|
|
@@ -68,7 +69,7 @@ export interface FatePlan {
|
|
|
68
69
|
* may have changed while the LLM call was in flight). Pure and unit-tested;
|
|
69
70
|
* mirrors the partition step of the wrap-up command.
|
|
70
71
|
*/
|
|
71
|
-
export declare function planLocalFates(items: readonly WrapupItem[], candidates: readonly WrapupCandidate[], globalState: HarnessState): FatePlan;
|
|
72
|
+
export declare function planLocalFates(items: readonly WrapupItem[], candidates: readonly WrapupCandidate[], globalState: HarnessState, policy?: PromotionPolicy): FatePlan;
|
|
72
73
|
/**
|
|
73
74
|
* The cooldown key of a candidate set: the sorted `kind:id` list. The set is
|
|
74
75
|
* the unit of consultation — a declined proposal is not offered again within
|
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
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import type { HarnessEntry, HarnessState } from "./types.js";
|
|
21
21
|
import type { EvolutionEngine } from "./service.js";
|
|
22
|
+
import { tokenize } from "./search.js";
|
|
23
|
+
/** CJK-bigram tokenizer re-exported for ranking consumers (see search.ts). */
|
|
24
|
+
export { tokenize };
|
|
22
25
|
/** Prompt sections render at most this many entries per kind. */
|
|
23
26
|
export declare const MAX_INJECTED_ENTRIES_PER_KIND = 6;
|
|
24
27
|
/** Per-entry content budget inside the injected block (matches render.ts). */
|
|
@@ -60,17 +63,6 @@ export interface AgentLike {
|
|
|
60
63
|
export interface InjectContext {
|
|
61
64
|
agent?: AgentLike;
|
|
62
65
|
}
|
|
63
|
-
/**
|
|
64
|
-
* Lowercase tokenization for the keyword relevance scorer: runs of ASCII
|
|
65
|
-
* alphanumerics and CJK characters become tokens (CJK is not split so whole
|
|
66
|
-
* Chinese words/characters stay comparable), everything else is a separator.
|
|
67
|
-
*/
|
|
68
|
-
export declare function tokenize(text: string): string[];
|
|
69
|
-
/**
|
|
70
|
-
* Keyword hit count of `query` tokens inside an entry: title hits weigh 2×,
|
|
71
|
-
* content/path hits 1×. BM25-level relevance without any external service.
|
|
72
|
-
*/
|
|
73
|
-
export declare function relevanceHits(entry: HarnessEntry, query: string): number;
|
|
74
66
|
/**
|
|
75
67
|
* Normalized recency in [0, 1]: 1 when the entry was just updated, decaying
|
|
76
68
|
* linearly to 0 after {@link RECENCY_HALF_LIFE_MS}. Unparseable timestamps
|
|
@@ -79,11 +71,13 @@ export declare function relevanceHits(entry: HarnessEntry, query: string): numbe
|
|
|
79
71
|
export declare function recencyScore(entry: HarnessEntry, now: number): number;
|
|
80
72
|
/**
|
|
81
73
|
* Rank entries for injection, best first. With no query the ranking is pure
|
|
82
|
-
* recency (newest first). With a query,
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
74
|
+
* recency (newest first). With a query, entries are scored once against a
|
|
75
|
+
* per-call BM25 index (CJK bigrams; field-weighted title ×2 — see
|
|
76
|
+
* search.ts): any entry with a positive score (≥1 matched token) outranks
|
|
77
|
+
* every hit-less entry (score exactly 0), scores decide the order among
|
|
78
|
+
* relevant entries, recency breaks remaining ties, and the stable dictionary
|
|
79
|
+
* order is the final tiebreak, so the result is deterministic. The input is
|
|
80
|
+
* never mutated.
|
|
87
81
|
*/
|
|
88
82
|
export declare function rankEntries(entries: readonly HarnessEntry[], query?: string, now?: number): HarnessEntry[];
|
|
89
83
|
/**
|
|
@@ -109,8 +103,16 @@ export declare function formatSubagentSpecsSection(entries: readonly HarnessEntr
|
|
|
109
103
|
* the model a zero-cost overview of what exists so it can ask for full text
|
|
110
104
|
* via `evolve_list` or `/evolve list`. The directory is appended after the
|
|
111
105
|
* curated top-N injection sections and adds minimal tokens.
|
|
106
|
+
*
|
|
107
|
+
* 2026-08-22 throttle: the directory is CAPPED at {@link DEFAULT_DIRECTORY_LINES}
|
|
108
|
+
* lines (oldest-sorted stable order) with the remainder folded into a single
|
|
109
|
+
* counter line — an uncapped directory across a polluted global store was
|
|
110
|
+
* measured at ~2K chars of every build in every project.
|
|
112
111
|
*/
|
|
112
|
+
export declare const DEFAULT_DIRECTORY_LINES = 15;
|
|
113
113
|
export declare function formatEntriesDirectory(...kindEntries: readonly HarnessEntry[][]): string;
|
|
114
|
+
/** {@link formatEntriesDirectory} with an explicit cap (configurable). */
|
|
115
|
+
export declare function formatEntriesDirectoryCapped(maxLines: number, ...kindEntries: readonly HarnessEntry[][]): string;
|
|
114
116
|
/**
|
|
115
117
|
* Walk the parent-session chain from `agent` upward and return the nearest
|
|
116
118
|
* session whose local store is non-empty, if any. Children inherit their
|
|
@@ -127,6 +129,13 @@ export declare function nearestLocalStateWithEntries(engine: EvolutionEngine, ag
|
|
|
127
129
|
* (relevance first, then recency; see {@link rankEntries}). Returns "" when
|
|
128
130
|
* nothing is injectable — the prompt renderer then drops the section, so an
|
|
129
131
|
* empty store adds zero tokens to every assembly.
|
|
132
|
+
*
|
|
133
|
+
* `opts.directoryLines` caps the entry-directory index (2026-08-22 throttle).
|
|
134
|
+
* Usage recording covers ALL kinds — memories and skills appear as directory
|
|
135
|
+
* lines, prompts/subagents as content — and is deduped per session so the
|
|
136
|
+
* counts read "how many sessions saw this", not "how many prompt builds".
|
|
130
137
|
*/
|
|
131
|
-
export declare function entriesSectionText(engine: EvolutionEngine, agent: AgentLike | undefined, query?: string
|
|
138
|
+
export declare function entriesSectionText(engine: EvolutionEngine, agent: AgentLike | undefined, query?: string, opts?: {
|
|
139
|
+
directoryLines?: number;
|
|
140
|
+
}): string;
|
|
132
141
|
//# sourceMappingURL=inject.d.ts.map
|