@yeaft/webchat-agent 0.1.482 → 0.1.484

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.482",
3
+ "version": "0.1.484",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -0,0 +1,116 @@
1
+ /**
2
+ * migrate-messages-threadid.js — task-307 one-shot migration.
3
+ *
4
+ * For every message under <yeaftDir>/conversation/messages/ and cold/ that
5
+ * does NOT yet carry a `threadId:` frontmatter field, stamp it with
6
+ * `threadId: main`. This matches design §5 semantics: pre-Phase-2 messages
7
+ * predate threading and belong to the root conversation.
8
+ *
9
+ * Idempotency:
10
+ * A marker file `<yeaftDir>/conversation/.migrations/messagesThreadId`
11
+ * is written on successful completion. Subsequent runs observe the
12
+ * marker and short-circuit with `{ ran: false }`.
13
+ *
14
+ * Safety:
15
+ * - Messages that already carry a threadId are left untouched.
16
+ * - If a file is unreadable / unparseable it is skipped (never rewritten).
17
+ * - Write errors are swallowed so a half-successful migration can resume
18
+ * on the next boot by re-running.
19
+ *
20
+ * Usage (programmatic):
21
+ * import { migrateMessagesThreadId } from './migrate-messages-threadid.js';
22
+ * const res = migrateMessagesThreadId('/home/me/.yeaft');
23
+ * // { ran: true, migrated: 42, skipped: 3 }
24
+ *
25
+ * Usage (CLI):
26
+ * node agent/unify/conversation/migrate-messages-threadid.js [yeaftDir]
27
+ */
28
+
29
+ import {
30
+ existsSync,
31
+ mkdirSync,
32
+ readdirSync,
33
+ readFileSync,
34
+ writeFileSync,
35
+ } from 'fs';
36
+ import { join } from 'path';
37
+ import { homedir } from 'os';
38
+
39
+ export function migrateMessagesThreadId(yeaftDir) {
40
+ const root = yeaftDir || join(homedir(), '.yeaft');
41
+ const convDir = join(root, 'conversation');
42
+ const markerDir = join(convDir, '.migrations');
43
+ const markerPath = join(markerDir, 'messagesThreadId');
44
+
45
+ if (!existsSync(convDir)) {
46
+ return { ran: false, reason: 'no conversation dir', migrated: 0, skipped: 0 };
47
+ }
48
+ try {
49
+ if (existsSync(markerPath)) {
50
+ return { ran: false, reason: 'already migrated', migrated: 0, skipped: 0 };
51
+ }
52
+ } catch { /* best-effort */ }
53
+
54
+ const dirs = [join(convDir, 'messages'), join(convDir, 'cold')];
55
+ let migrated = 0;
56
+ let skipped = 0;
57
+
58
+ for (const dir of dirs) {
59
+ if (!existsSync(dir)) continue;
60
+ let files;
61
+ try {
62
+ files = readdirSync(dir).filter(f => f.endsWith('.md'));
63
+ } catch {
64
+ continue;
65
+ }
66
+ for (const f of files) {
67
+ const path = join(dir, f);
68
+ let raw;
69
+ try {
70
+ raw = readFileSync(path, 'utf8');
71
+ } catch {
72
+ skipped += 1;
73
+ continue;
74
+ }
75
+ if (!raw || !raw.startsWith('---')) { skipped += 1; continue; }
76
+ const endIdx = raw.indexOf('\n---', 3);
77
+ if (endIdx === -1) { skipped += 1; continue; }
78
+ const frontmatter = raw.slice(4, endIdx);
79
+ const body = raw.slice(endIdx); // leading '\n---' ...
80
+ if (/^threadId:/m.test(frontmatter)) {
81
+ skipped += 1;
82
+ continue; // already has threadId
83
+ }
84
+ const newFm = frontmatter.replace(/\s*$/, '') + '\nthreadId: main';
85
+ const rebuilt = '---\n' + newFm.trimStart() + body;
86
+ try {
87
+ writeFileSync(path, rebuilt, { encoding: 'utf8', mode: 0o644 });
88
+ migrated += 1;
89
+ } catch {
90
+ skipped += 1;
91
+ }
92
+ }
93
+ }
94
+
95
+ try {
96
+ mkdirSync(markerDir, { recursive: true, mode: 0o755 });
97
+ writeFileSync(
98
+ markerPath,
99
+ `migrated: ${new Date().toISOString()}\ncount: ${migrated}\nskipped: ${skipped}\n`,
100
+ { encoding: 'utf8', mode: 0o644 },
101
+ );
102
+ } catch {
103
+ // Without the marker the migration will re-run; it's idempotent per-file
104
+ // (files already carrying threadId are skipped) so that's acceptable.
105
+ }
106
+
107
+ return { ran: true, migrated, skipped };
108
+ }
109
+
110
+ // CLI entry point.
111
+ if (import.meta.url === `file://${process.argv[1]}`) {
112
+ const dir = process.argv[2];
113
+ const res = migrateMessagesThreadId(dir);
114
+ // eslint-disable-next-line no-console
115
+ console.log(JSON.stringify(res, null, 2));
116
+ }
@@ -56,6 +56,10 @@ function serializeMessage(msg) {
56
56
  if (msg.turnNumber != null) fm.push(`turnNumber: ${msg.turnNumber}`);
57
57
  if (msg.toolCallId) fm.push(`toolCallId: ${msg.toolCallId}`);
58
58
  if (msg.isError) fm.push(`isError: true`);
59
+ // task-307: every message is stamped with a threadId so multi-thread
60
+ // routing can filter/replay by thread without rescanning JSON blobs.
61
+ // Defaults to 'main' for legacy messages (see migrate-messages-threadid.js).
62
+ fm.push(`threadId: ${msg.threadId || 'main'}`);
59
63
 
60
64
  // Token estimate
61
65
  const content = msg.content || '';
@@ -111,10 +115,14 @@ export function parseMessage(raw) {
111
115
  case 'toolCallId': msg.toolCallId = value; break;
112
116
  case 'isError': msg.isError = value === 'true'; break;
113
117
  case 'tokens_est': msg.tokens_est = parseInt(value, 10); break;
118
+ case 'threadId': msg.threadId = value; break;
114
119
  // toolCalls are multi-line YAML — handled separately below
115
120
  }
116
121
  }
