@yeaft/webchat-agent 1.0.129 → 1.0.130

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": "1.0.129",
3
+ "version": "1.0.130",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -69,12 +69,29 @@ export class WorkflowController {
69
69
  workflowTemplate: input.workflowTemplate || 'software-change',
70
70
  acceptanceCriteria: Array.isArray(input.acceptanceCriteria) ? input.acceptanceCriteria : [],
71
71
  };
72
- const firstAction = input.start !== false ? initialActionFor(draft) : null;
72
+ let firstAction = input.start !== false ? initialActionFor(draft) : null;
73
+ if (firstAction && draft.reuseMemory !== false) {
74
+ const context = this.store.getReusableContext(draft.workDir, draft.id);
75
+ firstAction = {
76
+ ...firstAction,
77
+ context,
78
+ instruction: actionInstruction(firstAction, draft, context),
79
+ };
80
+ }
73
81
  return this.store.createWorkItem(draft, firstAction);
74
82
  }
75
83
 
76
84
  start(id) {
77
- const detail = this.store.startWorkItemAtomic(id, initialActionFor);
85
+ const detail = this.store.startWorkItemAtomic(id, workItem => {
86
+ const action = initialActionFor(workItem);
87
+ if (workItem.reuseMemory === false) return action;
88
+ const context = this.store.getReusableContext(workItem.workDir, workItem.id);
89
+ return {
90
+ ...action,
91
+ context,
92
+ instruction: actionInstruction(action, workItem, context),
93
+ };
94
+ });
78
95
  if (!detail) throw new Error(`WorkItem not found: ${id}`);
79
96
  return detail;
80
97
  }
@@ -91,6 +108,35 @@ export class WorkflowController {
91
108
  return this.store.getWorkItemDetail(id);
92
109
  }
93
110
 
