@khorsheed/dsh-reader 0.3.0 → 0.3.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.
Files changed (89) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.en.md +26 -121
  3. package/README.i18n.yaml +3 -3
  4. package/README.md +26 -121
  5. package/cordis.patch.yml +7 -0
  6. package/lib/client.js +29105 -6692
  7. package/lib/index.js +3500 -321
  8. package/lib/typert.host.js +2145 -259
  9. package/lib/typert.remote-client.d.ts +97 -3
  10. package/lib/typert.remote-client.js +1590 -250
  11. package/lib/types/captions.d.ts +99 -0
  12. package/lib/types/captions.js +236 -0
  13. package/lib/types/client/MediaPlayer.d.ts +55 -0
  14. package/lib/types/client/MediaPlayer.js +106 -0
  15. package/lib/types/client/NotesView.d.ts +57 -0
  16. package/lib/types/client/NotesView.js +124 -0
  17. package/lib/types/client/ReaderPane.d.ts +20 -3
  18. package/lib/types/client/ReaderPane.js +860 -164
  19. package/lib/types/client/SpeakerLegend.d.ts +18 -0
  20. package/lib/types/client/SpeakerLegend.js +26 -0
  21. package/lib/types/client/canvas-sync.d.ts +84 -0
  22. package/lib/types/client/canvas-sync.js +59 -0
  23. package/lib/types/client/contract.d.ts +130 -3
  24. package/lib/types/client/extract-article.d.ts +38 -1
  25. package/lib/types/client/extract-article.js +561 -33
  26. package/lib/types/client/icons.d.ts +1 -1
  27. package/lib/types/client/icons.js +1 -1
  28. package/lib/types/client/index.js +81 -2
  29. package/lib/types/client/locales.d.ts +279 -11
  30. package/lib/types/client/locales.js +278 -10
  31. package/lib/types/client/notes.d.ts +142 -0
  32. package/lib/types/client/notes.js +334 -0
  33. package/lib/types/client/parse-rss.js +40 -0
  34. package/lib/types/client/preset-visibility.d.ts +93 -0
  35. package/lib/types/client/preset-visibility.js +115 -0
  36. package/lib/types/client/selectors.d.ts +5 -0
  37. package/lib/types/client/selectors.js +5 -0
  38. package/lib/types/client/session.d.ts +21 -1
  39. package/lib/types/client/session.js +12 -0
  40. package/lib/types/client/store.js +4 -1
  41. package/lib/types/client/translate.d.ts +26 -1
  42. package/lib/types/client/translate.js +29 -12
  43. package/lib/types/client/useLocalTranscript.d.ts +43 -0
  44. package/lib/types/client/useLocalTranscript.js +129 -0
  45. package/lib/types/client/useMedia.d.ts +68 -0
  46. package/lib/types/client/useMedia.js +299 -0
  47. package/lib/types/client/useMediaStates.d.ts +9 -0
  48. package/lib/types/client/useMediaStates.js +43 -0
  49. package/lib/types/client/useNotes.d.ts +110 -0
  50. package/lib/types/client/useNotes.js +423 -0
  51. package/lib/types/client/usePolish.d.ts +47 -0
  52. package/lib/types/client/usePolish.js +162 -0
  53. package/lib/types/document-fetch.d.ts +106 -0
  54. package/lib/types/document-fetch.js +100 -0
  55. package/lib/types/index.d.ts +15 -0
  56. package/lib/types/index.js +3 -0
  57. package/lib/types/local-transcribe.d.ts +171 -0
  58. package/lib/types/local-transcribe.js +473 -0
  59. package/lib/types/media-host.d.ts +91 -0
  60. package/lib/types/media-host.js +230 -0
  61. package/lib/types/media.d.ts +100 -0
  62. package/lib/types/media.js +207 -0
  63. package/lib/types/notes-types.d.ts +158 -0
  64. package/lib/types/notes-types.js +8 -0
  65. package/lib/types/notes.d.ts +213 -0
  66. package/lib/types/notes.js +718 -0
  67. package/lib/types/polish.d.ts +41 -0
  68. package/lib/types/polish.js +136 -0
  69. package/lib/types/remote.d.ts +118 -0
  70. package/lib/types/remote.js +151 -1
  71. package/lib/types/service.d.ts +81 -6
  72. package/lib/types/service.js +286 -65
  73. package/lib/types/skill.d.ts +20 -0
  74. package/lib/types/skill.js +78 -0
  75. package/lib/types/store.js +30 -8
  76. package/lib/types/tool.d.ts +23 -0
  77. package/lib/types/tool.js +40 -0
  78. package/lib/types/tools.d.ts +69 -0
  79. package/lib/types/tools.js +400 -0
  80. package/lib/types/types.d.ts +90 -2
  81. package/lib/types/types.js +66 -0
  82. package/lib/types/xiaoyuzhou.d.ts +43 -0
  83. package/lib/types/xiaoyuzhou.js +134 -0
  84. package/lib/types/ytdlp.d.ts +111 -0
  85. package/lib/types/ytdlp.js +269 -0
  86. package/package.json +33 -20
  87. package/lib/invariant.js +0 -38
  88. package/lib/types/invariant.d.ts +0 -29
  89. package/lib/types/invariant.js +0 -38
