@yeaft/webchat-agent 0.1.616 → 0.1.618

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,652 +0,0 @@
1
- /**
2
- * store.js — File-system backed TaskStore for Yeaft Unify.
3
- *
4
- * Persists tasks to ~/.yeaft/tasks/ with one folder per task.
5
- * Layout:
6
- * ~/.yeaft/tasks/
7
- * index.md — Task index (auto-generated overview)
8
- * plan.md — Global plan text
9
- * task-abc12345/ — One folder per task
10
- * task.md — Task metadata (YAML frontmatter + description)
11
- * progress.md — Progress log (append-only)
12
- * memory.md — Task-specific context/notes
13
- */
14
-
15
- import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from 'fs';
16
- import { join } from 'path';
17
-
18
- // ─── YAML Frontmatter helpers ────────────────────────────────
19
-
20
- /**
21
- * Serialize a task object to YAML frontmatter + body for task.md.
22
- * @param {object} task
23
- * @returns {string}
24
- */
25
- function serializeTask(task) {
26
- const fm = [
27
- '---',
28
- `id: ${task.id}`,
29
- `title: ${task.title}`,
30
- `status: ${task.status}`,
31
- `priority: ${task.priority || 'medium'}`,
32
- ];
33
-
34
- // task-299 (Q3 rework): parentTaskId is the canonical field per design §5.
35
- // parentId is kept as a legacy mirror for backward compat with any tool
36
- // that still reads it; both are always in sync after migration.
37
- if (task.parentTaskId) fm.push(`parentTaskId: ${task.parentTaskId}`);
38
- if (task.parentId) fm.push(`parentId: ${task.parentId}`);
39
- if (task.primaryThreadId) fm.push(`primaryThreadId: ${task.primaryThreadId}`);
40
- // task-334n — multi-VP collaboration protocol fields.
41
- // initiator: VP id that created the task (fallback target for ACL / reminder).
42
- // members: explicit VP roster for the task (supersedes group roster when set).
43
- // groupId: the group this task belongs to (null for legacy / standalone).
44
- if (task.initiator) fm.push(`initiator: ${task.initiator}`);
45
- if (Array.isArray(task.members) && task.members.length) {
46
- fm.push(`members: [${task.members.join(', ')}]`);
47
- }
48
- if (task.groupId) fm.push(`groupId: ${task.groupId}`);
49
- if (task.createdAt) fm.push(`createdAt: ${task.createdAt}`);
50
- if (task.updatedAt) fm.push(`updatedAt: ${task.updatedAt}`);
51
-
52
- fm.push('---');
53
- fm.push('');
54
-
55
- // Body: description + result
56
- const parts = [];
57
- if (task.description) parts.push(task.description);
58
- if (task.result) {
59
- parts.push('');
60
- parts.push('## Result');
61
- parts.push(task.result);
62
- }
63
- fm.push(parts.join('\n'));
64
-
65
- return fm.join('\n');
66
- }
67
-
68
- /**
69
- * Parse a task.md file (YAML frontmatter + body) into a task object.
70
- * @param {string} raw — File contents
71
- * @returns {object|null}
72
- */
73
- function parseTask(raw) {
74
- if (!raw || !raw.startsWith('---')) return null;
75
-
76
- const endIdx = raw.indexOf('---', 3);
77
- if (endIdx === -1) return null;
78
-
79
- const frontmatter = raw.slice(3, endIdx).trim();
80
- const body = raw.slice(endIdx + 3).trim();
81
-
82
- const task = {};
83
-
84
- for (const line of frontmatter.split('\n')) {
85
- const colonIdx = line.indexOf(':');
86
- if (colonIdx === -1) continue;
87
- const key = line.slice(0, colonIdx).trim();
88
- const val = line.slice(colonIdx + 1).trim();
89
- if (!key) continue;
90
-
91
- if (key === 'createdAt' || key === 'updatedAt') {
92
- task[key] = parseInt(val, 10) || 0;
93
- } else if (key === 'members') {
94
- // task-334n — members: [vp-a, vp-b, ...]
95
- task.members = val
96
- .replace(/^\[|\]$/g, '')
97
- .split(',')
98
- .map((s) => s.trim())
99
- .filter(Boolean);
100
- } else {
101
- task[key] = val;
102
- }
103
- }
104
-
105
- if (!task.id) return null;
106
-
107
- // Parse body: description and result
108
- const resultIdx = body.indexOf('## Result');
109
- if (resultIdx !== -1) {
110
- task.description = body.slice(0, resultIdx).trim();
111
- task.result = body.slice(resultIdx + '## Result'.length).trim();
112
- } else {
113
- task.description = body;
114
- }
115
-
116
- // Normalize parentId / parentTaskId (design §5 canonical is parentTaskId).
117
- // If only legacy parentId is present, promote it to parentTaskId so
118
- // anything that reads the canonical field sees a value. Null/"null"
119
- // strings become real null.
120
- if (!task.parentId || task.parentId === 'null') task.parentId = null;
121
- if (!task.parentTaskId || task.parentTaskId === 'null') task.parentTaskId = null;
122
- if (!task.parentTaskId && task.parentId) task.parentTaskId = task.parentId;
123
- if (!task.parentId && task.parentTaskId) task.parentId = task.parentTaskId;
124
-
125
- // primaryThreadId: per design §5 clarification (task-299 Q4), null means
126
- // "unbound / orphan" — it does NOT implicitly equal 'main'. Keep null as null.
127
- if (!task.primaryThreadId || task.primaryThreadId === 'null') {
128
- task.primaryThreadId = null;
129
- }
130
-
131
- return task;
132
- }
133
-
134
- // ─── Index generation ────────────────────────────────────────
135
-
136
- /**
137
- * Generate index.md content from all tasks.
138
- * @param {Map<string, object>} tasks
139
- * @returns {string}
140
- */
141
- function generateIndex(tasks) {
142
- const now = new Date().toISOString();
143
- const lines = [
144
- '---',
145
- `totalTasks: ${tasks.size}`,
146
- `lastUpdated: ${now}`,
147
- '---',
148
- '# Task Index',
149
- '',
150
- '| ID | Title | Status | Priority | Updated |',
151
- '|----|-------|--------|----------|---------|',
152
- ];
153
-
154
- // Sort: in_progress first, then pending, then others
155
- const ORDER = { in_progress: 0, pending: 1, blocked: 2, completed: 3, cancelled: 4 };
156
- const sorted = [...tasks.values()].sort(
157
- (a, b) => (ORDER[a.status] ?? 5) - (ORDER[b.status] ?? 5)
158
- );
159
-
160
- for (const t of sorted) {
161
- const date = t.updatedAt ? new Date(t.updatedAt).toISOString().slice(0, 10) : '-';
162
- lines.push(`| ${t.id} | ${t.title} | ${t.status} | ${t.priority || 'medium'} | ${date} |`);
163
- }
164
-
165
- return lines.join('\n') + '\n';
166
- }
167
-
168
- // ─── Progress log helpers ────────────────────────────────────
169
-
170
- /**
171
- * Format a progress entry for appending to progress.md.
172
- * @param {string} note
173
- * @param {object} [meta]
174
- * @returns {string}
175
- */
176
- function formatProgressEntry(note, meta = {}) {
177
- const now = new Date();
178
- const ts = `${now.toISOString().slice(0, 10)} ${now.toISOString().slice(11, 16)}`;
179
- const lines = [`## ${ts}`];
180
- lines.push(`- ${note}`);
181
- if (meta.status) lines.push(`- Status: ${meta.status}`);
182
- if (meta.result) lines.push(`- Result: ${meta.result}`);
183
- lines.push('');
184
- return lines.join('\n');
185
- }
186
-
187
- // ─── TaskStore ───────────────────────────────────────────────
188
-
189
- export class TaskStore {
190
- /** @type {string} */
191
- #dir;
192
- /** @type {string} */
193
- #indexPath;
194
- /** @type {string} */
195
- #planPath;
196
- /** @type {Map<string, object>} */
197
- #tasks;
198
- /** @type {boolean} */
199
- #readOnly;
200
- /** @type {Array<(evt:any)=>void>} */
201
- #listeners;
202
-
203
- /**
204
- * @param {string} yeaftDir — Base ~/.yeaft directory
205
- * @param {{ readOnly?: boolean }} [opts]
206
- */
207
- constructor(yeaftDir, opts = {}) {
208
- this.#dir = join(yeaftDir, 'tasks');
209
- this.#indexPath = join(this.#dir, 'index.md');
210
- this.#planPath = join(this.#dir, 'plan.md');
211
- this.#tasks = new Map();
212
- this.#readOnly = opts.readOnly || false;
213
- this.#listeners = [];
214
-
215
- // Ensure base directory exists
216
- if (!this.#readOnly) {
217
- if (!existsSync(this.#dir)) {
218
- try {
219
- mkdirSync(this.#dir, { recursive: true });
220
- } catch {
221
- this.#readOnly = true;
222
- }
223
- }
224
- }
225
-
226
- // Load existing tasks from disk
227
- this.#loadAll();
228
-
229
- // task-299 (Q3, rework): run a one-shot backfill that promotes legacy
230
- // `parentId` to the canonical `parentTaskId` field (design §5). A
231
- // marker file (.migrations/parentTaskId) records completion so the
232
- // migration is skipped on subsequent boots and is idempotent.
233
- this.#migrateParentTaskId();
234
- }
235
-
236
- /** Number of tasks in the store. */
237
- get size() {
238
- return this.#tasks.size;
239
- }
240
-
241
- /**
242
- * Create a new task. Creates its folder with task.md, progress.md, memory.md.
243
- * @param {object} task — Must have .id, .title, .status
244
- * @returns {object} The created task
245
- */
246
- create(task) {
247
- this.#tasks.set(task.id, task);
248
-
249
- if (!this.#readOnly) {
250
- const taskDir = join(this.#dir, task.id);
251
- try {
252
- mkdirSync(taskDir, { recursive: true });
253
- writeFileSync(join(taskDir, 'task.md'), serializeTask(task), 'utf8');
254
- writeFileSync(join(taskDir, 'progress.md'), '# Progress Log\n\n', 'utf8');
255
- writeFileSync(join(taskDir, 'memory.md'), '# Task Memory\n', 'utf8');
256
- this.#appendProgressInternal(task.id, `Created task: ${task.title}`, { status: 'pending' });
257
- this.#updateIndex();
258
- } catch {
259
- // Best-effort write
260
- }
261
- }
262
-
263
- return task;
264
- }
265
-
266
- /**
267
- * Update an existing task.
268
- * @param {string} id
269
- * @param {object} updates
270
- * @returns {object|null} Updated task, or null if not found
271
- */
272
- update(id, updates) {
273
- const task = this.#tasks.get(id);
274
- if (!task) return null;
275
-
276
- const oldStatus = task.status;
277
- Object.assign(task, updates, { updatedAt: Date.now() });
278
-
279
- if (!this.#readOnly) {
280
- try {
281
- const taskDir = join(this.#dir, id);
282
- writeFileSync(join(taskDir, 'task.md'), serializeTask(task), 'utf8');
283
-
284
- // Log progress on status change
285
- if (updates.status && updates.status !== oldStatus) {
286
- this.#appendProgressInternal(id, `Status changed: ${oldStatus} → ${updates.status}`, updates);
287
- }
288
- this.#updateIndex();
289
- } catch {
290
- // Best-effort
291
- }
292
- }
293
-
294
- return task;
295
- }
296
-
297
- /**
298
- * task-334n — add a VP member to a task's collaboration roster.
299
- * Idempotent: adding an existing member is a no-op (no event emitted).
300
- * Returns `{ task, added: boolean }`.
301
- *
302
- * If an `onEvent` callback was passed at construction time, emits a
303
- * `task_member_added` event synchronously after the write:
304
- * { type: 'task_member_added', taskId, vpId, addedBy, members, ts }
305
- *
306
- * @param {string} id — task id
307
- * @param {string} vpId — VP being added
308
- * @param {{ addedBy?: string }} [opts] — provenance: who triggered the add
309
- */
310
- addMember(id, vpId, opts) {
311
- const task = this.#tasks.get(id);
312
- if (!task) return { task: null, added: false };
313
- if (!vpId || typeof vpId !== 'string') {
314
- throw new Error('addMember: vpId required (string)');
315
- }
316
- const members = Array.isArray(task.members) ? task.members.slice() : [];
317
- if (members.includes(vpId)) {
318
- return { task, added: false };
319
- }
320
- members.push(vpId);
321
- this.update(id, { members });
322
- const addedBy = opts?.addedBy || null;
323
- this.#emit({
324
- type: 'task_member_added',
325
- taskId: id,
326
- vpId,
327
- addedBy,
328
- members: members.slice(),
329
- ts: Date.now(),
330
- });
331
- return { task: this.#tasks.get(id), added: true };
332
- }
333
-
334
- /**
335
- * task-334n — remove a VP member from a task.
336
- * Idempotent: removing a non-member is a no-op (no event emitted).
337
- * Returns `{ task, removed: boolean }`.
338
- * Emits `task_member_removed` on successful removal.
339
- */
340
- removeMember(id, vpId) {
341
- const task = this.#tasks.get(id);
342
- if (!task) return { task: null, removed: false };
343
- if (!vpId || typeof vpId !== 'string') {
344
- throw new Error('removeMember: vpId required (string)');
345
- }
346
- const members = Array.isArray(task.members) ? task.members.slice() : [];
347
- const idx = members.indexOf(vpId);
348
- if (idx === -1) return { task, removed: false };
349
- members.splice(idx, 1);
350
- this.update(id, { members });
351
- this.#emit({
352
- type: 'task_member_removed',
353
- taskId: id,
354
- vpId,
355
- members: members.slice(),
356
- ts: Date.now(),
357
- });
358
- return { task: this.#tasks.get(id), removed: true };
359
- }
360
-
361
- /**
362
- * task-334n §Δ27.3 ACL — true iff `vpId` may read `otherTaskId`'s
363
- * memory/summary. Pass grants when:
364
- * - both tasks share the same non-null groupId, OR
365
- * - members sets intersect on at least one vpId
366
- * Fail-closed: missing task, missing groupId match, no intersection → false.
367
- *
368
- * @param {string} currentTaskId — task the caller is running in
369
- * @param {string} otherTaskId — task whose data the caller wants to read
370
- * @param {string} [vpId] — caller's vp id; if set, must also be
371
- * a member of currentTaskId (prevents stranger elevating via URL probe)
372
- * @returns {boolean}
373
- */
374
- canAccessRelated(currentTaskId, otherTaskId, vpId) {
375
- if (!currentTaskId || !otherTaskId || currentTaskId === otherTaskId) {
376
- return false;
377
- }
378
- const cur = this.#tasks.get(currentTaskId);
379
- const other = this.#tasks.get(otherTaskId);
380
- if (!cur || !other) return false;
381
-
382
- // If caller claims a vpId, they must be a member of the current task or
383
- // its initiator. Otherwise this is a cross-context read — fail-closed.
384
- if (vpId) {
385
- const curMembers = Array.isArray(cur.members) ? cur.members : [];
386
- const isInsider = curMembers.includes(vpId) || cur.initiator === vpId;
387
- if (!isInsider) return false;
388
- }
389
-
390
- // Same-group rule.
391
- if (cur.groupId && other.groupId && cur.groupId === other.groupId) {
392
- return true;
393
- }
394
-
395
- // Members-intersection rule.
396
- const a = Array.isArray(cur.members) ? cur.members : [];
397
- const b = Array.isArray(other.members) ? other.members : [];
398
- if (a.length === 0 || b.length === 0) return false;
399
- const bSet = new Set(b);
400
- for (const v of a) if (bSet.has(v)) return true;
401
- return false;
402
- }
403
-
404
- /** Register an event listener (task-334n member events). */
405
- onEvent(fn) {
406
- if (typeof fn === 'function') this.#listeners.push(fn);
407
- return () => {
408
- const i = this.#listeners.indexOf(fn);
409
- if (i >= 0) this.#listeners.splice(i, 1);
410
- };
411
- }
412
-
413
- #emit(evt) {
414
- for (const fn of this.#listeners) {
415
- try { fn(evt); } catch { /* listener failures must not corrupt store */ }
416
- }
417
- }
418
-
419
- /**
420
- * Get a task by ID.
421
- * @param {string} id
422
- * @returns {object|null}
423
- */
424
- get(id) {
425
- return this.#tasks.get(id) || null;
426
- }
427
-
428
- /**
429
- * List tasks with optional filters.
430
- * @param {{ status?: string, priority?: string }} [filter]
431
- * @returns {object[]}
432
- */
433
- list(filter) {
434
- let results = [...this.#tasks.values()];
435
- if (filter?.status) results = results.filter(t => t.status === filter.status);
436
- if (filter?.priority) results = results.filter(t => t.priority === filter.priority);
437
- return results;
438
- }
439
-
440
- /**
441
- * Return tasks grouped as a tree. Tasks with no parent (parentTaskId == null)
442
- * are "roots"; each non-root task becomes a child of its parent.
443
- *
444
- * Used by task-298/task-300 to render task hierarchies. Critical for the
445
- * Q3 migration test: before the backfill, old tasks stored only `parentId`
446
- * and `tree()` would see every task as a root.
447
- *
448
- * @returns {{ roots: object[], orphans: object[] }}
449
- * - roots : tasks whose parentTaskId is null
450
- * - orphans : tasks whose parentTaskId points to an id that no longer exists
451
- */
452
- tree() {
453
- const all = [...this.#tasks.values()];
454
- const byId = new Map(all.map(t => [t.id, t]));
455
- const roots = [];
456
- const orphans = [];
457
- for (const t of all) {
458
- if (!t.parentTaskId) roots.push(t);
459
- else if (!byId.has(t.parentTaskId)) orphans.push(t);
460
- }
461
- return { roots, orphans };
462
- }
463
-
464
- /**
465
- * Get progress log for a task.
466
- * @param {string} id
467
- * @returns {string}
468
- */
469
- getProgress(id) {
470
- const path = join(this.#dir, id, 'progress.md');
471
- try {
472
- if (existsSync(path)) return readFileSync(path, 'utf8');
473
- } catch { /* */ }
474
- return '';
475
- }
476
-
477
- /**
478
- * Append a progress note to a task's progress log.
479
- * @param {string} id
480
- * @param {string} note
481
- * @param {object} [meta]
482
- */
483
- appendProgress(id, note, meta = {}) {
484
- if (!this.#tasks.has(id)) return;
485
- this.#appendProgressInternal(id, note, meta);
486
- }
487
-
488
- /**
489
- * Get memory content for a task.
490
- * @param {string} id
491
- * @returns {string}
492
- */
493
- getMemory(id) {
494
- const path = join(this.#dir, id, 'memory.md');
495
- try {
496
- if (existsSync(path)) return readFileSync(path, 'utf8');
497
- } catch { /* */ }
498
- return '';
499
- }
500
-
501
- /**
502
- * Update memory content for a task.
503
- * @param {string} id
504
- * @param {string} content
505
- */
506
- updateMemory(id, content) {
507
- if (this.#readOnly || !this.#tasks.has(id)) return;
508
- try {
509
- writeFileSync(join(this.#dir, id, 'memory.md'), content, 'utf8');
510
- } catch { /* */ }
511
- }
512
-
513
- /**
514
- * Get current plan text.
515
- * @returns {string}
516
- */
517
- getPlan() {
518
- try {
519
- if (existsSync(this.#planPath)) return readFileSync(this.#planPath, 'utf8');
520
- } catch { /* */ }
521
- return '';
522
- }
523
-
524
- /**
525
- * Set plan text.
526
- * @param {string} text
527
- */
528
- setPlan(text) {
529
- if (this.#readOnly) return;
530
- try {
531
- writeFileSync(this.#planPath, text, 'utf8');
532
- } catch { /* */ }
533
- }
534
-
535
- // ─── Internal methods ──────────────────────────────────────
536
-
537
- /** Load all task folders from disk. */
538
- #loadAll() {
539
- if (!existsSync(this.#dir)) return;
540
- let entries;
541
- try {
542
- entries = readdirSync(this.#dir, { withFileTypes: true });
543
- } catch {
544
- return;
545
- }
546
-
547
- for (const entry of entries) {
548
- if (!entry.isDirectory() || !entry.name.startsWith('task-')) continue;
549
- const taskMdPath = join(this.#dir, entry.name, 'task.md');
550
- try {
551
- if (!existsSync(taskMdPath)) continue;
552
- const raw = readFileSync(taskMdPath, 'utf8');
553
- const task = parseTask(raw);
554
- if (task && task.id) {
555
- this.#tasks.set(task.id, task);
556
- }
557
- } catch {
558
- // Skip corrupt task folders
559
- }
560
- }
561
- }
562
-
563
- /** Append to a task's progress.md. */
564
- #appendProgressInternal(id, note, meta) {
565
- if (this.#readOnly) return;
566
- const path = join(this.#dir, id, 'progress.md');
567
- try {
568
- const existing = existsSync(path) ? readFileSync(path, 'utf8') : '# Progress Log\n\n';
569
- writeFileSync(path, existing + formatProgressEntry(note, meta), 'utf8');
570
- } catch { /* */ }
571
- }
572
-
573
- /** Regenerate index.md from all tasks. */
574
- #updateIndex() {
575
- if (this.#readOnly) return;
576
- try {
577
- writeFileSync(this.#indexPath, generateIndex(this.#tasks), 'utf8');
578
- } catch { /* */ }
579
- }
580
-
581
- /**
582
- * task-299 Q3 migration — one-shot backfill from legacy `parentId` to
583
- * canonical `parentTaskId` (design §5).
584
- *
585
- * Behaviour:
586
- * - Reads .migrations/parentTaskId meta marker. If present, returns
587
- * immediately (skips). → guarantees idempotency on repeated boots.
588
- * - Otherwise: for every loaded task, if parentId is set but
589
- * parentTaskId is not, copy parentId → parentTaskId and rewrite
590
- * task.md so the new field survives future loads.
591
- * - Writes the meta marker on success. Read-only mode is a no-op.
592
- *
593
- * Exposed publicly as `migrateParentTaskId()` so tests can re-run it.
594
- *
595
- * @returns {{ ran: boolean, migratedCount: number }}
596
- */
597
- migrateParentTaskId() {
598
- return this.#migrateParentTaskId();
599
- }
600
-
601
- #migrateParentTaskId() {
602
- if (this.#readOnly) return { ran: false, migratedCount: 0 };
603
-
604
- const markerDir = join(this.#dir, '.migrations');
605
- const markerPath = join(markerDir, 'parentTaskId');
606
-
607
- try {
608
- if (existsSync(markerPath)) return { ran: false, migratedCount: 0 };
609
- } catch {
610
- // If existsSync throws (pathological FS), proceed cautiously — the
611
- // migration itself is idempotent on per-task level.
612
- }
613
-
614
- let migratedCount = 0;
615
- for (const task of this.#tasks.values()) {
616
- // parseTask() already promotes parentId → parentTaskId in memory,
617
- // but the on-disk YAML still lacks the canonical field for old
618
- // tasks. Rewriting guarantees future loads see parentTaskId and
619
- // makes the migration visible.
620
- if (task.parentId && !task.__parentTaskIdWritten) {
621
- // Ensure the canonical field is set (parseTask normalised this,
622
- // but handle the edge case where parseTask wasn't used).
623
- if (!task.parentTaskId) task.parentTaskId = task.parentId;
624
- const taskDir = join(this.#dir, task.id);
625
- try {
626
- writeFileSync(join(taskDir, 'task.md'), serializeTask(task), 'utf8');
627
- task.__parentTaskIdWritten = true;
628
- migratedCount += 1;
629
- } catch {
630
- // Best-effort; marker is only written if the pass completes.
631
- }
632
- }
633
- }
634
-
635
- try {
636
- mkdirSync(markerDir, { recursive: true });
637
- writeFileSync(
638
- markerPath,
639
- `migrated: ${new Date().toISOString()}\ncount: ${migratedCount}\n`,
640
- 'utf8',
641
- );
642
- } catch {
643
- // Without the marker the migration may re-run; since it's idempotent
644
- // that is acceptable but not ideal. Log silently.
645
- }
646
-
647
- return { ran: true, migratedCount };
648
- }
649
- }
650
-
651
- // Exported for testing
652
- export { serializeTask as _serializeTask, parseTask as _parseTask };