typeclaw 0.1.0 → 0.1.2
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 +12 -12
- package/package.json +3 -2
- package/src/agent/auth.ts +10 -4
- package/src/agent/doctor.ts +173 -0
- package/src/agent/subagents.ts +24 -2
- package/src/bundled-plugins/backup/README.md +81 -0
- package/src/bundled-plugins/backup/index.ts +209 -0
- package/src/bundled-plugins/backup/runner.ts +231 -0
- package/src/bundled-plugins/backup/subagents.ts +200 -0
- package/src/bundled-plugins/memory/index.ts +42 -1
- package/src/bundled-plugins/security/index.ts +5 -1
- package/src/bundled-plugins/security/policies/git-exfil.ts +184 -4
- package/src/bundled-plugins/security/policies/remote-taint-state.ts +59 -0
- package/src/channels/adapters/kakaotalk-attachment.ts +224 -0
- package/src/channels/adapters/kakaotalk-channel-resolver.ts +20 -1
- package/src/channels/adapters/kakaotalk-fetch-attachment.ts +91 -0
- package/src/channels/adapters/kakaotalk.ts +58 -3
- package/src/channels/router.ts +40 -2
- package/src/cli/compose.ts +92 -1
- package/src/cli/doctor.ts +100 -0
- package/src/cli/index.ts +1 -0
- package/src/compose/doctor.ts +141 -0
- package/src/compose/index.ts +8 -0
- package/src/compose/logs.ts +32 -19
- package/src/config/config.ts +20 -0
- package/src/container/log-colors.ts +75 -0
- package/src/container/log-timestamps.ts +84 -0
- package/src/container/logs.ts +71 -5
- package/src/container/start.ts +23 -8
- package/src/cron/consumer.ts +29 -7
- package/src/doctor/checks.ts +426 -0
- package/src/doctor/commit.ts +71 -0
- package/src/doctor/index.ts +287 -0
- package/src/doctor/plugin-bridge.ts +147 -0
- package/src/doctor/report.ts +142 -0
- package/src/doctor/types.ts +87 -0
- package/src/init/cli-version.ts +81 -0
- package/src/init/dockerfile.ts +223 -25
- package/src/init/ensure-deps.ts +2 -2
- package/src/init/index.ts +23 -13
- package/src/init/run-bun-install.ts +17 -1
- package/src/plugin/hooks.ts +32 -0
- package/src/plugin/index.ts +7 -0
- package/src/plugin/manager.ts +2 -0
- package/src/plugin/registry.ts +32 -3
- package/src/plugin/types.ts +65 -0
- package/src/run/bundled-plugins.ts +8 -0
- package/src/run/index.ts +10 -5
- package/src/secrets/env.ts +43 -0
- package/src/secrets/index.ts +2 -0
- package/src/server/index.ts +103 -5
- package/src/shared/index.ts +3 -0
- package/src/shared/protocol.ts +22 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +26 -3
- package/src/skills/typeclaw-config/SKILL.md +1 -1
- package/tsconfig.json +30 -0
- package/typeclaw.schema.json +50 -4
package/src/plugin/registry.ts
CHANGED
|
@@ -3,13 +3,28 @@ import { existsSync } from 'node:fs'
|
|
|
3
3
|
import type { CronJob, PromptJob } from '@/cron'
|
|
4
4
|
|
|
5
5
|
import type { HookBus } from './hooks'
|
|
6
|
-
import type {
|
|
6
|
+
import type {
|
|
7
|
+
PluginCronJob,
|
|
8
|
+
PluginDoctorCheck,
|
|
9
|
+
PluginExports,
|
|
10
|
+
PluginLogger,
|
|
11
|
+
PluginSkill,
|
|
12
|
+
Subagent,
|
|
13
|
+
Tool,
|
|
14
|
+
} from './types'
|
|
7
15
|
|
|
8
16
|
export type RegisteredTool = { pluginName: string; toolName: string; tool: Tool<any>; logger: PluginLogger }
|
|
9
17
|
export type RegisteredSubagent = { pluginName: string; subagentName: string; subagent: Subagent<any> }
|
|
10
18
|
export type RegisteredCronJob = { pluginName: string; localId: string; globalId: string; job: CronJob }
|
|
11
19
|
export type RegisteredSkillEntry = { pluginName: string; localName: string; skill: PluginSkill }
|
|
12
20
|
export type RegisteredSkillDir = { pluginName: string; path: string }
|
|
21
|
+
export type RegisteredDoctorCheck = {
|
|
22
|
+
pluginName: string
|
|
23
|
+
checkName: string
|
|
24
|
+
pluginConfig: unknown
|
|
25
|
+
logger: PluginLogger
|
|
26
|
+
check: PluginDoctorCheck
|
|
27
|
+
}
|
|
13
28
|
|
|
14
29
|
export type PluginRegistry = {
|
|
15
30
|
tools: RegisteredTool[]
|
|
@@ -17,6 +32,7 @@ export type PluginRegistry = {
|
|
|
17
32
|
cronJobs: RegisteredCronJob[]
|
|
18
33
|
skills: RegisteredSkillEntry[]
|
|
19
34
|
skillsDirs: RegisteredSkillDir[]
|
|
35
|
+
doctorChecks: RegisteredDoctorCheck[]
|
|
20
36
|
}
|
|
21
37
|
|
|
22
38
|
export type RegisterContributionsOptions = {
|
|
@@ -26,6 +42,7 @@ export type RegisterContributionsOptions = {
|
|
|
26
42
|
registry: PluginRegistry
|
|
27
43
|
hooks: HookBus
|
|
28
44
|
agentDir: string
|
|
45
|
+
pluginConfig: unknown
|
|
29
46
|
}
|
|
30
47
|
|
|
31
48
|
export function buildPluginCronGlobalId(pluginName: string, localId: string): string {
|
|
@@ -33,7 +50,7 @@ export function buildPluginCronGlobalId(pluginName: string, localId: string): st
|
|
|
33
50
|
}
|
|
34
51
|
|
|
35
52
|
export function registerContributions(opts: RegisterContributionsOptions): void {
|
|
36
|
-
const { pluginName, logger, exports: ex, registry, hooks, agentDir } = opts
|
|
53
|
+
const { pluginName, logger, exports: ex, registry, hooks, agentDir, pluginConfig } = opts
|
|
37
54
|
|
|
38
55
|
if (ex.tools) {
|
|
39
56
|
for (const [toolName, tool] of Object.entries(ex.tools)) {
|
|
@@ -99,6 +116,17 @@ export function registerContributions(opts: RegisterContributionsOptions): void
|
|
|
99
116
|
if (ex.hooks) {
|
|
100
117
|
hooks.registerAll(pluginName, agentDir, logger, ex.hooks)
|
|
101
118
|
}
|
|
119
|
+
|
|
120
|
+
if (ex.doctorChecks) {
|
|
121
|
+
for (const [checkName, check] of Object.entries(ex.doctorChecks)) {
|
|
122
|
+
assertNotEmpty('doctor check name', checkName, pluginName)
|
|
123
|
+
const conflict = registry.doctorChecks.find((c) => c.pluginName === pluginName && c.checkName === checkName)
|
|
124
|
+
if (conflict) {
|
|
125
|
+
throw new Error(`plugin ${pluginName}: doctor check "${checkName}" already registered`)
|
|
126
|
+
}
|
|
127
|
+
registry.doctorChecks.push({ pluginName, checkName, pluginConfig, logger, check })
|
|
128
|
+
}
|
|
129
|
+
}
|
|
102
130
|
}
|
|
103
131
|
|
|
104
132
|
export function discardRegistrationsBy(pluginName: string, registry: PluginRegistry, hooks: HookBus): void {
|
|
@@ -107,11 +135,12 @@ export function discardRegistrationsBy(pluginName: string, registry: PluginRegis
|
|
|
107
135
|
registry.cronJobs = registry.cronJobs.filter((j) => j.pluginName !== pluginName)
|
|
108
136
|
registry.skills = registry.skills.filter((s) => s.pluginName !== pluginName)
|
|
109
137
|
registry.skillsDirs = registry.skillsDirs.filter((d) => d.pluginName !== pluginName)
|
|
138
|
+
registry.doctorChecks = registry.doctorChecks.filter((d) => d.pluginName !== pluginName)
|
|
110
139
|
hooks.unregisterAll(pluginName)
|
|
111
140
|
}
|
|
112
141
|
|
|
113
142
|
export function emptyRegistry(): PluginRegistry {
|
|
114
|
-
return { tools: [], subagents: [], cronJobs: [], skills: [], skillsDirs: [] }
|
|
143
|
+
return { tools: [], subagents: [], cronJobs: [], skills: [], skillsDirs: [], doctorChecks: [] }
|
|
115
144
|
}
|
|
116
145
|
|
|
117
146
|
function assertNotEmpty(kind: string, value: string, pluginName: string): void {
|
package/src/plugin/types.ts
CHANGED
|
@@ -97,6 +97,24 @@ export type SessionIdleEvent = {
|
|
|
97
97
|
origin?: SessionOrigin
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
// Brackets every `session.prompt(...)` invocation. Distinct from
|
|
101
|
+
// `session.start`/`session.end` (which bracket session lifetime) so that
|
|
102
|
+
// long-lived TUI or channel sessions, which can sit idle between turns,
|
|
103
|
+
// don't wedge a turn-counter forever. `origin` carries the session's origin
|
|
104
|
+
// so observers can exclude their own induced turns when counting (e.g. the
|
|
105
|
+
// backup plugin excludes `subagent: 'backup'` to avoid self-gating).
|
|
106
|
+
export type SessionTurnStartEvent = {
|
|
107
|
+
sessionId: string
|
|
108
|
+
agentDir: string
|
|
109
|
+
origin?: SessionOrigin
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export type SessionTurnEndEvent = {
|
|
113
|
+
sessionId: string
|
|
114
|
+
agentDir: string
|
|
115
|
+
origin?: SessionOrigin
|
|
116
|
+
}
|
|
117
|
+
|
|
100
118
|
// Provider prompt caching requires byte-identical prefixes. Mutations near the
|
|
101
119
|
// end of `event.prompt` preserve cache hits across sessions; mutations near
|
|
102
120
|
// the start invalidate the cache on every LLM call.
|
|
@@ -136,6 +154,8 @@ export type Hooks = {
|
|
|
136
154
|
'session.end'?: (event: SessionEndEvent, ctx: HookContext) => Promise<void> | void
|
|
137
155
|
'session.idle'?: (event: SessionIdleEvent, ctx: HookContext) => Promise<void> | void
|
|
138
156
|
'session.prompt'?: (event: SessionPromptEvent, ctx: HookContext) => Promise<void> | void
|
|
157
|
+
'session.turn.start'?: (event: SessionTurnStartEvent, ctx: HookContext) => Promise<void> | void
|
|
158
|
+
'session.turn.end'?: (event: SessionTurnEndEvent, ctx: HookContext) => Promise<void> | void
|
|
139
159
|
'tool.before'?: (event: ToolBeforeEvent, ctx: HookContext) => Promise<ToolBeforeResult> | ToolBeforeResult
|
|
140
160
|
'tool.after'?: (event: ToolAfterEvent, ctx: HookContext) => Promise<void> | void
|
|
141
161
|
}
|
|
@@ -164,6 +184,51 @@ export type PluginExports = {
|
|
|
164
184
|
skills?: Record<string, PluginSkill>
|
|
165
185
|
skillsDirs?: string[]
|
|
166
186
|
hooks?: Hooks
|
|
187
|
+
doctorChecks?: Record<string, PluginDoctorCheck>
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// `typeclaw doctor` plugin extension surface. Each check is read-only by
|
|
191
|
+
// default; declaring `fix.apply` opts the check into `typeclaw doctor --fix`,
|
|
192
|
+
// where the host serializes plugin fixes, validates their `changedPaths`
|
|
193
|
+
// against the agent folder, and commits the union of all fixes in a single
|
|
194
|
+
// commit.
|
|
195
|
+
export type PluginDoctorCheck = {
|
|
196
|
+
description: string
|
|
197
|
+
category?: string
|
|
198
|
+
run: (ctx: PluginDoctorContext) => Promise<PluginCheckResult>
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export type PluginDoctorContext = {
|
|
202
|
+
readonly pluginName: string
|
|
203
|
+
readonly agentDir: string
|
|
204
|
+
readonly config: unknown
|
|
205
|
+
readonly logger: PluginLogger
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export type PluginCheckStatus = 'ok' | 'warning' | 'error'
|
|
209
|
+
|
|
210
|
+
export type PluginCheckResult = {
|
|
211
|
+
status: PluginCheckStatus
|
|
212
|
+
message: string
|
|
213
|
+
details?: string[]
|
|
214
|
+
fix?: PluginFixSuggestion
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export type PluginFixSuggestion = {
|
|
218
|
+
description: string
|
|
219
|
+
// When omitted, the fix is advisory-only. `typeclaw doctor --fix` only
|
|
220
|
+
// attempts to remediate checks whose suggestion includes an `apply`.
|
|
221
|
+
apply?: (ctx: PluginDoctorContext) => Promise<PluginFixResult>
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export type PluginFixResult = {
|
|
225
|
+
// One-line description that appears in the commit body as a bullet.
|
|
226
|
+
summary: string
|
|
227
|
+
// POSIX paths relative to agentDir; the host validates each one stays
|
|
228
|
+
// inside agentDir before `git add`ing. Absolute paths and `..` segments
|
|
229
|
+
// are rejected to keep plugin fixes from staging files outside the agent
|
|
230
|
+
// folder. Empty array is valid (e.g. a fix that only logs).
|
|
231
|
+
changedPaths: string[]
|
|
167
232
|
}
|
|
168
233
|
|
|
169
234
|
export type DefinedPlugin<TConfig = never> = {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import agentBrowserPlugin from '@/bundled-plugins/agent-browser'
|
|
2
|
+
import backupPlugin from '@/bundled-plugins/backup'
|
|
2
3
|
import guardPlugin from '@/bundled-plugins/guard'
|
|
3
4
|
import memoryPlugin from '@/bundled-plugins/memory'
|
|
4
5
|
import securityPlugin from '@/bundled-plugins/security'
|
|
@@ -16,9 +17,16 @@ import type { ResolvedPlugin } from '@/plugin'
|
|
|
16
17
|
// Letting `guard` run first would still work today since the two plugins
|
|
17
18
|
// guard disjoint surfaces, but seeding the order now means future overlap
|
|
18
19
|
// (e.g. a security policy on writes) blocks before guard's softer advice.
|
|
20
|
+
//
|
|
21
|
+
// `memory` is registered before `backup` so memory's dreaming commits always
|
|
22
|
+
// land in the same git index window before backup's commit-and-push cycle.
|
|
23
|
+
// They commit disjoint paths today (memory/ vs sessions/ + agent changes),
|
|
24
|
+
// but if either ever holds .git/index.lock the deterministic order makes the
|
|
25
|
+
// contention easier to reason about.
|
|
19
26
|
export const BUNDLED_PLUGINS: ResolvedPlugin[] = [
|
|
20
27
|
{ name: 'security', version: undefined, source: '<bundled>', defined: securityPlugin },
|
|
21
28
|
{ name: 'guard', version: undefined, source: '<bundled>', defined: guardPlugin },
|
|
22
29
|
{ name: 'memory', version: undefined, source: '<bundled>', defined: memoryPlugin },
|
|
30
|
+
{ name: 'backup', version: undefined, source: '<bundled>', defined: backupPlugin },
|
|
23
31
|
{ name: 'agent-browser', version: undefined, source: '<bundled>', defined: agentBrowserPlugin },
|
|
24
32
|
]
|
package/src/run/index.ts
CHANGED
|
@@ -142,14 +142,15 @@ export async function startAgent({
|
|
|
142
142
|
const entry = snap.pluginSubagentByShim.get(subagent)
|
|
143
143
|
if (entry) {
|
|
144
144
|
const sessionId = `subagent-${entry.pluginName}-${crypto.randomUUID()}`
|
|
145
|
+
const origin = {
|
|
146
|
+
kind: 'subagent' as const,
|
|
147
|
+
subagent: subagentOptions?.name ?? entry.subagentName,
|
|
148
|
+
parentSessionId: subagentOptions?.parentSessionId ?? '<unknown>',
|
|
149
|
+
}
|
|
145
150
|
const created = await createSessionWithDispose({
|
|
146
151
|
systemPromptOverride: entry.pluginSubagent.systemPrompt,
|
|
147
152
|
channelRouter: channelManager.router,
|
|
148
|
-
origin
|
|
149
|
-
kind: 'subagent',
|
|
150
|
-
subagent: subagentOptions?.name ?? entry.subagentName,
|
|
151
|
-
parentSessionId: subagentOptions?.parentSessionId ?? '<unknown>',
|
|
152
|
-
},
|
|
153
|
+
origin,
|
|
153
154
|
plugins: {
|
|
154
155
|
registry: snap.registry,
|
|
155
156
|
hooks: snap.hooks,
|
|
@@ -167,6 +168,8 @@ export async function startAgent({
|
|
|
167
168
|
...created,
|
|
168
169
|
hooks: snap.hooks,
|
|
169
170
|
sessionId,
|
|
171
|
+
agentDir: cwd,
|
|
172
|
+
origin,
|
|
170
173
|
}
|
|
171
174
|
}
|
|
172
175
|
return defaultCreateSessionForSubagent(subagent, subagentOptions)
|
|
@@ -221,6 +224,8 @@ export async function startAgent({
|
|
|
221
224
|
prompt: (text) => session.prompt(text),
|
|
222
225
|
dispose: () => session.dispose(),
|
|
223
226
|
sessionId,
|
|
227
|
+
agentDir: cwd,
|
|
228
|
+
origin: { kind: 'cron' as const, jobId: job.id, jobKind: 'prompt' as const },
|
|
224
229
|
...(snap.hasAnyPluginContent ? { hooks: snap.hooks } : {}),
|
|
225
230
|
getTranscriptPath: () => sessionManager.getSessionFile(),
|
|
226
231
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
// No-op when the file is missing or the key is absent: the caller has
|
|
4
|
+
// already persisted to `secrets.json` and just wants `.env` to stop being a
|
|
5
|
+
// second source of truth. Parsing matches `parseEnvKeys` in
|
|
6
|
+
// `src/init/index.ts` — line-based, trim, skip blanks/comments, split on the
|
|
7
|
+
// first `=`. Duplicate assignments to the same key are all removed because
|
|
8
|
+
// dotenv resolves "last wins" so every duplicate carries the value we just
|
|
9
|
+
// promoted.
|
|
10
|
+
export function stripEnvKey(path: string, key: string): void {
|
|
11
|
+
let original: string
|
|
12
|
+
try {
|
|
13
|
+
original = readFileSync(path, 'utf8')
|
|
14
|
+
} catch (error) {
|
|
15
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return
|
|
16
|
+
throw error
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const next = removeKeyFromEnvText(original, key)
|
|
20
|
+
if (next === original) return
|
|
21
|
+
writeFileSync(path, next)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function removeKeyFromEnvText(content: string, key: string): string {
|
|
25
|
+
const lines = content.split('\n')
|
|
26
|
+
const kept: string[] = []
|
|
27
|
+
for (const line of lines) {
|
|
28
|
+
const trimmed = line.trim()
|
|
29
|
+
if (trimmed === '' || trimmed.startsWith('#')) {
|
|
30
|
+
kept.push(line)
|
|
31
|
+
continue
|
|
32
|
+
}
|
|
33
|
+
const eq = trimmed.indexOf('=')
|
|
34
|
+
if (eq <= 0) {
|
|
35
|
+
kept.push(line)
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
const lineKey = trimmed.slice(0, eq).trim()
|
|
39
|
+
if (lineKey === key) continue
|
|
40
|
+
kept.push(line)
|
|
41
|
+
}
|
|
42
|
+
return kept.join('\n')
|
|
43
|
+
}
|
package/src/secrets/index.ts
CHANGED
package/src/server/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type CreateSessionOptions,
|
|
7
7
|
type CreateSessionResult,
|
|
8
8
|
} from '@/agent'
|
|
9
|
+
import { runPluginDoctorChecks, runPluginDoctorFix } from '@/agent/doctor'
|
|
9
10
|
import type { SessionOrigin } from '@/agent/session-origin'
|
|
10
11
|
import type { ChannelRouter } from '@/channels/router'
|
|
11
12
|
import type { HookBus } from '@/plugin'
|
|
@@ -159,7 +160,7 @@ export function createServer({
|
|
|
159
160
|
|
|
160
161
|
if (stream) {
|
|
161
162
|
state.unsubPrompts = stream.subscribe({ target: { kind: 'session', sessionId: sessionFileId } }, (msg) =>
|
|
162
|
-
enqueuePrompt(ws, state, msg),
|
|
163
|
+
enqueuePrompt(ws, state, msg, agentDir),
|
|
163
164
|
)
|
|
164
165
|
|
|
165
166
|
state.unsubBroadcast = stream.subscribe({ target: { kind: 'broadcast' } }, (msg) => {
|
|
@@ -190,6 +191,16 @@ export function createServer({
|
|
|
190
191
|
return
|
|
191
192
|
}
|
|
192
193
|
|
|
194
|
+
if (msg.type === 'doctor') {
|
|
195
|
+
await handleDoctor(ws, msg.requestId, pluginRuntime, agentDir)
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (msg.type === 'doctor_fix') {
|
|
200
|
+
await handleDoctorFix(ws, msg.requestId, msg.checkId, pluginRuntime, agentDir)
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
|
|
193
204
|
if (msg.type === 'abort') {
|
|
194
205
|
if (!state) return
|
|
195
206
|
await state.session.abort()
|
|
@@ -215,13 +226,27 @@ export function createServer({
|
|
|
215
226
|
return
|
|
216
227
|
}
|
|
217
228
|
send(ws, { type: 'prompt_started', messageId: `local-${crypto.randomUUID()}`, text: msg.text })
|
|
229
|
+
const fallbackHooks = state.runtimeSnapshot?.hooks
|
|
230
|
+
if (fallbackHooks !== undefined && agentDir !== undefined) {
|
|
231
|
+
await fallbackHooks.runSessionTurnStart({
|
|
232
|
+
sessionId: state.sessionFileId,
|
|
233
|
+
agentDir,
|
|
234
|
+
origin: state.origin,
|
|
235
|
+
})
|
|
236
|
+
}
|
|
218
237
|
try {
|
|
219
238
|
await state.session.prompt(msg.text)
|
|
220
239
|
send(ws, { type: 'done' })
|
|
221
240
|
} catch (err) {
|
|
222
241
|
send(ws, { type: 'error', message: err instanceof Error ? err.message : String(err) })
|
|
223
242
|
}
|
|
224
|
-
|
|
243
|
+
if (fallbackHooks !== undefined && agentDir !== undefined) {
|
|
244
|
+
await fallbackHooks.runSessionTurnEnd({
|
|
245
|
+
sessionId: state.sessionFileId,
|
|
246
|
+
agentDir,
|
|
247
|
+
origin: state.origin,
|
|
248
|
+
})
|
|
249
|
+
}
|
|
225
250
|
if (fallbackHooks !== undefined) {
|
|
226
251
|
await fallbackHooks.runSessionIdle({
|
|
227
252
|
sessionId: state.sessionFileId,
|
|
@@ -323,7 +348,7 @@ function forwardAssistantError(ws: Ws, message: unknown): void {
|
|
|
323
348
|
send(ws, { type: 'error', message: text })
|
|
324
349
|
}
|
|
325
350
|
|
|
326
|
-
function enqueuePrompt(ws: Ws, state: SessionState, msg: StreamMessage): void {
|
|
351
|
+
function enqueuePrompt(ws: Ws, state: SessionState, msg: StreamMessage, agentDir: string | undefined): void {
|
|
327
352
|
const payload = msg.payload as { kind?: string; text?: string; delivery?: PromptDelivery }
|
|
328
353
|
if (payload?.kind !== 'prompt' || typeof payload.text !== 'string') return
|
|
329
354
|
const delivery: PromptDelivery = payload.delivery ?? 'queue'
|
|
@@ -339,7 +364,7 @@ function enqueuePrompt(ws: Ws, state: SessionState, msg: StreamMessage): void {
|
|
|
339
364
|
ts: msg.ts,
|
|
340
365
|
})
|
|
341
366
|
pushQueueState(ws, state)
|
|
342
|
-
void drain(ws, state)
|
|
367
|
+
void drain(ws, state, agentDir)
|
|
343
368
|
}
|
|
344
369
|
|
|
345
370
|
// `session.idle` semantically means "the agent finished a prompt and is now
|
|
@@ -360,10 +385,26 @@ function makeIdleHookCaller(state: SessionState): () => Promise<void> {
|
|
|
360
385
|
}
|
|
361
386
|
}
|
|
362
387
|
|
|
363
|
-
|
|
388
|
+
function makeTurnHookCallers(
|
|
389
|
+
state: SessionState,
|
|
390
|
+
agentDir: string | undefined,
|
|
391
|
+
): { fireTurnStart: () => Promise<void>; fireTurnEnd: () => Promise<void> } {
|
|
392
|
+
const hooks: HookBus | undefined = state.runtimeSnapshot?.hooks
|
|
393
|
+
if (hooks === undefined || agentDir === undefined) {
|
|
394
|
+
return { fireTurnStart: async () => {}, fireTurnEnd: async () => {} }
|
|
395
|
+
}
|
|
396
|
+
const event = { sessionId: state.sessionFileId, agentDir, origin: state.origin }
|
|
397
|
+
return {
|
|
398
|
+
fireTurnStart: () => hooks.runSessionTurnStart(event),
|
|
399
|
+
fireTurnEnd: () => hooks.runSessionTurnEnd(event),
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function drain(ws: Ws, state: SessionState, agentDir: string | undefined): Promise<void> {
|
|
364
404
|
if (state.draining) return
|
|
365
405
|
state.draining = true
|
|
366
406
|
const fireIdle = makeIdleHookCaller(state)
|
|
407
|
+
const { fireTurnStart, fireTurnEnd } = makeTurnHookCallers(state, agentDir)
|
|
367
408
|
try {
|
|
368
409
|
while (state.drainQueue.length > 0) {
|
|
369
410
|
const item = state.drainQueue.shift()
|
|
@@ -371,12 +412,14 @@ async function drain(ws: Ws, state: SessionState): Promise<void> {
|
|
|
371
412
|
pushQueueState(ws, state)
|
|
372
413
|
send(ws, { type: 'prompt_started', messageId: item.streamMessageId, text: item.text })
|
|
373
414
|
|
|
415
|
+
await fireTurnStart()
|
|
374
416
|
try {
|
|
375
417
|
await state.session.prompt(item.text)
|
|
376
418
|
send(ws, { type: 'done' })
|
|
377
419
|
} catch (err) {
|
|
378
420
|
send(ws, { type: 'error', message: err instanceof Error ? err.message : String(err) })
|
|
379
421
|
}
|
|
422
|
+
await fireTurnEnd()
|
|
380
423
|
await fireIdle()
|
|
381
424
|
}
|
|
382
425
|
} finally {
|
|
@@ -393,6 +436,61 @@ function pushQueueState(ws: Ws, state: SessionState): void {
|
|
|
393
436
|
send(ws, { type: 'queue_state', pending })
|
|
394
437
|
}
|
|
395
438
|
|
|
439
|
+
async function handleDoctor(
|
|
440
|
+
ws: Ws,
|
|
441
|
+
requestId: string,
|
|
442
|
+
pluginRuntime: PluginRuntime | undefined,
|
|
443
|
+
agentDir: string | undefined,
|
|
444
|
+
): Promise<void> {
|
|
445
|
+
if (pluginRuntime === undefined || agentDir === undefined) {
|
|
446
|
+
send(ws, { type: 'doctor_result', requestId, checks: [] })
|
|
447
|
+
return
|
|
448
|
+
}
|
|
449
|
+
const snapshot = pluginRuntime.get()
|
|
450
|
+
if (snapshot === undefined) {
|
|
451
|
+
send(ws, { type: 'doctor_result', requestId, checks: [] })
|
|
452
|
+
return
|
|
453
|
+
}
|
|
454
|
+
try {
|
|
455
|
+
const checks = await runPluginDoctorChecks({ registry: snapshot.registry, agentDir })
|
|
456
|
+
send(ws, { type: 'doctor_result', requestId, checks })
|
|
457
|
+
} catch (err) {
|
|
458
|
+
send(ws, { type: 'error', message: err instanceof Error ? err.message : String(err) })
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function handleDoctorFix(
|
|
463
|
+
ws: Ws,
|
|
464
|
+
requestId: string,
|
|
465
|
+
checkId: string,
|
|
466
|
+
pluginRuntime: PluginRuntime | undefined,
|
|
467
|
+
agentDir: string | undefined,
|
|
468
|
+
): Promise<void> {
|
|
469
|
+
if (pluginRuntime === undefined || agentDir === undefined) {
|
|
470
|
+
send(ws, {
|
|
471
|
+
type: 'doctor_fix_result',
|
|
472
|
+
requestId,
|
|
473
|
+
result: { ok: false, checkId, error: 'plugin runtime not configured' },
|
|
474
|
+
})
|
|
475
|
+
return
|
|
476
|
+
}
|
|
477
|
+
const snapshot = pluginRuntime.get()
|
|
478
|
+
if (snapshot === undefined) {
|
|
479
|
+
send(ws, {
|
|
480
|
+
type: 'doctor_fix_result',
|
|
481
|
+
requestId,
|
|
482
|
+
result: { ok: false, checkId, error: 'plugin runtime not configured' },
|
|
483
|
+
})
|
|
484
|
+
return
|
|
485
|
+
}
|
|
486
|
+
const outcome = await runPluginDoctorFix({ registry: snapshot.registry, agentDir, checkId })
|
|
487
|
+
const result =
|
|
488
|
+
outcome.ok === true
|
|
489
|
+
? { ok: true as const, checkId, summary: outcome.summary, changedPaths: outcome.changedPaths }
|
|
490
|
+
: { ok: false as const, checkId, error: outcome.error }
|
|
491
|
+
send(ws, { type: 'doctor_fix_result', requestId, result })
|
|
492
|
+
}
|
|
493
|
+
|
|
396
494
|
async function handleReload(
|
|
397
495
|
ws: Ws,
|
|
398
496
|
reloadAll: ReloadAllFn | undefined,
|
package/src/shared/index.ts
CHANGED
package/src/shared/protocol.ts
CHANGED
|
@@ -4,11 +4,31 @@ export type ReloadResultPayload =
|
|
|
4
4
|
|
|
5
5
|
export type PromptDelivery = 'queue' | 'steer' | 'interrupt'
|
|
6
6
|
|
|
7
|
+
export type DoctorRequestId = string
|
|
8
|
+
|
|
9
|
+
export type DoctorCheckPayload = {
|
|
10
|
+
id: string
|
|
11
|
+
pluginName: string
|
|
12
|
+
checkName: string
|
|
13
|
+
description: string
|
|
14
|
+
category: string
|
|
15
|
+
status: 'ok' | 'warning' | 'error'
|
|
16
|
+
message: string
|
|
17
|
+
details?: string[]
|
|
18
|
+
fix?: { description: string; hasApply: boolean }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type DoctorFixPayload =
|
|
22
|
+
| { ok: true; checkId: string; summary: string; changedPaths: string[] }
|
|
23
|
+
| { ok: false; checkId: string; error: string }
|
|
24
|
+
|
|
7
25
|
export type ClientMessage =
|
|
8
26
|
| { type: 'prompt'; text: string; delivery?: PromptDelivery }
|
|
9
27
|
| { type: 'reload'; scope?: string }
|
|
10
28
|
| { type: 'abort' }
|
|
11
29
|
| { type: 'queue_cancel'; messageId: string }
|
|
30
|
+
| { type: 'doctor'; requestId: DoctorRequestId }
|
|
31
|
+
| { type: 'doctor_fix'; requestId: DoctorRequestId; checkId: string }
|
|
12
32
|
|
|
13
33
|
export type QueueStateItem = { id: string; text: string; ts: number }
|
|
14
34
|
|
|
@@ -23,3 +43,5 @@ export type ServerMessage =
|
|
|
23
43
|
| { type: 'notification'; payload: unknown; replyTo?: string; meta?: Record<string, string> }
|
|
24
44
|
| { type: 'queue_state'; pending: QueueStateItem[] }
|
|
25
45
|
| { type: 'prompt_started'; messageId: string; text: string }
|
|
46
|
+
| { type: 'doctor_result'; requestId: DoctorRequestId; checks: DoctorCheckPayload[] }
|
|
47
|
+
| { type: 'doctor_fix_result'; requestId: DoctorRequestId; result: DoctorFixPayload }
|
|
@@ -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
|
|
3
|
+
description: Use this skill BEFORE every `channel_reply` or `channel_send` call whose adapter is `kakaotalk`, AND before calling `channel_fetch_attachment` against a KakaoTalk URL. 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 file attachments or stickers. Inbound photos / files / video / audio CAN be downloaded via `channel_fetch_attachment` (the placeholder text includes the URL); inbound stickers are metadata-only and cannot be fetched. URLs expire ~3 days after the message arrives. Read this skill before composing or fetching anything on KakaoTalk.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# typeclaw-channel-kakaotalk
|
|
@@ -21,9 +21,9 @@ If you produce any of the following, KakaoTalk will render it literally and the
|
|
|
21
21
|
- **Links with display text** — `[label](url)` becomes the literal string. Send the bare URL on its own; the KakaoTalk client will auto-link it.
|
|
22
22
|
- **Mentions** — there is no `@user` syntax that the protocol surfaces. Address people by name in the message body.
|
|
23
23
|
- **Threads / replies-with-quote** — every message is a top-level chat post. There is no per-message reply UI.
|
|
24
|
-
- **
|
|
24
|
+
- **Outbound attachments / stickers** — agent-messenger's KakaoTalk SDK exposes no upload API. The adapter is outbound text-only. If the user asks you to send a file or sticker, say so and offer an alternative (paste a link, summarize the file, ship it via another channel).
|
|
25
25
|
|
|
26
|
-
The adapter
|
|
26
|
+
The adapter rejects outbound attachments via `ok: false` rather than partially sending the text — the agent contract is "ok=true means the whole request succeeded", so a silent drop would let you confidently report "I sent your file" when the file never arrived.
|
|
27
27
|
|
|
28
28
|
## What KakaoTalk DOES support
|
|
29
29
|
|
|
@@ -31,6 +31,29 @@ The adapter logs a warning the first time you try to send attachments and then d
|
|
|
31
31
|
- URLs auto-linkify in the client. Send them bare — `https://example.com/foo`, no markdown wrapping.
|
|
32
32
|
- Newlines render as line breaks. You can use `\n\n` to space paragraphs.
|
|
33
33
|
|
|
34
|
+
## Inbound attachments and stickers
|
|
35
|
+
|
|
36
|
+
Even though you cannot SEND attachments or stickers, you DO receive them. The adapter surfaces incoming non-text content by appending a `[KakaoTalk message with ...]` placeholder to the inbound text (same convention as Slack/Discord/Telegram). Examples of what you'll see:
|
|
37
|
+
|
|
38
|
+
- A photo (with no caption): `[KakaoTalk message with photo 1320x2868 (image/jpeg) https://talk.kakaocdn.net/...]`
|
|
39
|
+
- A photo with a caption: `look at this\n[KakaoTalk message with photo 1320x2868 (image/jpeg) https://...]`
|
|
40
|
+
- A file: `[KakaoTalk message with file spec.pdf (application/pdf) size=12345 https://...]`
|
|
41
|
+
- A video / audio (with a usable URL): `[KakaoTalk message with video (keys=[dur,url]) https://talk.kakaocdn.net/...]`. The SDK leaves video / audio / multiphoto payloads opaque, so we list the keys that were present alongside the URL when one exists; when no URL is present the placeholder is just `[KakaoTalk message with video keys=[...]]` and there is nothing for you to fetch.
|
|
42
|
+
- A sticker / emoticon: `[KakaoTalk message with sticker (sticker) pack=4412724 path=4412724.emot_001.webp]`
|
|
43
|
+
- An animated sticker: `[KakaoTalk message with sticker (sticker_ani) pack=... path=...]`
|
|
44
|
+
|
|
45
|
+
### Fetching attachment bytes
|
|
46
|
+
|
|
47
|
+
For photos, files, and any video / audio / multiphoto whose placeholder includes a `https://...kakaocdn.net/...` URL, call `channel_fetch_attachment` with that URL as the `ref` to download the bytes. The adapter validates the host (only `*.kakaocdn.net` is accepted — you cannot use this tool as a generic web fetcher) and returns the raw buffer plus mimetype.
|
|
48
|
+
|
|
49
|
+
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.
|
|
50
|
+
|
|
51
|
+
**Expiry caveat**: KakaoCDN URLs are pre-signed with an `expires=` timestamp baked into the query string — empirically ~3 days after the message arrived. Fetch promptly. If the URL has expired you will get a `403` error with the hint _"likely an expired pre-signed URL; ask the sender to re-share"_ — relay that to the user verbatim rather than guessing the cause.
|
|
52
|
+
|
|
53
|
+
**Stickers cannot be fetched** as bytes through this tool. The sticker placeholder carries `pack=` and `path=` identifiers (KakaoTalk sticker pack metadata), not a downloadable URL. Treat stickers as descriptive metadata only — acknowledge them ("cute sticker") without trying to "see" them.
|
|
54
|
+
|
|
55
|
+
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).
|
|
56
|
+
|
|
34
57
|
## Message length & cadence
|
|
35
58
|
|
|
36
59
|
KakaoTalk is mobile-first. The reading surface is small and the user is on their phone. Keep messages **short and conversational**, not essay-length. If you have a long answer:
|
|
@@ -403,7 +403,7 @@ RUN apt-get install ... <baseline + enabled toggle packages> ← toggles fan o
|
|
|
403
403
|
ENV NODE_ENV=production
|
|
404
404
|
# Custom lines from typeclaw.json#dockerfile.append. ← only emitted when append is non-empty
|
|
405
405
|
<your appended lines>
|
|
406
|
-
ENTRYPOINT ["
|
|
406
|
+
ENTRYPOINT ["/usr/local/bin/typeclaw-entrypoint"]
|
|
407
407
|
CMD ["run"]
|
|
408
408
|
```
|
|
409
409
|
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2025",
|
|
4
|
+
"module": "Preserve",
|
|
5
|
+
"moduleDetection": "force",
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"verbatimModuleSyntax": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
|
|
10
|
+
"lib": ["ESNext"],
|
|
11
|
+
"types": ["bun"],
|
|
12
|
+
"jsx": "react-jsx",
|
|
13
|
+
"allowJs": true,
|
|
14
|
+
|
|
15
|
+
"strict": true,
|
|
16
|
+
"skipLibCheck": true,
|
|
17
|
+
"noFallthroughCasesInSwitch": true,
|
|
18
|
+
"noUncheckedIndexedAccess": true,
|
|
19
|
+
"noImplicitOverride": true,
|
|
20
|
+
|
|
21
|
+
"noUnusedLocals": false,
|
|
22
|
+
"noUnusedParameters": false,
|
|
23
|
+
"noPropertyAccessFromIndexSignature": false,
|
|
24
|
+
|
|
25
|
+
"paths": {
|
|
26
|
+
"@/*": ["./src/*"]
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"include": ["src", "scripts"]
|
|
30
|
+
}
|