@awak-app/simy-cli 0.1.0 → 0.1.2
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/README.md +51 -4
- package/package.json +16 -3
- package/src/agent.js +744 -56
- package/src/backend-executable.js +44 -0
- package/src/browser.js +59 -0
- package/src/console/app.js +1042 -0
- package/src/console/commands.js +100 -0
- package/src/console/index.js +25 -0
- package/src/index.js +44 -3
- package/src/local-attachments.js +270 -0
- package/src/orchestrator/contract.js +1 -0
- package/src/orchestrator/independent-audit.js +25 -0
- package/src/orchestrator/index.js +1 -1
- package/src/orchestrator/instruction.js +27 -1
- package/src/orchestrator/loop.js +61 -25
- package/src/orchestrator/presentation.js +189 -0
- package/src/orchestrator/result.js +11 -0
- package/src/provider-stream.js +310 -0
- package/src/repository-inventory.js +186 -0
- package/src/run-registry.js +44 -0
- package/src/runner.js +525 -64
- package/src/session-store.js +46 -17
- package/src/web-api.js +66 -0
- package/src/web-origin.js +46 -0
- package/src/workspace-context.js +37 -0
package/src/orchestrator/loop.js
CHANGED
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
buildIndependentAuditInstruction,
|
|
5
5
|
} from "./independent-audit.js";
|
|
6
6
|
import { buildCodingInstruction, buildPromptInterventions } from "./instruction.js";
|
|
7
|
-
import { buildAttempt } from "./result.js";
|
|
7
|
+
import { buildAttempt, mergeTokenUsage } from "./result.js";
|
|
8
8
|
import { appendEvent, publish } from "./shared.js";
|
|
9
9
|
|
|
10
10
|
export async function runCodingLoop({
|
|
@@ -13,41 +13,53 @@ export async function runCodingLoop({
|
|
|
13
13
|
executeIndependentAudit,
|
|
14
14
|
collectEvidence,
|
|
15
15
|
onUpdate,
|
|
16
|
+
humanGuidance = "",
|
|
17
|
+
resume = false,
|
|
18
|
+
shouldStop = () => false,
|
|
19
|
+
consumeHumanGuidance = () => "",
|
|
16
20
|
}) {
|
|
17
|
-
|
|
18
|
-
risk
|
|
19
|
-
|
|
20
|
-
|
|
21
|
+
if (!resume || snapshot.attempts.length === 0) {
|
|
22
|
+
appendEvent(snapshot, "risk_classifying", "PR risk classification completed.", {
|
|
23
|
+
risk: snapshot.charter.risk,
|
|
24
|
+
});
|
|
25
|
+
await publish(snapshot, "risk_classifying", onUpdate);
|
|
21
26
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
const charterFindings = validateCharter(snapshot.charter);
|
|
28
|
+
if (charterFindings.length > 0) {
|
|
29
|
+
snapshot.final_audit = humanGate(
|
|
30
|
+
"Requirements or design review must be completed before implementation.",
|
|
31
|
+
charterFindings,
|
|
32
|
+
);
|
|
33
|
+
appendEvent(snapshot, "waiting_human", "Requirements require human clarification.", {
|
|
34
|
+
finding_codes: charterFindings.map((finding) => finding.code),
|
|
35
|
+
});
|
|
36
|
+
await publish(snapshot, "waiting_human", onUpdate);
|
|
37
|
+
return snapshot;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
appendEvent(snapshot, "chartering", "Requirement charter created locally.", {
|
|
41
|
+
charter_id: snapshot.charter.id,
|
|
42
|
+
base_branch: snapshot.charter.base_branch,
|
|
43
|
+
risk_level: snapshot.charter.risk.level,
|
|
30
44
|
});
|
|
31
|
-
await publish(snapshot, "
|
|
32
|
-
return snapshot;
|
|
45
|
+
await publish(snapshot, "chartering", onUpdate);
|
|
33
46
|
}
|
|
34
47
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
risk_level: snapshot.charter.risk.level,
|
|
39
|
-
});
|
|
40
|
-
await publish(snapshot, "chartering", onUpdate);
|
|
41
|
-
|
|
42
|
-
let previousFindings = [];
|
|
48
|
+
let previousFindings = resume ? snapshot.final_audit?.findings || [] : [];
|
|
49
|
+
const firstAttempt = resume ? snapshot.attempts.length + 1 : 1;
|
|
50
|
+
const lastAttempt = resume ? firstAttempt : snapshot.charter.max_attempts;
|
|
43
51
|
for (
|
|
44
|
-
let attemptNumber =
|
|
45
|
-
attemptNumber <=
|
|
52
|
+
let attemptNumber = firstAttempt;
|
|
53
|
+
attemptNumber <= lastAttempt;
|
|
46
54
|
attemptNumber += 1
|
|
47
55
|
) {
|
|
56
|
+
if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
|
|
57
|
+
const queuedGuidance = consumeHumanGuidance();
|
|
58
|
+
const attemptGuidance = [humanGuidance, queuedGuidance].filter(Boolean).join("\n\n");
|
|
48
59
|
const promptInterventions = buildPromptInterventions(snapshot.charter, {
|
|
49
60
|
attemptNumber,
|
|
50
61
|
previousFindings,
|
|
62
|
+
humanGuidance: attemptGuidance,
|
|
51
63
|
});
|
|
52
64
|
const instruction = buildCodingInstruction(snapshot.charter, {
|
|
53
65
|
attemptNumber,
|
|
@@ -78,6 +90,7 @@ export async function runCodingLoop({
|
|
|
78
90
|
charter: snapshot.charter,
|
|
79
91
|
}),
|
|
80
92
|
);
|
|
93
|
+
if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
|
|
81
94
|
const attempt = buildAttempt({
|
|
82
95
|
attemptNumber,
|
|
83
96
|
charter: snapshot.charter,
|
|
@@ -88,6 +101,11 @@ export async function runCodingLoop({
|
|
|
88
101
|
|
|
89
102
|
appendEvent(snapshot, "collecting_evidence", `Collecting attempt ${attemptNumber} evidence.`, {
|
|
90
103
|
attempt_number: attemptNumber,
|
|
104
|
+
outcome_kind: attempt.outcome_kind,
|
|
105
|
+
branch_name: attempt.branch_name,
|
|
106
|
+
commit_sha: attempt.commit_sha,
|
|
107
|
+
pr_url: attempt.pr_url,
|
|
108
|
+
tests_run: attempt.tests_run,
|
|
91
109
|
});
|
|
92
110
|
await publish(snapshot, "collecting_evidence", onUpdate);
|
|
93
111
|
attempt.observed_evidence = await safeEvidence(collectEvidence, attempt);
|
|
@@ -95,6 +113,12 @@ export async function runCodingLoop({
|
|
|
95
113
|
appendEvent(snapshot, "auditing", `Verifying local evidence for attempt ${attemptNumber}.`, {
|
|
96
114
|
attempt_number: attemptNumber,
|
|
97
115
|
outcome_kind: attempt.outcome_kind,
|
|
116
|
+
tests_run: attempt.tests_run,
|
|
117
|
+
tests_passed: attempt.tests_passed,
|
|
118
|
+
changed_files: attempt.raw_output?.changed_files,
|
|
119
|
+
evidence_available:
|
|
120
|
+
attempt.observed_evidence?.local?.available === true ||
|
|
121
|
+
attempt.observed_evidence?.github?.available === true,
|
|
98
122
|
});
|
|
99
123
|
await publish(snapshot, "auditing", onUpdate);
|
|
100
124
|
attempt.audit = await auditAttempt(snapshot.charter, attempt);
|
|
@@ -121,6 +145,7 @@ export async function runCodingLoop({
|
|
|
121
145
|
backend: snapshot.charter.audit_backend,
|
|
122
146
|
});
|
|
123
147
|
await publish(snapshot, "independent_auditing", onUpdate);
|
|
148
|
+
if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
|
|
124
149
|
const auditInstruction = buildIndependentAuditInstruction(snapshot.charter, attempt);
|
|
125
150
|
const auditExecution = await safeExecution(() =>
|
|
126
151
|
executeIndependentAudit({
|
|
@@ -131,7 +156,9 @@ export async function runCodingLoop({
|
|
|
131
156
|
attempt,
|
|
132
157
|
}),
|
|
133
158
|
);
|
|
159
|
+
if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
|
|
134
160
|
attempt.independent_audit = buildIndependentAudit(auditExecution);
|
|
161
|
+
attempt.token_usage = mergeTokenUsage(attempt.token_usage, auditExecution.tokenUsage);
|
|
135
162
|
|
|
136
163
|
// Re-collect after the read-only auditor to catch any mutated HEAD or working tree.
|
|
137
164
|
attempt.observed_evidence = await safeEvidence(collectEvidence, attempt);
|
|
@@ -177,6 +204,15 @@ export async function runCodingLoop({
|
|
|
177
204
|
return snapshot;
|
|
178
205
|
}
|
|
179
206
|
|
|
207
|
+
async function stopCodingLoop(snapshot, onUpdate) {
|
|
208
|
+
if (snapshot.state === "stopped") return snapshot;
|
|
209
|
+
appendEvent(snapshot, "stopped", "Coding loop stopped by the local human operator.", {
|
|
210
|
+
source: "local_cli",
|
|
211
|
+
});
|
|
212
|
+
await publish(snapshot, "stopped", onUpdate);
|
|
213
|
+
return snapshot;
|
|
214
|
+
}
|
|
215
|
+
|
|
180
216
|
export async function recheckPrReadiness({ snapshot, collectEvidence, onUpdate }) {
|
|
181
217
|
const attempt = snapshot.attempts.at(-1);
|
|
182
218
|
if (!attempt || !attempt.audit?.passed || !attempt.independent_audit?.passed) return snapshot;
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { cleanString } from "./shared.js";
|
|
2
|
+
|
|
3
|
+
export function summarizeCodingLoopEvent(snapshot, event) {
|
|
4
|
+
const detail = objectValue(event?.detail);
|
|
5
|
+
const state = cleanString(detail.pr_lifecycle_state) || cleanString(event?.state);
|
|
6
|
+
const explicit = {
|
|
7
|
+
summary: cleanString(detail.step_summary),
|
|
8
|
+
result: cleanString(detail.step_result),
|
|
9
|
+
next: cleanString(detail.next_action),
|
|
10
|
+
};
|
|
11
|
+
if (explicit.summary) return explicit;
|
|
12
|
+
|
|
13
|
+
const charter = objectValue(snapshot?.charter);
|
|
14
|
+
const attemptNumber = numberValue(detail.attempt_number) || latestAttemptNumber(snapshot);
|
|
15
|
+
const maxAttempts = numberValue(charter.max_attempts) || 1;
|
|
16
|
+
const executor = executorLabel(detail.backend || charter.backend);
|
|
17
|
+
const repository = cleanString(charter.repository) || "the selected repository";
|
|
18
|
+
const branch = cleanString(detail.branch_name || charter.base_branch) || "the selected branch";
|
|
19
|
+
const requirement = sentencePreview(charter.requirement, 120) || "the requested change";
|
|
20
|
+
const findings = stringArray(detail.finding_codes);
|
|
21
|
+
const pendingReasons = stringArray(detail.pending_reasons);
|
|
22
|
+
|
|
23
|
+
switch (state) {
|
|
24
|
+
case "queued":
|
|
25
|
+
return {
|
|
26
|
+
summary: `Task recorded for local execution in ${repository} on ${branch}.`,
|
|
27
|
+
result: `Goal: ${requirement}`,
|
|
28
|
+
next: "SIMY will classify risk and turn the request into an execution charter.",
|
|
29
|
+
};
|
|
30
|
+
case "risk_classifying": {
|
|
31
|
+
const risk = objectValue(detail.risk || charter.risk);
|
|
32
|
+
const level = cleanString(risk.level) || "unknown";
|
|
33
|
+
return {
|
|
34
|
+
summary: `Risk classified as ${level}; the required review gates are now known.`,
|
|
35
|
+
result: risk.requires_design_review
|
|
36
|
+
? "A design review is required before implementation."
|
|
37
|
+
: "No pre-implementation design review is required.",
|
|
38
|
+
next: "SIMY will finalize scope, acceptance criteria, tests, and evidence requirements.",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
case "chartering":
|
|
42
|
+
return {
|
|
43
|
+
summary: `Execution scope set for: ${requirement}`,
|
|
44
|
+
result: `${countLabel(charter.acceptance_criteria, "acceptance criterion")}, ${countLabel(charter.expected_tests, "expected test")}, and ${countLabel(charter.expected_evidence, "evidence requirement")}.`,
|
|
45
|
+
next: `Attempt 1 of ${maxAttempts} will be handed to ${executor}.`,
|
|
46
|
+
};
|
|
47
|
+
case "dispatching":
|
|
48
|
+
return {
|
|
49
|
+
summary: `Preparing ${executor} attempt ${attemptNumber}/${maxAttempts} for ${branch}.`,
|
|
50
|
+
result: "The executor instruction now includes the task, constraints, checks, and prior findings.",
|
|
51
|
+
next: `${executor} will start the local coding process.`,
|
|
52
|
+
};
|
|
53
|
+
case "coding":
|
|
54
|
+
return {
|
|
55
|
+
summary: `${executor} is implementing attempt ${attemptNumber}/${maxAttempts} on ${branch}.`,
|
|
56
|
+
result: "Live provider output is shown below as the local process works.",
|
|
57
|
+
next: "When execution finishes, SIMY will collect code, test, PR, and UI evidence.",
|
|
58
|
+
};
|
|
59
|
+
case "collecting_evidence":
|
|
60
|
+
return {
|
|
61
|
+
summary: `Attempt ${attemptNumber} finished; SIMY is collecting verifiable implementation evidence.`,
|
|
62
|
+
result: listResult(detail.tests_run, "Checks to verify", "Checks will be read from the executor result and repository."),
|
|
63
|
+
next: "The evidence will be checked against the original requirement and acceptance criteria.",
|
|
64
|
+
};
|
|
65
|
+
case "auditing":
|
|
66
|
+
return {
|
|
67
|
+
summary: `SIMY is verifying attempt ${attemptNumber} against the requirement and recorded evidence.`,
|
|
68
|
+
result: evidenceResult(detail),
|
|
69
|
+
next: "A passing implementation proceeds to an independent AI review; findings trigger repair or human input.",
|
|
70
|
+
};
|
|
71
|
+
case "independent_auditing":
|
|
72
|
+
return {
|
|
73
|
+
summary: `${executorLabel(detail.backend || charter.audit_backend)} is independently reviewing attempt ${attemptNumber}.`,
|
|
74
|
+
result: "The reviewer uses the frozen instruction, diff, checks, and evidence rather than the implementer's conclusion.",
|
|
75
|
+
next: "SIMY will combine both audits and decide whether the PR is ready for human review.",
|
|
76
|
+
};
|
|
77
|
+
case "checking_pr":
|
|
78
|
+
return {
|
|
79
|
+
summary: "SIMY is refreshing the latest GitHub review and CI evidence.",
|
|
80
|
+
result: "The existing PR, commit, approvals, mergeability, and required checks are being re-read.",
|
|
81
|
+
next: "The run will move to merge-ready when every required gate passes.",
|
|
82
|
+
};
|
|
83
|
+
case "re_instructing":
|
|
84
|
+
return {
|
|
85
|
+
summary: `Attempt ${attemptNumber} needs another coding pass before review.`,
|
|
86
|
+
result: findingResult(findings),
|
|
87
|
+
next: `SIMY will give the findings to ${executor} for attempt ${Math.min(attemptNumber + 1, maxAttempts)}/${maxAttempts}.`,
|
|
88
|
+
};
|
|
89
|
+
case "pr_ready_for_review":
|
|
90
|
+
return {
|
|
91
|
+
summary: "Implementation is complete, but the PR still needs human review or external evidence.",
|
|
92
|
+
result: pendingReasons.length ? `Pending: ${pendingReasons.join("; ")}` : cleanString(event?.message),
|
|
93
|
+
next: "Review or approve the PR, then refresh its evidence from Web or CLI.",
|
|
94
|
+
};
|
|
95
|
+
case "merge_ready":
|
|
96
|
+
return {
|
|
97
|
+
summary: "Implementation, checks, evidence, and required review gates have passed.",
|
|
98
|
+
result: cleanString(detail.pr_url) ? `Merge-ready PR: ${detail.pr_url}` : "The recorded PR is ready to merge.",
|
|
99
|
+
next: "A human can merge the PR when the release timing is appropriate.",
|
|
100
|
+
};
|
|
101
|
+
case "waiting_human":
|
|
102
|
+
return {
|
|
103
|
+
summary: "The automated loop is paused because a human decision or missing input is required.",
|
|
104
|
+
result: findings.length ? findingResult(findings) : cleanString(snapshot?.final_audit?.summary || event?.message),
|
|
105
|
+
next: "Provide guidance or approval in Web or CLI to continue the same run.",
|
|
106
|
+
};
|
|
107
|
+
case "blocked":
|
|
108
|
+
return {
|
|
109
|
+
summary: "The run cannot continue automatically with its current attempt budget or evidence.",
|
|
110
|
+
result: findings.length ? findingResult(findings) : cleanString(event?.message),
|
|
111
|
+
next: "Review the findings, adjust the request or evidence, and resume the run.",
|
|
112
|
+
};
|
|
113
|
+
case "failed":
|
|
114
|
+
return {
|
|
115
|
+
summary: "Local orchestration stopped before the requested outcome was verified.",
|
|
116
|
+
result: cleanString(detail.error || event?.message),
|
|
117
|
+
next: "Fix the reported problem, then retry or continue the run.",
|
|
118
|
+
};
|
|
119
|
+
case "stopped":
|
|
120
|
+
return {
|
|
121
|
+
summary: "The local human operator stopped this coding run.",
|
|
122
|
+
result: "No further executor handoff will occur for this run.",
|
|
123
|
+
next: "Start a new run or explicitly continue this one when ready.",
|
|
124
|
+
};
|
|
125
|
+
default:
|
|
126
|
+
return {
|
|
127
|
+
summary: cleanString(event?.message) || "SIMY recorded a coding lifecycle update.",
|
|
128
|
+
result: "Open details for the complete structured record.",
|
|
129
|
+
next: "SIMY will continue from the latest recorded state.",
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function evidenceResult(detail) {
|
|
135
|
+
const testsPassed = detail.tests_passed;
|
|
136
|
+
const changedFiles = stringArray(detail.changed_files);
|
|
137
|
+
const parts = [];
|
|
138
|
+
if (testsPassed === true) parts.push("reported checks passed");
|
|
139
|
+
if (testsPassed === false) parts.push("one or more reported checks failed");
|
|
140
|
+
if (changedFiles.length) parts.push(`${changedFiles.length} changed ${changedFiles.length === 1 ? "file" : "files"} recorded`);
|
|
141
|
+
return parts.length ? `${capitalize(parts.join("; "))}.` : "SIMY is checking the code result, tests, PR facts, and any required UI evidence.";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function findingResult(findings) {
|
|
145
|
+
return findings.length ? `Findings: ${findings.join(", ")}.` : "The audit recorded findings that require attention.";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function listResult(values, label, fallback) {
|
|
149
|
+
const items = stringArray(values);
|
|
150
|
+
return items.length ? `${label}: ${items.join(", ")}.` : fallback;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function countLabel(values, singular) {
|
|
154
|
+
const count = Array.isArray(values) ? values.length : 0;
|
|
155
|
+
const plural = singular === "acceptance criterion" ? "acceptance criteria" : `${singular}s`;
|
|
156
|
+
return `${count} ${count === 1 ? singular : plural}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function latestAttemptNumber(snapshot) {
|
|
160
|
+
const attempts = Array.isArray(snapshot?.attempts) ? snapshot.attempts : [];
|
|
161
|
+
return numberValue(attempts.at(-1)?.attempt_number) || 1;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function executorLabel(value) {
|
|
165
|
+
return cleanString(value) === "claude" ? "Claude Code" : "Codex";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function sentencePreview(value, maxLength) {
|
|
169
|
+
const text = cleanString(value).replace(/\s+/g, " ");
|
|
170
|
+
if (text.length <= maxLength) return text;
|
|
171
|
+
return `${text.slice(0, maxLength - 3).trimEnd()}...`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function stringArray(value) {
|
|
175
|
+
return Array.isArray(value) ? value.map(cleanString).filter(Boolean) : [];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function numberValue(value) {
|
|
179
|
+
const number = Number.parseInt(String(value ?? ""), 10);
|
|
180
|
+
return Number.isFinite(number) && number > 0 ? number : 0;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function objectValue(value) {
|
|
184
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function capitalize(value) {
|
|
188
|
+
return value ? `${value[0].toUpperCase()}${value.slice(1)}` : value;
|
|
189
|
+
}
|
|
@@ -44,6 +44,13 @@ export function buildAttempt({ attemptNumber, charter, instruction, promptInterv
|
|
|
44
44
|
};
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
export function mergeTokenUsage(...values) {
|
|
48
|
+
const records = values.flatMap((value) =>
|
|
49
|
+
Array.isArray(value?.records) ? value.records.filter(isUsageRecord) : [],
|
|
50
|
+
);
|
|
51
|
+
return records.length > 0 ? { records } : {};
|
|
52
|
+
}
|
|
53
|
+
|
|
47
54
|
export function parseStructuredResult(text) {
|
|
48
55
|
return parseStructuredMarker(text, "SIMY_RESULT_JSON:");
|
|
49
56
|
}
|
|
@@ -89,3 +96,7 @@ function buildArtifactPreviews(result) {
|
|
|
89
96
|
: null,
|
|
90
97
|
].filter(Boolean);
|
|
91
98
|
}
|
|
99
|
+
|
|
100
|
+
function isUsageRecord(value) {
|
|
101
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
102
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from "node:util";
|
|
2
|
+
|
|
3
|
+
const MAX_LINE_CHARS = 2_000;
|
|
4
|
+
const STRUCTURED_MARKERS = ["SIMY_RESULT_JSON:", "SIMY_AUDIT_JSON:"];
|
|
5
|
+
|
|
6
|
+
export function createProviderStreamDecoder({ backend, stream = "stdout", onLine, onUsage }) {
|
|
7
|
+
let pending = "";
|
|
8
|
+
let previous = "";
|
|
9
|
+
let model = "";
|
|
10
|
+
|
|
11
|
+
function consume(value) {
|
|
12
|
+
const text = stripVTControlCharacters(String(value || "")).trim();
|
|
13
|
+
if (!text || STRUCTURED_MARKERS.some((marker) => text.startsWith(marker))) return;
|
|
14
|
+
|
|
15
|
+
let event;
|
|
16
|
+
try {
|
|
17
|
+
event = JSON.parse(text);
|
|
18
|
+
} catch {
|
|
19
|
+
emit(formatPlainLine(backend, stream, text));
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
model = providerModel(backend, event) || model;
|
|
24
|
+
const usage = extractProviderTokenUsage(backend, event, { model });
|
|
25
|
+
if (usage && onUsage) onUsage(usage);
|
|
26
|
+
|
|
27
|
+
for (const line of formatProviderEvent(backend, event)) emit(line);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function emit(line) {
|
|
31
|
+
const text = cleanLine(line);
|
|
32
|
+
if (!text || text === previous) return;
|
|
33
|
+
previous = text;
|
|
34
|
+
onLine(text);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
push(chunk) {
|
|
39
|
+
pending += String(chunk || "");
|
|
40
|
+
const lines = pending.split(/\r?\n/);
|
|
41
|
+
pending = lines.pop() || "";
|
|
42
|
+
for (const line of lines) consume(line);
|
|
43
|
+
},
|
|
44
|
+
flush() {
|
|
45
|
+
consume(pending);
|
|
46
|
+
pending = "";
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function extractProviderTokenUsage(backend, event, { model = "" } = {}) {
|
|
52
|
+
if (!event || typeof event !== "object") return null;
|
|
53
|
+
if (backend === "claude") {
|
|
54
|
+
if (event.type !== "result") return null;
|
|
55
|
+
return normalizeClaudeUsage(event.usage, model || event.model);
|
|
56
|
+
}
|
|
57
|
+
if (event.type !== "turn.completed") return null;
|
|
58
|
+
return normalizeCodexUsage(event.usage, model || event.model);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function formatProviderEvent(backend, event) {
|
|
62
|
+
if (!event || typeof event !== "object") return [];
|
|
63
|
+
return backend === "claude" ? formatClaudeEvent(event) : formatCodexEvent(event);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function formatCodexEvent(event) {
|
|
67
|
+
const prefix = "[Codex]";
|
|
68
|
+
if (event.type === "thread.started") {
|
|
69
|
+
return [`${prefix} session started${shortIdentifier(event.thread_id)}`];
|
|
70
|
+
}
|
|
71
|
+
if (event.type === "turn.started") return [`${prefix} turn started`];
|
|
72
|
+
if (event.type === "turn.completed") {
|
|
73
|
+
return [`${prefix} completed${formatUsage(event.usage)}`];
|
|
74
|
+
}
|
|
75
|
+
if (event.type === "turn.failed" || event.type === "error") {
|
|
76
|
+
return [`${prefix} error: ${errorMessage(event)}`];
|
|
77
|
+
}
|
|
78
|
+
if (event.type !== "item.started" && event.type !== "item.completed") return [];
|
|
79
|
+
|
|
80
|
+
const item = event.item && typeof event.item === "object" ? event.item : {};
|
|
81
|
+
const phase = event.type === "item.started" ? "started" : "completed";
|
|
82
|
+
switch (item.type) {
|
|
83
|
+
case "agent_message":
|
|
84
|
+
return contentLines(prefix, "assistant", item.text || item.message);
|
|
85
|
+
case "reasoning":
|
|
86
|
+
return contentLines(prefix, "reasoning", item.text || item.summary);
|
|
87
|
+
case "command_execution":
|
|
88
|
+
return formatCodexCommand(prefix, item, phase);
|
|
89
|
+
case "file_change":
|
|
90
|
+
return formatFileChanges(prefix, item);
|
|
91
|
+
case "mcp_tool_call":
|
|
92
|
+
return [`${prefix} tool ${phase}: ${toolName(item)}`];
|
|
93
|
+
case "web_search":
|
|
94
|
+
return [`${prefix} web search ${phase}: ${cleanLine(item.query || "search")}`];
|
|
95
|
+
case "todo_list":
|
|
96
|
+
return [`${prefix} plan updated`];
|
|
97
|
+
case "error":
|
|
98
|
+
return [`${prefix} error: ${cleanLine(item.message || "provider item failed")}`];
|
|
99
|
+
default:
|
|
100
|
+
return item.type ? [`${prefix} ${cleanLine(item.type)} ${phase}`] : [];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function formatClaudeEvent(event) {
|
|
105
|
+
const prefix = "[Claude Code]";
|
|
106
|
+
if (event.type === "system" && event.subtype === "init") {
|
|
107
|
+
const model = cleanLine(event.model || "");
|
|
108
|
+
return [`${prefix} session started${model ? ` | ${model}` : ""}`];
|
|
109
|
+
}
|
|
110
|
+
if (event.type === "system" && event.subtype === "hook_response") {
|
|
111
|
+
const warning = cleanLine(event.stderr || "");
|
|
112
|
+
return warning ? [`${prefix} hook warning: ${warning}`] : [];
|
|
113
|
+
}
|
|
114
|
+
if (event.type === "assistant") {
|
|
115
|
+
return formatClaudeContent(prefix, event.message?.content);
|
|
116
|
+
}
|
|
117
|
+
if (event.type === "user") {
|
|
118
|
+
return formatClaudeToolResults(prefix, event.message?.content);
|
|
119
|
+
}
|
|
120
|
+
if (event.type === "result") {
|
|
121
|
+
if (event.is_error) return [`${prefix} error: ${cleanLine(event.result || event.subtype)}`];
|
|
122
|
+
const turns = Number.isInteger(event.num_turns) ? ` | ${event.num_turns} turn(s)` : "";
|
|
123
|
+
const duration = Number.isFinite(event.duration_ms)
|
|
124
|
+
? ` | ${(event.duration_ms / 1_000).toFixed(1)}s`
|
|
125
|
+
: "";
|
|
126
|
+
return [`${prefix} completed${turns}${duration}${formatUsage(event.usage)}`];
|
|
127
|
+
}
|
|
128
|
+
if (event.type === "rate_limit_event") return [`${prefix} rate limit status updated`];
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function formatCodexCommand(prefix, item, phase) {
|
|
133
|
+
const command = cleanLine(item.command || item.cmd || "command");
|
|
134
|
+
if (phase === "started") return [`${prefix} command started: ${command}`];
|
|
135
|
+
const exitCode = Number.isInteger(item.exit_code) ? ` (exit ${item.exit_code})` : "";
|
|
136
|
+
const lines = [`${prefix} command completed${exitCode}: ${command}`];
|
|
137
|
+
const output = item.aggregated_output || item.output || "";
|
|
138
|
+
if (String(output).trim()) lines.push(...contentLines(prefix, "output", output).slice(-6));
|
|
139
|
+
return lines;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function formatFileChanges(prefix, item) {
|
|
143
|
+
const changes = Array.isArray(item.changes) ? item.changes : [];
|
|
144
|
+
const paths = changes
|
|
145
|
+
.map((change) => cleanLine(change?.path || change?.file || ""))
|
|
146
|
+
.filter(Boolean);
|
|
147
|
+
if (paths.length > 0) return [`${prefix} files changed: ${paths.join(", ")}`];
|
|
148
|
+
return [`${prefix} file changes completed`];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function formatClaudeContent(prefix, content) {
|
|
152
|
+
const blocks = Array.isArray(content) ? content : [];
|
|
153
|
+
const lines = [];
|
|
154
|
+
for (const block of blocks) {
|
|
155
|
+
if (block?.type === "text") lines.push(...contentLines(prefix, "assistant", block.text));
|
|
156
|
+
if (block?.type === "thinking") lines.push(...contentLines(prefix, "reasoning", block.thinking));
|
|
157
|
+
if (block?.type === "tool_use") {
|
|
158
|
+
const detail = toolInputSummary(block.input);
|
|
159
|
+
lines.push(`${prefix} tool started: ${cleanLine(block.name || "tool")}${detail}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return lines;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function formatClaudeToolResults(prefix, content) {
|
|
166
|
+
const blocks = Array.isArray(content) ? content : [];
|
|
167
|
+
const lines = [];
|
|
168
|
+
for (const block of blocks) {
|
|
169
|
+
if (block?.type !== "tool_result") continue;
|
|
170
|
+
const status = block.is_error ? "failed" : "completed";
|
|
171
|
+
lines.push(`${prefix} tool ${status}${shortIdentifier(block.tool_use_id)}`);
|
|
172
|
+
const output = toolResultText(block.content);
|
|
173
|
+
if (output) {
|
|
174
|
+
lines.push(
|
|
175
|
+
...contentLines(prefix, block.is_error ? "error" : "output", output).slice(-6),
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return lines;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function contentLines(prefix, label, value) {
|
|
183
|
+
return String(value || "")
|
|
184
|
+
.split(/\r?\n/)
|
|
185
|
+
.map((line) => cleanLine(line))
|
|
186
|
+
.filter(Boolean)
|
|
187
|
+
.map((line) => `${prefix} ${label}: ${line}`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function formatPlainLine(backend, stream, value) {
|
|
191
|
+
const prefix = backend === "claude" ? "[Claude Code]" : "[Codex]";
|
|
192
|
+
const diagnostic = String(value).match(
|
|
193
|
+
/^\S+\s+(WARN|ERROR|INFO)\s+([\w.-]+(?:::[\w.-]+)*):\s*(.*)$/,
|
|
194
|
+
);
|
|
195
|
+
if (diagnostic) {
|
|
196
|
+
const level = diagnostic[1].toLowerCase();
|
|
197
|
+
return `${prefix} ${level}: ${diagnostic[3] || diagnostic[2]}`;
|
|
198
|
+
}
|
|
199
|
+
return stream === "stderr" ? `${prefix} stderr: ${value}` : `${prefix} ${value}`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function toolResultText(value) {
|
|
203
|
+
if (typeof value === "string") return value;
|
|
204
|
+
if (!Array.isArray(value)) return "";
|
|
205
|
+
return value
|
|
206
|
+
.map((item) => (typeof item === "string" ? item : item?.text || item?.content || ""))
|
|
207
|
+
.filter(Boolean)
|
|
208
|
+
.join("\n");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function formatUsage(usage) {
|
|
212
|
+
if (!usage || typeof usage !== "object") return "";
|
|
213
|
+
const input = integer(usage.input_tokens ?? usage.inputTokens);
|
|
214
|
+
const output = integer(usage.output_tokens ?? usage.outputTokens);
|
|
215
|
+
const cached = integer(usage.cached_input_tokens ?? usage.cache_read_input_tokens);
|
|
216
|
+
const parts = [];
|
|
217
|
+
if (input !== null) parts.push(`${input.toLocaleString("en-US")} in`);
|
|
218
|
+
if (output !== null) parts.push(`${output.toLocaleString("en-US")} out`);
|
|
219
|
+
if (cached !== null) parts.push(`${cached.toLocaleString("en-US")} cached`);
|
|
220
|
+
return parts.length > 0 ? ` | ${parts.join(" / ")}` : "";
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function normalizeCodexUsage(usage, model) {
|
|
224
|
+
if (!usage || typeof usage !== "object") return null;
|
|
225
|
+
const tokensIn = tokenNumber(usage.input_tokens ?? usage.inputTokens);
|
|
226
|
+
const tokensOut = tokenNumber(usage.output_tokens ?? usage.outputTokens);
|
|
227
|
+
if (tokensIn + tokensOut <= 0) return null;
|
|
228
|
+
const cached = tokenNumber(usage.cached_input_tokens ?? usage.cachedInputTokens);
|
|
229
|
+
return compactUsage({
|
|
230
|
+
tokens_in: tokensIn,
|
|
231
|
+
tokens_out: tokensOut,
|
|
232
|
+
total_tokens: tokensIn + tokensOut,
|
|
233
|
+
cached_input_tokens: cached,
|
|
234
|
+
model: cleanLine(model || usage.model || ""),
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function normalizeClaudeUsage(usage, model) {
|
|
239
|
+
if (!usage || typeof usage !== "object") return null;
|
|
240
|
+
const uncached = tokenNumber(usage.input_tokens ?? usage.inputTokens);
|
|
241
|
+
const cacheRead = tokenNumber(
|
|
242
|
+
usage.cache_read_input_tokens ?? usage.cacheReadInputTokens,
|
|
243
|
+
);
|
|
244
|
+
const cacheCreation = tokenNumber(
|
|
245
|
+
usage.cache_creation_input_tokens ?? usage.cacheCreationInputTokens,
|
|
246
|
+
);
|
|
247
|
+
const tokensOut = tokenNumber(usage.output_tokens ?? usage.outputTokens);
|
|
248
|
+
const tokensIn = uncached + cacheRead + cacheCreation;
|
|
249
|
+
if (tokensIn + tokensOut <= 0) return null;
|
|
250
|
+
return compactUsage({
|
|
251
|
+
tokens_in: tokensIn,
|
|
252
|
+
tokens_out: tokensOut,
|
|
253
|
+
total_tokens: tokensIn + tokensOut,
|
|
254
|
+
uncached_input_tokens: uncached,
|
|
255
|
+
cached_input_tokens: cacheRead,
|
|
256
|
+
cache_creation_input_tokens: cacheCreation,
|
|
257
|
+
model: cleanLine(model || usage.model || ""),
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function compactUsage(value) {
|
|
262
|
+
return Object.fromEntries(
|
|
263
|
+
Object.entries(value).filter(([, item]) => item !== "" && item !== null && item !== undefined),
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function providerModel(backend, event) {
|
|
268
|
+
if (backend === "claude" && event.type === "system" && event.subtype === "init") {
|
|
269
|
+
return cleanLine(event.model || "");
|
|
270
|
+
}
|
|
271
|
+
return cleanLine(event.model || "");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function tokenNumber(value) {
|
|
275
|
+
return Number.isFinite(value) && value >= 0 ? Math.round(value) : 0;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function toolName(item) {
|
|
279
|
+
const server = cleanLine(item.server || item.server_name || "");
|
|
280
|
+
const tool = cleanLine(item.tool || item.tool_name || item.name || "tool");
|
|
281
|
+
return server ? `${server}.${tool}` : tool;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function toolInputSummary(input) {
|
|
285
|
+
if (!input || typeof input !== "object") return "";
|
|
286
|
+
const value = input.command || input.file_path || input.path || input.query || input.pattern;
|
|
287
|
+
const summary = cleanLine(value || "");
|
|
288
|
+
return summary ? `: ${summary}` : "";
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function errorMessage(event) {
|
|
292
|
+
const error = event.error;
|
|
293
|
+
if (typeof error === "string") return cleanLine(error);
|
|
294
|
+
return cleanLine(error?.message || event.message || "provider turn failed");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function shortIdentifier(value) {
|
|
298
|
+
const text = cleanLine(value || "");
|
|
299
|
+
return text ? ` | ${text.slice(0, 8)}` : "";
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function integer(value) {
|
|
303
|
+
return Number.isInteger(value) ? value : null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function cleanLine(value) {
|
|
307
|
+
const text = stripVTControlCharacters(String(value || "")).replace(/\s+/g, " ").trim();
|
|
308
|
+
if (text.length <= MAX_LINE_CHARS) return text;
|
|
309
|
+
return `${text.slice(0, MAX_LINE_CHARS - 3)}...`;
|
|
310
|
+
}
|