From 4c9ce72395a474a044c74856ff798bad56ef9fc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Tue, 19 May 2026 02:38:27 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20uwf=20thread=20read=20=E2=80=94=20human?= =?UTF-8?q?-readable=20markdown=20with=20pagination?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Outputs markdown directly (not JSON/YAML) - --quota : character budget, loads steps backward until exceeded (default 4000) - --before : load steps before this hash (exclusive), omits start - --start: force include start section even with --before - --detail: expand detail CAS node content for each step - Skip hint with uwf thread read command for pagination - Reuses walkChain/collectOrderedSteps/expandOutput Closes #349 --- .gitignore | 1 + packages/cli-uwf/src/cli.ts | 25 +++ packages/cli-uwf/src/commands/thread.ts | 203 +++++++++++++++++++++--- 3 files changed, 204 insertions(+), 25 deletions(-) diff --git a/.gitignore b/.gitignore index a514b35..4b95466 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ xiaoju/ solve-issue-entry.ts packages/workflow-template-develop/develop.esm.js .DS_Store +*.py diff --git a/packages/cli-uwf/src/cli.ts b/packages/cli-uwf/src/cli.ts index a8b8b22..5a93593 100755 --- a/packages/cli-uwf/src/cli.ts +++ b/packages/cli-uwf/src/cli.ts @@ -1,11 +1,14 @@ #!/usr/bin/env bun import { Command } from "commander"; +import type { ThreadId } from "@uncaged/uwf-protocol"; import { cmdThreadFork, cmdThreadKill, cmdThreadList, + cmdThreadRead, + THREAD_READ_DEFAULT_QUOTA, cmdThreadShow, cmdThreadStart, cmdThreadStep, @@ -158,6 +161,28 @@ thread }); }); +thread + .command("read") + .description("Read thread context as human-readable markdown") + .argument("", "Thread ULID") + .option("--quota ", "Max output characters", String(THREAD_READ_DEFAULT_QUOTA)) + .option("--before ", "Load steps before this hash (exclusive)") + .option("--start", "Include start step in output") + .option("--detail", "Expand detail content for each step") + .action((threadId: string, opts: { quota: string; before: string | undefined; start: boolean; detail: boolean }) => { + const storageRoot = resolveStorageRoot(); + runAction(async () => { + const quota = Number.parseInt(opts.quota, 10); + if (!Number.isFinite(quota) || quota < 1) { + process.stderr.write("invalid --quota: must be a positive integer\n"); + process.exit(1); + } + const before = opts.before ?? null; + const markdown = await cmdThreadRead(storageRoot, threadId as ThreadId, quota, before, opts.start ?? false, opts.detail ?? false); + process.stdout.write(markdown.endsWith("\n") ? markdown : `${markdown}\n`); + }); + }); + thread .command("fork") .description("Fork a thread from a specific step") diff --git a/packages/cli-uwf/src/commands/thread.ts b/packages/cli-uwf/src/commands/thread.ts index f718996..69b7ff6 100644 --- a/packages/cli-uwf/src/commands/thread.ts +++ b/packages/cli-uwf/src/commands/thread.ts @@ -1,6 +1,7 @@ import { execFileSync } from "node:child_process"; import { validate } from "@uncaged/json-cas"; +import { stringify } from "yaml"; import { getEnvPath, loadWorkflowConfig } from "@uncaged/uwf-agent-kit"; import { evaluate } from "@uncaged/uwf-moderator"; import type { @@ -40,6 +41,7 @@ import { import { isCasRef } from "../validate.js"; const END_ROLE = "$END"; +export const THREAD_READ_DEFAULT_QUOTA = 4000; type ChainState = { startHash: CasRef; @@ -48,6 +50,12 @@ type ChainState = { headIsStart: boolean; }; +type OrderedStepItem = { + hash: CasRef; + payload: StepNodePayload; + timestamp: number; +}; + export type KillOutput = { thread: ThreadId; archived: boolean; @@ -266,6 +274,147 @@ function expandOutput(uwf: UwfStore, outputRef: CasRef): unknown { return node.payload; } +function collectOrderedSteps( + uwf: UwfStore, + headHash: CasRef, + chain: ChainState, +): OrderedStepItem[] { + let hash: CasRef | null = headHash; + const hashToNode = new Map(); + while (hash !== null) { + const node = uwf.store.get(hash); + if (node === null || node.type !== uwf.schemas.stepNode) { + break; + } + const payload = node.payload as StepNodePayload; + hashToNode.set(hash, { payload, timestamp: node.timestamp }); + hash = payload.prev; + } + + let cur: CasRef | null = chain.headIsStart ? null : headHash; + const ordered: OrderedStepItem[] = []; + while (cur !== null) { + const entry = hashToNode.get(cur); + if (entry === undefined) { + break; + } + ordered.push({ hash: cur, ...entry }); + cur = entry.payload.prev; + } + ordered.reverse(); + return ordered; +} + +function formatYaml(value: unknown): string { + return stringify(value).trimEnd(); +} + +function formatCompactStep(index: number, item: OrderedStepItem, outputYaml: string): string { + return [ + `## Step ${index}: ${item.payload.role}`, + "", + `- **Hash:** \`${item.hash}\``, + `- **Agent:** ${item.payload.agent}`, + "", + "### Output", + "", + "```yaml", + outputYaml, + "```", + ].join("\n"); +} + +function formatThreadReadMarkdown(options: { + threadId: ThreadId; + workflowName: string; + workflowHash: CasRef; + prompt: string; + ordered: OrderedStepItem[]; + uwf: UwfStore; + quota: number; + before: CasRef | null; + showStart: boolean; + showDetail: boolean; +}): string { + const { ordered, uwf, quota, before, showStart, showDetail } = options; + + // Determine which steps to consider + let candidates = ordered; + if (before !== null) { + const idx = candidates.findIndex((s) => s.hash === before); + if (idx === -1) { + fail(`step ${before} not found in thread ${options.threadId}`); + } + candidates = candidates.slice(0, idx); + } + + // Walk backward from newest, accumulating chars until quota exceeded + const selected: OrderedStepItem[] = []; + let totalChars = 0; + for (let i = candidates.length - 1; i >= 0; i--) { + const item = candidates[i]; + if (item === undefined) continue; + const outputYaml = formatYaml(expandOutput(uwf, item.payload.output)); + const blockLen = formatCompactStep(i + 1, item, outputYaml).length; + selected.unshift(item); + totalChars += blockLen; + if (totalChars > quota) break; + } + + const skippedCount = candidates.length - selected.length; + const parts: string[] = []; + + // Start section + if (before === null || showStart) { + parts.push( + [ + `# Thread \`${options.threadId}\``, + "", + `**Workflow:** ${options.workflowName} (\`${options.workflowHash}\`)`, + "", + "## Task", + "", + options.prompt, + ].join("\n"), + ); + } + + // Skip hint + if (skippedCount > 0 && selected.length > 0) { + const firstSelected = selected[0]; + if (firstSelected !== undefined) { + parts.push( + `*(${skippedCount} earlier step${skippedCount > 1 ? "s" : ""}, load with \`uwf thread read ${options.threadId} --before ${firstSelected.hash}\`)*`, + ); + } + } + + // Step blocks + const startIndex = candidates.length - selected.length; + for (let i = 0; i < selected.length; i++) { + const item = selected[i]; + if (item === undefined) continue; + const stepNum = startIndex + i + 1; + const outputYaml = formatYaml(expandOutput(uwf, item.payload.output)); + const ts = new Date(item.timestamp).toISOString().replace("T", " ").replace(/\.\d+Z$/, ""); + const stepLines = [ + `## Step ${stepNum}: ${item.payload.role} \`${item.hash}\``, + `**Agent:** ${item.payload.agent} | **Time:** ${ts}`, + "", + "```yaml", + outputYaml, + "```", + ]; + if (showDetail && item.payload.detail) { + const detailYaml = formatYaml(expandOutput(uwf, item.payload.detail)); + stepLines.push("", "### Detail", "", "```yaml", detailYaml, "```"); + } + parts.push(stepLines.join("\n")); + } + + return parts.join("\n\n---\n\n"); +} + function buildModeratorContext(uwf: UwfStore, chain: ChainState): ModeratorContext { const chronological = [...chain.stepsNewestFirst].reverse(); const steps: StepContext[] = chronological.map((step) => ({ @@ -475,31 +624,7 @@ export async function cmdThreadSteps( }; const stepEntries: StepEntry[] = []; - - // Walk again to get hashes for each step - let hash: CasRef | null = headHash; - const hashToNode = new Map(); - while (hash !== null) { - const node = uwf.store.get(hash); - if (node === null || node.type !== uwf.schemas.stepNode) { - break; - } - const payload = node.payload as StepNodePayload; - hashToNode.set(hash, { payload, timestamp: node.timestamp }); - hash = payload.prev; - } - - // Build chronological list with hashes - // Walk from start's next to head - let cur: CasRef | null = chain.headIsStart ? null : headHash; - const ordered: { hash: CasRef; payload: StepNodePayload; timestamp: number }[] = []; - while (cur !== null) { - const entry = hashToNode.get(cur); - if (entry === undefined) break; - ordered.push({ hash: cur, ...entry }); - cur = entry.payload.prev; - } - ordered.reverse(); + const ordered = collectOrderedSteps(uwf, headHash, chain); for (const item of ordered) { stepEntries.push({ @@ -519,6 +644,34 @@ export async function cmdThreadSteps( }; } +export async function cmdThreadRead( + storageRoot: string, + threadId: ThreadId, + quota: number = THREAD_READ_DEFAULT_QUOTA, + before: CasRef | null = null, + showStart: boolean = false, + showDetail: boolean = false, +): Promise { + const headHash = await resolveHeadHash(storageRoot, threadId); + const uwf = await createUwfStore(storageRoot); + const chain = walkChain(uwf, headHash); + const workflow = loadWorkflowPayload(uwf, chain.start.workflow); + const ordered = collectOrderedSteps(uwf, headHash, chain); + + return formatThreadReadMarkdown({ + threadId, + workflowName: workflow.name, + workflowHash: chain.start.workflow, + prompt: chain.start.prompt, + ordered, + uwf, + quota, + before, + showStart, + showDetail, + }); +} + export async function cmdThreadFork( storageRoot: string, stepHash: CasRef,