add-coder 0.3.34 → 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 (147) hide show
  1. package/dist/index.js +21 -3
  2. package/package.json +3 -3
  3. package/templates/.add-coder-src-hash.json +144 -90
  4. package/templates/adapters/claude/hooks/doc-format-guard.mjs +172 -88
  5. package/templates/adapters/claude/hooks/notification.mjs +0 -4
  6. package/templates/adapters/claude/hooks/permission-denied.mjs +0 -4
  7. package/templates/adapters/claude/hooks/permission-gate.mjs +0 -4
  8. package/templates/adapters/claude/hooks/post-tool-use.mjs +59 -5
  9. package/templates/adapters/claude/hooks/pre-compact.mjs +0 -4
  10. package/templates/adapters/claude/hooks/pre-tool-use.mjs +0 -4
  11. package/templates/adapters/claude/hooks/prompt-submit.mjs +72 -4
  12. package/templates/adapters/claude/hooks/session-end.mjs +0 -4
  13. package/templates/adapters/claude/hooks/session-start.mjs +65 -5
  14. package/templates/adapters/claude/hooks/stop-check.mjs +6 -87
  15. package/templates/adapters/claude/hooks/stop-failure.mjs +0 -4
  16. package/templates/adapters/claude/hooks/subagent-guard.mjs +0 -4
  17. package/templates/adapters/claude/hooks/subagent-stop.mjs +0 -4
  18. package/templates/adapters/codex/hooks/doc-format-guard.mjs +172 -88
  19. package/templates/adapters/codex/hooks/notification.mjs +0 -4
  20. package/templates/adapters/codex/hooks/permission-gate.mjs +0 -4
  21. package/templates/adapters/codex/hooks/post-tool-failure.mjs +0 -4
  22. package/templates/adapters/codex/hooks/post-tool-use.mjs +59 -5
  23. package/templates/adapters/codex/hooks/pre-compact.mjs +0 -4
  24. package/templates/adapters/codex/hooks/pre-tool-use.mjs +0 -4
  25. package/templates/adapters/codex/hooks/prompt-submit.mjs +72 -4
  26. package/templates/adapters/codex/hooks/session-end.mjs +0 -4
  27. package/templates/adapters/codex/hooks/session-start.mjs +65 -5
  28. package/templates/adapters/codex/hooks/stop-check.mjs +6 -87
  29. package/templates/adapters/codex/hooks/subagent-guard.mjs +0 -4
  30. package/templates/adapters/codex/hooks/subagent-stop.mjs +0 -4
  31. package/templates/adapters/qoder/hooks/doc-format-guard.mjs +172 -88
  32. package/templates/adapters/qoder/hooks/notification.mjs +0 -4
  33. package/templates/adapters/qoder/hooks/permission-gate.mjs +0 -4
  34. package/templates/adapters/qoder/hooks/post-tool-failure.mjs +0 -4
  35. package/templates/adapters/qoder/hooks/post-tool-use.mjs +59 -5
  36. package/templates/adapters/qoder/hooks/pre-compact.mjs +0 -4
  37. package/templates/adapters/qoder/hooks/pre-tool-use.mjs +0 -4
  38. package/templates/adapters/qoder/hooks/prompt-submit.mjs +72 -4
  39. package/templates/adapters/qoder/hooks/review-checklist.mjs +0 -4
  40. package/templates/adapters/qoder/hooks/session-end.mjs +0 -4
  41. package/templates/adapters/qoder/hooks/session-start.mjs +67 -5
  42. package/templates/adapters/qoder/hooks/stop-check.mjs +6 -87
  43. package/templates/adapters/qoder/hooks/subagent-guard.mjs +0 -4
  44. package/templates/adapters/qoder/hooks/subagent-stop.mjs +0 -4
  45. package/templates/adapters/trae/hooks/doc-format-guard.mjs +172 -88
  46. package/templates/adapters/trae/hooks/notification.mjs +0 -4
  47. package/templates/adapters/trae/hooks/permission-gate.mjs +0 -4
  48. package/templates/adapters/trae/hooks/post-tool-failure.mjs +0 -4
  49. package/templates/adapters/trae/hooks/post-tool-use.mjs +59 -5
  50. package/templates/adapters/trae/hooks/pre-compact.mjs +0 -4
  51. package/templates/adapters/trae/hooks/pre-tool-use.mjs +0 -4
  52. package/templates/adapters/trae/hooks/prompt-submit.mjs +72 -4
  53. package/templates/adapters/trae/hooks/session-end.mjs +0 -4
  54. package/templates/adapters/trae/hooks/session-start.mjs +65 -5
  55. package/templates/adapters/trae/hooks/stop-check.mjs +6 -87
  56. package/templates/adapters/trae/hooks/subagent-guard.mjs +0 -4
  57. package/templates/adapters/trae/hooks/subagent-stop.mjs +0 -4
  58. package/templates/adapters/vscode/hooks/doc-format-guard.mjs +172 -88
  59. package/templates/adapters/vscode/hooks/notification.mjs +0 -4
  60. package/templates/adapters/vscode/hooks/permission-gate.mjs +0 -4
  61. package/templates/adapters/vscode/hooks/post-tool-failure.mjs +0 -4
  62. package/templates/adapters/vscode/hooks/post-tool-use.mjs +59 -5
  63. package/templates/adapters/vscode/hooks/pre-compact.mjs +0 -4
  64. package/templates/adapters/vscode/hooks/pre-tool-use.mjs +0 -4
  65. package/templates/adapters/vscode/hooks/prompt-submit.mjs +72 -4
  66. package/templates/adapters/vscode/hooks/session-end.mjs +0 -4
  67. package/templates/adapters/vscode/hooks/session-start.mjs +65 -5
  68. package/templates/adapters/vscode/hooks/stop-check.mjs +6 -87
  69. package/templates/adapters/vscode/hooks/subagent-guard.mjs +0 -4
  70. package/templates/adapters/vscode/hooks/subagent-stop.mjs +0 -4
  71. package/templates/core/governance/doc-format-guard.ts +29 -112
  72. package/templates/core/governance/post-tool-router.ts +33 -1
  73. package/templates/core/governance/prompt-router.ts +47 -0
  74. package/templates/core/governance/rules.ts +0 -4
  75. package/templates/core/governance/session-start-guard.ts +48 -1
  76. package/templates/core/governance/stop-router.ts +7 -101
  77. package/templates/core/prisma/add.prisma +203 -0
  78. package/templates/core/scripts/db-ensure.sh +61 -2
  79. package/templates/core/scripts/mcp-server/shared/db-types.ts +119 -0
  80. package/templates/core/scripts/mcp-server/shared/hitl-create-policy.ts +27 -0
  81. package/templates/core/scripts/mcp-server/shared/hitl-proposal-content.ts +110 -0
  82. package/templates/core/scripts/mcp-server/shared/hitl-widget-instance.ts +85 -0
  83. package/templates/core/scripts/mcp-server/shared/memory/calibration/batch-fit.ts +250 -0
  84. package/templates/core/scripts/mcp-server/shared/memory/calibration/feedback-stats.ts +101 -0
  85. package/templates/core/scripts/mcp-server/shared/memory/calibration/unit-state.ts +224 -0
  86. package/templates/core/scripts/mcp-server/shared/memory/domain/conflicts.ts +59 -0
  87. package/templates/core/scripts/mcp-server/shared/memory/domain/dedup.ts +45 -0
  88. package/templates/core/scripts/mcp-server/shared/memory/domain/errors.ts +33 -0
  89. package/templates/core/scripts/mcp-server/shared/memory/domain/handoff-digest.ts +92 -0
  90. package/templates/core/scripts/mcp-server/shared/memory/domain/metric-candidate.ts +79 -0
  91. package/templates/core/scripts/mcp-server/shared/memory/domain/scope.ts +94 -0
  92. package/templates/core/scripts/mcp-server/shared/memory/domain/secrets.ts +50 -0
  93. package/templates/core/scripts/mcp-server/shared/memory/domain/state-machine.ts +90 -0
  94. package/templates/core/scripts/mcp-server/shared/memory/embedding/index.ts +117 -0
  95. package/templates/core/scripts/mcp-server/shared/memory/embedding/local-onnx.ts +105 -0
  96. package/templates/core/scripts/mcp-server/shared/memory/embedding/openai-compatible.ts +87 -0
  97. package/templates/core/scripts/mcp-server/shared/memory/jobs/consolidation.ts +226 -0
  98. package/templates/core/scripts/mcp-server/shared/memory/jobs/evidence-collector.ts +153 -0
  99. package/templates/core/scripts/mcp-server/shared/memory/jobs/snapshot.ts +114 -0
  100. package/templates/core/scripts/mcp-server/shared/memory/metrics/gate-recall.ts +134 -0
  101. package/templates/core/scripts/mcp-server/shared/memory/metrics/gate-writer.ts +217 -0
  102. package/templates/core/scripts/mcp-server/shared/memory/metrics/stage-words.ts +69 -0
  103. package/templates/core/scripts/mcp-server/shared/memory/retrieval/context-builder.ts +89 -0
  104. package/templates/core/scripts/mcp-server/shared/memory/retrieval/fts/pg.ts +139 -0
  105. package/templates/core/scripts/mcp-server/shared/memory/retrieval/fts/sqlite-fts5.sql +29 -0
  106. package/templates/core/scripts/mcp-server/shared/memory/retrieval/fts/sqlite.ts +106 -0
  107. package/templates/core/scripts/mcp-server/shared/memory/retrieval/fusion.ts +43 -0
  108. package/templates/core/scripts/mcp-server/shared/memory/retrieval/pipeline.ts +285 -0
  109. package/templates/core/scripts/mcp-server/shared/memory/retrieval/query-terms.ts +31 -0
  110. package/templates/core/scripts/mcp-server/shared/memory/retrieval/recall-writer.ts +87 -0
  111. package/templates/core/scripts/mcp-server/shared/memory/retrieval/reranker.ts +116 -0
  112. package/templates/core/scripts/mcp-server/shared/memory/retrieval/types.ts +52 -0
  113. package/templates/core/scripts/mcp-server/shared/memory/retrieval/vector/pgvector.ts +143 -0
  114. package/templates/core/scripts/mcp-server/shared/memory/retrieval/vector/sqlite-vec.ts +118 -0
  115. package/templates/core/scripts/mcp-server/shared/memory/switches.ts +39 -0
  116. package/templates/core/scripts/mcp-server/shared/review-files.ts +22 -0
  117. package/templates/core/scripts/mcp-server/shared/runtime-freshness.ts +235 -0
  118. package/templates/core/scripts/mcp-server/tools/gateway/check_dps.ts +41 -3
  119. package/templates/core/scripts/mcp-server/tools/gateway/check_rahs.ts +40 -1
  120. package/templates/core/scripts/mcp-server/tools/gateway/check_spec_sync.ts +2 -2
  121. package/templates/core/scripts/mcp-server/tools/hitl.ts +108 -42
  122. package/templates/core/scripts/mcp-server/tools/index.ts +7 -1
  123. package/templates/core/scripts/mcp-server/tools/memory-compat.ts +258 -0
  124. package/templates/core/scripts/mcp-server/tools/memory.ts +654 -0
  125. package/templates/core/scripts/mcp-server/tools/plan.ts +8 -3
  126. package/templates/core/scripts/mcp-server/tools/review.ts +10 -7
  127. package/templates/core/scripts/mcp-server.ts +36 -0
  128. package/templates/core/templates/checklist-template.md +13 -0
  129. package/templates/core/templates/review-implementation-template.md +24 -0
  130. package/templates/core/templates/review-template.md +16 -0
  131. package/templates/core/validation/index.ts +136 -0
  132. package/templates/core/validation/policy.ts +91 -0
  133. package/templates/core/validation/registry.ts +61 -0
  134. package/templates/core/validation/schema-validator.ts +277 -0
  135. package/templates/core/validation/validators/add-route.ts +32 -0
  136. package/templates/core/validation/validators/checklist.ts +48 -0
  137. package/templates/core/validation/validators/handoff.ts +46 -0
  138. package/templates/core/validation/validators/hitl.ts +22 -0
  139. package/templates/core/validation/validators/index.ts +52 -0
  140. package/templates/core/validation/validators/plan.ts +20 -0
  141. package/templates/core/validation/validators/report.ts +16 -0
  142. package/templates/core/validation/validators/review.ts +30 -0
  143. package/templates/core/validation/validators/spec.ts +25 -0
  144. package/templates/core/validation/validators/tasks.ts +40 -0
  145. package/templates/core/validation/validators/types.ts +32 -0
  146. package/templates/core/vocabulary/add-governance-vocabulary.md +18 -0
  147. package/templates/core/scripts/mcp-server/shared/dps-spec-ref.ts +0 -17
