dsh-continual-evolve 0.4.0 → 0.6.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/command.js CHANGED
@@ -8,11 +8,13 @@ import { saveHarnessState } from "./state.js";
8
8
  import { appendResult, storePaths } from "./store.js";
9
9
  import { entrySourceOf } from "./source.js";
10
10
  import { filterLogBySession, formatLogLine, pluginLogFilePath } from "./logfile.js";
11
- import { readBenchmarkFailures, readReviewFailures, summarizeFailures, formatFailureSummary } from "./failures.js";
11
+ import { collectFailureSummary, formatFailureSummary } from "./failures.js";
12
12
  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)
@@ -24,6 +26,10 @@ const USAGE = `Usage:
24
26
  /evolve archive <id> [global] hide an entry from injection (data kept, restorable)
25
27
  /evolve unarchive <id> [global] restore an archived entry
26
28
  /evolve demote <id> hide a (global) entry from injection, keep data
29
+ /evolve consolidate [apply] [merge]
30
+ report (or apply) a batch archive of conflict-hinted
31
+ and stale zero-use global entries; "merge" folds
32
+ near-duplicate content into the surviving original
27
33
  /evolve log [tail N] show the recent plugin log (default 50 lines)
28
34
  /evolve failures aggregated failure counts (gate + benchmark, by class)
29
35
  /evolve export [global] <path> backup a store to a JSON file
@@ -172,13 +178,43 @@ async function executeEvolveCommand(ctx, engine, invocation, opts, runtime) {
172
178
  }, { scope });
173
179
  return success(renderResult(result));
174
180
  }
181
+ case "consolidate": {
182
+ // R3: deterministic global-store hygiene. Report by default;
183
+ // `apply` re-scans fresh state and lands the whole batch as ONE
184
+ // refinement (single snapshot + audit record, fully rollback-able).
185
+ // `merge` (P1 反膨胀) additionally merges conflict-pair content
186
+ // into the surviving original instead of only archiving.
187
+ const apply = rest[0] === "apply";
188
+ const merge = rest.includes("merge");
189
+ const state = engine.load("global", undefined);
190
+ const { candidates, edits } = planConsolidation(state, loadUsage(engine.baseDir), Date.now(), { mergeDuplicates: merge });
191
+ if (candidates.length === 0) {
192
+ return success("global store is already consolidated — no conflict-hinted or stale zero-use entries.");
193
+ }
194
+ const mergeCount = merge ? candidates.filter((candidate) => candidate.mergeInto).length : 0;
195
+ const report = candidates
196
+ .map((candidate, index) => {
197
+ const mergeNote = candidate.mergeInto && merge ? ` → 内容并入 ${candidate.mergeInto.id}` : "";
198
+ return `${index + 1}. [${candidate.kind}:${candidate.id}] ${candidate.title}${mergeNote}\n ${candidate.reason}`;
199
+ })
200
+ .join("\n");
201
+ if (!apply) {
202
+ return success(`consolidation plan — ${candidates.length} archive candidate(s):\n${report}\n(run "/evolve consolidate apply" to archive all of them in one refinement; add "merge" to fold near-duplicate content into the survivors)`);
203
+ }
204
+ const result = engine.apply("global", undefined, {
205
+ summary: `Consolidate global store: archive ${candidates.length} entries${mergeCount > 0 ? `, merge ${mergeCount} into survivors` : ""}`,
206
+ rationale: "Human-invoked batch consolidation via /evolve consolidate apply.",
207
+ expectedOutcome: "Candidate entries are hidden from injection (data kept; restorable via /evolve unarchive). Merged survivors carry the near-duplicate content with a mergedFrom provenance stamp.",
208
+ edits,
209
+ }, { scope: "global" });
210
+ return success(`${report}\n\napplied:\n${renderResult(result)}`);
211
+ }
175
212
  case "failures": {
176
213
  // /evolve failures — failure-signature aggregation (D1 observation):
177
214
  // failed review-gate records + failed benchmark cells, counted by class.
178
- const failed = [...readReviewFailures(engine.baseDir), ...readBenchmarkFailures(engine.baseDir)];
179
- const summary = summarizeFailures(failed);
215
+ const { summary, records } = collectFailureSummary(engine.baseDir);
180
216
  const parts = formatFailureSummary(summary).split("\n");
181
- const recent = failed
217
+ const recent = records
182
218
  .sort((a, b) => (b.timestamp ?? "").localeCompare(a.timestamp ?? ""))
183
219
  .slice(0, 10)
184
220
  .map((f) => ` [${f.timestamp ?? "(benchmark)"}] ${f.kind} · ${f.source}: ${f.message.slice(0, 140)}`);
@@ -234,7 +270,7 @@ async function executeEvolveCommand(ctx, engine, invocation, opts, runtime) {
234
270
  refinements: state.refinements,
235
271
  history,
236
272
  };
237
- writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
273
+ writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
238
274
  return success(`exported ${scope} store (${Object.values(state.entries).reduce((n, e) => n + Object.keys(e).length, 0)} entries, ${history.length} refinements) to ${path}`);
239
275
  }
240
276
  case "import": {
@@ -0,0 +1,90 @@
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
+ * Merge tier (P1 反膨胀): set on conflict-pair candidates — the entry's
40
+ * content merges into this survivor (pointed-to original) before the
41
+ * entry itself archives, so nothing readable is lost by the archive. The
42
+ * plan only EMITS the merge when `mergeDuplicates` is on.
43
+ */
44
+ mergeInto?: {
45
+ kind: RefinementKind;
46
+ id: string;
47
+ title: string;
48
+ };
49
+ }
50
+ /**
51
+ * Entries stamped by the write-time conflict guard whose target still exists
52
+ * and is still active. The hinted (newer) entry is the archive candidate —
53
+ * the pointed-to original stays as the live copy.
54
+ */
55
+ export declare function findConflictPairs(state: HarnessState): ConsolidationCandidate[];
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 declare function findStaleEntries(state: HarnessState, store: UsageStore, now: number, minAgeMs?: number): ConsolidationCandidate[];
62
+ /**
63
+ * Append a source entry's content to a survivor with an attributed divider,
64
+ * so the survivor's reader can see which part came from which entry.
65
+ */
66
+ export declare function mergeContent(target: string, source: string, sourceRef: string, dateIso: string): string;
67
+ /**
68
+ * Merge both scans (conflict reason wins on overlap), deduped by kind:id,
69
+ * and build the batch archive edits. Each edit preserves the entry's full
70
+ * content and existing metadata — only ARCHIVED_AT_KEY is added — so
71
+ * `/evolve unarchive` restores everything intact.
72
+ *
73
+ * With `opts.mergeDuplicates`, conflict-pair candidates additionally merge
74
+ * their content INTO the pointed-to survivor (an attributed `mergeContent`
75
+ * section + a `mergedFrom` provenance stamp) before archiving — the #11
76
+ * candidate-c tier the 2026-08-28 ecosystem review promoted. Per-target
77
+ * accumulation composes multiple hints into one survivor (update edits
78
+ * replace content/metadata wholesale, so parallel merge edits would clobber
79
+ * each other).
80
+ *
81
+ * @param now Epoch ms used for the archivedAt stamp (injected for tests).
82
+ */
83
+ export declare function planConsolidation(state: HarnessState, store: UsageStore, now?: number, opts?: {
84
+ minAgeMs?: number;
85
+ mergeDuplicates?: boolean;
86
+ }): {
87
+ candidates: ConsolidationCandidate[];
88
+ edits: RefinementEdit[];
89
+ };
90
+ //# sourceMappingURL=consolidate.d.ts.map
@@ -0,0 +1,180 @@
1
+ import { ARCHIVED_AT_KEY, CONFLICT_HINT_KEY, MERGED_FROM_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
+ mergeInto: { kind: hint.kind, id: hint.id, title: target.title },
52
+ });
53
+ }
54
+ }
55
+ return candidates;
56
+ }
57
+ /**
58
+ * Active global entries never injected in any session and untouched for at
59
+ * least `minAgeMs`: prime staleness candidates. Operates on whatever state
60
+ * it is handed (callers pass the global store).
61
+ */
62
+ export function findStaleEntries(state, store, now, minAgeMs = STALE_MIN_AGE_MS) {
63
+ const candidates = [];
64
+ for (const kind of KINDS) {
65
+ for (const entry of Object.values(state.entries[kind])) {
66
+ if (isArchived(entry))
67
+ continue;
68
+ if (getUsageCount(store, kind, entry.id) !== 0)
69
+ continue;
70
+ const updatedAt = Date.parse(entry.updated_at);
71
+ if (Number.isNaN(updatedAt) || now - updatedAt < minAgeMs)
72
+ continue;
73
+ candidates.push({
74
+ kind,
75
+ id: entry.id,
76
+ title: entry.title,
77
+ reason: `0 injections since ${entry.updated_at.slice(0, 10)} (stale)`,
78
+ });
79
+ }
80
+ }
81
+ return candidates;
82
+ }
83
+ /**
84
+ * Append a source entry's content to a survivor with an attributed divider,
85
+ * so the survivor's reader can see which part came from which entry.
86
+ */
87
+ export function mergeContent(target, source, sourceRef, dateIso) {
88
+ const body = source.trim();
89
+ if (body.length === 0) {
90
+ return target;
91
+ }
92
+ return `${target.trimEnd()}\n\n---\n[Merged from ${sourceRef} on ${dateIso.slice(0, 10)} — near-duplicate consolidated]\n${body}`;
93
+ }
94
+ /** Existing `<kind>:<id>` strings of a target's {@link MERGED_FROM_KEY} trail. */
95
+ function existingMergedFrom(metadata) {
96
+ const value = metadata[MERGED_FROM_KEY];
97
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
98
+ }
99
+ /**
100
+ * Merge both scans (conflict reason wins on overlap), deduped by kind:id,
101
+ * and build the batch archive edits. Each edit preserves the entry's full
102
+ * content and existing metadata — only ARCHIVED_AT_KEY is added — so
103
+ * `/evolve unarchive` restores everything intact.
104
+ *
105
+ * With `opts.mergeDuplicates`, conflict-pair candidates additionally merge
106
+ * their content INTO the pointed-to survivor (an attributed `mergeContent`
107
+ * section + a `mergedFrom` provenance stamp) before archiving — the #11
108
+ * candidate-c tier the 2026-08-28 ecosystem review promoted. Per-target
109
+ * accumulation composes multiple hints into one survivor (update edits
110
+ * replace content/metadata wholesale, so parallel merge edits would clobber
111
+ * each other).
112
+ *
113
+ * @param now Epoch ms used for the archivedAt stamp (injected for tests).
114
+ */
115
+ export function planConsolidation(state, store, now = Date.now(), opts) {
116
+ const byKey = new Map();
117
+ for (const candidate of [...findConflictPairs(state), ...findStaleEntries(state, store, now, opts?.minAgeMs)]) {
118
+ const key = `${candidate.kind}:${candidate.id}`;
119
+ if (!byKey.has(key)) {
120
+ byKey.set(key, candidate);
121
+ }
122
+ }
123
+ const candidates = [...byKey.values()];
124
+ const dateIso = new Date(now).toISOString();
125
+ // A merge survivor must not itself be an archive candidate in the same
126
+ // batch: its archive edit is built from the ORIGINAL state snapshot, so it
127
+ // would overwrite the merge edit's content/mergedFrom wholesale and then
128
+ // archive the survivor (review audit 2026-08-28 S2). Survivors stay live;
129
+ // their merge lands, the source archives.
130
+ const mergeTargets = new Set(candidates.filter((candidate) => candidate.mergeInto).map((candidate) => `${candidate.mergeInto.kind}:${candidate.mergeInto.id}`));
131
+ const archiveCandidates = candidates.filter((candidate) => !mergeTargets.has(`${candidate.kind}:${candidate.id}`));
132
+ const edits = archiveCandidates.map((candidate) => {
133
+ const entry = state.entries[candidate.kind][candidate.id];
134
+ const metadata = { ...entry?.metadata, [ARCHIVED_AT_KEY]: dateIso };
135
+ return {
136
+ action: "update",
137
+ kind: candidate.kind,
138
+ id: candidate.id,
139
+ title: candidate.title,
140
+ content: entry?.content ?? "",
141
+ metadata,
142
+ };
143
+ });
144
+ if (opts?.mergeDuplicates) {
145
+ const merges = new Map();
146
+ for (const candidate of candidates) {
147
+ if (!candidate.mergeInto)
148
+ continue;
149
+ const source = state.entries[candidate.kind][candidate.id];
150
+ const target = state.entries[candidate.mergeInto.kind]?.[candidate.mergeInto.id];
151
+ if (!source || !target)
152
+ continue;
153
+ const key = `${candidate.mergeInto.kind}:${candidate.mergeInto.id}`;
154
+ const acc = merges.get(key) ??
155
+ {
156
+ kind: candidate.mergeInto.kind,
157
+ id: candidate.mergeInto.id,
158
+ title: target.title,
159
+ content: target.content,
160
+ mergedFrom: existingMergedFrom(target.metadata),
161
+ metadata: target.metadata,
162
+ };
163
+ acc.content = mergeContent(acc.content, source.content ?? "", `${candidate.kind}:${candidate.id}`, dateIso);
164
+ acc.mergedFrom.push(`${candidate.kind}:${candidate.id}`);
165
+ merges.set(key, acc);
166
+ }
167
+ for (const acc of merges.values()) {
168
+ edits.unshift({
169
+ action: "update",
170
+ kind: acc.kind,
171
+ id: acc.id,
172
+ title: acc.title,
173
+ content: acc.content,
174
+ metadata: { ...acc.metadata, [MERGED_FROM_KEY]: acc.mergedFrom },
175
+ });
176
+ }
177
+ }
178
+ return { candidates, edits };
179
+ }
180
+ //# sourceMappingURL=consolidate.js.map
package/lib/failures.d.ts CHANGED
@@ -33,7 +33,10 @@ export declare function readReviewFailures(baseDir: string): FailureRecord[];
33
33
  */
34
34
  export declare function readBenchmarkFailures(baseDir: string): FailureRecord[];
35
35
  /** Combine both sources into one summary. */
36
- export declare function collectFailureSummary(baseDir: string): FailureSummary;
36
+ export declare function collectFailureSummary(baseDir: string): {
37
+ summary: FailureSummary;
38
+ records: FailureRecord[];
39
+ };
37
40
  /** Human-readable report for the command line. */
38
41
  export declare function formatFailureSummary(summary: FailureSummary): string;
39
42
  //# sourceMappingURL=failures.d.ts.map
package/lib/failures.js CHANGED
@@ -143,7 +143,8 @@ export function readBenchmarkFailures(baseDir) {
143
143
  }
144
144
  /** Combine both sources into one summary. */
145
145
  export function collectFailureSummary(baseDir) {
146
- return summarizeFailures([...readReviewFailures(baseDir), ...readBenchmarkFailures(baseDir)]);
146
+ const records = [...readReviewFailures(baseDir), ...readBenchmarkFailures(baseDir)];
147
+ return { summary: summarizeFailures(records), records };
147
148
  }
148
149
  /** Human-readable report for the command line. */
149
150
  export function formatFailureSummary(summary) {
package/lib/index.d.ts CHANGED
@@ -34,6 +34,12 @@ export declare const Config: z<Schemastery.ObjectS<{
34
34
  logMaxBytes: z<number, number>;
35
35
  /** After a benchmark decision rejects a candidate, roll the refinement back automatically. */
36
36
  autoRollbackOnReject: z<boolean, boolean>;
37
+ /**
38
+ * P1 auto-case capture: failed evolution attempts (benchmark-rejected
39
+ * candidates, gate proposals without consent) land as draft cases in the
40
+ * auto-regression container benchmark, seeding the regression loop.
41
+ */
42
+ autoCase: z<boolean, boolean>;
37
43
  /**
38
44
  * Gap C1: optional model override for the review gate (cheaper model).
39
45
  * Format: "provider/model" or just "model" (same provider as the agent).
@@ -97,6 +103,12 @@ export declare const Config: z<Schemastery.ObjectS<{
97
103
  logMaxBytes: z<number, number>;
98
104
  /** After a benchmark decision rejects a candidate, roll the refinement back automatically. */
99
105
  autoRollbackOnReject: z<boolean, boolean>;
106
+ /**
107
+ * P1 auto-case capture: failed evolution attempts (benchmark-rejected
108
+ * candidates, gate proposals without consent) land as draft cases in the
109
+ * auto-regression container benchmark, seeding the regression loop.
110
+ */
111
+ autoCase: z<boolean, boolean>;
100
112
  /**
101
113
  * Gap C1: optional model override for the review gate (cheaper model).
102
114
  * Format: "provider/model" or just "model" (same provider as the agent).
package/lib/index.js CHANGED
@@ -53,6 +53,12 @@ export const Config = z.object({
53
53
  logMaxBytes: z.natural().default(5 * 1024 * 1024),
54
54
  /** After a benchmark decision rejects a candidate, roll the refinement back automatically. */
55
55
  autoRollbackOnReject: z.boolean().default(true),
56
+ /**
57
+ * P1 auto-case capture: failed evolution attempts (benchmark-rejected
58
+ * candidates, gate proposals without consent) land as draft cases in the
59
+ * auto-regression container benchmark, seeding the regression loop.
60
+ */
61
+ autoCase: z.boolean().default(true),
56
62
  /**
57
63
  * Gap C1: optional model override for the review gate (cheaper model).
58
64
  * Format: "provider/model" or just "model" (same provider as the agent).
@@ -127,9 +133,11 @@ export function apply(ctx, config) {
127
133
  minPromoteChars: config.promotionMinChars,
128
134
  });
129
135
  registerEvolveTools(ctx, engine, gate);
136
+ const rubricKey = resolveRubricKey(baseDir, config.rubricKey, process.env, (m) => ctx.logger("continual-evolve").warn(m));
130
137
  registerEvolveCommand(ctx, engine, gate, {
131
- rubricKey: resolveRubricKey(baseDir, config.rubricKey, process.env, (m) => ctx.logger("continual-evolve").warn(m)),
138
+ rubricKey,
132
139
  autoRollbackOnReject: config.autoRollbackOnReject ?? true,
140
+ autoCase: config.autoCase ?? true,
133
141
  promotionPolicy,
134
142
  });
135
143
  // Plugin-owned file logging: every cordis log message lands in
@@ -155,6 +163,8 @@ export function apply(ctx, config) {
155
163
  fateIntervalTurns: config.fateIntervalTurns ?? config.reviewIntervalTurns ?? 6,
156
164
  goalBlockedWrapupTurns: config.goalBlockedWrapupTurns ?? 3,
157
165
  promotionPolicy,
166
+ autoCase: config.autoCase ?? true,
167
+ rubricKey,
158
168
  ...(config.reviewModel ? { reviewModel: config.reviewModel } : {}),
159
169
  });
160
170
  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
@@ -78,12 +70,16 @@ export declare function relevanceHits(entry: HarnessEntry, query: string): numbe
78
70
  */
79
71
  export declare function recencyScore(entry: HarnessEntry, now: number): number;
80
72
  /**
81
- * Rank entries for injection, best first. With no query the ranking is pure
82
- * recency (newest first). With a query, any entry with at least one keyword
83
- * hit outranks every hit-less entry (`hits * 2 + recency <= 1` for the
84
- * latter), and hits decide the order among relevant entries; recency then
85
- * breaks remaining ties, and the stable dictionary order is the final
86
- * tiebreak, so the result is deterministic.
73
+ * Rank entries for injection, best first. With no query the ranking is
74
+ * negative-valence first (contradicted entries last), then pure recency
75
+ * (newest first). With a query, entries are scored once against a per-call
76
+ * BM25 index (CJK bigrams; field-weighted title ×2 see search.ts): any
77
+ * entry with a positive score (≥1 matched token) outranks every hit-less
78
+ * entry (score exactly 0), scores decide the order among relevant entries,
79
+ * the negative-valence counter breaks remaining ties (contradicted entries
80
+ * sink), recency breaks remaining ties, and the stable dictionary order is
81
+ * the final tiebreak, so the result is deterministic. The input is never
82
+ * mutated.
87
83
  */
88
84
  export declare function rankEntries(entries: readonly HarnessEntry[], query?: string, now?: number): HarnessEntry[];
89
85
  /**
package/lib/inject.js CHANGED
@@ -1,7 +1,10 @@
1
- import { isArchived } from "./types.js";
1
+ import { isArchived, VALENCE_NEGATIVE_KEY } from "./types.js";
2
2
  import { mergeHarnessStates } from "./state.js";
3
3
  import { entryLine } from "./render.js";
4
4
  import { recordInjection } from "./usage.js";
5
+ import { buildRelevanceIndex, relevanceScore, tokenize } from "./search.js";
6
+ /** CJK-bigram tokenizer re-exported for ranking consumers (see search.ts). */
7
+ export { tokenize };
5
8
  /** Prompt sections render at most this many entries per kind. */
6
9
  export const MAX_INJECTED_ENTRIES_PER_KIND = 6;
7
10
  /** Per-entry content budget inside the injected block (matches render.ts). */
@@ -18,31 +21,6 @@ export const MAX_QUERY_CHARS = 400;
18
21
  function stableCompare(a, b) {
19
22
  return [a.path, a.title, a.id].join("\0").localeCompare([b.path, b.title, b.id].join("\0"));
20
23
  }
21
- /**
22
- * Lowercase tokenization for the keyword relevance scorer: runs of ASCII
23
- * alphanumerics and CJK characters become tokens (CJK is not split so whole
24
- * Chinese words/characters stay comparable), everything else is a separator.
25
- */
26
- export function tokenize(text) {
27
- return text
28
- .toLowerCase()
29
- .split(/[^a-z0-9\u4e00-\u9fff]+/)
30
- .filter((token) => token.length > 0);
31
- }
32
- /**
33
- * Keyword hit count of `query` tokens inside an entry: title hits weigh 2×,
34
- * content/path hits 1×. BM25-level relevance without any external service.
35
- */
36
- export function relevanceHits(entry, query) {
37
- const titleTokens = tokenize(entry.title);
38
- const bodyTokens = tokenize(`${entry.content} ${entry.path}`);
39
- let hits = 0;
40
- for (const token of tokenize(query)) {
41
- hits += titleTokens.filter((t) => t === token).length * 2;
42
- hits += bodyTokens.filter((t) => t === token).length;
43
- }
44
- return hits;
45
- }
46
24
  /**
47
25
  * Normalized recency in [0, 1]: 1 when the entry was just updated, decaying
48
26
  * linearly to 0 after {@link RECENCY_HALF_LIFE_MS}. Unparseable timestamps
@@ -60,21 +38,55 @@ export function recencyScore(entry, now) {
60
38
  return Math.max(0, 1 - age / RECENCY_HALF_LIFE_MS);
61
39
  }
62
40
  /**
63
- * Rank entries for injection, best first. With no query the ranking is pure
64
- * recency (newest first). With a query, any entry with at least one keyword
65
- * hit outranks every hit-less entry (`hits * 2 + recency <= 1` for the
66
- * latter), and hits decide the order among relevant entries; recency then
67
- * breaks remaining ties, and the stable dictionary order is the final
68
- * tiebreak, so the result is deterministic.
41
+ * Negative-valence counter (P1 效价反馈): entries contradicted by later
42
+ * assessments sink below clean ones at equal relevance/recency the
43
+ * behavioral analog of a confidence penalty (pi-continuous-learning's
44
+ * contradicted −0.15), without inventing a new score axis.
45
+ */
46
+ function negativeValence(entry) {
47
+ const value = entry.metadata[VALENCE_NEGATIVE_KEY];
48
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
49
+ }
50
+ /**
51
+ * Rank entries for injection, best first. With no query the ranking is
52
+ * negative-valence first (contradicted entries last), then pure recency
53
+ * (newest first). With a query, entries are scored once against a per-call
54
+ * BM25 index (CJK bigrams; field-weighted title ×2 — see search.ts): any
55
+ * entry with a positive score (≥1 matched token) outranks every hit-less
56
+ * entry (score exactly 0), scores decide the order among relevant entries,
57
+ * the negative-valence counter breaks remaining ties (contradicted entries
58
+ * sink), recency breaks remaining ties, and the stable dictionary order is
59
+ * the final tiebreak, so the result is deterministic. The input is never
60
+ * mutated.
69
61
  */
70
62
  export function rankEntries(entries, query, now = Date.now()) {
71
63
  const q = (query ?? "").trim();
72
- return [...entries].sort((a, b) => {
73
- if (q.length > 0) {
74
- const relevanceDelta = relevanceHits(b, q) * 2 - relevanceHits(a, q) * 2;
75
- if (relevanceDelta !== 0) {
76
- return relevanceDelta;
64
+ if (q.length === 0) {
65
+ return [...entries].sort((a, b) => {
66
+ const valenceDelta = negativeValence(a) - negativeValence(b);
67
+ if (valenceDelta !== 0) {
68
+ return valenceDelta;
69
+ }
70
+ const recencyDelta = recencyScore(b, now) - recencyScore(a, now);
71
+ if (recencyDelta !== 0) {
72
+ return recencyDelta;
77
73
  }
74
+ return stableCompare(a, b);
75
+ });
76
+ }
77
+ // Precompute scores once: the old comparator re-tokenized both sides on
78
+ // every comparison (O(n log n) tokenizations); one index + one score per
79
+ // entry turns the pass into table lookups.
80
+ const index = buildRelevanceIndex(entries);
81
+ const scores = new Map(entries.map((entry) => [entry, relevanceScore(index, entry, q)]));
82
+ return [...entries].sort((a, b) => {
83
+ const relevanceDelta = (scores.get(b) ?? 0) - (scores.get(a) ?? 0);
84
+ if (relevanceDelta !== 0) {
85
+ return relevanceDelta;
86
+ }
87
+ const valenceDelta = negativeValence(a) - negativeValence(b);
88
+ if (valenceDelta !== 0) {
89
+ return valenceDelta;
78
90
  }
79
91
  const recencyDelta = recencyScore(b, now) - recencyScore(a, now);
80
92
  if (recencyDelta !== 0) {
package/lib/mount.js CHANGED
@@ -16,6 +16,7 @@
16
16
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
17
17
  import { join } from "node:path";
18
18
  import { skillNameOf } from "./skill.js";
19
+ import { secretLeakReason } from "./promotion.js";
19
20
  export function mountedDir(baseDir) {
20
21
  return join(baseDir, "evolve", "mounted");
21
22
  }
@@ -41,6 +42,14 @@ export function saveLedger(baseDir, ledger) {
41
42
  }
42
43
  /** Generate the plugin package files for one skill entry; returns the package dir. */
43
44
  export function renderMountPackage(baseDir, entry) {
45
+ // Secret-leak quarantine: the entry content AND its reference contract are
46
+ // embedded verbatim into the generated index.js, so a credential in either
47
+ // would be written to disk (and executed in-process). Block before ANY
48
+ // file is created (review audit 2026-08-28 B2: reference was unscreened).
49
+ const secret = secretLeakReason([entry.title, entry.content, JSON.stringify(entry.reference ?? {}), JSON.stringify(entry.arguments ?? {})].join("\n"));
50
+ if (secret) {
51
+ throw new Error(`mount blocked: ${secret}`);
52
+ }
44
53
  const dir = join(mountedDir(baseDir), skillNameOf(entry.id));
45
54
  mkdirSync(dir, { recursive: true });
46
55
  const toolName = `skill_${skillNameOf(entry.id)}`;