dsh-continual-evolve 0.4.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 CHANGED
@@ -7,7 +7,7 @@
7
7
  [![CI](https://github.com/ZK-Andy/dsh-continual-evolve/actions/workflows/ci.yml/badge.svg)](https://github.com/ZK-Andy/dsh-continual-evolve/actions/workflows/ci.yml)
8
8
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
9
9
  [![Node](https://img.shields.io/badge/node-%5E22.19%20%7C%7C%20%3E%3D24-339933)](package.json)
10
- [![Tests](https://img.shields.io/badge/tests-527%20passing-brightgreen)]()
10
+ [![Tests](https://img.shields.io/badge/tests-543%20passing-brightgreen)]()
11
11
 
12
12
  Continual self-evolution for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness): a versioned, auditable, rollback-safe harness state layer — prompt notes, memories, skills, subagent specs — refined from session trajectories.
13
13
 
@@ -20,11 +20,12 @@ Agents accumulate reusable experience (repeated failures, durable facts, reusabl
20
20
  - **Local scope** per session; **global scope** across sessions with merge semantics — plus mechanical promotion guards so only portable, substantial, non-duplicate knowledge reaches global
21
21
  - **Deterministic rollback**: inverse edits generated from applied results — no LLM re-guessing
22
22
  - **Benchmark loop**: candidate refinements are evaluated against frozen cases by a separate scorer before acceptance (rubric encrypted at rest)
23
+ - **Store hygiene**: `/evolve consolidate` turns write-time conflict hints and zero-use staleness into one approved, fully reversible batch of archives
23
24
 
24
25
  ## How it works
25
26
 
26
27
  1. **Sediment** — the model creates entries via `evolve_add`, or the automatic review gate proposes them from the session trajectory (turn-interval + compaction checkpoints).
27
- 2. **Guard** — code-enforced validation: edit schema, blast-radius/scope coherence, and the promotion policy (project-scoped markers, thin content, near-duplicate detection keep the global store clean).
28
+ 2. **Guard** — code-enforced validation: edit schema, blast-radius/scope coherence, and the promotion policy (project-scoped markers, thin content, near-duplicate detection keep the global store clean). Global creates that near-duplicate an existing entry are rejected at write time (≥0.8 similarity); moderate overlaps carry a `conflictHint` for later consolidation.
28
29
  3. **Approve** — global writes require explicit human approval; local-fate proposals are consulted before they land.
29
30
  4. **Apply & inject** — atomic apply with snapshot + audit event. Prompt notes and delegation specs inject into the system prompt (capped, relevance-ranked, zero tokens when empty); memories/skills appear as a capped directory index.
30
31
  5. **Validate & roll back** — benchmarks score candidates against frozen cases; rejected candidates roll back deterministically.
@@ -52,6 +53,7 @@ Commands (in-session):
52
53
  | `/evolve plan [msg]` | run the LLM planner against the store |
53
54
  | `/evolve wrapup` | assess this session's local entries: promote / archive / keep |
54
55
  | `/evolve archive · unarchive · demote <id>` | hide from injection (data kept, restorable) — `demote` targets global noise |
56
+ | `/evolve consolidate [apply]` | report (or apply) one batch archive of conflict-hinted + stale zero-use global entries |
55
57
  | `/evolve failures` | aggregated failure classes (gate + benchmark) |
56
58
  | `/evolve log [tail N] [session <id>]` | plugin log |
57
59
  | `/evolve export · import <path>` | backup / restore a store |
@@ -100,7 +102,7 @@ Example profile patch:
100
102
 
101
103
  ```bash
102
104
  pnpm install && pnpm build # deps + tsc -> lib/
103
- pnpm test # vitest (527 tests)
105
+ pnpm test # vitest (543 tests)
104
106
  pnpm test:coverage # v8 coverage, thresholds enforced in CI
105
107
  pnpm lint # oxlint src test
106
108
  ```
package/README.zh.md CHANGED
@@ -7,7 +7,7 @@
7
7
  [![CI](https://github.com/ZK-Andy/dsh-continual-evolve/actions/workflows/ci.yml/badge.svg)](https://github.com/ZK-Andy/dsh-continual-evolve/actions/workflows/ci.yml)
8
8
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
9
9
  [![Node](https://img.shields.io/badge/node-%5E22.19%20%7C%7C%20%3E%3D24-339933)](package.json)
10
- [![Tests](https://img.shields.io/badge/tests-527%20passing-brightgreen)]()
10
+ [![Tests](https://img.shields.io/badge/tests-543%20passing-brightgreen)]()
11
11
 
12
12
  [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(`dsh`)的持续自进化插件:一套**版本化、可审计、可回滚**的 harness 状态层——提示词补充、记忆、技能、子代理规格——从会话轨迹中沉淀而来。
13
13
 
@@ -20,11 +20,12 @@ Agent 在每个会话里积累可复用经验(重复失败、持久事实、
20
20
  - **local 会话级 / global 跨会话** 双作用域与合并语义——配合机械化晋升守卫,只有可携带、有分量、非重复的知识才能进全局
21
21
  - **确定性回滚**:逆操作编辑由已应用结果生成——不靠 LLM 重新猜测
22
22
  - **benchmark 闭环**:候选沉淀先经冻结用例 + 独立评分者评估再接受(rubric 加密落盘)
23
+ - **store 卫生**:`/evolve consolidate` 把写入时冲突提示与零使用陈旧条目变成一次批准、完全可逆的批量归档
23
24
 
24
25
  ## 工作原理
25
26
 
26
27
  1. **沉淀**——模型经 `evolve_add` 创建条目,或自动 review 门禁从会话轨迹提议(回合间隔 + 压缩检查点)。
27
- 2. **守卫**——代码强制校验:编辑 schema、blast-radius 与作用域一致性、晋升政策(项目专属标记 / 过薄内容 / 近似重复检测保持全局库干净)。
28
+ 2. **守卫**——代码强制校验:编辑 schema、blast-radius 与作用域一致性、晋升政策(项目专属标记 / 过薄内容 / 近似重复检测保持全局库干净)。全局 create 与既有条目高度相似(≥0.8)时写入即拒;中等重叠带 `conflictHint` 供后续合并。
28
29
  3. **审批**——全局写入需明确人工批准;local 归宿提议先征询后落地。
29
30
  4. **应用与注入**——原子应用带快照与审计事件。prompt 补充与委派规格注入系统提示词(封顶、按相关性排序、空 store 零 token);memory/skill 以目录索引出现。
30
31
  5. **验证与回滚**——benchmark 用冻结用例为候选打分;被拒候选确定性回滚。
@@ -52,6 +53,7 @@ dsh plugin add ZK-Andy/dsh-continual-evolve
52
53
  | `/evolve plan [msg]` | 对 store 运行 LLM 规划器 |
53
54
  | `/evolve wrapup` | 收尾本会话 local 条目:晋升 / 归档 / 保留 |
54
55
  | `/evolve archive · unarchive · demote <id>` | 从注入中隐藏(数据保留可恢复)——`demote` 针对全局噪声 |
56
+ | `/evolve consolidate [apply]` | 报告(或应用)冲突提示 + 零使用陈旧全局条目的批量归档 |
55
57
  | `/evolve failures` | 失败类聚合(门禁 + benchmark) |
56
58
  | `/evolve log [tail N] [session <id>]` | 插件日志 |
57
59
  | `/evolve export · import <path>` | 备份 / 恢复 store |
@@ -100,7 +102,7 @@ profile patch 示例:
100
102
 
101
103
  ```bash
102
104
  pnpm install && pnpm build # 依赖 + tsc -> lib/
103
- pnpm test # vitest(527 例)
105
+ pnpm test # vitest(543 例)
104
106
  pnpm test:coverage # v8 覆盖率,CI 强制阈值
105
107
  pnpm lint # oxlint src test
106
108
  ```
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)
@@ -24,6 +26,8 @@ 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] report (or apply) a batch archive of conflict-hinted
30
+ and stale zero-use global entries
27
31
  /evolve log [tail N] show the recent plugin log (default 50 lines)
28
32
  /evolve failures aggregated failure counts (gate + benchmark, by class)
29
33
  /evolve export [global] <path> backup a store to a JSON file
@@ -172,6 +176,28 @@ async function executeEvolveCommand(ctx, engine, invocation, opts, runtime) {
172
176
  }, { scope });
173
177
  return success(renderResult(result));
174
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
+ }
175
201
  case "failures": {
176
202
  // /evolve failures — failure-signature aggregation (D1 observation):
177
203
  // failed review-gate records + failed benchmark cells, counted by class.
@@ -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/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, 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.
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
  /**
package/lib/inject.js CHANGED
@@ -2,6 +2,9 @@ import { isArchived } 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
@@ -61,20 +39,34 @@ export function recencyScore(entry, now) {
61
39
  }
62
40
  /**
63
41
  * 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.
42
+ * recency (newest first). With a query, entries are scored once against a
43
+ * per-call BM25 index (CJK bigrams; field-weighted title ×2 see
44
+ * search.ts): any entry with a positive score (≥1 matched token) outranks
45
+ * every hit-less entry (score exactly 0), scores decide the order among
46
+ * relevant entries, recency breaks remaining ties, and the stable dictionary
47
+ * order is the final tiebreak, so the result is deterministic. The input is
48
+ * never mutated.
69
49
  */
70
50
  export function rankEntries(entries, query, now = Date.now()) {
71
51
  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;
52
+ if (q.length === 0) {
53
+ return [...entries].sort((a, b) => {
54
+ const recencyDelta = recencyScore(b, now) - recencyScore(a, now);
55
+ if (recencyDelta !== 0) {
56
+ return recencyDelta;
77
57
  }
58
+ return stableCompare(a, b);
59
+ });
60
+ }
61
+ // Precompute scores once: the old comparator re-tokenized both sides on
62
+ // every comparison (O(n log n) tokenizations); one index + one score per
63
+ // entry turns the pass into table lookups.
64
+ const index = buildRelevanceIndex(entries);
65
+ const scores = new Map(entries.map((entry) => [entry, relevanceScore(index, entry, q)]));
66
+ return [...entries].sort((a, b) => {
67
+ const relevanceDelta = (scores.get(b) ?? 0) - (scores.get(a) ?? 0);
68
+ if (relevanceDelta !== 0) {
69
+ return relevanceDelta;
78
70
  }
79
71
  const recencyDelta = recencyScore(b, now) - recencyScore(a, now);
80
72
  if (recencyDelta !== 0) {
@@ -14,7 +14,7 @@
14
14
  * Pure functions only — the callers (wrapup command, gate local-fate phase)
15
15
  * supply the resolved {@link PromotionPolicy}.
16
16
  */
17
- import type { HarnessState, RefinementKind } from "./types.js";
17
+ import type { HarnessEntry, HarnessState, RefinementKind } from "./types.js";
18
18
  export interface PromotionPolicy {
19
19
  /** Content matching any pattern is project-scoped and stays local. */
20
20
  blockPatterns: RegExp[];
@@ -24,6 +24,15 @@ export interface PromotionPolicy {
24
24
  maxContentOverlap: number;
25
25
  }
26
26
  export declare const DEFAULT_PROMOTION_POLICY: PromotionPolicy;
27
+ /**
28
+ * Write-time conflict guard (R2): a global create whose similarity against an
29
+ * existing same-kind entry reaches this score is rejected outright — a
30
+ * near-duplicate adds zero information and the model should evolve_update
31
+ * the existing entry instead.
32
+ */
33
+ export declare const CONFLICT_BLOCK_SCORE = 0.8;
34
+ /** Similarity at/above this stamps {@link CONFLICT_HINT_KEY} but lets the write proceed. */
35
+ export declare const CONFLICT_WARN_SCORE = 0.5;
27
36
  /**
28
37
  * Build a policy from config values (schemastery strings compiled here so
29
38
  * the config layer never touches RegExp). Invalid patterns are skipped —
@@ -53,10 +62,22 @@ export interface SimilarEntryHit {
53
62
  title: string;
54
63
  score: number;
55
64
  }
65
+ /**
66
+ * Human/LLM-readable description of a similarity hit, shared by the block
67
+ * error and the approval-question suffix so both surfaces explain the same
68
+ * way.
69
+ */
70
+ export declare function buildConflictNotice(hit: SimilarEntryHit): string;
71
+ /**
72
+ * The most similar entry of the list, above `minScore`. Title and content
73
+ * both feed the comparison (titles are short; content carries the real
74
+ * signal). Generic form of {@link mostSimilarGlobalEntry} — callers decide
75
+ * which corpus (global store, merged view) the candidates come from.
76
+ */
77
+ export declare function mostSimilarEntry(entries: readonly HarnessEntry[], title: string, content: string, minScore: number): SimilarEntryHit | undefined;
56
78
  /**
57
79
  * 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).
80
+ * policy threshold.
60
81
  */
61
82
  export declare function mostSimilarGlobalEntry(globalState: HarnessState, kind: RefinementKind, title: string, content: string, policy: PromotionPolicy): SimilarEntryHit | undefined;
62
83
  //# sourceMappingURL=promotion.d.ts.map
package/lib/promotion.js CHANGED
@@ -10,6 +10,15 @@ export const DEFAULT_PROMOTION_POLICY = {
10
10
  minPromoteChars: 100,
11
11
  maxContentOverlap: 0.6,
12
12
  };
13
+ /**
14
+ * Write-time conflict guard (R2): a global create whose similarity against an
15
+ * existing same-kind entry reaches this score is rejected outright — a
16
+ * near-duplicate adds zero information and the model should evolve_update
17
+ * the existing entry instead.
18
+ */
19
+ export const CONFLICT_BLOCK_SCORE = 0.8;
20
+ /** Similarity at/above this stamps {@link CONFLICT_HINT_KEY} but lets the write proceed. */
21
+ export const CONFLICT_WARN_SCORE = 0.5;
13
22
  /**
14
23
  * Build a policy from config values (schemastery strings compiled here so
15
24
  * the config layer never touches RegExp). Invalid patterns are skipped —
@@ -83,20 +92,36 @@ export function contentOverlap(a, b) {
83
92
  return intersection / (left.size + right.size - intersection);
84
93
  }
85
94
  /**
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).
95
+ * Human/LLM-readable description of a similarity hit, shared by the block
96
+ * error and the approval-question suffix so both surfaces explain the same
97
+ * way.
89
98
  */
90
- export function mostSimilarGlobalEntry(globalState, kind, title, content, policy) {
99
+ export function buildConflictNotice(hit) {
100
+ return `near-duplicate of ${hit.id} 「${hit.title}」 (similarity ${Math.round(hit.score * 100)}%)`;
101
+ }
102
+ /**
103
+ * The most similar entry of the list, above `minScore`. Title and content
104
+ * both feed the comparison (titles are short; content carries the real
105
+ * signal). Generic form of {@link mostSimilarGlobalEntry} — callers decide
106
+ * which corpus (global store, merged view) the candidates come from.
107
+ */
108
+ export function mostSimilarEntry(entries, title, content, minScore) {
91
109
  let best;
92
- for (const other of Object.values(globalState.entries[kind])) {
110
+ for (const other of entries) {
93
111
  if (isArchived(other))
94
112
  continue;
95
113
  const score = Math.max(contentOverlap(title, other.title), contentOverlap(content, other.content));
96
- if (score >= policy.maxContentOverlap && (best === undefined || score > best.score)) {
114
+ if (score >= minScore && (best === undefined || score > best.score)) {
97
115
  best = { id: other.id, title: other.title, score };
98
116
  }
99
117
  }
100
118
  return best;
101
119
  }
120
+ /**
121
+ * The most similar non-archived global entry of the same kind, above the
122
+ * policy threshold.
123
+ */
124
+ export function mostSimilarGlobalEntry(globalState, kind, title, content, policy) {
125
+ return mostSimilarEntry(Object.values(globalState.entries[kind]), title, content, policy.maxContentOverlap);
126
+ }
102
127
  //# sourceMappingURL=promotion.js.map
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Retrieval primitives for injection ranking: CJK-aware tokenization plus a
3
+ * dependency-free, field-weighted BM25 scorer (ADR
4
+ * `implemented/feature/2026-08-24-cjk-bigram-bm25-retrieval.md`).
5
+ *
6
+ * Why this shape:
7
+ * - the old scorer tokenized whole CJK runs as single tokens compared for
8
+ * exact equality, so any rewording of a Chinese query missed every entry
9
+ * (「检索升级」 never matched 「全文检索方案」); overlapping character
10
+ * bigrams — the standard cjk_bigram analyzer shape (Elasticsearch/
11
+ * OpenSearch) — fix recall while keeping English behavior identical;
12
+ * - raw hit counts weighted common words and distinctive terms alike; BM25's
13
+ * IDF separates them. We use Lucene's non-negative variant
14
+ * `ln(1 + (N - df + 0.5) / (df + 0.5))` so EVERY matched token scores > 0
15
+ * and unmatched documents score exactly 0, preserving rankEntries'
16
+ * "relevant outranks recent-but-irrelevant" ordering invariant;
17
+ * - the index lifetime is one ranking call. Stores hold tens~hundreds of
18
+ * entries (measured full rebuild + score: single-digit milliseconds), the
19
+ * JSON state files stay the single source of truth, and npm installers
20
+ * carry zero native/WASM baggage — better-sqlite3 FTS5, sql.js, MiniSearch,
21
+ * FlexSearch were all evaluated and rejected (see the ADR's alternatives).
22
+ */
23
+ import type { HarnessEntry } from "./types.js";
24
+ /** BM25 term-saturation constant (Okapi k1). */
25
+ export declare const BM25_K1 = 1.5;
26
+ /** BM25 document-length normalization constant (Okapi b). */
27
+ export declare const BM25_B = 0.75;
28
+ /** Multiplier for title-field matches (title hits outweigh body hits 2:1). */
29
+ export declare const TITLE_FIELD_WEIGHT = 2;
30
+ /**
31
+ * Tokenize text for relevance scoring: lowercase ASCII runs become word
32
+ * tokens; CJK runs become overlapping character bigrams (a single-character
33
+ * run stays a unigram). Deterministic and allocation-light — safe to call per
34
+ * injection build.
35
+ *
36
+ * @param text Arbitrary entry or query text.
37
+ * @returns Tokens in occurrence order (duplicates kept; consumers dedupe when
38
+ * order-independent weighting is wanted).
39
+ */
40
+ export declare function tokenize(text: string): string[];
41
+ /** Per-field corpus statistics for one ranking pass. */
42
+ interface FieldStats {
43
+ /** Term-frequency map per document, positioned like {@link RelevanceIndex.positions}. */
44
+ readonly tfs: ReadonlyArray<ReadonlyMap<string, number>>;
45
+ /** Token count per document (this field's dl for BM25 length normalization). */
46
+ readonly lengths: ReadonlyArray<number>;
47
+ /** Document frequency per distinct term across the corpus. */
48
+ readonly df: ReadonlyMap<string, number>;
49
+ /** Average token count across the corpus (0 when empty). */
50
+ readonly averageLength: number;
51
+ }
52
+ /** Precomputed corpus statistics backing {@link relevanceScore}. */
53
+ export interface RelevanceIndex {
54
+ /** Number of documents in the corpus. */
55
+ readonly size: number;
56
+ /** Identity positions: the exact entry objects the index was built from. */
57
+ readonly positions: ReadonlyMap<object, number>;
58
+ readonly titles: FieldStats;
59
+ readonly bodies: FieldStats;
60
+ }
61
+ /**
62
+ * Build the corpus statistics (per-field tf, df, average length) for one
63
+ * ranking call. Pure: the index reads its input snapshot and never mutates
64
+ * the entries.
65
+ *
66
+ * @param entries The candidate entries being ranked this call.
67
+ * @returns An index that answers {@link relevanceScore} for exactly these
68
+ * entries (identity-keyed).
69
+ */
70
+ export declare function buildRelevanceIndex(entries: readonly HarnessEntry[]): RelevanceIndex;
71
+ /**
72
+ * Field-weighted BM25 score of `entry` against `query` within `index`:
73
+ * `TITLE_FIELD_WEIGHT × bm25(title) + bm25(body)`. Query tokens are deduped,
74
+ * so repeating a word adds no weight.
75
+ *
76
+ * @returns A score > 0 when at least one query token occurs in the entry
77
+ * (either field), and exactly 0 otherwise — the property rankEntries'
78
+ * relevance-first ordering relies on. Never throws; an entry outside
79
+ * the index simply scores 0.
80
+ */
81
+ export declare function relevanceScore(index: RelevanceIndex, entry: HarnessEntry, query: string): number;
82
+ export {};
83
+ //# sourceMappingURL=search.d.ts.map
package/lib/search.js ADDED
@@ -0,0 +1,136 @@
1
+ /** BM25 term-saturation constant (Okapi k1). */
2
+ export const BM25_K1 = 1.5;
3
+ /** BM25 document-length normalization constant (Okapi b). */
4
+ export const BM25_B = 0.75;
5
+ /** Multiplier for title-field matches (title hits outweigh body hits 2:1). */
6
+ export const TITLE_FIELD_WEIGHT = 2;
7
+ /**
8
+ * One pass, two run shapes: ASCII alphanumeric words, or maximal CJK runs.
9
+ * Runs never mix scripts because the alternation splits at the boundary, so
10
+ * 「深色主题dark主题」 tokenizes as 深色/色主/主题, "dark", 主题.
11
+ */
12
+ const TOKEN_RUN_PATTERN = /[a-z0-9]+|[\u4e00-\u9fff]+/g;
13
+ const ASCII_RUN_PATTERN = /^[a-z0-9]+$/;
14
+ /**
15
+ * Tokenize text for relevance scoring: lowercase ASCII runs become word
16
+ * tokens; CJK runs become overlapping character bigrams (a single-character
17
+ * run stays a unigram). Deterministic and allocation-light — safe to call per
18
+ * injection build.
19
+ *
20
+ * @param text Arbitrary entry or query text.
21
+ * @returns Tokens in occurrence order (duplicates kept; consumers dedupe when
22
+ * order-independent weighting is wanted).
23
+ */
24
+ export function tokenize(text) {
25
+ const tokens = [];
26
+ for (const match of text.toLowerCase().matchAll(TOKEN_RUN_PATTERN)) {
27
+ const run = match[0];
28
+ if (ASCII_RUN_PATTERN.test(run)) {
29
+ tokens.push(run);
30
+ }
31
+ else if (run.length === 1) {
32
+ tokens.push(run);
33
+ }
34
+ else {
35
+ for (let i = 0; i < run.length - 1; i += 1) {
36
+ tokens.push(run.slice(i, i + 2));
37
+ }
38
+ }
39
+ }
40
+ return tokens;
41
+ }
42
+ function buildFieldStats(fieldTokens) {
43
+ const tfs = fieldTokens.map((tokens) => {
44
+ const tf = new Map();
45
+ for (const token of tokens) {
46
+ tf.set(token, (tf.get(token) ?? 0) + 1);
47
+ }
48
+ return tf;
49
+ });
50
+ const df = new Map();
51
+ let totalLength = 0;
52
+ for (let position = 0; position < tfs.length; position += 1) {
53
+ const tf = tfs[position];
54
+ let length = 0;
55
+ for (const count of tf.values()) {
56
+ length += count;
57
+ }
58
+ totalLength += length;
59
+ for (const token of tf.keys()) {
60
+ df.set(token, (df.get(token) ?? 0) + 1);
61
+ }
62
+ }
63
+ return {
64
+ tfs,
65
+ lengths: fieldTokens.map((tokens) => tokens.length),
66
+ df,
67
+ averageLength: tfs.length === 0 ? 0 : totalLength / tfs.length,
68
+ };
69
+ }
70
+ /**
71
+ * Build the corpus statistics (per-field tf, df, average length) for one
72
+ * ranking call. Pure: the index reads its input snapshot and never mutates
73
+ * the entries.
74
+ *
75
+ * @param entries The candidate entries being ranked this call.
76
+ * @returns An index that answers {@link relevanceScore} for exactly these
77
+ * entries (identity-keyed).
78
+ */
79
+ export function buildRelevanceIndex(entries) {
80
+ const titles = [];
81
+ const bodies = [];
82
+ for (const entry of entries) {
83
+ titles.push(tokenize(entry.title));
84
+ bodies.push(tokenize(`${entry.content} ${entry.path}`));
85
+ }
86
+ return {
87
+ size: entries.length,
88
+ positions: new Map(entries.map((entry, position) => [entry, position])),
89
+ titles: buildFieldStats(titles),
90
+ bodies: buildFieldStats(bodies),
91
+ };
92
+ }
93
+ /** One field's BM25 contribution for a single term (0 when the term is absent). */
94
+ function bm25Term(termFrequency, documentFrequency, corpusSize, docLength, averageLength) {
95
+ if (termFrequency <= 0 || documentFrequency <= 0) {
96
+ return 0;
97
+ }
98
+ const inverseDocumentFrequency = Math.log(1 + (corpusSize - documentFrequency + 0.5) / (documentFrequency + 0.5));
99
+ const normalization = averageLength > 0 ? 1 - BM25_B + BM25_B * (docLength / averageLength) : 1;
100
+ return inverseDocumentFrequency * ((termFrequency * (BM25_K1 + 1)) / (termFrequency + BM25_K1 * normalization));
101
+ }
102
+ /**
103
+ * Field-weighted BM25 score of `entry` against `query` within `index`:
104
+ * `TITLE_FIELD_WEIGHT × bm25(title) + bm25(body)`. Query tokens are deduped,
105
+ * so repeating a word adds no weight.
106
+ *
107
+ * @returns A score > 0 when at least one query token occurs in the entry
108
+ * (either field), and exactly 0 otherwise — the property rankEntries'
109
+ * relevance-first ordering relies on. Never throws; an entry outside
110
+ * the index simply scores 0.
111
+ */
112
+ export function relevanceScore(index, entry, query) {
113
+ const position = index.positions.get(entry);
114
+ if (position === undefined || index.size === 0) {
115
+ return 0;
116
+ }
117
+ const seen = new Set();
118
+ let titleScore = 0;
119
+ let bodyScore = 0;
120
+ for (const token of tokenize(query)) {
121
+ if (seen.has(token)) {
122
+ continue;
123
+ }
124
+ seen.add(token);
125
+ const titleDf = index.titles.df.get(token) ?? 0;
126
+ if (titleDf > 0) {
127
+ titleScore += bm25Term(index.titles.tfs[position]?.get(token) ?? 0, titleDf, index.size, index.titles.lengths[position] ?? 0, index.titles.averageLength);
128
+ }
129
+ const bodyDf = index.bodies.df.get(token) ?? 0;
130
+ if (bodyDf > 0) {
131
+ bodyScore += bm25Term(index.bodies.tfs[position]?.get(token) ?? 0, bodyDf, index.size, index.bodies.lengths[position] ?? 0, index.bodies.averageLength);
132
+ }
133
+ }
134
+ return TITLE_FIELD_WEIGHT * titleScore + bodyScore;
135
+ }
136
+ //# sourceMappingURL=search.js.map
package/lib/service.js CHANGED
@@ -1,8 +1,10 @@
1
+ import { CONFLICT_HINT_KEY } from "./types.js";
1
2
  import { applyRefinementProposal } from "./apply.js";
2
3
  import { randomUUID } from "node:crypto";
3
4
  import { rollbackProposal } from "./rollback.js";
4
5
  import { loadHarnessState, saveHarnessState } from "./state.js";
5
6
  import { appendResult, loadResults, snapshotBefore, storePaths } from "./store.js";
7
+ import { CONFLICT_BLOCK_SCORE, CONFLICT_WARN_SCORE, buildConflictNotice, mostSimilarEntry } from "./promotion.js";
6
8
  export function createEvolutionEngine(baseDir, hooks = {}) {
7
9
  function load(scope, sessionId) {
8
10
  return loadHarnessState(storePaths(baseDir, scope, sessionId).stateDir, scope);
@@ -10,6 +12,28 @@ export function createEvolutionEngine(baseDir, hooks = {}) {
10
12
  function apply(scope, sessionId, proposal, context) {
11
13
  const paths = storePaths(baseDir, scope, sessionId);
12
14
  const state = context?.baselineState ?? load(scope, sessionId);
15
+ // Write-time conflict guard (R2): global creates are checked against
16
+ // the existing same-kind entries BEFORE any side effect — a
17
+ // near-duplicate is rejected with an actionable error (evolve_update
18
+ // instead), a moderate overlap proceeds stamped with
19
+ // CONFLICT_HINT_KEY. Rollbacks bypass the guard: re-creating an entry
20
+ // that resembles its successor is the point of rollback. Local scope
21
+ // is never blocked (scratch space); the wrapup/fate promotion path
22
+ // already enforces its own overlap policy there.
23
+ const warnHits = new Map();
24
+ if (scope === "global" && !context?.rollbackOf) {
25
+ for (const [index, edit] of proposal.edits.entries()) {
26
+ if (edit.action !== "create")
27
+ continue;
28
+ const hit = mostSimilarEntry(Object.values(state.entries[edit.kind]), edit.title ?? "", edit.content ?? "", CONFLICT_WARN_SCORE);
29
+ if (!hit)
30
+ continue;
31
+ if (hit.score >= CONFLICT_BLOCK_SCORE) {
32
+ throw new Error(`create blocked: ${buildConflictNotice(hit)} already lives in the global ${edit.kind} store — use evolve_update on it instead of adding a duplicate`);
33
+ }
34
+ warnHits.set(index, hit);
35
+ }
36
+ }
13
37
  const id = `evolve_${Date.now().toString(36)}_${randomUUID().slice(0, 8)}`;
14
38
  // Code-enforced snapshot: runs before any mutation, cannot be skipped by the model.
15
39
  snapshotBefore(paths, id);
@@ -20,6 +44,21 @@ export function createEvolutionEngine(baseDir, hooks = {}) {
20
44
  ...(context?.baselineState ? { baselineState: context.baselineState } : {}),
21
45
  ...(context?.rollbackOf ? { rollbackOf: context.rollbackOf } : {}),
22
46
  });
47
+ // Stamp warn-tier conflicts onto the freshly created entries (both the
48
+ // live state and the result's after-snapshot stay coherent).
49
+ for (const [index, hit] of warnHits) {
50
+ const applied = result.appliedEdits[index];
51
+ if (!applied?.applied || applied.action !== "create" || !applied.id)
52
+ continue;
53
+ const hint = `${applied.kind}:${hit.id}:${hit.score.toFixed(2)}`;
54
+ const live = state.entries[applied.kind][applied.id];
55
+ if (live) {
56
+ live.metadata[CONFLICT_HINT_KEY] = hint;
57
+ if (applied.after) {
58
+ applied.after.metadata[CONFLICT_HINT_KEY] = hint;
59
+ }
60
+ }
61
+ }
23
62
  saveHarnessState(paths.stateDir, state);
24
63
  appendResult(paths, result);
25
64
  hooks.onApplied?.(result);
package/lib/tool.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { defineTool } from "@deepseek-ai/dsh-tools";
2
2
  import { formatHarnessStateForPrompt } from "./render.js";
3
3
  import { requireGlobalApproval } from "./approval.js";
4
+ import { CONFLICT_WARN_SCORE, buildConflictNotice, mostSimilarEntry } from "./promotion.js";
4
5
  import { entrySourceOf } from "./source.js";
5
6
  import { getUsageCount, loadUsage } from "./usage.js";
6
7
  import { buildEvolveCompleteEvent, emitEvolveComplete } from "./evolve-event.js";
@@ -72,7 +73,13 @@ export function registerEvolveTools(ctx, engine, opts) {
72
73
  execute: async (args, exec) => {
73
74
  const scope = scopeOf(args.global, "local");
74
75
  if (scope === "global" && opts.requireGlobalApproval) {
75
- await requireGlobalApproval(ctx, exec.agent, exec.signal, `evolve_add ${args.kind} "${args.title}" 跨会话全局 store`);
76
+ // Informed approval: surface a similarity hit against the
77
+ // existing global store BEFORE the human decides — the
78
+ // engine's write-time guard still has the final say.
79
+ const globalState = engine.load("global", undefined);
80
+ const hit = mostSimilarEntry(Object.values(globalState.entries[args.kind]), args.title ?? "", args.content ?? "", CONFLICT_WARN_SCORE);
81
+ const conflictNote = hit ? ` ⚠️ ${buildConflictNotice(hit)}——建议改用 evolve_update` : "";
82
+ await requireGlobalApproval(ctx, exec.agent, exec.signal, `evolve_add ${args.kind} "${args.title}" → 跨会话全局 store${conflictNote}`);
76
83
  }
77
84
  const edit = {
78
85
  action: "create",
package/lib/types.d.ts CHANGED
@@ -57,6 +57,14 @@ export declare const PROMOTED_AT_KEY = "promotedAt";
57
57
  * cross-session copy back to the session it was distilled from.
58
58
  */
59
59
  export declare const SOURCED_FROM_KEY = "sourcedFromLocal";
60
+ /**
61
+ * Metadata key stamped on a GLOBAL entry that was created despite a
62
+ * moderate-similarity overlap with an existing entry (write-time conflict
63
+ * guard, warn tier): `<kind>:<id>:<score>` — points at the entry it may
64
+ * duplicate so wrapup/fate can consolidate later. Creates at/above the block
65
+ * threshold never reach this stamp; they are rejected outright.
66
+ */
67
+ export declare const CONFLICT_HINT_KEY = "conflictHint";
60
68
  /**
61
69
  * True when the entry is archived (hidden from injection, restorable).
62
70
  * Absent or empty archivedAt means the entry is active.
package/lib/types.js CHANGED
@@ -43,6 +43,14 @@ export const PROMOTED_AT_KEY = "promotedAt";
43
43
  * cross-session copy back to the session it was distilled from.
44
44
  */
45
45
  export const SOURCED_FROM_KEY = "sourcedFromLocal";
46
+ /**
47
+ * Metadata key stamped on a GLOBAL entry that was created despite a
48
+ * moderate-similarity overlap with an existing entry (write-time conflict
49
+ * guard, warn tier): `<kind>:<id>:<score>` — points at the entry it may
50
+ * duplicate so wrapup/fate can consolidate later. Creates at/above the block
51
+ * threshold never reach this stamp; they are rejected outright.
52
+ */
53
+ export const CONFLICT_HINT_KEY = "conflictHint";
46
54
  /**
47
55
  * True when the entry is archived (hidden from injection, restorable).
48
56
  * Absent or empty archivedAt means the entry is active.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-continual-evolve",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Continual self-evolution plugin for DeepSeek Harness: versioned, auditable, rollback-safe harness state (prompt notes, memories, skills, subagent specs) refined from session trajectories.",
5
5
  "license": "MIT",
6
6
  "repository": {