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.
- package/package.json +2 -1
- package/src/agent/index.ts +55 -1
- package/src/agent/loop-guard.ts +180 -53
- package/src/agent/restart/index.ts +101 -0
- package/src/agent/tools/restart.ts +23 -52
- package/src/bundled-plugins/bun-hygiene/README.md +82 -0
- package/src/bundled-plugins/bun-hygiene/index.ts +11 -0
- package/src/bundled-plugins/bun-hygiene/policy.ts +318 -0
- package/src/bundled-plugins/github-cli-auth/gh-command.ts +98 -6
- package/src/bundled-plugins/github-cli-auth/graphql-auth-nudge.ts +80 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +7 -0
- package/src/bundled-plugins/memory/memory-logger.ts +6 -2
- package/src/bundled-plugins/reviewer/skills/code-review.ts +8 -0
- package/src/channels/adapters/discord-bot-classify.ts +8 -2
- package/src/channels/adapters/discord-bot.ts +29 -2
- package/src/channels/adapters/github/decoy-reviewer.ts +43 -0
- package/src/channels/adapters/github/inbound.ts +92 -1
- package/src/channels/adapters/github/index.ts +12 -1
- package/src/channels/adapters/github/reactions.ts +138 -4
- package/src/channels/adapters/slack-bot-classify.ts +2 -2
- package/src/channels/adapters/slack-bot.ts +129 -7
- package/src/channels/engagement.ts +71 -31
- package/src/channels/manager.ts +8 -0
- package/src/channels/router.ts +180 -25
- package/src/channels/schema.ts +18 -0
- package/src/channels/types.ts +16 -1
- package/src/cli/builtins.ts +1 -0
- package/src/cli/dreams.ts +148 -0
- package/src/cli/index.ts +1 -0
- package/src/cli/inspect.ts +2 -1
- package/src/cli/ui.ts +34 -0
- package/src/commands/index.ts +5 -2
- package/src/config/config.ts +89 -0
- package/src/dreams/git.ts +85 -0
- package/src/dreams/index.ts +134 -0
- package/src/dreams/parse.ts +224 -0
- package/src/dreams/render.ts +155 -0
- package/src/dreams/types.ts +50 -0
- package/src/mcp/catalog.ts +29 -0
- package/src/mcp/client.ts +236 -0
- package/src/mcp/index.ts +25 -0
- package/src/mcp/manager.ts +156 -0
- package/src/mcp/tools.ts +190 -0
- package/src/permissions/builtins.ts +9 -0
- package/src/reload/format.ts +14 -0
- package/src/reload/index.ts +1 -0
- package/src/run/bundled-plugins.ts +7 -0
- package/src/run/channel-session-factory.ts +3 -0
- package/src/run/index.ts +38 -1
- package/src/server/command-runner.ts +5 -0
- package/src/server/index.ts +53 -0
- package/src/shared/protocol.ts +2 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +86 -22
- package/src/tui/index.ts +70 -18
- 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: {
|
package/src/commands/index.ts
CHANGED
|
@@ -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).
|
|
17
|
-
|
|
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
|
package/src/config/config.ts
CHANGED
|
@@ -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
|
+
}
|