@yeaft/webchat-agent 1.0.174 → 1.0.177

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.174",
3
+ "version": "1.0.177",
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",
@@ -248,20 +248,26 @@ export async function readScopeDreamMarker(memoryMdAbsPath) {
248
248
  */
249
249
  export function withDreamMarker(memoryMd, fields) {
250
250
  const block = renderDreamBlock(fields);
251
- const body = String(memoryMd || '');
252
- if (body.includes(DREAM_BLOCK_OPEN) && body.includes(DREAM_BLOCK_CLOSE)) {
253
- // Replace existing block.
254
- return body.replace(
255
- new RegExp(`${escapeRe(DREAM_BLOCK_OPEN)}[\\s\\S]*?${escapeRe(DREAM_BLOCK_CLOSE)}`),
256
- block,
257
- );
258
- }
251
+ const body = stripDreamMarker(memoryMd);
259
252
  // Append. Ensure exactly one newline before the block.
260
253
  const trimmed = body.replace(/\s+$/, '');
261
254
  const sep = trimmed.length === 0 ? '' : '\n\n';
262
255
  return `${trimmed}${sep}${block}\n`;
263
256
  }
264
257
 
258
+ /**
259
+ * Remove all per-scope dream-state blocks from a markdown blob.
260
+ *
261
+ * @param {string} body
262
+ * @returns {string}
263
+ */
264
+ export function stripDreamMarker(body) {
265
+ return String(body || '').replace(
266
+ new RegExp(`${escapeRe(DREAM_BLOCK_OPEN)}[\\s\\S]*?${escapeRe(DREAM_BLOCK_CLOSE)}`, 'g'),
267
+ '',
268
+ ).trim();
269
+ }
270
+
265
271
  /**
266
272
  * Extract the contents of the dream-state block (between the two HTML
267
273
  * comments). Returns null if the block isn't present.
package/yeaft/engine.js CHANGED
@@ -32,6 +32,7 @@ import { archiveTurn } from './archive/turn-archive.js';
32
32
  import { archiveToolResults } from './archive/tool-results.js';
33
33
  import { readSummary as readScopeSummary } from './memory/store.js';
34
34
  import { runAdjust } from './memory/adjust.js';
35
+ import { cleanMemoryPromptText } from './memory/prompt-cleanup.js';
35
36
  import { isVpSeedBackfillStub } from './memory/seed-backfill.js';
36
37
  import { runStopHooks } from './stop-hooks.js';
37
38
  import { perfNowMs, recordAgentPerfTrace } from './perf-trace.js';
@@ -389,13 +390,17 @@ export function shouldAllowGroupReflection({
389
390
  export function buildResidentEntries(args) {
390
391
  const summaries = (args && args.summaries) || {};
391
392
  const out = [];
392
- if (summaries.user) out.push({ scope: 'user', summary: summaries.user });
393
- if (args.sessionId && summaries.session) {
394
- out.push({ scope: `sessions/${args.sessionId}`, summary: summaries.session });
393
+ const userSummary = cleanMemoryPromptText(summaries.user);
394
+ const sessionSummary = cleanMemoryPromptText(summaries.session);
395
+ const vpSummary = cleanMemoryPromptText(summaries.vp);
396
+ if (userSummary) out.push({ scope: 'user', summary: userSummary });
397
+ if (args.sessionId && sessionSummary) {
398
+ out.push({ scope: `sessions/${args.sessionId}`, summary: sessionSummary });
395
399
  }
396
400
  if (args.sessionId && Array.isArray(summaries.topics)) {
397
401
  for (const topic of summaries.topics) {
398
- if (topic?.scope && topic?.summary) out.push({ scope: topic.scope, summary: topic.summary });
402
+ const summary = cleanMemoryPromptText(topic?.summary);
403
+ if (topic?.scope && summary) out.push({ scope: topic.scope, summary });
399
404
  }
400
405
  }
401
406
  // VP per-session isolation (2026-06-09): the VP summary scope MUST be
@@ -407,8 +412,8 @@ export function buildResidentEntries(args) {
407
412
  // by id rather than by full scope path. The session-qualified form
408
413
  // makes the per-session boundary explicit and matches the on-disk
409
414
  // layout 1:1.
410
- if (args.sessionId && args.ownVpId && summaries.vp && !isVpSeedBackfillStub(summaries.vp)) {
411
- out.push({ scope: `sessions/${args.sessionId}/vp/${args.ownVpId}`, summary: summaries.vp });
415
+ if (args.sessionId && args.ownVpId && vpSummary && !isVpSeedBackfillStub(vpSummary)) {
416
+ out.push({ scope: `sessions/${args.sessionId}/vp/${args.ownVpId}`, summary: vpSummary });
412
417
  }
413
418
  return out;
414
419
  }
@@ -1940,8 +1945,8 @@ export class Engine {
1940
1945
  ))
1941
1946
  .map(e => ({
1942
1947
  scope: e.scope,
1943
- summary: String(e.summary).slice(0, 4000),
1944
- truncated: String(e.summary).length > 4000,
1948
+ summary: String(e.summary),
1949
+ truncated: false,
1945
1950
  source: 'resident-summary',
1946
1951
  }))
1947
1952
  : [];
@@ -16,7 +16,8 @@
16
16
  * vpId at construction.
17
17
  */
18
18
 
19
- import { approxTokens, packWithinBudget } from './budget.js';
19
+ import { approxTokens } from './budget.js';
20
+ import { cleanMemoryPromptText, isDuplicateMemoryText, rememberMemoryText } from './prompt-cleanup.js';
20
21
  import { isVpForeign } from './store.js';
21
22
 
22
23
  const RECENT_DEFAULT_CAPACITY = 64;
@@ -69,8 +70,9 @@ export class ActiveMemorySet {
69
70
  this._resident.clear();
70
71
  for (const e of entries) {
71
72
  if (this._isForeignVp(e.scope)) continue;
72
- if (!e.summary) continue;
73
- this._resident.set(e.scope, e.summary);
73
+ const summary = cleanMemoryPromptText(e.summary);
74
+ if (!summary) continue;
75
+ this._resident.set(e.scope, summary);
74
76
  }
75
77
  }
76
78
 
@@ -85,8 +87,10 @@ export class ActiveMemorySet {
85
87
  touchRecent(seg) {
86
88
  if (!seg || !seg.id) return;
87
89
  if (this._isForeignVp(seg.scope)) return;
90
+ const body = cleanMemoryPromptText(seg.body);
91
+ if (!body) return;
88
92
  if (this._recent.has(seg.id)) this._recent.delete(seg.id);
89
- this._recent.set(seg.id, { seg, ts: Date.now() });
93
+ this._recent.set(seg.id, { seg: { ...seg, body }, ts: Date.now() });
90
94
  while (this._recent.size > this.recentCapacity) {
91
95
  const firstKey = this._recent.keys().next().value;
92
96
  this._recent.delete(firstKey);
@@ -104,7 +108,9 @@ export class ActiveMemorySet {
104
108
  this._onDemand.clear();
105
109
  for (const seg of segments) {
106
110
  if (this._isForeignVp(seg.scope)) continue;
107
- this._onDemand.set(seg.id, seg);
111
+ const body = cleanMemoryPromptText(seg.body);
112
+ if (!body) continue;
113
+ this._onDemand.set(seg.id, { ...seg, body });
108
114
  }
109
115
  }
110
116
 
@@ -116,7 +122,9 @@ export class ActiveMemorySet {
116
122
  addOnDemand(segments) {
117
123
  for (const seg of segments) {
118
124
  if (this._isForeignVp(seg.scope)) continue;
119
- this._onDemand.set(seg.id, seg);
125
+ const body = cleanMemoryPromptText(seg.body);
126
+ if (!body) continue;
127
+ this._onDemand.set(seg.id, { ...seg, body });
120
128
  }
121
129
  }
122
130
 
@@ -139,31 +147,40 @@ export class ActiveMemorySet {
139
147
  * @returns {AmsSnapshot}
140
148
  */
141
149
  snapshot() {
150
+ const seenPromptText = new Set();
151
+
142
152
  // Resident: pack scopes by priority order (caller provides via insert
143
153
  // order — current group's own vp first, then user, etc.).
144
- const resEntries = [...this._resident.entries()].map(([scope, summary]) => ({
145
- scope, summary,
146
- }));
147
- const { picked: resPicked, cost: resCost } = packWithinBudget(
148
- resEntries, this.budget.resident,
149
- e => approxTokens(e.summary),
150
- );
154
+ const { picked: resPicked, cost: resCost } = pickMemoryItems({
155
+ items: [...this._resident.entries()].map(([scope, summary]) => ({
156
+ scope, summary: cleanMemoryPromptText(summary),
157
+ })),
158
+ budget: this.budget.resident,
159
+ seen: seenPromptText,
160
+ textOf: e => e.summary,
161
+ costOf: e => approxTokens(e.summary),
162
+ });
151
163
 
152
164
  // Recent: insertion order is oldest-first; we want newest first.
153
- const recentArr = [...this._recent.values()]
154
- .reverse()
155
- .map(e => e.seg);
156
- const { picked: recPicked, cost: recCost } = packWithinBudget(
157
- recentArr, this.budget.recent,
158
- seg => approxTokens(seg.body),
159
- );
165
+ const { picked: recPicked, cost: recCost } = pickMemoryItems({
166
+ items: [...this._recent.values()]
167
+ .reverse()
168
+ .map(e => ({ ...e.seg, body: cleanMemoryPromptText(e.seg?.body) })),
169
+ budget: this.budget.recent,
170
+ seen: seenPromptText,
171
+ textOf: seg => seg.body,
172
+ costOf: seg => approxTokens(seg.body),
173
+ });
160
174
 
161
175
  // OnDemand: insertion order from caller (already FTS-ranked).
162
- const odArr = [...this._onDemand.values()];
163
- const { picked: odPicked, cost: odCost } = packWithinBudget(
164
- odArr, this.budget.onDemand,
165
- seg => approxTokens(seg.body),
166
- );
176
+ const { picked: odPicked, cost: odCost } = pickMemoryItems({
177
+ items: [...this._onDemand.values()]
178
+ .map(seg => ({ ...seg, body: cleanMemoryPromptText(seg?.body) })),
179
+ budget: this.budget.onDemand,
180
+ seen: seenPromptText,
181
+ textOf: seg => seg.body,
182
+ costOf: seg => approxTokens(seg.body),
183
+ });
167
184
 
168
185
  return {
169
186
  resident: resPicked,
@@ -193,3 +210,18 @@ export class ActiveMemorySet {
193
210
  return isVpForeign(scope, this.ownVpId);
194
211
  }
195
212
  }
213
+
214
+ function pickMemoryItems({ items, budget, seen, textOf, costOf }) {
215
+ const picked = [];
216
+ let cost = 0;
217
+ for (const item of items) {
218
+ const text = textOf(item);
219
+ if (!text || isDuplicateMemoryText(text, seen)) continue;
220
+ const itemCost = costOf(item);
221
+ if (cost + itemCost > budget) continue;
222
+ picked.push(item);
223
+ cost += itemCost;
224
+ rememberMemoryText(text, seen);
225
+ }
226
+ return { picked, cost };
227
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * memory/prompt-cleanup.js — prompt-facing memory text hygiene.
3
+ *
4
+ * Disk memory can carry operational metadata such as the per-scope Dream
5
+ * marker. That metadata is useful for schedulers, not for the model. Keep the
6
+ * storage schema compatible and clean only at read / prompt boundaries.
7
+ */
8
+
9
+ const DREAM_STATE_BLOCK_RE = /<!--\s*dream-state\s*-->[\s\S]*?<!--\s*\/dream-state\s*-->/gi;
10
+
11
+ /**
12
+ * Remove Dream scheduler metadata blocks from memory text.
13
+ *
14
+ * @param {string} text
15
+ * @returns {string}
16
+ */
17
+ export function stripDreamStateBlocks(text) {
18
+ return String(text || '').replace(DREAM_STATE_BLOCK_RE, '').trim();
19
+ }
20
+
21
+ /**
22
+ * Clean text before it is eligible for prompt injection.
23
+ *
24
+ * @param {string} text
25
+ * @returns {string}
26
+ */
27
+ export function cleanMemoryPromptText(text) {
28
+ return stripDreamStateBlocks(text)
29
+ .replace(/[ \t]+\n/g, '\n')
30
+ .replace(/\n{3,}/g, '\n\n')
31
+ .trim();
32
+ }
33
+
34
+ /**
35
+ * Normalized key for conservative prompt dedupe. This is intentionally simple:
36
+ * exact semantic clustering belongs in Dream; prompt assembly only removes
37
+ * obvious repeats and near-contained copies.
38
+ *
39
+ * @param {string} text
40
+ * @returns {string}
41
+ */
42
+ export function memoryDedupeKey(text) {
43
+ return cleanMemoryPromptText(text)
44
+ .toLowerCase()
45
+ .replace(/[`*_>#\-\[\]().,,。::;;!!??"'“”‘’]/g, ' ')
46
+ .replace(/\s+/g, ' ')
47
+ .trim();
48
+ }
49
+
50
+ /**
51
+ * Whether a candidate is redundant with text already emitted to the prompt.
52
+ * Does not mutate `seen`; call `rememberMemoryText` only after the candidate
53
+ * is actually selected into the snapshot.
54
+ *
55
+ * @param {string} candidate
56
+ * @param {Set<string>} seen
57
+ * @returns {boolean}
58
+ */
59
+ export function isDuplicateMemoryText(candidate, seen) {
60
+ const key = memoryDedupeKey(candidate);
61
+ if (!key) return true;
62
+ if (seen.has(key)) return true;
63
+
64
+ // Avoid dropping tiny generic snippets via containment. A candidate is
65
+ // redundant only when already-selected text covers it. If the candidate is
66
+ // longer and contains the selected text, keep it: actual memory often carries
67
+ // details that a resident summary compressed away.
68
+ if (key.length >= 80) {
69
+ for (const existing of seen) {
70
+ if (existing.length >= 80 && existing.includes(key)) {
71
+ return true;
72
+ }
73
+ }
74
+ }
75
+ return false;
76
+ }
77
+
78
+ /**
79
+ * Mark text as emitted to the prompt for later dedupe checks.
80
+ *
81
+ * @param {string} text
82
+ * @param {Set<string>} seen
83
+ */
84
+ export function rememberMemoryText(text, seen) {
85
+ const key = memoryDedupeKey(text);
86
+ if (key) seen.add(key);
87
+ }
@@ -19,6 +19,7 @@ import {
19
19
  readdirSync, statSync, renameSync,
20
20
  } from 'node:fs';
21
21
  import { join, dirname, relative, sep } from 'node:path';
22
+ import { stripDreamStateBlocks } from './prompt-cleanup.js';
22
23
  import { parseSegments, serializeSegments } from './segment.js';
23
24
 
24
25
  /**
@@ -32,7 +33,7 @@ export function readScope(memoryRoot, scope) {
32
33
  const path = scopeFilePath(memoryRoot, scope);
33
34
  if (!existsSync(path)) return [];
34
35
  const text = readFileSync(path, 'utf8');
35
- return parseSegments(text, { defaultScope: scope });
36
+ return parseSegments(stripDreamStateBlocks(text), { defaultScope: scope });
36
37
  }
37
38
 
38
39
  /**
@@ -202,6 +202,9 @@ export class WorkflowController {
202
202
  assignmentPolicy: previous.assignmentPolicy,
203
203
  modelPolicy: previous.modelPolicy,
204
204
  requiredRole: previous.requiredRole,
205
+ dependsOnStageIds: previous.dependsOnStageIds,
206
+ workspaceMode: previous.workspaceMode,
207
+ changesRequestedStageId: previous.changesRequestedStageId,
205
208
  brief: previous.brief,
206
209
  };
207
210
  return {
@@ -237,7 +240,12 @@ export class WorkflowController {
237
240
  if (!['waiting', 'needs_attention'].includes(workItem.status)) {
238
241
  throw new Error(`WorkItem in ${workItem.status} cannot accept Action input`);
239
242
  }
240
- if (workItem.currentActionId !== input.actionId || workItem.revision !== input.revision) {
243
+ const targetAction = this.store.getAction(input.actionId);
244
+ const graphMode = workItem.workflowSnapshot?.executionMode === 'graph';
245
+ const targetMatches = graphMode
246
+ ? targetAction?.workItemId === id && ['waiting', 'failed'].includes(targetAction.status)
247
+ : workItem.currentActionId === input.actionId;
248
+ if (!targetMatches || workItem.revision !== input.revision) {
241
249
  throw new Error('Action changed before input was applied; refresh and try again');
242
250
  }
243
251
  return this.retry(id, {
@@ -337,7 +345,7 @@ export class WorkflowController {
337
345
  ({ action, workItem }) => {
338
346
  if (result.outcome === 'waiting') {
339
347
  return {
340
- actionStatus: 'completed',
348
+ actionStatus: 'waiting',
341
349
  workItemStatus: 'waiting',
342
350
  keepCurrentAction: true,
343
351
  eventType: 'action.waiting',
@@ -770,21 +770,35 @@ export class WorkItemStore {
770
770
  throw new Error('WorkItem has no active Action for guidance');
771
771
  }
772
772
  const now = this.now();
773
- this.#invalidateExecution(
774
- workItem,
775
- 'superseded',
776
- 'superseded',
777
- 'Action restarted after user guidance',
778
- now,
779
- );
780
- const action = this.#insertAction(id, {
773
+ const revision = workItem.revision + 1;
774
+ const replacement = {
781
775
  ...makeAction(workItem, previous),
782
- contractRevision: workItem.revision,
783
- }, this.#nextSequence(id), now);
776
+ contractRevision: previous.contractRevision,
777
+ };
778
+ let action;
779
+ if (workItem.workflowSnapshot?.executionMode === 'graph') {
780
+ action = this.#resetGraphFromStage(
781
+ id,
782
+ previous.stageId,
783
+ replacement,
784
+ 'Action restarted after user guidance',
785
+ now,
786
+ );
787
+ } else {
788
+ this.#invalidateExecution(
789
+ workItem,
790
+ 'superseded',
791
+ 'superseded',
792
+ 'Action restarted after user guidance',
793
+ now,
794
+ );
795
+ action = this.#insertAction(id, replacement, this.#nextSequence(id), now);
796
+ }
784
797
  this.db.prepare(`UPDATE work_items SET status = 'ready', current_action_id = ?,
785
- current_run_id = NULL, attachments = ?, updated_at = ? WHERE id = ?`).run(
798
+ current_run_id = NULL, attachments = ?, revision = ?, updated_at = ? WHERE id = ?`).run(
786
799
  action.id,
787
800
  stringify(Array.isArray(attachments) ? attachments : workItem.attachments),
801
+ revision,
788
802
  now,
789
803
  id,
790
804
  );
@@ -922,19 +936,29 @@ export class WorkItemStore {
922
936
  if (!['waiting', 'needs_attention'].includes(workItem.status)) {
923
937
  throw new Error(`WorkItem in ${workItem.status} does not need retry`);
924
938
  }
939
+ const graphMode = workItem.workflowSnapshot?.executionMode === 'graph';
940
+ let previous = workItem.currentActionId ? this.getAction(workItem.currentActionId) : null;
925
941
  if (options.expected) {
926
- if (workItem.currentActionId !== options.expected.actionId || workItem.revision !== options.expected.revision) {
942
+ const expectedAction = this.getAction(options.expected.actionId);
943
+ const expectedMatches = graphMode
944
+ ? expectedAction?.workItemId === id && ['waiting', 'failed'].includes(expectedAction.status)
945
+ : workItem.currentActionId === options.expected.actionId;
946
+ if (!expectedMatches || workItem.revision !== options.expected.revision) {
927
947
  throw new Error('Action changed before input was applied; refresh and try again');
928
948
  }
949
+ previous = expectedAction;
929
950
  }
930
- const previous = workItem.currentActionId ? this.getAction(workItem.currentActionId) : null;
931
951
  const previousRun = previous
932
952
  ? mapRun(this.db.prepare(`SELECT * FROM runs WHERE work_item_id = ? AND action_id = ?
933
953
  AND status != 'running' ORDER BY ended_at DESC, started_at DESC LIMIT 1`).get(id, previous.id))
934
954
  : null;
935
955
  const now = this.now();
936
- const replacement = makeAction(workItem, previous, previousRun);
937
- if (workItem.workflowSnapshot?.executionMode === 'graph') {
956
+ const revision = options.expected ? workItem.revision + 1 : workItem.revision;
957
+ const replacement = {
958
+ ...makeAction(workItem, previous, previousRun),
959
+ contractRevision: previous?.contractRevision ?? workItem.revision,
960
+ };
961
+ if (graphMode) {
938
962
  if (!previous) throw new Error('WorkItem graph retry target is missing');
939
963
  const action = this.#resetGraphFromStage(
940
964
  id,
@@ -944,18 +968,29 @@ export class WorkItemStore {
944
968
  now,
945
969
  );
946
970
  this.db.prepare(`UPDATE work_items SET status = 'ready', current_action_id = ?,
947
- current_run_id = NULL, updated_at = ? WHERE id = ?`).run(action.id, now, id);
948
- this.appendEvent(id, 'work_item.retried', { targetStageId: action.stageId }, { actionId: action.id });
971
+ current_run_id = NULL, attachments = ?, revision = ?, updated_at = ? WHERE id = ?`).run(
972
+ action.id,
973
+ stringify(Array.isArray(options.attachments) ? options.attachments : workItem.attachments),
974
+ revision,
975
+ now,
976
+ id,
977
+ );
978
+ const inputEvent = options.inputEvent && typeof options.inputEvent === 'object'
979
+ ? options.inputEvent
980
+ : null;
981
+ if (inputEvent) {
982
+ this.appendEvent(id, 'action.input_added', inputEvent, { actionId: action.id });
983
+ } else {
984
+ this.appendEvent(id, 'work_item.retried', { targetStageId: action.stageId }, { actionId: action.id });
985
+ }
949
986
  return this.getWorkItemDetail(id);
950
987
  }
951
- const action = this.#insertAction(id, {
952
- ...replacement,
953
- contractRevision: workItem.revision,
954
- }, this.#nextSequence(id), now);
988
+ const action = this.#insertAction(id, replacement, this.#nextSequence(id), now);
955
989
  this.db.prepare(`UPDATE work_items SET status = 'ready', current_action_id = ?,
956
- current_run_id = NULL, attachments = ?, updated_at = ? WHERE id = ?`).run(
990
+ current_run_id = NULL, attachments = ?, revision = ?, updated_at = ? WHERE id = ?`).run(
957
991
  action.id,
958
992
  stringify(Array.isArray(options.attachments) ? options.attachments : workItem.attachments),
993
+ revision,
959
994
  now,
960
995
  id,
961
996
  );