astroshot 0.2.1 → 0.2.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/node_modules/@archastro/astroshot/README.md +4 -0
- package/node_modules/@archastro/astroshot/bin/astroshot.mjs +56 -1
- package/node_modules/@archastro/astroshot/bin/demo.mjs +72 -16
- package/node_modules/@archastro/astroshot/package.json +5 -4
- package/node_modules/@archastro/astroshot-review/LICENSE +21 -0
- package/node_modules/@archastro/astroshot-review/README.md +19 -0
- package/node_modules/@archastro/astroshot-review/bin/astroshot-review.mjs +17 -0
- package/node_modules/@archastro/astroshot-review/dist/cli.d.ts +12 -0
- package/node_modules/@archastro/astroshot-review/dist/cli.js +240 -0
- package/node_modules/@archastro/astroshot-review/dist/data/friction.d.ts +22 -0
- package/node_modules/@archastro/astroshot-review/dist/data/friction.js +278 -0
- package/node_modules/@archastro/astroshot-review/dist/data/hash-cache.d.ts +17 -0
- package/node_modules/@archastro/astroshot-review/dist/data/hash-cache.js +51 -0
- package/node_modules/@archastro/astroshot-review/dist/data/index-cache.d.ts +22 -0
- package/node_modules/@archastro/astroshot-review/dist/data/index-cache.js +64 -0
- package/node_modules/@archastro/astroshot-review/dist/data/manifest.d.ts +41 -0
- package/node_modules/@archastro/astroshot-review/dist/data/manifest.js +105 -0
- package/node_modules/@archastro/astroshot-review/dist/data/model.d.ts +96 -0
- package/node_modules/@archastro/astroshot-review/dist/data/model.js +1 -0
- package/node_modules/@archastro/astroshot-review/dist/data/paths.d.ts +27 -0
- package/node_modules/@archastro/astroshot-review/dist/data/paths.js +98 -0
- package/node_modules/@archastro/astroshot-review/dist/data/review-store.d.ts +67 -0
- package/node_modules/@archastro/astroshot-review/dist/data/review-store.js +237 -0
- package/node_modules/@archastro/astroshot-review/dist/data/scan.d.ts +32 -0
- package/node_modules/@archastro/astroshot-review/dist/data/scan.js +227 -0
- package/node_modules/@archastro/astroshot-review/dist/data/store.d.ts +92 -0
- package/node_modules/@archastro/astroshot-review/dist/data/store.js +408 -0
- package/node_modules/@archastro/astroshot-review/dist/data/watcher.d.ts +37 -0
- package/node_modules/@archastro/astroshot-review/dist/data/watcher.js +126 -0
- package/node_modules/@archastro/astroshot-review/dist/images/halfblocks.d.ts +10 -0
- package/node_modules/@archastro/astroshot-review/dist/images/halfblocks.js +21 -0
- package/node_modules/@archastro/astroshot-review/dist/images/png.d.ts +14 -0
- package/node_modules/@archastro/astroshot-review/dist/images/png.js +45 -0
- package/node_modules/@archastro/astroshot-review/dist/images/scale.d.ts +31 -0
- package/node_modules/@archastro/astroshot-review/dist/images/scale.js +102 -0
- package/node_modules/@archastro/astroshot-review/dist/images/service.d.ts +54 -0
- package/node_modules/@archastro/astroshot-review/dist/images/service.js +163 -0
- package/node_modules/@archastro/astroshot-review/dist/images/worker.d.ts +21 -0
- package/node_modules/@archastro/astroshot-review/dist/images/worker.js +30 -0
- package/node_modules/@archastro/astroshot-review/dist/index.d.ts +12 -0
- package/node_modules/@archastro/astroshot-review/dist/index.js +9 -0
- package/node_modules/@archastro/astroshot-review/dist/terminal/graphics-stdout.d.ts +9 -0
- package/node_modules/@archastro/astroshot-review/dist/terminal/graphics-stdout.js +36 -0
- package/node_modules/@archastro/astroshot-review/dist/terminal/herdr.d.ts +61 -0
- package/node_modules/@archastro/astroshot-review/dist/terminal/herdr.js +327 -0
- package/node_modules/@archastro/astroshot-review/dist/terminal/image-layer.d.ts +122 -0
- package/node_modules/@archastro/astroshot-review/dist/terminal/image-layer.js +471 -0
- package/node_modules/@archastro/astroshot-review/dist/terminal/kitty.d.ts +74 -0
- package/node_modules/@archastro/astroshot-review/dist/terminal/kitty.js +112 -0
- package/node_modules/@archastro/astroshot-review/dist/terminal/probe.d.ts +49 -0
- package/node_modules/@archastro/astroshot-review/dist/terminal/probe.js +206 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/app.d.ts +6 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/app.js +695 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/chrome.d.ts +53 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/chrome.js +69 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/context.d.ts +16 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/context.js +8 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/detail.d.ts +33 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/detail.js +39 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/friction.d.ts +42 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/friction.js +84 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/help.d.ts +4 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/help.js +61 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/hooks.d.ts +9 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/hooks.js +32 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/movie-player.d.ts +29 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/movie-player.js +119 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/picture.d.ts +19 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/picture.js +100 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/selectors.d.ts +26 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/selectors.js +69 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/settings.d.ts +5 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/settings.js +17 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/stream.d.ts +38 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/stream.js +118 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/system.d.ts +4 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/system.js +34 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/takeover.d.ts +26 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/takeover.js +29 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/text-input.d.ts +8 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/text-input.js +74 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/theme.d.ts +21 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/theme.js +63 -0
- package/node_modules/@archastro/astroshot-review/dist/video/ffmpeg.d.ts +54 -0
- package/node_modules/@archastro/astroshot-review/dist/video/ffmpeg.js +206 -0
- package/node_modules/@archastro/astroshot-review/package.json +71 -0
- package/node_modules/@archastro/movie-harness/package.json +1 -1
- package/node_modules/@archastro/react-shot/package.json +4 -4
- package/node_modules/@archastro/tui-shot/README.md +9 -0
- package/node_modules/@archastro/tui-shot/dist/index.d.ts +2 -0
- package/node_modules/@archastro/tui-shot/dist/index.js +1 -0
- package/node_modules/@archastro/tui-shot/dist/kitty-graphics.d.ts +45 -0
- package/node_modules/@archastro/tui-shot/dist/kitty-graphics.js +279 -0
- package/node_modules/@archastro/tui-shot/dist/pty-shot.js +25 -3
- package/node_modules/@archastro/tui-shot/dist/shot.d.ts +2 -0
- package/node_modules/@archastro/tui-shot/dist/shot.js +3 -1
- package/node_modules/@archastro/tui-shot/dist/types.d.ts +6 -0
- package/node_modules/@archastro/tui-shot/package.json +1 -1
- package/package.json +10 -5
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { HashRecord } from "./hash-cache.js";
|
|
2
|
+
export interface IndexDocument {
|
|
3
|
+
version: 1;
|
|
4
|
+
roots: string[];
|
|
5
|
+
astroshotDirs: string[];
|
|
6
|
+
arrivalOrder: string[];
|
|
7
|
+
hashes: Record<string, HashRecord>;
|
|
8
|
+
updatedAt: string;
|
|
9
|
+
/** When the last deep walk of every root finished (ISO), if ever. */
|
|
10
|
+
fullScanAt?: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function indexCachePath(env?: NodeJS.ProcessEnv): string;
|
|
13
|
+
export declare function loadIndex(roots: string[], filePath?: string): Promise<IndexDocument | null>;
|
|
14
|
+
export declare function saveIndex(document: IndexDocument, filePath?: string): Promise<void>;
|
|
15
|
+
/**
|
|
16
|
+
* Keep known paths in their prior relative order; new paths go first, sorted
|
|
17
|
+
* newest capture first. Paths that vanished are dropped.
|
|
18
|
+
*/
|
|
19
|
+
export declare function reconcileArrivalOrder(previous: string[], shots: Array<{
|
|
20
|
+
path: string;
|
|
21
|
+
capturedAt: number;
|
|
22
|
+
}>): string[];
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable index so the tray opens instantly: known `.astroshot` directories,
|
|
3
|
+
* the newest-first arrival order, and memoized image hashes.
|
|
4
|
+
*/
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
export function indexCachePath(env = process.env) {
|
|
9
|
+
if (env.ASTROSHOT_REVIEW_CACHE_DIR)
|
|
10
|
+
return path.join(env.ASTROSHOT_REVIEW_CACHE_DIR, "index.json");
|
|
11
|
+
const base = env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache");
|
|
12
|
+
return path.join(base, "astroshot-review", "index.json");
|
|
13
|
+
}
|
|
14
|
+
function normalizedRoots(roots) {
|
|
15
|
+
return [...roots].sort();
|
|
16
|
+
}
|
|
17
|
+
export async function loadIndex(roots, filePath = indexCachePath()) {
|
|
18
|
+
let raw;
|
|
19
|
+
try {
|
|
20
|
+
raw = await fs.promises.readFile(filePath, "utf8");
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const parsed = JSON.parse(raw);
|
|
27
|
+
if (parsed.version !== 1 || !Array.isArray(parsed.roots))
|
|
28
|
+
return null;
|
|
29
|
+
if (JSON.stringify(normalizedRoots(parsed.roots)) !== JSON.stringify(normalizedRoots(roots)))
|
|
30
|
+
return null;
|
|
31
|
+
return {
|
|
32
|
+
version: 1,
|
|
33
|
+
roots: parsed.roots,
|
|
34
|
+
astroshotDirs: Array.isArray(parsed.astroshotDirs) ? parsed.astroshotDirs : [],
|
|
35
|
+
arrivalOrder: Array.isArray(parsed.arrivalOrder) ? parsed.arrivalOrder : [],
|
|
36
|
+
hashes: parsed.hashes && typeof parsed.hashes === "object" ? parsed.hashes : {},
|
|
37
|
+
updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : "",
|
|
38
|
+
fullScanAt: typeof parsed.fullScanAt === "string" ? parsed.fullScanAt : undefined,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export async function saveIndex(document, filePath = indexCachePath()) {
|
|
46
|
+
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
|
47
|
+
const temp = `${filePath}.${process.pid}.tmp`;
|
|
48
|
+
await fs.promises.writeFile(temp, JSON.stringify(document));
|
|
49
|
+
await fs.promises.rename(temp, filePath);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Keep known paths in their prior relative order; new paths go first, sorted
|
|
53
|
+
* newest capture first. Paths that vanished are dropped.
|
|
54
|
+
*/
|
|
55
|
+
export function reconcileArrivalOrder(previous, shots) {
|
|
56
|
+
const present = new Map(shots.map((shot) => [shot.path, shot.capturedAt]));
|
|
57
|
+
const kept = previous.filter((entry) => present.has(entry));
|
|
58
|
+
const known = new Set(kept);
|
|
59
|
+
const fresh = shots
|
|
60
|
+
.filter((shot) => !known.has(shot.path))
|
|
61
|
+
.sort((a, b) => b.capturedAt - a.capturedAt)
|
|
62
|
+
.map((shot) => shot.path);
|
|
63
|
+
return [...fresh, ...kept];
|
|
64
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Chapter, FeatureStatus } from "./model.js";
|
|
2
|
+
export interface ManifestChapter {
|
|
3
|
+
slug?: string;
|
|
4
|
+
title?: string;
|
|
5
|
+
t_ms?: number;
|
|
6
|
+
tMs?: number;
|
|
7
|
+
}
|
|
8
|
+
export interface ManifestShot {
|
|
9
|
+
id?: string;
|
|
10
|
+
file?: string;
|
|
11
|
+
slug?: string;
|
|
12
|
+
title?: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
captured_at?: string;
|
|
15
|
+
url?: string;
|
|
16
|
+
viewport?: unknown;
|
|
17
|
+
kind?: string;
|
|
18
|
+
video?: string;
|
|
19
|
+
poster?: string;
|
|
20
|
+
duration_ms?: number;
|
|
21
|
+
source?: string;
|
|
22
|
+
chapters?: ManifestChapter[];
|
|
23
|
+
}
|
|
24
|
+
export interface FeatureManifest {
|
|
25
|
+
version?: number;
|
|
26
|
+
feature?: string;
|
|
27
|
+
run_id?: string;
|
|
28
|
+
status?: string;
|
|
29
|
+
description?: string;
|
|
30
|
+
shots?: ManifestShot[];
|
|
31
|
+
}
|
|
32
|
+
export declare function readManifest(featureDir: string): Promise<FeatureManifest | null>;
|
|
33
|
+
/** First match wins: file, poster, id (sequence), slug. */
|
|
34
|
+
export declare function matchManifestShot(manifest: FeatureManifest | null, fileName: string): ManifestShot | null;
|
|
35
|
+
export declare function parseFeatureStatus(raw: string | undefined | null): FeatureStatus | null;
|
|
36
|
+
export declare function parseIsoDate(value: string | undefined | null): number | null;
|
|
37
|
+
export declare function chaptersOf(entry: ManifestShot | null): Chapter[];
|
|
38
|
+
/** `2.8s` under a minute, `m:ss` above; null when unknown. */
|
|
39
|
+
export declare function durationLabel(durationMs: number | null | undefined): string | null;
|
|
40
|
+
/** Chapter timestamp label used by the detail card. */
|
|
41
|
+
export declare function chapterTimeLabel(tMs: number | undefined): string;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { sequenceAndSlug } from "./paths.js";
|
|
4
|
+
function isRecord(value) {
|
|
5
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
export async function readManifest(featureDir) {
|
|
8
|
+
let raw;
|
|
9
|
+
try {
|
|
10
|
+
raw = await fs.promises.readFile(path.join(featureDir, "manifest.json"), "utf8");
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
const parsed = JSON.parse(raw);
|
|
17
|
+
if (!isRecord(parsed))
|
|
18
|
+
return null;
|
|
19
|
+
const manifest = parsed;
|
|
20
|
+
if (manifest.shots !== undefined && !Array.isArray(manifest.shots))
|
|
21
|
+
manifest.shots = [];
|
|
22
|
+
return manifest;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** First match wins: file, poster, id (sequence), slug. */
|
|
29
|
+
export function matchManifestShot(manifest, fileName) {
|
|
30
|
+
const shots = manifest?.shots ?? [];
|
|
31
|
+
if (shots.length === 0)
|
|
32
|
+
return null;
|
|
33
|
+
const { sequence, slug } = sequenceAndSlug(fileName);
|
|
34
|
+
return (shots.find((shot) => shot.file === fileName) ??
|
|
35
|
+
shots.find((shot) => shot.poster === fileName) ??
|
|
36
|
+
(sequence ? shots.find((shot) => shot.id === sequence) : undefined) ??
|
|
37
|
+
shots.find((shot) => shot.slug === slug) ??
|
|
38
|
+
null);
|
|
39
|
+
}
|
|
40
|
+
export function parseFeatureStatus(raw) {
|
|
41
|
+
switch ((raw ?? "").toLowerCase()) {
|
|
42
|
+
case "running":
|
|
43
|
+
case "run":
|
|
44
|
+
case "in_progress":
|
|
45
|
+
case "in-progress":
|
|
46
|
+
return "running";
|
|
47
|
+
case "pass":
|
|
48
|
+
case "passed":
|
|
49
|
+
case "ok":
|
|
50
|
+
case "success":
|
|
51
|
+
return "pass";
|
|
52
|
+
case "fail":
|
|
53
|
+
case "failed":
|
|
54
|
+
case "error":
|
|
55
|
+
return "fail";
|
|
56
|
+
case "idle":
|
|
57
|
+
case "pending":
|
|
58
|
+
return "idle";
|
|
59
|
+
default:
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function parseIsoDate(value) {
|
|
64
|
+
if (!value)
|
|
65
|
+
return null;
|
|
66
|
+
const time = Date.parse(value);
|
|
67
|
+
return Number.isFinite(time) ? time : null;
|
|
68
|
+
}
|
|
69
|
+
export function chaptersOf(entry) {
|
|
70
|
+
if (!entry?.chapters || !Array.isArray(entry.chapters))
|
|
71
|
+
return [];
|
|
72
|
+
return entry.chapters.filter(isRecord).map((chapter) => ({
|
|
73
|
+
slug: typeof chapter.slug === "string" ? chapter.slug : undefined,
|
|
74
|
+
title: typeof chapter.title === "string" ? chapter.title : undefined,
|
|
75
|
+
tMs: typeof chapter.t_ms === "number"
|
|
76
|
+
? chapter.t_ms
|
|
77
|
+
: typeof chapter.tMs === "number"
|
|
78
|
+
? chapter.tMs
|
|
79
|
+
: undefined,
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
/** `2.8s` under a minute, `m:ss` above; null when unknown. */
|
|
83
|
+
export function durationLabel(durationMs) {
|
|
84
|
+
if (durationMs === null || durationMs === undefined || durationMs <= 0)
|
|
85
|
+
return null;
|
|
86
|
+
const seconds = durationMs / 1000;
|
|
87
|
+
if (seconds < 60) {
|
|
88
|
+
const rounded = Math.round(seconds * 10) / 10;
|
|
89
|
+
return Number.isInteger(rounded) ? `${rounded}s` : `${rounded.toFixed(1)}s`;
|
|
90
|
+
}
|
|
91
|
+
const minutes = Math.floor(seconds / 60);
|
|
92
|
+
const rest = Math.floor(seconds % 60);
|
|
93
|
+
return `${minutes}:${String(rest).padStart(2, "0")}`;
|
|
94
|
+
}
|
|
95
|
+
/** Chapter timestamp label used by the detail card. */
|
|
96
|
+
export function chapterTimeLabel(tMs) {
|
|
97
|
+
if (tMs === undefined || tMs < 0)
|
|
98
|
+
return "—";
|
|
99
|
+
const seconds = tMs / 1000;
|
|
100
|
+
if (seconds < 60)
|
|
101
|
+
return `${seconds.toFixed(1)}s`;
|
|
102
|
+
const minutes = Math.floor(seconds / 60);
|
|
103
|
+
const rest = Math.floor(seconds % 60);
|
|
104
|
+
return `${minutes}:${String(rest).padStart(2, "0")}`;
|
|
105
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
export type FeatureStatus = "running" | "pass" | "fail" | "idle";
|
|
2
|
+
export interface Chapter {
|
|
3
|
+
slug?: string;
|
|
4
|
+
title?: string;
|
|
5
|
+
tMs?: number;
|
|
6
|
+
}
|
|
7
|
+
export interface ReviewComment {
|
|
8
|
+
id: string;
|
|
9
|
+
body: string;
|
|
10
|
+
createdAt: string;
|
|
11
|
+
}
|
|
12
|
+
export type ReviewState = "seen" | "pending";
|
|
13
|
+
export interface ReviewSnapshot {
|
|
14
|
+
state: ReviewState;
|
|
15
|
+
/** Raw decision from disk, before hash scoping. */
|
|
16
|
+
decision: string | null;
|
|
17
|
+
hashMatches: boolean;
|
|
18
|
+
/** A decision exists but the bytes changed since it was recorded. */
|
|
19
|
+
isStale: boolean;
|
|
20
|
+
comments: ReviewComment[];
|
|
21
|
+
reviewedAt: string | null;
|
|
22
|
+
}
|
|
23
|
+
export interface Shot {
|
|
24
|
+
/** Absolute image path; stable identity. */
|
|
25
|
+
id: string;
|
|
26
|
+
path: string;
|
|
27
|
+
fileName: string;
|
|
28
|
+
worktreePath: string;
|
|
29
|
+
worktree: string;
|
|
30
|
+
worktreeShort: string;
|
|
31
|
+
feature: string;
|
|
32
|
+
featureDir: string;
|
|
33
|
+
sequence: string | null;
|
|
34
|
+
slug: string;
|
|
35
|
+
title: string;
|
|
36
|
+
description: string;
|
|
37
|
+
url: string | null;
|
|
38
|
+
runId: string | null;
|
|
39
|
+
status: FeatureStatus | null;
|
|
40
|
+
/** Epoch milliseconds. */
|
|
41
|
+
capturedAt: number;
|
|
42
|
+
mtimeMs: number;
|
|
43
|
+
isMovie: boolean;
|
|
44
|
+
videoFileName: string | null;
|
|
45
|
+
/** Absolute path when the video exists on disk. */
|
|
46
|
+
videoPath: string | null;
|
|
47
|
+
durationMs: number | null;
|
|
48
|
+
source: string | null;
|
|
49
|
+
chapters: Chapter[];
|
|
50
|
+
review: ReviewSnapshot | null;
|
|
51
|
+
}
|
|
52
|
+
export interface FrictionStep {
|
|
53
|
+
id: string;
|
|
54
|
+
step: number;
|
|
55
|
+
stepId: string;
|
|
56
|
+
title: string;
|
|
57
|
+
description: string;
|
|
58
|
+
transcript: string;
|
|
59
|
+
/** Absolute paths that exist on disk. */
|
|
60
|
+
screenshots: string[];
|
|
61
|
+
good: string[];
|
|
62
|
+
improve: string[];
|
|
63
|
+
url: string | null;
|
|
64
|
+
capturedAt: string | null;
|
|
65
|
+
}
|
|
66
|
+
export interface FrictionRun {
|
|
67
|
+
runId: string;
|
|
68
|
+
directory: string;
|
|
69
|
+
logPath: string | null;
|
|
70
|
+
capturedAt: number;
|
|
71
|
+
status: string | null;
|
|
72
|
+
steps: FrictionStep[];
|
|
73
|
+
review: ReviewSnapshot | null;
|
|
74
|
+
}
|
|
75
|
+
export interface FrictionLog {
|
|
76
|
+
/** `<worktreePath>::<slug>` */
|
|
77
|
+
id: string;
|
|
78
|
+
slug: string;
|
|
79
|
+
directory: string;
|
|
80
|
+
worktreePath: string;
|
|
81
|
+
worktree: string;
|
|
82
|
+
worktreeShort: string;
|
|
83
|
+
title: string;
|
|
84
|
+
description: string;
|
|
85
|
+
status: string | null;
|
|
86
|
+
updatedAt: number;
|
|
87
|
+
promptPath: string | null;
|
|
88
|
+
runs: FrictionRun[];
|
|
89
|
+
}
|
|
90
|
+
export interface AstroshotTree {
|
|
91
|
+
astroshotDir: string;
|
|
92
|
+
worktreePath: string;
|
|
93
|
+
worktree: string;
|
|
94
|
+
shots: Shot[];
|
|
95
|
+
frictionLogs: FrictionLog[];
|
|
96
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|