117
122
 
123
+ // task-307: legacy messages written before threadId existed default to 'main'.
124
+ if (!msg.threadId) msg.threadId = 'main';
125
+
118
126
  // Parse toolCalls if present (simplified multi-line YAML)
119
127
  if (frontmatter.includes('toolCalls:')) {
120
128
  const toolCalls = [];
@@ -0,0 +1,322 @@
1
+ /**
2
+ * input-queue/store.js — Persistent FIFO input queue for Yeaft Unify.
3
+ *
4
+ * task-307b (Phase 2): a disk-backed queue of user inputs waiting to be
5
+ * routed to a thread/engine. Each entry is one JSON file at
6
+ * <yeaftDir>/input-queue/<id>.json
7
+ * so that crashing during a routing decision never loses a queued input.
8
+ *
9
+ * Schema follows design doc §5:
10
+ * {
11
+ * id: 'iq-xxxxxxxx',
12
+ * text: '<the user-typed input>',
13
+ * createdAt: 1234567890123, // epoch ms
14
+ * status: 'pending' | 'routing' | 'dispatched',
15
+ * routedTo: '<threadId>' | null, // set when status === 'dispatched'
16
+ * routedAt: 1234567890456 | null,
17
+ * error: '<message>' | null, // optional — populated when a
18
+ * // routing attempt fails and
19
+ * // the entry is put back to
20
+ * // 'pending' for retry.
21
+ * }
22
+ *
23
+ * State machine:
24
+ * enqueue(text)
25
+ * │
26
+ * ▼
27
+ * pending ─────claim()─────► routing ─────markRouted()────► dispatched
28
+ * ▲ │
29
+ * └──────markFailed()──────┘ (status back to pending; error field recorded)
30
+ *
31
+ * - pending : waiting for a dispatcher
32
+ * - routing : a dispatcher has taken responsibility; crash here means
33
+ * boot-time recovery will still see the entry and can
34
+ * re-claim it (the row is still on disk).
35
+ * - dispatched : routed to a thread — the file is removed because the
36
+ * durable audit trail now lives in the messages.
37
+ *
38
+ * API (task-307b per PM):
39
+ * - enqueue(text) → entry Create + persist pending.
40
+ * - dequeue() → entry | null Peek at oldest pending
41
+ * (non-mutating — see
42
+ * claim() for the mutating
43
+ * transition).
44
+ * - claim() → entry | null Oldest pending → 'routing'
45
+ * (atomic w.r.t. disk).
46
+ * - list(status?) → entry[] Snapshot filtered by status.
47
+ * - markRouted(id, routedTo) → entry | null routing → dispatched;
48
+ * file deleted.
49
+ * - markFailed(id, err) → entry | null routing → pending; error
50
+ * recorded; kept on disk.
51
+ * - peek() / get(id) / remove(id) / size() / pendingCount()
52
+ *
53
+ * Persistence:
54
+ * - Writes are synchronous. The queue is a durability boundary, not a
55
+ * hot path (one write per state transition).
56
+ * - Permission/FS failures don't throw: they are swallowed and logged once
57
+ * per process (matches ConversationStore's philosophy).
58
+ * - If `yeaftDir` is omitted, the store operates purely in memory.
59
+ */
60
+
61
+ import { randomUUID } from 'crypto';
62
+ import {
63
+ existsSync,
64
+ mkdirSync,
65
+ readdirSync,
66
+ readFileSync,
67
+ writeFileSync,
68
+ unlinkSync,
69
+ } from 'fs';
70
+ import { join } from 'path';
71
+
72
+ /** All valid status values, per design §5. */
73
+ export const INPUT_QUEUE_STATUSES = ['pending', 'routing', 'dispatched'];
74
+
75
+ export class InputQueueStore {
76
+ /** @type {Map<string, object>} */
77
+ #entries;
78
+ /** @type {string|null} */
79
+ #dir;
80
+ /** @type {boolean} */
81
+ #persistent;
82
+ /** @type {boolean} */
83
+ #warned;
84
+
85
+ /**
86
+ * @param {string|null} [yeaftDir]
87
+ */
88
+ constructor(yeaftDir = null) {
89
+ this.#entries = new Map();
90
+ this.#warned = false;
91
+
92
+ if (yeaftDir) {
93
+ this.#dir = join(yeaftDir, 'input-queue');
94
+ this.#persistent = true;
95
+ this.#ensureDir();
96
+ this.#loadAll();
97
+ } else {
98
+ this.#dir = null;
99
+ this.#persistent = false;
100
+ }
101
+ }
102
+
103
+ get persistent() { return this.#persistent; }
104
+ size() { return this.#entries.size; }
105
+
106
+ pendingCount() {
107
+ let n = 0;
108
+ for (const e of this.#entries.values()) if (e.status === 'pending') n += 1;
109
+ return n;
110
+ }
111
+
112
+ /**
113
+ * Append a new pending entry and persist it.
114
+ * @param {string} text
115
+ * @returns {object} the entry
116
+ */
117
+ enqueue(text) {
118
+ if (typeof text !== 'string') throw new Error('text must be a string');
119
+ const id = `iq-${randomUUID().slice(0, 8)}`;
120
+ const entry = {
121
+ id,
122
+ text,
123
+ createdAt: Date.now(),
124
+ status: 'pending',
125
+ routedTo: null,
126
+ routedAt: null,
127
+ error: null,
128
+ };
129
+ this.#entries.set(id, entry);
130
+ this.#writeEntry(entry);
131
+ return entry;
132
+ }
133
+
134
+ /** Oldest pending entry (no state change). null if empty. */
135
+ peek() {
136
+ let oldest = null;
137
+ for (const e of this.#entries.values()) {
138
+ if (e.status !== 'pending') continue;
139
+ if (!oldest || e.createdAt < oldest.createdAt) oldest = e;
140
+ }
141
+ return oldest;
142
+ }
143
+
144
+ /**
145
+ * Return the oldest pending entry without mutating state. Kept as a
146
+ * separate method from claim() because some consumers only want to
147
+ * observe the head of the queue (e.g. UI preview).
148
+ */
149
+ dequeue() {
150
+ return this.peek();
151
+ }
152
+
153
+ /**
154
+ * Transition the oldest pending entry → 'routing' and persist. This is
155
+ * the real consumer-facing take: after claim() the caller must eventually
156
+ * call markRouted() (success) or markFailed() (→ put back as pending).
157
+ *
158
+ * @returns {object|null} the claimed entry, or null if nothing pending
159
+ */
160
+ claim() {
161
+ const e = this.peek();
162
+ if (!e) return null;
163
+ e.status = 'routing';
164
+ this.#writeEntry(e);
165
+ return e;
166
+ }
167
+
168
+ /**
169
+ * Mark an entry as successfully dispatched to a thread. Persists the
170
+ * updated state then removes the file — the authoritative record now
171
+ * lives in the conversation/messages log.
172
+ *
173
+ * @param {string} id
174
+ * @param {string} routedTo — thread id (e.g. 'main' or 'thr-xxxxxxxx')
175
+ * @returns {object|null}
176
+ */
177
+ markRouted(id, routedTo) {
178
+ const e = this.#entries.get(id);
179
+ if (!e) return null;
180
+ if (!routedTo || typeof routedTo !== 'string') throw new Error('routedTo required');
181
+ e.status = 'dispatched';
182
+ e.routedTo = routedTo;
183
+ e.routedAt = Date.now();
184
+ // Persist the transition before removing (crash-safe ordering).
185
+ this.#writeEntry(e);
186
+ this.#removeFile(id);
187
+ this.#entries.delete(id);
188
+ return e;
189
+ }
190
+
191
+ /**
192
+ * Mark a routing attempt failed. The entry returns to 'pending' so the
193
+ * next claim() can retry it; the error string is retained for diagnostics.
194
+ *
195
+ * @param {string} id
196
+ * @param {string|Error} err
197
+ */
198
+ markFailed(id, err) {
199
+ const e = this.#entries.get(id);
200
+ if (!e) return null;
201
+ e.status = 'pending';
202
+ e.error = typeof err === 'string' ? err : (err?.message || String(err));
203
+ this.#writeEntry(e);
204
+ return e;
205
+ }
206
+
207
+ /** Remove an entry entirely (memory + disk). */
208
+ remove(id) {
209
+ if (!this.#entries.has(id)) return false;
210
+ this.#entries.delete(id);
211
+ this.#removeFile(id);
212
+ return true;
213
+ }
214
+
215
+ /**
216
+ * Snapshot of all entries, optionally filtered by status. Chronological.
217
+ * @param {'pending'|'routing'|'dispatched'} [status]
218
+ */
219
+ list(status) {
220
+ let arr = [...this.#entries.values()];
221
+ if (status) arr = arr.filter(e => e.status === status);
222
+ arr.sort((a, b) => a.createdAt - b.createdAt);
223
+ return arr;
224
+ }
225
+
226
+ get(id) { return this.#entries.get(id) || null; }
227
+
228
+ // ─── Persistence internals ────────────────────────────
229
+
230
+ #ensureDir() {
231
+ try {
232
+ if (!existsSync(this.#dir)) mkdirSync(this.#dir, { recursive: true, mode: 0o755 });
233
+ } catch (err) {
234
+ this.#warn(`Cannot create input-queue dir: ${err?.code || err?.message}`);
235
+ }
236
+ }
237
+
238
+ #loadAll() {
239
+ if (!existsSync(this.#dir)) return;
240
+ let files;
241
+ try {
242
+ files = readdirSync(this.#dir);
243
+ } catch (err) {
244
+ this.#warn(`Cannot read input-queue dir: ${err?.code || err?.message}`);
245
+ return;
246
+ }
247
+ for (const f of files) {
248
+ if (!f.endsWith('.json')) continue;
249
+ const path = join(this.#dir, f);
250
+ try {
251
+ const raw = readFileSync(path, 'utf8');
252
+ const parsed = JSON.parse(raw);
253
+ if (!parsed || typeof parsed !== 'object' || !parsed.id) continue;
254
+ if (!INPUT_QUEUE_STATUSES.includes(parsed.status)) continue;
255
+ // Crash recovery: an entry left in 'routing' at startup had a
256
+ // dispatcher claim it right before the crash. Put it back to
257
+ // 'pending' so the next claim() can retry it.
258
+ const status = parsed.status === 'routing' ? 'pending' : parsed.status;
259
+ this.#entries.set(parsed.id, {
260
+ id: parsed.id,
261
+ text: typeof parsed.text === 'string' ? parsed.text : '',
262
+ createdAt: Number(parsed.createdAt) || Date.now(),
263
+ status,
264
+ routedTo: parsed.routedTo || null,
265
+ routedAt: parsed.routedAt || null,
266
+ error: parsed.error || null,
267
+ });
268
+ } catch {
269
+ // Skip corrupt file.
270
+ }
271
+ }
272
+ }
273
+
274
+ #writeEntry(entry) {
275
+ if (!this.#persistent) return;
276
+ const path = join(this.#dir, `${entry.id}.json`);
277
+ try {
278
+ writeFileSync(path, JSON.stringify(entry, null, 2), { encoding: 'utf8', mode: 0o644 });
279
+ } catch (err) {
280
+ this.#warn(`Cannot write ${entry.id}: ${err?.code || err?.message}`);
281
+ }
282
+ }
283
+
284
+ #removeFile(id) {
285
+ if (!this.#persistent) return;
286
+ const path = join(this.#dir, `${id}.json`);
287
+ if (!existsSync(path)) return;
288
+ try {
289
+ unlinkSync(path);
290
+ } catch (err) {
291
+ this.#warn(`Cannot remove ${id}: ${err?.code || err?.message}`);
292
+ }
293
+ }
294
+
295
+ #warn(msg) {
296
+ if (this.#warned) return;
297
+ this.#warned = true;
298
+ // eslint-disable-next-line no-console
299
+ console.warn(`[Yeaft InputQueue] ${msg}`);
300
+ }
301
+ }
302
+
303
+ // ─── Singleton helpers ────────────────────────────────────
304
+
305
+ /** @type {InputQueueStore|null} */
306
+ let inputQueueStore = null;
307
+
308
+ export function initInputQueueStore(opts = {}) {
309
+ if (!inputQueueStore || opts.force) {
310
+ inputQueueStore = new InputQueueStore(opts.yeaftDir || null);
311
+ }
312
+ return inputQueueStore;
313
+ }
314
+
315
+ export function getInputQueueStore() {
316
+ if (!inputQueueStore) inputQueueStore = new InputQueueStore(null);
317
+ return inputQueueStore;
318
+ }
319
+
320
+ export function _resetInputQueueStoreForTests() {
321
+ inputQueueStore = null;
322
+ }
package/unify/session.js CHANGED
@@ -125,9 +125,9 @@ export async function loadSession(options = {}) {
125
125
  initTaskStore(yeaftDir, { readOnly: config._readOnly || false });
126
126
 
127
127
  // ─── 5b. Initialize thread store (task-299 Phase 1) ────
128
- // In-memory only for Phase 1; replaced by a file-backed store
129
- // when task-298's data layer merges.
130
- initThreadStore();
128
+ // task-307a: now file-backed under ~/.yeaft/threads/. Passing the
129
+ // yeaftDir switches on disk persistence; read-only mode is honoured.
130
+ initThreadStore(yeaftDir, { readOnly: config._readOnly || false, force: true });
131
131
 
132
132
  // ─── 6. Load skills ────────────────────────────────────
133
133
  let skillManager;
@@ -1,26 +1,49 @@
1
1
  /**
2
- * store.js — In-memory ThreadStore for Yeaft Unify (Phase 1 mock).
2
+ * store.js — File-backed ThreadStore for Yeaft Unify (task-307a).
3
3
  *
4
- * Phase 1 intentionally keeps the implementation in-memory only: it lets the
5
- * thread/task spawn tools ship and be tested before task-298's real
6
- * filesystem layer is merged. When task-298 merges, this module will be
7
- * replaced (or promoted to a shim) by a file-backed store with the same API.
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.
8
10
  *
9
- * Cached fields (task-299 rework, prev-2 suggestion):
10
- * - messageCount, lastMessageAt, archived are maintained incrementally via
11
- * noteMessage()/archive()/setStatus() so that ListThreads does NOT need
12
- * to scan every message on each call. A rebuildFromMessages(messages)
13
- * helper exists for sanity / crash-recovery reconciliation.
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.
14
18
  *
15
- * Responsibilities (Phase 1):
16
- * - Maintain a map of threadId thread metadata + cached counters.
17
- * - Track a "currentThreadId" marker for the engine.
18
- * - Maintain attachments from threadId taskId.
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.
19
28
  *
20
- * A single "main" thread is created on construction so that pre-spawn
21
- * messages have a valid threadId to carry.
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.
22
36
  */
23
37
 
38
+ import {
39
+ existsSync,
40
+ mkdirSync,
41
+ readdirSync,
42
+ readFileSync,
43
+ unlinkSync,
44
+ writeFileSync,
45
+ } from 'fs';
46
+ import { join } from 'path';
24
47
  import { randomUUID } from 'crypto';
25
48
 
26
49
  /** Default / root thread id — every fresh ThreadStore has one. */
@@ -29,16 +52,170 @@ export const MAIN_THREAD_ID = 'main';
29
52
  /** Valid thread status values. Mirrors design doc §5. */
30
53
  export const THREAD_STATUSES = ['active', 'idle', 'archived'];
31
54
 
55
+ /** Debounce window for grouped disk writes. Kept short so tests don't hang. */
56
+ const FLUSH_DEBOUNCE_MS = 8;
57
+
58
+ // ─── YAML (de)serialisation ──────────────────────────────────────────────
59
+
60
+ /**
61
+ * Serialise a thread record to Markdown with YAML frontmatter.
62
+ *
63
+ * Only scalar / boolean / number / null frontmatter values are emitted.
64
+ * Strings that span multiple lines or contain leading whitespace get folded
65
+ * into a single-line value (threads' free-text goes in the body).
66
+ *
67
+ * @param {object} t
68
+ * @returns {string}
69
+ */
70
+ function serializeThread(t) {
71
+ const fm = [
72
+ '---',
73
+ `id: ${t.id}`,
74
+ `name: ${escapeScalar(t.name)}`,
75
+ `goal: ${escapeScalar(t.goal || '')}`,
76
+ `parentThreadId: ${t.parentThreadId == null ? 'null' : t.parentThreadId}`,
77
+ `status: ${t.status}`,
78
+ `archived: ${t.archived ? 'true' : 'false'}`,
79
+ `messageCount: ${t.messageCount | 0}`,
80
+ `lastMessageAt: ${t.lastMessageAt == null ? 'null' : t.lastMessageAt}`,
81
+ `lastActivityAt: ${t.lastActivityAt == null ? 'null' : t.lastActivityAt}`,
82
+ `unread: ${t.unread | 0}`,
83
+ `createdAt: ${t.createdAt}`,
84
+ `updatedAt: ${t.updatedAt}`,
85
+ '---',
86
+ '',
87
+ ];
88
+ // Body is the preview (wrapped so the file remains human-readable).
89
+ if (t.preview) fm.push(t.preview);
90
+ return fm.join('\n') + '\n';
91
+ }
92
+
93
+ function escapeScalar(v) {
94
+ if (v == null) return '';
95
+ // Keep on one physical line; any embedded newline becomes a space so YAML
96
+ // stays flat.
97
+ return String(v).replace(/\s+/g, ' ').trim();
98
+ }
99
+
100
+ /**
101
+ * Parse the markdown-with-frontmatter produced by `serializeThread`. Returns
102
+ * null when the file is malformed; callers should skip it silently so one
103
+ * corrupt thread file never blocks recovery of the rest.
104
+ *
105
+ * @param {string} raw
106
+ * @returns {object|null}
107
+ */
108
+ function parseThread(raw) {
109
+ if (!raw || !raw.startsWith('---')) return null;
110
+ const end = raw.indexOf('\n---', 3);
111
+ if (end === -1) return null;
112
+ const fm = raw.slice(3, end).trim();
113
+ const body = raw.slice(end + 4).replace(/^\n/, '').trimEnd();
114
+ const record = {};
115
+ for (const line of fm.split('\n')) {
116
+ const idx = line.indexOf(':');
117
+ if (idx === -1) continue;
118
+ const key = line.slice(0, idx).trim();
119
+ const rawVal = line.slice(idx + 1).trim();
120
+ if (!key) continue;
121
+ if (rawVal === 'null' || rawVal === '') {
122
+ record[key] = null;
123
+ } else if (rawVal === 'true' || rawVal === 'false') {
124
+ record[key] = rawVal === 'true';
125
+ } else if (/^-?\d+$/.test(rawVal)) {
126
+ record[key] = parseInt(rawVal, 10);
127
+ } else {
128
+ record[key] = rawVal;
129
+ }
130
+ }
131
+ if (!record.id || !record.name) return null;
132
+ // Default to safe values if the file pre-dates a field.
133
+ if (!THREAD_STATUSES.includes(record.status)) record.status = 'active';
134
+ record.archived = record.status === 'archived';
135
+ record.messageCount = Number.isFinite(record.messageCount) ? record.messageCount : 0;
136
+ record.unread = Number.isFinite(record.unread) ? record.unread : 0;
137
+ record.preview = body;
138
+ record.lastActivityAt = record.lastActivityAt ?? record.lastMessageAt ?? null;
139
+ return record;
140
+ }
141
+
142
+ /**
143
+ * Generate `index.md` — a human-readable roll-up of all threads in the
144
+ * store, the current thread marker, and attachments. Parsers should NOT
145
+ * depend on this file; it exists for human inspection and crash-triage.
146
+ */
147
+ function generateIndex(threads, currentId, attachments) {
148
+ const now = new Date().toISOString();
149
+ const lines = [
150
+ '---',
151
+ `currentId: ${currentId}`,
152
+ `totalThreads: ${threads.size}`,
153
+ `lastUpdated: ${now}`,
154
+ '---',
155
+ '# Thread Index',
156
+ '',
157
+ '| ID | Name | Status | Messages | Last Activity |',
158
+ '|----|------|--------|----------|---------------|',
159
+ ];
160
+ for (const t of threads.values()) {
161
+ const stamp = t.lastActivityAt
162
+ ? new Date(t.lastActivityAt).toISOString().slice(0, 19).replace('T', ' ')
163
+ : '-';
164
+ lines.push(`| ${t.id} | ${t.name} | ${t.status} | ${t.messageCount} | ${stamp} |`);
165
+ }
166
+ if (attachments.size > 0) {
167
+ lines.push('');
168
+ lines.push('## Attachments');
169
+ lines.push('');
170
+ lines.push('| Thread | Task |');
171
+ lines.push('|--------|------|');
172
+ for (const [threadId, taskId] of attachments.entries()) {
173
+ lines.push(`| ${threadId} | ${taskId} |`);
174
+ }
175
+ }
176
+ return lines.join('\n') + '\n';
177
+ }
178
+
179
+ /**
180
+ * Serialise the attachments map to a stable JSON payload (array form so key
181
+ * order is preserved on reload). Stored separately from `index.md` so the
182
+ * human-readable index stays cosmetic.
183
+ */
184
+ function serializeAttachments(attachments) {
185
+ return JSON.stringify(
186
+ [...attachments.entries()].map(([threadId, taskId]) => ({ threadId, taskId })),
187
+ null,
188
+ 2,
189
+ ) + '\n';
190
+ }
191
+
192
+ function parseAttachments(raw) {
193
+ try {
194
+ const arr = JSON.parse(raw);
195
+ if (!Array.isArray(arr)) return [];
196
+ return arr.filter(
197
+ (e) => e && typeof e.threadId === 'string' && typeof e.taskId === 'string',
198
+ );
199
+ } catch {
200
+ return [];
201
+ }
202
+ }
203
+
204
+ // ─── ThreadStore class ───────────────────────────────────────────────────
205
+
32
206
  /**
33
207
  * @typedef {Object} Thread
34
208
  * @property {string} id
35
209
  * @property {string} name
36
210
  * @property {string} [goal]
37
211
  * @property {string|null} parentThreadId
38
- * @property {'active'|'idle'|'archived'} status — cached; initial 'active'
39
- * @property {number} messageCount — cached counter, incremented via noteMessage
40
- * @property {number|null} lastMessageAt — cached timestamp of last noted message
41
- * @property {boolean} archived — convenience mirror of (status === 'archived')
212
+ * @property {'active'|'idle'|'archived'} status
213
+ * @property {number} messageCount
214
+ * @property {number|null} lastMessageAt
215
+ * @property {number|null} lastActivityAt
216
+ * @property {boolean} archived
217
+ * @property {number} unread
218
+ * @property {string} preview
42
219
  * @property {number} createdAt
43
220
  * @property {number} updatedAt
44
221
  */
@@ -46,53 +223,243 @@ export const THREAD_STATUSES = ['active', 'idle', 'archived'];
46
223
  export class ThreadStore {
47
224
  /** @type {Map<string, Thread>} */
48
225
  #threads;
49
-
50
226
  /** @type {string} */
51
227
  #currentId;
52
-
53
228
  /** @type {Map<string, string>} threadId → taskId */
54
229
  #attachments;
55
230
 
56
- constructor() {
231
+ /** @type {string|null} */
232
+ #dir;
233
+ /** @type {string|null} */
234
+ #indexPath;
235
+ /** @type {string|null} */
236
+ #attachmentsPath;
237
+ /** @type {boolean} */
238
+ #readOnly;
239
+ /** @type {Set<string>} dirty thread ids pending flush */
240
+ #dirtyThreads;
241
+ /** @type {boolean} */
242
+ #dirtyIndex;
243
+ /** @type {boolean} */
244
+ #dirtyAttachments;
245
+ /** @type {any} NodeJS.Timeout */
246
+ #flushTimer;
247
+
248
+ /**
249
+ * @param {string} [yeaftDir] — Base ~/.yeaft directory. Omit for in-memory mode.
250
+ * @param {{ readOnly?: boolean }} [opts]
251
+ */
252
+ constructor(yeaftDir, opts = {}) {
57
253
  this.#threads = new Map();
58
254
  this.#attachments = new Map();
59
-
60
- const now = Date.now();
61
- this.#threads.set(MAIN_THREAD_ID, this.#newThreadRecord({
62
- id: MAIN_THREAD_ID,
63
- name: 'main',
64
- goal: '',
65
- parentThreadId: null,
66
- createdAt: now,
67
- updatedAt: now,
68
- }));
69
255
  this.#currentId = MAIN_THREAD_ID;
256
+ this.#dirtyThreads = new Set();
257
+ this.#dirtyIndex = false;
258
+ this.#dirtyAttachments = false;
259
+ this.#flushTimer = null;
260
+
261
+ this.#readOnly = !!opts.readOnly;
262
+ if (yeaftDir) {
263
+ this.#dir = join(yeaftDir, 'threads');
264
+ this.#indexPath = join(this.#dir, 'index.md');
265
+ this.#attachmentsPath = join(this.#dir, 'attachments.json');
266
+ if (!this.#readOnly) {
267
+ try {
268
+ if (!existsSync(this.#dir)) mkdirSync(this.#dir, { recursive: true });
269
+ } catch {
270
+ this.#readOnly = true;
271
+ }
272
+ }
273
+ this.#loadAll();
274
+ } else {
275
+ this.#dir = null;
276
+ this.#indexPath = null;
277
+ this.#attachmentsPath = null;
278
+ }
279
+
280
+ // Ensure the main thread is always present.
281
+ if (!this.#threads.has(MAIN_THREAD_ID)) {
282
+ const now = Date.now();
283
+ this.#threads.set(
284
+ MAIN_THREAD_ID,
285
+ this.#newThreadRecord({
286
+ id: MAIN_THREAD_ID,
287
+ name: 'main',
288
+ goal: '',
289
+ parentThreadId: null,
290
+ createdAt: now,
291
+ updatedAt: now,
292
+ }),
293
+ );
294
+ this.#markDirty(MAIN_THREAD_ID);
295
+ }
296
+
297
+ // If the on-disk currentId is unknown, fall back to main.
298
+ if (!this.#threads.has(this.#currentId)) {
299
+ this.#currentId = MAIN_THREAD_ID;
300
+ }
70
301
  }
71
302
 
72
- /** Internal: build a thread record with default cached fields. */
303
+ /** Build a thread record with default cached fields. */
73
304
  #newThreadRecord(base) {
74
305
  return {
75
306
  status: 'active',
76
307
  messageCount: 0,
77
308
  lastMessageAt: null,
78
- lastActivityAt: null, // task-300 sidebar: latest of lastMessageAt/updatedAt
309
+ lastActivityAt: null,
79
310
  archived: false,
80
- unread: 0, // task-300 sidebar: messages since last read marker
81
- preview: '', // task-300 sidebar: short excerpt of latest content
311
+ unread: 0,
312
+ preview: '',
82
313
  ...base,
83
314
  };
84
315
  }
85
316
 
86
- /** Get current thread id (defaults to 'main'). */
87
- get currentId() {
88
- return this.#currentId;
317
+ // ─── load / persist ────────────────────────────────────────────────
318
+
319
+ /** Load all thread files + attachments from disk into memory. */
320
+ #loadAll() {
321
+ if (!this.#dir || !existsSync(this.#dir)) return;
322
+ let entries;
323
+ try {
324
+ entries = readdirSync(this.#dir, { withFileTypes: true });
325
+ } catch {
326
+ return;
327
+ }
328
+ for (const entry of entries) {
329
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
330
+ if (entry.name === 'index.md') continue;
331
+ const id = entry.name.slice(0, -3);
332
+ try {
333
+ const raw = readFileSync(join(this.#dir, entry.name), 'utf8');
334
+ const parsed = parseThread(raw);
335
+ if (parsed && parsed.id === id) {
336
+ this.#threads.set(id, parsed);
337
+ }
338
+ } catch {
339
+ // Skip corrupt files silently.
340
+ }
341
+ }
342
+ // Attachments side-car.
343
+ try {
344
+ if (this.#attachmentsPath && existsSync(this.#attachmentsPath)) {
345
+ const raw = readFileSync(this.#attachmentsPath, 'utf8');
346
+ for (const { threadId, taskId } of parseAttachments(raw)) {
347
+ if (this.#threads.has(threadId)) {
348
+ this.#attachments.set(threadId, taskId);
349
+ }
350
+ }
351
+ }
352
+ } catch {
353
+ // Skip silently.
354
+ }
355
+ // Try to recover currentId from index.md frontmatter.
356
+ try {
357
+ if (this.#indexPath && existsSync(this.#indexPath)) {
358
+ const raw = readFileSync(this.#indexPath, 'utf8');
359
+ const m = raw.match(/^currentId:\s*(\S+)/m);
360
+ if (m && this.#threads.has(m[1])) {
361
+ this.#currentId = m[1];
362
+ }
363
+ }
364
+ } catch {
365
+ // Ignore — fall back to main on miss.
366
+ }
367
+ }
368
+
369
+ #markDirty(threadId) {
370
+ if (!this.#dir || this.#readOnly) return;
371
+ this.#dirtyThreads.add(threadId);
372
+ this.#dirtyIndex = true;
373
+ this.#scheduleFlush();
89
374
  }
90
375
 
91
- /** Total thread count (including 'main'). */
92
- get size() {
93
- return this.#threads.size;
376
+ #markAttachmentsDirty() {
377
+ if (!this.#dir || this.#readOnly) return;
378
+ this.#dirtyAttachments = true;
379
+ this.#dirtyIndex = true;
380
+ this.#scheduleFlush();
381
+ }
382
+
383
+ #scheduleFlush() {
384
+ if (this.#flushTimer || typeof setTimeout !== 'function') return;
385
+ this.#flushTimer = setTimeout(() => {
386
+ this.#flushTimer = null;
387
+ this.flush();
388
+ }, FLUSH_DEBOUNCE_MS);
389
+ // Do not hold the process open for a pending flush — if the Node loop
390
+ // has nothing else to do, the store still makes it through shutdown
391
+ // via explicit flush() or process exit handlers.
392
+ if (this.#flushTimer && typeof this.#flushTimer.unref === 'function') {
393
+ this.#flushTimer.unref();
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Write any pending dirty state to disk immediately. Safe to call on an
399
+ * in-memory or read-only store (it becomes a no-op). Returns the number of
400
+ * files written.
401
+ */
402
+ flush() {
403
+ if (!this.#dir || this.#readOnly) {
404
+ this.#dirtyThreads.clear();
405
+ this.#dirtyIndex = false;
406
+ this.#dirtyAttachments = false;
407
+ return 0;
408
+ }
409
+ let written = 0;
410
+ for (const id of this.#dirtyThreads) {
411
+ const t = this.#threads.get(id);
412
+ if (!t) {
413
+ // Deleted thread → remove the file if present.
414
+ try {
415
+ const p = join(this.#dir, `${id}.md`);
416
+ if (existsSync(p)) {
417
+ unlinkSync(p);
418
+ written += 1;
419
+ }
420
+ } catch {
421
+ // ignore
422
+ }
423
+ continue;
424
+ }
425
+ try {
426
+ writeFileSync(join(this.#dir, `${id}.md`), serializeThread(t), 'utf8');
427
+ written += 1;
428
+ } catch {
429
+ // Best-effort
430
+ }
431
+ }
432
+ this.#dirtyThreads.clear();
433
+ if (this.#dirtyAttachments) {
434
+ try {
435
+ writeFileSync(this.#attachmentsPath, serializeAttachments(this.#attachments), 'utf8');
436
+ } catch {
437
+ // ignore
438
+ }
439
+ this.#dirtyAttachments = false;
440
+ }
441
+ if (this.#dirtyIndex) {
442
+ try {
443
+ writeFileSync(this.#indexPath, generateIndex(this.#threads, this.#currentId, this.#attachments), 'utf8');
444
+ } catch {
445
+ // ignore
446
+ }
447
+ this.#dirtyIndex = false;
448
+ }
449
+ return written;
94
450
  }
95
451
 
452
+ // ─── Query API (unchanged) ─────────────────────────────────────────
453
+
454
+ get currentId() { return this.#currentId; }
455
+ get size() { return this.#threads.size; }
456
+
457
+ get(id) { return this.#threads.get(id) || null; }
458
+ list() { return [...this.#threads.values()]; }
459
+ has(id) { return this.#threads.has(id); }
460
+
461
+ // ─── Mutation API (all calls schedule a debounced flush) ───────────
462
+
96
463
  /**
97
464
  * Create a new thread.
98
465
  * @param {{ name: string, goal?: string, parentThreadId?: string }} spec
@@ -116,28 +483,11 @@ export class ThreadStore {
116
483
  updatedAt: now,
117
484
  });
118
485
  this.#threads.set(id, thread);
486
+ this.#markDirty(id);
119
487
  return thread;
120
488
  }
121
489
 
122
- /** @param {string} id */
123
- get(id) {
124
- return this.#threads.get(id) || null;
125
- }
126
-
127
- /** @returns {Thread[]} */
128
- list() {
129
- return [...this.#threads.values()];
130
- }
131
-
132
- /** @param {string} id */
133
- has(id) {
134
- return this.#threads.has(id);
135
- }
136
-
137
- /**
138
- * Set the current thread marker. Throws if unknown.
139
- * @param {string} id
140
- */
490
+ /** Set the current thread marker. Throws if unknown. */
141
491
  switch(id) {
142
492
  if (!this.#threads.has(id)) {
143
493
  throw new Error(`thread not found: ${id}`);
@@ -145,16 +495,13 @@ export class ThreadStore {
145
495
  this.#currentId = id;
146
496
  const t = this.#threads.get(id);
147
497
  t.updatedAt = Date.now();
498
+ this.#markDirty(id);
148
499
  }
149
500
 
150
501
  /**
151
502
  * Record that a message has been persisted on a thread. Increments the
152
503
  * cached messageCount and updates lastMessageAt. Safe to call repeatedly;
153
- * unknown threadIds are silently ignored (defense: bookkeeping must never
154
- * block the main persist path).
155
- *
156
- * @param {string} threadId
157
- * @param {number} [at=Date.now()]
504
+ * unknown threadIds are silently ignored.
158
505
  */
159
506
  noteMessage(threadId, at = Date.now(), opts = {}) {
160
507
  const t = this.#threads.get(threadId);
@@ -163,38 +510,29 @@ export class ThreadStore {
163
510
  t.lastMessageAt = at;
164
511
  t.lastActivityAt = at;
165
512
  t.updatedAt = at;
166
- // task-300 sidebar unread counter: any new message not originating from the
167
- // user themselves counts as unread until markRead() is called. Callers may
168
- // pass { countsAsUnread: false } (e.g. for user's own messages).
169
513
  if (opts.countsAsUnread !== false) {
170
514
  t.unread += 1;
171
515
  }
172
- // Short preview for sidebar hover / list (capped at 160 chars).
173
516
  if (typeof opts.preview === 'string' && opts.preview.length > 0) {
174
517
  const p = opts.preview.replace(/\s+/g, ' ').trim();
175
518
  t.preview = p.length > 160 ? p.slice(0, 157) + '...' : p;
176
519
  }
177
- // Any activity bumps archived back to active.
178
520
  if (t.status === 'archived') {
179
521
  t.status = 'active';
180
522
  t.archived = false;
181
523
  }
524
+ this.#markDirty(threadId);
182
525
  }
183
526
 
184
- /**
185
- * Mark a thread as read — resets unread counter to 0. Safe on unknown id.
186
- * @param {string} threadId
187
- */
527
+ /** Mark a thread as read — resets unread counter to 0. */
188
528
  markRead(threadId) {
189
529
  const t = this.#threads.get(threadId);
190
530
  if (!t) return;
531
+ if (t.unread === 0) return;
191
532
  t.unread = 0;
533
+ this.#markDirty(threadId);
192
534
  }
193
535
 
194
- /**
195
- * Mark a thread archived. 'main' cannot be archived.
196
- * @param {string} id
197
- */
198
536
  archive(id) {
199
537
  const t = this.#threads.get(id);
200
538
  if (!t) throw new Error(`thread not found: ${id}`);
@@ -202,13 +540,9 @@ export class ThreadStore {
202
540
  t.status = 'archived';
203
541
  t.archived = true;
204
542
  t.updatedAt = Date.now();
543
+ this.#markDirty(id);
205
544
  }
206
545
 
207
- /**
208
- * Set thread status explicitly. Must be one of THREAD_STATUSES.
209
- * @param {string} id
210
- * @param {'active'|'idle'|'archived'} status
211
- */
212
546
  setStatus(id, status) {
213
547
  if (!THREAD_STATUSES.includes(status)) {
214
548
  throw new Error(`invalid status: ${status}`);
@@ -221,24 +555,19 @@ export class ThreadStore {
221
555
  t.status = status;
222
556
  t.archived = status === 'archived';
223
557
  t.updatedAt = Date.now();
558
+ this.#markDirty(id);
224
559
  }
225
560
 
226
561
  /**
227
562
  * Rebuild cached fields (messageCount/lastMessageAt) from a flat messages
228
- * list. Used for crash recovery or as a sanity check in tests. Each
229
- * message must have { threadId, createdAt? }; missing threadId is treated
230
- * as MAIN_THREAD_ID (matches design doc §5 default).
231
- *
232
- * Counts per thread are reset to zero first to guarantee idempotency.
233
- *
234
- * @param {Array<{threadId?: string, createdAt?: number}>} messages
563
+ * list. Used for crash recovery or as a sanity check in tests.
235
564
  */
236
565
  rebuildFromMessages(messages) {
237
- // Reset counters
238
566
  for (const t of this.#threads.values()) {
239
567
  t.messageCount = 0;
240
568
  t.lastMessageAt = null;
241
569
  t.lastActivityAt = null;
570
+ this.#markDirty(t.id);
242
571
  }
243
572
  for (const m of messages || []) {
244
573
  const tid = m.threadId || MAIN_THREAD_ID;
@@ -250,14 +579,10 @@ export class ThreadStore {
250
579
  t.lastMessageAt = ts;
251
580
  t.lastActivityAt = ts;
252
581
  }
582
+ this.#markDirty(tid);
253
583
  }
254
584
  }
255
585
 
256
- /**
257
- * Attach a task to a thread. Overwrites any existing attachment.
258
- * @param {string} threadId
259
- * @param {string} taskId
260
- */
261
586
  attachTask(threadId, taskId) {
262
587
  if (!this.#threads.has(threadId)) {
263
588
  throw new Error(`thread not found: ${threadId}`);
@@ -266,35 +591,46 @@ export class ThreadStore {
266
591
  throw new Error('taskId is required');
267
592
  }
268
593
  this.#attachments.set(threadId, taskId);
594
+ this.#markAttachmentsDirty();
269
595
  }
270
596
 
271
- /**
272
- * Get the taskId attached to a thread, if any.
273
- * @param {string} threadId
274
- * @returns {string|null}
275
- */
276
597
  attachedTask(threadId) {
277
598
  return this.#attachments.get(threadId) || null;
278
599
  }
279
600
 
280
- /** @returns {Array<{ threadId: string, taskId: string }>} */
281
601
  listAttachments() {
282
602
  return [...this.#attachments.entries()].map(([threadId, taskId]) => ({ threadId, taskId }));
283
603
  }
284
604
  }
285
605
 
606
+ // ─── Singleton helpers ───────────────────────────────────────────────────
607
+
286
608
  /** @type {ThreadStore|null} */
287
609
  let threadStore = null;
288
610
 
289
611
  /**
290
- * Initialize the thread store. Safe to call multiple times — subsequent calls
612
+ * Initialise the thread store. Safe to call multiple times — subsequent calls
291
613
  * replace the store only if `force` is true (primarily for tests).
292
- * @param {{ force?: boolean }} [opts]
614
+ *
615
+ * Accepts either `initThreadStore()` (legacy, in-memory) or
616
+ * `initThreadStore(yeaftDir, opts)` (persistent). Legacy callers keep working.
617
+ *
618
+ * @param {string|{ force?: boolean }} [yeaftDirOrOpts]
619
+ * @param {{ force?: boolean, readOnly?: boolean }} [opts]
293
620
  * @returns {ThreadStore}
294
621
  */
295
- export function initThreadStore(opts = {}) {
296
- if (!threadStore || opts.force) {
297
- threadStore = new ThreadStore();
622
+ export function initThreadStore(yeaftDirOrOpts, opts = {}) {
623
+ let yeaftDir;
624
+ let mergedOpts;
625
+ if (typeof yeaftDirOrOpts === 'string') {
626
+ yeaftDir = yeaftDirOrOpts;
627
+ mergedOpts = opts || {};
628
+ } else {
629
+ yeaftDir = undefined;
630
+ mergedOpts = yeaftDirOrOpts || {};
631
+ }
632
+ if (!threadStore || mergedOpts.force) {
633
+ threadStore = new ThreadStore(yeaftDir, mergedOpts);
298
634
  }
299
635
  return threadStore;
300
636
  }
@@ -309,5 +645,11 @@ export function getThreadStore() {
309
645
 
310
646
  /** Test-only reset helper. */
311
647
  export function _resetThreadStoreForTests() {
648
+ if (threadStore && typeof threadStore.flush === 'function') {
649
+ try { threadStore.flush(); } catch { /* ignore */ }
650
+ }
312
651
  threadStore = null;
313
652
  }
653
+
654
+ // Exported for tests.
655
+ export { serializeThread as _serializeThread, parseThread as _parseThread };