Files
HologramAgent-DSH-Memory-Port/lib/embed/http.js
jianf 00a4a5f6ee feat(memory-b): G2† 编成 test + H 组完成(http adapter + 两表指纹一致)
G2† 补成自动化(你坚持的那条,我认)
你指出 G 组只有三条变异是自动化断言,G2† 只活在我的 commit message 里 ——
手工记录的变异不会随时间复现。已编成 test,并扩到覆盖**每一条**降级路径:

  DEGRADATION_PATHS 数据表(6 条,新增第五条时未覆盖则测试即红)
    embedder is null / undefined / 无 embedText
    ★ 无 info()                      ← 你点名的那条
    ★ info() 的 fingerprint 为空串
    ★ info() 的 fingerprint 非字符串
  + 每条都断言 degraded=no_embedder、仍能召回、且零 fetch
  + 额外一条「指纹检查在 embedText 之前」(钉顺序,不只钉结果)

变异实测打红
  把 embedText 调用挪到指纹检查之前
    not ok 8 - G2†: the fingerprint check runs BEFORE embedText is called
  让无指纹分支发一次 fetch
    not ok 5 - G2†: "embedder with no info()" ... zero network calls

H 组实现(lib/embed/http.js,单文件,G5 守卫)
- 协议与 HA 逐字段一致:model / modality / side / text;响应两种形状都收
- 指纹 http:<model>:<dim>:<sha256(规范化 endpoint)[:8]>
  规范化(hash 之前):去空白 / scheme+host 小写 / 去尾斜杠 / 去默认端口
  不做 query 重排与 .. 解析(那些差异通常是真实的)
  ★ 构造时算一次并复用 —— 因为同一个串必须同时进两张表
- 失败在 adapter 层是硬错误(非 2xx / 无 embedding / 维度不符 / 超限 / 超时),
  在召回层被 catch 成 degraded='embed_error',退回 FTS5
- 维度不符绝不截断不填充(block_recall.go:135-138)

票② 两表指纹一致 —— 落在 H 组 schema,未拖到 F
- memory_blocks 加 vector / fingerprint 两列(phase A 的表已发布,
  这里用 CREATE TABLE IF NOT EXISTS 的新列,旧库需 ALTER;已记入待办)
- graph_centroid 列名对齐 HA(vector_count / vector / stats / built_at),
  并把 is_anomalous 提成独立列(票③口径),不塞进 stats JSON
- putBlock 拒绝「有向量但无指纹」——无标签的向量永远不可安全比较
- putBlock 重提交不带向量时不清空已有向量(与 PutMemoryBlocks 的空值不覆盖同构)
- 新增 saveCentroid / loadCentroid / blockFingerprints

票② 判据
  ok - ticket②: block fingerprint and centroid fingerprint are byte-identical
  ok - ticket② mutation: a re-derived centroid fingerprint misses the lookup
       (加一个尾随空格即查不到,且不抛错 —— 正是必须用断言钉住的原因)
  ok - ticket②: a vector without a fingerprint is refused at write time
  ok - ticket②: re-committing without a vector must not erase the stored one

H 组变异(8 条,全部实跑打红)
  HM1 side 写死 document        → not ok 1 - H1
  HM2 指纹去掉 endpoint hash     → not ok 10/12/13 - F-fp1/fp3/fp4(三条一起红)
  HM3 维度不符改为截断           → not ok 5 - H3b
  HM4 去掉响应体积上限           → not ok 9 - H6
  HM5 保留尾斜杠                 → not ok 11 - F-fp2
  HM6 非 2xx 当成功              → not ok 4 - H3
  HM7 忽略 timeout signal        → not ok 8 - H4
  HM8 指纹每次 info() 重算       → not ok 15 - F-fp6

一处设计确认:lib/index.js 现在 import './embed/http.js',
G5 仍绿 —— 因为该文件没有 fetch/Authorization 等 transport 标识符。
「可以 import adapter,不可以知道 transport」正是 G5 的设计意图。

