typeclaw 0.1.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.
Files changed (213) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +134 -0
  3. package/auth.schema.json +63 -0
  4. package/cron.schema.json +96 -0
  5. package/package.json +72 -0
  6. package/scripts/emit-base-dockerfile.ts +5 -0
  7. package/scripts/generate-schema.ts +34 -0
  8. package/secrets.schema.json +63 -0
  9. package/src/agent/auth.ts +119 -0
  10. package/src/agent/compaction.ts +35 -0
  11. package/src/agent/git-nudge.ts +95 -0
  12. package/src/agent/index.ts +451 -0
  13. package/src/agent/plugin-tools.ts +269 -0
  14. package/src/agent/reload-tool.ts +71 -0
  15. package/src/agent/self.ts +45 -0
  16. package/src/agent/session-origin.ts +288 -0
  17. package/src/agent/subagents.ts +253 -0
  18. package/src/agent/system-prompt.ts +68 -0
  19. package/src/agent/tools/channel-fetch-attachment.ts +118 -0
  20. package/src/agent/tools/channel-history.ts +119 -0
  21. package/src/agent/tools/channel-reply.ts +182 -0
  22. package/src/agent/tools/channel-send.ts +212 -0
  23. package/src/agent/tools/ddg.ts +218 -0
  24. package/src/agent/tools/restart.ts +122 -0
  25. package/src/agent/tools/stream-snapshot.ts +181 -0
  26. package/src/agent/tools/webfetch/fetch.ts +102 -0
  27. package/src/agent/tools/webfetch/index.ts +1 -0
  28. package/src/agent/tools/webfetch/strategies/grep.ts +70 -0
  29. package/src/agent/tools/webfetch/strategies/jq.ts +31 -0
  30. package/src/agent/tools/webfetch/strategies/raw.ts +3 -0
  31. package/src/agent/tools/webfetch/strategies/readability.ts +30 -0
  32. package/src/agent/tools/webfetch/strategies/selector.ts +41 -0
  33. package/src/agent/tools/webfetch/strategies/snapshot.ts +135 -0
  34. package/src/agent/tools/webfetch/tool.ts +281 -0
  35. package/src/agent/tools/webfetch/types.ts +33 -0
  36. package/src/agent/tools/websearch.ts +96 -0
  37. package/src/agent/tools/wikipedia.ts +52 -0
  38. package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +170 -0
  39. package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +421 -0
  40. package/src/bundled-plugins/agent-browser/index.ts +179 -0
  41. package/src/bundled-plugins/agent-browser/shim-install.ts +158 -0
  42. package/src/bundled-plugins/agent-browser/shim.ts +152 -0
  43. package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +113 -0
  44. package/src/bundled-plugins/guard/index.ts +26 -0
  45. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +98 -0
  46. package/src/bundled-plugins/guard/policies/skill-authoring.ts +185 -0
  47. package/src/bundled-plugins/guard/policies/uncommitted-changes.ts +85 -0
  48. package/src/bundled-plugins/guard/policy.ts +18 -0
  49. package/src/bundled-plugins/memory/README.md +71 -0
  50. package/src/bundled-plugins/memory/append-tool.ts +84 -0
  51. package/src/bundled-plugins/memory/dreaming-state.ts +86 -0
  52. package/src/bundled-plugins/memory/dreaming.ts +470 -0
  53. package/src/bundled-plugins/memory/fragment-parser.ts +67 -0
  54. package/src/bundled-plugins/memory/index.ts +238 -0
  55. package/src/bundled-plugins/memory/load-memory.ts +122 -0
  56. package/src/bundled-plugins/memory/memory-logger.ts +257 -0
  57. package/src/bundled-plugins/memory/secret-detector.ts +49 -0
  58. package/src/bundled-plugins/memory/watermark.ts +15 -0
  59. package/src/bundled-plugins/security/index.ts +35 -0
  60. package/src/bundled-plugins/security/policies/git-exfil.ts +120 -0
  61. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +167 -0
  62. package/src/bundled-plugins/security/policies/prompt-injection.ts +488 -0
  63. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +99 -0
  64. package/src/bundled-plugins/security/policies/secret-exfil-read.ts +127 -0
  65. package/src/bundled-plugins/security/policies/session-search-secrets.ts +86 -0
  66. package/src/bundled-plugins/security/policies/ssrf.ts +196 -0
  67. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +81 -0
  68. package/src/bundled-plugins/security/policy.ts +9 -0
  69. package/src/channels/adapters/discord-bot-channel-resolver.ts +77 -0
  70. package/src/channels/adapters/discord-bot-classify.ts +148 -0
  71. package/src/channels/adapters/discord-bot.ts +640 -0
  72. package/src/channels/adapters/kakaotalk-author-resolver.ts +78 -0
  73. package/src/channels/adapters/kakaotalk-channel-resolver.ts +105 -0
  74. package/src/channels/adapters/kakaotalk-classify.ts +77 -0
  75. package/src/channels/adapters/kakaotalk.ts +622 -0
  76. package/src/channels/adapters/slack-bot-author-resolver.ts +80 -0
  77. package/src/channels/adapters/slack-bot-channel-resolver.ts +84 -0
  78. package/src/channels/adapters/slack-bot-classify.ts +213 -0
  79. package/src/channels/adapters/slack-bot-dedupe.ts +51 -0
  80. package/src/channels/adapters/slack-bot-time.ts +10 -0
  81. package/src/channels/adapters/slack-bot.ts +881 -0
  82. package/src/channels/adapters/telegram-bot-classify.ts +155 -0
  83. package/src/channels/adapters/telegram-bot-format.ts +309 -0
  84. package/src/channels/adapters/telegram-bot.ts +604 -0
  85. package/src/channels/engagement.ts +227 -0
  86. package/src/channels/index.ts +21 -0
  87. package/src/channels/manager.ts +292 -0
  88. package/src/channels/membership-cache.ts +116 -0
  89. package/src/channels/membership-from-history.ts +53 -0
  90. package/src/channels/membership.ts +30 -0
  91. package/src/channels/participants.ts +47 -0
  92. package/src/channels/persistence.ts +209 -0
  93. package/src/channels/reloadable.ts +28 -0
  94. package/src/channels/router.ts +1570 -0
  95. package/src/channels/schema.ts +273 -0
  96. package/src/channels/types.ts +160 -0
  97. package/src/cli/channel.ts +403 -0
  98. package/src/cli/compose-status.ts +95 -0
  99. package/src/cli/compose.ts +240 -0
  100. package/src/cli/hostd.ts +163 -0
  101. package/src/cli/index.ts +27 -0
  102. package/src/cli/init.ts +592 -0
  103. package/src/cli/logs.ts +38 -0
  104. package/src/cli/reload.ts +68 -0
  105. package/src/cli/restart.ts +66 -0
  106. package/src/cli/run.ts +77 -0
  107. package/src/cli/shell.ts +33 -0
  108. package/src/cli/start.ts +57 -0
  109. package/src/cli/status.ts +178 -0
  110. package/src/cli/stop.ts +31 -0
  111. package/src/cli/tui.ts +35 -0
  112. package/src/cli/ui.ts +110 -0
  113. package/src/commands/index.ts +74 -0
  114. package/src/compose/discover.ts +43 -0
  115. package/src/compose/index.ts +25 -0
  116. package/src/compose/logs.ts +162 -0
  117. package/src/compose/restart.ts +69 -0
  118. package/src/compose/start.ts +62 -0
  119. package/src/compose/status.ts +28 -0
  120. package/src/compose/stop.ts +43 -0
  121. package/src/config/config.ts +424 -0
  122. package/src/config/index.ts +25 -0
  123. package/src/config/providers.ts +234 -0
  124. package/src/config/reloadable.ts +47 -0
  125. package/src/container/index.ts +27 -0
  126. package/src/container/logs.ts +37 -0
  127. package/src/container/port.ts +137 -0
  128. package/src/container/shared.ts +290 -0
  129. package/src/container/shell.ts +58 -0
  130. package/src/container/start.ts +670 -0
  131. package/src/container/status.ts +76 -0
  132. package/src/container/stop.ts +120 -0
  133. package/src/container/verify-running.ts +149 -0
  134. package/src/cron/consumer.ts +138 -0
  135. package/src/cron/index.ts +54 -0
  136. package/src/cron/reloadable.ts +64 -0
  137. package/src/cron/scheduler.ts +200 -0
  138. package/src/cron/schema.ts +96 -0
  139. package/src/hostd/client.ts +113 -0
  140. package/src/hostd/daemon.ts +587 -0
  141. package/src/hostd/index.ts +25 -0
  142. package/src/hostd/paths.ts +82 -0
  143. package/src/hostd/portbroker-manager.ts +101 -0
  144. package/src/hostd/protocol.ts +48 -0
  145. package/src/hostd/spawn.ts +224 -0
  146. package/src/hostd/supervisor.ts +60 -0
  147. package/src/hostd/tailscale.ts +172 -0
  148. package/src/hostd/version.ts +115 -0
  149. package/src/init/dockerfile.ts +327 -0
  150. package/src/init/ensure-deps.ts +152 -0
  151. package/src/init/gitignore.ts +46 -0
  152. package/src/init/hatching.ts +60 -0
  153. package/src/init/index.ts +786 -0
  154. package/src/init/kakaotalk-auth.ts +114 -0
  155. package/src/init/models-dev.ts +130 -0
  156. package/src/init/oauth-login.ts +74 -0
  157. package/src/init/packagejson.ts +94 -0
  158. package/src/init/paths.ts +2 -0
  159. package/src/init/run-bun-install.ts +20 -0
  160. package/src/markdown/chunk.ts +299 -0
  161. package/src/markdown/index.ts +1 -0
  162. package/src/plugin/context.ts +40 -0
  163. package/src/plugin/define.ts +35 -0
  164. package/src/plugin/hooks.ts +204 -0
  165. package/src/plugin/index.ts +63 -0
  166. package/src/plugin/loader.ts +111 -0
  167. package/src/plugin/manager.ts +136 -0
  168. package/src/plugin/registry.ts +145 -0
  169. package/src/plugin/skills.ts +62 -0
  170. package/src/plugin/types.ts +172 -0
  171. package/src/portbroker/bind-with-forward.ts +102 -0
  172. package/src/portbroker/container-server.ts +305 -0
  173. package/src/portbroker/forward-result-bus.ts +36 -0
  174. package/src/portbroker/hostd-client.ts +443 -0
  175. package/src/portbroker/index.ts +33 -0
  176. package/src/portbroker/policy.ts +24 -0
  177. package/src/portbroker/proc-net-tcp.ts +72 -0
  178. package/src/portbroker/protocol.ts +39 -0
  179. package/src/reload/client.ts +59 -0
  180. package/src/reload/index.ts +3 -0
  181. package/src/reload/registry.ts +60 -0
  182. package/src/reload/types.ts +13 -0
  183. package/src/run/bundled-plugins.ts +24 -0
  184. package/src/run/channel-session-factory.ts +105 -0
  185. package/src/run/index.ts +432 -0
  186. package/src/run/plugin-runtime.ts +43 -0
  187. package/src/run/schema-with-plugins.ts +14 -0
  188. package/src/secrets/index.ts +13 -0
  189. package/src/secrets/migrate.ts +95 -0
  190. package/src/secrets/schema.ts +75 -0
  191. package/src/secrets/storage.ts +231 -0
  192. package/src/server/index.ts +436 -0
  193. package/src/sessions/index.ts +23 -0
  194. package/src/shared/index.ts +9 -0
  195. package/src/shared/local-time.ts +21 -0
  196. package/src/shared/protocol.ts +25 -0
  197. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +87 -0
  198. package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +64 -0
  199. package/src/skills/typeclaw-config/SKILL.md +643 -0
  200. package/src/skills/typeclaw-cron/SKILL.md +159 -0
  201. package/src/skills/typeclaw-git/SKILL.md +89 -0
  202. package/src/skills/typeclaw-memory/SKILL.md +174 -0
  203. package/src/skills/typeclaw-monorepo/SKILL.md +175 -0
  204. package/src/skills/typeclaw-plugins/SKILL.md +594 -0
  205. package/src/skills/typeclaw-skills/SKILL.md +246 -0
  206. package/src/stream/broker.ts +161 -0
  207. package/src/stream/index.ts +16 -0
  208. package/src/stream/types.ts +69 -0
  209. package/src/tui/client.ts +45 -0
  210. package/src/tui/format.ts +317 -0
  211. package/src/tui/index.ts +225 -0
  212. package/src/tui/theme.ts +41 -0
  213. package/typeclaw.schema.json +826 -0
