headlesscode 1.0.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/ATTRIBUTION.md +53 -0
- package/CODE_OF_CONDUCT.md +130 -0
- package/CONTRIBUTING.md +107 -0
- package/LICENSE +202 -0
- package/README.md +486 -0
- package/SECURITY.md +211 -0
- package/bin/headlesscode.mjs +83 -0
- package/package.json +63 -0
- package/shared/prompts/review-mode-prompt-short.md +93 -0
- package/shared/prompts/review-mode-prompt.md +281 -0
- package/shared/rules-code/rules.md +22 -0
- package/shared/stacks/cpp/rules.md +30 -0
- package/shared/stacks/fastapi/rules.md +30 -0
- package/shared/stacks/javascript/rules.md +37 -0
- package/shared/stacks/postgresql/rules.md +31 -0
- package/shared/stacks/python/rules.md +35 -0
- package/shared/stacks/react/rules.md +11 -0
- package/shared/stacks/typescript/rules.md +10 -0
- package/src/budget/budget.ts +221 -0
- package/src/budget/concurrency.ts +126 -0
- package/src/budget/cost.ts +309 -0
- package/src/budget/index.ts +8 -0
- package/src/checkpoints/cli.ts +256 -0
- package/src/checkpoints/service.ts +227 -0
- package/src/cli.ts +1535 -0
- package/src/cloud/docker-provider.ts +334 -0
- package/src/cloud/provider.ts +300 -0
- package/src/codeintel/call-graph.ts +78 -0
- package/src/codeintel/find-references.ts +123 -0
- package/src/codeintel/go-to-definition.ts +193 -0
- package/src/codeintel/handlers.ts +190 -0
- package/src/codeintel/import-graph.ts +173 -0
- package/src/codeintel/outline.ts +180 -0
- package/src/codeintel/position.ts +77 -0
- package/src/codeintel/program.ts +350 -0
- package/src/codeintel/rename-symbol.ts +213 -0
- package/src/codeintel/tools.ts +280 -0
- package/src/codemap/build.ts +135 -0
- package/src/codemap/cli.ts +190 -0
- package/src/codemap/extract.ts +339 -0
- package/src/codemap/files.ts +236 -0
- package/src/codemap/fingerprint.ts +65 -0
- package/src/codemap/flows.ts +62 -0
- package/src/codemap/html.ts +451 -0
- package/src/codemap/lock.ts +80 -0
- package/src/codemap/types.ts +101 -0
- package/src/codesearch/airunner-embedder.ts +185 -0
- package/src/codesearch/chunk.ts +339 -0
- package/src/codesearch/cli.ts +223 -0
- package/src/codesearch/embedder.ts +332 -0
- package/src/codesearch/files.ts +280 -0
- package/src/codesearch/index.ts +469 -0
- package/src/codesearch/ollama-embedder.ts +205 -0
- package/src/codesearch/search.ts +141 -0
- package/src/codesearch/types.ts +100 -0
- package/src/config/mode-models.ts +218 -0
- package/src/dashboard/aggregate.ts +364 -0
- package/src/dashboard/chat-thread.ts +141 -0
- package/src/dashboard/checkpoints.ts +124 -0
- package/src/dashboard/cli.ts +193 -0
- package/src/dashboard/codemap.ts +44 -0
- package/src/dashboard/files.ts +121 -0
- package/src/dashboard/page.ts +2803 -0
- package/src/dashboard/self-improvement-metrics.ts +282 -0
- package/src/dashboard/server.ts +1103 -0
- package/src/dashboard/session-launch.ts +310 -0
- package/src/dashboard/timeline.ts +273 -0
- package/src/dashboard/tool-exec.ts +107 -0
- package/src/dashboard/trend-cli.ts +141 -0
- package/src/dashboard/trend.ts +413 -0
- package/src/decision-proxy/cli.ts +261 -0
- package/src/decision-proxy/proxy.ts +569 -0
- package/src/deploy/gate-cli.ts +147 -0
- package/src/deploy/gate.ts +254 -0
- package/src/engine/condense.ts +512 -0
- package/src/engine/events.ts +428 -0
- package/src/engine/handoff.ts +71 -0
- package/src/engine/lazy-tools.ts +160 -0
- package/src/engine/local-explore.ts +653 -0
- package/src/engine/logger.ts +96 -0
- package/src/engine/loop.ts +5517 -0
- package/src/engine/parser.ts +347 -0
- package/src/engine/prompt.ts +860 -0
- package/src/engine/reports.ts +47 -0
- package/src/engine/stacks.ts +448 -0
- package/src/engine/types.ts +291 -0
- package/src/engine/usage.ts +186 -0
- package/src/github/app-auth.ts +161 -0
- package/src/github/cli.ts +448 -0
- package/src/github/installations.ts +133 -0
- package/src/github/pr.ts +321 -0
- package/src/github/provision.ts +118 -0
- package/src/github/push.ts +122 -0
- package/src/index-util.ts +50 -0
- package/src/index.ts +81 -0
- package/src/init/cli.ts +248 -0
- package/src/init/gitignore.ts +74 -0
- package/src/llm/ollama.ts +308 -0
- package/src/llm/openrouter.ts +868 -0
- package/src/llm/preflight.ts +367 -0
- package/src/llm/transcript-capture.ts +84 -0
- package/src/memory/embed.ts +110 -0
- package/src/memory/index.ts +22 -0
- package/src/memory/local.ts +259 -0
- package/src/memory/summarizer.ts +283 -0
- package/src/memory/types.ts +153 -0
- package/src/memory/uwuchat.ts +157 -0
- package/src/migrate/cli.ts +115 -0
- package/src/orchestrator/analyze-cli.ts +104 -0
- package/src/orchestrator/auto-split.ts +206 -0
- package/src/orchestrator/cleanup.ts +1003 -0
- package/src/orchestrator/cli.ts +3571 -0
- package/src/orchestrator/cost-estimate.ts +564 -0
- package/src/orchestrator/cost-history-cli.ts +242 -0
- package/src/orchestrator/cost-history.ts +397 -0
- package/src/orchestrator/git-sync.ts +250 -0
- package/src/orchestrator/index.ts +153 -0
- package/src/orchestrator/log-analysis.ts +0 -0
- package/src/orchestrator/merge-check.ts +108 -0
- package/src/orchestrator/pipeline.ts +411 -0
- package/src/orchestrator/resume.ts +1940 -0
- package/src/orchestrator/reviewer.ts +503 -0
- package/src/orchestrator/split.ts +296 -0
- package/src/orchestrator/state.ts +542 -0
- package/src/orchestrator/status.ts +697 -0
- package/src/orchestrator/verification-gate.ts +134 -0
- package/src/orchestrator/watch.ts +898 -0
- package/src/permissions/commands.ts +1083 -0
- package/src/permissions/config.ts +241 -0
- package/src/permissions/index.ts +12 -0
- package/src/permissions/protected-files.ts +96 -0
- package/src/permissions/store-protection.ts +272 -0
- package/src/project-store.ts +648 -0
- package/src/projects/cli.ts +382 -0
- package/src/qa/qa.ts +487 -0
- package/src/tools/browser/handler.ts +346 -0
- package/src/tools/browser/service.ts +406 -0
- package/src/tools/browser/smoke.ts +78 -0
- package/src/tools/browser/tool.ts +99 -0
- package/src/tools/executor.ts +2575 -0
- package/src/tools/language-detect.ts +183 -0
- package/src/tools/output-summarizer.ts +369 -0
- package/src/tools/run-tests.ts +302 -0
- package/src/tools/set-indentation-tool.ts +49 -0
- package/src/tools/test-selection.ts +160 -0
- package/src/vendor/tests/smoke.ts +103 -0
- package/src/vendor/zoo-code/VENDOR-NOTES.md +213 -0
- package/src/vendor/zoo-code/shim/anthropic.ts +71 -0
- package/src/vendor/zoo-code/shim/openai.d.ts +60 -0
- package/src/vendor/zoo-code/shim/os-name.ts +18 -0
- package/src/vendor/zoo-code/shim/strip-bom.ts +14 -0
- package/src/vendor/zoo-code/shim/vscode.ts +76 -0
- package/src/vendor/zoo-code/src/core/config/CustomModesManager.ts +1015 -0
- package/src/vendor/zoo-code/src/core/diff/strategies/multi-search-replace.ts +670 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/capabilities.ts +46 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/custom-instructions.ts +559 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/index.ts +10 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/markdown-formatting.ts +7 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/modes.ts +35 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/objective.ts +13 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/rules.ts +95 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/skills.ts +105 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/system-info.ts +30 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/tool-use-guidelines.ts +9 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/tool-use.ts +7 -0
- package/src/vendor/zoo-code/src/core/prompts/system.ts +176 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/access_mcp_resource.ts +41 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_diff.ts +40 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_patch.ts +61 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/ask_followup_question.ts +62 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/attempt_completion.ts +33 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/codebase_search.ts +43 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/converters.ts +109 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit.ts +48 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit_file.ts +72 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/execute_command.ts +54 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/generate_image.ts +51 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/index.ts +75 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/list_files.ts +41 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/mcp_server.ts +75 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/new_task.ts +39 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_command_output.ts +81 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_file.ts +169 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/run_slash_command.ts +31 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_files.ts +50 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_replace.ts +51 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/skill.ts +33 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/switch_mode.ts +31 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/update_todo_list.ts +54 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/write_to_file.ts +40 -0
- package/src/vendor/zoo-code/src/core/prompts/types.ts +12 -0
- package/src/vendor/zoo-code/src/i18n/index.ts +19 -0
- package/src/vendor/zoo-code/src/integrations/misc/extract-text.ts +81 -0
- package/src/vendor/zoo-code/src/services/checkpoints/RepoPerTaskCheckpointService.ts +15 -0
- package/src/vendor/zoo-code/src/services/checkpoints/ShadowCheckpointService.ts +553 -0
- package/src/vendor/zoo-code/src/services/checkpoints/excludes.ts +212 -0
- package/src/vendor/zoo-code/src/services/checkpoints/index.ts +3 -0
- package/src/vendor/zoo-code/src/services/checkpoints/types.ts +35 -0
- package/src/vendor/zoo-code/src/services/code-index/manager.ts +19 -0
- package/src/vendor/zoo-code/src/services/mcp/McpHub.ts +36 -0
- package/src/vendor/zoo-code/src/services/roo-config/index.ts +441 -0
- package/src/vendor/zoo-code/src/services/search/file-search.ts +143 -0
- package/src/vendor/zoo-code/src/services/skills/SkillsManager.ts +20 -0
- package/src/vendor/zoo-code/src/shared/globalFileNames.ts +9 -0
- package/src/vendor/zoo-code/src/shared/language.ts +43 -0
- package/src/vendor/zoo-code/src/shared/modes.ts +257 -0
- package/src/vendor/zoo-code/src/shared/tools.ts +385 -0
- package/src/vendor/zoo-code/src/utils/fs.ts +39 -0
- package/src/vendor/zoo-code/src/utils/globalContext.ts +22 -0
- package/src/vendor/zoo-code/src/utils/json-schema.ts +16 -0
- package/src/vendor/zoo-code/src/utils/logging.ts +21 -0
- package/src/vendor/zoo-code/src/utils/mcp-name.ts +190 -0
- package/src/vendor/zoo-code/src/utils/object.ts +18 -0
- package/src/vendor/zoo-code/src/utils/path.ts +94 -0
- package/src/vendor/zoo-code/src/utils/shell.ts +376 -0
- package/src/vendor/zoo-code/src/utils/text-normalization.ts +99 -0
- package/src/vendor/zoo-code/types/global-settings.ts +19 -0
- package/src/vendor/zoo-code/types/index.ts +22 -0
- package/src/vendor/zoo-code/types/message.ts +375 -0
- package/src/vendor/zoo-code/types/mode.ts +241 -0
- package/src/vendor/zoo-code/types/todo.ts +19 -0
- package/src/vendor/zoo-code/types/tool-params.ts +116 -0
- package/src/vendor/zoo-code/types/tool.ts +67 -0
- package/src/vendor/zoo-code/types/vscode.ts +84 -0
- package/src/vision/describe.ts +242 -0
- package/src/vision/tool.ts +91 -0
- package/src/watcher/cli.ts +369 -0
- package/src/watcher/github.ts +304 -0
- package/src/watcher/index.ts +59 -0
- package/src/watcher/state.ts +254 -0
- package/src/watcher/watch.ts +562 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cost/token dashboard — pure aggregation logic (workstream 3) + live
|
|
3
|
+
* per-session event feed (live worker monitoring).
|
|
4
|
+
*
|
|
5
|
+
* Scans `.headlesscode/usage/*.jsonl` files (see `src/engine/usage.ts` for
|
|
6
|
+
* the on-disk shape/writer) under a repo root — both the repo's own usage
|
|
7
|
+
* dir (direct, non-orchestrated runs) and every `.worktrees/<name>/` worker
|
|
8
|
+
* worktree (orchestrated runs) — and, when `.worktrees/.orchestrator-state.json`
|
|
9
|
+
* exists, folds in the round-level rollup (per-group usage + any group
|
|
10
|
+
* currently `status: "blocked"`, surfacing the pending question).
|
|
11
|
+
*
|
|
12
|
+
* `readSessionEvents` (the Phase 1 live-monitoring feed) reads a session's
|
|
13
|
+
* `<workspaceRoot>/.headlesscode/events/<sessionId>.jsonl` (see
|
|
14
|
+
* `src/engine/events.ts` for the on-disk shape/writer) from the repo root's
|
|
15
|
+
* own events dir OR any `.worktrees/<name>/` events dir — the same dual
|
|
16
|
+
* search `buildSummary` performs for usage files.
|
|
17
|
+
*
|
|
18
|
+
* Deliberately pure + fs-only: no `node:http` here, so it's unit-testable
|
|
19
|
+
* without spinning up the dashboard server (see `src/dashboard/server.ts`
|
|
20
|
+
* for the HTTP wrapper that calls `buildSummary`).
|
|
21
|
+
*
|
|
22
|
+
* Validation is loose (matches the project's established ethos — plain fs +
|
|
23
|
+
* JSON, no schema library): missing/malformed files are skipped, never
|
|
24
|
+
* thrown from `buildSummary`.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import * as fs from "node:fs/promises"
|
|
28
|
+
import * as path from "node:path"
|
|
29
|
+
|
|
30
|
+
import { eventsFilePath, readEventsFile, type EventRecord } from "../engine/events.js"
|
|
31
|
+
import { readLiveUsageFile, readUsageFile, type LiveUsageRecord, type UsageRecord } from "../engine/usage.js"
|
|
32
|
+
import { loadState, type OrchestratorGroup, type OrchestratorState } from "../orchestrator/state.js"
|
|
33
|
+
|
|
34
|
+
/** One row in the dashboard's per-session table. */
|
|
35
|
+
export interface SessionRow {
|
|
36
|
+
sessionId: string
|
|
37
|
+
mode: string
|
|
38
|
+
model: string
|
|
39
|
+
iterations: number
|
|
40
|
+
inputTokens: number
|
|
41
|
+
outputTokens: number
|
|
42
|
+
/** Subset of inputTokens served from the provider's prompt cache (0 when unreported). */
|
|
43
|
+
cachedTokens: number
|
|
44
|
+
costUsd: number
|
|
45
|
+
startedAt: string
|
|
46
|
+
endedAt: string
|
|
47
|
+
status: string
|
|
48
|
+
workspaceRoot: string
|
|
49
|
+
/** Which worktree/usage dir this record was found under (relative to repo, for display). */
|
|
50
|
+
source: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface UsageTotals {
|
|
54
|
+
costUsd: number
|
|
55
|
+
inputTokens: number
|
|
56
|
+
outputTokens: number
|
|
57
|
+
cachedTokens: number
|
|
58
|
+
iterations: number
|
|
59
|
+
sessionCount: number
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A group currently blocked on `ask_followup_question`, surfaced for display. */
|
|
63
|
+
export interface BlockedGroup {
|
|
64
|
+
name: string
|
|
65
|
+
question: string
|
|
66
|
+
suggestions?: string[]
|
|
67
|
+
askedAt?: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface RoundSummary {
|
|
71
|
+
batch?: string
|
|
72
|
+
updated?: string
|
|
73
|
+
totalUsage?: OrchestratorState["totalUsage"]
|
|
74
|
+
/** Issue #118: usage summed over only the current batch's groups (undefined when none yet). */
|
|
75
|
+
batchUsage?: OrchestratorState["batchUsage"]
|
|
76
|
+
groups: OrchestratorGroup[]
|
|
77
|
+
blocked: BlockedGroup[]
|
|
78
|
+
/** Issue #13 preflight probe result (present once orchestrate ran it). */
|
|
79
|
+
preflight?: OrchestratorState["preflight"]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface DashboardSummary {
|
|
83
|
+
generatedAt: string
|
|
84
|
+
repo?: string
|
|
85
|
+
sessions: SessionRow[]
|
|
86
|
+
totals: UsageTotals
|
|
87
|
+
round?: RoundSummary
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function emptyTotals(): UsageTotals {
|
|
91
|
+
return { costUsd: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0, iterations: 0, sessionCount: 0 }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** List `*.jsonl` files directly inside `dir` (non-recursive); [] when missing/unreadable. */
|
|
95
|
+
async function listJsonlFiles(dir: string): Promise<string[]> {
|
|
96
|
+
try {
|
|
97
|
+
const entries = await fs.readdir(dir, { withFileTypes: true })
|
|
98
|
+
return entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => path.join(dir, e.name))
|
|
99
|
+
} catch {
|
|
100
|
+
return []
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** List `*.live.json` snapshot files directly inside `dir` (non-recursive); [] when missing/unreadable. */
|
|
105
|
+
async function listLiveJsonFiles(dir: string): Promise<string[]> {
|
|
106
|
+
try {
|
|
107
|
+
const entries = await fs.readdir(dir, { withFileTypes: true })
|
|
108
|
+
return entries.filter((e) => e.isFile() && e.name.endsWith(".live.json")).map((e) => path.join(dir, e.name))
|
|
109
|
+
} catch {
|
|
110
|
+
return []
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Map one usage record (completed or live) to a dashboard session row. */
|
|
115
|
+
function toSessionRow(r: UsageRecord | LiveUsageRecord, source: string): SessionRow {
|
|
116
|
+
return {
|
|
117
|
+
sessionId: r.sessionId,
|
|
118
|
+
mode: r.mode,
|
|
119
|
+
model: r.model,
|
|
120
|
+
iterations: r.iterations ?? 0,
|
|
121
|
+
inputTokens: r.inputTokens ?? 0,
|
|
122
|
+
outputTokens: r.outputTokens ?? 0,
|
|
123
|
+
cachedTokens: r.cachedTokens ?? 0,
|
|
124
|
+
costUsd: r.costUsd ?? 0,
|
|
125
|
+
startedAt: r.startedAt,
|
|
126
|
+
// Live snapshots have no endedAt (the session is still running).
|
|
127
|
+
endedAt: "endedAt" in r ? r.endedAt : "",
|
|
128
|
+
status: r.status,
|
|
129
|
+
workspaceRoot: r.workspaceRoot,
|
|
130
|
+
source,
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Read every usage record under `<root>/.headlesscode/usage` — both completed
|
|
136
|
+
* `*.jsonl` records and live `*.live.json` snapshots — tagged with `source`.
|
|
137
|
+
*/
|
|
138
|
+
async function readUsageDir(root: string, source: string): Promise<SessionRow[]> {
|
|
139
|
+
const dir = path.join(root, ".headlesscode", "usage")
|
|
140
|
+
const rows: SessionRow[] = []
|
|
141
|
+
|
|
142
|
+
// Completed sessions: one JSONL file per session (append-only format).
|
|
143
|
+
for (const file of await listJsonlFiles(dir)) {
|
|
144
|
+
let records: UsageRecord[]
|
|
145
|
+
try {
|
|
146
|
+
records = await readUsageFile(file)
|
|
147
|
+
} catch {
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
for (const r of records) {
|
|
151
|
+
rows.push(toSessionRow(r, source))
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Live (in-progress) sessions: one overwritten JSON snapshot per session.
|
|
156
|
+
for (const file of await listLiveJsonFiles(dir)) {
|
|
157
|
+
let live: LiveUsageRecord | null
|
|
158
|
+
try {
|
|
159
|
+
live = await readLiveUsageFile(file)
|
|
160
|
+
} catch {
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
if (live) {
|
|
164
|
+
rows.push(toSessionRow(live, source))
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return rows
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Sum a set of session rows into totals. */
|
|
171
|
+
export function sumSessions(rows: SessionRow[]): UsageTotals {
|
|
172
|
+
const totals = emptyTotals()
|
|
173
|
+
for (const row of rows) {
|
|
174
|
+
totals.costUsd += row.costUsd
|
|
175
|
+
totals.inputTokens += row.inputTokens
|
|
176
|
+
totals.outputTokens += row.outputTokens
|
|
177
|
+
totals.cachedTokens += row.cachedTokens
|
|
178
|
+
totals.iterations += row.iterations
|
|
179
|
+
totals.sessionCount += 1
|
|
180
|
+
}
|
|
181
|
+
return totals
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Locate the events feed file for one session id, searching the repo root's
|
|
186
|
+
* own events dir AND every `.worktrees/<name>/` worktree events dir (the same
|
|
187
|
+
* dual search `buildSummary` performs for usage files). Returns the file path
|
|
188
|
+
* + a display `source` label, or undefined when no feed exists anywhere.
|
|
189
|
+
*/
|
|
190
|
+
export async function findSessionEventsFile(
|
|
191
|
+
repo: string,
|
|
192
|
+
sessionId: string,
|
|
193
|
+
): Promise<{ file: string; source: string } | undefined> {
|
|
194
|
+
const repoRoot = path.resolve(repo)
|
|
195
|
+
|
|
196
|
+
// 1. Direct (non-orchestrated) sessions run against the repo root itself.
|
|
197
|
+
const rootFile = eventsFilePath(repoRoot, sessionId)
|
|
198
|
+
try {
|
|
199
|
+
await fs.access(rootFile)
|
|
200
|
+
return { file: rootFile, source: "." }
|
|
201
|
+
} catch {
|
|
202
|
+
// Not here — check worktrees.
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// 2. Orchestrated worker sessions, one events dir per worktree.
|
|
206
|
+
const worktreesDir = path.join(repoRoot, ".worktrees")
|
|
207
|
+
let worktreeNames: string[] = []
|
|
208
|
+
try {
|
|
209
|
+
const entries = await fs.readdir(worktreesDir, { withFileTypes: true })
|
|
210
|
+
worktreeNames = entries.filter((e) => e.isDirectory()).map((e) => e.name)
|
|
211
|
+
} catch {
|
|
212
|
+
worktreeNames = []
|
|
213
|
+
}
|
|
214
|
+
for (const name of worktreeNames) {
|
|
215
|
+
const file = eventsFilePath(path.join(worktreesDir, name), sessionId)
|
|
216
|
+
try {
|
|
217
|
+
await fs.access(file)
|
|
218
|
+
return { file, source: `.worktrees/${name}` }
|
|
219
|
+
} catch {
|
|
220
|
+
// Keep looking.
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return undefined
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Read a session's structured event feed, optionally starting from a byte
|
|
228
|
+
* offset (`sinceOffset` — the incremental "give me only what's new since I
|
|
229
|
+
* last asked" polling idea, like `tail -f`). Returns the new events plus the
|
|
230
|
+
* next offset to pass on the following poll.
|
|
231
|
+
*
|
|
232
|
+
* Pure + fs-only, never throws for a missing feed: a session with no events
|
|
233
|
+
* file yet (or already cleaned up) yields `{ events: [], nextOffset: 0 }`.
|
|
234
|
+
* Loose validation is inherited from `readEventsFile` (malformed /
|
|
235
|
+
* partially-written trailing lines are skipped).
|
|
236
|
+
*/
|
|
237
|
+
export async function readSessionEvents(
|
|
238
|
+
repo: string,
|
|
239
|
+
sessionId: string,
|
|
240
|
+
sinceOffset = 0,
|
|
241
|
+
): Promise<{ events: EventRecord[]; nextOffset: number }> {
|
|
242
|
+
const found = await findSessionEventsFile(repo, sessionId)
|
|
243
|
+
if (!found) {
|
|
244
|
+
return { events: [], nextOffset: 0 }
|
|
245
|
+
}
|
|
246
|
+
const events = await readEventsFile(found.file)
|
|
247
|
+
// Byte-offset semantics (tail -f): a line is considered consumed only
|
|
248
|
+
// when its END (start + encoded length, newline included) lies at or
|
|
249
|
+
// before the offset — a partially-appended line (the caller's offset
|
|
250
|
+
// lands mid-line) is returned whole. Lines after the consumed prefix are
|
|
251
|
+
// returned; nextOffset is the byte offset just past the last returned
|
|
252
|
+
// line, so the next poll passes it back as sinceOffset.
|
|
253
|
+
let fileSize = 0
|
|
254
|
+
try {
|
|
255
|
+
fileSize = (await fs.stat(found.file)).size
|
|
256
|
+
} catch {
|
|
257
|
+
fileSize = 0
|
|
258
|
+
}
|
|
259
|
+
// The offset never exceeds the current file size, so a shrunk/recreated
|
|
260
|
+
// file degrades to "read from the start".
|
|
261
|
+
const safeOffset = Math.min(Math.max(0, sinceOffset), fileSize)
|
|
262
|
+
const encoded = events.map((e) => JSON.stringify(e) + "\n")
|
|
263
|
+
let startIndex = 0
|
|
264
|
+
let bytesSeen = 0
|
|
265
|
+
for (let i = 0; i < encoded.length; i++) {
|
|
266
|
+
const lineBytes = Buffer.byteLength(encoded[i])
|
|
267
|
+
if (safeOffset < bytesSeen + lineBytes) {
|
|
268
|
+
// This line is not fully consumed — it (and everything after) is new.
|
|
269
|
+
startIndex = i
|
|
270
|
+
break
|
|
271
|
+
}
|
|
272
|
+
bytesSeen += lineBytes
|
|
273
|
+
startIndex = i + 1
|
|
274
|
+
}
|
|
275
|
+
const newEvents = events.slice(startIndex)
|
|
276
|
+
const newBytes = encoded.slice(startIndex).reduce((acc, line) => acc + Buffer.byteLength(line), 0)
|
|
277
|
+
return { events: newEvents, nextOffset: safeOffset + newBytes }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Build the dashboard's summary for one repo (or none — sessions/totals are
|
|
282
|
+
* simply empty). Never throws: every fs read is defensive (missing dir,
|
|
283
|
+
* malformed JSON, missing state file all degrade gracefully).
|
|
284
|
+
*/
|
|
285
|
+
export async function buildSummary(repo?: string): Promise<DashboardSummary> {
|
|
286
|
+
if (!repo) {
|
|
287
|
+
return { generatedAt: new Date().toISOString(), sessions: [], totals: emptyTotals() }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const repoRoot = path.resolve(repo)
|
|
291
|
+
const sessions: SessionRow[] = []
|
|
292
|
+
|
|
293
|
+
// 1. Direct (non-orchestrated) sessions run against the repo root itself.
|
|
294
|
+
sessions.push(...(await readUsageDir(repoRoot, ".")))
|
|
295
|
+
|
|
296
|
+
// 2. Orchestrated worker sessions, one usage dir per worktree.
|
|
297
|
+
const worktreesDir = path.join(repoRoot, ".worktrees")
|
|
298
|
+
let worktreeNames: string[] = []
|
|
299
|
+
try {
|
|
300
|
+
const entries = await fs.readdir(worktreesDir, { withFileTypes: true })
|
|
301
|
+
worktreeNames = entries.filter((e) => e.isDirectory()).map((e) => e.name)
|
|
302
|
+
} catch {
|
|
303
|
+
worktreeNames = []
|
|
304
|
+
}
|
|
305
|
+
for (const name of worktreeNames) {
|
|
306
|
+
sessions.push(...(await readUsageDir(path.join(worktreesDir, name), `.worktrees/${name}`)))
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// 3. Round-level rollup from .orchestrator-state.json, when present.
|
|
310
|
+
let round: RoundSummary | undefined
|
|
311
|
+
const statePath = path.join(worktreesDir, ".orchestrator-state.json")
|
|
312
|
+
try {
|
|
313
|
+
await fs.stat(statePath)
|
|
314
|
+
const state = await loadState(statePath)
|
|
315
|
+
const blocked: BlockedGroup[] = state.groups
|
|
316
|
+
.filter((g) => g.status === "blocked" && g.blocked?.question)
|
|
317
|
+
.map((g) => ({
|
|
318
|
+
name: g.name,
|
|
319
|
+
question: g.blocked?.question ?? "",
|
|
320
|
+
suggestions: g.blocked?.suggestions,
|
|
321
|
+
askedAt: g.blocked?.askedAt,
|
|
322
|
+
}))
|
|
323
|
+
round = {
|
|
324
|
+
batch: state.batch,
|
|
325
|
+
updated: state.updated,
|
|
326
|
+
totalUsage: state.totalUsage,
|
|
327
|
+
batchUsage: state.batchUsage,
|
|
328
|
+
groups: state.groups,
|
|
329
|
+
blocked,
|
|
330
|
+
preflight: state.preflight,
|
|
331
|
+
}
|
|
332
|
+
} catch {
|
|
333
|
+
round = undefined
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Gap 1: merge live snapshots with completed records. If both a
|
|
337
|
+
// `.live.json` and a completed `.jsonl` record exist for the same session
|
|
338
|
+
// id (a narrow race at the exact moment of completion), the completed
|
|
339
|
+
// record wins — never double-count or show duplicates. Duplicate completed
|
|
340
|
+
// records (same id, multiple jsonl lines) keep their prior behavior.
|
|
341
|
+
const deduped: SessionRow[] = []
|
|
342
|
+
const bySessionId = new Map<string, number>()
|
|
343
|
+
for (const row of sessions) {
|
|
344
|
+
const idx = bySessionId.get(row.sessionId)
|
|
345
|
+
if (idx === undefined) {
|
|
346
|
+
bySessionId.set(row.sessionId, deduped.length)
|
|
347
|
+
deduped.push(row)
|
|
348
|
+
} else if (deduped[idx].status === "running" && row.status !== "running") {
|
|
349
|
+
// A completed .jsonl record takes precedence over a stale live snapshot.
|
|
350
|
+
deduped[idx] = row
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Deterministic ordering: most recently started session first.
|
|
355
|
+
deduped.sort((a, b) => (a.startedAt < b.startedAt ? 1 : a.startedAt > b.startedAt ? -1 : 0))
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
generatedAt: new Date().toISOString(),
|
|
359
|
+
repo: repoRoot,
|
|
360
|
+
sessions: deduped,
|
|
361
|
+
totals: sumSessions(deduped),
|
|
362
|
+
round,
|
|
363
|
+
}
|
|
364
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat-thread rendering support for the dashboard's session detail view —
|
|
3
|
+
* groups the flat chronological event feed (the same data served by
|
|
4
|
+
* `GET /api/session/:id/events`) into conversation "turns" so the page can
|
|
5
|
+
* render it as a chat thread (assistant bubble + inline tool calls + results)
|
|
6
|
+
* instead of a flat event log.
|
|
7
|
+
*
|
|
8
|
+
* This module is deliberately pure (no DOM, no fetch): the grouping is the
|
|
9
|
+
* one piece of the feature that's worth unit-testing, and keeping it here
|
|
10
|
+
* lets `src/dashboard/__tests__/chat-thread.test.ts` exercise it with fixture
|
|
11
|
+
* event arrays. All DOM work stays in `src/dashboard/page.ts`'s inline
|
|
12
|
+
* vanilla JS, matching that page's no-framework style.
|
|
13
|
+
*
|
|
14
|
+
* Grouping rules (per the existing event ordering in `src/engine/events.ts`):
|
|
15
|
+
* - An assistant "turn" STARTS at an `iteration_start` event (a fresh
|
|
16
|
+
* iteration = a fresh assistant turn).
|
|
17
|
+
* - The `llm_response` event and the `tool_call`/`tool_result` pairs that
|
|
18
|
+
* follow it belong to that turn, until the next `iteration_start`.
|
|
19
|
+
* - `tool_result` events are attached to the preceding `tool_call` in the
|
|
20
|
+
* same turn (tool name + same iteration), forming a call/result pair.
|
|
21
|
+
* - `decision_blocked` starts a turn (a new question the model asked the
|
|
22
|
+
* human); the matching `decision_answered` is rendered as an inline
|
|
23
|
+
* system marker within that same turn.
|
|
24
|
+
* - `llm_stream_chunk` events belong to the current turn (streamed chunks
|
|
25
|
+
* arrive BETWEEN iteration_start and llm_response). They must NOT close
|
|
26
|
+
* the turn — the turn would fragment into one system marker per chunk.
|
|
27
|
+
* - Everything else (`checkpoint_saved`, `paused`, `resumed`, `llm_error`,
|
|
28
|
+
* `session_end`, …) is a system-level marker rendered as a small inline
|
|
29
|
+
* chip between turns, not a chat bubble.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import type { EventRecord } from "../engine/events.js"
|
|
33
|
+
|
|
34
|
+
/** The chat-relevant payloads extracted from a flat event feed. */
|
|
35
|
+
export type ChatBlock =
|
|
36
|
+
| { kind: "system"; event: EventRecord }
|
|
37
|
+
| { kind: "turn"; iteration?: number; turnStart: EventRecord; events: EventRecord[] }
|
|
38
|
+
|
|
39
|
+
export interface ChatGrouping {
|
|
40
|
+
/** Message list (the user's original task), only present when the feed has a session_start. */
|
|
41
|
+
task?: string
|
|
42
|
+
/** The blocks in feed order: turns + system markers interleaved. */
|
|
43
|
+
blocks: ChatBlock[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The user's task text from a session_start event ("" when absent). */
|
|
47
|
+
function taskFromSessionStart(e: EventRecord): string {
|
|
48
|
+
if (e.type !== "session_start") return ""
|
|
49
|
+
return typeof e.task === "string" ? e.task : ""
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A tool_call event's short argument summary — mirrors loop.ts's summarizeToolArg style. */
|
|
53
|
+
export function summarizeArg(e: EventRecord): string {
|
|
54
|
+
if (e.type !== "tool_call") return ""
|
|
55
|
+
const raw = typeof e.args === "string" ? e.args : ""
|
|
56
|
+
// The feed already carries a server-side short summary (path/command), so
|
|
57
|
+
// this is a belt-and-braces re-summary for robustness only.
|
|
58
|
+
const trimmed = raw.trim()
|
|
59
|
+
if (trimmed.length <= 80) return trimmed
|
|
60
|
+
return trimmed.slice(0, 80) + "…"
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Group a flat, chronological event array into chat blocks. Pure: no DOM, no
|
|
65
|
+
* I/O, no mutation of the input. Malformed/unknown event types fall through
|
|
66
|
+
* to system markers (the flat log remains the source of truth for anything
|
|
67
|
+
* this grouping doesn't understand).
|
|
68
|
+
*/
|
|
69
|
+
export function groupEventsIntoChat(events: EventRecord[]): ChatGrouping {
|
|
70
|
+
const blocks: ChatBlock[] = []
|
|
71
|
+
let task = ""
|
|
72
|
+
let current: { iteration?: number; turnStart: EventRecord; events: EventRecord[] } | undefined
|
|
73
|
+
|
|
74
|
+
const closeTurn = (): void => {
|
|
75
|
+
if (current) {
|
|
76
|
+
blocks.push({ kind: "turn", iteration: current.iteration, turnStart: current.turnStart, events: current.events })
|
|
77
|
+
current = undefined
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const e of events) {
|
|
82
|
+
switch (e.type) {
|
|
83
|
+
case "session_start": {
|
|
84
|
+
task = taskFromSessionStart(e)
|
|
85
|
+
blocks.push({ kind: "system", event: e })
|
|
86
|
+
break
|
|
87
|
+
}
|
|
88
|
+
case "iteration_start":
|
|
89
|
+
closeTurn()
|
|
90
|
+
current = { iteration: e.iteration, turnStart: e, events: [] }
|
|
91
|
+
break
|
|
92
|
+
case "decision_blocked":
|
|
93
|
+
// A new question = a new "turn" (it's a fresh user→assistant
|
|
94
|
+
// exchange); the answer arrives as a later decision_answered.
|
|
95
|
+
closeTurn()
|
|
96
|
+
current = { iteration: e.iteration, turnStart: e, events: [] }
|
|
97
|
+
break
|
|
98
|
+
case "tool_result": {
|
|
99
|
+
// Attach to the preceding tool_call in the same turn, if any.
|
|
100
|
+
if (current && current.events.length > 0) {
|
|
101
|
+
const last = current.events[current.events.length - 1]
|
|
102
|
+
if (last.type === "tool_call") {
|
|
103
|
+
current.events.push(e)
|
|
104
|
+
break
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Orphaned result (no call in the current turn): system marker.
|
|
108
|
+
blocks.push({ kind: "system", event: e })
|
|
109
|
+
break
|
|
110
|
+
}
|
|
111
|
+
case "session_end":
|
|
112
|
+
case "llm_error":
|
|
113
|
+
case "checkpoint_saved":
|
|
114
|
+
case "paused":
|
|
115
|
+
case "resumed":
|
|
116
|
+
closeTurn()
|
|
117
|
+
blocks.push({ kind: "system", event: e })
|
|
118
|
+
break
|
|
119
|
+
case "llm_response":
|
|
120
|
+
case "tool_call":
|
|
121
|
+
case "decision_answered":
|
|
122
|
+
case "llm_stream_chunk":
|
|
123
|
+
// Belongs to the current turn when there is one; otherwise a
|
|
124
|
+
// lone marker (a feed that starts mid-session).
|
|
125
|
+
if (current) {
|
|
126
|
+
current.events.push(e)
|
|
127
|
+
} else {
|
|
128
|
+
blocks.push({ kind: "system", event: e })
|
|
129
|
+
}
|
|
130
|
+
break
|
|
131
|
+
default:
|
|
132
|
+
// Unknown type: keep it as a system marker rather than hiding it.
|
|
133
|
+
closeTurn()
|
|
134
|
+
blocks.push({ kind: "system", event: e })
|
|
135
|
+
break
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
closeTurn()
|
|
139
|
+
|
|
140
|
+
return { task: task || undefined, blocks }
|
|
141
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser control plane — checkpoint list/diff/restore, as a thin HTTP-agnostic
|
|
3
|
+
* wrapper around `src/checkpoints/service.ts` (the same way
|
|
4
|
+
* `src/dashboard/session-launch.ts` wraps the session-launch logic).
|
|
5
|
+
*
|
|
6
|
+
* The dashboard routes pass `repo` (the workspace whose files the shadow git
|
|
7
|
+
* repo tracks) + `session` (the session/task id — HeadlessSession uses its
|
|
8
|
+
* sessionId as the checkpoint service's taskId, see src/engine/loop.ts) and
|
|
9
|
+
* this module does the rest: resolve the service against the default shadow
|
|
10
|
+
* storage root, init it, and delegate list/diff/restore.
|
|
11
|
+
*
|
|
12
|
+
* `renderUnifiedDiff` turns the service's `CheckpointDiff[]` (one entry per
|
|
13
|
+
* changed file, each with before/after full content) into a simple
|
|
14
|
+
* unified-diff-style text block — the same plain-text ethos the CLI uses, no
|
|
15
|
+
* diff library. This is a deliberate UI-only rendering choice; nothing here
|
|
16
|
+
* reimplements shadow-git diffing.
|
|
17
|
+
*
|
|
18
|
+
* Restore is DESTRUCTIVE (reverts real workspace files) — the HTTP layer
|
|
19
|
+
* gates it behind the optional bearer token, and the UI requires an explicit
|
|
20
|
+
* confirmation step before calling it. This module just performs the call.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import * as path from "node:path"
|
|
24
|
+
|
|
25
|
+
import { createCheckpointService, defaultCheckpointDir, type CheckpointLogEntry } from "../checkpoints/service.js"
|
|
26
|
+
import type { CheckpointDiff } from "../vendor/zoo-code/src/services/checkpoints/types.js"
|
|
27
|
+
|
|
28
|
+
/** Options for resolving a checkpoint service for one session. */
|
|
29
|
+
export interface CheckpointRouteOptions {
|
|
30
|
+
/** Workspace root whose files the shadow repo tracks (the dashboard's repo param). */
|
|
31
|
+
workspaceRoot: string
|
|
32
|
+
/** Session/task id (the checkpoint service's taskId). */
|
|
33
|
+
sessionId: string
|
|
34
|
+
/** Shadow-git storage root override (default: defaultCheckpointDir()). Test-only. */
|
|
35
|
+
checkpointDir?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolve + initialize the checkpoint service for a session. Throws on
|
|
40
|
+
* invalid input (e.g. checkpointDir nested inside workspaceRoot) — the HTTP
|
|
41
|
+
* layer surfaces the message as an error response.
|
|
42
|
+
*/
|
|
43
|
+
export async function resolveCheckpointService(
|
|
44
|
+
options: CheckpointRouteOptions,
|
|
45
|
+
): Promise<ReturnType<typeof createCheckpointService>> {
|
|
46
|
+
const checkpointDir = options.checkpointDir
|
|
47
|
+
? path.resolve(options.checkpointDir)
|
|
48
|
+
: defaultCheckpointDir()
|
|
49
|
+
const service = createCheckpointService({
|
|
50
|
+
taskId: options.sessionId,
|
|
51
|
+
workspaceRoot: path.resolve(options.workspaceRoot),
|
|
52
|
+
checkpointDir,
|
|
53
|
+
})
|
|
54
|
+
await service.init()
|
|
55
|
+
return service
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** List checkpoints oldest-first (message, date, hash). */
|
|
59
|
+
export async function listCheckpoints(options: CheckpointRouteOptions): Promise<CheckpointLogEntry[]> {
|
|
60
|
+
const service = await resolveCheckpointService(options)
|
|
61
|
+
return service.list()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Diff between two checkpoints, or a checkpoint and the current working tree (to omitted). */
|
|
65
|
+
export async function diffCheckpoints(
|
|
66
|
+
options: CheckpointRouteOptions,
|
|
67
|
+
from: string,
|
|
68
|
+
to?: string,
|
|
69
|
+
): Promise<CheckpointDiff[]> {
|
|
70
|
+
const service = await resolveCheckpointService(options)
|
|
71
|
+
return service.diff({ from, to })
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Restore a checkpoint — reverts REAL workspace files (destructive). */
|
|
75
|
+
export async function restoreCheckpoint(options: CheckpointRouteOptions, hash: string): Promise<void> {
|
|
76
|
+
const service = await resolveCheckpointService(options)
|
|
77
|
+
await service.restore(hash)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Render `CheckpointDiff[]` as a plain unified-diff-style text block:
|
|
82
|
+
*
|
|
83
|
+
* --- a/src/greet.js
|
|
84
|
+
* +++ b/src/greet.js
|
|
85
|
+
* @@ src/greet.js @@
|
|
86
|
+
* -// BUG: old line
|
|
87
|
+
* +// fixed line
|
|
88
|
+
*
|
|
89
|
+
* Line-level `-`/`+` marking requires the before/after content line counts to
|
|
90
|
+
* align for readability, so this does a trivial line-by-line prefix (before
|
|
91
|
+
* lines get `-`, after lines get `+`, padding the shorter side with a blank
|
|
92
|
+
* marker) — deliberately NOT a real diff algorithm. Good enough for a plain
|
|
93
|
+
* dashboard; matches the project's zero-dependency ethos.
|
|
94
|
+
*/
|
|
95
|
+
export function renderUnifiedDiff(changes: CheckpointDiff[]): string {
|
|
96
|
+
if (changes.length === 0) {
|
|
97
|
+
return "(no changes)"
|
|
98
|
+
}
|
|
99
|
+
const blocks: string[] = []
|
|
100
|
+
for (const change of changes) {
|
|
101
|
+
const rel = change.paths.relative
|
|
102
|
+
const before = change.content.before.split("\n")
|
|
103
|
+
const after = change.content.after.split("\n")
|
|
104
|
+
// split() leaves a trailing "" when content ends with \n — drop it so
|
|
105
|
+
// the marker count matches the actual displayed lines.
|
|
106
|
+
if (before.length > 0 && before[before.length - 1] === "") before.pop()
|
|
107
|
+
if (after.length > 0 && after[after.length - 1] === "") after.pop()
|
|
108
|
+
|
|
109
|
+
const width = Math.max(before.length, after.length)
|
|
110
|
+
const lines: string[] = []
|
|
111
|
+
for (let i = 0; i < width; i++) {
|
|
112
|
+
const b = i < before.length ? before[i] : ""
|
|
113
|
+
const a = i < after.length ? after[i] : ""
|
|
114
|
+
if (b !== a) {
|
|
115
|
+
if (b !== "") lines.push("-" + b)
|
|
116
|
+
if (a !== "") lines.push("+" + a)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
blocks.push(
|
|
120
|
+
`--- a/${rel}\n+++ b/${rel}\n@@ ${rel} @@\n` + (lines.length > 0 ? lines.join("\n") : "(identical content)"),
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
return blocks.join("\n\n")
|
|
124
|
+
}
|