typeclaw 0.11.1 → 0.13.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/README.md +1 -1
- package/package.json +1 -1
- package/scripts/dump-system-prompt.ts +12 -11
- package/src/agent/index.ts +15 -22
- package/src/agent/loop-guard.ts +170 -0
- package/src/agent/model-fallback.ts +2 -1
- package/src/agent/multimodal/index.ts +1 -1
- package/src/agent/multimodal/look-at.ts +118 -55
- package/src/agent/plugin-tools.ts +57 -0
- package/src/agent/subagents.ts +2 -1
- package/src/agent/system-prompt.ts +28 -25
- package/src/agent/tools/channel-fetch-attachment.ts +45 -16
- package/src/agent/tools/normalize-ref.ts +11 -0
- package/src/bundled-plugins/reviewer/index.ts +11 -0
- package/src/bundled-plugins/reviewer/reviewer.ts +171 -0
- package/src/bundled-plugins/reviewer/skills/code-review.ts +73 -0
- package/src/bundled-plugins/reviewer/skills/general.ts +68 -0
- package/src/channels/adapters/discord-bot-classify.ts +32 -24
- package/src/channels/adapters/github/inbound.ts +19 -2
- package/src/channels/adapters/kakaotalk-attachment.ts +140 -133
- package/src/channels/adapters/kakaotalk-classify.ts +8 -1
- package/src/channels/adapters/kakaotalk.ts +19 -11
- package/src/channels/adapters/slack-bot-classify.ts +30 -14
- package/src/channels/adapters/slack-bot.ts +3 -2
- package/src/channels/adapters/telegram-bot-classify.ts +36 -13
- package/src/channels/adapters/telegram-bot.ts +3 -3
- package/src/channels/outbound-flood-filter.ts +57 -0
- package/src/channels/router.ts +93 -5
- package/src/channels/types.ts +52 -1
- package/src/cli/builtins.ts +2 -0
- package/src/cli/index.ts +2 -0
- package/src/cli/mount.ts +157 -0
- package/src/cli/update.ts +84 -0
- package/src/config/mounts-mutation.ts +161 -0
- package/src/init/hatching.ts +1 -1
- package/src/plugin/index.ts +6 -0
- package/src/plugin/load-skill.ts +99 -0
- package/src/run/bundled-plugins.ts +2 -0
- package/src/run/index.ts +14 -1
- package/src/secrets/codex-auth-json.ts +67 -0
- package/src/secrets/export-codex-auth-file.ts +243 -0
- package/src/secrets/index.ts +6 -0
- package/src/server/command-runner.ts +2 -1
- package/src/server/index.ts +3 -2
- package/src/shared/index.ts +7 -1
- package/src/shared/local-time.ts +32 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +47 -13
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +10 -11
- package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +8 -0
- package/src/skills/typeclaw-codex-cli/SKILL.md +2 -1
- package/src/skills/typeclaw-codex-cli/references/auth-flow.md +22 -0
- package/src/skills/typeclaw-kaomoji/SKILL.md +116 -0
- package/src/update/index.ts +155 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmodSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
readlinkSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
statSync,
|
|
8
|
+
unlinkSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from 'node:fs'
|
|
11
|
+
import { homedir } from 'node:os'
|
|
12
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
13
|
+
|
|
14
|
+
import { decodeCodexAccessTokenExpiryMs, emitCodexAuthJson } from './codex-auth-json'
|
|
15
|
+
import type { ProviderCredential, Providers } from './schema'
|
|
16
|
+
import { SecretsBackend } from './storage'
|
|
17
|
+
|
|
18
|
+
const FILE_MODE = 0o600
|
|
19
|
+
const DIR_MODE = 0o700
|
|
20
|
+
|
|
21
|
+
export type ExportCodexAuthFileResult =
|
|
22
|
+
| { action: 'skipped'; reason: SkipReason }
|
|
23
|
+
| { action: 'wrote'; path: string }
|
|
24
|
+
| { action: 'failed'; reason: string }
|
|
25
|
+
|
|
26
|
+
export type SkipReason =
|
|
27
|
+
| 'codex-cli-disabled'
|
|
28
|
+
| 'no-openai-codex-credential'
|
|
29
|
+
| 'credential-not-oauth'
|
|
30
|
+
| 'on-disk-is-fresher'
|
|
31
|
+
|
|
32
|
+
export type ExportCodexAuthFileOptions = {
|
|
33
|
+
codexCliEnabled: boolean
|
|
34
|
+
providers: Providers
|
|
35
|
+
homeDir?: string
|
|
36
|
+
now?: () => number
|
|
37
|
+
log?: (message: string) => void
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Writes typeclaw's openai-codex OAuth credential to $HOME/.codex/auth.json
|
|
41
|
+
// when it's safe to do so. The Dockerfile entrypoint shim symlinks
|
|
42
|
+
// $HOME/.codex/auth.json to /agent/.typeclaw/home/.codex/auth.json on every
|
|
43
|
+
// boot, so the write follows the symlink and lands on the persistent
|
|
44
|
+
// host-side path — that's the stable contract from src/init/dockerfile.ts
|
|
45
|
+
// "link_persistent_home_files" and we MUST use it instead of writing to
|
|
46
|
+
// /agent/.typeclaw/home/ directly.
|
|
47
|
+
//
|
|
48
|
+
// Three guards, cheapest first. The first two return without ever touching
|
|
49
|
+
// the filesystem, which keeps the 90% case (users who don't enable Codex
|
|
50
|
+
// CLI) at zero overhead on every container start.
|
|
51
|
+
export function exportCodexAuthFileIfApplicable(options: ExportCodexAuthFileOptions): ExportCodexAuthFileResult {
|
|
52
|
+
if (!options.codexCliEnabled) return { action: 'skipped', reason: 'codex-cli-disabled' }
|
|
53
|
+
|
|
54
|
+
const credential = options.providers['openai-codex']
|
|
55
|
+
if (credential === undefined) return { action: 'skipped', reason: 'no-openai-codex-credential' }
|
|
56
|
+
if (credential.type !== 'oauth') return { action: 'skipped', reason: 'credential-not-oauth' }
|
|
57
|
+
|
|
58
|
+
const targetPath = join(options.homeDir ?? homedir(), '.codex', 'auth.json')
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
if (!shouldOverwrite(targetPath, credential, options.now ?? Date.now)) {
|
|
62
|
+
return { action: 'skipped', reason: 'on-disk-is-fresher' }
|
|
63
|
+
}
|
|
64
|
+
const contents = emitCodexAuthJson(credential)
|
|
65
|
+
writeAtomic(targetPath, contents)
|
|
66
|
+
return { action: 'wrote', path: targetPath }
|
|
67
|
+
} catch (err) {
|
|
68
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
69
|
+
options.log?.(`exportCodexAuthFile: ${reason}`)
|
|
70
|
+
return { action: 'failed', reason }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Newer-wins: skip the write unless typeclaw's stored credential is
|
|
75
|
+
// strictly fresher than the on-disk JWT. Codex CLI rotates tokens
|
|
76
|
+
// in-place (it rewrites auth.json with a refreshed access_token whose
|
|
77
|
+
// JWT exp is later), so on a restart the file may legitimately be ahead
|
|
78
|
+
// of secrets.json. We must not clobber that.
|
|
79
|
+
//
|
|
80
|
+
// Ties skip: when expiries match, there's nothing to gain from a write,
|
|
81
|
+
// and avoiding the I/O keeps the steady state at zero churn after the
|
|
82
|
+
// first boot. The only writes we ever do are first-write (B1), recovery
|
|
83
|
+
// (B6), or refresh-from-typeclaw-side (B3).
|
|
84
|
+
//
|
|
85
|
+
// On any error reading or parsing the on-disk file (missing, corrupt JSON,
|
|
86
|
+
// missing JWT, undecodable exp), we return true. That's the "we have a
|
|
87
|
+
// valid credential, the file is unusable, replace it" fallback case (B1
|
|
88
|
+
// and B6 in the design doc).
|
|
89
|
+
function shouldOverwrite(
|
|
90
|
+
targetPath: string,
|
|
91
|
+
credential: ProviderCredential & { expires?: unknown; access?: unknown },
|
|
92
|
+
now: () => number,
|
|
93
|
+
): boolean {
|
|
94
|
+
let raw: string
|
|
95
|
+
try {
|
|
96
|
+
raw = readFileSync(targetPath, 'utf8')
|
|
97
|
+
} catch {
|
|
98
|
+
return true
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let parsed: unknown
|
|
102
|
+
try {
|
|
103
|
+
parsed = JSON.parse(raw)
|
|
104
|
+
} catch {
|
|
105
|
+
return true
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const onDiskAccess = readOnDiskAccessToken(parsed)
|
|
109
|
+
if (onDiskAccess === null) return true
|
|
110
|
+
|
|
111
|
+
const onDiskExpiry = decodeCodexAccessTokenExpiryMs(onDiskAccess)
|
|
112
|
+
if (onDiskExpiry === null) return true
|
|
113
|
+
|
|
114
|
+
const credentialExpiry = readCredentialExpiry(credential, now)
|
|
115
|
+
return credentialExpiry > onDiskExpiry
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function readOnDiskAccessToken(parsed: unknown): string | null {
|
|
119
|
+
if (typeof parsed !== 'object' || parsed === null) return null
|
|
120
|
+
const tokens = (parsed as Record<string, unknown>)['tokens']
|
|
121
|
+
if (typeof tokens !== 'object' || tokens === null) return null
|
|
122
|
+
const access = (tokens as Record<string, unknown>)['access_token']
|
|
123
|
+
return typeof access === 'string' && access.length > 0 ? access : null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Resolution order for the credential's expiry:
|
|
127
|
+
// 1. The `expires` field pi-ai writes (absolute ms epoch).
|
|
128
|
+
// 2. The JWT `exp` claim decoded from `access`.
|
|
129
|
+
// 3. Now — guarantees we still write on first boot when the credential
|
|
130
|
+
// lacks both, rather than silently skipping forever.
|
|
131
|
+
function readCredentialExpiry(credential: { expires?: unknown; access?: unknown }, now: () => number): number {
|
|
132
|
+
if (typeof credential.expires === 'number' && Number.isFinite(credential.expires)) {
|
|
133
|
+
return credential.expires
|
|
134
|
+
}
|
|
135
|
+
if (typeof credential.access === 'string') {
|
|
136
|
+
const fromJwt = decodeCodexAccessTokenExpiryMs(credential.access)
|
|
137
|
+
if (fromJwt !== null) return fromJwt
|
|
138
|
+
}
|
|
139
|
+
return now()
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Atomic temp-then-rename, mirroring src/secrets/storage.ts's
|
|
143
|
+
// writeEnvelopeAtomic. The directory is created with 0700 and the file
|
|
144
|
+
// with 0600 because $HOME/.codex/auth.json holds a long-lived refresh
|
|
145
|
+
// token — leaking it via lax permissions defeats the whole point of
|
|
146
|
+
// running typeclaw on a multi-user host. The 0600 chmod after rename is
|
|
147
|
+
// belt-and-suspenders: writeFileSync's `mode` is applied at create time,
|
|
148
|
+
// but umask can mask it down on some filesystems.
|
|
149
|
+
//
|
|
150
|
+
// Symlink preservation: the entrypoint shim
|
|
151
|
+
// (src/init/dockerfile.ts link_persistent_home_files) installs
|
|
152
|
+
// $HOME/.codex/auth.json as a symlink to
|
|
153
|
+
// /agent/.typeclaw/home/.codex/auth.json on every boot. POSIX rename(2)
|
|
154
|
+
// replaces the directory entry at the destination atomically — it does
|
|
155
|
+
// NOT follow symlinks — so a naive `renameSync(tmp, $HOME/.codex/auth.json)`
|
|
156
|
+
// would replace the symlink with a regular file, leaving the persistent
|
|
157
|
+
// path empty. Next boot the shim recreates the symlink (force-removing
|
|
158
|
+
// our file), the persistent path is still empty, and Codex's in-place
|
|
159
|
+
// token refresh is silently lost on every restart.
|
|
160
|
+
//
|
|
161
|
+
// Fix: resolve the symlink target with readlinkSync and rename against
|
|
162
|
+
// the real path so the symlink itself is preserved. The temp file MUST
|
|
163
|
+
// live alongside the real target (same filesystem) because renameSync
|
|
164
|
+
// across filesystems fails with EXDEV — $HOME is the container's
|
|
165
|
+
// overlayfs, but the symlink target is a bind-mounted host path.
|
|
166
|
+
function writeAtomic(targetPath: string, contents: string): void {
|
|
167
|
+
const realTarget = resolveSymlinkTarget(targetPath)
|
|
168
|
+
const dir = dirname(realTarget)
|
|
169
|
+
mkdirSync(dir, { recursive: true, mode: DIR_MODE })
|
|
170
|
+
const tmp = `${realTarget}.${process.pid}.${Date.now()}.tmp`
|
|
171
|
+
writeFileSync(tmp, contents, { encoding: 'utf8', mode: FILE_MODE })
|
|
172
|
+
try {
|
|
173
|
+
renameSync(tmp, realTarget)
|
|
174
|
+
} catch (err) {
|
|
175
|
+
try {
|
|
176
|
+
unlinkSync(tmp)
|
|
177
|
+
} catch {
|
|
178
|
+
// best-effort cleanup of the temp file when rename fails
|
|
179
|
+
}
|
|
180
|
+
throw err
|
|
181
|
+
}
|
|
182
|
+
// statSync + chmodSync rather than unconditional chmod so a 0644 file
|
|
183
|
+
// installed by something else stays visible in tests (we WANT to overwrite
|
|
184
|
+
// permissions when we own the file).
|
|
185
|
+
try {
|
|
186
|
+
statSync(realTarget)
|
|
187
|
+
chmodSync(realTarget, FILE_MODE)
|
|
188
|
+
} catch {
|
|
189
|
+
// ignore — file vanished between rename and chmod is benign
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Returns the absolute path renameSync should target. When `path` is a
|
|
194
|
+
// symlink (production: $HOME/.codex/auth.json -> /agent/.typeclaw/home/...),
|
|
195
|
+
// returns the resolved absolute target so we write through the link
|
|
196
|
+
// instead of replacing it. Otherwise (tests, or first boot before the
|
|
197
|
+
// shim installs the symlink — though the shim runs before the agent in
|
|
198
|
+
// production), returns the path unchanged.
|
|
199
|
+
//
|
|
200
|
+
// readlinkSync throws EINVAL when the path exists but isn't a symlink,
|
|
201
|
+
// and ENOENT when nothing is there. Either case → write to the original
|
|
202
|
+
// path; the parent-dir mkdir + atomic rename handle the rest. We don't
|
|
203
|
+
// distinguish errno because both have the same fallback.
|
|
204
|
+
function resolveSymlinkTarget(path: string): string {
|
|
205
|
+
let link: string
|
|
206
|
+
try {
|
|
207
|
+
link = readlinkSync(path)
|
|
208
|
+
} catch {
|
|
209
|
+
return path
|
|
210
|
+
}
|
|
211
|
+
return isAbsolute(link) ? link : resolve(dirname(path), link)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export type ExportCodexAuthFileForAgentOptions = {
|
|
215
|
+
agentDir: string
|
|
216
|
+
codexCliEnabled: boolean
|
|
217
|
+
homeDir?: string
|
|
218
|
+
log?: (message: string) => void
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Boot-time convenience wrapper for src/run/index.ts. Mirrors
|
|
222
|
+
// hydrateChannelEnvFromSecrets's contract: takes agentDir, never throws,
|
|
223
|
+
// returns a result the caller can ignore. Secrets-file read failures are
|
|
224
|
+
// caught and surfaced as a 'failed' result so the agent boot is not blocked
|
|
225
|
+
// by a missing or malformed secrets.json — same non-fatal policy hydrate
|
|
226
|
+
// uses on the channels slice.
|
|
227
|
+
export function exportCodexAuthFileForAgent(options: ExportCodexAuthFileForAgentOptions): ExportCodexAuthFileResult {
|
|
228
|
+
if (!options.codexCliEnabled) return { action: 'skipped', reason: 'codex-cli-disabled' }
|
|
229
|
+
let providers: Providers
|
|
230
|
+
try {
|
|
231
|
+
providers = new SecretsBackend(join(options.agentDir, 'secrets.json')).tryReadProvidersSync()
|
|
232
|
+
} catch (err) {
|
|
233
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
234
|
+
options.log?.(`exportCodexAuthFile: ${reason}`)
|
|
235
|
+
return { action: 'failed', reason }
|
|
236
|
+
}
|
|
237
|
+
return exportCodexAuthFileIfApplicable({
|
|
238
|
+
codexCliEnabled: options.codexCliEnabled,
|
|
239
|
+
providers,
|
|
240
|
+
...(options.homeDir !== undefined ? { homeDir: options.homeDir } : {}),
|
|
241
|
+
...(options.log !== undefined ? { log: options.log } : {}),
|
|
242
|
+
})
|
|
243
|
+
}
|
package/src/secrets/index.ts
CHANGED
|
@@ -7,3 +7,9 @@ export { type Secret } from './resolve'
|
|
|
7
7
|
export { hydrateChannelEnvFromSecrets } from './hydrate'
|
|
8
8
|
|
|
9
9
|
export { migrateKakaotalkCredentials } from './migrate-kakaotalk'
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
type ExportCodexAuthFileResult,
|
|
13
|
+
exportCodexAuthFileForAgent,
|
|
14
|
+
exportCodexAuthFileIfApplicable,
|
|
15
|
+
} from './export-codex-auth-file'
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createSessionWithDispose,
|
|
3
|
+
renderTurnTimeAnchor,
|
|
3
4
|
type CreateSessionOptions,
|
|
4
5
|
type CreateSessionResult,
|
|
5
6
|
type SessionOrigin,
|
|
@@ -392,7 +393,7 @@ export async function runPromptForCommand(args: {
|
|
|
392
393
|
})
|
|
393
394
|
const detachAbort = bindSignalToSession(args.signal, session)
|
|
394
395
|
try {
|
|
395
|
-
await session.prompt(args.text)
|
|
396
|
+
await session.prompt(`${renderTurnTimeAnchor()}\n\n${args.text}`)
|
|
396
397
|
return session.getLastAssistantText() ?? ''
|
|
397
398
|
} finally {
|
|
398
399
|
detachAbort()
|
package/src/server/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { Server as BunServer, ServerWebSocket } from 'bun'
|
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
5
|
createSessionWithDispose as defaultCreateSessionWithDispose,
|
|
6
|
+
renderTurnTimeAnchor,
|
|
6
7
|
type AgentSession,
|
|
7
8
|
type CreateSessionOptions,
|
|
8
9
|
type CreateSessionResult,
|
|
@@ -711,7 +712,7 @@ export function createServer({
|
|
|
711
712
|
})
|
|
712
713
|
}
|
|
713
714
|
try {
|
|
714
|
-
await state.session.prompt(msg.text)
|
|
715
|
+
await state.session.prompt(`${renderTurnTimeAnchor()}\n\n${msg.text}`)
|
|
715
716
|
send(ws, { type: 'done' })
|
|
716
717
|
} catch (err) {
|
|
717
718
|
const message = err instanceof Error ? err.message : String(err)
|
|
@@ -951,7 +952,7 @@ async function drain(ws: Ws, state: SessionState, agentDir: string | undefined,
|
|
|
951
952
|
|
|
952
953
|
await fireTurnStart(item.text)
|
|
953
954
|
try {
|
|
954
|
-
await state.session.prompt(item.text)
|
|
955
|
+
await state.session.prompt(`${renderTurnTimeAnchor()}\n\n${item.text}`)
|
|
955
956
|
send(ws, { type: 'done' })
|
|
956
957
|
} catch (err) {
|
|
957
958
|
const message = err instanceof Error ? err.message : String(err)
|
package/src/shared/index.ts
CHANGED
|
@@ -24,4 +24,10 @@ export {
|
|
|
24
24
|
type TunnelSnapshot,
|
|
25
25
|
} from './protocol'
|
|
26
26
|
|
|
27
|
-
export {
|
|
27
|
+
export {
|
|
28
|
+
formatLocalDate,
|
|
29
|
+
formatLocalDateTime,
|
|
30
|
+
formatLocalWeekday,
|
|
31
|
+
type LocalWeekday,
|
|
32
|
+
resolveLocalTimezoneName,
|
|
33
|
+
} from './local-time'
|
package/src/shared/local-time.ts
CHANGED
|
@@ -36,3 +36,35 @@ export function resolveLocalTimezoneName(): string {
|
|
|
36
36
|
return 'UTC'
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
|
+
|
|
40
|
+
// English + Korean weekday name pair for a given Date. The per-turn time
|
|
41
|
+
// anchor renders both so the model has the answer to "what day is it"
|
|
42
|
+
// without computing weekday-from-ISO-date — a step LLMs get wrong often
|
|
43
|
+
// enough to matter, especially when answering in a non-English language.
|
|
44
|
+
// Pre-computing in both candidate reply languages removes the arithmetic
|
|
45
|
+
// step entirely instead of trusting the model to do it correctly each
|
|
46
|
+
// turn.
|
|
47
|
+
//
|
|
48
|
+
// Uses Intl.DateTimeFormat with explicit locales. No `timeZone` option:
|
|
49
|
+
// the container's local clock is already host-local (the entrypoint
|
|
50
|
+
// propagates TZ via `-e TZ=<host-tz>`), so the runtime's default zone is
|
|
51
|
+
// the one the user sees. Both locales fall back to the hand-rolled
|
|
52
|
+
// 7-entry lookup if Intl throws (no-tzdata, locked-down sandbox) — the
|
|
53
|
+
// fallback names stay readable and never make the prefix empty.
|
|
54
|
+
const WEEKDAYS_EN = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] as const
|
|
55
|
+
const WEEKDAYS_KO = ['일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일'] as const
|
|
56
|
+
|
|
57
|
+
export type LocalWeekday = { en: string; ko: string }
|
|
58
|
+
|
|
59
|
+
export function formatLocalWeekday(date: Date = new Date()): LocalWeekday {
|
|
60
|
+
const dow = date.getDay()
|
|
61
|
+
const fallback: LocalWeekday = { en: WEEKDAYS_EN[dow]!, ko: WEEKDAYS_KO[dow]! }
|
|
62
|
+
try {
|
|
63
|
+
return {
|
|
64
|
+
en: new Intl.DateTimeFormat('en-US', { weekday: 'long' }).format(date),
|
|
65
|
+
ko: new Intl.DateTimeFormat('ko-KR', { weekday: 'long' }).format(date),
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
return fallback
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: typeclaw-channel-github
|
|
3
|
-
description: Use this skill BEFORE every `channel_reply` or `channel_send` call whose adapter is `github`, AND before composing replies to GitHub-originated inbounds, AND before opening new issues or PRs with `gh`, AND ALWAYS when an inbound says "requested your review on PR #N" or "requested a review from team @… on PR #N" (the agent has been assigned as a reviewer and must
|
|
3
|
+
description: Use this skill BEFORE every `channel_reply` or `channel_send` call whose adapter is `github`, AND before composing replies to GitHub-originated inbounds, AND before opening new issues or PRs with `gh`, AND ALWAYS when an inbound says "requested your review on PR #N" or "requested a review from team @… on PR #N" (the agent has been assigned as a reviewer and must delegate the analysis to the `reviewer` subagent, then translate its findings into line-by-line comments via `gh api`). GitHub renders **real markdown** — `**bold**`, `## headings`, `| tables |`, fenced code blocks, and `inline code` all render natively. Use rich markdown freely. GitHub cannot send file attachments via API — do not call `channel_send` with attachments on github chats. GitHub has no typing indicator. PR review threads use `thread` keyed on the root comment id; reply to a thread to stay in it, or omit `thread` to post a top-level issue/PR comment. To open new issues or PRs use the `gh` CLI — `GH_TOKEN` is pre-set by the adapter. Read this skill before composing anything on GitHub.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
GitHub renders normal Markdown in issues, PRs, discussions, and review comments. Use headings, lists, tables, fenced code blocks, links, and inline code when they improve clarity.
|
|
@@ -25,27 +25,56 @@ For App auth, `GH_TOKEN` is an installation access token that refreshes automati
|
|
|
25
25
|
|
|
26
26
|
## Reviewing pull requests
|
|
27
27
|
|
|
28
|
-
When an incoming message says **"requested your review on PR #N"** (or "requested a review from team @… on PR #N"), you have been assigned as a reviewer. Do
|
|
28
|
+
When an incoming message says **"requested your review on PR #N"** (or "requested a review from team @… on PR #N"), you have been assigned as a reviewer. Do **not** review inline yourself and do **not** just reply in the channel — delegate the analysis to the bundled `reviewer` subagent, then translate its findings into line-by-line comments via `gh api`.
|
|
29
|
+
|
|
30
|
+
Why delegate: the `reviewer` subagent runs on the `deep` model profile, loads a curated `code-review` skill on demand, and produces a structured `<review>` block with severity-tagged findings. You are the integration layer between that output and GitHub's review API.
|
|
29
31
|
|
|
30
32
|
### Workflow
|
|
31
33
|
|
|
32
|
-
1. **
|
|
34
|
+
1. **Confirm the target.** Capture the PR number, the repo, and the head SHA — you'll need the SHA to read files at the revision the reviewer analyzed.
|
|
33
35
|
|
|
34
36
|
```sh
|
|
35
|
-
gh pr diff <N> --repo owner/repo
|
|
36
37
|
gh pr view <N> --repo owner/repo --json title,body,baseRefName,headRefOid,files
|
|
37
38
|
```
|
|
38
39
|
|
|
39
|
-
2. **
|
|
40
|
+
2. **Spawn the `reviewer` subagent with the PR target.** Use `run_in_background: true` so you stay responsive while the deep model works. Pass the PR URL (or `owner/repo#N`) plus any context the requester gave you (focus areas, specific files, etc.) so the reviewer knows what the requester cares about.
|
|
41
|
+
|
|
42
|
+
The reviewer will fetch the diff itself (`gh pr diff`, `gh api /repos/.../pulls/<n>`), load the matching skill (`code-review` for a code PR; `general` for a mixed-format change), and return a `<review>` block.
|
|
43
|
+
|
|
44
|
+
3. **Wait for the completion `<system-reminder>`,** then call `subagent_output({ task_id })` to read the reviewer's final assistant message. The structured payload looks like:
|
|
45
|
+
|
|
46
|
+
```xml
|
|
47
|
+
<review>
|
|
48
|
+
<summary>...</summary>
|
|
49
|
+
<findings>
|
|
50
|
+
<finding severity="blocker|concern|nit|praise" location="path:line">
|
|
51
|
+
<issue>...</issue>
|
|
52
|
+
<evidence>...</evidence>
|
|
53
|
+
<suggestion>...</suggestion>
|
|
54
|
+
</finding>
|
|
55
|
+
</findings>
|
|
56
|
+
<verdict>approve | request-changes | comment</verdict>
|
|
57
|
+
</review>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
4. **Translate findings into a `gh api` review payload.** Each `<finding>` with `severity` of `blocker`, `concern`, or `nit` and a `location="path:line"` becomes one entry in `comments[]`. Compose the inline `body` from the reviewer's `<issue>` + `<evidence>` + `<suggestion>` — preserve the reviewer's wording, do not paraphrase. Findings whose `location` is `general` (no file:line anchor) go into the top-level review `body` instead. **Skip `praise` findings when building `comments[]`** — they are not actionable, and inline praise comments are exactly the noise the reviewer is supposed to filter out at the source; if you want to surface them, weave them into the top-level review `body` alongside the summary. Map the reviewer's `<verdict>` to the GitHub `event`:
|
|
61
|
+
|
|
62
|
+
| Reviewer verdict | GitHub `event` |
|
|
63
|
+
| ----------------- | ----------------- |
|
|
64
|
+
| `approve` | `APPROVE` |
|
|
65
|
+
| `request-changes` | `REQUEST_CHANGES` |
|
|
66
|
+
| `comment` | `COMMENT` |
|
|
67
|
+
|
|
68
|
+
Then submit the review in one API call:
|
|
40
69
|
|
|
41
70
|
```sh
|
|
42
71
|
cat <<'JSON' | gh api -X POST /repos/owner/repo/pulls/<N>/reviews --input -
|
|
43
72
|
{
|
|
44
73
|
"event": "COMMENT",
|
|
45
|
-
"body": "
|
|
74
|
+
"body": "<reviewer's <summary> goes here>",
|
|
46
75
|
"comments": [
|
|
47
|
-
{ "path": "src/foo.ts", "line": 42, "side": "RIGHT", "body": "
|
|
48
|
-
{ "path": "src/bar.ts", "line": 10, "side": "RIGHT", "body": "
|
|
76
|
+
{ "path": "src/foo.ts", "line": 42, "side": "RIGHT", "body": "<issue + evidence + suggestion from the reviewer's finding>" },
|
|
77
|
+
{ "path": "src/bar.ts", "line": 10, "side": "RIGHT", "body": "..." }
|
|
49
78
|
]
|
|
50
79
|
}
|
|
51
80
|
JSON
|
|
@@ -53,17 +82,22 @@ When an incoming message says **"requested your review on PR #N"** (or "requeste
|
|
|
53
82
|
|
|
54
83
|
**Always use `--input -` with a quoted heredoc (`<<'JSON'`) for review bodies.** Do **not** use `-f body=...` or `-F 'comments[][body]=...'`: those go through shell argument parsing, so backticks (\`) trigger command substitution and have to be backslash-escaped, which leaks the literal `\` into the rendered comment. The quoted heredoc passes the JSON through untouched — backticks, newlines, and `${...}` all survive verbatim. The same applies to any other `gh api` POST whose body contains backticks, embedded newlines, or shell metacharacters.
|
|
55
84
|
|
|
56
|
-
|
|
85
|
+
5. **Post a one-line summary with `channel_reply`** so the conversation has a human-readable trace pointing at the review (e.g., "Posted review on PR #N: <verdict>, N findings.").
|
|
57
86
|
|
|
58
87
|
### Rules
|
|
59
88
|
|
|
60
|
-
-
|
|
61
|
-
- **
|
|
89
|
+
- **Always delegate to the `reviewer` subagent.** Do not perform the review craft yourself. The reviewer is the source of truth for severity, evidence quality, and what counts as a finding. Your job is mechanics: spawn, wait, translate, post.
|
|
90
|
+
- **Trust the verdict.** Use the GitHub `event` mapped from the reviewer's `<verdict>`. Do not upgrade `comment` → `APPROVE` to seem agreeable, and do not downgrade `request-changes` → `COMMENT` to soften the tone. The reviewer chose deliberately.
|
|
91
|
+
- **No actionable findings → no inline review post.** A finding is "actionable" if its severity is `blocker`, `concern`, or `nit`. If the reviewer returns zero actionable findings:
|
|
92
|
+
- `approve` verdict → post a plain `APPROVE` with the `<summary>` as the review body (no `comments[]` array).
|
|
93
|
+
- `comment` verdict → post the summary as a top-level PR comment via `gh api -X POST /repos/.../issues/<N>/comments` instead of submitting an empty review.
|
|
94
|
+
- `request-changes` verdict → submit `REQUEST_CHANGES` with the `<summary>` as the review body and no `comments[]` array. This combination is rare (the reviewer's contract says `request-changes` requires at least one blocker or load-bearing concern), so if it happens, faithfully encode the verdict and trust the reviewer's reasoning is in the summary.
|
|
95
|
+
- **Preserve the reviewer's wording.** Inline comment bodies should reflect the reviewer's `<issue>`, `<evidence>`, and `<suggestion>` verbatim (modulo markdown formatting). Paraphrasing dilutes the analysis — the deep-model reviewer chose those words on purpose.
|
|
62
96
|
- `line` is a line number **in the file**, not a position in the diff. `side: RIGHT` is the new revision (default for additions); `side: LEFT` is the old revision (use for comments on removed lines).
|
|
63
97
|
- For multi-line comments, also set `start_line` and `start_side` (same semantics).
|
|
64
|
-
- If you need to read whole files at the PR's head SHA, use `gh api /repos/owner/repo/contents/<path>?ref=<headRefOid>`.
|
|
98
|
+
- If you need to read whole files at the PR's head SHA, use `gh api /repos/owner/repo/contents/<path>?ref=<headRefOid>`. The reviewer can do this itself, but you may need to as well — e.g., when validating a finding's `location` against the actual file before posting.
|
|
65
99
|
- The bundled `agent-browser` is **not** for PR reviews — `gh api` is faster and more reliable. Only use the browser when the API genuinely can't reach what you need.
|
|
66
|
-
- A `review_request_removed` event means the requester un-assigned you.
|
|
100
|
+
- A `review_request_removed` event means the requester un-assigned you. Cancel any in-flight reviewer subagent (`subagent_cancel`) and do not post a partial review.
|
|
67
101
|
|
|
68
102
|
### Self-loop safety
|
|
69
103
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: typeclaw-channel-kakaotalk
|
|
3
|
-
description: Use this skill BEFORE every `channel_reply` or `channel_send` call whose adapter is `kakaotalk`, AND before
|
|
3
|
+
description: Use this skill BEFORE every `channel_reply` or `channel_send` call whose adapter is `kakaotalk`, AND before fetching/viewing KakaoTalk inbound attachments. KakaoTalk renders messages as plain text — `**bold**`, `## headings`, `| tables |`, fenced code blocks, and other markdown all appear literally. There is no `@mention` syntax, no message threads, no replies-with-quote, and no outbound stickers. Outbound file attachments (photos, videos, audio, generic files, multi-photo galleries) ARE supported — pass them via `attachments[]` on `channel_send` / `channel_reply` and the adapter routes by MIME. Inbound attachments appear as `[KakaoTalk attachment #N: ...]`; fetch with `channel_fetch_attachment({ attachment_id: N })` or view images with `look_at_channel_attachment({ attachment_id: N })`. Read this skill before composing or fetching anything on KakaoTalk.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# typeclaw-channel-kakaotalk
|
|
@@ -37,24 +37,23 @@ If you produce any of the following, KakaoTalk will render it literally and the
|
|
|
37
37
|
|
|
38
38
|
## Inbound attachments and stickers
|
|
39
39
|
|
|
40
|
-
Even though you cannot SEND
|
|
40
|
+
Even though you cannot SEND stickers, you DO receive attachments and stickers. The adapter surfaces incoming non-text content by appending a ref-free `[KakaoTalk attachment #N: <kind> <metadata>]` placeholder to the inbound text (same convention as Slack/Discord/Telegram). Examples of what you'll see:
|
|
41
41
|
|
|
42
|
-
- A photo (with no caption): `[KakaoTalk
|
|
43
|
-
- A photo with a caption: `look at this\n[KakaoTalk
|
|
44
|
-
- A file: `[KakaoTalk
|
|
45
|
-
- A video / audio
|
|
46
|
-
- A sticker / emoticon: `[KakaoTalk
|
|
47
|
-
- An animated sticker: `[KakaoTalk message with sticker (sticker_ani) pack=... path=...]`
|
|
42
|
+
- A photo (with no caption): `[KakaoTalk attachment #1: photo 1320x2868 image/jpeg]`
|
|
43
|
+
- A photo with a caption: `look at this\n[KakaoTalk attachment #1: photo 1320x2868 image/jpeg]`
|
|
44
|
+
- A file: `[KakaoTalk attachment #1: file application/pdf name=spec.pdf size=12345]`
|
|
45
|
+
- A video / audio / multiphoto: `[KakaoTalk attachment #1: video video/mp4]` or `[KakaoTalk attachment #1: multiphoto]`
|
|
46
|
+
- A sticker / emoticon: `[KakaoTalk attachment #1: sticker name=4412724.emot_001.webp]`
|
|
48
47
|
|
|
49
48
|
### Fetching attachment bytes
|
|
50
49
|
|
|
51
|
-
For photos, files, and any video / audio / multiphoto
|
|
50
|
+
For photos, files, and any video / audio / multiphoto with an attachment token, call `channel_fetch_attachment` with the numeric `attachment_id` from the token to download the bytes. To view an image directly, call `look_at_channel_attachment` with the same `attachment_id`.
|
|
52
51
|
|
|
53
52
|
Use this when you actually need to look at the content — e.g. the user sends a screenshot and asks "what's in this?". The download lands in your inbox directory and you can pass it to a vision-capable inspection tool or read it directly depending on the file type.
|
|
54
53
|
|
|
55
|
-
|
|
54
|
+
If no attachment token appears in the inbound text, no attachment was sent. Do not invent attachment ids — the tool will reject ids that do not appear in the current turn.
|
|
56
55
|
|
|
57
|
-
**Stickers cannot be fetched** as bytes through this tool.
|
|
56
|
+
**Stickers cannot be fetched** as bytes through this tool. Treat stickers as descriptive metadata only — acknowledge them ("cute sticker") without trying to "see" them.
|
|
58
57
|
|
|
59
58
|
If the inbound text is JUST a sticker (no accompanying text), the agent still gets a routed event — stickers count as engagement under `reply` and `dm` triggers (group chats with only sticker activity will not trigger `mention` because aliases require text matching).
|
|
60
59
|
|
|
@@ -48,6 +48,14 @@ You do NOT need to manually escape any of `_ * [ ] ( ) ~ \` > # + - = | { } . !`
|
|
|
48
48
|
|
|
49
49
|
URLs containing **unescaped parentheses** (Wikipedia-style `Foo_(bar)`) intentionally fall back to escaped literal text rather than render as a link — the adapter cannot disambiguate the closing `)` from a content paren. If you need to link such a URL, percent-encode the parens in the URL (`%28`, `%29`) before putting it in the link.
|
|
50
50
|
|
|
51
|
+
## Inbound attachments
|
|
52
|
+
|
|
53
|
+
Inbound Telegram messages with photos or documents show a ref-free attachment token in the text: `[Telegram attachment #N: <kind> <metadata>]`, for example `[Telegram attachment #1: photo 1280x960]` or `[Telegram attachment #1: file application/pdf name=spec.pdf]`.
|
|
54
|
+
|
|
55
|
+
- To download the attachment, call `channel_fetch_attachment` with `attachment_id: N`.
|
|
56
|
+
- To view an image, call `look_at_channel_attachment` with `attachment_id: N`.
|
|
57
|
+
- If no attachment token appears in the inbound, no attachment was sent. Do not invent attachment ids — the tool will reject ids that do not appear in the current turn.
|
|
58
|
+
|
|
51
59
|
## When the user says "your formatting looks broken"
|
|
52
60
|
|
|
53
61
|
Three classes of failure to triage in this order:
|
|
@@ -38,7 +38,8 @@ If `codex` is installed but no credential is set up, you have to broker the auth
|
|
|
38
38
|
|
|
39
39
|
**Decision rule, top to bottom:**
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
0. **typeclaw may have already done it for you.** If the agent was initialized with the `openai-codex` provider (the user pasted/ran OAuth into typeclaw itself), typeclaw writes `~/.codex/auth.json` automatically on every container start — provided `docker.file.codexCli: true` is set. Check `test -f ~/.codex/auth.json && jq -e '.tokens.access_token' ~/.codex/auth.json >/dev/null`; if both succeed, skip auth and go straight to delegation. The file is refreshed on every start via the newer-wins compare in `src/secrets/export-codex-auth-file.ts`, so a stale credential gets replaced without user intervention as long as the typeclaw-side credential is fresher.
|
|
42
|
+
1. **Already authenticated some other way?** Check both env (`env | grep -E '^(OPENAI_API_KEY|CODEX_API_KEY)='`) and on-disk (`test -f ~/.codex/auth.json`). If either resolves, skip auth entirely.
|
|
42
43
|
2. **User has an OpenAI API account** (api.openai.com billing, no ChatGPT Plus/Pro subscription) → API key path.
|
|
43
44
|
3. **User has a ChatGPT Plus / Pro / Team / Enterprise subscription and wants to use their subscription credits** → OAuth path via `codex login`.
|
|
44
45
|
4. **User is unsure** → ask which kind of OpenAI account they have. Both paths are equally low-friction. Pick by account shape, not by flow complexity.
|
|
@@ -4,6 +4,28 @@ Deep dive for the auth paths. Read it when `SKILL.md`'s "First-time auth (intera
|
|
|
4
4
|
|
|
5
5
|
The two paths are intentionally symmetric: in both, the user produces one artifact on their side, pastes it to you, you validate it, you do read-modify-write on `.env` (or write `~/.codex/auth.json`), you offer a restart. Only the credential medium differs.
|
|
6
6
|
|
|
7
|
+
## Path 0 — typeclaw-managed OAuth (auto-export, no user action)
|
|
8
|
+
|
|
9
|
+
Before walking the user through either interactive path, check whether typeclaw has already provisioned `~/.codex/auth.json` from its own secrets store. This is the canonical state for users who configured `openai-codex` as their typeclaw model backend during `typeclaw init`:
|
|
10
|
+
|
|
11
|
+
- `typeclaw init` ran the OAuth flow against pi-ai and wrote the credential to `secrets.json#providers.openai-codex` (shape: `{ type: 'oauth', access, refresh, expires, accountId }`).
|
|
12
|
+
- `docker.file.codexCli: true` is set in `typeclaw.json`, so the Codex CLI is installed in the container.
|
|
13
|
+
- On every `typeclaw start` / `typeclaw restart`, `src/run/index.ts`'s boot sequence calls `exportCodexAuthFileForAgent`, which:
|
|
14
|
+
- Returns early (zero filesystem touches) if `codexCli` is off or no `openai-codex` credential exists.
|
|
15
|
+
- Otherwise emits the modern `~/.codex/auth.json` shape (`{ tokens: { access_token, refresh_token, account_id? } }`) — no top-level `expires`, because Codex CLI re-derives expiry from the JWT on every load.
|
|
16
|
+
- Compares the JWT `exp` claim in the on-disk access token against typeclaw's stored expiry. If the on-disk token is the same or newer (Codex CLI rotated it in-place since the last typeclaw write), the file is left alone — no clobber. If typeclaw's copy is strictly fresher (the user re-pasted OAuth), the file is replaced atomically.
|
|
17
|
+
|
|
18
|
+
Detection check before launching the interactive flow:
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
test -f ~/.codex/auth.json \
|
|
22
|
+
&& jq -e '.tokens.access_token' ~/.codex/auth.json >/dev/null
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
If both succeed, the credential is ready; skip Paths A and B and proceed to delegation. If only the first succeeds but the second fails (file exists but no `tokens.access_token`), the file is either an API-key shape (legacy) or corrupt — the runtime exporter's next-start pass will overwrite it from `secrets.json` if typeclaw has a valid OAuth credential, but for the current delegation you can either re-run `typeclaw restart` to force the resync, or fall back to interactive Path A / Path B.
|
|
26
|
+
|
|
27
|
+
If the user has `docker.file.codexCli: true` but typeclaw was initialized with a non-`openai-codex` model backend (e.g. `anthropic`, `openai`, `fireworks`), Path 0 won't fire — the auto-export's gate-2 returns because `secrets.json#providers.openai-codex` is absent. The user's manually-pasted `~/.codex/auth.json` (if any) is never touched in that case. Fall through to Path A or Path B.
|
|
28
|
+
|
|
7
29
|
## Path A — API key (`OPENAI_API_KEY`)
|
|
8
30
|
|
|
9
31
|
The API key path is direct. Summary:
|