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
package/src/channels/schema.ts
CHANGED
|
@@ -4,11 +4,6 @@ export const ADAPTER_IDS = ['discord-bot', 'kakaotalk', 'slack-bot', 'telegram-b
|
|
|
4
4
|
|
|
5
5
|
export type AdapterId = (typeof ADAPTER_IDS)[number]
|
|
6
6
|
|
|
7
|
-
const allowRuleSchema = z.string().min(1).refine(isValidAllowRule, {
|
|
8
|
-
message:
|
|
9
|
-
'allow rule must be one of: *, guild:*, guild:<id>, guild:<id>/<channel>, team:*, team:<id>, team:<id>/<channel>, tg:*, tg:<chat_id>, channel:<id>, dm:*, dm:<id>, im:*, im:<id>, kakao:*, kakao:<chat>, kakao:dm/*, kakao:group/*, kakao:open/*',
|
|
10
|
-
})
|
|
11
|
-
|
|
12
7
|
const engagementTriggerSchema = z.enum(['mention', 'reply', 'dm'])
|
|
13
8
|
|
|
14
9
|
const stickinessSchema = z.union([
|
|
@@ -92,8 +87,13 @@ const historySchema = z
|
|
|
92
87
|
},
|
|
93
88
|
})
|
|
94
89
|
|
|
90
|
+
// Deliberately non-strict: a stale on-disk file may still carry the
|
|
91
|
+
// legacy `allow` field (`migrateLegacyConfigShape` lifts it into
|
|
92
|
+
// `roles.member.match[]` on load, but a between-reload window can
|
|
93
|
+
// briefly contain both). Zod silently drops unknown keys here, which is
|
|
94
|
+
// exactly what we want — a hard `.strict()` reject would brick recovery
|
|
95
|
+
// for any user mid-migration.
|
|
95
96
|
const adapterSchema = z.object({
|
|
96
|
-
allow: z.array(allowRuleSchema).default([]),
|
|
97
97
|
engagement: engagementSchema,
|
|
98
98
|
history: historySchema,
|
|
99
99
|
enabled: z.boolean().default(true),
|
|
@@ -118,156 +118,6 @@ export const channelsSchema = z
|
|
|
118
118
|
})
|
|
119
119
|
.default({})
|
|
120
120
|
|
|
121
|
-
export type AllowRule = string
|
|
122
121
|
export type EngagementConfig = z.infer<typeof engagementSchema>
|
|
123
122
|
export type ChannelAdapterConfig = z.infer<typeof adapterSchema>
|
|
124
|
-
export type KakaotalkAdapterConfig = ChannelAdapterConfig
|
|
125
123
|
export type ChannelsConfig = z.infer<typeof channelsSchema>
|
|
126
|
-
|
|
127
|
-
// Discord IDs are numeric snowflakes; Slack IDs start with a single uppercase
|
|
128
|
-
// letter (T for teams, C/D/G for channels) followed by alphanumerics; Telegram
|
|
129
|
-
// chat IDs are signed integers (negative for groups, `-100…` for supergroups
|
|
130
|
-
// and channels); KakaoTalk chat IDs are LOCO-protocol decimal integers
|
|
131
|
-
// (large enough to need BigInt at the protocol layer, but rendered as plain
|
|
132
|
-
// decimal strings here). All shapes are accepted on every adapter so the
|
|
133
|
-
// allow list stays declarative — the runtime ensures only the right adapter
|
|
134
|
-
// ever sees its own IDs.
|
|
135
|
-
const RULE_PATTERNS = [
|
|
136
|
-
/^\*$/,
|
|
137
|
-
// Discord
|
|
138
|
-
/^guild:\*$/,
|
|
139
|
-
/^guild:[0-9]+$/,
|
|
140
|
-
/^guild:[0-9]+\/[0-9]+$/,
|
|
141
|
-
/^dm:\*$/,
|
|
142
|
-
/^dm:[0-9]+$/,
|
|
143
|
-
// Slack
|
|
144
|
-
/^team:\*$/,
|
|
145
|
-
/^team:[A-Z0-9]+$/,
|
|
146
|
-
/^team:[A-Z0-9]+\/[A-Z0-9]+$/,
|
|
147
|
-
/^im:\*$/,
|
|
148
|
-
/^im:[A-Z0-9]+$/,
|
|
149
|
-
// Telegram (`tg:*` admits all chats; `tg:<chat_id>` scopes to one chat —
|
|
150
|
-
// numeric, may be negative). There is no team/guild concept; every chat is
|
|
151
|
-
// identified by its absolute id.
|
|
152
|
-
/^tg:\*$/,
|
|
153
|
-
/^tg:-?[0-9]+$/,
|
|
154
|
-
// KakaoTalk: a single workspace per logged-in account, so the rules scope
|
|
155
|
-
// by chat-type (1:1 / group / open) rather than by workspace. `kakao:*`
|
|
156
|
-
// admits every chat the account can see; `kakao:dm/*`, `kakao:group/*`,
|
|
157
|
-
// `kakao:open/*` admit one chat-type bucket; `kakao:<chat-id>` admits a
|
|
158
|
-
// single chat. The runtime classifies each chat into a bucket based on
|
|
159
|
-
// KakaoChat.type at chat-resolver time and surfaces the bucket via the
|
|
160
|
-
// workspace coordinate.
|
|
161
|
-
/^kakao:\*$/,
|
|
162
|
-
/^kakao:dm\/\*$/,
|
|
163
|
-
/^kakao:group\/\*$/,
|
|
164
|
-
/^kakao:open\/\*$/,
|
|
165
|
-
/^kakao:[0-9]+$/,
|
|
166
|
-
// Shared (channel ids are unique on both platforms)
|
|
167
|
-
/^channel:[A-Z0-9]+$/,
|
|
168
|
-
/^channel:-?[0-9]+$/,
|
|
169
|
-
]
|
|
170
|
-
|
|
171
|
-
function isValidAllowRule(rule: string): boolean {
|
|
172
|
-
return RULE_PATTERNS.some((p) => p.test(rule))
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
export function isAllowed(rules: readonly AllowRule[], workspace: string, chat: string): boolean {
|
|
176
|
-
for (const rule of rules) {
|
|
177
|
-
if (matchRule(rule, workspace, chat)) return true
|
|
178
|
-
}
|
|
179
|
-
return false
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// `*` → every workspace channel + every DM (catch-all)
|
|
183
|
-
// `guild:*` → every Discord guild channel (no DMs)
|
|
184
|
-
// `guild:G` → every channel in guild G
|
|
185
|
-
// `guild:G/C` → channel C in guild G only
|
|
186
|
-
// `team:*` → every Slack team channel (no DMs)
|
|
187
|
-
// `team:T` → every channel in team T
|
|
188
|
-
// `team:T/C` → channel C in team T only
|
|
189
|
-
// `tg:*` → every Telegram chat (DMs, groups, supergroups, channels)
|
|
190
|
-
// `tg:C` → Telegram chat C only (signed numeric chat id)
|
|
191
|
-
// `channel:C` → channel C in any workspace (IDs are globally unique on
|
|
192
|
-
// Discord/Slack and Telegram chat ids are also globally
|
|
193
|
-
// unique numeric values)
|
|
194
|
-
// `dm:*` → every Discord DM
|
|
195
|
-
// `dm:C` → Discord DM channel C only
|
|
196
|
-
// `im:*` → every Slack DM (im channel)
|
|
197
|
-
// `im:D` → Slack DM channel D only
|
|
198
|
-
// `kakao:*` → every KakaoTalk chat the account is in
|
|
199
|
-
// `kakao:dm/*` → every KakaoTalk 1:1 chat
|
|
200
|
-
// `kakao:group/*` → every KakaoTalk group chat
|
|
201
|
-
// `kakao:open/*` → every KakaoTalk open chat
|
|
202
|
-
// `kakao:<id>` → KakaoTalk chat with the given numeric chat_id
|
|
203
|
-
//
|
|
204
|
-
// `guild:`/`dm:`, `team:`/`im:`, `tg:`, and `kakao:` identify which adapter
|
|
205
|
-
// the rule was written for, but the matcher applies any rule that the
|
|
206
|
-
// (workspace, chat) pair satisfies. That keeps the adapter-side coupling at
|
|
207
|
-
// the schema/UX layer (Slack users write `team:`, Discord users write
|
|
208
|
-
// `guild:`, Telegram users write `tg:`, KakaoTalk users write `kakao:`)
|
|
209
|
-
// without bloating the matching logic. Telegram has no workspace concept;
|
|
210
|
-
// the adapter pins workspace to `'telegram'` so `tg:*` only ever admits
|
|
211
|
-
// Telegram chats. KakaoTalk uses `@kakao-dm` / `@kakao-group` / `@kakao-open`
|
|
212
|
-
// as workspace coordinates so the bucket-* rules are pure prefix matches
|
|
213
|
-
// against `workspace`.
|
|
214
|
-
function matchRule(rule: string, workspace: string, chat: string): boolean {
|
|
215
|
-
// KakaoTalk workspaces accept the global `*` catch-all or any `kakao:`
|
|
216
|
-
// rule. Adapter-specific non-kakao rules (`team:*`, `guild:*`, `dm:*`,
|
|
217
|
-
// `im:*`, `tg:*`) never admit kakao workspaces — those are scoped to
|
|
218
|
-
// their own adapter's coordinate space and would be meaningless here.
|
|
219
|
-
// The init wizard still defaults kakaotalk to the narrower `kakao:dm/*`
|
|
220
|
-
// (group chats with personal accounts are sensitive — every member sees
|
|
221
|
-
// every reply), so opting into `*` is an explicit, per-adapter decision
|
|
222
|
-
// made in `channels.kakaotalk.allow`.
|
|
223
|
-
if (KAKAO_WORKSPACES.has(workspace)) {
|
|
224
|
-
if (rule === '*') return true
|
|
225
|
-
if (rule.startsWith('kakao:')) return matchKakaoRule(rule.slice(6), workspace, chat)
|
|
226
|
-
return false
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
if (rule === '*') return true
|
|
230
|
-
if (rule.startsWith('kakao:')) return false
|
|
231
|
-
|
|
232
|
-
if (workspace === '@dm') {
|
|
233
|
-
if (rule === 'dm:*' || rule === 'im:*') return true
|
|
234
|
-
if (rule.startsWith('dm:')) return rule.slice(3) === chat
|
|
235
|
-
if (rule.startsWith('im:')) return rule.slice(3) === chat
|
|
236
|
-
if (rule.startsWith('channel:')) return rule.slice(8) === chat
|
|
237
|
-
return false
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
if (workspace === 'telegram') {
|
|
241
|
-
if (rule === 'tg:*') return true
|
|
242
|
-
if (rule.startsWith('tg:')) return rule.slice(3) === chat
|
|
243
|
-
if (rule.startsWith('channel:')) return rule.slice(8) === chat
|
|
244
|
-
return false
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
if (rule === 'guild:*' || rule === 'team:*') return true
|
|
248
|
-
if (rule.startsWith('channel:')) return rule.slice(8) === chat
|
|
249
|
-
if (rule.startsWith('guild:')) {
|
|
250
|
-
const body = rule.slice(6)
|
|
251
|
-
const slash = body.indexOf('/')
|
|
252
|
-
if (slash === -1) return body === workspace
|
|
253
|
-
return body.slice(0, slash) === workspace && body.slice(slash + 1) === chat
|
|
254
|
-
}
|
|
255
|
-
if (rule.startsWith('team:')) {
|
|
256
|
-
const body = rule.slice(5)
|
|
257
|
-
const slash = body.indexOf('/')
|
|
258
|
-
if (slash === -1) return body === workspace
|
|
259
|
-
return body.slice(0, slash) === workspace && body.slice(slash + 1) === chat
|
|
260
|
-
}
|
|
261
|
-
return false
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
const KAKAO_WORKSPACES = new Set(['@kakao-dm', '@kakao-group', '@kakao-open'])
|
|
265
|
-
|
|
266
|
-
function matchKakaoRule(body: string, workspace: string, chat: string): boolean {
|
|
267
|
-
if (!KAKAO_WORKSPACES.has(workspace)) return false
|
|
268
|
-
if (body === '*') return true
|
|
269
|
-
if (body === 'dm/*') return workspace === '@kakao-dm'
|
|
270
|
-
if (body === 'group/*') return workspace === '@kakao-group'
|
|
271
|
-
if (body === 'open/*') return workspace === '@kakao-open'
|
|
272
|
-
return body === chat
|
|
273
|
-
}
|
package/src/cli/channel.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
type KakaotalkAuthResult,
|
|
15
15
|
} from '@/init'
|
|
16
16
|
import { runKakaotalkBootstrap } from '@/init/kakaotalk-auth'
|
|
17
|
+
import { SecretsKakaoCredentialStore } from '@/secrets/kakao-store'
|
|
17
18
|
|
|
18
19
|
import { c, done, errorLine } from './ui'
|
|
19
20
|
|
|
@@ -68,6 +69,44 @@ const addSub = defineCommand({
|
|
|
68
69
|
},
|
|
69
70
|
})
|
|
70
71
|
|
|
72
|
+
// Only adapters with an interactive credential flow appear here. Bot tokens
|
|
73
|
+
// (Discord/Slack/Telegram) are rotated by editing secrets.json or .env
|
|
74
|
+
// directly — they don't need a guided CLI flow because there's no
|
|
75
|
+
// passcode-on-phone equivalent. KakaoTalk is the only adapter that does, so
|
|
76
|
+
// it's the only adapter that needs `reauth`.
|
|
77
|
+
const REAUTHABLE_ADAPTERS = ['kakaotalk'] as const
|
|
78
|
+
type ReauthableAdapter = (typeof REAUTHABLE_ADAPTERS)[number]
|
|
79
|
+
|
|
80
|
+
const reauthSub = defineCommand({
|
|
81
|
+
meta: {
|
|
82
|
+
name: 'reauth',
|
|
83
|
+
description:
|
|
84
|
+
're-authenticate a channel adapter (currently only `kakaotalk`). Use after a stale-token 401 or to rotate the saved password.',
|
|
85
|
+
},
|
|
86
|
+
args: {
|
|
87
|
+
adapter: {
|
|
88
|
+
type: 'positional',
|
|
89
|
+
description: `which adapter to re-authenticate (${REAUTHABLE_ADAPTERS.join(' | ')})`,
|
|
90
|
+
required: false,
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
async run({ args }) {
|
|
94
|
+
const cwd = findAgentDir(process.cwd()) ?? process.cwd()
|
|
95
|
+
|
|
96
|
+
if (!isInitialized(cwd)) {
|
|
97
|
+
console.error(errorLine('TypeClaw config file not found. Run `typeclaw init` first, or cd into an agent folder.'))
|
|
98
|
+
process.exit(1)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const configured = await readConfiguredChannels(cwd)
|
|
102
|
+
const adapter = await resolveReauthableAdapter(args.adapter, configured)
|
|
103
|
+
|
|
104
|
+
intro(`Re-authenticating channel: ${CHANNEL_LABELS[adapter]}`)
|
|
105
|
+
|
|
106
|
+
await runReauth(cwd, adapter)
|
|
107
|
+
},
|
|
108
|
+
})
|
|
109
|
+
|
|
71
110
|
export const channelCommand = defineCommand({
|
|
72
111
|
meta: {
|
|
73
112
|
name: 'channel',
|
|
@@ -75,9 +114,162 @@ export const channelCommand = defineCommand({
|
|
|
75
114
|
},
|
|
76
115
|
subCommands: {
|
|
77
116
|
add: addSub,
|
|
117
|
+
reauth: reauthSub,
|
|
78
118
|
},
|
|
79
119
|
})
|
|
80
120
|
|
|
121
|
+
async function resolveReauthableAdapter(
|
|
122
|
+
requested: string | undefined,
|
|
123
|
+
configured: Set<ChannelKind>,
|
|
124
|
+
): Promise<ReauthableAdapter> {
|
|
125
|
+
if (requested !== undefined) {
|
|
126
|
+
if (!isReauthableAdapter(requested)) {
|
|
127
|
+
console.error(
|
|
128
|
+
errorLine(`Adapter "${requested}" does not support reauth. Supported: ${REAUTHABLE_ADAPTERS.join(', ')}.`),
|
|
129
|
+
)
|
|
130
|
+
process.exit(1)
|
|
131
|
+
}
|
|
132
|
+
if (!configured.has(requested)) {
|
|
133
|
+
console.error(
|
|
134
|
+
errorLine(
|
|
135
|
+
`${CHANNEL_LABELS[requested]} ("${requested}") is not configured in typeclaw.json. Run \`typeclaw channel add ${requested}\` first.`,
|
|
136
|
+
),
|
|
137
|
+
)
|
|
138
|
+
process.exit(1)
|
|
139
|
+
}
|
|
140
|
+
return requested
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const available = REAUTHABLE_ADAPTERS.filter((kind) => configured.has(kind))
|
|
144
|
+
if (available.length === 0) {
|
|
145
|
+
console.error(
|
|
146
|
+
errorLine(
|
|
147
|
+
`No reauth-capable channels are configured. Run \`typeclaw channel add ${REAUTHABLE_ADAPTERS[0]}\` first.`,
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
process.exit(1)
|
|
151
|
+
}
|
|
152
|
+
if (available.length === 1) return available[0]!
|
|
153
|
+
|
|
154
|
+
const selected = await select<ReauthableAdapter>({
|
|
155
|
+
message: 'Pick a channel to re-authenticate',
|
|
156
|
+
options: available.map((kind) => ({ value: kind, label: CHANNEL_LABELS[kind] })),
|
|
157
|
+
initialValue: available[0],
|
|
158
|
+
})
|
|
159
|
+
if (isCancel(selected)) {
|
|
160
|
+
cancel('Aborted.')
|
|
161
|
+
process.exit(0)
|
|
162
|
+
}
|
|
163
|
+
return selected
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function isReauthableAdapter(value: string): value is ReauthableAdapter {
|
|
167
|
+
return (REAUTHABLE_ADAPTERS as ReadonlyArray<string>).includes(value)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function runReauth(cwd: string, adapter: ReauthableAdapter): Promise<void> {
|
|
171
|
+
switch (adapter) {
|
|
172
|
+
case 'kakaotalk':
|
|
173
|
+
await runKakaotalkReauth(cwd)
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function runKakaotalkReauth(cwd: string): Promise<void> {
|
|
179
|
+
const existingEmail = await readExistingKakaotalkEmail(cwd)
|
|
180
|
+
const creds = await promptKakaotalkCredentials({ defaultEmail: existingEmail })
|
|
181
|
+
|
|
182
|
+
const s = spinner()
|
|
183
|
+
s.start('Logging in to KakaoTalk...')
|
|
184
|
+
const result = await runKakaotalkBootstrap({
|
|
185
|
+
email: creds.email,
|
|
186
|
+
password: creds.password,
|
|
187
|
+
agentDir: cwd,
|
|
188
|
+
callbacks: {
|
|
189
|
+
onPasscode: (code) => log.info(`Confirm this passcode on your phone: ${code}`),
|
|
190
|
+
},
|
|
191
|
+
})
|
|
192
|
+
if (!result.ok) {
|
|
193
|
+
s.stop(`KakaoTalk login failed: ${result.reason}`)
|
|
194
|
+
process.exit(1)
|
|
195
|
+
}
|
|
196
|
+
s.stop('KakaoTalk credentials refreshed in secrets.json.')
|
|
197
|
+
|
|
198
|
+
await maybePromptReauthRefresh(cwd, 'kakaotalk')
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function readExistingKakaotalkEmail(cwd: string): Promise<string | undefined> {
|
|
202
|
+
try {
|
|
203
|
+
const store = new SecretsKakaoCredentialStore({ mode: 'host', secretsPath: `${cwd}/secrets.json` })
|
|
204
|
+
const account = await store.getAccountWithRenewalFields()
|
|
205
|
+
return account?.email ?? undefined
|
|
206
|
+
} catch {
|
|
207
|
+
// First-time reauth or a brand-new agent dir: no account yet, prompt from scratch.
|
|
208
|
+
return undefined
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// The renewed tokens are already on disk via secrets.json. What still needs
|
|
213
|
+
// to happen depends on the running adapter's state:
|
|
214
|
+
// - Container NOT running → nothing to do; next `typeclaw start` picks them up.
|
|
215
|
+
// - Container running, adapter previously 401'd → `typeclaw reload` re-runs
|
|
216
|
+
// startAdapter, which loads the fresh tokens.
|
|
217
|
+
// - Container running, adapter currently live (e.g. proactive rotation) →
|
|
218
|
+
// reload will report `restart-required` because tokens are captured at
|
|
219
|
+
// start time; `typeclaw restart` is needed to actually pick them up.
|
|
220
|
+
// We can't reliably distinguish the last two cases from outside the container
|
|
221
|
+
// without calling reload first, so the next-step hints surface both paths.
|
|
222
|
+
async function maybePromptReauthRefresh(cwd: string, adapter: ReauthableAdapter): Promise<void> {
|
|
223
|
+
const label = CHANNEL_LABELS[adapter]
|
|
224
|
+
const current = await status({ cwd }).catch(() => null)
|
|
225
|
+
if (current === null || current.kind !== 'running') {
|
|
226
|
+
done({
|
|
227
|
+
title: c.green(`${label} re-authenticated.`),
|
|
228
|
+
hints: [
|
|
229
|
+
{ label: 'Start the agent:', command: 'typeclaw start' },
|
|
230
|
+
{ label: 'Then check status:', command: 'typeclaw status' },
|
|
231
|
+
],
|
|
232
|
+
})
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const restartNow = await confirm({
|
|
237
|
+
message:
|
|
238
|
+
'The agent container is running. Restart it now so the adapter picks up the fresh credentials (recommended)?',
|
|
239
|
+
initialValue: true,
|
|
240
|
+
})
|
|
241
|
+
if (isCancel(restartNow) || !restartNow) {
|
|
242
|
+
done({
|
|
243
|
+
title: c.green(`${label} re-authenticated.`),
|
|
244
|
+
hints: [
|
|
245
|
+
{ label: 'Try a live reload first:', command: 'typeclaw reload' },
|
|
246
|
+
{ label: 'If reload reports restart-required:', command: 'typeclaw restart' },
|
|
247
|
+
],
|
|
248
|
+
})
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const stopped = await stop({ cwd })
|
|
253
|
+
if (!stopped.ok) {
|
|
254
|
+
console.error(errorLine(`Restart failed during stop: ${stopped.reason}`))
|
|
255
|
+
process.exit(1)
|
|
256
|
+
}
|
|
257
|
+
const started = await start({ cwd, preferredHostPort: config.port, cliEntry: process.argv[1] })
|
|
258
|
+
if (!started.ok) {
|
|
259
|
+
console.error(errorLine(`Restart failed during start: ${started.reason}`))
|
|
260
|
+
process.exit(1)
|
|
261
|
+
}
|
|
262
|
+
done({
|
|
263
|
+
title: c.green(
|
|
264
|
+
`${label} re-authenticated. Restarted ${started.plan.containerName} on host port ${started.hostPort}.`,
|
|
265
|
+
),
|
|
266
|
+
hints: [
|
|
267
|
+
{ label: 'Attach TUI:', command: 'typeclaw tui' },
|
|
268
|
+
{ label: 'Follow logs:', command: 'typeclaw logs -f' },
|
|
269
|
+
],
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
|
|
81
273
|
function validateAdapterArg(adapter: string, configured: Set<ChannelKind>): ChannelKind {
|
|
82
274
|
if (!isChannelKind(adapter)) {
|
|
83
275
|
console.error(errorLine(`Unknown adapter "${adapter}". Expected one of: ${CHANNEL_KINDS.join(', ')}.`))
|
|
@@ -86,7 +278,7 @@ function validateAdapterArg(adapter: string, configured: Set<ChannelKind>): Chan
|
|
|
86
278
|
if (configured.has(adapter)) {
|
|
87
279
|
console.error(
|
|
88
280
|
errorLine(
|
|
89
|
-
`${CHANNEL_LABELS[adapter]} ("${adapter}") is already configured in typeclaw.json. Edit the file directly to change its
|
|
281
|
+
`${CHANNEL_LABELS[adapter]} ("${adapter}") is already configured in typeclaw.json. Edit the file directly to change its configuration.`,
|
|
90
282
|
),
|
|
91
283
|
)
|
|
92
284
|
process.exit(1)
|
|
@@ -282,20 +474,24 @@ async function promptTelegramToken(): Promise<string> {
|
|
|
282
474
|
return token
|
|
283
475
|
}
|
|
284
476
|
|
|
285
|
-
async function promptKakaotalkCredentials(
|
|
477
|
+
async function promptKakaotalkCredentials(
|
|
478
|
+
opts: { defaultEmail?: string } = {},
|
|
479
|
+
): Promise<{ email: string; password: string }> {
|
|
286
480
|
note(
|
|
287
481
|
[
|
|
288
482
|
'KakaoTalk authentication uses a personal account, registered as a',
|
|
289
483
|
'tablet sub-device. Messages will be sent and received under this',
|
|
290
484
|
'account. Use a non-primary account if possible.',
|
|
291
485
|
'',
|
|
292
|
-
'
|
|
293
|
-
'
|
|
486
|
+
'On reauth, the existing device_uuid is preserved automatically, so',
|
|
487
|
+
'subsequent logins for the same account typically skip the phone',
|
|
488
|
+
'passcode confirmation.',
|
|
294
489
|
].join('\n'),
|
|
295
490
|
'About to log in to KakaoTalk',
|
|
296
491
|
)
|
|
297
492
|
const email = await text({
|
|
298
493
|
message: 'KakaoTalk email',
|
|
494
|
+
...(opts.defaultEmail !== undefined ? { initialValue: opts.defaultEmail, placeholder: opts.defaultEmail } : {}),
|
|
299
495
|
validate: (value) => (value && value.length > 0 ? undefined : 'Email is required'),
|
|
300
496
|
})
|
|
301
497
|
if (isCancel(email)) {
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { styleText } from 'node:util'
|
|
2
|
+
|
|
3
|
+
import type { ComposeUsageResult } from '@/compose'
|
|
4
|
+
import type { UsageReport, UsageTotals } from '@/usage'
|
|
5
|
+
import { formatCacheHitRate, formatCost, formatTokens } from '@/usage/format'
|
|
6
|
+
|
|
7
|
+
export type FormatComposeUsageOptions = {
|
|
8
|
+
useColor?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type ColorFn = (s: string) => string
|
|
12
|
+
type Palette = {
|
|
13
|
+
bold: ColorFn
|
|
14
|
+
dim: ColorFn
|
|
15
|
+
cyan: ColorFn
|
|
16
|
+
yellow: ColorFn
|
|
17
|
+
red: ColorFn
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const identity: ColorFn = (s) => s
|
|
21
|
+
const NO_PALETTE: Palette = { bold: identity, dim: identity, cyan: identity, yellow: identity, red: identity }
|
|
22
|
+
const COLOR_PALETTE: Palette = {
|
|
23
|
+
bold: (s) => styleText('bold', s),
|
|
24
|
+
dim: (s) => styleText('dim', s),
|
|
25
|
+
cyan: (s) => styleText('cyan', s),
|
|
26
|
+
yellow: (s) => styleText('yellow', s),
|
|
27
|
+
red: (s) => styleText('red', s),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function formatComposeUsage(result: ComposeUsageResult, opts: FormatComposeUsageOptions = {}): string {
|
|
31
|
+
const p: Palette = opts.useColor ? COLOR_PALETTE : NO_PALETTE
|
|
32
|
+
|
|
33
|
+
if (result.agents.length === 0) {
|
|
34
|
+
return p.dim(`No typeclaw agents in ${result.rootCwd}.`)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const sections: string[] = []
|
|
38
|
+
sections.push(`${p.bold('USAGE')} ${p.dim(`— ${result.rootCwd}`)}`)
|
|
39
|
+
sections.push(rangeLine(result.range, p))
|
|
40
|
+
sections.push('')
|
|
41
|
+
sections.push(renderTable(result, p))
|
|
42
|
+
|
|
43
|
+
const warnings = collectWarnings(result)
|
|
44
|
+
if (warnings.length > 0) {
|
|
45
|
+
sections.push('')
|
|
46
|
+
sections.push(p.yellow(`${warnings.length} warning(s):`))
|
|
47
|
+
for (const w of warnings) sections.push(` - ${w}`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return sections.join('\n')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function formatComposeUsageJson(result: ComposeUsageResult): string {
|
|
54
|
+
return JSON.stringify(result, null, 2)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function rangeLine(range: ComposeUsageResult['range'], p: Palette): string {
|
|
58
|
+
if (range.since === null && range.until === null) return p.dim('Range: all time')
|
|
59
|
+
const since = range.since !== null ? new Date(range.since).toISOString() : '—'
|
|
60
|
+
const until = range.until !== null ? new Date(range.until).toISOString() : '—'
|
|
61
|
+
return p.dim(`Range: ${since} → ${until}`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type Row = {
|
|
65
|
+
label: string
|
|
66
|
+
ok: boolean
|
|
67
|
+
reason: string | null
|
|
68
|
+
totals: UsageTotals
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function renderTable(result: ComposeUsageResult, p: Palette): string {
|
|
72
|
+
const rows: Row[] = result.results.map((r) => {
|
|
73
|
+
if (!r.ok) {
|
|
74
|
+
return { label: r.name, ok: false, reason: r.reason, totals: emptyTotals() }
|
|
75
|
+
}
|
|
76
|
+
return { label: r.name, ok: true, reason: null, totals: totalsFrom(r.data) }
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const total = sumTotals(rows.filter((r) => r.ok).map((r) => r.totals))
|
|
80
|
+
const headers = ['Agent', 'Sessions', 'Msgs', 'In', 'Out', 'Cache %', 'Cost']
|
|
81
|
+
|
|
82
|
+
const dataCells = rows.map((r) => {
|
|
83
|
+
if (!r.ok) {
|
|
84
|
+
return [p.red(r.label), p.red(`error: ${r.reason ?? 'unknown'}`), '', '', '', '', '']
|
|
85
|
+
}
|
|
86
|
+
return [
|
|
87
|
+
p.bold(r.label),
|
|
88
|
+
String(sessionCountFor(r, result)),
|
|
89
|
+
String(r.totals.messageCount),
|
|
90
|
+
formatTokens(r.totals.input),
|
|
91
|
+
formatTokens(r.totals.output),
|
|
92
|
+
formatCacheHitRate(r.totals.input, r.totals.cacheRead),
|
|
93
|
+
formatCost(r.totals.cost),
|
|
94
|
+
]
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
const totalSessions = result.results.reduce((acc, r) => acc + (r.ok ? r.data.aggregation.bySession.length : 0), 0)
|
|
98
|
+
const totalCells = [
|
|
99
|
+
'Total',
|
|
100
|
+
String(totalSessions),
|
|
101
|
+
String(total.messageCount),
|
|
102
|
+
formatTokens(total.input),
|
|
103
|
+
formatTokens(total.output),
|
|
104
|
+
formatCacheHitRate(total.input, total.cacheRead),
|
|
105
|
+
formatCost(total.cost),
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
return alignTable([headers, ...dataCells, totalCells], p, { totalRowIdx: dataCells.length + 1 })
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function sessionCountFor(row: Row, result: ComposeUsageResult): number {
|
|
112
|
+
const match = result.results.find((r) => r.name === row.label)
|
|
113
|
+
if (match === undefined || !match.ok) return 0
|
|
114
|
+
return match.data.aggregation.bySession.length
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function collectWarnings(result: ComposeUsageResult): string[] {
|
|
118
|
+
const out: string[] = []
|
|
119
|
+
for (const r of result.results) {
|
|
120
|
+
if (!r.ok) continue
|
|
121
|
+
for (const w of r.data.warnings) out.push(`[${r.name}] ${w}`)
|
|
122
|
+
}
|
|
123
|
+
return out
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function totalsFrom(report: UsageReport): UsageTotals {
|
|
127
|
+
return report.aggregation.total
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function emptyTotals(): UsageTotals {
|
|
131
|
+
return { messageCount: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: 0 }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function sumTotals(parts: UsageTotals[]): UsageTotals {
|
|
135
|
+
const acc = emptyTotals()
|
|
136
|
+
for (const t of parts) {
|
|
137
|
+
acc.messageCount += t.messageCount
|
|
138
|
+
acc.input += t.input
|
|
139
|
+
acc.output += t.output
|
|
140
|
+
acc.cacheRead += t.cacheRead
|
|
141
|
+
acc.cacheWrite += t.cacheWrite
|
|
142
|
+
acc.totalTokens += t.totalTokens
|
|
143
|
+
acc.cost += t.cost
|
|
144
|
+
}
|
|
145
|
+
return acc
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function alignTable(table: string[][], p: Palette, opts: { totalRowIdx: number }): string {
|
|
149
|
+
const widths = computeNaturalWidths(table)
|
|
150
|
+
return table
|
|
151
|
+
.map((row, idx) => {
|
|
152
|
+
const cells = row.map((cell, c) => {
|
|
153
|
+
const pad = widths[c]! - stripAnsi(cell).length
|
|
154
|
+
return c === 0 ? cell + ' '.repeat(pad) : ' '.repeat(pad) + cell
|
|
155
|
+
})
|
|
156
|
+
const line = cells.join(' ')
|
|
157
|
+
if (idx === 0) return p.cyan(line)
|
|
158
|
+
if (idx === opts.totalRowIdx) return p.yellow(p.bold(line))
|
|
159
|
+
return line
|
|
160
|
+
})
|
|
161
|
+
.join('\n')
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function computeNaturalWidths(table: string[][]): number[] {
|
|
165
|
+
const cols = table[0]?.length ?? 0
|
|
166
|
+
const widths: number[] = []
|
|
167
|
+
for (let c = 0; c < cols; c++) {
|
|
168
|
+
let w = 0
|
|
169
|
+
for (const row of table) {
|
|
170
|
+
const cell = row[c] ?? ''
|
|
171
|
+
const visible = stripAnsi(cell).length
|
|
172
|
+
if (visible > w) w = visible
|
|
173
|
+
}
|
|
174
|
+
widths.push(w)
|
|
175
|
+
}
|
|
176
|
+
return widths
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function stripAnsi(s: string): string {
|
|
180
|
+
// eslint-disable-next-line no-control-regex
|
|
181
|
+
return s.replace(/\u001b\[[0-9;]*m/g, '')
|
|
182
|
+
}
|
package/src/cli/compose.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
composeStart,
|
|
8
8
|
composeStatus,
|
|
9
9
|
composeStop,
|
|
10
|
+
composeUsage,
|
|
10
11
|
type AgentResult,
|
|
11
12
|
type ComposeDoctorReport,
|
|
12
13
|
} from '@/compose'
|
|
@@ -14,7 +15,9 @@ import { config } from '@/config'
|
|
|
14
15
|
import { formatJson, formatReport } from '@/doctor'
|
|
15
16
|
|
|
16
17
|
import { formatComposeStatus } from './compose-status'
|
|
18
|
+
import { formatComposeUsage, formatComposeUsageJson } from './compose-usage'
|
|
17
19
|
import { c, spinner } from './ui'
|
|
20
|
+
import { parseSince, parseUntil } from './usage-args'
|
|
18
21
|
|
|
19
22
|
const startSub = defineCommand({
|
|
20
23
|
meta: { name: 'start', description: 'start every agent in immediate subdirectories of cwd' },
|
|
@@ -166,6 +169,35 @@ const logsSub = defineCommand({
|
|
|
166
169
|
},
|
|
167
170
|
})
|
|
168
171
|
|
|
172
|
+
const usageSub = defineCommand({
|
|
173
|
+
meta: {
|
|
174
|
+
name: 'usage',
|
|
175
|
+
description: 'report LLM token usage and cost across every agent in immediate subdirectories of cwd',
|
|
176
|
+
},
|
|
177
|
+
args: {
|
|
178
|
+
json: { type: 'boolean', description: 'emit the usage report as JSON', default: false },
|
|
179
|
+
since: { type: 'string', description: "ISO date or relative duration ('today', '7d', '30d')" },
|
|
180
|
+
until: { type: 'string', description: 'ISO date upper bound (exclusive)' },
|
|
181
|
+
},
|
|
182
|
+
async run({ args }) {
|
|
183
|
+
const since = parseSince(args.since, 'typeclaw compose usage')
|
|
184
|
+
const until = parseUntil(args.until, 'typeclaw compose usage')
|
|
185
|
+
const result = await composeUsage({
|
|
186
|
+
rootCwd: process.cwd(),
|
|
187
|
+
...(since !== undefined ? { since } : {}),
|
|
188
|
+
...(until !== undefined ? { until } : {}),
|
|
189
|
+
})
|
|
190
|
+
if (args.json) {
|
|
191
|
+
process.stdout.write(`${formatComposeUsageJson(result)}\n`)
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
const useColor = Boolean(process.stdout.isTTY) && process.env.NO_COLOR === undefined
|
|
195
|
+
process.stdout.write(`${formatComposeUsage(result, { useColor })}\n`)
|
|
196
|
+
const anyFailed = result.results.some((r) => !r.ok)
|
|
197
|
+
if (anyFailed) process.exit(1)
|
|
198
|
+
},
|
|
199
|
+
})
|
|
200
|
+
|
|
169
201
|
const doctorSub = defineCommand({
|
|
170
202
|
meta: { name: 'doctor', description: 'diagnose every agent in immediate subdirectories of cwd' },
|
|
171
203
|
args: {
|
|
@@ -212,6 +244,7 @@ export const composeCommand = defineCommand({
|
|
|
212
244
|
restart: restartSub,
|
|
213
245
|
status: statusSub,
|
|
214
246
|
logs: logsSub,
|
|
247
|
+
usage: usageSub,
|
|
215
248
|
doctor: doctorSub,
|
|
216
249
|
},
|
|
217
250
|
})
|