@plannotator/pi-extension 0.28.50 → 0.28.51
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/generated/config.ts +33 -1
- package/generated/diff-files.ts +13 -0
- package/generated/guide-viewer-manifest.ts +4 -4
- package/generated/inbox/agent-link.ts +31 -16
- package/generated/inbox/connection.ts +26 -0
- package/generated/open-in-apps.ts +10 -0
- package/generated/question-block.ts +11 -2
- package/generated/review-core.ts +32 -19
- package/generated/review-skill-loader.ts +23 -2
- package/generated/shape/core.ts +190 -74
- package/generated/shape/routes.ts +2 -2
- package/generated/shape/store.ts +147 -48
- package/package.json +1 -1
- package/plannotator.html +335 -335
- package/release.json +1 -1
- package/review-editor.html +451 -441
package/generated/shape/store.ts
CHANGED
|
@@ -46,8 +46,10 @@ import {
|
|
|
46
46
|
shapeNoteId,
|
|
47
47
|
shapeNoteWaits,
|
|
48
48
|
shapeParts,
|
|
49
|
+
shapeViewNow,
|
|
49
50
|
type ShapeBoard,
|
|
50
51
|
type ShapeNote,
|
|
52
|
+
type ShapeProject,
|
|
51
53
|
type ShapeRecord,
|
|
52
54
|
type ShapeSession,
|
|
53
55
|
type ShapeState,
|
|
@@ -65,7 +67,7 @@ const MESSAGE_ID_RE = /^msg_[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
|
65
67
|
export interface ShapeSummary {
|
|
66
68
|
id: string;
|
|
67
69
|
title: string;
|
|
68
|
-
project:
|
|
70
|
+
project: ShapeProject;
|
|
69
71
|
thread: string | null;
|
|
70
72
|
state: ShapeState;
|
|
71
73
|
updated: string;
|
|
@@ -92,7 +94,7 @@ export interface ShapeUpdateInput {
|
|
|
92
94
|
thread?: string | null;
|
|
93
95
|
/** The last thread message this version took in. */
|
|
94
96
|
through?: string | null;
|
|
95
|
-
project:
|
|
97
|
+
project: ShapeProject;
|
|
96
98
|
session?: ShapeSession | null;
|
|
97
99
|
/** The folder the board file is in: image references by relative path are read from here. */
|
|
98
100
|
sourceDir?: string | null;
|
|
@@ -263,6 +265,25 @@ export function shapesDirOf(dataDir: string): string {
|
|
|
263
265
|
return join(dataDir, "shapes");
|
|
264
266
|
}
|
|
265
267
|
|
|
268
|
+
export type { ShapeProject };
|
|
269
|
+
|
|
270
|
+
/** A linked worktree's `.git` is a file saying `gitdir: <path>`, and that path exists; a main checkout's is a folder. */
|
|
271
|
+
export function isLinkedWorktree(folder: string): boolean {
|
|
272
|
+
try {
|
|
273
|
+
const dotGit = join(folder, ".git");
|
|
274
|
+
if (!statSync(dotGit).isFile()) return false;
|
|
275
|
+
const gitdir = /^gitdir: (.+)$/.exec(readFileSync(dotGit, "utf8").trim())?.[1];
|
|
276
|
+
return !!gitdir && existsSync(resolve(folder, gitdir));
|
|
277
|
+
} catch {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** The checkout a board's code lives in: the linked worktree of its latest write while that is still one, else the project's root. */
|
|
283
|
+
export function shapeCodeRoot(project: ShapeProject): string {
|
|
284
|
+
return project.worktree && isLinkedWorktree(project.worktree) ? project.worktree : project.root;
|
|
285
|
+
}
|
|
286
|
+
|
|
266
287
|
export function createShapeStore(options: { dataDir: string; now?: () => number }): ShapeStore {
|
|
267
288
|
const dir = shapesDirOf(options.dataDir);
|
|
268
289
|
const now = options.now ?? (() => Date.now());
|
|
@@ -287,68 +308,128 @@ export function createShapeStore(options: { dataDir: string; now?: () => number
|
|
|
287
308
|
}
|
|
288
309
|
|
|
289
310
|
const recordOf = (folder: string) => readJson<ShapeRecord>(join(folder, "shape.json"));
|
|
290
|
-
|
|
311
|
+
// A version or note saved when What to know was Requirements says "reqs": read it as the view it is now. The file stays as written.
|
|
312
|
+
const nowKnow = <T extends { view: unknown }>(x: T): T => (shapeViewNow(x.view) === x.view ? x : { ...x, view: "know" });
|
|
313
|
+
const versionsOf = (folder: string) => readLines<ShapeVersion>(join(folder, "versions.jsonl")).map((v) => (v.parts ? { ...v, parts: v.parts.map(nowKnow) } : v));
|
|
291
314
|
|
|
292
315
|
function notesOf(folder: string): ShapeNote[] {
|
|
293
316
|
const byId = new Map<string, ShapeNote>();
|
|
294
317
|
for (const note of readLines<ShapeNote>(join(folder, "chat.jsonl"))) {
|
|
295
|
-
if (note && typeof note.id === "string") byId.set(note.id, note);
|
|
318
|
+
if (note && typeof note.id === "string") byId.set(note.id, nowKnow(note));
|
|
296
319
|
}
|
|
297
320
|
return [...byId.values()];
|
|
298
321
|
}
|
|
299
322
|
|
|
300
323
|
const drawingNow = (record: ShapeRecord) => !!record.drawing && Date.parse(record.drawing.until) > now();
|
|
301
324
|
|
|
325
|
+
/** Where an image comes from and its name in files/. Null: it stays as written (a files/ name, a data:image) or an error was recorded. */
|
|
326
|
+
function imageFile(src: string, field: string, sourceDir: string | null | undefined, errors: string[]): { path: string; name: string } | null {
|
|
327
|
+
// files/… is a picture the board keeps, unless the board's own folder really has one by that name: that one is copied.
|
|
328
|
+
const local = src.startsWith("files/") && sourceDir ? resolve(sourceDir, src) : null;
|
|
329
|
+
if (src.startsWith("files/") && !(local && existsSync(local))) {
|
|
330
|
+
if (!/^files\/[^/\\%?#.][^/\\%?#]*$/.test(src)) errors.push(`${field}: "${src.slice(0, 80)}" is not one file under files/`);
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
if (/^data:image\/(?:png|jpeg|gif|webp)[;,]/i.test(src)) return null;
|
|
334
|
+
// A board loads nothing from outside: an image is a file on this computer, copied in.
|
|
335
|
+
if (/^[a-z][a-z0-9+.-]+:|^\/\/|^\\\\/i.test(src)) {
|
|
336
|
+
errors.push(`${field}: "${src.slice(0, 80)}" is a URL; an image must be a file on this computer (it is copied into the board)`);
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
const path = isAbsolute(src) ? src : sourceDir ? resolve(sourceDir, src) : null;
|
|
340
|
+
if (!path) {
|
|
341
|
+
errors.push(`${field}: a relative image path needs the board file's folder`);
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
if (!IMAGE_EXT.has(extname(path).toLowerCase())) {
|
|
345
|
+
errors.push(`${field}: "${src}" is not a PNG, JPEG, GIF or WebP image`);
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
let size: number;
|
|
349
|
+
let real: string;
|
|
350
|
+
try {
|
|
351
|
+
const stat = statSync(path);
|
|
352
|
+
if (!stat.isFile()) throw new Error("not a file");
|
|
353
|
+
size = stat.size;
|
|
354
|
+
real = realpathSync(path);
|
|
355
|
+
} catch {
|
|
356
|
+
errors.push(`${field}: "${src}" was not found`);
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
// A link named .png is what it points at.
|
|
360
|
+
if (!IMAGE_EXT.has(extname(real).toLowerCase())) {
|
|
361
|
+
errors.push(`${field}: "${src}" is not a PNG, JPEG, GIF or WebP image`);
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
if (size > SHAPE_IMAGE_MAX_BYTES) {
|
|
365
|
+
errors.push(`${field}: "${src}" is ${size} bytes; at most ${SHAPE_IMAGE_MAX_BYTES}`);
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
return { path, name: `${createHash("sha256").update(readFileSync(path)).digest("hex").slice(0, 10)}-${basename(path).replace(/[^\w.-]+/g, "_")}` };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function copyImage(file: { path: string; name: string }, folder: string): void {
|
|
372
|
+
const files = join(folder, "files");
|
|
373
|
+
ensureDir(files);
|
|
374
|
+
const dest = join(files, file.name);
|
|
375
|
+
if (!existsSync(dest)) copyFileSync(file.path, dest);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* A picture a drawing names by its path (`<img src>`, SVG `<image href>`) gets
|
|
380
|
+
* its files/ name before the board is checked; the file is copied once the
|
|
381
|
+
* board's folder is known. A URL stays as written, for the check to refuse.
|
|
382
|
+
*/
|
|
383
|
+
function drawnImages(input: unknown, sourceDir: string | null | undefined): { board: unknown; files: { path: string; name: string }[]; errors: string[] } {
|
|
384
|
+
const errors: string[] = [];
|
|
385
|
+
const files: { path: string; name: string }[] = [];
|
|
386
|
+
const views = (input as { views?: unknown } | null)?.views;
|
|
387
|
+
if (!views || typeof views !== "object") return { board: input, files, errors };
|
|
388
|
+
const inHtml = (html: unknown, field: string): unknown =>
|
|
389
|
+
typeof html !== "string"
|
|
390
|
+
? html
|
|
391
|
+
// Attribute by attribute, so words inside another attribute's value (alt="x src=y") are left alone.
|
|
392
|
+
: html.replace(/(<(?:img|image)\b(?:\s+(?!(?:xlink:href|src|href)\s*=)[\w:.-]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'>]+))?)*\s+)(xlink:href|src|href)\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s"'>]+))/gi, (all, head: string, attr: string, q2?: string, q1?: string, bare?: string) => {
|
|
393
|
+
const src = (q2 ?? q1 ?? bare)!;
|
|
394
|
+
if (/^(?:#|[a-z][a-z0-9+.-]+:|\/\/|\\\\)/i.test(src)) return all;
|
|
395
|
+
const name = attr.toLowerCase() === "xlink:href" ? "href" : attr;
|
|
396
|
+
const file = imageFile(src, field, sourceDir, errors);
|
|
397
|
+
if (!file) return src.startsWith("files/") ? `${head}${name}="${src}"` : all;
|
|
398
|
+
files.push(file);
|
|
399
|
+
return `${head}${name}="files/${file.name}"`;
|
|
400
|
+
});
|
|
401
|
+
const next: Record<string, unknown> = {};
|
|
402
|
+
for (const [name, view] of Object.entries(views)) {
|
|
403
|
+
if (!view || typeof view !== "object") {
|
|
404
|
+
next[name] = view;
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
const v = view as { html?: unknown; tall?: { html?: unknown }; more?: { html?: unknown } };
|
|
408
|
+
next[name] = {
|
|
409
|
+
...v,
|
|
410
|
+
...("html" in v ? { html: inHtml(v.html, `views.${name}.html`) } : {}),
|
|
411
|
+
...(v.tall ? { tall: { ...v.tall, html: inHtml(v.tall.html, `views.${name}.tall.html`) } } : {}),
|
|
412
|
+
...(v.more ? { more: { ...v.more, html: inHtml(v.more.html, `views.${name}.more.html`) } } : {}),
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
return { board: { ...(input as object), views: next }, files, errors };
|
|
416
|
+
}
|
|
417
|
+
|
|
302
418
|
/** Copy the images the refs cite into files/, and point the refs at the copies. */
|
|
303
419
|
function keepImages(board: ShapeBoard, folder: string, sourceDir: string | null | undefined): { board: ShapeBoard; errors: string[] } {
|
|
304
420
|
const errors: string[] = [];
|
|
305
421
|
if (!board.refs?.length) return { board, errors };
|
|
306
|
-
const files = join(folder, "files");
|
|
307
422
|
const keep = (src: string, field: string): string => {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
}
|
|
312
|
-
if (/^data:image\/(?:png|jpeg|gif|webp)[;,]/i.test(src)) return src;
|
|
313
|
-
// A board loads nothing from outside: an image is a file on this computer, copied in.
|
|
314
|
-
if (/^[a-z][a-z0-9+.-]+:|^\/\/|^\\\\/i.test(src)) {
|
|
315
|
-
errors.push(`${field}: "${src.slice(0, 80)}" is a URL; an image must be a file on this computer (it is copied into the board)`);
|
|
316
|
-
return src;
|
|
317
|
-
}
|
|
318
|
-
const path = isAbsolute(src) ? src : sourceDir ? resolve(sourceDir, src) : null;
|
|
319
|
-
if (!path) {
|
|
320
|
-
errors.push(`${field}: a relative image path needs the board file's folder`);
|
|
321
|
-
return src;
|
|
322
|
-
}
|
|
323
|
-
if (!IMAGE_EXT.has(extname(path).toLowerCase())) {
|
|
324
|
-
errors.push(`${field}: "${src}" is not a PNG, JPEG, GIF or WebP image`);
|
|
325
|
-
return src;
|
|
326
|
-
}
|
|
327
|
-
let size: number;
|
|
328
|
-
try {
|
|
329
|
-
const stat = statSync(path);
|
|
330
|
-
if (!stat.isFile()) throw new Error("not a file");
|
|
331
|
-
size = stat.size;
|
|
332
|
-
} catch {
|
|
333
|
-
errors.push(`${field}: "${src}" was not found`);
|
|
334
|
-
return src;
|
|
335
|
-
}
|
|
336
|
-
if (size > SHAPE_IMAGE_MAX_BYTES) {
|
|
337
|
-
errors.push(`${field}: "${src}" is ${size} bytes; at most ${SHAPE_IMAGE_MAX_BYTES}`);
|
|
338
|
-
return src;
|
|
339
|
-
}
|
|
340
|
-
const bytes = readFileSync(path);
|
|
341
|
-
const name = `${createHash("sha256").update(bytes).digest("hex").slice(0, 10)}-${basename(path).replace(/[^\w.-]+/g, "_")}`;
|
|
342
|
-
ensureDir(files);
|
|
343
|
-
const dest = join(files, name);
|
|
344
|
-
if (!existsSync(dest)) copyFileSync(path, dest);
|
|
345
|
-
return `files/${name}`;
|
|
423
|
+
const file = imageFile(src, field, sourceDir, errors);
|
|
424
|
+
if (!file) return src;
|
|
425
|
+
copyImage(file, folder);
|
|
426
|
+
return `files/${file.name}`;
|
|
346
427
|
};
|
|
347
428
|
const refs = board.refs.map((ref, i) => {
|
|
348
|
-
if (ref.kind !== "image") return ref;
|
|
429
|
+
if (ref.kind !== "image" && !ref.images) return ref;
|
|
349
430
|
return {
|
|
350
431
|
...ref,
|
|
351
|
-
target: keep(ref.target, `refs[${i}].target`),
|
|
432
|
+
...(ref.kind === "image" ? { target: keep(ref.target, `refs[${i}].target`) } : {}),
|
|
352
433
|
...(ref.images ? { images: ref.images.map((im, j) => ({ ...im, src: keep(im.src, `refs[${i}].images[${j}].src`) })) } : {}),
|
|
353
434
|
};
|
|
354
435
|
});
|
|
@@ -356,7 +437,9 @@ export function createShapeStore(options: { dataDir: string; now?: () => number
|
|
|
356
437
|
}
|
|
357
438
|
|
|
358
439
|
function update(input: ShapeUpdateInput): ShapeUpdateResult {
|
|
359
|
-
const
|
|
440
|
+
const drawn = drawnImages(input.board, input.sourceDir);
|
|
441
|
+
if (drawn.errors.length) return { ok: false, code: "invalid", errors: drawn.errors };
|
|
442
|
+
const checked = checkShapeBoard(drawn.board);
|
|
360
443
|
if (!checked.ok) return { ok: false, code: "invalid", errors: checked.errors };
|
|
361
444
|
const ids = (["thread", "through"] as const).filter((k) => input[k] != null && !MESSAGE_ID_RE.test(input[k]!));
|
|
362
445
|
if (ids.length) return { ok: false, code: "invalid", errors: ids.map((k) => `${k}: must be an Inbox message id (msg_…)`) };
|
|
@@ -372,6 +455,7 @@ export function createShapeStore(options: { dataDir: string; now?: () => number
|
|
|
372
455
|
rmSync(folder, { recursive: true, force: true });
|
|
373
456
|
return { ok: false, code: "invalid", errors: kept.errors };
|
|
374
457
|
}
|
|
458
|
+
for (const file of drawn.files) copyImage(file, folder);
|
|
375
459
|
const version: ShapeVersion = {
|
|
376
460
|
...kept.board,
|
|
377
461
|
n: 1,
|
|
@@ -429,6 +513,7 @@ export function createShapeStore(options: { dataDir: string; now?: () => number
|
|
|
429
513
|
}
|
|
430
514
|
const kept = keepImages(checked.value, folder, input.sourceDir);
|
|
431
515
|
if (kept.errors.length) return { ok: false, code: "invalid", errors: kept.errors };
|
|
516
|
+
for (const file of drawn.files) copyImage(file, folder);
|
|
432
517
|
const n = latest.n + 1;
|
|
433
518
|
const version: ShapeVersion = {
|
|
434
519
|
...kept.board,
|
|
@@ -440,7 +525,9 @@ export function createShapeStore(options: { dataDir: string; now?: () => number
|
|
|
440
525
|
parts: shapeParts(kept.board),
|
|
441
526
|
};
|
|
442
527
|
appendLine(join(folder, "versions.jsonl"), version);
|
|
443
|
-
|
|
528
|
+
// The board's checkout follows its latest write, within its project.
|
|
529
|
+
const project = input.project.root === record.project.root ? { ...input.project, name: record.project.name } : record.project;
|
|
530
|
+
const next: ShapeRecord = { ...record, project, title: kept.board.title, updated: at, latest: n, drawing: null };
|
|
444
531
|
writeJsonAtomic(join(folder, "shape.json"), next);
|
|
445
532
|
return { ok: true, id: record.id, v: n, created: false, record: next };
|
|
446
533
|
});
|
|
@@ -591,8 +678,17 @@ export function createShapeStore(options: { dataDir: string; now?: () => number
|
|
|
591
678
|
if (!record) return { ok: false, code: "not_found", message: `No shape ${id}.` };
|
|
592
679
|
if (record.state === "finished") return { ok: false, code: "finished", message: `${id} is finished. Reopen it to change it.` };
|
|
593
680
|
if (checked.value.v > record.latest) return { ok: false, code: "no_version", message: `${id} has no version ${checked.value.v}.` };
|
|
681
|
+
const noteId = shapeNoteId(now());
|
|
682
|
+
let picture: string | undefined;
|
|
683
|
+
if (checked.value.picture) {
|
|
684
|
+
// A box's picture: a file the shape keeps, named for its note.
|
|
685
|
+
const name = `note-${noteId}.png`;
|
|
686
|
+
ensureDir(join(folder, "files"));
|
|
687
|
+
writeFileSync(join(folder, "files", name), Buffer.from(checked.value.picture.slice(checked.value.picture.indexOf(",") + 1), "base64"), { mode: 0o600 });
|
|
688
|
+
picture = `files/${name}`;
|
|
689
|
+
}
|
|
594
690
|
const note: ShapeNote = {
|
|
595
|
-
id:
|
|
691
|
+
id: noteId,
|
|
596
692
|
at: iso(),
|
|
597
693
|
kind: checked.value.kind,
|
|
598
694
|
v: checked.value.v,
|
|
@@ -603,6 +699,9 @@ export function createShapeStore(options: { dataDir: string; now?: () => number
|
|
|
603
699
|
from: "you",
|
|
604
700
|
re: null,
|
|
605
701
|
delivered: null,
|
|
702
|
+
...(checked.value.box ? { box: checked.value.box } : {}),
|
|
703
|
+
...(checked.value.code ? { code: checked.value.code } : {}),
|
|
704
|
+
...(picture ? { picture } : {}),
|
|
606
705
|
};
|
|
607
706
|
appendLine(join(folder, "chat.jsonl"), note);
|
|
608
707
|
return { ok: true, note };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plannotator/pi-extension",
|
|
3
|
-
"version": "0.28.
|
|
3
|
+
"version": "0.28.51",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Plannotator Pi extension - interactive plan review with annotations, annotate agent messages, and review code/PRs",
|
|
6
6
|
"author": "backnotprop",
|