diff --git a/scripts/issues/README.md b/scripts/issues/README.md index 1acc67ef4..2c1a14b91 100644 --- a/scripts/issues/README.md +++ b/scripts/issues/README.md @@ -8,7 +8,7 @@ A local tool for the daily issue review. It runs in this order: - picks a category from `categories/*.md` - writes a one-sentence summary and signals - suggests p0, p1, or none -3. **Review.** A web UI lists the inbox: every issue created since the inbox start, grouped by the agent's suggestion. You set p0, p1, or none, and fix the category if needed. Each issue links to GitHub. +3. **Review.** A web UI lists the inbox: every issue created since the inbox start, plus every issue still labeled `untriaged` on GitHub (auto-closed by the issue gate and not reviewed yet), grouped by the agent's suggestion. You set p0, p1, or none, and fix the category if needed. Each issue links to GitHub. Decisions are stored locally only. Nothing is written to GitHub yet. @@ -52,8 +52,12 @@ The agent authenticates through `~/.pi/agent/auth.json`, the same credentials pi | `TRIAGE_SOCKET` | | Listen on this unix socket instead of the port, for a reverse proxy | | `TRIAGE_DATA` | `scripts/issues/data` | State directory | +The "Show related" checkbox in the header hides or shows related issues; duplicates are always shown. The choice is saved in the browser. + ## UI keys - `j`/`k`: move between issues - `0`, `1`, `n`: set p0, p1, none - `o`: open the issue on GitHub +- `c`: show or hide the agent's conversation for the issue +- `?`: show the keyboard shortcuts diff --git a/scripts/issues/github.ts b/scripts/issues/github.ts index 51f9c48bc..e0b7d02f2 100644 --- a/scripts/issues/github.ts +++ b/scripts/issues/github.ts @@ -129,6 +129,11 @@ export function isActedOn(issue: Issue): boolean { ); } +/** In the inbox: created since the inbox start, or still labeled `untriaged` (auto-closed by the issue gate and not reviewed yet). */ +export function inInbox(issue: Issue, inboxSince: string): boolean { + return issue.createdAt >= inboxSince || issue.labels.includes("untriaged"); +} + export function issueStatus(issue: Issue): string { const labels = issue.labels.filter((label) => ["p0", "p1", "no-action", "untriaged"].includes(label)); const state = issue.state === "open" ? "open" : `closed${issue.stateReason ? `:${issue.stateReason}` : ""}`; diff --git a/scripts/issues/main.ts b/scripts/issues/main.ts index 9c3838896..48ac1f862 100644 --- a/scripts/issues/main.ts +++ b/scripts/issues/main.ts @@ -6,7 +6,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:ht import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import type { ModelThinkingLevel } from "@earendil-works/pi-ai"; -import { Corpus, issueStatus, REPO } from "./github.ts"; +import { Corpus, inInbox, issueStatus, REPO } from "./github.ts"; import { PRIORITIES, type Priority, Triage } from "./triage.ts"; const scriptDir = dirname(fileURLToPath(import.meta.url)); @@ -54,7 +54,7 @@ async function view() { const state = await triage.state(); const items = corpus .all() - .filter((issue) => issue.createdAt >= state.inboxSince) + .filter((issue) => inInbox(issue, state.inboxSince)) .sort((a, b) => b.number - a.number) .map((issue) => { const entry = state.issues[issue.number]; @@ -124,6 +124,10 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise return send(res, 200, readFileSync(join(scriptDir, "ui.html"), "utf8"), "text/html; charset=utf-8"); } if (req.method === "GET" && url.pathname === "/api/state") return send(res, 200, JSON.stringify(await view())); + if (req.method === "GET" && url.pathname === "/api/conversation") { + const items = await triage.transcript(Number(url.searchParams.get("number"))); + return items ? send(res, 200, JSON.stringify({ items })) : send(res, 404, '{"error":"no conversation"}'); + } if (req.method === "POST" && url.pathname === "/api/sync") { void runSync(); return send(res, 202, "{}"); diff --git a/scripts/issues/triage.ts b/scripts/issues/triage.ts index d803ff843..80e104303 100644 --- a/scripts/issues/triage.ts +++ b/scripts/issues/triage.ts @@ -3,7 +3,7 @@ import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; -import { type ModelThinkingLevel, StringEnum, Type } from "@earendil-works/pi-ai"; +import { type Message, type ModelThinkingLevel, StringEnum, Type } from "@earendil-works/pi-ai"; import { createModels } from "@earendil-works/pi-ai/models"; import { anthropicProvider } from "@earendil-works/pi-ai/providers/anthropic"; import { openaiCodexProvider } from "@earendil-works/pi-ai/providers/openai-codex"; @@ -19,7 +19,7 @@ import { type Tx, } from "../../packages/durable/src/index.ts"; import { openNodeSqliteStorage } from "../../packages/durable/src/storage/sqlite/node.ts"; -import { type Corpus, type Issue, issueStatus } from "./github.ts"; +import { type Corpus, type Issue, inInbox, issueStatus } from "./github.ts"; const context = BACKGROUND_CONTEXT; /** Labels the issue templates set. Other labels and the issue's state are hidden from the agent: they are maintainer decisions. */ @@ -58,6 +58,11 @@ export type IssueState = { type TriageState = { inboxSince: string; issues: Record }; +/** One transcript line for the UI. A tool call carries its result once the result exists. */ +export type TranscriptItem = + | { type: "user" | "text" | "thinking"; text: string } + | { type: "tool"; name: string; args: unknown; output: string | null; isError: boolean }; + const StateDoc = defineDoc({ checkpointWhen: (_value, _ops, { deltasSinceBase }) => deltasSinceBase >= 64, kind: "triage.state", @@ -153,6 +158,42 @@ export class Triage { return (await this.harness.snapshot(StateDoc, context)) ?? StateDoc.definition.initial(); } + /** The issue's triage conversation without the system prompt, or null when it has none yet. */ + async transcript(number: number): Promise { + const id = (await this.state()).issues[number]?.conversationId; + const conversation = id ? await this.harness.conversation(id as ConversationId, context) : undefined; + if (!conversation) return null; + const { entries } = await conversation.context(context); + const items: TranscriptItem[] = []; + const calls = new Map>(); + const text = (content: Message["content"]) => + typeof content === "string" + ? content + : content.map((block) => (block.type === "text" ? block.text : `[${block.type}]`)).join("\n"); + for (const message of entries.flatMap((entry) => entry.model ?? [])) { + if (message.role === "user") items.push({ type: "user", text: text(message.content) }); + else if (message.role === "toolResult") { + const call = calls.get(message.toolCallId); + if (call) { + call.output = text(message.content); + call.isError = message.isError; + } + } else if (message.role === "assistant") { + for (const block of message.content) { + if (block.type === "text" && block.text.trim()) items.push({ type: "text", text: block.text }); + else if (block.type === "thinking" && block.thinking.trim()) { + items.push({ type: "thinking", text: block.thinking }); + } else if (block.type === "toolCall") { + const call = { type: "tool" as const, name: block.name, args: block.arguments, output: null, isError: false }; + calls.set(block.id, call); + items.push(call); + } + } + } + } + return items; + } + async usageCost(): Promise { const usage = await this.harness.usage(context); return Object.values(usage.models).reduce((sum, model) => sum + (model.cost?.total ?? 0), 0); @@ -166,7 +207,7 @@ export class Triage { async enqueueInbox(): Promise { const state = await this.state(); for (const issue of this.options.corpus.all()) { - if (issue.createdAt < state.inboxSince) continue; + if (!inInbox(issue, state.inboxSince)) continue; const current = state.issues[issue.number]; if (current === undefined || current.status === "queued") this.enqueue(issue.number); } diff --git a/scripts/issues/ui.html b/scripts/issues/ui.html index 6a46b66aa..9f7d833f5 100644 --- a/scripts/issues/ui.html +++ b/scripts/issues/ui.html @@ -16,6 +16,7 @@ h1{font-size:17px;margin:0 8px 0 0} .status{font-size:12px;color:var(--muted);max-width:860px;margin:4px auto 0} .tabs{display:flex;gap:2px} .tab,.btn,select{font:inherit;font-size:14px;border:1px solid var(--line);background:var(--card);color:var(--text);border-radius:7px;padding:5px 10px;cursor:pointer} +.opt{font-size:14px;display:flex;gap:4px;align-items:center;cursor:pointer} .tab.on{background:var(--text);color:var(--bg);border-color:var(--text)} main{max-width:860px;margin:0 auto;padding:8px 10px 60px} .grp{display:flex;justify-content:space-between;align-items:center;margin:18px 4px 6px;font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted)} @@ -45,6 +46,18 @@ pre{white-space:pre-wrap;word-break:break-word;font:12.5px/1.45 ui-monospace,Men .dec.p0{background:var(--p0)}.dec.p1{background:var(--p1)}.dec.none{background:var(--none)} .empty{color:var(--muted);text-align:center;margin:40px 0} .err{color:var(--p0)} +dialog{border:1px solid var(--line);border-radius:10px;background:var(--card);color:var(--text);padding:16px 20px;min-width:280px} +dialog::backdrop{background:rgb(0 0 0 / .35)} +dialog h2{font-size:15px;margin:0 0 10px} +dialog table{border-collapse:collapse;font-size:14px} +dialog td{padding:3px 12px 3px 0} +kbd{font:12.5px ui-monospace,Menlo,monospace;border:1px solid var(--line);border-bottom-width:2px;border-radius:4px;padding:1px 6px;background:var(--hover)} +.conv{margin:6px 0 0;display:flex;flex-direction:column;gap:6px} +.conv .msg{white-space:pre-wrap;word-break:break-word} +.conv .who{font-size:11px;font-weight:600;text-transform:uppercase;color:var(--muted)} +.conv .thinking{color:var(--muted);font-style:italic} +.conv details{margin:0} +.conv summary code{font:12.5px ui-monospace,Menlo,monospace;color:var(--text)} @@ -57,13 +70,37 @@ pre{white-space:pre-wrap;word-break:break-word;font:12.5px/1.45 ui-monospace,Men + + +
+ +

Keyboard shortcuts

+ + + + + + + + + +
j kNext / previous issue
0Set p0
1Set p1
nSet none
oOpen on GitHub
cShow / hide the conversation
?Show this help
EscClose
+