@@ -0,0 +1,111 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+
5
+ import type { DefinedPlugin } from './types'
6
+
7
+ export type ResolvedPlugin = {
8
+ name: string
9
+ version: string | undefined
10
+ source: string
11
+ defined: DefinedPlugin<any>
12
+ }
13
+
14
+ export type LoadPluginEntryFn = (entry: string, agentDir: string) => Promise<ResolvedPlugin>
15
+
16
+ export async function loadPluginEntry(entry: string, agentDir: string): Promise<ResolvedPlugin> {
17
+ if (isLocalPath(entry)) {
18
+ return loadLocal(entry, agentDir)
19
+ }
20
+ return loadNpm(entry, agentDir)
21
+ }
22
+
23
+ function isLocalPath(entry: string): boolean {
24
+ return entry.startsWith('./') || entry.startsWith('../') || isAbsolute(entry)
25
+ }
26
+
27
+ async function loadLocal(entry: string, agentDir: string): Promise<ResolvedPlugin> {
28
+ const resolved = resolve(agentDir, entry)
29
+ // Confine local plugin paths to within agentDir so a malicious typeclaw.json
30
+ // cannot point at arbitrary files on the host.
31
+ const rel = relative(agentDir, resolved)
32
+ if (rel.startsWith('..') || isAbsolute(rel)) {
33
+ throw new Error(`plugin path escapes agent directory: ${entry} (resolved to ${resolved})`)
34
+ }
35
+ if (!existsSync(resolved)) {
36
+ throw new Error(`plugin path does not exist: ${entry} (resolved to ${resolved})`)
37
+ }
38
+ const url = pathToFileURL(resolved).href
39
+ const mod = (await import(url)) as { default?: unknown }
40
+ const defined = expectDefined(mod, entry)
41
+ const name = basename(resolved).replace(/\.(ts|tsx|js|mjs|cjs)$/i, '')
42
+ return { name, version: undefined, source: entry, defined }
43
+ }
44
+
45
+ async function loadNpm(entry: string, agentDir: string): Promise<ResolvedPlugin> {
46
+ const pkgJsonPath = findPackageJson(entry, agentDir)
47
+ let pkgName = entry
48
+ let version: string | undefined
49
+ let entryPath: string | null = null
50
+ if (pkgJsonPath !== null) {
51
+ try {
52
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8')) as {
53
+ name?: unknown
54
+ version?: unknown
55
+ main?: unknown
56
+ module?: unknown
57
+ }
58
+ if (typeof pkg.name === 'string' && pkg.name.length > 0) pkgName = pkg.name
59
+ if (typeof pkg.version === 'string' && pkg.version.length > 0) version = pkg.version
60
+ const main = typeof pkg.module === 'string' ? pkg.module : typeof pkg.main === 'string' ? pkg.main : null
61
+ if (main !== null) {
62
+ const candidate = join(dirname(pkgJsonPath), main)
63
+ if (existsSync(candidate)) {
64
+ entryPath = candidate
65
+ }
66
+ }
67
+ } catch {
68
+ // Fall through to bare-import resolution.
69
+ }
70
+ }
71
+ // Falls back to bare-import resolution when entryPath cannot be located on
72
+ // disk. Modern packages with `exports` map (and no `main`/`module`) take
73
+ // this path so Bun's resolver can read `exports`.
74
+ const importTarget = entryPath !== null ? pathToFileURL(entryPath).href : entry
75
+ const mod = (await import(importTarget)) as { default?: unknown }
76
+ const defined = expectDefined(mod, entry)
77
+ const name = derivePluginNameFromPackage(pkgName)
78
+ return { name, version, source: entry, defined }
79
+ }
80
+
81
+ export function derivePluginNameFromPackage(packageName: string): string {
82
+ const PREFIX = 'typeclaw-plugin-'
83
+ const SCOPED_PREFIX_RE = /^@[^/]+\//
84
+ const stripped = packageName.replace(SCOPED_PREFIX_RE, '')
85
+ return stripped.startsWith(PREFIX) ? stripped.slice(PREFIX.length) : stripped
86
+ }
87
+
88
+ function findPackageJson(entry: string, agentDir: string): string | null {
89
+ const PACKAGE_JSON = 'package.json'
90
+ let cur = agentDir
91
+ while (true) {
92
+ const p = join(cur, 'node_modules', entry, PACKAGE_JSON)
93
+ if (existsSync(p)) return p
94
+ const parent = dirname(cur)
95
+ if (parent === cur) return null
96
+ cur = parent
97
+ }
98
+ }
99
+
100
+ function expectDefined(mod: { default?: unknown }, entry: string): DefinedPlugin<any> {
101
+ const def = mod.default
102
+ if (
103
+ def !== null &&
104
+ typeof def === 'object' &&
105
+ 'plugin' in (def as Record<string, unknown>) &&
106
+ typeof (def as { plugin: unknown }).plugin === 'function'
107
+ ) {
108
+ return def as DefinedPlugin<any>
109
+ }
110
+ throw new Error(`plugin ${entry}: default export is not a definePlugin(...) result`)
111
+ }
@@ -0,0 +1,136 @@
1
+ import { z } from 'zod'
2
+
3
+ import type { CronJob } from '@/cron'
4
+
5
+ import { createPluginContext, createPluginLogger, type SpawnSubagentFn } from './context'
6
+ import { createHookBus, type HookBus } from './hooks'
7
+ import { loadPluginEntry, type LoadPluginEntryFn, type ResolvedPlugin } from './loader'
8
+ import { discardRegistrationsBy, emptyRegistry, type PluginRegistry, registerContributions } from './registry'
9
+ import type { PluginExports } from './types'
10
+
11
+ export type LoadPluginsOptions = {
12
+ entries: string[]
13
+ agentDir: string
14
+ configsByName: Record<string, unknown>
15
+ loadEntry?: LoadPluginEntryFn
16
+ // Bundled plugins resolved by the runtime (not from typeclaw.json). Loaded
17
+ // before user-declared `entries` so a config block named after a bundled
18
+ // plugin (e.g. "memory") is consumed by the bundled plugin, and so plugin-
19
+ // name conflicts with a user-declared entry surface as a clear error.
20
+ bundled?: ResolvedPlugin[]
21
+ }
22
+
23
+ export type LoadPluginsResult = {
24
+ registry: PluginRegistry
25
+ hooks: HookBus
26
+ loadedPlugins: { name: string; version: string | undefined; source: string }[]
27
+ markBooted: () => void
28
+ setSpawnSubagent: (fn: SpawnSubagentFn) => void
29
+ }
30
+
31
+ export async function loadPlugins(opts: LoadPluginsOptions): Promise<LoadPluginsResult> {
32
+ const registry = emptyRegistry()
33
+ const hooks = createHookBus()
34
+ const loaded: { name: string; version: string | undefined; source: string }[] = []
35
+ const loadEntry = opts.loadEntry ?? loadPluginEntry
36
+
37
+ let booted = false
38
+ let spawnSubagentImpl: SpawnSubagentFn = async () => {
39
+ throw new Error('plugin: spawnSubagent is not yet wired')
40
+ }
41
+
42
+ const allPlugins: { entry: string; resolved: ResolvedPlugin }[] = [
43
+ ...(opts.bundled?.map((resolved) => ({ entry: `<bundled:${resolved.name}>`, resolved })) ?? []),
44
+ ...(await Promise.all(
45
+ opts.entries.map(async (entry) => ({ entry, resolved: await loadEntry(entry, opts.agentDir) })),
46
+ )),
47
+ ]
48
+
49
+ for (const { entry, resolved } of allPlugins) {
50
+ if (loaded.find((l) => l.name === resolved.name)) {
51
+ throw new Error(`plugin name conflict: ${resolved.name} (entry ${entry}) already loaded`)
52
+ }
53
+
54
+ let validatedConfig: unknown = undefined
55
+ if (resolved.defined.configSchema) {
56
+ const raw = opts.configsByName[resolved.name]
57
+ const parsed = (resolved.defined.configSchema as z.ZodType<unknown>).safeParse(raw ?? {})
58
+ if (!parsed.success) {
59
+ throw new Error(`plugin ${resolved.name}: config invalid: ${formatZodIssues(parsed.error)}`)
60
+ }
61
+ validatedConfig = parsed.data
62
+ } else if (opts.configsByName[resolved.name] !== undefined) {
63
+ throw new Error(
64
+ `plugin ${resolved.name}: config block "${resolved.name}" present in typeclaw.json but plugin declares no configSchema`,
65
+ )
66
+ }
67
+
68
+ const logger = createPluginLogger(resolved.name)
69
+ const ctx = createPluginContext({
70
+ name: resolved.name,
71
+ version: resolved.version,
72
+ agentDir: opts.agentDir,
73
+ config: validatedConfig as never,
74
+ logger,
75
+ spawnSubagent: (name, payload) => spawnSubagentImpl(name, payload),
76
+ isBooted: () => booted,
77
+ })
78
+
79
+ let exports: PluginExports
80
+ try {
81
+ exports = await resolved.defined.plugin(ctx)
82
+ } catch (err) {
83
+ discardRegistrationsBy(resolved.name, registry, hooks)
84
+ const message = err instanceof Error ? err.message : String(err)
85
+ throw new Error(`plugin ${resolved.name}: factory threw: ${message}`)
86
+ }
87
+
88
+ try {
89
+ registerContributions({
90
+ pluginName: resolved.name,
91
+ logger,
92
+ exports,
93
+ registry,
94
+ hooks,
95
+ agentDir: opts.agentDir,
96
+ })
97
+ } catch (err) {
98
+ discardRegistrationsBy(resolved.name, registry, hooks)
99
+ throw err
100
+ }
101
+
102
+ loaded.push({ name: resolved.name, version: resolved.version, source: resolved.source })
103
+ }
104
+
105
+ return {
106
+ registry,
107
+ hooks,
108
+ loadedPlugins: loaded,
109
+ markBooted: () => {
110
+ booted = true
111
+ },
112
+ setSpawnSubagent: (fn) => {
113
+ spawnSubagentImpl = fn
114
+ },
115
+ }
116
+ }
117
+
118
+ export function summarizeLoaded(loaded: LoadPluginsResult['loadedPlugins'], registry: PluginRegistry): string {
119
+ const head = loaded.map((p) => (p.version !== undefined ? `${p.name} v${p.version}` : p.name)).join(', ')
120
+ const counts = [
121
+ `${registry.tools.length} tool(s)`,
122
+ `${registry.subagents.length} subagent(s)`,
123
+ `${registry.cronJobs.length} cron job(s)`,
124
+ `${registry.skills.length} skill(s)`,
125
+ `${registry.skillsDirs.length} skills dir(s)`,
126
+ ].join(', ')
127
+ return `${loaded.length} plugin(s): ${head} [${counts}]`
128
+ }
129
+
130
+ export function pluginCronJobs(registry: PluginRegistry): CronJob[] {
131
+ return registry.cronJobs.map((j) => j.job)
132
+ }
133
+
134
+ function formatZodIssues(error: z.ZodError): string {
135
+ return error.issues.map((i) => `${i.path.length > 0 ? i.path.join('.') : '<root>'}: ${i.message}`).join('; ')
136
+ }
@@ -0,0 +1,145 @@
1
+ import { existsSync } from 'node:fs'
2
+
3
+ import type { CronJob, PromptJob } from '@/cron'
4
+
5
+ import type { HookBus } from './hooks'
6
+ import type { PluginCronJob, PluginExports, PluginLogger, PluginSkill, Subagent, Tool } from './types'
7
+
8
+ export type RegisteredTool = { pluginName: string; toolName: string; tool: Tool<any>; logger: PluginLogger }
9
+ export type RegisteredSubagent = { pluginName: string; subagentName: string; subagent: Subagent<any> }
10
+ export type RegisteredCronJob = { pluginName: string; localId: string; globalId: string; job: CronJob }
11
+ export type RegisteredSkillEntry = { pluginName: string; localName: string; skill: PluginSkill }
12
+ export type RegisteredSkillDir = { pluginName: string; path: string }
13
+
14
+ export type PluginRegistry = {
15
+ tools: RegisteredTool[]
16
+ subagents: RegisteredSubagent[]
17
+ cronJobs: RegisteredCronJob[]
18
+ skills: RegisteredSkillEntry[]
19
+ skillsDirs: RegisteredSkillDir[]
20
+ }
21
+
22
+ export type RegisterContributionsOptions = {
23
+ pluginName: string
24
+ logger: PluginLogger
25
+ exports: PluginExports
26
+ registry: PluginRegistry
27
+ hooks: HookBus
28
+ agentDir: string
29
+ }
30
+
31
+ export function buildPluginCronGlobalId(pluginName: string, localId: string): string {
32
+ return `__plugin_${pluginName}_${localId}`
33
+ }
34
+
35
+ export function registerContributions(opts: RegisterContributionsOptions): void {
36
+ const { pluginName, logger, exports: ex, registry, hooks, agentDir } = opts
37
+
38
+ if (ex.tools) {
39
+ for (const [toolName, tool] of Object.entries(ex.tools)) {
40
+ assertNotEmpty('tool name', toolName, pluginName)
41
+ const conflict = registry.tools.find((t) => t.toolName === toolName)
42
+ if (conflict) {
43
+ throw new Error(`plugin ${pluginName}: tool "${toolName}" already registered by plugin ${conflict.pluginName}`)
44
+ }
45
+ registry.tools.push({ pluginName, toolName, tool, logger })
46
+ }
47
+ }
48
+
49
+ if (ex.subagents) {
50
+ for (const [subagentName, subagent] of Object.entries(ex.subagents)) {
51
+ assertNotEmpty('subagent name', subagentName, pluginName)
52
+ const conflict = registry.subagents.find((s) => s.subagentName === subagentName)
53
+ if (conflict) {
54
+ throw new Error(
55
+ `plugin ${pluginName}: subagent "${subagentName}" already registered by plugin ${conflict.pluginName}`,
56
+ )
57
+ }
58
+ registry.subagents.push({ pluginName, subagentName, subagent })
59
+ }
60
+ }
61
+
62
+ if (ex.cronJobs) {
63
+ for (const [localId, spec] of Object.entries(ex.cronJobs)) {
64
+ assertNotEmpty('cron job id', localId, pluginName)
65
+ const globalId = buildPluginCronGlobalId(pluginName, localId)
66
+ const conflict = registry.cronJobs.find((j) => j.globalId === globalId)
67
+ if (conflict) {
68
+ throw new Error(
69
+ `plugin ${pluginName}: cron job "${localId}" globalId "${globalId}" conflicts with plugin ${conflict.pluginName}`,
70
+ )
71
+ }
72
+ const job = toCronJob(globalId, spec)
73
+ registry.cronJobs.push({ pluginName, localId, globalId, job })
74
+ }
75
+ }
76
+
77
+ if (ex.skills) {
78
+ for (const [localName, skill] of Object.entries(ex.skills)) {
79
+ assertNotEmpty('skill name', localName, pluginName)
80
+ const conflict = registry.skills.find((s) => s.localName === localName)
81
+ if (conflict) {
82
+ throw new Error(
83
+ `plugin ${pluginName}: skill "${localName}" already registered by plugin ${conflict.pluginName}`,
84
+ )
85
+ }
86
+ registry.skills.push({ pluginName, localName, skill })
87
+ }
88
+ }
89
+
90
+ if (ex.skillsDirs) {
91
+ for (const path of ex.skillsDirs) {
92
+ if (!existsSync(path)) {
93
+ logger.warn(`skillsDirs entry does not exist on disk: ${path}`)
94
+ }
95
+ registry.skillsDirs.push({ pluginName, path })
96
+ }
97
+ }
98
+
99
+ if (ex.hooks) {
100
+ hooks.registerAll(pluginName, agentDir, logger, ex.hooks)
101
+ }
102
+ }
103
+
104
+ export function discardRegistrationsBy(pluginName: string, registry: PluginRegistry, hooks: HookBus): void {
105
+ registry.tools = registry.tools.filter((t) => t.pluginName !== pluginName)
106
+ registry.subagents = registry.subagents.filter((s) => s.pluginName !== pluginName)
107
+ registry.cronJobs = registry.cronJobs.filter((j) => j.pluginName !== pluginName)
108
+ registry.skills = registry.skills.filter((s) => s.pluginName !== pluginName)
109
+ registry.skillsDirs = registry.skillsDirs.filter((d) => d.pluginName !== pluginName)
110
+ hooks.unregisterAll(pluginName)
111
+ }
112
+
113
+ export function emptyRegistry(): PluginRegistry {
114
+ return { tools: [], subagents: [], cronJobs: [], skills: [], skillsDirs: [] }
115
+ }
116
+
117
+ function assertNotEmpty(kind: string, value: string, pluginName: string): void {
118
+ if (value.length === 0) {
119
+ throw new Error(`plugin ${pluginName}: empty ${kind}`)
120
+ }
121
+ }
122
+
123
+ function toCronJob(globalId: string, spec: PluginCronJob): CronJob {
124
+ if (spec.kind === 'prompt') {
125
+ const job: PromptJob = {
126
+ id: globalId,
127
+ schedule: spec.schedule,
128
+ enabled: spec.enabled ?? true,
129
+ kind: 'prompt',
130
+ prompt: spec.prompt,
131
+ ...(spec.timezone !== undefined ? { timezone: spec.timezone } : {}),
132
+ ...(spec.subagent !== undefined ? { subagent: spec.subagent } : {}),
133
+ ...(spec.payload !== undefined ? { payload: spec.payload } : {}),
134
+ }
135
+ return job
136
+ }
137
+ return {
138
+ id: globalId,
139
+ schedule: spec.schedule,
140
+ enabled: spec.enabled ?? true,
141
+ kind: 'exec',
142
+ command: spec.command,
143
+ ...(spec.timezone !== undefined ? { timezone: spec.timezone } : {}),
144
+ }
145
+ }
@@ -0,0 +1,62 @@
1
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+
5
+ import type { PluginSkill } from './types'
6
+
7
+ export type SkillEntry = { pluginName: string; localName: string; skill: PluginSkill }
8
+
9
+ export type MaterializedSkills = {
10
+ dir: string
11
+ dispose: () => Promise<void>
12
+ }
13
+
14
+ const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-_]*$/
15
+
16
+ export async function materializeSkills(skills: SkillEntry[]): Promise<MaterializedSkills> {
17
+ const root = await mkdtemp(join(tmpdir(), 'typeclaw-plugin-skills-'))
18
+
19
+ try {
20
+ const seen = new Set<string>()
21
+ for (const entry of skills) {
22
+ const sanitized = sanitizeSkillName(entry.localName)
23
+ if (seen.has(sanitized)) {
24
+ throw new Error(`plugin ${entry.pluginName}: duplicate skill name after sanitization: ${entry.localName}`)
25
+ }
26
+ seen.add(sanitized)
27
+
28
+ const skillDir = join(root, sanitized)
29
+ await mkdir(skillDir, { recursive: true })
30
+ const body = renderSkillFile(entry.skill)
31
+ await writeFile(join(skillDir, 'SKILL.md'), body, 'utf8')
32
+ }
33
+ } catch (err) {
34
+ await rm(root, { recursive: true, force: true })
35
+ throw err
36
+ }
37
+
38
+ return {
39
+ dir: root,
40
+ dispose: async () => {
41
+ await rm(root, { recursive: true, force: true })
42
+ },
43
+ }
44
+ }
45
+
46
+ function sanitizeSkillName(name: string): string {
47
+ if (SKILL_NAME_PATTERN.test(name)) return name
48
+ return name.toLowerCase().replace(/[^a-z0-9_-]/g, '-')
49
+ }
50
+
51
+ function renderSkillFile(skill: PluginSkill): string {
52
+ const fm = skill.frontmatter ?? {}
53
+ const fmEntries = Object.entries({ ...fm, description: skill.description })
54
+ if (fmEntries.length === 0) return skill.content
55
+ const lines = ['---']
56
+ for (const [key, value] of fmEntries) {
57
+ lines.push(`${key}: ${JSON.stringify(value)}`)
58
+ }
59
+ lines.push('---', '')
60
+ lines.push(skill.content)
61
+ return lines.join('\n')
62
+ }
@@ -0,0 +1,172 @@
1
+ import type { z } from 'zod'
2
+
3
+ import type { SessionOrigin } from '@/agent/session-origin'
4
+
5
+ export type ContentPart = { type: 'text'; text: string } | { type: 'image'; mimeType: string; data: string }
6
+
7
+ export type ToolResult = {
8
+ content: ContentPart[]
9
+ details?: unknown
10
+ }
11
+
12
+ export type ToolLogger = {
13
+ info: (msg: string) => void
14
+ warn: (msg: string) => void
15
+ error: (msg: string) => void
16
+ }
17
+
18
+ export type ToolContext = {
19
+ signal: AbortSignal | undefined
20
+ sessionId: string
21
+ agentDir: string
22
+ logger: ToolLogger
23
+ }
24
+
25
+ export type Tool<P = unknown> = {
26
+ description: string
27
+ parameters: z.ZodType<P>
28
+ execute: (args: P, ctx: ToolContext) => Promise<ToolResult>
29
+ }
30
+
31
+ export type BuiltinToolRef = { readonly __builtinTool: string }
32
+
33
+ export type SubagentContext<P = unknown> = {
34
+ userPrompt: string
35
+ agentDir: string
36
+ payload: P
37
+ }
38
+
39
+ export type RunSession = (override?: { userPrompt?: string }) => Promise<void>
40
+
41
+ export type Subagent<P = unknown> = {
42
+ systemPrompt: string
43
+ tools?: BuiltinToolRef[]
44
+ customTools?: Tool<any>[]
45
+ payloadSchema?: z.ZodType<P>
46
+ handler?: (ctx: SubagentContext<P>, runSession: RunSession) => Promise<void>
47
+ // Coalescing key for the SubagentConsumer's in-flight set. Default is the
48
+ // subagent name alone (only one instance of the subagent runs at a time).
49
+ // Override to allow per-payload concurrency, e.g. memory-logger keyed by
50
+ // parentSessionId so different parent sessions run in parallel while
51
+ // duplicate runs against the same session deduplicate.
52
+ inFlightKey?: (payload: P) => string
53
+ }
54
+
55
+ // Cron job map keys are local; the runtime prefixes with `__plugin_<plugin-name>_`
56
+ // to form the global cron id, guaranteeing no collision with cron.json user
57
+ // jobs (no underscore prefix) or across plugins.
58
+ export type PluginPromptCronJob = {
59
+ schedule: string
60
+ kind: 'prompt'
61
+ prompt: string
62
+ enabled?: boolean
63
+ timezone?: string
64
+ subagent?: string
65
+ payload?: unknown
66
+ }
67
+
68
+ export type PluginExecCronJob = {
69
+ schedule: string
70
+ kind: 'exec'
71
+ command: string[]
72
+ enabled?: boolean
73
+ timezone?: string
74
+ }
75
+
76
+ export type PluginCronJob = PluginPromptCronJob | PluginExecCronJob
77
+
78
+ export type PluginSkill = {
79
+ description: string
80
+ content: string
81
+ frontmatter?: Record<string, unknown>
82
+ }
83
+
84
+ export type SessionStartEvent = {
85
+ sessionId: string
86
+ agentDir: string
87
+ }
88
+
89
+ export type SessionEndEvent = {
90
+ sessionId: string
91
+ }
92
+
93
+ export type SessionIdleEvent = {
94
+ sessionId: string
95
+ parentTranscriptPath: string | undefined
96
+ idleMs: number
97
+ origin?: SessionOrigin
98
+ }
99
+
100
+ // Provider prompt caching requires byte-identical prefixes. Mutations near the
101
+ // end of `event.prompt` preserve cache hits across sessions; mutations near
102
+ // the start invalidate the cache on every LLM call.
103
+ export type SessionPromptEvent = {
104
+ prompt: string
105
+ sessionId: string
106
+ agentDir: string
107
+ origin?: SessionOrigin
108
+ }
109
+
110
+ // Fired for plugin-defined tools and TypeClaw-exposed system tools, including
111
+ // built-in pi tools (read/bash/edit/write/grep/find/ls) when plugins are wired.
112
+ export type ToolBeforeEvent = {
113
+ tool: string
114
+ sessionId: string
115
+ callId: string
116
+ args: Record<string, unknown>
117
+ }
118
+
119
+ export type ToolBeforeResult = void | undefined | { block: true; reason: string }
120
+
121
+ export type ToolAfterEvent = {
122
+ tool: string
123
+ sessionId: string
124
+ callId: string
125
+ result: ToolResult
126
+ }
127
+
128
+ export type HookContext = {
129
+ agentDir: string
130
+ pluginName: string
131
+ logger: PluginLogger
132
+ }
133
+
134
+ export type Hooks = {
135
+ 'session.start'?: (event: SessionStartEvent, ctx: HookContext) => Promise<void> | void
136
+ 'session.end'?: (event: SessionEndEvent, ctx: HookContext) => Promise<void> | void
137
+ 'session.idle'?: (event: SessionIdleEvent, ctx: HookContext) => Promise<void> | void
138
+ 'session.prompt'?: (event: SessionPromptEvent, ctx: HookContext) => Promise<void> | void
139
+ 'tool.before'?: (event: ToolBeforeEvent, ctx: HookContext) => Promise<ToolBeforeResult> | ToolBeforeResult
140
+ 'tool.after'?: (event: ToolAfterEvent, ctx: HookContext) => Promise<void> | void
141
+ }
142
+
143
+ export type HookName = keyof Hooks
144
+
145
+ export type PluginLogger = {
146
+ info: (msg: string) => void
147
+ warn: (msg: string) => void
148
+ error: (msg: string) => void
149
+ }
150
+
151
+ export type PluginContext<TConfig = never> = {
152
+ readonly name: string
153
+ readonly version: string | undefined
154
+ readonly agentDir: string
155
+ readonly config: TConfig
156
+ readonly logger: PluginLogger
157
+ spawnSubagent: (name: string, payload?: unknown) => Promise<void>
158
+ }
159
+
160
+ export type PluginExports = {
161
+ tools?: Record<string, Tool<any>>
162
+ subagents?: Record<string, Subagent<any>>
163
+ cronJobs?: Record<string, PluginCronJob>
164
+ skills?: Record<string, PluginSkill>
165
+ skillsDirs?: string[]
166
+ hooks?: Hooks
167
+ }
168
+
169
+ export type DefinedPlugin<TConfig = never> = {
170
+ readonly configSchema?: z.ZodType<TConfig>
171
+ readonly plugin: (ctx: PluginContext<TConfig>) => Promise<PluginExports>
172
+ }