typeclaw 0.20.0 → 0.22.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 (55) hide show
  1. package/package.json +2 -1
  2. package/src/agent/index.ts +55 -1
  3. package/src/agent/loop-guard.ts +180 -53
  4. package/src/agent/restart/index.ts +101 -0
  5. package/src/agent/tools/restart.ts +23 -52
  6. package/src/bundled-plugins/bun-hygiene/README.md +82 -0
  7. package/src/bundled-plugins/bun-hygiene/index.ts +11 -0
  8. package/src/bundled-plugins/bun-hygiene/policy.ts +318 -0
  9. package/src/bundled-plugins/github-cli-auth/gh-command.ts +98 -6
  10. package/src/bundled-plugins/github-cli-auth/graphql-auth-nudge.ts +80 -0
  11. package/src/bundled-plugins/github-cli-auth/index.ts +7 -0
  12. package/src/bundled-plugins/memory/memory-logger.ts +6 -2
  13. package/src/bundled-plugins/reviewer/skills/code-review.ts +8 -0
  14. package/src/channels/adapters/discord-bot-classify.ts +8 -2
  15. package/src/channels/adapters/discord-bot.ts +29 -2
  16. package/src/channels/adapters/github/decoy-reviewer.ts +43 -0
  17. package/src/channels/adapters/github/inbound.ts +92 -1
  18. package/src/channels/adapters/github/index.ts +12 -1
  19. package/src/channels/adapters/github/reactions.ts +138 -4
  20. package/src/channels/adapters/slack-bot-classify.ts +2 -2
  21. package/src/channels/adapters/slack-bot.ts +129 -7
  22. package/src/channels/engagement.ts +71 -31
  23. package/src/channels/manager.ts +8 -0
  24. package/src/channels/router.ts +180 -25
  25. package/src/channels/schema.ts +18 -0
  26. package/src/channels/types.ts +16 -1
  27. package/src/cli/builtins.ts +1 -0
  28. package/src/cli/dreams.ts +148 -0
  29. package/src/cli/index.ts +1 -0
  30. package/src/cli/inspect.ts +2 -1
  31. package/src/cli/ui.ts +34 -0
  32. package/src/commands/index.ts +5 -2
  33. package/src/config/config.ts +89 -0
  34. package/src/dreams/git.ts +85 -0
  35. package/src/dreams/index.ts +134 -0
  36. package/src/dreams/parse.ts +224 -0
  37. package/src/dreams/render.ts +155 -0
  38. package/src/dreams/types.ts +50 -0
  39. package/src/mcp/catalog.ts +29 -0
  40. package/src/mcp/client.ts +236 -0
  41. package/src/mcp/index.ts +25 -0
  42. package/src/mcp/manager.ts +156 -0
  43. package/src/mcp/tools.ts +190 -0
  44. package/src/permissions/builtins.ts +9 -0
  45. package/src/reload/format.ts +14 -0
  46. package/src/reload/index.ts +1 -0
  47. package/src/run/bundled-plugins.ts +7 -0
  48. package/src/run/channel-session-factory.ts +3 -0
  49. package/src/run/index.ts +38 -1
  50. package/src/server/command-runner.ts +5 -0
  51. package/src/server/index.ts +53 -0
  52. package/src/shared/protocol.ts +2 -0
  53. package/src/skills/typeclaw-channel-github/SKILL.md +86 -22
  54. package/src/tui/index.ts +70 -18
  55. package/typeclaw.schema.json +82 -0
package/src/cli/ui.ts CHANGED
@@ -7,6 +7,28 @@ import { type AutoUpgradeOutcome, describeAutoUpgrade } from '@/init/auto-upgrad
7
7
 
8
8
  export { cancel, intro, isCancel, log, note, outro }
9
9
 
