typeclaw 0.1.5 → 0.1.6
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.md +14 -12
- package/auth.schema.json +41 -0
- package/cron.schema.json +8 -0
- package/package.json +1 -1
- package/secrets.schema.json +41 -0
- package/src/agent/auth.ts +45 -22
- package/src/agent/index.ts +189 -19
- package/src/agent/multimodal/index.ts +12 -0
- package/src/agent/multimodal/look-at.ts +185 -0
- package/src/agent/multimodal/looker.ts +145 -0
- package/src/agent/plugin-tools.ts +30 -1
- package/src/agent/session-origin.ts +194 -46
- package/src/agent/subagents.ts +57 -1
- package/src/agent/system-prompt.ts +1 -1
- package/src/agent/tool-result-budget.ts +121 -0
- package/src/bundled-plugins/backup/index.ts +23 -8
- package/src/bundled-plugins/backup/runner.ts +22 -0
- package/src/bundled-plugins/memory/README.md +7 -4
- package/src/bundled-plugins/memory/append-tool.ts +87 -61
- package/src/bundled-plugins/memory/dreaming.ts +23 -9
- package/src/bundled-plugins/memory/find-entry-tool.ts +62 -0
- package/src/bundled-plugins/memory/fragment-parser.ts +19 -44
- package/src/bundled-plugins/memory/index.ts +91 -8
- package/src/bundled-plugins/memory/load-memory.ts +74 -34
- package/src/bundled-plugins/memory/memory-logger.ts +72 -29
- package/src/bundled-plugins/memory/migration.ts +276 -0
- package/src/bundled-plugins/memory/stream-events.ts +55 -0
- package/src/bundled-plugins/memory/stream-io.ts +63 -0
- package/src/bundled-plugins/memory/watermark.ts +48 -8
- package/src/bundled-plugins/security/index.ts +103 -10
- package/src/bundled-plugins/security/permissions.ts +12 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +51 -18
- package/src/bundled-plugins/tool-result-cap/README.md +9 -4
- package/src/bundled-plugins/tool-result-cap/cap-jsonl.ts +115 -0
- package/src/bundled-plugins/tool-result-cap/cap-result.ts +25 -13
- package/src/bundled-plugins/tool-result-cap/index.ts +16 -2
- package/src/channels/adapters/discord-bot-classify.ts +2 -6
- package/src/channels/adapters/discord-bot.ts +4 -45
- package/src/channels/adapters/kakaotalk-classify.ts +3 -7
- package/src/channels/adapters/kakaotalk.ts +28 -47
- package/src/channels/adapters/slack-bot-classify.ts +2 -6
- package/src/channels/adapters/slack-bot.ts +4 -50
- package/src/channels/adapters/telegram-bot-classify.ts +8 -10
- package/src/channels/adapters/telegram-bot.ts +3 -16
- package/src/channels/index.ts +3 -2
- package/src/channels/manager.ts +15 -1
- package/src/channels/persistence.ts +44 -10
- package/src/channels/router.ts +228 -19
- package/src/channels/schema.ts +6 -156
- package/src/cli/channel.ts +200 -4
- package/src/cli/compose-usage.ts +182 -0
- package/src/cli/compose.ts +33 -0
- package/src/cli/hostd.ts +49 -1
- package/src/cli/index.ts +4 -0
- package/src/cli/init.ts +799 -319
- package/src/cli/model.ts +244 -0
- package/src/cli/provider.ts +404 -0
- package/src/cli/reload.ts +6 -1
- package/src/cli/role.ts +156 -0
- package/src/cli/run.ts +3 -1
- package/src/cli/tui.ts +8 -1
- package/src/cli/usage-args.ts +47 -0
- package/src/cli/usage.ts +97 -0
- package/src/compose/index.ts +1 -0
- package/src/compose/usage.ts +65 -0
- package/src/config/config.ts +385 -12
- package/src/config/index.ts +7 -0
- package/src/config/models-mutation.ts +200 -0
- package/src/config/providers-mutation.ts +250 -0
- package/src/config/providers.ts +141 -2
- package/src/config/reloadable.ts +15 -4
- package/src/container/index.ts +5 -0
- package/src/container/require-running.ts +33 -0
- package/src/container/start.ts +39 -58
- package/src/cron/consumer.ts +22 -2
- package/src/cron/index.ts +45 -4
- package/src/cron/schema.ts +104 -0
- package/src/doctor/checks.ts +50 -33
- package/src/git/system-commit.ts +103 -0
- package/src/hostd/daemon.ts +16 -0
- package/src/hostd/kakao-renewal-manager.ts +223 -0
- package/src/hostd/paths.ts +7 -0
- package/src/init/dockerfile.ts +32 -6
- package/src/init/index.ts +183 -62
- package/src/init/kakaotalk-auth.ts +18 -1
- package/src/init/models-dev.ts +26 -1
- package/src/init/run-owner-claim.ts +77 -0
- package/src/permissions/builtins.ts +70 -0
- package/src/permissions/grant.ts +99 -0
- package/src/permissions/index.ts +29 -0
- package/src/permissions/match-rule.ts +305 -0
- package/src/permissions/permissions.ts +196 -0
- package/src/permissions/resolve.ts +80 -0
- package/src/permissions/schema.ts +79 -0
- package/src/plugin/context.ts +8 -4
- package/src/plugin/define.ts +2 -0
- package/src/plugin/index.ts +2 -0
- package/src/plugin/manager.ts +41 -0
- package/src/plugin/registry.ts +9 -0
- package/src/plugin/types.ts +35 -1
- package/src/role-claim/client.ts +182 -0
- package/src/role-claim/code.ts +53 -0
- package/src/role-claim/controller.ts +194 -0
- package/src/role-claim/index.ts +19 -0
- package/src/role-claim/match-rule.ts +43 -0
- package/src/role-claim/pending.ts +100 -0
- package/src/run/channel-session-factory.ts +76 -5
- package/src/run/index.ts +55 -6
- package/src/secrets/encryption.ts +116 -0
- package/src/secrets/kakao-renewal.ts +248 -0
- package/src/secrets/kakao-store.ts +66 -7
- package/src/secrets/keys.ts +173 -0
- package/src/secrets/schema.ts +23 -0
- package/src/secrets/storage.ts +68 -0
- package/src/server/index.ts +122 -11
- package/src/shared/index.ts +4 -0
- package/src/shared/protocol.ts +27 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
- package/src/skills/typeclaw-config/SKILL.md +38 -64
- package/src/skills/typeclaw-memory/SKILL.md +1 -1
- package/src/skills/typeclaw-permissions/SKILL.md +166 -0
- package/src/stream/types.ts +7 -1
- package/src/usage/aggregate.ts +117 -0
- package/src/usage/format.ts +30 -0
- package/src/usage/index.ts +68 -0
- package/src/usage/report.ts +354 -0
- package/src/usage/scan.ts +186 -0
- package/typeclaw.schema.json +57 -45
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: typeclaw-config
|
|
3
|
-
description: "Read or edit typeclaw.json: model, port, mounts, plugins, channels (
|
|
3
|
+
description: "Read or edit typeclaw.json: model, port, mounts, plugins, channels (per-adapter engagement and history; access control lives in roles — see typeclaw-permissions), portForward (auto port forwarding policy), docker.file (tmux/gh/python/ffmpeg toggles + append), git.ignore.append. Also: any question about a default value or whether a behavior is already on by default — port forwarding, channel visibility, model choice, container packages (tmux/gh/python on by default; ffmpeg off), anything ending in 'by default', 'automatically', 'out of the box', 'do I need to configure', 'is X on', 'what does X default to', '기본값', '기본적으로', '자동으로', '디폴트'. MUST load before saying you do not know what X defaults to, or proposing to add a field whose default the user is asking about — most fields already default to the behavior the user expects (portForward defaults to forwarding every container LISTEN; tmux/gh/python are pre-installed in the container; no edit needed). Read it before touching typeclaw.json — strict schema, mix of live-reloadable and restart-required fields."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# typeclaw-config
|
|
@@ -18,7 +18,7 @@ The runtime reads `typeclaw.json` at container startup. Some fields are picked u
|
|
|
18
18
|
- `mounts` — additional host directories the user has chosen to expose to you. Each entry produces a `docker run -v <hostPath>:/agent/mounts/<name>` flag at `typeclaw start` time, so the directory shows up at `mounts/<name>` inside your agent folder. **The launcher reads this; the running container does not.** Editing `mounts` only takes effect on the next `typeclaw start`. **Restart-required.**
|
|
19
19
|
- `plugins` — array of plugin package names loaded at server boot. **Restart-required.**
|
|
20
20
|
- `alias` — additional names the agent answers to when a channel message contains its name in plain text (no `<@id>` mention). The agent folder's directory name (`basename(agentDir)`) is always implicit; `alias` adds further forms (Latin transliteration, nicknames, Korean particles, etc.). Used by the channel engagement layer alongside the structural mention/reply/dm triggers. **Live-reloadable.**
|
|
21
|
-
- `channels` — per-adapter
|
|
21
|
+
- `channels` — per-adapter engagement triggers and history-prefetch knobs for external messengers (Discord, Slack, Telegram, KakaoTalk). Access control lives in `roles`, not here. **Live-reloadable** — edits take effect on the next `reload` without a container restart.
|
|
22
22
|
- `docker.file` — controls what ships in the autogenerated container image. Two layers: (1) **toggles** for opinionated apt packages (`tmux`, `gh`, `python` default `true`; `ffmpeg` defaults `false`) — set the toggle to `false` to omit, or to a version string like `"2.40.0"` to apt-pin (`python` is boolean-only). (2) **`append`** — extra Dockerfile lines spliced in right before `ENTRYPOINT` for anything the toggles don't cover. The whole Dockerfile is rewritten on every `start` from the typeclaw template. Lives under the `docker` namespace alongside future Docker-related blocks (e.g. `docker.compose`). **Restart-required** (next `typeclaw start` rebuilds the image).
|
|
23
23
|
- `git.ignore.append` — extra `.gitignore` patterns `typeclaw start` splices into the TypeClaw-owned `.gitignore` before the protected TypeClaw rules. The whole `.gitignore` is rewritten and auto-committed on every `start` when it changes; `append` is the supported escape hatch for local ignore patterns without editing the managed file by hand. Lives under the `git` namespace. **Restart-required** (next `typeclaw start` refreshes and commits `.gitignore`).
|
|
24
24
|
- `portForward` — allow/deny policy for the auto port-forwarder (the host-stage `_hostd` daemon's portbroker). When the agent runs a server inside the container that LISTENs on a TCP port, the broker proxies it to the same port number on `127.0.0.1` of the host so the user can hit it directly. `portForward` decides which ports are allowed through. **Restart-required** — the broker captures the policy at register time on `typeclaw start`.
|
|
@@ -47,14 +47,14 @@ You yourself cannot run `typeclaw restart` — that is a host-stage command and
|
|
|
47
47
|
| `mounts` | no | array of objects | Host directories bind-mounted into your container. Defaults to `[]` (no host paths exposed). Omitted from scaffolded `typeclaw.json` — add it only when the user wants host paths exposed. See **Mounts** section below. **Restart-required.** |
|
|
48
48
|
| `plugins` | no | array of strings | Plugin package names loaded at server boot. Defaults to `[]`. **Restart-required.** Plugin-owned config blocks live alongside as additional top-level keys; see **Plugin config blocks**. |
|
|
49
49
|
| `alias` | no | array of strings | Additional names the agent answers to in channel engagement, on top of the implicit `basename(agentDir)`. Each entry is a non-empty trimmed string matched case-insensitively as a substring of the inbound text. Defaults to `[]`. Hatching populates this with the agent's chosen name. See **Alias** section below. **Live-reloadable.** |
|
|
50
|
-
| `channels` | no | object | Per-adapter
|
|
50
|
+
| `channels` | no | object | Per-adapter engagement triggers and history-prefetch knobs for external messengers. Defaults to `{}` (no adapters configured). `typeclaw init` scaffolds an empty block per requested adapter (e.g. `"discord-bot": {}`) and the schema fills in defaults. Channel access control lives in `roles` — see the `typeclaw-permissions` skill. **Live-reloadable.** See **Channels** section below. |
|
|
51
51
|
| `portForward` | no | object | Allow/deny policy for the host-stage portbroker that auto-forwards container LISTEN ports to `127.0.0.1` on the host. Defaults to `{ "allow": "*" }` (forward everything). Omitted from scaffolded `typeclaw.json`. **Restart-required.** See **portForward** section below. |
|
|
52
52
|
| `docker` | no | object | Namespace for Docker-related blocks. Today the only child is `docker.file` — toggles (`tmux`, `gh`, `python`, `ffmpeg`) gate opinionated apt packages; `append` adds custom Dockerfile lines just before `ENTRYPOINT`. `docker.file` defaults to `{ ffmpeg: false, gh: true, python: true, tmux: true, append: [] }`. Omitted from scaffolded `typeclaw.json`. **Restart-required** (next `typeclaw start` rebuilds the image). See **Dockerfile** section below. |
|
|
53
53
|
| `git` | no | object | Namespace for git-related blocks. Today the only child is `git.ignore` — extra patterns spliced into the autogenerated `.gitignore` before TypeClaw's protected rules. `git.ignore` defaults to `{ "append": [] }`. Omitted from scaffolded `typeclaw.json`. **Restart-required** (next `typeclaw start` refreshes `.gitignore`). See **Gitignore** section below. |
|
|
54
54
|
|
|
55
55
|
> **Top-level keys not in this table are not "ignored unknowns" anymore** — they are reserved for **plugin config blocks**. The schema's `catchall(z.unknown())` preserves them, and the plugin loader hands each block to its owning plugin's `configSchema` for validation. The bundled memory plugin owns `memory` at the top level — see the `typeclaw-memory` skill for that block's semantics. Do not write a top-level key unless you know which plugin owns it.
|
|
56
56
|
|
|
57
|
-
Within the well-known ten (`$schema`, `port`, `
|
|
57
|
+
Within the well-known ten (`$schema`, `port`, `models`, `mounts`, `plugins`, `alias`, `channels`, `portForward`, `docker`, `git`), **fields the schema doesn't predeclare are silently dropped**. Legacy top-level `dockerfile` and `gitignore` keys are migrated to `docker.file` / `git.ignore` automatically the first time the CLI loads the file — see **Legacy migration** below. Do not invent runtime fields like `provider`, `apiKey`, `temperature`, `maxTokens`, `systemPrompt`, `tools`, `timeout`, etc. — those are not plugin blocks, they are imaginary. If the user asks for one, say it is not yet supported and (if it makes sense) suggest they file a request.
|
|
58
58
|
|
|
59
59
|
A scaffolded `typeclaw.json` looks like:
|
|
60
60
|
|
|
@@ -71,11 +71,11 @@ If the user said yes to "Wire a Discord bot?" during `typeclaw init`, the scaffo
|
|
|
71
71
|
|
|
72
72
|
```json
|
|
73
73
|
"channels": {
|
|
74
|
-
"discord-bot": {
|
|
74
|
+
"discord-bot": {}
|
|
75
75
|
}
|
|
76
76
|
```
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
The empty block declares the adapter to the runtime — the schema fills in `enabled: true`, default engagement triggers, and history prefetch. **Access control is separate**: by default an adapter with no `roles` block matching the speaker resolves to `guest` and the router drops every inbound. To let conversations through, declare a `roles` block (see the `typeclaw-permissions` skill). For example, `"roles": { "member": { "match": ["discord:<guild>"] } }` lets every member of that guild reach the agent on the `channel.respond` permission.
|
|
79
79
|
|
|
80
80
|
## Mounts
|
|
81
81
|
|
|
@@ -128,39 +128,21 @@ The `mounts/` directory itself is **gitignored** in your agent folder. The mount
|
|
|
128
128
|
|
|
129
129
|
## Channels
|
|
130
130
|
|
|
131
|
-
`channels` configures which external messenger
|
|
131
|
+
`channels` configures which external messenger adapters are enabled and how the engagement layer should behave on each. **Access control lives in `roles`, not here** — to admit a chat, declare a role match-rule that covers it (see `typeclaw-permissions`). The shape is `channels: { "<adapter-id>": { engagement, history, enabled } }`. Today the adapters are `discord-bot`, `slack-bot`, `telegram-bot`, and `kakaotalk`.
|
|
132
132
|
|
|
133
|
-
The channels block is **live-reloadable** — edits take effect on the next `reload`, no container restart.
|
|
133
|
+
The channels block is **live-reloadable** — edits take effect on the next `reload`, no container restart.
|
|
134
134
|
|
|
135
135
|
### Adapter block
|
|
136
136
|
|
|
137
137
|
Each entry in `channels` is keyed by adapter id and has this shape:
|
|
138
138
|
|
|
139
|
-
| Field | Required | Type
|
|
140
|
-
| ------------ | -------- |
|
|
141
|
-
| `
|
|
142
|
-
| `
|
|
143
|
-
| `enabled` | no | boolean
|
|
139
|
+
| Field | Required | Type | Notes |
|
|
140
|
+
| ------------ | -------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
141
|
+
| `engagement` | no | object | When the agent should auto-reply vs. stay silent. Defaults to mention/reply/dm with 5-minute reply stickiness. See **Engagement** below. |
|
|
142
|
+
| `history` | no | object | Cold-start prefetch windows for `(thread.head, thread.tail, channel.tail)`. Set any to `0` to disable that side. Defaults to `{ thread: { head: 3, tail: 10 }, channel: { tail: 10 } }`. |
|
|
143
|
+
| `enabled` | no | boolean | Defaults to `true`. Set `false` to disable the adapter entirely without removing its config. |
|
|
144
144
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
### Allow rules
|
|
148
|
-
|
|
149
|
-
Each entry in `allow` is a string matching one of the patterns below. Workspace `@dm` is the literal placeholder for direct messages (Discord doesn't expose a guild id for DMs).
|
|
150
|
-
|
|
151
|
-
| Rule | Matches |
|
|
152
|
-
| ---------------------- | ---------------------------------------------------------------------- |
|
|
153
|
-
| `*` | Every guild channel **and** every DM (full firehose). |
|
|
154
|
-
| `guild:*` | Every channel in every guild. Does **not** include DMs. |
|
|
155
|
-
| `guild:<id>` | Every channel in guild `<id>`. |
|
|
156
|
-
| `guild:<id>/<channel>` | Channel `<channel>` in guild `<id>` only. |
|
|
157
|
-
| `channel:<id>` | Channel `<id>` in any guild (Discord channel IDs are globally unique). |
|
|
158
|
-
| `dm:*` | Every DM channel. Does **not** include guild channels. |
|
|
159
|
-
| `dm:<id>` | DM channel `<id>` only. |
|
|
160
|
-
|
|
161
|
-
The schema validates each rule string at load. **Bad rule = config load fails**, the runtime refuses to boot or refuses the reload. Don't fudge this; the regex is strict (numeric IDs only).
|
|
162
|
-
|
|
163
|
-
`channel:<id>` is the most surgical option and the right default when the user says "let me talk to you in #the-channel-i'm-pointing-at" — channel IDs are globally unique so you don't need the guild id. `guild:<id>` is convenient on a server you fully trust. `*` and `guild:*` are firehose patterns; only set them in single-user setups.
|
|
145
|
+
To stop the agent answering in a specific channel, narrow the `roles` block so the speaking author's role no longer carries `channel.respond` — engagement triggers gate wake-up _given_ the message is admitted; `channel.respond` gates whether the message is admitted at all.
|
|
164
146
|
|
|
165
147
|
### Engagement
|
|
166
148
|
|
|
@@ -181,59 +163,51 @@ The schema validates each rule string at load. **Bad rule = config load fails**,
|
|
|
181
163
|
- `perReply` means: after the agent replies to a user, follow-up messages from that same user in that same channel within the window also wake the loop, even without a mention. The window is bounded server-side (`1` to `86_400_000` ms — 1 ms to 24 hours).
|
|
182
164
|
- `"off"` disables stickiness — the agent only wakes on explicit triggers.
|
|
183
165
|
|
|
184
|
-
There is also a **solo-human fallback** built into the runtime that is **not configurable** through `engagement`: in any
|
|
166
|
+
There is also a **solo-human fallback** built into the runtime that is **not configurable** through `engagement`: in any channel where the participants cache currently holds at most one distinct human author, every admitted inbound wakes the loop, regardless of `trigger` or `stickiness`. The fallback turns off the moment a second distinct human posts in that channel. This makes "private dev channel with one human and the bot" work without forcing an `@mention` on every message; clearing `trigger` to `[]` does **not** override it.
|
|
185
167
|
|
|
186
|
-
**Engagement does not gate
|
|
187
|
-
|
|
188
|
-
To make the agent silent in a channel without removing it from `allow`, the right move is usually `engagement: { trigger: [], stickiness: "off" }`, **not** removing the allow rule — removing the allow rule cuts off both inbound visibility and outbound posting, which is rarely what the user means by "stop replying". Caveat: in a one-human channel the solo-human fallback overrides `trigger: []` and the agent will still wake; the only way to silence the bot in that case is to remove the allow rule (or add a second human to the channel).
|
|
168
|
+
**Engagement does not gate access.** Access is gated by `permissions.has(origin, 'channel.respond')` — see the `typeclaw-permissions` skill. Engagement decides whether an _admitted_ inbound wakes the loop or sits in the context buffer.
|
|
189
169
|
|
|
190
170
|
### Example
|
|
191
171
|
|
|
192
172
|
```json
|
|
193
173
|
"channels": {
|
|
194
174
|
"discord-bot": {
|
|
195
|
-
"allow": [
|
|
196
|
-
"guild:123456789012345678/987654321098765432",
|
|
197
|
-
"dm:*"
|
|
198
|
-
],
|
|
199
175
|
"engagement": {
|
|
200
176
|
"trigger": ["mention", "reply", "dm"],
|
|
201
177
|
"stickiness": { "perReply": { "window": 300000 } }
|
|
202
178
|
},
|
|
203
179
|
"enabled": true
|
|
204
180
|
}
|
|
181
|
+
},
|
|
182
|
+
"roles": {
|
|
183
|
+
"member": { "match": ["discord:123456789012345678/987654321098765432", "discord:dm/*"] }
|
|
205
184
|
}
|
|
206
185
|
```
|
|
207
186
|
|
|
208
|
-
This says:
|
|
187
|
+
This says: the `discord-bot` adapter is enabled with default engagement; one specific channel in one specific guild plus all DMs admit speakers as `member` (which carries `channel.respond` by default).
|
|
209
188
|
|
|
210
189
|
### When the user asks "let me talk to you in this channel"
|
|
211
190
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
- Guild channel they fully trust: `guild:<guildId>/<channelId>` is most explicit; `channel:<channelId>` is shorter and equivalent.
|
|
218
|
-
- Whole guild: `guild:<guildId>` — confirm explicitly that they want every channel.
|
|
219
|
-
5. **Append to `allow`.** Preserve existing entries.
|
|
220
|
-
6. **Write the file back** (pretty-printed, 2-space indent, trailing newline).
|
|
221
|
-
7. **Commit** with a message naming the rule and why (`typeclaw-git` skill).
|
|
222
|
-
8. **Tell the user the effect:** "Added `<rule>` to `channels.discord-bot.allow`. This is live-reloadable — it takes effect on the next `reload`, no restart needed."
|
|
191
|
+
This is a **`roles`** edit, not a `channels` edit. See the `typeclaw-permissions` skill for the full procedure. Short version:
|
|
192
|
+
|
|
193
|
+
1. Get the platform ID (Discord channel ID, Slack channel ID, Telegram chat ID, KakaoTalk chat ID).
|
|
194
|
+
2. Append a match-rule to `roles.member.match` using the canonical DSL (`discord:<guild>/<channel>`, `slack:<team>/<channel>`, `telegram:<chat>`, `kakao:<chat>`).
|
|
195
|
+
3. **`roles` is restart-required** — `typeclaw reload` won't apply it; the user needs `typeclaw restart`.
|
|
223
196
|
|
|
224
197
|
### When the user asks "stop replying in this channel"
|
|
225
198
|
|
|
226
199
|
Two interpretations — ask if unclear:
|
|
227
200
|
|
|
228
|
-
- **"Stop everything"** — remove the
|
|
229
|
-
- **"Just stop auto-replying"** — leave the
|
|
201
|
+
- **"Stop everything"** — remove the match-rule from `roles.<role>.match`. The agent loses both inbound visibility and outbound posting on that channel.
|
|
202
|
+
- **"Just stop auto-replying"** — leave the match-rule, but adjust `engagement` on the adapter (set `trigger: []` and/or `stickiness: "off"`). The agent can still receive the channel and can still post if you tell it to. Caveat: this approach does NOT silence the agent in a channel that currently has only one human posting — the solo-human fallback (see Engagement) overrides `trigger: []`. In that case the only way to go silent today is to remove the match-rule.
|
|
230
203
|
|
|
231
204
|
The second is usually what people mean by "be quieter".
|
|
232
205
|
|
|
233
206
|
### When the user asks "what channels can you see / are you in"
|
|
234
207
|
|
|
235
|
-
1. **Read `typeclaw.json`**, list each adapter under `channels`: which is enabled, the
|
|
236
|
-
2.
|
|
208
|
+
1. **Read `typeclaw.json`**, list each adapter under `channels`: which is enabled, the engagement triggers and stickiness window.
|
|
209
|
+
2. Also read `roles.<role>.match` for every role — those are the actual admit lists.
|
|
210
|
+
3. Note that the live runtime may have a different view if `typeclaw.json` was edited but `reload` hasn't run yet — say so when relevant.
|
|
237
211
|
|
|
238
212
|
## Alias
|
|
239
213
|
|
|
@@ -517,7 +491,7 @@ What this means for you:
|
|
|
517
491
|
|
|
518
492
|
## Plugin config blocks
|
|
519
493
|
|
|
520
|
-
Top-level keys in `typeclaw.json` that are **not** in the well-known ten (`$schema`, `port`, `
|
|
494
|
+
Top-level keys in `typeclaw.json` that are **not** in the well-known ten (`$schema`, `port`, `models`, `mounts`, `plugins`, `alias`, `channels`, `portForward`, `docker`, `git`) are treated as plugin config blocks. The schema preserves them via `catchall(z.unknown())`, and `extractPluginConfigs` hands each block to the owning plugin's `configSchema` for validation at boot.
|
|
521
495
|
|
|
522
496
|
This skill does **not** document individual plugin blocks. For schema, defaults, and reload semantics of a specific plugin's config, defer to that plugin's own skill:
|
|
523
497
|
|
|
@@ -638,9 +612,9 @@ Never echo, log, or commit values from `.env` or `secrets.json`. Both are gitign
|
|
|
638
612
|
- If `model` is set: exactly one of the values in **Allowed models** above
|
|
639
613
|
- If `plugins` is set: array of non-empty strings
|
|
640
614
|
- If `alias` is set: array of strings, each non-empty after trimming surrounding whitespace
|
|
641
|
-
- If `channels.
|
|
642
|
-
- If `channels
|
|
643
|
-
-
|
|
615
|
+
- If `channels.<adapter>.engagement.trigger` is set: array of `"mention"`, `"reply"`, `"dm"` (any subset, including empty)
|
|
616
|
+
- If `channels.<adapter>.engagement.stickiness` is set: either the literal `"off"` or `{ "perReply": { "window": <int 1..86400000> } }`
|
|
617
|
+
- `channels.<adapter>.allow` (legacy) is silently dropped on parse; `migrateLegacyConfigShape` lifts it into `roles.member.match` on load. See the `typeclaw-permissions` skill.
|
|
644
618
|
- If `portForward` is set: `allow` is either `"*"` or an array of integers (1–65535); `deny`, if present, is an array of integers and **only valid when `allow` is `"*"`** (the schema rejects `deny` paired with a number-array `allow`)
|
|
645
619
|
- If `docker.file.append` is set: array of strings, each with no embedded `\n` or `\r` (multi-step shell logic goes in a single `&&`-chained `RUN` entry)
|
|
646
620
|
- If any `docker.file` toggle is set: `tmux`/`gh`/`ffmpeg` are boolean or version string (no whitespace, no `=`); `python` is boolean only
|
|
@@ -657,10 +631,10 @@ Never echo, log, or commit values from `.env` or `secrets.json`. Both are gitign
|
|
|
657
631
|
- **Do not re-add `"mounts": []` "for clarity" if the user has none.** The scaffold deliberately omits it; defaults live in `configSchema`. Re-emitting it adds maintenance noise (the user has to keep two sources of truth in sync) without changing behavior.
|
|
658
632
|
- **Do not promise to write to a `readOnly: true` mount.** Docker enforces it via `:ro`; writes will fail with EROFS. If the user wants you to edit a read-only mount, the fix is to flip `readOnly` to `false` in `typeclaw.json` and restart, not to retry the write.
|
|
659
633
|
- **Do not invent mount entries the user did not request.** Mounts expose host paths to your container; adding them silently is a security surprise.
|
|
660
|
-
- **Do not add
|
|
661
|
-
- **Do not promise the user that
|
|
662
|
-
- **Do not promise to post to a channel
|
|
663
|
-
- **Do not conflate "stop replying" with "remove
|
|
634
|
+
- **Do not add `roles.<role>.match` entries the user did not request, especially `*` or platform-wildcards (`discord:*`, `slack:*`).** Match-rules grant the agent visibility (and, for outbound, posting permission) on real channels with real people in them. Widening them silently is the same class of security surprise as adding a mount. See the `typeclaw-permissions` skill.
|
|
635
|
+
- **Do not promise the user that a `roles` edit took effect immediately just because you wrote the file.** `roles` is **restart-required** — `typeclaw reload` returns it under `restartRequired`; the live runtime keeps the old role table until `typeclaw restart`.
|
|
636
|
+
- **Do not promise to post to a channel the speaker's role does not cover.** The router drops every inbound where the speaking author resolves to a role without `channel.respond`. If the user wants you to post somewhere new, the prerequisite is a `roles` edit + restart, not a retry.
|
|
637
|
+
- **Do not conflate "stop replying" with "remove the role's match-rule".** Removing the match-rule cuts off both inbound visibility and outbound posting. If the user just wants quieter behavior, edit `engagement` instead.
|
|
664
638
|
- **Do not edit the `Dockerfile` directly.** It is autogenerated and rewritten on every `typeclaw start` from `src/init/dockerfile.ts` in the typeclaw repo. Manual edits will be silently overwritten (and auto-committed away if the working tree is dirty). Customizations belong in the `docker.file` block (toggles or `append`).
|
|
665
639
|
- **Do not reach for `docker.file.append` when a toggle covers it.** If the user wants tmux, gh, python, or ffmpeg installed (or removed, or pinned), use the toggle — it's the cache-mounted path. `append` for these is slower and harder to read.
|
|
666
640
|
- **Do not use `docker.file.append` for things that belong in the template.** If the user wants a system package _every_ typeclaw user should have, that's a typeclaw release, not a per-agent `append`. Suggest filing an issue.
|
|
@@ -74,7 +74,7 @@ If the undreamed tails contain only watermarks, or every new fragment is already
|
|
|
74
74
|
|
|
75
75
|
### What gets injected into your prompt every turn
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
Core's `createResourceLoader` appends a `# Memory` section as the LAST block of your system prompt (after `gitNudge`) by calling `loadMemory`. It is pinned to the cache-suffix end so growth in the daily stream invalidates only the memory section itself, not the skills/tools/history above. The section contains:
|
|
78
78
|
|
|
79
79
|
- `MEMORY.md` (truncated to 12 KB; if larger, the rest is dropped with a `[truncated]` marker)
|
|
80
80
|
- The **undreamed tails** of each `memory/yyyy-MM-dd.md`, with bare watermark lines stripped (they are bookkeeping for the memory-logger, no signal for you)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: typeclaw-permissions
|
|
3
|
+
description: Use this skill whenever the user asks who you talk to, why you went silent in a channel, why a tool call was blocked with `blocked:` / "denied by permissions", how to grant access, what a role can or can't do, or whenever you are about to edit the `roles` block in `typeclaw.json`. Triggers include "who can talk to you", "why aren't you replying in #channel", "add me to the agent", "let X talk to you", "grant trusted to Y", "your role", "what permissions do you have", "blocked", "denied by permissions", "owner", "trusted", "member", "guest", "match rule", "channel.respond", "security.bypass", "scheduledByRole", "spawnedByRole", or any mention of `roles` / `roles[*].match` / `roles[*].permissions` in `typeclaw.json`. Read it before editing `roles` — the file has a strict match-rule DSL, restart semantics, and silent failure modes (a missing role match makes you silently drop every inbound), and the agent's own runtime behavior depends on its role and resolved permissions.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# typeclaw-permissions
|
|
7
|
+
|
|
8
|
+
You run under an access-control system that gates which sessions wake you, which tools succeed, and which guards you can bypass. This skill exists so you can answer the user's questions about access honestly, edit `roles` without bricking your own inbound channel, and explain `blocked:` messages in terms of the role/permission model rather than the surface-level guard reason.
|
|
9
|
+
|
|
10
|
+
## The model in one paragraph
|
|
11
|
+
|
|
12
|
+
Every session you run in has a `SessionOrigin` (TUI / channel / cron / subagent). How the runtime resolves it to a **role** depends on the origin kind:
|
|
13
|
+
|
|
14
|
+
- **TUI and channel** sessions resolve by walking the `roles` block in `typeclaw.json` in declaration order and picking the first role whose `match` rules cover the origin. This is the only origin shape that match rules actually grant roles to at runtime.
|
|
15
|
+
- **Cron** sessions resolve from `scheduledByRole`, a string stamped on the cron job record itself (in `cron.json` for hand-authored entries, or by the runtime for plugin-contributed cron). Match rules of the form `cron` parse but never grant a role to a running cron session — provenance wins.
|
|
16
|
+
- **Subagent** sessions resolve from `spawnedByRole`, snapshotted from the spawning session's resolved role at spawn time. Same story: `subagent` / `subagent:<name>` rules parse but don't grant roles at runtime; the spawn provenance is the source of truth.
|
|
17
|
+
|
|
18
|
+
Each role carries a set of **permissions** — opaque dotted strings like `channel.respond`, `cron.schedule`, `security.bypass.gitExfil`. The runtime checks `permissions.has(origin, '<perm>')` at three places: the channel router (gates `channel.respond` before creating a session for an inbound message), the security plugin's `tool.before` hook (gates each `security.bypass.*` so the corresponding guard can be skipped), and plugin code that opts in. There is no other access-control surface — no per-tool ACL, no file-system isolation, no per-author allowlist outside `match` rules.
|
|
19
|
+
|
|
20
|
+
## The four built-in roles
|
|
21
|
+
|
|
22
|
+
You always have these four, even if `typeclaw.json` declares zero `roles`. User-declared roles **append** match rules to the built-ins but **replace** the permission list entirely (so `"permissions": []` on a built-in role means "no permissions" — be careful).
|
|
23
|
+
|
|
24
|
+
| Role | Built-in `match[]` | Default `permissions[]` |
|
|
25
|
+
| --------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
|
26
|
+
| `owner` | `["tui"]` (always prepended) | `channel.respond`, `cron.schedule`, `cron.modify`, **all `security.bypass.*` contributed by plugins** (wildcard sentinel) |
|
|
27
|
+
| `trusted` | none | `channel.respond`, `cron.schedule`, `security.bypass.secretExfilBash` |
|
|
28
|
+
| `member` | none | `channel.respond` |
|
|
29
|
+
| `guest` | none (fallback when nothing else matches, or stamped role is bad) | none |
|
|
30
|
+
|
|
31
|
+
A session that doesn't match anything resolves to `guest`. `guest` has no `channel.respond`, so the router silently drops inbound messages whose author resolves to `guest`. **This is the most common cause of "the agent stopped responding"**: the user added a channel but did not add a match rule, so every speaker in that channel is `guest` and every inbound is dropped before you ever see it. There is no message in your session log when this happens — only a host-side line `[channels] <key>: denied by permissions (channel.respond) author=<id>`.
|
|
32
|
+
|
|
33
|
+
## What your current session sees
|
|
34
|
+
|
|
35
|
+
When the runtime knows your permissions, it prepends a block under your `## Session origin`:
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
## Your role in this session
|
|
39
|
+
|
|
40
|
+
Role: `member`. Permissions: `channel.respond`.
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The block renders for cron / channel / subagent sessions. For TUI sessions, the block is omitted **when** the resolved role is the built-in `owner` (the common case, so we save tokens on every interactive session) and rendered when a user-declared role matched TUI first (because the resolver is first-match-wins in declaration order, a custom role with `match: ["tui"]` placed before `owner` will demote TUI). If you don't see the block in a TUI session, treat yourself as `owner`.
|
|
44
|
+
|
|
45
|
+
**The role line reflects the session at creation time.** For channel sessions, the speaker on subsequent turns may resolve to a different role; the runtime updates that internally for tool gating (the channel router and the security plugin re-resolve on each turn), but the system prompt is not regenerated mid-session. If the user asks "what role am I right now in this channel", read `typeclaw.json` `roles` and match their author id against `match[]` yourself — do not parrot the system-prompt line as if it always applied.
|
|
46
|
+
|
|
47
|
+
**The permission list is exhaustive at session-creation time** for the resolved role. If a permission you expect isn't listed there, the role doesn't carry it — adding it requires editing `roles.<role>.permissions[]` and restarting.
|
|
48
|
+
|
|
49
|
+
## The match-rule DSL
|
|
50
|
+
|
|
51
|
+
`roles.<role>.match[]` is an array of compact strings. The parser is hand-rolled in `src/permissions/match-rule.ts`; the canonical shapes are:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
tui # any TUI session
|
|
55
|
+
* # any channel session, any platform
|
|
56
|
+
<platform>:* # any chat on this platform (slack | discord | telegram | kakao)
|
|
57
|
+
<platform>:<workspace> # one workspace, any chat
|
|
58
|
+
<platform>:<workspace>/<chat> # one specific chat
|
|
59
|
+
<platform>:dm/* # any DM on this platform
|
|
60
|
+
kakao:group/* # any KakaoTalk group chat
|
|
61
|
+
kakao:open/* # any KakaoTalk open chat
|
|
62
|
+
<rule> author:<authorId> # AND-tighten any of the above to one author
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`cron`, `subagent`, and `subagent:<name>` are also valid parser shapes (they parse without error), but they do **not** grant a role to a running cron or subagent session — those resolve from stamped provenance (`scheduledByRole` / `spawnedByRole`) instead. Don't write those rules expecting them to admit traffic the way channel rules do.
|
|
66
|
+
|
|
67
|
+
Within a single string, tokens are **AND**'d. Across multiple strings in `match[]`, they're **OR**'d. The platform names are exactly `slack | discord | telegram | kakao`. Workspace and chat coordinates are platform-native IDs (Slack team `T0123`, Discord guild `123456789012345678`, Telegram chat `42`, KakaoTalk chat hash) — **never** display names. If the user gives you a name, you need to resolve it to an ID before writing the match rule.
|
|
68
|
+
|
|
69
|
+
Things the DSL rejects (the parser emits actionable errors at boot, but you should not write these in the first place):
|
|
70
|
+
|
|
71
|
+
- `slack:*/*` — `*/*` is redundant; use `slack:*` for "any Slack chat".
|
|
72
|
+
- `slack:*/C0ABCDE` — workspace-less chat ID is impossible; pick a workspace.
|
|
73
|
+
- `slack:T0123/*` — workspace-only is enough; drop the trailing `/*`.
|
|
74
|
+
- `team:T0123`, `guild:G123`, `tg:42` — these are legacy prefixes from the old `channels.<adapter>.allow[]` field. They are auto-migrated on load but **don't write them in new code** — use `slack:T0123`, `discord:G123`, `telegram:42` directly.
|
|
75
|
+
- `autor:U_ME` — typo of `author:`. The parser will suggest the fix at boot.
|
|
76
|
+
|
|
77
|
+
## Permission strings you will see
|
|
78
|
+
|
|
79
|
+
Three sources contribute permission strings:
|
|
80
|
+
|
|
81
|
+
1. **Core** (always present): `channel.respond`, `cron.schedule`, `cron.modify`.
|
|
82
|
+
2. **Bundled security plugin** (always loaded): `security.bypass.secretExfilBash`, `security.bypass.gitExfil`, `security.bypass.gitRemoteTainted`, `security.bypass.secretExfilRead`, `security.bypass.ssrf`, `security.bypass.sessionSearchSecrets`, `security.bypass.systemPromptLeak`, `security.bypass.outboundSecret`.
|
|
83
|
+
3. **User-declared plugins** (variable): each plugin can contribute its own strings via `definePlugin({ permissions: [...] })`.
|
|
84
|
+
|
|
85
|
+
`owner` carries every `security.bypass.*` from sources 2 and 3 by default (via a wildcard sentinel expanded at boot). `trusted` carries only `security.bypass.secretExfilBash` by default. `member` and `guest` carry no `security.bypass.*` strings.
|
|
86
|
+
|
|
87
|
+
User-declared `permissions[]` strings that don't appear in any of the three sources are **logged as warnings at boot** (`[permissions] role "X" declares unknown permission "Y" — did you mean 'Z'?`) but the role still resolves with the unknown string in its list. This is intentional — the runtime is forward-compatible with strings from plugins that aren't loaded yet — but it also means typos silently fail to bypass guards. If you wrote `security.bypass.secretExfilBach` instead of `Bash`, no guard will be skipped and you will only notice when you read the boot logs.
|
|
88
|
+
|
|
89
|
+
## When a tool is blocked
|
|
90
|
+
|
|
91
|
+
The security plugin's `tool.before` hook produces block messages of the form:
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
Guard `<guardName>` blocked <what>. If this is genuinely intentional and the user
|
|
95
|
+
explicitly asked for it, retry with `acknowledgeGuards.<guardName>: true` in the
|
|
96
|
+
<tool> arguments. Or run as a role carrying `<permission>` (owner has all
|
|
97
|
+
security.bypass.*; trusted has security.bypass.secretExfilBash).
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Three escape hatches, ordered from least to most invasive:
|
|
101
|
+
|
|
102
|
+
1. **`acknowledgeGuards.<guardName>: true`** in the tool args. This is a per-call, in-session bypass. Use it when the user has just explicitly told you to run the dangerous thing (e.g. "yes, push the secret to a private gist on purpose"). Never use it without explicit user confirmation — the guard exists for a reason.
|
|
103
|
+
2. **Run as a role with the bypass permission**. If the user wants this pattern to keep working without an ack every time, they edit `roles.<role>.permissions[]` to include the `security.bypass.<X>` string the block message named. This is the right answer for "I'm `trusted` in this Slack channel and I want to be able to run dangerous bash without confirming each time" — give `trusted` the relevant `security.bypass.*` permission.
|
|
104
|
+
3. **Run from a session that already resolves to a role with the bypass**. The TUI is always `owner`, so a guard that blocks in channel sessions for a `member` author will not block at all from the TUI. This is why "the agent can do X in TUI but not in Slack" is normal, not a bug.
|
|
105
|
+
|
|
106
|
+
When you see a block, tell the user **which permission would skip it** (the block message now names it) and **which built-in roles have that permission**. Do not just relay the guard reason — that loses the access-control framing entirely.
|
|
107
|
+
|
|
108
|
+
## When the user asks "why aren't you replying in #channel?"
|
|
109
|
+
|
|
110
|
+
Probable causes, in descending order of frequency:
|
|
111
|
+
|
|
112
|
+
1. **No match rule covers the speaking author's coordinates.** Read `typeclaw.json` `roles`, compare every `match[]` entry to the channel ID and author ID the user is reporting. If nothing matches, the author resolves to `guest`, which has no `channel.respond`, so every inbound is dropped at the router. The fix is to append a match rule to `roles.<role>.match[]` for that channel (or DM bucket).
|
|
113
|
+
2. **The match rule exists but the role has `permissions: []`** (or otherwise lacks `channel.respond`). A user-declared role replaces the built-in's permissions wholesale. Re-add `channel.respond` or use a built-in role name (`member`, `trusted`, `owner`) that carries it by default.
|
|
114
|
+
3. **Engagement triggers are filtering admitted messages.** This is a different problem — the inbound was admitted by permissions but engagement (`channels.<adapter>.engagement.trigger`) decided not to wake you. See the `typeclaw-config` skill for the engagement model.
|
|
115
|
+
|
|
116
|
+
To distinguish cause 1/2 from cause 3: if `typeclaw logs <container> -f` (host stage) shows `[channels] ... denied by permissions (channel.respond)`, it's a permissions problem. If it shows the message being admitted but no LLM call follows, it's engagement.
|
|
117
|
+
|
|
118
|
+
## When the user asks "let X talk to you in this channel"
|
|
119
|
+
|
|
120
|
+
This is a `roles` edit. The full procedure:
|
|
121
|
+
|
|
122
|
+
1. **Resolve the coordinates.** Get the platform name (`slack | discord | telegram | kakao`), the workspace ID, the chat ID. If the user gave you names, ask them or look them up in the participants list of a previous inbound from that channel.
|
|
123
|
+
2. **Pick a role.** Default to `member` for "give them normal channel access". Use `trusted` if they should also be able to schedule cron and bypass the bash secret guard. Only use `owner` if they should have full bypass on every security guard — typically the agent's primary operator.
|
|
124
|
+
3. **Edit `typeclaw.json` `roles.<role>.match[]`.** Append the canonical DSL string. Example: `roles.member.match` adds `"slack:T0123/C0ABCDE"`. If the user wants only a specific person in that channel, append `slack:T0123/C0ABCDE author:U_ME` instead.
|
|
125
|
+
4. **Restart.** `roles` is **restart-required** — `typeclaw reload` does not re-evaluate role config. Tell the user: "edited `roles.<role>.match` — restart-required. Run `typeclaw restart` (host stage)."
|
|
126
|
+
5. **Commit the change.** See the `typeclaw-git` skill. The decision context in the commit message should name the role, the channel, and the author/scope ("let @X talk to me as `member` in #foo in workspace bar").
|
|
127
|
+
|
|
128
|
+
## When the user asks "stop replying to X"
|
|
129
|
+
|
|
130
|
+
Two interpretations — clarify if ambiguous:
|
|
131
|
+
|
|
132
|
+
- **"Stop everything"** — remove the match rule from `roles.<role>.match[]`. The author resolves to `guest`, and the channel router silently drops every inbound. You lose all visibility into their messages. Restart-required.
|
|
133
|
+
- **"Just stop auto-replying"** — keep the match rule, but narrow `channels.<adapter>.engagement.trigger` and/or `stickiness`. See `typeclaw-config`. The agent still receives the messages and can still post if you tell it to. The solo-human fallback (single human in a channel) overrides `trigger: []`, so this approach can't fully silence you in a 1:1; only removing the match rule does.
|
|
134
|
+
|
|
135
|
+
## When the user asks "what role am I in this session?"
|
|
136
|
+
|
|
137
|
+
Read your `## Session origin` block — the role/permissions line is there for non-TUI sessions. For TUI it's `owner` by definition. If the user is in a channel and asks about themselves, read `typeclaw.json` `roles` and match their `<authorId>` against every `match[]` entry in declaration order; the first hit wins. Do not invent a role they aren't in.
|
|
138
|
+
|
|
139
|
+
## When the user asks about cron / subagent provenance
|
|
140
|
+
|
|
141
|
+
Cron and subagent sessions don't resolve their role by matching their own origin — instead, the role is **stamped at creation**:
|
|
142
|
+
|
|
143
|
+
- **Cron jobs** carry `scheduledByRole` in `cron.json`. The job runs as that role. If `scheduledByRole` is absent on a hand-authored cron entry, **boot fails** with a precise error (there is no implicit fallback). Plugin-contributed cron jobs default to `owner`.
|
|
144
|
+
- **Subagents** carry `spawnedByRole`, snapshotted from the spawning session's resolved role at spawn time. A cron-fired subagent inherits the cron's stamped role.
|
|
145
|
+
|
|
146
|
+
This forecloses the laundering attack — an attacker who only resolves to `guest` can ask you to schedule a cron, but the cron entry will be stamped `scheduledByRole: 'guest'`, and when it fires it will still be `guest` (with no permissions, including no `channel.respond` or `security.bypass.*`).
|
|
147
|
+
|
|
148
|
+
If you see a cron job mysteriously failing every fire with `denied by permissions` in logs, check its `scheduledByRole` — it may have been scheduled by a `guest` session at some point in the past.
|
|
149
|
+
|
|
150
|
+
## Things you must not do
|
|
151
|
+
|
|
152
|
+
- **Do not write `*` in user-declared `permissions[]`.** The owner wildcard is a runtime sentinel, not part of the user-facing string format. The schema rejects `*` (it's not a valid dotted permission string anyway).
|
|
153
|
+
- **Do not invent permission strings.** Only the three sources above (core, security plugin, declared plugins) contribute valid strings. A string like `bash.execute` looks plausible but is not gated by anything and will only earn a boot warning. If the user asks for a permission the model doesn't have, tell them — don't invent one.
|
|
154
|
+
- **Do not promise that `typeclaw reload` applied a `roles` edit.** `roles` is restart-required. The reload tool will return success on the config file change, but the live `PermissionService` was built at boot and is not swapped on reload.
|
|
155
|
+
- **Do not silently change a built-in role's permission list.** Setting `"permissions": []` on `member` is a wholesale replace, not a merge — you just took `channel.respond` away from every speaker who resolves to `member`. If the user said "give member just `channel.respond` and nothing else", that's fine (it's the same as the default), but say so explicitly: "this matches the default for `member`, no behavior change". If the user said "remove cron from `trusted`", make the change but warn that `trusted` no longer carries `cron.schedule` either.
|
|
156
|
+
- **Do not write match rules using display names** (`#general`, `@user`, channel/user names). Match rules are platform IDs. Display names change; IDs don't. Always look up the ID before writing the rule.
|
|
157
|
+
- **Do not edit `roles` to "fix" a security block** without explaining the alternative. The right first move for a guard block is usually `acknowledgeGuards.<X>: true` for the specific call. Editing `roles` to grant a permanent bypass is a heavier change with security implications — get explicit consent.
|
|
158
|
+
- **Do not interpret a missing `## Session origin` role line as "I have no role".** TUI sessions don't render the line because TUI is always `owner`. If you see no role line and you're not in TUI, something has gone wrong with the system prompt build — flag it, don't fabricate.
|
|
159
|
+
|
|
160
|
+
## What this skill does not cover
|
|
161
|
+
|
|
162
|
+
- **The `channels.<adapter>` block** — engagement, history, stickiness, alias. See `typeclaw-config`. Engagement decides whether an _admitted_ inbound wakes the loop; this skill is only about admission.
|
|
163
|
+
- **The full `typeclaw.json` schema** — model, mounts, plugins, docker, git.ignore. See `typeclaw-config`.
|
|
164
|
+
- **Cron job authoring** — schedule syntax, `prompt` vs `exec`, the `reload` tool. See `typeclaw-cron`. This skill only covers the `scheduledByRole` field and its provenance semantics.
|
|
165
|
+
- **Plugin authoring** — `definePlugin`, contributing permissions, custom `tool.before` hooks. See `typeclaw-plugins`. The bundled security plugin is an example of a plugin that contributes `security.bypass.*` strings and uses `permissions.has()` to gate its own guards.
|
|
166
|
+
- **The container vs host stage split** — `typeclaw restart` runs on the host; this skill assumes you know which stage you're in. See `AGENTS.md` for the stage model.
|
package/src/stream/types.ts
CHANGED
|
@@ -3,7 +3,13 @@ export type StreamMessageId = string
|
|
|
3
3
|
export type StreamTarget =
|
|
4
4
|
| { kind: 'broadcast' }
|
|
5
5
|
| { kind: 'session'; sessionId: string }
|
|
6
|
-
| {
|
|
6
|
+
| {
|
|
7
|
+
kind: 'new-session'
|
|
8
|
+
subagent: string
|
|
9
|
+
parentSessionId?: string
|
|
10
|
+
spawnedByRole?: string
|
|
11
|
+
spawnedByOriginJson?: string
|
|
12
|
+
}
|
|
7
13
|
| { kind: 'cron'; jobId: string }
|
|
8
14
|
|
|
9
15
|
export type StreamMessage = {
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { AssistantRow } from './scan'
|
|
2
|
+
|
|
3
|
+
export type UsageTotals = {
|
|
4
|
+
messageCount: number
|
|
5
|
+
input: number
|
|
6
|
+
output: number
|
|
7
|
+
cacheRead: number
|
|
8
|
+
cacheWrite: number
|
|
9
|
+
totalTokens: number
|
|
10
|
+
cost: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type DailyUsage = UsageTotals & { date: string; sessionCount: number }
|
|
14
|
+
export type ModelUsage = UsageTotals & { provider: string; model: string }
|
|
15
|
+
export type SessionUsage = UsageTotals & {
|
|
16
|
+
sessionId: string
|
|
17
|
+
sessionFile: string
|
|
18
|
+
firstAt: number
|
|
19
|
+
lastAt: number
|
|
20
|
+
models: string[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type Aggregation = {
|
|
24
|
+
total: UsageTotals
|
|
25
|
+
byDay: DailyUsage[]
|
|
26
|
+
byModel: ModelUsage[]
|
|
27
|
+
bySession: SessionUsage[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function aggregate(rows: AsyncIterable<AssistantRow>): Promise<Aggregation> {
|
|
31
|
+
const total = emptyTotals()
|
|
32
|
+
const byDay = new Map<string, DailyUsage & { _sessionIds: Set<string> }>()
|
|
33
|
+
const byModel = new Map<string, ModelUsage>()
|
|
34
|
+
const bySession = new Map<string, SessionUsage & { _modelSet: Set<string> }>()
|
|
35
|
+
|
|
36
|
+
for await (const row of rows) {
|
|
37
|
+
addInto(total, row)
|
|
38
|
+
|
|
39
|
+
const date = isoDate(row.timestamp)
|
|
40
|
+
const sessionKey = sessionIdFromBasename(row.sessionBasename)
|
|
41
|
+
const dayBucket = byDay.get(date) ?? {
|
|
42
|
+
...emptyTotals(),
|
|
43
|
+
date,
|
|
44
|
+
sessionCount: 0,
|
|
45
|
+
_sessionIds: new Set<string>(),
|
|
46
|
+
}
|
|
47
|
+
addInto(dayBucket, row)
|
|
48
|
+
dayBucket._sessionIds.add(sessionKey)
|
|
49
|
+
dayBucket.sessionCount = dayBucket._sessionIds.size
|
|
50
|
+
byDay.set(date, dayBucket)
|
|
51
|
+
|
|
52
|
+
const modelKey = `${row.provider}/${row.model}`
|
|
53
|
+
const modelBucket = byModel.get(modelKey) ?? {
|
|
54
|
+
...emptyTotals(),
|
|
55
|
+
provider: row.provider,
|
|
56
|
+
model: row.model,
|
|
57
|
+
}
|
|
58
|
+
addInto(modelBucket, row)
|
|
59
|
+
byModel.set(modelKey, modelBucket)
|
|
60
|
+
|
|
61
|
+
const sessionBucket = bySession.get(sessionKey) ?? {
|
|
62
|
+
...emptyTotals(),
|
|
63
|
+
sessionId: sessionKey,
|
|
64
|
+
sessionFile: row.sessionFile,
|
|
65
|
+
firstAt: row.timestamp,
|
|
66
|
+
lastAt: row.timestamp,
|
|
67
|
+
models: [],
|
|
68
|
+
_modelSet: new Set<string>(),
|
|
69
|
+
}
|
|
70
|
+
addInto(sessionBucket, row)
|
|
71
|
+
sessionBucket.firstAt = Math.min(sessionBucket.firstAt, row.timestamp)
|
|
72
|
+
sessionBucket.lastAt = Math.max(sessionBucket.lastAt, row.timestamp)
|
|
73
|
+
sessionBucket._modelSet.add(modelKey)
|
|
74
|
+
sessionBucket.models = [...sessionBucket._modelSet]
|
|
75
|
+
bySession.set(sessionKey, sessionBucket)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
total,
|
|
80
|
+
byDay: [...byDay.values()].map(({ _sessionIds: _, ...rest }) => rest).sort((a, b) => a.date.localeCompare(b.date)),
|
|
81
|
+
byModel: [...byModel.values()].sort((a, b) => b.cost - a.cost),
|
|
82
|
+
bySession: [...bySession.values()].map(({ _modelSet: _, ...rest }) => rest).sort((a, b) => b.cost - a.cost),
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function emptyTotals(): UsageTotals {
|
|
87
|
+
return { messageCount: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: 0 }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function addInto(target: UsageTotals, row: AssistantRow): void {
|
|
91
|
+
target.messageCount += 1
|
|
92
|
+
target.input += row.input
|
|
93
|
+
target.output += row.output
|
|
94
|
+
target.cacheRead += row.cacheRead
|
|
95
|
+
target.cacheWrite += row.cacheWrite
|
|
96
|
+
target.totalTokens += row.totalTokens
|
|
97
|
+
target.cost += row.cost
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isoDate(ts: number): string {
|
|
101
|
+
// Local tz so "today" matches the user's wall clock; lexicographic order
|
|
102
|
+
// matches chronological order.
|
|
103
|
+
const d = new Date(ts)
|
|
104
|
+
const y = d.getFullYear()
|
|
105
|
+
const m = String(d.getMonth() + 1).padStart(2, '0')
|
|
106
|
+
const day = String(d.getDate()).padStart(2, '0')
|
|
107
|
+
return `${y}-${m}-${day}`
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// File scheme: `${ISO_TIMESTAMP}_${SESSION_UUID}.jsonl` (pi-coding-agent).
|
|
111
|
+
// Take the segment after the last underscore so a future suffix-before-ext
|
|
112
|
+
// change does not silently regroup messages onto the wrong session.
|
|
113
|
+
function sessionIdFromBasename(basename: string): string {
|
|
114
|
+
const stem = basename.endsWith('.jsonl') ? basename.slice(0, -'.jsonl'.length) : basename
|
|
115
|
+
const idx = stem.lastIndexOf('_')
|
|
116
|
+
return idx === -1 ? stem : stem.slice(idx + 1)
|
|
117
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export function formatTokens(n: number): string {
|
|
2
|
+
if (n === 0) return '0'
|
|
3
|
+
const abs = Math.abs(n)
|
|
4
|
+
if (abs < 1_000) return String(Math.round(n))
|
|
5
|
+
if (abs < 1_000_000) return `${(n / 1_000).toFixed(n < 10_000 ? 1 : 0)}k`
|
|
6
|
+
if (abs < 1_000_000_000) return `${(n / 1_000_000).toFixed(n < 10_000_000 ? 1 : 0)}M`
|
|
7
|
+
return `${(n / 1_000_000_000).toFixed(2)}B`
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function formatCost(usd: number): string {
|
|
11
|
+
if (usd === 0) return '$0.00'
|
|
12
|
+
if (usd < 0.01) return `$${usd.toFixed(4)}`
|
|
13
|
+
if (usd < 1) return `$${usd.toFixed(3)}`
|
|
14
|
+
return `$${usd.toFixed(2)}`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function formatCacheHitRate(input: number, cacheRead: number): string {
|
|
18
|
+
const total = input + cacheRead
|
|
19
|
+
if (total <= 0) return '—'
|
|
20
|
+
const pct = Math.round((cacheRead / total) * 100)
|
|
21
|
+
return `${pct}%`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isoDay(ts: number): string {
|
|
25
|
+
const d = new Date(ts)
|
|
26
|
+
const y = d.getFullYear()
|
|
27
|
+
const m = String(d.getMonth() + 1).padStart(2, '0')
|
|
28
|
+
const day = String(d.getDate()).padStart(2, '0')
|
|
29
|
+
return `${y}-${m}-${day}`
|
|
30
|
+
}
|