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:
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user