typeclaw 0.32.1 → 0.34.0
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/auth.schema.json +66 -0
- package/cron.schema.json +26 -2
- package/package.json +1 -1
- package/secrets.schema.json +66 -0
- package/src/agent/index.ts +7 -3
- package/src/agent/session-origin.ts +17 -0
- package/src/agent/subagent-completion-reminder.ts +14 -1
- package/src/agent/subagent-drain.ts +2 -0
- package/src/agent/subagents.ts +21 -7
- package/src/agent/tools/channel-disengage.ts +66 -0
- package/src/agent/tools/channel-log.ts +3 -2
- package/src/agent/tools/spawn-subagent.ts +25 -5
- package/src/agent/tools/subagent-output.ts +13 -1
- package/src/bundled-plugins/guard/policies/managed-config.ts +1 -1
- package/src/bundled-plugins/memory/memory-logger.ts +7 -0
- package/src/bundled-plugins/researcher/researcher.ts +14 -11
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +2 -0
- package/src/channels/adapters/line-channel-resolver.ts +129 -0
- package/src/channels/adapters/line-classify.ts +80 -0
- package/src/channels/adapters/line-format.ts +11 -0
- package/src/channels/adapters/line.ts +350 -0
- package/src/channels/engagement.ts +4 -2
- package/src/channels/manager.ts +65 -6
- package/src/channels/router.ts +186 -41
- package/src/channels/schema.ts +6 -1
- package/src/cli/channel.ts +112 -1
- package/src/cli/cron.ts +22 -4
- package/src/cli/init.ts +267 -82
- package/src/cli/model.ts +5 -1
- package/src/cli/oauth-callbacks.ts +5 -4
- package/src/cli/provider.ts +41 -10
- package/src/config/providers.ts +366 -7
- package/src/cron/consumer.ts +33 -0
- package/src/cron/count-state.ts +208 -0
- package/src/cron/index.ts +4 -17
- package/src/cron/list.ts +24 -6
- package/src/cron/scheduler.ts +84 -9
- package/src/cron/schema.ts +100 -13
- package/src/doctor/channel-checks.ts +28 -0
- package/src/hostd/daemon.ts +14 -6
- package/src/hostd/protocol.ts +6 -2
- package/src/init/gitignore.ts +1 -1
- package/src/init/index.ts +36 -3
- package/src/init/line-auth.ts +98 -0
- package/src/init/models-dev.ts +3 -0
- package/src/init/run-owner-claim.ts +1 -0
- package/src/init/validate-api-key.ts +15 -0
- package/src/inspect/label.ts +1 -0
- package/src/permissions/match-rule.ts +28 -12
- package/src/permissions/resolve.ts +8 -1
- package/src/role-claim/match-rule.ts +5 -1
- package/src/run/index.ts +41 -4
- package/src/secrets/line-store.ts +112 -0
- package/src/secrets/oauth-xai.ts +342 -0
- package/src/secrets/schema.ts +25 -0
- package/src/secrets/storage.ts +2 -0
- package/src/server/index.ts +17 -4
- package/src/shared/protocol.ts +4 -1
- package/src/skills/typeclaw-channel-line/SKILL.md +46 -0
- package/src/skills/typeclaw-channels/SKILL.md +153 -0
- package/src/skills/typeclaw-config/SKILL.md +54 -184
- package/src/skills/typeclaw-config/references/dockerfile.md +66 -0
- package/src/skills/typeclaw-cron/SKILL.md +68 -14
- package/src/skills/typeclaw-permissions/SKILL.md +3 -3
- package/typeclaw.schema.json +185 -3
package/src/hostd/daemon.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type { Socket, UnixSocketListener } from 'bun'
|
|
|
7
7
|
import type { PortForward } from '@/config'
|
|
8
8
|
import { defaultDockerExec, type DockerExec } from '@/container'
|
|
9
9
|
import type { PortForwardEvent } from '@/portbroker'
|
|
10
|
-
import { kakaoChannelBlockSchema } from '@/secrets/schema'
|
|
10
|
+
import { kakaoChannelBlockSchema, lineChannelBlockSchema } from '@/secrets/schema'
|
|
11
11
|
import { SecretsBackend } from '@/secrets/storage'
|
|
12
12
|
|
|
13
13
|
import { isDaemonReachable } from './client'
|
|
@@ -410,19 +410,27 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
|
|
|
410
410
|
|
|
411
411
|
const handleSecretsPatch = async (req: {
|
|
412
412
|
containerName: string
|
|
413
|
-
patch: { channels: { kakaotalk: unknown } }
|
|
413
|
+
patch: { channels: { kakaotalk: unknown } | { line: unknown } }
|
|
414
414
|
}): Promise<RpcResponse> =>
|
|
415
415
|
runSerially(req.containerName, async () => {
|
|
416
416
|
const cwd = cwds.get(req.containerName)
|
|
417
417
|
if (!cwd) return { ok: false, reason: `not registered: ${req.containerName}` }
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
|
|
418
|
+
const channelsPatch = req.patch?.channels
|
|
419
|
+
// Exactly one personal-account channel block per patch. KakaoTalk and
|
|
420
|
+
// LINE both write their structured account block through this RPC; the
|
|
421
|
+
// key present in the patch selects which block to validate and merge.
|
|
422
|
+
const patch =
|
|
423
|
+
'line' in channelsPatch
|
|
424
|
+
? { key: 'line' as const, parsed: lineChannelBlockSchema.safeParse(channelsPatch.line) }
|
|
425
|
+
: { key: 'kakaotalk' as const, parsed: kakaoChannelBlockSchema.safeParse(channelsPatch.kakaotalk) }
|
|
426
|
+
if (!patch.parsed.success) {
|
|
427
|
+
return { ok: false, reason: patch.parsed.error.issues.map((issue) => issue.message).join('; ') }
|
|
421
428
|
}
|
|
429
|
+
const data = patch.parsed.data
|
|
422
430
|
const backend = new SecretsBackend(join(cwd, 'secrets.json'))
|
|
423
431
|
await backend.updateChannelsAsync(async (channels) => ({
|
|
424
432
|
result: undefined,
|
|
425
|
-
next: { ...channels,
|
|
433
|
+
next: { ...channels, [patch.key]: data },
|
|
426
434
|
}))
|
|
427
435
|
const result: SecretsPatchResult = { containerName: req.containerName, patched: true }
|
|
428
436
|
return { ok: true, result }
|
package/src/hostd/protocol.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { PortForward } from '@/config'
|
|
2
|
-
import type { KakaoChannelBlock } from '@/secrets/schema'
|
|
2
|
+
import type { KakaoChannelBlock, LineChannelBlock } from '@/secrets/schema'
|
|
3
3
|
|
|
4
4
|
export type Request =
|
|
5
5
|
| {
|
|
@@ -15,7 +15,11 @@ export type Request =
|
|
|
15
15
|
| { kind: 'list' }
|
|
16
16
|
| { kind: 'status'; containerName: string }
|
|
17
17
|
| { kind: 'restart'; containerName: string; build?: boolean }
|
|
18
|
-
| {
|
|
18
|
+
| {
|
|
19
|
+
kind: 'secrets-patch'
|
|
20
|
+
containerName: string
|
|
21
|
+
patch: { channels: { kakaotalk: KakaoChannelBlock } | { line: LineChannelBlock } }
|
|
22
|
+
}
|
|
19
23
|
| { kind: 'http-info' }
|
|
20
24
|
| { kind: 'version' }
|
|
21
25
|
| { kind: 'shutdown' }
|
package/src/init/gitignore.ts
CHANGED
|
@@ -23,7 +23,7 @@ export const TRULY_IGNORED_PATTERNS = [
|
|
|
23
23
|
// The reconciler MUST fail-closed and never untrack these, even if a custom
|
|
24
24
|
// git.ignore.append pattern (e.g. `**`) matches them — doing so would drop
|
|
25
25
|
// runtime-owned state out of git.
|
|
26
|
-
export const SYSTEM_MANAGED_ROOTS = ['sessions/', 'memory/', 'channels/', 'todo/'] as const
|
|
26
|
+
export const SYSTEM_MANAGED_ROOTS = ['sessions/', 'memory/', 'channels/', 'todo/', 'cron/'] as const
|
|
27
27
|
|
|
28
28
|
export function buildGitignore(config: GitignoreConfig = { append: [] }): string {
|
|
29
29
|
const customEntries = renderCustomGitignoreEntries(config.append)
|
package/src/init/index.ts
CHANGED
|
@@ -139,6 +139,9 @@ export type HatchRunner = (options: {
|
|
|
139
139
|
|
|
140
140
|
export type KakaotalkAuthRunner = (options: { cwd: string }) => Promise<KakaotalkAuthResult>
|
|
141
141
|
|
|
142
|
+
export type LineAuthResult = { ok: true } | { ok: false; reason: string }
|
|
143
|
+
export type LineAuthRunner = (options: { cwd: string }) => Promise<LineAuthResult>
|
|
144
|
+
|
|
142
145
|
// Discriminated by `kind` so the type system enforces "you can't pass an
|
|
143
146
|
// API key to an OAuth provider, and you can't pass an OAuth runner to an
|
|
144
147
|
// API-key provider". Optional model defaults to DEFAULT_MODEL_REF, which is
|
|
@@ -834,7 +837,7 @@ export async function hasExistingOAuthCredentials(root: string, providerId: Know
|
|
|
834
837
|
// kakaotalk` anyway — better to re-auth now during init.
|
|
835
838
|
export async function hasExistingChannelSecrets(
|
|
836
839
|
root: string,
|
|
837
|
-
channel: 'discord' | 'slack' | 'telegram' | 'kakaotalk' | 'github',
|
|
840
|
+
channel: 'discord' | 'slack' | 'telegram' | 'line' | 'kakaotalk' | 'github',
|
|
838
841
|
): Promise<boolean> {
|
|
839
842
|
const channels = new SecretsBackend(join(root, 'secrets.json')).tryReadChannelsSync()
|
|
840
843
|
if (channels === null) return false
|
|
@@ -854,6 +857,21 @@ export async function hasExistingChannelSecrets(
|
|
|
854
857
|
// surfaced as a hard error inside `runAddChannel` to prevent silent
|
|
855
858
|
// overwrites.
|
|
856
859
|
return false
|
|
860
|
+
case 'line': {
|
|
861
|
+
// A usable LINE block needs a current account whose record carries an
|
|
862
|
+
// auth_token. Unlike KakaoTalk there are no renewal fields (email +
|
|
863
|
+
// encrypted password) to require — LINE has no unattended renewal cron.
|
|
864
|
+
const block = channels.line
|
|
865
|
+
if (!isObjectRecord(block)) return false
|
|
866
|
+
const current = (block as { currentAccount?: unknown }).currentAccount
|
|
867
|
+
if (typeof current !== 'string' || current.length === 0) return false
|
|
868
|
+
const accounts = (block as { accounts?: unknown }).accounts
|
|
869
|
+
if (!isObjectRecord(accounts)) return false
|
|
870
|
+
const account = accounts[current]
|
|
871
|
+
if (!isObjectRecord(account)) return false
|
|
872
|
+
const authToken = (account as { auth_token?: unknown }).auth_token
|
|
873
|
+
return typeof authToken === 'string' && authToken.length > 0
|
|
874
|
+
}
|
|
857
875
|
case 'kakaotalk': {
|
|
858
876
|
const block = channels.kakaotalk
|
|
859
877
|
if (!isObjectRecord(block)) return false
|
|
@@ -924,7 +942,7 @@ function ignoreExists(error: NodeJS.ErrnoException): void {
|
|
|
924
942
|
// scaffold-test cases above demonstrates how easy it is to lose a single
|
|
925
943
|
// behavior under a mode flag.
|
|
926
944
|
|
|
927
|
-
export type ChannelKind = 'discord-bot' | 'slack-bot' | 'telegram-bot' | 'kakaotalk' | 'github'
|
|
945
|
+
export type ChannelKind = 'discord-bot' | 'slack-bot' | 'telegram-bot' | 'line' | 'kakaotalk' | 'github'
|
|
928
946
|
|
|
929
947
|
// Public adapter names match the typeclaw.json `channels.*` keys exactly.
|
|
930
948
|
// The CLI takes these as the optional positional arg, the picker shows
|
|
@@ -934,15 +952,18 @@ export const CHANNEL_KINDS: ReadonlyArray<ChannelKind> = [
|
|
|
934
952
|
'slack-bot',
|
|
935
953
|
'discord-bot',
|
|
936
954
|
'telegram-bot',
|
|
955
|
+
'line',
|
|
937
956
|
'kakaotalk',
|
|
938
957
|
'github',
|
|
939
958
|
]
|
|
940
959
|
|
|
941
|
-
export type AddChannelStep = 'kakaotalk-auth' | 'config' | 'secrets' | 'github-webhooks'
|
|
960
|
+
export type AddChannelStep = 'line-auth' | 'kakaotalk-auth' | 'config' | 'secrets' | 'github-webhooks'
|
|
942
961
|
|
|
943
962
|
export type AddChannelStepEvent =
|
|
944
963
|
| { step: 'config'; phase: 'start' }
|
|
945
964
|
| { step: 'config'; phase: 'done' }
|
|
965
|
+
| { step: 'line-auth'; phase: 'start' }
|
|
966
|
+
| { step: 'line-auth'; phase: 'done'; result: LineAuthResult }
|
|
946
967
|
| { step: 'kakaotalk-auth'; phase: 'start' }
|
|
947
968
|
| { step: 'kakaotalk-auth'; phase: 'done'; result: KakaotalkAuthResult }
|
|
948
969
|
| { step: 'secrets'; phase: 'start' }
|
|
@@ -960,6 +981,7 @@ export type AddChannelOptions = {
|
|
|
960
981
|
| { channel: 'discord-bot'; discordBotToken: string }
|
|
961
982
|
| { channel: 'slack-bot'; slackBotToken: string; slackAppToken: string }
|
|
962
983
|
| { channel: 'telegram-bot'; telegramBotToken: string }
|
|
984
|
+
| { channel: 'line'; runLineAuth: LineAuthRunner }
|
|
963
985
|
| { channel: 'kakaotalk'; runKakaotalkAuth: KakaotalkAuthRunner }
|
|
964
986
|
| {
|
|
965
987
|
channel: 'github'
|
|
@@ -986,6 +1008,13 @@ export async function runAddChannel(options: AddChannelOptions): Promise<void> {
|
|
|
986
1008
|
// drops messages — the same trap `runInit` already guards against. Aborting
|
|
987
1009
|
// before any file write means the user's next `typeclaw channel add
|
|
988
1010
|
// kakaotalk` retry has no half-applied state to clean up.
|
|
1011
|
+
if (options.channel === 'line') {
|
|
1012
|
+
emit({ step: 'line-auth', phase: 'start' })
|
|
1013
|
+
const result = await options.runLineAuth({ cwd: options.cwd })
|
|
1014
|
+
emit({ step: 'line-auth', phase: 'done', result })
|
|
1015
|
+
if (!result.ok) throw new Error(`LINE authentication failed: ${result.reason}`)
|
|
1016
|
+
}
|
|
1017
|
+
|
|
989
1018
|
if (options.channel === 'kakaotalk') {
|
|
990
1019
|
emit({ step: 'kakaotalk-auth', phase: 'start' })
|
|
991
1020
|
const result = await options.runKakaotalkAuth({ cwd: options.cwd })
|
|
@@ -1065,6 +1094,10 @@ function channelSecretsFromOptions(options: AddChannelOptions): ChannelSecrets {
|
|
|
1065
1094
|
return { botToken: options.slackBotToken, appToken: options.slackAppToken }
|
|
1066
1095
|
case 'telegram-bot':
|
|
1067
1096
|
return { token: options.telegramBotToken }
|
|
1097
|
+
case 'line':
|
|
1098
|
+
// LINE auth writes its structured account block directly to
|
|
1099
|
+
// secrets.json#channels.line before config mutation.
|
|
1100
|
+
return {}
|
|
1068
1101
|
case 'kakaotalk':
|
|
1069
1102
|
// KakaoTalk auth writes its structured multi-account block directly to
|
|
1070
1103
|
// secrets.json#channels.kakaotalk before config mutation.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { LineClient as RealLineClient, LineCredentialManager, type LineLoginResult } from 'agent-messenger/line'
|
|
4
|
+
|
|
5
|
+
import { SecretsLineCredentialStore } from '@/secrets/line-store'
|
|
6
|
+
|
|
7
|
+
export type LineBootstrapStatus = { ok: true } | { ok: false; reason: string }
|
|
8
|
+
|
|
9
|
+
export type LineLoginCallbacks = {
|
|
10
|
+
onQRUrl?: (url: string) => void
|
|
11
|
+
onPincode: (pin: string) => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// QR is the default because a LINE account may have no usable e-mail/password
|
|
15
|
+
// (social-login accounts), and QR only adds bootstrap-time UX — the persisted
|
|
16
|
+
// credential (auth_token + certificate) is identical regardless of method.
|
|
17
|
+
export type LineLoginInput =
|
|
18
|
+
| {
|
|
19
|
+
method: 'qr'
|
|
20
|
+
agentDir: string
|
|
21
|
+
callbacks: LineLoginCallbacks
|
|
22
|
+
client?: LineLoginClient
|
|
23
|
+
}
|
|
24
|
+
| {
|
|
25
|
+
method: 'email'
|
|
26
|
+
email: string
|
|
27
|
+
password: string
|
|
28
|
+
agentDir: string
|
|
29
|
+
callbacks: LineLoginCallbacks
|
|
30
|
+
client?: LineLoginClient
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Structural subset of the upstream LineClient the bootstrap drives. Declared
|
|
34
|
+
// here so tests can inject a fake without standing up the real LOCO client.
|
|
35
|
+
export type LineLoginClient = {
|
|
36
|
+
loginWithQR(options: { onQRUrl: (url: string) => void; onPincode: (pin: string) => void }): Promise<LineLoginResult>
|
|
37
|
+
loginWithEmail(options: {
|
|
38
|
+
email: string
|
|
39
|
+
password: string
|
|
40
|
+
onPincode: (pin: string) => void
|
|
41
|
+
}): Promise<LineLoginResult>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function lineSecretsPath(agentDir: string): string {
|
|
45
|
+
return join(agentDir, 'secrets.json')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function runLineBootstrap(input: LineLoginInput): Promise<LineBootstrapStatus> {
|
|
49
|
+
try {
|
|
50
|
+
const store = new SecretsLineCredentialStore({ mode: 'host', secretsPath: lineSecretsPath(input.agentDir) })
|
|
51
|
+
// The LINE SDK persists the minted auth_token + certificate by calling
|
|
52
|
+
// setAccount() on whatever credential manager the client was built with.
|
|
53
|
+
// Wiring our secrets.json-backed store in here means a successful login
|
|
54
|
+
// writes straight to secrets.json#channels.line — no second copy in
|
|
55
|
+
// ~/.config/agent-messenger to keep in sync.
|
|
56
|
+
const client = input.client ?? buildLineClient(store)
|
|
57
|
+
|
|
58
|
+
const result =
|
|
59
|
+
input.method === 'qr'
|
|
60
|
+
? await client.loginWithQR({
|
|
61
|
+
onQRUrl: (url) => input.callbacks.onQRUrl?.(url),
|
|
62
|
+
onPincode: input.callbacks.onPincode,
|
|
63
|
+
})
|
|
64
|
+
: await client.loginWithEmail({
|
|
65
|
+
email: input.email,
|
|
66
|
+
password: input.password,
|
|
67
|
+
onPincode: input.callbacks.onPincode,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
if (!result.authenticated || result.account_id === undefined) {
|
|
71
|
+
const reason = result.message ?? result.error ?? 'LINE login did not authenticate'
|
|
72
|
+
return { ok: false, reason }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The SDK persists the account by calling setAccount() on the credential
|
|
76
|
+
// manager as a side effect of login. We can't assume it did: read the
|
|
77
|
+
// record back and require an auth_token before declaring success, so a
|
|
78
|
+
// login that authenticated but failed to persist surfaces as an error
|
|
79
|
+
// instead of a green "added" with an empty secrets.json#channels.line.
|
|
80
|
+
const persisted = await store.getAccount(result.account_id)
|
|
81
|
+
if (persisted === null || persisted.auth_token === '') {
|
|
82
|
+
return { ok: false, reason: 'LINE login authenticated but did not persist credentials' }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
await store.setCurrentAccount(result.account_id)
|
|
86
|
+
return { ok: true }
|
|
87
|
+
} catch (err) {
|
|
88
|
+
return { ok: false, reason: err instanceof Error ? err.message : String(err) }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function buildLineClient(store: SecretsLineCredentialStore): LineLoginClient {
|
|
93
|
+
// The upstream LineClient constructor takes a LineCredentialManager. Our
|
|
94
|
+
// store implements the same setAccount/getAccount surface the login path
|
|
95
|
+
// calls, so it stands in as the credential manager via a structural cast.
|
|
96
|
+
const credManager = store as unknown as LineCredentialManager
|
|
97
|
+
return new RealLineClient(credManager) as unknown as LineLoginClient
|
|
98
|
+
}
|
package/src/init/models-dev.ts
CHANGED
|
@@ -20,6 +20,9 @@ const PROVIDER_TO_MODELS_DEV: Record<KnownProviderId, string> = {
|
|
|
20
20
|
// catalog. models.dev tracks the underlying model metadata under `zai`,
|
|
21
21
|
// so we route lookups there. The curated entries still get surfaced.
|
|
22
22
|
'zai-coding': 'zai',
|
|
23
|
+
xai: 'xai',
|
|
24
|
+
minimax: 'minimax',
|
|
25
|
+
deepseek: 'deepseek',
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
export type ModelOption = {
|
|
@@ -7,6 +7,9 @@ const PROVIDER_PROBE: Partial<Record<KnownProviderId, { url: string; authHeader:
|
|
|
7
7
|
fireworks: { url: 'https://api.fireworks.ai/inference/v1/models', authHeader: 'bearer' },
|
|
8
8
|
zai: { url: 'https://api.z.ai/api/paas/v4/models', authHeader: 'bearer' },
|
|
9
9
|
'zai-coding': { url: 'https://api.z.ai/api/coding/paas/v4/models', authHeader: 'bearer' },
|
|
10
|
+
xai: { url: 'https://api.x.ai/v1/models', authHeader: 'bearer' },
|
|
11
|
+
minimax: { url: 'https://api.minimax.io/v1/models', authHeader: 'bearer' },
|
|
12
|
+
deepseek: { url: 'https://api.deepseek.com/models', authHeader: 'bearer' },
|
|
10
13
|
}
|
|
11
14
|
|
|
12
15
|
// When a base-URL override (ANTHROPIC_BASE_URL / OPENAI_BASE_URL) points at a
|
|
@@ -159,8 +162,20 @@ export const API_KEY_DASHBOARD_URL: Partial<Record<KnownProviderId, string>> = {
|
|
|
159
162
|
fireworks: 'https://fireworks.ai/account/api-keys',
|
|
160
163
|
zai: 'https://docs.z.ai/devpack/tool/claude#api-key',
|
|
161
164
|
'zai-coding': 'https://docs.z.ai/devpack/tool/claude#api-key',
|
|
165
|
+
xai: 'https://console.x.ai',
|
|
166
|
+
minimax: 'https://platform.minimax.io/user-center/basic-information/interface-key',
|
|
167
|
+
deepseek: 'https://platform.deepseek.com/api_keys',
|
|
162
168
|
}
|
|
163
169
|
|
|
170
|
+
// MiniMax sells the same `minimax` provider under two billing surfaces that
|
|
171
|
+
// each hand out a key on a DIFFERENT dashboard page: pay-as-you-go API keys
|
|
172
|
+
// (the API_KEY_DASHBOARD_URL above) and Token Plan "Subscription Keys"
|
|
173
|
+
// (sk-cp-…, this URL). Both keys are Bearer tokens for the same api.minimax.io
|
|
174
|
+
// endpoint and store in the same MINIMAX_API_KEY slot — the runtime doesn't
|
|
175
|
+
// care which. The init wizard surfaces the choice only to deep-link the
|
|
176
|
+
// correct dashboard, so a Token Plan subscriber isn't sent to the paygo page.
|
|
177
|
+
export const MINIMAX_TOKEN_PLAN_DASHBOARD_URL = 'https://platform.minimax.io/user-center/payment/token-plan'
|
|
178
|
+
|
|
164
179
|
export function providersWithApiKeyProbe(): KnownProviderId[] {
|
|
165
180
|
return Object.keys(PROVIDER_PROBE) as KnownProviderId[]
|
|
166
181
|
}
|
package/src/inspect/label.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// error messages with typo suggestions; a single big regex would only ever
|
|
17
17
|
// say "didn't match".
|
|
18
18
|
|
|
19
|
-
export const PLATFORMS = ['slack', 'discord', 'telegram', 'kakao', 'github'] as const
|
|
19
|
+
export const PLATFORMS = ['slack', 'discord', 'telegram', 'line', 'kakao', 'github'] as const
|
|
20
20
|
export type Platform = (typeof PLATFORMS)[number]
|
|
21
21
|
|
|
22
22
|
const SUBAGENT_NAME = /^[a-z][a-z0-9-]*$/
|
|
@@ -35,9 +35,10 @@ export type MatchRule =
|
|
|
35
35
|
workspace?: string
|
|
36
36
|
chat?: string
|
|
37
37
|
// Buckets for DM-style scopes. `slack:dm/*`, `discord:dm/*`,
|
|
38
|
-
// `kakao:dm/*`, `kakao:group/*`, `kakao:open
|
|
39
|
-
// (no workspace,
|
|
40
|
-
|
|
38
|
+
// `kakao:dm/*`, `kakao:group/*`, `kakao:open/*`, `line:dm/*`,
|
|
39
|
+
// `line:group/*`, `line:square/*` produce `bucket` only (no workspace,
|
|
40
|
+
// no chat).
|
|
41
|
+
bucket?: 'dm' | 'group' | 'open' | 'square'
|
|
41
42
|
author?: string
|
|
42
43
|
}
|
|
43
44
|
|
|
@@ -50,7 +51,7 @@ export type ParseMatchRuleResult = { ok: true; value: MatchRule } | { ok: false;
|
|
|
50
51
|
// this to `author:` only, the JSON schema would reject typos with a generic
|
|
51
52
|
// "did not match pattern" error and the user would lose the actionable hint.
|
|
52
53
|
export const MATCH_RULE_REGEX_SOURCE =
|
|
53
|
-
'^(tui|cron|subagent(:[a-z][a-z0-9-]*)?|\\*|(slack|discord|telegram|kakao|github):[^\\s]+)(\\s+[a-zA-Z][a-zA-Z0-9_]*:[^\\s]+)*$'
|
|
54
|
+
'^(tui|cron|subagent(:[a-z][a-z0-9-]*)?|\\*|(slack|discord|telegram|line|kakao|github):[^\\s]+)(\\s+[a-zA-Z][a-zA-Z0-9_]*:[^\\s]+)*$'
|
|
54
55
|
|
|
55
56
|
export function parseMatchRule(input: string): ParseMatchRuleResult {
|
|
56
57
|
if (input !== input.trim() || input.length === 0) {
|
|
@@ -164,15 +165,16 @@ function parseChannelScope(platform: Platform, rest: string, author: string | un
|
|
|
164
165
|
}
|
|
165
166
|
}
|
|
166
167
|
|
|
167
|
-
if (head === 'dm' || head === 'group' || head === 'open') {
|
|
168
|
-
|
|
169
|
-
|
|
168
|
+
if (head === 'dm' || head === 'group' || head === 'open' || head === 'square') {
|
|
169
|
+
const bucketError = invalidBucketForPlatform(head, platform)
|
|
170
|
+
if (bucketError !== null) {
|
|
171
|
+
return { ok: false, error: bucketError }
|
|
170
172
|
}
|
|
171
173
|
if (tail === '') {
|
|
172
174
|
return { ok: false, error: `bucket '${platform}:${head}/' requires '*' or a chat id` }
|
|
173
175
|
}
|
|
174
176
|
if (tail === '*') {
|
|
175
|
-
return { ok: true, value: buildChannelRule(platform, { bucket: head
|
|
177
|
+
return { ok: true, value: buildChannelRule(platform, { bucket: head, author }) }
|
|
176
178
|
}
|
|
177
179
|
// `slack:dm/<id>` — keep the bucket plus the specific chat. We omit a
|
|
178
180
|
// separate workspace field; DM IDs are globally unique within a
|
|
@@ -180,7 +182,7 @@ function parseChannelScope(platform: Platform, rest: string, author: string | un
|
|
|
180
182
|
return {
|
|
181
183
|
ok: true,
|
|
182
184
|
value: buildChannelRule(platform, {
|
|
183
|
-
bucket: head
|
|
185
|
+
bucket: head,
|
|
184
186
|
chat: tail,
|
|
185
187
|
author,
|
|
186
188
|
}),
|
|
@@ -199,12 +201,26 @@ function parseChannelScope(platform: Platform, rest: string, author: string | un
|
|
|
199
201
|
}
|
|
200
202
|
|
|
201
203
|
// No slash: `slack:T0123` or `kakao:dm` (bare bucket — error).
|
|
202
|
-
if (rest === 'dm' || rest === 'group' || rest === 'open') {
|
|
204
|
+
if (rest === 'dm' || rest === 'group' || rest === 'open' || rest === 'square') {
|
|
203
205
|
return { ok: false, error: `bucket '${platform}:${rest}' requires a chat id or '*'` }
|
|
204
206
|
}
|
|
205
207
|
return { ok: true, value: buildChannelRule(platform, { workspace: rest, author }) }
|
|
206
208
|
}
|
|
207
209
|
|
|
210
|
+
// `dm` is universal. `group`/`open` are KakaoTalk buckets; `group`/`square`
|
|
211
|
+
// are LINE buckets. Reject a bucket on a platform whose workspace shapes don't
|
|
212
|
+
// produce it, so a typo'd rule fails loudly instead of silently never matching.
|
|
213
|
+
function invalidBucketForPlatform(bucket: 'dm' | 'group' | 'open' | 'square', platform: Platform): string | null {
|
|
214
|
+
if (bucket === 'dm') return null
|
|
215
|
+
if (bucket === 'open') {
|
|
216
|
+
return platform === 'kakao' ? null : `bucket 'open' is only valid for kakao`
|
|
217
|
+
}
|
|
218
|
+
if (bucket === 'square') {
|
|
219
|
+
return platform === 'line' ? null : `bucket 'square' is only valid for line`
|
|
220
|
+
}
|
|
221
|
+
return platform === 'kakao' || platform === 'line' ? null : `bucket 'group' is only valid for kakao or line`
|
|
222
|
+
}
|
|
223
|
+
|
|
208
224
|
function parseGithubChannelScope(rest: string, author: string | undefined): ParseMatchRuleResult {
|
|
209
225
|
const [owner, repo, ...chatParts] = rest.split('/')
|
|
210
226
|
if (owner === undefined || owner === '' || repo === undefined || repo === '') {
|
|
@@ -230,7 +246,7 @@ function buildChannelRule(
|
|
|
230
246
|
parts: {
|
|
231
247
|
workspace?: string
|
|
232
248
|
chat?: string
|
|
233
|
-
bucket?: 'dm' | 'group' | 'open'
|
|
249
|
+
bucket?: 'dm' | 'group' | 'open' | 'square'
|
|
234
250
|
author?: string
|
|
235
251
|
},
|
|
236
252
|
): MatchRule {
|
|
@@ -7,6 +7,7 @@ const ADAPTER_TO_PLATFORM: Record<AdapterId, Platform> = {
|
|
|
7
7
|
'discord-bot': 'discord',
|
|
8
8
|
github: 'github',
|
|
9
9
|
'telegram-bot': 'telegram',
|
|
10
|
+
line: 'line',
|
|
10
11
|
kakaotalk: 'kakao',
|
|
11
12
|
}
|
|
12
13
|
|
|
@@ -63,10 +64,16 @@ function matchesChannel(rule: Extract<MatchRule, { kind: 'channel' }>, origin: M
|
|
|
63
64
|
// the origin's workspace is `@dm` (Slack) or `dm` (Discord). KakaoTalk uses
|
|
64
65
|
// the workspace prefix itself.
|
|
65
66
|
function matchesBucket(
|
|
66
|
-
bucket: 'dm' | 'group' | 'open',
|
|
67
|
+
bucket: 'dm' | 'group' | 'open' | 'square',
|
|
67
68
|
origin: Extract<MatchableOrigin, { kind: 'channel' }>,
|
|
68
69
|
): boolean {
|
|
69
70
|
const platform = ADAPTER_TO_PLATFORM[origin.adapter]
|
|
71
|
+
if (platform === 'line') {
|
|
72
|
+
if (bucket === 'dm') return origin.workspace === '@line-dm'
|
|
73
|
+
if (bucket === 'group') return origin.workspace === '@line-group'
|
|
74
|
+
if (bucket === 'square') return origin.workspace === '@line-square'
|
|
75
|
+
return false
|
|
76
|
+
}
|
|
70
77
|
if (platform === 'kakao') {
|
|
71
78
|
if (bucket === 'dm') return origin.workspace === '@kakao-dm'
|
|
72
79
|
if (bucket === 'group') return origin.workspace === '@kakao-group'
|
|
@@ -25,11 +25,15 @@ export type PartialChannelOrigin = {
|
|
|
25
25
|
authorId: string
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
const ADAPTER_TO_PLATFORM: Record<
|
|
28
|
+
const ADAPTER_TO_PLATFORM: Record<
|
|
29
|
+
ChannelKey['adapter'],
|
|
30
|
+
'slack' | 'discord' | 'telegram' | 'kakao' | 'line' | 'github'
|
|
31
|
+
> = {
|
|
29
32
|
'slack-bot': 'slack',
|
|
30
33
|
'discord-bot': 'discord',
|
|
31
34
|
github: 'github',
|
|
32
35
|
'telegram-bot': 'telegram',
|
|
36
|
+
line: 'line',
|
|
33
37
|
kakaotalk: 'kakao',
|
|
34
38
|
}
|
|
35
39
|
|
package/src/run/index.ts
CHANGED
|
@@ -30,9 +30,11 @@ import {
|
|
|
30
30
|
import { createTunnelBridge, type TunnelBridge } from '@/channels/tunnel-bridge'
|
|
31
31
|
import { createConfigReloadable, getConfig, loadConfigSync, loadPluginConfigsSync, reloadConfig } from '@/config'
|
|
32
32
|
import {
|
|
33
|
+
type CountStore,
|
|
33
34
|
type CronConsumer,
|
|
34
35
|
type CronJob,
|
|
35
36
|
type CronFile,
|
|
37
|
+
createCountStore,
|
|
36
38
|
createCronConsumer,
|
|
37
39
|
createCronReloadable,
|
|
38
40
|
createScheduler,
|
|
@@ -77,7 +79,12 @@ type BunServer = ReturnType<Server['start']>
|
|
|
77
79
|
export type TuiFactory = (options: TuiOptions) => { run: () => Promise<unknown> }
|
|
78
80
|
|
|
79
81
|
export type LoadCronFn = (agentDir: string, options?: { subagents?: SubagentRegistry }) => Promise<LoadCronResult>
|
|
80
|
-
export type SchedulerFactory = (options: {
|
|
82
|
+
export type SchedulerFactory = (options: {
|
|
83
|
+
cwd: string
|
|
84
|
+
file: CronFile
|
|
85
|
+
onFire: (job: CronJob) => void
|
|
86
|
+
onCountStore?: (store: CountStore) => void
|
|
87
|
+
}) => Scheduler | Promise<Scheduler>
|
|
81
88
|
export type ChannelManagerFactory = typeof createChannelManager
|
|
82
89
|
export type TunnelManagerFactory = (options: TunnelManagerOptions) => TunnelManager
|
|
83
90
|
|
|
@@ -419,9 +426,23 @@ export async function startAgent({
|
|
|
419
426
|
})
|
|
420
427
|
subagentConsumer.start()
|
|
421
428
|
|
|
429
|
+
// Populated by startScheduler's factory (onCountStore). The consumer
|
|
430
|
+
// subscribes before this is set, but only touches the holder at fire time
|
|
431
|
+
// (reading the count via `get` and recording it via `increment`) — and the
|
|
432
|
+
// scheduler (the sole cron publisher) is armed only AFTER the holder is
|
|
433
|
+
// populated, so no count-limited fire can observe an undefined holder. If
|
|
434
|
+
// another cron publisher is ever added, create the store before this point.
|
|
435
|
+
let cronCountStore: CountStore | undefined
|
|
422
436
|
const cronConsumer = createCronConsumer({
|
|
423
437
|
stream,
|
|
424
438
|
cwd,
|
|
439
|
+
countStore: {
|
|
440
|
+
get: (id, job) => cronCountStore?.get(id, job) ?? 0,
|
|
441
|
+
// Holder is always set before any fire (see above); the `false` fallback
|
|
442
|
+
// fails safe — skip dispatch rather than run an uncounted count-job — for
|
|
443
|
+
// the unreachable case where a fire somehow predates the holder.
|
|
444
|
+
increment: (id, job, at) => cronCountStore?.increment(id, job, at) ?? Promise.resolve(false),
|
|
445
|
+
},
|
|
425
446
|
invokeHandler: async (job) => {
|
|
426
447
|
const snap = pluginRuntime.get()
|
|
427
448
|
const registered = snap.registry.cronJobs.find((j) => j.globalId === job.id)
|
|
@@ -532,6 +553,11 @@ export async function startAgent({
|
|
|
532
553
|
|
|
533
554
|
const internalJobs = () => pluginCronJobs(pluginRuntime.get().registry)
|
|
534
555
|
const factory = createSchedulerFor ?? makeDefaultSchedulerFactory(internalJobs)
|
|
556
|
+
// Subscribe the consumer BEFORE the scheduler arms any timers. The stream
|
|
557
|
+
// delivers only to live subscribers (no replay), so a fire published before
|
|
558
|
+
// the subscription exists would be lost. Subscribing to an empty stream is
|
|
559
|
+
// harmless when there are no jobs.
|
|
560
|
+
cronConsumer.start()
|
|
535
561
|
const scheduler = await startScheduler({
|
|
536
562
|
cwd,
|
|
537
563
|
loadCron,
|
|
@@ -539,10 +565,12 @@ export async function startAgent({
|
|
|
539
565
|
stream,
|
|
540
566
|
hasInternalJobs: internalJobs().length > 0,
|
|
541
567
|
getSubagents: () => pluginRuntime.get().subagents,
|
|
568
|
+
onCountStore: (store) => {
|
|
569
|
+
cronCountStore = store
|
|
570
|
+
},
|
|
542
571
|
})
|
|
543
572
|
|
|
544
573
|
if (scheduler) {
|
|
545
|
-
cronConsumer.start()
|
|
546
574
|
reloadRegistry.register(
|
|
547
575
|
createCronReloadable({ cwd, scheduler, internalJobs, getSubagents: () => pluginRuntime.get().subagents }),
|
|
548
576
|
)
|
|
@@ -721,6 +749,7 @@ export async function startAgent({
|
|
|
721
749
|
...mcpManagerOpt,
|
|
722
750
|
agentDir: cwd,
|
|
723
751
|
pluginRuntime,
|
|
752
|
+
getFiredCount: (job) => cronCountStore?.get(job.id, job) ?? 0,
|
|
724
753
|
claimController,
|
|
725
754
|
commandRunnerFactory,
|
|
726
755
|
tunnelManager,
|
|
@@ -838,6 +867,7 @@ async function startScheduler({
|
|
|
838
867
|
stream,
|
|
839
868
|
hasInternalJobs,
|
|
840
869
|
getSubagents,
|
|
870
|
+
onCountStore,
|
|
841
871
|
}: {
|
|
842
872
|
cwd: string
|
|
843
873
|
loadCron: LoadCronFn
|
|
@@ -845,6 +875,7 @@ async function startScheduler({
|
|
|
845
875
|
stream: Stream
|
|
846
876
|
hasInternalJobs: boolean
|
|
847
877
|
getSubagents?: () => SubagentRegistry
|
|
878
|
+
onCountStore?: (store: CountStore) => void
|
|
848
879
|
}): Promise<Scheduler | null> {
|
|
849
880
|
let result: LoadCronResult
|
|
850
881
|
const subagents = getSubagents?.()
|
|
@@ -864,13 +895,19 @@ async function startScheduler({
|
|
|
864
895
|
const onFire = (job: CronJob) => {
|
|
865
896
|
stream.publish({ target: { kind: 'cron', jobId: job.id }, payload: job })
|
|
866
897
|
}
|
|
867
|
-
const scheduler = createSchedulerFor({ cwd, file, onFire })
|
|
898
|
+
const scheduler = await createSchedulerFor({ cwd, file, onFire, onCountStore })
|
|
868
899
|
scheduler.start()
|
|
869
900
|
return scheduler
|
|
870
901
|
}
|
|
871
902
|
|
|
872
903
|
function makeDefaultSchedulerFactory(internalJobs: () => CronJob[]): SchedulerFactory {
|
|
873
|
-
return ({ file, onFire }) =>
|
|
904
|
+
return async ({ cwd, file, onFire, onCountStore }) => {
|
|
905
|
+
const jobs = [...file.jobs, ...internalJobs()]
|
|
906
|
+
const countStore = await createCountStore(cwd, jobs)
|
|
907
|
+
// Share the one store instance with the consumer's authoritative count gate.
|
|
908
|
+
onCountStore?.(countStore)
|
|
909
|
+
return createScheduler({ jobs, onFire, countStore })
|
|
910
|
+
}
|
|
874
911
|
}
|
|
875
912
|
|
|
876
913
|
// Exported for the regression test in `merge-subagents.test.ts`. The shim
|