@sema-agent/server 7.13.0 → 7.15.0
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/MIGRATION.md +16 -1
- package/USAGE.md +21 -0
- package/dist/approval-ask-machine.d.ts +10 -0
- package/dist/approval-ask-machine.js +10 -0
- package/dist/boot/coordinators.js +2 -1
- package/dist/boot/memory-boundary.d.ts +90 -0
- package/dist/boot/memory-boundary.js +118 -0
- package/dist/boot/resolve-spec.js +31 -0
- package/dist/boot/runner-deps.js +19 -0
- package/dist/boot/stores.js +101 -10
- package/dist/capabilities/memory-notice.d.ts +87 -0
- package/dist/capabilities/memory-notice.js +110 -0
- package/dist/config-types.d.ts +86 -9
- package/dist/config.d.ts +1 -1
- package/dist/config.js +111 -1
- package/dist/governance-ask-marks.js +2 -1
- package/dist/http/routes/capabilities.js +22 -4
- package/dist/http/routes/runs.js +23 -2
- package/dist/http/routes/trace-usage.js +49 -19
- package/dist/http/server.d.ts +9 -1
- package/dist/http/server.js +9 -1
- package/dist/http/wire-types.d.ts +6 -1
- package/dist/memory-scope.d.ts +20 -0
- package/dist/memory-scope.js +45 -0
- package/dist/observability/metrics.js +4 -1
- package/dist/plugins/approval-ask-store-sql.d.ts +2 -1
- package/dist/plugins/approval-ask-store-sql.js +2 -1
- package/dist/plugins/memory-embedder.d.ts +44 -0
- package/dist/plugins/memory-embedder.js +173 -0
- package/dist/plugins/store-backend.d.ts +3 -1
- package/dist/plugins/tidb-pool.js +11 -4
- package/dist/plugins/tool-result-store-sql.d.ts +35 -2
- package/dist/plugins/tool-result-store-sql.js +127 -11
- package/dist/plugins/web-search.d.ts +3 -1
- package/dist/plugins/web-search.js +3 -1
- package/dist/security.js +3 -1
- package/dist/tool-approval.d.ts +1 -0
- package/dist/tool-approval.js +88 -6
- package/package.json +3 -3
package/dist/http/routes/runs.js
CHANGED
|
@@ -14,6 +14,7 @@ import { scopedIdempotencyKey } from "../idempotency.js";
|
|
|
14
14
|
import { streamSseLog } from "../sse-log.js";
|
|
15
15
|
import { buildApprovalPreamble, buildApprovalPreambleSseFrames } from "../../approval-card.js";
|
|
16
16
|
import { resolveStreamApprovalGate } from "../../tool-approval.js";
|
|
17
|
+
import { MAX_DECISION_NOTE_CHARS } from "../../approval-ask-machine.js";
|
|
17
18
|
import { normalizeRunEventType } from "../../trace/project.js";
|
|
18
19
|
import { sendJson, sendError, httpErrorCode, sseHeaders } from "../send.js";
|
|
19
20
|
import { buildActiveRunConflict } from "../active-run-conflict.js";
|
|
@@ -89,7 +90,9 @@ const AskDecisionBodySchema = z
|
|
|
89
90
|
decision: z.enum(["approve", "deny"]),
|
|
90
91
|
/** 仅 approve 臂有意义;deny 带 = 宽收后忽略(与三条 respond 同姿势)。 */
|
|
91
92
|
updatedInput: z.unknown().optional(),
|
|
92
|
-
|
|
93
|
+
/** 上限走**共享常量**(#229):live 腿的手写 parser 写同一列,两处各写一份 `2048` 字面量 =
|
|
94
|
+
* 同一列上两个口径,而分歧只会在「一条腿收下、另一条腿 400」的那天才暴露。 */
|
|
95
|
+
note: z.string().max(MAX_DECISION_NOTE_CHARS).optional(),
|
|
93
96
|
idempotencyKey: z
|
|
94
97
|
.string()
|
|
95
98
|
.min(1)
|
|
@@ -1748,6 +1751,18 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
|
|
|
1748
1751
|
deps.toolApproval?.notifyExternalDecision(row.askId, row.decision === "approve");
|
|
1749
1752
|
return true;
|
|
1750
1753
|
};
|
|
1754
|
+
/**
|
|
1755
|
+
* #229(设计稿 233 稿B v2 §2):回决理由的**读面**投影 —— 三个回体共用这一份,行上有才发。
|
|
1756
|
+
*
|
|
1757
|
+
* 🔴 **裁定翻面,原样记账**:本 handler 原先明写「`note` 不回显(以请求者可见权限为界的最窄安全形)」。
|
|
1758
|
+
* 那条最窄形的实测代价是 `decisionNote` 全仓**只写不读** —— 一个零读面的「审计位」对任何按审计面
|
|
1759
|
+
* 接它的消费端都是当场落空。现行裁定:三回体一律 additive 投,**从不发 null / 空键**(缺席 = 这条
|
|
1760
|
+
* 决议没留理由,与 `updatedInputForwarded` 的「从不发 false」同族)。
|
|
1761
|
+
* 读权前提没有放宽:能走到这条口的调用方本来就有权决这只 ask(属主门在本 handler 上游),而 409 支
|
|
1762
|
+
* 回显的**首决**理由与它同支已经在回显的 `decision`/`decidedAtMs`/`actor` 是同一份首决投影 ——
|
|
1763
|
+
* 多这一格不新开任何一条越权读路径。
|
|
1764
|
+
*/
|
|
1765
|
+
const decisionNoteEcho = (row) => (row.decisionNote === null ? {} : { decisionNote: row.decisionNote });
|
|
1751
1766
|
/** 200 回放形:与首决 200 同键集,只差 `updatedInputForwarded`(§12-A 明写回放不承诺字节等同)。
|
|
1752
1767
|
* 调用前提 = 已过 {@link syncLiveFromDecidedRow}(行合形且活体窗已同步)。 */
|
|
1753
1768
|
const replayDecided = (row) => {
|
|
@@ -1758,6 +1773,7 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
|
|
|
1758
1773
|
decision: row.decision,
|
|
1759
1774
|
decidedAtMs: row.decidedAtMs,
|
|
1760
1775
|
...(storedActor ? { actor: storedActor } : {}),
|
|
1776
|
+
...decisionNoteEcho(row),
|
|
1761
1777
|
});
|
|
1762
1778
|
};
|
|
1763
1779
|
// 终局分派 —— **一律按传进来的这一行**投影,不重读(§12-A:`DecideResult.row` 已是新鲜行;
|
|
@@ -1775,11 +1791,13 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
|
|
|
1775
1791
|
return;
|
|
1776
1792
|
}
|
|
1777
1793
|
const first = projectDecisionActor(row.decisionActor);
|
|
1778
|
-
//
|
|
1794
|
+
// 首决回显四件(#229 起含 `decisionNote`;翻面理由见 {@link decisionNoteEcho})——回显的恒是
|
|
1795
|
+
// **首决**那条理由,不是本次请求带来的那条(本次请求压根没落地)。
|
|
1779
1796
|
sendError(res, 409, "conflict.ask_decided", "this ask was already decided — the first decision stands and is echoed here", {
|
|
1780
1797
|
decision: row.decision,
|
|
1781
1798
|
decidedAtMs: row.decidedAtMs,
|
|
1782
1799
|
...(first ? { actor: first } : {}),
|
|
1800
|
+
...decisionNoteEcho(row),
|
|
1783
1801
|
});
|
|
1784
1802
|
return;
|
|
1785
1803
|
}
|
|
@@ -1935,6 +1953,9 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
|
|
|
1935
1953
|
decidedAtMs: outcome.row.decidedAtMs,
|
|
1936
1954
|
actor,
|
|
1937
1955
|
...(forwarded ? { updatedInputForwarded: true } : {}),
|
|
1956
|
+
// #229:同上,**按行**投(不按 `body.note`)——赢者行才是这次回决的真相,而 `decideAsk` 的提交与
|
|
1957
|
+
// 回读之间那条行仍可能被版本化收敛改写(同一条「每个入参都取自行」的纪律)。
|
|
1958
|
+
...decisionNoteEcho(outcome.row),
|
|
1938
1959
|
});
|
|
1939
1960
|
return;
|
|
1940
1961
|
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { buildToolResultRef } from "@sema-agent/core";
|
|
2
1
|
import { projectEvents, taskSummary, mapTraceEvent } from "../../trace/project.js";
|
|
3
2
|
import { projectArtifacts } from "../../trace/artifacts.js";
|
|
4
3
|
import { usageSummary, usageSeries, usageBreakdown } from "../../usage-analytics.js";
|
|
@@ -235,7 +234,11 @@ async function handleTaskArtifacts(res, runStore, taskId) {
|
|
|
235
234
|
* 「坏 query + 已知 ref = 400」而「坏 query + 未知 ref = 404」,状态码之差立刻成了存在性 oracle。
|
|
236
235
|
* query 的合法性只取决于 query 自身,与调用方看不看得见这个 ref 无关,所以先判是安全的。
|
|
237
236
|
*
|
|
238
|
-
* ② **归属绑定 =
|
|
237
|
+
* ② **归属绑定 = 店里记的出处(`ownerOf`)与本 run 的 session/task 精确相等**(实现见 ②-a),不解析 ref、
|
|
238
|
+
* 不复制段转义规则,**且没有第二条腿**。
|
|
239
|
+
*
|
|
240
|
+
* 下面这一大段是这条判据的**病历**(前缀形 → 本 task 日志作证形 → 出处形),留着是因为「为什么不
|
|
241
|
+
* 照 ref 的字面去判」每隔一阵就被重新问一次;当前生效的判据只有 🟡 段之后的那一条。
|
|
239
242
|
*
|
|
240
243
|
* 先说为什么**不是**前缀判定([3321]② 的字面形、[3322] 的指定形):core 的 ref 形是
|
|
241
244
|
* `tr_<seg(session)>_<seg(toolCallId)>`,而 `_` 在两段里都合法 ⇒ session `team` 的前缀 `tr_team_`
|
|
@@ -247,7 +250,7 @@ async function handleTaskArtifacts(res, runStore, taskId) {
|
|
|
247
250
|
* 跨租户**拒绝**面(别人注册一个 `<你的 session>_toolu` 就能让你自己的 ref 永远 404)——R2 复审两条
|
|
248
251
|
* 中标,已撤回该启发式。
|
|
249
252
|
*
|
|
250
|
-
*
|
|
253
|
+
* 当年的改判据(**已被 ②-a 取代,见下**):ref **必须由本 task 自己的耐久事件日志作证**。日志里每条 tool_start/tool_end 都带
|
|
251
254
|
* `toolCallId`(`trace/project.ts` 的 toolStartEventData / toolEndEventData 是唯一铸造点),用 core
|
|
252
255
|
* 的**同一个** `buildToolResultRef` 把它们铸成 ref 集合,再要求 `ref` 是其中一员 —— 精确相等,
|
|
253
256
|
* 零解析、零转义规则复制,歧义面结构上不存在(A 的 taskId 只作证 A 自己的 toolCallId)。
|
|
@@ -255,6 +258,19 @@ async function handleTaskArtifacts(res, runStore, taskId) {
|
|
|
255
258
|
* trace 的 tool-result 块与本判据读的是同一份日志,所以「trace 上看得见 ⇒ 这里读得到」成立;
|
|
256
259
|
* 同 session 里**别的 task** 的 ref(或日志已过保留窗被逐出的)按 404 同形拒,请到那条 task 上读。
|
|
257
260
|
*
|
|
261
|
+
* 🟡 **两条残余的现状(7.14.0 合并窗复扫改口,下面原文保留作病历)**。core 按当年这里写的方向动了刀:
|
|
262
|
+
* `put` 带出处落库 + `ownerOf` 读回,读面改用出处判定(实现见 ②-a)。逐条对账:
|
|
263
|
+
* · (b) 拼接非单射的越权面**已彻底消失** —— ref 铸法换成 `~` 多段,`~` 不在段的合法字符集里。
|
|
264
|
+
* · (a) 合成 id 铸的 ref **只销了一半**:offload / budget / compaction 三个写点的出处只声明
|
|
265
|
+
* session ⇒ 现在读得到;而 `task-registry-monitor` 的 spill(`<stream>_seg<n>`)与
|
|
266
|
+
* `task-registry-agent` 的 spill(`c<cycle>`)在出处里**点名了 taskId,填的是 core TaskRegistry
|
|
267
|
+
* 的 handle id**(`b/w/a/m` 前缀 + 16 hex,见 core `task-registry.js`),与本路由段里的 server
|
|
268
|
+
* run id(uuidv7)是两个命名空间,恒不相等 ⇒ 这两族 ref 在本面**恒 404**(改前也 404,不是回归)。
|
|
269
|
+
* 而它们恰恰是 core 在成品文本里明写「call ReadToolResult with ref …」露给调用方的那批。
|
|
270
|
+
* 真解仍在 core 侧(出处该声明的是**调用方认得的** task 坐标,或干脆只声明 session);本仓不自行
|
|
271
|
+
* 放宽 taskId 那条腿 —— 那是单方面改判定语义,归属判据会与上游各漂一份。
|
|
272
|
+
* 日志重铸那条腿**已删**(见 ②-b:它对任何真实行都不可达,且每次未命中还要付一次全量日志读)。
|
|
273
|
+
*
|
|
258
274
|
* ⚠️ 两处**已知残余**,都要 core 侧动刀才能消,写在明处而不是让下一个人自己撞(codex 复审 R3):
|
|
259
275
|
*
|
|
260
276
|
* (a) **覆盖面**:core 除了「按工具调用 id 卸载」这条主路,还从**合成 id** 铸 ref ——
|
|
@@ -271,7 +287,7 @@ async function handleTaskArtifacts(res, runStore, taskId) {
|
|
|
271
287
|
* 铸出那一枚精确的 toolCallId(provider 铸,本服务不控也不产)。同样只有 core 换成单射编码才真消。
|
|
272
288
|
* 这里**不留特征化测试**去钉这条通路 —— 把缺陷钉成契约是另一种病。
|
|
273
289
|
*
|
|
274
|
-
* ③ **同形 404**(unknown ref /
|
|
290
|
+
* ③ **同形 404**(unknown ref / 出处判不过(含无属主行)/ 本部署没有 durable store)。任何可区分的响应都是
|
|
275
291
|
* 缺陷:前两者之差会告诉调用方「这枚 ref 存在,只是不是你的」;第三者若回 501 则会把部署形告诉一个
|
|
276
292
|
* 连自己的 ref 都读不到的 caller。文案恒定且**不回显 ref**(回显=把输入原样反射进错误面)。
|
|
277
293
|
* 残余面(诚实写明):响应字节同形,**时序**并不同形 —— 三条臂到达的后端调用数不同。要抹平时序得让
|
|
@@ -315,21 +331,35 @@ ref, query) {
|
|
|
315
331
|
const run = ownedRun ?? (await runStore.getRun(taskId));
|
|
316
332
|
if (!run)
|
|
317
333
|
return notFound();
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
334
|
+
// ②-a 出处判定(#119,core 5.26.0 —— 上面 (a)/(b) 两条残余的**正解到货**)。
|
|
335
|
+
// 店里现在自己记着「这枚 ref 属于谁」(`put` 的第三参落库,`ownerOf` 读回),读面拿**自己已知的字段**
|
|
336
|
+
// 比对即可:零解析、零段转义规则复制,拼接歧义那条残余彻底消失,合成 id 那条**消了一半**
|
|
337
|
+
// (monitor/agent spill 两族的出处点名的 taskId 是 core handle id,与本函数收到的 server run id 不同
|
|
338
|
+
// 命名空间 ⇒ 恒判假;详见头注 🟡 段)。
|
|
339
|
+
//
|
|
340
|
+
// 🔴 为什么必须改而不是"锦上添花":5.26.0 起 offload ref 是多段单射形(内容坐标进 ref),服务端手里
|
|
341
|
+
// 没有内容 ⇒ 原来那条重铸判据**重铸不出来** ⇒ 每一枚真 offload ref 都会 404。读面整死。
|
|
342
|
+
//
|
|
343
|
+
// ⚠️ 辖域(有意的口径变化,写在明处):出处的粒度由 core 定 —— offload/budget/projection 三个写点只声明
|
|
344
|
+
// session,把 taskId 钉在那里会把「一个 session 的两条 task 共用一枚 ref」这个**设计内**的共享形变成拒绝
|
|
345
|
+
// (core d.ts 逐字裁过)。所以有出处时的判据是「同 session,且出处**若点名了** task 则必须是本 task」——
|
|
346
|
+
// 相对旧的「本 task 日志作证过」是**放宽到同 session**。放宽的边界仍在 owner 之内(run 归属门在本函数
|
|
347
|
+
// 之前已过,session 的属主就是这个 caller),换来的是这条读面在 5.26.0 上还活着。
|
|
348
|
+
const owner = store?.ownerOf ? await store.ownerOf(ref) : undefined;
|
|
349
|
+
const ownedByThisCaller = owner !== undefined && owner.sessionId === run.sessionId && (owner.taskId === undefined || owner.taskId === taskId);
|
|
350
|
+
// ②-b **没有**第二条腿:出处判不过就是 404(unowned 行也在内 —— core 的口径就是「读面对 unowned
|
|
351
|
+
// fail-closed」)。这里曾有一条「用本 task 耐久日志作证过的 toolCallId 经 `buildToolResultRef` 重铸再
|
|
352
|
+
// 精确相等」的兜底,7.14.0 合并窗复扫判定为**结构性死支**并整条删除,证据链写在明处:
|
|
353
|
+
// · 重铸只喂得起两个参数(服务端手里没有内容)⇒ 铸出的是**两段**形 `tr_<sid>~<callId>`;
|
|
354
|
+
// · core 5.27.0 的**每一个**落库写点铸的都是 ≥3 段(offload/budget/compaction 三段带内容摘要、
|
|
355
|
+
// detail 卸载四段、monitor/agent spill 三段带 `<stream>_seg<n>`/`c<cycle>`),而 `~` 不在段的合法
|
|
356
|
+
// 字符集里 ⇒ **段数不同的 ref 恒不相等** ⇒ 该判据对库里任何真实行都不可能成立;
|
|
357
|
+
// · 旧引擎(≤5.25.0)的 `tr_<sid>_<call>` 同样重铸不出来(分隔符换了),而按旧规则手写一个 legacy
|
|
358
|
+
// 编码器 = 复制上游规则(本文件头注自己禁掉的那件事);存量行的真处置是**删表重建**(见 CHANGELOG)。
|
|
359
|
+
// 删的另一半理由是**代价**:走到这条腿的前提是「出处缺席」,而任意乱写的未知 ref 恰好也落在这里,
|
|
360
|
+
// 于是每个必然 404 的请求都要付一次整份耐久事件日志读(已鉴权调用方可用随机 ref 放大后端读),
|
|
361
|
+
// 换不到任何放行。行为面只收窄不放宽:能放行的集合原本就是空集。
|
|
362
|
+
if (!ownedByThisCaller)
|
|
333
363
|
return notFound();
|
|
334
364
|
if (!store)
|
|
335
365
|
return notFound(); // durable store 缺席:404 同形,不 501(不泄露部署形)
|
package/dist/http/server.d.ts
CHANGED
|
@@ -511,7 +511,15 @@ export declare function validateUserSkills(skills: unknown): string | null;
|
|
|
511
511
|
* POST /v1/runs → async: 202 {taskId, sessionId, status}, runs in background (S1)
|
|
512
512
|
* GET /v1/runs/:id → run status + result (poll)
|
|
513
513
|
* GET /v1/runs/:id/events → SSE replay from Last-Event-ID, then tail to terminal (S2)
|
|
514
|
-
* POST /v1/sessions/:id/
|
|
514
|
+
* POST /v1/sessions/:id/notify → inject an out-of-session event (design/144 §2): live stream ⇒ core
|
|
515
|
+
* `TaskStream.notify()`; idle ⇒ parked in the session inbox, drained
|
|
516
|
+
* as a `task_notification` on the next stream open
|
|
517
|
+
* POST /v1/sessions/:id/wake → wake a `task_done` pure park with a message and resume it
|
|
518
|
+
* (design/144 §3). NOT a gate decision — a pending gate is refused
|
|
519
|
+
* (`wake.gate_pending`). Goes through `resumeWake`, which reuses the
|
|
520
|
+
* resume family's lease/CAS/run-log leg, so it BURNS MODEL TOKENS and
|
|
521
|
+
* is subject to the billable-submit gates (`SESSION_WAKE_RE`).
|
|
522
|
+
* (both live in `http/routes/notify-wake.ts` — design/158 A9 domain split)
|
|
515
523
|
*/
|
|
516
524
|
export declare function createHttpServer(rawDeps: ServiceDeps): http.Server & {
|
|
517
525
|
denyExpiredApprovals: (now: number) => Promise<void>;
|
package/dist/http/server.js
CHANGED
|
@@ -180,7 +180,15 @@ void ROUTE_DOMAINS;
|
|
|
180
180
|
* POST /v1/runs → async: 202 {taskId, sessionId, status}, runs in background (S1)
|
|
181
181
|
* GET /v1/runs/:id → run status + result (poll)
|
|
182
182
|
* GET /v1/runs/:id/events → SSE replay from Last-Event-ID, then tail to terminal (S2)
|
|
183
|
-
* POST /v1/sessions/:id/
|
|
183
|
+
* POST /v1/sessions/:id/notify → inject an out-of-session event (design/144 §2): live stream ⇒ core
|
|
184
|
+
* `TaskStream.notify()`; idle ⇒ parked in the session inbox, drained
|
|
185
|
+
* as a `task_notification` on the next stream open
|
|
186
|
+
* POST /v1/sessions/:id/wake → wake a `task_done` pure park with a message and resume it
|
|
187
|
+
* (design/144 §3). NOT a gate decision — a pending gate is refused
|
|
188
|
+
* (`wake.gate_pending`). Goes through `resumeWake`, which reuses the
|
|
189
|
+
* resume family's lease/CAS/run-log leg, so it BURNS MODEL TOKENS and
|
|
190
|
+
* is subject to the billable-submit gates (`SESSION_WAKE_RE`).
|
|
191
|
+
* (both live in `http/routes/notify-wake.ts` — design/158 A9 domain split)
|
|
184
192
|
*/
|
|
185
193
|
export function createHttpServer(rawDeps) {
|
|
186
194
|
// design/158 A8:分组装配 → 平铺视图。**分组是装配面的形,不是消费面的形**——下面 ~700 处 `deps.x`
|
|
@@ -287,7 +287,12 @@ export interface TaskRequestBody {
|
|
|
287
287
|
/** MF-30 memory PAUSE (shell-host contract, option B per-request — clay 2026-06-27): `false` makes THIS run
|
|
288
288
|
* read-only over long-term memory (`TaskSpec.memory.writeScope:null` — the design/138 memory ENGINE materializes/
|
|
289
289
|
* reads but its harvest commits nothing). Absent/`true` ⇒ normal read+write. The shell's `/memory` pause carries
|
|
290
|
-
* this per request (no stored per-session flag). Re-applies on resume (rides in the persisted body).
|
|
290
|
+
* this per request (no stored per-session flag). Re-applies on resume (rides in the persisted body).
|
|
291
|
+
* ⚠️ **NOT the same axis as the deployment's `MEMORY_PERSISTENCE_CAPABLE:false`** (core 5.27.0 / [3612] F2):
|
|
292
|
+
* that one declares the SESSION restricted — materialize/search/harvest serve the committed account and
|
|
293
|
+
* unbacked disk divergence is refused (`restricted_divergence`). A `writeScope:null` PLANE keeps its ordinary
|
|
294
|
+
* adopt-on-read semantics: a paused run still sees the user's hand-edits and git-pull drops, it just commits
|
|
295
|
+
* nothing. Restriction is the session's DECLARATION, never the plane's structure. */
|
|
291
296
|
memoryWrite?: boolean;
|
|
292
297
|
/** 142-S4 projectId 线程化(design/142 §2):这个 run 归属的项目(center 登记簿键,generic lowercase
|
|
293
298
|
* UUID — core S1 `resolveProjectId` marker 或 S3 无仓 mint 的产物;客户端只透传,零铸造权威)。是「哪个
|
package/dist/memory-scope.d.ts
CHANGED
|
@@ -44,6 +44,26 @@ export declare function memoryEngineRemoteLanePosture(config: ServiceConfig): {
|
|
|
44
44
|
lane: string;
|
|
45
45
|
posture: "dark" | "forced";
|
|
46
46
|
} | undefined;
|
|
47
|
+
/**
|
|
48
|
+
* N0 启动告警的**文案**(纯,可单测;boot/stores.ts 的装配点只负责喂「引擎这一腿真的接上了吗」并打日志)。
|
|
49
|
+
*
|
|
50
|
+
* 为什么要按引擎真身分腿(2026-08-12 复扫,已核真):{@link memoryEngineRemoteLanePosture} 的 `dark` 只对
|
|
51
|
+
* **file** 引擎腿有裁决权 —— 车道门的唯一消费者是 {@link memoryEngineBackendFor}。`MEMORY_ENGINE_BACKEND=pg|tidb`
|
|
52
|
+
* 的两条腿在 boot/stores.ts 里**先于**任何车道判断就被点亮(库是持久真身,与手的文件平面无关),于是同一次
|
|
53
|
+
* 启动会先打 `memory_engine_enabled {enabled:true, backend:"pg"}`,几行之后再打一句「memory dark
|
|
54
|
+
* (fail-closed) … Set MEMORY_ENGINE_REMOTE_LANE=allow」—— 两句直接对撞,而且把运维指向一个在该腿上**不接线**
|
|
55
|
+
* 的旋钮(`memoryEngineRemoteLaneAllowed` 的读者只有本文件与那条日志)。日志是运维唯一能看见的部署事实,
|
|
56
|
+
* 一句谎比没有这句更贵。
|
|
57
|
+
*
|
|
58
|
+
* 返回 `undefined` = 本部署没什么可说的(无平面分裂车道 / 记忆整体关 / 多租户 file 腿的 dark 成因是租户隔离,
|
|
59
|
+
* 已由 `memory_engine_enabled` 的 reason 位报因,不在这里重复)。
|
|
60
|
+
*/
|
|
61
|
+
export declare function buildMemoryRemoteLaneWarn(config: ServiceConfig,
|
|
62
|
+
/** 记忆引擎这一腿**真的**接上了吗(`memoryEngine !== undefined`)——装配结果,不在本函数里重算。 */
|
|
63
|
+
engineWired: boolean): {
|
|
64
|
+
lane: string;
|
|
65
|
+
effect: string;
|
|
66
|
+
} | undefined;
|
|
47
67
|
/**
|
|
48
68
|
* design/138 S1 wiring gate (pure — unit-testable without booting main): build the file-based memory-engine
|
|
49
69
|
* backend for this deployment, or `undefined` when memory must stay dark.
|
package/dist/memory-scope.js
CHANGED
|
@@ -98,6 +98,51 @@ export function memoryEngineRemoteLanePosture(config) {
|
|
|
98
98
|
return undefined; // hands work THIS machine's fs — same plane
|
|
99
99
|
return { lane, posture: config.memoryEngineRemoteLaneAllowed === true ? "forced" : "dark" };
|
|
100
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* N0 启动告警的**文案**(纯,可单测;boot/stores.ts 的装配点只负责喂「引擎这一腿真的接上了吗」并打日志)。
|
|
103
|
+
*
|
|
104
|
+
* 为什么要按引擎真身分腿(2026-08-12 复扫,已核真):{@link memoryEngineRemoteLanePosture} 的 `dark` 只对
|
|
105
|
+
* **file** 引擎腿有裁决权 —— 车道门的唯一消费者是 {@link memoryEngineBackendFor}。`MEMORY_ENGINE_BACKEND=pg|tidb`
|
|
106
|
+
* 的两条腿在 boot/stores.ts 里**先于**任何车道判断就被点亮(库是持久真身,与手的文件平面无关),于是同一次
|
|
107
|
+
* 启动会先打 `memory_engine_enabled {enabled:true, backend:"pg"}`,几行之后再打一句「memory dark
|
|
108
|
+
* (fail-closed) … Set MEMORY_ENGINE_REMOTE_LANE=allow」—— 两句直接对撞,而且把运维指向一个在该腿上**不接线**
|
|
109
|
+
* 的旋钮(`memoryEngineRemoteLaneAllowed` 的读者只有本文件与那条日志)。日志是运维唯一能看见的部署事实,
|
|
110
|
+
* 一句谎比没有这句更贵。
|
|
111
|
+
*
|
|
112
|
+
* 返回 `undefined` = 本部署没什么可说的(无平面分裂车道 / 记忆整体关 / 多租户 file 腿的 dark 成因是租户隔离,
|
|
113
|
+
* 已由 `memory_engine_enabled` 的 reason 位报因,不在这里重复)。
|
|
114
|
+
*/
|
|
115
|
+
export function buildMemoryRemoteLaneWarn(config,
|
|
116
|
+
/** 记忆引擎这一腿**真的**接上了吗(`memoryEngine !== undefined`)——装配结果,不在本函数里重算。 */
|
|
117
|
+
engineWired) {
|
|
118
|
+
const posture = memoryEngineRemoteLanePosture(config);
|
|
119
|
+
if (posture === undefined || !config.memoryEngineEnabled)
|
|
120
|
+
return undefined;
|
|
121
|
+
if (config.memoryEngineBackend !== "file") {
|
|
122
|
+
if (!engineWired)
|
|
123
|
+
return undefined; // DB 腿没接上只可能是拒启路径(半配 fail-loud),不在这里猜
|
|
124
|
+
return {
|
|
125
|
+
lane: posture.lane,
|
|
126
|
+
effect: `memory ON over a remote lane: the "${config.memoryEngineBackend}" memory plane is durable in the DB and is NOT gated by ` +
|
|
127
|
+
`MEMORY_ENGINE_REMOTE_LANE (that knob gates the FILE engine only). What IS split here is the file plane — the ` +
|
|
128
|
+
`engine's materialization dir lives on the WORKER fs while this lane routes the model's file tools to the sandbox fs, ` +
|
|
129
|
+
`so harvest only sees what lands on the worker. Declare MEMORY_PERSISTENCE_CAPABLE=true|false to state which one it is.`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
if (config.requirePrincipal === true)
|
|
133
|
+
return undefined; // 多租户 file 腿:dark 的成因是隔离,不是车道
|
|
134
|
+
return posture.posture === "dark"
|
|
135
|
+
? {
|
|
136
|
+
lane: posture.lane,
|
|
137
|
+
effect: "memory dark (fail-closed): the file engine works the worker's local fs while this lane routes model file tools " +
|
|
138
|
+
"to the sandbox fs — sandbox writes are never harvested. Set MEMORY_ENGINE_REMOTE_LANE=allow ONLY if both are one fs.",
|
|
139
|
+
}
|
|
140
|
+
: {
|
|
141
|
+
lane: posture.lane,
|
|
142
|
+
effect: "MEMORY_ENGINE_REMOTE_LANE=allow: memory engine ON over a remote lane — harvest only sees files landing on the " +
|
|
143
|
+
"WORKER fs; verify the lane really shares it.",
|
|
144
|
+
};
|
|
145
|
+
}
|
|
101
146
|
/**
|
|
102
147
|
* design/138 S1 wiring gate (pure — unit-testable without booting main): build the file-based memory-engine
|
|
103
148
|
* backend for this deployment, or `undefined` when memory must stay dark.
|
|
@@ -320,7 +320,10 @@ export function createMetrics() {
|
|
|
320
320
|
// S21 — which backend snapshot blobs actually land on (minio|sql). Partial MINIO_* config silently falls to sql.
|
|
321
321
|
m.gauge("snapshot_blob_backend", "1 on the active snapshot-blob backend series (S21), by backend (minio|sql)");
|
|
322
322
|
// S22 — memory embed/KNN runtime failures (boot said vector; runtime silently degrades to lexical).
|
|
323
|
-
|
|
323
|
+
// HELP 文案按**唯一活发射点**写(#228 接线后的复扫):stores.ts 的 onFailure 腿打 `{ backend: dialect }`,
|
|
324
|
+
// 从没有过 `where` 维度(那是退役的 MEMORY_BACKEND/EMBEDDING_* store 面的形)—— 运维照旧文案写
|
|
325
|
+
// `sum by (where)` 得到的是空序列。USAGE 的 `memory_embed_failed_total{backend}` 才是对的那一份。
|
|
326
|
+
m.counter("memory_embed_failed_total", "Memory embedding calls that failed at runtime (S22), by backend (the memory-engine dialect that owns the embedder)");
|
|
324
327
|
m.counter("memory_knn_query_failed_total", "Memory KNN searchScored queries that failed at runtime (S22)");
|
|
325
328
|
// LOW — dedup fold vs new insert (a fold UPDATEs the existing row; previously indistinguishable), and the
|
|
326
329
|
// dedup probe failing over to a plain INSERT.
|
|
@@ -308,7 +308,8 @@ export declare const APPROVAL_BATCH_TABLE = "approval_batch";
|
|
|
308
308
|
* `task_run.task_id VARCHAR(64)` 同宽(此前 255 是本家族独有的偏离,无依据)。
|
|
309
309
|
* · `session_id` → 64:提交入口硬拒 >64(`http/server.ts` "sessionId must be at most 64 characters"),
|
|
310
310
|
* 与 `session_meta`/`task_run` 同宽。
|
|
311
|
-
* · `owner` → 190:`assertPrincipalShape` 的 `PRINCIPAL_MAX_LENGTH = 190`(`security.ts
|
|
311
|
+
* · `owner` → 190:`assertPrincipalShape` 的 `PRINCIPAL_MAX_LENGTH = 190`(`security.ts` 的同名导出常量,
|
|
312
|
+
* 刻意不钉行号 —— 旧注写的 `security.ts:350` 已随该文件编辑漂走)硬拒更长者;
|
|
312
313
|
* 全仓每一根 owner/scope 轴都是 190,此前 255 同样是无依据偏离。
|
|
313
314
|
* · `gate_token` → 120:它抄的是 checkpoint 的 token(core `mintCheckpointToken` = 16 字节 hex = 32 字符),
|
|
314
315
|
* 取与**被抄那一列** `checkpoint.token VARCHAR(120)` 同宽 —— 同一个值在两张表上宽度必须一致,
|
|
@@ -82,7 +82,8 @@ export const APPROVAL_BATCH_TABLE = "approval_batch";
|
|
|
82
82
|
* `task_run.task_id VARCHAR(64)` 同宽(此前 255 是本家族独有的偏离,无依据)。
|
|
83
83
|
* · `session_id` → 64:提交入口硬拒 >64(`http/server.ts` "sessionId must be at most 64 characters"),
|
|
84
84
|
* 与 `session_meta`/`task_run` 同宽。
|
|
85
|
-
* · `owner` → 190:`assertPrincipalShape` 的 `PRINCIPAL_MAX_LENGTH = 190`(`security.ts
|
|
85
|
+
* · `owner` → 190:`assertPrincipalShape` 的 `PRINCIPAL_MAX_LENGTH = 190`(`security.ts` 的同名导出常量,
|
|
86
|
+
* 刻意不钉行号 —— 旧注写的 `security.ts:350` 已随该文件编辑漂走)硬拒更长者;
|
|
86
87
|
* 全仓每一根 owner/scope 轴都是 190,此前 255 同样是无依据偏离。
|
|
87
88
|
* · `gate_token` → 120:它抄的是 checkpoint 的 token(core `mintCheckpointToken` = 16 字节 hex = 32 字符),
|
|
88
89
|
* 取与**被抄那一列** `checkpoint.token VARCHAR(120)` 同宽 —— 同一个值在两张表上宽度必须一致,
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { MemoryEmbedderConfig } from "../config-types.js";
|
|
2
|
+
import type { PgEmbedder } from "./pg-query.js";
|
|
3
|
+
/** 工厂入参 = 配置坐标(单一属主 `config-types.ts`)+ 部署腿(观测/注入)。 */
|
|
4
|
+
export interface OpenAiCompatEmbedderOptions extends MemoryEmbedderConfig {
|
|
5
|
+
/** 注入 fetch(测试/代理场景);缺省全局 `fetch`。 */
|
|
6
|
+
fetchImpl?: typeof fetch;
|
|
7
|
+
/**
|
|
8
|
+
* 失败观测腿(codex 复审 F2,已核真):embed 抛出去之后,`PgMemoryEngineBackend.applyPatches` 的
|
|
9
|
+
* 外层 catch 会把它折成一条 `io error: …` **冲突**记进 PatchReport(core 的冲突词表把它讲成「并发
|
|
10
|
+
* 改动」),而 `search` 那腿直接 `.catch(() => null)` 退回词面档 —— 两条路都不会在部署面留下任何
|
|
11
|
+
* 「向量供应商挂了」的信号。这个 hook 就是那个信号:装配点接 metrics + warn。
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ hook 本身不改变结果:抛照抛(不吞、不补零),它只负责让故障**被看见**。
|
|
14
|
+
*/
|
|
15
|
+
onFailure?: (err: unknown) => void;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* 端点归一:`MEMORY_EMBEDDER_ENDPOINT` 收两种写法 —— OpenAI 兼容**基址**(`https://x/v1`)或**完整**
|
|
19
|
+
* 端点(`https://x/v1/embeddings`)。两种都是 operator 手上真实存在的抄法,而一律硬拼 `/embeddings`
|
|
20
|
+
* 会产出 `/v1/embeddings/embeddings`(这条键最常见的手滑形)。归一不是降级:两条路都指向同一个真 URL,
|
|
21
|
+
* 没有任何一种输入被静默改成**别的**语义。
|
|
22
|
+
*
|
|
23
|
+
* ⚠️ 只动 `pathname`(codex 复审 F3,已核真):第一版在**整串**上做 `endsWith`/拼接,于是带 query 的
|
|
24
|
+
* 端点全错 —— Azure OpenAI 形 `https://h/v1/embeddings?api-version=1` 会被拼成 query 值里带
|
|
25
|
+
* `1/embeddings`,`https://h/v1?api-version=1` 则永远拿不到 `/embeddings` 路径。两种都过得了启动期的
|
|
26
|
+
* URL 合法性门,只在运行期炸(而运行期的炸法就是上面那条「写入失败/检索退档」的静默病)。
|
|
27
|
+
*/
|
|
28
|
+
export declare function embeddingsUrl(endpoint: string): string;
|
|
29
|
+
/**
|
|
30
|
+
* 诊断面用的**脱敏** URL(codex 复审 F4,已核真):端点里可能带 userinfo(`https://user:pass@h/v1`)
|
|
31
|
+
* 或把凭据放 query(Azure 的 `api-key=`),而错误文本会进日志、进 PatchReport 的 conflict reason。
|
|
32
|
+
* 主机与路径保留(诊断价值全在这里),userinfo 抹掉、query 值一律换成 `***`(键名保留,便于认形)。
|
|
33
|
+
*/
|
|
34
|
+
export declare function redactEndpointForLog(raw: string): string;
|
|
35
|
+
/** 造一个 OpenAI 兼容的 `PgEmbedder`(工厂命名律:返回带行为的对象 ⇒ `create*`)。 */
|
|
36
|
+
export declare function createOpenAiCompatEmbedder(opts: OpenAiCompatEmbedderOptions): PgEmbedder;
|
|
37
|
+
/**
|
|
38
|
+
* 装配腿:配置在场 ⇒ 造 embedder,缺席 ⇒ `undefined`(= core 的三档推断落回 lexical)。
|
|
39
|
+
* 这一层单独存在是为了让「注入真的接上了」可测 —— `openStores` 本身要真 DB 才跑得起来。
|
|
40
|
+
*/
|
|
41
|
+
export declare function memoryEmbedderFor(config: {
|
|
42
|
+
memoryEmbedder?: MemoryEmbedderConfig;
|
|
43
|
+
}, deps?: Pick<OpenAiCompatEmbedderOptions, "fetchImpl" | "onFailure">): PgEmbedder | undefined;
|
|
44
|
+
//# sourceMappingURL=memory-embedder.d.ts.map
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #228 —— OpenAI 兼容的 embedder(黑板 [3590] 裁②:core 只留了 `PgEmbedder` 这个**纯接口**
|
|
3
|
+
* (`{ embed(text) → number[]; dimensions }`),没有任何现成实现件;部署半场归本仓)。
|
|
4
|
+
*
|
|
5
|
+
* ## 形
|
|
6
|
+
*
|
|
7
|
+
* `POST {endpoint}/embeddings`,body `{ model, input }`,读 `data[0].embedding` —— OpenAI /
|
|
8
|
+
* DashScope / TEI / vllm / ollama 的 `/v1` 面同形。auth 可选(自托管端点通常没有 key)。
|
|
9
|
+
*
|
|
10
|
+
* ## 为什么校验做得这么硬
|
|
11
|
+
*
|
|
12
|
+
* 向量列写错维度是**静默毒库**形([3606] 裁③):错长度向量既不会让 DB 报错(本仓 pg 记忆表的
|
|
13
|
+
* embedding 列是 `jsonb`,无维度约束),也不会让检索报错(维度不匹配的行只是悄悄退回词面档)——
|
|
14
|
+
* 于是「向量面开着」这句话变成一句谎,而且没有任何一层会说出来。所以:
|
|
15
|
+
* · 响应形用 zod 校验(`data[0].embedding` 必须是 `number[]`),不符 = 抛带上下文的错;
|
|
16
|
+
* · 长度与声明维度不等 = 抛,**绝不截断 / 补零**(那正是把毒喂进库的两种手法);
|
|
17
|
+
* · 非 2xx / 非 JSON = 抛(status + body 前 200 字节,memory-sync transport 同款诊断姿势)。
|
|
18
|
+
*
|
|
19
|
+
* 抛出去之后由调用方处置:`PgMemoryEngineBackend.embeddingParam` 的长度门会让该行退回词面档,
|
|
20
|
+
* `search` 的词面腿 `.catch(() => null)` 会让检索继续跑 —— 即「embedder 坏了 = 退回 lexical」,
|
|
21
|
+
* 而不是「embedder 坏了 = 写坏向量」。
|
|
22
|
+
*
|
|
23
|
+
* ## 超时
|
|
24
|
+
*
|
|
25
|
+
* `AbortSignal.timeout(timeoutMs)`(缺省 30s,`MEMORY_EMBEDDER_TIMEOUT_MS` 覆盖,越界/坏值拒启)。
|
|
26
|
+
* 记忆写入路径是同步等 embed 的,挂死的端点会把整条 harvest 吊住(undici 默认 headers 超时 300s 太钝)。
|
|
27
|
+
*/
|
|
28
|
+
import { z } from "zod";
|
|
29
|
+
/** OpenAI `/v1/embeddings` 响应的**最小**契约:只读第一条向量,其余键(usage/object/model)不关心。 */
|
|
30
|
+
const EmbeddingsResponse = z.object({
|
|
31
|
+
data: z.array(z.object({ embedding: z.array(z.number()) })).min(1),
|
|
32
|
+
});
|
|
33
|
+
/**
|
|
34
|
+
* 端点归一:`MEMORY_EMBEDDER_ENDPOINT` 收两种写法 —— OpenAI 兼容**基址**(`https://x/v1`)或**完整**
|
|
35
|
+
* 端点(`https://x/v1/embeddings`)。两种都是 operator 手上真实存在的抄法,而一律硬拼 `/embeddings`
|
|
36
|
+
* 会产出 `/v1/embeddings/embeddings`(这条键最常见的手滑形)。归一不是降级:两条路都指向同一个真 URL,
|
|
37
|
+
* 没有任何一种输入被静默改成**别的**语义。
|
|
38
|
+
*
|
|
39
|
+
* ⚠️ 只动 `pathname`(codex 复审 F3,已核真):第一版在**整串**上做 `endsWith`/拼接,于是带 query 的
|
|
40
|
+
* 端点全错 —— Azure OpenAI 形 `https://h/v1/embeddings?api-version=1` 会被拼成 query 值里带
|
|
41
|
+
* `1/embeddings`,`https://h/v1?api-version=1` 则永远拿不到 `/embeddings` 路径。两种都过得了启动期的
|
|
42
|
+
* URL 合法性门,只在运行期炸(而运行期的炸法就是上面那条「写入失败/检索退档」的静默病)。
|
|
43
|
+
*/
|
|
44
|
+
export function embeddingsUrl(endpoint) {
|
|
45
|
+
const u = new URL(endpoint); // config 层已校验;直调工厂时坏 URL 在这里响亮抛
|
|
46
|
+
u.hash = ""; // fragment 对服务端无意义,带上只会污染日志
|
|
47
|
+
const path = u.pathname.replace(/\/+$/, "");
|
|
48
|
+
// 「已经带尾巴了吗」按**解码后**的末段判(codex 二轮 F3,已核真):`/v1/%65mbeddings` 与
|
|
49
|
+
// `/v1/embeddings` 是同一条路径,只比对序列化文本会给前者再叠一层。只解 unreserved 转义
|
|
50
|
+
// (RFC 3986 的 `A-Za-z0-9-._~`)—— 其余转义原样保留,decodeURIComponent 的坏 `%` 抛错面也一并避开。
|
|
51
|
+
u.pathname = decodeUnreservedEscapes(path).endsWith("/embeddings") ? path : `${path}/embeddings`;
|
|
52
|
+
return u.toString();
|
|
53
|
+
}
|
|
54
|
+
/** 只把 unreserved 字符的百分号转义解回来(不抛、不改变其它转义)。 */
|
|
55
|
+
function decodeUnreservedEscapes(s) {
|
|
56
|
+
return s.replace(/%([0-9A-Fa-f]{2})/g, (whole, hex) => {
|
|
57
|
+
const c = String.fromCharCode(Number.parseInt(hex, 16));
|
|
58
|
+
return /[A-Za-z0-9\-._~]/.test(c) ? c : whole;
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* 诊断面用的**脱敏** URL(codex 复审 F4,已核真):端点里可能带 userinfo(`https://user:pass@h/v1`)
|
|
63
|
+
* 或把凭据放 query(Azure 的 `api-key=`),而错误文本会进日志、进 PatchReport 的 conflict reason。
|
|
64
|
+
* 主机与路径保留(诊断价值全在这里),userinfo 抹掉、query 值一律换成 `***`(键名保留,便于认形)。
|
|
65
|
+
*/
|
|
66
|
+
export function redactEndpointForLog(raw) {
|
|
67
|
+
if (!URL.canParse(raw))
|
|
68
|
+
return "<invalid-url>"; // 谓词先行,不用 catch 兜(#191 静默降级门:catch 回默认值是被数的形)
|
|
69
|
+
const u = new URL(raw);
|
|
70
|
+
u.username = "";
|
|
71
|
+
u.password = "";
|
|
72
|
+
for (const k of [...u.searchParams.keys()])
|
|
73
|
+
u.searchParams.set(k, "***");
|
|
74
|
+
return u.toString();
|
|
75
|
+
}
|
|
76
|
+
/** 端点 query 上的**值**(Azure 形把 key 放 query;这些字面量要从任何诊断文本里洗掉)。 */
|
|
77
|
+
function credentialsInQuery(endpoint) {
|
|
78
|
+
if (!URL.canParse(endpoint))
|
|
79
|
+
return [];
|
|
80
|
+
return [...new URL(endpoint).searchParams.values()].filter((v) => v.length > 0);
|
|
81
|
+
}
|
|
82
|
+
/** 造一个 OpenAI 兼容的 `PgEmbedder`(工厂命名律:返回带行为的对象 ⇒ `create*`)。 */
|
|
83
|
+
export function createOpenAiCompatEmbedder(opts) {
|
|
84
|
+
const url = embeddingsUrl(opts.endpoint);
|
|
85
|
+
const shownUrl = redactEndpointForLog(url);
|
|
86
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
87
|
+
const { model, dimensions, timeoutMs, apiKey, onFailure } = opts;
|
|
88
|
+
// 洗白名单 = 本部署已知的**全部**凭据字面量:apiKey + 端点 query 上的值(Azure 形把 key 放 query;
|
|
89
|
+
// codex 二轮 F1)。userinfo 在 config 层就被拒了,这里不再单列。
|
|
90
|
+
const secrets = [...(apiKey !== undefined ? [apiKey] : []), ...credentialsInQuery(opts.endpoint)];
|
|
91
|
+
/** 供应商 body / 传输层错误进诊断文本前先洗一遍:回显自家凭据的代理/网关是真实存在的形。 */
|
|
92
|
+
const scrub = (raw) => secrets.reduce((acc, s) => acc.replaceAll(s, "***"), raw);
|
|
93
|
+
const safeSlice = (raw) => scrub(raw).slice(0, 200);
|
|
94
|
+
const call = async (text) => {
|
|
95
|
+
// 传输层失败(DNS/连不上/超时)也要走脱敏形(codex 二轮 F1):undici 的错误链里可能带上完整 URL,
|
|
96
|
+
// 而这条错会进 warn 日志与 PatchReport 的 conflict reason。**刻意不挂 cause**:挂上等于把没洗过的
|
|
97
|
+
// 原始文本从后门放回诊断面(打印错误链的人一样看得见),这里只带洗过的文本。
|
|
98
|
+
let res;
|
|
99
|
+
try {
|
|
100
|
+
res = await fetchImpl(url, {
|
|
101
|
+
method: "POST",
|
|
102
|
+
headers: {
|
|
103
|
+
"content-type": "application/json",
|
|
104
|
+
...(apiKey !== undefined ? { authorization: `Bearer ${apiKey}` } : {}),
|
|
105
|
+
},
|
|
106
|
+
body: JSON.stringify({ model, input: text }),
|
|
107
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
catch (transportErr) {
|
|
111
|
+
throw new Error(`memory embedder POST ${shownUrl}: transport failure — ${safeSlice(String(transportErr))}`);
|
|
112
|
+
}
|
|
113
|
+
const raw = await res.text().catch((readErr) => {
|
|
114
|
+
throw new Error(`memory embedder POST ${shownUrl}: response body unreadable — ${safeSlice(String(readErr))}`);
|
|
115
|
+
});
|
|
116
|
+
// 诊断面纪律:回显 status + body 前缀(端点的 typed error body 就是最好的线索),但 URL 走脱敏形、
|
|
117
|
+
// body 先洗掉全部已知凭据字面量 —— 错误文本会进日志与 PatchReport 的 conflict reason。
|
|
118
|
+
if (!res.ok)
|
|
119
|
+
throw new Error(`memory embedder POST ${shownUrl}: HTTP ${res.status} ${safeSlice(raw)}`);
|
|
120
|
+
let json;
|
|
121
|
+
try {
|
|
122
|
+
json = JSON.parse(raw);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
throw new Error(`memory embedder POST ${shownUrl}: response is not JSON — ${safeSlice(raw)}`);
|
|
126
|
+
}
|
|
127
|
+
const parsed = EmbeddingsResponse.safeParse(json);
|
|
128
|
+
if (!parsed.success) {
|
|
129
|
+
throw new Error(`memory embedder POST ${shownUrl}: not an OpenAI-compatible embeddings response (${parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}) — body ${safeSlice(raw)}`);
|
|
130
|
+
}
|
|
131
|
+
const vector = parsed.data.data[0].embedding;
|
|
132
|
+
if (vector.length !== dimensions) {
|
|
133
|
+
throw new Error(`memory embedder model "${model}" returned ${vector.length} dimensions but MEMORY_EMBEDDER_DIM=${dimensions} — refusing to truncate or pad (a wrong-length vector in the memory embedding column is silent corruption: neither the DB nor retrieval reports it). Fix MEMORY_EMBEDDER_DIM to match the model, or point MEMORY_EMBEDDER_MODEL at the model you sized for.`);
|
|
134
|
+
}
|
|
135
|
+
return vector;
|
|
136
|
+
};
|
|
137
|
+
return {
|
|
138
|
+
dimensions,
|
|
139
|
+
async embed(text) {
|
|
140
|
+
try {
|
|
141
|
+
return await call(text);
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
// 先让部署面看见,再**原样**抛(观测腿绝不改变结果,连真错的属性都不碰)
|
|
145
|
+
try {
|
|
146
|
+
onFailure?.(err);
|
|
147
|
+
}
|
|
148
|
+
catch (hookErr) {
|
|
149
|
+
// 观测腿自己坏了:①不许顶替真故障(拿监控的病盖住业务的病是最坏的一种交换);②不许改动真错
|
|
150
|
+
// ——第一版往 err.cause 上挂,冻结/不可写的 err 会让**赋值本身**抛,于是 TypeError 顶替了
|
|
151
|
+
// 真错(codex 二轮 F2,已核真);③也不许无声 —— 走 process.emitWarning,不碰真错分毫。
|
|
152
|
+
process.emitWarning(`memory embedder onFailure hook threw: ${String(hookErr)}`, "MemoryEmbedderObservabilityWarning");
|
|
153
|
+
}
|
|
154
|
+
throw err; // 原对象原样抛出(identity 不变)
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* 装配腿:配置在场 ⇒ 造 embedder,缺席 ⇒ `undefined`(= core 的三档推断落回 lexical)。
|
|
161
|
+
* 这一层单独存在是为了让「注入真的接上了」可测 —— `openStores` 本身要真 DB 才跑得起来。
|
|
162
|
+
*/
|
|
163
|
+
export function memoryEmbedderFor(config, deps = {}) {
|
|
164
|
+
const cfg = config.memoryEmbedder;
|
|
165
|
+
if (cfg === undefined)
|
|
166
|
+
return undefined;
|
|
167
|
+
return createOpenAiCompatEmbedder({
|
|
168
|
+
...cfg,
|
|
169
|
+
...(deps.fetchImpl !== undefined ? { fetchImpl: deps.fetchImpl } : {}),
|
|
170
|
+
...(deps.onFailure !== undefined ? { onFailure: deps.onFailure } : {}),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
//# sourceMappingURL=memory-embedder.js.map
|
|
@@ -93,7 +93,9 @@ export type ToolResultStoreFull = ToolResultStore & {
|
|
|
93
93
|
/** TTL sweep (SQL twins only): purge rows older than the cutoff. Local file store omits it (CC posture:
|
|
94
94
|
* a single user's tool-result files persist like transcripts; bounded by being text previews). */
|
|
95
95
|
reapOlderThan?(cutoffMs: number): Promise<number>;
|
|
96
|
-
/** E21 purge (SQL twins only): delete
|
|
96
|
+
/** E21 purge (SQL twins only): delete this session's refs when a session is purged. core 5.26.0 (#119)
|
|
97
|
+
* changed the mint from `tr_<sid>_<call>` to `tr_<sid>~<call>~<content>`, so the twin matches BOTH prefixes —
|
|
98
|
+
* see `deleteBySession` in tool-result-store-sql.ts for why dropping either one is a silent purge failure. */
|
|
97
99
|
deleteBySession?(sessionId: string): Promise<number>;
|
|
98
100
|
};
|
|
99
101
|
/** Cross-replica counter twins expose the write-behind lifecycle (startRefresh/stop) main.ts drives. */
|
|
@@ -187,13 +187,20 @@ export const SCHEMA_STATEMENTS = [
|
|
|
187
187
|
KEY idx_rate_limit_window (window_bucket)
|
|
188
188
|
) COLLATE utf8mb4_bin`,
|
|
189
189
|
// Durable backing for offloaded large tool results (core 1.47/1.49). One row per offload ref
|
|
190
|
-
// (`tr_<sessionId
|
|
190
|
+
// (`tr_<sessionId>~<toolCallId>~<contentSeg>` since core 5.26.0 #119, globally unique → PK); content is the full tool output moved out of
|
|
191
191
|
// context. Write-once (INSERT IGNORE); TTL-reaped by created_at (idx_created). LONGTEXT holds large
|
|
192
192
|
// file/diff reads. Lets an async run wake on any replica and still read_tool_result the full text.
|
|
193
|
+
// #119(core 5.26.0 提货):`ref` 从 VARCHAR(190) 抬到 518 = core 导出的 `MAX_MINTED_TOOL_RESULT_REF_CHARS`
|
|
194
|
+
// (`"tr_"` + 四段 × 128 + 三个 `~` 分隔),并新增 #119 出处两列(NULL = 该行无属主,读面 fail-closed)。
|
|
195
|
+
// 抬宽不是保守裕度:core 的 d.ts 逐字点名过旧宽度(「TiDB's was VARCHAR(191), which a long three-segment
|
|
196
|
+
// ref overruns — a truncating key aliases distinct refs」)—— 截断的主键会把两枚不同的 ref 折成同一行,
|
|
197
|
+
// 于是 A 的产物能从 B 的 ref 读出来。utf8mb4 下 518 字符 = 2072 字节,仍在 InnoDB/TiDB 3072 字节键长内。
|
|
193
198
|
`CREATE TABLE IF NOT EXISTS tool_result (
|
|
194
|
-
ref
|
|
195
|
-
content
|
|
196
|
-
|
|
199
|
+
ref VARCHAR(518) NOT NULL,
|
|
200
|
+
content LONGTEXT NOT NULL,
|
|
201
|
+
owner_session_id VARCHAR(190) NULL,
|
|
202
|
+
owner_task_id VARCHAR(190) NULL,
|
|
203
|
+
created_at DATETIME(3) NOT NULL,
|
|
197
204
|
PRIMARY KEY (ref),
|
|
198
205
|
KEY idx_tool_result_created (created_at)
|
|
199
206
|
) COLLATE utf8mb4_bin`,
|