10
+ type ClackInput = Pick<NodeJS.ReadStream, 'isTTY' | 'setRawMode' | 'resume'>
11
+
12
+ // Hand stdin to a clack picker in a state it can own. Over an SSH pseudo-TTY,
13
+ // Bun's readline keypress wiring only transitions stdin into flowing raw mode
14
+ // reliably once the stream has already been resumed; on a never-resumed stdin
15
+ // the picker renders but arrow keys echo as raw `^[[B` and never advance it.
16
+ // Local terminals dodge this because stdin was already flowing. So before every
17
+ // picker: clear any stale raw mode for a clean baseline, then resume the stream.
18
+ // Never pause() here — a previously-paused process.stdin does not reliably
19
+ // re-flow under Bun, which is the same failure this resume() is fixing.
20
+ export function prepareStdinForClack(input: ClackInput = process.stdin): void {
21
+ if (!input.isTTY) return
22
+ if (typeof input.setRawMode === 'function') {
23
+ try {
24
+ input.setRawMode(false)
25
+ } catch {
26
+ /* terminal already torn down */
27
+ }
28
+ }
29
+ input.resume()
30
+ }
31
+
10
32
  function colorize(modifier: Parameters<typeof styleText>[0], s: string): string {
11
33
  if (!colorsEnabled()) return s
12
34
  return styleText(modifier, s)
@@ -169,6 +191,18 @@ export const SLACK_APP_MANIFEST = {
169
191
  url: 'https://example.invalid/typeclaw-uses-socket-mode',
170
192
  should_escape: false,
171
193
  },
194
+ {
195
+ command: '/reload',
196
+ description: 'Reload typeclaw config and subsystems from disk',
197
+ url: 'https://example.invalid/typeclaw-uses-socket-mode',
198
+ should_escape: false,
199
+ },
200
+ {
201
+ command: '/restart',
202
+ description: 'Restart the typeclaw container',
203
+ url: 'https://example.invalid/typeclaw-uses-socket-mode',
204
+ should_escape: false,
205
+ },
172
206
  ],
173
207
  },
