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,98 @@
1
+ import { realpath } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ import { ACKNOWLEDGE_GUARDS, type GuardBlock, isGuardAcknowledged } from '../policy'
5
+ import { isSkillAuthoringAllowed } from './skill-authoring'
6
+
7
+ export const GUARD_NON_WORKSPACE_WRITE = 'nonWorkspaceWrite'
8
+
9
+ const AGENT_ROOT_WRITE_ALLOWLIST = new Set([
10
+ 'AGENTS.md',
11
+ 'IDENTITY.md',
12
+ 'MEMORY.md',
13
+ 'SOUL.md',
14
+ 'USER.md',
15
+ 'cron.json',
16
+ 'package.json',
17
+ 'typeclaw.json',
18
+ ])
19
+
20
+ // `packages/` is a bun workspace root scaffolded at init (see
21
+ // src/init/index.ts#DIRECTORIES). Reusable systems and custom typeclaw
22
+ // plugins live there as standalone packages, so the agent must be able to
23
+ // write into `packages/<name>/...` without acknowledging the guard — same
24
+ // as `workspace/`, but for code intended to be reused rather than discarded.
25
+ const AGENT_ROOT_DIRECTORY_ALLOWLIST = new Set(['mounts', 'packages'])
26
+
27
+ export async function checkNonWorkspaceWriteGuard(options: {
28
+ tool: string
29
+ args: Record<string, unknown>
30
+ agentDir: string
31
+ }): Promise<GuardBlock | undefined> {
32
+ const { tool, args, agentDir } = options
33
+ if (tool !== 'write' && tool !== 'edit') return undefined
34
+
35
+ const rawPath = args.path
36
+ if (typeof rawPath !== 'string') return undefined
37
+
38
+ const targetPath = path.resolve(agentDir, rawPath)
39
+ const workspacePath = path.resolve(agentDir, 'workspace')
40
+ const [realTargetPath, realWorkspacePath] = await Promise.all([
41
+ resolveRealIntendedPath(targetPath),
42
+ resolveRealIntendedPath(workspacePath),
43
+ ])
44
+ if (await isSkillAuthoringAllowed({ tool, args, agentDir })) return undefined
45
+ if (await isAllowedAgentRootWrite(agentDir, targetPath, realTargetPath)) return undefined
46
+ if (isInside(realWorkspacePath, realTargetPath)) return undefined
47
+ if (isGuardAcknowledged(args, GUARD_NON_WORKSPACE_WRITE)) return undefined
48
+
49
+ return {
50
+ block: true,
51
+ reason: [
52
+ `Guard \`${GUARD_NON_WORKSPACE_WRITE}\` blocked ${tool} outside the workspace: ${targetPath}.`,
53
+ `The free-write zone is ${workspacePath}.`,
54
+ `Retry with \`${ACKNOWLEDGE_GUARDS}.${GUARD_NON_WORKSPACE_WRITE}: true\` only if this write is intentional.`,
55
+ ].join(' '),
56
+ }
57
+ }
58
+
59
+ async function isAllowedAgentRootWrite(agentDir: string, targetPath: string, realTargetPath: string): Promise<boolean> {
60
+ const resolvedAgentDir = path.resolve(agentDir)
61
+ if (path.dirname(targetPath) === resolvedAgentDir && AGENT_ROOT_WRITE_ALLOWLIST.has(path.basename(targetPath))) {
62
+ return true
63
+ }
64
+
65
+ for (const dir of AGENT_ROOT_DIRECTORY_ALLOWLIST) {
66
+ const rootDir = path.join(resolvedAgentDir, dir)
67
+ if (isInside(await resolveRealIntendedPath(rootDir), realTargetPath)) return true
68
+ }
69
+ return false
70
+ }
71
+
72
+ function isInside(parent: string, child: string): boolean {
73
+ const relative = path.relative(parent, child)
74
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
75
+ }
76
+
77
+ async function resolveRealIntendedPath(absolutePath: string): Promise<string> {
78
+ const pending: string[] = []
79
+ let current = absolutePath
80
+
81
+ while (true) {
82
+ try {
83
+ const realCurrent = await realpath(current)
84
+ return path.join(realCurrent, ...pending.reverse())
85
+ } catch (err) {
86
+ if (!isNotFoundError(err)) throw err
87
+ }
88
+
89
+ const parent = path.dirname(current)
90
+ if (parent === current) throw new Error(`could not resolve existing parent for ${absolutePath}`)
91
+ pending.push(path.basename(current))
92
+ current = parent
93
+ }
94
+ }
95
+
96
+ function isNotFoundError(err: unknown): boolean {
97
+ return err instanceof Error && 'code' in err && err.code === 'ENOENT'
98
+ }
@@ -0,0 +1,185 @@
1
+ import { readFile, realpath } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ import type { GuardBlock } from '../policy'
5
+
6
+ export const GUARD_SKILL_AUTHORING = 'skillAuthoring'
7
+
8
+ export type SkillAuthoringDecision = GuardBlock | { allow: true } | undefined
9
+
10
+ const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]*$/
11
+
12
+ type SkillRoot = {
13
+ path: string
14
+ }
15
+
16
+ export async function checkSkillAuthoringGuard(options: {
17
+ tool: string
18
+ args: Record<string, unknown>
19
+ agentDir: string
20
+ }): Promise<GuardBlock | undefined> {
21
+ const decision = await checkSkillAuthoringDecision(options)
22
+ return decision && 'block' in decision ? decision : undefined
23
+ }
24
+
25
+ export async function checkSkillAuthoringDecision(options: {
26
+ tool: string
27
+ args: Record<string, unknown>
28
+ agentDir: string
29
+ }): Promise<SkillAuthoringDecision> {
30
+ const { tool, args, agentDir } = options
31
+ if (tool !== 'write' && tool !== 'edit') return undefined
32
+
33
+ const rawPath = args.path
34
+ if (typeof rawPath !== 'string') return undefined
35
+
36
+ const targetPath = path.resolve(agentDir, rawPath)
37
+ const target = await resolveSkillTarget(agentDir, targetPath)
38
+ if (!target) return undefined
39
+
40
+ if (target.rest.length !== 2 || target.rest[1] !== 'SKILL.md') {
41
+ return block(tool, targetPath, 'skill writes must target exactly <skill-name>/SKILL.md')
42
+ }
43
+
44
+ const skillName = target.rest[0]
45
+ if (!skillName || !SKILL_NAME_PATTERN.test(skillName)) {
46
+ return block(tool, targetPath, `skill name must match ${SKILL_NAME_PATTERN}`)
47
+ }
48
+ if (skillName.startsWith('typeclaw-')) {
49
+ return block(tool, targetPath, 'the typeclaw- skill namespace is reserved for bundled skills')
50
+ }
51
+
52
+ const contentResult = await intendedContent(tool, args, targetPath)
53
+ if ('block' in contentResult) return contentResult
54
+
55
+ const frontmatter = parseFrontmatter(contentResult.content)
56
+ if (!frontmatter) {
57
+ return block(tool, targetPath, 'SKILL.md must start with YAML frontmatter')
58
+ }
59
+ if (frontmatter.name !== skillName) {
60
+ return block(tool, targetPath, `frontmatter name must match path segment ${skillName}`)
61
+ }
62
+ if (!frontmatter.description || frontmatter.description.trim().length === 0) {
63
+ return block(tool, targetPath, 'frontmatter description is required')
64
+ }
65
+
66
+ return { allow: true }
67
+ }
68
+
69
+ export async function isSkillAuthoringAllowed(options: {
70
+ tool: string
71
+ args: Record<string, unknown>
72
+ agentDir: string
73
+ }): Promise<boolean> {
74
+ const decision = await checkSkillAuthoringDecision(options)
75
+ return decision !== undefined && 'allow' in decision
76
+ }
77
+
78
+ async function resolveSkillTarget(agentDir: string, targetPath: string): Promise<{ rest: string[] } | undefined> {
79
+ const roots: SkillRoot[] = [
80
+ { path: path.join(agentDir, 'memory', 'skills') },
81
+ { path: path.join(agentDir, '.agents', 'skills') },
82
+ ]
83
+ const realTargetPath = await resolveRealIntendedPath(targetPath)
84
+
85
+ for (const root of roots) {
86
+ const realRootPath = await resolveRealIntendedPath(root.path)
87
+ if (!isInside(realRootPath, realTargetPath)) continue
88
+ return { rest: path.relative(realRootPath, realTargetPath).split(path.sep).filter(Boolean) }
89
+ }
90
+ return undefined
91
+ }
92
+
93
+ async function intendedContent(
94
+ tool: string,
95
+ args: Record<string, unknown>,
96
+ targetPath: string,
97
+ ): Promise<{ content: string } | GuardBlock> {
98
+ if (tool === 'write') {
99
+ const content = args.content
100
+ if (typeof content !== 'string') return block(tool, targetPath, 'write content must be a string')
101
+ return { content }
102
+ }
103
+
104
+ const edits = args.edits
105
+ if (!Array.isArray(edits)) return block(tool, targetPath, 'edit calls must include an edits array')
106
+
107
+ let content: string
108
+ try {
109
+ content = await readFile(targetPath, 'utf8')
110
+ } catch (err) {
111
+ const message = err instanceof Error ? err.message : String(err)
112
+ return block(tool, targetPath, `could not read existing skill before edit: ${message}`)
113
+ }
114
+
115
+ for (const edit of edits) {
116
+ if (!edit || typeof edit !== 'object') return block(tool, targetPath, 'each edit must be an object')
117
+ const { oldText, newText } = edit as Record<string, unknown>
118
+ if (typeof oldText !== 'string' || typeof newText !== 'string') {
119
+ return block(tool, targetPath, 'each edit must include string oldText and newText')
120
+ }
121
+ if (oldText.length === 0) return block(tool, targetPath, 'edit oldText must not be empty')
122
+ if (!content.includes(oldText)) return block(tool, targetPath, 'edit oldText was not found in existing skill')
123
+ content = content.replace(oldText, newText)
124
+ }
125
+ return { content }
126
+ }
127
+
128
+ function parseFrontmatter(content: string): { name?: string; description?: string } | undefined {
129
+ const normalized = content.replaceAll('\r\n', '\n')
130
+ if (!normalized.startsWith('---\n')) return undefined
131
+ const close = normalized.indexOf('\n---', 4)
132
+ if (close === -1) return undefined
133
+
134
+ const values: { name?: string; description?: string } = {}
135
+ for (const line of normalized.slice(4, close).split('\n')) {
136
+ const separator = line.indexOf(':')
137
+ if (separator === -1) continue
138
+ const key = line.slice(0, separator).trim()
139
+ if (key !== 'name' && key !== 'description') continue
140
+ values[key] = parseScalar(line.slice(separator + 1).trim())
141
+ }
142
+ return values
143
+ }
144
+
145
+ function parseScalar(value: string): string {
146
+ if (value.length === 0) return ''
147
+ const quote = value[0]
148
+ if ((quote === '"' || quote === "'") && value.endsWith(quote)) return value.slice(1, -1)
149
+ return value
150
+ }
151
+
152
+ function block(tool: string, targetPath: string, reason: string): GuardBlock {
153
+ return {
154
+ block: true,
155
+ reason: `Guard \`${GUARD_SKILL_AUTHORING}\` blocked ${tool} for ${targetPath}: ${reason}.`,
156
+ }
157
+ }
158
+
159
+ function isInside(parent: string, child: string): boolean {
160
+ const relative = path.relative(parent, child)
161
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
162
+ }
163
+
164
+ async function resolveRealIntendedPath(absolutePath: string): Promise<string> {
165
+ const pending: string[] = []
166
+ let current = absolutePath
167
+
168
+ while (true) {
169
+ try {
170
+ const realCurrent = await realpath(current)
171
+ return path.join(realCurrent, ...pending.reverse())
172
+ } catch (err) {
173
+ if (!isNotFoundError(err)) throw err
174
+ }
175
+
176
+ const parent = path.dirname(current)
177
+ if (parent === current) throw new Error(`could not resolve existing parent for ${absolutePath}`)
178
+ pending.push(path.basename(current))
179
+ current = parent
180
+ }
181
+ }
182
+
183
+ function isNotFoundError(err: unknown): boolean {
184
+ return err instanceof Error && 'code' in err && err.code === 'ENOENT'
185
+ }
@@ -0,0 +1,85 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import type { ContentPart, ToolResult } from '@/plugin'
5
+
6
+ export const GUARD_UNCOMMITTED_CHANGES = 'uncommittedChanges'
7
+
8
+ const FILE_TOUCHING_TOOLS = new Set(['write', 'edit', 'bash'])
9
+
10
+ const RUNTIME_OWNED_PREFIXES = ['sessions/', 'memory/']
11
+
12
+ const WARNING_TEXT =
13
+ '\n\n[guard:uncommittedChanges] The worktree has uncommitted changes. Commit (or stash) them when this task is done — leaving stale changes around between turns risks losing work and confusing future commits.'
14
+
15
+ export type UncommittedChangesDeps = {
16
+ readStatus: (agentDir: string) => Promise<readonly string[] | null>
17
+ }
18
+
19
+ export async function checkUncommittedChangesAdvice(options: {
20
+ tool: string
21
+ agentDir: string
22
+ result: ToolResult
23
+ deps?: UncommittedChangesDeps
24
+ }): Promise<void> {
25
+ const { tool, agentDir, result } = options
26
+ if (!FILE_TOUCHING_TOOLS.has(tool)) return
27
+ if (!existsSync(join(agentDir, '.git'))) return
28
+
29
+ const deps = options.deps ?? defaultDeps
30
+ const status = await deps.readStatus(agentDir)
31
+ if (status === null) return
32
+
33
+ const dirty = status.filter((p) => !RUNTIME_OWNED_PREFIXES.some((prefix) => p.startsWith(prefix)))
34
+ if (dirty.length === 0) return
35
+
36
+ appendAdviceToContent(result.content, WARNING_TEXT)
37
+ }
38
+
39
+ export function parsePorcelain(stdout: string): string[] {
40
+ const out: string[] = []
41
+ for (const raw of stdout.split('\n')) {
42
+ if (raw.length < 4) continue
43
+ const rest = raw.slice(3)
44
+ const arrowIdx = rest.indexOf(' -> ')
45
+ out.push(arrowIdx === -1 ? rest : rest.slice(arrowIdx + 4))
46
+ }
47
+ return out
48
+ }
49
+
50
+ function appendAdviceToContent(content: ContentPart[], advice: string): void {
51
+ for (let i = content.length - 1; i >= 0; i--) {
52
+ const part = content[i]
53
+ if (part && part.type === 'text') {
54
+ content[i] = { ...part, text: `${part.text}${advice}` }
55
+ return
56
+ }
57
+ }
58
+ content.push({ type: 'text', text: advice.trimStart() })
59
+ }
60
+
61
+ const defaultDeps: UncommittedChangesDeps = {
62
+ async readStatus(agentDir) {
63
+ const bun = getBun()
64
+ if (!bun) return null
65
+ try {
66
+ const proc = bun.spawn({
67
+ cmd: ['git', 'status', '--porcelain=v1'],
68
+ cwd: agentDir,
69
+ stdout: 'pipe',
70
+ stderr: 'pipe',
71
+ })
72
+ const exit = await proc.exited
73
+ if (exit !== 0) return null
74
+ const text = await new Response(proc.stdout).text()
75
+ return parsePorcelain(text)
76
+ } catch {
77
+ return null
78
+ }
79
+ },
80
+ }
81
+
82
+ function getBun(): typeof Bun | null {
83
+ const g = globalThis as { Bun?: typeof Bun }
84
+ return g.Bun ?? null
85
+ }
@@ -0,0 +1,18 @@
1
+ export const ACKNOWLEDGE_GUARDS = 'acknowledgeGuards'
2
+
3
+ export type GuardBlock = { block: true; reason: string }
4
+
5
+ export function isGuardAcknowledged(args: Record<string, unknown>, guard: string): boolean {
6
+ const acknowledgements = args[ACKNOWLEDGE_GUARDS]
7
+ if (!acknowledgements || typeof acknowledgements !== 'object') return false
8
+ return (acknowledgements as Record<string, unknown>)[guard] === true
9
+ }
10
+
11
+ export { GUARD_NON_WORKSPACE_WRITE, checkNonWorkspaceWriteGuard } from './policies/non-workspace-write'
12
+ export {
13
+ GUARD_SKILL_AUTHORING,
14
+ checkSkillAuthoringDecision,
15
+ checkSkillAuthoringGuard,
16
+ isSkillAuthoringAllowed,
17
+ } from './policies/skill-authoring'
18
+ export { GUARD_UNCOMMITTED_CHANGES, checkUncommittedChangesAdvice } from './policies/uncommitted-changes'
@@ -0,0 +1,71 @@
1
+ # typeclaw-plugin-memory
2
+
3
+ The bundled memory plugin. Owns `MEMORY.md` (long-term memory) and `memory/yyyy-MM-dd.md` (daily streams) plus the two subagents that write them: `memory-logger` and `dreaming`.
4
+
5
+ This plugin is **auto-loaded** by every TypeClaw agent. There is no `plugins[]` entry to add and no opt-out. To configure it, add a `memory` block to `typeclaw.json`.
6
+
7
+ ## Config
8
+
9
+ ```json
10
+ {
11
+ "memory": {
12
+ "idleMs": 10000,
13
+ "bufferBytes": 100000,
14
+ "dreaming": { "schedule": "*/30 * * * *" }
15
+ }
16
+ }
17
+ ```
18
+
19
+ | Field | Default | Effect |
20
+ | -------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
21
+ | `memory.idleMs` | `10000` | Debounce window before `memory-logger` spawns after a prompt completes. Minimum `1000`. |
22
+ | `memory.bufferBytes` | `100000` | Size-based ceiling: spawns `memory-logger` when the transcript grows by this many bytes since the last run, even during continuous activity. `0` disables. Minimum `10000` when non-zero. |
23
+ | `memory.dreaming` | `{}` (cron job on) | Dreaming cron job is always registered. Override `schedule` to change when it fires. |
24
+ | `memory.dreaming.schedule` | `"*/30 * * * *"` | Five-field cron expression. Defaults to every 30 minutes; fires short-circuit with zero LLM cost when nothing sits past the watermark, so frequent no-op fires are cheap and let sporadic agents still consolidate while alive (`src/cron/scheduler.ts` has no catchup for missed fires). Second-level schedules are rejected to avoid noisy no-op dreaming loops. |
25
+
26
+ All fields are **restart-required** — the plugin reads them once at boot.
27
+
28
+ ## What it contributes
29
+
30
+ | Kind | Name | Notes |
31
+ | -------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
32
+ | Subagent | `memory-logger` | Reads a parent transcript past a watermark and appends fragments to `memory/<today>.md`. Coalesced per `agentDir`; the plugin chains spawn calls onto a per-agent Promise so two concurrent channel sessions never race on the same daily stream file. |
33
+ | Subagent | `dreaming` | Reads `MEMORY.md` plus undreamed daily-stream tails, rewrites `MEMORY.md`, optionally writes muscle-memory skills under `memory/skills/<name>/SKILL.md`, advances the per-day watermark, and `git commit -m Dream` the result. Coalesced per `agentDir`. |
34
+ | Cron job | `__plugin_memory_dreaming` | `kind: 'prompt'`, `subagent: 'dreaming'`, scheduled per `memory.dreaming.schedule`. |
35
+ | Hook | `session.prompt` | Appends the rendered memory section (`# Memory`, `MEMORY.md`, undreamed stream tails) to `event.prompt`. |
36
+ | Hook | `session.idle` | Per-session debouncer with size-based ceiling. Resets a `setTimeout(idleMs)` on every event; on fire, calls `ctx.spawnSubagent('memory-logger', ...)`. Also `fs.stat`s the transcript on every event and spawns immediately when growth since the last run reaches `bufferBytes`. |
37
+ | Hook | `session.end` | Cancels the debounce timer and immediately spawns `memory-logger` (so the final transcript is captured even when the user disconnects right away). |
38
+
39
+ ## Files on disk
40
+
41
+ - **`MEMORY.md`** — long-term memory. Created by the dreaming subagent on first run if absent. Force-committed by the runtime; `skip-worktree` flag is set so the human's `git status` stays clean.
42
+ - **`memory/yyyy-MM-dd.md`** — daily fragment streams. Appended to by `memory-logger`. Created on demand. Gitignored at the agent's level but force-committed alongside `MEMORY.md` after each dreaming run.
43
+ - **`memory/skills/<name>/SKILL.md`** — _muscle memory_. Skills the dreaming subagent distills from repeated procedures it sees in daily streams. Auto-discovered as first-class skills by `createResourceLoader`, and force-committed under the same `memory/` snapshot path as the daily streams. Written or refined with the standard `write` / `edit` tools; the bundled guard plugin enforces the exact `memory/skills/<name>/SKILL.md` path shape, single-segment kebab/snake-case names, matching frontmatter, and symlink/path-traversal safety. There is no runtime skill-delete tool; outright deletion of muscle-memory skills remains a user decision.
44
+ - **`memory/.dreaming-state.json`** — per-day watermarks (line counts already consolidated into `MEMORY.md`). Plain JSON; on malformed input the plugin fails open with empty state.
45
+
46
+ `typeclaw init` does **not** scaffold these files. They appear when needed.
47
+
48
+ ## How `session.idle` works
49
+
50
+ Core fires `session.idle` immediately after every `session.prompt()` completion (success or error). The plugin owns the debounce: it keeps a `Map<sessionId, Timeout>` and resets the timer on every event. When the timer fires, the plugin spawns `memory-logger` for that session.
51
+
52
+ If the user starts a new prompt before the timer fires, the next `session.idle` event resets the timer. If the user disconnects, `session.end` cancels the timer and fires `memory-logger` immediately so the final transcript is captured.
53
+
54
+ In channel sessions, the agent rarely goes idle long enough to trip the timer because new participant messages keep arriving. The size-based ceiling handles this: on every `session.idle` the plugin `fs.stat`s the transcript and compares against the size at the last memory-logger run. Once growth reaches `memory.bufferBytes`, the timer is cancelled and `memory-logger` spawns immediately. The watermark on the output side absorbs any over-firing — if a buffer-trip arrives on a transcript chunk that's all tool noise, `memory-logger` reads it, decides nothing is worth logging, advances the watermark, and exits.
55
+
56
+ ## Migration notes (from before the plugin existed)
57
+
58
+ - `memory.idleMs` and `memory.dreaming.schedule` already existed in core's `typeclaw.json` schema. They moved into this plugin's `configSchema` verbatim. Existing agents continue to work with no config change.
59
+ - `memory.dreaming.schedule` was previously live-reloadable. It is now **restart-required** because plugin config is read once at boot. To change the schedule, edit `typeclaw.json` and run `typeclaw restart`.
60
+ - The cron job ID changed from `__internal_dreaming` to `__plugin_memory_dreaming`. Anything that referenced the old ID (custom dashboards, scripts) needs updating.
61
+
62
+ ## Tests
63
+
64
+ - `index.test.ts` — composition tests (config schema, hook wiring, debounce semantics, MEMORY.md auto-create).
65
+ - `memory-logger.test.ts` — system prompt invariants, watermark handling.
66
+ - `dreaming.test.ts` — orchestration, watermark advancement, git snapshot (including muscle-memory skill files), system prompt + tool-surface invariants.
67
+ - `dreaming-state.test.ts` — fail-open semantics on malformed state.
68
+ - `watermark.test.ts` — marker parsing.
69
+ - `append-tool.test.ts` — append-only semantics.
70
+ - `src/bundled-plugins/guard/policies/skill-authoring.test.ts` — runtime skill authoring guard: path sandboxing, name validation, YAML frontmatter, and write/edit final-content validation.
71
+ - `load-memory.test.ts` — memory section rendering, undreamed-tail filtering, watermark stripping.
@@ -0,0 +1,84 @@
1
+ import { appendFile, mkdir, open, readFile, stat } from 'node:fs/promises'
2
+ import { dirname } from 'node:path'
3
+
4
+ import { z } from 'zod'
5
+
6
+ import { defineTool } from '@/plugin'
7
+
8
+ import { fragmentContentHash, parseFragments } from './fragment-parser'
9
+ import { detectSecrets } from './secret-detector'
10
+
11
+ const NEWLINE_BYTE = 0x0a
12
+
13
+ export const appendTool = defineTool({
14
+ description:
15
+ 'Append content to a file. Creates the file (and any missing parent directories) if needed. Never truncates or overwrites existing content. If the file is non-empty and does not already end in a newline, a single newline is inserted before the appended content so consecutive appends do not run together. Refuses to write content that contains recognizable credential patterns (API keys, tokens, private keys); record the variable name and how it was discovered, never the value. Refuses to append a fragment whose topic+body already exists in the file (case-by-case; topics legitimately repeat across days, but byte-equivalent fragments within the same daily stream are duplicates by design).',
16
+ parameters: z.object({
17
+ path: z.string().describe('Path to the file to append to (relative or absolute).'),
18
+ content: z.string().describe('Content to append, exactly as given.'),
19
+ }),
20
+ async execute({ path, content }) {
21
+ const secrets = detectSecrets(content)
22
+ if (secrets.length > 0) {
23
+ const ruleNames = [...new Set(secrets.map((s) => s.rule))].join(', ')
24
+ throw new Error(
25
+ `Refusing to append: content contains a recognized credential pattern (${ruleNames}). ` +
26
+ `Memory fragments must never quote secret values verbatim. Record the env var name and how it ` +
27
+ `was discovered, not the value itself.`,
28
+ )
29
+ }
30
+ const incomingFragments = parseFragments(content)
31
+ if (incomingFragments.length > 0) {
32
+ const existingHashes = await readExistingFragmentHashes(path)
33
+ const duplicates = incomingFragments.filter((f) => existingHashes.has(fragmentContentHash(f)))
34
+ if (duplicates.length > 0) {
35
+ const topics = duplicates.map((d) => `"${d.topic}"`).join(', ')
36
+ throw new Error(
37
+ `Refusing to append: ${duplicates.length} fragment${duplicates.length === 1 ? '' : 's'} (${topics}) ` +
38
+ `already exist in ${path} with byte-equivalent content. The dreaming subagent will see the existing ` +
39
+ `fragment; do not write it again. If the new occurrence is genuinely informative (e.g. a recurrence ` +
40
+ `that establishes a pattern), write a fragment that says so explicitly rather than restating the ` +
41
+ `original.`,
42
+ )
43
+ }
44
+ }
45
+ await mkdir(dirname(path), { recursive: true })
46
+ const prefix = (await needsLeadingNewline(path)) ? '\n' : ''
47
+ await appendFile(path, prefix + content, 'utf-8')
48
+ const bytesAppended = prefix.length + content.length
49
+ return {
50
+ content: [{ type: 'text' as const, text: `Appended ${bytesAppended} bytes to ${path}` }],
51
+ details: { path, bytesAppended, leadingNewlineInserted: prefix.length > 0 },
52
+ }
53
+ },
54
+ })
55
+
56
+ async function readExistingFragmentHashes(path: string): Promise<Set<string>> {
57
+ let content: string
58
+ try {
59
+ content = await readFile(path, 'utf8')
60
+ } catch (err) {
61
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return new Set()
62
+ throw err
63
+ }
64
+ return new Set(parseFragments(content).map((f) => fragmentContentHash(f)))
65
+ }
66
+
67
+ async function needsLeadingNewline(path: string): Promise<boolean> {
68
+ let info: Awaited<ReturnType<typeof stat>>
69
+ try {
70
+ info = await stat(path)
71
+ } catch (err) {
72
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false
73
+ throw err
74
+ }
75
+ if (info.size === 0) return false
76
+ const fh = await open(path, 'r')
77
+ try {
78
+ const buf = Buffer.alloc(1)
79
+ await fh.read(buf, 0, 1, info.size - 1)
80
+ return buf[0] !== NEWLINE_BYTE
81
+ } finally {
82
+ await fh.close()
83
+ }
84
+ }
@@ -0,0 +1,86 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
3
+ import { dirname, join } from 'node:path'
4
+
5
+ export const DREAMING_STATE_FILE = 'memory/.dreaming-state.json'
6
+
7
+ const VERSION = 1
8
+
9
+ // Per-day watermark: the number of lines of `memory/yyyy-MM-dd.md` that have
10
+ // been consolidated into MEMORY.md. The next dreaming run reads only the tail
11
+ // past this point. The next system-prompt injection (loadMemory) shows only
12
+ // the tail too, so already-consolidated content does not appear twice.
13
+ //
14
+ // We deliberately track lines (not bytes) because line-based slicing is
15
+ // human-inspectable and the `fragments:` citations in MEMORY.md already use
16
+ // `memory/yyyy-MM-dd:<line>-<line>` notation.
17
+ export type DreamingState = {
18
+ version: number
19
+ dreamedThrough: Record<string, DreamedDay>
20
+ }
21
+
22
+ export type DreamedDay = {
23
+ lines: number
24
+ ts: string
25
+ }
26
+
27
+ export function emptyState(): DreamingState {
28
+ return { version: VERSION, dreamedThrough: {} }
29
+ }
30
+
31
+ // Missing or unreadable file → empty state. Malformed JSON or wrong shape is
32
+ // also treated as empty: the cost is one redundant re-consolidation, which is
33
+ // strictly safer than crashing the dreaming pipeline because of a bad state
34
+ // file.
35
+ export async function loadDreamingState(agentDir: string): Promise<DreamingState> {
36
+ const path = join(agentDir, DREAMING_STATE_FILE)
37
+ if (!existsSync(path)) return emptyState()
38
+
39
+ let raw: string
40
+ try {
41
+ raw = await readFile(path, 'utf8')
42
+ } catch {
43
+ return emptyState()
44
+ }
45
+
46
+ let parsed: unknown
47
+ try {
48
+ parsed = JSON.parse(raw)
49
+ } catch {
50
+ return emptyState()
51
+ }
52
+
53
+ if (!isDreamingState(parsed)) return emptyState()
54
+ return parsed
55
+ }
56
+
57
+ export async function saveDreamingState(agentDir: string, state: DreamingState): Promise<void> {
58
+ const path = join(agentDir, DREAMING_STATE_FILE)
59
+ await mkdir(dirname(path), { recursive: true })
60
+ await writeFile(path, `${JSON.stringify(state, null, 2)}\n`, 'utf8')
61
+ }
62
+
63
+ export function getDreamedLines(state: DreamingState, date: string): number {
64
+ return state.dreamedThrough[date]?.lines ?? 0
65
+ }
66
+
67
+ export function setDreamedLines(state: DreamingState, date: string, lines: number, ts: string): DreamingState {
68
+ return {
69
+ version: state.version,
70
+ dreamedThrough: { ...state.dreamedThrough, [date]: { lines, ts } },
71
+ }
72
+ }
73
+
74
+ function isDreamingState(value: unknown): value is DreamingState {
75
+ if (typeof value !== 'object' || value === null) return false
76
+ const v = value as Record<string, unknown>
77
+ if (v.version !== VERSION) return false
78
+ if (typeof v.dreamedThrough !== 'object' || v.dreamedThrough === null) return false
79
+ for (const [, entry] of Object.entries(v.dreamedThrough as Record<string, unknown>)) {
80
+ if (typeof entry !== 'object' || entry === null) return false
81
+ const e = entry as Record<string, unknown>
82
+ if (typeof e.lines !== 'number' || e.lines < 0) return false
83
+ if (typeof e.ts !== 'string') return false
84
+ }
85
+ return true
86
+ }