zigmini 0.9.0-beta.3 → 1.0.0-beta.2

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.
Files changed (143) hide show
  1. package/README.ko.md +36 -4
  2. package/README.md +36 -4
  3. package/assets/agent/gateway-rules.md +29 -19
  4. package/assets/android/zigmini.apk +0 -0
  5. package/assets/android/zigmini.apk.json +3 -3
  6. package/assets/build.json +1 -1
  7. package/docs/manual.ko.md +60 -11
  8. package/docs/manual.md +61 -12
  9. package/host/omo-bridge/agent-control.ts +274 -0
  10. package/host/omo-bridge/bridge.ts +1 -1
  11. package/host/omo-bridge/continuity-control.ts +1 -1
  12. package/host/omo-bridge/task-control.ts +26 -16
  13. package/host/omo-check/patches/omo-5.1.51-compaction-long-context.patch +92 -0
  14. package/host/omo-check/patches/omo-5.1.51-compaction-port.patch +2635 -0
  15. package/host/omo-check/patches/omo-5.1.51-compaction-recovery.patch +911 -0
  16. package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +363 -0
  17. package/host/omo-check/patches/omo-5.1.51-provider-failover.patch +50 -0
  18. package/host/omo-check/patches/omo-5.1.51-provider-port.patch +441 -0
  19. package/host/omo-check/patches/omo-5.1.51-rpc-port.patch +799 -0
  20. package/host/omo-check/patches/omo-5.1.51-runtime-compat.patch +31 -0
  21. package/package.json +3 -2
  22. package/src/agent/api.ts +215 -0
  23. package/src/agent/definition.ts +177 -0
  24. package/src/agent/fields.ts +42 -0
  25. package/src/agent/files.ts +192 -0
  26. package/src/agent/legacy-files.ts +46 -0
  27. package/src/agent/list.ts +21 -0
  28. package/src/agent-rules.ts +97 -13
  29. package/src/approvals.ts +6 -1
  30. package/src/cli/doctor.ts +7 -0
  31. package/src/cli/main.ts +38 -0
  32. package/src/cli/messages.ts +10 -2
  33. package/src/cli/rotate-main.ts +2 -1
  34. package/src/cli/rules.ts +70 -0
  35. package/src/cli/scan.ts +414 -0
  36. package/src/cli/security-import.ts +155 -0
  37. package/src/compact.ts +22 -0
  38. package/src/decisions.ts +6 -4
  39. package/src/engine/compat.json +3 -3
  40. package/src/engine/index.ts +1 -0
  41. package/src/engine/schema-guard.ts +12 -4
  42. package/src/engine/sdk.ts +8 -0
  43. package/src/formal-verification.ts +366 -0
  44. package/src/gc.ts +37 -6
  45. package/src/hub.ts +339 -79
  46. package/src/main.ts +95 -9
  47. package/src/maintenance.ts +1 -1
  48. package/src/manual.ts +9 -2
  49. package/src/profile-prompt.ts +73 -0
  50. package/src/profile.ts +33 -10
  51. package/src/protocol.ts +11 -2
  52. package/src/question-notifications.ts +4 -1
  53. package/src/reply-notifications.ts +7 -1
  54. package/src/repo-security-setting.ts +33 -0
  55. package/src/runtime/paths.ts +16 -0
  56. package/src/security/baseline.ts +172 -0
  57. package/src/security/dex-strings.ts +242 -0
  58. package/src/security/repo-scan.ts +369 -0
  59. package/src/security/revision-probe.ts +144 -0
  60. package/src/security/terms.ts +97 -0
  61. package/src/security-allow.ts +1 -0
  62. package/src/security-baseline.ts +113 -0
  63. package/src/security-check.ts +91 -16
  64. package/src/server-text.ts +38 -0
  65. package/src/store.ts +142 -16
  66. package/src/transcript.ts +7 -1
  67. package/src/ui-language.ts +33 -0
  68. package/src/update/check.ts +7 -3
  69. package/src/update/handoff-system.ts +24 -16
  70. package/src/update/handoff.ts +23 -12
  71. package/src/update/helper.ts +72 -36
  72. package/src/update/request.ts +46 -30
  73. package/src/update/state.ts +21 -11
  74. package/src/update/strategy.ts +12 -5
  75. package/src/update/target.ts +32 -15
  76. package/src/usage.ts +43 -20
  77. package/src/version.ts +39 -16
  78. package/src/voice/agents.ts +8 -3
  79. package/src/voice/install.ts +29 -18
  80. package/src/work-watch.ts +40 -18
  81. package/web/agent-icon.js +15 -9
  82. package/web/agents.d.ts +201 -0
  83. package/web/agents.js +813 -0
  84. package/web/app-download.js +32 -19
  85. package/web/app.js +1407 -278
  86. package/web/autocopy.js +6 -1
  87. package/web/boot.js +27 -8
  88. package/web/commands.js +20 -11
  89. package/web/decisions.js +70 -49
  90. package/web/devlog.js +25 -19
  91. package/web/diagram.js +8 -3
  92. package/web/enroll.js +43 -22
  93. package/web/gc.js +32 -19
  94. package/web/i18n/agent-icon.js +24 -0
  95. package/web/i18n/agents.js +48 -0
  96. package/web/i18n/app-download.js +26 -0
  97. package/web/i18n/autocopy.js +5 -0
  98. package/web/i18n/boot.js +24 -0
  99. package/web/i18n/commands.js +34 -0
  100. package/web/i18n/decisions.js +24 -0
  101. package/web/i18n/devlog.js +60 -0
  102. package/web/i18n/diagram.js +12 -0
  103. package/web/i18n/enroll.js +22 -0
  104. package/web/i18n/gc.js +38 -0
  105. package/web/i18n/lastsent.js +10 -0
  106. package/web/i18n/main.d.ts +4 -0
  107. package/web/i18n/main.js +527 -0
  108. package/web/i18n/manual.d.ts +4 -0
  109. package/web/i18n/manual.js +14 -0
  110. package/web/i18n/memo.js +30 -0
  111. package/web/i18n/notifications.js +16 -0
  112. package/web/i18n/profile.js +13 -0
  113. package/web/i18n/reference.js +26 -0
  114. package/web/i18n/security-inbox.js +40 -0
  115. package/web/i18n/slash.js +38 -0
  116. package/web/i18n/turn-stats.js +5 -0
  117. package/web/i18n/update.js +42 -0
  118. package/web/i18n/voice.js +28 -0
  119. package/web/i18n/wake.js +16 -0
  120. package/web/i18n/workid.js +10 -0
  121. package/web/i18n/workstatus.js +20 -0
  122. package/web/i18n.d.ts +20 -0
  123. package/web/i18n.js +63 -0
  124. package/web/index.html +156 -96
  125. package/web/lastsent.js +9 -4
  126. package/web/manual.d.ts +1 -1
  127. package/web/manual.js +11 -5
  128. package/web/memo.js +18 -13
  129. package/web/notifications.js +12 -7
  130. package/web/profile.js +42 -17
  131. package/web/reference.js +20 -15
  132. package/web/security-inbox.d.ts +18 -1
  133. package/web/security-inbox.js +173 -4
  134. package/web/slash.js +23 -13
  135. package/web/style.css +132 -3
  136. package/web/tooltip.js +5 -2
  137. package/web/turn-stats.js +6 -2
  138. package/web/update.js +31 -23
  139. package/web/voice.js +18 -8
  140. package/web/wake.d.ts +4 -0
  141. package/web/wake.js +7 -3
  142. package/web/workid.js +12 -2
  143. package/web/workstatus.js +36 -32
