zigmini 1.0.0-beta.2 → 1.1.0-beta.1
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/README.ko.md +36 -3
- package/README.md +36 -3
- package/assets/agent/gateway-request-rules.md +1 -1
- package/assets/agent/gateway-rules.md +6 -1
- package/assets/android/zigmini.apk +0 -0
- package/assets/android/zigmini.apk.json +3 -3
- package/assets/build.json +1 -1
- package/assets/document-reader/document-reader +0 -0
- package/assets/document-reader/host-selector +0 -0
- package/assets/document-reader/manifest.json +27 -0
- package/docs/manual.ko.md +120 -11
- package/docs/manual.md +123 -12
- package/host/document-reader/DocumentRead.swift +370 -0
- package/host/document-reader/HostSelector.swift +192 -0
- package/host/document-reader/Protocol.swift +647 -0
- package/host/document-reader/main.swift +22 -0
- package/host/omo-bridge/agent-aside-tool.ts +119 -0
- package/host/omo-bridge/agent-aside.ts +522 -0
- package/host/omo-bridge/agent-collaboration-files.ts +282 -0
- package/host/omo-bridge/agent-collaboration.ts +385 -0
- package/host/omo-bridge/agent-control.ts +400 -28
- package/host/omo-bridge/agent-execution.sb +42 -0
- package/host/omo-bridge/agent-execution.ts +158 -0
- package/host/omo-bridge/agent-tool-adapters.ts +388 -0
- package/host/omo-bridge/agent-tool-permissions.ts +96 -0
- package/host/omo-bridge/aside-tabs.ts +41 -0
- package/host/omo-bridge/attachment-read.ts +100 -0
- package/host/omo-bridge/bridge.ts +10 -0
- package/host/omo-bridge/managed-call.ts +46 -0
- package/host/omo-bridge/task-control.ts +39 -5
- package/host/omo-check/patches/attachments-293/README.md +138 -0
- package/host/omo-check/patches/attachments-293/document-content.js +234 -0
- package/host/omo-check/patches/attachments-293/document-content.types.txt +91 -0
- package/host/omo-check/patches/attachments-293/omo-5.1.51-documents.patch +831 -0
- package/host/omo-check/patches/attachments-293/omo-5.1.51-gateway-port.manifest.json +471 -0
- package/host/omo-check/patches/omo-5.1.51-agent-tool-authority.patch +471 -0
- package/host/omo-check/patches/omo-5.1.51-chatgpt-terra.patch +21 -0
- package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +44 -6
- package/host/omo-check/patches/omo-5.1.51-memory-receipts.patch +5 -0
- package/package.json +5 -3
- package/src/agent/api.ts +345 -20
- package/src/agent/artifacts.ts +196 -0
- package/src/agent/aside-target-routes.ts +369 -0
- package/src/agent/aside-targets.ts +215 -0
- package/src/agent/collaboration-admission.ts +47 -0
- package/src/agent/collaboration-read.ts +117 -0
- package/src/agent/collaboration-session.ts +196 -0
- package/src/agent/collaboration-types.ts +258 -0
- package/src/agent/collaboration.ts +399 -0
- package/src/agent/definition.ts +65 -3
- package/src/agent/fields.ts +9 -3
- package/src/agent/file-core.ts +325 -0
- package/src/agent/files.ts +87 -8
- package/src/agent/list.ts +3 -3
- package/src/agent/managed-call.ts +144 -0
- package/src/agent/tool-catalog.ts +184 -0
- package/src/agent/tool-delivery.ts +359 -0
- package/src/agent/tool-permission-types.ts +81 -0
- package/src/agent/tool-permissions.ts +1366 -0
- package/src/attachments/api.ts +161 -0
- package/src/attachments/document-reader.ts +325 -0
- package/src/attachments/host-selector.ts +410 -0
- package/src/attachments/managed-binding.ts +83 -0
- package/src/attachments/prepare.ts +214 -0
- package/src/attachments/selector-port.ts +28 -0
- package/src/attachments/service.ts +729 -0
- package/src/attachments/store.ts +698 -0
- package/src/attachments/types.ts +123 -0
- package/src/cli/doctor.ts +30 -10
- package/src/decisions.ts +109 -10
- package/src/engine/apply-patch.ts +21 -0
- package/src/engine/compat.json +2 -1
- package/src/engine/install.ts +45 -4
- package/src/engine/sandbox-cell.ts +2 -0
- package/src/gc.ts +33 -225
- package/src/housekeeping/api.ts +163 -0
- package/src/housekeeping/audit.ts +61 -0
- package/src/housekeeping/collectors/agent-collaboration.ts +33 -0
- package/src/housekeeping/collectors/agent-drafts.ts +43 -0
- package/src/housekeeping/collectors/agent-file-trash.ts +36 -0
- package/src/housekeeping/collectors/agent-trash.ts +28 -0
- package/src/housekeeping/collectors/attachment-cleanup.ts +35 -0
- package/src/housekeeping/collectors/db-backup.ts +25 -0
- package/src/housekeeping/collectors/docker-build-cache.ts +29 -0
- package/src/housekeeping/collectors/docker-image.ts +135 -0
- package/src/housekeeping/collectors/omo-version.ts +32 -0
- package/src/housekeeping/collectors/orphan-test-process.ts +205 -0
- package/src/housekeeping/collectors/reconcile-work.ts +55 -0
- package/src/housekeeping/collectors/rpc-daemon.ts +39 -0
- package/src/housekeeping/collectors/shared.ts +149 -0
- package/src/housekeeping/collectors/temp-dir.ts +28 -0
- package/src/housekeeping/collectors/update-file.ts +25 -0
- package/src/housekeeping/collectors/worktree.ts +193 -0
- package/src/housekeeping/engine.ts +125 -0
- package/src/housekeeping/reconcile/work.ts +130 -0
- package/src/housekeeping/registry.ts +63 -0
- package/src/housekeeping/trash.ts +59 -0
- package/src/housekeeping/types.ts +31 -0
- package/src/hub.ts +1095 -41
- package/src/main.ts +230 -31
- package/src/message-references.ts +14 -5
- package/src/profile-prompt.ts +13 -1
- package/src/profile.ts +13 -5
- package/src/protocol.ts +41 -6
- package/src/security-allow.ts +79 -2
- package/src/security-check.ts +1 -1
- package/src/store.ts +1946 -23
- package/src/test-crash.ts +20 -0
- package/src/usage.ts +18 -7
- package/src/work-watch.ts +55 -7
- package/src/work.ts +192 -3
- package/web/DESIGN.md +35 -1
- package/web/agents.d.ts +189 -12
- package/web/agents.js +898 -39
- package/web/app.js +2634 -210
- package/web/attachments.d.ts +91 -0
- package/web/attachments.js +422 -0
- package/web/collaboration.d.ts +87 -0
- package/web/collaboration.js +290 -0
- package/web/decisions.d.ts +39 -2
- package/web/decisions.js +354 -2
- package/web/gc.d.ts +26 -5
- package/web/gc.js +113 -13
- package/web/i18n/agents.d.ts +4 -0
- package/web/i18n/agents.js +200 -8
- package/web/i18n/attachments.js +78 -0
- package/web/i18n/decisions.d.ts +4 -0
- package/web/i18n/decisions.js +68 -0
- package/web/i18n/gc.d.ts +4 -0
- package/web/i18n/gc.js +184 -2
- package/web/i18n/main.js +80 -8
- package/web/i18n/tour.d.ts +4 -0
- package/web/i18n/tour.js +37 -0
- package/web/index.html +20 -3
- package/web/profile.d.ts +2 -2
- package/web/profile.js +16 -10
- package/web/style.css +342 -21
- package/web/tooltip.d.ts +2 -0
- package/web/tooltip.js +12 -0
- package/web/tour.d.ts +57 -0
- package/web/tour.js +450 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema": 1,
|
|
3
|
-
"revision":
|
|
3
|
+
"revision": 6,
|
|
4
4
|
"upstream": {
|
|
5
5
|
"omo": "5.1.51",
|
|
6
6
|
"senpi": "2026.10.27-1"
|
|
7
7
|
},
|
|
8
|
-
"candidate": "<zigmini home>/omo/5.1.51-zgw.
|
|
8
|
+
"candidate": "<zigmini home>/omo/5.1.51-zgw.6",
|
|
9
9
|
"pristine": "<zigmini home>/omo/5.1.51",
|
|
10
10
|
"entrypoint": "node_modules/@code-yeongyu/senpi/dist/cli.js",
|
|
11
11
|
"patchRoot": "node_modules",
|
|
@@ -16,7 +16,10 @@
|
|
|
16
16
|
"omo-5.1.51-runtime-compat.patch",
|
|
17
17
|
"omo-5.1.51-compaction-recovery.patch",
|
|
18
18
|
"omo-5.1.51-provider-failover.patch",
|
|
19
|
-
"omo-5.1.51-compaction-long-context.patch"
|
|
19
|
+
"omo-5.1.51-compaction-long-context.patch",
|
|
20
|
+
"omo-5.1.51-chatgpt-terra.patch",
|
|
21
|
+
"omo-5.1.51-memory-receipts.patch",
|
|
22
|
+
"omo-5.1.51-agent-tool-authority.patch"
|
|
20
23
|
],
|
|
21
24
|
"files": [
|
|
22
25
|
{
|
|
@@ -27,7 +30,7 @@
|
|
|
27
30
|
{
|
|
28
31
|
"path": "node_modules/@code-yeongyu/senpi/dist/core/agent-session.js",
|
|
29
32
|
"originalSha256": "6bc07b317862db56e6277767320673ac87cdc637751f365c00ad861df39f22e0",
|
|
30
|
-
"patchedSha256": "
|
|
33
|
+
"patchedSha256": "b4b48626e4feb21bb8e28cd6410363d9c16eeb45cb6d0fcbb14a18493c2efb6c"
|
|
31
34
|
},
|
|
32
35
|
{
|
|
33
36
|
"path": "node_modules/@code-yeongyu/senpi/dist/core/auth-storage.js",
|
|
@@ -257,7 +260,7 @@
|
|
|
257
260
|
{
|
|
258
261
|
"path": "node_modules/@code-yeongyu/senpi/dist/core/extensions/types.d.ts",
|
|
259
262
|
"originalSha256": "ae1efdb5f5f68c3aa0d147d8f2828552ebcb6ec95b54817cb90393b927abcc20",
|
|
260
|
-
"patchedSha256": "
|
|
263
|
+
"patchedSha256": "1939cd9035b886859fe748ec7e08052f9bca7b98f6471e10d8b75bc88bf1ee9b"
|
|
261
264
|
},
|
|
262
265
|
{
|
|
263
266
|
"path": "node_modules/@code-yeongyu/senpi/dist/core/model-runtime.js",
|
|
@@ -272,7 +275,7 @@
|
|
|
272
275
|
{
|
|
273
276
|
"path": "node_modules/@code-yeongyu/senpi/dist/modes/rpc/connection-handler.js",
|
|
274
277
|
"originalSha256": "df28c8c8aca7c01633510e2a892a1cca6cfef785f15e44c026a997788543e570",
|
|
275
|
-
"patchedSha256": "
|
|
278
|
+
"patchedSha256": "bcd721298ed68439885fdfaef87026694feb836f0c72ec4296796816b55e390f"
|
|
276
279
|
},
|
|
277
280
|
{
|
|
278
281
|
"path": "node_modules/@code-yeongyu/senpi/dist/modes/rpc/connection-question-bridge.js",
|
|
@@ -358,6 +361,41 @@
|
|
|
358
361
|
"path": "node_modules/@code-yeongyu/senpi/dist/core/extensions/builtin/anthropic-subscription/failover.js",
|
|
359
362
|
"originalSha256": "bcc9dbb3aaafbbd2840d95635c00d31d062ff1b61450ec039042873c71f8f51e",
|
|
360
363
|
"patchedSha256": "007bac87475fff79afd3070c023e668747399ae30156b4bf8db9e2bdab9c2d98"
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
"path": "node_modules/@earendil-works/pi-ai/dist/providers/data/chatgpt-subscription.json",
|
|
367
|
+
"originalSha256": "42b88c93d717b2a7469b71da7da5adf87347d0d9cc0f264b39117f0f940b51c0",
|
|
368
|
+
"patchedSha256": "2058226d5808194448c8e301a2c265238a608d0a97b6410672aff7f91bbfcf73"
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
"path": "node_modules/@code-yeongyu/senpi/dist/bundle/chunks/chunk-ZAS4NBVK.js",
|
|
372
|
+
"originalSha256": "ecb242933351a6c2535cb4283ed6fb7da2174b9326915fc82a2a5b6a0c162794",
|
|
373
|
+
"patchedSha256": "c3c4345fecec02a1070f5658e66547d0bb4f2199fa4f9b3eb0cdaa278986f2ac"
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
"path": "node_modules/@code-yeongyu/senpi/dist/bundle/chunks/session-worker.js",
|
|
377
|
+
"originalSha256": "8cd4a903ea9243667f63b131b9c3cb87ca3d9e1b46f8e8d54cfa2a11add853b2",
|
|
378
|
+
"patchedSha256": "cf6a2a7e06aef03bf8fdc3583578e66c0e0f35951bf6a373107f9e375e00bbd5"
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
"path": "node_modules/@code-yeongyu/senpi/dist/core/extensions/builtin/permission-system/dispatch.js",
|
|
382
|
+
"originalSha256": "0a94637e71e60260cf8de7a9b5c39300a5d91f9cd8adffdd379a5337c45cd903",
|
|
383
|
+
"patchedSha256": "0966ee6a7e3ddaab8242fce7cba75e7a83af64e97a3e08bcd5ab1b8ea4248365"
|
|
384
|
+
},
|
|
385
|
+
{
|
|
386
|
+
"path": "node_modules/@code-yeongyu/senpi/dist/core/cursor-exec-bridge.js",
|
|
387
|
+
"originalSha256": "e6c4fedef2bdd5f69b3373947204266f8b1fc9bcd31f18ab10c3f512f559cf29",
|
|
388
|
+
"patchedSha256": "1cffccc2e71ea684aa1b611b9a8c11eadde48890ffe0b76ff4e733a8c4b7ddc0"
|
|
389
|
+
},
|
|
390
|
+
{
|
|
391
|
+
"path": "node_modules/@code-yeongyu/senpi-codemode/src/tool/eval-tool.ts",
|
|
392
|
+
"originalSha256": "107e41edbfd1d0403a62d8265a18c62042c0f6b48e0a5a2f65d171b9fcdc914b",
|
|
393
|
+
"patchedSha256": "5ee8e1aca3f0ca73067c0da7387051c95211e784288a25c153b9c9112640f49f"
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
"path": "node_modules/omo-ai/plugin/extensions/omo.js",
|
|
397
|
+
"originalSha256": "443394ed1461a1502d91124667dc6fc94d82c458867b07e5549f8080e061080e",
|
|
398
|
+
"patchedSha256": "e4823cb98e4c7e17ce1cee865ace6fad97613f349e644344b10669ac7bbe9add"
|
|
361
399
|
}
|
|
362
400
|
]
|
|
363
401
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
--- a/omo-ai/plugin/extensions/omo.js
|
|
2
|
+
+++ b/omo-ai/plugin/extensions/omo.js
|
|
3
|
+
@@ replace @@
|
|
4
|
+
-async function L1(e,t){let n=(await e.lsTree(t)).filter(z1);if(0===n.length)return 0;let r=(await Promise.all(n.map(n=>e.show(t,n)))).reduce((e,t)=>e+Buffer.byteLength(t,"utf8"),0);return Math.floor(r/4)}
|
|
5
|
+
+async function L1(e,t){let n=(await e.lsTree(t)).filter(z1);if(0===n.length)return 0;let r=(await Promise.all(n.map(async n=>{let r=await e.show(t,n);return"system/agreements.md"===n?r.split(/\r?\n/).filter(e=>void 0===$F(e.trim())).join("\n"):r}))).reduce((e,t)=>e+Buffer.byteLength(t,"utf8"),0);return Math.floor(r/4)}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zigmini",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0-beta.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"zigmini": "bin/zigmini"
|
|
@@ -57,9 +57,11 @@
|
|
|
57
57
|
"typecheck": "tsc --noEmit",
|
|
58
58
|
"test": "bun test test/unit",
|
|
59
59
|
"test:e2e": "bun test test/e2e",
|
|
60
|
-
"test:contract": "bun test
|
|
61
|
-
"test:all": "tsc --noEmit && bun test
|
|
60
|
+
"test:contract": "bun scripts/test-split.ts contract",
|
|
61
|
+
"test:all": "tsc --noEmit && bun scripts/test-split.ts unit && bun scripts/test-split.ts contract",
|
|
62
62
|
"test:mutation": "bun scripts/mutation-run.ts",
|
|
63
|
+
"test:documents:native": "bun scripts/build-document-reader.ts --native-test",
|
|
64
|
+
"build:document-reader": "bun scripts/build-document-reader.ts",
|
|
63
65
|
"heavy": "bun host/heavy-slot.ts run",
|
|
64
66
|
"formal:check": "bun scripts/formal-check.ts"
|
|
65
67
|
},
|
package/src/agent/api.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { YAML } from "bun";
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import type { Hub } from "../hub.ts";
|
|
4
5
|
import { MEDIA_MAX_BYTES } from "../media.ts";
|
|
5
6
|
import type { Store } from "../store.ts";
|
|
6
7
|
import { mergeAgentDefinition, parseAgentDefinition, AgentDefinitionError, serializeAgentDefinition, type AgentDefinition } from "./definition.ts";
|
|
7
8
|
import { registryMetadata } from "./fields.ts";
|
|
8
|
-
import { AgentFileError, AgentFileStore, unreadableAgent } from "./files.ts";
|
|
9
|
+
import { agentFileTrashName, AgentFileError, AgentFileStore, parseAgentFileTrashName, unreadableAgent } from "./files.ts";
|
|
10
|
+
import { AgentContentError, AgentContentFiles, contentIdentity, numberedName, sourceName } from "./file-core.ts";
|
|
9
11
|
|
|
10
12
|
class AgentApiError extends Error {
|
|
11
13
|
constructor(readonly status: number, readonly code: string) {
|
|
@@ -17,15 +19,76 @@ function object(value: unknown): value is Record<string, unknown> {
|
|
|
17
19
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
18
20
|
}
|
|
19
21
|
|
|
22
|
+
/** A draft keeps the editor text of a new agent; 64 KB is twice the definition cap, room for a text being edited. */
|
|
23
|
+
export const DRAFT_MAX_BYTES = 65536;
|
|
24
|
+
|
|
25
|
+
/** Draft ids are made by the web (12 lowercase letters or digits); nothing else reaches the file system. */
|
|
26
|
+
function draftId(value: string): string {
|
|
27
|
+
if (!/^[a-z0-9]{12}$/.test(value)) throw new AgentApiError(400, "invalid_draft_id");
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface DraftBody { readonly source: string; readonly kind: "new"; readonly files: readonly { readonly name: string; readonly size: number }[] }
|
|
32
|
+
|
|
33
|
+
/** The source is kept unvalidated (it is still being typed); only the shape is checked. */
|
|
34
|
+
function draftBody(value: unknown): DraftBody {
|
|
35
|
+
if (!object(value) || typeof value.source !== "string" || value.kind !== "new" || !Array.isArray(value.files)) throw new AgentApiError(400, "invalid_draft");
|
|
36
|
+
const files = value.files.map((file: unknown) => {
|
|
37
|
+
if (!object(file) || typeof file.name !== "string" || typeof file.size !== "number" || !Number.isSafeInteger(file.size) || file.size < 0) throw new AgentApiError(400, "invalid_draft");
|
|
38
|
+
return { name: file.name, size: file.size };
|
|
39
|
+
});
|
|
40
|
+
if (Buffer.byteLength(value.source) > DRAFT_MAX_BYTES) throw new AgentApiError(413, "too_large");
|
|
41
|
+
return { source: value.source, kind: "new", files };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The name a half-typed draft shows in the list, or null while its frontmatter does not parse yet. */
|
|
45
|
+
function draftName(source: string): string | null {
|
|
46
|
+
const frontmatter = /^---\r?\n([\s\S]*?)^---[ \t]*(?:\r?\n|$)/m.exec(source)?.[1];
|
|
47
|
+
if (frontmatter === undefined) return null;
|
|
48
|
+
let parsed: unknown;
|
|
49
|
+
try {
|
|
50
|
+
parsed = YAML.parse(frontmatter);
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
return object(parsed) && typeof parsed.name === "string" && parsed.name.trim() ? parsed.name.trim() : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type KnowledgeArea = "source" | "outputs" | "notes" | "memory" | "other";
|
|
58
|
+
|
|
59
|
+
/** The area of a path relative to knowledge/: uploads, results, notes, MEMORY.md, or anything else. */
|
|
60
|
+
function knowledgeArea(parts: readonly string[]): KnowledgeArea {
|
|
61
|
+
if (parts.length === 1 && parts[0] === "MEMORY.md") return "memory";
|
|
62
|
+
const top = parts[0];
|
|
63
|
+
return top === "source" || top === "outputs" || top === "notes" ? top : "other";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** RFC 5987 value: encodeURIComponent leaves ' ( ) * as they are, which the header grammar does not allow. */
|
|
67
|
+
function attachment(name: string): string {
|
|
68
|
+
return `attachment; filename*=UTF-8''${encodeURIComponent(name).replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
20
71
|
async function source(req: Request): Promise<string> {
|
|
21
72
|
const value: unknown = await req.json();
|
|
22
73
|
if (!object(value) || typeof value.source !== "string") throw new AgentApiError(400, "source_required");
|
|
23
74
|
return value.source;
|
|
24
75
|
}
|
|
25
76
|
|
|
77
|
+
/**
|
|
78
|
+
* The text the editor holds, for POST /api/agents/preview: a text up to the draft size; a save's own limit is reported in the
|
|
79
|
+
* answer. `kind: "new"` marks the create screen's text, which may not have its name and description yet (agent-ux int).
|
|
80
|
+
*/
|
|
81
|
+
function previewSource(value: unknown): { readonly source: string; readonly kind: "agent" | "new" } {
|
|
82
|
+
if (!object(value) || typeof value.source !== "string") throw new AgentApiError(400, "source_required");
|
|
83
|
+
if (value.kind !== undefined && value.kind !== "agent" && value.kind !== "new") throw new AgentApiError(400, "invalid_kind");
|
|
84
|
+
if (Buffer.byteLength(value.source) > DRAFT_MAX_BYTES) throw new AgentApiError(413, "too_large");
|
|
85
|
+
return { source: value.source, kind: value.kind === "new" ? "new" : "agent" };
|
|
86
|
+
}
|
|
87
|
+
|
|
26
88
|
/** Only metadata leaves list endpoints; definition text is returned by the detail endpoint. */
|
|
27
89
|
export class AgentApi {
|
|
28
90
|
private readonly deleting = new Set<string>();
|
|
91
|
+
private lastTime = 0;
|
|
29
92
|
|
|
30
93
|
constructor(private readonly files: AgentFileStore, private readonly store: Store, private readonly hub: Hub) {}
|
|
31
94
|
|
|
@@ -41,21 +104,62 @@ export class AgentApi {
|
|
|
41
104
|
const applied = this.store.eventsAfter(conv.id, this.store.lastSeq(conv.id, "session.rotated"))
|
|
42
105
|
.filter((event) => event.type === "agent.applied").at(-1)?.data;
|
|
43
106
|
const appliedSha = object(applied) && typeof applied.sha === "string" ? applied.sha : null;
|
|
107
|
+
const opened = this.hub.hasSession(conv.id) || applied !== undefined;
|
|
108
|
+
const stale = opened && appliedSha !== merged.sha;
|
|
44
109
|
return {
|
|
45
110
|
id, name: definition.fields.name, description: definition.fields.description, icon: definition.fields.icon ?? null,
|
|
46
|
-
convId: conv.id, definitionSha: merged.sha,
|
|
111
|
+
convId: conv.id, definitionSha: merged.sha, appliedSha, stale,
|
|
112
|
+
...(opened && appliedSha === null ? { staleReason: "unknown" } : {}),
|
|
47
113
|
};
|
|
48
114
|
}
|
|
49
115
|
|
|
50
|
-
/**
|
|
116
|
+
/**
|
|
117
|
+
* The definition text, the merged result, the shared layer as this gateway parsed it (`defaultsFields`: what the merge applied
|
|
118
|
+
* under the agent's own values, which the editor reads instead of parsing defaults.md itself) and the field registry.
|
|
119
|
+
*/
|
|
51
120
|
private detail(id: string) {
|
|
52
121
|
const definition = this.files.read(id);
|
|
122
|
+
const defaults = this.files.readDefaults();
|
|
53
123
|
return { ...this.summary(id, definition), source: serializeAgentDefinition(definition),
|
|
54
|
-
...mergeAgentDefinition(definition,
|
|
124
|
+
...mergeAgentDefinition(definition, defaults), defaultsFields: defaults?.fields ?? {}, registry: registryMetadata(), lastDaily: this.lastDaily(id) };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The date of the newest notes/daily/YYYY-MM-DD.md, or null; a linked folder or note does not count. */
|
|
128
|
+
private lastDaily(id: string): string | null {
|
|
129
|
+
let folder: string;
|
|
130
|
+
try {
|
|
131
|
+
folder = this.path([id, "knowledge", "notes", "daily"]);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (error instanceof AgentApiError) return null;
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
if (!existsSync(folder)) return null;
|
|
137
|
+
const dates = readdirSync(folder).filter((name) => /^\d{4}-\d{2}-\d{2}\.md$/.test(name) && lstatSync(join(folder, name)).isFile()).sort();
|
|
138
|
+
return dates.at(-1)?.slice(0, 10) ?? null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The editor's current text evaluated exactly as a save reads it back (agent-ux todo 12): schema 1 filled in, parsed and
|
|
143
|
+
* validated by this gateway's parser, merged with the current defaults.md; nothing is written. Text a save would refuse
|
|
144
|
+
* answers `valid: false` with the save's field-level code, so the editor shows no guess for it. A new agent's text without
|
|
145
|
+
* its name or description yet is evaluated with a placeholder for them (agent-ux int); the answer leaves those fields out.
|
|
146
|
+
*/
|
|
147
|
+
private preview({ source, kind }: { readonly source: string; readonly kind: "agent" | "new" }) {
|
|
148
|
+
try {
|
|
149
|
+
const { definition, filled } = kind === "new" ? this.files.parseNew(source) : { definition: this.files.parse(source), filled: [] };
|
|
150
|
+
const merged = mergeAgentDefinition(definition, this.files.readDefaults());
|
|
151
|
+
const own = (entries: object) => Object.fromEntries(Object.entries(entries).filter(([name]) => !filled.includes(name)));
|
|
152
|
+
return { valid: true, fields: own(merged.fields), origins: own(merged.origins) };
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if (error instanceof AgentDefinitionError) return { valid: false, error: "invalid_agent_definition", field: error.field };
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
55
157
|
}
|
|
56
158
|
|
|
159
|
+
/** defaults.md as text (what the defaults editor edits) and as this gateway parsed it (`fields`: the layer the create screen reads under a new agent's values). */
|
|
57
160
|
private defaults() {
|
|
58
|
-
|
|
161
|
+
const defaults = this.files.readDefaults();
|
|
162
|
+
return { source: defaults?.source ?? "---\n{}\n---\n", fields: defaults?.fields ?? {}, registry: registryMetadata() };
|
|
59
163
|
}
|
|
60
164
|
|
|
61
165
|
private list() {
|
|
@@ -79,13 +183,180 @@ export class AgentApi {
|
|
|
79
183
|
return path;
|
|
80
184
|
}
|
|
81
185
|
|
|
186
|
+
private timestamp(): string {
|
|
187
|
+
this.lastTime = Math.max(Date.now(), this.lastTime + 1);
|
|
188
|
+
return new Date(this.lastTime).toISOString();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** A request path relative to knowledge/: every part checked by path() (no links, no ".."), with its area. */
|
|
192
|
+
private knowledge(id: string, value: unknown): { readonly parts: string[]; readonly file: string; readonly area: KnowledgeArea } {
|
|
193
|
+
if (typeof value !== "string") throw new AgentApiError(400, "invalid_path");
|
|
194
|
+
const parts = value.split("/");
|
|
195
|
+
return { parts, file: this.path([id, "knowledge", ...parts]), area: knowledgeArea(parts) };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The size of a regular file; a missing one is 404 file_not_found, anything else 400 `notFile`. */
|
|
199
|
+
private regularFile(file: string, notFile: string): number {
|
|
200
|
+
try {
|
|
201
|
+
const stat = lstatSync(file);
|
|
202
|
+
if (!stat.isFile()) throw new AgentApiError(400, notFile);
|
|
203
|
+
return stat.size;
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") throw new AgentApiError(404, "file_not_found");
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The first of `name`, `name (2)` ... that nothing (no file, folder or link) takes in the folder `parts`. */
|
|
211
|
+
private freePath(parts: readonly string[], name: string): { readonly name: string; readonly file: string } {
|
|
212
|
+
for (let n = 1; n <= 1000; n++) {
|
|
213
|
+
const candidate = numberedName(name, n);
|
|
214
|
+
const file = this.path([...parts, candidate]);
|
|
215
|
+
if (!existsSync(file)) return { name: candidate, file };
|
|
216
|
+
}
|
|
217
|
+
throw new AgentApiError(409, "file_exists");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Knowledge file actions. Delete moves a regular file of source/ or outputs/ to agents/.file-trash/<id>/ (outside
|
|
222
|
+
* .trash/, whose folders the archived-agent cleanup takes whole); housekeeping offers it to the operator after 30 days.
|
|
223
|
+
*/
|
|
224
|
+
private async fileAction(req: Request, id: string, action: string, entryPart: string | undefined): Promise<Response> {
|
|
225
|
+
if (action === "download" && req.method === "GET") {
|
|
226
|
+
const query = new URL(req.url).searchParams, artifactId = query.get("artifactId");
|
|
227
|
+
if (artifactId !== null) {
|
|
228
|
+
if (!artifactId || query.has("path") || !query.get("requestId")) throw new AgentApiError(400, "invalid_artifact_selector");
|
|
229
|
+
const requestId = query.get("requestId") ?? "";
|
|
230
|
+
const conv = this.conversation(id), request = this.store.getAgentRequest(requestId);
|
|
231
|
+
const artifact = this.store.agentArtifact(artifactId);
|
|
232
|
+
if (!request || !artifact || artifact.requestId !== requestId) throw new AgentApiError(404, "artifact_not_found");
|
|
233
|
+
if (request.requesterConv !== conv.id && request.targetConv !== conv.id) throw new AgentApiError(403, "request_forbidden");
|
|
234
|
+
const grant = this.store.agentArtifactGrant(artifactId, conv.id, requestId);
|
|
235
|
+
if (!grant) throw new AgentApiError(403, "artifact_forbidden");
|
|
236
|
+
if (grant.revoked || artifact.state === "trashed") throw new AgentApiError(410, "artifact_revoked");
|
|
237
|
+
// T3's readGranted requires a live native binding. An owner download must not fabricate one.
|
|
238
|
+
return Response.json({ error: "capability_unavailable", capability: "owner_artifact_release" }, { status: 503 });
|
|
239
|
+
}
|
|
240
|
+
const target = this.knowledge(id, new URL(req.url).searchParams.get("path"));
|
|
241
|
+
if (target.area === "other") throw new AgentApiError(400, "file_not_readable");
|
|
242
|
+
this.regularFile(target.file, "not_a_file");
|
|
243
|
+
return new Response(readFileSync(target.file), {
|
|
244
|
+
headers: { "Content-Type": "application/octet-stream", "Content-Disposition": attachment(target.parts.at(-1) ?? "file") },
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
if (action === "trash" && req.method === "GET") {
|
|
248
|
+
const folder = this.path([".file-trash", id]);
|
|
249
|
+
if (!existsSync(folder)) return Response.json([]);
|
|
250
|
+
const rows = readdirSync(folder).flatMap((entry) => {
|
|
251
|
+
const parsed = parseAgentFileTrashName(entry);
|
|
252
|
+
const stat = lstatSync(join(folder, entry));
|
|
253
|
+
return parsed === null || !stat.isFile() ? [] : [{ entry, path: parsed.path, size: stat.size, deletedAt: new Date(parsed.deletedAt).toISOString() }];
|
|
254
|
+
});
|
|
255
|
+
return Response.json(rows.sort((a, b) => b.deletedAt.localeCompare(a.deletedAt) || b.entry.localeCompare(a.entry)));
|
|
256
|
+
}
|
|
257
|
+
if (req.method !== "POST") throw new AgentApiError(405, "method_not_allowed");
|
|
258
|
+
if (action === "delete") {
|
|
259
|
+
const body: unknown = await req.json();
|
|
260
|
+
const target = this.knowledge(id, object(body) ? body.path : undefined);
|
|
261
|
+
if ((target.area !== "source" && target.area !== "outputs") || target.parts.at(-1)?.endsWith(".lock")) throw new AgentApiError(400, "file_not_deletable");
|
|
262
|
+
this.regularFile(target.file, "file_not_deletable");
|
|
263
|
+
const folder = this.path([".file-trash", id]);
|
|
264
|
+
let entry = agentFileTrashName(target.parts.join("/"), this.timestamp());
|
|
265
|
+
if (Buffer.byteLength(entry) > 255) throw new AgentApiError(400, "path_too_long");
|
|
266
|
+
this.conversation(id);
|
|
267
|
+
if (this.deleting.has(id)) throw new AgentApiError(409, "agent_busy");
|
|
268
|
+
mkdirSync(folder, { recursive: true });
|
|
269
|
+
while (existsSync(this.path([".file-trash", id, entry]))) entry = agentFileTrashName(target.parts.join("/"), this.timestamp());
|
|
270
|
+
renameSync(target.file, this.path([".file-trash", id, entry]));
|
|
271
|
+
this.hub.agentUpdated(this.summary(id, this.files.read(id)));
|
|
272
|
+
return Response.json({ entry });
|
|
273
|
+
}
|
|
274
|
+
if (action.startsWith("trash/") && entryPart !== undefined) {
|
|
275
|
+
const entry = decodeURIComponent(entryPart);
|
|
276
|
+
const parsed = parseAgentFileTrashName(entry);
|
|
277
|
+
if (parsed === null) throw new AgentApiError(400, "invalid_trash_entry");
|
|
278
|
+
const archived = this.path([".file-trash", id, entry]);
|
|
279
|
+
const target = this.knowledge(id, parsed.path);
|
|
280
|
+
if (target.area !== "source" && target.area !== "outputs") throw new AgentApiError(400, "invalid_trash_entry");
|
|
281
|
+
this.regularFile(archived, "invalid_trash_entry");
|
|
282
|
+
this.conversation(id);
|
|
283
|
+
if (this.deleting.has(id)) throw new AgentApiError(409, "agent_busy");
|
|
284
|
+
const parent = target.parts.slice(0, -1);
|
|
285
|
+
mkdirSync(this.path([id, "knowledge", ...parent]), { recursive: true });
|
|
286
|
+
const free = this.freePath([id, "knowledge", ...parent], target.parts.at(-1) ?? "");
|
|
287
|
+
renameSync(archived, free.file);
|
|
288
|
+
this.hub.agentUpdated(this.summary(id, this.files.read(id)));
|
|
289
|
+
return Response.json({ path: [...parent, free.name].join("/") });
|
|
290
|
+
}
|
|
291
|
+
throw new AgentApiError(405, "method_not_allowed");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Moves a draft to agents/.drafts-trash/ (outside .trash/); housekeeping offers it to the operator after 30 days. */
|
|
295
|
+
private archiveDraft(id: string): boolean {
|
|
296
|
+
const file = this.path([".drafts", `${id}.json`]);
|
|
297
|
+
if (!existsSync(file)) return false;
|
|
298
|
+
mkdirSync(this.path([".drafts-trash"]), { recursive: true });
|
|
299
|
+
let entry = `${this.timestamp()}-${id}.json`;
|
|
300
|
+
while (existsSync(this.path([".drafts-trash", entry]))) entry = `${this.timestamp()}-${id}.json`;
|
|
301
|
+
renameSync(file, this.path([".drafts-trash", entry]));
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** An id whose draft was archived (created into an agent or discarded) is closed: a late save must not bring it back. */
|
|
306
|
+
private draftClosed(id: string): boolean {
|
|
307
|
+
const trash = this.path([".drafts-trash"]);
|
|
308
|
+
return existsSync(trash) && readdirSync(trash).some((name) => name.endsWith(`-${id}.json`));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private drafts() {
|
|
312
|
+
const folder = this.path([".drafts"]);
|
|
313
|
+
if (!existsSync(folder)) return [];
|
|
314
|
+
const rows = readdirSync(folder).filter((name) => /^[a-z0-9]{12}\.json$/.test(name)).flatMap((name) => {
|
|
315
|
+
try {
|
|
316
|
+
const draft: unknown = JSON.parse(readFileSync(this.path([".drafts", name]), "utf8"));
|
|
317
|
+
if (!object(draft) || typeof draft.updatedAt !== "string") return [];
|
|
318
|
+
return [{ id: name.slice(0, 12), name: typeof draft.name === "string" ? draft.name : null, updatedAt: draft.updatedAt }];
|
|
319
|
+
} catch (error) {
|
|
320
|
+
if (error instanceof SyntaxError || (error instanceof AgentApiError && error.code === "symlink_not_allowed")) return [];
|
|
321
|
+
throw error;
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
return rows.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || a.id.localeCompare(b.id));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private async draft(req: Request, id: string): Promise<Response> {
|
|
328
|
+
const file = this.path([".drafts", `${id}.json`]);
|
|
329
|
+
if (req.method === "GET") {
|
|
330
|
+
if (!existsSync(file)) throw new AgentApiError(404, "draft_not_found");
|
|
331
|
+
return new Response(readFileSync(file), { headers: { "Content-Type": "application/json" } });
|
|
332
|
+
}
|
|
333
|
+
if (req.method === "DELETE") {
|
|
334
|
+
if (!this.archiveDraft(id)) throw new AgentApiError(404, "draft_not_found");
|
|
335
|
+
return Response.json({ ok: true });
|
|
336
|
+
}
|
|
337
|
+
if (req.method !== "PUT") throw new AgentApiError(405, "method_not_allowed");
|
|
338
|
+
if (Number(req.headers.get("content-length")) > DRAFT_MAX_BYTES * 4 + 65536) throw new AgentApiError(413, "too_large");
|
|
339
|
+
const body = draftBody(await req.json());
|
|
340
|
+
// Checked right before the write (no await between): a create or discard that archived the draft meanwhile wins.
|
|
341
|
+
if (!existsSync(file) && this.draftClosed(id)) throw new AgentApiError(409, "draft_closed");
|
|
342
|
+
const draft = { id, name: draftName(body.source), ...body, updatedAt: this.timestamp() };
|
|
343
|
+
mkdirSync(this.path([".drafts"]), { recursive: true });
|
|
344
|
+
writeFileSync(file, JSON.stringify(draft));
|
|
345
|
+
return Response.json(draft);
|
|
346
|
+
}
|
|
347
|
+
|
|
82
348
|
private tree(id: string) {
|
|
83
|
-
const
|
|
349
|
+
const copies = new Set(this.store.eventsAfter(this.conversation(id).id, 0).flatMap(event =>
|
|
350
|
+
event.type === "agent.collaboration.materialized" && object(event.data) && typeof event.data.fileIdentity === "string" ? [event.data.fileIdentity] : []));
|
|
351
|
+
const entries: { path: string; type: "file" | "directory"; size: number; area: KnowledgeArea; modifiedAt: string | null; origin?: "user-upload" | "collaboration-copy" }[] = [];
|
|
84
352
|
const walk = (parts: readonly string[]) => {
|
|
85
353
|
for (const name of readdirSync(this.path([id, "knowledge", ...parts]))) {
|
|
86
|
-
const stat = lstatSync(this.path([id, "knowledge", ...parts, name]));
|
|
354
|
+
const stat = lstatSync(this.path([id, "knowledge", ...parts, name]), { bigint: true });
|
|
87
355
|
if (!stat.isFile() && !stat.isDirectory()) throw new AgentApiError(400, "unsupported_file");
|
|
88
|
-
|
|
356
|
+
// The modified time rides along for the editor's outputs list (agent-ux todo 17); a folder has none.
|
|
357
|
+
const path = [...parts, name].join("/");
|
|
358
|
+
entries.push({ path, type: stat.isDirectory() ? "directory" : "file", size: stat.isFile() ? Number(stat.size) : 0, area: knowledgeArea([...parts, name]), modifiedAt: stat.isFile() ? stat.mtime.toISOString() : null,
|
|
359
|
+
...(stat.isFile() && knowledgeArea([...parts, name]) === "source" ? { origin: copies.has(contentIdentity(stat)) ? "collaboration-copy" as const : "user-upload" as const } : {}) });
|
|
89
360
|
if (stat.isDirectory()) walk([...parts, name]);
|
|
90
361
|
}
|
|
91
362
|
};
|
|
@@ -93,6 +364,25 @@ export class AgentApi {
|
|
|
93
364
|
return entries;
|
|
94
365
|
}
|
|
95
366
|
|
|
367
|
+
/**
|
|
368
|
+
* Restores an archive entry beside a live agent of the same id. The entry's own conversation comes back only while it is
|
|
369
|
+
* still hidden (its agent is gone); a conversation that a live agent shows stays with it, and a new one is opened.
|
|
370
|
+
*/
|
|
371
|
+
private restoreAs(entry: string, oldId: string): Response {
|
|
372
|
+
const visible = this.store.listConversations(false);
|
|
373
|
+
const hidden = this.store.listConversations(true).find((row) => row.agentId === oldId && !visible.some((shown) => shown.id === row.id));
|
|
374
|
+
const id = this.files.restoreAs(entry);
|
|
375
|
+
// A new id is a new agent (C9): managed with zero grants; the archived agent's grants stay with its old id.
|
|
376
|
+
this.store.toolPermissions.startManaged(id);
|
|
377
|
+
if (hidden) {
|
|
378
|
+
this.store.setConversationAgent(hidden.id, id);
|
|
379
|
+
this.store.setConversationHidden(hidden.id, false);
|
|
380
|
+
} else {
|
|
381
|
+
this.hub.createConversation(String(this.files.read(id).fields.name), id);
|
|
382
|
+
}
|
|
383
|
+
return this.changed(id);
|
|
384
|
+
}
|
|
385
|
+
|
|
96
386
|
private changed(id: string) {
|
|
97
387
|
const detail = this.detail(id);
|
|
98
388
|
this.hub.agentUpdated(this.summary(id, this.files.read(id)));
|
|
@@ -106,11 +396,29 @@ export class AgentApi {
|
|
|
106
396
|
if (path === "/api/agents") {
|
|
107
397
|
if (req.method === "GET") return Response.json(this.list());
|
|
108
398
|
if (req.method !== "POST") throw new AgentApiError(405, "method_not_allowed");
|
|
109
|
-
const
|
|
399
|
+
const body: unknown = await req.json();
|
|
400
|
+
if (!object(body) || typeof body.source !== "string") throw new AgentApiError(400, "source_required");
|
|
401
|
+
if (body.draftId !== undefined && typeof body.draftId !== "string") throw new AgentApiError(400, "invalid_draft_id");
|
|
402
|
+
const fromDraft = body.draftId === undefined ? null : draftId(body.draftId);
|
|
403
|
+
const id = this.files.create(body.source);
|
|
404
|
+
// New agents start managed with zero grants; tools.web in the file or in defaults.md grants nothing (C9).
|
|
405
|
+
this.store.toolPermissions.startManaged(id);
|
|
110
406
|
const definition = this.files.read(id);
|
|
111
407
|
this.hub.createConversation(String(definition.fields.name), id);
|
|
408
|
+
if (fromDraft !== null) this.archiveDraft(fromDraft);
|
|
112
409
|
return new Response((await this.changed(id)).body, { status: 201, headers: { "Content-Type": "application/json" } });
|
|
113
410
|
}
|
|
411
|
+
if (path === "/api/agents/preview") {
|
|
412
|
+
if (req.method !== "POST") throw new AgentApiError(405, "method_not_allowed");
|
|
413
|
+
if (Number(req.headers.get("content-length")) > DRAFT_MAX_BYTES * 4 + 65536) throw new AgentApiError(413, "too_large");
|
|
414
|
+
return Response.json(this.preview(previewSource(await req.json())));
|
|
415
|
+
}
|
|
416
|
+
if (path === "/api/agents/drafts") {
|
|
417
|
+
if (req.method !== "GET") throw new AgentApiError(405, "method_not_allowed");
|
|
418
|
+
return Response.json(this.drafts());
|
|
419
|
+
}
|
|
420
|
+
const draft = /^\/api\/agents\/drafts\/([^/]+)$/.exec(path);
|
|
421
|
+
if (draft?.[1]) return await this.draft(req, draftId(decodeURIComponent(draft[1])));
|
|
114
422
|
if (path === "/api/agents/defaults") {
|
|
115
423
|
if (req.method === "GET") return Response.json(this.defaults());
|
|
116
424
|
if (req.method !== "PUT") throw new AgentApiError(405, "method_not_allowed");
|
|
@@ -137,11 +445,28 @@ export class AgentApi {
|
|
|
137
445
|
const entry = decodeURIComponent(restore[1]);
|
|
138
446
|
const id = /^([a-f0-9]{8})-/.exec(entry)?.[1];
|
|
139
447
|
if (!id) throw new AgentApiError(400, "invalid_trash_entry");
|
|
448
|
+
const text = await req.text();
|
|
449
|
+
const body: unknown = text.trim() ? JSON.parse(text) : {};
|
|
450
|
+
if (!object(body) || (body.newId !== undefined && typeof body.newId !== "boolean")) throw new AgentApiError(400, "invalid_request");
|
|
451
|
+
if (body.newId === true) return this.restoreAs(entry, id);
|
|
140
452
|
const conv = this.conversation(id, false);
|
|
141
|
-
|
|
453
|
+
try {
|
|
454
|
+
this.files.restore(entry);
|
|
455
|
+
} catch (error) {
|
|
456
|
+
if (error instanceof AgentFileError && error.code === "exists") return Response.json({ error: "exists", id }, { status: 409 });
|
|
457
|
+
throw error;
|
|
458
|
+
}
|
|
459
|
+
// A restored agent is a new lifecycle: earlier requests, grants and cancellations never revive.
|
|
460
|
+
this.store.advanceAgentLifecycle(id, this.store.ensureAgentLifecycle(id));
|
|
142
461
|
this.store.setConversationHidden(conv.id, false);
|
|
143
462
|
return this.changed(id);
|
|
144
463
|
}
|
|
464
|
+
const fileRoute = /^\/api\/agents\/([a-f0-9]{8})\/files\/(delete|trash|download|trash\/([^/]+)\/restore)$/.exec(path);
|
|
465
|
+
if (fileRoute?.[1] && fileRoute[2]) {
|
|
466
|
+
this.conversation(fileRoute[1]);
|
|
467
|
+
if (this.deleting.has(fileRoute[1])) throw new AgentApiError(409, "agent_busy");
|
|
468
|
+
return await this.fileAction(req, fileRoute[1], fileRoute[2], fileRoute[3]);
|
|
469
|
+
}
|
|
145
470
|
const match = /^\/api\/agents\/([a-f0-9]{8})(?:\/(clone|delete|files|source))?$/.exec(path);
|
|
146
471
|
const id = match?.[1];
|
|
147
472
|
if (!id) throw new AgentApiError(404, "not_found");
|
|
@@ -159,6 +484,8 @@ export class AgentApi {
|
|
|
159
484
|
if (action === "files" && req.method === "GET") return Response.json(this.tree(id));
|
|
160
485
|
if (action === "clone" && req.method === "POST") {
|
|
161
486
|
const cloned = this.files.clone(id);
|
|
487
|
+
// A clone never copies the source agent's grants or legacy scope (C9, Q09).
|
|
488
|
+
this.store.toolPermissions.startManaged(cloned);
|
|
162
489
|
this.hub.createConversation(String(this.files.read(cloned).fields.name), cloned);
|
|
163
490
|
return this.changed(cloned);
|
|
164
491
|
}
|
|
@@ -167,6 +494,8 @@ export class AgentApi {
|
|
|
167
494
|
this.conversation(id);
|
|
168
495
|
if (!object(body) || body.name !== this.files.read(id).fields.name) throw new AgentApiError(400, "name_mismatch");
|
|
169
496
|
if (this.deleting.has(id)) throw new AgentApiError(409, "agent_busy");
|
|
497
|
+
// Open collaboration obligations keep the agent and its files; cancel or let them finish, then retry.
|
|
498
|
+
if (this.store.agentCollaborationBusy(this.conversation(id).id)) throw new AgentApiError(409, "agent_collaboration_busy");
|
|
170
499
|
this.deleting.add(id);
|
|
171
500
|
try {
|
|
172
501
|
const conv = this.conversation(id);
|
|
@@ -184,22 +513,18 @@ export class AgentApi {
|
|
|
184
513
|
const file = form.get("file");
|
|
185
514
|
if (!(file instanceof File) || file.size === 0) throw new AgentApiError(400, "missing_file");
|
|
186
515
|
if (file.size > MEDIA_MAX_BYTES) throw new AgentApiError(413, "too_large");
|
|
187
|
-
|
|
188
|
-
if (!file.name || file.name.startsWith(".") || /[/\\\0]/.test(file.name)) throw new AgentApiError(400, "invalid_filename");
|
|
189
|
-
let name = "";
|
|
190
|
-
for (const character of file.name.replace(/[^\p{L}\p{N}._-]/gu, "_")) {
|
|
191
|
-
if (Buffer.byteLength(name + character) > 180) break;
|
|
192
|
-
name += character;
|
|
193
|
-
}
|
|
516
|
+
const name = sourceName(file.name);
|
|
194
517
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
195
518
|
this.conversation(id);
|
|
196
519
|
if (this.deleting.has(id)) throw new AgentApiError(409, "agent_busy");
|
|
197
|
-
|
|
520
|
+
using content = new AgentContentFiles(this.files.root);
|
|
521
|
+
const free = content.publish({ directory: [id, "knowledge", "source"], name, bytes, immutable: false });
|
|
198
522
|
this.hub.agentUpdated(this.summary(id, this.files.read(id)));
|
|
199
|
-
return Response.json({ name, size: bytes.length }, { status: 201 });
|
|
523
|
+
return Response.json({ name: free.name, size: bytes.length }, { status: 201 });
|
|
200
524
|
}
|
|
201
525
|
throw new AgentApiError(405, "method_not_allowed");
|
|
202
526
|
} catch (error) {
|
|
527
|
+
if (error instanceof AgentContentError) return Response.json({ error: error.code }, { status: error.status });
|
|
203
528
|
if (error instanceof AgentApiError) return Response.json({ error: error.code }, { status: error.status });
|
|
204
529
|
if (error instanceof AgentDefinitionError) return Response.json({ error: "invalid_agent_definition", field: error.field }, { status: 400 });
|
|
205
530
|
if (error instanceof AgentFileError) return Response.json({ error: error.code }, { status: error.code === "exists" ? 409 : 400 });
|