add-coder 0.3.33 → 0.3.35
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/dist/index.js +21 -3
- package/package.json +3 -3
- package/templates/.add-coder-src-hash.json +95 -41
- package/templates/adapters/claude/hooks/doc-format-guard.mjs +172 -84
- package/templates/adapters/claude/hooks/post-tool-use.mjs +59 -1
- package/templates/adapters/claude/hooks/prompt-submit.mjs +72 -0
- package/templates/adapters/claude/hooks/session-start.mjs +65 -1
- package/templates/adapters/codex/hooks/doc-format-guard.mjs +172 -84
- package/templates/adapters/codex/hooks/post-tool-use.mjs +59 -1
- package/templates/adapters/codex/hooks/prompt-submit.mjs +72 -0
- package/templates/adapters/codex/hooks/session-start.mjs +65 -1
- package/templates/adapters/qoder/hooks/doc-format-guard.mjs +172 -84
- package/templates/adapters/qoder/hooks/post-tool-use.mjs +59 -1
- package/templates/adapters/qoder/hooks/prompt-submit.mjs +72 -0
- package/templates/adapters/qoder/hooks/session-start.mjs +67 -1
- package/templates/adapters/trae/hooks/doc-format-guard.mjs +172 -84
- package/templates/adapters/trae/hooks/post-tool-use.mjs +59 -1
- package/templates/adapters/trae/hooks/prompt-submit.mjs +72 -0
- package/templates/adapters/trae/hooks/session-start.mjs +65 -1
- package/templates/adapters/vscode/hooks/doc-format-guard.mjs +172 -84
- package/templates/adapters/vscode/hooks/post-tool-use.mjs +59 -1
- package/templates/adapters/vscode/hooks/prompt-submit.mjs +72 -0
- package/templates/adapters/vscode/hooks/session-start.mjs +65 -1
- package/templates/core/governance/doc-format-guard.ts +29 -112
- package/templates/core/governance/post-tool-router.ts +33 -1
- package/templates/core/governance/prompt-router.ts +47 -0
- package/templates/core/governance/session-start-guard.ts +48 -1
- package/templates/core/prisma/add.prisma +203 -0
- package/templates/core/scripts/db-ensure.sh +61 -2
- package/templates/core/scripts/mcp-server/shared/db-types.ts +119 -0
- package/templates/core/scripts/mcp-server/shared/hitl-create-policy.ts +27 -0
- package/templates/core/scripts/mcp-server/shared/hitl-proposal-content.ts +110 -0
- package/templates/core/scripts/mcp-server/shared/hitl-widget-instance.ts +85 -0
- package/templates/core/scripts/mcp-server/shared/memory/calibration/batch-fit.ts +250 -0
- package/templates/core/scripts/mcp-server/shared/memory/calibration/feedback-stats.ts +101 -0
- package/templates/core/scripts/mcp-server/shared/memory/calibration/unit-state.ts +224 -0
- package/templates/core/scripts/mcp-server/shared/memory/domain/conflicts.ts +59 -0
- package/templates/core/scripts/mcp-server/shared/memory/domain/dedup.ts +45 -0
- package/templates/core/scripts/mcp-server/shared/memory/domain/errors.ts +33 -0
- package/templates/core/scripts/mcp-server/shared/memory/domain/handoff-digest.ts +92 -0
- package/templates/core/scripts/mcp-server/shared/memory/domain/metric-candidate.ts +79 -0
- package/templates/core/scripts/mcp-server/shared/memory/domain/scope.ts +94 -0
- package/templates/core/scripts/mcp-server/shared/memory/domain/secrets.ts +50 -0
- package/templates/core/scripts/mcp-server/shared/memory/domain/state-machine.ts +90 -0
- package/templates/core/scripts/mcp-server/shared/memory/embedding/index.ts +117 -0
- package/templates/core/scripts/mcp-server/shared/memory/embedding/local-onnx.ts +105 -0
- package/templates/core/scripts/mcp-server/shared/memory/embedding/openai-compatible.ts +87 -0
- package/templates/core/scripts/mcp-server/shared/memory/jobs/consolidation.ts +226 -0
- package/templates/core/scripts/mcp-server/shared/memory/jobs/evidence-collector.ts +153 -0
- package/templates/core/scripts/mcp-server/shared/memory/jobs/snapshot.ts +114 -0
- package/templates/core/scripts/mcp-server/shared/memory/metrics/gate-recall.ts +134 -0
- package/templates/core/scripts/mcp-server/shared/memory/metrics/gate-writer.ts +217 -0
- package/templates/core/scripts/mcp-server/shared/memory/metrics/stage-words.ts +69 -0
- package/templates/core/scripts/mcp-server/shared/memory/retrieval/context-builder.ts +89 -0
- package/templates/core/scripts/mcp-server/shared/memory/retrieval/fts/pg.ts +139 -0
- package/templates/core/scripts/mcp-server/shared/memory/retrieval/fts/sqlite-fts5.sql +29 -0
- package/templates/core/scripts/mcp-server/shared/memory/retrieval/fts/sqlite.ts +106 -0
- package/templates/core/scripts/mcp-server/shared/memory/retrieval/fusion.ts +43 -0
- package/templates/core/scripts/mcp-server/shared/memory/retrieval/pipeline.ts +285 -0
- package/templates/core/scripts/mcp-server/shared/memory/retrieval/query-terms.ts +31 -0
- package/templates/core/scripts/mcp-server/shared/memory/retrieval/recall-writer.ts +87 -0
- package/templates/core/scripts/mcp-server/shared/memory/retrieval/reranker.ts +116 -0
- package/templates/core/scripts/mcp-server/shared/memory/retrieval/types.ts +52 -0
- package/templates/core/scripts/mcp-server/shared/memory/retrieval/vector/pgvector.ts +143 -0
- package/templates/core/scripts/mcp-server/shared/memory/retrieval/vector/sqlite-vec.ts +118 -0
- package/templates/core/scripts/mcp-server/shared/memory/switches.ts +39 -0
- package/templates/core/scripts/mcp-server/shared/review-files.ts +22 -0
- package/templates/core/scripts/mcp-server/shared/runtime-freshness.ts +235 -0
- package/templates/core/scripts/mcp-server/tools/gateway/check_dps.ts +41 -3
- package/templates/core/scripts/mcp-server/tools/gateway/check_rahs.ts +40 -1
- package/templates/core/scripts/mcp-server/tools/gateway/check_spec_sync.ts +2 -2
- package/templates/core/scripts/mcp-server/tools/hitl.ts +108 -42
- package/templates/core/scripts/mcp-server/tools/index.ts +7 -1
- package/templates/core/scripts/mcp-server/tools/memory-compat.ts +258 -0
- package/templates/core/scripts/mcp-server/tools/memory.ts +654 -0
- package/templates/core/scripts/mcp-server/tools/plan.ts +8 -3
- package/templates/core/scripts/mcp-server/tools/review.ts +10 -7
- package/templates/core/scripts/mcp-server.ts +36 -0
- package/templates/core/templates/checklist-template.md +13 -0
- package/templates/core/templates/review-implementation-template.md +24 -0
- package/templates/core/templates/review-template.md +16 -0
- package/templates/core/validation/index.ts +136 -0
- package/templates/core/validation/policy.ts +91 -0
- package/templates/core/validation/registry.ts +61 -0
- package/templates/core/validation/schema-validator.ts +277 -0
- package/templates/core/validation/validators/add-route.ts +32 -0
- package/templates/core/validation/validators/checklist.ts +48 -0
- package/templates/core/validation/validators/handoff.ts +46 -0
- package/templates/core/validation/validators/hitl.ts +22 -0
- package/templates/core/validation/validators/index.ts +52 -0
- package/templates/core/validation/validators/plan.ts +20 -0
- package/templates/core/validation/validators/report.ts +16 -0
- package/templates/core/validation/validators/review.ts +30 -0
- package/templates/core/validation/validators/spec.ts +25 -0
- package/templates/core/validation/validators/tasks.ts +40 -0
- package/templates/core/validation/validators/types.ts +32 -0
- package/templates/core/vocabulary/add-governance-vocabulary.md +18 -0
- package/templates/core/scripts/mcp-server/shared/dps-spec-ref.ts +0 -17
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Consolidation 异步任务(Spec §10,Plan §9.2 Post-Handoff 行)
|
|
3
|
+
*
|
|
4
|
+
* 职责(全部 fail-open 隔离:单步失败不影响他步,错误入报告):
|
|
5
|
+
* 1. drainEvidenceQueue —— 采证队列落库
|
|
6
|
+
* 2. 重复检测 —— 同 repositoryRef+contentHash 多行 → 报告(不自动合并,人审)
|
|
7
|
+
* 3. 冲突队列 —— CANDIDATE/PENDING 与 ACTIVE 高相似 → 报告(不自动激活,Plan §3)
|
|
8
|
+
* 4. 指标候选 —— AddMetricSnapshot 连续异常 streak 命中 → 创建 CANDIDATE(幂等)
|
|
9
|
+
* 5. Handoff Digest —— HANDOFF Evidence → HANDOFF_DIGEST Candidate(幂等,绝不直接 ACTIVE)
|
|
10
|
+
* 6. 刷新 L1 快照 —— 供 session-start Hook 注入
|
|
11
|
+
*/
|
|
12
|
+
import type {
|
|
13
|
+
AddMemoryEvidenceRow,
|
|
14
|
+
AddMemoryEvidenceLinkRow,
|
|
15
|
+
AddMemoryRow,
|
|
16
|
+
AddMetricSnapshotRow,
|
|
17
|
+
TableDelegate,
|
|
18
|
+
} from "../../db-types.js"
|
|
19
|
+
import { detectConflicts, type Conflict } from "../domain/conflicts.js"
|
|
20
|
+
import { contentHash } from "../domain/dedup.js"
|
|
21
|
+
import { detectAnomalyStreak, type MetricPoint } from "../domain/metric-candidate.js"
|
|
22
|
+
import { buildHandoffDigest } from "../domain/handoff-digest.js"
|
|
23
|
+
import { drainEvidenceQueue, type DrainResult } from "./evidence-collector.js"
|
|
24
|
+
import { refreshL1Snapshot, type SnapshotDeps, type SnapshotResult } from "./snapshot.js"
|
|
25
|
+
|
|
26
|
+
export interface ConsolidationDeps extends SnapshotDeps {
|
|
27
|
+
memoryDb: TableDelegate<AddMemoryRow>
|
|
28
|
+
evidenceDb: Pick<TableDelegate<AddMemoryEvidenceRow>, "upsert" | "findMany">
|
|
29
|
+
linkDb: Pick<TableDelegate<AddMemoryEvidenceLinkRow>, "findFirst" | "create">
|
|
30
|
+
metricDb: TableDelegate<AddMetricSnapshotRow>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ConsolidationReport {
|
|
34
|
+
drain: DrainResult
|
|
35
|
+
duplicates: { contentHash: string; ids: string[] }[]
|
|
36
|
+
conflictQueue: { candidateId: string; topic: string; conflicts: Conflict[] }[]
|
|
37
|
+
metricCandidatesCreated: string[]
|
|
38
|
+
handoffDigestsCreated: string[]
|
|
39
|
+
snapshot: SnapshotResult | null
|
|
40
|
+
errors: string[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function runConsolidation(deps: ConsolidationDeps): Promise<ConsolidationReport> {
|
|
44
|
+
const report: ConsolidationReport = {
|
|
45
|
+
drain: { processed: 0, skipped: 0, errors: [], newOffset: 0 },
|
|
46
|
+
duplicates: [],
|
|
47
|
+
conflictQueue: [],
|
|
48
|
+
metricCandidatesCreated: [],
|
|
49
|
+
handoffDigestsCreated: [],
|
|
50
|
+
snapshot: null,
|
|
51
|
+
errors: [],
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 1. 采证队列落库
|
|
55
|
+
try {
|
|
56
|
+
report.drain = await drainEvidenceQueue(deps)
|
|
57
|
+
} catch (e) {
|
|
58
|
+
report.errors.push(`drain: ${e instanceof Error ? e.message : String(e)}`)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 2/3. 重复检测 + 冲突队列(共享一次全量读取)
|
|
62
|
+
try {
|
|
63
|
+
const rows = await deps.memoryDb.findMany({
|
|
64
|
+
where: { repositoryRef: deps.repositoryRef, status: { notIn: ["REJECTED", "ARCHIVED"] } },
|
|
65
|
+
take: 500,
|
|
66
|
+
})
|
|
67
|
+
const byHash = new Map<string, string[]>()
|
|
68
|
+
for (const r of rows) {
|
|
69
|
+
const arr = byHash.get(r.contentHash) ?? []
|
|
70
|
+
arr.push(r.id)
|
|
71
|
+
byHash.set(r.contentHash, arr)
|
|
72
|
+
}
|
|
73
|
+
for (const [hash, ids] of byHash) {
|
|
74
|
+
if (ids.length > 1) report.duplicates.push({ contentHash: hash, ids })
|
|
75
|
+
}
|
|
76
|
+
const actives = rows.filter((r) => r.status === "ACTIVE")
|
|
77
|
+
for (const c of rows.filter((r) => r.status === "CANDIDATE" || r.status === "PENDING")) {
|
|
78
|
+
const conflicts = detectConflicts(c, actives)
|
|
79
|
+
if (conflicts.length > 0) report.conflictQueue.push({ candidateId: c.id, topic: c.topic, conflicts })
|
|
80
|
+
}
|
|
81
|
+
} catch (e) {
|
|
82
|
+
report.errors.push(`dedup/conflicts: ${e instanceof Error ? e.message : String(e)}`)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 4. 指标候选(Plan §10:连续异常 streak 才生成,内容必须是可验证结论)
|
|
86
|
+
try {
|
|
87
|
+
const metrics = await deps.metricDb.findMany({
|
|
88
|
+
where: { repositoryRef: deps.repositoryRef },
|
|
89
|
+
orderBy: { measuredAt: "asc" },
|
|
90
|
+
take: 500,
|
|
91
|
+
})
|
|
92
|
+
const byType = new Map<string, MetricPoint[]>()
|
|
93
|
+
for (const m of metrics) {
|
|
94
|
+
const arr = byType.get(m.metricType) ?? []
|
|
95
|
+
arr.push({
|
|
96
|
+
repositoryRef: m.repositoryRef,
|
|
97
|
+
metricType: m.metricType,
|
|
98
|
+
value: m.value,
|
|
99
|
+
baseline: m.baseline,
|
|
100
|
+
planKeyword: m.planKeyword,
|
|
101
|
+
measuredAt: m.measuredAt,
|
|
102
|
+
sourceRef: m.sourceRef,
|
|
103
|
+
})
|
|
104
|
+
byType.set(m.metricType, arr)
|
|
105
|
+
}
|
|
106
|
+
for (const points of byType.values()) {
|
|
107
|
+
const proposal = detectAnomalyStreak(points)
|
|
108
|
+
if (!proposal) continue
|
|
109
|
+
const hash = contentHash(proposal.content)
|
|
110
|
+
const existing = await deps.memoryDb.findFirst({
|
|
111
|
+
where: {
|
|
112
|
+
repositoryRef: deps.repositoryRef,
|
|
113
|
+
contentHash: hash,
|
|
114
|
+
scopeType: "REPOSITORY",
|
|
115
|
+
scopeValue: deps.repositoryRef,
|
|
116
|
+
status: { notIn: ["REJECTED", "ARCHIVED"] },
|
|
117
|
+
},
|
|
118
|
+
})
|
|
119
|
+
if (existing) continue // 幂等:已提过同结论
|
|
120
|
+
const created = await deps.memoryDb.create({
|
|
121
|
+
data: {
|
|
122
|
+
kind: "PATTERN",
|
|
123
|
+
topic: proposal.topic,
|
|
124
|
+
content: proposal.content,
|
|
125
|
+
scopeType: "REPOSITORY",
|
|
126
|
+
scopeValue: deps.repositoryRef,
|
|
127
|
+
repositoryRef: deps.repositoryRef,
|
|
128
|
+
contentHash: hash,
|
|
129
|
+
createdBy: "memory-jobs:consolidation",
|
|
130
|
+
},
|
|
131
|
+
})
|
|
132
|
+
for (const ref of proposal.evidenceSourceRefs) {
|
|
133
|
+
const ev = await deps.evidenceDb.upsert({
|
|
134
|
+
where: {
|
|
135
|
+
repositoryRef_sourceType_sourceRef_contentHash: {
|
|
136
|
+
repositoryRef: deps.repositoryRef, sourceType: "DPS_GATE", sourceRef: ref, contentHash: contentHash(ref),
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
create: {
|
|
140
|
+
repositoryRef: deps.repositoryRef, sourceType: "DPS_GATE", sourceRef: ref,
|
|
141
|
+
excerpt: ref.slice(0, 500), contentHash: contentHash(ref),
|
|
142
|
+
},
|
|
143
|
+
update: {},
|
|
144
|
+
})
|
|
145
|
+
const linked = await deps.linkDb.findFirst({ where: { memoryId: created.id, evidenceId: ev.id } })
|
|
146
|
+
if (!linked) await deps.linkDb.create({ data: { memoryId: created.id, evidenceId: ev.id } })
|
|
147
|
+
}
|
|
148
|
+
report.metricCandidatesCreated.push(created.id)
|
|
149
|
+
}
|
|
150
|
+
} catch (e) {
|
|
151
|
+
report.errors.push(`metric-candidates: ${e instanceof Error ? e.message : String(e)}`)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 5. Handoff Digest(Plan §3.1 轮 2):HANDOFF Evidence → HANDOFF_DIGEST Candidate
|
|
155
|
+
// 幂等去重靠 dedupKey 派生的 contentHash;绝不直接置 ACTIVE(只产 CANDIDATE)
|
|
156
|
+
try {
|
|
157
|
+
const handoffEvidence = await deps.evidenceDb.findMany({
|
|
158
|
+
where: { repositoryRef: deps.repositoryRef, sourceType: "HANDOFF" },
|
|
159
|
+
orderBy: { occurredAt: "asc" },
|
|
160
|
+
take: 200,
|
|
161
|
+
})
|
|
162
|
+
for (const ev of handoffEvidence) {
|
|
163
|
+
const proposal = buildHandoffDigest(
|
|
164
|
+
{ handoffRef: ev.sourceRef, excerpt: ev.excerpt, planKeyword: ev.planKeyword },
|
|
165
|
+
{ repositoryRef: deps.repositoryRef },
|
|
166
|
+
)
|
|
167
|
+
const existing = await deps.memoryDb.findFirst({
|
|
168
|
+
where: {
|
|
169
|
+
repositoryRef: deps.repositoryRef,
|
|
170
|
+
contentHash: proposal.contentHash,
|
|
171
|
+
scopeType: proposal.scopeType,
|
|
172
|
+
scopeValue: proposal.scopeValue,
|
|
173
|
+
status: { notIn: ["REJECTED", "ARCHIVED"] },
|
|
174
|
+
},
|
|
175
|
+
})
|
|
176
|
+
if (existing) {
|
|
177
|
+
// 幂等:同一交接 evidence 的摘要已入队过 → 仅补齐证据关联
|
|
178
|
+
const linkedAlready = await deps.linkDb.findFirst({
|
|
179
|
+
where: { memoryId: existing.id, evidenceId: ev.id },
|
|
180
|
+
})
|
|
181
|
+
if (!linkedAlready) {
|
|
182
|
+
await deps.linkDb.create({
|
|
183
|
+
data: { memoryId: existing.id, evidenceId: ev.id },
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
continue
|
|
187
|
+
}
|
|
188
|
+
const created = await deps.memoryDb.create({
|
|
189
|
+
data: {
|
|
190
|
+
kind: proposal.kind,
|
|
191
|
+
status: "CANDIDATE", // 绝不直接 ACTIVE
|
|
192
|
+
topic: proposal.topic,
|
|
193
|
+
content: proposal.content,
|
|
194
|
+
scopeType: proposal.scopeType,
|
|
195
|
+
scopeValue: proposal.scopeValue,
|
|
196
|
+
repositoryRef: deps.repositoryRef,
|
|
197
|
+
contentHash: proposal.contentHash,
|
|
198
|
+
importance: 0.6,
|
|
199
|
+
confidence: 0.5,
|
|
200
|
+
createdBy: "memory-jobs:consolidation",
|
|
201
|
+
metadata: { ...proposal.metadata, dedupKey: proposal.dedupKey, evidenceId: ev.id },
|
|
202
|
+
},
|
|
203
|
+
})
|
|
204
|
+
const linked = await deps.linkDb.findFirst({
|
|
205
|
+
where: { memoryId: created.id, evidenceId: ev.id },
|
|
206
|
+
})
|
|
207
|
+
if (!linked) {
|
|
208
|
+
await deps.linkDb.create({
|
|
209
|
+
data: { memoryId: created.id, evidenceId: ev.id },
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
report.handoffDigestsCreated.push(created.id)
|
|
213
|
+
}
|
|
214
|
+
} catch (e) {
|
|
215
|
+
report.errors.push(`handoff-digest: ${e instanceof Error ? e.message : String(e)}`)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 6. 刷新 L1 快照
|
|
219
|
+
try {
|
|
220
|
+
report.snapshot = await refreshL1Snapshot(deps)
|
|
221
|
+
} catch (e) {
|
|
222
|
+
report.errors.push(`snapshot: ${e instanceof Error ? e.message : String(e)}`)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return report
|
|
226
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Evidence 自动采集(Spec §10/§11,Plan §9.2 PostToolUse 行)
|
|
3
|
+
*
|
|
4
|
+
* 两段式:
|
|
5
|
+
* ① Hook 侧(同步 ≤200ms,无 DB):白名单判定 + 事件入队(evidence-queue.jsonl append-only)
|
|
6
|
+
* —— classifyEvidenceSource / buildEvidenceEvent 为纯函数,供 post-tool-router 直接引用
|
|
7
|
+
* ② Job 侧(异步,可重试):drainEvidenceQueue 消费队列 → upsert Evidence(幂等键
|
|
8
|
+
* repositoryRef+sourceType+sourceRef+contentHash,与 add.prisma @@unique 一一对应)
|
|
9
|
+
* 消费进度落 evidence-queue.offset(字节偏移),重放幂等:同偏移不重复处理,
|
|
10
|
+
* 同事件重复消费由 upsert 幂等吸收。
|
|
11
|
+
*/
|
|
12
|
+
import { createHash } from "node:crypto"
|
|
13
|
+
import { existsSync, mkdirSync, openSync, readFileSync, readSync, closeSync, statSync, writeFileSync, renameSync } from "node:fs"
|
|
14
|
+
import { dirname, join } from "node:path"
|
|
15
|
+
import type { AddMemoryEvidenceRow, TableDelegate } from "../../db-types.js"
|
|
16
|
+
import { EVIDENCE_OFFSET_FILE, EVIDENCE_QUEUE_FILE, MEMORY_DIR_NAME } from "../switches.js"
|
|
17
|
+
|
|
18
|
+
// ── ① Hook 侧纯函数(无 IO 之外的依赖,无 DB) ──
|
|
19
|
+
|
|
20
|
+
export type EvidenceSourceType = "PLAN" | "SPEC" | "HANDOFF" | "DEV_OPERATION"
|
|
21
|
+
|
|
22
|
+
export interface EvidenceEvent {
|
|
23
|
+
dedupKey: string
|
|
24
|
+
sourceType: EvidenceSourceType
|
|
25
|
+
sourceRef: string
|
|
26
|
+
excerpt: string
|
|
27
|
+
occurredAt: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 白名单分类:命中返回 sourceType,未命中返回 null(spec §10:按白名单采集) */
|
|
31
|
+
export function classifyEvidenceSource(filePath: string): EvidenceSourceType | null {
|
|
32
|
+
const p = filePath.replace(/\\/g, "/")
|
|
33
|
+
if (/(^|\/)plans\/[^/]*-plan-v\d+\.md$/.test(p)) return "PLAN"
|
|
34
|
+
if (/(^|\/)plans\/[^/]*-add-route-v\d+\.md$/.test(p)) return "PLAN"
|
|
35
|
+
if (/(^|\/)specs\/.+\.md$/.test(p)) return "SPEC"
|
|
36
|
+
if (/handoff[^/]*\.md$/i.test(p)) return "HANDOFF"
|
|
37
|
+
if (/(^|\/)reviews\/.+\.md$/.test(p)) return "DEV_OPERATION"
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 构造采证事件(幂等 key = sha256(sourceType|sourceRef|excerpt)):同文件同内容重复写入被吸收 */
|
|
42
|
+
export function buildEvidenceEvent(filePath: string, excerpt: string, occurredAt = new Date()): EvidenceEvent {
|
|
43
|
+
const sourceType = classifyEvidenceSource(filePath)
|
|
44
|
+
if (!sourceType) throw new Error(`非白名单路径: ${filePath}`)
|
|
45
|
+
const sourceRef = filePath
|
|
46
|
+
const dedupKey = createHash("sha256")
|
|
47
|
+
.update(`${sourceType}|${sourceRef}|${excerpt}`, "utf8")
|
|
48
|
+
.digest("hex")
|
|
49
|
+
.slice(0, 32)
|
|
50
|
+
return { dedupKey, sourceType, sourceRef, excerpt: excerpt.slice(0, 500), occurredAt: occurredAt.toISOString() }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── ② Job 侧消费(异步、可重试) ──
|
|
54
|
+
|
|
55
|
+
export interface DrainDeps {
|
|
56
|
+
projectDir: string
|
|
57
|
+
magicDir: string
|
|
58
|
+
repositoryRef: string
|
|
59
|
+
evidenceDb: Pick<TableDelegate<AddMemoryEvidenceRow>, "upsert">
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface DrainResult {
|
|
63
|
+
processed: number
|
|
64
|
+
skipped: number
|
|
65
|
+
errors: string[]
|
|
66
|
+
newOffset: number
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 读 offset 标记(缺失/损坏 → 0,从头重放;upsert 幂等保证安全) */
|
|
70
|
+
export function readOffset(projectDir: string, magicDir: string): number {
|
|
71
|
+
try {
|
|
72
|
+
const f = join(projectDir, magicDir, MEMORY_DIR_NAME, EVIDENCE_OFFSET_FILE)
|
|
73
|
+
if (!existsSync(f)) return 0
|
|
74
|
+
const n = Number(readFileSync(f, "utf-8").trim())
|
|
75
|
+
return Number.isFinite(n) && n >= 0 ? n : 0
|
|
76
|
+
} catch {
|
|
77
|
+
return 0
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 消费 evidence 队列:从上次偏移继续,逐行 upsert Evidence。
|
|
83
|
+
* fail-open 粒度到行:坏行跳过并计入 errors,不阻塞后续。
|
|
84
|
+
*/
|
|
85
|
+
export async function drainEvidenceQueue(deps: DrainDeps): Promise<DrainResult> {
|
|
86
|
+
const queueFile = join(deps.projectDir, deps.magicDir, MEMORY_DIR_NAME, EVIDENCE_QUEUE_FILE)
|
|
87
|
+
const result: DrainResult = { processed: 0, skipped: 0, errors: [], newOffset: readOffset(deps.projectDir, deps.magicDir) }
|
|
88
|
+
if (!existsSync(queueFile)) return result
|
|
89
|
+
|
|
90
|
+
const size = (() => { try { return statSync(queueFile).size } catch { return 0 } })()
|
|
91
|
+
if (size <= result.newOffset) return result // 无增量
|
|
92
|
+
|
|
93
|
+
// 按偏移读取增量字节(避免大文件全量加载)
|
|
94
|
+
const fd = openSync(queueFile, "r")
|
|
95
|
+
let chunk: string
|
|
96
|
+
try {
|
|
97
|
+
const buf = Buffer.alloc(size - result.newOffset)
|
|
98
|
+
readSync(fd, buf, 0, buf.length, result.newOffset)
|
|
99
|
+
chunk = buf.toString("utf-8")
|
|
100
|
+
} finally {
|
|
101
|
+
closeSync(fd)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let cursor = result.newOffset
|
|
105
|
+
for (const line of chunk.split("\n")) {
|
|
106
|
+
const lineBytes = Buffer.byteLength(line, "utf-8") + 1 // + '\n'
|
|
107
|
+
cursor += lineBytes
|
|
108
|
+
const trimmed = line.trim()
|
|
109
|
+
if (!trimmed) continue
|
|
110
|
+
let ev: EvidenceEvent
|
|
111
|
+
try {
|
|
112
|
+
ev = JSON.parse(trimmed) as EvidenceEvent
|
|
113
|
+
} catch {
|
|
114
|
+
result.skipped++
|
|
115
|
+
result.errors.push(`坏行@${cursor - lineBytes}: JSON 解析失败`)
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
await deps.evidenceDb.upsert({
|
|
120
|
+
where: {
|
|
121
|
+
repositoryRef_sourceType_sourceRef_contentHash: {
|
|
122
|
+
repositoryRef: deps.repositoryRef,
|
|
123
|
+
sourceType: ev.sourceType,
|
|
124
|
+
sourceRef: ev.sourceRef,
|
|
125
|
+
contentHash: ev.dedupKey,
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
create: {
|
|
129
|
+
repositoryRef: deps.repositoryRef,
|
|
130
|
+
sourceType: ev.sourceType,
|
|
131
|
+
sourceRef: ev.sourceRef,
|
|
132
|
+
excerpt: ev.excerpt,
|
|
133
|
+
contentHash: ev.dedupKey,
|
|
134
|
+
occurredAt: new Date(ev.occurredAt),
|
|
135
|
+
} as Partial<AddMemoryEvidenceRow>,
|
|
136
|
+
update: {},
|
|
137
|
+
})
|
|
138
|
+
result.processed++
|
|
139
|
+
} catch (e) {
|
|
140
|
+
result.skipped++
|
|
141
|
+
result.errors.push(`落库失败 ${ev.sourceRef}: ${e instanceof Error ? e.message : String(e)}`)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 原子推进 offset(先写后改:offset 落后只会重放,upsert 幂等吸收)
|
|
146
|
+
result.newOffset = cursor
|
|
147
|
+
const offsetFile = join(deps.projectDir, deps.magicDir, MEMORY_DIR_NAME, EVIDENCE_OFFSET_FILE)
|
|
148
|
+
mkdirSync(dirname(offsetFile), { recursive: true })
|
|
149
|
+
const tmp = `${offsetFile}.tmp-${process.pid}`
|
|
150
|
+
writeFileSync(tmp, String(cursor), "utf-8")
|
|
151
|
+
renameSync(tmp, offsetFile)
|
|
152
|
+
return result
|
|
153
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* L1/L2 召回快照生成(Spec §10:session-start 仅注入 repository 级 L1 小上下文)
|
|
3
|
+
*
|
|
4
|
+
* 设计约束(Plan §9.3):同步 Hook ≤200ms 且无 DB 依赖 → Hook 只读本模块预计算的快照文件;
|
|
5
|
+
* 快照由本 job 异步刷新(consolidation / 手动 CLI / Gate 后触发)。
|
|
6
|
+
* 原子写:tmp + rename,Hook 读侧永不遇到半文件。
|
|
7
|
+
*/
|
|
8
|
+
import { mkdirSync, renameSync, writeFileSync } from "node:fs"
|
|
9
|
+
import { dirname, join } from "node:path"
|
|
10
|
+
import { recallPipeline, type MemoryRowLike } from "../retrieval/pipeline.js"
|
|
11
|
+
import type { RecallAuditStore } from "../retrieval/recall-writer.js"
|
|
12
|
+
import type { LexicalSearchAdapter, RecalledMemory } from "../retrieval/types.js"
|
|
13
|
+
import { L1_SNAPSHOT_FILE, L2_SNAPSHOT_FILE, MEMORY_DIR_NAME, memoryMaxTokens } from "../switches.js"
|
|
14
|
+
|
|
15
|
+
export interface SnapshotDeps {
|
|
16
|
+
repositoryRef: string
|
|
17
|
+
projectDir: string
|
|
18
|
+
magicDir: string
|
|
19
|
+
lexical: LexicalSearchAdapter[]
|
|
20
|
+
fetchByIds(ids: string[]): Promise<MemoryRowLike[]>
|
|
21
|
+
fetchEvidenceSourceRefs(memoryIds: string[]): Promise<Map<string, string[]>>
|
|
22
|
+
audit: RecallAuditStore
|
|
23
|
+
now?: Date
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SnapshotResult {
|
|
27
|
+
path: string
|
|
28
|
+
itemCount: number
|
|
29
|
+
injectedTokens: number
|
|
30
|
+
recallId: string | null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 渲染带来源边界标签的注入文本(Plan §9.3:Recall 注入采用明确的数据边界与来源标签) */
|
|
34
|
+
export function renderSnapshotMarkdown(level: "L1" | "L2", items: RecalledMemory[], generatedAt: Date): string {
|
|
35
|
+
const lines = [
|
|
36
|
+
`[Memory ${level} · 来源: AddMemory 治理库 · 生成于 ${generatedAt.toISOString()}]`,
|
|
37
|
+
`<agent-memory source="add-memory" trust="governed">`,
|
|
38
|
+
]
|
|
39
|
+
for (const it of items) {
|
|
40
|
+
lines.push(
|
|
41
|
+
`- [${it.kind}] ${it.topic}(置信 ${it.confidence.toFixed(2)})`,
|
|
42
|
+
` ${it.content.length > 120 ? it.content.slice(0, 120) + "…" : it.content}`,
|
|
43
|
+
)
|
|
44
|
+
if (it.sourceRefs.length > 0) lines.push(` 来源: ${it.sourceRefs.join(", ")}`)
|
|
45
|
+
}
|
|
46
|
+
lines.push(`</agent-memory>`)
|
|
47
|
+
return lines.join("\n") + "\n"
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 刷新 L1 快照:repository 级核心约束/决策/陷阱/约定 */
|
|
51
|
+
export async function refreshL1Snapshot(deps: SnapshotDeps): Promise<SnapshotResult> {
|
|
52
|
+
const maxTokens = memoryMaxTokens()
|
|
53
|
+
const result = await recallPipeline(
|
|
54
|
+
{
|
|
55
|
+
query: "核心约束 关键决策 常见陷阱 项目约定",
|
|
56
|
+
stage: "session-start",
|
|
57
|
+
repositoryRef: deps.repositoryRef,
|
|
58
|
+
scopeCtx: { repository: deps.repositoryRef },
|
|
59
|
+
maxTokens,
|
|
60
|
+
kinds: ["CONSTRAINT", "DECISION", "PITFALL", "CONVENTION"],
|
|
61
|
+
limit: 20,
|
|
62
|
+
consumerRef: "memory-jobs:refresh-l1",
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
lexical: deps.lexical,
|
|
66
|
+
fetchByIds: deps.fetchByIds,
|
|
67
|
+
fetchEvidenceSourceRefs: deps.fetchEvidenceSourceRefs,
|
|
68
|
+
audit: deps.audit,
|
|
69
|
+
degradedMode: "fts-only(snapshot-job)",
|
|
70
|
+
now: deps.now,
|
|
71
|
+
},
|
|
72
|
+
)
|
|
73
|
+
const path = join(deps.projectDir, deps.magicDir, MEMORY_DIR_NAME, L1_SNAPSHOT_FILE)
|
|
74
|
+
atomicWrite(path, renderSnapshotMarkdown("L1", result.items, deps.now ?? new Date()))
|
|
75
|
+
return { path, itemCount: result.items.length, injectedTokens: result.injectedTokens, recallId: result.recallId }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 刷新 L2 快照:按给定 query/stage/scope(供 prompt-router 提示与人工查阅) */
|
|
79
|
+
export async function refreshL2Snapshot(
|
|
80
|
+
deps: SnapshotDeps,
|
|
81
|
+
query: string,
|
|
82
|
+
stage: string,
|
|
83
|
+
maxTokens = 1200,
|
|
84
|
+
): Promise<SnapshotResult> {
|
|
85
|
+
const result = await recallPipeline(
|
|
86
|
+
{
|
|
87
|
+
query,
|
|
88
|
+
stage,
|
|
89
|
+
repositoryRef: deps.repositoryRef,
|
|
90
|
+
scopeCtx: { repository: deps.repositoryRef },
|
|
91
|
+
maxTokens,
|
|
92
|
+
limit: 20,
|
|
93
|
+
consumerRef: "memory-jobs:refresh-l2",
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
lexical: deps.lexical,
|
|
97
|
+
fetchByIds: deps.fetchByIds,
|
|
98
|
+
fetchEvidenceSourceRefs: deps.fetchEvidenceSourceRefs,
|
|
99
|
+
audit: deps.audit,
|
|
100
|
+
degradedMode: "fts-only(snapshot-job)",
|
|
101
|
+
now: deps.now,
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
const path = join(deps.projectDir, deps.magicDir, MEMORY_DIR_NAME, L2_SNAPSHOT_FILE)
|
|
105
|
+
atomicWrite(path, renderSnapshotMarkdown("L2", result.items, deps.now ?? new Date()))
|
|
106
|
+
return { path, itemCount: result.items.length, injectedTokens: result.injectedTokens, recallId: result.recallId }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function atomicWrite(path: string, content: string): void {
|
|
110
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
111
|
+
const tmp = `${path}.tmp-${process.pid}`
|
|
112
|
+
writeFileSync(tmp, content, "utf-8")
|
|
113
|
+
renameSync(tmp, path)
|
|
114
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 工具侧阶段确定性召回(Plan §3.1 方案 B1 / Spec §2 §DeterministicRecall)
|
|
3
|
+
*
|
|
4
|
+
* 与 stage-words.ts 的分工:
|
|
5
|
+
* - stage-words.ts:纯函数,Hook 侧识别阶段词(无 DB)
|
|
6
|
+
* - 本模块:有 DB 的工具侧落点,复用既有 recallPipeline(不另起一套召回实现)
|
|
7
|
+
*
|
|
8
|
+
* 三条硬约束:
|
|
9
|
+
* 1. 阶段白名单命中才召回——未命中直接返回,不访问 DB、不写审计;
|
|
10
|
+
* 2. ADD_MEMORY_RECALL_MODE=off 时整体停用(与 recall_memory 工具同语义);
|
|
11
|
+
* 3. fail-open:管线异常只降级为 skippedReason="pipeline-error",绝不抛出,
|
|
12
|
+
* 不阻塞调用它的门禁/交接流程。
|
|
13
|
+
*/
|
|
14
|
+
import { recallPipeline, type RecallPipelineDeps, type RecallPipelineResult } from "../retrieval/pipeline.js"
|
|
15
|
+
import { buildContext, estimateTokens } from "../retrieval/context-builder.js"
|
|
16
|
+
import { recallMode, type RecallMode } from "../switches.js"
|
|
17
|
+
import type { ScopeContext } from "../domain/scope.js"
|
|
18
|
+
import { isRecallStage, RECALL_STAGES, type RecallStage } from "./stage-words.js"
|
|
19
|
+
|
|
20
|
+
/** 默认 token 预算(Spec §10:ADD_MEMORY_MAX_TOKENS 默认 600,此处与 Recall 通道对齐) */
|
|
21
|
+
export const DEFAULT_STAGE_RECALL_MAX_TOKENS = 600
|
|
22
|
+
|
|
23
|
+
export type StageRecallSkipReason = "stage-not-whitelisted" | "recall-off" | "pipeline-error"
|
|
24
|
+
|
|
25
|
+
export interface StageRecallInput {
|
|
26
|
+
/** 调用方声明的阶段;不在白名单内即不召回 */
|
|
27
|
+
stage: string
|
|
28
|
+
query: string
|
|
29
|
+
repositoryRef: string
|
|
30
|
+
scopeCtx?: Partial<ScopeContext>
|
|
31
|
+
planKeyword?: string
|
|
32
|
+
specRef?: string
|
|
33
|
+
maxTokens?: number
|
|
34
|
+
limit?: number
|
|
35
|
+
consumerRef?: string
|
|
36
|
+
diagnostic?: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface StageRecallResult {
|
|
40
|
+
injected: boolean
|
|
41
|
+
stage: RecallStage | null
|
|
42
|
+
context?: string
|
|
43
|
+
auditId?: string | null
|
|
44
|
+
degradedMode?: string | null
|
|
45
|
+
itemCount: number
|
|
46
|
+
usedTokens: number
|
|
47
|
+
latencyMs: number
|
|
48
|
+
skippedReason?: StageRecallSkipReason
|
|
49
|
+
error?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 按阶段执行确定性召回。deps 由调用方(MCP 工具)注入,便于测试与复用。
|
|
54
|
+
*/
|
|
55
|
+
export async function recallForStage(
|
|
56
|
+
input: StageRecallInput,
|
|
57
|
+
deps: RecallPipelineDeps,
|
|
58
|
+
options: { mode?: RecallMode } = {},
|
|
59
|
+
): Promise<StageRecallResult> {
|
|
60
|
+
const startedAt = Date.now()
|
|
61
|
+
const empty = (): Omit<StageRecallResult, "skippedReason" | "stage"> => ({
|
|
62
|
+
injected: false,
|
|
63
|
+
itemCount: 0,
|
|
64
|
+
usedTokens: 0,
|
|
65
|
+
latencyMs: Math.max(0, Date.now() - startedAt),
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
if (!isRecallStage(input.stage)) {
|
|
69
|
+
return { ...empty(), stage: null, skippedReason: "stage-not-whitelisted" }
|
|
70
|
+
}
|
|
71
|
+
const mode = options.mode ?? recallMode()
|
|
72
|
+
if (mode === "off") {
|
|
73
|
+
return { ...empty(), stage: input.stage, skippedReason: "recall-off" }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const stage = input.stage
|
|
77
|
+
const maxTokens = input.maxTokens ?? DEFAULT_STAGE_RECALL_MAX_TOKENS
|
|
78
|
+
const scopeCtx: ScopeContext = {
|
|
79
|
+
repository: input.repositoryRef,
|
|
80
|
+
planKeyword: input.planKeyword,
|
|
81
|
+
specRef: input.specRef,
|
|
82
|
+
...input.scopeCtx,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const result: RecallPipelineResult = await recallPipeline(
|
|
87
|
+
{
|
|
88
|
+
query: input.query,
|
|
89
|
+
stage,
|
|
90
|
+
repositoryRef: input.repositoryRef,
|
|
91
|
+
scopeCtx,
|
|
92
|
+
maxTokens,
|
|
93
|
+
limit: input.limit ?? 20,
|
|
94
|
+
consumerRef: input.consumerRef ?? `mcp:stage-recall:${stage}`,
|
|
95
|
+
diagnostic: input.diagnostic ?? false,
|
|
96
|
+
},
|
|
97
|
+
deps,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
const budget = buildContext(
|
|
101
|
+
result.items.map((item) => ({
|
|
102
|
+
memoryId: item.memoryId,
|
|
103
|
+
kind: item.kind,
|
|
104
|
+
finalScore: item.scoreBreakdown?.final ?? item.confidence,
|
|
105
|
+
tokens: estimateTokens(item.content),
|
|
106
|
+
content: item.content,
|
|
107
|
+
sourceRefs: item.sourceRefs,
|
|
108
|
+
})),
|
|
109
|
+
maxTokens,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
injected: budget.selected.length > 0,
|
|
114
|
+
stage,
|
|
115
|
+
context: budget.selected.map((i) => i.content).join("\n\n"),
|
|
116
|
+
auditId: result.recallId,
|
|
117
|
+
degradedMode: result.degradedMode,
|
|
118
|
+
itemCount: budget.selected.length,
|
|
119
|
+
usedTokens: budget.usedTokens,
|
|
120
|
+
latencyMs: result.latencyMs,
|
|
121
|
+
}
|
|
122
|
+
} catch (error) {
|
|
123
|
+
// fail-open:召回失败不阻塞门禁/交接;错误信息进审计与返回值
|
|
124
|
+
return {
|
|
125
|
+
...empty(),
|
|
126
|
+
stage,
|
|
127
|
+
skippedReason: "pipeline-error",
|
|
128
|
+
error: error instanceof Error ? error.message : String(error),
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** 供工具/文档展示的白名单(单一事实源来自 stage-words) */
|
|
134
|
+
export { RECALL_STAGES }
|