typeclaw 0.1.4 → 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 +15 -13
- 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 +13 -10
- package/src/bundled-plugins/memory/append-tool.ts +87 -61
- package/src/bundled-plugins/memory/dreaming.ts +137 -7
- 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 +809 -300
- package/src/cli/model.ts +244 -0
- package/src/cli/provider.ts +404 -0
- package/src/cli/reload.ts +11 -3
- package/src/cli/role.ts +156 -0
- package/src/cli/run.ts +3 -1
- package/src/cli/tui.ts +13 -3
- 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 +491 -19
- package/src/config/index.ts +15 -1
- 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 +6 -1
- package/src/container/port.ts +10 -0
- package/src/container/require-running.ts +33 -0
- package/src/container/start.ts +81 -63
- 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 +51 -34
- package/src/doctor/plugin-bridge.ts +28 -4
- 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 +36 -10
- package/src/init/gitignore.ts +1 -1
- package/src/init/index.ts +213 -85
- 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/reload/client.ts +25 -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 +68 -7
- 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 +83 -0
- package/src/server/index.ts +198 -71
- 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 +104 -112
- package/src/skills/typeclaw-memory/SKILL.md +9 -9
- package/src/skills/typeclaw-permissions/SKILL.md +166 -0
- package/src/stream/types.ts +7 -1
- package/src/tui/client.ts +66 -5
- package/src/tui/index.ts +61 -9
- 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 +134 -98
|
@@ -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 = {
|
package/src/tui/client.ts
CHANGED
|
@@ -2,9 +2,19 @@ import type { ClientMessage, ServerMessage } from '@/shared'
|
|
|
2
2
|
|
|
3
3
|
export type Client = Awaited<ReturnType<typeof createClient>>
|
|
4
4
|
|
|
5
|
-
export
|
|
5
|
+
export type CreateClientOptions = {
|
|
6
|
+
timeoutMs?: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 30_000
|
|
10
|
+
|
|
11
|
+
export async function createClient(url: string, { timeoutMs = DEFAULT_CONNECT_TIMEOUT_MS }: CreateClientOptions = {}) {
|
|
6
12
|
const ws = new WebSocket(url)
|
|
13
|
+
const displayUrl = redactUrl(url)
|
|
7
14
|
const listeners = new Set<(msg: ServerMessage) => void>()
|
|
15
|
+
const closeListeners = new Set<() => void>()
|
|
16
|
+
const errorListeners = new Set<(err: unknown) => void>()
|
|
17
|
+
let closed = false
|
|
8
18
|
// Buffer messages that arrive before any listener is registered. In-process
|
|
9
19
|
// connections (typeclaw run's local tui) deliver the first server frame
|
|
10
20
|
// before the caller has a chance to attach onMessage.
|
|
@@ -20,12 +30,43 @@ export async function createClient(url: string) {
|
|
|
20
30
|
})
|
|
21
31
|
|
|
22
32
|
ws.addEventListener('close', () => {
|
|
33
|
+
closed = true
|
|
23
34
|
listeners.clear()
|
|
35
|
+
for (const fn of closeListeners) fn()
|
|
36
|
+
closeListeners.clear()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
ws.addEventListener('error', (err) => {
|
|
40
|
+
for (const fn of errorListeners) fn(err)
|
|
24
41
|
})
|
|
25
42
|
|
|
26
43
|
await new Promise<void>((resolve, reject) => {
|
|
27
|
-
|
|
28
|
-
|
|
44
|
+
const timer = setTimeout(() => {
|
|
45
|
+
cleanup()
|
|
46
|
+
ws.close()
|
|
47
|
+
reject(new Error(`timed out connecting to ${displayUrl} after ${timeoutMs}ms`))
|
|
48
|
+
}, timeoutMs)
|
|
49
|
+
const cleanup = () => {
|
|
50
|
+
clearTimeout(timer)
|
|
51
|
+
ws.removeEventListener('open', onOpen)
|
|
52
|
+
ws.removeEventListener('error', onError)
|
|
53
|
+
ws.removeEventListener('close', onClose)
|
|
54
|
+
}
|
|
55
|
+
const onOpen = () => {
|
|
56
|
+
cleanup()
|
|
57
|
+
resolve()
|
|
58
|
+
}
|
|
59
|
+
const onError = (err: unknown) => {
|
|
60
|
+
cleanup()
|
|
61
|
+
reject(err)
|
|
62
|
+
}
|
|
63
|
+
const onClose = () => {
|
|
64
|
+
cleanup()
|
|
65
|
+
reject(new Error(`connection to ${displayUrl} closed before opening`))
|
|
66
|
+
}
|
|
67
|
+
ws.addEventListener('open', onOpen, { once: true })
|
|
68
|
+
ws.addEventListener('error', onError, { once: true })
|
|
69
|
+
ws.addEventListener('close', onClose, { once: true })
|
|
29
70
|
})
|
|
30
71
|
|
|
31
72
|
return {
|
|
@@ -37,9 +78,29 @@ export async function createClient(url: string) {
|
|
|
37
78
|
}
|
|
38
79
|
return () => listeners.delete(fn)
|
|
39
80
|
},
|
|
40
|
-
onClose: (fn: () => void) =>
|
|
41
|
-
|
|
81
|
+
onClose: (fn: () => void) => {
|
|
82
|
+
if (closed) {
|
|
83
|
+
queueMicrotask(fn)
|
|
84
|
+
return () => {}
|
|
85
|
+
}
|
|
86
|
+
closeListeners.add(fn)
|
|
87
|
+
return () => closeListeners.delete(fn)
|
|
88
|
+
},
|
|
89
|
+
onError: (fn: (err: unknown) => void) => {
|
|
90
|
+
errorListeners.add(fn)
|
|
91
|
+
return () => errorListeners.delete(fn)
|
|
92
|
+
},
|
|
42
93
|
send: (msg: ClientMessage) => ws.send(JSON.stringify(msg)),
|
|
43
94
|
close: () => ws.close(),
|
|
44
95
|
}
|
|
45
96
|
}
|
|
97
|
+
|
|
98
|
+
function redactUrl(url: string): string {
|
|
99
|
+
try {
|
|
100
|
+
const parsed = new URL(url)
|
|
101
|
+
if (parsed.searchParams.has('token')) parsed.searchParams.set('token', '<redacted>')
|
|
102
|
+
return parsed.toString()
|
|
103
|
+
} catch {
|
|
104
|
+
return url
|
|
105
|
+
}
|
|
106
|
+
}
|
package/src/tui/index.ts
CHANGED
|
@@ -7,11 +7,14 @@ import { colors, editorTheme, markdownTheme } from './theme'
|
|
|
7
7
|
export type ClientFactory = (url: string) => Promise<Client>
|
|
8
8
|
export type TerminalFactory = () => Terminal
|
|
9
9
|
|
|
10
|
+
const DEFAULT_HANDSHAKE_TIMEOUT_MS = 30_000
|
|
11
|
+
|
|
10
12
|
export type TuiOptions = {
|
|
11
13
|
url: string
|
|
12
14
|
initialPrompt?: string
|
|
13
15
|
createClient?: ClientFactory
|
|
14
16
|
createTerminal?: TerminalFactory
|
|
17
|
+
handshakeTimeoutMs?: number
|
|
15
18
|
exit?: (code: number) => void
|
|
16
19
|
}
|
|
17
20
|
|
|
@@ -20,13 +23,15 @@ export function createTui({
|
|
|
20
23
|
initialPrompt,
|
|
21
24
|
createClient = createClientDefault,
|
|
22
25
|
createTerminal = () => new ProcessTerminal(),
|
|
26
|
+
handshakeTimeoutMs = DEFAULT_HANDSHAKE_TIMEOUT_MS,
|
|
23
27
|
exit = process.exit.bind(process),
|
|
24
28
|
}: TuiOptions) {
|
|
25
29
|
async function run(): Promise<void> {
|
|
26
30
|
const terminal = createTerminal()
|
|
27
31
|
const tui = new TUI(terminal)
|
|
32
|
+
const displayUrl = redactUrl(url)
|
|
28
33
|
|
|
29
|
-
const status = new Text(colors.dim(`connecting to ${
|
|
34
|
+
const status = new Text(colors.dim(`connecting to ${displayUrl}...`), 0, 0)
|
|
30
35
|
tui.addChild(status)
|
|
31
36
|
tui.start()
|
|
32
37
|
tui.requestRender()
|
|
@@ -39,14 +44,13 @@ export function createTui({
|
|
|
39
44
|
throw err
|
|
40
45
|
})
|
|
41
46
|
|
|
42
|
-
const sessionId = await
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
})
|
|
47
|
+
const sessionId = await waitForConnected(client, displayUrl, handshakeTimeoutMs).catch((err) => {
|
|
48
|
+
status.setText(colors.red(`connection error: ${err instanceof Error ? err.message : String(err)}`))
|
|
49
|
+
tui.requestRender()
|
|
50
|
+
client.close()
|
|
51
|
+
tui.stop()
|
|
52
|
+
exit(1)
|
|
53
|
+
throw err
|
|
50
54
|
})
|
|
51
55
|
status.setText(colors.dim(`session: ${sessionId}`))
|
|
52
56
|
tui.requestRender()
|
|
@@ -223,3 +227,51 @@ export function createTui({
|
|
|
223
227
|
|
|
224
228
|
return { run }
|
|
225
229
|
}
|
|
230
|
+
|
|
231
|
+
function redactUrl(url: string): string {
|
|
232
|
+
try {
|
|
233
|
+
const parsed = new URL(url)
|
|
234
|
+
if (parsed.searchParams.has('token')) parsed.searchParams.set('token', '<redacted>')
|
|
235
|
+
return parsed.toString()
|
|
236
|
+
} catch {
|
|
237
|
+
return url
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function waitForConnected(client: Client, url: string, timeoutMs: number): Promise<string> {
|
|
242
|
+
return await new Promise<string>((resolve, reject) => {
|
|
243
|
+
const timer = setTimeout(() => {
|
|
244
|
+
cleanup()
|
|
245
|
+
reject(new Error(`timed out waiting for connected message from ${url} after ${timeoutMs}ms`))
|
|
246
|
+
}, timeoutMs)
|
|
247
|
+
const cleanupFns: Array<() => void> = []
|
|
248
|
+
const cleanup = () => {
|
|
249
|
+
clearTimeout(timer)
|
|
250
|
+
for (const fn of cleanupFns.splice(0)) fn()
|
|
251
|
+
}
|
|
252
|
+
cleanupFns.push(
|
|
253
|
+
client.onMessage((msg) => {
|
|
254
|
+
if (msg.type === 'connected') {
|
|
255
|
+
cleanup()
|
|
256
|
+
resolve(msg.sessionId)
|
|
257
|
+
}
|
|
258
|
+
if (msg.type === 'error') {
|
|
259
|
+
cleanup()
|
|
260
|
+
reject(new Error(msg.message))
|
|
261
|
+
}
|
|
262
|
+
}),
|
|
263
|
+
)
|
|
264
|
+
cleanupFns.push(
|
|
265
|
+
client.onClose(() => {
|
|
266
|
+
cleanup()
|
|
267
|
+
reject(new Error(`connection to ${url} closed before the session was ready`))
|
|
268
|
+
}),
|
|
269
|
+
)
|
|
270
|
+
cleanupFns.push(
|
|
271
|
+
client.onError((err) => {
|
|
272
|
+
cleanup()
|
|
273
|
+
reject(err instanceof Error ? err : new Error(`connection to ${url} failed`))
|
|
274
|
+
}),
|
|
275
|
+
)
|
|
276
|
+
})
|
|
277
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
|
|
3
|
+
import type { Aggregation } from './aggregate'
|
|
4
|
+
import { aggregate } from './aggregate'
|
|
5
|
+
import { scanAssistantRows } from './scan'
|
|
6
|
+
|
|
7
|
+
export type { Aggregation, DailyUsage, ModelUsage, SessionUsage, UsageTotals } from './aggregate'
|
|
8
|
+
export type { AssistantRow } from './scan'
|
|
9
|
+
|
|
10
|
+
export type UsageReport = {
|
|
11
|
+
generatedAt: number
|
|
12
|
+
agentDir: string
|
|
13
|
+
range: { since: number | null; until: number | null }
|
|
14
|
+
// The process timezone used by the date helpers (startOfToday,
|
|
15
|
+
// startOfDaysAgo) and by per-day grouping. Container processes default to
|
|
16
|
+
// UTC; host CLI uses the user's local TZ. Surfaced explicitly so consumers
|
|
17
|
+
// (humans, --json, downstream tooling) can interpret "today" unambiguously.
|
|
18
|
+
timezone: string
|
|
19
|
+
aggregation: Aggregation
|
|
20
|
+
warnings: string[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type RunUsageOptions = {
|
|
24
|
+
agentDir: string
|
|
25
|
+
since?: number
|
|
26
|
+
until?: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function runUsage(opts: RunUsageOptions): Promise<UsageReport> {
|
|
30
|
+
const warnings: string[] = []
|
|
31
|
+
const sessionsDir = join(opts.agentDir, 'sessions')
|
|
32
|
+
const rows = scanAssistantRows({
|
|
33
|
+
sessionsDir,
|
|
34
|
+
...(opts.since !== undefined ? { since: opts.since } : {}),
|
|
35
|
+
...(opts.until !== undefined ? { until: opts.until } : {}),
|
|
36
|
+
onWarn: (m) => warnings.push(m),
|
|
37
|
+
})
|
|
38
|
+
const aggregation = await aggregate(rows)
|
|
39
|
+
return {
|
|
40
|
+
generatedAt: Date.now(),
|
|
41
|
+
agentDir: opts.agentDir,
|
|
42
|
+
range: { since: opts.since ?? null, until: opts.until ?? null },
|
|
43
|
+
timezone: processTimezone(),
|
|
44
|
+
aggregation,
|
|
45
|
+
warnings,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function processTimezone(): string {
|
|
50
|
+
try {
|
|
51
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
|
|
52
|
+
} catch {
|
|
53
|
+
return process.env.TZ ?? 'UTC'
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function startOfToday(now: Date = new Date()): number {
|
|
58
|
+
const d = new Date(now)
|
|
59
|
+
d.setHours(0, 0, 0, 0)
|
|
60
|
+
return d.getTime()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function startOfDaysAgo(days: number, now: Date = new Date()): number {
|
|
64
|
+
const d = new Date(now)
|
|
65
|
+
d.setHours(0, 0, 0, 0)
|
|
66
|
+
d.setDate(d.getDate() - days)
|
|
67
|
+
return d.getTime()
|
|
68
|
+
}
|