@yeaft/webchat-agent 0.1.482 → 0.1.483

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.483",
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
+ }