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.
Files changed (98) hide show
  1. package/dist/index.js +21 -3
  2. package/package.json +3 -3
  3. package/templates/.add-coder-src-hash.json +95 -41
  4. package/templates/adapters/claude/hooks/doc-format-guard.mjs +172 -84
  5. package/templates/adapters/claude/hooks/post-tool-use.mjs +59 -1
  6. package/templates/adapters/claude/hooks/prompt-submit.mjs +72 -0
  7. package/templates/adapters/claude/hooks/session-start.mjs +65 -1
  8. package/templates/adapters/codex/hooks/doc-format-guard.mjs +172 -84
  9. package/templates/adapters/codex/hooks/post-tool-use.mjs +59 -1
  10. package/templates/adapters/codex/hooks/prompt-submit.mjs +72 -0
  11. package/templates/adapters/codex/hooks/session-start.mjs +65 -1
  12. package/templates/adapters/qoder/hooks/doc-format-guard.mjs +172 -84
  13. package/templates/adapters/qoder/hooks/post-tool-use.mjs +59 -1
  14. package/templates/adapters/qoder/hooks/prompt-submit.mjs +72 -0
  15. package/templates/adapters/qoder/hooks/session-start.mjs +67 -1
  16. package/templates/adapters/trae/hooks/doc-format-guard.mjs +172 -84
  17. package/templates/adapters/trae/hooks/post-tool-use.mjs +59 -1
  18. package/templates/adapters/trae/hooks/prompt-submit.mjs +72 -0
  19. package/templates/adapters/trae/hooks/session-start.mjs +65 -1
  20. package/templates/adapters/vscode/hooks/doc-format-guard.mjs +172 -84
  21. package/templates/adapters/vscode/hooks/post-tool-use.mjs +59 -1
  22. package/templates/adapters/vscode/hooks/prompt-submit.mjs +72 -0
  23. package/templates/adapters/vscode/hooks/session-start.mjs +65 -1
  24. package/templates/core/governance/doc-format-guard.ts +29 -112
  25. package/templates/core/governance/post-tool-router.ts +33 -1
  26. package/templates/core/governance/prompt-router.ts +47 -0
  27. package/templates/core/governance/session-start-guard.ts +48 -1
  28. package/templates/core/prisma/add.prisma +203 -0
  29. package/templates/core/scripts/db-ensure.sh +61 -2
  30. package/templates/core/scripts/mcp-server/shared/db-types.ts +119 -0
  31. package/templates/core/scripts/mcp-server/shared/hitl-create-policy.ts +27 -0
  32. package/templates/core/scripts/mcp-server/shared/hitl-proposal-content.ts +110 -0
  33. package/templates/core/scripts/mcp-server/shared/hitl-widget-instance.ts +85 -0
  34. package/templates/core/scripts/mcp-server/shared/memory/calibration/batch-fit.ts +250 -0
  35. package/templates/core/scripts/mcp-server/shared/memory/calibration/feedback-stats.ts +101 -0
  36. package/templates/core/scripts/mcp-server/shared/memory/calibration/unit-state.ts +224 -0
  37. package/templates/core/scripts/mcp-server/shared/memory/domain/conflicts.ts +59 -0
  38. package/templates/core/scripts/mcp-server/shared/memory/domain/dedup.ts +45 -0
  39. package/templates/core/scripts/mcp-server/shared/memory/domain/errors.ts +33 -0
  40. package/templates/core/scripts/mcp-server/shared/memory/domain/handoff-digest.ts +92 -0
  41. package/templates/core/scripts/mcp-server/shared/memory/domain/metric-candidate.ts +79 -0
  42. package/templates/core/scripts/mcp-server/shared/memory/domain/scope.ts +94 -0
  43. package/templates/core/scripts/mcp-server/shared/memory/domain/secrets.ts +50 -0
  44. package/templates/core/scripts/mcp-server/shared/memory/domain/state-machine.ts +90 -0
  45. package/templates/core/scripts/mcp-server/shared/memory/embedding/index.ts +117 -0
  46. package/templates/core/scripts/mcp-server/shared/memory/embedding/local-onnx.ts +105 -0
  47. package/templates/core/scripts/mcp-server/shared/memory/embedding/openai-compatible.ts +87 -0
  48. package/templates/core/scripts/mcp-server/shared/memory/jobs/consolidation.ts +226 -0
  49. package/templates/core/scripts/mcp-server/shared/memory/jobs/evidence-collector.ts +153 -0
  50. package/templates/core/scripts/mcp-server/shared/memory/jobs/snapshot.ts +114 -0
  51. package/templates/core/scripts/mcp-server/shared/memory/metrics/gate-recall.ts +134 -0
  52. package/templates/core/scripts/mcp-server/shared/memory/metrics/gate-writer.ts +217 -0
  53. package/templates/core/scripts/mcp-server/shared/memory/metrics/stage-words.ts +69 -0
  54. package/templates/core/scripts/mcp-server/shared/memory/retrieval/context-builder.ts +89 -0
  55. package/templates/core/scripts/mcp-server/shared/memory/retrieval/fts/pg.ts +139 -0
  56. package/templates/core/scripts/mcp-server/shared/memory/retrieval/fts/sqlite-fts5.sql +29 -0
  57. package/templates/core/scripts/mcp-server/shared/memory/retrieval/fts/sqlite.ts +106 -0
  58. package/templates/core/scripts/mcp-server/shared/memory/retrieval/fusion.ts +43 -0
  59. package/templates/core/scripts/mcp-server/shared/memory/retrieval/pipeline.ts +285 -0
  60. package/templates/core/scripts/mcp-server/shared/memory/retrieval/query-terms.ts +31 -0
  61. package/templates/core/scripts/mcp-server/shared/memory/retrieval/recall-writer.ts +87 -0
  62. package/templates/core/scripts/mcp-server/shared/memory/retrieval/reranker.ts +116 -0
  63. package/templates/core/scripts/mcp-server/shared/memory/retrieval/types.ts +52 -0
  64. package/templates/core/scripts/mcp-server/shared/memory/retrieval/vector/pgvector.ts +143 -0
  65. package/templates/core/scripts/mcp-server/shared/memory/retrieval/vector/sqlite-vec.ts +118 -0
  66. package/templates/core/scripts/mcp-server/shared/memory/switches.ts +39 -0
  67. package/templates/core/scripts/mcp-server/shared/review-files.ts +22 -0
  68. package/templates/core/scripts/mcp-server/shared/runtime-freshness.ts +235 -0
  69. package/templates/core/scripts/mcp-server/tools/gateway/check_dps.ts +41 -3
  70. package/templates/core/scripts/mcp-server/tools/gateway/check_rahs.ts +40 -1
  71. package/templates/core/scripts/mcp-server/tools/gateway/check_spec_sync.ts +2 -2
  72. package/templates/core/scripts/mcp-server/tools/hitl.ts +108 -42
  73. package/templates/core/scripts/mcp-server/tools/index.ts +7 -1
  74. package/templates/core/scripts/mcp-server/tools/memory-compat.ts +258 -0
  75. package/templates/core/scripts/mcp-server/tools/memory.ts +654 -0
  76. package/templates/core/scripts/mcp-server/tools/plan.ts +8 -3
  77. package/templates/core/scripts/mcp-server/tools/review.ts +10 -7
  78. package/templates/core/scripts/mcp-server.ts +36 -0
  79. package/templates/core/templates/checklist-template.md +13 -0
  80. package/templates/core/templates/review-implementation-template.md +24 -0
  81. package/templates/core/templates/review-template.md +16 -0
  82. package/templates/core/validation/index.ts +136 -0
  83. package/templates/core/validation/policy.ts +91 -0
  84. package/templates/core/validation/registry.ts +61 -0
  85. package/templates/core/validation/schema-validator.ts +277 -0
  86. package/templates/core/validation/validators/add-route.ts +32 -0
  87. package/templates/core/validation/validators/checklist.ts +48 -0
  88. package/templates/core/validation/validators/handoff.ts +46 -0
  89. package/templates/core/validation/validators/hitl.ts +22 -0
  90. package/templates/core/validation/validators/index.ts +52 -0
  91. package/templates/core/validation/validators/plan.ts +20 -0
  92. package/templates/core/validation/validators/report.ts +16 -0
  93. package/templates/core/validation/validators/review.ts +30 -0
  94. package/templates/core/validation/validators/spec.ts +25 -0
  95. package/templates/core/validation/validators/tasks.ts +40 -0
  96. package/templates/core/validation/validators/types.ts +32 -0
  97. package/templates/core/vocabulary/add-governance-vocabulary.md +18 -0
  98. package/templates/core/scripts/mcp-server/shared/dps-spec-ref.ts +0 -17