@@ -0,0 +1,718 @@
1
+ /**
2
+ * The reader's notes: excerpts the reader kept from an article, the comment on
3
+ * each, which conversations discussed the article, and the article text the
4
+ * model reads when it does.
5
+ *
6
+ * Notes are what the reader OWNS, so they live on the host next to the rest of
7
+ * the reader's state (the state-boundaries note, rule 1) — but in their own
8
+ * file, `notes.json`, not inside `state.json`'s per-entry annotations. An
9
+ * annotation is shaped around the body cache: `boundAnnotations` evicts by
10
+ * body, `normalizeAnnotations` drops entries that carry nothing it knows. A
11
+ * note must outlive every one of those (the excerpt carries its own text), and
12
+ * keeping it out of that machinery is what makes "the body was evicted" and
13
+ * "the note is gone" two unrelated events.
14
+ *
15
+ * The article text is a second, separate concern: the host never parses an
16
+ * article (the browser half does), and a feed entry's text never reaches the
17
+ * host at all. So the browser hands over a SNAPSHOT — the article's original
18
+ * prose, one string per top-level block, numbered the way the pane numbers its
19
+ * blocks — and the model reads that. Snapshots are capped and pruned; notes
20
+ * are never pruned.
21
+ *
22
+ * Persistence mirrors `store.ts`: native fs at the deployment's state root,
23
+ * atomic temp-file-and-rename writes, and an in-memory document when the root
24
+ * is not writable. Writes are serialized through one promise chain, so two
25
+ * gestures landing together never read the same version and lose one.
26
+ *
27
+ * @module @khorsheed/dsh-reader/notes
28
+ */
29
+ import { ReaderPolishStore, sourceKey } from "./polish.js";
30
+ import { createHash } from 'node:crypto';
31
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
32
+ import { join } from 'node:path';
33
+ import { resolveReaderStateRoot } from "./store.js";
34
+ /** The notes document's file name inside the state root. */
35
+ export const READER_NOTES_FILE_NAME = 'notes.json';
36
+ /** Where article snapshots live, inside the state root. */
37
+ export const READER_SNAPSHOTS_DIR = 'snapshots';
38
+ /** Most notes one article keeps (a cap against a runaway client, not a quota anyone reaches). */
39
+ export const MAX_NOTES_PER_ENTRY = 500;
40
+ /** Most conversations one article remembers. */
41
+ export const MAX_SESSIONS_PER_ENTRY = 50;
42
+ /** Snapshots kept for articles with no notes and no discussions (the most recent ones). */
43
+ export const MAX_LOOSE_SNAPSHOTS = 30;
44
+ /** The cap on one snapshot's total characters (a long paper fits; a book does not). */
45
+ export const MAX_SNAPSHOT_CHARS = 400_000;
46
+ /** Most lines under one note. */
47
+ export const MAX_REPLIES_PER_NOTE = 50;
48
+ /** Most discussion notes one article keeps (the oldest go first). */
49
+ export const MAX_DISCUSSIONS_PER_ENTRY = 100;
50
+ /** Most points in one discussion note. */
51
+ export const MAX_POINTS = 30;
52
+ /** The cap on one reply or one discussion point. */
53
+ export const MAX_POINT_CHARS = 4_000;
54
+ /** The cap on one excerpt. */
55
+ export const MAX_QUOTE_CHARS = 2_000;
56
+ /** The cap on one comment. */
57
+ export const MAX_COMMENT_CHARS = 4_000;
58
+ /** The empty document. */
59
+ export function emptyNotesDoc() {
60
+ return { version: 1, entries: {}, snapshots: {} };
61
+ }
62
+ /** A string field, trimmed and capped, or `undefined` when empty. */
63
+ function text(value, cap) {
64
+ if (typeof value !== 'string')
65
+ return undefined;
66
+ const trimmed = value.trim();
67
+ return trimmed.length === 0 ? undefined : trimmed.slice(0, cap);
68
+ }
69
+ /** A timestamp field, or the epoch when it is missing. */
70
+ function stamp(value) {
71
+ return typeof value === 'string' && value.length > 0 ? value : new Date(0).toISOString();
72
+ }
73
+ /** One note, when it carries an id and an excerpt. */
74
+ function normalizeNote(value) {
75
+ if (typeof value !== 'object' || value === null)
76
+ return undefined;
77
+ const record = value;
78
+ const id = text(record.id, 20);
79
+ const anchor = record.anchor;
80
+ const quote = text(anchor?.quote, MAX_QUOTE_CHARS);
81
+ if (id === undefined || quote === undefined)
82
+ return undefined;
83
+ const block = typeof anchor?.block === 'number' && Number.isInteger(anchor.block) && anchor.block >= 0 ? anchor.block : 0;
84
+ const start = typeof anchor?.start === 'number' && Number.isInteger(anchor.start) && anchor.start >= 0 ? anchor.start : undefined;
85
+ const translated = text(anchor?.translated, MAX_QUOTE_CHARS);
86
+ const time = typeof anchor?.time === 'number' && Number.isFinite(anchor.time) && anchor.time >= 0 ? anchor.time : undefined;
87
+ const comment = text(record.comment, MAX_COMMENT_CHARS);
88
+ const replies = Array.isArray(record.replies)
89
+ ? record.replies.map(normalizeReply).filter((reply) => reply !== undefined).slice(-MAX_REPLIES_PER_NOTE)
90
+ : [];
91
+ return {
92
+ id,
93
+ anchor: { block, ...(start === undefined ? {} : { start }), quote, ...(translated === undefined ? {} : { translated }), ...(time === undefined ? {} : { time }) },
94
+ ...(comment === undefined ? {} : { comment }),
95
+ ...(replies.length === 0 ? {} : { replies }),
96
+ ...(normalizeSynced(record.syncedTo).length === 0 ? {} : { syncedTo: normalizeSynced(record.syncedTo) }),
97
+ createdAt: stamp(record.createdAt),
98
+ updatedAt: stamp(record.updatedAt ?? record.createdAt),
99
+ };
100
+ }
101
+ /** The canvas cards an item was synced to (bounded, malformed rows dropped). */
102
+ function normalizeSynced(value) {
103
+ if (!Array.isArray(value))
104
+ return [];
105
+ return value.flatMap(item => {
106
+ if (typeof item !== 'object' || item === null)
107
+ return [];
108
+ const record = item;
109
+ const canvasId = text(record.canvasId, 200);
110
+ const cardId = text(record.cardId, 200);
111
+ if (canvasId === undefined || cardId === undefined)
112
+ return [];
113
+ return [{ canvasId, cardId, canvasTitle: text(record.canvasTitle, 200) ?? canvasId, at: stamp(record.at) }];
114
+ }).slice(-20);
115
+ }
116
+ /** One line under a note, when it has text. */
117
+ function normalizeReply(value) {
118
+ if (typeof value !== 'object' || value === null)
119
+ return undefined;
120
+ const record = value;
121
+ const body = text(record.text, MAX_POINT_CHARS);
122
+ if (body === undefined)
123
+ return undefined;
124
+ const sessionId = text(record.sessionId, 200);
125
+ return {
126
+ by: record.by === 'agent' ? 'agent' : 'user',
127
+ text: body,
128
+ ...(sessionId === undefined ? {} : { sessionId }),
129
+ ...(record.editedFrom === 'agent' ? { editedFrom: 'agent' } : {}),
130
+ at: stamp(record.at),
131
+ };
132
+ }
133
+ /** Bound a discussion's points: trimmed, capped, ¶ kept only when it is a positive integer. */
134
+ export function boundPoints(points) {
135
+ const out = [];
136
+ for (const item of points) {
137
+ if (typeof item !== 'object' || item === null)
138
+ continue;
139
+ const record = item;
140
+ const body = text(record.text, MAX_POINT_CHARS);
141
+ if (body === undefined)
142
+ continue;
143
+ const para = typeof record.para === 'number' && Number.isInteger(record.para) && record.para > 0 ? record.para : undefined;
144
+ out.push({ text: body, ...(para === undefined ? {} : { para }) });
145
+ if (out.length >= MAX_POINTS)
146
+ break;
147
+ }
148
+ return out;
149
+ }
150
+ /** One discussion note, when it has an id and at least one point. */
151
+ function normalizeDiscussion(value) {
152
+ if (typeof value !== 'object' || value === null)
153
+ return undefined;
154
+ const record = value;
155
+ const id = text(record.id, 20);
156
+ const points = Array.isArray(record.points) ? boundPoints(record.points) : [];
157
+ if (id === undefined || points.length === 0)
158
+ return undefined;
159
+ const sessionId = text(record.sessionId, 200);
160
+ const aside = text(record.aside, 300);
161
+ return {
162
+ id,
163
+ kind: record.kind === 'check' ? 'check' : 'discussion',
164
+ ...(sessionId === undefined ? {} : { sessionId }),
165
+ title: text(record.title, 200) ?? '',
166
+ points,
167
+ ...(aside === undefined ? {} : { aside }),
168
+ ...(normalizeSynced(record.syncedTo).length === 0 ? {} : { syncedTo: normalizeSynced(record.syncedTo) }),
169
+ createdAt: stamp(record.createdAt),
170
+ };
171
+ }
172
+ /** One article's notes, when it has an id and a title. */
173
+ function normalizeEntry(entryId, value) {
174
+ if (typeof value !== 'object' || value === null)
175
+ return undefined;
176
+ const record = value;
177
+ const title = text(record.title, 300) ?? entryId;
178
+ const notes = Array.isArray(record.notes)
179
+ ? record.notes.map(normalizeNote).filter((note) => note !== undefined).slice(0, MAX_NOTES_PER_ENTRY)
180
+ : [];
181
+ const sessions = Array.isArray(record.sessions)
182
+ ? record.sessions.flatMap(item => {
183
+ if (typeof item !== 'object' || item === null)
184
+ return [];
185
+ const row = item;
186
+ const sessionId = text(row.sessionId, 200);
187
+ if (sessionId === undefined)
188
+ return [];
189
+ const sessionTitle = text(row.title, 200);
190
+ return [{ sessionId, ...(sessionTitle === undefined ? {} : { title: sessionTitle }), lastAt: stamp(row.lastAt) }];
191
+ }).slice(0, MAX_SESSIONS_PER_ENTRY)
192
+ : [];
193
+ const discussions = Array.isArray(record.discussions)
194
+ ? record.discussions.map(normalizeDiscussion).filter((item) => item !== undefined).slice(0, MAX_DISCUSSIONS_PER_ENTRY)
195
+ : [];
196
+ if (notes.length === 0 && sessions.length === 0 && discussions.length === 0)
197
+ return undefined;
198
+ // The counters can only move forward: a hand-edited document that lowered
199
+ // one would hand out an id that is already taken.
200
+ const highest = notes.reduce((max, note) => Math.max(max, Number(/^n(\d+)$/.exec(note.id)?.[1] ?? 0)), 0);
201
+ const stored = typeof record.nextNote === 'number' && Number.isInteger(record.nextNote) ? record.nextNote : 1;
202
+ const highestDiscussion = discussions.reduce((max, item) => Math.max(max, Number(/^d(\d+)$/.exec(item.id)?.[1] ?? 0)), 0);
203
+ const storedDiscussion = typeof record.nextDiscussion === 'number' && Number.isInteger(record.nextDiscussion) ? record.nextDiscussion : 1;
204
+ const url = text(record.url, 2000);
205
+ const source = text(record.source, 200);
206
+ return {
207
+ entryId,
208
+ title,
209
+ ...(url === undefined ? {} : { url }),
210
+ ...(source === undefined ? {} : { source }),
211
+ notes,
212
+ sessions,
213
+ ...(discussions.length === 0 ? {} : { discussions }),
214
+ nextNote: Math.max(stored, highest + 1),
215
+ nextDiscussion: Math.max(storedDiscussion, highestDiscussion + 1),
216
+ updatedAt: stamp(record.updatedAt),
217
+ };
218
+ }
219
+ /**
220
+ * Coerce a parsed value into a notes document, dropping what it cannot read.
221
+ *
222
+ * @param value - the parsed file.
223
+ * @returns the document.
224
+ */
225
+ export function normalizeNotesDoc(value) {
226
+ if (typeof value !== 'object' || value === null)
227
+ return emptyNotesDoc();
228
+ const record = value;
229
+ const entries = {};
230
+ if (typeof record.entries === 'object' && record.entries !== null) {
231
+ for (const [entryId, entry] of Object.entries(record.entries)) {
232
+ if (entryId.length === 0)
233
+ continue;
234
+ const normalized = normalizeEntry(entryId, entry);
235
+ if (normalized !== undefined)
236
+ entries[entryId] = normalized;
237
+ }
238
+ }
239
+ const snapshots = {};
240
+ if (typeof record.snapshots === 'object' && record.snapshots !== null) {
241
+ for (const [entryId, row] of Object.entries(record.snapshots)) {
242
+ if (typeof row !== 'object' || row === null)
243
+ continue;
244
+ const item = row;
245
+ const file = typeof item.file === 'string' && /^[0-9a-f]{32}\.json$/.test(item.file) ? item.file : undefined;
246
+ if (file === undefined)
247
+ continue;
248
+ snapshots[entryId] = {
249
+ file,
250
+ chars: typeof item.chars === 'number' && Number.isFinite(item.chars) ? item.chars : 0,
251
+ capturedAt: stamp(item.capturedAt),
252
+ };
253
+ }
254
+ }
255
+ return { version: 1, entries, snapshots };
256
+ }
257
+ /**
258
+ * Bound a snapshot's paragraphs: whitespace collapsed, empty blocks kept as
259
+ * empty strings (so ¶ numbers stay aligned with the pane's blocks), and the
260
+ * whole capped — cut at a paragraph boundary, with the cut reported.
261
+ *
262
+ * @param paragraphs - the blocks' original text.
263
+ * @returns the bounded paragraphs and whether anything was dropped.
264
+ */
265
+ export function boundParagraphs(paragraphs) {
266
+ const out = [];
267
+ let total = 0;
268
+ for (const raw of paragraphs) {
269
+ const block = typeof raw === 'string' ? raw.replace(/\s+/g, ' ').trim() : '';
270
+ if (total + block.length > MAX_SNAPSHOT_CHARS)
271
+ return { paragraphs: out, truncated: true };
272
+ out.push(block);
273
+ total += block.length;
274
+ }
275
+ return { paragraphs: out, truncated: false };
276
+ }
277
+ /** The bare file name one entry's snapshot lives under. */
278
+ function snapshotFileName(entryId) {
279
+ return `${createHash('sha1').update(entryId).digest('hex').slice(0, 32)}.json`;
280
+ }
281
+ /** A native fs failure's message, with its code. */
282
+ function errorMessage(error) {
283
+ const code = error?.code;
284
+ const message = error instanceof Error ? error.message : String(error);
285
+ return code === undefined ? message : `${code}: ${message}`;
286
+ }
287
+ /** Write a file atomically (temp file, then rename). */
288
+ function writeAtomic(target, content) {
289
+ const temporary = `${target}.${process.pid}.tmp`;
290
+ try {
291
+ writeFileSync(temporary, content);
292
+ renameSync(temporary, target);
293
+ }
294
+ catch (error) {
295
+ rmSync(temporary, { force: true });
296
+ throw new Error(`reader: ${target} could not be written — ${errorMessage(error)}`);
297
+ }
298
+ }
299
+ /** The entry row a write lands on: the existing one refreshed with the latest identity, or a new one. */
300
+ function entryFor(doc, identity, now) {
301
+ const existing = doc.entries[identity.entryId];
302
+ const title = identity.title.trim().slice(0, 300) || existing?.title || identity.entryId;
303
+ const url = identity.url ?? existing?.url;
304
+ const source = identity.source ?? existing?.source;
305
+ return {
306
+ entryId: identity.entryId,
307
+ title,
308
+ ...(url === undefined || url === '' ? {} : { url }),
309
+ ...(source === undefined || source === '' ? {} : { source }),
310
+ notes: existing?.notes ?? [],
311
+ sessions: existing?.sessions ?? [],
312
+ ...(existing?.discussions === undefined ? {} : { discussions: existing.discussions }),
313
+ nextNote: existing?.nextNote ?? 1,
314
+ nextDiscussion: existing?.nextDiscussion ?? 1,
315
+ updatedAt: now,
316
+ };
317
+ }
318
+ /** The document with one entry replaced (or removed, when it no longer carries anything). */
319
+ function withEntry(doc, entry) {
320
+ const { [entry.entryId]: _previous, ...rest } = doc.entries;
321
+ return entry.notes.length === 0 && entry.sessions.length === 0 && (entry.discussions ?? []).length === 0
322
+ ? { ...doc, entries: rest }
323
+ : { ...doc, entries: { ...rest, [entry.entryId]: entry } };
324
+ }
325
+ /** The reader's notes store. */
326
+ export class ReaderNotesStore {
327
+ root;
328
+ file;
329
+ snapshotsDir;
330
+ unwritable;
331
+ /** The document of record when the root is not writable. */
332
+ memoryDoc;
333
+ /** Snapshots kept in memory when the root is not writable. */
334
+ memorySnapshots = new Map();
335
+ /** The write queue: every mutation runs after the previous one settled. */
336
+ queue = Promise.resolve();
337
+ /** The agent's 润色 translations, per article (keyed to the snapshot's text). */
338
+ polish;
339
+ /**
340
+ * @param config - the same state-root override the reader's main store takes.
341
+ */
342
+ constructor(config = {}) {
343
+ this.root = resolveReaderStateRoot(config.stateRoot);
344
+ this.file = join(this.root, READER_NOTES_FILE_NAME);
345
+ this.snapshotsDir = join(this.root, READER_SNAPSHOTS_DIR);
346
+ this.polish = new ReaderPolishStore(this.root);
347
+ }
348
+ /** An article's 润色 layer, when it belongs to the text captured now. */
349
+ polishView(entryId) {
350
+ const snapshot = this.readSnapshot(entryId);
351
+ return this.polish.view(entryId, snapshot === undefined ? undefined : sourceKey(snapshot.paragraphs));
352
+ }
353
+ /** Turn one paragraph back to the machine translation, or to the 润色 again. */
354
+ setPolishOff(entryId, paragraph, off) {
355
+ const snapshot = this.readSnapshot(entryId);
356
+ return this.polish.setOff(entryId, snapshot === undefined ? undefined : sourceKey(snapshot.paragraphs), paragraph, off);
357
+ }
358
+ /** Whether the notes persist (false means memory-only, for this process). */
359
+ get available() {
360
+ if (this.unwritable !== undefined)
361
+ return false;
362
+ try {
363
+ mkdirSync(this.root, { recursive: true });
364
+ return true;
365
+ }
366
+ catch (error) {
367
+ this.unwritable = `${this.root}: ${errorMessage(error)}`;
368
+ return false;
369
+ }
370
+ }
371
+ /** Read the document; a missing or unreadable file reads as empty. */
372
+ read() {
373
+ if (!this.available)
374
+ return this.memoryDoc ?? emptyNotesDoc();
375
+ try {
376
+ return normalizeNotesDoc(JSON.parse(readFileSync(this.file, 'utf8')));
377
+ }
378
+ catch (error) {
379
+ if (error.code === 'ENOENT')
380
+ return emptyNotesDoc();
381
+ // A notes file that does not parse is the reader's work: refuse to write
382
+ // over it rather than start a fresh one on top.
383
+ throw new Error(`reader: ${this.file} does not parse — fix or remove it by hand`);
384
+ }
385
+ }
386
+ /** Run one read-modify-write after every earlier one. */
387
+ update(mutate) {
388
+ const run = this.queue.then(() => {
389
+ const { doc, result } = mutate(this.read());
390
+ if (this.available) {
391
+ writeAtomic(this.file, `${JSON.stringify(doc, null, 2)}\n`);
392
+ }
393
+ else {
394
+ this.memoryDoc = doc;
395
+ }
396
+ return result;
397
+ });
398
+ // A failed write must not wedge every later one behind it.
399
+ this.queue = run.catch(() => undefined);
400
+ return run;
401
+ }
402
+ /** One article's notes and discussions, oldest note first. */
403
+ entry(entryId) {
404
+ return this.read().entries[entryId];
405
+ }
406
+ /** Every article that carries notes or discussions. */
407
+ entries() {
408
+ return Object.values(this.read().entries);
409
+ }
410
+ /** How many notes each article carries (the wall's badge). */
411
+ counts() {
412
+ const counts = {};
413
+ for (const entry of Object.values(this.read().entries)) {
414
+ if (entry.notes.length > 0)
415
+ counts[entry.entryId] = entry.notes.length;
416
+ }
417
+ return counts;
418
+ }
419
+ /**
420
+ * Keep one excerpt.
421
+ *
422
+ * @param input - the article, the excerpt and where it sits, and the comment.
423
+ * @returns the new note, or why none was kept.
424
+ */
425
+ addNote(input) {
426
+ const quote = input.quote.replace(/\s+/g, ' ').trim().slice(0, MAX_QUOTE_CHARS);
427
+ if (quote.length === 0 || input.entryId.length === 0)
428
+ return Promise.resolve('empty');
429
+ return this.update(doc => {
430
+ const now = new Date().toISOString();
431
+ const entry = entryFor(doc, input, now);
432
+ if (entry.notes.length >= MAX_NOTES_PER_ENTRY)
433
+ return { doc, result: 'full' };
434
+ const translated = input.translated?.replace(/\s+/g, ' ').trim().slice(0, MAX_QUOTE_CHARS);
435
+ const comment = input.comment?.trim().slice(0, MAX_COMMENT_CHARS);
436
+ const note = {
437
+ id: `n${String(entry.nextNote)}`,
438
+ anchor: {
439
+ block: Number.isInteger(input.block) && input.block >= 0 ? input.block : 0,
440
+ ...(input.start !== undefined && Number.isInteger(input.start) && input.start >= 0 ? { start: input.start } : {}),
441
+ quote,
442
+ ...(translated === undefined || translated === '' ? {} : { translated }),
443
+ ...(input.time !== undefined && Number.isFinite(input.time) && input.time >= 0 ? { time: input.time } : {}),
444
+ },
445
+ ...(comment === undefined || comment === '' ? {} : { comment }),
446
+ createdAt: now,
447
+ updatedAt: now,
448
+ };
449
+ // Kept in article order, so the list reads the way the article does.
450
+ const notes = [...entry.notes, note].sort((left, right) => left.anchor.block - right.anchor.block || (left.anchor.start ?? 0) - (right.anchor.start ?? 0));
451
+ return { doc: withEntry(doc, { ...entry, notes, nextNote: entry.nextNote + 1 }), result: note };
452
+ });
453
+ }
454
+ /** Change (or clear) one note's comment. */
455
+ updateNote(request) {
456
+ return this.update(doc => {
457
+ const entry = doc.entries[request.entryId];
458
+ const note = entry?.notes.find(item => item.id === request.noteId);
459
+ if (entry === undefined || note === undefined)
460
+ return { doc, result: 'not-found' };
461
+ const comment = request.comment.trim().slice(0, MAX_COMMENT_CHARS);
462
+ const { comment: _old, ...rest } = note;
463
+ const next = { ...rest, ...(comment === '' ? {} : { comment }), updatedAt: new Date().toISOString() };
464
+ const notes = entry.notes.map(item => item.id === note.id ? next : item);
465
+ return { doc: withEntry(doc, { ...entry, notes, updatedAt: next.updatedAt }), result: 'ok' };
466
+ });
467
+ }
468
+ /** Delete one note (its id is not handed out again). */
469
+ deleteNote(request) {
470
+ return this.update(doc => {
471
+ const entry = doc.entries[request.entryId];
472
+ if (entry === undefined || !entry.notes.some(item => item.id === request.noteId))
473
+ return { doc, result: 'not-found' };
474
+ const notes = entry.notes.filter(item => item.id !== request.noteId);
475
+ return { doc: withEntry(doc, { ...entry, notes, updatedAt: new Date().toISOString() }), result: 'ok' };
476
+ });
477
+ }
478
+ /**
479
+ * Add one line under a note: a conclusion (by the model, when the reader asked
480
+ * for it) or the reader's own addition.
481
+ *
482
+ * @param request - the article, the note, the line and who wrote it.
483
+ */
484
+ addReply(request) {
485
+ const body = request.text.trim().slice(0, MAX_POINT_CHARS);
486
+ if (body.length === 0)
487
+ return Promise.resolve('empty');
488
+ return this.update(doc => {
489
+ const entry = doc.entries[request.entryId];
490
+ const note = entry?.notes.find(item => item.id === request.noteId);
491
+ if (entry === undefined || note === undefined)
492
+ return { doc, result: 'not-found' };
493
+ const replies = note.replies ?? [];
494
+ if (replies.length >= MAX_REPLIES_PER_NOTE)
495
+ return { doc, result: 'full' };
496
+ const now = new Date().toISOString();
497
+ const reply = {
498
+ by: request.by,
499
+ text: body,
500
+ ...(request.sessionId === undefined || request.sessionId === '' ? {} : { sessionId: request.sessionId }),
501
+ at: now,
502
+ };
503
+ const notes = entry.notes.map(item => item.id === note.id ? { ...item, replies: [...replies, reply], updatedAt: now } : item);
504
+ return { doc: withEntry(doc, { ...entry, notes, updatedAt: now }), result: 'ok' };
505
+ });
506
+ }
507
+ /**
508
+ * Rewrite (or, with empty text, remove) one line under a note. A line the
509
+ * model wrote becomes the reader's, marked as rewritten from the model's.
510
+ */
511
+ editReply(request) {
512
+ return this.update(doc => {
513
+ const entry = doc.entries[request.entryId];
514
+ const note = entry?.notes.find(item => item.id === request.noteId);
515
+ const replies = note?.replies ?? [];
516
+ const old = replies[request.index];
517
+ if (entry === undefined || note === undefined || old === undefined)
518
+ return { doc, result: 'not-found' };
519
+ const body = request.text.trim().slice(0, MAX_POINT_CHARS);
520
+ const now = new Date().toISOString();
521
+ const next = body === ''
522
+ ? replies.filter((_, index) => index !== request.index)
523
+ : replies.map((item, index) => index !== request.index ? item : {
524
+ ...item,
525
+ by: 'user',
526
+ text: body,
527
+ ...(item.by === 'agent' || item.editedFrom === 'agent' ? { editedFrom: 'agent' } : {}),
528
+ at: now,
529
+ });
530
+ const { replies: _old, ...rest } = note;
531
+ const notes = entry.notes.map(item => item.id === note.id ? { ...rest, ...(next.length === 0 ? {} : { replies: next }), updatedAt: now } : item);
532
+ return { doc: withEntry(doc, { ...entry, notes, updatedAt: now }), result: 'ok' };
533
+ });
534
+ }
535
+ /**
536
+ * Keep one discussion note (newest first). The conversation it came from is
537
+ * remembered on the article too.
538
+ *
539
+ * @param input - the article, the kind, the points.
540
+ * @returns the new discussion note, or why none was kept.
541
+ */
542
+ addDiscussion(input) {
543
+ const points = boundPoints(input.points);
544
+ if (points.length === 0 || input.entryId.length === 0)
545
+ return Promise.resolve('empty');
546
+ return this.update(doc => {
547
+ const now = new Date().toISOString();
548
+ const entry = entryFor(doc, input, now);
549
+ const number = entry.nextDiscussion ?? 1;
550
+ const aside = input.aside?.trim().slice(0, 300);
551
+ const sessionId = input.sessionId?.trim();
552
+ const discussion = {
553
+ id: `d${String(number)}`,
554
+ kind: input.kind === 'check' ? 'check' : 'discussion',
555
+ ...(sessionId === undefined || sessionId === '' ? {} : { sessionId }),
556
+ title: input.heading?.trim().slice(0, 200) ?? '',
557
+ points,
558
+ ...(aside === undefined || aside === '' ? {} : { aside }),
559
+ createdAt: now,
560
+ };
561
+ const sessions = sessionId === undefined || sessionId === ''
562
+ ? entry.sessions
563
+ : [
564
+ entry.sessions.find(item => item.sessionId === sessionId) === undefined
565
+ ? { sessionId, lastAt: now }
566
+ : { ...entry.sessions.find(item => item.sessionId === sessionId), lastAt: now },
567
+ ...entry.sessions.filter(item => item.sessionId !== sessionId),
568
+ ].slice(0, MAX_SESSIONS_PER_ENTRY);
569
+ const discussions = [discussion, ...(entry.discussions ?? [])].slice(0, MAX_DISCUSSIONS_PER_ENTRY);
570
+ return { doc: withEntry(doc, { ...entry, sessions, discussions, nextDiscussion: number + 1 }), result: discussion };
571
+ });
572
+ }
573
+ /**
574
+ * Remember that a note (or a discussion note) became a canvas card, so the
575
+ * notes face can say where it went and not offer the same sync twice blindly.
576
+ */
577
+ markSynced(request) {
578
+ return this.update(doc => {
579
+ const entry = doc.entries[request.entryId];
580
+ if (entry === undefined)
581
+ return { doc, result: 'not-found' };
582
+ const now = new Date().toISOString();
583
+ const card = { canvasId: request.canvasId, cardId: request.cardId, canvasTitle: request.canvasTitle.slice(0, 200), at: now };
584
+ if (request.noteId !== undefined) {
585
+ if (!entry.notes.some(item => item.id === request.noteId))
586
+ return { doc, result: 'not-found' };
587
+ const notes = entry.notes.map(item => item.id === request.noteId ? { ...item, syncedTo: [...(item.syncedTo ?? []), card].slice(-20) } : item);
588
+ return { doc: withEntry(doc, { ...entry, notes }), result: 'ok' };
589
+ }
590
+ const discussions = entry.discussions ?? [];
591
+ if (request.discussionId === undefined || !discussions.some(item => item.id === request.discussionId))
592
+ return { doc, result: 'not-found' };
593
+ const next = discussions.map(item => item.id === request.discussionId ? { ...item, syncedTo: [...(item.syncedTo ?? []), card].slice(-20) } : item);
594
+ return { doc: withEntry(doc, { ...entry, discussions: next }), result: 'ok' };
595
+ });
596
+ }
597
+ /** Delete one discussion note. */
598
+ deleteDiscussion(request) {
599
+ return this.update(doc => {
600
+ const entry = doc.entries[request.entryId];
601
+ if (entry === undefined || !(entry.discussions ?? []).some(item => item.id === request.discussionId))
602
+ return { doc, result: 'not-found' };
603
+ const discussions = (entry.discussions ?? []).filter(item => item.id !== request.discussionId);
604
+ return { doc: withEntry(doc, { ...entry, discussions, updatedAt: new Date().toISOString() }), result: 'ok' };
605
+ });
606
+ }
607
+ /**
608
+ * The article this conversation discussed most recently, when there is one —
609
+ * where a model's write lands when it names no article.
610
+ */
611
+ sessionEntry(sessionId) {
612
+ let best;
613
+ for (const entry of Object.values(this.read().entries)) {
614
+ const row = entry.sessions.find(item => item.sessionId === sessionId);
615
+ if (row !== undefined && (best === undefined || row.lastAt > best.at))
616
+ best = { entryId: entry.entryId, at: row.lastAt };
617
+ }
618
+ return best?.entryId;
619
+ }
620
+ /**
621
+ * Remember that a conversation discussed this article (most recent first).
622
+ *
623
+ * @param request - the article and the conversation.
624
+ */
625
+ recordSession(request) {
626
+ const sessionId = request.sessionId.trim();
627
+ if (sessionId.length === 0 || request.entryId.length === 0)
628
+ return Promise.resolve('empty');
629
+ return this.update(doc => {
630
+ const now = new Date().toISOString();
631
+ const entry = entryFor(doc, request, now);
632
+ const previous = entry.sessions.find(item => item.sessionId === sessionId);
633
+ const title = request.sessionTitle?.trim().slice(0, 200) || previous?.title;
634
+ const session = { sessionId, ...(title === undefined || title === '' ? {} : { title }), lastAt: now };
635
+ const sessions = [session, ...entry.sessions.filter(item => item.sessionId !== sessionId)].slice(0, MAX_SESSIONS_PER_ENTRY);
636
+ return { doc: withEntry(doc, { ...entry, sessions }), result: 'ok' };
637
+ });
638
+ }
639
+ /**
640
+ * Keep the article's text for the model, replacing the previous snapshot.
641
+ *
642
+ * Loose snapshots (articles with no notes and no discussions) are pruned to
643
+ * the {@link MAX_LOOSE_SNAPSHOTS} most recent; the rest are kept as long as
644
+ * their article carries anything.
645
+ *
646
+ * @param request - the article and its blocks' original text.
647
+ */
648
+ snapshot(request) {
649
+ const { paragraphs, truncated } = boundParagraphs(request.paragraphs);
650
+ if (request.entryId.length === 0 || paragraphs.every(item => item.length === 0))
651
+ return Promise.resolve('empty');
652
+ const snapshot = {
653
+ entryId: request.entryId,
654
+ title: request.title.trim().slice(0, 300) || request.entryId,
655
+ ...(request.url === undefined || request.url === '' ? {} : { url: request.url }),
656
+ ...(request.source === undefined || request.source === '' ? {} : { source: request.source }),
657
+ paragraphs,
658
+ ...(truncated ? { truncated: true } : {}),
659
+ capturedAt: new Date().toISOString(),
660
+ };
661
+ return this.update(doc => {
662
+ if (!this.available) {
663
+ this.memorySnapshots.set(snapshot.entryId, snapshot);
664
+ return { doc, result: 'ok' };
665
+ }
666
+ mkdirSync(this.snapshotsDir, { recursive: true });
667
+ const file = snapshotFileName(snapshot.entryId);
668
+ writeAtomic(join(this.snapshotsDir, file), `${JSON.stringify(snapshot)}\n`);
669
+ const chars = paragraphs.reduce((sum, item) => sum + item.length, 0);
670
+ const snapshots = {
671
+ ...doc.snapshots,
672
+ [snapshot.entryId]: { file, chars, capturedAt: snapshot.capturedAt },
673
+ };
674
+ const loose = Object.entries(snapshots)
675
+ .filter(([entryId]) => doc.entries[entryId] === undefined)
676
+ .sort((left, right) => right[1].capturedAt.localeCompare(left[1].capturedAt));
677
+ for (const [entryId, row] of loose.slice(MAX_LOOSE_SNAPSHOTS)) {
678
+ delete snapshots[entryId];
679
+ rmSync(join(this.snapshotsDir, row.file), { force: true });
680
+ }
681
+ return { doc: { ...doc, snapshots }, result: 'ok' };
682
+ });
683
+ }
684
+ /** The article's text as last captured, when there is one. */
685
+ readSnapshot(entryId) {
686
+ if (!this.available)
687
+ return this.memorySnapshots.get(entryId);
688
+ const row = this.read().snapshots[entryId];
689
+ if (row === undefined)
690
+ return undefined;
691
+ try {
692
+ const parsed = JSON.parse(readFileSync(join(this.snapshotsDir, row.file), 'utf8'));
693
+ if (!Array.isArray(parsed.paragraphs))
694
+ return undefined;
695
+ return {
696
+ entryId,
697
+ title: text(parsed.title, 300) ?? entryId,
698
+ ...(typeof parsed.url === 'string' && parsed.url !== '' ? { url: parsed.url } : {}),
699
+ ...(typeof parsed.source === 'string' && parsed.source !== '' ? { source: parsed.source } : {}),
700
+ paragraphs: parsed.paragraphs.map(item => typeof item === 'string' ? item : ''),
701
+ ...(parsed.truncated === true ? { truncated: true } : {}),
702
+ capturedAt: stamp(parsed.capturedAt),
703
+ };
704
+ }
705
+ catch {
706
+ // A snapshot deleted by hand is no snapshot: the model is told the text is missing.
707
+ return undefined;
708
+ }
709
+ }
710
+ /** Every article with a snapshot, newest capture first. */
711
+ snapshotIds() {
712
+ if (!this.available)
713
+ return [...this.memorySnapshots.keys()].reverse();
714
+ return Object.entries(this.read().snapshots)
715
+ .sort((left, right) => right[1].capturedAt.localeCompare(left[1].capturedAt))
716
+ .map(([entryId]) => entryId);
717
+ }
718
+ }