111
+ guide(id, input = {}) {
112
+ const guidance = typeof input.guidance === 'string' ? input.guidance.trim().slice(0, 8_000) : '';
113
+ if (!guidance) throw new Error('guidance is required');
114
+ const expected = {
115
+ actionId: typeof input.actionId === 'string' ? input.actionId : '',
116
+ revision: Number(input.revision),
117
+ };
118
+ if (!expected.actionId || !Number.isInteger(expected.revision)) {
119
+ throw new Error('actionId and revision are required for guidance');
120
+ }
121
+ const detail = this.store.addActionGuidance(id, guidance, expected, (workItem, previous) => {
122
+ const context = [...(previous.context || []), {
123
+ type: 'guidance',
124
+ role: 'user',
125
+ summary: guidance,
126
+ evidence: [],
127
+ }];
128
+ const step = { type: previous.type, requiredRole: previous.requiredRole };
129
+ return {
130
+ ...step,
131
+ context,
132
+ instruction: actionInstruction(step, workItem, context),
133
+ maxAttempts: previous.maxAttempts || 2,
134
+ };
135
+ });
136
+ if (!detail) throw new Error(`WorkItem not found: ${id}`);
137
+ return detail;
138
+ }
139
+
94
140
  retry(id, input = {}) {
95
141
  const answer = typeof input.answer === 'string' ? input.answer.trim().slice(0, 8_000) : '';
96
142
  const detail = this.store.retryWorkItemAtomic(id, (workItem, previous, previousRun) => {
@@ -54,6 +54,21 @@ function canonicalWorkDir(workDir) {
54
54
  return realpathSync(workDir);
55
55
  }
56
56
 
57
+ export function resolveWorkItemWorkDir(workItem, defaultWorkDir) {
58
+ if (typeof workItem?.workspaceKey === 'string' && workItem.workspaceKey) {
59
+ const expected = path.resolve(workItem.workspaceKey);
60
+ const actual = canonicalWorkDir(expected);
61
+ if (actual !== expected) {
62
+ throw new Error('WorkItem canonical workspace identity changed; update its workDir before retrying');
63
+ }
64
+ return expected;
65
+ }
66
+ if (typeof workItem?.workDir === 'string' && workItem.workDir.trim()) {
67
+ throw new Error('WorkItem has no canonical workspace identity; update its workDir before retrying');
68
+ }
69
+ return canonicalWorkDir(path.resolve(defaultWorkDir || process.cwd()));
70
+ }
71
+
57
72
  function assertPathInside(toolName, workDir, value) {
58
73
  const resolved = path.resolve(workDir, value);
59
74
  if (!isPathInsideOrEqual(workDir, resolved)) {
@@ -203,8 +218,7 @@ export class WorkItemRunner {
203
218
 
204
219
  async run({ workItem, action, run, signal, ownerBootId }) {
205
220
  const runtime = await this.runtimeProvider();
206
- const rawWorkDir = workItem.workDir || runtime.defaultWorkDir || process.cwd();
207
- const workDir = canonicalWorkDir(path.resolve(rawWorkDir));
221
+ const workDir = resolveWorkItemWorkDir(workItem, runtime.defaultWorkDir);
208
222
  const vp = copyVp(this.registry.getVp(action.requiredRole));
209
223
  if (!vp) {
210
224
  const error = new Error(`Required Work Center role is unavailable: ${action.requiredRole}`);
@@ -260,7 +274,6 @@ export class WorkItemRunner {
260
274
  });
261
275
 
262
276
  let text = '';
263
- const toolEvidence = [];
264
277
  try {
265
278
  for await (const event of engine.query({
266
279
  prompt: `${action.instruction}${completionContract(action)}`,
@@ -275,18 +288,10 @@ export class WorkItemRunner {
275
288
  if (typeof event?.text === 'string') text += event.text;
276
289
  else if (typeof event?.delta === 'string') text += event.delta;
277
290
  else if (typeof event?.content === 'string' && event.type === 'assistant') text += event.content;
278
- if (event?.type === 'tool_end') {
279
- toolEvidence.push({
280
- tool: event.name,
281
- isError: !!event.isError,
282
- });
283
- }
284
291
  }
285
292
  } finally {
286
293
  try { engine.abort?.('work_item_run_finished'); } catch {}
287
294
  }
288
- const result = parseStructuredResult(text, action.type);
289
- result.evidence = [...result.evidence, ...toolEvidence].slice(-100);
290
- return result;
295
+ return parseStructuredResult(text, action.type);
291
296
  }
292
297
  }
@@ -47,6 +47,7 @@ export class WorkCenterService {
47
47
  : [],
48
48
  workflowTemplate: payload.workflowTemplate || 'software-change',
49
49
  workDir: typeof payload.workDir === 'string' ? payload.workDir.trim() : '',
50
+ reuseMemory: payload.reuseMemory !== false,
50
51
  origin: payload.origin && typeof payload.origin === 'object'
51
52
  ? {
52
53
  sessionId: typeof payload.origin.sessionId === 'string' ? payload.origin.sessionId : null,
@@ -82,6 +83,17 @@ export class WorkCenterService {
82
83
  this.#emit({ type: 'work_item.cancelled', workItem: detail });
83
84
  return detail;
84
85
  }
86
+ case 'guide': {
87
+ const id = requiredString(payload.id, 'id');
88
+ const detail = this.controller.guide(id, {
89
+ guidance: typeof payload.guidance === 'string' ? payload.guidance : '',
90
+ actionId: typeof payload.actionId === 'string' ? payload.actionId : '',
91
+ revision: payload.revision,
92
+ });
93
+ this.watcher.abortInvalidWorkItemRuns(id);
94
+ this.#emit({ type: 'action.guidance_added', workItem: detail });
95
+ return detail;
96
+ }
85
97
  case 'retry': {
86
98
  const detail = this.controller.retry(requiredString(payload.id, 'id'), {
87
99
  answer: typeof payload.answer === 'string' ? payload.answer : '',
@@ -1,11 +1,17 @@
1
1
  import { DatabaseSync } from 'node:sqlite';
2
- import { mkdirSync } from 'node:fs';
3
- import { dirname } from 'node:path';
2
+ import { mkdirSync, realpathSync } from 'node:fs';
3
+ import { dirname, resolve } from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { normalizeEvidence } from './evidence.js';
6
6
 
7
- const SCHEMA_VERSION = 2;
7
+ const SCHEMA_VERSION = 3;
8
8
  const OPEN_ACTION_STATUSES = "'ready','running','waiting'";
9
+ const MAX_REUSABLE_CONTEXT_ITEMS = 12;
10
+
11
+ function canonicalWorkspaceKey(workDir) {
12
+ if (typeof workDir !== 'string' || !workDir.trim()) return '';
13
+ try { return realpathSync(resolve(workDir.trim())); } catch { return ''; }
14
+ }
9
15
 
10
16
  function parseJson(value, fallback) {
11
17
  if (typeof value !== 'string' || !value) return fallback;
@@ -29,6 +35,8 @@ function mapWorkItem(row) {
29
35
  currentActionId: row.current_action_id || null,
30
36
  currentRunId: row.current_run_id || null,
31
37
  workDir: row.work_dir || '',
38
+ workspaceKey: row.workspace_key || '',
39
+ reuseMemory: row.reuse_memory !== 0,
32
40
  origin: parseJson(row.origin, null),
33
41
  linkedSessionIds: parseJson(row.linked_session_ids, []),
34
42
  createdAt: row.created_at,
@@ -142,6 +150,8 @@ export class WorkItemStore {
142
150
  current_action_id TEXT,
143
151
  current_run_id TEXT,
144
152
  work_dir TEXT NOT NULL DEFAULT '',
153
+ workspace_key TEXT NOT NULL DEFAULT '',
154
+ reuse_memory INTEGER NOT NULL DEFAULT 1,
145
155
  origin TEXT,
146
156
  linked_session_ids TEXT NOT NULL DEFAULT '[]',
147
157
  created_at INTEGER NOT NULL,
@@ -202,7 +212,20 @@ export class WorkItemStore {
202
212
  `);
203
213
 
204
214
  // The feature shipped first as an unmerged PR, but keep the store tolerant
205
- // of a v1 database created by a review build.
215
+ // of databases created by review builds.
216
+ if (!hasColumn(this.db, 'work_items', 'workspace_key')) {
217
+ withTransaction(this.db, () => {
218
+ this.db.exec("ALTER TABLE work_items ADD COLUMN workspace_key TEXT NOT NULL DEFAULT ''");
219
+ const update = this.db.prepare('UPDATE work_items SET workspace_key = ? WHERE id = ?');
220
+ for (const row of this.db.prepare("SELECT id, work_dir FROM work_items WHERE work_dir != ''").all()) {
221
+ const workspaceKey = canonicalWorkspaceKey(row.work_dir);
222
+ if (workspaceKey) update.run(workspaceKey, row.id);
223
+ }
224
+ });
225
+ }
226
+ if (!hasColumn(this.db, 'work_items', 'reuse_memory')) {
227
+ this.db.exec('ALTER TABLE work_items ADD COLUMN reuse_memory INTEGER NOT NULL DEFAULT 1');
228
+ }
206
229
  if (!hasColumn(this.db, 'actions', 'context')) {
207
230
  this.db.exec("ALTER TABLE actions ADD COLUMN context TEXT NOT NULL DEFAULT '[]'");
208
231
  }
@@ -244,11 +267,12 @@ export class WorkItemStore {
244
267
  return withTransaction(this.db, () => {
245
268
  const now = this.now();
246
269
  const id = input.id || randomUUID();
270
+ const workspaceKey = canonicalWorkspaceKey(input.workDir);
247
271
  this.db.prepare(`INSERT INTO work_items
248
272
  (id, revision, title, goal, acceptance_criteria, workflow_template, status,
249
- current_action_id, current_run_id, work_dir, origin, linked_session_ids,
273
+ current_action_id, current_run_id, work_dir, workspace_key, reuse_memory, origin, linked_session_ids,
250
274
  created_at, updated_at)
251
- VALUES (?, 1, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, ?)`).run(
275
+ VALUES (?, 1, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?)`).run(
252
276
  id,
253
277
  input.title,
254
278
  input.goal,
@@ -256,6 +280,8 @@ export class WorkItemStore {
256
280
  input.workflowTemplate || 'software-change',
257
281
  firstAction ? 'ready' : 'draft',
258
282
  input.workDir || '',
283
+ workspaceKey,
284
+ input.reuseMemory === false ? 0 : 1,
259
285
  stringify(input.origin || null),
260
286
  stringify(input.linkedSessionIds || []),
261
287
  now,
@@ -366,6 +392,64 @@ export class WorkItemStore {
366
392
  };
367
393
  }
368
394
 
395
+ getReusableContext(workDir, excludeWorkItemId = null) {
396
+ const workspaceKey = canonicalWorkspaceKey(workDir);
397
+ if (!workspaceKey) return [];
398
+ const rows = this.db.prepare(`SELECT r.*, a.type AS action_type, a.required_role,
399
+ w.title AS source_title
400
+ FROM runs r
401
+ JOIN actions a ON a.id = r.action_id
402
+ JOIN work_items w ON w.id = r.work_item_id
403
+ WHERE w.workspace_key = ? AND w.reuse_memory = 1 AND w.id != ? AND w.status = 'done'
404
+ AND r.status = 'completed' AND length(trim(COALESCE(r.summary, ''))) > 0
405
+ ORDER BY r.ended_at DESC, r.started_at DESC
406
+ LIMIT ?`).all(workspaceKey, excludeWorkItemId || '', MAX_REUSABLE_CONTEXT_ITEMS);
407
+ return rows.reverse().map(row => {
408
+ const run = mapRun(row);
409
+ return {
410
+ type: row.action_type,
411
+ role: row.required_role,
412
+ summary: run.summary,
413
+ evidence: run.evidence,
414
+ reviewDecision: run.reviewDecision,
415
+ sourceTitle: row.source_title,
416
+ };
417
+ });
418
+ }
419
+
420
+ addActionGuidance(id, guidance, expected, makeAction) {
421
+ return withTransaction(this.db, () => {
422
+ const workItem = this.getWorkItem(id);
423
+ if (!workItem) return null;
424
+ if (workItem.currentActionId !== expected.actionId || workItem.revision !== expected.revision) {
425
+ throw new Error('Action changed before guidance was applied; refresh and try again');
426
+ }
427
+ if (!['ready', 'running'].includes(workItem.status)) {
428
+ throw new Error(`WorkItem in ${workItem.status} cannot accept Action guidance`);
429
+ }
430
+ const previous = workItem.currentActionId ? this.getAction(workItem.currentActionId) : null;
431
+ if (!previous || !['ready', 'running'].includes(previous.status)) {
432
+ throw new Error('WorkItem has no active Action for guidance');
433
+ }
434
+ const now = this.now();
435
+ this.#invalidateExecution(
436
+ workItem,
437
+ 'superseded',
438
+ 'superseded',
439
+ 'Action restarted after user guidance',
440
+ now,
441
+ );
442
+ const action = this.#insertAction(id, {
443
+ ...makeAction(workItem, previous),
444
+ contractRevision: workItem.revision,
445
+ }, this.#nextSequence(id), now);
446
+ this.db.prepare(`UPDATE work_items SET status = 'ready', current_action_id = ?,
447
+ current_run_id = NULL, updated_at = ? WHERE id = ?`).run(action.id, now, id);
448
+ this.appendEvent(id, 'action.guidance_added', { guidance }, { actionId: action.id });
449
+ return this.getWorkItemDetail(id);
450
+ });
451
+ }
452
+
369
453
  #invalidateExecution(workItem, actionStatus, runStatus, reason, now) {
370
454
  if (workItem.currentRunId) {
371
455
  this.db.prepare(`UPDATE runs SET status = ?, ended_at = ?, error = ?
@@ -403,11 +487,12 @@ export class WorkItemStore {
403
487
  );
404
488
  }
405
489
  this.db.prepare(`UPDATE work_items SET title = ?, goal = ?, acceptance_criteria = ?,
406
- work_dir = ?, revision = ?, updated_at = ? WHERE id = ?`).run(
490
+ work_dir = ?, workspace_key = ?, revision = ?, updated_at = ? WHERE id = ?`).run(
407
491
  next.title,
408
492
  next.goal,
409
493
  stringify(next.acceptanceCriteria),
410
494
  next.workDir,
495
+ canonicalWorkspaceKey(next.workDir),
411
496
  revision,
412
497
  now,
413
498
  id,
@@ -54,9 +54,10 @@ function renderContext(context = []) {
54
54
  const decision = entry.reviewDecision ? `\nReview decision: ${entry.reviewDecision}` : '';
55
55
  const waitingReason = entry.waitingReason ? `\nWaiting reason: ${entry.waitingReason}` : '';
56
56
  const answer = entry.answer ? `\nUser answer: ${entry.answer}` : '';
57
- return `### ${entry.type} (${entry.role || 'unknown role'})\n${entry.summary || '(no summary)'}${decision}${waitingReason}${answer}${evidence}`;
57
+ const source = entry.sourceTitle ? ` from ${entry.sourceTitle}` : '';
58
+ return `### ${entry.type}${source} (${entry.role || 'unknown role'})\n${entry.summary || '(no summary)'}${decision}${waitingReason}${answer}${evidence}`;
58
59
  });
59
- return `\n\nPrior Action results:\n${blocks.join('\n\n')}`;
60
+ return `\n\nReusable Work Center context and prior Action results:\n${blocks.join('\n\n')}`;
60
61
  }
61
62
 
62
63
  export function actionInstruction(step, workItem, context = []) {