@plannotator/pi-extension 0.28.48 → 0.28.49

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.
@@ -0,0 +1,687 @@
1
+ // @generated — DO NOT EDIT. Source: packages/shared/shape/store.ts
2
+ /**
3
+ * The Shape store: one folder per shape under `<data dir>/shapes/`, shared by
4
+ * the Inbox and the standalone Shapes page (SPEC.md §1, "Where the files live").
5
+ *
6
+ * <data>/shapes/
7
+ * seen.json { "<shp id>": 7 } the last version you looked at
8
+ * <projectKey>/<shp_id>/
9
+ * shape.json the shape (ShapeRecord), rewritten whole (temp file, then rename)
10
+ * versions.jsonl one full snapshot per line (ShapeVersion), append only
11
+ * chat.jsonl notes (ShapeNote), append only; a later line for the same id wins
12
+ * files/ copies of the images the board cites
13
+ *
14
+ * `projectKey` is the Inbox's own `inboxProjectKey(name, root)`, so shapes and
15
+ * Inbox threads group under the same project names. Nothing here reads or
16
+ * writes the Inbox's store: an Inbox shape is a shape with `thread` set.
17
+ *
18
+ * Several processes may write (two agents, the CLI and a server), so every
19
+ * change to one shape holds that shape's lock file. A torn last line (a crash
20
+ * mid-append) is skipped on read, and the next append starts on a new line.
21
+ */
22
+ import { createHash, randomUUID } from "node:crypto";
23
+ import {
24
+ appendFileSync,
25
+ closeSync,
26
+ copyFileSync,
27
+ existsSync,
28
+ mkdirSync,
29
+ openSync,
30
+ readdirSync,
31
+ readFileSync,
32
+ realpathSync,
33
+ renameSync,
34
+ rmSync,
35
+ statSync,
36
+ writeFileSync,
37
+ } from "node:fs";
38
+ import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
39
+ import { inboxProjectKey } from "../inbox/project-key.ts";
40
+ import {
41
+ checkShapeBoard,
42
+ checkShapeNoteInput,
43
+ isShapeId,
44
+ SHAPE_FORMAT_VERSION,
45
+ shapeId,
46
+ shapeNoteId,
47
+ shapeNoteWaits,
48
+ shapeParts,
49
+ type ShapeBoard,
50
+ type ShapeNote,
51
+ type ShapeRecord,
52
+ type ShapeSession,
53
+ type ShapeState,
54
+ type ShapeVersion,
55
+ } from "./format.ts";
56
+
57
+ export const SHAPE_DRAWING_MAX_MS = 10 * 60 * 1000;
58
+ /** An image a board cites, copied into the shape's files/. */
59
+ export const SHAPE_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
60
+ const IMAGE_EXT = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
61
+ const LOCK_WAIT_MS = 5000;
62
+ const LOCK_STALE_MS = 30_000;
63
+ const MESSAGE_ID_RE = /^msg_[0-9A-HJKMNP-TV-Z]{26}$/;
64
+
65
+ export interface ShapeSummary {
66
+ id: string;
67
+ title: string;
68
+ project: { name: string; root: string };
69
+ thread: string | null;
70
+ state: ShapeState;
71
+ updated: string;
72
+ latest: number;
73
+ /** The last version you looked at, or null. */
74
+ seen: number | null;
75
+ /** The agent wrote a version newer than the one you looked at. */
76
+ changed: boolean;
77
+ /** The agent is drawing now. */
78
+ drawing: boolean;
79
+ /** Change it notes no agent has picked up. */
80
+ waiting: number;
81
+ session: ShapeSession | null;
82
+ }
83
+
84
+ export interface ShapeUpdateInput {
85
+ /** What the agent wrote; validated here. */
86
+ board: unknown;
87
+ /** The shape to add a version to. Absent: a new shape. */
88
+ id?: string | null;
89
+ /** The version the agent started from. Required for an existing shape. */
90
+ base?: number | null;
91
+ /** The Inbox thread a new shape hangs off (`msg_…`). */
92
+ thread?: string | null;
93
+ /** The last thread message this version took in. */
94
+ through?: string | null;
95
+ project: { name: string; root: string };
96
+ session?: ShapeSession | null;
97
+ /** The folder the board file is in: image references by relative path are read from here. */
98
+ sourceDir?: string | null;
99
+ }
100
+
101
+ export type ShapeUpdateResult =
102
+ | { ok: true; id: string; v: number; created: boolean; record: ShapeRecord }
103
+ | { ok: false; code: "invalid"; errors: string[] }
104
+ | { ok: false; code: "base_required" | "not_found" | "finished"; message: string; latest?: number }
105
+ | { ok: false; code: "stale_base"; message: string; latest: ShapeVersion };
106
+
107
+ export type ShapeNoteResult =
108
+ | { ok: true; note: ShapeNote }
109
+ | { ok: false; code: "invalid"; errors: string[] }
110
+ | { ok: false; code: "not_found" | "finished" | "no_version"; message: string };
111
+
112
+ export interface ShapeStore {
113
+ readonly dir: string;
114
+ update(input: ShapeUpdateInput): ShapeUpdateResult;
115
+ get(id: string): ShapeRecord | null;
116
+ /** One version (the latest when `n` is absent). */
117
+ read(id: string, n?: number): ShapeVersion | null;
118
+ versions(id: string): { n: number; at: string; note: string | null; base: number | null }[];
119
+ list(options?: { state?: ShapeState | "all" }): ShapeSummary[];
120
+ /** The newest active shape of a session, else of a project root. For CLI calls that name no shape. */
121
+ latestFor(options: { session?: ShapeSession | null; root?: string | null }): ShapeRecord | null;
122
+ drawing(id: string, options?: { untilMs?: number }): ShapeRecord | null;
123
+ setState(id: string, state: ShapeState): ShapeRecord | null;
124
+ remove(id: string): boolean;
125
+ seen(): Record<string, number>;
126
+ markSeen(id: string, n: number): void;
127
+ addNote(id: string, input: unknown): ShapeNoteResult;
128
+ answer(id: string, input: { re: string | null; text: string; from: ShapeSession }): ShapeNoteResult;
129
+ notes(id: string): ShapeNote[];
130
+ /**
131
+ * The notes waiting for an agent (Change it, Ask). Reading them marks
132
+ * nothing: the caller hands them over, then calls `markDelivered`, so a
133
+ * hand-over that fails (a closed pipe) leaves them waiting. Two readers at
134
+ * once may both get them: delivery is at least once.
135
+ */
136
+ waiting(id: string): ShapeNote[];
137
+ /** Mark these notes delivered (the ones not delivered yet); the notes as marked. */
138
+ markDelivered(id: string, noteIds: readonly string[]): ShapeNote[];
139
+ /** A file under the shape's files/ folder, path-guarded; null when absent or outside. */
140
+ filePath(id: string, name: string): string | null;
141
+ /**
142
+ * Someone holds the store's lock (or this shape's). A server awaits this
143
+ * clearing before it writes, so the store's blocking wait never stalls its
144
+ * event loop behind an agent's CLI.
145
+ */
146
+ locked(id?: string | null): boolean;
147
+ }
148
+
149
+ // The lock is a file holding its owner's token. A lock older than
150
+ // LOCK_STALE_MS is broken, but only under a second lock (`.lock.break`) and
151
+ // only if it is still stale when looked at again there, so two waiters can
152
+ // never both break it and each take a fresh lock. The owner removes the lock
153
+ // only while it still holds its own token. `sleep` blocks the thread: the
154
+ // store is called from the CLI, one command per process; a server that calls
155
+ // it must call it off its event loop.
156
+ function sleep(ms: number) {
157
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
158
+ }
159
+
160
+ function readJson<T>(path: string): T | null {
161
+ try {
162
+ return JSON.parse(readFileSync(path, "utf8")) as T;
163
+ } catch {
164
+ return null;
165
+ }
166
+ }
167
+
168
+ function writeJsonAtomic(path: string, value: unknown) {
169
+ const temp = `${path}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
170
+ writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
171
+ renameSync(temp, path);
172
+ }
173
+
174
+ function readLines<T>(path: string): T[] {
175
+ let text: string;
176
+ try {
177
+ text = readFileSync(path, "utf8");
178
+ } catch {
179
+ return [];
180
+ }
181
+ const out: T[] = [];
182
+ for (const line of text.split("\n")) {
183
+ if (!line.trim()) continue;
184
+ try {
185
+ out.push(JSON.parse(line) as T);
186
+ } catch {
187
+ // A torn line from a crash mid-append: skipped.
188
+ }
189
+ }
190
+ return out;
191
+ }
192
+
193
+ /** Append one line; when the file's last line is torn, start on a new one so both stay readable. */
194
+ function appendLine(path: string, value: unknown) {
195
+ let prefix = "";
196
+ try {
197
+ const size = statSync(path).size;
198
+ if (size > 0) {
199
+ const text = readFileSync(path, "utf8");
200
+ if (!text.endsWith("\n")) prefix = "\n";
201
+ }
202
+ } catch {
203
+ // a new file
204
+ }
205
+ appendFileSync(path, `${prefix}${JSON.stringify(value)}\n`, { mode: 0o600 });
206
+ }
207
+
208
+ function lockAge(path: string): number | null {
209
+ try {
210
+ return Date.now() - statSync(path).mtimeMs;
211
+ } catch {
212
+ return null;
213
+ }
214
+ }
215
+
216
+ function breakIfStale(lock: string) {
217
+ if ((lockAge(lock) ?? 0) <= LOCK_STALE_MS) return;
218
+ const breaker = `${lock}.break`;
219
+ let fd: number;
220
+ try {
221
+ fd = openSync(breaker, "wx", 0o600);
222
+ } catch (error) {
223
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
224
+ // A breaker that died mid-break: its lock goes the same way, later.
225
+ if ((lockAge(breaker) ?? 0) > LOCK_STALE_MS) rmSync(breaker, { force: true });
226
+ return;
227
+ }
228
+ try {
229
+ if ((lockAge(lock) ?? 0) > LOCK_STALE_MS) rmSync(lock, { force: true });
230
+ } finally {
231
+ closeSync(fd);
232
+ rmSync(breaker, { force: true });
233
+ }
234
+ }
235
+
236
+ function withLock<T>(dir: string, fn: () => T): T {
237
+ const lock = join(dir, ".lock");
238
+ const token = `${process.pid}.${randomUUID()}`;
239
+ const start = Date.now();
240
+ for (;;) {
241
+ try {
242
+ writeFileSync(lock, token, { flag: "wx", mode: 0o600 });
243
+ break;
244
+ } catch (error) {
245
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
246
+ breakIfStale(lock);
247
+ if (Date.now() - start > LOCK_WAIT_MS) throw new Error(`shape store: ${dir} stayed locked for ${LOCK_WAIT_MS} ms`);
248
+ sleep(15);
249
+ }
250
+ }
251
+ try {
252
+ return fn();
253
+ } finally {
254
+ try {
255
+ if (readFileSync(lock, "utf8") === token) rmSync(lock, { force: true });
256
+ } catch {
257
+ // broken as stale while we held it, or the folder is gone (remove)
258
+ }
259
+ }
260
+ }
261
+
262
+ export function shapesDirOf(dataDir: string): string {
263
+ return join(dataDir, "shapes");
264
+ }
265
+
266
+ export function createShapeStore(options: { dataDir: string; now?: () => number }): ShapeStore {
267
+ const dir = shapesDirOf(options.dataDir);
268
+ const now = options.now ?? (() => Date.now());
269
+ const iso = () => new Date(now()).toISOString();
270
+ const seenPath = join(dir, "seen.json");
271
+
272
+ const ensureDir = (path: string) => mkdirSync(path, { recursive: true, mode: 0o700 });
273
+
274
+ function shapeDir(id: string): string | null {
275
+ if (!isShapeId(id)) return null;
276
+ let projects: string[];
277
+ try {
278
+ projects = readdirSync(dir);
279
+ } catch {
280
+ return null;
281
+ }
282
+ for (const project of projects) {
283
+ const candidate = join(dir, project, id);
284
+ if (existsSync(join(candidate, "shape.json"))) return candidate;
285
+ }
286
+ return null;
287
+ }
288
+
289
+ const recordOf = (folder: string) => readJson<ShapeRecord>(join(folder, "shape.json"));
290
+ const versionsOf = (folder: string) => readLines<ShapeVersion>(join(folder, "versions.jsonl"));
291
+
292
+ function notesOf(folder: string): ShapeNote[] {
293
+ const byId = new Map<string, ShapeNote>();
294
+ for (const note of readLines<ShapeNote>(join(folder, "chat.jsonl"))) {
295
+ if (note && typeof note.id === "string") byId.set(note.id, note);
296
+ }
297
+ return [...byId.values()];
298
+ }
299
+
300
+ const drawingNow = (record: ShapeRecord) => !!record.drawing && Date.parse(record.drawing.until) > now();
301
+
302
+ /** Copy the images the refs cite into files/, and point the refs at the copies. */
303
+ function keepImages(board: ShapeBoard, folder: string, sourceDir: string | null | undefined): { board: ShapeBoard; errors: string[] } {
304
+ const errors: string[] = [];
305
+ if (!board.refs?.length) return { board, errors };
306
+ const files = join(folder, "files");
307
+ const keep = (src: string, field: string): string => {
308
+ if (src.startsWith("files/")) {
309
+ if (!/^files\/[^/\\%?#.][^/\\%?#]*$/.test(src)) errors.push(`${field}: "${src.slice(0, 80)}" is not one file under files/`);
310
+ return src;
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}`;
346
+ };
347
+ const refs = board.refs.map((ref, i) => {
348
+ if (ref.kind !== "image") return ref;
349
+ return {
350
+ ...ref,
351
+ target: keep(ref.target, `refs[${i}].target`),
352
+ ...(ref.images ? { images: ref.images.map((im, j) => ({ ...im, src: keep(im.src, `refs[${i}].images[${j}].src`) })) } : {}),
353
+ };
354
+ });
355
+ return { board: { ...board, refs }, errors };
356
+ }
357
+
358
+ function update(input: ShapeUpdateInput): ShapeUpdateResult {
359
+ const checked = checkShapeBoard(input.board);
360
+ if (!checked.ok) return { ok: false, code: "invalid", errors: checked.errors };
361
+ const ids = (["thread", "through"] as const).filter((k) => input[k] != null && !MESSAGE_ID_RE.test(input[k]!));
362
+ if (ids.length) return { ok: false, code: "invalid", errors: ids.map((k) => `${k}: must be an Inbox message id (msg_…)`) };
363
+ const at = iso();
364
+
365
+ if (!input.id) {
366
+ const id = shapeId(now());
367
+ const folder = join(dir, inboxProjectKey(input.project.name, input.project.root), id);
368
+ ensureDir(folder);
369
+ return withLock(folder, () => {
370
+ const kept = keepImages(checked.value, folder, input.sourceDir);
371
+ if (kept.errors.length) {
372
+ rmSync(folder, { recursive: true, force: true });
373
+ return { ok: false, code: "invalid", errors: kept.errors };
374
+ }
375
+ const version: ShapeVersion = {
376
+ ...kept.board,
377
+ n: 1,
378
+ at,
379
+ base: null,
380
+ through: input.through ?? null,
381
+ by: input.session ?? null,
382
+ parts: shapeParts(kept.board),
383
+ };
384
+ const record: ShapeRecord = {
385
+ v: SHAPE_FORMAT_VERSION,
386
+ id,
387
+ title: kept.board.title,
388
+ project: input.project,
389
+ session: input.session ?? null,
390
+ thread: input.thread ?? null,
391
+ state: "active",
392
+ created: at,
393
+ updated: at,
394
+ latest: 1,
395
+ drawing: null,
396
+ };
397
+ appendLine(join(folder, "versions.jsonl"), version);
398
+ writeJsonAtomic(join(folder, "shape.json"), record);
399
+ return { ok: true, id, v: 1, created: true, record };
400
+ });
401
+ }
402
+
403
+ const folder = shapeDir(input.id);
404
+ if (!folder) return { ok: false, code: "not_found", message: `No shape ${input.id}. List them with \`plannotator shape list\`.` };
405
+ return withLock(folder, () => {
406
+ const record = recordOf(folder);
407
+ if (!record) return { ok: false, code: "not_found", message: `No shape ${input.id}.` };
408
+ if (record.state === "finished") {
409
+ return { ok: false, code: "finished", message: `${record.id} is finished; it takes no new versions until the person reopens it.`, latest: record.latest };
410
+ }
411
+ if (input.base === undefined || input.base === null) {
412
+ return {
413
+ ok: false,
414
+ code: "base_required",
415
+ message: `${record.id} is at version ${record.latest}. Pass the version you started from (--base ${record.latest} if you drew from the latest).`,
416
+ latest: record.latest,
417
+ };
418
+ }
419
+ const versions = versionsOf(folder);
420
+ const latest = versions.at(-1);
421
+ if (!latest) return { ok: false, code: "not_found", message: `${record.id} has no versions.` };
422
+ if (input.base !== latest.n) {
423
+ return {
424
+ ok: false,
425
+ code: "stale_base",
426
+ message: `${record.id} is at version ${latest.n}, not ${input.base}: someone updated it since. Read version ${latest.n}, fold your change into it, and update with --base ${latest.n}.`,
427
+ latest,
428
+ };
429
+ }
430
+ const kept = keepImages(checked.value, folder, input.sourceDir);
431
+ if (kept.errors.length) return { ok: false, code: "invalid", errors: kept.errors };
432
+ const n = latest.n + 1;
433
+ const version: ShapeVersion = {
434
+ ...kept.board,
435
+ n,
436
+ at,
437
+ base: input.base,
438
+ through: input.through ?? latest.through ?? null,
439
+ by: input.session ?? null,
440
+ parts: shapeParts(kept.board),
441
+ };
442
+ appendLine(join(folder, "versions.jsonl"), version);
443
+ const next: ShapeRecord = { ...record, title: kept.board.title, updated: at, latest: n, drawing: null };
444
+ writeJsonAtomic(join(folder, "shape.json"), next);
445
+ return { ok: true, id: record.id, v: n, created: false, record: next };
446
+ });
447
+ }
448
+
449
+ function get(id: string): ShapeRecord | null {
450
+ const folder = shapeDir(id);
451
+ if (!folder) return null;
452
+ const record = recordOf(folder);
453
+ if (!record) return null;
454
+ // An expired "drawing" reads as not drawing; it is cleared on the next write.
455
+ return record.drawing && !drawingNow(record) ? { ...record, drawing: null } : record;
456
+ }
457
+
458
+ function read(id: string, n?: number): ShapeVersion | null {
459
+ const folder = shapeDir(id);
460
+ if (!folder) return null;
461
+ const versions = versionsOf(folder);
462
+ if (n === undefined) return versions.at(-1) ?? null;
463
+ return versions.find((v) => v.n === n) ?? null;
464
+ }
465
+
466
+ function versions(id: string) {
467
+ const folder = shapeDir(id);
468
+ if (!folder) return [];
469
+ return versionsOf(folder).map((v) => ({ n: v.n, at: v.at, note: v.note ?? null, base: v.base }));
470
+ }
471
+
472
+ function seen(): Record<string, number> {
473
+ const value = readJson<Record<string, unknown>>(seenPath);
474
+ const out: Record<string, number> = {};
475
+ if (value && typeof value === "object") {
476
+ for (const [key, n] of Object.entries(value)) if (isShapeId(key) && typeof n === "number") out[key] = n;
477
+ }
478
+ return out;
479
+ }
480
+
481
+ function markSeen(id: string, n: number) {
482
+ const record = get(id);
483
+ if (!record || !Number.isInteger(n) || n < 1) return;
484
+ ensureDir(dir);
485
+ withLock(dir, () => {
486
+ const all = seen();
487
+ // Seen only moves forward, and never past what exists.
488
+ const next = Math.min(Math.max(all[id] ?? 0, n), record.latest);
489
+ if (all[id] === next) return;
490
+ writeJsonAtomic(seenPath, { ...all, [id]: next });
491
+ });
492
+ }
493
+
494
+ function list(opts: { state?: ShapeState | "all" } = {}): ShapeSummary[] {
495
+ const state = opts.state ?? "all";
496
+ const marks = seen();
497
+ const out: ShapeSummary[] = [];
498
+ let projects: string[] = [];
499
+ try {
500
+ projects = readdirSync(dir);
501
+ } catch {
502
+ return [];
503
+ }
504
+ for (const project of projects) {
505
+ let ids: string[] = [];
506
+ try {
507
+ ids = readdirSync(join(dir, project)).filter(isShapeId);
508
+ } catch {
509
+ continue;
510
+ }
511
+ for (const id of ids) {
512
+ const folder = join(dir, project, id);
513
+ const record = recordOf(folder);
514
+ if (!record) continue;
515
+ if (state !== "all" && record.state !== state) continue;
516
+ const mark = marks[id] ?? null;
517
+ out.push({
518
+ id,
519
+ title: record.title,
520
+ project: record.project,
521
+ thread: record.thread,
522
+ state: record.state,
523
+ updated: record.updated,
524
+ latest: record.latest,
525
+ seen: mark,
526
+ changed: record.latest > (mark ?? 0),
527
+ drawing: drawingNow(record),
528
+ waiting: notesOf(folder).filter(shapeNoteWaits).length,
529
+ session: record.session,
530
+ });
531
+ }
532
+ }
533
+ // Newest change first (SPEC: "Last changed, by default and everywhere").
534
+ return out.sort((a, b) => b.updated.localeCompare(a.updated) || b.id.localeCompare(a.id));
535
+ }
536
+
537
+ function latestFor({ session, root }: { session?: ShapeSession | null; root?: string | null }): ShapeRecord | null {
538
+ const active = list({ state: "active" });
539
+ const mine = session ? active.find((s) => s.session?.host === session.host && s.session.id === session.id) : undefined;
540
+ const here = mine ?? (root ? active.find((s) => s.project.root === root) : undefined);
541
+ return here ? get(here.id) : null;
542
+ }
543
+
544
+ function mutate(id: string, change: (record: ShapeRecord) => ShapeRecord): ShapeRecord | null {
545
+ const folder = shapeDir(id);
546
+ if (!folder) return null;
547
+ return withLock(folder, () => {
548
+ const record = recordOf(folder);
549
+ if (!record) return null;
550
+ const next = change(record);
551
+ writeJsonAtomic(join(folder, "shape.json"), next);
552
+ return next;
553
+ });
554
+ }
555
+
556
+ function drawing(id: string, opts: { untilMs?: number } = {}) {
557
+ const span = Math.min(Math.max(opts.untilMs ?? SHAPE_DRAWING_MAX_MS, 1000), SHAPE_DRAWING_MAX_MS);
558
+ return mutate(id, (record) =>
559
+ record.state === "finished" ? record : { ...record, drawing: { at: iso(), until: new Date(now() + span).toISOString() } },
560
+ );
561
+ }
562
+
563
+ function setState(id: string, state: ShapeState) {
564
+ return mutate(id, (record) => ({ ...record, state, drawing: state === "finished" ? null : record.drawing, updated: record.updated }));
565
+ }
566
+
567
+ function remove(id: string): boolean {
568
+ const folder = shapeDir(id);
569
+ if (!folder) return false;
570
+ // Under the shape's lock, so no write lands in a folder being removed.
571
+ withLock(folder, () => rmSync(folder, { recursive: true, force: true }));
572
+ if (existsSync(seenPath)) {
573
+ withLock(dir, () => {
574
+ const all = seen();
575
+ if (id in all) {
576
+ delete all[id];
577
+ writeJsonAtomic(seenPath, all);
578
+ }
579
+ });
580
+ }
581
+ return true;
582
+ }
583
+
584
+ function addNote(id: string, input: unknown): ShapeNoteResult {
585
+ const checked = checkShapeNoteInput(input);
586
+ if (!checked.ok) return { ok: false, code: "invalid", errors: checked.errors };
587
+ const folder = shapeDir(id);
588
+ if (!folder) return { ok: false, code: "not_found", message: `No shape ${id}.` };
589
+ return withLock(folder, () => {
590
+ const record = recordOf(folder);
591
+ if (!record) return { ok: false, code: "not_found", message: `No shape ${id}.` };
592
+ if (record.state === "finished") return { ok: false, code: "finished", message: `${id} is finished. Reopen it to change it.` };
593
+ if (checked.value.v > record.latest) return { ok: false, code: "no_version", message: `${id} has no version ${checked.value.v}.` };
594
+ const note: ShapeNote = {
595
+ id: shapeNoteId(now()),
596
+ at: iso(),
597
+ kind: checked.value.kind,
598
+ v: checked.value.v,
599
+ view: checked.value.view ?? null,
600
+ part: checked.value.part ?? null,
601
+ quote: checked.value.quote ?? null,
602
+ text: checked.value.text,
603
+ from: "you",
604
+ re: null,
605
+ delivered: null,
606
+ };
607
+ appendLine(join(folder, "chat.jsonl"), note);
608
+ return { ok: true, note };
609
+ });
610
+ }
611
+
612
+ function answer(id: string, input: { re: string | null; text: string; from: ShapeSession }): ShapeNoteResult {
613
+ const text = typeof input.text === "string" ? input.text.trim() : "";
614
+ if (!text) return { ok: false, code: "invalid", errors: ["text: is empty"] };
615
+ const folder = shapeDir(id);
616
+ if (!folder) return { ok: false, code: "not_found", message: `No shape ${id}.` };
617
+ return withLock(folder, () => {
618
+ const record = recordOf(folder);
619
+ if (!record) return { ok: false, code: "not_found", message: `No shape ${id}.` };
620
+ const asked = input.re ? notesOf(folder).find((n) => n.id === input.re) : undefined;
621
+ if (input.re && !asked) return { ok: false, code: "invalid", errors: [`re: no note ${input.re}`] };
622
+ const note: ShapeNote = {
623
+ id: shapeNoteId(now()),
624
+ at: iso(),
625
+ kind: "answer",
626
+ v: record.latest,
627
+ view: asked?.view ?? null,
628
+ part: asked?.part ?? null,
629
+ quote: null,
630
+ text,
631
+ from: input.from,
632
+ re: input.re,
633
+ delivered: null,
634
+ };
635
+ appendLine(join(folder, "chat.jsonl"), note);
636
+ return { ok: true, note };
637
+ });
638
+ }
639
+
640
+ function notes(id: string): ShapeNote[] {
641
+ const folder = shapeDir(id);
642
+ return folder ? notesOf(folder) : [];
643
+ }
644
+
645
+ const waitsForAgent = (n: ShapeNote) => n.from === "you" && (n.kind === "change" || n.kind === "ask") && !n.delivered;
646
+
647
+ function waiting(id: string): ShapeNote[] {
648
+ const folder = shapeDir(id);
649
+ return folder ? notesOf(folder).filter(waitsForAgent) : [];
650
+ }
651
+
652
+ function markDelivered(id: string, noteIds: readonly string[]): ShapeNote[] {
653
+ const folder = shapeDir(id);
654
+ if (!folder || !noteIds.length) return [];
655
+ const wanted = new Set(noteIds);
656
+ return withLock(folder, () => {
657
+ const at = iso();
658
+ const marked = notesOf(folder).filter((n) => wanted.has(n.id) && waitsForAgent(n));
659
+ for (const note of marked) appendLine(join(folder, "chat.jsonl"), { ...note, delivered: at });
660
+ return marked.map((n) => ({ ...n, delivered: at }));
661
+ });
662
+ }
663
+
664
+ function filePath(id: string, name: string): string | null {
665
+ const folder = shapeDir(id);
666
+ if (!folder) return null;
667
+ const rel = name.replace(/^files\//, "");
668
+ if (!rel || rel.includes("/") || rel.includes("\\") || rel.startsWith(".")) return null;
669
+ const path = join(folder, "files", rel);
670
+ // A link planted in files/ never leads out: the real file must sit in the real files/ folder, and be a file.
671
+ try {
672
+ const real = realpathSync(path);
673
+ if (dirname(real) !== realpathSync(join(folder, "files")) || !statSync(real).isFile()) return null;
674
+ return real;
675
+ } catch {
676
+ return null;
677
+ }
678
+ }
679
+
680
+ function locked(id?: string | null): boolean {
681
+ if (existsSync(join(dir, ".lock"))) return true;
682
+ const folder = id ? shapeDir(id) : null;
683
+ return !!folder && existsSync(join(folder, ".lock"));
684
+ }
685
+
686
+ return { dir, locked, update, get, read, versions, list, latestFor, drawing, setState, remove, seen, markSeen, addNote, answer, notes, waiting, markDelivered, filePath };
687
+ }