zigmini 0.9.0-beta.3 → 1.0.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.
Files changed (141) hide show
  1. package/README.ko.md +24 -4
  2. package/README.md +24 -4
  3. package/assets/agent/gateway-rules.md +1 -1
  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 +51 -11
  8. package/docs/manual.md +52 -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 +14 -5
  29. package/src/approvals.ts +6 -1
  30. package/src/cli/main.ts +33 -0
  31. package/src/cli/messages.ts +10 -2
  32. package/src/cli/rotate-main.ts +2 -1
  33. package/src/cli/scan.ts +414 -0
  34. package/src/cli/security-import.ts +155 -0
  35. package/src/compact.ts +22 -0
  36. package/src/decisions.ts +6 -4
  37. package/src/engine/compat.json +3 -3
  38. package/src/engine/index.ts +1 -0
  39. package/src/engine/schema-guard.ts +12 -4
  40. package/src/engine/sdk.ts +8 -0
  41. package/src/formal-verification.ts +366 -0
  42. package/src/gc.ts +37 -6
  43. package/src/hub.ts +339 -79
  44. package/src/main.ts +95 -9
  45. package/src/maintenance.ts +1 -1
  46. package/src/manual.ts +9 -2
  47. package/src/profile-prompt.ts +73 -0
  48. package/src/profile.ts +33 -10
  49. package/src/protocol.ts +11 -2
  50. package/src/question-notifications.ts +4 -1
  51. package/src/reply-notifications.ts +7 -1
  52. package/src/repo-security-setting.ts +33 -0
  53. package/src/runtime/paths.ts +16 -0
  54. package/src/security/baseline.ts +170 -0
  55. package/src/security/dex-strings.ts +242 -0
  56. package/src/security/repo-scan.ts +369 -0
  57. package/src/security/revision-probe.ts +144 -0
  58. package/src/security/terms.ts +97 -0
  59. package/src/security-allow.ts +1 -0
  60. package/src/security-baseline.ts +113 -0
  61. package/src/security-check.ts +91 -16
  62. package/src/server-text.ts +38 -0
  63. package/src/store.ts +142 -16
  64. package/src/transcript.ts +7 -1
  65. package/src/ui-language.ts +33 -0
  66. package/src/update/check.ts +7 -3
  67. package/src/update/handoff-system.ts +24 -16
  68. package/src/update/handoff.ts +23 -12
  69. package/src/update/helper.ts +72 -36
  70. package/src/update/request.ts +46 -30
  71. package/src/update/state.ts +21 -11
  72. package/src/update/strategy.ts +12 -5
  73. package/src/update/target.ts +32 -15
  74. package/src/usage.ts +43 -20
  75. package/src/version.ts +39 -16
  76. package/src/voice/agents.ts +8 -3
  77. package/src/voice/install.ts +29 -18
  78. package/src/work-watch.ts +40 -18
  79. package/web/agent-icon.js +15 -9
  80. package/web/agents.d.ts +201 -0
  81. package/web/agents.js +813 -0
  82. package/web/app-download.js +32 -19
  83. package/web/app.js +1407 -278
  84. package/web/autocopy.js +6 -1
  85. package/web/boot.js +27 -8
  86. package/web/commands.js +20 -11
  87. package/web/decisions.js +70 -49
  88. package/web/devlog.js +25 -19
  89. package/web/diagram.js +8 -3
  90. package/web/enroll.js +43 -22
  91. package/web/gc.js +32 -19
  92. package/web/i18n/agent-icon.js +24 -0
  93. package/web/i18n/agents.js +48 -0
  94. package/web/i18n/app-download.js +26 -0
  95. package/web/i18n/autocopy.js +5 -0
  96. package/web/i18n/boot.js +24 -0
  97. package/web/i18n/commands.js +34 -0
  98. package/web/i18n/decisions.js +24 -0
  99. package/web/i18n/devlog.js +60 -0
  100. package/web/i18n/diagram.js +12 -0
  101. package/web/i18n/enroll.js +22 -0
  102. package/web/i18n/gc.js +38 -0
  103. package/web/i18n/lastsent.js +10 -0
  104. package/web/i18n/main.d.ts +4 -0
  105. package/web/i18n/main.js +527 -0
  106. package/web/i18n/manual.d.ts +4 -0
  107. package/web/i18n/manual.js +14 -0
  108. package/web/i18n/memo.js +30 -0
  109. package/web/i18n/notifications.js +16 -0
  110. package/web/i18n/profile.js +13 -0
  111. package/web/i18n/reference.js +26 -0
  112. package/web/i18n/security-inbox.js +40 -0
  113. package/web/i18n/slash.js +38 -0
  114. package/web/i18n/turn-stats.js +5 -0
  115. package/web/i18n/update.js +42 -0
  116. package/web/i18n/voice.js +28 -0
  117. package/web/i18n/wake.js +16 -0
  118. package/web/i18n/workid.js +10 -0
  119. package/web/i18n/workstatus.js +20 -0
  120. package/web/i18n.d.ts +20 -0
  121. package/web/i18n.js +63 -0
  122. package/web/index.html +156 -96
  123. package/web/lastsent.js +9 -4
  124. package/web/manual.d.ts +1 -1
  125. package/web/manual.js +11 -5
  126. package/web/memo.js +18 -13
  127. package/web/notifications.js +12 -7
  128. package/web/profile.js +42 -17
  129. package/web/reference.js +20 -15
  130. package/web/security-inbox.d.ts +18 -1
  131. package/web/security-inbox.js +173 -4
  132. package/web/slash.js +23 -13
  133. package/web/style.css +132 -3
  134. package/web/tooltip.js +5 -2
  135. package/web/turn-stats.js +6 -2
  136. package/web/update.js +31 -23
  137. package/web/voice.js +18 -8
  138. package/web/wake.d.ts +4 -0
  139. package/web/wake.js +7 -3
  140. package/web/workid.js +12 -2
  141. package/web/workstatus.js +36 -32
