@sema-agent/server 7.13.0 → 7.14.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.
@@ -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 `tr_<sessionId>_%` refs when a session is purged. */
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>_<toolCallId>`, globally unique → PK); content is the full tool output moved out of
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 VARCHAR(190) NOT NULL,
195
- content LONGTEXT NOT NULL,
196
- created_at DATETIME(3) NOT NULL,
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`,
@@ -1,4 +1,4 @@
1
- import type { ToolResultStore, ToolResultSlice } from "@sema-agent/core";
1
+ import type { ToolResultProvenance, ToolResultStore, ToolResultSlice } from "@sema-agent/core";
2
2
  import type { Pool as MySqlPool } from "mysql2/promise";
3
3
  import type { Pool as PgPool, PoolClient as PgPoolClient } from "pg";
4
4
  import { type SqlDriver } from "./sql-driver.js";
@@ -8,6 +8,21 @@ import { type SqlDriver } from "./sql-driver.js";
8
8
  export declare const PG_TOOL_RESULT_SCHEMA: string[];
9
9
  /** Idempotent schema apply for the tool_result table (for the integration test to call). */
10
10
  export declare function ensureSchema(pool: PgPool | PgPoolClient): Promise<void>;
11
+ /**
12
+ * #119 升级前置断言 —— **拒启**,不是 warn(codex 复审 [high],已核真)。
13
+ *
14
+ * 病:本仓不发 `ALTER TABLE` 迁移(标准裁定:改列就改 CREATE + 删库重建)。于是一台**没删表**就升上来的
15
+ * 部署,`CREATE TABLE IF NOT EXISTS` 对它是空操作,旧表既没有出处两列也只有 `VARCHAR(190)` 的 ref 列。
16
+ * 那样跑起来的后果不是「少个功能」:每一次 offload 写都会撞 unknown column 报错,而 core 的 offload
17
+ * 失败臂会把错误吞成一条内联占位("[offload LOST at write time…]")—— 服务照跑,工具产物全丢,日志里只有
18
+ * 一句看不出根因的话。这正是「响亮或 fail-closed,禁静默降级」那条要挡的形态。
19
+ *
20
+ * 判据用**能力探测**而不是版本号/information_schema:发一条恒空的 `WHERE 1=0` 读,列不在就报错。
21
+ * 探不通即拒启,错误文案直接给出两条方言的动作(删表重建),不让运维去猜。
22
+ */
23
+ export declare function assertToolResultProvenanceSchema(query: (sql: string) => Promise<{
24
+ rows: Record<string, unknown>[];
25
+ }>, dialect: "tidb" | "pg"): Promise<void>;
11
26
  /** Dual-dialect durable ToolResultStore. See the file header for the dialect-delta ledger. */
