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.
- package/README.ko.md +36 -4
- package/README.md +36 -4
- package/assets/agent/gateway-rules.md +29 -19
- package/assets/android/zigmini.apk +0 -0
- package/assets/android/zigmini.apk.json +3 -3
- package/assets/build.json +1 -1
- package/docs/manual.ko.md +60 -11
- package/docs/manual.md +61 -12
- package/host/omo-bridge/agent-control.ts +274 -0
- package/host/omo-bridge/bridge.ts +1 -1
- package/host/omo-bridge/continuity-control.ts +1 -1
- package/host/omo-bridge/task-control.ts +26 -16
- package/host/omo-check/patches/omo-5.1.51-compaction-long-context.patch +92 -0
- package/host/omo-check/patches/omo-5.1.51-compaction-port.patch +2635 -0
- package/host/omo-check/patches/omo-5.1.51-compaction-recovery.patch +911 -0
- package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +363 -0
- package/host/omo-check/patches/omo-5.1.51-provider-failover.patch +50 -0
- package/host/omo-check/patches/omo-5.1.51-provider-port.patch +441 -0
- package/host/omo-check/patches/omo-5.1.51-rpc-port.patch +799 -0
- package/host/omo-check/patches/omo-5.1.51-runtime-compat.patch +31 -0
- package/package.json +3 -2
- package/src/agent/api.ts +215 -0
- package/src/agent/definition.ts +177 -0
- package/src/agent/fields.ts +42 -0
- package/src/agent/files.ts +192 -0
- package/src/agent/legacy-files.ts +46 -0
- package/src/agent/list.ts +21 -0
- package/src/agent-rules.ts +97 -13
- package/src/approvals.ts +6 -1
- package/src/cli/doctor.ts +7 -0
- package/src/cli/main.ts +38 -0
- package/src/cli/messages.ts +10 -2
- package/src/cli/rotate-main.ts +2 -1
- package/src/cli/rules.ts +70 -0
- package/src/cli/scan.ts +414 -0
- package/src/cli/security-import.ts +155 -0
- package/src/compact.ts +22 -0
- package/src/decisions.ts +6 -4
- package/src/engine/compat.json +3 -3
- package/src/engine/index.ts +1 -0
- package/src/engine/schema-guard.ts +12 -4
- package/src/engine/sdk.ts +8 -0
- package/src/formal-verification.ts +366 -0
- package/src/gc.ts +37 -6
- package/src/hub.ts +339 -79
- package/src/main.ts +95 -9
- package/src/maintenance.ts +1 -1
- package/src/manual.ts +9 -2
- package/src/profile-prompt.ts +73 -0
- package/src/profile.ts +33 -10
- package/src/protocol.ts +11 -2
- package/src/question-notifications.ts +4 -1
- package/src/reply-notifications.ts +7 -1
- package/src/repo-security-setting.ts +33 -0
- package/src/runtime/paths.ts +16 -0
- package/src/security/baseline.ts +172 -0
- package/src/security/dex-strings.ts +242 -0
- package/src/security/repo-scan.ts +369 -0
- package/src/security/revision-probe.ts +144 -0
- package/src/security/terms.ts +97 -0
- package/src/security-allow.ts +1 -0
- package/src/security-baseline.ts +113 -0
- package/src/security-check.ts +91 -16
- package/src/server-text.ts +38 -0
- package/src/store.ts +142 -16
- package/src/transcript.ts +7 -1
- package/src/ui-language.ts +33 -0
- package/src/update/check.ts +7 -3
- package/src/update/handoff-system.ts +24 -16
- package/src/update/handoff.ts +23 -12
- package/src/update/helper.ts +72 -36
- package/src/update/request.ts +46 -30
- package/src/update/state.ts +21 -11
- package/src/update/strategy.ts +12 -5
- package/src/update/target.ts +32 -15
- package/src/usage.ts +43 -20
- package/src/version.ts +39 -16
- package/src/voice/agents.ts +8 -3
- package/src/voice/install.ts +29 -18
- package/src/work-watch.ts +40 -18
- package/web/agent-icon.js +15 -9
- package/web/agents.d.ts +201 -0
- package/web/agents.js +813 -0
- package/web/app-download.js +32 -19
- package/web/app.js +1407 -278
- package/web/autocopy.js +6 -1
- package/web/boot.js +27 -8
- package/web/commands.js +20 -11
- package/web/decisions.js +70 -49
- package/web/devlog.js +25 -19
- package/web/diagram.js +8 -3
- package/web/enroll.js +43 -22
- package/web/gc.js +32 -19
- package/web/i18n/agent-icon.js +24 -0
- package/web/i18n/agents.js +48 -0
- package/web/i18n/app-download.js +26 -0
- package/web/i18n/autocopy.js +5 -0
- package/web/i18n/boot.js +24 -0
- package/web/i18n/commands.js +34 -0
- package/web/i18n/decisions.js +24 -0
- package/web/i18n/devlog.js +60 -0
- package/web/i18n/diagram.js +12 -0
- package/web/i18n/enroll.js +22 -0
- package/web/i18n/gc.js +38 -0
- package/web/i18n/lastsent.js +10 -0
- package/web/i18n/main.d.ts +4 -0
- package/web/i18n/main.js +527 -0
- package/web/i18n/manual.d.ts +4 -0
- package/web/i18n/manual.js +14 -0
- package/web/i18n/memo.js +30 -0
- package/web/i18n/notifications.js +16 -0
- package/web/i18n/profile.js +13 -0
- package/web/i18n/reference.js +26 -0
- package/web/i18n/security-inbox.js +40 -0
- package/web/i18n/slash.js +38 -0
- package/web/i18n/turn-stats.js +5 -0
- package/web/i18n/update.js +42 -0
- package/web/i18n/voice.js +28 -0
- package/web/i18n/wake.js +16 -0
- package/web/i18n/workid.js +10 -0
- package/web/i18n/workstatus.js +20 -0
- package/web/i18n.d.ts +20 -0
- package/web/i18n.js +63 -0
- package/web/index.html +156 -96
- package/web/lastsent.js +9 -4
- package/web/manual.d.ts +1 -1
- package/web/manual.js +11 -5
- package/web/memo.js +18 -13
- package/web/notifications.js +12 -7
- package/web/profile.js +42 -17
- package/web/reference.js +20 -15
- package/web/security-inbox.d.ts +18 -1
- package/web/security-inbox.js +173 -4
- package/web/slash.js +23 -13
- package/web/style.css +132 -3
- package/web/tooltip.js +5 -2
- package/web/turn-stats.js +6 -2
- package/web/update.js +31 -23
- package/web/voice.js +18 -8
- package/web/wake.d.ts +4 -0
- package/web/wake.js +7 -3
- package/web/workid.js +12 -2
- package/web/workstatus.js +36 -32
package/docs/manual.md
CHANGED
|
@@ -19,7 +19,7 @@ zigmini has two surfaces.
|
|
|
19
19
|
|
|
20
20
|
The web screen has three parts.
|
|
21
21
|
|
|
22
|
-
- Left sidebar: the conversation
|
|
22
|
+
- Left sidebar: the default agent (main) conversation, the agent group with one conversation per agent, the work status area, your profile and the default agent profile. The "엔진" (engine) badge at the top shows the engine connection.
|
|
23
23
|
- Center chat: the header, the message list and the input box.
|
|
24
24
|
- Right panels: the inbox, settings and the event panel open here.
|
|
25
25
|
|
|
@@ -65,9 +65,11 @@ No new setting is required. With `compaction.model` absent or set to `"auto"`, t
|
|
|
65
65
|
|
|
66
66
|
If a summary fails, the engine keeps the previous working context and stops instead of applying an emergency shortened substitute. The notice tells you what to do: check the model setting, log in to the summary provider again, wait for the usage limit to recover, or check account and organization permissions. Other failures require you to resolve the cause and explicitly continue or retry. The gateway does not resend the previous input or start a new session. A cancellation is separate from a failure. A successful summary is not a guarantee that every detail survives; check critical requirements when you continue.
|
|
67
67
|
|
|
68
|
+
To summarize on demand, send `/compact` in a gateway chat (main or another web or app chat). The gateway asks the engine to summarize that chat's session right away, and the text never goes to the model as a prompt. Add text after the command to give extra instructions for the summary, for example `/compact 결정 사항 위주로`. The gateway refuses `/compact` while an answer is running, a summary is already running, the engine is retrying a model request, the session is being replaced, or the engine is disconnected. A one-line warning says why and what to do. A refused `/compact` isn't queued, so send it again later. You get the usual start notice and one completed, cancelled or failed notice. If the engine refuses before a summary starts, for example with no model selected, chat shows one error that keeps the previous content. A very short or already summarized session shows the usual failure notice. A failed `/compact` doesn't count toward the rule that two failed summaries in a row open a fresh session. It works even with automatic compaction off. Work sessions don't get `/compact`. With a quoted message or an image attached, `/compact` is ordinary input.
|
|
69
|
+
|
|
68
70
|
The gateway opens main and work sessions in the engine's existing app mode, so replies are shaped for the chat screen rather than a terminal. You don't need to change any setting. App mode doesn't guarantee that every model answer follows a requested format such as valid JSON, and it doesn't change the summary recovery above.
|
|
69
71
|
|
|
70
|
-
|
|
72
|
+
The sidebar lists only the main conversation and one conversation per agent. For another role conversation, create an agent (see Agents). Conversations made with the "+" button of earlier versions no longer appear anywhere; the gateway keeps their data.
|
|
71
73
|
|
|
72
74
|
#### Slash command menu
|
|
73
75
|
|
|
@@ -90,6 +92,8 @@ With voice off, the voice commands leave the menu. Typed by hand, they get the r
|
|
|
90
92
|
|
|
91
93
|
The README's Commands section says that sending `/usage` in chat makes the gateway reply with the model account's usage.
|
|
92
94
|
|
|
95
|
+
The menu also lists the gateway command `/compact`, which summarizes the conversation on demand. See Conversation summaries above.
|
|
96
|
+
|
|
93
97
|
### 2.3 Attachments and references
|
|
94
98
|
|
|
95
99
|
#### Flow and structure diagrams
|
|
@@ -142,7 +146,7 @@ Right-click a message to attach it as a reference tag above the input box. The m
|
|
|
142
146
|
|
|
143
147
|
#### Memo
|
|
144
148
|
|
|
145
|
-
The 쪽지 (memo) button opens and closes the memo. Keep frequent prompts or notes there. It saves on its own. 이미지 보관 keeps up to 2 images in it.
|
|
149
|
+
The 쪽지 (memo) button opens and closes the memo. Keep frequent prompts or notes there. It saves on its own. 이미지 보관 keeps up to 2 images in it. In the phone app the open memo takes the whole chat area and hides the chat input and the "새 앱 버전" row; close it with its 닫기 button or Back.
|
|
146
150
|
|
|
147
151
|
#### Auto copy
|
|
148
152
|
|
|
@@ -197,21 +201,34 @@ At phone width the sidebar is hidden. Click the Zig face in the header to switch
|
|
|
197
201
|
|
|
198
202
|
Open it with the header's 설정 button. Close it with Esc, the × button, or a click outside the card.
|
|
199
203
|
|
|
204
|
+
#### Language
|
|
205
|
+
|
|
206
|
+
The Language selector picks the screen language for the whole gateway: English or 한국어 (Korean). The default is English. The choice changes the web screen text, its tooltips and the app screen text, and the web and the app switch together. The manual opens in the selected language. Fixed messages from the gateway (chat notices and errors, update messages, `/version` and `/usage` replies, notification titles) also use it from the next message on; messages already in the chat keep their language. Agent replies and your own content aren't translated.
|
|
207
|
+
|
|
200
208
|
#### Profile
|
|
201
209
|
|
|
202
210
|
The profile sits at the bottom of the sidebar.
|
|
203
211
|
|
|
204
212
|
| Target | How | Rules |
|
|
205
213
|
| --- | --- | --- |
|
|
206
|
-
| Your nickname | Double-click your profile. | Up to 20 characters. Blank
|
|
207
|
-
|
|
|
208
|
-
|
|
|
214
|
+
| Your nickname | Double-click your profile. | Up to 20 characters. Blank shows the default of the screen language: "Me" in English, "나" in Korean. |
|
|
215
|
+
| Default agent name | Double-click the default agent profile. | Up to 10 Korean or 20 Latin characters. Too long and it isn't changed. Blank shows the default of the screen language: "Zig" in English, "지그" in Korean. A name you set stays as you wrote it in both languages. |
|
|
216
|
+
| Default agent icon | Double-click the default agent icon. | PNG, JPEG or WebP, 1 MB at most. The center is cropped to a square. You can go back to the default icon. |
|
|
209
217
|
|
|
210
218
|
In a name field, Enter saves and Esc cancels. Leaving the field also saves. Names are stored on the gateway. If that fails, only this browser shows the new name.
|
|
211
219
|
|
|
220
|
+
The gateway tells main, work and agent sessions your nickname at each turn, so every session calls you by it from the next turn. Main also learns the default agent name, and each agent learns the name you gave it in the Agents screen.
|
|
221
|
+
|
|
212
222
|
#### Security check (보안검사)
|
|
213
223
|
|
|
214
|
-
|
|
224
|
+
"Message check before the engine", "Output check" and each tool row under the "Formal verification" group are switches. Each item shows a one or two line summary. For the full description, hover over or focus the info icon (i) on the web, or tap the info icon in the app to open a dialog. The Android settings screen has the same switches.
|
|
225
|
+
|
|
226
|
+
- "Message check before the engine" turns the check of your messages on or off. It is on by default and the gateway keeps the choice.
|
|
227
|
+
- "Output check" turns the check of work output on or off. On (the default), `zigmini scan` looks for secret-like values and your baseline values, and new sessions get the rule to run it before a merge. Off makes `zigmini scan` stop without scanning (exit code 4) and removes that rule. The message check has its own switch.
|
|
228
|
+
- "Formal verification" is a group title with one row per tool (TLA+ today). Each row shows the tool name and its status, an info icon and the tool's switch. The switch chooses whether checks (`bun run formal:check`) use the tool once it's installed; it never installs anything.
|
|
229
|
+
- A tool that isn't installed shows "Not installed" and an "Install" button. Pressing Install installs only that tool with Homebrew (`openjdk@21` without `brew link`, and tla2tools.jar 1.7.4 checked by size and SHA-256). While it runs, the row shows "Installing…" and hides the button. Once installed, the button goes away. Nothing is installed until you press Install.
|
|
230
|
+
- A failed install shows "Install failed", "Failure reason: …" under the row and the Install button again. Without Homebrew, the reason tells you to install Homebrew from https://brew.sh and press Install again. A failed install keeps the switch as you set it.
|
|
231
|
+
- If your old overall formal verification setting was off, every tool switch stays off after the update.
|
|
215
232
|
- When a message or inbox answer contains a detected value that is not allowed, the gateway holds it before sending it to the agent. The same security item appears in the inbox and chat. It shows the complete values to be remembered and their types.
|
|
216
233
|
- Approving sends the original message and remembers the displayed exact values on this gateway. The same values do not prompt again. A different value still needs approval. Existing held messages do not send automatically.
|
|
217
234
|
- Cancel stops only this transmission. It does not stop the existing work. If the held transmission is an inbox answer, the original question remains unanswered and its answer form stays available.
|
|
@@ -221,7 +238,8 @@ In a name field, Enter saves and Esc cancels. Leaving the field also saves. Name
|
|
|
221
238
|
- The allowed-value list is private to this installation and survives gateway restarts. Web settings show the list under Security check. On Android, open the gateway settings screen that contains Manual, then open the allowed values.
|
|
222
239
|
- Each entry shows its full value, type, approval time and approving client. Remove one entry or clear the list to check those values again on future transmissions. Removal does not cancel a transmission already approved or erase a sent message.
|
|
223
240
|
- An older app's send-only approval still sends once without remembering values. Remembering requires the new send-and-remember choice. Unapproved original messages are not saved in ordinary question records or history; pending originals are lost on gateway restart.
|
|
224
|
-
-
|
|
241
|
+
- By default a remembered value is allowed only for transmission. Each entry has a "저장소 검사에도 허용" (also allow in repository scan) switch. Only values with the switch on pass `zigmini scan`. A value equal to this Mac's home path or account name can't be switched on.
|
|
242
|
+
- "보안 기준" (security baseline) lists the terms that the repository scan always looks for. Add a term on the web; the web and Android can remove one after a confirmation. Rows for this Mac's home path and account name are automatic and read-only. The status line shows when the gateway last exported the baseline file.
|
|
225
243
|
|
|
226
244
|
#### Update (업데이트)
|
|
227
245
|
|
|
@@ -294,10 +312,39 @@ Voice features work only while voice is on (음성 켜짐).
|
|
|
294
312
|
|
|
295
313
|
### 2.10 Viewing the manual
|
|
296
314
|
|
|
297
|
-
- Web: click the 매뉴얼 (manual) button at the top of the settings dialog. The
|
|
315
|
+
- Web: click the 매뉴얼 (manual) button at the top of the settings dialog. The version in the selected screen language opens first. The "English" button or the link on the first line of the document switches to the English version. Esc, the close button (×) or a click outside the dialog closes it.
|
|
298
316
|
- App: short press the header status tag (for example "작업 상태 확인 중") to open the gateway connection screen, then tap its 매뉴얼 button. The "English" button switches to the English version. 닫기 (close) or Back returns to the connection screen.
|
|
299
317
|
- When the gateway does not serve the manual, the notice "이 게이트웨이 버전에는 매뉴얼이 없습니다" (this gateway version has no manual) shows. Update the gateway to read it.
|
|
300
318
|
|
|
319
|
+
### 2.11 Agents
|
|
320
|
+
|
|
321
|
+
An agent is a conversation with a role, which you create on the web. Each agent has its own conversation, history, engine session and knowledge folder. It's separate from the default agent (main, Zig). The default agent stays your assistant and keeps managing work and the inbox. An agent does one job, like interview practice, and doesn't manage work or other sessions. No agents come built in.
|
|
322
|
+
|
|
323
|
+
Terms: the default agent (main, Zig) is the conversation the gateway has from the start. An agent is a role conversation that you create. A work session or a sub-task is a separate engine session that runs one job; it is not an agent.
|
|
324
|
+
|
|
325
|
+
```zigmini-diagram
|
|
326
|
+
{"version":1,"title":"Default agent, agents and work sessions","nodes":[{"id":"user","label":"You"},{"id":"main","label":"Default agent (main, Zig)"},{"id":"agent","label":"Agent conversation"},{"id":"work","label":"Work"},{"id":"knowledge","label":"Agent knowledge folder"}],"edges":[{"from":"user","to":"main"},{"from":"user","to":"agent"},{"from":"main","to":"work"},{"from":"agent","to":"knowledge"}]}
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
- **Sidebar.** Agents appear in their own group. The **⋯** button beside the group name (or **Agents** at the top of the chat) opens the Agents screen with three views: **List**, **Shared defaults** and **Archive**. You create and edit agents only on this screen: **+ Agent** in **List** creates one, and **Edit** on a row opens its editor. The sidebar and the chat header have no add or edit button. Each agent has exactly one conversation.
|
|
330
|
+
- **Editor.** Name and a one-line purpose are required. Optional fields: icon, model (provider and model ID, both filled or both empty; empty means the gateway's current model), thinking level, skill allow list, web research (web_search, webfetch), app notification when a reply ends, gateway and install rules on or off, and the MEMORY.md size limit (0 to 32768 bytes). Shell and code execution are always off. Each field shows where its value comes from: package default, shared default or this agent. The body is the role instruction.
|
|
331
|
+
- **Changes apply from the next session.** After you edit an agent, its header shows **Definition changed · applies from the next session**. The open session keeps the old definition. Click the badge to start a new session with the edited one.
|
|
332
|
+
- **Skills.** The "/" menu in an agent conversation hides skills outside its allow list.
|
|
333
|
+
- **Knowledge.** The agent's file tools work only inside its knowledge folder. Upload source files in the agent's editor (**Agents** screen, **List**, **Edit**). With web research on, research notes are saved under `knowledge/notes/` with the source URL beside each fact. An agent can search only its own conversation history.
|
|
334
|
+
- **Clone.** Cloning copies the definition, source files and MEMORY.md into a new agent named "<name> 사본". The conversation isn't copied.
|
|
335
|
+
- **Delete and restore.** To delete, type the agent's exact name. The conversation is hidden, and the definition and knowledge move to **Archive** for 30 days. You can't delete during a running turn or an open question. **Restore** in **Archive** brings the conversation back.
|
|
336
|
+
- **Default agent.** Zig can list agents (name, purpose, link) but can't talk to them. Trashed agents aren't listed.
|
|
337
|
+
- **Android.** The app stays on the default agent (main) conversation. Agent conversations are web only for now.
|
|
338
|
+
|
|
339
|
+
### 2.12 Agent rules
|
|
340
|
+
|
|
341
|
+
The gateway adds the common rules of the package and this installation's `~/.zigmini/rules.md` to every main and work session.
|
|
342
|
+
|
|
343
|
+
Replacing a common rule item: each item in the common rules file has a fixed id in a comment (for example `<!--id:f2468e-->`). The id is set once when the item is made and stays the same when its text changes. The item numbers are only a reading order, not a priority. To replace a whole common item, put `<!--replaces:<id>-->` inside an item of `rules.md`. The gateway then leaves out that common item and uses your item. An item without `replaces` is a new rule and goes after the common rules, as before; `rules.md` still wins on a conflict. If an id no longer exists in the common rules (for example after an update), the item is added as a new rule and `zigmini doctor` shows a warning.
|
|
344
|
+
|
|
345
|
+
- `zigmini rules`: lists the common items with number, id and first line.
|
|
346
|
+
- `zigmini rules check`: shows each `rules.md` item as "replaces common N", "new rule" or "unknown id" (exit code 1 when an unknown id exists).
|
|
347
|
+
|
|
301
348
|
## 3. Keyboard shortcuts
|
|
302
349
|
|
|
303
350
|
| Key | Where | What it does |
|
|
@@ -322,9 +369,9 @@ Voice features work only while voice is on (음성 켜짐).
|
|
|
322
369
|
| Tab, Shift+Tab | Settings | Focus cycles inside settings. |
|
|
323
370
|
| Esc | Manual | Closes the manual and returns to settings. |
|
|
324
371
|
| Tab, Shift+Tab | Manual | Focus cycles inside the manual. |
|
|
325
|
-
| Enter | Nickname or agent name field | Saves. |
|
|
326
|
-
| Esc | Nickname or agent name field | Closes without change. |
|
|
327
|
-
| Esc |
|
|
372
|
+
| Enter | Nickname or default agent name field | Saves. |
|
|
373
|
+
| Esc | Nickname or default agent name field | Closes without change. |
|
|
374
|
+
| Esc | Default agent icon editor | Closes without change. |
|
|
328
375
|
|
|
329
376
|
Ctrl shortcuts do nothing with Cmd, Alt or Shift held. They match by key position, so a Korean layout works too.
|
|
330
377
|
|
|
@@ -451,6 +498,8 @@ Main commands for the Mac terminal:
|
|
|
451
498
|
- `zigmini pair [--apk]`: shows the phone registration QR or the app download QR.
|
|
452
499
|
- `zigmini consent`: shows the permission notice and records consent.
|
|
453
500
|
- `zigmini update [version]`: updates.
|
|
501
|
+
- `zigmini scan [<repo>]`: checks a repository with the security baseline. Exit 0 clean, 1 findings, 2 error, 3 baseline missing, empty or out of date.
|
|
502
|
+
- `zigmini security import-baseline [--dry-run]`: adds an old plain-text list to the security baseline while the gateway runs.
|
|
454
503
|
- `zigmini rotate-main [--restart]`: moves the main conversation to a new session, like "새 세션" (new session). Ask the operator first. See the operator guide.
|
|
455
504
|
|
|
456
505
|
For the full list, see the README's Commands section.
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { INSTALL_RULES_FILE, INSTALL_RULES_HEADING, packageRulesDrop, PACKAGE_RULES_FILE, PACKAGE_RULES_HEADING, readRulesFile } from "../../src/agent-rules.ts";
|
|
5
|
+
import { applyRequestRules, loadRequestRules, type ProviderRequestEvent } from "../../src/agent-request-rules.ts";
|
|
6
|
+
import { loadEngineSdk } from "../../src/engine/index.ts";
|
|
7
|
+
import { validateAgentFields } from "../../src/agent/definition.ts";
|
|
8
|
+
import { runtimeHome, runtimePaths } from "../../src/runtime/paths.ts";
|
|
9
|
+
import { agentNamePrompt, nicknamePrompt, readAgentPromptName, readPromptProfile, type PromptProfile } from "../../src/profile-prompt.ts";
|
|
10
|
+
|
|
11
|
+
type Context = { readonly cwd: string; readonly sessionManager: { getSessionId(): string }; readonly ui: { notify(message: string, level: "warning"): void } };
|
|
12
|
+
type ToolCall = { readonly toolName: string; readonly toolCallId: string; readonly input: Record<string, unknown> };
|
|
13
|
+
type Block = { readonly block: true; readonly reason: string };
|
|
14
|
+
type Skill = { readonly name: string; readonly filePath: string };
|
|
15
|
+
type Start = { readonly systemPrompt: string; readonly systemPromptOptions: { readonly skills?: readonly Skill[] } };
|
|
16
|
+
type Events = {
|
|
17
|
+
session_start: unknown; session_shutdown: unknown; before_agent_start: Start;
|
|
18
|
+
before_provider_request: ProviderRequestEvent; tool_call: ToolCall; tool_result: { readonly toolCallId: string };
|
|
19
|
+
input: { readonly text: string }; agent_end: unknown; session_abort: unknown;
|
|
20
|
+
};
|
|
21
|
+
interface ExtensionApi {
|
|
22
|
+
readonly sessionKind: string;
|
|
23
|
+
readonly sessionContext: Readonly<Record<string, string>>;
|
|
24
|
+
readonly rpc: { emit(name: string, data: unknown): void; handle(name: string, handler: () => unknown): void };
|
|
25
|
+
on<K extends keyof Events>(event: K, handler: (event: Events[K], ctx: Context) => unknown, options?: { previewSafe: boolean }): void;
|
|
26
|
+
setActiveTools(names: string[]): void;
|
|
27
|
+
getCommands(): readonly { readonly name: string; readonly source: string }[];
|
|
28
|
+
appendEntry(name: string, data: unknown): void;
|
|
29
|
+
registerTool(tool: HistoryTool): void;
|
|
30
|
+
registerFilesystemPolicy(policy: { check(request: { operation: string; canonicalPath: string }): { allow: true } | { allow: false; reason: string } }): void;
|
|
31
|
+
}
|
|
32
|
+
type HistoryTool = {
|
|
33
|
+
name: string; label: string; description: string; parameters: unknown;
|
|
34
|
+
execute(id: string, params: Record<string, unknown>, signal: AbortSignal | undefined): Promise<unknown>;
|
|
35
|
+
};
|
|
36
|
+
type Edit = { readonly oldText: string; readonly newText: string };
|
|
37
|
+
/** The runtime supplies this public SDK. Tests use the same native editor, not a replacement algorithm. */
|
|
38
|
+
export interface AgentSdk {
|
|
39
|
+
createEditTool(cwd: string, options: { operations: {
|
|
40
|
+
readFile(path: string): Promise<Buffer>; writeFile(path: string, content: string): Promise<void>; access(path: string): Promise<void>;
|
|
41
|
+
} }): { execute(id: string, input: { path: string; edits: Edit[] }): Promise<unknown> };
|
|
42
|
+
}
|
|
43
|
+
const BASE_TOOLS = ["read", "write", "edit", "ask_user_question", "zgw_agent_history_search"];
|
|
44
|
+
export const MEMORY_CAP_MESSAGE = "MEMORY.md를 요약해 줄인 뒤 다시 쓰세요.";
|
|
45
|
+
const DAILY_RULES = "Record each day's facts, results and decisions chronologically in notes/daily/YYYY-MM-DD.md (KST). " +
|
|
46
|
+
"Keep only long-lived preferences, established facts and ongoing goals summarized in MEMORY.md; do not copy daily logs into it.";
|
|
47
|
+
export const RESEARCH_RULES = "When you research on the web, write the results to knowledge/notes/companies/<company>.md " +
|
|
48
|
+
"(other topics knowledge/notes/research/<topic>.md) and put each source URL on the line of the fact it supports.";
|
|
49
|
+
const HISTORY_LIMIT = 50;
|
|
50
|
+
const blocked = (reason: string): Block => ({ block: true, reason });
|
|
51
|
+
function record(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
|
|
52
|
+
function missing(error: unknown): boolean { return error instanceof Error && "code" in error && error.code === "ENOENT"; }
|
|
53
|
+
function inside(root: string, path: string): boolean {
|
|
54
|
+
const part = relative(root, path);
|
|
55
|
+
return part === "" || (!isAbsolute(part) && part !== ".." && !part.startsWith(`..${sep}`));
|
|
56
|
+
}
|
|
57
|
+
/** Resolve existing links, including a new target's nearest existing parent. Dangling links fail closed. */
|
|
58
|
+
export function agentPath(path: string): string {
|
|
59
|
+
try { lstatSync(path); } catch (error) {
|
|
60
|
+
if (!missing(error)) throw error;
|
|
61
|
+
return join(agentPath(dirname(path)), basename(path));
|
|
62
|
+
}
|
|
63
|
+
return realpathSync(path);
|
|
64
|
+
}
|
|
65
|
+
function owner(path: string): { sessionId: string; pid: number } | undefined {
|
|
66
|
+
try {
|
|
67
|
+
const data: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
68
|
+
if (record(data) && typeof data.sessionId === "string" && Number.isSafeInteger(data.pid) && typeof data.pid === "number" && data.pid > 0) return { sessionId: data.sessionId, pid: data.pid };
|
|
69
|
+
throw new Error("agent_lock_invalid");
|
|
70
|
+
} catch (error) { if (missing(error)) return undefined; throw error; }
|
|
71
|
+
}
|
|
72
|
+
function live(pid: number): boolean {
|
|
73
|
+
try { process.kill(pid, 0); return true; } catch (error) {
|
|
74
|
+
if (error instanceof Error && "code" in error && error.code === "ESRCH") return false;
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** All lock changes share one exclusive claim directory; a conflicting claimant never removes a newer lock. */
|
|
79
|
+
function claimLock(root: string, sessionId: string, release = false): boolean {
|
|
80
|
+
const claim = join(root, ".lock.claim");
|
|
81
|
+
try { mkdirSync(claim); } catch (error) {
|
|
82
|
+
if (error instanceof Error && "code" in error && error.code === "EEXIST") return false;
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const path = join(root, ".lock");
|
|
87
|
+
const held = owner(path);
|
|
88
|
+
const ours = held?.sessionId === sessionId && held.pid === process.pid;
|
|
89
|
+
if (release) { if (ours) unlinkSync(path); return ours; }
|
|
90
|
+
if (held && !ours && live(held.pid)) return false;
|
|
91
|
+
if (!ours) writeFileSync(path, JSON.stringify({ sessionId, pid: process.pid }), { mode: 0o600 });
|
|
92
|
+
return true;
|
|
93
|
+
} finally { rmdirSync(claim); }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export default function agentControl(pi: ExtensionApi, loadSdk: () => Promise<AgentSdk> = () => loadEngineSdk<AgentSdk>()): void {
|
|
97
|
+
const id = pi.sessionContext.zgw_agent;
|
|
98
|
+
if (pi.sessionContext.zgw_role !== "agent" || pi.sessionKind !== "interactive" || !id) return;
|
|
99
|
+
pi.registerTool({
|
|
100
|
+
name: "zgw_agent_history_search", label: "Agent history search",
|
|
101
|
+
description: "Search this agent's own past conversation messages for a text. Returns the newest matches first (at most 50). Other conversations are never searched.",
|
|
102
|
+
parameters: { type: "object", properties: { query: { type: "string", minLength: 1, maxLength: 200 }, limit: { type: "integer", minimum: 1, maximum: HISTORY_LIMIT } }, required: ["query"], additionalProperties: false },
|
|
103
|
+
async execute(_toolCallId, params, signal) {
|
|
104
|
+
if (typeof params.query !== "string" || params.query === "") throw new Error("agent_history_query_required");
|
|
105
|
+
const limit = typeof params.limit === "number" && Number.isInteger(params.limit) ? Math.min(Math.max(params.limit, 1), HISTORY_LIMIT) : 20;
|
|
106
|
+
const token = readFileSync(runtimePaths().omoTokenFile, "utf8").trim();
|
|
107
|
+
const base = process.env.ZGW_WORK_URL ?? `http://127.0.0.1:${process.env.ZGW_PORT ?? "8700"}`;
|
|
108
|
+
const url = new URL("/internal/agent-history", base);
|
|
109
|
+
// The agent id comes from the session context, never from the model's arguments.
|
|
110
|
+
url.search = new URLSearchParams({ agent: id, query: params.query, limit: String(limit) }).toString();
|
|
111
|
+
const response = await fetch(url, {
|
|
112
|
+
headers: { authorization: `Bearer ${token}` }, redirect: "error",
|
|
113
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(10_000)]) : AbortSignal.timeout(10_000),
|
|
114
|
+
});
|
|
115
|
+
const result: unknown = await response.json();
|
|
116
|
+
if (!response.ok) throw new Error(`agent_history_http_${response.status}`);
|
|
117
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
let root = "", sessionId = "", prompt = "", ready = false, agentName = "";
|
|
121
|
+
let maxBytes = 0;
|
|
122
|
+
let tools: string[] = [], skills: string[] = [];
|
|
123
|
+
const skillSnapshots = new Map<string, string>();
|
|
124
|
+
let memoryCall: string | undefined;
|
|
125
|
+
let ui: Context["ui"] | undefined;
|
|
126
|
+
let startupWarnings: { code: string; message: string }[] = [];
|
|
127
|
+
// session_start precedes the gateway's open_session attachment. Read this snapshot after binding the owner.
|
|
128
|
+
pi.rpc.handle("zgw.agent_warnings", () => ({ warnings: startupWarnings }));
|
|
129
|
+
const warn = (code: string) => {
|
|
130
|
+
const data = { agentId: id, code };
|
|
131
|
+
const message = code === "memory_over_cap" ? `MEMORY.md가 크기 상한을 넘어 이번 세션에서 생략되었습니다. ${MEMORY_CAP_MESSAGE}` : `에이전트 요청이 제한되었습니다: ${code}`;
|
|
132
|
+
if (!ready) startupWarnings.push({ code, message });
|
|
133
|
+
pi.appendEntry("zgw.agent_warning", data);
|
|
134
|
+
pi.rpc.emit("zgw.agent_warning", data);
|
|
135
|
+
ui?.notify(message, "warning");
|
|
136
|
+
};
|
|
137
|
+
const pathDenial = (path: string, write: boolean): string | undefined => {
|
|
138
|
+
if (!ready) return "agent_not_ready";
|
|
139
|
+
if (!inside(root, path)) return "agent_path_outside_knowledge";
|
|
140
|
+
if (!write) return undefined;
|
|
141
|
+
if (inside(join(root, "source"), path)) return "agent_source_read_only";
|
|
142
|
+
if (path === root || inside(join(root, ".lock"), path) || inside(join(root, ".lock.claim"), path)) return "agent_lock_protected";
|
|
143
|
+
if (!claimLock(root, sessionId)) return "agent_knowledge_locked";
|
|
144
|
+
return undefined;
|
|
145
|
+
};
|
|
146
|
+
pi.registerFilesystemPolicy({ check: ({ operation, canonicalPath }) => {
|
|
147
|
+
try {
|
|
148
|
+
const reason = pathDenial(canonicalPath, operation === "write");
|
|
149
|
+
return reason ? { allow: false, reason } : { allow: true };
|
|
150
|
+
} catch { return { allow: false, reason: "agent_path_unavailable" }; }
|
|
151
|
+
} });
|
|
152
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
153
|
+
ready = false;
|
|
154
|
+
startupWarnings = [];
|
|
155
|
+
ui = ctx.ui;
|
|
156
|
+
pi.setActiveTools([]);
|
|
157
|
+
const token = readFileSync(runtimePaths().omoTokenFile, "utf8").trim();
|
|
158
|
+
const base = process.env.ZGW_WORK_URL ?? `http://127.0.0.1:${process.env.ZGW_PORT ?? "8700"}`;
|
|
159
|
+
const response = await fetch(new URL(`/internal/agent/${encodeURIComponent(id)}`, base), {
|
|
160
|
+
headers: { authorization: `Bearer ${token}` }, redirect: "error", signal: AbortSignal.timeout(5_000),
|
|
161
|
+
});
|
|
162
|
+
if (!response.ok) throw new Error(`agent_definition_http_${response.status}`);
|
|
163
|
+
const definition: unknown = await response.json();
|
|
164
|
+
if (!record(definition) || !record(definition.fields) || typeof definition.body !== "string" || typeof definition.defaultsBody !== "string") throw new Error("agent_definition_invalid");
|
|
165
|
+
validateAgentFields(definition.fields);
|
|
166
|
+
if (typeof definition.fields.name !== "string") throw new Error("agent_name_invalid");
|
|
167
|
+
agentName = definition.fields.name;
|
|
168
|
+
const { memory, rules, tools: toolSettings, skills: allowedSkills } = definition.fields;
|
|
169
|
+
if (!record(memory) || typeof memory.maxBytes !== "number" || !record(rules) || !record(toolSettings) ||
|
|
170
|
+
!Array.isArray(allowedSkills) || !allowedSkills.every((s): s is string => typeof s === "string")) throw new Error("agent_definition_unmerged");
|
|
171
|
+
const expected = join(realpathSync(join(runtimeHome(), "agents")), id, "knowledge");
|
|
172
|
+
root = realpathSync(expected);
|
|
173
|
+
if (root !== expected) throw new Error("agent_knowledge_symlink");
|
|
174
|
+
if (realpathSync(ctx.cwd) !== root) throw new Error("agent_cwd_mismatch");
|
|
175
|
+
sessionId = ctx.sessionManager.getSessionId();
|
|
176
|
+
maxBytes = memory.maxBytes;
|
|
177
|
+
skills = allowedSkills;
|
|
178
|
+
tools = [...BASE_TOOLS, ...(toolSettings.web === true ? ["web_search", "webfetch"] : [])];
|
|
179
|
+
const sections: string[] = [];
|
|
180
|
+
for (const [enabled, path, heading] of [
|
|
181
|
+
[rules.gateway, PACKAGE_RULES_FILE, PACKAGE_RULES_HEADING],
|
|
182
|
+
[rules.install, join(runtimeHome(), INSTALL_RULES_FILE), INSTALL_RULES_HEADING],
|
|
183
|
+
] as const) {
|
|
184
|
+
if (enabled) { const source = readRulesFile(path, path === PACKAGE_RULES_FILE ? packageRulesDrop() : undefined); if (source.text) sections.push(`${heading}\n\n${source.text}`); }
|
|
185
|
+
}
|
|
186
|
+
// body already includes defaultsBody. Put the package daily rule first so defaults can override it.
|
|
187
|
+
sections.push(DAILY_RULES, definition.body);
|
|
188
|
+
if (toolSettings.web === true) sections.push(RESEARCH_RULES);
|
|
189
|
+
const memoryPath = agentPath(join(root, "MEMORY.md"));
|
|
190
|
+
if (!inside(root, memoryPath)) throw new Error("agent_memory_outside_knowledge");
|
|
191
|
+
if (existsSync(memoryPath)) {
|
|
192
|
+
if (statSync(memoryPath).size > maxBytes) warn("memory_over_cap");
|
|
193
|
+
else {
|
|
194
|
+
const snapshot = readFileSync(memoryPath);
|
|
195
|
+
if (snapshot.byteLength > maxBytes) warn("memory_over_cap");
|
|
196
|
+
else sections.push(`<agent-memory>\n${snapshot.toString("utf8")}\n</agent-memory>`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
prompt = sections.join("\n\n");
|
|
200
|
+
if (!claimLock(root, sessionId)) warn("knowledge_locked");
|
|
201
|
+
ready = true;
|
|
202
|
+
pi.setActiveTools(tools);
|
|
203
|
+
});
|
|
204
|
+
pi.on("session_shutdown", () => { if (root && sessionId) claimLock(root, sessionId, true); ready = false; });
|
|
205
|
+
const requestRules = loadRequestRules();
|
|
206
|
+
pi.on("before_provider_request", (event) => applyRequestRules(event, requestRules));
|
|
207
|
+
let profile: PromptProfile = { nickname: undefined, agentName: "" };
|
|
208
|
+
pi.on("before_agent_start", async (event) => {
|
|
209
|
+
if (!ready) throw new Error("agent_not_ready");
|
|
210
|
+
[profile, agentName] = await Promise.all([readPromptProfile(profile), readAgentPromptName(id, agentName)]);
|
|
211
|
+
pi.setActiveTools(tools);
|
|
212
|
+
// File tools remain confined to knowledge. Approved skill bodies are loaded here, not through a read exception.
|
|
213
|
+
for (const skill of event.systemPromptOptions.skills ?? []) {
|
|
214
|
+
if (skills.includes(skill.name) && !skillSnapshots.has(skill.name)) skillSnapshots.set(skill.name, readFileSync(skill.filePath, "utf8"));
|
|
215
|
+
}
|
|
216
|
+
const base = event.systemPrompt.replace(/<available_skills>[\s\S]*?<\/available_skills>/g, "")
|
|
217
|
+
.replace(/<skill_roots>[\s\S]*?<\/skill_roots>/g, "");
|
|
218
|
+
const selected = [...skillSnapshots].map(([name, text]) => `<agent-skill name="${name}">\n${text}\n</agent-skill>`).join("\n\n");
|
|
219
|
+
return { systemPrompt: [base, prompt, selected, agentNamePrompt(agentName), nicknamePrompt(profile.nickname)].filter(Boolean).join("\n\n") };
|
|
220
|
+
});
|
|
221
|
+
pi.on("input", (event) => {
|
|
222
|
+
if (!ready) { warn("agent_not_ready"); return { action: "handled" }; }
|
|
223
|
+
const known = new Set(pi.getCommands().filter((command) => command.source === "skill").map((command) => command.name.replace(/^skill:/, "")));
|
|
224
|
+
for (const match of event.text.matchAll(/(?:^|\s)(\/skill:|\$(?:skill:)?)([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g)) {
|
|
225
|
+
const name = match[2] ?? "";
|
|
226
|
+
if (known.has(name) && !skills.includes(name)) { warn("skill_not_allowed"); return { action: "handled" }; }
|
|
227
|
+
}
|
|
228
|
+
return { action: "continue" };
|
|
229
|
+
});
|
|
230
|
+
pi.on("tool_call", async (event) => {
|
|
231
|
+
if (!ready || !tools.includes(event.toolName)) return blocked("agent_tool_not_allowed");
|
|
232
|
+
if (!["read", "write", "edit"].includes(event.toolName)) return;
|
|
233
|
+
try {
|
|
234
|
+
if (typeof event.input.path !== "string" || event.input.path === "") return blocked("agent_path_required");
|
|
235
|
+
const expanded = event.input.path.replace(/^~(?=\/|$)/, homedir());
|
|
236
|
+
const lexical = resolve(root, expanded);
|
|
237
|
+
const path = agentPath(lexical);
|
|
238
|
+
const write = event.toolName !== "read";
|
|
239
|
+
const reason = pathDenial(lexical, write) ?? pathDenial(path, write);
|
|
240
|
+
if (reason) return blocked(reason);
|
|
241
|
+
if (write && existsSync(path) && lstatSync(path).nlink > 1) return blocked("agent_linked_file_read_only");
|
|
242
|
+
// Canonicalize the actual argument too; do not leave a checked symlink for the native tool to follow.
|
|
243
|
+
event.input.path = path;
|
|
244
|
+
if (!write || path !== agentPath(join(root, "MEMORY.md"))) return;
|
|
245
|
+
if (memoryCall !== undefined) return blocked("agent_memory_busy");
|
|
246
|
+
memoryCall = event.toolCallId;
|
|
247
|
+
let bytes: number;
|
|
248
|
+
if (event.toolName === "write") {
|
|
249
|
+
if (typeof event.input.content !== "string") throw new Error("agent_write_content_required");
|
|
250
|
+
bytes = Buffer.byteLength(event.input.content);
|
|
251
|
+
} else {
|
|
252
|
+
const edits = event.input.edits;
|
|
253
|
+
if (!Array.isArray(edits) || !edits.every((e): e is Edit => record(e) && typeof e.oldText === "string" && typeof e.newText === "string")) throw new Error("agent_edit_input_invalid");
|
|
254
|
+
// Run the pinned native editor against memory-only operations: BOM, CRLF, fuzzy and multi-edit semantics stay exact.
|
|
255
|
+
const sdk = await loadSdk();
|
|
256
|
+
let result: string | undefined;
|
|
257
|
+
await sdk.createEditTool(root, { operations: {
|
|
258
|
+
readFile: async () => readFileSync(path), access: async () => {},
|
|
259
|
+
writeFile: async (_path, content) => { result = content; },
|
|
260
|
+
} }).execute(event.toolCallId, { path, edits });
|
|
261
|
+
if (result === undefined) throw new Error("agent_edit_result_missing");
|
|
262
|
+
bytes = Buffer.byteLength(result);
|
|
263
|
+
}
|
|
264
|
+
if (bytes > maxBytes) { memoryCall = undefined; return blocked(MEMORY_CAP_MESSAGE); }
|
|
265
|
+
} catch (error) {
|
|
266
|
+
if (memoryCall === event.toolCallId) memoryCall = undefined;
|
|
267
|
+
return blocked(error instanceof Error ? error.message : "agent_path_unavailable");
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
pi.on("tool_result", (event) => { if (memoryCall === event.toolCallId) memoryCall = undefined; });
|
|
271
|
+
// A later hook/permission can refuse a call without a tool_result. Its turn end also releases the reservation.
|
|
272
|
+
pi.on("agent_end", () => { memoryCall = undefined; });
|
|
273
|
+
pi.on("session_abort", () => { memoryCall = undefined; });
|
|
274
|
+
}
|
|
@@ -85,7 +85,7 @@ function spawnOmo(): Subprocess {
|
|
|
85
85
|
if (existsSync(socketPath)) rmSync(socketPath);
|
|
86
86
|
const child = Bun.spawn(engine.launchCommand({
|
|
87
87
|
home, socket: socketPath, sessionDir,
|
|
88
|
-
extensions: [join(import.meta.dir, "model-control.ts"), join(import.meta.dir, "task-control.ts")],
|
|
88
|
+
extensions: [join(import.meta.dir, "model-control.ts"), join(import.meta.dir, "task-control.ts"), join(import.meta.dir, "agent-control.ts")],
|
|
89
89
|
...(engineRoot ? { engineRoot } : {}),
|
|
90
90
|
}), {
|
|
91
91
|
cwd: process.env.HOME ?? home,
|
|
@@ -122,7 +122,7 @@ export default function continuityControl<Context extends ContinuityContext>(
|
|
|
122
122
|
const role = pi.sessionContext.zgw_role;
|
|
123
123
|
const isMain =
|
|
124
124
|
pi.sessionKind === "interactive" &&
|
|
125
|
-
(role === "main" || role === undefined) &&
|
|
125
|
+
(role === "main" || role === "agent" || role === undefined) &&
|
|
126
126
|
Boolean(pi.sessionContext.zgw_conv);
|
|
127
127
|
if (!isMain) return;
|
|
128
128
|
|
|
@@ -3,6 +3,7 @@ import { isDeepStrictEqual } from "node:util";
|
|
|
3
3
|
import { runtimePaths } from "../../src/runtime/paths.ts";
|
|
4
4
|
import { agentRules } from "../../src/agent-rules.ts";
|
|
5
5
|
import { applyRequestRules, loadRequestRules, type ProviderRequestEvent } from "../../src/agent-request-rules.ts";
|
|
6
|
+
import { nicknamePrompt, PROFILE_TIMEOUT_MS, readPromptProfile, type PromptProfile } from "../../src/profile-prompt.ts";
|
|
6
7
|
|
|
7
8
|
// The pinned runtime supplies this SDK surface, just as it does for model-control.ts.
|
|
8
9
|
type Entry = { id: string; type: string; customType?: string; data?: unknown; message?: { role: string; content: unknown; toolName?: string; details?: unknown; toolCallId?: string } };
|
|
@@ -107,21 +108,11 @@ function gatewayBase(): string {
|
|
|
107
108
|
}
|
|
108
109
|
|
|
109
110
|
export const DEFAULT_AGENT_NAME = "지그";
|
|
110
|
-
export const AGENT_NAME_TIMEOUT_MS =
|
|
111
|
+
export const AGENT_NAME_TIMEOUT_MS = PROFILE_TIMEOUT_MS;
|
|
111
112
|
|
|
112
113
|
/** The agent name the gateway keeps (GET /api/profile, improvement 59); `fallback` when the gateway does not answer. */
|
|
113
114
|
export async function readAgentName(fallback: string): Promise<string> {
|
|
114
|
-
|
|
115
|
-
const response = await fetch(new URL("/api/profile", gatewayBase()), { signal: AbortSignal.timeout(AGENT_NAME_TIMEOUT_MS) });
|
|
116
|
-
if (!response.ok) throw new Error(`profile_http_${response.status}`);
|
|
117
|
-
const data: unknown = await response.json();
|
|
118
|
-
const name = record(data) && typeof data.agentName === "string" ? data.agentName.replace(/\s+/g, " ").trim() : "";
|
|
119
|
-
if (!name) throw new Error("profile_agent_name_missing");
|
|
120
|
-
return name;
|
|
121
|
-
} catch (error) {
|
|
122
|
-
console.warn(`agent name: keeping "${fallback}":`, error instanceof Error ? error.message : String(error));
|
|
123
|
-
return fallback;
|
|
124
|
-
}
|
|
115
|
+
return (await readPromptProfile({ nickname: undefined, agentName: fallback })).agentName;
|
|
125
116
|
}
|
|
126
117
|
|
|
127
118
|
async function postWork(operation: string, requestKey: string, sourceSessionPath: string, payload: Params, signal: AbortSignal | undefined): Promise<unknown> {
|
|
@@ -162,13 +153,14 @@ export default function taskControl(pi: ExtensionApi): void {
|
|
|
162
153
|
});
|
|
163
154
|
}
|
|
164
155
|
|
|
165
|
-
//
|
|
166
|
-
let
|
|
156
|
+
// Read every turn so profile changes also reach already-open main and work sessions.
|
|
157
|
+
let profile: PromptProfile = { nickname: undefined, agentName: DEFAULT_AGENT_NAME };
|
|
167
158
|
pi.on("before_agent_start", async (event) => {
|
|
168
|
-
|
|
159
|
+
profile = await readPromptProfile(profile);
|
|
160
|
+
const agentName = profile.agentName;
|
|
169
161
|
return { systemPrompt: `${event.systemPrompt}\n\n${isMain
|
|
170
162
|
? `You are the gateway main assistant. Your name is ${agentName}. The user calls you ${agentName}. ` + "Route project implementation and independent long-running work through zgw_work_start with the intended absolute project cwd; do not implement project changes in the main conversation. Keep short answers and orchestration here. These tools acknowledge durable submission, not execution or completion. Use exact work IDs for follow-up. Never replay unknown delivery automatically. Read reports and publish their results with zgw_work_publish; do not repeat published text. When you cannot answer the user immediately, first call zgw_precomment once with one line saying what you are doing now; skip it when you can answer right away, and never send periodic progress or routine work notices with it. When you need the user's answer or permission to run something, ask with ask_user_question instead of a plain chat question, so it stays open in the inbox until answered or cancelled; state the exact action and scope in the question, and cite the chat message it is about as [참조 #<message seq>] when you know its seq. Only that inbox answer covers the shown question and scope: a chat reply or an attached message reference is never permission by itself. In this main conversation the question never pauses your turn, even with waitForAnswer true: the gateway keeps it open in the inbox and returns at once with a ZGW_QUESTION_OPEN note. The answer arrives later as a ZGW_DECISION_COMMAND user message. Until then, do not take the action that answer decides, and keep handling every other message."
|
|
171
|
-
: `You own gateway work ${workId}. Read each ZGW_WORK_COMMAND envelope and preserve its command_seq. Execute with the existing permissions and native OMO capabilities. Report progress, questions, and verified outcomes with zgw_work_report. Turn end is not work completion. On cooperative cancel, clean up owned execution and report remaining activity honestly; interrupt alone is not cancellation.`}${rulesText}` };
|
|
163
|
+
: `You own gateway work ${workId}. Read each ZGW_WORK_COMMAND envelope and preserve its command_seq. Execute with the existing permissions and native OMO capabilities. Report progress, questions, and verified outcomes with zgw_work_report. Turn end is not work completion. On cooperative cancel, clean up owned execution and report remaining activity honestly; interrupt alone is not cancellation.`}${rulesText}\n\n${nicknamePrompt(profile.nickname)}` };
|
|
172
164
|
}, { previewSafe: true });
|
|
173
165
|
|
|
174
166
|
if (isMain) {
|
|
@@ -191,6 +183,24 @@ export default function taskControl(pi: ExtensionApi): void {
|
|
|
191
183
|
});
|
|
192
184
|
}
|
|
193
185
|
|
|
186
|
+
if (isMain) {
|
|
187
|
+
pi.registerTool({
|
|
188
|
+
name: "zgw_agent_list", label: "Agent list",
|
|
189
|
+
description: "List the user's agents read-only: name, one-line description and the web link of each agent's conversation. Use it to answer which agents exist or to give the user the link to open one. Returns no agent knowledge.",
|
|
190
|
+
parameters: { type: "object", properties: {}, additionalProperties: false },
|
|
191
|
+
async execute(_toolCallId, _params, signal) {
|
|
192
|
+
const token = readFileSync(runtimePaths().omoTokenFile, "utf8").trim();
|
|
193
|
+
const response = await fetch(new URL("/internal/agent-list", gatewayBase()), {
|
|
194
|
+
headers: { authorization: `Bearer ${token}` },
|
|
195
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(10_000)]) : AbortSignal.timeout(10_000),
|
|
196
|
+
});
|
|
197
|
+
const result: unknown = await response.json();
|
|
198
|
+
if (!response.ok) throw new Error(`agent_list_http_${response.status}:${JSON.stringify(result)}`);
|
|
199
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
194
204
|
for (const operation of isMain ? ["start", "send", "list", "get", "cancel", "publish", "report"] : ["report"]) {
|
|
195
205
|
const spec = isMain && operation === "report" ? {
|
|
196
206
|
fields: { kind: { type: "string", enum: ["question"] }, summary: string, details: {} },
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
--- a/@code-yeongyu/senpi/dist/core/agent-session.js
|
|
2
|
+
+++ b/@code-yeongyu/senpi/dist/core/agent-session.js
|
|
3
|
+
@@ -65,6 +65,7 @@ import { exportSessionToHtml, renderSessionToHtml, } from "./export-html/index.j
|
|
4
|
+
import { createToolHtmlRenderer } from "./export-html/tool-renderer.js";
|
|
5
|
+
import { reportExtensionHandlerStall } from "./extension-handler-stall-report.js";
|
|
6
|
+
import { ANTHROPIC_SUBSCRIPTION_PROVIDER_ID } from "./extensions/builtin/anthropic-subscription/account-management.js";
|
|
7
|
+
+import { CLAUDE_CODE_DEFAULT_CONTEXT_WINDOW, LEGACY_LONG_CONTEXT_MODEL_IDS } from "./extensions/builtin/anthropic-subscription/context-window.js";
|
|
8
|
+
import { askUserAnswerDisplayText } from "./extensions/builtin/ask-user/format.js";
|
|
9
|
+
import { getPromptCachePrewarmUsage } from "./extensions/builtin/cache-keepalive/prewarm-entry.js";
|
|
10
|
+
import { ModelUsabilityBudgetError, projectModelUsabilityBudget, } from "./extensions/builtin/compaction/model-usability-budget.js";
|
|
11
|
+
@@ -1053,9 +1054,9 @@ export class AgentSession {
|
|
12
|
+
const usable = (model) => chat.some((entry) => same(entry, model)) && (!available.some((entry) => entry.provider === model.provider) || available.some((entry) => same(entry, model))) &&
|
|
13
|
+
!this._retryFallback.isProviderSpent(model.provider) &&
|
|
14
|
+
this._modelRuntime.isFallbackEligible(model.provider);
|
|
15
|
+
- const fits = (model) => {
|
|
16
|
+
- const output = Math.min(32768, model.maxTokens > 0 ? model.maxTokens : 32768, Math.floor(model.contextWindow / 2));
|
|
17
|
+
- return model.contextWindow > 0 && inputTokens + Math.max(settings.reserveTokens, output) <= model.contextWindow;
|
|
18
|
+
+ const fits = (model, window = model.contextWindow) => {
|
|
19
|
+
+ const output = Math.min(32768, model.maxTokens > 0 ? model.maxTokens : 32768, Math.floor(window / 2));
|
|
20
|
+
+ return window > 0 && inputTokens + Math.max(settings.reserveTokens, output) <= window;
|
|
21
|
+
};
|
|
22
|
+
if (explicit) {
|
|
23
|
+
if (typeof override !== "string" || !/^[^/\s]+\/\S+$/.test(override))
|
|
24
|
+
@@ -1080,11 +1081,32 @@ export class AgentSession {
|
|
25
|
+
model.provider === sessionModel.provider && family.test(model.id)) : [];
|
|
26
|
+
// Stable sort preserves registry order within the regular and fast Luna groups.
|
|
27
|
+
preferred.sort((left, right) => Number(/-luna-fast$/.test(left.id)) - Number(/-luna-fast$/.test(right.id)));
|
|
28
|
+
- const candidates = [...(family?.test(sessionModel.id) ? [sessionModel] : []), ...preferred, sessionModel];
|
|
29
|
+
+ // A subscription opens a legacy model's 1M window only with usage credits; a native-1M model needs none.
|
|
30
|
+
+ // Subscription order: the newest Sonnet of the catalog, the main model, the other Sonnets newest first, then any
|
|
31
|
+
+ // other available model, each within the window it opens without credits. Other providers keep light, then main.
|
|
32
|
+
+ const subscription = sessionModel.provider === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID;
|
|
33
|
+
+ const creditFreeWindow = (model) => subscription && LEGACY_LONG_CONTEXT_MODEL_IDS.has(model.id) ? Math.min(model.contextWindow, CLAUDE_CODE_DEFAULT_CONTEXT_WINDOW) : model.contextWindow;
|
|
34
|
+
+ const version = (model) => {
|
|
35
|
+
+ const match = /^claude-sonnet-(\d{1,3})(?:-(\d{1,3}))?(?=-|$)/.exec(model.id);
|
|
36
|
+
+ return match ? Number(match[1]) * 1000 + Number(match[2] ?? 0) : 0;
|
|
37
|
+
+ };
|
|
38
|
+
+ const light = [...(family?.test(sessionModel.id) ? [sessionModel] : []), ...preferred];
|
|
39
|
+
+ const sonnets = subscription ? chat.filter((model) => model.provider === sessionModel.provider && family?.test(model.id)).sort((left, right) => version(right) - version(left)) : [];
|
|
40
|
+
+ const candidates = subscription ? [...sonnets.slice(0, 1), sessionModel, ...sonnets.slice(1), ...available] : [...light, sessionModel];
|
|
41
|
+
+ // A subscription candidate with a recorded summary block yields to the next one; if every fitting
|
|
42
|
+
+ // candidate is blocked, the first block is the reported cause.
|
|
43
|
+
+ let blocked;
|
|
44
|
+
for (const candidate of candidates) {
|
|
45
|
+
- if (candidate.provider === sessionModel.provider && usable(candidate) && fits(candidate))
|
|
46
|
+
+ if (candidate.provider === sessionModel.provider && usable(candidate) && fits(candidate, creditFreeWindow(candidate))) {
|
|
47
|
+
+ if (subscription && summaryUnavailable(this.sessionManager, candidate)) {
|
|
48
|
+
+ blocked ??= candidate;
|
|
49
|
+
+ continue;
|
|
50
|
+
+ }
|
|
51
|
+
return admit(candidate);
|
|
52
|
+
+ }
|
|
53
|
+
}
|
|
54
|
+
+ if (blocked)
|
|
55
|
+
+ return admit(blocked);
|
|
56
|
+
return fail(usable(sessionModel) ? "context_budget" : "model_unavailable");
|
|
57
|
+
}
|
|
58
|
+
_logCompactionModelFallback(override, reason, sessionModel) {
|
|
59
|
+
--- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/speculative.js
|
|
60
|
+
+++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/speculative.js
|
|
61
|
+
@@ -252,7 +252,31 @@ export function canStartSummary(context, snapshot) {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
+/**
|
|
66
|
+
+ * Gateway: a summary that a quota or account block stops moves once to the next model the resolver offers.
|
|
67
|
+
+ * The failed request recorded its block, so automatic selection skips that model; an explicit model, a model
|
|
68
|
+
+ * already tried and every other failure end the run. When every candidate is blocked the first error is thrown.
|
|
69
|
+
+ */
|
|
70
|
+
export async function runExtensionCompaction(context, snapshot, signal, onProgress) {
|
|
71
|
+
+ const tried = new Set();
|
|
72
|
+
+ let first;
|
|
73
|
+
+ for (;;) {
|
|
74
|
+
+ let selectedModel;
|
|
75
|
+
+ try { ({ selectedModel } = selectSummaryModel(context, snapshot)); }
|
|
76
|
+
+ catch (error) { throw first ?? error; }
|
|
77
|
+
+ const key = selectedModel.provider + "/" + selectedModel.id;
|
|
78
|
+
+ if (tried.has(key))
|
|
79
|
+
+ throw first;
|
|
80
|
+
+ tried.add(key);
|
|
81
|
+
+ try { return await runExtensionCompactionOnce(context, snapshot, signal, onProgress); }
|
|
82
|
+
+ catch (error) {
|
|
83
|
+
+ if (signal?.aborted || (error?.failure?.kind !== "quota" && error?.failure?.kind !== "account_blocked"))
|
|
84
|
+
+ throw error;
|
|
85
|
+
+ first ??= error;
|
|
86
|
+
+ }
|
|
87
|
+
+ }
|
|
88
|
+
+}
|
|
89
|
+
+async function runExtensionCompactionOnce(context, snapshot, signal, onProgress) {
|
|
90
|
+
if (signal?.aborted)
|
|
91
|
+
return undefined;
|
|
92
|
+
const { prompt, messages, inputTokens, selectedModel } = selectSummaryModel(context, snapshot);
|