package/web/agents.js ADDED
@@ -0,0 +1,813 @@
1
+ // Agent screens (agent-sessions plan todo 10, design D5). Only decisions live here, with no DOM, so
2
+ // test/unit/agent-web.test.ts can exercise them: the sidebar group and header view models, the definition editor
3
+ // state (registry widgets, origin labels, raw YAML for unknown keys, the instruction body), the `agent.md` source the
4
+ // editor saves, the delete confirmation, 보관함 rows and the knowledge file list. app.js draws the screens from these.
5
+ // The gateway validates every save (src/agent/definition.ts); this module composes the source and checks what the
6
+ // editor can tell before the request.
7
+ import { formatBytes } from "./gc.js";
8
+ import strings from "./i18n/agents.js";
9
+ import { onLanguageChange, registerStrings, t } from "./i18n.js";
10
+
11
+ registerStrings(strings);
12
+
13
+ export const AGENTS_PATH = "/api/agents";
14
+ export const DEFAULTS_PATH = "/api/agents/defaults";
15
+ export const TRASH_PATH = "/api/agents/trash";
16
+ export const AGENT_MAX_BYTES = 32768;
17
+ export const DESCRIPTION_MAX = 120;
18
+ export const MEMORY_MAX_BYTES = 32768;
19
+ export const THINKING_LEVELS = Object.freeze(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
20
+ export let ORIGIN_LABELS = originLabels();
21
+ export const DEFAULT_ICON = "◎";
22
+ export let STALE_LABEL = t("agents.stale.label");
23
+ export let STALE_TITLE = t("agents.stale.title");
24
+ export let DELETE_HINT = t("agents.delete.hint");
25
+ export let EMPTY_AGENTS_LINE = t("agents.empty.list");
26
+ export let EMPTY_TRASH_LINE = t("agents.empty.trash");
27
+ export let EMPTY_FILES_LINE = t("agents.empty.files");
28
+ export let NEW_AGENT_BODY = t("agents.new.body");
29
+
30
+ function originLabels() {
31
+ return Object.freeze({ package: t("agents.origin.package"), defaults: t("agents.origin.defaults"), agent: t("agents.origin.agent"), none: t("agents.origin.none") });
32
+ }
33
+
34
+ // The exported texts follow the UI language (app.js reads them at render time).
35
+ onLanguageChange(() => {
36
+ ORIGIN_LABELS = originLabels();
37
+ STALE_LABEL = t("agents.stale.label");
38
+ STALE_TITLE = t("agents.stale.title");
39
+ DELETE_HINT = t("agents.delete.hint");
40
+ EMPTY_AGENTS_LINE = t("agents.empty.list");
41
+ EMPTY_TRASH_LINE = t("agents.empty.trash");
42
+ EMPTY_FILES_LINE = t("agents.empty.files");
43
+ NEW_AGENT_BODY = t("agents.new.body");
44
+ });
45
+
46
+ /**
47
+ * The editor's labels and hints by registry field name. The field list itself (names, types, widgets, defaults, forbidden
48
+ * rows) is the gateway's registry (src/agent/fields.ts), sent as `registry` in every GET /api/agents/:id and
49
+ * /api/agents/defaults answer; editorFields() joins the two. A gateway row without an entry here renders with its name
50
+ * as the label, so a new registry row needs no web change.
51
+ */
52
+ export const FIELD_PRESENTATION = Object.freeze({
53
+ schema: { label: "agents.field.schema.label", help: "agents.field.schema.help" },
54
+ name: { label: "agents.field.name.label", help: "agents.field.name.help" },
55
+ description: { label: "agents.field.description.label", help: "agents.field.description.help" },
56
+ icon: { label: "agents.field.icon.label", help: "agents.field.icon.help" },
57
+ model: { label: "agents.field.model.label", help: "agents.field.model.help" },
58
+ "model.provider": { label: "agents.field.model.provider.label" }, "model.modelId": { label: "agents.field.model.modelId.label" },
59
+ thinking: { label: "agents.field.thinking.label", options: THINKING_LEVELS },
60
+ skills: { label: "agents.field.skills.label", help: "agents.field.skills.help" },
61
+ tools: { label: "agents.field.tools.label" }, "tools.web": { label: "agents.field.tools.web.label" },
62
+ "tools.bash": { label: "agents.field.tools.bash.label" }, "tools.eval": { label: "agents.field.tools.eval.label" },
63
+ notify: { label: "agents.field.notify.label" }, rules: { label: "agents.field.rules.label" },
64
+ "rules.gateway": { label: "agents.field.rules.gateway.label" }, "rules.install": { label: "agents.field.rules.install.label" },
65
+ memory: { label: "agents.field.memory.label" },
66
+ "memory.maxBytes": { label: "agents.field.memory.maxBytes.label", help: "agents.field.memory.maxBytes.help", min: 0, max: MEMORY_MAX_BYTES },
67
+ });
68
+
69
+ /** The gateway's registry rows with the editor's labels, in registry order; a row the web has no entry for is labelled by its name. */
70
+ export function editorFields(registry) {
71
+ return (Array.isArray(registry) ? registry : []).map((field) => {
72
+ const presentation = FIELD_PRESENTATION[field.name] ?? {};
73
+ return { ...field, ...presentation, label: t(presentation.label ?? field.name), help: t(presentation.help ?? "") };
74
+ });
75
+ }
76
+
77
+ const FRONTMATTER = /^---\r?\n([\s\S]*?)^---[ \t]*(?:\r?\n|$)/m;
78
+
79
+ function isObject(value) {
80
+ return value !== null && typeof value === "object" && !Array.isArray(value);
81
+ }
82
+
83
+ export function fieldAt(fields, name) {
84
+ let value = fields;
85
+ for (const key of name.split(".")) {
86
+ if (!isObject(value) || !Object.hasOwn(value, key)) return undefined;
87
+ value = value[key];
88
+ }
89
+ return value;
90
+ }
91
+
92
+ function setPath(target, keys, value) {
93
+ const [head, ...rest] = keys;
94
+ if (rest.length === 0) {
95
+ if (value === undefined) delete target[head];
96
+ else target[head] = value;
97
+ return;
98
+ }
99
+ if (value === undefined) {
100
+ if (!isObject(target[head])) return;
101
+ setPath(target[head], rest, value);
102
+ if (Object.keys(target[head]).length === 0) delete target[head];
103
+ return;
104
+ }
105
+ if (!isObject(target[head])) target[head] = {};
106
+ setPath(target[head], rest, value);
107
+ }
108
+
109
+ /** Top-level registry keys, in registry order (a group's children share their group's key). */
110
+ export function topLevelKeys(registry) {
111
+ const keys = [];
112
+ for (const field of registry) {
113
+ const top = field.name.split(".")[0];
114
+ if (!keys.includes(top)) keys.push(top);
115
+ }
116
+ return keys;
117
+ }
118
+
119
+ // ---- YAML subset: what agent.md frontmatter uses (block maps and sequences, flow maps and sequences, plain, quoted
120
+ // and block scalars, comments). Anything else throws, and the editor then keeps that entry's text as it is.
121
+
122
+ const QUOTE_OPENER = /^(?:|.*[\s:,\[{-])$/;
123
+ const FLOW_START = /^[\[{]/;
124
+
125
+ function stripComment(line) {
126
+ let quote = null;
127
+ for (let i = 0; i < line.length; i++) {
128
+ const ch = line[i];
129
+ if (quote) {
130
+ if (ch === "\\" && quote === '"') i++;
131
+ else if (ch === "'" && quote === "'" && line[i + 1] === "'") i++;
132
+ else if (ch === quote) quote = null;
133
+ continue;
134
+ }
135
+ if ((ch === '"' || ch === "'") && QUOTE_OPENER.test(line.slice(0, i))) quote = ch;
136
+ else if (ch === "#" && (i === 0 || /\s/.test(line[i - 1]))) return line.slice(0, i).trimEnd();
137
+ }
138
+ return line.trimEnd();
139
+ }
140
+
141
+ function indentOf(line) {
142
+ return line.length - line.trimStart().length;
143
+ }
144
+
145
+ function closingQuote(text, start, quote) {
146
+ for (let i = start + 1; i < text.length; i++) {
147
+ if (quote === '"' && text[i] === "\\") {
148
+ i++;
149
+ continue;
150
+ }
151
+ if (text[i] === quote) {
152
+ if (quote === "'" && text[i + 1] === "'") {
153
+ i++;
154
+ continue;
155
+ }
156
+ return i;
157
+ }
158
+ }
159
+ throw new Error("unterminated quote");
160
+ }
161
+
162
+ function parseQuoted(text) {
163
+ if (text[0] === '"') return JSON.parse(text);
164
+ return text.slice(1, -1).replace(/''/g, "'");
165
+ }
166
+
167
+ function parseFlow(text) {
168
+ let i = 0;
169
+ const ws = () => {
170
+ while (i < text.length && /\s/.test(text[i])) i++;
171
+ };
172
+ const scalarToken = (key) => {
173
+ ws();
174
+ const ch = text[i];
175
+ if (ch === '"' || ch === "'") {
176
+ const end = closingQuote(text, i, ch);
177
+ const value = parseQuoted(text.slice(i, end + 1));
178
+ i = end + 1;
179
+ return value;
180
+ }
181
+ const start = i;
182
+ while (i < text.length) {
183
+ const c = text[i];
184
+ if (c === "," || c === "]" || c === "}") break;
185
+ if (c === ":" && (i + 1 >= text.length || /[\s,\]}]/.test(text[i + 1]))) break;
186
+ i++;
187
+ }
188
+ const raw = text.slice(start, i).trim();
189
+ if (key) return raw;
190
+ return parsePlain(raw);
191
+ };
192
+ const value = () => {
193
+ ws();
194
+ const ch = text[i];
195
+ if (ch === "[") {
196
+ i++;
197
+ const out = [];
198
+ ws();
199
+ if (text[i] === "]") {
200
+ i++;
201
+ return out;
202
+ }
203
+ for (;;) {
204
+ out.push(value());
205
+ ws();
206
+ if (text[i] === ",") {
207
+ i++;
208
+ ws();
209
+ if (text[i] === "]") {
210
+ i++;
211
+ return out;
212
+ }
213
+ continue;
214
+ }
215
+ if (text[i] === "]") {
216
+ i++;
217
+ return out;
218
+ }
219
+ throw new Error("flow sequence");
220
+ }
221
+ }
222
+ if (ch === "{") {
223
+ i++;
224
+ const out = {};
225
+ ws();
226
+ if (text[i] === "}") {
227
+ i++;
228
+ return out;
229
+ }
230
+ for (;;) {
231
+ const key = String(scalarToken(true));
232
+ ws();
233
+ if (text[i] !== ":") throw new Error("flow map key");
234
+ i++;
235
+ out[key] = value();
236
+ ws();
237
+ if (text[i] === ",") {
238
+ i++;
239
+ ws();
240
+ if (text[i] === "}") {
241
+ i++;
242
+ return out;
243
+ }
244
+ continue;
245
+ }
246
+ if (text[i] === "}") {
247
+ i++;
248
+ return out;
249
+ }
250
+ throw new Error("flow map");
251
+ }
252
+ }
253
+ return scalarToken(false);
254
+ };
255
+ const result = value();
256
+ ws();
257
+ if (i !== text.length) throw new Error("trailing text");
258
+ return result;
259
+ }
260
+
261
+ function parsePlain(raw) {
262
+ const t = raw.trim();
263
+ if (t === "" || t === "~" || /^null$/i.test(t)) return null;
264
+ if (/^true$/i.test(t)) return true;
265
+ if (/^false$/i.test(t)) return false;
266
+ if (/^[-+]?(?:\.[0-9]+|[0-9]+(?:\.[0-9]*)?)(?:[eE][-+]?[0-9]+)?$/.test(t)) return Number(t);
267
+ if (t[0] === '"' || t[0] === "'") {
268
+ if (closingQuote(t, 0, t[0]) !== t.length - 1) throw new Error("text after quote");
269
+ return parseQuoted(t);
270
+ }
271
+ if (FLOW_START.test(t)) return parseFlow(t);
272
+ if (/^[&*!|>@`%]/.test(t)) throw new Error("unsupported scalar");
273
+ return t;
274
+ }
275
+
276
+ function flowBalanced(text) {
277
+ let depth = 0;
278
+ let quote = null;
279
+ for (let i = 0; i < text.length; i++) {
280
+ const ch = text[i];
281
+ if (quote) {
282
+ if (ch === "\\" && quote === '"') i++;
283
+ else if (ch === "'" && quote === "'" && text[i + 1] === "'") i++;
284
+ else if (ch === quote) quote = null;
285
+ continue;
286
+ }
287
+ if (ch === '"' || ch === "'") quote = ch;
288
+ else if (ch === "[" || ch === "{") depth++;
289
+ else if (ch === "]" || ch === "}") depth--;
290
+ }
291
+ return depth <= 0 && quote === null;
292
+ }
293
+
294
+ function parseBlockScalar(lines, i, indent, header) {
295
+ const style = header[0];
296
+ const chomp = header.includes("-") ? "strip" : header.includes("+") ? "keep" : "clip";
297
+ const collected = [];
298
+ let inner = null;
299
+ while (i < lines.length) {
300
+ const line = lines[i];
301
+ if (line.trim() === "") {
302
+ collected.push("");
303
+ i++;
304
+ continue;
305
+ }
306
+ if (indentOf(line) <= indent) break;
307
+ inner ??= indentOf(line);
308
+ if (indentOf(line) < inner) throw new Error("block scalar indentation");
309
+ collected.push(line.slice(inner));
310
+ i++;
311
+ }
312
+ while (collected.length && collected.at(-1) === "") collected.pop();
313
+ let text;
314
+ if (style === "|") text = collected.join("\n");
315
+ else {
316
+ text = "";
317
+ for (const [index, piece] of collected.entries()) {
318
+ if (piece === "") text += "\n";
319
+ else text += (index > 0 && collected[index - 1] !== "" ? " " : "") + piece;
320
+ }
321
+ }
322
+ if (chomp !== "strip" && collected.length) text += "\n";
323
+ return [text, i];
324
+ }
325
+
326
+ function parseMap(lines, i, indent) {
327
+ const out = {};
328
+ while (i < lines.length) {
329
+ if (lines[i].trim() === "") {
330
+ i++;
331
+ continue;
332
+ }
333
+ const ind = indentOf(lines[i]);
334
+ if (ind < indent) break;
335
+ if (ind > indent) throw new Error("mapping indentation");
336
+ const line = lines[i].trim();
337
+ if (line === "-" || line.startsWith("- ")) throw new Error("sequence in mapping");
338
+ const match = /^("(?:[^"\\]|\\.)*"|'(?:[^']|'')*'|[^\s"'#][^:]*?)\s*:(?:\s+(.*))?$/.exec(line);
339
+ if (!match) throw new Error("expected key");
340
+ const key = match[1][0] === '"' || match[1][0] === "'" ? parseQuoted(match[1]) : match[1];
341
+ let rest = match[2] ?? "";
342
+ i++;
343
+ if (rest.trim() === "") {
344
+ const [value, next] = parseNode(lines, i, indent + 1);
345
+ out[key] = value;
346
+ i = next;
347
+ } else if (/^[|>]/.test(rest.trim())) {
348
+ const [value, next] = parseBlockScalar(lines, i, indent, rest.trim());
349
+ out[key] = value;
350
+ i = next;
351
+ } else {
352
+ while (!flowBalanced(rest) && i < lines.length && (lines[i].trim() === "" || indentOf(lines[i]) > indent)) rest += ` ${lines[i++].trim()}`;
353
+ out[key] = parsePlain(rest);
354
+ }
355
+ }
356
+ return [out, i];
357
+ }
358
+
359
+ function parseSeq(lines, i, indent) {
360
+ const out = [];
361
+ while (i < lines.length) {
362
+ if (lines[i].trim() === "") {
363
+ i++;
364
+ continue;
365
+ }
366
+ const ind = indentOf(lines[i]);
367
+ if (ind < indent) break;
368
+ if (ind > indent) throw new Error("sequence indentation");
369
+ const line = lines[i].trim();
370
+ if (!(line === "-" || line.startsWith("- "))) throw new Error("expected sequence item");
371
+ const rest = line === "-" ? "" : line.slice(2);
372
+ if (rest.trim() === "") {
373
+ const [value, next] = parseNode(lines, i + 1, indent + 1);
374
+ out.push(value);
375
+ i = next;
376
+ } else if (/^[^\s"'#\[{][^:]*?\s*:(?:\s|$)/.test(rest) || rest === "-" || rest.startsWith("- ")) {
377
+ // "- key: value" and "- - item": the item is a collection whose first entry sits on the dash line.
378
+ lines[i] = " ".repeat(indent + 2) + rest;
379
+ const [value, next] = parseNode(lines, i, indent + 2);
380
+ out.push(value);
381
+ i = next;
382
+ } else {
383
+ out.push(parsePlain(rest));
384
+ i++;
385
+ }
386
+ }
387
+ return [out, i];
388
+ }
389
+
390
+ function parseNode(lines, i, minIndent) {
391
+ while (i < lines.length && lines[i].trim() === "") i++;
392
+ if (i >= lines.length) return [null, i];
393
+ const indent = indentOf(lines[i]);
394
+ if (indent < minIndent) return [null, i];
395
+ const first = lines[i].trim();
396
+ if (first === "-" || first.startsWith("- ")) return parseSeq(lines, i, indent);
397
+ if (/^[\[{"']/.test(first)) {
398
+ let text = first;
399
+ let next = i + 1;
400
+ while (next < lines.length && (lines[next].trim() === "" || indentOf(lines[next]) >= minIndent)) text += ` ${lines[next++].trim()}`;
401
+ return [parsePlain(text), next];
402
+ }
403
+ return parseMap(lines, i, indent);
404
+ }
405
+
406
+ /** Parses the YAML subset agent.md frontmatter uses; throws for anything outside it. */
407
+ export function parseYaml(text) {
408
+ const lines = text.replace(/\r\n/g, "\n").split("\n").map(stripComment);
409
+ let i = 0;
410
+ while (i < lines.length && lines[i].trim() === "") i++;
411
+ if (i >= lines.length) return null;
412
+ if (indentOf(lines[i]) !== 0) throw new Error("root indentation");
413
+ const rootIsScalar = /^[\[{"']/.test(lines[i]) || (!/:(\s|$)/.test(lines[i]) && lines[i] !== "-" && !lines[i].startsWith("- "));
414
+ const [value, next] = rootIsScalar ? [parsePlain(lines.slice(i).join(" ")), lines.length] : parseNode(lines, i, 0);
415
+ for (let j = next; j < lines.length; j++) if (lines[j].trim() !== "") throw new Error("trailing text");
416
+ return value;
417
+ }
418
+
419
+ const PLAIN_UNSAFE = /^[-?:,\[\]{}#&*!|>'"%@`\s]|[\[\]{},]|:\s|\s#|:$|[\u0000-\u001f\u007f]|\s$/;
420
+ const PLAIN_RESERVED = /^(?:true|false|null|~|yes|no|on|off|y|n|\.inf|-\.inf|\.nan)$/i;
421
+ const PLAIN_NUMERIC = /^[-+]?(?:0x[0-9a-f]+|0o[0-7]+|(?:0|[1-9][0-9_]*)(?:\.[0-9_]*)?(?:[eE][-+]?[0-9]+)?|\.[0-9]+(?:[eE][-+]?[0-9]+)?)$/i;
422
+
423
+ export function emitScalar(value) {
424
+ if (value === null || value === undefined) return "null";
425
+ if (typeof value === "boolean") return String(value);
426
+ if (typeof value === "number") return Number.isFinite(value) ? String(value) : "null";
427
+ const text = String(value);
428
+ if (text === "" || PLAIN_UNSAFE.test(text) || PLAIN_RESERVED.test(text) || PLAIN_NUMERIC.test(text)) return JSON.stringify(text);
429
+ return text;
430
+ }
431
+
432
+ function isScalar(value) {
433
+ return !Array.isArray(value) && !isObject(value);
434
+ }
435
+
436
+ function emitMapEntries(value, indent) {
437
+ const pad = " ".repeat(indent);
438
+ return Object.keys(value).map((key) => `${pad}${emitScalar(key)}:${emitValue(value[key], indent + 2)}`).join("");
439
+ }
440
+
441
+ function emitSeqItems(value, indent) {
442
+ const pad = " ".repeat(indent);
443
+ return value.map((item) => {
444
+ if (isScalar(item) || item.length === 0 || (Array.isArray(item) && item.every(isScalar))) return `${pad}-${emitValue(item, indent + 2)}`;
445
+ if (Array.isArray(item)) return `${pad}-\n${emitSeqItems(item, indent + 2)}`;
446
+ if (Object.keys(item).length === 0) return `${pad}- {}\n`;
447
+ return `${pad}- ${emitMapEntries(item, indent + 2).slice(indent + 2)}`;
448
+ }).join("");
449
+ }
450
+
451
+ function emitValue(value, indent) {
452
+ if (Array.isArray(value)) {
453
+ if (value.length === 0) return " []\n";
454
+ if (value.every(isScalar)) return ` [${value.map((item) => emitScalar(item)).join(", ")}]\n`;
455
+ return `\n${emitSeqItems(value, indent)}`;
456
+ }
457
+ if (isObject(value)) return Object.keys(value).length === 0 ? " {}\n" : `\n${emitMapEntries(value, indent)}`;
458
+ return ` ${emitScalar(value)}\n`;
459
+ }
460
+
461
+ /** One top-level frontmatter entry (block style for objects, flow style for lists), ending with a newline. */
462
+ export function emitEntry(key, value) {
463
+ return `${emitScalar(key)}:${emitValue(value, 2)}`;
464
+ }
465
+
466
+ /**
467
+ * Splits agent.md into the comment lines before the first key, the top-level frontmatter entries (each `key` with its
468
+ * exact text, `null` when the line is not `key:`) and the instruction body. `ok` is false without a frontmatter block.
469
+ */
470
+ export function splitSource(source) {
471
+ const match = FRONTMATTER.exec(source);
472
+ if (!match || match.index !== 0) return { ok: false, preamble: "", entries: [], body: source };
473
+ const lines = match[1].split("\n");
474
+ if (lines.at(-1) === "") lines.pop();
475
+ const entries = [];
476
+ let preamble = "";
477
+ for (const line of lines) {
478
+ if (/^[^\s#]/.test(line)) {
479
+ const key = /^("(?:[^"\\]|\\.)*"|'(?:[^']|'')*'|[^\s"'#:\[\]{},&*!|>%@`?-][^:]*?)\s*:(?:\s|$)/.exec(line);
480
+ entries.push({ key: key ? (key[1][0] === '"' || key[1][0] === "'" ? parseQuoted(key[1]) : key[1]) : null, raw: `${line}\n` });
481
+ } else if (entries.length === 0) preamble += `${line}\n`;
482
+ else entries.at(-1).raw += `${line}\n`;
483
+ }
484
+ return { ok: true, preamble, entries, body: source.slice(match[0].length) };
485
+ }
486
+
487
+ /**
488
+ * The editor's state for one definition: `kind` is "new", "agent" or "defaults"; `detail` is the gateway's answer, whose
489
+ * `registry` decides which frontmatter keys are fields and which stay raw YAML.
490
+ */
491
+ export function editorState(detail, kind = "agent") {
492
+ const split = splitSource(detail?.source ?? "");
493
+ const registry = editorFields(detail?.registry);
494
+ const known = topLevelKeys(registry);
495
+ const own = {};
496
+ const rawByKey = {};
497
+ const unparsed = new Set();
498
+ let unknownRaw = "";
499
+ for (const entry of split.entries) {
500
+ if (entry.key !== null && known.includes(entry.key)) {
501
+ rawByKey[entry.key] = entry.raw;
502
+ try {
503
+ const parsed = parseYaml(entry.raw);
504
+ if (!isObject(parsed) || !Object.hasOwn(parsed, entry.key)) throw new Error("entry");
505
+ own[entry.key] = parsed[entry.key];
506
+ } catch {
507
+ unparsed.add(entry.key);
508
+ }
509
+ } else unknownRaw += entry.raw;
510
+ }
511
+ const inherited = kind === "agent" && isObject(detail?.fields) ? detail.fields : packageDefaults(registry);
512
+ return {
513
+ kind, registry, id: kind === "agent" ? detail?.id ?? null : null, name: kind === "agent" ? detail?.name ?? null : null,
514
+ convId: kind === "agent" ? detail?.convId ?? null : null, stale: kind === "agent" && detail?.stale === true,
515
+ own, rawByKey, touched: new Set(), unparsed, unknownRaw, unknownTouched: false, preamble: split.preamble,
516
+ body: split.ok ? split.body : kind === "new" ? NEW_AGENT_BODY : split.body, bodyTouched: false,
517
+ inherited, origins: kind === "agent" && isObject(detail?.origins) ? detail.origins : {}, dirty: false,
518
+ };
519
+ }
520
+
521
+ /** The registry's package defaults as nested fields (the values every agent has before defaults.md and agent.md). */
522
+ export function packageDefaults(registry) {
523
+ const fields = {};
524
+ for (const field of registry) if (field.default !== undefined) setPath(fields, field.name.split("."), structuredClone(field.default));
525
+ return fields;
526
+ }
527
+
528
+ /** The rows the editor draws, in the gateway's registry order: a `group` row before its children and a `field` row per leaf. */
529
+ export function editorRows(state) {
530
+ const rows = [];
531
+ for (const field of state.registry) {
532
+ if (field.forbidden) continue;
533
+ if (state.kind === "defaults" && field.required) continue;
534
+ const top = field.name.split(".")[0];
535
+ if (field.widget === "group" || field.widget === "model") {
536
+ rows.push({ kind: "group", name: field.name, label: field.label, help: field.help });
537
+ continue;
538
+ }
539
+ const ownValue = fieldAt(state.own, field.name);
540
+ const own = ownValue !== undefined;
541
+ const inheritedValue = state.kind === "agent" ? fieldAt(state.inherited, field.name) : field.default;
542
+ const value = own ? ownValue : inheritedValue;
543
+ let origin;
544
+ if (own) origin = state.kind === "defaults" ? "defaults" : "agent";
545
+ else if (state.kind === "agent") origin = state.origins[field.name] ?? (value === undefined ? "none" : "package");
546
+ else origin = field.default === undefined ? "none" : "package";
547
+ rows.push({
548
+ kind: "field", name: field.name, label: field.label, help: field.help, widget: field.widget, type: field.type, required: field.required,
549
+ value: value === undefined ? null : value, own, origin, originLabel: ORIGIN_LABELS[origin],
550
+ clearable: own && !field.required,
551
+ unparsed: state.unparsed.has(top) && !state.touched.has(top),
552
+ ...(field.options ? { options: field.options } : {}), ...(field.min !== undefined ? { min: field.min } : {}), ...(field.max !== undefined ? { max: field.max } : {}),
553
+ });
554
+ }
555
+ return rows;
556
+ }
557
+
558
+ /** The value a widget's input means for its field; `undefined` clears an optional override; `{ error }` refuses it. */
559
+ export function widgetValue(field, input) {
560
+ if (field.widget === "toggle") return { value: Boolean(input) };
561
+ const text = typeof input === "string" ? input : input === null || input === undefined ? "" : String(input);
562
+ if (field.widget === "number") {
563
+ if (text.trim() === "") return field.required ? { error: t("agents.error.required", { label: field.label }) } : { value: undefined };
564
+ const number = Number(text.trim());
565
+ if (!Number.isSafeInteger(number)) return { error: t("agents.error.integer", { label: field.label }) };
566
+ if (field.min !== undefined && number < field.min || field.max !== undefined && number > field.max) return { error: t("agents.error.range", { label: field.label, min: field.min ?? "", max: field.max ?? "" }) };
567
+ return { value: number };
568
+ }
569
+ if (field.widget === "skills") {
570
+ const items = [...new Set(text.split(/[\n,]+/).map((item) => item.trim()).filter(Boolean))];
571
+ return { value: items };
572
+ }
573
+ if (field.widget === "select") {
574
+ if (text === "") return { value: undefined };
575
+ if (field.options && !field.options.includes(text)) return { error: t("agents.error.option", { label: field.label, options: field.options.join(", ") }) };
576
+ }
577
+ if (field.required) return { value: text };
578
+ return { value: text.trim() === "" ? undefined : text };
579
+ }
580
+
581
+ /** A new state with the field set (`undefined` removes the override and empties parents); never changes `state`. */
582
+ export function setField(state, name, value) {
583
+ const own = structuredClone(state.own);
584
+ setPath(own, name.split("."), value === undefined ? undefined : structuredClone(value));
585
+ const touched = new Set(state.touched);
586
+ touched.add(name.split(".")[0]);
587
+ return { ...state, own, touched, dirty: true };
588
+ }
589
+
590
+ export function clearField(state, name) {
591
+ return setField(state, name, undefined);
592
+ }
593
+
594
+ export function setBody(state, body) {
595
+ return { ...state, body, bodyTouched: true, dirty: true };
596
+ }
597
+
598
+ export function setUnknownRaw(state, unknownRaw) {
599
+ return { ...state, unknownRaw, unknownTouched: true, dirty: true };
600
+ }
601
+
602
+ /** The agent.md text the editor saves: untouched entries keep their exact text, touched ones are emitted anew. */
603
+ export function composeSource(state) {
604
+ let header = state.preamble ?? "";
605
+ for (const key of topLevelKeys(state.registry)) {
606
+ if (!state.touched.has(key) && state.rawByKey[key] !== undefined) {
607
+ header += state.rawByKey[key];
608
+ continue;
609
+ }
610
+ const value = state.own[key];
611
+ if (value !== undefined) header += emitEntry(key, value);
612
+ }
613
+ const extra = state.unknownRaw.replace(/\s+$/, "");
614
+ if (extra) header += `${extra}\n`;
615
+ return `---\n${header}---\n${state.body}`;
616
+ }
617
+
618
+ function characters(text) {
619
+ return [...text].length;
620
+ }
621
+
622
+ /** What the editor can tell before the gateway validates: required fields, lengths, ranges, the model pair, the size. */
623
+ export function validateEditor(state) {
624
+ const errors = [];
625
+ const own = state.own;
626
+ if (state.kind !== "defaults") {
627
+ if (typeof own.name !== "string" || !own.name.trim()) errors.push({ name: "name", message: t("agents.validation.name.required") });
628
+ if (typeof own.description !== "string" || !own.description.trim()) errors.push({ name: "description", message: t("agents.validation.description.required") });
629
+ } else {
630
+ if (own.name !== undefined) errors.push({ name: "name", message: t("agents.validation.defaults.name") });
631
+ if (own.description !== undefined) errors.push({ name: "description", message: t("agents.validation.defaults.description") });
632
+ }
633
+ if (typeof own.description === "string" && characters(own.description) > DESCRIPTION_MAX) errors.push({ name: "description", message: t("agents.validation.description.length", { max: DESCRIPTION_MAX, count: characters(own.description) }) });
634
+ const maxBytes = fieldAt(own, "memory.maxBytes");
635
+ if (maxBytes !== undefined && (!Number.isSafeInteger(maxBytes) || maxBytes < 0 || maxBytes > MEMORY_MAX_BYTES)) errors.push({ name: "memory.maxBytes", message: t("agents.validation.memory.range", { max: MEMORY_MAX_BYTES }) });
636
+ if (own.schema !== undefined && own.schema !== 1) errors.push({ name: "schema", message: t("agents.validation.schema") });
637
+ if (own.thinking !== undefined && !THINKING_LEVELS.includes(own.thinking)) errors.push({ name: "thinking", message: t("agents.validation.thinking", { levels: THINKING_LEVELS.join(", ") }) });
638
+ if (own.model !== undefined) {
639
+ const provider = fieldAt(own, "model.provider");
640
+ const modelId = fieldAt(own, "model.modelId");
641
+ if (typeof provider !== "string" || !provider.trim() || typeof modelId !== "string" || !modelId.trim()) errors.push({ name: "model", message: t("agents.validation.model.pair") });
642
+ }
643
+ for (const { name, forbidden } of state.registry) if (forbidden && fieldAt(own, name) !== undefined) errors.push({ name, message: t("agents.validation.forbidden", { name }) });
644
+ const bytes = new TextEncoder().encode(composeSource(state)).length;
645
+ if (bytes > AGENT_MAX_BYTES) errors.push({ name: "file", message: t("agents.validation.size", { max: AGENT_MAX_BYTES, size: bytes }) });
646
+ return errors;
647
+ }
648
+
649
+ /** The request that saves the editor: POST for a new agent, PUT for an existing one or the defaults. */
650
+ export function saveRequest(state) {
651
+ const body = JSON.stringify({ source: composeSource(state) });
652
+ const headers = { "content-type": "application/json" };
653
+ if (state.kind === "defaults") return { method: "PUT", path: DEFAULTS_PATH, headers, body };
654
+ if (state.kind === "new" || !state.id) return { method: "POST", path: AGENTS_PATH, headers, body };
655
+ return { method: "PUT", path: `${AGENTS_PATH}/${encodeURIComponent(state.id)}`, headers, body };
656
+ }
657
+
658
+ /** The delete button works only when the typed name is exactly the agent's current name (the gateway checks the same). */
659
+ export function deleteEnabled(typed, name) {
660
+ return typeof name === "string" && name !== "" && typed === name;
661
+ }
662
+
663
+ export function deleteRequest(id, name) {
664
+ return { method: "POST", path: `${AGENTS_PATH}/${encodeURIComponent(id)}/delete`, headers: { "content-type": "application/json" }, body: JSON.stringify({ name }) };
665
+ }
666
+
667
+ export function cloneRequest(id) {
668
+ return { method: "POST", path: `${AGENTS_PATH}/${encodeURIComponent(id)}/clone` };
669
+ }
670
+
671
+ export function restoreRequest(entry) {
672
+ return { method: "POST", path: `${TRASH_PATH}/${encodeURIComponent(entry)}/restore` };
673
+ }
674
+
675
+ export function filesPath(id) {
676
+ return `${AGENTS_PATH}/${encodeURIComponent(id)}/files`;
677
+ }
678
+
679
+ export function sourceUploadPath(id) {
680
+ return `${AGENTS_PATH}/${encodeURIComponent(id)}/source`;
681
+ }
682
+
683
+ const ERROR_LINES = Object.freeze({
684
+ name_mismatch: "agents.error.name_mismatch",
685
+ agent_busy: "agents.error.agent_busy",
686
+ agent_not_found: "agents.error.agent_not_found",
687
+ invalid_agent_definition: "agents.error.invalid_agent_definition",
688
+ invalid_request: "agents.error.invalid_request",
689
+ too_large: "agents.error.too_large",
690
+ missing_file: "agents.error.missing_file",
691
+ invalid_filename: "agents.error.invalid_filename",
692
+ file_exists: "agents.error.file_exists",
693
+ exists: "agents.error.exists",
694
+ invalid_trash_entry: "agents.error.invalid_trash_entry",
695
+ forbidden: "agents.error.forbidden",
696
+ method_not_allowed: "agents.error.method_not_allowed",
697
+ });
698
+
699
+ /** The Korean line for a gateway refusal (`{ error, field? }` with its HTTP status). */
700
+ export function errorLine(body, status) {
701
+ const code = typeof body?.error === "string" ? body.error : null;
702
+ const base = code && ERROR_LINES[code] ? t(ERROR_LINES[code]) : `${t("agents.error.rejected")} (${code ?? `HTTP ${status}`})`;
703
+ return typeof body?.field === "string" && body.field ? `${base}: ${body.field}` : base;
704
+ }
705
+
706
+ /** `agent.updated` from the gateway: a summary upserts the agent (and brings its conversation back after a restore); `hidden` removes both. */
707
+ export function applyAgentUpdate(agents, conversations, data, now = Date.now()) {
708
+ if (!isObject(data) || typeof data.id !== "string") return { agents, conversations, removedConv: null, restoredConv: null };
709
+ if (data.hidden === true) {
710
+ const convId = typeof data.convId === "string" ? data.convId : agents.find((p) => p.id === data.id)?.convId ?? null;
711
+ return {
712
+ agents: agents.filter((p) => p.id !== data.id),
713
+ conversations: conversations.filter((c) => c.id !== convId && c.agentId !== data.id),
714
+ removedConv: convId, restoredConv: null,
715
+ };
716
+ }
717
+ const summary = { ...data };
718
+ const next = agents.some((p) => p.id === data.id) ? agents.map((p) => (p.id === data.id ? { ...p, ...summary } : p)) : [...agents, summary];
719
+ let restoredConv = null;
720
+ let convs = conversations;
721
+ if (typeof data.convId === "string") {
722
+ if (!conversations.some((c) => c.id === data.convId)) {
723
+ convs = [...conversations, { id: data.convId, title: String(data.name ?? data.id), threadId: null, createdAt: new Date(now).toISOString(), agentId: data.id }];
724
+ restoredConv = data.convId;
725
+ } else if (typeof data.name === "string") convs = conversations.map((c) => (c.id === data.convId && c.title !== data.name ? { ...c, title: data.name } : c));
726
+ }
727
+ return { agents: next, conversations: convs, removedConv: null, restoredConv };
728
+ }
729
+
730
+ /** `agent.applied` of a conversation: the new session carries `sha`; the badge stays only while the definition differs. */
731
+ export function applyAgentApplied(agents, convId, data) {
732
+ const sha = typeof data?.sha === "string" ? data.sha : null;
733
+ if (!sha) return agents;
734
+ return agents.map((p) => (p.convId === convId ? { ...p, stale: p.definitionSha !== sha } : p));
735
+ }
736
+
737
+ /**
738
+ * Improvement 262: the sidebar shows the one main conversation and one conversation per agent. Other conversations
739
+ * without an agent (made by the old "+" button) stay hidden; the gateway keeps them.
740
+ */
741
+ export function mainConversations(conversations) {
742
+ return conversations.filter((c) => c.id === "main" && !c.agentId);
743
+ }
744
+
745
+ /** Each agent's one conversation: the first in conversation order when a store still holds more than one. */
746
+ function agentConversations(conversations) {
747
+ const seen = new Set();
748
+ return conversations.filter((c) => {
749
+ if (!c.agentId || seen.has(c.agentId)) return false;
750
+ seen.add(c.agentId);
751
+ return true;
752
+ });
753
+ }
754
+
755
+ /** The conversation to open for `id`: itself when the sidebar lists it (main or an agent's conversation), else main. */
756
+ export function shownConversation(conversations, id) {
757
+ return agentConversations(conversations).some((c) => c.id === id) ? id : "main";
758
+ }
759
+
760
+ export function agentInitial(name) {
761
+ const text = String(name ?? "").trim();
762
+ return text ? [...text][0] : DEFAULT_ICON;
763
+ }
764
+
765
+ /** The sidebar group's rows, in conversation order: icon (or the name's first letter), name, running dot, stale and pending. */
766
+ export function sidebarItems(conversations, agents, view) {
767
+ const items = [];
768
+ for (const conv of agentConversations(conversations)) {
769
+ const agent = agents.find((p) => p.id === conv.agentId) ?? null;
770
+ const name = agent?.name ?? conv.title;
771
+ items.push({
772
+ convId: conv.id, agentId: conv.agentId, name, description: agent?.description ?? "",
773
+ icon: typeof agent?.icon === "string" && agent.icon.trim() ? agent.icon.trim() : agentInitial(name),
774
+ active: conv.id === view.current, running: view.running.has(conv.id) || view.busy(conv.id) === true,
775
+ pending: (view.pending.get(conv.id) ?? 0) > 0, stale: agent?.stale === true,
776
+ });
777
+ }
778
+ return items;
779
+ }
780
+
781
+ /** The chat header for the open conversation: the agent name and its stale badge, or nothing for main. */
782
+ export function headerView(conv, agents) {
783
+ if (!conv?.agentId) return { agent: false, icon: "", name: "", stale: false, agentId: null };
784
+ const agent = agents.find((p) => p.id === conv.agentId) ?? null;
785
+ const name = agent?.name ?? conv.title;
786
+ return {
787
+ agent: true, agentId: conv.agentId, name,
788
+ icon: typeof agent?.icon === "string" && agent.icon.trim() ? agent.icon.trim() : agentInitial(name),
789
+ stale: agent?.stale === true,
790
+ };
791
+ }
792
+
793
+ /** 보관함 rows: the entry name carries the agent id and the UTC time it was deleted. */
794
+ export function trashRows(entries) {
795
+ if (!Array.isArray(entries)) return [];
796
+ return entries.map((entry) => {
797
+ const match = /^([a-f0-9]{8})-(.+)$/.exec(String(entry.entry ?? ""));
798
+ const deletedAt = match && !Number.isNaN(Date.parse(match[2])) ? new Date(match[2]).toISOString() : null;
799
+ return { entry: String(entry.entry ?? ""), id: match?.[1] ?? null, name: String(entry.name ?? ""), description: String(entry.description ?? ""), icon: typeof entry.icon === "string" && entry.icon ? entry.icon : agentInitial(entry.name), deletedAt };
800
+ });
801
+ }
802
+
803
+ /** Knowledge file rows, folders first within each folder, with a size label; `source/` files are the ones the web uploads. */
804
+ export function fileRows(entries) {
805
+ if (!Array.isArray(entries)) return [];
806
+ const rows = entries.filter((e) => typeof e?.path === "string").map((e) => ({
807
+ path: e.path, type: e.type === "directory" ? "directory" : "file", size: typeof e.size === "number" ? e.size : 0,
808
+ depth: e.path.split("/").length - 1, name: e.path.slice(e.path.lastIndexOf("/") + 1),
809
+ label: e.type === "directory" ? t("agents.file.folder") : formatBytes(typeof e.size === "number" ? e.size : null),
810
+ source: e.path === "source" || e.path.startsWith("source/"),
811
+ }));
812
+ return rows.sort((a, b) => a.path.localeCompare(b.path, "ko"));
813
+ }