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,94 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Memory Scope 规则(Plan §7.2)
|
|
3
|
+
*
|
|
4
|
+
* 优先级:symbol > path > module > branch > repository > organization
|
|
5
|
+
* ORGANIZATION 首版禁用(写入拒绝,§17-7);PLAN/SPEC 只在对应流程或显式历史查询中参与。
|
|
6
|
+
*/
|
|
7
|
+
import { MemoryError } from "./errors.js"
|
|
8
|
+
|
|
9
|
+
export type MemoryScopeType =
|
|
10
|
+
| "ORGANIZATION" | "REPOSITORY" | "BRANCH" | "MODULE"
|
|
11
|
+
| "PATH" | "SYMBOL" | "PLAN" | "SPEC"
|
|
12
|
+
|
|
13
|
+
export interface Scope {
|
|
14
|
+
type: MemoryScopeType
|
|
15
|
+
value: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** 召回时的上下文坐标(由调用方从运行时环境组装) */
|
|
19
|
+
export interface ScopeContext {
|
|
20
|
+
repository: string
|
|
21
|
+
branch?: string
|
|
22
|
+
module?: string
|
|
23
|
+
paths?: string[]
|
|
24
|
+
symbols?: string[]
|
|
25
|
+
planKeyword?: string
|
|
26
|
+
specRef?: string
|
|
27
|
+
/** 显式历史查询时放行 PLAN/SPEC scope(默认 false) */
|
|
28
|
+
includePlanScope?: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const RANK: Record<MemoryScopeType, number> = {
|
|
32
|
+
SYMBOL: 7,
|
|
33
|
+
PATH: 6,
|
|
34
|
+
MODULE: 5,
|
|
35
|
+
BRANCH: 4,
|
|
36
|
+
REPOSITORY: 3,
|
|
37
|
+
PLAN: 2,
|
|
38
|
+
SPEC: 2,
|
|
39
|
+
ORGANIZATION: 1,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function scopeRank(t: MemoryScopeType): number {
|
|
43
|
+
return RANK[t]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 写入守卫:ORGANIZATION 首版禁用 */
|
|
47
|
+
export function assertScopeWritable(scope: Scope): void {
|
|
48
|
+
if (scope.type === "ORGANIZATION") {
|
|
49
|
+
throw new MemoryError("ERR_ORG_SCOPE_DISABLED")
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 判定一条 Memory 的 scope 在当前上下文是否有效(召回过滤用) */
|
|
54
|
+
export function scopeApplies(memScope: Scope, ctx: ScopeContext): boolean {
|
|
55
|
+
switch (memScope.type) {
|
|
56
|
+
case "ORGANIZATION":
|
|
57
|
+
return false // 首版禁用
|
|
58
|
+
case "REPOSITORY":
|
|
59
|
+
return memScope.value === ctx.repository
|
|
60
|
+
case "BRANCH":
|
|
61
|
+
return !!ctx.branch && ctx.branch === memScope.value
|
|
62
|
+
case "MODULE":
|
|
63
|
+
// module 语义:当前任一路径以该模块目录为前缀
|
|
64
|
+
return !!ctx.module && ctx.module === memScope.value ||
|
|
65
|
+
(ctx.paths ?? []).some((p) => p === memScope.value || p.startsWith(memScope.value + "/"))
|
|
66
|
+
case "PATH":
|
|
67
|
+
return (ctx.paths ?? []).some(
|
|
68
|
+
(p) => p === memScope.value || p.startsWith(memScope.value.replace(/\/?$/, "/")),
|
|
69
|
+
)
|
|
70
|
+
case "SYMBOL":
|
|
71
|
+
return (ctx.symbols ?? []).includes(memScope.value)
|
|
72
|
+
case "PLAN":
|
|
73
|
+
return !!ctx.includePlanScope && !!ctx.planKeyword && ctx.planKeyword === memScope.value
|
|
74
|
+
case "SPEC":
|
|
75
|
+
return !!ctx.includePlanScope && !!ctx.specRef && ctx.specRef === memScope.value
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* supersede 兼容性(Plan §4.4):新旧 Memory 的 repository/scope 必须兼容。
|
|
81
|
+
* 兼容 = 同类型同值;或 PATH/MODULE 类型间存在前缀覆盖;或一方为 REPOSITORY(全库覆盖)。
|
|
82
|
+
*/
|
|
83
|
+
export function scopesCompatible(a: Scope, b: Scope): boolean {
|
|
84
|
+
if (a.type === b.type && a.value === b.value) return true
|
|
85
|
+
if (a.type === "REPOSITORY" || b.type === "REPOSITORY") {
|
|
86
|
+
return a.type === "REPOSITORY" && b.type === "REPOSITORY" ? a.value === b.value : true
|
|
87
|
+
}
|
|
88
|
+
const pathLike = (t: MemoryScopeType) => t === "PATH" || t === "MODULE"
|
|
89
|
+
if (pathLike(a.type) && pathLike(b.type)) {
|
|
90
|
+
return a.value.startsWith(b.value.replace(/\/?$/, "/")) ||
|
|
91
|
+
b.value.startsWith(a.value.replace(/\/?$/, "/"))
|
|
92
|
+
}
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 密钥/凭证扫描(Plan §11:Evidence 摘录在写入前执行秘密与敏感信息检测)
|
|
3
|
+
* 命中即拒写(ERR_SECRET_DETECTED)。宁可误报,不可漏报。
|
|
4
|
+
*/
|
|
5
|
+
import { MemoryError } from "./errors.js"
|
|
6
|
+
|
|
7
|
+
export interface SecretHit {
|
|
8
|
+
pattern: string
|
|
9
|
+
index: number
|
|
10
|
+
preview: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface SecretPattern {
|
|
14
|
+
name: string
|
|
15
|
+
re: RegExp
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const PATTERNS: SecretPattern[] = [
|
|
19
|
+
{ name: "private-key-block", re: /-----BEGIN (?:RSA |EC |OPENSSH |PGP )?PRIVATE KEY-----/ },
|
|
20
|
+
{ name: "aws-access-key", re: /\bAKIA[0-9A-Z]{16}\b/ },
|
|
21
|
+
{ name: "github-token", re: /\b(?:ghp|gho|ghu|ghs|ghr|github_pat)_[A-Za-z0-9_]{20,}\b/ },
|
|
22
|
+
{ name: "openai-style-key", re: /\bsk-[A-Za-z0-9_-]{20,}\b/ },
|
|
23
|
+
{ name: "slack-token", re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/ },
|
|
24
|
+
{ name: "bearer-token", re: /\bBearer\s+[A-Za-z0-9._~+/=-]{20,}\b/i },
|
|
25
|
+
{ name: "conn-string-password", re: /(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis):\/\/[^:\s/]+:[^@\s]+@/i },
|
|
26
|
+
{ name: "generic-secret-assign", re: /\b(?:password|passwd|secret|api[_-]?key|access[_-]?token)\b\s*[:=]\s*["'][^"'\s]{8,}["']/i },
|
|
27
|
+
{ name: "jwt", re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/ },
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
export function scanSecrets(text: string): SecretHit[] {
|
|
31
|
+
const hits: SecretHit[] = []
|
|
32
|
+
for (const p of PATTERNS) {
|
|
33
|
+
const m = p.re.exec(text)
|
|
34
|
+
if (m) {
|
|
35
|
+
hits.push({
|
|
36
|
+
pattern: p.name,
|
|
37
|
+
index: m.index,
|
|
38
|
+
preview: text.slice(Math.max(0, m.index - 10), m.index + 20).replace(/\s+/g, " ").slice(0, 40),
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return hits
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function assertNoSecrets(text: string): void {
|
|
46
|
+
const hits = scanSecrets(text)
|
|
47
|
+
if (hits.length > 0) {
|
|
48
|
+
throw new MemoryError("ERR_SECRET_DETECTED", hits.map((h) => h.pattern).join(","))
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Memory 生命周期状态机(Plan §5)
|
|
3
|
+
*
|
|
4
|
+
* CANDIDATE → PENDING → ACTIVE → STALE → SUPERSEDED → ARCHIVED
|
|
5
|
+
* │ │ │ └──────────────→ ARCHIVED
|
|
6
|
+
* └──────────┴─────────┴──────────────────────→ REJECTED
|
|
7
|
+
*
|
|
8
|
+
* 首版默认只允许人工或显式治理调用激活(approve 的强制校验在此表达)。
|
|
9
|
+
*/
|
|
10
|
+
import { MemoryError } from "./errors.js"
|
|
11
|
+
|
|
12
|
+
export type MemoryStatus =
|
|
13
|
+
| "CANDIDATE" | "PENDING" | "ACTIVE" | "STALE"
|
|
14
|
+
| "SUPERSEDED" | "REJECTED" | "ARCHIVED"
|
|
15
|
+
|
|
16
|
+
export type MemoryAction =
|
|
17
|
+
| "propose" | "submit_review" | "approve" | "reject"
|
|
18
|
+
| "mark_stale" | "supersede" | "archive" | "restore"
|
|
19
|
+
|
|
20
|
+
interface Transition {
|
|
21
|
+
from: MemoryStatus[]
|
|
22
|
+
to: MemoryStatus
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 迁移表:propose 是创建语义(无 from),其余均为状态到状态 */
|
|
26
|
+
export const TRANSITIONS: Record<MemoryAction, Transition> = {
|
|
27
|
+
propose: { from: [], to: "CANDIDATE" },
|
|
28
|
+
submit_review: { from: ["CANDIDATE"], to: "PENDING" },
|
|
29
|
+
approve: { from: ["PENDING", "CANDIDATE"], to: "ACTIVE" },
|
|
30
|
+
reject: { from: ["CANDIDATE", "PENDING"], to: "REJECTED" },
|
|
31
|
+
mark_stale: { from: ["ACTIVE"], to: "STALE" },
|
|
32
|
+
supersede: { from: ["ACTIVE", "STALE"], to: "SUPERSEDED" },
|
|
33
|
+
archive: { from: ["ACTIVE", "STALE", "SUPERSEDED"], to: "ARCHIVED" },
|
|
34
|
+
restore: { from: ["ARCHIVED", "REJECTED"], to: "CANDIDATE" },
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** approve / supersede 的不变量校验输入(DB 查询结果由调用方传入,领域层保持纯函数) */
|
|
38
|
+
export interface TransitionContext {
|
|
39
|
+
evidenceCount?: number
|
|
40
|
+
approvedBy?: string | null
|
|
41
|
+
approvedAt?: Date | null
|
|
42
|
+
/** supersede 专用:新记忆 id 与 repository/scope 兼容性判定结果 */
|
|
43
|
+
supersededById?: string | null
|
|
44
|
+
supersessionCompatible?: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface TransitionResult {
|
|
48
|
+
from: MemoryStatus
|
|
49
|
+
to: MemoryStatus
|
|
50
|
+
action: MemoryAction
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 判定迁移是否合法;非法时抛 MemoryError(ERR_ILLEGAL_TRANSITION / ERR_EVIDENCE_REQUIRED / ...) */
|
|
54
|
+
export function assertTransition(
|
|
55
|
+
current: MemoryStatus,
|
|
56
|
+
action: MemoryAction,
|
|
57
|
+
ctx: TransitionContext = {},
|
|
58
|
+
): TransitionResult {
|
|
59
|
+
const t = TRANSITIONS[action]
|
|
60
|
+
if (action === "propose") {
|
|
61
|
+
// 创建语义:不与现有状态冲突(由调用方决定新建或合并)
|
|
62
|
+
return { from: current, to: t.to, action }
|
|
63
|
+
}
|
|
64
|
+
if (!t.from.includes(current)) {
|
|
65
|
+
throw new MemoryError("ERR_ILLEGAL_TRANSITION", `${current} --${action}--> ${t.to} 不允许(允许起点: ${t.from.join("/")})`)
|
|
66
|
+
}
|
|
67
|
+
if (action === "approve") {
|
|
68
|
+
if (!ctx.evidenceCount || ctx.evidenceCount < 1) {
|
|
69
|
+
throw new MemoryError("ERR_EVIDENCE_REQUIRED")
|
|
70
|
+
}
|
|
71
|
+
if (!ctx.approvedBy) {
|
|
72
|
+
throw new MemoryError("ERR_APPROVAL_REQUIRED", "缺少 approvedBy")
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (action === "supersede") {
|
|
76
|
+
if (!ctx.supersededById) {
|
|
77
|
+
throw new MemoryError("ERR_SUPERSESSION_INVALID", "缺少 supersededById")
|
|
78
|
+
}
|
|
79
|
+
if (ctx.supersessionCompatible === false) {
|
|
80
|
+
throw new MemoryError("ERR_SUPERSESSION_INVALID", "新旧 Memory 的 repository/scope 不兼容")
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { from: current, to: t.to, action }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 默认召回允许的状态集合(Plan §7.1:默认排除 CANDIDATE/PENDING/REJECTED/ARCHIVED/SUPERSEDED/过期项) */
|
|
87
|
+
export const DEFAULT_RECALL_STATUSES: readonly MemoryStatus[] = ["ACTIVE"]
|
|
88
|
+
|
|
89
|
+
/** 显式诊断模式额外放行 STALE(带警告) */
|
|
90
|
+
export const DIAGNOSTIC_RECALL_STATUSES: readonly MemoryStatus[] = ["ACTIVE", "STALE"]
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Embedding 抽象与降级(Plan §8.1/§8.4,Spec §9)
|
|
3
|
+
*
|
|
4
|
+
* 提供者:none(默认,显式不可用)/ local-onnx / openai-compatible(Phase 5 轮 3)。
|
|
5
|
+
* Vector adapter:pgvector / sqlite-vec(retrieval/vector/),能力检测失败即降级。
|
|
6
|
+
* 契约:记忆系统任何环节不得因 embedding 缺失而阻塞 Gate(FTS-only 合法降级)。
|
|
7
|
+
*/
|
|
8
|
+
import { MemoryError } from "../domain/errors.js"
|
|
9
|
+
import type { ComponentHealth, RankedId, RecallFilter } from "../retrieval/types.js"
|
|
10
|
+
|
|
11
|
+
export interface EmbeddingProvider {
|
|
12
|
+
readonly id: string // "none" | "local-onnx" | "openai-compatible" | "custom"
|
|
13
|
+
readonly dimension: number
|
|
14
|
+
embed(texts: string[]): Promise<number[][]>
|
|
15
|
+
health(): Promise<ComponentHealth>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface VectorSearchAdapter {
|
|
19
|
+
search(vector: number[], filter: RecallFilter, limit: number): Promise<RankedId[]>
|
|
20
|
+
upsert(memoryId: string, vector: number[], model: string): Promise<void>
|
|
21
|
+
health(): Promise<ComponentHealth>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 首版唯一 provider:显式不可用。调用方据此走 FTS-only 并标注 degradedMode */
|
|
25
|
+
export function createNoneEmbeddingProvider(): EmbeddingProvider {
|
|
26
|
+
return {
|
|
27
|
+
id: "none",
|
|
28
|
+
dimension: 0,
|
|
29
|
+
embed: (): Promise<number[][]> => Promise.reject(new MemoryError("ERR_EMBEDDING_DISABLED")),
|
|
30
|
+
health: (): Promise<ComponentHealth> =>
|
|
31
|
+
Promise.resolve({ component: "embedding", status: "disabled", detail: "EmbeddingProvider=none(首版定案,FTS-only)" }),
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Vector adapter capability 存根:health 报告 unavailable,search/upsert 拒绝 */
|
|
36
|
+
export function createUnavailableVectorAdapter(): VectorSearchAdapter {
|
|
37
|
+
return {
|
|
38
|
+
search: (): Promise<RankedId[]> =>
|
|
39
|
+
Promise.reject(new MemoryError("ERR_EMBEDDING_DISABLED", "VectorSearchAdapter 未配置(Phase 5 接入)")),
|
|
40
|
+
upsert: (): Promise<void> =>
|
|
41
|
+
Promise.reject(new MemoryError("ERR_EMBEDDING_DISABLED", "VectorSearchAdapter 未配置(Phase 5 接入)")),
|
|
42
|
+
health: (): Promise<ComponentHealth> =>
|
|
43
|
+
Promise.resolve({ component: "vector-search", status: "unavailable", detail: "向量索引未启用(Phase 5 接入 pgvector/sqlite-vec)" }),
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 维度真源校验(Review R5 回流):provider 上报维度必须与 DB 侧声明维度一致,否则拒写。
|
|
49
|
+
* declaredDim 来自 AddMemory.embeddingDim(写入路径的真源)。
|
|
50
|
+
*/
|
|
51
|
+
export function assertEmbeddingDimension(providerDim: number, declaredDim: number): void {
|
|
52
|
+
if (declaredDim <= 0) return // 未声明维度:首写,由 provider 值落库
|
|
53
|
+
if (providerDim !== declaredDim) {
|
|
54
|
+
throw new MemoryError(
|
|
55
|
+
"ERR_DIMENSION_MISMATCH",
|
|
56
|
+
`嵌入维度不一致:provider=${providerDim} vs 声明=${declaredDim}(拒绝写入,避免脏向量)`,
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type EmbeddingMode = "none" | "local-onnx" | "openai-compatible"
|
|
62
|
+
|
|
63
|
+
export interface EmbeddingEnvConfig {
|
|
64
|
+
mode?: string
|
|
65
|
+
model?: string
|
|
66
|
+
dimension?: number
|
|
67
|
+
baseUrl?: string
|
|
68
|
+
apiKey?: string
|
|
69
|
+
cacheDir?: string
|
|
70
|
+
remoteHost?: string
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 从环境变量解析提供者配置(纯函数,便于测试与文档化) */
|
|
74
|
+
export function parseEmbeddingEnv(env: NodeJS.ProcessEnv = process.env): EmbeddingEnvConfig {
|
|
75
|
+
const mode = (env.ADD_MEMORY_EMBEDDING ?? "none").toLowerCase()
|
|
76
|
+
const dim = Number(env.ADD_MEMORY_EMBEDDING_DIM ?? "")
|
|
77
|
+
return {
|
|
78
|
+
mode,
|
|
79
|
+
model: env.ADD_MEMORY_EMBEDDING_MODEL,
|
|
80
|
+
dimension: Number.isFinite(dim) && dim > 0 ? Math.floor(dim) : undefined,
|
|
81
|
+
baseUrl: env.ADD_MEMORY_EMBEDDING_BASE_URL,
|
|
82
|
+
apiKey: env.ADD_MEMORY_EMBEDDING_API_KEY,
|
|
83
|
+
cacheDir: env.ADD_MEMORY_EMBEDDING_CACHE_DIR,
|
|
84
|
+
remoteHost: env.ADD_MEMORY_EMBEDDING_REMOTE_HOST,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 按配置装配提供者(默认 none → 行为与首版一致)。
|
|
90
|
+
* local-onnx / openai-compatible 走动态 import,未配置的路径不加载任何重依赖。
|
|
91
|
+
*/
|
|
92
|
+
export async function createEmbeddingProviderFromConfig(
|
|
93
|
+
config: EmbeddingEnvConfig = parseEmbeddingEnv(),
|
|
94
|
+
): Promise<EmbeddingProvider> {
|
|
95
|
+
const mode = (config.mode ?? "none") as EmbeddingMode
|
|
96
|
+
if (mode === "local-onnx") {
|
|
97
|
+
const { createLocalOnnxEmbeddingProvider } = await import("./local-onnx.js")
|
|
98
|
+
return createLocalOnnxEmbeddingProvider({
|
|
99
|
+
model: config.model,
|
|
100
|
+
cacheDir: config.cacheDir,
|
|
101
|
+
remoteHost: config.remoteHost,
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
if (mode === "openai-compatible") {
|
|
105
|
+
if (!config.baseUrl) {
|
|
106
|
+
throw new MemoryError("ERR_EMBEDDING_DISABLED", "openai-compatible 需要 ADD_MEMORY_EMBEDDING_BASE_URL")
|
|
107
|
+
}
|
|
108
|
+
const { createOpenAiCompatibleEmbeddingProvider } = await import("./openai-compatible.js")
|
|
109
|
+
return createOpenAiCompatibleEmbeddingProvider({
|
|
110
|
+
baseUrl: config.baseUrl,
|
|
111
|
+
model: config.model ?? "text-embedding-3-small",
|
|
112
|
+
apiKey: config.apiKey,
|
|
113
|
+
dimension: config.dimension,
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
return createNoneEmbeddingProvider()
|
|
117
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 本地 ONNX 嵌入提供者(Plan §3.4 轮 3 Task 3.1 / Spec §6 §VectorCapability)
|
|
3
|
+
*
|
|
4
|
+
* 关键约束:
|
|
5
|
+
* 1. **可选依赖**:@huggingface/transformers 与模型权重都可能在目标机器缺失——加载失败
|
|
6
|
+
* 必须收敛为 ERR_EMBEDDING_DISABLED(调用方据此走 fts-only),绝不抛裸异常阻塞 Gate;
|
|
7
|
+
* 2. **维度以模型元数据为真源**:dimension 由首次推理的实际输出长度决定(不硬编码),
|
|
8
|
+
* 与 DB 侧 AddMemory.embeddingDim 不一致时由调用方拒绝写入(Review R5 回流);
|
|
9
|
+
* 3. 惰性加载:不在模块顶层 import/下载模型,首次 embed 时才初始化。
|
|
10
|
+
*/
|
|
11
|
+
import { MemoryError } from "../domain/errors.js"
|
|
12
|
+
import type { ComponentHealth } from "../retrieval/types.js"
|
|
13
|
+
import type { EmbeddingProvider } from "./index.js"
|
|
14
|
+
|
|
15
|
+
/** 默认模型:中文小模型(真源维度 512,与 Plan R5「维度真源」一致) */
|
|
16
|
+
export const DEFAULT_LOCAL_ONNX_MODEL = "Xenova/bge-small-zh-v1.5"
|
|
17
|
+
|
|
18
|
+
export interface LocalOnnxOptions {
|
|
19
|
+
model?: string
|
|
20
|
+
/** 显式缓存目录;缺省复用 HF_HUB_CACHE → HF_HOME/hub → ~/.cache/huggingface/hub */
|
|
21
|
+
cacheDir?: string
|
|
22
|
+
/** 镜像站(内网/受限网络) */
|
|
23
|
+
remoteHost?: string
|
|
24
|
+
/** 测试注入:直接给定 pipeline 工厂 */
|
|
25
|
+
loadPipeline?: (model: string) => Promise<(texts: string[]) => Promise<number[][]>>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface LocalOnnxProvider extends EmbeddingProvider {
|
|
29
|
+
/** 首次推理后可用;未初始化时为 0(调用方以 assertEmbeddingDimension 校验) */
|
|
30
|
+
readonly resolvedDimension: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createLocalOnnxEmbeddingProvider(opts: LocalOnnxOptions = {}): LocalOnnxProvider {
|
|
34
|
+
const model = opts.model ?? DEFAULT_LOCAL_ONNX_MODEL
|
|
35
|
+
let embedFn: ((texts: string[]) => Promise<number[][]>) | null = null
|
|
36
|
+
let dimension = 0
|
|
37
|
+
|
|
38
|
+
async function ensureLoaded(): Promise<(texts: string[]) => Promise<number[][]>> {
|
|
39
|
+
if (embedFn) return embedFn
|
|
40
|
+
try {
|
|
41
|
+
if (opts.loadPipeline) {
|
|
42
|
+
embedFn = await opts.loadPipeline(model)
|
|
43
|
+
} else {
|
|
44
|
+
const { pipeline, env } = await import("@huggingface/transformers")
|
|
45
|
+
if (opts.cacheDir) env.cacheDir = opts.cacheDir
|
|
46
|
+
if (opts.remoteHost) env.remoteHost = opts.remoteHost
|
|
47
|
+
const extractor = await pipeline("feature-extraction", model)
|
|
48
|
+
embedFn = async (texts: string[]) => {
|
|
49
|
+
const result = await extractor(texts, { pooling: "mean", normalize: true })
|
|
50
|
+
const list = result.tolist() as number[][]
|
|
51
|
+
return list.length === texts.length ? list : [list as unknown as number[]]
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return embedFn
|
|
55
|
+
} catch (error) {
|
|
56
|
+
throw new MemoryError(
|
|
57
|
+
"ERR_EMBEDDING_DISABLED",
|
|
58
|
+
`本地 ONNX 模型不可用(model=${model}):${error instanceof Error ? error.message : String(error)}`,
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
id: "local-onnx",
|
|
65
|
+
get dimension(): number {
|
|
66
|
+
return dimension
|
|
67
|
+
},
|
|
68
|
+
get resolvedDimension(): number {
|
|
69
|
+
return dimension
|
|
70
|
+
},
|
|
71
|
+
async embed(texts: string[]): Promise<number[][]> {
|
|
72
|
+
if (texts.length === 0) return []
|
|
73
|
+
const fn = await ensureLoaded()
|
|
74
|
+
let vectors: number[][]
|
|
75
|
+
try {
|
|
76
|
+
vectors = await fn(texts)
|
|
77
|
+
} catch (error) {
|
|
78
|
+
throw new MemoryError(
|
|
79
|
+
"ERR_EMBEDDING_DISABLED",
|
|
80
|
+
`本地 ONNX 推理失败(model=${model}):${error instanceof Error ? error.message : String(error)}`,
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
const dim = vectors[0]?.length ?? 0
|
|
84
|
+
if (dim === 0) {
|
|
85
|
+
throw new MemoryError("ERR_EMBEDDING_DISABLED", `本地 ONNX 返回空向量(model=${model})`)
|
|
86
|
+
}
|
|
87
|
+
// 维度真源:以模型实际输出为准;同一 provider 内必须自洽
|
|
88
|
+
if (dimension !== 0 && dimension !== dim) {
|
|
89
|
+
throw new MemoryError("ERR_DIMENSION_MISMATCH", `provider 维度漂移:${dimension} → ${dim}`)
|
|
90
|
+
}
|
|
91
|
+
dimension = dim
|
|
92
|
+
return vectors
|
|
93
|
+
},
|
|
94
|
+
health(): Promise<ComponentHealth> {
|
|
95
|
+
if (embedFn && dimension > 0) {
|
|
96
|
+
return Promise.resolve({ component: "embedding", status: "ok", detail: `local-onnx model=${model} dim=${dimension}` })
|
|
97
|
+
}
|
|
98
|
+
return Promise.resolve({
|
|
99
|
+
component: "embedding",
|
|
100
|
+
status: "degraded",
|
|
101
|
+
detail: `local-onnx 未初始化(model=${model});首次 embed 时加载,失败则降级 fts-only`,
|
|
102
|
+
})
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* OpenAI 兼容嵌入提供者(Plan §3.4 轮 3 Task 3.1)
|
|
3
|
+
*
|
|
4
|
+
* 适用于任意 /v1/embeddings 兼容端点(本地 vLLM / 自建网关 / 官方 API)。
|
|
5
|
+
* 契约同 local-onnx:维度以首次响应为准(配置项 dimension 仅作期望值校验),
|
|
6
|
+
* 任何失败收敛为 ERR_EMBEDDING_DISABLED,不阻塞 Gate。
|
|
7
|
+
*/
|
|
8
|
+
import { MemoryError } from "../domain/errors.js"
|
|
9
|
+
import type { ComponentHealth } from "../retrieval/types.js"
|
|
10
|
+
import type { EmbeddingProvider } from "./index.js"
|
|
11
|
+
|
|
12
|
+
export interface OpenAiCompatibleOptions {
|
|
13
|
+
baseUrl: string
|
|
14
|
+
model: string
|
|
15
|
+
apiKey?: string
|
|
16
|
+
/** 期望维度(可选):与首次响应不一致时报 ERR_DIMENSION_MISMATCH */
|
|
17
|
+
dimension?: number
|
|
18
|
+
timeoutMs?: number
|
|
19
|
+
/** 测试注入 */
|
|
20
|
+
fetchImpl?: typeof fetch
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createOpenAiCompatibleEmbeddingProvider(
|
|
24
|
+
opts: OpenAiCompatibleOptions,
|
|
25
|
+
): EmbeddingProvider {
|
|
26
|
+
const doFetch = opts.fetchImpl ?? fetch
|
|
27
|
+
const endpoint = `${opts.baseUrl.replace(/\/+$/, "")}/embeddings`
|
|
28
|
+
let dimension = opts.dimension ?? 0
|
|
29
|
+
let lastError: string | null = null
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
id: "openai-compatible",
|
|
33
|
+
get dimension(): number {
|
|
34
|
+
return dimension
|
|
35
|
+
},
|
|
36
|
+
async embed(texts: string[]): Promise<number[][]> {
|
|
37
|
+
if (texts.length === 0) return []
|
|
38
|
+
const controller = new AbortController()
|
|
39
|
+
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 15_000)
|
|
40
|
+
let payload: { data?: { embedding?: number[] }[] }
|
|
41
|
+
try {
|
|
42
|
+
const res = await doFetch(endpoint, {
|
|
43
|
+
method: "POST",
|
|
44
|
+
headers: {
|
|
45
|
+
"content-type": "application/json",
|
|
46
|
+
...(opts.apiKey ? { authorization: `Bearer ${opts.apiKey}` } : {}),
|
|
47
|
+
},
|
|
48
|
+
body: JSON.stringify({ model: opts.model, input: texts }),
|
|
49
|
+
signal: controller.signal,
|
|
50
|
+
})
|
|
51
|
+
if (!res.ok) {
|
|
52
|
+
throw new Error(`HTTP ${res.status}`)
|
|
53
|
+
}
|
|
54
|
+
payload = (await res.json()) as { data?: { embedding?: number[] }[] }
|
|
55
|
+
} catch (error) {
|
|
56
|
+
lastError = error instanceof Error ? error.message : String(error)
|
|
57
|
+
throw new MemoryError("ERR_EMBEDDING_DISABLED", `兼容端点调用失败(${endpoint}):${lastError}`)
|
|
58
|
+
} finally {
|
|
59
|
+
clearTimeout(timer)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const vectors = (payload.data ?? []).map((d) => d.embedding ?? []).filter((v) => v.length > 0)
|
|
63
|
+
if (vectors.length === 0) {
|
|
64
|
+
throw new MemoryError("ERR_EMBEDDING_DISABLED", `兼容端点未返回向量(model=${opts.model})`)
|
|
65
|
+
}
|
|
66
|
+
const dim = vectors[0].length
|
|
67
|
+
if (dimension !== 0 && dimension !== dim) {
|
|
68
|
+
throw new MemoryError("ERR_DIMENSION_MISMATCH", `期望维度 ${dimension},实收 ${dim}`)
|
|
69
|
+
}
|
|
70
|
+
dimension = dim
|
|
71
|
+
lastError = null
|
|
72
|
+
return vectors
|
|
73
|
+
},
|
|
74
|
+
health(): Promise<ComponentHealth> {
|
|
75
|
+
if (dimension > 0) {
|
|
76
|
+
return Promise.resolve({ component: "embedding", status: "ok", detail: `openai-compatible model=${opts.model} dim=${dimension}` })
|
|
77
|
+
}
|
|
78
|
+
return Promise.resolve({
|
|
79
|
+
component: "embedding",
|
|
80
|
+
status: lastError ? "unavailable" : "degraded",
|
|
81
|
+
detail: lastError
|
|
82
|
+
? `兼容端点不可用:${lastError}`
|
|
83
|
+
: `兼容端点未调用(model=${opts.model},endpoint=${endpoint})`,
|
|
84
|
+
})
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
}
|