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
package/src/agent/definition.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { YAML } from "bun";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
+
import { join } from "node:path";
|
|
3
4
|
import { AGENT_FIELDS, type AgentField } from "./fields.ts";
|
|
5
|
+
import type { ToolKey } from "./tool-permission-types.ts";
|
|
4
6
|
|
|
5
7
|
export const AGENT_MAX_BYTES = 32768;
|
|
6
8
|
export const AGENT_SCHEMA = 1;
|
|
@@ -84,6 +86,17 @@ export function validateAgentFields(fields: DefinitionFields, kind: DefinitionKi
|
|
|
84
86
|
if (typeof maxBytes === "number" && (maxBytes < 0 || maxBytes > 32768)) throw new AgentDefinitionError("memory.maxBytes", "expected 0..32768");
|
|
85
87
|
const schema = fields.schema;
|
|
86
88
|
if (schema !== undefined && schema !== AGENT_SCHEMA) throw new AgentDefinitionError("schema", "unsupported version");
|
|
89
|
+
const targets = at(fields, "collaboration.targets");
|
|
90
|
+
if (Array.isArray(targets) && (targets.some(id => typeof id !== "string" || !/^[a-f0-9]{8}$/.test(id)) || new Set(targets).size !== targets.length))
|
|
91
|
+
throw new AgentDefinitionError("collaboration.targets", "expected distinct stable agent IDs");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Save/admission callers supply their current catalogue; names never substitute for stable IDs. */
|
|
95
|
+
export function validateCollaborationTargets(input: { readonly agentId: string | null; readonly fields: DefinitionFields; readonly knownIds: ReadonlySet<string> }): void {
|
|
96
|
+
const targets = at(input.fields, "collaboration.targets");
|
|
97
|
+
if (!Array.isArray(targets)) return;
|
|
98
|
+
if (targets.some(id => id === input.agentId || !input.knownIds.has(id)))
|
|
99
|
+
throw new AgentDefinitionError("collaboration.targets", "self or unknown agent");
|
|
87
100
|
}
|
|
88
101
|
|
|
89
102
|
/** Read-time migration seam. A missing schema is the original v1 format; never mutates the caller's object. */
|
|
@@ -91,7 +104,8 @@ export function migrateAgentFields(fields: DefinitionFields): DefinitionFields {
|
|
|
91
104
|
return { ...fields };
|
|
92
105
|
}
|
|
93
106
|
|
|
94
|
-
|
|
107
|
+
/** The frontmatter as YAML reads it and the body, before any field check (the size, the --- block and an object are checked). */
|
|
108
|
+
export function readAgentFrontmatter(source: string): { readonly fields: Record<string, unknown>; readonly body: string } {
|
|
95
109
|
if (Buffer.byteLength(source, "utf8") > AGENT_MAX_BYTES) throw new AgentDefinitionError("file", "maximum 32768 bytes");
|
|
96
110
|
const match = /^---\r?\n([\s\S]*?)^---[ \t]*(?:\r?\n|$)/m.exec(source);
|
|
97
111
|
if (!match || match.index !== 0) throw new AgentDefinitionError("frontmatter", "expected --- YAML frontmatter");
|
|
@@ -103,8 +117,13 @@ export function parseAgentDefinition(source: string, kind: DefinitionKind = "age
|
|
|
103
117
|
}
|
|
104
118
|
if (parsed === null) parsed = {};
|
|
105
119
|
if (!object(parsed)) throw new AgentDefinitionError("frontmatter", "expected an object");
|
|
106
|
-
|
|
107
|
-
|
|
120
|
+
return { fields: parsed, body: source.slice(match[0].length) };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function parseAgentDefinition(source: string, kind: DefinitionKind = "agent"): AgentDefinition {
|
|
124
|
+
const { fields, body } = readAgentFrontmatter(source);
|
|
125
|
+
validateAgentFields(fields, kind);
|
|
126
|
+
return { fields: migrateAgentFields(fields), body, kind, source };
|
|
108
127
|
}
|
|
109
128
|
|
|
110
129
|
export function serializeAgentDefinition(definition: AgentDefinition): string {
|
|
@@ -149,6 +168,49 @@ function set(fields: Record<string, unknown>, keys: readonly string[], value: un
|
|
|
149
168
|
if (last !== undefined) Object.defineProperty(target, last, { value, enumerable: true, writable: true, configurable: true });
|
|
150
169
|
}
|
|
151
170
|
|
|
171
|
+
export interface LegacyScopeGrant {
|
|
172
|
+
readonly toolKey: ToolKey;
|
|
173
|
+
readonly operations: readonly string[];
|
|
174
|
+
readonly resources: readonly string[];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** One existing (pre-294) scope the owner confirms or declines on its own. `source` says where it came from. */
|
|
178
|
+
export interface LegacyScopeItem {
|
|
179
|
+
readonly id: "knowledge_read" | "knowledge_write" | "history_search" | "web";
|
|
180
|
+
readonly source: "implicit" | "defaults" | "agent";
|
|
181
|
+
readonly grants: readonly LegacyScopeGrant[];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Legacy grants name the tool, not a registration: the gateway has no engine tool schema to digest. A digest that never
|
|
186
|
+
* matches a real registration makes the catalog hold such a grant as tool_changed, so its first use asks the owner again
|
|
187
|
+
* for the exact registration instead of silently binding one.
|
|
188
|
+
*/
|
|
189
|
+
export const LEGACY_REGISTRATION = "legacy:unbound";
|
|
190
|
+
|
|
191
|
+
const legacyKey = (sourceKind: ToolKey["sourceKind"], ownerId: string, originalName: string): ToolKey =>
|
|
192
|
+
({ sourceKind, ownerId, originalName, registrationDigest: LEGACY_REGISTRATION });
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The scopes an existing agent has today (plan C9), as a list for the migration card. The implicit knowledge read (whole
|
|
196
|
+
* knowledge/, source/ stays read-only), write (outputs/, notes/, MEMORY.md) and own history search are always listed.
|
|
197
|
+
* Web is listed only for an explicit boolean `tools.web: true` from defaults.md or agent.md, with that source. Unknown
|
|
198
|
+
* YAML keys, other values and the package default never add a scope, and skills never widen tools.
|
|
199
|
+
*/
|
|
200
|
+
export function legacyToolScopes(merged: { readonly fields: DefinitionFields; readonly origins: Readonly<Record<string, string>> }, knowledgeRoot: string): LegacyScopeItem[] {
|
|
201
|
+
const writable = [join(knowledgeRoot, "MEMORY.md"), join(knowledgeRoot, "notes"), join(knowledgeRoot, "outputs")].map((path) => `path:${path}`);
|
|
202
|
+
const items: LegacyScopeItem[] = [
|
|
203
|
+
{ id: "knowledge_read", source: "implicit", grants: [{ toolKey: legacyKey("builtin", "engine", "read"), operations: ["read", "list"], resources: [`path:${knowledgeRoot}`] }] },
|
|
204
|
+
{ id: "knowledge_write", source: "implicit", grants: ["write", "edit"].map((name) => ({ toolKey: legacyKey("builtin", "engine", name), operations: ["write"], resources: writable })) },
|
|
205
|
+
{ id: "history_search", source: "implicit", grants: [{ toolKey: legacyKey("gateway", "gateway", "zgw_agent_history_search"), operations: ["search"], resources: [] }] },
|
|
206
|
+
];
|
|
207
|
+
const origin = merged.origins["tools.web"];
|
|
208
|
+
if (at(merged.fields, "tools.web") === true && (origin === "defaults" || origin === "agent")) {
|
|
209
|
+
items.push({ id: "web", source: origin, grants: [{ toolKey: legacyKey("builtin", "engine", "web_search"), operations: ["search"], resources: ["provider:engine/web_search"] }] });
|
|
210
|
+
}
|
|
211
|
+
return items;
|
|
212
|
+
}
|
|
213
|
+
|
|
152
214
|
export function mergeAgentDefinition(agent: AgentDefinition, defaults?: AgentDefinition): MergedAgentDefinition {
|
|
153
215
|
validateAgentFields(agent.fields);
|
|
154
216
|
if (defaults) validateAgentFields(defaults.fields, "defaults");
|
package/src/agent/fields.ts
CHANGED
|
@@ -7,11 +7,13 @@ export interface AgentField {
|
|
|
7
7
|
readonly applies: "gateway" | "extension";
|
|
8
8
|
readonly widget: "text" | "textarea" | "number" | "toggle" | "model" | "select" | "skills" | "group";
|
|
9
9
|
readonly forbidden: boolean;
|
|
10
|
+
/** Not drawn by the editor: the gateway writes the value itself (the schema version). */
|
|
11
|
+
readonly hidden?: boolean;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
/** The registry owns defaults, validation types and editor metadata. Undefined means no package override. */
|
|
13
15
|
export const AGENT_FIELDS: readonly AgentField[] = [
|
|
14
|
-
{ name: "schema", type: "integer", default: 1, required: false, merge: "replace", applies: "gateway", widget: "number", forbidden: false },
|
|
16
|
+
{ name: "schema", type: "integer", default: 1, required: false, merge: "replace", applies: "gateway", widget: "number", forbidden: false, hidden: true },
|
|
15
17
|
{ name: "name", type: "string", default: undefined, required: true, merge: "replace", applies: "gateway", widget: "text", forbidden: false },
|
|
16
18
|
{ name: "description", type: "string", default: undefined, required: true, merge: "replace", applies: "gateway", widget: "textarea", forbidden: false },
|
|
17
19
|
{ name: "icon", type: "string", default: undefined, required: false, merge: "replace", applies: "gateway", widget: "text", forbidden: false },
|
|
@@ -20,6 +22,8 @@ export const AGENT_FIELDS: readonly AgentField[] = [
|
|
|
20
22
|
{ name: "model.modelId", type: "string", default: undefined, required: false, merge: "replace", applies: "extension", widget: "text", forbidden: false },
|
|
21
23
|
{ name: "thinking", type: "string", default: undefined, required: false, merge: "replace", applies: "extension", widget: "select", forbidden: false },
|
|
22
24
|
{ name: "skills", type: "strings", default: [], required: false, merge: "replace", applies: "extension", widget: "skills", forbidden: false },
|
|
25
|
+
{ name: "collaboration", type: "object", default: {}, required: false, merge: "keys", applies: "gateway", widget: "group", forbidden: false, hidden: true },
|
|
26
|
+
{ name: "collaboration.targets", type: "strings", default: [], required: false, merge: "replace", applies: "gateway", widget: "skills", forbidden: false, hidden: true },
|
|
23
27
|
{ name: "tools", type: "object", default: {}, required: false, merge: "keys", applies: "extension", widget: "group", forbidden: false },
|
|
24
28
|
{ name: "tools.web", type: "boolean", default: false, required: false, merge: "replace", applies: "extension", widget: "toggle", forbidden: false },
|
|
25
29
|
{ name: "tools.bash", type: "boolean", default: undefined, required: false, merge: "replace", applies: "extension", widget: "toggle", forbidden: true },
|
|
@@ -35,8 +39,10 @@ export const AGENT_FIELDS: readonly AgentField[] = [
|
|
|
35
39
|
/**
|
|
36
40
|
* The registry rows as the agent API sends them with every definition answer (`registry` in GET /api/agents/:id and
|
|
37
41
|
* /api/agents/defaults). The web editor draws its widgets from these rows, so a new registry row needs no web change.
|
|
38
|
-
* Forbidden rows are sent marked, so the editor can refuse them before the gateway does
|
|
42
|
+
* Forbidden rows are sent marked, so the editor can refuse them before the gateway does; hidden rows carry
|
|
43
|
+
* `hidden: true`, so the editor does not draw them (other rows omit the key).
|
|
39
44
|
*/
|
|
40
45
|
export function registryMetadata(rows: readonly AgentField[] = AGENT_FIELDS): AgentField[] {
|
|
41
|
-
return rows.map(({ name, type, default: value, required, merge, applies, widget, forbidden }) =>
|
|
46
|
+
return rows.map(({ name, type, default: value, required, merge, applies, widget, forbidden, hidden }) =>
|
|
47
|
+
({ name, type, default: value, required, merge, applies, widget, forbidden, ...(hidden === true ? { hidden: true } : {}) }));
|
|
42
48
|
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { dlopen, read as nativeRead } from "bun:ffi";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { closeSync, constants, fchmodSync, fstatSync, fsyncSync, ftruncateSync, readSync, writeFileSync, type BigIntStats } from "node:fs";
|
|
4
|
+
import { MEDIA_MAX_BYTES } from "../media.ts";
|
|
5
|
+
|
|
6
|
+
export class AgentContentError extends Error {
|
|
7
|
+
constructor(readonly code: string, readonly status = 400, readonly errno?: number) { super(errno === undefined ? code : `${code} (errno ${errno})`); }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Keep descriptor-relative access on macOS and the existing Linux/glibc test entry.
|
|
11
|
+
const native = (() => {
|
|
12
|
+
const common = {
|
|
13
|
+
openat: { args: ["i32", "buffer", "i32", "i32"], returns: "i32" },
|
|
14
|
+
mkdirat: { args: ["i32", "buffer", "i32"], returns: "i32" },
|
|
15
|
+
} as const;
|
|
16
|
+
const rename = { args: ["i32", "buffer", "i32", "buffer", "u32"], returns: "i32" } as const;
|
|
17
|
+
const error = { args: [], returns: "ptr" } as const;
|
|
18
|
+
switch (process.platform) {
|
|
19
|
+
case "darwin": {
|
|
20
|
+
const symbols = dlopen("/usr/lib/libSystem.B.dylib", { ...common, renameatx_np: rename, __error: error }).symbols;
|
|
21
|
+
// Darwin sys/fcntl.h and sys/stdio.h: AT_FDCWD and RENAME_EXCL.
|
|
22
|
+
return { ...symbols, renameExclusive: symbols.renameatx_np, errorLocation: symbols.__error, cwd: -2, exclusive: 0x00000004 };
|
|
23
|
+
}
|
|
24
|
+
case "linux": {
|
|
25
|
+
const symbols = dlopen("libc.so.6", { ...common, renameat2: rename, __errno_location: error }).symbols;
|
|
26
|
+
// Linux uapi/linux/{fcntl,fs}.h: AT_FDCWD and RENAME_NOREPLACE.
|
|
27
|
+
return { ...symbols, renameExclusive: symbols.renameat2, errorLocation: symbols.__errno_location, cwd: -100, exclusive: 1 };
|
|
28
|
+
}
|
|
29
|
+
default: throw new AgentContentError("unsupported_filesystem_platform", 503);
|
|
30
|
+
}
|
|
31
|
+
})();
|
|
32
|
+
const DIRECTORY = constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW;
|
|
33
|
+
|
|
34
|
+
function errno(): number {
|
|
35
|
+
const pointer = native.errorLocation();
|
|
36
|
+
if (pointer === null) throw new AgentContentError("filesystem_errno_unavailable", 503);
|
|
37
|
+
return nativeRead.i32(pointer);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function openRoot(root: string): number {
|
|
41
|
+
if (!root.startsWith("/") || root.includes("\0")) throw new AgentContentError("invalid_path");
|
|
42
|
+
let fd = native.openat(native.cwd, Buffer.from("/\0"), DIRECTORY, 0);
|
|
43
|
+
if (fd < 0) throw new AgentContentError("unsafe_root", 400, errno());
|
|
44
|
+
try {
|
|
45
|
+
for (const part of root.split("/").filter(Boolean)) {
|
|
46
|
+
const next = native.openat(fd, component(part), DIRECTORY, 0);
|
|
47
|
+
if (next < 0) throw new AgentContentError("unsafe_root", 400, errno());
|
|
48
|
+
closeSync(fd);
|
|
49
|
+
fd = next;
|
|
50
|
+
}
|
|
51
|
+
return fd;
|
|
52
|
+
} catch (error) { closeSync(fd); throw error; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function component(value: string): Buffer {
|
|
56
|
+
if (!value || value === "." || value === ".." || /[/\\\0]/.test(value)) throw new AgentContentError("invalid_path");
|
|
57
|
+
return Buffer.from(`${value}\0`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function sourceName(value: string): string {
|
|
61
|
+
if (!value || value.startsWith(".") || /[/\\\0]/.test(value)) throw new AgentContentError("invalid_filename");
|
|
62
|
+
let name = "";
|
|
63
|
+
for (const character of value.replace(/[^\p{L}\p{N}._-]/gu, "_")) {
|
|
64
|
+
if (Buffer.byteLength(name + character) > 180) break;
|
|
65
|
+
name += character;
|
|
66
|
+
}
|
|
67
|
+
return name;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function numberedName(name: string, ordinal: number): string {
|
|
71
|
+
if (ordinal === 1) return name;
|
|
72
|
+
const dot = name.lastIndexOf(".");
|
|
73
|
+
return dot > 0 ? `${name.slice(0, dot)} (${ordinal})${name.slice(dot)}` : `${name} (${ordinal})`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function identity(a: BigIntStats, b: BigIntStats): boolean {
|
|
77
|
+
return a.dev === b.dev && a.ino === b.ino;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Creation identity survives rename/restore but not replacement at the same display path. */
|
|
81
|
+
export function contentIdentity(stat: BigIntStats): string {
|
|
82
|
+
return `${stat.dev}:${stat.ino}:${stat.birthtimeNs}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface ContentVersion {
|
|
86
|
+
readonly dev: bigint;
|
|
87
|
+
readonly ino: bigint;
|
|
88
|
+
readonly size: bigint;
|
|
89
|
+
readonly mtimeNs: bigint;
|
|
90
|
+
readonly ctimeNs: bigint;
|
|
91
|
+
/** Opaque root/ancestor identity captured by readWithIdentity; keep it with the version. */
|
|
92
|
+
readonly ancestry: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function fileStamp(a: Omit<ContentVersion, "ancestry">, b: BigIntStats): boolean {
|
|
96
|
+
return a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs && b.nlink === 1n;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface ContentPublication {
|
|
100
|
+
readonly directory: readonly string[];
|
|
101
|
+
readonly name: string;
|
|
102
|
+
readonly bytes: Uint8Array;
|
|
103
|
+
readonly immutable: boolean;
|
|
104
|
+
/** Immutable artifact identifiers never take a numbered replacement. */
|
|
105
|
+
readonly exact?: boolean;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** All names are relative to a gateway-owned root; no caller-selected host path reader. */
|
|
109
|
+
// allow: SIZE_OK - one descriptor lifecycle; this task permits only this product file, not a separate filesystem layer.
|
|
110
|
+
export class AgentContentFiles implements Disposable {
|
|
111
|
+
private readonly fd: number;
|
|
112
|
+
private readonly rootIdentity: BigIntStats;
|
|
113
|
+
|
|
114
|
+
constructor(private readonly root: string) {
|
|
115
|
+
this.fd = openRoot(root);
|
|
116
|
+
try { this.rootIdentity = fstatSync(this.fd, { bigint: true }); }
|
|
117
|
+
catch (error) { closeSync(this.fd); throw error; }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
[Symbol.dispose](): void { closeSync(this.fd); }
|
|
121
|
+
|
|
122
|
+
private open(parent: number, name: string, flags: number): number {
|
|
123
|
+
const fd = native.openat(parent, component(name), flags | constants.O_NOFOLLOW, 0o600);
|
|
124
|
+
if (fd < 0) throw new AgentContentError("unsafe_path", 400, errno());
|
|
125
|
+
return fd;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private directory<T>(parts: readonly string[], create: boolean | (() => void), action: (fd: number, check: () => void, ancestry: string) => T): T {
|
|
129
|
+
const chain: { readonly parent: number; readonly name: string; readonly fd: number; readonly stat: BigIntStats }[] = [];
|
|
130
|
+
let parent = this.fd;
|
|
131
|
+
const check = () => {
|
|
132
|
+
const root = openRoot(this.root);
|
|
133
|
+
try {
|
|
134
|
+
if (!identity(this.rootIdentity, fstatSync(root, { bigint: true }))) throw new AgentContentError("file_changed", 409);
|
|
135
|
+
} finally { closeSync(root); }
|
|
136
|
+
for (const entry of chain) {
|
|
137
|
+
const current = this.open(entry.parent, entry.name, DIRECTORY);
|
|
138
|
+
try {
|
|
139
|
+
if (!identity(entry.stat, fstatSync(current, { bigint: true }))) throw new AgentContentError("file_changed", 409);
|
|
140
|
+
} finally { closeSync(current); }
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
try {
|
|
144
|
+
for (const part of parts) {
|
|
145
|
+
let fd: number;
|
|
146
|
+
if (typeof create === "function") {
|
|
147
|
+
try { fd = this.open(parent, part, DIRECTORY); }
|
|
148
|
+
catch (error) {
|
|
149
|
+
if (!(error instanceof AgentContentError) || error.errno !== 2) throw error;
|
|
150
|
+
check();
|
|
151
|
+
create();
|
|
152
|
+
check();
|
|
153
|
+
if (native.mkdirat(parent, component(part), 0o700) < 0) throw new AgentContentError("directory_create_failed", 503, errno());
|
|
154
|
+
fd = this.open(parent, part, DIRECTORY);
|
|
155
|
+
}
|
|
156
|
+
} else {
|
|
157
|
+
if (create && native.mkdirat(parent, component(part), 0o700) < 0 && errno() !== 17) throw new AgentContentError("directory_create_failed", 503);
|
|
158
|
+
fd = this.open(parent, part, DIRECTORY);
|
|
159
|
+
}
|
|
160
|
+
try { chain.push({ parent, name: part, fd, stat: fstatSync(fd, { bigint: true }) }); }
|
|
161
|
+
catch (error) { closeSync(fd); throw error; }
|
|
162
|
+
parent = fd;
|
|
163
|
+
}
|
|
164
|
+
check();
|
|
165
|
+
const ancestry = JSON.stringify([String(this.rootIdentity.dev), String(this.rootIdentity.ino),
|
|
166
|
+
...chain.map(entry => [entry.name, String(entry.stat.dev), String(entry.stat.ino)])]);
|
|
167
|
+
const result = action(parent, check, ancestry);
|
|
168
|
+
check();
|
|
169
|
+
return result;
|
|
170
|
+
} finally {
|
|
171
|
+
for (const entry of chain.reverse()) closeSync(entry.fd);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
read(parts: readonly string[]): Buffer {
|
|
176
|
+
return this.readWithIdentity(parts).bytes;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
readWithIdentity(parts: readonly string[]): { readonly bytes: Buffer; readonly identity: ContentVersion } {
|
|
180
|
+
const name = parts.at(-1);
|
|
181
|
+
if (name === undefined) throw new AgentContentError("invalid_path");
|
|
182
|
+
return this.directory(parts.slice(0, -1), false, (parent, _check, ancestry) => {
|
|
183
|
+
// NONBLOCK ensures a substituted FIFO cannot hang before fstat rejects it.
|
|
184
|
+
const fd = this.open(parent, name, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
185
|
+
try {
|
|
186
|
+
const before = fstatSync(fd, { bigint: true });
|
|
187
|
+
if (!before.isFile() || before.nlink !== 1n) throw new AgentContentError("not_single_regular_file");
|
|
188
|
+
if (before.size > BigInt(MEDIA_MAX_BYTES)) throw new AgentContentError("too_large", 413);
|
|
189
|
+
const bytes = Buffer.alloc(Number(before.size) + 1);
|
|
190
|
+
let length = 0;
|
|
191
|
+
while (length < bytes.length) {
|
|
192
|
+
const count = readSync(fd, bytes, length, bytes.length - length, length);
|
|
193
|
+
if (count === 0) break;
|
|
194
|
+
length += count;
|
|
195
|
+
}
|
|
196
|
+
if (length !== Number(before.size) || !fileStamp(before, fstatSync(fd, { bigint: true }))) throw new AgentContentError("file_changed", 409);
|
|
197
|
+
const current = this.open(parent, name, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
198
|
+
try {
|
|
199
|
+
if (!fileStamp(before, fstatSync(current, { bigint: true }))) throw new AgentContentError("file_changed", 409);
|
|
200
|
+
} finally { closeSync(current); }
|
|
201
|
+
return { bytes: bytes.subarray(0, length), identity: Object.freeze({
|
|
202
|
+
dev: before.dev, ino: before.ino, size: before.size, mtimeNs: before.mtimeNs, ctimeNs: before.ctimeNs, ancestry,
|
|
203
|
+
}) };
|
|
204
|
+
} finally { closeSync(fd); }
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Synchronous admission precedes the first mutation, including mkdir. No awaits or second SDK queue. */
|
|
209
|
+
write(input: {
|
|
210
|
+
readonly segments: readonly string[];
|
|
211
|
+
readonly bytes: Uint8Array;
|
|
212
|
+
readonly expectedIdentity?: ContentVersion;
|
|
213
|
+
readonly beforeMutation: () => void;
|
|
214
|
+
}): void {
|
|
215
|
+
const parts = [...input.segments];
|
|
216
|
+
const name = parts.at(-1);
|
|
217
|
+
if (name === undefined) throw new AgentContentError("invalid_path");
|
|
218
|
+
for (const part of parts) component(part);
|
|
219
|
+
if (input.bytes.byteLength > MEDIA_MAX_BYTES) throw new AgentContentError("too_large", 413);
|
|
220
|
+
const bytes = Buffer.from(input.bytes);
|
|
221
|
+
const expected = input.expectedIdentity;
|
|
222
|
+
let admitted = false;
|
|
223
|
+
let callbackFailure: unknown;
|
|
224
|
+
const admit = () => {
|
|
225
|
+
if (admitted) return;
|
|
226
|
+
try { input.beforeMutation(); }
|
|
227
|
+
catch (error) { callbackFailure = error; throw error; }
|
|
228
|
+
admitted = true;
|
|
229
|
+
};
|
|
230
|
+
try {
|
|
231
|
+
this.directory(parts.slice(0, -1), expected ? false : admit, (parent, check, ancestry) => {
|
|
232
|
+
let fd: number;
|
|
233
|
+
let created = false;
|
|
234
|
+
try { fd = this.open(parent, name, constants.O_RDWR | constants.O_NONBLOCK); }
|
|
235
|
+
catch (error) {
|
|
236
|
+
if (!(error instanceof AgentContentError) || error.errno !== 2 || expected) throw error;
|
|
237
|
+
check();
|
|
238
|
+
admit();
|
|
239
|
+
check();
|
|
240
|
+
fd = this.open(parent, name, constants.O_RDWR | constants.O_NONBLOCK | constants.O_CREAT | constants.O_EXCL);
|
|
241
|
+
created = true;
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
// Match publish: set the mode on the newly owned fd before reopening it.
|
|
245
|
+
if (created) fchmodSync(fd, 0o600);
|
|
246
|
+
const before = fstatSync(fd, { bigint: true });
|
|
247
|
+
if (expected && (expected.ancestry !== ancestry || !fileStamp(expected, before))) throw new AgentContentError("file_changed", 409);
|
|
248
|
+
if (!before.isFile() || before.nlink !== 1n) throw new AgentContentError("not_single_regular_file");
|
|
249
|
+
if (before.size > BigInt(MEDIA_MAX_BYTES)) throw new AgentContentError("too_large", 413);
|
|
250
|
+
const verify = (stamp: Omit<ContentVersion, "ancestry">) => {
|
|
251
|
+
check();
|
|
252
|
+
const current = this.open(parent, name, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
253
|
+
try {
|
|
254
|
+
if (!fileStamp(stamp, fstatSync(fd, { bigint: true })) || !fileStamp(stamp, fstatSync(current, { bigint: true }))) throw new AgentContentError("file_changed", 409);
|
|
255
|
+
} finally { closeSync(current); }
|
|
256
|
+
};
|
|
257
|
+
verify(before);
|
|
258
|
+
admit();
|
|
259
|
+
verify(before);
|
|
260
|
+
// Never O_TRUNC on open: the same validated fd is mutated only after admission.
|
|
261
|
+
if (!created) ftruncateSync(fd, 0);
|
|
262
|
+
writeFileSync(fd, bytes);
|
|
263
|
+
fsyncSync(fd);
|
|
264
|
+
const written = fstatSync(fd, { bigint: true });
|
|
265
|
+
if (written.size !== BigInt(bytes.length)) throw new AgentContentError("file_changed", 409);
|
|
266
|
+
verify(written);
|
|
267
|
+
} finally { closeSync(fd); }
|
|
268
|
+
});
|
|
269
|
+
} catch (error) {
|
|
270
|
+
if (expected && error !== callbackFailure && error instanceof AgentContentError &&
|
|
271
|
+
["unsafe_path", "unsafe_root", "not_single_regular_file", "too_large"].includes(error.code)) throw new AgentContentError("file_changed", 409);
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
publish(input: ContentPublication): { readonly name: string; readonly sha256: string; readonly bytes: number; readonly fileIdentity: string } {
|
|
277
|
+
if (input.bytes.byteLength > MEDIA_MAX_BYTES) throw new AgentContentError("too_large", 413);
|
|
278
|
+
const bytes = Buffer.from(input.bytes);
|
|
279
|
+
const name = sourceName(input.name);
|
|
280
|
+
return this.directory(input.directory, true, parent => {
|
|
281
|
+
const pending = `.pending-${randomUUID()}`;
|
|
282
|
+
const fd = this.open(parent, pending, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL);
|
|
283
|
+
let written: BigIntStats;
|
|
284
|
+
try {
|
|
285
|
+
fchmodSync(fd, 0o600);
|
|
286
|
+
writeFileSync(fd, bytes);
|
|
287
|
+
fchmodSync(fd, input.immutable ? 0o400 : 0o600);
|
|
288
|
+
fsyncSync(fd);
|
|
289
|
+
written = fstatSync(fd, { bigint: true });
|
|
290
|
+
if (!written.isFile() || written.nlink !== 1n || written.size !== BigInt(bytes.length)) throw new AgentContentError("file_changed", 409);
|
|
291
|
+
} finally { closeSync(fd); }
|
|
292
|
+
// A failed write remains an unreferenced pending file, never an accepted manifest.
|
|
293
|
+
for (let ordinal = 1; ordinal <= (input.exact ? 1 : 1000); ordinal++) {
|
|
294
|
+
const candidate = numberedName(name, ordinal);
|
|
295
|
+
if (native.renameExclusive(parent, component(pending), parent, component(candidate), native.exclusive) === 0) {
|
|
296
|
+
const published = this.open(parent, candidate, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
297
|
+
try {
|
|
298
|
+
const current = fstatSync(published, { bigint: true });
|
|
299
|
+
if (!identity(written, current) || current.nlink !== 1n || current.size !== written.size || current.mtimeNs !== written.mtimeNs) throw new AgentContentError("file_changed", 409);
|
|
300
|
+
} finally { closeSync(published); }
|
|
301
|
+
fsyncSync(parent);
|
|
302
|
+
return { name: candidate, sha256: createHash("sha256").update(bytes).digest("hex"), bytes: bytes.length, fileIdentity: contentIdentity(written) };
|
|
303
|
+
}
|
|
304
|
+
const code = errno();
|
|
305
|
+
if (code !== 17) throw new AgentContentError("publish_failed", 503, code);
|
|
306
|
+
// Preserve the upload contract: an occupied symlink is an error, not a suffix candidate.
|
|
307
|
+
const occupied = this.open(parent, candidate, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
308
|
+
closeSync(occupied);
|
|
309
|
+
}
|
|
310
|
+
throw new AgentContentError("file_exists", 409);
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Called only after the Store's protected explicit deletion has recorded its tombstone. */
|
|
315
|
+
trash(name: string): void {
|
|
316
|
+
this.directory([".collaboration", "artifacts"], false, source =>
|
|
317
|
+
this.directory([".collaboration", "trash"], true, target => {
|
|
318
|
+
if (native.renameExclusive(source, component(name), target, component(name), native.exclusive) < 0) {
|
|
319
|
+
throw new AgentContentError("trash_failed", 503, errno());
|
|
320
|
+
}
|
|
321
|
+
fsyncSync(source);
|
|
322
|
+
fsyncSync(target);
|
|
323
|
+
}));
|
|
324
|
+
}
|
|
325
|
+
}
|
package/src/agent/files.ts
CHANGED
|
@@ -3,17 +3,42 @@ import { copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSy
|
|
|
3
3
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { runtimePaths, type Env } from "../runtime/paths.ts";
|
|
5
5
|
import { say, type Text } from "../server-text.ts";
|
|
6
|
-
import { parseAgentDefinition, AgentDefinitionError, serializeAgentDefinition, type AgentDefinition } from "./definition.ts";
|
|
6
|
+
import { AGENT_SCHEMA, parseAgentDefinition, AgentDefinitionError, mergeAgentDefinition, validateCollaborationTargets, migrateAgentFields, readAgentFrontmatter, serializeAgentDefinition, validateAgentFields, type AgentDefinition } from "./definition.ts";
|
|
7
|
+
import { AGENT_FIELDS } from "./fields.ts";
|
|
7
8
|
import { migrateLegacyAgentFiles } from "./legacy-files.ts";
|
|
8
9
|
|
|
9
10
|
export function agentTrashEntry(id: string, timestamp: string): string {
|
|
10
11
|
return `${id}-${timestamp}`;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
/**
|
|
14
|
+
/** A UTC ISO time as toISOString writes it; null for any other text, so Date.parse never rolls Feb 30 or hour 25 over. */
|
|
15
|
+
export function utcIsoTime(text: string): number | null {
|
|
16
|
+
const at = Date.parse(text);
|
|
17
|
+
return Number.isFinite(at) && new Date(at).toISOString() === text ? at : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Trash names retain the UTC ISO timestamp used by the file store, including colons; NaN for an impossible time. */
|
|
14
21
|
export function agentTrashTimestamp(entry: string): number | null {
|
|
15
22
|
const timestamp = /-(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)$/.exec(entry)?.[1];
|
|
16
|
-
return timestamp === undefined ? null :
|
|
23
|
+
return timestamp === undefined ? null : utcIsoTime(timestamp) ?? Number.NaN;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A deleted knowledge file is kept as agents/.file-trash/<id>/<UTC ISO time>-<path>, with only "%", "/" and the control
|
|
28
|
+
* characters U+0000-U+001F and U+007F of the path escaped as %XX (uppercase hex), so any name the agent wrote comes back.
|
|
29
|
+
* The path stays readable and short: base64 would push a 180-byte upload name past the 255-byte limit.
|
|
30
|
+
*/
|
|
31
|
+
export function agentFileTrashName(path: string, timestamp: string): string {
|
|
32
|
+
return `${timestamp}-${path.replace(/[%/\u0000-\u001f\u007f]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`)}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The deletion time and the original path of a file archive entry; null for any name the store did not write. */
|
|
36
|
+
export function parseAgentFileTrashName(entry: string): { readonly deletedAt: number; readonly path: string } | null {
|
|
37
|
+
const match = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)-((?:[^%/\u0000-\u001f\u007f]|%(?:25|2F|[01][0-9A-F]|7F))+)$/su.exec(entry);
|
|
38
|
+
if (!match?.[1] || !match[2]) return null;
|
|
39
|
+
const deletedAt = utcIsoTime(match[1]);
|
|
40
|
+
if (deletedAt === null) return null;
|
|
41
|
+
return { deletedAt, path: match[2].replace(/%([0-9A-F]{2})/g, (_, hex: string) => String.fromCharCode(Number.parseInt(hex, 16))) };
|
|
17
42
|
}
|
|
18
43
|
|
|
19
44
|
/** A broken entry must not prevent the other agents from being listed. */
|
|
@@ -24,6 +49,24 @@ export function unreadableAgent(error: unknown): boolean {
|
|
|
24
49
|
}
|
|
25
50
|
|
|
26
51
|
const COPY_SUFFIX: Text = { en: " copy", ko: " 사본" };
|
|
52
|
+
/** What parseNew reads for a required text the new agent does not have yet; it never leaves the preview. */
|
|
53
|
+
const NEW_AGENT_PLACEHOLDER = "new agent";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A definition without a schema is the v1 format; write the number, so the editor never has to show it. The line goes
|
|
57
|
+
* first and the rest stays byte for byte; a frontmatter that cannot take a leading line (a flow map) is re-serialized.
|
|
58
|
+
*/
|
|
59
|
+
function withSchema(source: string): string {
|
|
60
|
+
const definition = parseAgentDefinition(source);
|
|
61
|
+
if (definition.fields.schema !== undefined) return source;
|
|
62
|
+
const block = source.replace(/^---(\r?\n)/, `---$1schema: ${AGENT_SCHEMA}$1`);
|
|
63
|
+
try {
|
|
64
|
+
if (parseAgentDefinition(block).fields.schema === AGENT_SCHEMA) return block;
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (!(error instanceof AgentDefinitionError)) throw error;
|
|
67
|
+
}
|
|
68
|
+
return serializeAgentDefinition({ ...definition, fields: { schema: AGENT_SCHEMA, ...definition.fields } });
|
|
69
|
+
}
|
|
27
70
|
|
|
28
71
|
export class AgentFileError extends Error {
|
|
29
72
|
constructor(readonly code: "path" | "exists" | "id", message: string) {
|
|
@@ -77,12 +120,14 @@ export class AgentFileStore {
|
|
|
77
120
|
return new Date(this.lastTime).toISOString();
|
|
78
121
|
}
|
|
79
122
|
|
|
80
|
-
create(
|
|
81
|
-
|
|
123
|
+
create(input: string): string {
|
|
124
|
+
const source = withSchema(input);
|
|
82
125
|
const id = randomUUID().slice(0, 8);
|
|
126
|
+
validateCollaborationTargets({ agentId: id, fields: mergeAgentDefinition(parseAgentDefinition(source), this.readDefaults()).fields, knownIds: new Set(this.list().map(agent => agent.id)) });
|
|
83
127
|
const folder = this.folder(id);
|
|
84
128
|
mkdirSync(folder);
|
|
85
129
|
mkdirSync(join(folder, "knowledge/source"), { recursive: true });
|
|
130
|
+
mkdirSync(join(folder, "knowledge/outputs"), { recursive: true });
|
|
86
131
|
mkdirSync(join(folder, "knowledge/notes/daily"), { recursive: true });
|
|
87
132
|
writeFileSync(join(folder, "knowledge/MEMORY.md"), "", { flag: "wx" });
|
|
88
133
|
writeFileSync(join(folder, "agent.md"), source, { flag: "wx" });
|
|
@@ -123,8 +168,28 @@ export class AgentFileStore {
|
|
|
123
168
|
}
|
|
124
169
|
|
|
125
170
|
save(id: string, source: string): void {
|
|
126
|
-
|
|
127
|
-
this.saveFile(join(this.folder(id), "agent.md"), source);
|
|
171
|
+
validateCollaborationTargets({ agentId: id, fields: mergeAgentDefinition(this.parse(source), this.readDefaults()).fields, knownIds: new Set(this.list().map(agent => agent.id)) });
|
|
172
|
+
this.saveFile(join(this.folder(id), "agent.md"), withSchema(source));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** `source` as a save of it reads back (schema 1 filled in, parsed and validated); nothing is written. */
|
|
176
|
+
parse(source: string): AgentDefinition {
|
|
177
|
+
return parseAgentDefinition(withSchema(source));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* A new agent's text before its required texts are written (agent-ux int): a missing or blank name or description reads as a
|
|
182
|
+
* placeholder, so the permission fields, which do not depend on them, can be evaluated as a save would read them. `filled`
|
|
183
|
+
* names the fields that hold the placeholder; nothing is written.
|
|
184
|
+
*/
|
|
185
|
+
parseNew(source: string): { readonly definition: AgentDefinition; readonly filled: readonly string[] } {
|
|
186
|
+
const { fields, body } = readAgentFrontmatter(source);
|
|
187
|
+
const filled = AGENT_FIELDS.filter((field) => field.required && field.type === "string" && !field.name.includes("."))
|
|
188
|
+
.map((field) => field.name).filter((name) => fields[name] === undefined || (typeof fields[name] === "string" && !fields[name].trim()));
|
|
189
|
+
if (filled.length === 0) return { definition: this.parse(source), filled };
|
|
190
|
+
const complete = { schema: AGENT_SCHEMA, ...fields, ...Object.fromEntries(filled.map((name) => [name, NEW_AGENT_PLACEHOLDER])) };
|
|
191
|
+
validateAgentFields(complete);
|
|
192
|
+
return { definition: { fields: migrateAgentFields(complete), body, kind: "agent", source }, filled };
|
|
128
193
|
}
|
|
129
194
|
|
|
130
195
|
readDefaults(): AgentDefinition | undefined {
|
|
@@ -133,7 +198,10 @@ export class AgentFileStore {
|
|
|
133
198
|
}
|
|
134
199
|
|
|
135
200
|
saveDefaults(source: string): void {
|
|
136
|
-
parseAgentDefinition(source, "defaults");
|
|
201
|
+
const defaults = parseAgentDefinition(source, "defaults");
|
|
202
|
+
const agents = this.list(), knownIds = new Set(agents.map(agent => agent.id));
|
|
203
|
+
validateCollaborationTargets({ agentId: null, fields: defaults.fields, knownIds });
|
|
204
|
+
for (const agent of agents) validateCollaborationTargets({ agentId: agent.id, fields: mergeAgentDefinition(agent.definition, defaults).fields, knownIds });
|
|
137
205
|
this.saveFile(join(this.root, "defaults.md"), source);
|
|
138
206
|
}
|
|
139
207
|
|
|
@@ -189,4 +257,15 @@ export class AgentFileStore {
|
|
|
189
257
|
renameSync(this.checked(join(".trash", entry)), target);
|
|
190
258
|
return id;
|
|
191
259
|
}
|
|
260
|
+
|
|
261
|
+
/** Restores an archive entry under a fresh id, for an entry whose own id is taken; never touches the live folder. */
|
|
262
|
+
restoreAs(entry: string): string {
|
|
263
|
+
if (!/^[a-f0-9]{8}-\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(entry)) throw new AgentFileError("id", "Invalid trash entry");
|
|
264
|
+
const source = this.checked(join(".trash", entry));
|
|
265
|
+
if (!lstatSync(source).isDirectory()) throw new AgentFileError("path", "Trash entry is not a folder");
|
|
266
|
+
let id = randomUUID().slice(0, 8);
|
|
267
|
+
while (existsSync(this.folder(id))) id = randomUUID().slice(0, 8);
|
|
268
|
+
renameSync(source, this.folder(id));
|
|
269
|
+
return id;
|
|
270
|
+
}
|
|
192
271
|
}
|
package/src/agent/list.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { AgentDefinition } from "./definition.ts";
|
|
2
2
|
|
|
3
|
-
export interface AgentListEntry { readonly name: string; readonly description: string; readonly link: string }
|
|
3
|
+
export interface AgentListEntry { readonly id: string; readonly name: string; readonly description: string; readonly convId: string; readonly link: string }
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* The read-only list main sees: name, one-line purpose and
|
|
6
|
+
* The read-only list main sees: stable agent and conversation ids, name, one-line purpose and gateway-relative link.
|
|
7
7
|
* `visible` holds only non-hidden conversations, so a trashed agent (hidden conversation) never appears.
|
|
8
8
|
* Definition body and knowledge are never part of the result.
|
|
9
9
|
*/
|
|
@@ -15,7 +15,7 @@ export function agentList(
|
|
|
15
15
|
for (const { id, definition } of agents) {
|
|
16
16
|
const conv = visible.find((row) => row.agentId === id);
|
|
17
17
|
if (!conv) continue;
|
|
18
|
-
entries.push({ name: String(definition.fields.name), description: String(definition.fields.description), link: `/#conv=${encodeURIComponent(conv.id)}` });
|
|
18
|
+
entries.push({ id, name: String(definition.fields.name), description: String(definition.fields.description), convId: conv.id, link: `/#conv=${encodeURIComponent(conv.id)}` });
|
|
19
19
|
}
|
|
20
20
|
return entries;
|
|
21
21
|
}
|