@@ -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
+ }
@@ -0,0 +1,43 @@
1
+ /*
2
+ * RRF 融合(Plan §7.3):RRF(d) = Σ 1 / (k + rank_i(d))
3
+ * 避免强行比较 PG、SQLite 和不同 FTS 实现的原始分值。
4
+ * k 可配置化(写入 Recall.rankingVersion 的配置快照)。
5
+ */
6
+ import type { RankedId } from "./types.js"
7
+
8
+ export const DEFAULT_RRF_K = 10
9
+
10
+ /**
11
+ * 加权 RRF:score(d) = Σ_i w_i / (k + rank_i(d))
12
+ * 通道权重让「高精度通道」主导、低精度通道只做补充(Plan 轮 3 融合迭代)。
13
+ * weights 缺省为 1(退化为经典 RRF,保持向后兼容)。
14
+ */
15
+ export function rrfFuse(
16
+ lists: RankedId[][],
17
+ k: number = DEFAULT_RRF_K,
18
+ weights?: readonly number[],
19
+ ): Map<string, number> {
20
+ const scores = new Map<string, number>()
21
+ lists.forEach((list, i) => {
22
+ const w = weights?.[i] ?? 1
23
+ if (w <= 0) return
24
+ for (const item of list) {
25
+ const prev = scores.get(item.memoryId) ?? 0
26
+ scores.set(item.memoryId, prev + w / (k + item.rank))
27
+ }
28
+ })
29
+ return scores
30
+ }
31
+
32
+ /** 便捷入口:融合后按分数降序返回有序 id 列表 */
33
+ export function rrfRank(
34
+ lists: RankedId[][],
35
+ k: number = DEFAULT_RRF_K,
36
+ weights?: readonly number[],
37
+ ): RankedId[] {
38
+ const scores = rrfFuse(lists, k, weights)
39
+ return [...scores.entries()]
40
+ .map(([memoryId, score]) => ({ memoryId, rank: 0, score }))
41
+ .sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
42
+ .map((r, i) => ({ ...r, rank: i + 1 }))
43
+ }
@@ -0,0 +1,285 @@
1
+ /*
2
+ * Hybrid Recall 编排管线(Plan §7.1 十步流水线)
3
+ *
4
+ * 1. Repository/tenant 边界校验(调用方前置,此处防御性复核)
5
+ * 2. Lifecycle 过滤(默认仅 ACTIVE;诊断模式放行 STALE)
6
+ * 3. Scope 过滤与强约束匹配
7
+ * 4. FTS/BM25 候选生成(双后端 adapter)
8
+ * 5. Vector 候选生成(能力可用时;首版 none)
9
+ * 6. Reciprocal Rank Fusion
10
+ * 7. 治理重排
11
+ * 8. 冲突、重复和多样性处理
12
+ * 9. Token-budget 摘要与裁剪
13
+ * 10. 写入 AddMemoryRecall
14
+ */
15
+ import { DEFAULT_RECALL_STATUSES, DIAGNOSTIC_RECALL_STATUSES, type MemoryStatus } from "../domain/state-machine.js"
16
+ import { scopeApplies, type ScopeContext } from "../domain/scope.js"
17
+ import { rrfFuse, DEFAULT_RRF_K } from "./fusion.js"
18
+ import {
19
+ rerankOne,
20
+ DEFAULT_WEIGHTS,
21
+ RANKING_VERSION,
22
+ RANKING_VERSION_HYBRID,
23
+ RRF_SCORE_SCALE,
24
+ type RerankWeights,
25
+ } from "./reranker.js"
26
+ import { buildContext, estimateTokens } from "./context-builder.js"
27
+ import { writeRecallAudit, type RecallAuditStore } from "./recall-writer.js"
28
+ import type { LexicalSearchAdapter, RankedId, RecallFilter, RecalledMemory } from "./types.js"
29
+
30
+ /** 向量通道默认权重与候选预算(Plan 轮 3 融合迭代:低精度通道不与词法等权) */
31
+ export const DEFAULT_VECTOR_WEIGHT = 0.3
32
+ export const DEFAULT_VECTOR_TOP_K = 5
33
+
34
+ /** 管线需要的记忆行字段子集(与 AddMemoryRow 对齐) */
35
+ export interface MemoryRowLike {
36
+ id: string
37
+ kind: string
38
+ status: string
39
+ topic: string
40
+ content: string
41
+ summary: string | null
42
+ scopeType: string
43
+ scopeValue: string
44
+ repositoryRef: string
45
+ importance: number
46
+ confidence: number
47
+ validUntil: Date | null
48
+ supersedes?: { id: string }[]
49
+ }
50
+
51
+ export interface RecallPipelineInput {
52
+ query: string
53
+ stage: string
54
+ repositoryRef: string
55
+ scopeCtx: ScopeContext
56
+ maxTokens: number
57
+ kinds?: string[]
58
+ limit?: number
59
+ consumerRef?: string
60
+ /** 诊断模式:放行 STALE(带警告标记),默认 false */
61
+ diagnostic?: boolean
62
+ }
63
+
64
+ export interface RecallPipelineDeps {
65
+ lexical: LexicalSearchAdapter[]
66
+ /** Vector 候选(可选;首版不传即 FTS-only) */
67
+ vector?: { search(query: string, filter: RecallFilter, limit: number): Promise<RankedId[]> } | null
68
+ /** 向量通道权重(RRF 加权;默认 DEFAULT_VECTOR_WEIGHT —— 低精度通道不与词法等权) */
69
+ vectorWeight?: number
70
+ /** 向量候选预算(默认 DEFAULT_VECTOR_TOP_K —— 不按总 limit 灌入,避免稀释词法信号) */
71
+ vectorTopK?: number
72
+ /** 补位模式阈值:仅当词法候选数 < 该值时启用向量通道(缺省=始终补充) */
73
+ vectorFallbackThreshold?: number
74
+ fetchByIds(ids: string[]): Promise<MemoryRowLike[]>
75
+ fetchEvidenceSourceRefs(memoryIds: string[]): Promise<Map<string, string[]>>
76
+ audit?: RecallAuditStore | null
77
+ weights?: RerankWeights
78
+ rrfK?: number
79
+ rankingVersion?: string
80
+ degradedMode?: string
81
+ now?: Date
82
+ }
83
+
84
+ export interface RecallPipelineResult {
85
+ items: RecalledMemory[]
86
+ recallId: string | null
87
+ degradedMode: string | null
88
+ /** 实际参与融合的候选通道(Spec §7 RecallResultMeta) */
89
+ fusedChannels: ("lexical" | "vector")[]
90
+ excluded: { memoryId: string; reason: string }[]
91
+ candidateCount: number
92
+ injectedTokens: number
93
+ latencyMs: number
94
+ rankingVersion: string
95
+ }
96
+
97
+ export async function recallPipeline(
98
+ input: RecallPipelineInput,
99
+ deps: RecallPipelineDeps,
100
+ ): Promise<RecallPipelineResult> {
101
+ const start = Date.now()
102
+ const now = deps.now ?? new Date()
103
+ const statuses: readonly MemoryStatus[] = input.diagnostic ? DIAGNOSTIC_RECALL_STATUSES : DEFAULT_RECALL_STATUSES
104
+ const limit = input.limit ?? 20
105
+
106
+ // Step 1(防御性复核):repository 边界
107
+ if (input.scopeCtx.repository !== input.repositoryRef) {
108
+ throw new Error(`ERR_REPOSITORY_MISMATCH: scopeCtx.repository=${input.scopeCtx.repository} 与 repositoryRef 不一致`)
109
+ }
110
+
111
+ const filter: RecallFilter = {
112
+ repositoryRef: input.repositoryRef,
113
+ statuses,
114
+ scopeCtx: input.scopeCtx,
115
+ kinds: input.kinds,
116
+ now,
117
+ }
118
+
119
+ // Step 4/5:候选生成(FTS 多通道 + 可选 Vector)
120
+ const channelLists: RankedId[][] = []
121
+ let lexicalChannels = 0
122
+ for (const adapter of deps.lexical) {
123
+ const multi = adapter as LexicalSearchAdapter & {
124
+ searchChannels?(q: string, f: RecallFilter, l: number): Promise<RankedId[][]>
125
+ }
126
+ if (typeof multi.searchChannels === "function") {
127
+ const channels = await multi.searchChannels(input.query, filter, limit)
128
+ channelLists.push(...channels)
129
+ lexicalChannels += channels.length
130
+ } else {
131
+ channelLists.push(await adapter.search(input.query, filter, limit))
132
+ lexicalChannels += 1
133
+ }
134
+ }
135
+ let vectorUsed = false
136
+ const channelWeights: number[] = channelLists.map(() => 1) // 词法通道权重恒为 1
137
+ if (deps.vector) {
138
+ const lexicalCandidates = new Set(channelLists.flat().map((c) => c.memoryId)).size
139
+ const fallbackOnly = deps.vectorFallbackThreshold != null
140
+ const shouldUseVector = !fallbackOnly || lexicalCandidates < (deps.vectorFallbackThreshold as number)
141
+ try {
142
+ if (shouldUseVector) {
143
+ const vectorCandidates = await deps.vector.search(
144
+ input.query,
145
+ filter,
146
+ Math.min(deps.vectorTopK ?? DEFAULT_VECTOR_TOP_K, limit),
147
+ )
148
+ channelLists.push(vectorCandidates)
149
+ channelWeights.push(deps.vectorWeight ?? DEFAULT_VECTOR_WEIGHT)
150
+ vectorUsed = true
151
+ }
152
+ } catch {
153
+ // Vector 故障不阻塞 FTS(Plan §8.4)
154
+ }
155
+ }
156
+ const fusedChannels: ("lexical" | "vector")[] = [
157
+ ...(lexicalChannels > 0 ? (["lexical"] as const) : []),
158
+ ...(vectorUsed ? (["vector"] as const) : []),
159
+ ]
160
+ // rankingVersion:向量通道真正参与融合才记 v2;否则保持 v1(可被调用方显式覆盖)
161
+ const effectiveRankingVersion =
162
+ deps.rankingVersion ?? (vectorUsed ? RANKING_VERSION_HYBRID : RANKING_VERSION)
163
+
164
+ // Step 6:RRF 融合
165
+ const fused = rrfFuse(channelLists, deps.rrfK ?? DEFAULT_RRF_K, channelWeights)
166
+ const candidateIds = [...fused.keys()]
167
+ if (candidateIds.length === 0) {
168
+ const empty: RecallPipelineResult = {
169
+ items: [], recallId: null, degradedMode: deps.degradedMode ?? null,
170
+ fusedChannels,
171
+ excluded: [], candidateCount: 0, injectedTokens: 0,
172
+ latencyMs: Date.now() - start, rankingVersion: effectiveRankingVersion,
173
+ }
174
+ if (deps.audit) {
175
+ empty.recallId = await writeRecallAudit(deps.audit, {
176
+ repositoryRef: input.repositoryRef, query: input.query, stage: input.stage,
177
+ consumerRef: input.consumerRef, scopeContext: input.scopeCtx,
178
+ candidateIds: [], items: [], excluded: [],
179
+ rankingVersion: empty.rankingVersion, tokenBudget: input.maxTokens,
180
+ injectedTokens: 0, latencyMs: empty.latencyMs, degradedMode: empty.degradedMode ?? undefined,
181
+ })
182
+ }
183
+ return empty
184
+ }
185
+
186
+ // Step 2/3:取行 + lifecycle/scope 防御性复核(SQL 已过滤,此处兜底语义一致性)
187
+ const rows = await deps.fetchByIds(candidateIds)
188
+ const rowById = new Map(rows.map((r) => [r.id, r]))
189
+ const eligible = rows.filter((r) => {
190
+ if (r.repositoryRef !== input.repositoryRef) return false
191
+ if (!statuses.includes(r.status as MemoryStatus)) return false
192
+ if (r.validUntil && r.validUntil <= now) return false
193
+ return scopeApplies(
194
+ { type: r.scopeType as Parameters<typeof scopeApplies>[0]["type"], value: r.scopeValue },
195
+ input.scopeCtx,
196
+ )
197
+ })
198
+ const scopeExcluded = candidateIds
199
+ .filter((id) => rowById.has(id) && !eligible.some((r) => r.id === id))
200
+ .map((id) => ({ memoryId: id, reason: "lifecycle/scope/有效期过滤" }))
201
+
202
+ // Step 7/8:治理重排
203
+ const reranked = eligible.map((r) => ({
204
+ row: r,
205
+ rr: rerankOne({
206
+ memoryId: r.id,
207
+ // 相关性主导:RRF 放大到与治理 boost 可比量级(见 RRF_SCORE_SCALE 注释)
208
+ rrfScore: (fused.get(r.id) ?? 0) * RRF_SCORE_SCALE,
209
+ kind: r.kind,
210
+ status: r.status as MemoryStatus,
211
+ importance: r.importance,
212
+ confidence: r.confidence,
213
+ scopeType: r.scopeType,
214
+ scopeValue: r.scopeValue,
215
+ }, input.scopeCtx, deps.weights ?? DEFAULT_WEIGHTS),
216
+ }))
217
+
218
+ // Step 9:token 预算
219
+ const budgetItems = reranked.map(({ row, rr }) => ({
220
+ memoryId: row.id,
221
+ kind: row.kind,
222
+ finalScore: rr.finalScore,
223
+ tokens: estimateTokens(row.summary ?? row.content),
224
+ content: row.content,
225
+ sourceRefs: [] as string[],
226
+ _why: rr.whySelected,
227
+ _breakdown: rr.scoreBreakdown,
228
+ }))
229
+ const budget = buildContext(budgetItems, input.maxTokens)
230
+
231
+ const evidenceRefs = await deps.fetchEvidenceSourceRefs(budget.selected.map((s) => s.memoryId))
232
+ const items: RecalledMemory[] = budget.selected.map((s) => {
233
+ const row = rowById.get(s.memoryId)!
234
+ const extra = budgetItems.find((b) => b.memoryId === s.memoryId)!
235
+ return {
236
+ memoryId: s.memoryId,
237
+ kind: row.kind,
238
+ topic: row.topic,
239
+ content: row.content,
240
+ scope: { type: row.scopeType, value: row.scopeValue },
241
+ confidence: row.confidence,
242
+ importance: row.importance,
243
+ sourceRefs: evidenceRefs.get(s.memoryId) ?? s.sourceRefs,
244
+ whySelected: extra._why,
245
+ scoreBreakdown: extra._breakdown,
246
+ supersedes: (row.supersedes ?? []).map((x) => x.id),
247
+ }
248
+ })
249
+
250
+ const excluded = [...scopeExcluded, ...budget.excluded]
251
+ const latencyMs = Date.now() - start
252
+ const injectedTokens = budget.usedTokens
253
+
254
+ // Step 10:审计落库
255
+ let recallId: string | null = null
256
+ if (deps.audit) {
257
+ recallId = await writeRecallAudit(deps.audit, {
258
+ repositoryRef: input.repositoryRef,
259
+ query: input.query,
260
+ stage: input.stage,
261
+ consumerRef: input.consumerRef,
262
+ scopeContext: input.scopeCtx,
263
+ candidateIds,
264
+ items,
265
+ excluded,
266
+ rankingVersion: effectiveRankingVersion,
267
+ tokenBudget: input.maxTokens,
268
+ injectedTokens,
269
+ latencyMs,
270
+ degradedMode: deps.degradedMode,
271
+ })
272
+ }
273
+
274
+ return {
275
+ items,
276
+ recallId,
277
+ degradedMode: deps.degradedMode ?? null,
278
+ fusedChannels,
279
+ excluded,
280
+ candidateCount: candidateIds.length,
281
+ injectedTokens,
282
+ latencyMs,
283
+ rankingVersion: effectiveRankingVersion,
284
+ }
285
+ }