@yeaft/webchat-agent 0.1.604 → 0.1.606

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.604",
3
+ "version": "0.1.606",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -0,0 +1,109 @@
1
+ /**
2
+ * dream-v2/refresh.js — DESIGN.md §9.14 refresh hook (Phase 8 PR-J).
3
+ *
4
+ * The PR-F wire-up plumbed `runDreamTick` into the scheduler with a v1
5
+ * no-op refresh placeholder. PR-J makes the refresh real but stays
6
+ * within the §8 v1 charter — "Thin: skip pruning/demotion; refresh-only
7
+ * in v1, no LLM".
8
+ *
9
+ * What "refresh" does today:
10
+ * 1. Read the scope's `index.md` rows (the entry catalog).
11
+ * 2. Select the top-N most recent rows by `updated` timestamp
12
+ * (default 12 — enough to surface the active context without
13
+ * exploding Layer A token cost).
14
+ * 3. Render a deterministic markdown synopsis (one bullet per row:
15
+ * `- <title> [<kind>; tags] (<updated>)`).
16
+ * 4. Write to the scope's `summary.md` via `writeSummary` (atomic
17
+ * rename — readers never see a partial write).
18
+ *
19
+ * If the index is empty (cold-start scope), we leave `summary.md`
20
+ * alone. Overwriting with an empty file would erase any human-written
21
+ * synopsis a future tool may have placed there.
22
+ *
23
+ * No LLM call. No pruning. No tombstones. The cursor is advanced by
24
+ * `runDreamTick` after this hook resolves, so a clean run of refresh
25
+ * counts as "scope handled" and we won't re-run until the diff-gate
26
+ * sees new content.
27
+ *
28
+ * Errors are propagated — `runDreamTick` already catches per-scope
29
+ * failures and records them under `errors[]` without aborting siblings.
30
+ */
31
+
32
+ import { readIndex, writeSummary } from '../memory/scope-tree.js';
33
+
34
+ /** Default number of recent entries surfaced on the synopsis. */
35
+ export const DEFAULT_TOP_N = 12;
36
+
37
+ /**
38
+ * Render a deterministic markdown synopsis for a scope from its index rows.
39
+ * Pure function — exported for unit tests.
40
+ *
41
+ * @param {{ kind: string, id?: string, scopeDir: string }} scope
42
+ * @param {Array<{ path: string, title?: string, tags?: string[]|string, kind?: string, updated?: string }>} rows
43
+ * @param {number} [topN]
44
+ * @returns {string}
45
+ */
46
+ export function buildScopeSynopsis(scope, rows, topN = DEFAULT_TOP_N) {
47
+ if (!Array.isArray(rows) || rows.length === 0) return '';
48
+
49
+ // Sort by `updated` desc, then by path asc as a stable tiebreaker.
50
+ const sorted = [...rows].sort((a, b) => {
51
+ const u = (b.updated || '').localeCompare(a.updated || '');
52
+ return u !== 0 ? u : (a.path || '').localeCompare(b.path || '');
53
+ });
54
+ const top = sorted.slice(0, Math.max(1, topN | 0));
55
+
56
+ const header = `# ${scope.scopeDir} — recent context`;
57
+ const lines = top.map((r) => {
58
+ const title = (r.title || r.path || 'entry').trim();
59
+ const kind = r.kind ? `${r.kind}` : '';
60
+ const tagsRaw = Array.isArray(r.tags) ? r.tags : (typeof r.tags === 'string' ? r.tags.split(',') : []);
61
+ const tags = tagsRaw.map((t) => String(t).trim()).filter(Boolean);
62
+ const meta = [kind, ...tags].filter(Boolean).join('; ');
63
+ const metaPart = meta ? ` [${meta}]` : '';
64
+ const updated = r.updated ? ` (${r.updated})` : '';
65
+ return `- ${title}${metaPart}${updated}`;
66
+ });
67
+ return [header, '', ...lines, ''].join('\n');
68
+ }
69
+
70
+ /**
71
+ * Translate a `runDreamTick` ScopeRef ({ kind, id?, scopeDir }) into a
72
+ * `scope-tree.js` Scope ({ kind, id }). The two surfaces share the
73
+ * `kind` enum but `runDreamTick` carries `scopeDir` as the canonical
74
+ * identifier; scope-tree derives it from `kind`+`id`.
75
+ *
76
+ * @param {{ kind: string, id?: string, scopeDir: string }} ref
77
+ * @returns {{ kind: string, id?: string }}
78
+ */
79
+ export function refToScope(ref) {
80
+ if (!ref || typeof ref !== 'object') {
81
+ throw new Error('refToScope: ref required');
82
+ }
83
+ if (ref.kind === 'user') return { kind: 'user' };
84
+ if (!ref.id) throw new Error(`refToScope: ${ref.kind} scope requires id`);
85
+ return { kind: ref.kind, id: ref.id };
86
+ }
87
+
88
+ /**
89
+ * Build a refresh hook bound to a specific memory root. The returned
90
+ * function is the `refresh` arg `runDreamTick` expects.
91
+ *
92
+ * @param {{ root: string, topN?: number }} args
93
+ * @returns {(scope: { kind: string, id?: string, scopeDir: string }) => Promise<void>}
94
+ */
95
+ export function createScopeRefreshHook({ root, topN = DEFAULT_TOP_N } = {}) {
96
+ if (!root || typeof root !== 'string') {
97
+ throw new Error('createScopeRefreshHook: root required');
98
+ }
99
+ return async function refreshScopeSummary(scope) {
100
+ const target = refToScope(scope);
101
+ const rows = await readIndex(target, { root });
102
+ const body = buildScopeSynopsis(scope, rows, topN);
103
+ if (!body) {
104
+ // Cold-start scope: leave summary.md alone (see header docs).
105
+ return;
106
+ }
107
+ await writeSummary(target, body, { root });
108
+ };
109
+ }
package/unify/engine.js CHANGED
@@ -713,7 +713,7 @@ export class Engine {
713
713
  * SCENARIO_EFFORT. Unknown values fall through to 'high'.
714
714
  * @yields {EngineEvent}
715
715
  */
716
- async *query({ prompt, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId } = {}) {
716
+ async *query({ prompt, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan } = {}) {
717
717
  if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
718
718
  yield {
719
719
  type: 'error',
@@ -764,7 +764,7 @@ export class Engine {
764
764
  const runSignal = abortCtrl.signal;
765
765
 
766
766
  try {
767
- yield* this.#runQuery({ prompt: effectivePrompt, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId });
767
+ yield* this.#runQuery({ prompt: effectivePrompt, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan });
768
768
  } finally {
769
769
  if (signal) {
770
770
  try { signal.removeEventListener('abort', onExternalAbort); } catch { /* ignore */ }
@@ -782,7 +782,7 @@ export class Engine {
782
782
  * in a try/finally without indenting the whole loop.
783
783
  * @private
784
784
  */
785
- async *#runQuery({ prompt, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId }) {
785
+ async *#runQuery({ prompt, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan }) {
786
786
 
787
787
  // ─── Pre-query: Memory Injection (task-287) + Compact Summary ──
788
788
  // Two-layer recall:
@@ -902,10 +902,19 @@ export class Engine {
902
902
  if (vpPersona && vpPersona.vpId) {
903
903
  const priorPlan = extractPriorPlan(conversationMessages, vpPersona.vpId);
904
904
  const thinkingCfg = (this.#config && this.#config.thinking) || {};
905
+ // PR-I: live routerPlan.thinking — when the dispatcher passes
906
+ // `vpPlan` for this turn (per-VP plan from the V2 router) and its
907
+ // `vpId` matches the active persona, surface its `thinking` field
908
+ // to resolveThinking. Mismatched vpId means the plan addresses a
909
+ // different VP — ignore it for this VP's thinking decision.
910
+ const liveRouterThinking = (vpPlan && typeof vpPlan === 'object'
911
+ && typeof vpPlan.vpId === 'string' && vpPlan.vpId === vpPersona.vpId
912
+ && (vpPlan.thinking === 'high' || vpPlan.thinking === 'max'))
913
+ ? vpPlan.thinking
914
+ : null;
905
915
  const resolved = resolveThinking({
906
916
  uiOverride: (userEffort === 'max' || userEffort === 'high') ? userEffort : null,
907
- routerPlan: null, // PR-C scope: priorPlan continuity only;
908
- // live router-plan thinking is a follow-up.
917
+ routerPlan: liveRouterThinking,
909
918
  priorPlan: priorPlan && priorPlan.thinking ? priorPlan.thinking : null,
910
919
  vpDefault: typeof vpPersona.thinking === 'string' ? vpPersona.thinking : null,
911
920
  globalDefault: typeof thinkingCfg.default === 'string' ? thinkingCfg.default : null,
@@ -1106,12 +1115,29 @@ export class Engine {
1106
1115
  // assistant message that produced it. Stripped at the wire by
1107
1116
  // stripMetaForWire — pure bookkeeping for priorPlan continuity.
1108
1117
  if (vpPersona && vpPersona.vpId) {
1118
+ // PR-I: when the dispatcher hands us a per-VP plan whose vpId matches
1119
+ // the active persona, persist its `forwardQuery`, `preselect`, and
1120
+ // `thinking` on the assistant message so the next turn's
1121
+ // priorPlan continuity (DESIGN.md §9.15) sees the live router's
1122
+ // decision — not a synthetic stub.
1123
+ const planForThisVp = (vpPlan && typeof vpPlan === 'object'
1124
+ && typeof vpPlan.vpId === 'string' && vpPlan.vpId === vpPersona.vpId)
1125
+ ? vpPlan
1126
+ : null;
1109
1127
  attachRouterPlan(assistantMsg, {
1110
1128
  vpId: vpPersona.vpId,
1111
- forwardQuery: { userOriginal: prompt || '', intent: '' },
1112
- preselect: undefined,
1113
- thinking: null,
1114
- thinkingReason: '',
1129
+ forwardQuery: planForThisVp && planForThisVp.forwardQuery
1130
+ ? planForThisVp.forwardQuery
1131
+ : { userOriginal: prompt || '', intent: '' },
1132
+ preselect: planForThisVp && planForThisVp.preselect
1133
+ ? planForThisVp.preselect
1134
+ : undefined,
1135
+ thinking: planForThisVp && (planForThisVp.thinking === 'high' || planForThisVp.thinking === 'max')
1136
+ ? planForThisVp.thinking
1137
+ : null,
1138
+ thinkingReason: planForThisVp && typeof planForThisVp.thinkingReason === 'string'
1139
+ ? planForThisVp.thinkingReason
1140
+ : '',
1115
1141
  });
1116
1142
  }
1117
1143
  conversationMessages.push(assistantMsg);
@@ -25,6 +25,7 @@ import { dreamShard } from './dream-shard.js';
25
25
  import { checkRecompression } from './recompression.js';
26
26
  import { runUserDreamJob } from './user-memory-store.js';
27
27
  import { runDreamTick } from '../dream-v2/tick.js';
28
+ import { createScopeRefreshHook } from '../dream-v2/refresh.js';
28
29
 
29
30
  /** Default idle timeout before dream triggers (ms). */
30
31
  export const DREAM_IDLE_MS = 30 * 60 * 1000; // 30 min
@@ -169,16 +170,14 @@ export function createDreamScheduler(opts = {}) {
169
170
  try {
170
171
  const scopes = [{ kind: 'user', scopeDir: 'user' }];
171
172
  if (group?.id) scopes.push({ kind: 'group', id: group.id, scopeDir: `groups/${group.id}` });
173
+ // PR-J: real refresh hook — read each scope's `index.md`,
174
+ // render a deterministic top-N synopsis, atomically write
175
+ // `summary.md`. No LLM, refresh-only (DESIGN.md §8 line 395).
176
+ const refresh = createScopeRefreshHook({ root: memoryDir });
172
177
  const tickResult = await runDreamTick({
173
178
  root: memoryDir,
174
179
  scopes,
175
- refresh: async (_scope) => {
176
- // v1 refresh hook: no-op placeholder. The actual scope
177
- // summary refresh continues to flow through the legacy
178
- // shard / user-dream paths above; this tick only
179
- // exercises the diff-gate + cursor write so future hook
180
- // implementations can plug in without re-wiring.
181
- },
180
+ refresh,
182
181
  });
183
182
  result.dreamV2Tick = {
184
183
  ran: tickResult.ran.length,
@@ -97,7 +97,7 @@ export class EngineInstance {
97
97
  * @param {AbortSignal} [params.signal]
98
98
  * @yields {object} EngineEvent with { ...event, threadId }
99
99
  */
100
- async *query({ prompt, mode, signal, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId } = {}) {
100
+ async *query({ prompt, mode, signal, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan } = {}) {
101
101
  if (this.#terminated) {
102
102
  yield {
103
103
  type: 'error',
@@ -173,7 +173,7 @@ export class EngineInstance {
173
173
  curToolResults = [];
174
174
  }
175
175
 
176
- for await (const event of this.#engine.query({ prompt, mode, messages: snapshot, signal, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId })) {
176
+ for await (const event of this.#engine.query({ prompt, mode, messages: snapshot, signal, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan })) {
177
177
  // Re-tag every event with the bound threadId. Non-object events
178
178
  // (shouldn't happen — all engine events are objects) are passed
179
179
  // through untouched.