12
27
  export declare class SqlToolResultStore implements ToolResultStore {
13
28
  protected readonly db: SqlDriver;
@@ -24,7 +39,25 @@ export declare class SqlToolResultStore implements ToolResultStore {
24
39
  * 抛错文案与 core 逐字相同,所以 wire 与既有钉都不变。 */
25
40
  private isUnsafeRef;
26
41
  private assertSafeRef;
27
- put(ref: string, content: string): Promise<void>;
42
+ /**
43
+ * #119(core 5.26.0)—— 出处的**写面**。语义一律取 core 单源(`assertToolResultProvenanceMatch`),
44
+ * 本方法只负责把它落到 SQL 上:
45
+ * · **写一次选举同时定属主** —— 内容与属主是同一条 INSERT,不存在「内容写进去了属主还没跟上」的窗口
46
+ * (core 头注允许 file 双对象后端出现这个窗口,单行后端没有,别自造);
47
+ * · 抢输的那一方(IGNORE / DO NOTHING ⇒ affected=0)回读已存属主再判:同属主(或本次无出处)=
48
+ * 幂等空转;异属主 = `ToolResultRefConflictError` typed 拒。**绝不静默 keep-first**:ref 是主键,
49
+ * 静默空转会让第二位写者拿着自己的 ref 读回第一位的字节;
50
+ * · 无出处的行永久 unowned —— 后续带出处的 put 不回填(没有证据的收养),`ownerOf` 继续答 undefined。
51
+ */
52
+ put(ref: string, content: string, provenance?: ToolResultProvenance): Promise<void>;
53
+ /** 一次回读同时回答两件事:**行在不在**(reap/purge 窗口的判别位)与**属主是谁**。两件事必须来自同一
54
+ * 条 SELECT —— 分两次问会重新引入它要消灭的那个窗口。 */
55
+ private readOwner;
56
+ /**
57
+ * #119 —— 出处的**读面**。未知 ref 与「存了但无属主」两种情况都答 `undefined`:读面对二者一视同仁
58
+ * (fail-closed,谁都没被授权),所以这里也不必把它们分开报。
59
+ */
60
+ ownerOf(ref: string): Promise<ToolResultProvenance | undefined>;
28
61
  get(ref: string, opts?: {
29
62
  offset?: number;
30
63
  limit?: number;
@@ -9,7 +9,8 @@
9
9
  * offloaded result (the ref misses → the model is told it's unavailable; the preview still stands, so it
10
10
  * degrades, never crashes). A durable store survives wake/resume across the stateless fleet.
11
11
  *
12
- * core namespaces the ref as `tr_<sessionId>_<toolCallId>` (1.49) globally unique → usable directly as
12
+ * core namespaces the ref as `tr_<sessionId>~<toolCallId>~<contentCoordinate>` (1.49, re-minted injectively in
13
+ * 5.26.0 #119 — `~`-separated, four segments max, content-addressed) → globally unique → usable directly as
13
14
  * the PRIMARY KEY (no composite key needed). `put` is write-once (idempotent on replay/retry: a retry is
14
15
  * a NEW toolCallId → new ref, so a given ref never changes content). Retention is anchored to a run's
15
16
  * RECOVERABLE window via TTL (`reapOlderThan`): a resumable run may re-fetch an old ref after wake, but a
@@ -35,7 +36,7 @@
35
36
  * sessions' rows); PG spells `ESCAPE '\'` (its default escape char already, kept for lock-step intent
36
37
  * with the TiDB twin rather than out of necessity).
37
38
  */
38
- import { assertSafeToolResultRef } from "@sema-agent/core";
39
+ import { MAX_MINTED_TOOL_RESULT_REF_CHARS, assertSafeToolResultRef, assertToolResultProvenanceMatch, normalizeToolResultProvenance } from "@sema-agent/core";
39
40
  import { escapeLike } from "./sql-escape.js";
40
41
  import { pgSanitizeText } from "./pg-safe-json.js";
41
42
  import { mysqlDriver, pgDriver } from "./sql-driver.js";
@@ -43,10 +44,14 @@ import { mysqlDriver, pgDriver } from "./sql-driver.js";
43
44
  * (LONGTEXT→TEXT, DATETIME(3)→TIMESTAMPTZ(3), inline KEY→separate CREATE INDEX). Disjoint from other stores'
44
45
  * tables, so a self-contained ensureSchema is safe (central pg-pool aggregation is done separately). */
45
46
  export const PG_TOOL_RESULT_SCHEMA = [
47
+ // #119(core 5.26.0):宽度与出处两列与 TiDB 双生逐字对齐 —— 理由写在 tidb-pool.ts 的同一张 DDL 上
48
+ // (`MAX_MINTED_TOOL_RESULT_REF_CHARS` = 518;截断主键 = 两枚 ref 折成一行 = 身份故障)。
46
49
  `CREATE TABLE IF NOT EXISTS tool_result (
47
- ref VARCHAR(190) COLLATE "C" NOT NULL,
48
- content TEXT COLLATE "C" NOT NULL,
49
- created_at TIMESTAMPTZ(3) NOT NULL,
50
+ ref VARCHAR(518) COLLATE "C" NOT NULL,
51
+ content TEXT COLLATE "C" NOT NULL,
52
+ owner_session_id VARCHAR(190) COLLATE "C" NULL,
53
+ owner_task_id VARCHAR(190) COLLATE "C" NULL,
54
+ created_at TIMESTAMPTZ(3) NOT NULL,
50
55
  PRIMARY KEY (ref)
51
56
  )`,
52
57
  `CREATE INDEX IF NOT EXISTS idx_tool_result_created ON tool_result (created_at)`,
@@ -56,6 +61,49 @@ export async function ensureSchema(pool) {
56
61
  for (const stmt of PG_TOOL_RESULT_SCHEMA)
57
62
  await pool.query(stmt);
58
63
  }
64
+ /**
65
+ * #119 升级前置断言 —— **拒启**,不是 warn(codex 复审 [high],已核真)。
66
+ *
67
+ * 病:本仓不发 `ALTER TABLE` 迁移(标准裁定:改列就改 CREATE + 删库重建)。于是一台**没删表**就升上来的
68
+ * 部署,`CREATE TABLE IF NOT EXISTS` 对它是空操作,旧表既没有出处两列也只有 `VARCHAR(190)` 的 ref 列。
69
+ * 那样跑起来的后果不是「少个功能」:每一次 offload 写都会撞 unknown column 报错,而 core 的 offload
70
+ * 失败臂会把错误吞成一条内联占位("[offload LOST at write time…]")—— 服务照跑,工具产物全丢,日志里只有
71
+ * 一句看不出根因的话。这正是「响亮或 fail-closed,禁静默降级」那条要挡的形态。
72
+ *
73
+ * 判据用**能力探测**而不是版本号/information_schema:发一条恒空的 `WHERE 1=0` 读,列不在就报错。
74
+ * 探不通即拒启,错误文案直接给出两条方言的动作(删表重建),不让运维去猜。
75
+ */
76
+ export async function assertToolResultProvenanceSchema(query, dialect) {
77
+ // ② 键宽(codex 复审 round2 [high],已核真):只探两列会放过「有出处列、但 ref 仍是 VARCHAR(190)」的
78
+ // 半迁移表 —— 而这道断言的文案与 CHANGELOG 都在宣称它护着加宽后的键。截断的主键把两枚不同的 ref 折成
79
+ // 同一行 = 身份故障,正是本次升级要消灭的东西,所以宽度必须**真查**,不能靠「列在 ⇒ 表是新的」推断。
80
+ const widthRow = await query(dialect === "tidb"
81
+ ? "SELECT CHARACTER_MAXIMUM_LENGTH AS len FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'tool_result' AND column_name = 'ref'"
82
+ : "SELECT character_maximum_length AS len FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'tool_result' AND column_name = 'ref'").catch(() => ({ rows: [] }));
83
+ const width = Number(widthRow.rows[0]?.len ?? NaN);
84
+ // 查不到宽度(权限受限的 information_schema / 意外的列类型)⇒ **不**据此拒启:那是「没看见」,不是
85
+ // 「看见了不合格」。看见了才判,判就判死。
86
+ if (Number.isFinite(width) && width < MAX_MINTED_TOOL_RESULT_REF_CHARS) {
87
+ throw new Error(`tool_result.ref is VARCHAR(${width}) but core 5.26.0 mints refs up to ${MAX_MINTED_TOOL_RESULT_REF_CHARS} characters ` +
88
+ `(#119 made the ref injective: four \`~\`-separated segments). A truncating primary key aliases two distinct refs onto ` +
89
+ `one row — one run's tool output reads back from another run's ref. This repository ships no ALTER TABLE migrations: ` +
90
+ `run \`DROP TABLE tool_result;\` on the ${dialect === "pg" ? "PostgreSQL" : "MySQL-protocol"} backend and restart ` +
91
+ `(the schema is recreated at boot; offloaded results are a recoverable-window cache, inline previews are unaffected).`);
92
+ }
93
+ try {
94
+ await query("SELECT owner_session_id, owner_task_id FROM tool_result WHERE 1=0");
95
+ }
96
+ catch (err) {
97
+ throw new Error(`tool_result is missing the #119 provenance columns (owner_session_id / owner_task_id) — refusing to start. ` +
98
+ `This release picks up @sema-agent/core 5.26.0, which changed how an offloaded tool result is addressed ` +
99
+ `(the ref grew past the old VARCHAR(190) key) and made the store record who wrote each row. This repository ` +
100
+ `ships no ALTER TABLE migrations, so the table must be recreated: run \`DROP TABLE tool_result;\` on the ` +
101
+ `${dialect === "pg" ? "PostgreSQL" : "MySQL-protocol"} backend and restart — the schema is recreated at boot. ` +
102
+ `Offloaded tool results are a recoverable-window cache (the inline previews in transcripts are unaffected), ` +
103
+ `so the cost is at most the full text of results from runs still in flight. ` +
104
+ `Underlying probe error: ${err instanceof Error ? err.message : String(err)}`);
105
+ }
106
+ }
59
107
  /** Dual-dialect durable ToolResultStore. See the file header for the dialect-delta ledger. */
60
108
  export class SqlToolResultStore {
61
109
  db;
@@ -87,7 +135,17 @@ export class SqlToolResultStore {
87
135
  assertSafeRef(ref) {
88
136
  assertSafeToolResultRef(ref);
89
137
  }
90
- async put(ref, content) {
138
+ /**
139
+ * #119(core 5.26.0)—— 出处的**写面**。语义一律取 core 单源(`assertToolResultProvenanceMatch`),
140
+ * 本方法只负责把它落到 SQL 上:
141
+ * · **写一次选举同时定属主** —— 内容与属主是同一条 INSERT,不存在「内容写进去了属主还没跟上」的窗口
142
+ * (core 头注允许 file 双对象后端出现这个窗口,单行后端没有,别自造);
143
+ * · 抢输的那一方(IGNORE / DO NOTHING ⇒ affected=0)回读已存属主再判:同属主(或本次无出处)=
144
+ * 幂等空转;异属主 = `ToolResultRefConflictError` typed 拒。**绝不静默 keep-first**:ref 是主键,
145
+ * 静默空转会让第二位写者拿着自己的 ref 读回第一位的字节;
146
+ * · 无出处的行永久 unowned —— 后续带出处的 put 不回填(没有证据的收养),`ownerOf` 继续答 undefined。
147
+ */
148
+ async put(ref, content, provenance) {
91
149
  this.assertSafeRef(ref);
92
150
  // Sanitize invalid UTF-16 (lone/half surrogates — a JS string CAN hold them, e.g. a binary-ish tool
93
151
  // output) to U+FFFD so the column accepts the FULL value via a Buffer utf8 round-trip (valid content is
@@ -102,7 +160,58 @@ export class SqlToolResultStore {
102
160
  if (this.db.dialect === "pg")
103
161
  safe = pgSanitizeText(safe);
104
162
  // write-once / keep-first: a replay re-puts the SAME ref+content → IGNORE / DO NOTHING keeps the original row.
105
- await this.db.query(this.q("INSERT IGNORE INTO tool_result (ref, content, created_at) VALUES (?,?,?)", "INSERT INTO tool_result (ref, content, created_at) VALUES ($1,$2,$3) ON CONFLICT (ref) DO NOTHING"), [ref, safe, new Date()]);
163
+ const owner = provenance !== undefined ? normalizeToolResultProvenance(provenance) : undefined;
164
+ const insert = async () => (await this.db.query(this.q("INSERT IGNORE INTO tool_result (ref, content, owner_session_id, owner_task_id, created_at) VALUES (?,?,?,?,?)", "INSERT INTO tool_result (ref, content, owner_session_id, owner_task_id, created_at) VALUES ($1,$2,$3,$4,$5) ON CONFLICT (ref) DO NOTHING"), [ref, safe, owner?.sessionId ?? null, owner?.taskId ?? null, new Date()])).affected;
165
+ // 🔴 codex 复审(medium,已核真)——**抢输之后那一行可能已经不在了**。选举(INSERT)与观察(SELECT)
166
+ // 是两条语句,中间可以插进 TTL reap 或 §0.5 会话清除。那时 `readOwner` 回 `absent`,而 core 的比较器
167
+ // 对「缺一侧」是**不下判决**的(设计如此:无出处的行不许被收养)—— 于是 put 会**成功返回**,而库里
168
+ // 既没有内容也没有属主,调用方拿着一枚当场读不出来的 ref。
169
+ //
170
+ // 🔴 处置是**抛**,不是重投(codex 复审两轮的合取解;第一版写的重投被第二轮当场驳回,理由成立):
171
+ // 重投会让**删除输**。§0.5 会话清除 DELETE 掉这一行之后,一次重投就把它原地复活 —— 而 core 的部分
172
+ // offload 写点是不 await 的,能活过 active-run 围栏,于是被删会话的产物重新落库、留到 TTL 才消失,
173
+ // 直接违背 E21 的删除权保证。「少存一份可恢复窗口内的缓存」与「删了又回来」不是同一个量级的代价。
174
+ // 抛出去会走到 core 的 offload 失败臂 —— 它把这次卸载记成 "[offload LOST at write time: …]" 并保住
175
+ // 内联预览,模型看到的是实话。
176
+ if ((await insert()) > 0)
177
+ return; // 本次赢下选举:内容与属主同一条语句落地
178
+ const stored = await this.readOwner(ref);
179
+ if (stored.present) {
180
+ // 抢输(或纯重放):属主判定归 core 的单源比较器 —— 「同属主」在两个 store 里不许有两种含义。
181
+ assertToolResultProvenanceMatch(ref, stored.owner, owner);
182
+ return;
183
+ }
184
+ throw new Error(`tool-result store: ref ${JSON.stringify(ref)} lost the write-once election and the elected row was already gone ` +
185
+ `(a concurrent TTL reap or session purge deleted it). Nothing was stored — reporting the loss rather than ` +
186
+ `re-inserting (which would resurrect a purged session's content) or returning a ref that reads back empty.`);
187
+ }
188
+ /** 一次回读同时回答两件事:**行在不在**(reap/purge 窗口的判别位)与**属主是谁**。两件事必须来自同一
189
+ * 条 SELECT —— 分两次问会重新引入它要消灭的那个窗口。 */
190
+ async readOwner(ref) {
191
+ const { rows } = await this.db.query(this.q("SELECT owner_session_id, owner_task_id FROM tool_result WHERE ref = ?", "SELECT owner_session_id, owner_task_id FROM tool_result WHERE ref = $1"), [ref]);
192
+ const row = rows[0];
193
+ if (!row)
194
+ return { present: false, owner: undefined };
195
+ // 🔴 缺席的判别位**只有 SQL NULL**(codex 复审 round2 [medium],已核真):曾经写成
196
+ // `sessionId.length === 0` 也算无属主 —— 那会把一个 `sessionId: ""` 的合法(core 的
197
+ // `normalizeToolResultProvenance` 不拒空串)出处在往返中悄悄折成 unowned,与内存后端的逐字比较分家;
198
+ // `taskId: ""` 折成缺席更糟 —— 授权面会从「task 级」放宽成「整条 session」。字符串一律原样还原。
199
+ const sessionId = row.owner_session_id;
200
+ if (typeof sessionId !== "string")
201
+ return { present: true, owner: undefined }; // NULL 列 = 行在但无属主
202
+ const taskId = row.owner_task_id;
203
+ // 键**缺席**而不是 present-as-undefined:core 的比较器把「缺 taskId」与「显式 undefined」当同一个值,
204
+ // 但调用方(和本仓的 toEqual 钉)看得见键在不在,落库的 NULL 只能还原成缺席形。
205
+ return { present: true, owner: typeof taskId === "string" ? { sessionId, taskId } : { sessionId } };
206
+ }
207
+ /**
208
+ * #119 —— 出处的**读面**。未知 ref 与「存了但无属主」两种情况都答 `undefined`:读面对二者一视同仁
209
+ * (fail-closed,谁都没被授权),所以这里也不必把它们分开报。
210
+ */
211
+ async ownerOf(ref) {
212
+ if (this.isUnsafeRef(ref))
213
+ return undefined; // 与 get 同尺:写面拒过的 ref 必不在库,不把驱动层错误漏出去
214
+ return (await this.readOwner(ref)).owner;
106
215
  }
107
216
  async get(ref, opts = {}) {
108
217
  // D1 读面半条(RB-266「the read face degrades instead」):unsafe ref 在读面**降级**返回 undefined
@@ -147,11 +256,18 @@ export class SqlToolResultStore {
147
256
  * data-layer protection that matters (a crafted id can't widen the prefix across tenants).
148
257
  */
149
258
  async deleteBySession(sessionId) {
150
- // Escape the WHOLE literal prefix `tr_<sessionId>_` (incl. both literal underscores) so the only LIKE wildcard
151
- // is the trailing `%` a `_` left un-escaped is a single-char wildcard that could over-match across tenants.
152
- const prefix = `${escapeLike(`tr_${sessionId}_`)}%`;
259
+ // Escape the WHOLE literal prefix `tr_<sessionId><sep>` (incl. the literal underscore in `tr_` and, on the
260
+ // legacy arm, the separator underscore) so the only LIKE wildcard is the trailing `%` a `_` left
261
+ // un-escaped is a single-char wildcard that could over-match across tenants.
262
+ //
263
+ // 🔴 #119(core 5.26.0):**两个前缀**,因为铸法在 5.26.0 换了分隔符。5.25.0 及以前铸 `tr_<sid>_<callId>`,
264
+ // 5.26.0 起铸 `tr_<sid>~<callId>~<contentSeg>`(单射 ref)。只留旧前缀 = 升级后一行也命不中,§0.5 会话
265
+ // 删除**静默清零**;只留新前缀 = 升级前写下的存量行永远删不掉。删除面漏删比多跑一次 LIKE 贵得多,
266
+ // 所以两条都发,而且都保持「整段字面量转义 + 只有末尾 % 是通配」的老纪律。
267
+ const legacyPrefix = `${escapeLike(`tr_${sessionId}_`)}%`;
268
+ const modernPrefix = `${escapeLike(`tr_${sessionId}~`)}%`;
153
269
  // Explicit `ESCAPE` clause — see the file-header dialect-delta note for why each dialect spells it as it does.
154
- const { affected } = await this.db.query(this.q("DELETE FROM tool_result WHERE ref LIKE ? ESCAPE '\\\\'", "DELETE FROM tool_result WHERE ref LIKE $1 ESCAPE '\\'"), [prefix]);
270
+ const { affected } = await this.db.query(this.q("DELETE FROM tool_result WHERE ref LIKE ? ESCAPE '\\\\' OR ref LIKE ? ESCAPE '\\\\'", "DELETE FROM tool_result WHERE ref LIKE $1 ESCAPE '\\' OR ref LIKE $2 ESCAPE '\\'"), [legacyPrefix, modernPrefix]);
155
271
  return affected;
156
272
  }
157
273
  }
@@ -15,7 +15,9 @@
15
15
  *
16
16
  * ── 立案:装配层四条缺口([2176] 我方认领,2026-08-01 调研,**尚未实施**)────────────────────
17
17
  *
18
- * ① **`opts` 只有 tavily 腿在消费**(`brave`/`searxng` 的 switch 分支不传 `opts`)。
18
+ * ① **`opts` 仍有 brave 腿不消费**(只剩 `brave` 的 switch 分支不传 `opts`;`searxng` 腿已随 ② 的
19
+ * 还债改动接住 `opts` 并透传给 core 的 adapter,由它把 `allowedDomains` 原生下推成 `site:` 前缀。
20
+ * 立案时的原话是「`opts` 只有 tavily 腿在消费(`brave`/`searxng` 都不传)」,② 落地后已部分还清)。
19
21
  * 定性:**优化缺失,不是正确性缺口** —— 上面那句「core 再执行一次 FLOOR」经亲验属实
20
22
  * (core `dist/tools/web.js` 的 `webSearchResultAllowed(url, allowed, blocked)`,按 hostname
21
23
  * 逐条过滤 blocked/allowed)。所以模型请求的域限制**不会**被静默丢弃。
@@ -15,7 +15,9 @@
15
15
  *
16
16
  * ── 立案:装配层四条缺口([2176] 我方认领,2026-08-01 调研,**尚未实施**)────────────────────
17
17
  *
18
- * ① **`opts` 只有 tavily 腿在消费**(`brave`/`searxng` 的 switch 分支不传 `opts`)。
18
+ * ① **`opts` 仍有 brave 腿不消费**(只剩 `brave` 的 switch 分支不传 `opts`;`searxng` 腿已随 ② 的
19
+ * 还债改动接住 `opts` 并透传给 core 的 adapter,由它把 `allowedDomains` 原生下推成 `site:` 前缀。
20
+ * 立案时的原话是「`opts` 只有 tavily 腿在消费(`brave`/`searxng` 都不传)」,② 落地后已部分还清)。
19
21
  * 定性:**优化缺失,不是正确性缺口** —— 上面那句「core 再执行一次 FLOOR」经亲验属实
20
22
  * (core `dist/tools/web.js` 的 `webSearchResultAllowed(url, allowed, blocked)`,按 hostname
21
23
  * 逐条过滤 blocked/allowed)。所以模型请求的域限制**不会**被静默丢弃。
package/dist/security.js CHANGED
@@ -171,7 +171,9 @@ export function createAuthorizer(config, sessionStore) {
171
171
  }
172
172
  }
173
173
  // Enforce ownership whenever the session HAS an owner — including against callers that present
174
- // no principal (audit B, security.ts:77): with the old `principal &&` guard, a caller that
174
+ // no principal (audit B the fix IS this guard, right here in `createAuthorizer`; no line-number
175
+ // anchor on purpose, the old `security.ts:77` pointer had drifted onto an unrelated JSDoc):
176
+ // with the old `principal &&` guard, a caller that
175
177
  // omitted the header under requirePrincipal=false could attach to ANY owned session by id.
176
178
  // Anonymous callers may still attach to anonymous (owner=null) sessions, so single-tenant dev
177
179
  // (no principals anywhere) is unaffected; flipping the requirePrincipal default is NOT needed.
@@ -782,6 +782,25 @@ export class ToolApprovalCoordinator {
782
782
  // ③ 引擎真铸了候选(`req.ruleSuggestions` 非空)—— 命令不可匹配(复合/重定向/替换)时 core 交空数组;
783
783
  // ④ 命令原字节读得出(回决时**引擎**要拿它重铸候选;读不出就没有可兑付的路,投候选等于骗人)。
784
784
  // 读 `req.args.command` 走窄读:`args` 是 `unknown`,禁裸 as-cast(宪法 [2704])。
785
+ // 🔴 **core 5.27.0 起引擎自己也收窄了产源**(#231 提货,[3612]):mandated(`shellGate:"always"` /
786
+ // 工具自带 egress / irreversibility `always|maybe` 标记;⚠️ **不含 org 规则**,亲读 `persistedRuleMandateOf`
787
+ // 只收 {egress, irreversibility, shellGated} 三位)、`requiresRealApproval`、shadowed、hook 产、inherited-unresolved、ancestor-resolved、
788
+ // anonymous(无 principal 且未声明 local-owner)六族 ask **一律不带** `ruleSuggestions`(亲读
789
+ // `dist/core/runner/prepare-task.js` 的 `ruleSuggestionsOf`,不是读 CHANGELOG)。这与本仓那句
790
+ // 「发一格按下去无处可兑的『不再询问』= wire 谎言」是**同一条判据**,只是这次由引擎在源头执行。
791
+ // 上面四个合取项**一个都不删**:①③④ 是我方独有的前置(店装配 / 空数组不铸键 / 命令可读),
792
+ // ② 的 `governanceForced` 是**我方**的治理标(部署侧 AUTONOMY/commandPolicy/SENSITIVE_WRITE_PATTERNS,
793
+ // 引擎的 mandated 词表里没有它),六族里的另外四族只有引擎判得出(消音谓词 / hook 出处 / 继承未决 /
794
+ // 祖先决议),所以两侧不是重复,是各管各的那一段。
795
+ // 唯一**重叠**的是 anonymous 那一族:引擎的判据是 `spec.principal` 空且 `deps.localOwnerRules !== true`
796
+ // (我方从不设后者,亲验 `grep -rn localOwnerRules src` 只有 adoption 计划表里的一行声明位),
797
+ // 而我方的判据是 ask 的 `owner === null` —— 两者同源(`askOwner = gatedPrincipal(req) ?? null` 与
798
+ // `spec.principal = auth?.principal` 取自同一次鉴权)⇒ **本批对单机匿名部署零行为变化**(那种卡在
799
+ // #203 codex R1-F1 之后本来就不带候选)。⚠️ 记档:若将来要让单机形也能「不再询问」,正路是声明
800
+ // `RunnerDeps.localOwnerRules`(core 会在 provider 缺 `forLocalOwner` 时**抛**,不静默),而不是把
801
+ // 我方这道 owner 门放宽 —— 那会造出一张按下去无处可兑的卡。
802
+ // 候选**基数**同批从 ≤1 变成 ≤2(reviewed 前缀候选,`exact` 恒 index 0)——本层逐字透传整只数组,
803
+ // 不截长不重排;兑付口按**人报的文本**定位(`rules-consent.ts` 的 `findIndex`,不是 `[0]`)。
785
804
  // `let`:上面那条并集裁定可能在对账后把它撤回 undefined(行说这是治理门)。
786
805
  let ruleLaneMaterial = this.buildRuleLaneMaterial(req, primary.owner, governanceForced);
787
806
  const frame = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "7.13.0",
3
+ "version": "7.14.0",
4
4
  "description": "Sema Server — the server/API implementation layer for Sema, wiring core, registry, model providers, and cloud agent execution. Built on @sema-agent/core.",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -54,7 +54,7 @@
54
54
  "build:binary:run-local:darwin-arm64": "bun build --compile --target=bun-darwin-arm64 src/run-local.ts --outfile dist/run-local-darwin-arm64"
55
55
  },
56
56
  "dependencies": {
57
- "@sema-agent/core": "^5.25.0",
57
+ "@sema-agent/core": "^5.27.0",
58
58
  "@sema-agent/registry-core": "^0.16.0",
59
59
  "e2b": "^2.28.0",
60
60
  "libsodium-wrappers": "^0.8.4",
@@ -69,7 +69,7 @@
69
69
  "sharp": "^0.35.3"
70
70
  },
71
71
  "devDependencies": {
72
- "@sema-agent/sdk": "^6.14.0",
72
+ "@sema-agent/sdk": "^6.15.0",
73
73
  "@types/libsodium-wrappers": "^0.7.14",
74
74
  "@types/node": "22.10.2",
75
75
  "@types/pg": "^8.20.0",