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 +5 -3
- package/README.zh.md +5 -3
- package/lib/command.js +26 -0
- package/lib/consolidate.d.ts +65 -0
- package/lib/consolidate.js +113 -0
- package/lib/inject.d.ts +10 -16
- package/lib/inject.js +27 -35
- package/lib/promotion.d.ts +24 -3
- package/lib/promotion.js +31 -6
- package/lib/search.d.ts +83 -0
- package/lib/search.js +136 -0
- package/lib/service.js +39 -0
- package/lib/tool.js +8 -1
- package/lib/types.d.ts +8 -0
- package/lib/types.js +8 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
[](https://github.com/ZK-Andy/dsh-continual-evolve/actions/workflows/ci.yml)
|
|
8
8
|
[](LICENSE)
|
|
9
9
|
[](package.json)
|
|
10
|
-
[]()
|
|
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 (
|
|
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
|
[](https://github.com/ZK-Andy/dsh-continual-evolve/actions/workflows/ci.yml)
|
|
8
8
|
[](LICENSE)
|
|
9
9
|
[](package.json)
|
|
10
|
-
[]()
|
|
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(
|
|
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,
|
|
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
|
/**
|
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,
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
if (
|
|
76
|
-
return
|
|
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) {
|
package/lib/promotion.d.ts
CHANGED
|
@@ -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.
|
|
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
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
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
|
|
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
|
|
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 >=
|
|
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
|
package/lib/search.d.ts
ADDED
|
@@ -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
|
-
|
|
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.
|
|
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": {
|