@yeaft/webchat-agent 0.1.535 → 0.1.537

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.535",
3
+ "version": "0.1.537",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -301,9 +301,13 @@ export class TaskStore {
301
301
  *
302
302
  * If an `onEvent` callback was passed at construction time, emits a
303
303
  * `task_member_added` event synchronously after the write:
304
- * { type: 'task_member_added', taskId, vpId, members, ts }
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
305
309
  */
306
- addMember(id, vpId) {
310
+ addMember(id, vpId, opts) {
307
311
  const task = this.#tasks.get(id);
308
312
  if (!task) return { task: null, added: false };
309
313
  if (!vpId || typeof vpId !== 'string') {
@@ -315,10 +319,12 @@ export class TaskStore {
315
319
  }
316
320
  members.push(vpId);
317
321
  this.update(id, { members });
322
+ const addedBy = opts?.addedBy || null;
318
323
  this.#emit({
319
324
  type: 'task_member_added',
320
325
  taskId: id,
321
326
  vpId,
327
+ addedBy,
322
328
  members: members.slice(),
323
329
  ts: Date.now(),
324
330
  });
@@ -36,6 +36,11 @@ export const SUMMARY_REMINDER_MIN_AGE_MS = 20 * 60 * 1000;
36
36
  export const EXTRACT_MIN_ENTRIES = 2;
37
37
  export const EXTRACT_MAX_ENTRIES = 5;
38
38
 
39
+ /** Semantic progress anchors — string labels accepted alongside 0-100 number. */
40
+ export const PROGRESS_ANCHORS = Object.freeze([
41
+ 'blocked', 'planning', 'in-progress', 'review', 'milestone', 'shipped', 'done',
42
+ ]);
43
+
39
44
  /** Whitelist of R6 kinds emitted by the summary-extractor. */
40
45
  const EXTRACT_KINDS = Object.freeze(['progress', 'decision']);
41
46
 
@@ -56,7 +61,7 @@ const KIND_TO_SHARD = Object.freeze({
56
61
  * taskId: string,
57
62
  * fromVpId: string,
58
63
  * body: string,
59
- * progress?: number, // 0..100
64
+ * progress?: number|string, // 0..100 or PROGRESS_ANCHORS string
60
65
  * supersedes?: string[], // prior summary msgIds being superseded
61
66
  * memoryDir: string, // groups/<g>/tasks/<t>/memory/
62
67
  * now?: () => number, // test clock
@@ -87,9 +92,15 @@ export function postSummary(opts) {
87
92
  throw new Error('postSummary: body required (non-empty string)');
88
93
  }
89
94
  if (progress != null) {
90
- const p = Number(progress);
91
- if (!Number.isFinite(p) || p < 0 || p > 100) {
92
- throw new Error('postSummary: progress must be number in [0,100]');
95
+ if (typeof progress === 'string') {
96
+ if (!PROGRESS_ANCHORS.includes(progress)) {
97
+ throw new Error(`postSummary: progress string must be one of: ${PROGRESS_ANCHORS.join(', ')}`);
98
+ }
99
+ } else {
100
+ const p = Number(progress);
101
+ if (!Number.isFinite(p) || p < 0 || p > 100) {
102
+ throw new Error('postSummary: progress must be number in [0,100] or a semantic anchor string');
103
+ }
93
104
  }
94
105
  }
95
106
  const supersedesArr = Array.isArray(supersedes)
@@ -104,7 +115,7 @@ export function postSummary(opts) {
104
115
  taskId,
105
116
  meta: {
106
117
  type: 'summary',
107
- progress: progress == null ? null : Number(progress),
118
+ progress: progress == null ? null : (typeof progress === 'string' ? progress : Number(progress)),
108
119
  supersedes: supersedesArr,
109
120
  },
110
121
  });
@@ -186,8 +197,12 @@ export function defaultExtractor(body) {
186
197
  // If we ended up with fewer than MIN and there was a body, collapse to
187
198
  // one "progress" entry carrying the trimmed full body so we never emit 0
188
199
  // when the caller gave us real content and asked for 2-5.
189
- if (out.length < EXTRACT_MIN_ENTRIES && lines.length === 0 && body.trim()) {
190
- out.push({ kind: 'progress', body: body.trim() });
200
+ if (out.length < EXTRACT_MIN_ENTRIES && body.trim()) {
201
+ // Pad up to EXTRACT_MIN_ENTRIES with the full trimmed body when
202
+ // the line-by-line pass yielded fewer entries than the minimum.
203
+ while (out.length < EXTRACT_MIN_ENTRIES) {
204
+ out.push({ kind: 'progress', body: body.trim() });
205
+ }
191
206
  }
192
207
  return out;
193
208
  }
@@ -255,11 +270,12 @@ export function buildSummaryReminder(input) {
255
270
  * @param {{ tags?: string[], top?: number }} [opts]
256
271
  * tags : optional tag hints to boost relevance
257
272
  * top : default 5
258
- * @returns {Array<{body:string, shard:string}>}
273
+ * @returns {Array<{body:string, shard:string, authoredBy?:string}>}
259
274
  */
260
275
  export function buildTaskCtxMemories(memoryDir, opts = {}) {
261
276
  const top = Number.isFinite(opts.top) ? Number(opts.top) : 5;
262
277
  const tagHints = Array.isArray(opts.tags) ? opts.tags : [];
278
+ const nowMs = typeof opts.now === 'number' ? opts.now : Date.now();
263
279
  let results = [];
264
280
  try {
265
281
  const store = openMemoryShardStore(memoryDir, 'task');
@@ -277,6 +293,7 @@ export function buildTaskCtxMemories(memoryDir, opts = {}) {
277
293
  tags: Array.isArray(r.tags) ? r.tags : [],
278
294
  pinned: !!r.pinned,
279
295
  createdAt: full?.createdAt || null,
296
+ authoredBy: full?.authoredBy || null,
280
297
  };
281
298
  })
282
299
  .filter((r) => r.body && r.body.trim());
@@ -284,8 +301,13 @@ export function buildTaskCtxMemories(memoryDir, opts = {}) {
284
301
  const score = (r) => {
285
302
  let s = 0;
286
303
  if (r.pinned) s += 1000;
287
- // recency proxy (ISO string compare works lexicographically)
288
- if (r.createdAt) s += 10;
304
+ // Recency decay: half-life of 24h. Recent entries score up to +100,
305
+ // decaying toward 0 as they age.
306
+ if (r.createdAt) {
307
+ const ageMs = nowMs - new Date(r.createdAt).getTime();
308
+ const halfLifeMs = 24 * 60 * 60 * 1000; // 24 hours
309
+ s += Math.max(0, 100 * Math.pow(0.5, Math.max(0, ageMs) / halfLifeMs));
310
+ }
289
311
  // tag relevance
290
312
  for (const t of tagHints) if (r.tags.includes(t)) s += 5;
291
313
  return s;
@@ -296,7 +318,11 @@ export function buildTaskCtxMemories(memoryDir, opts = {}) {
296
318
  // stable recency tie-break
297
319
  return String(b.createdAt || '').localeCompare(String(a.createdAt || ''));
298
320
  });
299
- results = hits.slice(0, top).map((r) => ({ body: r.body, shard: r.shard }));
321
+ results = hits.slice(0, top).map((r) => ({
322
+ body: r.body,
323
+ shard: r.shard,
324
+ ...(r.authoredBy ? { authoredBy: r.authoredBy } : {}),
325
+ }));
300
326
  } catch {
301
327
  results = [];
302
328
  }
@@ -65,6 +65,10 @@ const QUERY_TIMEOUT_MS = 120_000;
65
65
  /** Virtual conversationId for the Unify session */
66
66
  let unifyConversationId = null;
67
67
 
68
+ /** task-334-followup-batch-b: stored unsubscribe fn from VP subscribe,
69
+ * called on session reset to prevent stale subscriber leaks. */
70
+ let _vpUnsubscribe = null;
71
+
68
72
  /**
69
73
  * task-320: per-thread accumulated conversation messages for context
70
74
  * continuity. Previously a single flat array — which cross-contaminated
@@ -127,7 +131,13 @@ function sendUnifyEvent(event) {
127
131
  * `vp_updated` / `vp_removed` events to every active subscriber.
128
132
  */
129
133
  export function handleUnifyVpSubscribe(_msg) {
130
- handleVpSubscribe(sendUnifyEvent);
134
+ // Unsub any prior subscription before re-subscribing to prevent duplicate
135
+ // handler registration on reconnect / re-subscribe.
136
+ if (_vpUnsubscribe) {
137
+ try { _vpUnsubscribe(); } catch { /* ignore */ }
138
+ _vpUnsubscribe = null;
139
+ }
140
+ _vpUnsubscribe = handleVpSubscribe(sendUnifyEvent);
131
141
  }
132
142
 
133
143
  /**
@@ -1520,6 +1530,11 @@ export async function resetUnifySession() {
1520
1530
  try { ctrl.abort(); } catch { /* ignore */ }
1521
1531
  }
1522
1532
  abortByThread.clear();
1533
+ // Clean up VP subscriber to prevent stale sends after reset.
1534
+ if (_vpUnsubscribe) {
1535
+ try { _vpUnsubscribe(); } catch { /* ignore */ }
1536
+ _vpUnsubscribe = null;
1537
+ }
1523
1538
  if (session) {
1524
1539
  await session.shutdown();
1525
1540
  session = null;