@@ -0,0 +1,217 @@
1
+ /*
2
+ * Gate → MetricSnapshot 幂等采证(Plan §3.1 方案 A1 / Spec §1 §GateMetric)
3
+ *
4
+ * 设计要点:
5
+ * 1. 与评分解耦:评分逻辑保持只读,采证在评分完成后单独提交(Review P0 #1 / Plan §2.1);
6
+ * 2. 幂等键:sourceRef = `{gate}:{planKeyword}:{runId}`,配合表级唯一键
7
+ * (repositoryRef, metricType, sourceRef) —— 同 runId 重放不产生重复快照;
8
+ * 3. fail-open:任何异常都不向上抛,返回 outcome="bypassed" + degradedReason,
9
+ * 由调用方保证评分照常返回(验收项「采证失败旁路」);
10
+ * 4. 失败路径审计密度不低于成功路径(ADD-6):buildGateCaptureDetail 对三种 outcome
11
+ * 返回同构字段集。
12
+ */
13
+ import type { AddMetricSnapshotRow, TableDelegate } from "../../db-types.js"
14
+ import { AddMetricSnapshotRowSchema, validatedDelegate } from "../../db-types.js"
15
+ import { MemoryError } from "../domain/errors.js"
16
+ import { createHash } from "node:crypto"
17
+
18
+ export type GateKind = "check_dps" | "check_rahs"
19
+ export type GateWriteOutcome = "written" | "skipped_duplicate" | "bypassed"
20
+
21
+ /** 门禁指标类型字面量(Spec §10 契约登记表) */
22
+ export const GATE_METRIC_TYPE = {
23
+ check_dps: "DPS_TOTAL",
24
+ check_rahs: "RAHS_TOTAL",
25
+ latency: "GATE_LATENCY_MS",
26
+ } as const
27
+
28
+ /** 采证耗时增量上限(ms)——验收项「工具响应耗时增量 ≤50ms」 */
29
+ export const GATE_CAPTURE_TOLERANCE_MS = 50
30
+
31
+ export interface GateMetricWriteInput {
32
+ gate: GateKind
33
+ planKeyword: string
34
+ runId: string
35
+ metricType?: string
36
+ score: number
37
+ repository: string
38
+ dimensionScores?: Record<string, number>
39
+ baseline?: number | null
40
+ unit?: string | null
41
+ specRef?: string | null
42
+ commitSha?: string | null
43
+ }
44
+
45
+ export interface GateMetricWriteResult {
46
+ outcome: GateWriteOutcome
47
+ sourceRef: string
48
+ metricId?: string
49
+ degradedReason?: string
50
+ elapsedMs: number
51
+ }
52
+
53
+ export interface GateWriterDeps {
54
+ /** 采证目标表;测试可注入内存实现 */
55
+ metricDb: Pick<TableDelegate<AddMetricSnapshotRow>, "findUnique" | "upsert">
56
+ /** 时钟注入(测试用),默认 Date.now */
57
+ now?: () => number
58
+ }
59
+
60
+ /**
61
+ * 幂等键构造。空值归一为 "-",避免出现 `::` 与前缀碰撞。
62
+ */
63
+ export function buildGateSourceRef(gate: GateKind, planKeyword: string, runId: string): string {
64
+ const norm = (v: string | undefined | null): string => {
65
+ const t = (v ?? "").trim()
66
+ return t.length > 0 ? t : "-"
67
+ }
68
+ return `${gate}:${norm(planKeyword)}:${norm(runId)}`
69
+ }
70
+
71
+ /**
72
+ * 由被评分产物的内容派生 runId。
73
+ *
74
+ * 语义:同一份文档重复跑同一门禁 → 同一幂等键 → 判定为重复采证(skipped_duplicate);
75
+ * 文档内容变化 → 新幂等键 → 产生新的证据快照。这样「重放」不会因为时间戳不同而假性去重,
76
+ * 也不需要调用方额外传参(门禁工具入参契约保持不变)。
77
+ */
78
+ export function deriveGateRunId(gate: GateKind, planKeyword: string, seed: string): string {
79
+ const digest = createHash("sha256")
80
+ .update(`${gate}|${planKeyword}|${seed}`)
81
+ .digest("hex")
82
+ return `auto-${digest.slice(0, 12)}`
83
+ }
84
+
85
+ /** 唯一键参数(与 prisma @@unique([repositoryRef, metricType, sourceRef]) 对应) */
86
+ function uniqueWhere(repositoryRef: string, metricType: string, sourceRef: string) {
87
+ return { repositoryRef_metricType_sourceRef: { repositoryRef, metricType, sourceRef } }
88
+ }
89
+
90
+ function isUniqueViolation(error: unknown): boolean {
91
+ const code = (error as { code?: string } | null)?.code
92
+ return code === "P2002"
93
+ }
94
+
95
+ /**
96
+ * 写入一次门禁指标快照。**不抛异常**:失败返回 bypassed,由调用方继续返回评分。
97
+ */
98
+ export async function writeGateMetric(
99
+ input: GateMetricWriteInput,
100
+ deps: GateWriterDeps,
101
+ ): Promise<GateMetricWriteResult> {
102
+ const now = deps.now ?? Date.now
103
+ const startedAt = now()
104
+ const metricType = input.metricType ?? GATE_METRIC_TYPE[input.gate]
105
+ const sourceRef = buildGateSourceRef(input.gate, input.planKeyword, input.runId)
106
+ const elapsed = () => Math.max(0, now() - startedAt)
107
+
108
+ try {
109
+ if (!input.repository || input.repository.trim().length === 0) {
110
+ throw new MemoryError("ERR_REPOSITORY_MISMATCH", "repositoryRef 为空,拒绝采证")
111
+ }
112
+ if (!Number.isFinite(input.score)) {
113
+ throw new MemoryError("ERR_INVARIANT", "score 非有限数值,拒绝采证")
114
+ }
115
+
116
+ const existing = await deps.metricDb.findUnique({
117
+ where: uniqueWhere(input.repository, metricType, sourceRef),
118
+ })
119
+ if (existing) {
120
+ // 同 runId 重放:既有快照是证据,不覆写
121
+ return { outcome: "skipped_duplicate", metricId: existing.id, sourceRef, elapsedMs: elapsed() }
122
+ }
123
+
124
+ const created = await deps.metricDb.upsert({
125
+ where: uniqueWhere(input.repository, metricType, sourceRef),
126
+ create: {
127
+ repositoryRef: input.repository,
128
+ metricType,
129
+ value: input.score,
130
+ baseline: input.baseline ?? null,
131
+ delta: input.baseline == null ? null : input.score - input.baseline,
132
+ unit: input.unit ?? null,
133
+ planKeyword: input.planKeyword || null,
134
+ specRef: input.specRef ?? null,
135
+ commitSha: input.commitSha ?? null,
136
+ sourceRef,
137
+ metadata: {
138
+ gate: input.gate,
139
+ runId: input.runId,
140
+ dimensionScores: input.dimensionScores ?? null,
141
+ capture: "gate-writer",
142
+ },
143
+ measuredAt: new Date(now()),
144
+ },
145
+ // 幂等:并发下由唯一键裁定,update 不改写既有证据
146
+ update: {},
147
+ })
148
+ return { outcome: "written", metricId: created.id, sourceRef, elapsedMs: elapsed() }
149
+ } catch (error) {
150
+ if (isUniqueViolation(error)) {
151
+ return { outcome: "skipped_duplicate", sourceRef, elapsedMs: elapsed() }
152
+ }
153
+ // fail-open:采证失败绝不阻塞门禁返回评分
154
+ return {
155
+ outcome: "bypassed",
156
+ sourceRef,
157
+ degradedReason: error instanceof Error ? error.message : String(error),
158
+ elapsedMs: elapsed(),
159
+ }
160
+ }
161
+ }
162
+
163
+ /**
164
+ * 审计明细(ADD-6:成功/去重/旁路三态返回同构字段集,失败路径不得更稀疏)。
165
+ */
166
+ export function buildGateCaptureDetail(
167
+ input: GateMetricWriteInput,
168
+ result: GateMetricWriteResult,
169
+ ): Record<string, unknown> {
170
+ return {
171
+ gate: input.gate,
172
+ planKeyword: input.planKeyword,
173
+ runId: input.runId,
174
+ metricType: input.metricType ?? GATE_METRIC_TYPE[input.gate],
175
+ sourceRef: result.sourceRef,
176
+ outcome: result.outcome,
177
+ metricId: result.metricId ?? null,
178
+ score: input.score,
179
+ dimensionCount: input.dimensionScores ? Object.keys(input.dimensionScores).length : 0,
180
+ baseline: input.baseline ?? null,
181
+ degradedReason: result.degradedReason ?? null,
182
+ elapsedMs: result.elapsedMs,
183
+ overTolerance: result.elapsedMs > GATE_CAPTURE_TOLERANCE_MS,
184
+ }
185
+ }
186
+
187
+ /** 单行摘要:供 MCP 工具在响应末尾追加(三态均返回非空文本) */
188
+ export function formatGateCaptureSummary(detail: Record<string, unknown>): string {
189
+ const bits = [
190
+ `结果: ${String(detail.outcome)}`,
191
+ `幂等键: ${String(detail.sourceRef)}`,
192
+ `耗时: ${String(detail.elapsedMs)}ms`,
193
+ ]
194
+ if (typeof detail.metricId === "string" && detail.metricId.length > 0) {
195
+ bits.push(`metricId: ${detail.metricId}`)
196
+ }
197
+ if (typeof detail.degradedReason === "string" && detail.degradedReason.length > 0) {
198
+ bits.push(`旁路原因: ${detail.degradedReason}(评分不受影响)`)
199
+ }
200
+ if (detail.overTolerance === true) {
201
+ bits.push(`⚠️ 超出 ${GATE_CAPTURE_TOLERANCE_MS}ms 容差`)
202
+ }
203
+ return bits.join(" | ")
204
+ }
205
+
206
+ /**
207
+ * 便捷装配:把 MCP 工具侧的 prisma delegate 包装为通过行校验的采证依赖。
208
+ */
209
+ export function createGateWriterDeps(rawMetricDelegate: unknown): GateWriterDeps {
210
+ return {
211
+ metricDb: validatedDelegate<AddMetricSnapshotRow>(
212
+ rawMetricDelegate,
213
+ AddMetricSnapshotRowSchema,
214
+ "AddMetricSnapshot",
215
+ ),
216
+ }
217
+ }
@@ -0,0 +1,69 @@
1
+ /*
2
+ * 阶段词识别(Plan §9.1 / Spec §2 §DeterministicRecall)
3
+ *
4
+ * 本模块是**纯函数**:零运行时依赖、零 IO、零 DB。
5
+ * 存在意义:governance Hook(同步 spawn、≤200ms 预算、无 DB 访问权限)需要识别
6
+ * 「现在处于哪个 ADD 阶段」,以便产出确定性召回提示;而真正的召回落点在有 DB 的
7
+ * MCP 工具侧(gate-recall.ts)。两者共用同一份阶段白名单,避免 Hook 与工具侧漂移。
8
+ */
9
+
10
+ export const RECALL_STAGES = ["plan-start", "spec-start", "dps", "rahs", "handoff"] as const
11
+ export type RecallStage = (typeof RECALL_STAGES)[number]
12
+
13
+ export function isRecallStage(value: string): value is RecallStage {
14
+ return (RECALL_STAGES as readonly string[]).includes(value)
15
+ }
16
+
17
+ export const STAGE_LABEL: Record<RecallStage, string> = {
18
+ "plan-start": "Plan 起草",
19
+ "spec-start": "Spec 起草",
20
+ dps: "DPS 门禁",
21
+ rahs: "RAHS 门禁",
22
+ handoff: "Handoff 交接",
23
+ }
24
+
25
+ /**
26
+ * 阶段词模式。按特异性从高到低排列——先命中者为准(handoff/rahs/dps 比 plan 更具体,
27
+ * 避免「交接 plan」被判成 plan-start)。
28
+ */
29
+ const STAGE_PATTERNS: ReadonlyArray<{ stage: RecallStage; re: RegExp }> = [
30
+ { stage: "handoff", re: /handoff|交接(手册|文档|说明)?|交接给/i },
31
+ { stage: "rahs", re: /rahs|注意力漂移|执行健康度/i },
32
+ { stage: "dps", re: /dps|文档质量闸门|质量闸门|门禁(评分|检查)?/i },
33
+ { stage: "spec-start", re: /(生成|写|新建|起草|补)\s*(spec|规格|三元组)|specs?\s*三元组|WHEN-?THEN/i },
34
+ { stage: "plan-start", re: /(生成|写|新建|起草|补)\s*(plan|计划|方案)|plan\s*阶段|规划阶段/i },
35
+ ]
36
+
37
+ /** 识别提示词所属的 ADD 阶段;无法判定返回 null(不猜测) */
38
+ export function detectRecallStage(prompt: string): RecallStage | null {
39
+ if (!prompt) return null
40
+ for (const { stage, re } of STAGE_PATTERNS) {
41
+ if (re.test(prompt)) return stage
42
+ }
43
+ return null
44
+ }
45
+
46
+ export type RecallModeLike = "off" | "shadow" | "inject"
47
+
48
+ /**
49
+ * 阶段召回提示文本(Hook 侧输出)。
50
+ * - off:返回 null(不提示、不召回)
51
+ * - shadow:提示可显式调用,并说明 Hook 不注入
52
+ * - inject:提示调用后将注入上下文
53
+ */
54
+ export function buildStageRecallHint(
55
+ stage: RecallStage,
56
+ mode: RecallModeLike,
57
+ ): string | null {
58
+ if (mode === "off") return null
59
+ const label = STAGE_LABEL[stage]
60
+ const tail =
61
+ mode === "shadow"
62
+ ? "(当前 shadow 模式:Hook 不注入上下文,召回结果需显式消费)"
63
+ : "(当前 inject 模式:调用后上下文将被注入)"
64
+ return (
65
+ `[Memory] 检测到${label}阶段 → 建议调用 ` +
66
+ `recall_memory({ stage: "${stage}", query: <本阶段意图>, planKeyword: <Plan 关键词> }) ` +
67
+ `获取受治理的历史上下文(含来源与评分)。${tail}\n`
68
+ )
69
+ }
@@ -0,0 +1,89 @@
1
+ /*
2
+ * Token-budget 上下文构建(Plan §7.4)
3
+ *
4
+ * - 先保留强约束和高优先级决策,再选择 failure/pitfall/convention
5
+ * - 同源/语义近重复项合并为一条带多个 sourceRef 的条目
6
+ * - 超预算时返回被排除项及原因,不静默截断关键约束
7
+ */
8
+ import { charBigrams, jaccardSimilarity } from "../domain/dedup.js"
9
+
10
+ /** 粗略 token 估算:CJK 按 1 token/字,其余按 4 字符/token */
11
+ export function estimateTokens(text: string): number {
12
+ let cjk = 0
13
+ let other = 0
14
+ for (const ch of text) {
15
+ // CJK 统一表意文字 + 平片假名 + 常用标点区
16
+ if (/[぀-ヿ㐀-䶿一-鿿豈-﫿＀-￯]/.test(ch)) cjk++
17
+ else other++
18
+ }
19
+ return cjk + Math.ceil(other / 4)
20
+ }
21
+
22
+ export interface BudgetItem {
23
+ memoryId: string
24
+ kind: string
25
+ finalScore: number
26
+ tokens: number
27
+ content: string
28
+ sourceRefs: string[]
29
+ }
30
+
31
+ export interface BudgetResult {
32
+ selected: BudgetItem[]
33
+ excluded: { memoryId: string; reason: string }[]
34
+ usedTokens: number
35
+ }
36
+
37
+ const KIND_PRIORITY: Record<string, number> = {
38
+ CONSTRAINT: 0,
39
+ DECISION: 1,
40
+ FAILURE: 2,
41
+ PITFALL: 3,
42
+ CONVENTION: 4,
43
+ LESSON: 5,
44
+ PATTERN: 5,
45
+ FACT: 6,
46
+ HANDOFF_DIGEST: 7,
47
+ HYPOTHESIS: 8,
48
+ }
49
+
50
+ /** 近重复合并阈值(字符二元组 Jaccard) */
51
+ const DUP_THRESHOLD = 0.75
52
+
53
+ export function buildContext(items: BudgetItem[], maxTokens: number): BudgetResult {
54
+ // 排序:kind 优先级 → 分数
55
+ const sorted = [...items].sort((a, b) => {
56
+ const ka = KIND_PRIORITY[a.kind] ?? 9
57
+ const kb = KIND_PRIORITY[b.kind] ?? 9
58
+ if (ka !== kb) return ka - kb
59
+ return b.finalScore - a.finalScore
60
+ })
61
+
62
+ const selected: BudgetItem[] = []
63
+ const excluded: { memoryId: string; reason: string }[] = []
64
+ const selectedVecs: Set<string>[] = []
65
+ let used = 0
66
+
67
+ for (const item of sorted) {
68
+ // 近重复合并:合并到已选项(追加 sourceRef),不重复占预算
69
+ const vec = charBigrams(item.content)
70
+ const dupIdx = selectedVecs.findIndex((v) => jaccardSimilarity(v, vec) >= DUP_THRESHOLD)
71
+ if (dupIdx >= 0) {
72
+ selected[dupIdx].sourceRefs = [...new Set([...selected[dupIdx].sourceRefs, ...item.sourceRefs])]
73
+ excluded.push({ memoryId: item.memoryId, reason: `近重复合并进 ${selected[dupIdx].memoryId}` })
74
+ continue
75
+ }
76
+ if (used + item.tokens > maxTokens) {
77
+ // 强制约束不可静默截断:CONSTRAINT 超额时显式标记
78
+ excluded.push({
79
+ memoryId: item.memoryId,
80
+ reason: item.kind === "CONSTRAINT" ? "超预算(强制约束,调用方必须知悉)" : "超预算",
81
+ })
82
+ continue
83
+ }
84
+ selected.push(item)
85
+ selectedVecs.push(vec)
86
+ used += item.tokens
87
+ }
88
+ return { selected, excluded, usedTokens: used }
89
+ }
@@ -0,0 +1,139 @@
1
+ /*
2
+ * PostgreSQL FTS 适配器(Plan §8.2,§17-3 定案:pg_trgm 支持 CJK)
3
+ *
4
+ * 双通道候选(RRF 融合用):
5
+ * - 通道 A:pg_trgm similarity(`%` 操作符 + similarity() 排序)
6
+ * - 通道 B:websearch_to_tsquery('simple') 全文匹配(拉丁词强、CJK 弱,作为补充信号)
7
+ * 短查询(<3 字符)pg_trgm 命中率低 → ILIKE 兜底(Plan §17-3 衍生意图一致)
8
+ *
9
+ * 降级:扩展/索引缺失 → health() 报 degraded,调用方切换受限结构化查询。
10
+ */
11
+ import type { LexicalSearchAdapter, RankedId, RecallFilter, RawQuerier, ComponentHealth } from "../types.js"
12
+ import { extractQueryTerms } from "../query-terms.js"
13
+
14
+ /**
15
+ * 通道 C:词项重叠计分(拉丁词 + CJK 二元组的 ILIKE 命中比例)
16
+ * 弥补 trigram 子串语义对释义查询的召回不足(如 "新端口怎么申请" → 命中 "新增端口")
17
+ */
18
+ function channelC(query: string, filter: RecallFilter, limit: number): { sql: string; params: unknown[] } | null {
19
+ const terms = extractQueryTerms(query)
20
+ if (terms.length === 0) return null
21
+ // 参数布局:$1 repo, $2 statuses, $3 now, $4 kinds, $5..$(4+n) terms, $(5+n) limit
22
+ const expr = terms
23
+ .map((_, i) => `CASE WHEN topic ILIKE '%' || $${5 + i} || '%' OR content ILIKE '%' || $${5 + i} || '%' THEN 1 ELSE 0 END`)
24
+ .join(" + ")
25
+ const sql = `
26
+ SELECT * FROM (
27
+ SELECT id, (${expr})::float / ${terms.length} AS score
28
+ FROM "AddMemory"
29
+ WHERE "repositoryRef" = $1
30
+ AND "status"::text = ANY($2)
31
+ AND ("validUntil" IS NULL OR "validUntil" > $3)
32
+ AND ($4::text[] IS NULL OR "kind"::text = ANY($4))
33
+ ) t WHERE score > 0
34
+ ORDER BY score DESC
35
+ LIMIT $${5 + terms.length}
36
+ `
37
+ return {
38
+ sql,
39
+ params: [filter.repositoryRef, [...filter.statuses], filter.now,
40
+ filter.kinds && filter.kinds.length > 0 ? [...filter.kinds] : null,
41
+ ...terms, limit],
42
+ }
43
+ }
44
+
45
+ const CHANNEL_A_SQL = `
46
+ SELECT id,
47
+ GREATEST(
48
+ similarity(topic || ' ' || content, $1),
49
+ CASE WHEN topic ILIKE '%' || $1 || '%' OR content ILIKE '%' || $1 || '%' THEN 0.01 ELSE 0 END
50
+ ) AS score
51
+ FROM "AddMemory"
52
+ WHERE "repositoryRef" = $2
53
+ AND "status"::text = ANY($3)
54
+ AND ("validUntil" IS NULL OR "validUntil" > $4)
55
+ AND ($5::text[] IS NULL OR "kind"::text = ANY($5))
56
+ AND (topic % $1 OR content % $1
57
+ OR topic ILIKE '%' || $1 || '%' OR content ILIKE '%' || $1 || '%')
58
+ ORDER BY score DESC
59
+ LIMIT $6
60
+ `
61
+
62
+ const CHANNEL_B_SQL = `
63
+ SELECT id, ts_rank(to_tsvector('simple', topic || ' ' || content), websearch_to_tsquery('simple', $1)) AS score
64
+ FROM "AddMemory"
65
+ WHERE "repositoryRef" = $2
66
+ AND "status"::text = ANY($3)
67
+ AND ("validUntil" IS NULL OR "validUntil" > $4)
68
+ AND ($5::text[] IS NULL OR "kind"::text = ANY($5))
69
+ AND to_tsvector('simple', topic || ' ' || content) @@ websearch_to_tsquery('simple', $1)
70
+ ORDER BY score DESC
71
+ LIMIT $6
72
+ `
73
+
74
+ function params(query: string, filter: RecallFilter, limit: number): unknown[] {
75
+ return [
76
+ query,
77
+ filter.repositoryRef,
78
+ [...filter.statuses],
79
+ filter.now,
80
+ filter.kinds && filter.kinds.length > 0 ? [...filter.kinds] : null,
81
+ limit,
82
+ ]
83
+ }
84
+
85
+ async function runChannel(
86
+ q: RawQuerier,
87
+ sql: string,
88
+ query: string,
89
+ filter: RecallFilter,
90
+ limit: number,
91
+ ): Promise<RankedId[]> {
92
+ const rows = await q.query<{ id: string; score: number | string }>(sql, params(query, filter, limit))
93
+ return rows.map((r, i) => ({ memoryId: r.id, rank: i + 1, score: Number(r.score) }))
94
+ }
95
+
96
+ export function createPgFtsAdapter(q: RawQuerier): LexicalSearchAdapter & {
97
+ searchChannels(query: string, filter: RecallFilter, limit: number): Promise<RankedId[][]>
98
+ } {
99
+ return {
100
+ id: "pg-trgm",
101
+ async search(query, filter, limit) {
102
+ const channels = await this.searchChannels(query, filter, limit)
103
+ return channels[0] ?? []
104
+ },
105
+ async searchChannels(query, filter, limit) {
106
+ const a = await runChannel(q, CHANNEL_A_SQL, query, filter, limit)
107
+ let b: RankedId[] = []
108
+ try {
109
+ b = await runChannel(q, CHANNEL_B_SQL, query, filter, limit)
110
+ } catch {
111
+ // tsquery 通道失败(如查询含特殊字符)不阻断主通道
112
+ }
113
+ let c: RankedId[] = []
114
+ const cc = channelC(query, filter, limit)
115
+ if (cc) {
116
+ const rows = await q.query<{ id: string; score: number | string }>(cc.sql, cc.params)
117
+ c = rows.map((r, i) => ({ memoryId: r.id, rank: i + 1, score: Number(r.score) }))
118
+ }
119
+ return [a, b, c]
120
+ },
121
+ async health(): Promise<ComponentHealth> {
122
+ try {
123
+ const ext = await q.query<{ count: number | string }>(
124
+ "SELECT COUNT(*)::int AS count FROM pg_extension WHERE extname = 'pg_trgm'", [])
125
+ if (Number(ext[0]?.count ?? 0) < 1) {
126
+ return { component: "pg-fts", status: "degraded", detail: "pg_trgm 扩展缺失" }
127
+ }
128
+ const idx = await q.query<{ count: number | string }>(
129
+ "SELECT COUNT(*)::int AS count FROM pg_indexes WHERE tablename = 'AddMemory' AND indexname LIKE '%trgm%'", [])
130
+ if (Number(idx[0]?.count ?? 0) < 2) {
131
+ return { component: "pg-fts", status: "degraded", detail: "trgm GIN 索引缺失,需 reindex" }
132
+ }
133
+ return { component: "pg-fts", status: "ok" }
134
+ } catch (e) {
135
+ return { component: "pg-fts", status: "unavailable", detail: e instanceof Error ? e.message : String(e) }
136
+ }
137
+ },
138
+ }
139
+ }
@@ -0,0 +1,29 @@
1
+ -- Agent Memory FTS 原生层(SQLite 后端)
2
+ -- Plan §8.3 + §17-3 定案:FTS5 trigram 分词器(CJK 友好,≥3 字符 n-gram 匹配;
3
+ -- 短于 3 字符的查询由 adapter 层回退 LIKE —— 见 retrieval/fts/sqlite.ts)
4
+ --
5
+ -- 说明:AddMemory 主键为 cuid 字符串,无法使用 FTS5 external-content 模式
6
+ -- (其要求 INTEGER rowid),故采用独立 FTS 表 + 触发器同步。
7
+ -- 幂等:全部 IF NOT EXISTS,可重复应用。
8
+
9
+ CREATE VIRTUAL TABLE IF NOT EXISTS add_memory_fts USING fts5(
10
+ memory_id UNINDEXED,
11
+ topic,
12
+ content,
13
+ tokenize = 'trigram'
14
+ );
15
+
16
+ CREATE TRIGGER IF NOT EXISTS add_memory_fts_ai AFTER INSERT ON "AddMemory" BEGIN
17
+ INSERT INTO add_memory_fts(memory_id, topic, content)
18
+ VALUES (new.id, new.topic, new.content);
19
+ END;
20
+
21
+ CREATE TRIGGER IF NOT EXISTS add_memory_fts_au AFTER UPDATE ON "AddMemory" BEGIN
22
+ DELETE FROM add_memory_fts WHERE memory_id = old.id;
23
+ INSERT INTO add_memory_fts(memory_id, topic, content)
24
+ VALUES (new.id, new.topic, new.content);
25
+ END;
26
+
27
+ CREATE TRIGGER IF NOT EXISTS add_memory_fts_ad AFTER DELETE ON "AddMemory" BEGIN
28
+ DELETE FROM add_memory_fts WHERE memory_id = old.id;
29
+ END;
@@ -0,0 +1,106 @@
1
+ /*
2
+ * SQLite FTS5 适配器(Plan §8.3,trigram 分词器,schema 见同目录 sqlite-fts5.sql)
3
+ *
4
+ * trigram 限制:查询词 <3 字符无法产生匹配 → 由 LIKE 通道兜底。
5
+ * 双通道候选:A = FTS5 MATCH + bm25 排序;B = 词项重叠 LIKE 计分(覆盖短查询与释义查询)。
6
+ */
7
+ import type { LexicalSearchAdapter, RankedId, RecallFilter, RawQuerier, ComponentHealth } from "../types.js"
8
+ import { extractQueryTerms } from "../query-terms.js"
9
+
10
+ /** 通道 B:词项重叠计分(拉丁词 + CJK 二元组的 LIKE 命中比例),覆盖短查询与释义查询 */
11
+ function channelB(query: string, filter: RecallFilter, limit: number): { sql: string; params: unknown[] } | null {
12
+ const terms = extractQueryTerms(query)
13
+ if (terms.length === 0) return null
14
+ const expr = terms
15
+ .map(() => `CASE WHEN topic LIKE '%' || ? || '%' OR content LIKE '%' || ? || '%' THEN 1 ELSE 0 END`)
16
+ .join(" + ")
17
+ const statuses = filter.statuses.map((s) => `'${s}'`).join(",")
18
+ const kinds = filter.kinds && filter.kinds.length > 0
19
+ ? `kind IN (${filter.kinds.map((k) => `'${k}'`).join(",")})`
20
+ : "1=1"
21
+ const sql = `
22
+ SELECT * FROM (
23
+ SELECT id, (${expr}) * 1.0 / ${terms.length} AS score
24
+ FROM "AddMemory"
25
+ WHERE repositoryRef = ?
26
+ AND status IN (${statuses})
27
+ AND (validUntil IS NULL OR validUntil > ?)
28
+ AND (${kinds})
29
+ ) WHERE score > 0
30
+ ORDER BY score DESC
31
+ LIMIT ?
32
+ `
33
+ const termParams = terms.flatMap((t) => [t, t])
34
+ return { sql, params: [...termParams, filter.repositoryRef, filter.now.toISOString(), limit] }
35
+ }
36
+
37
+ /** 抽取可参与 trigram MATCH 的片段(连续 CJK/字母数字,≥3 字符),逐段加引号 */
38
+ export function buildTrigramQuery(raw: string): string | null {
39
+ const segments = raw.match(/[㐀-鿿぀-ヿA-Za-z0-9_]+/g) ?? []
40
+ const usable = segments.filter((s) => s.length >= 3).map((s) => `"${s}"`)
41
+ return usable.length > 0 ? usable.join(" OR ") : null
42
+ }
43
+
44
+ const CHANNEL_A_SQL = `
45
+ SELECT m.id AS id, bm25(add_memory_fts) AS score
46
+ FROM add_memory_fts f
47
+ JOIN "AddMemory" m ON m.id = f.memory_id
48
+ WHERE add_memory_fts MATCH ?
49
+ AND m.repositoryRef = ?
50
+ AND m.status IN (STATUS_PLACEHOLDER)
51
+ AND (m.validUntil IS NULL OR m.validUntil > ?)
52
+ AND (KIND_PLACEHOLDER)
53
+ ORDER BY score
54
+ LIMIT ?
55
+ `
56
+
57
+ function buildSql(template: string, filter: RecallFilter): string {
58
+ const statuses = filter.statuses.map((s) => `'${s}'`).join(",")
59
+ const kinds = filter.kinds && filter.kinds.length > 0
60
+ ? `m.kind IN (${filter.kinds.map((k) => `'${k}'`).join(",")})`
61
+ : "1=1"
62
+ return template
63
+ .replace("STATUS_PLACEHOLDER", statuses)
64
+ .replace("KIND_PLACEHOLDER", kinds)
65
+ }
66
+
67
+ export function createSqliteFtsAdapter(q: RawQuerier): LexicalSearchAdapter & {
68
+ searchChannels(query: string, filter: RecallFilter, limit: number): Promise<RankedId[][]>
69
+ } {
70
+ return {
71
+ id: "sqlite-fts5",
72
+ async search(query, filter, limit) {
73
+ const channels = await this.searchChannels(query, filter, limit)
74
+ return channels[0] ?? []
75
+ },
76
+ async searchChannels(query, filter, limit) {
77
+ let a: RankedId[] = []
78
+ const matchQ = buildTrigramQuery(query)
79
+ if (matchQ) {
80
+ const rows = await q.query<{ id: string; score: number }>(
81
+ buildSql(CHANNEL_A_SQL, filter), [matchQ, filter.repositoryRef, filter.now.toISOString(), limit],
82
+ )
83
+ a = rows.map((r, i) => ({ memoryId: r.id, rank: i + 1, score: -Number(r.score) }))
84
+ }
85
+ let b: RankedId[] = []
86
+ const cb = channelB(query, filter, limit)
87
+ if (cb) {
88
+ const rowsB = await q.query<{ id: string; score: number }>(cb.sql, cb.params)
89
+ b = rowsB.map((r, i) => ({ memoryId: r.id, rank: i + 1, score: Number(r.score) }))
90
+ }
91
+ return [a, b]
92
+ },
93
+ async health(): Promise<ComponentHealth> {
94
+ try {
95
+ const rows = await q.query<{ name: string }>(
96
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'add_memory_fts'", [])
97
+ if (rows.length < 1) {
98
+ return { component: "sqlite-fts", status: "unavailable", detail: "add_memory_fts 虚表缺失,需执行 sqlite-fts5.sql" }
99
+ }
100
+ return { component: "sqlite-fts", status: "ok" }
101
+ } catch (e) {
102
+ return { component: "sqlite-fts", status: "unavailable", detail: e instanceof Error ? e.message : String(e) }
103
+ }
104
+ },
105
+ }
106
+ }