验证
- npm test 76/76(56 + H 组 20)
- npm run check 9/9
- 提交前连跑 3 次 test + 3 次 check,6 次全绿

待办(不催,记着)
- 票③ D7 fail-closed(中心表空但有向量行 ⇒ dim 报 null 不报 0)跟 F 组
- phase A 旧库升级需 ALTER TABLE 加两列,随 D 组的 stats 工具一起做
2026-10-30 10:17:18 +08:00

212 lines
7.4 KiB
JavaScript

/**
* HTTP embedding adapter — the ONLY file allowed to know a transport.
*
* Group G's `G5` guard enforces that: no other file under `lib/` may contain
* `fetch(`, `Authorization`, `Bearer`, `http(s)://`, `model_dir`, or `api_key`.
* Keeping the transport confined here is what makes "swap HTTP for a local ONNX
* runner" a one-file change instead of an audit of the whole plugin.
*
* ## Protocol
*
* Deliberately identical to HologramAgent's `http` provider
* (`internal/memory/vector/http_embedder.go:72-87`) so an existing HA sidecar can
* be pointed at without a shim:
*
* ```jsonc
* // request
* { "model": "...", "modality": "text", "side": "query",
* "text": "...", "data": "<base64>", "mime": "image/png" }
* // response — either shape is accepted
* { "embedding": [ ... ] }
* { "data": [ { "embedding": [ ... ] } ] }
* ```
*
* ## Fingerprint
*
* `http:<model>:<dim>:<sha256(normalised endpoint)[:8]>`
*
* HA derives `http:<model>:<dim>` (`http_embedder.go:99`) — model and dimension
* only. That is unsafe once an endpoint is configurable: two deployments of the
* same model name at the same dimension produce **the same fingerprint but
* different vectors** (different weights, different hardware, a different
* region), so blocks from both spaces compare silently and wrongly. Adding the
* endpoint hash closes that hole. The digest is truncated to 8 hex chars: it
* only needs to distinguish a handful of configured endpoints, not to resist an
* attacker.
*
* Normalisation happens **before** hashing so that cosmetic differences in the
* configuration string do not each trigger a full re-embed:
*
* - strip surrounding whitespace
* - lowercase the scheme and the host
* - drop a trailing `/` from the path
* - drop an explicit default port (`:80` for http, `:443` for https)
*
* It deliberately does NOT reorder query strings or resolve `..` in paths:
* those differences are usually real, and hiding them would mask a genuine
* misconfiguration.
*
* @module dsh-memory/embed/http
*/
import { createHash } from 'node:crypto'
/** Request timeout; HA's default is 30s (`http_embedder.go:95-97`). */
const DEFAULT_TIMEOUT_MS = 30_000
/** Response body cap. HA uses 4 MiB (`http_embedder.go:136`). */
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024
/**
* Normalise an endpoint string for fingerprinting.
*
* @param {string} endpoint
* @returns {string} the normalised form (empty string when input is unusable)
*/
export function normalizeEndpoint(endpoint) {
const raw = String(endpoint ?? '').trim()
if (raw === '') return ''
let url
try {
url = new URL(raw)
} catch {
// Not a parseable URL: fall back to the trimmed string so the fingerprint is
// still stable, and let the request itself fail loudly later.
return raw
}
// URL already lowercases protocol and host; be explicit about it so the
// behaviour survives a switch away from WHATWG URL.
const scheme = url.protocol.toLowerCase()
const host = url.hostname.toLowerCase()
const defaultPort = scheme === 'https:' ? '443' : scheme === 'http:' ? '80' : ''
const port = url.port === defaultPort ? '' : url.port
const path = url.pathname.replace(/\/+$/, '')
const search = url.search
return `${scheme}//${host}${port === '' ? '' : `:${port}`}${path}${search}`
}
/**
* Derive the vector-space fingerprint.
*
* @param {object} input
* @param {string} input.endpoint configured endpoint (normalised internally)
* @param {string} [input.model]
* @param {number} input.dimension
* @param {string} [input.fingerprint] explicit override (HA has the same escape hatch)
* @returns {string}
*/
export function deriveFingerprint({ endpoint, model = '', dimension, fingerprint }) {
if (typeof fingerprint === 'string' && fingerprint !== '') return fingerprint
const normalised = normalizeEndpoint(endpoint)
const digest = createHash('sha256').update(normalised, 'utf8').digest('hex').slice(0, 8)
return `http:${model}:${dimension}:${digest}`
}
/**
* Build an embedder over an HTTP endpoint.
*
* Returns the same three-member shape `recallBlocksHybrid` expects — nothing
* here leaks into scoring, storage, or grouping.
*
* @param {object} config
* @param {string} config.endpoint
* @param {number} config.dimension
* @param {string} [config.model]
* @param {string} [config.apiKey]
* @param {string} [config.fingerprint] override
* @param {number} [config.timeoutMs]
* @returns {{ embedText: (text: string) => Promise<number[]>,
* info: () => {fingerprint: string, dimension: number},
* close: () => Promise<void> }}
*/
export function createHttpEmbedder(config) {
const {
endpoint,
dimension,
model = '',
apiKey = '',
fingerprint,
timeoutMs = DEFAULT_TIMEOUT_MS,
} = config ?? {}
if (typeof endpoint !== 'string' || endpoint.trim() === '') {
throw new Error('dsh-memory: http embedder requires an endpoint')
}
if (!Number.isInteger(dimension) || dimension <= 0) {
throw new Error(`dsh-memory: http embedder requires a positive integer dimension, got ${dimension}`)
}
const normalisedEndpoint = normalizeEndpoint(endpoint)
// ★ Computed ONCE and reused. The same string must reach both
// `memory_blocks.fingerprint` and `graph_centroid.fingerprint`; deriving it
// per call risks two tables holding two spellings, which makes
// `LoadCentroid` miss silently (no error, centering just never applies).
const fp = deriveFingerprint({ endpoint: normalisedEndpoint, model, dimension, fingerprint })
let closed = false
const info = () => ({ fingerprint: fp, dimension })
async function embedText(text) {
if (closed) throw new Error('dsh-memory: http embedder closed')
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
const response = await fetch(normalisedEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(apiKey === '' ? {} : { Authorization: `Bearer ${apiKey}` }),
},
body: JSON.stringify({
model,
modality: 'text',
side: 'query',
text: String(text ?? ''),
}),
signal: controller.signal,
})
if (!response.ok) {
const body = await response.text().catch(() => '')
throw new Error(`dsh-memory: embed endpoint returned ${response.status}: ${body.slice(0, 200)}`)
}
const raw = await response.text()
if (raw.length > MAX_RESPONSE_BYTES) {
throw new Error(`dsh-memory: embed response exceeds ${MAX_RESPONSE_BYTES} bytes`)
}
const payload = JSON.parse(raw)
// Both shapes are accepted (HA: http_embedder.go:81-87).
const vector = Array.isArray(payload?.embedding)
? payload.embedding
: Array.isArray(payload?.data?.[0]?.embedding)
? payload.data[0].embedding
: undefined
if (!Array.isArray(vector)) {
throw new Error('dsh-memory: embed response carries no embedding array')
}
if (vector.length !== dimension) {
// Hard error, never truncate or pad: a silently mis-sized vector is a
// meaningless score that looks plausible (HA: block_recall.go:135-138).
throw new Error(`dsh-memory: embed dimension ${vector.length}, want ${dimension}`)
}
return vector
} finally {
clearTimeout(timer)
}
}
return {
embedText,
info,
async close() {
closed = true
},
}
}