feat: uwf thread read — human-readable markdown with pagination

- Outputs markdown directly (not JSON/YAML)
- --quota <chars>: character budget, loads steps backward until exceeded (default 4000)
- --before <step-hash>: 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
This commit is contained in:
2026-05-19 02:38:27 +00:00
parent 8b43f7993b
commit 4c9ce72395
3 changed files with 204 additions and 25 deletions
+1
View File
@@ -10,3 +10,4 @@ xiaoju/
solve-issue-entry.ts solve-issue-entry.ts
packages/workflow-template-develop/develop.esm.js packages/workflow-template-develop/develop.esm.js
.DS_Store .DS_Store
*.py
+25
View File
@@ -1,11 +1,14 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import { Command } from "commander"; import { Command } from "commander";
import type { ThreadId } from "@uncaged/uwf-protocol";
import { import {
cmdThreadFork, cmdThreadFork,
cmdThreadKill, cmdThreadKill,
cmdThreadList, cmdThreadList,
cmdThreadRead,
THREAD_READ_DEFAULT_QUOTA,
cmdThreadShow, cmdThreadShow,
cmdThreadStart, cmdThreadStart,
cmdThreadStep, cmdThreadStep,
@@ -158,6 +161,28 @@ thread
}); });
}); });
thread
.command("read")
.description("Read thread context as human-readable markdown")
.argument("<thread-id>", "Thread ULID")
.option("--quota <chars>", "Max output characters", String(THREAD_READ_DEFAULT_QUOTA))
.option("--before <step-hash>", "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 thread
.command("fork") .command("fork")
.description("Fork a thread from a specific step") .description("Fork a thread from a specific step")
+178 -25
View File
@@ -1,6 +1,7 @@
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { validate } from "@uncaged/json-cas"; import { validate } from "@uncaged/json-cas";
import { stringify } from "yaml";
import { getEnvPath, loadWorkflowConfig } from "@uncaged/uwf-agent-kit"; import { getEnvPath, loadWorkflowConfig } from "@uncaged/uwf-agent-kit";
import { evaluate } from "@uncaged/uwf-moderator"; import { evaluate } from "@uncaged/uwf-moderator";
import type { import type {
@@ -40,6 +41,7 @@ import {
import { isCasRef } from "../validate.js"; import { isCasRef } from "../validate.js";
const END_ROLE = "$END"; const END_ROLE = "$END";
export const THREAD_READ_DEFAULT_QUOTA = 4000;
type ChainState = { type ChainState = {
startHash: CasRef; startHash: CasRef;
@@ -48,6 +50,12 @@ type ChainState = {
headIsStart: boolean; headIsStart: boolean;
}; };
type OrderedStepItem = {
hash: CasRef;
payload: StepNodePayload;
timestamp: number;
};
export type KillOutput = { export type KillOutput = {
thread: ThreadId; thread: ThreadId;
archived: boolean; archived: boolean;
@@ -266,6 +274,147 @@ function expandOutput(uwf: UwfStore, outputRef: CasRef): unknown {
return node.payload; return node.payload;
} }
function collectOrderedSteps(
uwf: UwfStore,
headHash: CasRef,
chain: ChainState,
): OrderedStepItem[] {
let hash: CasRef | null = headHash;
const hashToNode = new Map<string, { payload: StepNodePayload; timestamp: number }>();
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 { function buildModeratorContext(uwf: UwfStore, chain: ChainState): ModeratorContext {
const chronological = [...chain.stepsNewestFirst].reverse(); const chronological = [...chain.stepsNewestFirst].reverse();
const steps: StepContext[] = chronological.map((step) => ({ const steps: StepContext[] = chronological.map((step) => ({
@@ -475,31 +624,7 @@ export async function cmdThreadSteps(
}; };
const stepEntries: StepEntry[] = []; const stepEntries: StepEntry[] = [];
const ordered = collectOrderedSteps(uwf, headHash, chain);
// Walk again to get hashes for each step
let hash: CasRef | null = headHash;
const hashToNode = new Map<string, { payload: StepNodePayload; timestamp: number }>();
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();
for (const item of ordered) { for (const item of ordered) {
stepEntries.push({ 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<string> {
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( export async function cmdThreadFork(
storageRoot: string, storageRoot: string,
stepHash: CasRef, stepHash: CasRef,