@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
|
@@ -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
|
-
//
|
|
129
|
-
//
|
|
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;
|
package/unify/threads/store.js
CHANGED
|
@@ -1,26 +1,49 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* store.js —
|
|
2
|
+
* store.js — File-backed ThreadStore for Yeaft Unify (task-307a).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
*
|
|
16
|
-
* -
|
|
17
|
-
*
|
|
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.
|
|
19
28
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
|
39
|
-
* @property {number} messageCount
|
|
40
|
-
* @property {number|null} lastMessageAt
|
|
41
|
-
* @property {
|
|
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
|
-
|
|
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
|
-
/**
|
|
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,
|
|
309
|
+
lastActivityAt: null,
|
|
79
310
|
archived: false,
|
|
80
|
-
unread: 0,
|
|
81
|
-
preview: '',
|
|
311
|
+
unread: 0,
|
|
312
|
+
preview: '',
|
|
82
313
|
...base,
|
|
83
314
|
};
|
|
84
315
|
}
|
|
85
316
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
/**
|
|
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
|
|
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.
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
297
|
-
|
|
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 };
|