@yeaft/webchat-agent 0.1.653 → 0.1.655

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.
@@ -1,886 +0,0 @@
1
- /**
2
- * store.js — File-backed ThreadStore for Yeaft Unify (task-307a).
3
- *
4
- * Promotes the Phase-1 in-memory stub to persist threads under
5
- * `~/.yeaft/threads/` so conversation structure survives agent restarts.
6
- * API is backward compatible with the task-299 canonical surface — callers
7
- * that used the in-memory version keep working without change, but a new
8
- * optional `yeaftDir` argument (to the constructor / `initThreadStore`)
9
- * switches persistence on.
10
- *
11
- * On-disk layout:
12
- * ~/.yeaft/threads/
13
- * index.md — Auto-generated overview (current thread id
14
- * + attachments table + thread summary list).
15
- * {threadId}.md — One markdown file per thread. YAML
16
- * frontmatter holds every cached field, the
17
- * body is the short preview.
18
- *
19
- * Write semantics:
20
- * - Every mutation schedules a debounced flush (8 ms) of the set of dirty
21
- * thread files and, when something changed, the index. A synchronous
22
- * `flush()` is exposed for tests and graceful shutdown.
23
- * - On construction we load any existing `{id}.md` files and rebuild the
24
- * in-memory map + attachments, so round-trips are a simple "close /
25
- * reopen".
26
- * - Read-only mode (e.g. when `~/.yeaft/` is not writable) silently skips
27
- * all filesystem writes — in-memory behaviour is preserved.
28
- *
29
- * Cached fields (task-299 contract, preserved verbatim):
30
- * messageCount / lastMessageAt / lastActivityAt / archived / unread /
31
- * preview. These all persist to the YAML frontmatter so ListThreads never
32
- * needs to scan messages after a restart.
33
- *
34
- * A single "main" thread is always present after init — either loaded from
35
- * disk or synthesised as a fresh record when the directory is empty.
36
- */
37
-
38
- import {
39
- existsSync,
40
- mkdirSync,
41
- readdirSync,
42
- readFileSync,
43
- unlinkSync,
44
- writeFileSync,
45
- } from 'fs';
46
- import { join } from 'path';
47
- import { randomUUID } from 'crypto';
48
-
49
- /** Default / root thread id — every fresh ThreadStore has one. */
50
- export const MAIN_THREAD_ID = 'main';
51
-
52
- /** Valid thread status values. Mirrors design doc §5. */
53
- export const THREAD_STATUSES = ['active', 'idle', 'archived'];
54
-
55
- /**
56
- * task-318: Coerce an idle-archive days input to a non-negative integer
57
- * (0 disables the feature). Anything invalid falls through to 0 so a
58
- * malformed config can never silently enable auto-archive.
59
- */
60
- function normaliseIdleDays(n) {
61
- if (n === null || n === undefined) return 0;
62
- const v = Number(n);
63
- if (!Number.isFinite(v) || v <= 0) return 0;
64
- return Math.floor(v);
65
- }
66
-
67
- /** Debounce window for grouped disk writes. Kept short so tests don't hang. */
68
- const FLUSH_DEBOUNCE_MS = 8;
69
-
70
- // ─── YAML (de)serialisation ──────────────────────────────────────────────
71
-
72
- /**
73
- * Serialise a thread record to Markdown with YAML frontmatter.
74
- *
75
- * Only scalar / boolean / number / null frontmatter values are emitted.
76
- * Strings that span multiple lines or contain leading whitespace get folded
77
- * into a single-line value (threads' free-text goes in the body).
78
- *
79
- * @param {object} t
80
- * @returns {string}
81
- */
82
- function serializeThread(t) {
83
- const forkedFrom = serializeForkedFrom(t.forkedFrom);
84
- const fm = [
85
- '---',
86
- `id: ${t.id}`,
87
- `name: ${escapeScalar(t.name)}`,
88
- `goal: ${escapeScalar(t.goal || '')}`,
89
- `parentThreadId: ${t.parentThreadId == null ? 'null' : t.parentThreadId}`,
90
- `status: ${t.status}`,
91
- `archived: ${t.archived ? 'true' : 'false'}`,
92
- `mergedInto: ${t.mergedInto == null ? 'null' : t.mergedInto}`,
93
- `forkedFrom: ${forkedFrom}`,
94
- `messageCount: ${t.messageCount | 0}`,
95
- `lastMessageAt: ${t.lastMessageAt == null ? 'null' : t.lastMessageAt}`,
96
- `lastActivityAt: ${t.lastActivityAt == null ? 'null' : t.lastActivityAt}`,
97
- `unread: ${t.unread | 0}`,
98
- `createdAt: ${t.createdAt}`,
99
- `updatedAt: ${t.updatedAt}`,
100
- '---',
101
- '',
102
- ];
103
- // Body is the preview (wrapped so the file remains human-readable).
104
- if (t.preview) fm.push(t.preview);
105
- return fm.join('\n') + '\n';
106
- }
107
-
108
- /**
109
- * Serialise `forkedFrom` as a single-line scalar so the YAML stays flat.
110
- * Shape: `{threadId}|{messageId}|{timestamp}`. Null becomes literal `null`.
111
- */
112
- function serializeForkedFrom(ff) {
113
- if (!ff || typeof ff !== 'object') return 'null';
114
- if (!ff.threadId || !ff.messageId) return 'null';
115
- const ts = Number.isFinite(ff.timestamp) ? ff.timestamp : 0;
116
- return `${ff.threadId}|${ff.messageId}|${ts}`;
117
- }
118
-
119
- function parseForkedFrom(raw) {
120
- if (!raw || raw === 'null') return null;
121
- const parts = String(raw).split('|');
122
- if (parts.length < 2) return null;
123
- const [threadId, messageId, tsStr] = parts;
124
- if (!threadId || !messageId) return null;
125
- const ts = parseInt(tsStr || '0', 10);
126
- return {
127
- threadId,
128
- messageId,
129
- timestamp: Number.isFinite(ts) ? ts : 0,
130
- };
131
- }
132
-
133
- function escapeScalar(v) {
134
- if (v == null) return '';
135
- // Keep on one physical line; any embedded newline becomes a space so YAML
136
- // stays flat.
137
- return String(v).replace(/\s+/g, ' ').trim();
138
- }
139
-
140
- /**
141
- * Parse the markdown-with-frontmatter produced by `serializeThread`. Returns
142
- * null when the file is malformed; callers should skip it silently so one
143
- * corrupt thread file never blocks recovery of the rest.
144
- *
145
- * @param {string} raw
146
- * @returns {object|null}
147
- */
148
- function parseThread(raw) {
149
- if (!raw || !raw.startsWith('---')) return null;
150
- const end = raw.indexOf('\n---', 3);
151
- if (end === -1) return null;
152
- const fm = raw.slice(3, end).trim();
153
- const body = raw.slice(end + 4).replace(/^\n/, '').trimEnd();
154
- const record = {};
155
- for (const line of fm.split('\n')) {
156
- const idx = line.indexOf(':');
157
- if (idx === -1) continue;
158
- const key = line.slice(0, idx).trim();
159
- const rawVal = line.slice(idx + 1).trim();
160
- if (!key) continue;
161
- if (rawVal === 'null' || rawVal === '') {
162
- record[key] = null;
163
- } else if (rawVal === 'true' || rawVal === 'false') {
164
- record[key] = rawVal === 'true';
165
- } else if (/^-?\d+$/.test(rawVal)) {
166
- record[key] = parseInt(rawVal, 10);
167
- } else {
168
- record[key] = rawVal;
169
- }
170
- }
171
- if (!record.id || !record.name) return null;
172
- // Default to safe values if the file pre-dates a field.
173
- if (!THREAD_STATUSES.includes(record.status)) record.status = 'active';
174
- record.archived = record.status === 'archived';
175
- if (!('mergedInto' in record)) record.mergedInto = null;
176
- // forkedFrom is a packed scalar — decode back to {threadId, messageId, timestamp}.
177
- record.forkedFrom = parseForkedFrom(record.forkedFrom);
178
- record.messageCount = Number.isFinite(record.messageCount) ? record.messageCount : 0;
179
- record.unread = Number.isFinite(record.unread) ? record.unread : 0;
180
- record.preview = body;
181
- record.lastActivityAt = record.lastActivityAt ?? record.lastMessageAt ?? null;
182
- return record;
183
- }
184
-
185
- /**
186
- * Generate `index.md` — a human-readable roll-up of all threads in the
187
- * store, the current thread marker, and attachments. Parsers should NOT
188
- * depend on this file; it exists for human inspection and crash-triage.
189
- */
190
- function generateIndex(threads, currentId, attachments) {
191
- const now = new Date().toISOString();
192
- const lines = [
193
- '---',
194
- `currentId: ${currentId}`,
195
- `totalThreads: ${threads.size}`,
196
- `lastUpdated: ${now}`,
197
- '---',
198
- '# Thread Index',
199
- '',
200
- '| ID | Name | Status | Messages | Last Activity |',
201
- '|----|------|--------|----------|---------------|',
202
- ];
203
- for (const t of threads.values()) {
204
- const stamp = t.lastActivityAt
205
- ? new Date(t.lastActivityAt).toISOString().slice(0, 19).replace('T', ' ')
206
- : '-';
207
- lines.push(`| ${t.id} | ${t.name} | ${t.status} | ${t.messageCount} | ${stamp} |`);
208
- }
209
- if (attachments.size > 0) {
210
- lines.push('');
211
- lines.push('## Attachments');
212
- lines.push('');
213
- lines.push('| Thread | Feature |');
214
- lines.push('|--------|---------|');
215
- for (const [threadId, featureId] of attachments.entries()) {
216
- lines.push(`| ${threadId} | ${featureId} |`);
217
- }
218
- }
219
- return lines.join('\n') + '\n';
220
- }
221
-
222
- /**
223
- * Serialise the attachments map to a stable JSON payload (array form so key
224
- * order is preserved on reload). Stored separately from `index.md` so the
225
- * human-readable index stays cosmetic.
226
- */
227
- function serializeAttachments(attachments) {
228
- return JSON.stringify(
229
- [...attachments.entries()].map(([threadId, featureId]) => ({ threadId, featureId })),
230
- null,
231
- 2,
232
- ) + '\n';
233
- }
234
-
235
- function parseAttachments(raw) {
236
- try {
237
- const arr = JSON.parse(raw);
238
- if (!Array.isArray(arr)) return [];
239
- return arr.filter(
240
- (e) => e && typeof e.threadId === 'string' && typeof e.featureId === 'string',
241
- );
242
- } catch {
243
- return [];
244
- }
245
- }
246
-
247
- // ─── ThreadStore class ───────────────────────────────────────────────────
248
-
249
- /**
250
- * @typedef {Object} Thread
251
- * @property {string} id
252
- * @property {string} name
253
- * @property {string} [goal]
254
- * @property {string|null} parentThreadId
255
- * @property {'active'|'idle'|'archived'} status
256
- * @property {number} messageCount
257
- * @property {number|null} lastMessageAt
258
- * @property {number|null} lastActivityAt
259
- * @property {boolean} archived
260
- * @property {number} unread
261
- * @property {string} preview
262
- * @property {number} createdAt
263
- * @property {number} updatedAt
264
- */
265
-
266
- export class ThreadStore {
267
- /** @type {Map<string, Thread>} */
268
- #threads;
269
- /** @type {string} */
270
- #currentId;
271
- /** @type {Map<string, string>} threadId → featureId */
272
- #attachments;
273
-
274
- /** @type {string|null} */
275
- #dir;
276
- /** @type {string|null} */
277
- #indexPath;
278
- /** @type {string|null} */
279
- #attachmentsPath;
280
- /** @type {boolean} */
281
- #readOnly;
282
- /** @type {Set<string>} dirty thread ids pending flush */
283
- #dirtyThreads;
284
- /** @type {boolean} */
285
- #dirtyIndex;
286
- /** @type {boolean} */
287
- #dirtyAttachments;
288
- /** @type {any} NodeJS.Timeout */
289
- #flushTimer;
290
- /** @type {number} task-318: days of inactivity before auto-archive (0 = disabled) */
291
- #idleArchiveDays;
292
-
293
- /**
294
- * @param {string} [yeaftDir] — Base ~/.yeaft directory. Omit for in-memory mode.
295
- * @param {{ readOnly?: boolean, idleArchiveDays?: number }} [opts]
296
- */
297
- constructor(yeaftDir, opts = {}) {
298
- this.#threads = new Map();
299
- this.#attachments = new Map();
300
- this.#currentId = MAIN_THREAD_ID;
301
- this.#dirtyThreads = new Set();
302
- this.#dirtyIndex = false;
303
- this.#dirtyAttachments = false;
304
- this.#flushTimer = null;
305
-
306
- this.#readOnly = !!opts.readOnly;
307
- // task-318: idle-archive threshold (days). Consumed by the archive
308
- // pass in task-317; here we just accept + expose the knob so the
309
- // config plumbing is testable end-to-end today. A value ≤ 0 means
310
- // "no auto-archive" (feature disabled).
311
- this.#idleArchiveDays = normaliseIdleDays(opts.idleArchiveDays);
312
- if (yeaftDir) {
313
- this.#dir = join(yeaftDir, 'threads');
314
- this.#indexPath = join(this.#dir, 'index.md');
315
- this.#attachmentsPath = join(this.#dir, 'attachments.json');
316
- if (!this.#readOnly) {
317
- try {
318
- if (!existsSync(this.#dir)) mkdirSync(this.#dir, { recursive: true });
319
- } catch {
320
- this.#readOnly = true;
321
- }
322
- }
323
- this.#loadAll();
324
- } else {
325
- this.#dir = null;
326
- this.#indexPath = null;
327
- this.#attachmentsPath = null;
328
- }
329
-
330
- // Ensure the main thread is always present.
331
- if (!this.#threads.has(MAIN_THREAD_ID)) {
332
- const now = Date.now();
333
- this.#threads.set(
334
- MAIN_THREAD_ID,
335
- this.#newThreadRecord({
336
- id: MAIN_THREAD_ID,
337
- name: 'main',
338
- goal: '',
339
- parentThreadId: null,
340
- createdAt: now,
341
- updatedAt: now,
342
- }),
343
- );
344
- this.#markDirty(MAIN_THREAD_ID);
345
- }
346
-
347
- // If the on-disk currentId is unknown, fall back to main.
348
- if (!this.#threads.has(this.#currentId)) {
349
- this.#currentId = MAIN_THREAD_ID;
350
- }
351
- }
352
-
353
- /** Build a thread record with default cached fields. */
354
- #newThreadRecord(base) {
355
- return {
356
- status: 'active',
357
- messageCount: 0,
358
- lastMessageAt: null,
359
- lastActivityAt: null,
360
- archived: false,
361
- mergedInto: null,
362
- forkedFrom: null,
363
- unread: 0,
364
- preview: '',
365
- ...base,
366
- };
367
- }
368
-
369
- // ─── load / persist ────────────────────────────────────────────────
370
-
371
- /** Load all thread files + attachments from disk into memory. */
372
- #loadAll() {
373
- if (!this.#dir || !existsSync(this.#dir)) return;
374
- let entries;
375
- try {
376
- entries = readdirSync(this.#dir, { withFileTypes: true });
377
- } catch {
378
- return;
379
- }
380
- for (const entry of entries) {
381
- if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
382
- if (entry.name === 'index.md') continue;
383
- const id = entry.name.slice(0, -3);
384
- try {
385
- const raw = readFileSync(join(this.#dir, entry.name), 'utf8');
386
- const parsed = parseThread(raw);
387
- if (parsed && parsed.id === id) {
388
- this.#threads.set(id, parsed);
389
- }
390
- } catch {
391
- // Skip corrupt files silently.
392
- }
393
- }
394
- // Attachments side-car.
395
- try {
396
- if (this.#attachmentsPath && existsSync(this.#attachmentsPath)) {
397
- const raw = readFileSync(this.#attachmentsPath, 'utf8');
398
- for (const { threadId, featureId } of parseAttachments(raw)) {
399
- if (this.#threads.has(threadId)) {
400
- this.#attachments.set(threadId, featureId);
401
- }
402
- }
403
- }
404
- } catch {
405
- // Skip silently.
406
- }
407
- // Try to recover currentId from index.md frontmatter.
408
- try {
409
- if (this.#indexPath && existsSync(this.#indexPath)) {
410
- const raw = readFileSync(this.#indexPath, 'utf8');
411
- const m = raw.match(/^currentId:\s*(\S+)/m);
412
- if (m && this.#threads.has(m[1])) {
413
- this.#currentId = m[1];
414
- }
415
- }
416
- } catch {
417
- // Ignore — fall back to main on miss.
418
- }
419
- }
420
-
421
- #markDirty(threadId) {
422
- if (!this.#dir || this.#readOnly) return;
423
- this.#dirtyThreads.add(threadId);
424
- this.#dirtyIndex = true;
425
- this.#scheduleFlush();
426
- }
427
-
428
- #markAttachmentsDirty() {
429
- if (!this.#dir || this.#readOnly) return;
430
- this.#dirtyAttachments = true;
431
- this.#dirtyIndex = true;
432
- this.#scheduleFlush();
433
- }
434
-
435
- #scheduleFlush() {
436
- if (this.#flushTimer || typeof setTimeout !== 'function') return;
437
- this.#flushTimer = setTimeout(() => {
438
- this.#flushTimer = null;
439
- this.flush();
440
- }, FLUSH_DEBOUNCE_MS);
441
- // Do not hold the process open for a pending flush — if the Node loop
442
- // has nothing else to do, the store still makes it through shutdown
443
- // via explicit flush() or process exit handlers.
444
- if (this.#flushTimer && typeof this.#flushTimer.unref === 'function') {
445
- this.#flushTimer.unref();
446
- }
447
- }
448
-
449
- /**
450
- * Write any pending dirty state to disk immediately. Safe to call on an
451
- * in-memory or read-only store (it becomes a no-op). Returns the number of
452
- * files written.
453
- */
454
- flush() {
455
- if (!this.#dir || this.#readOnly) {
456
- this.#dirtyThreads.clear();
457
- this.#dirtyIndex = false;
458
- this.#dirtyAttachments = false;
459
- return 0;
460
- }
461
- let written = 0;
462
- for (const id of this.#dirtyThreads) {
463
- const t = this.#threads.get(id);
464
- if (!t) {
465
- // Deleted thread → remove the file if present.
466
- try {
467
- const p = join(this.#dir, `${id}.md`);
468
- if (existsSync(p)) {
469
- unlinkSync(p);
470
- written += 1;
471
- }
472
- } catch {
473
- // ignore
474
- }
475
- continue;
476
- }
477
- try {
478
- writeFileSync(join(this.#dir, `${id}.md`), serializeThread(t), 'utf8');
479
- written += 1;
480
- } catch {
481
- // Best-effort
482
- }
483
- }
484
- this.#dirtyThreads.clear();
485
- if (this.#dirtyAttachments) {
486
- try {
487
- writeFileSync(this.#attachmentsPath, serializeAttachments(this.#attachments), 'utf8');
488
- } catch {
489
- // ignore
490
- }
491
- this.#dirtyAttachments = false;
492
- }
493
- if (this.#dirtyIndex) {
494
- try {
495
- writeFileSync(this.#indexPath, generateIndex(this.#threads, this.#currentId, this.#attachments), 'utf8');
496
- } catch {
497
- // ignore
498
- }
499
- this.#dirtyIndex = false;
500
- }
501
- return written;
502
- }
503
-
504
- // ─── Query API (unchanged) ─────────────────────────────────────────
505
-
506
- get currentId() { return this.#currentId; }
507
- get size() { return this.#threads.size; }
508
-
509
- /**
510
- * task-318: idle-archive threshold (days). 0 disables auto-archive.
511
- * The actual archive pass is owned by task-317 — this accessor is the
512
- * contract between config and that future consumer, plus a write path
513
- * so the Settings UI takes effect without restarting the session.
514
- * @returns {number}
515
- */
516
- get idleArchiveDays() { return this.#idleArchiveDays; }
517
- setIdleArchiveDays(days) {
518
- this.#idleArchiveDays = normaliseIdleDays(days);
519
- }
520
-
521
- /**
522
- * task-317: auto-archive pass. Scans every non-archived thread (except
523
- * `main`, which is never auto-archived) and archives those whose
524
- * `lastMessageAt` (fallback: `lastActivityAt`, fallback: `createdAt`)
525
- * is older than `now - idleArchiveDays * 86400000 ms`.
526
- *
527
- * Returns the list of newly-archived thread ids so callers can decide
528
- * whether to broadcast a UI update (no archived → no broadcast).
529
- *
530
- * Constraints:
531
- * - `idleArchiveDays === 0` disables the feature entirely (returns []).
532
- * - The main thread is NEVER archived regardless of its activity.
533
- * - Already-archived threads are skipped (idempotent).
534
- * - Threads with no recorded activity fall back to `createdAt`; a
535
- * thread created 100 days ago with zero messages IS archived when
536
- * idleArchiveDays ≤ 100 — silent threads aren't a special case.
537
- *
538
- * @param {number} [now] — override the clock for tests
539
- * @returns {{ archived: string[] }}
540
- */
541
- runArchivePass(now = Date.now()) {
542
- if (this.#idleArchiveDays <= 0) return { archived: [] };
543
- const cutoff = now - this.#idleArchiveDays * 86400000;
544
- const archived = [];
545
- for (const t of this.#threads.values()) {
546
- if (t.id === MAIN_THREAD_ID) continue;
547
- if (t.archived || t.status === 'archived') continue;
548
- const ref = t.lastMessageAt ?? t.lastActivityAt ?? t.createdAt ?? now;
549
- if (ref > cutoff) continue;
550
- t.status = 'archived';
551
- t.archived = true;
552
- t.updatedAt = now;
553
- this.#markDirty(t.id);
554
- archived.push(t.id);
555
- }
556
- return { archived };
557
- }
558
-
559
- get(id) { return this.#threads.get(id) || null; }
560
- list() { return [...this.#threads.values()]; }
561
- has(id) { return this.#threads.has(id); }
562
-
563
- // ─── Mutation API (all calls schedule a debounced flush) ───────────
564
-
565
- /**
566
- * Create a new thread.
567
- * @param {{ name: string, goal?: string, parentThreadId?: string }} spec
568
- * @returns {Thread}
569
- */
570
- create({ name, goal = '', parentThreadId = null } = {}) {
571
- if (!name || typeof name !== 'string' || !name.trim()) {
572
- throw new Error('thread name is required');
573
- }
574
- if (parentThreadId && !this.#threads.has(parentThreadId)) {
575
- throw new Error(`parent thread not found: ${parentThreadId}`);
576
- }
577
- const id = `thr-${randomUUID().slice(0, 8)}`;
578
- const now = Date.now();
579
- const thread = this.#newThreadRecord({
580
- id,
581
- name: name.trim(),
582
- goal: goal || '',
583
- parentThreadId: parentThreadId || null,
584
- createdAt: now,
585
- updatedAt: now,
586
- });
587
- this.#threads.set(id, thread);
588
- this.#markDirty(id);
589
- return thread;
590
- }
591
-
592
- /** Set the current thread marker. Throws if unknown. */
593
- switch(id) {
594
- if (!this.#threads.has(id)) {
595
- throw new Error(`thread not found: ${id}`);
596
- }
597
- this.#currentId = id;
598
- const t = this.#threads.get(id);
599
- t.updatedAt = Date.now();
600
- this.#markDirty(id);
601
- }
602
-
603
- /**
604
- * Record that a message has been persisted on a thread. Increments the
605
- * cached messageCount and updates lastMessageAt. Safe to call repeatedly;
606
- * unknown threadIds are silently ignored.
607
- */
608
- noteMessage(threadId, at = Date.now(), opts = {}) {
609
- const t = this.#threads.get(threadId);
610
- if (!t) return;
611
- t.messageCount += 1;
612
- t.lastMessageAt = at;
613
- t.lastActivityAt = at;
614
- t.updatedAt = at;
615
- if (opts.countsAsUnread !== false) {
616
- t.unread += 1;
617
- }
618
- if (typeof opts.preview === 'string' && opts.preview.length > 0) {
619
- const p = opts.preview.replace(/\s+/g, ' ').trim();
620
- t.preview = p.length > 160 ? p.slice(0, 157) + '...' : p;
621
- }
622
- if (t.status === 'archived') {
623
- t.status = 'active';
624
- t.archived = false;
625
- }
626
- this.#markDirty(threadId);
627
- }
628
-
629
- /** Mark a thread as read — resets unread counter to 0. */
630
- markRead(threadId) {
631
- const t = this.#threads.get(threadId);
632
- if (!t) return;
633
- if (t.unread === 0) return;
634
- t.unread = 0;
635
- this.#markDirty(threadId);
636
- }
637
-
638
- archive(id) {
639
- const t = this.#threads.get(id);
640
- if (!t) throw new Error(`thread not found: ${id}`);
641
- if (id === MAIN_THREAD_ID) throw new Error('cannot archive main thread');
642
- t.status = 'archived';
643
- t.archived = true;
644
- t.updatedAt = Date.now();
645
- this.#markDirty(id);
646
- }
647
-
648
- /**
649
- * Merge the source thread into the target (task-313). The source is
650
- * marked archived and gets `mergedInto: targetId`; target's cached
651
- * counters pick up the source's message count and activity. Callers
652
- * are still expected to reassign the actual messages on disk via
653
- * `ConversationStore.reassignThread(sourceId, targetId)`.
654
- *
655
- * Constraints:
656
- * - source !== target
657
- * - both threads must exist
658
- * - source cannot be the main thread (main cannot be archived)
659
- * - source cannot already have been merged elsewhere (idempotency)
660
- *
661
- * @param {string} sourceId
662
- * @param {string} targetId
663
- * @returns {{ source: Thread, target: Thread }}
664
- */
665
- mergeThread(sourceId, targetId) {
666
- if (!sourceId || !targetId) {
667
- throw new Error('mergeThread: sourceId and targetId required');
668
- }
669
- if (sourceId === targetId) {
670
- throw new Error('mergeThread: cannot merge a thread into itself');
671
- }
672
- if (sourceId === MAIN_THREAD_ID) {
673
- throw new Error('mergeThread: cannot merge the main thread into another');
674
- }
675
- const source = this.#threads.get(sourceId);
676
- if (!source) throw new Error(`thread not found: ${sourceId}`);
677
- const target = this.#threads.get(targetId);
678
- if (!target) throw new Error(`thread not found: ${targetId}`);
679
- if (source.mergedInto) {
680
- throw new Error(`thread ${sourceId} already merged into ${source.mergedInto}`);
681
- }
682
-
683
- const now = Date.now();
684
-
685
- // Accumulate counters onto target.
686
- target.messageCount += source.messageCount;
687
- if (source.lastMessageAt && (!target.lastMessageAt || source.lastMessageAt > target.lastMessageAt)) {
688
- target.lastMessageAt = source.lastMessageAt;
689
- }
690
- if (source.lastActivityAt && (!target.lastActivityAt || source.lastActivityAt > target.lastActivityAt)) {
691
- target.lastActivityAt = source.lastActivityAt;
692
- }
693
- target.updatedAt = now;
694
- // Revive target if it was archived — a merge is an activity signal.
695
- if (target.status === 'archived') {
696
- target.status = 'active';
697
- target.archived = false;
698
- }
699
-
700
- // Mark source as archived + pointer to target.
701
- source.status = 'archived';
702
- source.archived = true;
703
- source.mergedInto = targetId;
704
- source.updatedAt = now;
705
-
706
- // If source was current, move the pointer to target.
707
- if (this.#currentId === sourceId) {
708
- this.#currentId = targetId;
709
- }
710
-
711
- // Drop any feature attachment on source (it now belongs to target).
712
- if (this.#attachments.has(sourceId)) {
713
- const featureId = this.#attachments.get(sourceId);
714
- this.#attachments.delete(sourceId);
715
- // Preserve attachment on target if it had none; otherwise keep target's.
716
- if (!this.#attachments.has(targetId)) {
717
- this.#attachments.set(targetId, featureId);
718
- }
719
- this.#markAttachmentsDirty();
720
- }
721
-
722
- this.#markDirty(sourceId);
723
- this.#markDirty(targetId);
724
- return { source, target };
725
- }
726
-
727
- /**
728
- * Fork a new thread from an existing one at a specific message cursor.
729
- * ThreadStore only creates the new thread record (with `forkedFrom`
730
- * pointing at source + message + timestamp); the actual copying of
731
- * messages up to `atMessageId` is done by ConversationStore.copyThreadUpTo
732
- * — this keeps the two stores' responsibilities separate.
733
- *
734
- * Validation:
735
- * - source must exist
736
- * - source must not be archived (forking a dead thread is confusing)
737
- * - atMessageId must be a non-empty string (actual existence check is
738
- * the caller's responsibility, since ThreadStore doesn't own messages)
739
- * - source may itself be a fork (chain is supported)
740
- *
741
- * @param {string} sourceId
742
- * @param {string} atMessageId
743
- * @param {{ name?: string, title?: string, timestamp?: number }} [opts]
744
- * @returns {Thread} the newly created forked thread record
745
- */
746
- forkThread(sourceId, atMessageId, opts = {}) {
747
- if (!sourceId) throw new Error('forkThread: sourceId required');
748
- if (!atMessageId || typeof atMessageId !== 'string') {
749
- throw new Error('forkThread: atMessageId required');
750
- }
751
- const source = this.#threads.get(sourceId);
752
- if (!source) throw new Error(`thread not found: ${sourceId}`);
753
- if (source.archived || source.status === 'archived') {
754
- throw new Error(`forkThread: cannot fork an archived thread (${sourceId})`);
755
- }
756
- const now = Date.now();
757
- const id = `thr-${randomUUID().slice(0, 8)}`;
758
- const defaultName = source.id === MAIN_THREAD_ID ? 'inbox-fork' : `${source.name}-fork`;
759
- const thread = this.#newThreadRecord({
760
- id,
761
- name: (opts.name && opts.name.trim()) || defaultName,
762
- goal: source.goal || '',
763
- parentThreadId: sourceId,
764
- createdAt: now,
765
- updatedAt: now,
766
- forkedFrom: {
767
- threadId: sourceId,
768
- messageId: atMessageId,
769
- timestamp: Number.isFinite(opts.timestamp) ? opts.timestamp : now,
770
- },
771
- });
772
- this.#threads.set(id, thread);
773
- this.#markDirty(id);
774
- return thread;
775
- }
776
-
777
- setStatus(id, status) {
778
- if (!THREAD_STATUSES.includes(status)) {
779
- throw new Error(`invalid status: ${status}`);
780
- }
781
- const t = this.#threads.get(id);
782
- if (!t) throw new Error(`thread not found: ${id}`);
783
- if (id === MAIN_THREAD_ID && status === 'archived') {
784
- throw new Error('cannot archive main thread');
785
- }
786
- t.status = status;
787
- t.archived = status === 'archived';
788
- t.updatedAt = Date.now();
789
- this.#markDirty(id);
790
- }
791
-
792
- /**
793
- * Rebuild cached fields (messageCount/lastMessageAt) from a flat messages
794
- * list. Used for crash recovery or as a sanity check in tests.
795
- */
796
- rebuildFromMessages(messages) {
797
- for (const t of this.#threads.values()) {
798
- t.messageCount = 0;
799
- t.lastMessageAt = null;
800
- t.lastActivityAt = null;
801
- this.#markDirty(t.id);
802
- }
803
- for (const m of messages || []) {
804
- const tid = m.threadId || MAIN_THREAD_ID;
805
- const t = this.#threads.get(tid);
806
- if (!t) continue;
807
- t.messageCount += 1;
808
- const ts = typeof m.createdAt === 'number' ? m.createdAt : Date.now();
809
- if (!t.lastMessageAt || ts > t.lastMessageAt) {
810
- t.lastMessageAt = ts;
811
- t.lastActivityAt = ts;
812
- }
813
- this.#markDirty(tid);
814
- }
815
- }
816
-
817
- attachFeature(threadId, featureId) {
818
- if (!this.#threads.has(threadId)) {
819
- throw new Error(`thread not found: ${threadId}`);
820
- }
821
- if (!featureId || typeof featureId !== 'string') {
822
- throw new Error('featureId is required');
823
- }
824
- this.#attachments.set(threadId, featureId);
825
- this.#markAttachmentsDirty();
826
- }
827
-
828
- attachedFeature(threadId) {
829
- return this.#attachments.get(threadId) || null;
830
- }
831
-
832
- listAttachments() {
833
- return [...this.#attachments.entries()].map(([threadId, featureId]) => ({ threadId, featureId }));
834
- }
835
- }
836
-
837
- // ─── Singleton helpers ───────────────────────────────────────────────────
838
-
839
- /** @type {ThreadStore|null} */
840
- let threadStore = null;
841
-
842
- /**
843
- * Initialise the thread store. Safe to call multiple times — subsequent calls
844
- * replace the store only if `force` is true (primarily for tests).
845
- *
846
- * Accepts either `initThreadStore()` (legacy, in-memory) or
847
- * `initThreadStore(yeaftDir, opts)` (persistent). Legacy callers keep working.
848
- *
849
- * @param {string|{ force?: boolean }} [yeaftDirOrOpts]
850
- * @param {{ force?: boolean, readOnly?: boolean }} [opts]
851
- * @returns {ThreadStore}
852
- */
853
- export function initThreadStore(yeaftDirOrOpts, opts = {}) {
854
- let yeaftDir;
855
- let mergedOpts;
856
- if (typeof yeaftDirOrOpts === 'string') {
857
- yeaftDir = yeaftDirOrOpts;
858
- mergedOpts = opts || {};
859
- } else {
860
- yeaftDir = undefined;
861
- mergedOpts = yeaftDirOrOpts || {};
862
- }
863
- if (!threadStore || mergedOpts.force) {
864
- threadStore = new ThreadStore(yeaftDir, mergedOpts);
865
- }
866
- return threadStore;
867
- }
868
-
869
- /** @returns {ThreadStore} */
870
- export function getThreadStore() {
871
- if (!threadStore) {
872
- threadStore = new ThreadStore();
873
- }
874
- return threadStore;
875
- }
876
-
877
- /** Test-only reset helper. */
878
- export function _resetThreadStoreForTests() {
879
- if (threadStore && typeof threadStore.flush === 'function') {
880
- try { threadStore.flush(); } catch { /* ignore */ }
881
- }
882
- threadStore = null;
883
- }
884
-
885
- // Exported for tests.
886
- export { serializeThread as _serializeThread, parseThread as _parseThread };