174
208
  oauth_config: {
@@ -13,8 +13,11 @@ export type CommandHandler<Context> = (
13
13
  // dispatcher, so a new command declares its own requirements in one place:
14
14
  // 'session.control' + requiresLiveSession:true is the control-command default
15
15
  // (/stop); 'none' + requiresLiveSession:false is the informational default
16
- // (/help). Both are optional so plain registries (tests, TUI) need not care.
17
- export type CommandPermission = 'none' | 'session.control'
16
+ // (/help). 'session.admin' + requiresLiveSession:false is the operate-the-agent
17
+ // tier (/reload, /restart) owner+trusted only, no live session required since
18
+ // it acts on the container, not a channel turn. Both are optional so plain
19
+ // registries (tests, TUI) need not care.
20
+ export type CommandPermission = 'none' | 'session.control' | 'session.admin'
18
21
 
19
22
  export type Command<Context> = {
20
23
  name: string
@@ -8,6 +8,7 @@ import { z } from 'zod'
8
8
  import { channelsSchema } from '@/channels/schema'
9
9
  import { commitSystemFileSync } from '@/git/system-commit'
10
10
  import { rolesConfigSchema } from '@/permissions/schema'
11
+ import { secretFieldSchema } from '@/secrets/resolve'
11
12
 
12
13
  import {
13
14
  DEFAULT_MODEL_REF,
@@ -30,6 +31,30 @@ const DEFAULT_PORT = 8973
30
31
  // of files like `mounts/.git` or `mounts/Hello`.
31
32
  const MOUNT_NAME_PATTERN = /^[a-z0-9][a-z0-9-_]*$/
32
33
 
34
+ // Shell-portable env var identifier: a leading letter or underscore followed by
35
+ // letters, digits, or underscores. MCP `env` keys are passed verbatim to a child
36
+ // process environment, so an invalid identifier (spaces, `=`, leading digit)
37
+ // would be silently dropped or corrupt the spawned server's env.
38
+ const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
39
+
40
+ // Upper bound for a per-server MCP request timeout: 10 minutes. Long-running
41
+ // MCP tools (large crawls, builds) can legitimately take minutes, but a ceiling
42
+ // guards against fat-finger values that would re-introduce the unbounded-hang
43
+ // failure mode the explicit timeouts exist to prevent.
44
+ const MCP_MAX_TIMEOUT_MS = 600_000
45
+
46
+ // URL schemes are case-insensitive (RFC 3986), and the WHATWG parser normalizes
47
+ // `.protocol` to lowercase. Checking the parsed protocol instead of a raw
48
+ // `startsWith` keeps `HTTPS://…` valid, which `z.string().url()` already accepts.
49
+ function isHttpProtocol(value: string): boolean {
50
+ try {
51
+ const protocol = new URL(value).protocol
52
+ return protocol === 'http:' || protocol === 'https:'
53
+ } catch {
54
+ return false
55
+ }
56
+ }
57
+
33
58
  export const mountSchema = z.object({
34
59
  name: z.string().regex(MOUNT_NAME_PATTERN, 'mount name must be lowercase alphanumeric with - or _'),
35
60
  path: z.string().min(1),
@@ -39,6 +64,66 @@ export const mountSchema = z.object({
39
64
 
40
65
  export type Mount = z.infer<typeof mountSchema>
41
66
 
67
+ // MCP servers are keyed by the same shell/disk-safe namespace as mounts because
68
+ // the name becomes the tool namespace exposed to the agent. The transport is an
69
+ // XOR on purpose: stdio servers are child processes (`command` + `args` + env),
70
+ // while Streamable HTTP servers are remote endpoints (`url`); accepting both
71
+ // would make ownership, lifetime, and credential injection ambiguous at boot.
72
+ export const mcpServerSchema = z
73
+ .object({
74
+ name: z
75
+ .string()
76
+ .regex(MOUNT_NAME_PATTERN, 'MCP server name must be lowercase alphanumeric with - or _')
77
+ .refine((name) => !name.includes('__'), {
78
+ message: "MCP server name must not contain '__' (reserved as the tool-namespace separator)",
79
+ }),
80
+ description: z.string().optional(),
81
+ // Default true so omitting the field keeps the server on; set false to keep config but skip connecting.
82
+ enabled: z.boolean().default(true),
83
+ timeoutMs: z.number().int().positive().max(MCP_MAX_TIMEOUT_MS).optional(),
84
+ command: z.string().trim().min(1).optional(),
85
+ args: z.array(z.string()).default([]),
86
+ url: z
87
+ .string()
88
+ .url()
89
+ .refine((u) => isHttpProtocol(u), {
90
+ message: 'MCP server url must use http:// or https://',
91
+ })
92
+ .optional(),
93
+ env: z
94
+ .record(z.string().regex(ENV_NAME_PATTERN, 'env var name must be a valid identifier'), secretFieldSchema)
95
+ .default({}),
96
+ })
97
+ .refine((server) => (server.command !== undefined) !== (server.url !== undefined), {
98
+ message: 'MCP server must be either stdio (command) or http (url), not both or neither',
99
+ })
100
+
101
+ export type McpServer = z.infer<typeof mcpServerSchema>
102
+
103
+ // The name becomes the `<server>__<tool>` namespace at dispatch, so duplicates
104
+ // would make tool lookup ambiguous and silently shadow one server behind
105
+ // another. Reject them with an indexed path so the error points at the
106
+ // offending entry instead of the whole array.
107
+ const mcpServersArraySchema = z
108
+ .array(mcpServerSchema)
109
+ .default([])
110
+ .superRefine((entries, ctx) => {
111
+ const seen = new Map<string, number>()
112
+ for (let i = 0; i < entries.length; i++) {
113
+ const name = entries[i]!.name
114
+ const prev = seen.get(name)
115
+ if (prev !== undefined) {
116
+ ctx.addIssue({
117
+ code: 'custom',
118
+ path: [i, 'name'],
119
+ message: `mcpServers[${i}].name duplicates mcpServers[${prev}].name ('${name}')`,
120
+ })
121
+ } else {
122
+ seen.set(name, i)
123
+ }
124
+ }
125
+ })
126
+
42
127
  const portNumber = z.number().int().min(1).max(65535)
43
128
 
44
129
  // `allow` is the discriminator between "forward everything" ('*') and a fixed
@@ -391,6 +476,7 @@ export const configSchema = z
391
476
  // host paths exposed) without failing the whole config load. `typeclaw
392
477
  // init` omits this field so users don't see noise for the empty case.
393
478
  mounts: z.array(mountSchema).default([]),
479
+ mcpServers: mcpServersArraySchema,
394
480
  plugins: z.array(z.string().min(1)).default([]),
395
481
  // Additional names the agent answers to in channel engagement, on top
396
482
  // of `basename(agentDir)` which is always implicit. Each entry is a
@@ -538,6 +624,7 @@ export const FIELD_EFFECTS: Record<string, FieldEffect> = {
538
624
  models: 'applied',
539
625
  port: 'restart-required',
540
626
  mounts: 'restart-required',
627
+ mcpServers: 'restart-required',
541
628
  plugins: 'restart-required',
542
629
  alias: 'applied',
543
630
  channels: 'applied',
@@ -638,6 +725,8 @@ export function extractPluginConfigs(raw: unknown): Record<string, unknown> {
638
725
  'git',
639
726
  'roles',
640
727
  'permissions',
728
+ 'tunnels',
729
+ 'mcpServers',
641
730
  ])
642
731
  const result: Record<string, unknown> = {}
643
732
  for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
@@ -0,0 +1,85 @@
1
+ export type GitResult = { exitCode: number; stdout: string; stderr: string }
2
+ export type SpawnGit = (args: string[], cwd: string) => Promise<GitResult>
3
+
4
+ export type RawCommit = {
5
+ sha: string
6
+ shortSha: string
7
+ committedAt: string
8
+ subject: string
9
+ }
10
+
11
+ export type ResolveRepoResult = { ok: true; root: string } | { ok: false; reason: 'not-a-repo' | 'git-failed' }
12
+
13
+ const FIELD_SEP = '\x1f'
14
+ const RECORD_SEP = '\x1e'
15
+
16
+ export async function resolveGitRepo(cwd: string, spawnGit: SpawnGit = defaultSpawnGit): Promise<ResolveRepoResult> {
17
+ const res = await spawnGit(['rev-parse', '--show-toplevel'], cwd)
18
+ if (res.exitCode === 0) {
19
+ const root = res.stdout.trim()
20
+ if (root.length > 0) return { ok: true, root }
21
+ return { ok: false, reason: 'git-failed' }
22
+ }
23
+ if (/not a git repository/i.test(res.stderr)) return { ok: false, reason: 'not-a-repo' }
24
+ return { ok: false, reason: 'git-failed' }
25
+ }
26
+
27
+ const DREAM_SUBJECT_PREFIX = 'dream: '
28
+
29
+ export async function readDreamCommitLog(
30
+ root: string,
31
+ opts: { limit?: number } = {},
32
+ spawnGit: SpawnGit = defaultSpawnGit,
33
+ ): Promise<RawCommit[]> {
34
+ // --grep is only a cheap pre-filter: it matches ANY line of the commit
35
+ // message, so a non-dream commit with a `dream: ...` body line slips
36
+ // through. The subject is the authoritative contract, so the prefix filter
37
+ // below is what actually decides membership — and the limit is applied
38
+ // AFTER it so body-matching impostors can't consume a slot and shrink the
39
+ // result below the requested count.
40
+ const args = ['log', '--grep=^dream: ', `--format=%H${FIELD_SEP}%h${FIELD_SEP}%cI${FIELD_SEP}%s${RECORD_SEP}`]
41
+
42
+ const res = await spawnGit(args, root)
43
+ if (res.exitCode !== 0) return []
44
+ const dreams = parseLogOutput(res.stdout).filter((c) => c.subject.startsWith(DREAM_SUBJECT_PREFIX))
45
+ if (opts.limit !== undefined && opts.limit > 0) return dreams.slice(0, opts.limit)
46
+ return dreams
47
+ }
48
+
49
+ export function parseLogOutput(stdout: string): RawCommit[] {
50
+ const commits: RawCommit[] = []
51
+ for (const record of stdout.split(RECORD_SEP)) {
52
+ const trimmed = record.replace(/^\n+/, '')
53
+ if (trimmed.length === 0) continue
54
+ const [sha, shortSha, committedAt, subject] = trimmed.split(FIELD_SEP)
55
+ if (sha === undefined || shortSha === undefined || committedAt === undefined || subject === undefined) continue
56
+ commits.push({ sha, shortSha, committedAt, subject })
57
+ }
58
+ return commits
59
+ }
60
+
61
+ export async function readDreamCommitShow(
62
+ root: string,
63
+ sha: string,
64
+ spawnGit: SpawnGit = defaultSpawnGit,
65
+ ): Promise<{ nameStatus: string; patch: string } | null> {
66
+ const nameStatus = await spawnGit(['show', '--no-color', '--find-renames', '--format=', '--name-status', sha], root)
67
+ if (nameStatus.exitCode !== 0) return null
68
+ const patch = await spawnGit(['show', '--no-color', '--format=', '--unified=0', sha], root)
69
+ if (patch.exitCode !== 0) return null
70
+ return { nameStatus: nameStatus.stdout, patch: patch.stdout }
71
+ }
72
+
73
+ const defaultSpawnGit: SpawnGit = async (args, cwd) => {
74
+ const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
75
+ if (!bun) return { exitCode: -1, stdout: '', stderr: 'bun runtime not available' }
76
+ try {
77
+ const proc = bun.spawn({ cmd: ['git', ...args], cwd, stdout: 'pipe', stderr: 'pipe' })
78
+ const exitCode = await proc.exited
79
+ const stdout = await new Response(proc.stdout).text()
80
+ const stderr = await new Response(proc.stderr).text()
81
+ return { exitCode, stdout, stderr }
82
+ } catch (err) {
83
+ return { exitCode: -1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }
84
+ }
85
+ }
@@ -0,0 +1,134 @@
1
+ import { readDreamCommitLog, readDreamCommitShow, resolveGitRepo, type SpawnGit } from './git'
2
+ import { parseDreamDetail, parseDreamSubject } from './parse'
3
+ import { renderDetail, renderListRow, type RenderOptions, toJsonShape } from './render'
4
+ import type { DreamEntry } from './types'
5
+
6
+ export type { SpawnGit } from './git'
7
+ export type { DreamEntry } from './types'
8
+ export { renderDetail, renderListRow, toJsonShape } from './render'
9
+ export { parseDreamDetail, parseDreamSubject } from './parse'
10
+
11
+ export type SelectDreamOptions = {
12
+ initialSha?: string
13
+ }
14
+
15
+ export type SelectDream = (entries: DreamEntry[], opts?: SelectDreamOptions) => Promise<DreamEntry | null>
16
+
17
+ // 'back' re-opens the picker with the just-viewed dream pre-selected.
18
+ export type ViewAction = 'back' | 'exit'
19
+ export type ViewDream = () => Promise<ViewAction>
20
+
21
+ export type RunDreamsOptions = {
22
+ agentDir: string
23
+ json: boolean
24
+ details: boolean
25
+ color: boolean
26
+ limit?: number
27
+ selectDream: SelectDream
28
+ viewDream?: ViewDream
29
+ stdout: (line: string) => void
30
+ spawnGit?: SpawnGit
31
+ }
32
+
33
+ export type RunDreamsResult = { ok: true; exitCode: 0 } | { ok: false; exitCode: number; reason: string }
34
+
35
+ export async function listDreams(
36
+ agentDir: string,
37
+ opts: { limit?: number } = {},
38
+ spawnGit?: SpawnGit,
39
+ ): Promise<DreamEntry[]> {
40
+ const repo = await resolveGitRepo(agentDir, spawnGit)
41
+ if (!repo.ok) return []
42
+ const commits = await readDreamCommitLog(repo.root, opts.limit !== undefined ? { limit: opts.limit } : {}, spawnGit)
43
+ return commits.map((commit) => {
44
+ const subject = parseDreamSubject(commit.subject)
45
+ return {
46
+ sha: commit.sha,
47
+ shortSha: commit.shortSha,
48
+ subject: commit.subject,
49
+ committedAt: commit.committedAt,
50
+ isDreamCommit: subject.isDreamCommit,
51
+ summary: subject.summary,
52
+ emoji: subject.emoji,
53
+ categories: subject.categories,
54
+ }
55
+ })
56
+ }
57
+
58
+ export async function hydrateDream(agentDir: string, entry: DreamEntry, spawnGit?: SpawnGit): Promise<DreamEntry> {
59
+ const repo = await resolveGitRepo(agentDir, spawnGit)
60
+ if (!repo.ok) return entry
61
+ const show = await readDreamCommitShow(repo.root, entry.sha, spawnGit)
62
+ if (show === null) return entry
63
+ return { ...entry, detail: parseDreamDetail(show.nameStatus, show.patch) }
64
+ }
65
+
66
+ export async function runDreams(opts: RunDreamsOptions): Promise<RunDreamsResult> {
67
+ const repo = await resolveGitRepo(opts.agentDir, opts.spawnGit)
68
+ if (!repo.ok) {
69
+ if (repo.reason === 'not-a-repo') {
70
+ return {
71
+ ok: false,
72
+ exitCode: 1,
73
+ reason:
74
+ "Not a git repository. Dreams live in the agent folder's git history — run this from your agent folder.",
75
+ }
76
+ }
77
+ return { ok: false, exitCode: 1, reason: 'git failed while resolving the repository root.' }
78
+ }
79
+
80
+ const entries = await listDreams(opts.agentDir, opts.limit !== undefined ? { limit: opts.limit } : {}, opts.spawnGit)
81
+ const renderOpts: RenderOptions = { color: opts.color }
82
+
83
+ if (opts.json) return runJson(opts, repo.root, entries)
84
+
85
+ if (entries.length === 0) {
86
+ opts.stdout('No dreams yet. The dreaming subagent commits here after it consolidates memory.')
87
+ return { ok: true, exitCode: 0 }
88
+ }
89
+
90
+ if (!isInteractive()) {
91
+ for (const entry of entries) opts.stdout(renderListRow(entry, renderOpts))
92
+ return { ok: true, exitCode: 0 }
93
+ }
94
+
95
+ return runInteractiveLoop(opts, entries, renderOpts)
96
+ }
97
+
98
+ async function runInteractiveLoop(
99
+ opts: RunDreamsOptions,
100
+ entries: DreamEntry[],
101
+ renderOpts: RenderOptions,
102
+ ): Promise<RunDreamsResult> {
103
+ let initialSha: string | undefined
104
+ while (true) {
105
+ const picked = await opts.selectDream(entries, initialSha !== undefined ? { initialSha } : {})
106
+ if (picked === null) return { ok: true, exitCode: 0 }
107
+ initialSha = picked.sha
108
+
109
+ const hydrated = await hydrateDream(opts.agentDir, picked, opts.spawnGit)
110
+ opts.stdout(renderDetail(hydrated, renderOpts))
111
+
112
+ if (opts.viewDream === undefined) return { ok: true, exitCode: 0 }
113
+ const action = await opts.viewDream()
114
+ if (action === 'exit') return { ok: true, exitCode: 0 }
115
+ }
116
+ }
117
+
118
+ async function runJson(opts: RunDreamsOptions, root: string, entries: DreamEntry[]): Promise<RunDreamsResult> {
119
+ for (const entry of entries) {
120
+ const final = opts.details ? await hydrateEntryFromRoot(root, entry, opts.spawnGit) : entry
121
+ opts.stdout(JSON.stringify(toJsonShape(final)))
122
+ }
123
+ return { ok: true, exitCode: 0 }
124
+ }
125
+
126
+ async function hydrateEntryFromRoot(root: string, entry: DreamEntry, spawnGit?: SpawnGit): Promise<DreamEntry> {
127
+ const show = await readDreamCommitShow(root, entry.sha, spawnGit)
128
+ if (show === null) return entry
129
+ return { ...entry, detail: parseDreamDetail(show.nameStatus, show.patch) }
130
+ }
131
+
132
+ function isInteractive(): boolean {
133
+ return Boolean(process.stdout.isTTY) && Boolean(process.stdin.isTTY)
134
+ }
@@ -0,0 +1,224 @@
1
+ import {
2
+ type DreamCategory,
3
+ type DreamEmoji,
4
+ DREAM_EMOJI_POOL,
5
+ type DreamEntryDetail,
6
+ type FragmentEventSummary,
7
+ type ShardChangeStatus,
8
+ type SkillCreation,
9
+ type TopicShardChange,
10
+ } from './types'
11
+
12
+ const BODY_PREVIEW_MAX = 80
13
+ const EMOJI_SET = new Set<string>(DREAM_EMOJI_POOL)
14
+ const STREAM_PATH = /^memory\/(?:streams\/)?(\d{4}-\d{2}-\d{2})\.jsonl$/
15
+ const TOPIC_PATH = /^memory\/topics\/(.+)\.md$/
16
+ const SKILL_PATH = /^memory\/skills\/([^/]+)\/SKILL\.md$/
17
+ const STATE_PATH = 'memory/.dreaming-state.json'
18
+
19
+ export type DreamSubject = {
20
+ isDreamCommit: boolean
21
+ summary: string | null
22
+ emoji: DreamEmoji | null
23
+ categories: DreamCategory[]
24
+ }
25
+
26
+ export function parseDreamSubject(subject: string): DreamSubject {
27
+ const match = /^dream:\s+(.*)$/.exec(subject)
28
+ if (match === null) {
29
+ return { isDreamCommit: false, summary: null, emoji: null, categories: [] }
30
+ }
31
+
32
+ const rest = (match[1] ?? '').trim()
33
+ const { summary, emoji } = splitTrailingEmoji(rest)
34
+ return {
35
+ isDreamCommit: true,
36
+ summary: summary.length > 0 ? summary : null,
37
+ emoji,
38
+ categories: classifySummary(summary),
39
+ }
40
+ }
41
+
42
+ function splitTrailingEmoji(rest: string): { summary: string; emoji: DreamEmoji | null } {
43
+ const chars = [...rest]
44
+ const last = chars.at(-1)
45
+ if (last !== undefined && EMOJI_SET.has(last)) {
46
+ return { summary: chars.slice(0, -1).join('').trim(), emoji: last as DreamEmoji }
47
+ }
48
+ return { summary: rest, emoji: null }
49
+ }
50
+
51
+ function classifySummary(summary: string): DreamCategory[] {
52
+ const categories: DreamCategory[] = []
53
+ if (/\bfragments?\b/.test(summary)) categories.push('fragments')
54
+ if (/\bskills?\b/.test(summary)) categories.push('skills')
55
+ if (/watermarks only/.test(summary)) categories.push('watermarks-only')
56
+ if (/^snapshot$/.test(summary)) categories.push('snapshot')
57
+ if (categories.length === 0) categories.push('other')
58
+ return categories
59
+ }
60
+
61
+ export function parseDreamDetail(nameStatus: string, patch: string): DreamEntryDetail {
62
+ const warnings: string[] = []
63
+ const changedTopics: TopicShardChange[] = []
64
+ const createdSkills: SkillCreation[] = []
65
+ let stateChanged = false
66
+
67
+ for (const { status, path, oldPath } of parseNameStatus(nameStatus)) {
68
+ if (path === STATE_PATH || oldPath === STATE_PATH) {
69
+ stateChanged = true
70
+ continue
71
+ }
72
+ const topic = TOPIC_PATH.exec(path)
73
+ if (topic !== null) {
74
+ changedTopics.push({ path, slug: topic[1] ?? path, status, additions: null, deletions: null })
75
+ continue
76
+ }
77
+ if (status === 'added') {
78
+ const skill = SKILL_PATH.exec(path)
79
+ if (skill !== null) createdSkills.push({ name: skill[1] ?? '', path })
80
+ }
81
+ }
82
+
83
+ applyTopicLineCounts(changedTopics, patch, warnings)
84
+ const addedFragments = extractAddedFragments(patch, warnings)
85
+
86
+ return { addedFragments, changedTopics, createdSkills, stateChanged, parseWarnings: warnings }
87
+ }
88
+
89
+ type NameStatusRow = { status: ShardChangeStatus; path: string; oldPath: string | null }
90
+
91
+ function parseNameStatus(nameStatus: string): NameStatusRow[] {
92
+ const rows: NameStatusRow[] = []
93
+ for (const line of nameStatus.split('\n')) {
94
+ if (line.trim().length === 0) continue
95
+ const cols = line.split('\t')
96
+ const code = cols[0] ?? ''
97
+ if (code.startsWith('R')) {
98
+ const oldPath = cols[1] ?? ''
99
+ const newPath = cols[2] ?? ''
100
+ if (newPath.length > 0) rows.push({ status: 'renamed', path: newPath, oldPath })
101
+ continue
102
+ }
103
+ const path = cols[1] ?? ''
104
+ if (path.length === 0) continue
105
+ rows.push({ status: mapStatusCode(code), path, oldPath: null })
106
+ }
107
+ return rows
108
+ }
109
+
110
+ function mapStatusCode(code: string): ShardChangeStatus {
111
+ const c = code.charAt(0)
112
+ if (c === 'A') return 'added'
113
+ if (c === 'M') return 'modified'
114
+ if (c === 'D') return 'deleted'
115
+ if (c === 'R') return 'renamed'
116
+ return 'unknown'
117
+ }
118
+
119
+ type ParsedHunk = { path: string; addedLines: string[] }
120
+
121
+ function* iterateHunks(patch: string): Generator<ParsedHunk> {
122
+ let currentPath: string | null = null
123
+ let added: string[] = []
124
+ const flush = function* (): Generator<ParsedHunk> {
125
+ if (currentPath !== null) yield { path: currentPath, addedLines: added }
126
+ }
127
+
128
+ for (const line of patch.split('\n')) {
129
+ if (line.startsWith('diff --git ')) {
130
+ yield* flush()
131
+ currentPath = null
132
+ added = []
133
+ continue
134
+ }
135
+ if (line.startsWith('+++ ')) {
136
+ currentPath = stripDiffPathPrefix(line.slice(4))
137
+ continue
138
+ }
139
+ if (line.startsWith('+') && !line.startsWith('+++')) {
140
+ added.push(line.slice(1))
141
+ }
142
+ }
143
+ yield* flush()
144
+ }
145
+
146
+ function stripDiffPathPrefix(raw: string): string {
147
+ const trimmed = raw.trim()
148
+ if (trimmed === '/dev/null') return trimmed
149
+ return trimmed.replace(/^b\//, '')
150
+ }
151
+
152
+ function extractAddedFragments(patch: string, warnings: string[]): FragmentEventSummary[] {
153
+ const out: FragmentEventSummary[] = []
154
+ for (const hunk of iterateHunks(patch)) {
155
+ const streamMatch = STREAM_PATH.exec(hunk.path)
156
+ if (streamMatch === null) continue
157
+ const streamDate = streamMatch[1] ?? null
158
+ for (const line of hunk.addedLines) {
159
+ if (line.trim().length === 0) continue
160
+ const fragment = parseFragmentLine(line, streamDate)
161
+ if (fragment === null) {
162
+ warnings.push(`unparseable stream line in ${hunk.path}`)
163
+ continue
164
+ }
165
+ if (fragment !== 'skip') out.push(fragment)
166
+ }
167
+ }
168
+ return out
169
+ }
170
+
171
+ function parseFragmentLine(line: string, streamDate: string | null): FragmentEventSummary | 'skip' | null {
172
+ let raw: unknown
173
+ try {
174
+ raw = JSON.parse(line)
175
+ } catch {
176
+ return null
177
+ }
178
+ if (typeof raw !== 'object' || raw === null) return null
179
+ const obj = raw as Record<string, unknown>
180
+ if (obj.type !== 'fragment') return 'skip'
181
+ if (typeof obj.id !== 'string' || obj.id.length === 0) return null
182
+ return {
183
+ id: obj.id,
184
+ streamDate,
185
+ topic: typeof obj.topic === 'string' ? obj.topic : null,
186
+ bodyPreview: typeof obj.body === 'string' ? preview(obj.body) : null,
187
+ }
188
+ }
189
+
190
+ function applyTopicLineCounts(topics: TopicShardChange[], patch: string, warnings: string[]): void {
191
+ if (topics.length === 0) return
192
+ const counts = new Map<string, { additions: number; deletions: number }>()
193
+ let currentPath: string | null = null
194
+
195
+ for (const line of patch.split('\n')) {
196
+ if (line.startsWith('+++ ')) {
197
+ currentPath = stripDiffPathPrefix(line.slice(4))
198
+ if (currentPath !== null && !counts.has(currentPath)) counts.set(currentPath, { additions: 0, deletions: 0 })
199
+ continue
200
+ }
201
+ if (currentPath === null) continue
202
+ const bucket = counts.get(currentPath)
203
+ if (bucket === undefined) continue
204
+ if (line.startsWith('+') && !line.startsWith('+++')) bucket.additions++
205
+ else if (line.startsWith('-') && !line.startsWith('---')) bucket.deletions++
206
+ }
207
+
208
+ for (const topic of topics) {
209
+ if (topic.status === 'deleted') continue
210
+ const bucket = counts.get(topic.path)
211
+ if (bucket === undefined) {
212
+ if (topic.status === 'modified') warnings.push(`no diff hunk for modified topic ${topic.path}`)
213
+ continue
214
+ }
215
+ topic.additions = bucket.additions
216
+ topic.deletions = bucket.deletions
217
+ }
218
+ }
219
+
220
+ function preview(body: string): string {
221
+ const oneline = body.replace(/\s+/g, ' ').trim()
222
+ if (oneline.length <= BODY_PREVIEW_MAX) return oneline
223
+ return `${oneline.slice(0, BODY_PREVIEW_MAX)}…`
224
+ }