@archastro/astroshot-review 0.2.1
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/LICENSE +21 -0
- package/README.md +19 -0
- package/bin/astroshot-review.mjs +17 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.js +240 -0
- package/dist/data/friction.d.ts +22 -0
- package/dist/data/friction.js +278 -0
- package/dist/data/hash-cache.d.ts +17 -0
- package/dist/data/hash-cache.js +51 -0
- package/dist/data/index-cache.d.ts +22 -0
- package/dist/data/index-cache.js +64 -0
- package/dist/data/manifest.d.ts +41 -0
- package/dist/data/manifest.js +105 -0
- package/dist/data/model.d.ts +96 -0
- package/dist/data/model.js +1 -0
- package/dist/data/paths.d.ts +27 -0
- package/dist/data/paths.js +98 -0
- package/dist/data/review-store.d.ts +67 -0
- package/dist/data/review-store.js +237 -0
- package/dist/data/scan.d.ts +32 -0
- package/dist/data/scan.js +227 -0
- package/dist/data/store.d.ts +92 -0
- package/dist/data/store.js +408 -0
- package/dist/data/watcher.d.ts +37 -0
- package/dist/data/watcher.js +126 -0
- package/dist/images/halfblocks.d.ts +10 -0
- package/dist/images/halfblocks.js +21 -0
- package/dist/images/png.d.ts +14 -0
- package/dist/images/png.js +45 -0
- package/dist/images/scale.d.ts +31 -0
- package/dist/images/scale.js +102 -0
- package/dist/images/service.d.ts +54 -0
- package/dist/images/service.js +163 -0
- package/dist/images/worker.d.ts +21 -0
- package/dist/images/worker.js +30 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +9 -0
- package/dist/terminal/graphics-stdout.d.ts +9 -0
- package/dist/terminal/graphics-stdout.js +36 -0
- package/dist/terminal/herdr.d.ts +61 -0
- package/dist/terminal/herdr.js +327 -0
- package/dist/terminal/image-layer.d.ts +122 -0
- package/dist/terminal/image-layer.js +471 -0
- package/dist/terminal/kitty.d.ts +74 -0
- package/dist/terminal/kitty.js +112 -0
- package/dist/terminal/probe.d.ts +49 -0
- package/dist/terminal/probe.js +206 -0
- package/dist/ui/app.d.ts +6 -0
- package/dist/ui/app.js +695 -0
- package/dist/ui/chrome.d.ts +53 -0
- package/dist/ui/chrome.js +69 -0
- package/dist/ui/context.d.ts +16 -0
- package/dist/ui/context.js +8 -0
- package/dist/ui/detail.d.ts +33 -0
- package/dist/ui/detail.js +39 -0
- package/dist/ui/friction.d.ts +42 -0
- package/dist/ui/friction.js +84 -0
- package/dist/ui/help.d.ts +4 -0
- package/dist/ui/help.js +61 -0
- package/dist/ui/hooks.d.ts +9 -0
- package/dist/ui/hooks.js +32 -0
- package/dist/ui/movie-player.d.ts +29 -0
- package/dist/ui/movie-player.js +119 -0
- package/dist/ui/picture.d.ts +19 -0
- package/dist/ui/picture.js +100 -0
- package/dist/ui/selectors.d.ts +26 -0
- package/dist/ui/selectors.js +69 -0
- package/dist/ui/settings.d.ts +5 -0
- package/dist/ui/settings.js +17 -0
- package/dist/ui/stream.d.ts +38 -0
- package/dist/ui/stream.js +118 -0
- package/dist/ui/system.d.ts +4 -0
- package/dist/ui/system.js +34 -0
- package/dist/ui/takeover.d.ts +26 -0
- package/dist/ui/takeover.js +29 -0
- package/dist/ui/text-input.d.ts +8 -0
- package/dist/ui/text-input.js +74 -0
- package/dist/ui/theme.d.ts +21 -0
- package/dist/ui/theme.js +63 -0
- package/dist/video/ffmpeg.d.ts +54 -0
- package/dist/video/ffmpeg.js +206 -0
- package/package.json +71 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ArchAstro
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# @archastro/astroshot-review
|
|
2
|
+
|
|
3
|
+
The Astroshots review tray in your terminal. Streams `.astroshot/` captures
|
|
4
|
+
across worktrees newest-first, draws them with the Kitty graphics protocol,
|
|
5
|
+
plays movies through ffmpeg, and writes the same `review.json` Seen state and
|
|
6
|
+
feedback as the macOS app.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npx astroshot review # via the unified CLI (recommended)
|
|
10
|
+
npx astroshot-review ~/projects # this package's own bin
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
- Terminals: Ghostty, kitty, WezTerm (anything with the Kitty graphics protocol).
|
|
14
|
+
Others get labeled placeholders.
|
|
15
|
+
- Movies need `ffmpeg` on `PATH`.
|
|
16
|
+
- Keys: `↑↓` move · `⏎` open · `f` full screen · `s` seen · `c` feedback ·
|
|
17
|
+
`u` history · `m` movies · `1`/`2` tabs · `,` settings · `?` help · `q` quit.
|
|
18
|
+
|
|
19
|
+
Full guide: [docs/review-tui.md](https://github.com/ArchAstro/astroshots/blob/main/docs/review-tui.md).
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { main } from "../dist/cli.js";
|
|
3
|
+
|
|
4
|
+
main(process.argv.slice(2)).then(
|
|
5
|
+
(code) => {
|
|
6
|
+
process.exitCode = code;
|
|
7
|
+
// main() has torn the tray down, so the event loop should drain and the
|
|
8
|
+
// process exit on its own. If a stray handle is still referenced (a
|
|
9
|
+
// socket, a pipe), don't sit invisibly in the shell forever: exit. The
|
|
10
|
+
// timer is unref'd, so it never keeps the loop alive itself.
|
|
11
|
+
setTimeout(() => process.exit(code), 1000).unref();
|
|
12
|
+
},
|
|
13
|
+
(error) => {
|
|
14
|
+
console.error(error instanceof Error ? error.message : error);
|
|
15
|
+
process.exitCode = 1;
|
|
16
|
+
},
|
|
17
|
+
);
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare function reviewHelp(): string;
|
|
2
|
+
export interface ParsedArgs {
|
|
3
|
+
roots: string[];
|
|
4
|
+
graphics: boolean;
|
|
5
|
+
watch: boolean;
|
|
6
|
+
index: boolean;
|
|
7
|
+
help: boolean;
|
|
8
|
+
version: boolean;
|
|
9
|
+
rootsSource: "app" | "cli" | "cwd";
|
|
10
|
+
}
|
|
11
|
+
export declare function parseArgs(argv: string[]): ParsedArgs;
|
|
12
|
+
export declare function main(argv: string[]): Promise<number>;
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { render } from "ink";
|
|
4
|
+
import { createElement } from "react";
|
|
5
|
+
import { ReviewStore } from "./data/store.js";
|
|
6
|
+
import { ImageService } from "./images/service.js";
|
|
7
|
+
import { createGraphicsStdout } from "./terminal/graphics-stdout.js";
|
|
8
|
+
import { HerdrSink, discoverHerdr, herdrAddress, probeHerdrSet } from "./terminal/herdr.js";
|
|
9
|
+
import { ImageLayer } from "./terminal/image-layer.js";
|
|
10
|
+
import { probeTerminal } from "./terminal/probe.js";
|
|
11
|
+
import { App } from "./ui/app.js";
|
|
12
|
+
import { ServicesContext } from "./ui/context.js";
|
|
13
|
+
import { detectFfmpeg } from "./video/ffmpeg.js";
|
|
14
|
+
export function reviewHelp() {
|
|
15
|
+
return `astroshot review — the Astroshots tray in your terminal
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
astroshot review [<dir>...] [options]
|
|
19
|
+
|
|
20
|
+
Options:
|
|
21
|
+
--root <dir> Folder to watch for .astroshot/ trees (repeatable)
|
|
22
|
+
--no-graphics Skip terminal pictures (text only)
|
|
23
|
+
--no-watch Do not follow filesystem changes
|
|
24
|
+
--no-index Ignore the on-disk index (full scan every start)
|
|
25
|
+
-h, --help Show this help
|
|
26
|
+
|
|
27
|
+
Without roots, \`astroshot review\` uses the folders the Astroshots app
|
|
28
|
+
watches (macOS) and otherwise the current directory. Images render pixel-
|
|
29
|
+
perfect in Kitty-protocol terminals (Ghostty, kitty, WezTerm) and inside
|
|
30
|
+
herdr (enable [experimental] kitty_graphics and reattach the client once);
|
|
31
|
+
elsewhere — mosh, tmux, plain terminals — they render as truecolor
|
|
32
|
+
half-block text. Movie playback needs ffmpeg on PATH.
|
|
33
|
+
|
|
34
|
+
Keys: ↑↓ move · ⏎ open · f full screen · s seen · c feedback · u history ·
|
|
35
|
+
m movies · 1/2 tabs · , settings · ? help · q quit`;
|
|
36
|
+
}
|
|
37
|
+
export function parseArgs(argv) {
|
|
38
|
+
const parsed = { roots: [], graphics: true, watch: true, index: true, help: false, version: false, rootsSource: "cli" };
|
|
39
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
40
|
+
const argument = argv[index];
|
|
41
|
+
switch (argument) {
|
|
42
|
+
case "-h":
|
|
43
|
+
case "--help":
|
|
44
|
+
parsed.help = true;
|
|
45
|
+
break;
|
|
46
|
+
case "-v":
|
|
47
|
+
case "--version":
|
|
48
|
+
parsed.version = true;
|
|
49
|
+
break;
|
|
50
|
+
case "--no-graphics":
|
|
51
|
+
parsed.graphics = false;
|
|
52
|
+
break;
|
|
53
|
+
case "--no-watch":
|
|
54
|
+
parsed.watch = false;
|
|
55
|
+
break;
|
|
56
|
+
case "--no-index":
|
|
57
|
+
parsed.index = false;
|
|
58
|
+
break;
|
|
59
|
+
case "--root": {
|
|
60
|
+
const value = argv[index + 1];
|
|
61
|
+
if (!value)
|
|
62
|
+
throw new Error("--root requires a directory");
|
|
63
|
+
parsed.roots.push(value);
|
|
64
|
+
index += 1;
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
case "--roots-source": {
|
|
68
|
+
const value = argv[index + 1];
|
|
69
|
+
if (value !== "app" && value !== "cli" && value !== "cwd")
|
|
70
|
+
throw new Error("--roots-source must be app, cli, or cwd");
|
|
71
|
+
parsed.rootsSource = value;
|
|
72
|
+
index += 1;
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
default:
|
|
76
|
+
if (argument.startsWith("-"))
|
|
77
|
+
throw new Error(`Unknown option: ${argument}`);
|
|
78
|
+
parsed.roots.push(argument);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (parsed.roots.length === 0) {
|
|
82
|
+
parsed.roots = [process.cwd()];
|
|
83
|
+
parsed.rootsSource = "cwd";
|
|
84
|
+
}
|
|
85
|
+
return parsed;
|
|
86
|
+
}
|
|
87
|
+
function readVersion() {
|
|
88
|
+
try {
|
|
89
|
+
const packageJson = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
90
|
+
return packageJson.version ?? "0.0.0";
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return "0.0.0";
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export async function main(argv) {
|
|
97
|
+
let args;
|
|
98
|
+
try {
|
|
99
|
+
args = parseArgs(argv);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
103
|
+
console.error("");
|
|
104
|
+
console.error(reviewHelp());
|
|
105
|
+
return 1;
|
|
106
|
+
}
|
|
107
|
+
if (args.help) {
|
|
108
|
+
console.log(reviewHelp());
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
if (args.version) {
|
|
112
|
+
console.log(readVersion());
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
if (!process.stdout.isTTY || !process.stdin.isTTY) {
|
|
116
|
+
console.error("astroshot review needs an interactive terminal (stdin and stdout must be a TTY).");
|
|
117
|
+
return 1;
|
|
118
|
+
}
|
|
119
|
+
const roots = args.roots.map((root) => path.resolve(root)).filter((root) => {
|
|
120
|
+
try {
|
|
121
|
+
return fs.statSync(root).isDirectory();
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
console.error(`Ignoring missing folder: ${root}`);
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
const debugLog = process.env.ASTROSHOT_REVIEW_DEBUG
|
|
129
|
+
? (message) => fs.appendFileSync("astroshot-review.log", `${new Date().toISOString()} ${message}\n`)
|
|
130
|
+
: null;
|
|
131
|
+
let capabilities = await probeTerminal({ stdin: process.stdin, stdout: process.stdout }, { env: args.graphics ? process.env : { ...process.env, ASTROSHOT_REVIEW_GRAPHICS: "none" } });
|
|
132
|
+
// Inside herdr, raw Kitty escapes are dropped; render through its socket
|
|
133
|
+
// graphics API instead (pixel-perfect), falling back to half-blocks with an
|
|
134
|
+
// actionable reason when herdr can't yet report the host cell size.
|
|
135
|
+
let herdrSink;
|
|
136
|
+
const herdr = args.graphics ? herdrAddress() : null;
|
|
137
|
+
if (herdr) {
|
|
138
|
+
const discovery = await discoverHerdr(herdr, {
|
|
139
|
+
onWaiting: () => debugLog?.("herdr: waiting for host cell size"),
|
|
140
|
+
});
|
|
141
|
+
if (discovery.ok && discovery.cellWidth && discovery.cellHeight) {
|
|
142
|
+
const setSupport = await probeHerdrSet(herdr);
|
|
143
|
+
if (setSupport.ok) {
|
|
144
|
+
herdrSink = new HerdrSink(herdr, (error) => debugLog?.(`herdr: ${error.message}`));
|
|
145
|
+
capabilities = {
|
|
146
|
+
...capabilities,
|
|
147
|
+
graphics: "herdr",
|
|
148
|
+
cellWidth: discovery.cellWidth,
|
|
149
|
+
cellHeight: discovery.cellHeight,
|
|
150
|
+
cellSource: "query",
|
|
151
|
+
reason: undefined,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
// herdr can size images but not place per-image layers; half-blocks it is.
|
|
156
|
+
capabilities = { ...capabilities, reason: setSupport.reason ?? "herdr pane.graphics.set unavailable" };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
capabilities = { ...capabilities, reason: discovery.reason ?? capabilities.reason };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
debugLog?.(`capabilities ${JSON.stringify(capabilities)}`);
|
|
164
|
+
const ffmpeg = detectFfmpeg();
|
|
165
|
+
const service = new ImageService();
|
|
166
|
+
const layer = new ImageLayer({
|
|
167
|
+
capabilities,
|
|
168
|
+
service,
|
|
169
|
+
herdr: herdrSink,
|
|
170
|
+
write: (data) => {
|
|
171
|
+
process.stdout.write(data);
|
|
172
|
+
},
|
|
173
|
+
onError: (src, error) => debugLog?.(`image ${src}: ${error.message}`),
|
|
174
|
+
onDebug: debugLog ? (message) => debugLog(`layer ${message}`) : undefined,
|
|
175
|
+
});
|
|
176
|
+
const store = new ReviewStore({
|
|
177
|
+
roots,
|
|
178
|
+
watch: args.watch,
|
|
179
|
+
useIndex: args.index,
|
|
180
|
+
onLog: (message) => debugLog?.(`store ${message}`),
|
|
181
|
+
});
|
|
182
|
+
void store.start();
|
|
183
|
+
const stdout = createGraphicsStdout(process.stdout, layer);
|
|
184
|
+
const services = { store, layer, service, capabilities, ffmpeg, rootsSource: args.rootsSource, version: readVersion() };
|
|
185
|
+
let cleared = false;
|
|
186
|
+
const clearPictures = () => {
|
|
187
|
+
if (cleared)
|
|
188
|
+
return;
|
|
189
|
+
cleared = true;
|
|
190
|
+
const output = layer.clear();
|
|
191
|
+
if (output)
|
|
192
|
+
process.stdout.write(output);
|
|
193
|
+
herdrSink?.dispose();
|
|
194
|
+
};
|
|
195
|
+
const instance = render(createElement(ServicesContext.Provider, { value: services }, createElement(App, { onQuit: clearPictures })), {
|
|
196
|
+
stdout,
|
|
197
|
+
stdin: process.stdin,
|
|
198
|
+
alternateScreen: true,
|
|
199
|
+
exitOnCtrlC: true,
|
|
200
|
+
patchConsole: true,
|
|
201
|
+
// Ink treats CI=true as "not interactive": no alternate screen, no key
|
|
202
|
+
// handling, frames appended instead of redrawn. The tray already refuses
|
|
203
|
+
// to run without a TTY on both ends, so a TTY is the only signal that
|
|
204
|
+
// matters — CI-flavoured shells and CI pty harnesses get the real UI.
|
|
205
|
+
interactive: true,
|
|
206
|
+
});
|
|
207
|
+
// `kill <pid>` or a closing terminal must not leave pictures or the
|
|
208
|
+
// alternate screen behind; Ink only handles Ctrl+C itself.
|
|
209
|
+
const onSignal = (signal) => {
|
|
210
|
+
clearPictures();
|
|
211
|
+
try {
|
|
212
|
+
process.stdout.write("\x1b[?1049l\x1b[?25h");
|
|
213
|
+
if (process.stdin.isTTY)
|
|
214
|
+
process.stdin.setRawMode(false);
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
// The terminal may already be gone.
|
|
218
|
+
}
|
|
219
|
+
instance.unmount();
|
|
220
|
+
process.exit(signal === "SIGHUP" ? 129 : 143);
|
|
221
|
+
};
|
|
222
|
+
process.once("SIGTERM", onSignal);
|
|
223
|
+
process.once("SIGHUP", onSignal);
|
|
224
|
+
process.once("SIGQUIT", onSignal);
|
|
225
|
+
process.once("SIGINT", onSignal);
|
|
226
|
+
// Last-ditch: closing the herdr sockets makes herdr drop the layers even if
|
|
227
|
+
// we never got to run a signal handler.
|
|
228
|
+
process.once("exit", () => herdrSink?.dispose());
|
|
229
|
+
try {
|
|
230
|
+
await instance.waitUntilExit();
|
|
231
|
+
}
|
|
232
|
+
finally {
|
|
233
|
+
process.off("SIGTERM", onSignal);
|
|
234
|
+
process.off("SIGHUP", onSignal);
|
|
235
|
+
clearPictures();
|
|
236
|
+
await store.dispose();
|
|
237
|
+
await service.dispose();
|
|
238
|
+
}
|
|
239
|
+
return 0;
|
|
240
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { HashCache } from "./hash-cache.js";
|
|
2
|
+
import type { FrictionLog, FrictionRun, FrictionStep } from "./model.js";
|
|
3
|
+
export declare const PROMPT_FILE = "prompt.md";
|
|
4
|
+
export declare const META_FILE = "meta.json";
|
|
5
|
+
export declare const RUNS_DIR = "runs";
|
|
6
|
+
export declare const LOG_FILE = "log.jsonl";
|
|
7
|
+
/** Parse JSONL text into ordered steps; screenshots are resolved against `runDir`. */
|
|
8
|
+
export declare function parseJsonl(text: string, runDir: string): Promise<FrictionStep[]>;
|
|
9
|
+
export declare function loadRun(runDir: string, runId: string, hashes: HashCache): Promise<FrictionRun | null>;
|
|
10
|
+
export declare function loadRuns(logDir: string, hashes: HashCache): Promise<FrictionRun[]>;
|
|
11
|
+
export declare function loadFrictionLog(logDir: string, context: {
|
|
12
|
+
worktreePath: string;
|
|
13
|
+
worktree: string;
|
|
14
|
+
}, hashes: HashCache): Promise<FrictionLog | null>;
|
|
15
|
+
export declare function loadFrictionLogs(frictionDir: string, context: {
|
|
16
|
+
worktreePath: string;
|
|
17
|
+
worktree: string;
|
|
18
|
+
}, hashes: HashCache): Promise<FrictionLog[]>;
|
|
19
|
+
/** `MMM d · HH:mm` in local time for `yyyyMMddTHHmmssZ(-N)` run ids. */
|
|
20
|
+
export declare function runDisplayTitle(runId: string): string;
|
|
21
|
+
export declare function stepCountLabel(count: number): string;
|
|
22
|
+
export declare function frictionStatusLabel(status: string | null): string | null;
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Friction logs: `.astroshot/friction-logs/<slug>/{prompt.md,meta.json,runs/<run>/log.jsonl}`.
|
|
3
|
+
* Mirrors the macOS loader, including field aliases and silent skipping of
|
|
4
|
+
* malformed lines and missing screenshots.
|
|
5
|
+
*/
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { humanize, worktreeShort } from "./paths.js";
|
|
9
|
+
import { entryNeedsHash, readReviewDocument, scopedEntry, snapshotFromEntry, } from "./review-store.js";
|
|
10
|
+
export const PROMPT_FILE = "prompt.md";
|
|
11
|
+
export const META_FILE = "meta.json";
|
|
12
|
+
export const RUNS_DIR = "runs";
|
|
13
|
+
export const LOG_FILE = "log.jsonl";
|
|
14
|
+
function isRecord(value) {
|
|
15
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
16
|
+
}
|
|
17
|
+
function stringList(value) {
|
|
18
|
+
if (Array.isArray(value)) {
|
|
19
|
+
return value
|
|
20
|
+
.filter((entry) => typeof entry === "string")
|
|
21
|
+
.map((entry) => entry.trim())
|
|
22
|
+
.filter(Boolean);
|
|
23
|
+
}
|
|
24
|
+
if (typeof value === "string" && value.trim())
|
|
25
|
+
return [value.trim()];
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
function firstString(record, ...keys) {
|
|
29
|
+
for (const key of keys) {
|
|
30
|
+
const value = record[key];
|
|
31
|
+
if (typeof value === "string")
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
async function exists(filePath) {
|
|
37
|
+
try {
|
|
38
|
+
await fs.promises.access(filePath);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function isDirectoryEntry(parent, entry) {
|
|
46
|
+
if (entry.isDirectory())
|
|
47
|
+
return true;
|
|
48
|
+
if (!entry.isSymbolicLink())
|
|
49
|
+
return false;
|
|
50
|
+
try {
|
|
51
|
+
return (await fs.promises.stat(path.join(parent, entry.name))).isDirectory();
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async function mtimeOf(filePath) {
|
|
58
|
+
try {
|
|
59
|
+
return (await fs.promises.stat(filePath)).mtimeMs;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Parse JSONL text into ordered steps; screenshots are resolved against `runDir`. */
|
|
66
|
+
export async function parseJsonl(text, runDir) {
|
|
67
|
+
const steps = [];
|
|
68
|
+
let index = 0;
|
|
69
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
70
|
+
const line = rawLine.trim();
|
|
71
|
+
if (!line || line.startsWith("#"))
|
|
72
|
+
continue;
|
|
73
|
+
let parsed;
|
|
74
|
+
try {
|
|
75
|
+
parsed = JSON.parse(line);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (!isRecord(parsed))
|
|
81
|
+
continue;
|
|
82
|
+
index += 1;
|
|
83
|
+
const step = typeof parsed.step === "number" ? parsed.step : index;
|
|
84
|
+
const stepId = firstString(parsed, "id") ?? `step-${step}`;
|
|
85
|
+
const screenshots = [];
|
|
86
|
+
for (const name of stringList(parsed.screenshots ?? parsed.screenshot)) {
|
|
87
|
+
const byBasename = path.join(runDir, path.basename(name));
|
|
88
|
+
const asWritten = path.resolve(runDir, name);
|
|
89
|
+
if (await exists(byBasename))
|
|
90
|
+
screenshots.push(byBasename);
|
|
91
|
+
else if (await exists(asWritten))
|
|
92
|
+
screenshots.push(asWritten);
|
|
93
|
+
}
|
|
94
|
+
steps.push({
|
|
95
|
+
id: `${step}-${stepId}`,
|
|
96
|
+
step,
|
|
97
|
+
stepId,
|
|
98
|
+
title: firstString(parsed, "title") ?? humanize(stepId),
|
|
99
|
+
description: firstString(parsed, "description") ?? "",
|
|
100
|
+
transcript: firstString(parsed, "transcript", "narration", "voiceover") ?? "",
|
|
101
|
+
screenshots,
|
|
102
|
+
good: stringList(parsed.good ?? parsed.looks_good),
|
|
103
|
+
improve: stringList(parsed.improve ?? parsed.can_improve ?? parsed.improvements),
|
|
104
|
+
url: firstString(parsed, "url"),
|
|
105
|
+
capturedAt: firstString(parsed, "captured_at"),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return steps.sort((a, b) => a.step - b.step);
|
|
109
|
+
}
|
|
110
|
+
async function runReview(runDir, runId, hashes) {
|
|
111
|
+
const logPath = path.join(runDir, LOG_FILE);
|
|
112
|
+
let document;
|
|
113
|
+
try {
|
|
114
|
+
document = await readReviewDocument(runDir);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
if (!document)
|
|
120
|
+
return null;
|
|
121
|
+
const entry = scopedEntry(document, LOG_FILE, runId);
|
|
122
|
+
let sha = null;
|
|
123
|
+
if (entryNeedsHash(entry)) {
|
|
124
|
+
try {
|
|
125
|
+
sha = await hashes.hash(logPath);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
sha = null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return snapshotFromEntry(entry, sha);
|
|
132
|
+
}
|
|
133
|
+
export async function loadRun(runDir, runId, hashes) {
|
|
134
|
+
const logPath = path.join(runDir, LOG_FILE);
|
|
135
|
+
const hasLog = await exists(logPath);
|
|
136
|
+
let steps = [];
|
|
137
|
+
if (hasLog) {
|
|
138
|
+
steps = await parseJsonl(await fs.promises.readFile(logPath, "utf8"), runDir);
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
let entries;
|
|
142
|
+
try {
|
|
143
|
+
entries = await fs.promises.readdir(runDir);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
if (!entries.some((entry) => /\.(png|jpe?g|webp|gif)$/i.test(entry)))
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
const capturedAt = (await mtimeOf(runDir)) ?? (await mtimeOf(logPath)) ?? Date.now();
|
|
152
|
+
return {
|
|
153
|
+
runId,
|
|
154
|
+
directory: runDir,
|
|
155
|
+
logPath: hasLog ? logPath : null,
|
|
156
|
+
capturedAt,
|
|
157
|
+
status: null,
|
|
158
|
+
steps,
|
|
159
|
+
review: hasLog ? await runReview(runDir, runId, hashes) : null,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
export async function loadRuns(logDir, hashes) {
|
|
163
|
+
const runsDir = path.join(logDir, RUNS_DIR);
|
|
164
|
+
const runs = [];
|
|
165
|
+
let entries = [];
|
|
166
|
+
try {
|
|
167
|
+
entries = await fs.promises.readdir(runsDir, { withFileTypes: true });
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
entries = [];
|
|
171
|
+
}
|
|
172
|
+
for (const entry of entries) {
|
|
173
|
+
if (entry.name.startsWith(".") || !(await isDirectoryEntry(runsDir, entry)))
|
|
174
|
+
continue;
|
|
175
|
+
const run = await loadRun(path.join(runsDir, entry.name), entry.name, hashes);
|
|
176
|
+
if (run)
|
|
177
|
+
runs.push(run);
|
|
178
|
+
}
|
|
179
|
+
if (runs.length === 0 && (await exists(path.join(logDir, LOG_FILE)))) {
|
|
180
|
+
const flat = await loadRun(logDir, "latest", hashes);
|
|
181
|
+
if (flat)
|
|
182
|
+
runs.push(flat);
|
|
183
|
+
}
|
|
184
|
+
return runs.sort((a, b) => b.capturedAt - a.capturedAt);
|
|
185
|
+
}
|
|
186
|
+
export async function loadFrictionLog(logDir, context, hashes) {
|
|
187
|
+
const slug = path.basename(logDir);
|
|
188
|
+
const promptPath = path.join(logDir, PROMPT_FILE);
|
|
189
|
+
const metaPath = path.join(logDir, META_FILE);
|
|
190
|
+
const hasPrompt = await exists(promptPath);
|
|
191
|
+
const runs = await loadRuns(logDir, hashes);
|
|
192
|
+
if (!hasPrompt && runs.length === 0)
|
|
193
|
+
return null;
|
|
194
|
+
let meta = {};
|
|
195
|
+
try {
|
|
196
|
+
const parsed = JSON.parse(await fs.promises.readFile(metaPath, "utf8"));
|
|
197
|
+
if (isRecord(parsed))
|
|
198
|
+
meta = parsed;
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
meta = {};
|
|
202
|
+
}
|
|
203
|
+
const metaTitle = firstString(meta, "title")?.trim();
|
|
204
|
+
const candidates = [
|
|
205
|
+
await mtimeOf(logDir),
|
|
206
|
+
hasPrompt ? await mtimeOf(promptPath) : null,
|
|
207
|
+
await mtimeOf(metaPath),
|
|
208
|
+
runs[0]?.capturedAt ?? null,
|
|
209
|
+
typeof meta.updated_at === "string" ? Date.parse(meta.updated_at) : null,
|
|
210
|
+
].filter((value) => typeof value === "number" && Number.isFinite(value));
|
|
211
|
+
return {
|
|
212
|
+
id: `${context.worktreePath}::${slug}`,
|
|
213
|
+
slug,
|
|
214
|
+
directory: logDir,
|
|
215
|
+
worktreePath: context.worktreePath,
|
|
216
|
+
worktree: context.worktree,
|
|
217
|
+
worktreeShort: worktreeShort(context.worktree),
|
|
218
|
+
title: metaTitle || humanize(slug),
|
|
219
|
+
description: firstString(meta, "description") ?? "",
|
|
220
|
+
status: firstString(meta, "status") ?? runs[0]?.status ?? null,
|
|
221
|
+
updatedAt: candidates.length ? Math.max(...candidates) : Date.now(),
|
|
222
|
+
promptPath: hasPrompt ? promptPath : null,
|
|
223
|
+
runs,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
export async function loadFrictionLogs(frictionDir, context, hashes) {
|
|
227
|
+
let entries;
|
|
228
|
+
try {
|
|
229
|
+
entries = await fs.promises.readdir(frictionDir, { withFileTypes: true });
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return [];
|
|
233
|
+
}
|
|
234
|
+
const logs = [];
|
|
235
|
+
for (const entry of entries) {
|
|
236
|
+
if (entry.name.startsWith(".") || !(await isDirectoryEntry(frictionDir, entry)))
|
|
237
|
+
continue;
|
|
238
|
+
const log = await loadFrictionLog(path.join(frictionDir, entry.name), context, hashes);
|
|
239
|
+
if (log)
|
|
240
|
+
logs.push(log);
|
|
241
|
+
}
|
|
242
|
+
return logs.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
243
|
+
}
|
|
244
|
+
/** `MMM d · HH:mm` in local time for `yyyyMMddTHHmmssZ(-N)` run ids. */
|
|
245
|
+
export function runDisplayTitle(runId) {
|
|
246
|
+
const match = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z(?:-\d+)?$/.exec(runId);
|
|
247
|
+
if (match) {
|
|
248
|
+
const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]), Number(match[4]), Number(match[5]), Number(match[6])));
|
|
249
|
+
const month = date.toLocaleString("en-US", { month: "short" });
|
|
250
|
+
const hours = String(date.getHours()).padStart(2, "0");
|
|
251
|
+
const minutes = String(date.getMinutes()).padStart(2, "0");
|
|
252
|
+
return `${month} ${date.getDate()} · ${hours}:${minutes}`;
|
|
253
|
+
}
|
|
254
|
+
return runId.length > 16 ? runId.slice(-14) : runId;
|
|
255
|
+
}
|
|
256
|
+
export function stepCountLabel(count) {
|
|
257
|
+
return count === 1 ? "1 step" : `${count} steps`;
|
|
258
|
+
}
|
|
259
|
+
export function frictionStatusLabel(status) {
|
|
260
|
+
switch ((status ?? "").toLowerCase()) {
|
|
261
|
+
case "draft":
|
|
262
|
+
return "Draft";
|
|
263
|
+
case "ready":
|
|
264
|
+
return "Ready";
|
|
265
|
+
case "running":
|
|
266
|
+
return "Running";
|
|
267
|
+
case "complete":
|
|
268
|
+
case "completed":
|
|
269
|
+
case "done":
|
|
270
|
+
return "Complete";
|
|
271
|
+
case "failed":
|
|
272
|
+
case "fail":
|
|
273
|
+
case "error":
|
|
274
|
+
return "Failed";
|
|
275
|
+
default:
|
|
276
|
+
return status ? humanize(status) : null;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
export interface HashRecord {
|
|
3
|
+
mtimeMs: number;
|
|
4
|
+
size: number;
|
|
5
|
+
sha256: string;
|
|
6
|
+
}
|
|
7
|
+
/** Memoizes file hashes by (path, mtime, size) so rescans stay cheap. */
|
|
8
|
+
export declare class HashCache {
|
|
9
|
+
private readonly records;
|
|
10
|
+
private readonly inFlight;
|
|
11
|
+
constructor(initial?: Record<string, HashRecord>);
|
|
12
|
+
hash(filePath: string, stat?: fs.Stats): Promise<string>;
|
|
13
|
+
seed(records: Record<string, HashRecord>): void;
|
|
14
|
+
/** Drop records for files that are no longer part of the stream. */
|
|
15
|
+
retain(keep: Set<string>, alsoKeep?: (filePath: string) => boolean): void;
|
|
16
|
+
toJSON(): Record<string, HashRecord>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { sha256File } from "./review-store.js";
|
|
3
|
+
/** Memoizes file hashes by (path, mtime, size) so rescans stay cheap. */
|
|
4
|
+
export class HashCache {
|
|
5
|
+
records = new Map();
|
|
6
|
+
inFlight = new Map();
|
|
7
|
+
constructor(initial = {}) {
|
|
8
|
+
for (const [filePath, record] of Object.entries(initial)) {
|
|
9
|
+
this.records.set(filePath, record);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
async hash(filePath, stat) {
|
|
13
|
+
const info = stat ?? (await fs.promises.stat(filePath));
|
|
14
|
+
const cached = this.records.get(filePath);
|
|
15
|
+
if (cached && cached.mtimeMs === info.mtimeMs && cached.size === info.size) {
|
|
16
|
+
return cached.sha256;
|
|
17
|
+
}
|
|
18
|
+
const pending = this.inFlight.get(filePath);
|
|
19
|
+
if (pending)
|
|
20
|
+
return pending;
|
|
21
|
+
const job = sha256File(filePath).then((sha256) => {
|
|
22
|
+
this.records.set(filePath, { mtimeMs: info.mtimeMs, size: info.size, sha256 });
|
|
23
|
+
this.inFlight.delete(filePath);
|
|
24
|
+
return sha256;
|
|
25
|
+
});
|
|
26
|
+
this.inFlight.set(filePath, job);
|
|
27
|
+
try {
|
|
28
|
+
return await job;
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
this.inFlight.delete(filePath);
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
seed(records) {
|
|
36
|
+
for (const [filePath, record] of Object.entries(records)) {
|
|
37
|
+
if (!this.records.has(filePath))
|
|
38
|
+
this.records.set(filePath, record);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Drop records for files that are no longer part of the stream. */
|
|
42
|
+
retain(keep, alsoKeep = () => false) {
|
|
43
|
+
for (const filePath of [...this.records.keys()]) {
|
|
44
|
+
if (!keep.has(filePath) && !alsoKeep(filePath))
|
|
45
|
+
this.records.delete(filePath);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
toJSON() {
|
|
49
|
+
return Object.fromEntries(this.records);
|
|
50
|
+
}
|
|
51
|
+
}
|