@yeaft/webchat-agent 0.1.710 → 0.1.712
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 +1 -1
- package/unify/config.js +0 -12
- package/unify/dream-v2/limits.js +7 -1
- package/unify/dream-v2/schedule.js +13 -2
- package/unify/dream-v2/session-wiring.js +81 -3
- package/unify/engine.js +0 -2
- package/unify/feature-arc.js +437 -0
- package/unify/memory/adjust.js +8 -9
- package/unify/quick-response.js +229 -0
- package/unify/session.js +30 -16
- package/unify/web-bridge.js +99 -3
package/package.json
CHANGED
package/unify/config.js
CHANGED
|
@@ -217,8 +217,6 @@ function loadLegacyConfig(dir, overrides) {
|
|
|
217
217
|
maxContinueTurns: overrides.maxContinueTurns ?? fileConfig.maxContinueTurns ?? DEFAULTS.maxContinueTurns,
|
|
218
218
|
// task-318: legacy path never had the `unify` section — defaults.
|
|
219
219
|
unify: normaliseUnifySection(null),
|
|
220
|
-
// H2-AMS feature flag. Default true. Override wins.
|
|
221
|
-
memoryV2: overrides.memoryV2 !== undefined ? !!overrides.memoryV2 : true,
|
|
222
220
|
providers: null,
|
|
223
221
|
primaryModel: null,
|
|
224
222
|
fastModel: null,
|
|
@@ -315,16 +313,6 @@ export function loadConfig(overrides = {}) {
|
|
|
315
313
|
// don't pollute the flat config namespace used by chat/crew code.
|
|
316
314
|
unify: normaliseUnifySection(jsonConfig.unify),
|
|
317
315
|
|
|
318
|
-
// H2-AMS feature flag. When true the session opens the FTS5
|
|
319
|
-
// SegmentIndex (used by groups/pre-flow.js → memory/preflow.js
|
|
320
|
-
// for pre-turn recall) and wires the v2 dream pipeline
|
|
321
|
-
// (dream-v2/runner.js). When false both are skipped — no recall,
|
|
322
|
-
// no dream — turns still work but without memory injection. The
|
|
323
|
-
// legacy R6 recall + dream-scheduler paths have been deleted, so
|
|
324
|
-
// `false` is now a "memory off" kill switch rather than a fallback.
|
|
325
|
-
memoryV2: overrides.memoryV2 !== undefined ? !!overrides.memoryV2
|
|
326
|
-
: (jsonConfig.memoryV2 !== undefined ? !!jsonConfig.memoryV2 : true),
|
|
327
|
-
|
|
328
316
|
// Legacy fields (null when using config.json)
|
|
329
317
|
apiKey: overrides.apiKey || null,
|
|
330
318
|
openaiApiKey: null,
|
package/unify/dream-v2/limits.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* malformed values fall back to default.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
export const DREAM_INTERVAL_HOURS =
|
|
13
|
+
export const DREAM_INTERVAL_HOURS = 1;
|
|
14
14
|
export const DREAM_OVERLAP = 3;
|
|
15
15
|
export const MIN_NEW_PER_GROUP = 20;
|
|
16
16
|
export const MAX_SINGLE_MESSAGE_CHARS = 8000;
|
|
@@ -18,6 +18,11 @@ export const MAX_DIFF_TOKENS_PER_TRIAGE = 60000;
|
|
|
18
18
|
export const MAX_APPLY_TOKENS = 80000;
|
|
19
19
|
export const DREAM_BACKUP_KEEP = 7;
|
|
20
20
|
|
|
21
|
+
// task-710: nudge dream off the 1h timer when a group has accumulated
|
|
22
|
+
// this many user messages since the last successful pass. Keeps memory
|
|
23
|
+
// fresh during heavy chat windows without rivalling user latency.
|
|
24
|
+
export const DREAM_NUDGE_AFTER_MESSAGES = 50;
|
|
25
|
+
|
|
21
26
|
export const DEFAULT_LIMITS = Object.freeze({
|
|
22
27
|
DREAM_INTERVAL_HOURS,
|
|
23
28
|
DREAM_OVERLAP,
|
|
@@ -26,6 +31,7 @@ export const DEFAULT_LIMITS = Object.freeze({
|
|
|
26
31
|
MAX_DIFF_TOKENS_PER_TRIAGE,
|
|
27
32
|
MAX_APPLY_TOKENS,
|
|
28
33
|
DREAM_BACKUP_KEEP,
|
|
34
|
+
DREAM_NUDGE_AFTER_MESSAGES,
|
|
29
35
|
});
|
|
30
36
|
|
|
31
37
|
/**
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dream-v2/schedule.js.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Three trigger paths:
|
|
5
5
|
*
|
|
6
|
-
* 1.
|
|
6
|
+
* 1. Interval timer (default 1 hour, see DREAM_INTERVAL_HOURS in limits.js).
|
|
7
7
|
* 2. Manual trigger (UI button or `/dream` command), routed in via
|
|
8
8
|
* `triggerNow()` — sets `manual: true` so the per-group threshold
|
|
9
9
|
* is bypassed.
|
|
10
|
+
* 3. Nudge (task-710), routed in via `nudge()` — non-manual, so
|
|
11
|
+
* MIN_NEW_PER_GROUP still applies. Used by session-wiring when
|
|
12
|
+
* user-message traffic crosses DREAM_NUDGE_AFTER_MESSAGES.
|
|
10
13
|
*
|
|
11
14
|
* The scheduler is a thin wrapper around `runDream()` that prevents
|
|
12
15
|
* concurrent passes (a second tick while the previous is still running
|
|
@@ -66,6 +69,14 @@ export function createDreamScheduler({ run, intervalMs = DEFAULT_INTERVAL_MS, lo
|
|
|
66
69
|
triggerNow(scopeFilter) {
|
|
67
70
|
return fire({ manual: true, scopeFilter });
|
|
68
71
|
},
|
|
72
|
+
/**
|
|
73
|
+
* task-710: non-manual fire driven by user-message traffic. Unlike
|
|
74
|
+
* `triggerNow`, MIN_NEW_PER_GROUP still applies — groups below
|
|
75
|
+
* threshold are skipped exactly as on the timer path.
|
|
76
|
+
*/
|
|
77
|
+
nudge() {
|
|
78
|
+
return fire({ manual: false });
|
|
79
|
+
},
|
|
69
80
|
isRunning() { return !!inflight; },
|
|
70
81
|
/** Test hook: fires once without scheduling a timer. */
|
|
71
82
|
_fire: fire,
|
|
@@ -5,14 +5,15 @@
|
|
|
5
5
|
* to the live yeaft session: groups store, conversation log, LLM adapter,
|
|
6
6
|
* and the engine's progress event sink.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* Memory v2 is the only path. The legacy `config.memoryV2` opt-out flag was
|
|
9
|
+
* retired (task-710) — the wiring is unconditional.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { join } from 'path';
|
|
13
13
|
import { runDream } from './runner.js';
|
|
14
14
|
import { createDreamScheduler } from './schedule.js';
|
|
15
15
|
import { listGroups, openGroup } from '../groups/group-store.js';
|
|
16
|
+
import { DREAM_NUDGE_AFTER_MESSAGES } from './limits.js';
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Build the per-call options for runDream. Pure: takes a session and returns
|
|
@@ -157,14 +158,91 @@ export function createV2DreamScheduler(session) {
|
|
|
157
158
|
});
|
|
158
159
|
// Auto-start the timer.
|
|
159
160
|
v2.start();
|
|
161
|
+
|
|
162
|
+
// task-710: wire `noteUserMessage` to a real per-session message
|
|
163
|
+
// counter. When the count crosses DREAM_NUDGE_AFTER_MESSAGES (default
|
|
164
|
+
// 50) we kick off a non-manual dream pass and reset the counter.
|
|
165
|
+
// Non-manual still respects MIN_NEW_PER_GROUP per-group, so groups
|
|
166
|
+
// below threshold are still skipped — the nudge just frees us from
|
|
167
|
+
// waiting for the 1h timer when traffic is high.
|
|
168
|
+
//
|
|
169
|
+
// Counter resets on fire-attempt (not completion). If a pass is
|
|
170
|
+
// already in flight when we hit threshold, we CLAMP at threshold
|
|
171
|
+
// rather than letting the counter accumulate unbounded — otherwise
|
|
172
|
+
// the first message after the in-flight pass settles would fire
|
|
173
|
+
// immediately, defeating the 50-message guarantee.
|
|
174
|
+
let messagesSinceLastNudgeFire = 0;
|
|
175
|
+
function nudgeOnUserMessage() {
|
|
176
|
+
messagesSinceLastNudgeFire += 1;
|
|
177
|
+
if (messagesSinceLastNudgeFire < DREAM_NUDGE_AFTER_MESSAGES) return;
|
|
178
|
+
if (v2.isRunning()) {
|
|
179
|
+
// Clamp; don't accumulate. We want the next fire to wait another
|
|
180
|
+
// full DREAM_NUDGE_AFTER_MESSAGES once the in-flight pass clears.
|
|
181
|
+
messagesSinceLastNudgeFire = DREAM_NUDGE_AFTER_MESSAGES;
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
messagesSinceLastNudgeFire = 0;
|
|
185
|
+
// Fire-and-forget; failure flows through the scheduler's catch.
|
|
186
|
+
v2.nudge().catch(() => {});
|
|
187
|
+
}
|
|
188
|
+
|
|
160
189
|
// Adapter shim: legacy callers (web-bridge) call .noteUserMessage() and
|
|
161
190
|
// .triggerDreamNow() / .shutdown(). Map them onto the v2 API.
|
|
162
191
|
return {
|
|
163
|
-
noteUserMessage
|
|
192
|
+
noteUserMessage: nudgeOnUserMessage,
|
|
164
193
|
triggerDreamNow() { return v2.triggerNow(); },
|
|
194
|
+
triggerDreamForScopes(scopeFilter) { return v2.triggerNow(scopeFilter); },
|
|
165
195
|
shutdown() { v2.stop(); },
|
|
166
196
|
get isRunning() { return v2.isRunning(); },
|
|
167
197
|
// Preserve direct access for tests.
|
|
168
198
|
_v2: v2,
|
|
169
199
|
};
|
|
170
200
|
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* task-710: walk every group on disk, find the ones that have user messages
|
|
204
|
+
* but zero memory segments in the FTS index, and trigger an immediate dream
|
|
205
|
+
* pass scoped to those groups. Used at session boot so a freshly opened
|
|
206
|
+
* agent doesn't have to wait an hour (or for traffic to cross the nudge
|
|
207
|
+
* threshold) before its first memory write.
|
|
208
|
+
*
|
|
209
|
+
* Pure side-effect, fire-and-forget. Failure logs at debug only — never
|
|
210
|
+
* blocks session load.
|
|
211
|
+
*
|
|
212
|
+
* @param {{ yeaftDir: string, memoryIndex: import('../memory/index-db.js').SegmentIndex|null, dreamScheduler: { triggerDreamForScopes: (s:string[]) => Promise<any> }, config?: { debug?: boolean } }} args
|
|
213
|
+
* @returns {Promise<{ triggered: string[] }>}
|
|
214
|
+
*/
|
|
215
|
+
export async function bootInitEmptyGroups(args) {
|
|
216
|
+
const out = { triggered: [] };
|
|
217
|
+
if (!args || !args.memoryIndex || !args.dreamScheduler) return out;
|
|
218
|
+
const groupsRoot = join(args.yeaftDir, 'groups');
|
|
219
|
+
let ids;
|
|
220
|
+
try { ids = listGroups(groupsRoot).map(g => g.id); }
|
|
221
|
+
catch { return out; }
|
|
222
|
+
const empty = [];
|
|
223
|
+
for (const gid of ids) {
|
|
224
|
+
let segCount;
|
|
225
|
+
try { segCount = args.memoryIndex.listByScope(`group/${gid}`).length; }
|
|
226
|
+
catch { continue; }
|
|
227
|
+
if (segCount > 0) continue;
|
|
228
|
+
let hasMessages = false;
|
|
229
|
+
try {
|
|
230
|
+
const h = openGroup(groupsRoot, gid);
|
|
231
|
+
// Any message at all is enough — pull the first record off the
|
|
232
|
+
// iterator and stop.
|
|
233
|
+
const first = h.streamMessages().next();
|
|
234
|
+
hasMessages = !first.done;
|
|
235
|
+
} catch { continue; }
|
|
236
|
+
if (!hasMessages) continue;
|
|
237
|
+
empty.push(`group/${gid}`);
|
|
238
|
+
}
|
|
239
|
+
if (empty.length === 0) return out;
|
|
240
|
+
if (args.config?.debug) {
|
|
241
|
+
// eslint-disable-next-line no-console
|
|
242
|
+
console.log(`[dream-v2] boot init: triggering empty-AMS dream for ${empty.length} group(s):`, empty);
|
|
243
|
+
}
|
|
244
|
+
// Fire-and-forget; the scheduler swallows its own failures.
|
|
245
|
+
Promise.resolve(args.dreamScheduler.triggerDreamForScopes(empty)).catch(() => {});
|
|
246
|
+
out.triggered = empty;
|
|
247
|
+
return out;
|
|
248
|
+
}
|
package/unify/engine.js
CHANGED
|
@@ -574,8 +574,6 @@ export class Engine {
|
|
|
574
574
|
try {
|
|
575
575
|
const result = await runAdjust({
|
|
576
576
|
trigger: {
|
|
577
|
-
newMemoryWritten: false, // dream writes happen async; treat as false here
|
|
578
|
-
onDemandSize: ctx.ams.onDemandIds().length,
|
|
579
577
|
turnTokenUsage: args.turnTokenUsage,
|
|
580
578
|
totalBudget,
|
|
581
579
|
adjustRanThisSession,
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* feature-arc.js — Tracks a single VP turn's "is this heavy work?" arc.
|
|
3
|
+
*
|
|
4
|
+
* Design rationale
|
|
5
|
+
* ----------------
|
|
6
|
+
* The Unify group chat used to dump every tool call from every VP into
|
|
7
|
+
* one scrolling feed, which made anything beyond a one-shot Q&A
|
|
8
|
+
* unreadable. The fix is dual-layer:
|
|
9
|
+
*
|
|
10
|
+
* group chat → user prompt + a *pill* per heavy turn
|
|
11
|
+
* (active: "🔧 [vp] doing X…", done: "✅ [vp] X — summary")
|
|
12
|
+
* detail panel → the full unflattened timeline of whichever VP is
|
|
13
|
+
* selected
|
|
14
|
+
*
|
|
15
|
+
* For pills to exist we need to know **which VP turns are heavy**. The
|
|
16
|
+
* triage runs out of three signals (any-of):
|
|
17
|
+
*
|
|
18
|
+
* 1. Track A (quick-response) returned `intent: 'feature'`
|
|
19
|
+
* 2. Track B (main engine) has cycled ≥ FEATURE_TURN_THRESHOLD loops
|
|
20
|
+
* 3. Track B called any tool on KEY_TOOLS (work tools — bash, edits,
|
|
21
|
+
* sub-agent spawn, grep/find/glob — *not* pure read or web search)
|
|
22
|
+
*
|
|
23
|
+
* When any signal fires, this arc:
|
|
24
|
+
* - calls FeatureStore.create() with title := preview (or fallback)
|
|
25
|
+
* - stamps a `currentFeatureId` on the runVpTurn ctx so subsequent
|
|
26
|
+
* emits get featureId on their wire envelope
|
|
27
|
+
* - notifies the wire layer via a `feature_started` event so the
|
|
28
|
+
* frontend knows to fold prior messages into a pill
|
|
29
|
+
* - on turn close, runs a one-shot summarisation call against the
|
|
30
|
+
* accumulated assistant text, then writes status='completed' +
|
|
31
|
+
* result back through FeatureStore.update()
|
|
32
|
+
*
|
|
33
|
+
* The arc is **strictly additive** — it does not mutate engine state,
|
|
34
|
+
* does not consume engine events the dispatcher needs, and silently
|
|
35
|
+
* no-ops on any failure (logging only). A broken FeatureArc must never
|
|
36
|
+
* break the user's turn.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { runQuickResponse } from './quick-response.js';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Tools that strongly signal "doing real work". Single-file Read and
|
|
43
|
+
* web search are intentionally excluded — they show up in trivial Q&A
|
|
44
|
+
* (one quick lookup, one fact check) and would over-trigger the pill.
|
|
45
|
+
*
|
|
46
|
+
* Codebase grep/glob/find are *included* because they signal multi-file
|
|
47
|
+
* investigation, which is exactly the "this got heavy" mode we want to
|
|
48
|
+
* surface to the user.
|
|
49
|
+
*
|
|
50
|
+
* @type {Set<string>}
|
|
51
|
+
*/
|
|
52
|
+
export const KEY_TOOLS = new Set([
|
|
53
|
+
'Bash',
|
|
54
|
+
'FileEdit',
|
|
55
|
+
'FileWrite',
|
|
56
|
+
'FileCreate',
|
|
57
|
+
'ApplyPatch',
|
|
58
|
+
'NotebookEdit',
|
|
59
|
+
'Agent',
|
|
60
|
+
'Grep',
|
|
61
|
+
'Glob',
|
|
62
|
+
'Find',
|
|
63
|
+
'JsRepl',
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
/** Track-B loop count that on its own counts as "this got heavy". */
|
|
67
|
+
export const FEATURE_TURN_THRESHOLD = 3;
|
|
68
|
+
|
|
69
|
+
/** Cap title length we store on the Feature. */
|
|
70
|
+
const TITLE_MAX = 60;
|
|
71
|
+
/** Cap summary stored on completion. */
|
|
72
|
+
const SUMMARY_MAX = 600;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Build a one-shot system prompt asking the LLM to summarise what it
|
|
76
|
+
* just did in 1–3 sentences. Bilingual.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} language
|
|
79
|
+
*/
|
|
80
|
+
function buildSummarySystem(language = 'en') {
|
|
81
|
+
const isZh = String(language || '').toLowerCase().startsWith('zh');
|
|
82
|
+
if (isZh) {
|
|
83
|
+
return [
|
|
84
|
+
'你刚刚完成了一段工作。请用中文写一条 1–3 句的总结,告诉用户你做了什么、关键结果是什么。',
|
|
85
|
+
'只输出总结正文,不要 markdown,不要前缀(如「总结:」),不超过 600 字符。',
|
|
86
|
+
].join('\n');
|
|
87
|
+
}
|
|
88
|
+
return [
|
|
89
|
+
'You just finished a piece of work. Write a 1–3 sentence summary in English describing what you did and the key outcome.',
|
|
90
|
+
'Output only the summary prose. No markdown, no leading label like "Summary:", at most 600 characters.',
|
|
91
|
+
].join('\n');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Drive one summary call. Fails-soft: returns '' on any error.
|
|
96
|
+
*
|
|
97
|
+
* @param {{
|
|
98
|
+
* adapter: object,
|
|
99
|
+
* model: string,
|
|
100
|
+
* prompt: string, // user's original prompt — supplied as context
|
|
101
|
+
* assistantText: string, // joined VP text output for this turn
|
|
102
|
+
* language?: string,
|
|
103
|
+
* signal?: AbortSignal,
|
|
104
|
+
* }} args
|
|
105
|
+
* @returns {Promise<string>}
|
|
106
|
+
*/
|
|
107
|
+
async function runSummaryCall({ adapter, model, prompt, assistantText, language, signal }) {
|
|
108
|
+
if (!adapter || typeof adapter.stream !== 'function') return '';
|
|
109
|
+
if (!model) return '';
|
|
110
|
+
const text = (assistantText || '').trim();
|
|
111
|
+
if (!text) return '';
|
|
112
|
+
const system = buildSummarySystem(language);
|
|
113
|
+
// We feed the model BOTH the user request and what we said back, so
|
|
114
|
+
// a summary like "Looked at auth.js, found X, fixed it" is grounded.
|
|
115
|
+
const userMsg = [
|
|
116
|
+
'USER REQUEST:',
|
|
117
|
+
String(prompt || '').slice(0, 4000),
|
|
118
|
+
'',
|
|
119
|
+
'WHAT YOU DID / SAID:',
|
|
120
|
+
text.slice(0, 8000),
|
|
121
|
+
].join('\n');
|
|
122
|
+
try {
|
|
123
|
+
const parts = [];
|
|
124
|
+
for await (const evt of adapter.stream({
|
|
125
|
+
model,
|
|
126
|
+
system,
|
|
127
|
+
messages: [{ role: 'user', content: userMsg }],
|
|
128
|
+
maxTokens: 400,
|
|
129
|
+
signal,
|
|
130
|
+
})) {
|
|
131
|
+
if (evt && evt.type === 'text_delta' && typeof evt.text === 'string') {
|
|
132
|
+
parts.push(evt.text);
|
|
133
|
+
} else if (evt && evt.type === 'error') {
|
|
134
|
+
return '';
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return parts.join('').replace(/\s+/g, ' ').trim().slice(0, SUMMARY_MAX);
|
|
138
|
+
} catch {
|
|
139
|
+
return '';
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Make a feature title from preview / prompt. Strips trailing
|
|
145
|
+
* punctuation and clamps length.
|
|
146
|
+
*/
|
|
147
|
+
function makeTitle({ preview, prompt }) {
|
|
148
|
+
const src = (preview || prompt || '').replace(/\s+/g, ' ').trim();
|
|
149
|
+
if (!src) return '(untitled task)';
|
|
150
|
+
const clipped = src.slice(0, TITLE_MAX);
|
|
151
|
+
return clipped.replace(/[.!?。!?…]+$/u, '').trim() || clipped;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* @typedef {Object} FeatureArcEmits
|
|
156
|
+
* @property {(payload:{intent:'quick'|'feature', preview:string})=>void}
|
|
157
|
+
* [quickPreview] — Track A finished
|
|
158
|
+
* @property {(payload:{featureId:string, title:string, trigger:'quick'|'turns'|'tool', toolName?:string})=>void}
|
|
159
|
+
* [featureStarted] — auto-create fired, frontend should fold
|
|
160
|
+
* @property {(payload:{featureId:string, summary:string, status:'completed'|'aborted'|'error'})=>void}
|
|
161
|
+
* [featureCompleted] — turn ended, pill becomes done state
|
|
162
|
+
*
|
|
163
|
+
* @typedef {Object} FeatureArcDeps
|
|
164
|
+
* @property {object|null} adapter — LLMAdapter (session.adapter)
|
|
165
|
+
* @property {string|null} model — primaryModel
|
|
166
|
+
* @property {object|null} featureStore — FeatureStore instance (singleton)
|
|
167
|
+
* @property {string} prompt — user prompt that opened the turn
|
|
168
|
+
* @property {string} vpId
|
|
169
|
+
* @property {string|null} groupId
|
|
170
|
+
* @property {string} turnId
|
|
171
|
+
* @property {string} [vpDisplayName]
|
|
172
|
+
* @property {string} [language]
|
|
173
|
+
* @property {AbortSignal} [signal]
|
|
174
|
+
* @property {FeatureArcEmits} [emit]
|
|
175
|
+
* @property {Set<string>} [keyTools] — override for tests
|
|
176
|
+
* @property {number} [turnThreshold] — override for tests
|
|
177
|
+
*/
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Create a per-VP-turn arc tracker. Caller MUST:
|
|
181
|
+
* - call `arc.startTrackA()` once at the very beginning of runVpTurn
|
|
182
|
+
* (returns a Promise that backgrounds; do NOT await)
|
|
183
|
+
* - call `arc.observeEvent(event)` for every engine event before
|
|
184
|
+
* dispatching it to the existing handleEngineEvent
|
|
185
|
+
* - call `arc.collectAssistantText(chunk)` whenever a text_delta is
|
|
186
|
+
* forwarded (lets us seed the summary call without re-aggregating)
|
|
187
|
+
* - call `await arc.finalize({status})` AFTER the engine query
|
|
188
|
+
* generator drains, before sending the final 'result'.
|
|
189
|
+
*
|
|
190
|
+
* The arc is opinionated about ordering: featureId is published only
|
|
191
|
+
* once, the first time any signal fires. Subsequent fires are no-ops.
|
|
192
|
+
*
|
|
193
|
+
* @param {FeatureArcDeps} deps
|
|
194
|
+
*/
|
|
195
|
+
export function createFeatureArc(deps = {}) {
|
|
196
|
+
const {
|
|
197
|
+
adapter = null,
|
|
198
|
+
model = null,
|
|
199
|
+
featureStore = null,
|
|
200
|
+
prompt = '',
|
|
201
|
+
vpId,
|
|
202
|
+
groupId = null,
|
|
203
|
+
turnId,
|
|
204
|
+
vpDisplayName,
|
|
205
|
+
language,
|
|
206
|
+
signal,
|
|
207
|
+
emit = {},
|
|
208
|
+
keyTools = KEY_TOOLS,
|
|
209
|
+
turnThreshold = FEATURE_TURN_THRESHOLD,
|
|
210
|
+
} = deps;
|
|
211
|
+
|
|
212
|
+
let trackAResult = null; // {intent, preview} | null
|
|
213
|
+
let trackADone = false;
|
|
214
|
+
let featureId = null;
|
|
215
|
+
let featureTitle = null;
|
|
216
|
+
let assistantText = ''; // accumulated for summary call
|
|
217
|
+
let loopCount = 0; // 'turn_open'/'loop'/'reflection' increments
|
|
218
|
+
let _finalised = false;
|
|
219
|
+
|
|
220
|
+
/** Internal: try to fire the auto-create. Idempotent. */
|
|
221
|
+
function maybeCreateFeature(signalKind, extra = {}) {
|
|
222
|
+
// Race guard: Track A is fire-and-forget, so it can resolve AFTER
|
|
223
|
+
// the engine generator has drained and finalize() has already
|
|
224
|
+
// closed the arc. Without this guard a late Track A would publish
|
|
225
|
+
// `feature_started` *after* `feature_completed` (or worse, with
|
|
226
|
+
// no `feature_completed` at all), leaving a dangling-active pill
|
|
227
|
+
// on the frontend.
|
|
228
|
+
if (_finalised) return;
|
|
229
|
+
if (featureId) return; // already created
|
|
230
|
+
if (!featureStore || typeof featureStore.create !== 'function') {
|
|
231
|
+
// No store — at least publish a synthetic id so the frontend can
|
|
232
|
+
// still render a pill. Use a deterministic prefix so it's obvious
|
|
233
|
+
// when something is wrong.
|
|
234
|
+
featureId = `feat-local-${turnId}`;
|
|
235
|
+
} else {
|
|
236
|
+
try {
|
|
237
|
+
// Use the FULL UUID / random-string. A previous version
|
|
238
|
+
// sliced to 8 chars (32 bits of entropy) — collisions in a
|
|
239
|
+
// multi-VP group ingest were observed because the
|
|
240
|
+
// Date.now()/random fallback's first chars are dominated by
|
|
241
|
+
// the ms-precision timestamp, so two VPs in the same
|
|
242
|
+
// millisecond would hash to the same 8-char prefix and the
|
|
243
|
+
// frontend's featureId-keyed map would silently overwrite.
|
|
244
|
+
const rand = globalThis.crypto?.randomUUID?.()
|
|
245
|
+
|| (Date.now().toString(36) + Math.random().toString(36).slice(2));
|
|
246
|
+
const id = `feat-${rand}`;
|
|
247
|
+
const title = makeTitle({ preview: trackAResult?.preview, prompt });
|
|
248
|
+
featureTitle = title;
|
|
249
|
+
const record = {
|
|
250
|
+
id,
|
|
251
|
+
title,
|
|
252
|
+
description: prompt ? prompt.slice(0, 500) : '',
|
|
253
|
+
priority: 'medium',
|
|
254
|
+
status: 'in_progress',
|
|
255
|
+
parentId: null,
|
|
256
|
+
parentTaskId: null,
|
|
257
|
+
createdAt: Date.now(),
|
|
258
|
+
updatedAt: Date.now(),
|
|
259
|
+
};
|
|
260
|
+
if (groupId) {
|
|
261
|
+
record.groupId = groupId;
|
|
262
|
+
record.members = [vpId];
|
|
263
|
+
record.initiator = vpId;
|
|
264
|
+
}
|
|
265
|
+
featureStore.create(record);
|
|
266
|
+
featureId = id;
|
|
267
|
+
} catch (err) {
|
|
268
|
+
console.warn('[FeatureArc] create failed:', err?.message || err);
|
|
269
|
+
// Fallback: still publish a synthetic id so the UI gets a pill
|
|
270
|
+
// (matches the no-store branch above — a broken store should
|
|
271
|
+
// not silently disable the feature folding UX).
|
|
272
|
+
featureId = `feat-local-${turnId}`;
|
|
273
|
+
featureTitle = makeTitle({ preview: trackAResult?.preview, prompt });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (typeof emit.featureStarted === 'function') {
|
|
277
|
+
try {
|
|
278
|
+
emit.featureStarted({
|
|
279
|
+
featureId,
|
|
280
|
+
title: featureTitle || makeTitle({ preview: trackAResult?.preview, prompt }),
|
|
281
|
+
trigger: signalKind,
|
|
282
|
+
toolName: extra.toolName,
|
|
283
|
+
});
|
|
284
|
+
} catch (err) {
|
|
285
|
+
console.warn('[FeatureArc] featureStarted emit failed:', err?.message || err);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Launch Track A in the background. Returns the promise so callers can
|
|
292
|
+
* await it during shutdown if they need to (tests). In the hot path
|
|
293
|
+
* runVpTurn fires-and-forgets.
|
|
294
|
+
*/
|
|
295
|
+
async function startTrackA() {
|
|
296
|
+
try {
|
|
297
|
+
const result = await runQuickResponse({
|
|
298
|
+
adapter,
|
|
299
|
+
model,
|
|
300
|
+
prompt,
|
|
301
|
+
language,
|
|
302
|
+
vpDisplayName,
|
|
303
|
+
signal,
|
|
304
|
+
});
|
|
305
|
+
trackAResult = result;
|
|
306
|
+
trackADone = true;
|
|
307
|
+
if (result && typeof emit.quickPreview === 'function') {
|
|
308
|
+
try {
|
|
309
|
+
emit.quickPreview({ intent: result.intent, preview: result.preview });
|
|
310
|
+
} catch (err) {
|
|
311
|
+
console.warn('[FeatureArc] quickPreview emit failed:', err?.message || err);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (result && result.intent === 'feature') {
|
|
315
|
+
maybeCreateFeature('quick');
|
|
316
|
+
}
|
|
317
|
+
} catch (err) {
|
|
318
|
+
// runQuickResponse already swallows most things; log + continue.
|
|
319
|
+
trackADone = true;
|
|
320
|
+
console.warn('[FeatureArc] Track A failed:', err?.message || err);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Observe one engine event, mutating internal counters and possibly
|
|
326
|
+
* firing the auto-create. Always called BEFORE the existing
|
|
327
|
+
* handleEngineEvent dispatch so the featureId is set in time for
|
|
328
|
+
* the wire envelope to pick it up.
|
|
329
|
+
*
|
|
330
|
+
* @param {{type:string, name?:string, text?:string}} event
|
|
331
|
+
*/
|
|
332
|
+
function observeEvent(event) {
|
|
333
|
+
if (!event || typeof event !== 'object') return;
|
|
334
|
+
switch (event.type) {
|
|
335
|
+
// Only `loop` counts toward the heavy-turn threshold. The engine
|
|
336
|
+
// emits exactly one `turn_open` per turn (the bookkeeping marker
|
|
337
|
+
// that the turn started); `loop` is the per-iteration event.
|
|
338
|
+
// Counting both inflates by one and would cause
|
|
339
|
+
// FEATURE_TURN_THRESHOLD = 3 to fire after only 2 real loops.
|
|
340
|
+
case 'loop':
|
|
341
|
+
loopCount += 1;
|
|
342
|
+
if (loopCount >= turnThreshold) maybeCreateFeature('turns');
|
|
343
|
+
break;
|
|
344
|
+
case 'tool_call':
|
|
345
|
+
if (event.name && keyTools.has(event.name)) {
|
|
346
|
+
maybeCreateFeature('tool', { toolName: event.name });
|
|
347
|
+
}
|
|
348
|
+
break;
|
|
349
|
+
case 'text_delta':
|
|
350
|
+
if (typeof event.text === 'string') {
|
|
351
|
+
// Soft cap so a runaway VP doesn't balloon memory before
|
|
352
|
+
// summarisation. 50 KB is enough context for any 1–3 sentence
|
|
353
|
+
// summary.
|
|
354
|
+
if (assistantText.length < 50_000) {
|
|
355
|
+
assistantText += event.text;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
break;
|
|
359
|
+
default:
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Run summary + FeatureStore.update. Idempotent. Caller passes a
|
|
366
|
+
* status hint so we know whether to write 'completed' / 'aborted' /
|
|
367
|
+
* 'error'.
|
|
368
|
+
*
|
|
369
|
+
* @param {{status?:'completed'|'aborted'|'error'}} [opts]
|
|
370
|
+
*/
|
|
371
|
+
async function finalize(opts = {}) {
|
|
372
|
+
if (_finalised) return;
|
|
373
|
+
_finalised = true;
|
|
374
|
+
if (!featureId) return; // never escalated; nothing to close
|
|
375
|
+
|
|
376
|
+
const status = opts.status || 'completed';
|
|
377
|
+
let summary = '';
|
|
378
|
+
if (status === 'completed') {
|
|
379
|
+
summary = await runSummaryCall({
|
|
380
|
+
adapter, model, prompt, assistantText, language, signal,
|
|
381
|
+
});
|
|
382
|
+
} else if (status === 'aborted') {
|
|
383
|
+
summary = '(turn aborted)';
|
|
384
|
+
} else {
|
|
385
|
+
summary = '(turn ended with error)';
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (!summary) {
|
|
389
|
+
// Fallback to a truncated tail of the assistant text so the pill
|
|
390
|
+
// is never a blank "✅ — ".
|
|
391
|
+
summary = (assistantText || '').replace(/\s+/g, ' ').trim().slice(0, 200) || '(no summary)';
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Skip the persistence call for synthetic ids — those exist
|
|
395
|
+
// precisely because the store was unavailable or threw on create,
|
|
396
|
+
// so any update against them would also throw on the unknown id
|
|
397
|
+
// (and the catch would silently swallow it). Wire emits still
|
|
398
|
+
// happen so the frontend gets a consistent close.
|
|
399
|
+
const isSynthetic = featureId.startsWith('feat-local-');
|
|
400
|
+
if (!isSynthetic && featureStore && typeof featureStore.update === 'function') {
|
|
401
|
+
try {
|
|
402
|
+
featureStore.update(featureId, {
|
|
403
|
+
status,
|
|
404
|
+
result: summary,
|
|
405
|
+
});
|
|
406
|
+
} catch (err) {
|
|
407
|
+
console.warn('[FeatureArc] update failed:', err?.message || err);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (typeof emit.featureCompleted === 'function') {
|
|
412
|
+
try {
|
|
413
|
+
emit.featureCompleted({ featureId, summary, status });
|
|
414
|
+
} catch (err) {
|
|
415
|
+
console.warn('[FeatureArc] featureCompleted emit failed:', err?.message || err);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return {
|
|
421
|
+
startTrackA,
|
|
422
|
+
observeEvent,
|
|
423
|
+
finalize,
|
|
424
|
+
/** Mostly for tests / wire-tagging in the hot path. */
|
|
425
|
+
getFeatureId: () => featureId,
|
|
426
|
+
getTitle: () => featureTitle,
|
|
427
|
+
getTrackAResult: () => trackAResult,
|
|
428
|
+
isTrackADone: () => trackADone,
|
|
429
|
+
getLoopCount: () => loopCount,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Test seams.
|
|
434
|
+
export const __test = {
|
|
435
|
+
makeTitle,
|
|
436
|
+
buildSummarySystem,
|
|
437
|
+
};
|
package/unify/memory/adjust.js
CHANGED
|
@@ -12,23 +12,25 @@
|
|
|
12
12
|
* manipulates AMS membership.
|
|
13
13
|
*
|
|
14
14
|
* Triggered conditionally — typical session shape is "hot turn skips,
|
|
15
|
-
* adjust runs
|
|
15
|
+
* adjust runs once per session or under budget pressure":
|
|
16
16
|
*
|
|
17
17
|
* shouldRunAdjust =
|
|
18
|
-
* (
|
|
19
|
-
* || (turnTokenUsage
|
|
20
|
-
* || (!session.adjustRanThisSession) // first-turn guarantee
|
|
18
|
+
* (!session.adjustRanThisSession) // first-turn guarantee
|
|
19
|
+
* || (turnTokenUsage > totalBudget * 0.9) // budget pressure
|
|
21
20
|
*
|
|
22
21
|
* The trigger lives at the call site (engine post-turn hook); this
|
|
23
22
|
* module just exposes the policy + the LLM round-trip.
|
|
23
|
+
*
|
|
24
|
+
* task-710: the legacy `newMemoryWritten + onDemand >= 5` trigger was
|
|
25
|
+
* dropped — dream writes happen async on a background timer, so the
|
|
26
|
+
* caller had no good signal to pass and was hard-coding `false`. Adjust
|
|
27
|
+
* now relies on first-turn-guarantee + budget-pressure only.
|
|
24
28
|
*/
|
|
25
29
|
|
|
26
30
|
import { approxTokens } from './budget.js';
|
|
27
31
|
|
|
28
32
|
/**
|
|
29
33
|
* @typedef {object} AdjustTriggerInput
|
|
30
|
-
* @property {boolean} newMemoryWritten
|
|
31
|
-
* @property {number} onDemandSize
|
|
32
34
|
* @property {number} turnTokenUsage
|
|
33
35
|
* @property {number} totalBudget
|
|
34
36
|
* @property {boolean} adjustRanThisSession
|
|
@@ -48,9 +50,6 @@ export function shouldRunAdjust(input) {
|
|
|
48
50
|
if (input.turnTokenUsage > input.totalBudget * 0.9) {
|
|
49
51
|
return { run: true, reason: 'budget-pressure' };
|
|
50
52
|
}
|
|
51
|
-
if (input.newMemoryWritten && input.onDemandSize >= 5) {
|
|
52
|
-
return { run: true, reason: 'new-memory+onDemand-saturated' };
|
|
53
|
-
}
|
|
54
53
|
return { run: false, reason: 'no-trigger' };
|
|
55
54
|
}
|
|
56
55
|
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* quick-response.js — Track A of the Unify dual-track turn.
|
|
3
|
+
*
|
|
4
|
+
* Purpose
|
|
5
|
+
* -------
|
|
6
|
+
* Run a single, non-looping LLM call against the user prompt that:
|
|
7
|
+
* 1. classifies the turn as `quick` (one-shot reply) vs `feature`
|
|
8
|
+
* (heavy multi-step work that should be surfaced as a feature pill);
|
|
9
|
+
* 2. emits a short `preview` sentence telling the user what the VP is
|
|
10
|
+
* about to do (e.g. "I'll grep the auth code, give me a sec").
|
|
11
|
+
*
|
|
12
|
+
* The result feeds the dual-track UI:
|
|
13
|
+
* - `intent === 'feature'` is one of the three signals that auto-create
|
|
14
|
+
* a Feature record, collapsing all subsequent VP output into a pill.
|
|
15
|
+
* - `preview` is rendered as an instant bubble under the user's message
|
|
16
|
+
* so the user sees something within ~1s, even if the main engine
|
|
17
|
+
* loop (Track B) takes longer.
|
|
18
|
+
*
|
|
19
|
+
* Properties
|
|
20
|
+
* ----------
|
|
21
|
+
* - **One LLM call**, no tools, no loop. The whole point is to be cheap
|
|
22
|
+
* and predictable. Uses the same `primaryModel` as the main engine
|
|
23
|
+
* per design ruling — there is no separate `fastModel` channel.
|
|
24
|
+
* - **Retries once on parse/transport failure** then gives up silently.
|
|
25
|
+
* A failed Track A is fine: signals 2 (≥3 turns) and 3 (key tool)
|
|
26
|
+
* still pick up real heavy turns.
|
|
27
|
+
* - **Hard timeout** of 8s wall-clock. Track B must not be held back
|
|
28
|
+
* waiting on Track A.
|
|
29
|
+
*
|
|
30
|
+
* Wire shape — what we emit to the frontend
|
|
31
|
+
* -----------------------------------------
|
|
32
|
+
* On success:
|
|
33
|
+
* { type: 'quick_preview', vpId, turnId, intent, preview }
|
|
34
|
+
*
|
|
35
|
+
* The preview is plain text, ≤ 140 chars, in the user's language.
|
|
36
|
+
*
|
|
37
|
+
* Failure mode
|
|
38
|
+
* ------------
|
|
39
|
+
* Returns `null`. Caller MUST tolerate this and not block on the result.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
const QUICK_TIMEOUT_MS = 8000;
|
|
43
|
+
const PREVIEW_MAX_CHARS = 140;
|
|
44
|
+
const QUICK_MAX_TOKENS = 300;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Compose the system prompt that asks the LLM for a structured
|
|
48
|
+
* intent + preview. Bilingual to match the rest of Unify.
|
|
49
|
+
*
|
|
50
|
+
* @param {{ language?: string, vpDisplayName?: string }} opts
|
|
51
|
+
* @returns {string}
|
|
52
|
+
*/
|
|
53
|
+
function buildQuickSystem({ language = 'en', vpDisplayName = 'assistant' } = {}) {
|
|
54
|
+
const isZh = String(language || '').toLowerCase().startsWith('zh');
|
|
55
|
+
if (isZh) {
|
|
56
|
+
return [
|
|
57
|
+
`你正在以「${vpDisplayName}」的身份做一次极简的"先回声"判断。这不是真正的回答,主回答会由另一条线并发产出。`,
|
|
58
|
+
'',
|
|
59
|
+
'只输出一行 JSON,不要 markdown、不要 ```、不要前后空行:',
|
|
60
|
+
'{"intent":"quick"|"feature","preview":"<不超过 80 个字符的中文,告诉用户你打算做什么>"}',
|
|
61
|
+
'',
|
|
62
|
+
'intent 规则:',
|
|
63
|
+
'- "quick":用户是寒暄、问事实、要一句话答案,预计一次回复就够。',
|
|
64
|
+
'- "feature":需要查代码 / 改文件 / 调 bash / 跑测试 / 多步推理,预计要折腾若干轮。',
|
|
65
|
+
'',
|
|
66
|
+
'preview 规则:',
|
|
67
|
+
'- 用第一人称简短陈述「我去做什么」,例如:「我去看看 auth 模块再回你」。',
|
|
68
|
+
'- 不要承诺结果,不要复述用户的话。',
|
|
69
|
+
'- 不要带表情、不要带 markdown。',
|
|
70
|
+
].join('\n');
|
|
71
|
+
}
|
|
72
|
+
return [
|
|
73
|
+
`You are "${vpDisplayName}" giving a one-shot pre-reply. This is NOT the real answer; the real answer is being produced concurrently on another track.`,
|
|
74
|
+
'',
|
|
75
|
+
'Output ONE line of strict JSON, no markdown, no fences, no leading/trailing whitespace:',
|
|
76
|
+
'{"intent":"quick"|"feature","preview":"<at most 80 chars telling the user what you are about to do>"}',
|
|
77
|
+
'',
|
|
78
|
+
'intent rules:',
|
|
79
|
+
'- "quick": small talk / factual lookup / single-sentence answer.',
|
|
80
|
+
'- "feature": needs code reading, file edits, bash, tests, or multi-step reasoning.',
|
|
81
|
+
'',
|
|
82
|
+
'preview rules:',
|
|
83
|
+
'- First-person, short. Example: "Let me grep the auth module and get back to you."',
|
|
84
|
+
'- Do NOT promise outcomes. Do NOT echo the user.',
|
|
85
|
+
'- No emoji, no markdown.',
|
|
86
|
+
].join('\n');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Robust JSON extraction. Models occasionally wrap output in fences or
|
|
91
|
+
* leading prose despite instructions; we accept any single JSON object
|
|
92
|
+
* we can find.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} raw
|
|
95
|
+
* @returns {{intent:string, preview:string}|null}
|
|
96
|
+
*/
|
|
97
|
+
function parseQuickJson(raw) {
|
|
98
|
+
if (typeof raw !== 'string') return null;
|
|
99
|
+
let s = raw.trim();
|
|
100
|
+
if (!s) return null;
|
|
101
|
+
// Strip ``` fences if present.
|
|
102
|
+
if (s.startsWith('```')) {
|
|
103
|
+
s = s.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, '').trim();
|
|
104
|
+
}
|
|
105
|
+
// First-pass direct parse.
|
|
106
|
+
let obj = null;
|
|
107
|
+
try { obj = JSON.parse(s); } catch { /* fall through */ }
|
|
108
|
+
// Second-pass: locate first `{` and last `}`.
|
|
109
|
+
if (!obj) {
|
|
110
|
+
const i = s.indexOf('{');
|
|
111
|
+
const j = s.lastIndexOf('}');
|
|
112
|
+
if (i >= 0 && j > i) {
|
|
113
|
+
try { obj = JSON.parse(s.slice(i, j + 1)); } catch { /* nope */ }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (!obj || typeof obj !== 'object') return null;
|
|
117
|
+
const intent = obj.intent === 'feature' ? 'feature' : 'quick';
|
|
118
|
+
const previewRaw = typeof obj.preview === 'string' ? obj.preview : '';
|
|
119
|
+
const preview = previewRaw.replace(/\s+/g, ' ').trim().slice(0, PREVIEW_MAX_CHARS);
|
|
120
|
+
if (!preview) return null; // a preview-less response is useless
|
|
121
|
+
return { intent, preview };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Drive the adapter once. Collects text deltas, returns the assembled
|
|
126
|
+
* raw string. Throws on adapter error / abort / timeout.
|
|
127
|
+
*
|
|
128
|
+
* @param {object} adapter — LLMAdapter instance with .stream()
|
|
129
|
+
* @param {object} args — { model, system, messages, signal }
|
|
130
|
+
* @returns {Promise<string>}
|
|
131
|
+
*/
|
|
132
|
+
async function callOnce(adapter, args) {
|
|
133
|
+
const parts = [];
|
|
134
|
+
for await (const event of adapter.stream(args)) {
|
|
135
|
+
if (!event || typeof event !== 'object') continue;
|
|
136
|
+
if (event.type === 'text_delta' && typeof event.text === 'string') {
|
|
137
|
+
parts.push(event.text);
|
|
138
|
+
} else if (event.type === 'error') {
|
|
139
|
+
throw event.error || new Error('adapter stream error');
|
|
140
|
+
}
|
|
141
|
+
// tool_call / thinking_delta / usage / stop are ignored; we
|
|
142
|
+
// explicitly do not pass any tools to the adapter.
|
|
143
|
+
}
|
|
144
|
+
return parts.join('');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Run Track A. One adapter call, retry-once on failure, hard 8s deadline.
|
|
149
|
+
*
|
|
150
|
+
* @param {{
|
|
151
|
+
* adapter: object,
|
|
152
|
+
* model: string,
|
|
153
|
+
* prompt: string,
|
|
154
|
+
* language?: string,
|
|
155
|
+
* vpDisplayName?: string,
|
|
156
|
+
* signal?: AbortSignal,
|
|
157
|
+
* }} args
|
|
158
|
+
* @returns {Promise<{intent:'quick'|'feature', preview:string}|null>}
|
|
159
|
+
*/
|
|
160
|
+
export async function runQuickResponse({
|
|
161
|
+
adapter,
|
|
162
|
+
model,
|
|
163
|
+
prompt,
|
|
164
|
+
language,
|
|
165
|
+
vpDisplayName,
|
|
166
|
+
signal,
|
|
167
|
+
} = {}) {
|
|
168
|
+
if (!adapter || typeof adapter.stream !== 'function') return null;
|
|
169
|
+
if (typeof prompt !== 'string' || !prompt.trim()) return null;
|
|
170
|
+
if (!model) return null;
|
|
171
|
+
|
|
172
|
+
// Composite signal: caller's abort OR our timeout, whichever fires first.
|
|
173
|
+
const ctrl = new AbortController();
|
|
174
|
+
const onCallerAbort = () => ctrl.abort();
|
|
175
|
+
if (signal) {
|
|
176
|
+
if (signal.aborted) return null;
|
|
177
|
+
signal.addEventListener('abort', onCallerAbort, { once: true });
|
|
178
|
+
}
|
|
179
|
+
const timer = setTimeout(() => ctrl.abort(), QUICK_TIMEOUT_MS);
|
|
180
|
+
|
|
181
|
+
const system = buildQuickSystem({ language, vpDisplayName });
|
|
182
|
+
const messages = [{ role: 'user', content: prompt }];
|
|
183
|
+
const callArgs = {
|
|
184
|
+
model,
|
|
185
|
+
system,
|
|
186
|
+
messages,
|
|
187
|
+
maxTokens: QUICK_MAX_TOKENS,
|
|
188
|
+
signal: ctrl.signal,
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
// Attempt 1.
|
|
193
|
+
let raw = '';
|
|
194
|
+
try {
|
|
195
|
+
raw = await callOnce(adapter, callArgs);
|
|
196
|
+
} catch (err) {
|
|
197
|
+
// Abort or external error — exit silently. Don't retry on abort.
|
|
198
|
+
if (err && (err.name === 'AbortError' || err.name === 'LLMAbortError')) return null;
|
|
199
|
+
// Otherwise fall through to retry.
|
|
200
|
+
raw = '';
|
|
201
|
+
}
|
|
202
|
+
let parsed = raw ? parseQuickJson(raw) : null;
|
|
203
|
+
|
|
204
|
+
if (!parsed) {
|
|
205
|
+
// Attempt 2 (retry once). Reuse the same args; adapter is stateless.
|
|
206
|
+
if (ctrl.signal.aborted) return null;
|
|
207
|
+
try {
|
|
208
|
+
const raw2 = await callOnce(adapter, callArgs);
|
|
209
|
+
parsed = raw2 ? parseQuickJson(raw2) : null;
|
|
210
|
+
} catch {
|
|
211
|
+
parsed = null;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return parsed;
|
|
216
|
+
} finally {
|
|
217
|
+
clearTimeout(timer);
|
|
218
|
+
if (signal) signal.removeEventListener('abort', onCallerAbort);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Test seams — exported so tests can exercise pure helpers without
|
|
223
|
+
// spinning up an adapter.
|
|
224
|
+
export const __test = {
|
|
225
|
+
parseQuickJson,
|
|
226
|
+
buildQuickSystem,
|
|
227
|
+
QUICK_TIMEOUT_MS,
|
|
228
|
+
PREVIEW_MAX_CHARS,
|
|
229
|
+
};
|
package/unify/session.js
CHANGED
|
@@ -26,11 +26,12 @@ import { Engine } from './engine.js';
|
|
|
26
26
|
// H2.f.5: threads/, pipeline/dispatcher and input-queue retired. The
|
|
27
27
|
// session now exposes a single Engine.
|
|
28
28
|
//
|
|
29
|
-
// GC.1 (final):
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
29
|
+
// GC.1 (final): the session opens a SegmentIndex (SQLite FTS5 over
|
|
30
|
+
// memory.md) and passes it to the Engine. Engine.#recallMemory routes
|
|
31
|
+
// pre-turn recall through groups/pre-flow.js → memory/preflow.js (the
|
|
32
|
+
// previous per-scope file reader recall-v2.js has been deleted).
|
|
33
|
+
// The `config.memoryV2` opt-out flag was retired in task-710; wiring is
|
|
34
|
+
// unconditional.
|
|
34
35
|
//
|
|
35
36
|
// GC.1 follow-up: when memoryIndex is wired we also open an
|
|
36
37
|
// AmsRegistry. The registry caches per-group ActiveMemorySet
|
|
@@ -42,7 +43,7 @@ import { Engine } from './engine.js';
|
|
|
42
43
|
import { ensureDefaultGroupIfEmpty } from './groups/group-crud.js';
|
|
43
44
|
import { seedDefaultVps } from './vp/seed-defaults.js';
|
|
44
45
|
import { runSummaryBackfill } from './memory/seed-backfill.js';
|
|
45
|
-
import { createV2DreamScheduler } from './dream-v2/session-wiring.js';
|
|
46
|
+
import { createV2DreamScheduler, bootInitEmptyGroups } from './dream-v2/session-wiring.js';
|
|
46
47
|
import { openSegmentIndex } from './memory/index-db.js';
|
|
47
48
|
import { syncAll as syncSegmentIndex } from './memory/segment-sync.js';
|
|
48
49
|
import { openAmsRegistry } from './memory/ams-registry.js';
|
|
@@ -169,15 +170,14 @@ export async function loadSession(options = {}) {
|
|
|
169
170
|
const conversationStore = new ConversationStore(yeaftDir);
|
|
170
171
|
|
|
171
172
|
// ─── 5-fts. (GC.1) Open SegmentIndex for FTS pre-flow ────
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
// turn proceeds without pre-injected memory.
|
|
173
|
+
// Build a SQLite FTS5 index over ~/.yeaft/memory/<scope>/memory.md
|
|
174
|
+
// and pass it to the Engine. Engine.#recallMemory uses it via
|
|
175
|
+
// groups/pre-flow.js → memory/preflow.js. Disk is the source of
|
|
176
|
+
// truth; on boot we reconcile disk → index via syncAll. Failure
|
|
177
|
+
// to open the index is non-fatal: #recallMemory returns an empty
|
|
178
|
+
// result and the turn proceeds without pre-injected memory.
|
|
179
179
|
let memoryIndex = null;
|
|
180
|
-
if (
|
|
180
|
+
if (!config._readOnly) {
|
|
181
181
|
try {
|
|
182
182
|
const indexPath = join(yeaftDir, 'memory', 'index.db');
|
|
183
183
|
memoryIndex = openSegmentIndex(indexPath);
|
|
@@ -300,8 +300,8 @@ export async function loadSession(options = {}) {
|
|
|
300
300
|
|
|
301
301
|
// ─── 9a. Create dream scheduler ────────────
|
|
302
302
|
// The legacy R6 dream-scheduler was retired alongside recall-r6;
|
|
303
|
-
// dream-v2 is the only active path
|
|
304
|
-
//
|
|
303
|
+
// dream-v2 is the only active path (the `config.memoryV2` opt-out
|
|
304
|
+
// flag was retired in task-710 — wiring is unconditional).
|
|
305
305
|
// partialSession lets the v2 scheduler dereference adapter/config/
|
|
306
306
|
// engine/trace lazily — safe because callers attach more fields
|
|
307
307
|
// after this line.
|
|
@@ -314,6 +314,20 @@ export async function loadSession(options = {}) {
|
|
|
314
314
|
};
|
|
315
315
|
const dreamScheduler = createV2DreamScheduler(partialSession);
|
|
316
316
|
|
|
317
|
+
// task-710: kick a dream pass at boot for any group that has user
|
|
318
|
+
// messages but zero memory segments in the FTS index. Without this a
|
|
319
|
+
// freshly opened agent had to wait an hour (or for the nudge counter
|
|
320
|
+
// to cross 50) before the first segment landed and recall could find
|
|
321
|
+
// anything. Fire-and-forget; failure logs at debug only.
|
|
322
|
+
if (memoryIndex && !config._readOnly) {
|
|
323
|
+
bootInitEmptyGroups({
|
|
324
|
+
yeaftDir,
|
|
325
|
+
memoryIndex,
|
|
326
|
+
dreamScheduler,
|
|
327
|
+
config,
|
|
328
|
+
}).catch(() => { /* best-effort boot init */ });
|
|
329
|
+
}
|
|
330
|
+
|
|
317
331
|
// H2.f.5: thread engine registry, input queue, and dispatcher retired.
|
|
318
332
|
// The session exposes a single `engine`; web-bridge calls engine.query()
|
|
319
333
|
// directly. Memory recall happens via memory/preflow.js (pre-turn) and
|
package/unify/web-bridge.js
CHANGED
|
@@ -51,6 +51,8 @@ import {
|
|
|
51
51
|
compactHistory,
|
|
52
52
|
trimSnapshotForBudget,
|
|
53
53
|
} from './history-compact.js';
|
|
54
|
+
import { createFeatureArc } from './feature-arc.js';
|
|
55
|
+
import { getFeatureStore } from './tools/feature-tools.js';
|
|
54
56
|
|
|
55
57
|
/** @type {import('./session.js').Session | null} */
|
|
56
58
|
let session = null;
|
|
@@ -492,25 +494,27 @@ export async function __testResetVpState() {
|
|
|
492
494
|
* Envelope fields: conversationId, groupId, vpId, turnId — the last two
|
|
493
495
|
* let the frontend route incremental deltas to the correct per-VP message block.
|
|
494
496
|
*/
|
|
495
|
-
function sendUnifyOutput(data, { groupId, vpId, turnId } = {}) {
|
|
497
|
+
function sendUnifyOutput(data, { groupId, vpId, turnId, featureId } = {}) {
|
|
496
498
|
sendToServer({
|
|
497
499
|
type: 'unify_output',
|
|
498
500
|
conversationId: unifyConversationId,
|
|
499
501
|
...(groupId ? { groupId } : {}),
|
|
500
502
|
...(vpId ? { vpId } : {}),
|
|
501
503
|
...(turnId ? { turnId } : {}),
|
|
504
|
+
...(featureId ? { featureId } : {}),
|
|
502
505
|
data,
|
|
503
506
|
});
|
|
504
507
|
}
|
|
505
508
|
|
|
506
509
|
/** Send a unify_output event (non-claude_output metadata). */
|
|
507
|
-
function sendUnifyEvent(event, { groupId, vpId, turnId } = {}) {
|
|
510
|
+
function sendUnifyEvent(event, { groupId, vpId, turnId, featureId } = {}) {
|
|
508
511
|
sendToServer({
|
|
509
512
|
type: 'unify_output',
|
|
510
513
|
conversationId: unifyConversationId,
|
|
511
514
|
...(groupId ? { groupId } : {}),
|
|
512
515
|
...(vpId ? { vpId } : {}),
|
|
513
516
|
...(turnId ? { turnId } : {}),
|
|
517
|
+
...(featureId ? { featureId } : {}),
|
|
514
518
|
event,
|
|
515
519
|
});
|
|
516
520
|
}
|
|
@@ -871,7 +875,16 @@ export function installUnifyRuntimeBridge(s) {
|
|
|
871
875
|
*/
|
|
872
876
|
function handleEngineEvent(event, hctx) {
|
|
873
877
|
hctx.resetQueryTimer();
|
|
874
|
-
|
|
878
|
+
// featureId may have just been published mid-turn by the FeatureArc
|
|
879
|
+
// (the arc's observeEvent runs before this dispatch); pull it fresh
|
|
880
|
+
// so the wire envelope tags every subsequent emit with the right id.
|
|
881
|
+
const featureId = typeof hctx.getFeatureId === 'function' ? hctx.getFeatureId() : null;
|
|
882
|
+
const envelope = {
|
|
883
|
+
groupId: hctx.groupId,
|
|
884
|
+
vpId: hctx.vpId,
|
|
885
|
+
turnId: hctx.turnId,
|
|
886
|
+
...(featureId ? { featureId } : {}),
|
|
887
|
+
};
|
|
875
888
|
|
|
876
889
|
switch (event.type) {
|
|
877
890
|
case 'text_delta':
|
|
@@ -1572,6 +1585,11 @@ async function runVpTurn({ prompt, groupId, vpId, turnId, envelope: inboundEnvel
|
|
|
1572
1585
|
if (!prompt?.trim()) return;
|
|
1573
1586
|
|
|
1574
1587
|
const envelope = { groupId, vpId, turnId };
|
|
1588
|
+
// Arc is declared at outer-try scope so the catch / finally branches
|
|
1589
|
+
// below can call `arc.finalize({status:'aborted'|'error'})` after a
|
|
1590
|
+
// throw escaping the inner try. It's null until the inner try
|
|
1591
|
+
// populates it; all catch-side calls guard with `arc?.finalize?.`.
|
|
1592
|
+
let arc = null;
|
|
1575
1593
|
|
|
1576
1594
|
try {
|
|
1577
1595
|
if (session?.dreamScheduler) {
|
|
@@ -1611,6 +1629,63 @@ async function runVpTurn({ prompt, groupId, vpId, turnId, envelope: inboundEnvel
|
|
|
1611
1629
|
groupId,
|
|
1612
1630
|
envelope: inboundEnvelope,
|
|
1613
1631
|
});
|
|
1632
|
+
|
|
1633
|
+
// ── Dual-track feature arc ──
|
|
1634
|
+
// Track A (quick-response) runs concurrently against the same
|
|
1635
|
+
// primary model with a non-looping single call; its preview is
|
|
1636
|
+
// surfaced to the user immediately via `quick_preview` so they
|
|
1637
|
+
// see *something* within ~1s. Three signals (Track A intent,
|
|
1638
|
+
// ≥3 engine loops, key tool call) auto-create a Feature record
|
|
1639
|
+
// and the wire envelope starts tagging emits with `featureId`,
|
|
1640
|
+
// letting the frontend fold subsequent messages into a pill.
|
|
1641
|
+
arc = createFeatureArc({
|
|
1642
|
+
adapter: session?.adapter || null,
|
|
1643
|
+
model: session?.config?.model || null,
|
|
1644
|
+
featureStore: getFeatureStore(),
|
|
1645
|
+
prompt,
|
|
1646
|
+
vpId,
|
|
1647
|
+
groupId: groupId || null,
|
|
1648
|
+
turnId,
|
|
1649
|
+
vpDisplayName: queryOpts?.vpPersona?.displayName || vpId,
|
|
1650
|
+
language: session?.config?.language || 'en',
|
|
1651
|
+
signal: vpAbort.signal,
|
|
1652
|
+
emit: {
|
|
1653
|
+
quickPreview: ({ intent, preview }) => {
|
|
1654
|
+
sendUnifyEvent({
|
|
1655
|
+
type: 'quick_preview',
|
|
1656
|
+
intent,
|
|
1657
|
+
preview,
|
|
1658
|
+
vpId,
|
|
1659
|
+
turnId,
|
|
1660
|
+
}, envelope);
|
|
1661
|
+
},
|
|
1662
|
+
featureStarted: ({ featureId, title, trigger, toolName }) => {
|
|
1663
|
+
sendUnifyEvent({
|
|
1664
|
+
type: 'feature_started',
|
|
1665
|
+
featureId,
|
|
1666
|
+
title,
|
|
1667
|
+
trigger, // 'quick' | 'turns' | 'tool'
|
|
1668
|
+
toolName: toolName || null,
|
|
1669
|
+
vpId,
|
|
1670
|
+
turnId,
|
|
1671
|
+
}, { ...envelope, featureId });
|
|
1672
|
+
},
|
|
1673
|
+
featureCompleted: ({ featureId, summary, status }) => {
|
|
1674
|
+
sendUnifyEvent({
|
|
1675
|
+
type: 'feature_completed',
|
|
1676
|
+
featureId,
|
|
1677
|
+
summary,
|
|
1678
|
+
status, // 'completed' | 'aborted' | 'error'
|
|
1679
|
+
vpId,
|
|
1680
|
+
turnId,
|
|
1681
|
+
}, { ...envelope, featureId });
|
|
1682
|
+
},
|
|
1683
|
+
},
|
|
1684
|
+
});
|
|
1685
|
+
// Fire-and-forget — Track A produces its preview / decision when
|
|
1686
|
+
// ready; the main engine loop must not be held back waiting for it.
|
|
1687
|
+
arc.startTrackA();
|
|
1688
|
+
|
|
1614
1689
|
const handlerCtx = {
|
|
1615
1690
|
assistantTextParts,
|
|
1616
1691
|
toolCallsAccum,
|
|
@@ -1619,6 +1694,9 @@ async function runVpTurn({ prompt, groupId, vpId, turnId, envelope: inboundEnvel
|
|
|
1619
1694
|
groupId,
|
|
1620
1695
|
vpId,
|
|
1621
1696
|
turnId,
|
|
1697
|
+
// Lets handleEngineEvent stamp the latest featureId on each
|
|
1698
|
+
// outgoing envelope; the arc may publish it mid-turn.
|
|
1699
|
+
getFeatureId: () => arc.getFeatureId(),
|
|
1622
1700
|
};
|
|
1623
1701
|
// Always trim the snapshot before passing to engine.query. This is
|
|
1624
1702
|
// the second-line defense (history-compact only fires above 30K
|
|
@@ -1635,12 +1713,26 @@ async function runVpTurn({ prompt, groupId, vpId, turnId, envelope: inboundEnvel
|
|
|
1635
1713
|
...queryOpts,
|
|
1636
1714
|
})) {
|
|
1637
1715
|
resetQueryTimer();
|
|
1716
|
+
// Arc observes BEFORE dispatch so featureId (if just published)
|
|
1717
|
+
// is available when handleEngineEvent stamps the envelope.
|
|
1718
|
+
try { arc.observeEvent(event); } catch (err) {
|
|
1719
|
+
console.warn('[FeatureArc] observe failed:', err?.message || err);
|
|
1720
|
+
}
|
|
1638
1721
|
handleEngineEvent(event, handlerCtx);
|
|
1639
1722
|
}
|
|
1640
1723
|
|
|
1641
1724
|
// Turn completed — atomically append this VP's output to shared history.
|
|
1642
1725
|
appendTurnToHistory(prompt, assistantTextParts, toolCallsAccum, toolResultsAccum);
|
|
1643
1726
|
|
|
1727
|
+
// Close the arc: if a feature was opened during the turn, run the
|
|
1728
|
+
// summary call and write status='completed' back to FeatureStore.
|
|
1729
|
+
// Awaited so the `feature_completed` event reaches the frontend
|
|
1730
|
+
// before the final 'result' bubble (UI ordering matters: the pill
|
|
1731
|
+
// should reach its done state before the turn is marked done).
|
|
1732
|
+
try { await arc.finalize({ status: 'completed' }); } catch (err) {
|
|
1733
|
+
console.warn('[FeatureArc] finalize failed:', err?.message || err);
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1644
1736
|
sendUnifyOutput({
|
|
1645
1737
|
type: 'assistant',
|
|
1646
1738
|
message: { content: [] },
|
|
@@ -1655,6 +1747,9 @@ async function runVpTurn({ prompt, groupId, vpId, turnId, envelope: inboundEnvel
|
|
|
1655
1747
|
} catch (err) {
|
|
1656
1748
|
const isAbort = err && (err.name === 'AbortError' || err.name === 'LLMAbortError');
|
|
1657
1749
|
if (isAbort) {
|
|
1750
|
+
// Best-effort close: mark the feature aborted so the frontend pill
|
|
1751
|
+
// settles into the right terminal state instead of staying active.
|
|
1752
|
+
try { await arc?.finalize?.({ status: 'aborted' }); } catch { /* ignore */ }
|
|
1658
1753
|
sendUnifyOutput({
|
|
1659
1754
|
type: 'result',
|
|
1660
1755
|
result_text: '',
|
|
@@ -1664,6 +1759,7 @@ async function runVpTurn({ prompt, groupId, vpId, turnId, envelope: inboundEnvel
|
|
|
1664
1759
|
}
|
|
1665
1760
|
|
|
1666
1761
|
console.error('[Unify] query error:', err);
|
|
1762
|
+
try { await arc?.finalize?.({ status: 'error' }); } catch { /* ignore */ }
|
|
1667
1763
|
|
|
1668
1764
|
if (isPermissionErrorMsg(err.message)) {
|
|
1669
1765
|
if (!_permissionDiagnosticSent) {
|