@@ -0,0 +1,31 @@
1
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/speculative-summary.js
2
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/speculative-summary.js
3
+ @@ -124,6 +124,7 @@ export async function generateSummaryMessage(options) {
4
+ headers,
5
+ extraBody: options.auth.extraBody,
6
+ env: options.auth.env,
7
+ + cwd: options.context.cwd,
8
+ streamKind: "auxiliary",
9
+ onPayload: async (payload, model) => {
10
+ const sanitized = model.api === "anthropic-messages" ? sanitizeAnthropicPayload(payload) : payload;
11
+ --- a/@code-yeongyu/senpi/dist/modes/rpc/multi-session-host.js
12
+ +++ b/@code-yeongyu/senpi/dist/modes/rpc/multi-session-host.js
13
+ @@ -235,7 +235,7 @@ async function runSocketHost(options, socketPath) {
14
+ onHandoffParked: async (ids) => {
15
+ await Promise.all(ids.map(async (id) => {
16
+ await writer.flushConnection(id);
17
+ - connections.get(id)?.close();
18
+ + await connections.get(id)?.finish();
19
+ }));
20
+ },
21
+ }, hostContext, {
22
+ @@ -374,7 +374,8 @@ async function runSocketHost(options, socketPath) {
23
+ process.stderr.write(`senpi rpc connection ${id} release failed: ${errorMessage(cause)}\n`);
24
+ });
25
+ };
26
+ - connections.set(id, { id, sink, detach, close: () => socket.destroy() });
27
+ + connections.set(id, { id, sink, detach, close: () => socket.destroy(),
28
+ + finish: () => new Promise((resolve) => socket.end(resolve)) });
29
+ socket.once("close", detach);
30
+ socket.once("error", () => detach());
31
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zigmini",
3
- "version": "0.9.0-beta.3",
3
+ "version": "1.0.0-beta.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "zigmini": "bin/zigmini"
@@ -60,7 +60,8 @@
60
60
  "test:contract": "bun test test/contract",
61
61
  "test:all": "tsc --noEmit && bun test test/unit && bun test test/contract",
62
62
  "test:mutation": "bun scripts/mutation-run.ts",
63
- "heavy": "bun host/heavy-slot.ts run"
63
+ "heavy": "bun host/heavy-slot.ts run",
64
+ "formal:check": "bun scripts/formal-check.ts"
64
65
  },
