typeclaw 0.19.0 → 0.21.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 +1 -1
- package/src/agent/index.ts +7 -0
- package/src/agent/live-subagents.ts +4 -0
- package/src/agent/restart/index.ts +101 -0
- package/src/agent/session-origin.ts +32 -10
- package/src/agent/tools/channel-react.ts +79 -0
- package/src/agent/tools/restart.ts +23 -52
- package/src/agent/tools/spawn-subagent.ts +1 -0
- package/src/agent/tools/subagent-access.ts +67 -0
- package/src/agent/tools/subagent-cancel.ts +11 -6
- package/src/agent/tools/subagent-output.ts +10 -2
- package/src/channels/adapters/discord-bot-classify.ts +8 -2
- package/src/channels/adapters/discord-bot.ts +265 -22
- package/src/channels/adapters/github/decoy-reviewer.ts +43 -0
- package/src/channels/adapters/github/inbound.ts +79 -0
- package/src/channels/adapters/github/index.ts +19 -0
- package/src/channels/adapters/github/permission-guidance.ts +20 -1
- package/src/channels/adapters/github/reactions.ts +276 -0
- package/src/channels/adapters/slack-bot-classify.ts +2 -2
- package/src/channels/adapters/slack-bot.ts +25 -2
- package/src/channels/engagement.ts +81 -44
- package/src/channels/router.ts +255 -18
- package/src/channels/types.ts +57 -0
- package/src/cli/builtins.ts +1 -0
- package/src/cli/dreams.ts +147 -0
- package/src/cli/index.ts +1 -0
- package/src/cli/inspect.ts +3 -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/inspect/loop.ts +12 -1
- package/src/permissions/permissions.ts +24 -0
- package/src/server/index.ts +49 -0
- package/src/shared/protocol.ts +2 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +6 -2
- package/src/tui/index.ts +70 -18
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { defineCommand } from 'citty'
|
|
2
|
+
|
|
3
|
+
import { type DreamEntry, renderListRow, runDreams, type ViewAction } from '@/dreams'
|
|
4
|
+
import { findAgentDir } from '@/init'
|
|
5
|
+
|
|
6
|
+
import { createEscController } from './inspect-controller'
|
|
7
|
+
import { c, cancel, errorLine, isCancel } from './ui'
|
|
8
|
+
|
|
9
|
+
const ESC_DEBOUNCE_MS = 50
|
|
10
|
+
const QUIT_KEY = 0x71
|
|
11
|
+
|
|
12
|
+
export const dreamsCommand = defineCommand({
|
|
13
|
+
meta: {
|
|
14
|
+
name: 'dreams',
|
|
15
|
+
description: "browse the dreaming subagent's memory-consolidation journal from git history (host stage)",
|
|
16
|
+
},
|
|
17
|
+
args: {
|
|
18
|
+
limit: {
|
|
19
|
+
type: 'string',
|
|
20
|
+
description: 'show at most N most-recent dreams',
|
|
21
|
+
},
|
|
22
|
+
json: {
|
|
23
|
+
type: 'boolean',
|
|
24
|
+
description: 'emit one JSON object per dream (subject-level)',
|
|
25
|
+
default: false,
|
|
26
|
+
},
|
|
27
|
+
details: {
|
|
28
|
+
type: 'boolean',
|
|
29
|
+
description: 'with --json, hydrate each dream with its consolidated fragments/shards/skills',
|
|
30
|
+
default: false,
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
async run({ args }) {
|
|
34
|
+
const cwd = findAgentDir(process.cwd()) ?? process.cwd()
|
|
35
|
+
const color = useColor()
|
|
36
|
+
const limit = parseLimit(args.limit)
|
|
37
|
+
const interactive = isInteractive() && args.json !== true
|
|
38
|
+
|
|
39
|
+
const result = await runDreams({
|
|
40
|
+
agentDir: cwd,
|
|
41
|
+
json: args.json === true,
|
|
42
|
+
details: args.details === true,
|
|
43
|
+
color,
|
|
44
|
+
...(limit !== undefined ? { limit } : {}),
|
|
45
|
+
selectDream: (entries, selectOpts) => clackSelect(entries, color, selectOpts?.initialSha),
|
|
46
|
+
...(interactive ? { viewDream: () => waitForViewerKey(color) } : {}),
|
|
47
|
+
stdout: (line) => process.stdout.write(`${line}\n`),
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
if (!result.ok) {
|
|
51
|
+
process.stderr.write(`${errorLine(result.reason)}\n`)
|
|
52
|
+
process.exit(result.exitCode)
|
|
53
|
+
}
|
|
54
|
+
process.exit(result.exitCode)
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
function isInteractive(): boolean {
|
|
59
|
+
return Boolean(process.stdout.isTTY) && Boolean(process.stdin.isTTY)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type RawInput = Pick<NodeJS.ReadStream, 'isTTY' | 'setRawMode' | 'resume' | 'pause' | 'on' | 'off'>
|
|
63
|
+
|
|
64
|
+
// Esc routes through createEscController so a standalone Esc returns 'back'
|
|
65
|
+
// while a multi-byte CSI sequence (↑/↓ arrows) does not. Teardown restores
|
|
66
|
+
// raw mode but deliberately does NOT pause stdin: clack cannot re-flow a
|
|
67
|
+
// paused process.stdin under Bun, so the next picker would freeze — the same
|
|
68
|
+
// reason cli/inspect.ts leaves the stream flowing on its return path.
|
|
69
|
+
export async function waitForViewerKey(color: boolean, input: RawInput = process.stdin): Promise<ViewAction> {
|
|
70
|
+
const stdin = input
|
|
71
|
+
if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') return 'exit'
|
|
72
|
+
|
|
73
|
+
process.stdout.write(`${viewerHintLine(color)}\n`)
|
|
74
|
+
|
|
75
|
+
const ctrl = createEscController({ debounceMs: ESC_DEBOUNCE_MS })
|
|
76
|
+
const escSignal = ctrl.armForStream()
|
|
77
|
+
|
|
78
|
+
return new Promise<ViewAction>((resolve) => {
|
|
79
|
+
let settled = false
|
|
80
|
+
const finish = (action: ViewAction): void => {
|
|
81
|
+
if (settled) return
|
|
82
|
+
settled = true
|
|
83
|
+
escSignal.removeEventListener('abort', onEscAbort)
|
|
84
|
+
stdin.off('data', onData)
|
|
85
|
+
ctrl.dispose()
|
|
86
|
+
try {
|
|
87
|
+
stdin.setRawMode(false)
|
|
88
|
+
} catch {
|
|
89
|
+
/* terminal already torn down */
|
|
90
|
+
}
|
|
91
|
+
resolve(action)
|
|
92
|
+
}
|
|
93
|
+
const onEscAbort = (): void => finish('back')
|
|
94
|
+
const onData = (chunk: Buffer): void => {
|
|
95
|
+
if (chunk[0] === QUIT_KEY) {
|
|
96
|
+
finish('exit')
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
const { sigint } = ctrl.onChunk(chunk)
|
|
100
|
+
if (sigint) finish('exit')
|
|
101
|
+
}
|
|
102
|
+
escSignal.addEventListener('abort', onEscAbort, { once: true })
|
|
103
|
+
stdin.setRawMode(true)
|
|
104
|
+
stdin.resume()
|
|
105
|
+
stdin.on('data', onData)
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function viewerHintLine(color: boolean): string {
|
|
110
|
+
const text = '(esc to go back to the list · q to quit)'
|
|
111
|
+
return color ? c.dim(text) : text
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function parseLimit(raw: unknown): number | undefined {
|
|
115
|
+
if (typeof raw !== 'string') return undefined
|
|
116
|
+
const n = Number.parseInt(raw, 10)
|
|
117
|
+
return Number.isFinite(n) && n > 0 ? n : undefined
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function clackSelect(
|
|
121
|
+
entries: DreamEntry[],
|
|
122
|
+
color: boolean,
|
|
123
|
+
initialSha: string | undefined,
|
|
124
|
+
): Promise<DreamEntry | null> {
|
|
125
|
+
const { select } = await import('@clack/prompts')
|
|
126
|
+
const preferred = initialSha !== undefined && entries.some((e) => e.sha === initialSha) ? initialSha : entries[0]?.sha
|
|
127
|
+
const picked = await select<string>({
|
|
128
|
+
message: `Pick a dream to open (${entries.length} total)`,
|
|
129
|
+
options: entries.map((entry) => ({
|
|
130
|
+
value: entry.sha,
|
|
131
|
+
label: renderListRow(entry, { color }),
|
|
132
|
+
})),
|
|
133
|
+
initialValue: preferred,
|
|
134
|
+
})
|
|
135
|
+
if (isCancel(picked)) {
|
|
136
|
+
cancel('Cancelled.')
|
|
137
|
+
return null
|
|
138
|
+
}
|
|
139
|
+
return entries.find((entry) => entry.sha === picked) ?? null
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function useColor(): boolean {
|
|
143
|
+
if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '') return false
|
|
144
|
+
if (process.env.FORCE_COLOR === '0') return false
|
|
145
|
+
if (process.env.FORCE_COLOR) return true
|
|
146
|
+
return Boolean(process.stdout.isTTY)
|
|
147
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -23,6 +23,7 @@ const main = defineCommand({
|
|
|
23
23
|
reload: () => import('./reload').then((m) => m.reload),
|
|
24
24
|
logs: () => import('./logs').then((m) => m.logsCommand),
|
|
25
25
|
inspect: () => import('./inspect').then((m) => m.inspectCommand),
|
|
26
|
+
dreams: () => import('./dreams').then((m) => m.dreamsCommand),
|
|
26
27
|
shell: () => import('./shell').then((m) => m.shellCommand),
|
|
27
28
|
compose: () => import('./compose').then((m) => m.composeCommand),
|
|
28
29
|
channel: () => import('./channel').then((m) => m.channelCommand),
|
package/src/cli/inspect.ts
CHANGED
|
@@ -75,6 +75,9 @@ export const inspectCommand = defineCommand({
|
|
|
75
75
|
if (escListener === null) return new AbortController().signal
|
|
76
76
|
return escListener.armForStream()
|
|
77
77
|
},
|
|
78
|
+
afterEscStream: () => {
|
|
79
|
+
escListener?.pause()
|
|
80
|
+
},
|
|
78
81
|
...(liveHint !== undefined ? { liveHint } : {}),
|
|
79
82
|
stdout: (line) => process.stdout.write(`${line}\n`),
|
|
80
83
|
stderr: (line) => process.stderr.write(`${line}\n`),
|
|
@@ -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
|
+
}
|