peekable 0.5.0 → 0.6.0
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/dist/api.d.ts +2 -0
- package/dist/api.js +25 -1
- package/dist/commands/feedback.js +54 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +7 -0
package/dist/api.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type ShareConfig } from "./config.js";
|
|
2
2
|
export declare const DEFAULT_REQUEST_TIMEOUT_MS = 15000;
|
|
3
3
|
export declare const DEFAULT_UPLOAD_TIMEOUT_MS = 300000;
|
|
4
|
+
export declare function sanitizeTerminalText(value: string): string;
|
|
4
5
|
export declare const USER_AGENT: string;
|
|
5
6
|
export declare class ApiError extends Error {
|
|
6
7
|
status: number;
|
|
@@ -10,6 +11,7 @@ export declare class ApiError extends Error {
|
|
|
10
11
|
export declare function api(config: ShareConfig, method: string, path: string, body?: unknown, opts?: {
|
|
11
12
|
timeoutMs?: number;
|
|
12
13
|
}): Promise<any>;
|
|
14
|
+
export declare function apiBinary(config: ShareConfig, path: string): Promise<Buffer>;
|
|
13
15
|
export declare function uploadVideo(config: ShareConfig, sessionId: string, opts: {
|
|
14
16
|
video: Blob;
|
|
15
17
|
filename: string;
|
package/dist/api.js
CHANGED
|
@@ -2,7 +2,7 @@ import { CLI_VERSION } from "./version.js";
|
|
|
2
2
|
export const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
3
3
|
export const DEFAULT_UPLOAD_TIMEOUT_MS = 300_000;
|
|
4
4
|
const CONTROL_CHARS = new RegExp("[\\x00-\\x1f\\x7f-\\x9f]", "g");
|
|
5
|
-
function sanitizeTerminalText(value) {
|
|
5
|
+
export function sanitizeTerminalText(value) {
|
|
6
6
|
return value.replace(CONTROL_CHARS, "").slice(0, 500);
|
|
7
7
|
}
|
|
8
8
|
export const USER_AGENT = `peekable-cli/${CLI_VERSION} (${process.platform}; ${process.arch}; node ${process.version})`;
|
|
@@ -31,6 +31,30 @@ export async function api(config, method, path, body, opts = {}) {
|
|
|
31
31
|
}
|
|
32
32
|
return res.json();
|
|
33
33
|
}
|
|
34
|
+
export async function apiBinary(config, path) {
|
|
35
|
+
const apiPath = path.startsWith("/api/") ? path.slice(4) : path;
|
|
36
|
+
const res = await fetchWithTimeout(`${config.url}/api${apiPath}`, {
|
|
37
|
+
method: "GET",
|
|
38
|
+
headers: {
|
|
39
|
+
Authorization: `Bearer ${config.api_key}`,
|
|
40
|
+
"User-Agent": USER_AGENT,
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
if (!res.ok) {
|
|
44
|
+
throw await buildApiError(res);
|
|
45
|
+
}
|
|
46
|
+
// Stored frames are server-capped at 120KB; anything past 1MB is a
|
|
47
|
+
// misbehaving server and must not be buffered into memory unbounded.
|
|
48
|
+
const declaredLength = Number(res.headers.get("content-length"));
|
|
49
|
+
if (Number.isFinite(declaredLength) && declaredLength > 1_000_000) {
|
|
50
|
+
throw new Error("frame exceeds 1MB");
|
|
51
|
+
}
|
|
52
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
53
|
+
if (buffer.byteLength > 1_000_000) {
|
|
54
|
+
throw new Error("frame exceeds 1MB");
|
|
55
|
+
}
|
|
56
|
+
return buffer;
|
|
57
|
+
}
|
|
34
58
|
export async function uploadVideo(config, sessionId, opts) {
|
|
35
59
|
const form = new FormData();
|
|
36
60
|
form.set("video", opts.video, opts.filename);
|
|
@@ -1,21 +1,65 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { requireConfig } from "../config.js";
|
|
3
|
-
import { api } from "../api.js";
|
|
3
|
+
import { api, apiBinary, sanitizeTerminalText } from "../api.js";
|
|
4
4
|
import { CommandFailure, printCommandError } from "../command-error.js";
|
|
5
5
|
import { runWithTelemetry } from "../telemetry.js";
|
|
6
|
+
function formatTimecode(atS) {
|
|
7
|
+
const seconds = Number.isFinite(atS) ? Math.max(0, atS) : 0;
|
|
8
|
+
return `${Math.floor(seconds / 60)}:${String(Math.floor(seconds % 60)).padStart(2, "0")}`;
|
|
9
|
+
}
|
|
10
|
+
function isVideoAnnotation(annotation) {
|
|
11
|
+
return typeof annotation.comment === "string" && typeof annotation.at_s === "number";
|
|
12
|
+
}
|
|
13
|
+
const FRAME_URL_PATTERN = /^\/api\/sessions\/[A-Za-z0-9_-]+\/annotations\/[A-Za-z0-9_-]+\/frame$/;
|
|
14
|
+
async function includeFrames(config, annotationsData) {
|
|
15
|
+
const items = Array.isArray(annotationsData.annotations)
|
|
16
|
+
? annotationsData.annotations.flatMap((group) => Array.isArray(group.items) ? group.items : [])
|
|
17
|
+
: [];
|
|
18
|
+
const candidates = items.filter((item) => typeof item.frame_url === "string");
|
|
19
|
+
const frameItems = candidates.filter((item) => {
|
|
20
|
+
// Only canonical annotation-frame paths are fetched with the bearer key —
|
|
21
|
+
// a server response must not be able to steer the CLI at other endpoints.
|
|
22
|
+
if (FRAME_URL_PATTERN.test(item.frame_url))
|
|
23
|
+
return true;
|
|
24
|
+
item.frame_jpeg = null;
|
|
25
|
+
console.error(`Warning: skipping unexpected frame_url ${sanitizeTerminalText(item.frame_url)}`);
|
|
26
|
+
return false;
|
|
27
|
+
});
|
|
28
|
+
let nextIndex = 0;
|
|
29
|
+
async function worker() {
|
|
30
|
+
while (nextIndex < frameItems.length) {
|
|
31
|
+
const item = frameItems[nextIndex++];
|
|
32
|
+
try {
|
|
33
|
+
const frame = await apiBinary(config, item.frame_url);
|
|
34
|
+
item.frame_jpeg = `data:image/jpeg;base64,${frame.toString("base64")}`;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
item.frame_jpeg = null;
|
|
38
|
+
console.error(`Warning: failed to fetch frame ${sanitizeTerminalText(item.frame_url)}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
await Promise.all(Array.from({ length: Math.min(4, frameItems.length) }, () => worker()));
|
|
43
|
+
}
|
|
6
44
|
export const feedbackCommand = new Command("feedback")
|
|
7
45
|
.argument("<session-id>", "Session ID")
|
|
8
46
|
.description("Fetch feedback for a session")
|
|
9
47
|
.option("--json", "Output JSON")
|
|
48
|
+
.option("--include-frames", "Inline frame JPEGs as data URIs (requires --json)")
|
|
10
49
|
.action(async (sessionId, opts) => {
|
|
11
50
|
await runWithTelemetry("feedback", async () => {
|
|
12
51
|
try {
|
|
52
|
+
if (opts.includeFrames && !opts.json) {
|
|
53
|
+
throw new Error("--include-frames requires --json");
|
|
54
|
+
}
|
|
13
55
|
const config = requireConfig();
|
|
14
56
|
const [eventsData, annotationsData] = await Promise.all([
|
|
15
57
|
api(config, "GET", `/sessions/${sessionId}/feedback`),
|
|
16
58
|
api(config, "GET", `/sessions/${sessionId}/annotations`).catch(() => ({ annotations: [] })),
|
|
17
59
|
]);
|
|
18
60
|
if (opts.json) {
|
|
61
|
+
if (opts.includeFrames)
|
|
62
|
+
await includeFrames(config, annotationsData);
|
|
19
63
|
console.log(JSON.stringify({ ...eventsData, ...annotationsData }));
|
|
20
64
|
return;
|
|
21
65
|
}
|
|
@@ -42,6 +86,15 @@ export const feedbackCommand = new Command("feedback")
|
|
|
42
86
|
for (const group of annotationsData.annotations) {
|
|
43
87
|
console.log(`\n--- Version ${group.version} ---`);
|
|
44
88
|
for (const ann of group.items) {
|
|
89
|
+
if (isVideoAnnotation(ann)) {
|
|
90
|
+
const layer = sanitizeTerminalText(typeof ann.resolved?.layer === "string" ? ann.resolved.layer : "(unresolved)");
|
|
91
|
+
const status = ann.status === "addressed" && typeof ann.addressed_in === "number"
|
|
92
|
+
? `addressed in v${ann.addressed_in}`
|
|
93
|
+
: sanitizeTerminalText(typeof ann.status === "string" ? ann.status : "");
|
|
94
|
+
console.log(` [${formatTimecode(ann.at_s)}] ${layer} — ${status}`);
|
|
95
|
+
console.log(` "${sanitizeTerminalText(ann.comment)}"`);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
45
98
|
const who = ann.viewer ?? "Anonymous";
|
|
46
99
|
const elementName = ann.element_name || "Unknown";
|
|
47
100
|
const selector = ann.selector || "";
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const CLI_VERSION = "0.
|
|
1
|
+
export declare const CLI_VERSION = "0.6.0";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = "0.
|
|
1
|
+
export const CLI_VERSION = "0.6.0";
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -218,6 +218,7 @@ Video items come back in the same `peekable feedback <id> --json` payload as can
|
|
|
218
218
|
},
|
|
219
219
|
"comment": "unreadable at phone size — make it bigger",
|
|
220
220
|
"status": "pending", // pending | resolved | addressed
|
|
221
|
+
"addressed_in": null, // always present; version that addressed it
|
|
221
222
|
"frame_url": "/api/sessions/<id>/annotations/a_01j9x4kq/frame"
|
|
222
223
|
}
|
|
223
224
|
```
|
|
@@ -243,3 +244,9 @@ The rule is an array of server annotation IDs:
|
|
|
243
244
|
> per-layer rather than per-item.
|
|
244
245
|
|
|
245
246
|
Always use server annotation `id` values from `peekable feedback --json` in the array. If those IDs are unknown, use the layer-granularity fallback; it means the layer changed in response to feedback, not that a particular item was addressed.
|
|
247
|
+
|
|
248
|
+
> an item whose id (or layer, in fallback) appears in the new manifest's `changed_by` renders as 'addressed in vN'
|
|
249
|
+
|
|
250
|
+
Video items always include `addressed_in`; it is the addressing version when `status` is `addressed`, otherwise `null`.
|
|
251
|
+
|
|
252
|
+
The non-JSON feedback view renders video comments with timecodes. Frame bytes remain by reference through `frame_url`; use `peekable feedback <id> --json --include-frames` to inline each frame as an item's `frame_jpeg` data URI.
|