@ykurilov/agent-chat-sandbox 0.0.0-stage → 0.55.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/CHANGELOG.md +829 -0
- package/LICENSE +21 -0
- package/README.md +59 -2
- package/dist/chunk-4O5NPJOS.js +483 -0
- package/dist/chunk-4O5NPJOS.js.map +1 -0
- package/dist/index.js +695 -0
- package/dist/index.js.map +1 -0
- package/dist/postgres.js +236 -0
- package/dist/postgres.js.map +1 -0
- package/dist/react.js +1045 -0
- package/dist/react.js.map +1 -0
- package/dist/sqlite.js +221 -0
- package/dist/sqlite.js.map +1 -0
- package/dist/types/index.d.ts +6 -0
- package/dist/types/journal/hide-secrets.d.ts +32 -0
- package/dist/types/journal/journal-blobs.d.ts +25 -0
- package/dist/types/journal/journal-store.d.ts +77 -0
- package/dist/types/journal/journal.d.ts +19 -0
- package/dist/types/journal/memory-journal.d.ts +8 -0
- package/dist/types/journal/postgres-journal.d.ts +9 -0
- package/dist/types/journal/postgres-schema.d.ts +936 -0
- package/dist/types/journal/sqlite-journal.d.ts +4 -0
- package/dist/types/journal/sqlite-schema.d.ts +1001 -0
- package/dist/types/people/copy-person-chat.d.ts +28 -0
- package/dist/types/people/people-routes.d.ts +25 -0
- package/dist/types/people/staff-copies.d.ts +46 -0
- package/dist/types/postgres.d.ts +14 -0
- package/dist/types/react/agent-sandbox.d.ts +25 -0
- package/dist/types/react/answer-edit-draft.d.ts +42 -0
- package/dist/types/react/answer-editor-host.d.ts +13 -0
- package/dist/types/react/chat-reopen.d.ts +9 -0
- package/dist/types/react/instruction-header.d.ts +10 -0
- package/dist/types/react/journal-panel.d.ts +17 -0
- package/dist/types/react/journal-view.d.ts +61 -0
- package/dist/types/react/people-chats-panel.d.ts +18 -0
- package/dist/types/react/read-only-chat-skin.d.ts +8 -0
- package/dist/types/react/sandbox-client.d.ts +36 -0
- package/dist/types/react/sandbox-labels.d.ts +118 -0
- package/dist/types/react/sandbox-skin.d.ts +260 -0
- package/dist/types/react.d.ts +5 -0
- package/dist/types/read-only-memory.d.ts +18 -0
- package/dist/types/sandbox-handler.d.ts +15 -0
- package/dist/types/sandbox-owner.d.ts +6 -0
- package/dist/types/sandbox-tools.d.ts +14 -0
- package/dist/types/sqlite.d.ts +9 -0
- package/dist/types/types.d.ts +219 -0
- package/drizzle/postgres/0000_journal_init.sql +79 -0
- package/drizzle/postgres/meta/0000_snapshot.json +551 -0
- package/drizzle/postgres/meta/_journal.json +13 -0
- package/drizzle/sqlite/0000_journal_init.sql +77 -0
- package/drizzle/sqlite/meta/0000_snapshot.json +499 -0
- package/drizzle/sqlite/meta/_journal.json +13 -0
- package/package.json +95 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yury Kurilov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,60 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @ykurilov/agent-chat-sandbox
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Песочница агента для админа приложения: чат с боевым агентом, журнал каждого обращения к модели и вызова инструмента,
|
|
4
|
+
правка любого сообщения с веткой, своя модель и своя системная инструкция на чат. Отдельное расширение: приложению без
|
|
5
|
+
песочницы пакет не нужен, и в боевом чате ничего не меняется (ни таблиц, ни записи журнала).
|
|
6
|
+
|
|
7
|
+
`react`, `@ykurilov/agent-chat-react`, `better-sqlite3` и `pg` — необязательные зависимости (peer): ставьте те, что нужны.
|
|
8
|
+
|
|
9
|
+
## Сервер
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { createSandboxHandler } from "@ykurilov/agent-chat-sandbox";
|
|
13
|
+
import { createSqliteSandboxJournal } from "@ykurilov/agent-chat-sandbox/sqlite"; // или …/postgres
|
|
14
|
+
import { mountChat } from "@ykurilov/agent-chat-server/hono";
|
|
15
|
+
|
|
16
|
+
mountChat(app, createChatHandler(chatOptions));
|
|
17
|
+
mountChat(app, createSandboxHandler({
|
|
18
|
+
chat: chatOptions, // те же настройки, что у боевого чата
|
|
19
|
+
isAdmin: (user) => adminIds.has(user.id), // на каждом запросе; не админ получает 404
|
|
20
|
+
journal: await createSqliteSandboxJournal({ filePath: "app.sqlite" }),
|
|
21
|
+
}));
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Next.js: `createNextRouteHandlers(sandboxHandler)` в `app/api/sandbox/[[...path]]/route.ts`.
|
|
25
|
+
|
|
26
|
+
## Экран
|
|
27
|
+
|
|
28
|
+
```tsx
|
|
29
|
+
import { AgentSandbox } from "@ykurilov/agent-chat-sandbox/react";
|
|
30
|
+
import { tandemSandboxSkin } from "@ykurilov/agent-chat-skin-tandem/sandbox";
|
|
31
|
+
|
|
32
|
+
<AgentSandbox skin={tandemSkin} sandboxSkin={tandemSandboxSkin} chatListSkin={tandemSkin} chatProps={{ attachments, images, videos }} />
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Что важно знать
|
|
36
|
+
|
|
37
|
+
- **Доступ.** `resolveUser` → `isAdmin` на каждом запросе, результат не кэшируется. Песочные чаты личные: владелец
|
|
38
|
+
`sandbox:<id админа>`, боевые адреса, списки и траты их не видят. `isSandboxOwner(ownerId)` отделяет их в отчётах о тратах.
|
|
39
|
+
- **Журнал.** Таблицы `sandbox_*` в SQLite или PostgreSQL (`createMemorySandboxJournal` для тестов), миграции сами. Живёт 30 дней
|
|
40
|
+
(`retentionDays`), удаляется вместе с чатом. Запись не задерживает ответ; ключи из окружения (по именам KEY, TOKEN, SECRET… от 8 знаков), пароли из адресов подключения и заголовки не пишутся.
|
|
41
|
+
- **Память только на чтение.** Агент видит боевую память админа, записать ничего не может; экран памяти в песочнице закрыт.
|
|
42
|
+
- **Правка ответа.** Меняются текст и результаты обычных инструментов; вызовы, картинки, видео и ответы на виджеты остаются,
|
|
43
|
+
рассуждения убираются. `chat.lockedToolOutputs` добавляет инструменты, чей результат менять нельзя.
|
|
44
|
+
- **Инструменты.** В `agent.tools(model)` приходит настоящий `ownerId` админа. Инструментам из `chatOwnedTools` (по умолчанию
|
|
45
|
+
`generate_images`) — песочный владелец: файлы и траты принадлежат песочному чату.
|
|
46
|
+
- **Закрытый чат.** Инструмент закрывает песочный чат так же, как боевой: видно, что закрытие работает. Продолжить тест —
|
|
47
|
+
кнопка «Открыть чат снова» (`POST {sandboxBase}/chats/:chatId/reopen`) или новая копия чата человека: она всегда открыта.
|
|
48
|
+
Права приложения (`chatCapabilities`) и `beforeCreateChat` песочница не применяет.
|
|
49
|
+
- **Осторожно.** Инструменты приложения в песочнице выполняются по-настоящему от имени админа (письма, платежи, запись в базу). Опасные подмените заглушками: см. docs/sandbox.md, «Инструменты агента».
|
|
50
|
+
- **Чаты людей.** Только с опцией `people` у обработчика и `people` у `AgentSandbox`: `people: { find }` — человека ищет
|
|
51
|
+
приложение, `people: true` — его id вводят вручную. Без опции раздела нет, экран прежний. Чат человека только читается;
|
|
52
|
+
«Проверить в копии» делает песочный чат админа, агент в нём читает память человека. В копии `agent.tools(model)` получает
|
|
53
|
+
`ownerId` человека, `model.staff` и объект исходного чата (`model.subject`, ключ копии наружу не идёт): при
|
|
54
|
+
`model.staff` любая запись в данные `ownerId` запрещена — это обязанность инструментов приложения, ядро их записи
|
|
55
|
+
не перехватывает. `afterTurn` приложения зовётся только в копии, так же с `staff`, человеком и его объектом: при
|
|
56
|
+
`turn.staff` в данные человека не писать. В обычном песочном чате `afterTurn` и `beforeTurn` не зовутся.
|
|
57
|
+
- **Лимиты.** Картинки считаются на песочного владельца по правилам приложения; у модели лимит только если задан
|
|
58
|
+
`createSandboxHandler({ limits })`.
|
|
59
|
+
|
|
60
|
+
Подробно — [docs/sandbox.md](../../docs/sandbox.md). Живая проверка: `pnpm test:live sandbox`.
|
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
// src/journal/hide-secrets.ts
|
|
2
|
+
var HIDDEN = "[\u0441\u043A\u0440\u044B\u0442\u043E]";
|
|
3
|
+
var MIN_ENV_SECRET_LENGTH = 8;
|
|
4
|
+
var MIN_URL_PASSWORD_LENGTH = 6;
|
|
5
|
+
var SECRET_ENV_NAME = /KEY|TOKEN|SECRET|PASSWORD|PASSWD|PASS|AUTH|CREDENTIAL|PRIVATE/i;
|
|
6
|
+
var URL_WITH_PASSWORD = /^[a-z][a-z0-9+.-]*:\/\/[^/\s:@]*:([^@\s]+)@/i;
|
|
7
|
+
var MIN_FIELD_SECRET_LENGTH = 4;
|
|
8
|
+
var MIN_ENCODED_LENGTH = 16;
|
|
9
|
+
var SECRET_FIELD_NAME = /^(authorization|proxy-authorization|x-api-key|x-goog-api-key|api[-_]?key|apikey|access[-_]?token|auth[-_]?token|refresh[-_]?token|token|secret|client[-_]?secret|password|passwd|bearer|cookie|set-cookie)$/i;
|
|
10
|
+
var BASE64_LIKE = /^[A-Za-z0-9+/_-]+={0,2}$/;
|
|
11
|
+
function createSecretHider(options = {}) {
|
|
12
|
+
let fixed = unique([...secretsFromEnv(options.env ?? process.env), ...options.secrets ?? []]);
|
|
13
|
+
let bySettings = /* @__PURE__ */ new WeakMap();
|
|
14
|
+
let bySecrets = /* @__PURE__ */ new Map();
|
|
15
|
+
return {
|
|
16
|
+
forSettings(settings) {
|
|
17
|
+
const cached = settings !== null && typeof settings === "object" ? bySettings.get(settings) : void 0;
|
|
18
|
+
if (cached) return cached;
|
|
19
|
+
const secrets = unique([...fixed, ...secretFieldValues(settings)]);
|
|
20
|
+
const key = secrets.join("\0");
|
|
21
|
+
let hider = bySecrets.get(key);
|
|
22
|
+
if (!hider) {
|
|
23
|
+
hider = buildHider(secrets);
|
|
24
|
+
if (bySecrets.size >= MAX_CACHED_HIDERS) bySecrets.delete(bySecrets.keys().next().value);
|
|
25
|
+
bySecrets.set(key, hider);
|
|
26
|
+
}
|
|
27
|
+
if (settings !== null && typeof settings === "object") bySettings.set(settings, hider);
|
|
28
|
+
return hider;
|
|
29
|
+
},
|
|
30
|
+
addSecrets(values) {
|
|
31
|
+
const added = values.filter((value) => value.length > 0);
|
|
32
|
+
fixed = unique([...fixed, ...added]);
|
|
33
|
+
bySettings = /* @__PURE__ */ new WeakMap();
|
|
34
|
+
bySecrets = /* @__PURE__ */ new Map();
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
var MAX_CACHED_HIDERS = 16;
|
|
39
|
+
function secretsFromEnv(env) {
|
|
40
|
+
const found = [];
|
|
41
|
+
for (const [name, value] of Object.entries(env)) {
|
|
42
|
+
if (typeof value !== "string") continue;
|
|
43
|
+
if (SECRET_ENV_NAME.test(name) && value.length >= MIN_ENV_SECRET_LENGTH) found.push(value);
|
|
44
|
+
const password = URL_WITH_PASSWORD.exec(value)?.[1];
|
|
45
|
+
if (password && password.length >= MIN_URL_PASSWORD_LENGTH) {
|
|
46
|
+
found.push(password);
|
|
47
|
+
try {
|
|
48
|
+
found.push(decodeURIComponent(password));
|
|
49
|
+
} catch {
|
|
50
|
+
}
|
|
51
|
+
if (value.length >= MIN_ENV_SECRET_LENGTH) found.push(value);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return found;
|
|
55
|
+
}
|
|
56
|
+
function buildHider(secrets) {
|
|
57
|
+
const raw = withVariants(secrets);
|
|
58
|
+
const escaped = escapedVariants(secrets);
|
|
59
|
+
function hideInString(text) {
|
|
60
|
+
let result = text;
|
|
61
|
+
for (const secret of raw) if (result.includes(secret)) result = result.split(secret).join(HIDDEN);
|
|
62
|
+
if (result.length >= MIN_ENCODED_LENGTH && BASE64_LIKE.test(result) && decodesToSecret(result, secrets)) return HIDDEN;
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
function walk(value, onKey) {
|
|
66
|
+
if (typeof value === "string") return hideInString(value);
|
|
67
|
+
if (Array.isArray(value)) return value.map((item) => walk(item, onKey));
|
|
68
|
+
if (value !== null && typeof value === "object") {
|
|
69
|
+
const copy = {};
|
|
70
|
+
for (const [key, child] of Object.entries(value)) {
|
|
71
|
+
const replaced = onKey?.(key, child);
|
|
72
|
+
copy[hideInString(key)] = replaced !== void 0 ? replaced : walk(child, onKey);
|
|
73
|
+
}
|
|
74
|
+
return copy;
|
|
75
|
+
}
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
value: (value) => secrets.length === 0 ? value : walk(value),
|
|
80
|
+
settings: (settings) => walk(settings, (key, child) => SECRET_FIELD_NAME.test(key) && typeof child === "string" ? HIDDEN : void 0),
|
|
81
|
+
text(text) {
|
|
82
|
+
let result = text;
|
|
83
|
+
for (const secret of escaped) if (result.includes(secret)) result = result.split(secret).join(HIDDEN);
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function secretFieldValues(value, found = []) {
|
|
89
|
+
if (Array.isArray(value)) {
|
|
90
|
+
for (const item of value) secretFieldValues(item, found);
|
|
91
|
+
} else if (value !== null && typeof value === "object") {
|
|
92
|
+
for (const [key, child] of Object.entries(value)) {
|
|
93
|
+
if (SECRET_FIELD_NAME.test(key) && typeof child === "string") {
|
|
94
|
+
const trimmed = child.trim();
|
|
95
|
+
if (trimmed.length >= MIN_FIELD_SECRET_LENGTH) found.push(trimmed);
|
|
96
|
+
const token = /^(?:bearer|basic|token)\s+(\S+)$/i.exec(trimmed)?.[1];
|
|
97
|
+
if (token && token.length >= MIN_FIELD_SECRET_LENGTH) found.push(token);
|
|
98
|
+
} else {
|
|
99
|
+
secretFieldValues(child, found);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return found;
|
|
104
|
+
}
|
|
105
|
+
function withVariants(secrets) {
|
|
106
|
+
const variants = secrets.flatMap((secret) => [secret, Buffer.from(secret).toString("base64"), encodeURIComponent(secret)]);
|
|
107
|
+
return unique(variants).filter((variant) => variant.length > 0).sort((a, b) => b.length - a.length);
|
|
108
|
+
}
|
|
109
|
+
function escapedVariants(secrets) {
|
|
110
|
+
const variants = secrets.flatMap((secret) => {
|
|
111
|
+
const once = JSON.stringify(secret).slice(1, -1);
|
|
112
|
+
const twice = JSON.stringify(once).slice(1, -1);
|
|
113
|
+
return [secret, once, twice];
|
|
114
|
+
});
|
|
115
|
+
return unique(variants).sort((a, b) => b.length - a.length);
|
|
116
|
+
}
|
|
117
|
+
function decodesToSecret(text, secrets) {
|
|
118
|
+
let decoded;
|
|
119
|
+
try {
|
|
120
|
+
decoded = Buffer.from(text.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
|
|
121
|
+
} catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
return secrets.some((secret) => decoded.includes(secret));
|
|
125
|
+
}
|
|
126
|
+
function unique(values) {
|
|
127
|
+
return [...new Set(values)];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// src/journal/journal-blobs.ts
|
|
131
|
+
import { createHash } from "crypto";
|
|
132
|
+
import { promisify } from "util";
|
|
133
|
+
import { gunzip as gunzipCallback, gzip as gzipCallback } from "zlib";
|
|
134
|
+
var gzip = promisify(gzipCallback);
|
|
135
|
+
var gunzip = promisify(gunzipCallback);
|
|
136
|
+
var BLOB_LIMIT_BYTES = 256 * 1024;
|
|
137
|
+
var MIN_BARE_BASE64_LENGTH = 1024;
|
|
138
|
+
var BARE_BASE64 = /^[A-Za-z0-9+/_-]+={0,2}$/;
|
|
139
|
+
var DATA_URL = /^data:([^;,]*)(;[^,]*)?,/;
|
|
140
|
+
function describeSize(bytes) {
|
|
141
|
+
if (bytes < 1024) return `${bytes} \u0411`;
|
|
142
|
+
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} \u041A\u0411`;
|
|
143
|
+
return `${(bytes / (1024 * 1024)).toFixed(1).replace(".", ",")} \u041C\u0411`;
|
|
144
|
+
}
|
|
145
|
+
async function prepareBlob(value, hider) {
|
|
146
|
+
if (value === null || typeof value !== "object") return packText(hider.text(JSON.stringify(hider.value(toPlain(value))) ?? "null"));
|
|
147
|
+
const byObject = preparedBlobs.get(hider) ?? /* @__PURE__ */ new WeakMap();
|
|
148
|
+
preparedBlobs.set(hider, byObject);
|
|
149
|
+
const cached = byObject.get(value);
|
|
150
|
+
if (cached) return cached;
|
|
151
|
+
const prepared = packText(hider.text(JSON.stringify(hider.value(toPlain(value))) ?? "null"));
|
|
152
|
+
byObject.set(value, prepared);
|
|
153
|
+
prepared.catch(() => byObject.delete(value));
|
|
154
|
+
return prepared;
|
|
155
|
+
}
|
|
156
|
+
var preparedBlobs = /* @__PURE__ */ new WeakMap();
|
|
157
|
+
async function packText(text) {
|
|
158
|
+
const hash = createHash("sha256").update(text).digest("hex");
|
|
159
|
+
const bytes = Buffer.byteLength(text);
|
|
160
|
+
const truncated = bytes > BLOB_LIMIT_BYTES;
|
|
161
|
+
const stored = truncated ? JSON.stringify(cut(text, bytes)) : text;
|
|
162
|
+
return { hash, data: await gzip(Buffer.from(stored)), bytes, truncated };
|
|
163
|
+
}
|
|
164
|
+
function cut(text, bytes) {
|
|
165
|
+
const head = Buffer.from(text).subarray(0, BLOB_LIMIT_BYTES).toString("utf8").replace(/�+$/, "");
|
|
166
|
+
return `${head}
|
|
167
|
+
\u2026[\u043E\u0431\u0440\u0435\u0437\u0430\u043D\u043E: \u043F\u043E\u043A\u0430\u0437\u0430\u043D\u043E ${describeSize(Buffer.byteLength(head))} \u0438\u0437 ${describeSize(bytes)}]`;
|
|
168
|
+
}
|
|
169
|
+
async function unpackBlob(row) {
|
|
170
|
+
const text = (await gunzip(row.data)).toString("utf8");
|
|
171
|
+
return { hash: row.hash, content: JSON.parse(text), bytes: row.bytes, truncated: row.truncated };
|
|
172
|
+
}
|
|
173
|
+
async function splitModelCall(event, hider) {
|
|
174
|
+
const { request, response } = event;
|
|
175
|
+
const eventHider = hider;
|
|
176
|
+
const settings = await packText(eventHider.text(JSON.stringify(eventHider.settings(toPlain(request.settings))) ?? "null"));
|
|
177
|
+
const instructions = await prepareBlob(request.instructions, eventHider);
|
|
178
|
+
const messages = await Promise.all(request.messages.map((message) => prepareBlob(message, eventHider)));
|
|
179
|
+
const tools = await prepareBlob(request.tools, eventHider);
|
|
180
|
+
const answer = await prepareBlob(response, eventHider);
|
|
181
|
+
const row = {
|
|
182
|
+
id: event.callId,
|
|
183
|
+
chatId: event.chatId,
|
|
184
|
+
ownerId: event.ownerId,
|
|
185
|
+
messageId: event.messageId,
|
|
186
|
+
purpose: event.purpose,
|
|
187
|
+
attempt: event.attempt,
|
|
188
|
+
modelId: event.modelId,
|
|
189
|
+
providerId: event.providerId,
|
|
190
|
+
usage: event.usage,
|
|
191
|
+
costUsd: event.costUsd,
|
|
192
|
+
startedAt: event.startedAt,
|
|
193
|
+
firstChunkMs: event.firstChunkMs,
|
|
194
|
+
durationMs: event.durationMs,
|
|
195
|
+
status: event.status,
|
|
196
|
+
error: hideText(event.error, eventHider),
|
|
197
|
+
instructionsHash: instructions.hash,
|
|
198
|
+
messageHashes: messages.map((blob) => blob.hash),
|
|
199
|
+
toolsHash: tools.hash,
|
|
200
|
+
settingsHash: settings.hash,
|
|
201
|
+
responseHash: answer.hash,
|
|
202
|
+
toolCallIds: response.toolCalls.map((call) => call.toolCallId)
|
|
203
|
+
};
|
|
204
|
+
return { row, blobs: uniqueBlobs([instructions, ...messages, tools, settings, answer]) };
|
|
205
|
+
}
|
|
206
|
+
async function splitToolCall(event, hider) {
|
|
207
|
+
const input = await prepareBlob(event.input, hider);
|
|
208
|
+
const output = event.output === void 0 ? null : await prepareBlob(event.output, hider);
|
|
209
|
+
const row = {
|
|
210
|
+
id: crypto.randomUUID(),
|
|
211
|
+
chatId: event.chatId,
|
|
212
|
+
ownerId: event.ownerId,
|
|
213
|
+
messageId: event.messageId,
|
|
214
|
+
toolCallId: event.toolCallId,
|
|
215
|
+
toolName: event.toolName,
|
|
216
|
+
inputHash: input.hash,
|
|
217
|
+
outputHash: output?.hash ?? null,
|
|
218
|
+
error: hideText(event.error, hider),
|
|
219
|
+
startedAt: event.startedAt,
|
|
220
|
+
durationMs: event.durationMs
|
|
221
|
+
};
|
|
222
|
+
return { row, blobs: uniqueBlobs(output ? [input, output] : [input]) };
|
|
223
|
+
}
|
|
224
|
+
function hideText(text, hider) {
|
|
225
|
+
return text === null ? null : hider.text(String(hider.value(text)));
|
|
226
|
+
}
|
|
227
|
+
function uniqueBlobs(blobs) {
|
|
228
|
+
return [...new Map(blobs.map((blob) => [blob.hash, blob])).values()];
|
|
229
|
+
}
|
|
230
|
+
var BINARY_KINDS = [
|
|
231
|
+
["image/", "\u043A\u0430\u0440\u0442\u0438\u043D\u043A\u0430"],
|
|
232
|
+
["audio/", "\u0437\u0432\u0443\u043A"],
|
|
233
|
+
["video/", "\u0432\u0438\u0434\u0435\u043E"]
|
|
234
|
+
];
|
|
235
|
+
function describeBinary(mediaType, bytes) {
|
|
236
|
+
const kind = BINARY_KINDS.find(([prefix]) => mediaType?.startsWith(prefix))?.[1] ?? (mediaType ? "\u0444\u0430\u0439\u043B" : "\u0434\u0432\u043E\u0438\u0447\u043D\u044B\u0435 \u0434\u0430\u043D\u043D\u044B\u0435");
|
|
237
|
+
return [kind, mediaType, describeSize(bytes)].filter((part) => part !== null).join(", ");
|
|
238
|
+
}
|
|
239
|
+
function binaryLength(value) {
|
|
240
|
+
if (value instanceof ArrayBuffer) return value.byteLength;
|
|
241
|
+
if (ArrayBuffer.isView(value)) return value.byteLength;
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
function base64Bytes(text) {
|
|
245
|
+
const padding = text.endsWith("==") ? 2 : text.endsWith("=") ? 1 : 0;
|
|
246
|
+
return Math.max(0, Math.floor(text.length * 3 / 4) - padding);
|
|
247
|
+
}
|
|
248
|
+
function toPlain(value, seen = /* @__PURE__ */ new WeakSet(), mediaType = null) {
|
|
249
|
+
if (typeof value === "string") return plainString(value, mediaType);
|
|
250
|
+
if (value === null || typeof value === "number" || typeof value === "boolean") return value;
|
|
251
|
+
if (typeof value === "bigint") return value.toString();
|
|
252
|
+
if (value === void 0 || typeof value === "function" || typeof value === "symbol") return void 0;
|
|
253
|
+
const length = binaryLength(value);
|
|
254
|
+
if (length !== null) return describeBinary(mediaType, length);
|
|
255
|
+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value.toISOString();
|
|
256
|
+
if (value instanceof URL) return value.href;
|
|
257
|
+
if (value instanceof Error) return { name: value.name, message: value.message };
|
|
258
|
+
const object = value;
|
|
259
|
+
if (seen.has(object)) return "[\u0446\u0438\u043A\u043B]";
|
|
260
|
+
seen.add(object);
|
|
261
|
+
try {
|
|
262
|
+
if (Array.isArray(object)) return object.map((item) => toPlain(item, seen, mediaType) ?? null);
|
|
263
|
+
const record = object;
|
|
264
|
+
const ownType = typeof record.mediaType === "string" ? record.mediaType : typeof record.mimeType === "string" ? record.mimeType : null;
|
|
265
|
+
const copy = {};
|
|
266
|
+
for (const [key, child] of Object.entries(record)) {
|
|
267
|
+
const plain = toPlain(child, seen, key === "data" || key === "image" ? ownType : null);
|
|
268
|
+
if (plain !== void 0) copy[key] = plain;
|
|
269
|
+
}
|
|
270
|
+
return copy;
|
|
271
|
+
} finally {
|
|
272
|
+
seen.delete(object);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
function plainString(text, mediaType) {
|
|
276
|
+
const dataUrl = DATA_URL.exec(text);
|
|
277
|
+
if (dataUrl) return describeBinary(dataUrl[1] || null, dataUrl[2]?.includes("base64") ? base64Bytes(text.slice(dataUrl[0].length)) : Buffer.byteLength(text) - dataUrl[0].length);
|
|
278
|
+
if (text.length >= MIN_BARE_BASE64_LENGTH && BARE_BASE64.test(text)) return describeBinary(mediaType, base64Bytes(text));
|
|
279
|
+
if (mediaType && text.length >= 64 && BARE_BASE64.test(text)) return describeBinary(mediaType, base64Bytes(text));
|
|
280
|
+
return text;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// src/journal/journal.ts
|
|
284
|
+
var QUEUE_LIMIT = 1e3;
|
|
285
|
+
var DELETED_CHATS_LIMIT = 1e4;
|
|
286
|
+
var MISSING_BLOB = "[\u043A\u0443\u0441\u043E\u043A \u0436\u0443\u0440\u043D\u0430\u043B\u0430 \u0443\u0434\u0430\u043B\u0451\u043D]";
|
|
287
|
+
function createSandboxJournal(store, options = {}) {
|
|
288
|
+
const logError = options.logError ?? ((message) => console.error(message));
|
|
289
|
+
const queueLimit = options.queueLimit ?? QUEUE_LIMIT;
|
|
290
|
+
const hider = createSecretHider({ env: options.env, secrets: options.secrets });
|
|
291
|
+
const pending = /* @__PURE__ */ new Set();
|
|
292
|
+
let waiting = 0;
|
|
293
|
+
let turn = Promise.resolve();
|
|
294
|
+
let closed = false;
|
|
295
|
+
const deletedChats = /* @__PURE__ */ new Set();
|
|
296
|
+
function serial(operation) {
|
|
297
|
+
const result = turn.then(operation, operation);
|
|
298
|
+
turn = result.catch(() => {
|
|
299
|
+
});
|
|
300
|
+
return result;
|
|
301
|
+
}
|
|
302
|
+
function enqueue(kind, save) {
|
|
303
|
+
if (closed) return;
|
|
304
|
+
if (waiting >= queueLimit) {
|
|
305
|
+
logError(`\u0436\u0443\u0440\u043D\u0430\u043B \u043F\u0435\u0441\u043E\u0447\u043D\u0438\u0446\u044B: \u043E\u0447\u0435\u0440\u0435\u0434\u044C \u0437\u0430\u043F\u0438\u0441\u0438 \u043F\u0435\u0440\u0435\u043F\u043E\u043B\u043D\u0435\u043D\u0430 (${queueLimit}), ${kind} \u043F\u0440\u043E\u043F\u0443\u0449\u0435\u043D\u043E`);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
waiting += 1;
|
|
309
|
+
const task = new Promise((resolve) => setImmediate(resolve)).then(() => serial(save)).catch((error) => logError(`\u0436\u0443\u0440\u043D\u0430\u043B \u043F\u0435\u0441\u043E\u0447\u043D\u0438\u0446\u044B: \u043D\u0435 \u0437\u0430\u043F\u0438\u0441\u0430\u043D\u043E ${kind}: ${errorMessage(error)}`)).finally(() => {
|
|
310
|
+
waiting -= 1;
|
|
311
|
+
pending.delete(task);
|
|
312
|
+
});
|
|
313
|
+
pending.add(task);
|
|
314
|
+
}
|
|
315
|
+
async function flush() {
|
|
316
|
+
while (pending.size > 0) await Promise.all([...pending]);
|
|
317
|
+
}
|
|
318
|
+
async function entriesOf(chatId) {
|
|
319
|
+
const [models, tools] = await Promise.all([store.listModelCalls(chatId), store.listToolCalls(chatId)]);
|
|
320
|
+
models.sort(byTime);
|
|
321
|
+
tools.sort(byTime);
|
|
322
|
+
return { models, tools, changed: changedInstructions(models) };
|
|
323
|
+
}
|
|
324
|
+
async function blobMap(hashes) {
|
|
325
|
+
const rows = await store.loadBlobs([...new Set(hashes)]);
|
|
326
|
+
const blobs = await Promise.all(rows.map(unpackBlob));
|
|
327
|
+
return new Map(blobs.map((blob) => [blob.hash, blob]));
|
|
328
|
+
}
|
|
329
|
+
return {
|
|
330
|
+
addSecrets: (values) => hider.addSecrets(values),
|
|
331
|
+
enqueueModelCall(event) {
|
|
332
|
+
if (deletedChats.has(event.chatId)) return;
|
|
333
|
+
enqueue("\u043E\u0431\u0440\u0430\u0449\u0435\u043D\u0438\u0435 \u043A \u043C\u043E\u0434\u0435\u043B\u0438", async () => {
|
|
334
|
+
const { row, blobs } = await splitModelCall(event, hider.forSettings(event.request.settings));
|
|
335
|
+
await store.saveModelCall(row, blobs);
|
|
336
|
+
});
|
|
337
|
+
},
|
|
338
|
+
enqueueToolCall(event) {
|
|
339
|
+
if (deletedChats.has(event.chatId)) return;
|
|
340
|
+
enqueue("\u0432\u044B\u0437\u043E\u0432 \u0438\u043D\u0441\u0442\u0440\u0443\u043C\u0435\u043D\u0442\u0430", async () => {
|
|
341
|
+
const { row, blobs } = await splitToolCall(event, hider.forSettings(void 0));
|
|
342
|
+
await store.saveToolCall(row, blobs);
|
|
343
|
+
});
|
|
344
|
+
},
|
|
345
|
+
flush,
|
|
346
|
+
async listForChat(chatId, listOptions) {
|
|
347
|
+
await flush();
|
|
348
|
+
const { models, tools, changed } = await entriesOf(chatId);
|
|
349
|
+
const entries = [
|
|
350
|
+
...models.map((row) => modelSummary(row, changed.has(row.id))),
|
|
351
|
+
...tools.map((row) => toolRecord(row, linkedModelCall(row, models)))
|
|
352
|
+
].sort((a, b) => a.startedAt.getTime() - b.startedAt.getTime() || (a.kind === b.kind ? 0 : a.kind === "model" ? -1 : 1));
|
|
353
|
+
const messageId = listOptions?.messageId;
|
|
354
|
+
return messageId === void 0 ? entries : entries.filter((entry) => entry.messageId === messageId);
|
|
355
|
+
},
|
|
356
|
+
async getCall(callId) {
|
|
357
|
+
await flush();
|
|
358
|
+
const model = await store.findModelCall(callId);
|
|
359
|
+
if (model) {
|
|
360
|
+
const { changed } = await entriesOf(model.chatId);
|
|
361
|
+
const blobs2 = await blobMap([model.instructionsHash, ...model.messageHashes, model.toolsHash, model.settingsHash, model.responseHash]);
|
|
362
|
+
const blob2 = (hash) => blobs2.get(hash) ?? { hash, content: MISSING_BLOB, bytes: 0, truncated: false };
|
|
363
|
+
const call = {
|
|
364
|
+
...modelSummary(model, changed.has(model.id)),
|
|
365
|
+
instructionsHash: model.instructionsHash,
|
|
366
|
+
messageHashes: model.messageHashes,
|
|
367
|
+
toolsHash: model.toolsHash,
|
|
368
|
+
settingsHash: model.settingsHash,
|
|
369
|
+
responseHash: model.responseHash
|
|
370
|
+
};
|
|
371
|
+
return {
|
|
372
|
+
kind: "model",
|
|
373
|
+
call,
|
|
374
|
+
instructions: blob2(model.instructionsHash),
|
|
375
|
+
messages: model.messageHashes.map(blob2),
|
|
376
|
+
tools: blob2(model.toolsHash),
|
|
377
|
+
settings: blob2(model.settingsHash),
|
|
378
|
+
response: blob2(model.responseHash)
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
const tool = await store.findToolCall(callId);
|
|
382
|
+
if (!tool) return null;
|
|
383
|
+
const siblings = await store.listModelCalls(tool.chatId);
|
|
384
|
+
const blobs = await blobMap(tool.outputHash ? [tool.inputHash, tool.outputHash] : [tool.inputHash]);
|
|
385
|
+
const blob = (hash) => blobs.get(hash) ?? { hash, content: MISSING_BLOB, bytes: 0, truncated: false };
|
|
386
|
+
return { kind: "tool", call: toolRecord(tool, linkedModelCall(tool, siblings)), input: blob(tool.inputHash), output: tool.outputHash ? blob(tool.outputHash) : null };
|
|
387
|
+
},
|
|
388
|
+
async getBlob(hash, ownerId) {
|
|
389
|
+
await flush();
|
|
390
|
+
const row = await store.loadBlobForOwner(hash, ownerId);
|
|
391
|
+
return row ? unpackBlob(row) : null;
|
|
392
|
+
},
|
|
393
|
+
getSettings: (chatId) => store.getSettings(chatId),
|
|
394
|
+
setSettings: (settings) => serial(() => store.setSettings(settings)),
|
|
395
|
+
markAnswerEdited: (record) => serial(() => store.saveAnswerEdit(record)),
|
|
396
|
+
listEditedAnswers: (chatId) => store.listAnswerEdits(chatId),
|
|
397
|
+
async deleteChat(chatId) {
|
|
398
|
+
if (deletedChats.size >= DELETED_CHATS_LIMIT) deletedChats.delete(deletedChats.values().next().value);
|
|
399
|
+
deletedChats.add(chatId);
|
|
400
|
+
await flush();
|
|
401
|
+
await serial(() => store.deleteChat(chatId));
|
|
402
|
+
},
|
|
403
|
+
async deleteOlderThan(before) {
|
|
404
|
+
await flush();
|
|
405
|
+
return serial(() => store.deleteOlderThan(before));
|
|
406
|
+
},
|
|
407
|
+
async close() {
|
|
408
|
+
if (closed) return;
|
|
409
|
+
await flush();
|
|
410
|
+
closed = true;
|
|
411
|
+
await turn;
|
|
412
|
+
await store.close();
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
function errorMessage(error) {
|
|
417
|
+
return error instanceof Error ? error.message : String(error);
|
|
418
|
+
}
|
|
419
|
+
function byTime(a, b) {
|
|
420
|
+
return a.startedAt.getTime() - b.startedAt.getTime() || (a.attempt ?? 0) - (b.attempt ?? 0);
|
|
421
|
+
}
|
|
422
|
+
function changedInstructions(sortedModels) {
|
|
423
|
+
const lastByPurpose = /* @__PURE__ */ new Map();
|
|
424
|
+
const changed = /* @__PURE__ */ new Set();
|
|
425
|
+
for (const row of sortedModels) {
|
|
426
|
+
const previous = lastByPurpose.get(row.purpose);
|
|
427
|
+
if (previous !== void 0 && previous !== row.instructionsHash) changed.add(row.id);
|
|
428
|
+
lastByPurpose.set(row.purpose, row.instructionsHash);
|
|
429
|
+
}
|
|
430
|
+
return changed;
|
|
431
|
+
}
|
|
432
|
+
function linkedModelCall(tool, models) {
|
|
433
|
+
const candidates = models.filter((model) => model.chatId === tool.chatId && model.toolCallIds.includes(tool.toolCallId));
|
|
434
|
+
if (candidates.length === 0) return null;
|
|
435
|
+
const sameTurn = candidates.filter((model) => model.messageId === tool.messageId);
|
|
436
|
+
const pool = sameTurn.length > 0 ? sameTurn : candidates;
|
|
437
|
+
const before = pool.filter((model) => model.startedAt.getTime() <= tool.startedAt.getTime()).sort(byTime);
|
|
438
|
+
return (before.at(-1) ?? pool.sort(byTime)[0])?.id ?? null;
|
|
439
|
+
}
|
|
440
|
+
function modelSummary(row, instructionsChanged) {
|
|
441
|
+
return {
|
|
442
|
+
kind: "model",
|
|
443
|
+
id: row.id,
|
|
444
|
+
chatId: row.chatId,
|
|
445
|
+
ownerId: row.ownerId,
|
|
446
|
+
messageId: row.messageId,
|
|
447
|
+
purpose: row.purpose,
|
|
448
|
+
attempt: row.attempt,
|
|
449
|
+
modelId: row.modelId,
|
|
450
|
+
providerId: row.providerId,
|
|
451
|
+
usage: row.usage,
|
|
452
|
+
costUsd: row.costUsd,
|
|
453
|
+
startedAt: row.startedAt,
|
|
454
|
+
firstChunkMs: row.firstChunkMs,
|
|
455
|
+
durationMs: row.durationMs,
|
|
456
|
+
status: row.status,
|
|
457
|
+
error: row.error,
|
|
458
|
+
instructionsChanged
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
function toolRecord(row, modelCallId) {
|
|
462
|
+
return {
|
|
463
|
+
kind: "tool",
|
|
464
|
+
id: row.id,
|
|
465
|
+
chatId: row.chatId,
|
|
466
|
+
ownerId: row.ownerId,
|
|
467
|
+
messageId: row.messageId,
|
|
468
|
+
modelCallId,
|
|
469
|
+
toolCallId: row.toolCallId,
|
|
470
|
+
toolName: row.toolName,
|
|
471
|
+
inputHash: row.inputHash,
|
|
472
|
+
outputHash: row.outputHash,
|
|
473
|
+
error: row.error,
|
|
474
|
+
startedAt: row.startedAt,
|
|
475
|
+
durationMs: row.durationMs
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export {
|
|
480
|
+
secretsFromEnv,
|
|
481
|
+
createSandboxJournal
|
|
482
|
+
};
|
|
483
|
+
//# sourceMappingURL=chunk-4O5NPJOS.js.map
|