65
66
  "devDependencies": {
66
67
  "@types/bun": "^1.4.2",
@@ -0,0 +1,215 @@
1
+ import { existsSync, lstatSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { Hub } from "../hub.ts";
4
+ import { MEDIA_MAX_BYTES } from "../media.ts";
5
+ import type { Store } from "../store.ts";
6
+ import { mergeAgentDefinition, parseAgentDefinition, AgentDefinitionError, serializeAgentDefinition, type AgentDefinition } from "./definition.ts";
7
+ import { registryMetadata } from "./fields.ts";
8
+ import { AgentFileError, AgentFileStore, unreadableAgent } from "./files.ts";
9
+
10
+ class AgentApiError extends Error {
11
+ constructor(readonly status: number, readonly code: string) {
12
+ super(code);
13
+ }
14
+ }
15
+
16
+ function object(value: unknown): value is Record<string, unknown> {
17
+ return typeof value === "object" && value !== null && !Array.isArray(value);
18
+ }
19
+
20
+ async function source(req: Request): Promise<string> {
21
+ const value: unknown = await req.json();
22
+ if (!object(value) || typeof value.source !== "string") throw new AgentApiError(400, "source_required");
23
+ return value.source;
24
+ }
25
+
26
+ /** Only metadata leaves list endpoints; definition text is returned by the detail endpoint. */
27
+ export class AgentApi {
28
+ private readonly deleting = new Set<string>();
29
+
30
+ constructor(private readonly files: AgentFileStore, private readonly store: Store, private readonly hub: Hub) {}
31
+
32
+ private conversation(id: string, visible = true) {
33
+ const conv = this.store.listConversations(!visible).find((row) => row.agentId === id);
34
+ if (!conv) throw new AgentApiError(404, "agent_not_found");
35
+ return conv;
36
+ }
37
+
38
+ private summary(id: string, definition: AgentDefinition) {
39
+ const conv = this.conversation(id);
40
+ const merged = mergeAgentDefinition(definition, this.files.readDefaults());
41
+ const applied = this.store.eventsAfter(conv.id, this.store.lastSeq(conv.id, "session.rotated"))
42
+ .filter((event) => event.type === "agent.applied").at(-1)?.data;
43
+ const appliedSha = object(applied) && typeof applied.sha === "string" ? applied.sha : null;
44
+ return {
45
+ id, name: definition.fields.name, description: definition.fields.description, icon: definition.fields.icon ?? null,
46
+ convId: conv.id, definitionSha: merged.sha, stale: appliedSha !== null && appliedSha !== merged.sha,
47
+ };
48
+ }
49
+
50
+ /** The definition text, the merged result and the field registry the editor draws its widgets from. */
51
+ private detail(id: string) {
52
+ const definition = this.files.read(id);
53
+ return { ...this.summary(id, definition), source: serializeAgentDefinition(definition),
54
+ ...mergeAgentDefinition(definition, this.files.readDefaults()), registry: registryMetadata() };
55
+ }
56
+
57
+ private defaults() {
58
+ return { source: this.files.readDefaults()?.source ?? "---\n{}\n---\n", registry: registryMetadata() };
59
+ }
60
+
61
+ private list() {
62
+ const visible = this.store.listConversations(false);
63
+ return this.files.list().filter(({ id }) => visible.some((conv) => conv.agentId === id))
64
+ .map(({ id, definition }) => this.summary(id, definition));
65
+ }
66
+
67
+ /** Reject links at every component, including dangling links and links within another agent. */
68
+ private path(parts: readonly string[]): string {
69
+ let path = this.files.root;
70
+ for (const part of parts) {
71
+ if (!part || part === "." || part === ".." || /[/\\\0]/.test(part)) throw new AgentApiError(400, "invalid_path");
72
+ path = join(path, part);
73
+ try {
74
+ if (lstatSync(path).isSymbolicLink()) throw new AgentApiError(400, "symlink_not_allowed");
75
+ } catch (error) {
76
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
77
+ }
78
+ }
79
+ return path;
80
+ }
81
+
82
+ private tree(id: string) {
83
+ const entries: { path: string; type: "file" | "directory"; size: number }[] = [];
84
+ const walk = (parts: readonly string[]) => {
85
+ for (const name of readdirSync(this.path([id, "knowledge", ...parts]))) {
86
+ const stat = lstatSync(this.path([id, "knowledge", ...parts, name]));
87
+ if (!stat.isFile() && !stat.isDirectory()) throw new AgentApiError(400, "unsupported_file");
88
+ entries.push({ path: [...parts, name].join("/"), type: stat.isDirectory() ? "directory" : "file", size: stat.isFile() ? stat.size : 0 });
89
+ if (stat.isDirectory()) walk([...parts, name]);
90
+ }
91
+ };
92
+ walk([]);
93
+ return entries;
94
+ }
95
+
96
+ private changed(id: string) {
97
+ const detail = this.detail(id);
98
+ this.hub.agentUpdated(this.summary(id, this.files.read(id)));
99
+ return Response.json(detail);
100
+ }
101
+
102
+ async handle(req: Request, trusted: boolean): Promise<Response> {
103
+ if (!trusted) return Response.json({ error: "forbidden" }, { status: 403 });
104
+ try {
105
+ const path = new URL(req.url).pathname;
106
+ if (path === "/api/agents") {
107
+ if (req.method === "GET") return Response.json(this.list());
108
+ if (req.method !== "POST") throw new AgentApiError(405, "method_not_allowed");
109
+ const id = this.files.create(await source(req));
110
+ const definition = this.files.read(id);
111
+ this.hub.createConversation(String(definition.fields.name), id);
112
+ return new Response((await this.changed(id)).body, { status: 201, headers: { "Content-Type": "application/json" } });
113
+ }
114
+ if (path === "/api/agents/defaults") {
115
+ if (req.method === "GET") return Response.json(this.defaults());
116
+ if (req.method !== "PUT") throw new AgentApiError(405, "method_not_allowed");
117
+ this.files.saveDefaults(await source(req));
118
+ for (const summary of this.list()) this.hub.agentUpdated(summary);
119
+ return Response.json(this.defaults());
120
+ }
121
+ if (path === "/api/agents/trash") {
122
+ if (req.method !== "GET") throw new AgentApiError(405, "method_not_allowed");
123
+ const trash = this.path([".trash"]);
124
+ return Response.json(!existsSync(trash) ? [] : readdirSync(trash).flatMap((entry) => {
125
+ try {
126
+ const definition = parseAgentDefinition(readFileSync(this.path([".trash", entry, "agent.md"]), "utf8"));
127
+ return [{ entry, name: definition.fields.name, description: definition.fields.description, icon: definition.fields.icon ?? null }];
128
+ } catch (error) {
129
+ if (unreadableAgent(error) || (error instanceof AgentApiError && error.code === "symlink_not_allowed")) return [];
130
+ throw error;
131
+ }
132
+ }));
133
+ }
134
+ const restore = /^\/api\/agents\/trash\/([^/]+)\/restore$/.exec(path);
135
+ if (restore?.[1]) {
136
+ if (req.method !== "POST") throw new AgentApiError(405, "method_not_allowed");
137
+ const entry = decodeURIComponent(restore[1]);
138
+ const id = /^([a-f0-9]{8})-/.exec(entry)?.[1];
139
+ if (!id) throw new AgentApiError(400, "invalid_trash_entry");
140
+ const conv = this.conversation(id, false);
141
+ this.files.restore(entry);
142
+ this.store.setConversationHidden(conv.id, false);
143
+ return this.changed(id);
144
+ }
145
+ const match = /^\/api\/agents\/([a-f0-9]{8})(?:\/(clone|delete|files|source))?$/.exec(path);
146
+ const id = match?.[1];
147
+ if (!id) throw new AgentApiError(404, "not_found");
148
+ this.conversation(id);
149
+ if (this.deleting.has(id)) throw new AgentApiError(409, "agent_busy");
150
+ const action = match?.[2];
151
+ if (!action && req.method === "GET") return Response.json(this.detail(id));
152
+ if (!action && req.method === "PUT") {
153
+ const value = await source(req);
154
+ this.conversation(id);
155
+ if (this.deleting.has(id)) throw new AgentApiError(409, "agent_busy");
156
+ this.files.save(id, value);
157
+ return this.changed(id);
158
+ }
159
+ if (action === "files" && req.method === "GET") return Response.json(this.tree(id));
160
+ if (action === "clone" && req.method === "POST") {
161
+ const cloned = this.files.clone(id);
162
+ this.hub.createConversation(String(this.files.read(cloned).fields.name), cloned);
163
+ return this.changed(cloned);
164
+ }
165
+ if (action === "delete" && req.method === "POST") {
166
+ const body: unknown = await req.json();
167
+ this.conversation(id);
168
+ if (!object(body) || body.name !== this.files.read(id).fields.name) throw new AgentApiError(400, "name_mismatch");
169
+ if (this.deleting.has(id)) throw new AgentApiError(409, "agent_busy");
170
+ this.deleting.add(id);
171
+ try {
172
+ const conv = this.conversation(id);
173
+ const entry = await this.hub.deleteAgentConversation(conv.id, () => this.files.trash(id));
174
+ this.hub.agentUpdated({ id, convId: conv.id, hidden: true });
175
+ return Response.json({ entry });
176
+ } finally {
177
+ this.deleting.delete(id);
178
+ }
179
+ }
180
+ if (action === "source" && req.method === "POST") {
181
+ const length = Number(req.headers.get("content-length"));
182
+ if (length > MEDIA_MAX_BYTES + 65536) throw new AgentApiError(413, "too_large");
183
+ const form = await req.formData();
184
+ const file = form.get("file");
185
+ if (!(file instanceof File) || file.size === 0) throw new AgentApiError(400, "missing_file");
186
+ if (file.size > MEDIA_MAX_BYTES) throw new AgentApiError(413, "too_large");
187
+ // Reject traversal rather than silently turning it into a different name.
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
+ }
194
+ const bytes = new Uint8Array(await file.arrayBuffer());
195
+ this.conversation(id);
196
+ if (this.deleting.has(id)) throw new AgentApiError(409, "agent_busy");
197
+ writeFileSync(this.path([id, "knowledge", "source", name]), bytes, { flag: "wx" });
198
+ this.hub.agentUpdated(this.summary(id, this.files.read(id)));
199
+ return Response.json({ name, size: bytes.length }, { status: 201 });
200
+ }
201
+ throw new AgentApiError(405, "method_not_allowed");
202
+ } catch (error) {
203
+ if (error instanceof AgentApiError) return Response.json({ error: error.code }, { status: error.status });
204
+ if (error instanceof AgentDefinitionError) return Response.json({ error: "invalid_agent_definition", field: error.field }, { status: 400 });
205
+ if (error instanceof AgentFileError) return Response.json({ error: error.code }, { status: error.code === "exists" ? 409 : 400 });
206
+ if (error instanceof SyntaxError || error instanceof URIError || error instanceof TypeError) return Response.json({ error: "invalid_request" }, { status: 400 });
207
+ if (error instanceof Error && error.message === "agent_busy") return Response.json({ error: "agent_busy" }, { status: 409 });
208
+ if (error instanceof Error && "code" in error) {
209
+ if (error.code === "ENOENT") return Response.json({ error: "agent_not_found" }, { status: 404 });
210
+ if (error.code === "EEXIST") return Response.json({ error: "file_exists" }, { status: 409 });
211
+ }
212
+ throw error;
213
+ }
214
+ }
215
+ }
@@ -0,0 +1,177 @@
1
+ import { YAML } from "bun";
2
+ import { createHash } from "node:crypto";
3
+ import { AGENT_FIELDS, type AgentField } from "./fields.ts";
4
+
5
+ export const AGENT_MAX_BYTES = 32768;
6
+ export const AGENT_SCHEMA = 1;
7
+ export type DefinitionKind = "agent" | "defaults";
8
+ export type FieldOrigin = "package" | "defaults" | "agent";
9
+ export type DefinitionFields = Readonly<Record<string, unknown>>;
10
+
11
+ export interface AgentDefinition {
12
+ readonly fields: DefinitionFields;
13
+ readonly body: string;
14
+ readonly kind: DefinitionKind;
15
+ /** Original source allows an untouched definition (including unknown YAML) to save byte for byte. */
16
+ readonly source?: string;
17
+ }
18
+
19
+ export interface MergedAgentDefinition {
20
+ readonly fields: DefinitionFields;
21
+ readonly body: string;
22
+ readonly defaultsBody: string;
23
+ readonly origins: Readonly<Record<string, FieldOrigin>>;
24
+ readonly sha: string;
25
+ }
26
+
27
+ export class AgentDefinitionError extends Error {
28
+ constructor(readonly field: string, message: string) {
29
+ super(`${field}: ${message}`);
30
+ this.name = "AgentDefinitionError";
31
+ }
32
+ }
33
+
34
+ function object(value: unknown): value is Record<string, unknown> {
35
+ return value !== null && typeof value === "object" && !Array.isArray(value);
36
+ }
37
+
38
+ function at(fields: DefinitionFields, path: string): unknown {
39
+ let value: unknown = fields;
40
+ for (const key of path.split(".")) {
41
+ if (!object(value) || !Object.hasOwn(value, key)) return undefined;
42
+ value = value[key];
43
+ }
44
+ return value;
45
+ }
46
+
47
+ function matches(value: unknown, type: AgentField["type"]): boolean {
48
+ switch (type) {
49
+ case "string": return typeof value === "string";
50
+ case "integer": return typeof value === "number" && Number.isSafeInteger(value);
51
+ case "boolean": return typeof value === "boolean";
52
+ case "object": return object(value);
53
+ case "strings": return Array.isArray(value) && value.every((item) => typeof item === "string");
54
+ }
55
+ }
56
+
57
+ /** Canonical JSON also rejects recursive YAML aliases and non-JSON scalars at the input boundary. */
58
+ function canonical(value: unknown, ancestors = new Set<object>()): string {
59
+ if (value === null || typeof value === "string" || typeof value === "boolean") return JSON.stringify(value);
60
+ if (typeof value === "number" && Number.isFinite(value)) return JSON.stringify(value);
61
+ if (typeof value !== "object" || value === null || ancestors.has(value)) throw new AgentDefinitionError("frontmatter", "expected acyclic JSON-compatible YAML");
62
+ ancestors.add(value);
63
+ const result = Array.isArray(value)
64
+ ? `[${value.map((item) => canonical(item, ancestors)).join(",")}]`
65
+ : `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(Reflect.get(value, key), ancestors)}`).join(",")}}`;
66
+ ancestors.delete(value);
67
+ return result;
68
+ }
69
+
70
+ export function validateAgentFields(fields: DefinitionFields, kind: DefinitionKind = "agent"): void {
71
+ canonical(fields);
72
+ for (const field of AGENT_FIELDS) {
73
+ const value = at(fields, field.name);
74
+ const present = value !== undefined;
75
+ if (field.forbidden && present) throw new AgentDefinitionError(field.name, "forbidden");
76
+ if (kind === "defaults" && field.required && present) throw new AgentDefinitionError(field.name, "not allowed in defaults");
77
+ if (kind === "agent" && field.required && !present) throw new AgentDefinitionError(field.name, "required");
78
+ if (present && !matches(value, field.type)) throw new AgentDefinitionError(field.name, `expected ${field.type}`);
79
+ if (field.required && typeof value === "string" && !value.trim()) throw new AgentDefinitionError(field.name, "required");
80
+ }
81
+ const description = fields.description;
82
+ if (typeof description === "string" && [...description].length > 120) throw new AgentDefinitionError("description", "maximum 120 characters");
83
+ const maxBytes = at(fields, "memory.maxBytes");
84
+ if (typeof maxBytes === "number" && (maxBytes < 0 || maxBytes > 32768)) throw new AgentDefinitionError("memory.maxBytes", "expected 0..32768");
85
+ const schema = fields.schema;
86
+ if (schema !== undefined && schema !== AGENT_SCHEMA) throw new AgentDefinitionError("schema", "unsupported version");
87
+ }
88
+
89
+ /** Read-time migration seam. A missing schema is the original v1 format; never mutates the caller's object. */
90
+ export function migrateAgentFields(fields: DefinitionFields): DefinitionFields {
91
+ return { ...fields };
92
+ }
93
+
94
+ export function parseAgentDefinition(source: string, kind: DefinitionKind = "agent"): AgentDefinition {
95
+ if (Buffer.byteLength(source, "utf8") > AGENT_MAX_BYTES) throw new AgentDefinitionError("file", "maximum 32768 bytes");
96
+ const match = /^---\r?\n([\s\S]*?)^---[ \t]*(?:\r?\n|$)/m.exec(source);
97
+ if (!match || match.index !== 0) throw new AgentDefinitionError("frontmatter", "expected --- YAML frontmatter");
98
+ let parsed: unknown;
99
+ try {
100
+ parsed = YAML.parse(match[1] ?? "");
101
+ } catch (error) {
102
+ throw new AgentDefinitionError("frontmatter", error instanceof Error ? error.message : String(error));
103
+ }
104
+ if (parsed === null) parsed = {};
105
+ if (!object(parsed)) throw new AgentDefinitionError("frontmatter", "expected an object");
106
+ validateAgentFields(parsed, kind);
107
+ return { fields: migrateAgentFields(parsed), body: source.slice(match[0].length), kind, source };
108
+ }
109
+
110
+ export function serializeAgentDefinition(definition: AgentDefinition): string {
111
+ validateAgentFields(definition.fields, definition.kind);
112
+ let header = YAML.stringify(definition.fields, null, 2);
113
+ if (definition.source !== undefined) {
114
+ const original = parseAgentDefinition(definition.source, definition.kind);
115
+ if (canonical(original.fields) === canonical(definition.fields) && original.body === definition.body) return definition.source;
116
+ // Keep unchanged top-level YAML entries verbatim, including unknown fields, comments and scalar styles.
117
+ const frontmatter = /^---\r?\n([\s\S]*?)^---[ \t]*(?:\r?\n|$)/m.exec(definition.source)?.[1] ?? "";
118
+ const starts = [...frontmatter.matchAll(/^[^\s#].*$/gm)];
119
+ const entries = starts.map((start, index) => frontmatter.slice(start.index, starts[index + 1]?.index));
120
+ const retained = new Map<string, string>();
121
+ for (const entry of entries) {
122
+ const value: unknown = YAML.parse(entry);
123
+ if (!object(value)) continue;
124
+ // A flow-style root map can hold several fields in one entry; it cannot be reused per key.
125
+ if (Object.keys(value).length !== 1) continue;
126
+ for (const key of Object.keys(value)) {
127
+ if (Object.hasOwn(definition.fields, key) && canonical(value[key]) === canonical(definition.fields[key])) retained.set(key, entry);
128
+ }
129
+ }
130
+ header = Object.keys(definition.fields).map((key) => retained.get(key) ?? `${YAML.stringify({ [key]: definition.fields[key] }, null, 2).trimEnd()}\n`).join("");
131
+ }
132
+ const result = `---\n${header.trimEnd()}\n---\n${definition.body}`;
133
+ if (Buffer.byteLength(result, "utf8") > AGENT_MAX_BYTES) throw new AgentDefinitionError("file", "maximum 32768 bytes");
134
+ return result;
135
+ }
136
+
137
+ function set(fields: Record<string, unknown>, keys: readonly string[], value: unknown): void {
138
+ let target = fields;
139
+ for (const key of keys.slice(0, -1)) {
140
+ const child = target[key];
141
+ if (object(child)) target = child;
142
+ else {
143
+ const next: Record<string, unknown> = {};
144
+ Object.defineProperty(target, key, { value: next, enumerable: true, writable: true, configurable: true });
145
+ target = next;
146
+ }
147
+ }
148
+ const last = keys.at(-1);
149
+ if (last !== undefined) Object.defineProperty(target, last, { value, enumerable: true, writable: true, configurable: true });
150
+ }
151
+
152
+ export function mergeAgentDefinition(agent: AgentDefinition, defaults?: AgentDefinition): MergedAgentDefinition {
153
+ validateAgentFields(agent.fields);
154
+ if (defaults) validateAgentFields(defaults.fields, "defaults");
155
+ const fields: Record<string, unknown> = {};
156
+ const origins: Record<string, FieldOrigin> = {};
157
+ for (const field of AGENT_FIELDS) {
158
+ if (field.default !== undefined) set(fields, field.name.split("."), structuredClone(field.default));
159
+ }
160
+ const apply = (input: DefinitionFields, origin: FieldOrigin, prefix: readonly string[] = []): void => {
161
+ for (const [key, value] of Object.entries(input)) {
162
+ const path = [...prefix, key];
163
+ const name = path.join(".");
164
+ const field = AGENT_FIELDS.find((row) => row.name === name && row.name.split(".").length === path.length);
165
+ if (field) origins[name] = origin;
166
+ if (field?.merge === "keys" && object(value)) apply(value, origin, path);
167
+ else set(fields, path, structuredClone(value));
168
+ }
169
+ };
170
+ apply(structuredClone(fields), "package");
171
+ if (defaults) apply(defaults.fields, "defaults");
172
+ apply(agent.fields, "agent");
173
+ const defaultsBody = defaults?.body ?? "";
174
+ const body = [defaultsBody, agent.body].filter(Boolean).join("\n\n");
175
+ const sha = createHash("sha256").update(canonical({ fields, body })).digest("hex");
176
+ return { fields, body, defaultsBody, origins, sha };
177
+ }
@@ -0,0 +1,42 @@
1
+ export interface AgentField {
2
+ readonly name: string;
3
+ readonly type: "string" | "integer" | "boolean" | "object" | "strings";
4
+ readonly default: unknown;
5
+ readonly required: boolean;
6
+ readonly merge: "replace" | "keys";
7
+ readonly applies: "gateway" | "extension";
8
+ readonly widget: "text" | "textarea" | "number" | "toggle" | "model" | "select" | "skills" | "group";
9
+ readonly forbidden: boolean;
10
+ }
11
+
12
+ /** The registry owns defaults, validation types and editor metadata. Undefined means no package override. */
13
+ export const AGENT_FIELDS: readonly AgentField[] = [
14
+ { name: "schema", type: "integer", default: 1, required: false, merge: "replace", applies: "gateway", widget: "number", forbidden: false },
15
+ { name: "name", type: "string", default: undefined, required: true, merge: "replace", applies: "gateway", widget: "text", forbidden: false },
16
+ { name: "description", type: "string", default: undefined, required: true, merge: "replace", applies: "gateway", widget: "textarea", forbidden: false },
17
+ { name: "icon", type: "string", default: undefined, required: false, merge: "replace", applies: "gateway", widget: "text", forbidden: false },
18
+ { name: "model", type: "object", default: undefined, required: false, merge: "keys", applies: "extension", widget: "model", forbidden: false },
19
+ { name: "model.provider", type: "string", default: undefined, required: false, merge: "replace", applies: "extension", widget: "text", forbidden: false },
20
+ { name: "model.modelId", type: "string", default: undefined, required: false, merge: "replace", applies: "extension", widget: "text", forbidden: false },
21
+ { name: "thinking", type: "string", default: undefined, required: false, merge: "replace", applies: "extension", widget: "select", forbidden: false },
22
+ { name: "skills", type: "strings", default: [], required: false, merge: "replace", applies: "extension", widget: "skills", forbidden: false },
23
+ { name: "tools", type: "object", default: {}, required: false, merge: "keys", applies: "extension", widget: "group", forbidden: false },
24
+ { name: "tools.web", type: "boolean", default: false, required: false, merge: "replace", applies: "extension", widget: "toggle", forbidden: false },
25
+ { name: "tools.bash", type: "boolean", default: undefined, required: false, merge: "replace", applies: "extension", widget: "toggle", forbidden: true },
26
+ { name: "tools.eval", type: "boolean", default: undefined, required: false, merge: "replace", applies: "extension", widget: "toggle", forbidden: true },
27
+ { name: "notify", type: "boolean", default: false, required: false, merge: "replace", applies: "gateway", widget: "toggle", forbidden: false },
28
+ { name: "rules", type: "object", default: {}, required: false, merge: "keys", applies: "extension", widget: "group", forbidden: false },
29
+ { name: "rules.gateway", type: "boolean", default: true, required: false, merge: "replace", applies: "extension", widget: "toggle", forbidden: false },
30
+ { name: "rules.install", type: "boolean", default: true, required: false, merge: "replace", applies: "extension", widget: "toggle", forbidden: false },
31
+ { name: "memory", type: "object", default: {}, required: false, merge: "keys", applies: "extension", widget: "group", forbidden: false },
32
+ { name: "memory.maxBytes", type: "integer", default: 8192, required: false, merge: "replace", applies: "extension", widget: "number", forbidden: false },
33
+ ];
34
+
35
+ /**
36
+ * The registry rows as the agent API sends them with every definition answer (`registry` in GET /api/agents/:id and
37
+ * /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.
39
+ */
40
+ export function registryMetadata(rows: readonly AgentField[] = AGENT_FIELDS): AgentField[] {
41
+ return rows.map(({ name, type, default: value, required, merge, applies, widget, forbidden }) => ({ name, type, default: value, required, merge, applies, widget, forbidden }));
42
+ }