@yeaft/webchat-agent 0.1.508 → 0.1.510
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/crew/role-output.js +17 -4
- package/crew/routing.js +231 -52
- package/package.json +1 -1
- package/unify/engine.js +5 -1
- package/unify/llm/anthropic.js +20 -1
- package/unify/llm/chat-completions.js +17 -1
- package/unify/memory/consolidate.js +6 -0
- package/unify/memory/dream.js +7 -0
- package/unify/memory/extract.js +4 -0
- package/unify/memory/recall.js +4 -0
package/crew/role-output.js
CHANGED
|
@@ -153,8 +153,13 @@ export async function processRoleOutput(session, roleName, roleQuery, roleState)
|
|
|
153
153
|
});
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
-
// 解析路由
|
|
157
|
-
|
|
156
|
+
// 解析路由 — task-328: parseRoutes returns a decorated Array
|
|
157
|
+
// (`.routes`/`.displayBody`/`.strippedRanges`). We keep treating it as
|
|
158
|
+
// an Array for routing iteration, but use `.displayBody` whenever we
|
|
159
|
+
// need the role's prose with ROUTE blocks accurately removed.
|
|
160
|
+
const parseResult = parseRoutes(roleState.accumulatedText);
|
|
161
|
+
const routes = parseResult;
|
|
162
|
+
const displayBody = parseResult.displayBody || roleState.accumulatedText;
|
|
158
163
|
// Fallback: 如果 route summary 仍为空占位符,用 accumulatedText 末尾 500 字符
|
|
159
164
|
for (const route of routes) {
|
|
160
165
|
if (route.summary === '[该角色未提供消息摘要]' && roleState.accumulatedText) {
|
|
@@ -191,7 +196,12 @@ export async function processRoleOutput(session, roleName, roleQuery, roleState)
|
|
|
191
196
|
}
|
|
192
197
|
|
|
193
198
|
// 保存本 turn 文本(供 routing.js 预检时 saveRoleWorkSummary 使用)
|
|
199
|
+
// task-328: lastTurnText keeps the raw transcript (consumers may need
|
|
200
|
+
// ROUTE blocks for analysis); lastTurnDisplayBody is the parser-clean
|
|
201
|
+
// body used by auto-forward/UI logic to avoid sending broken ROUTE
|
|
202
|
+
// residue downstream.
|
|
194
203
|
roleState.lastTurnText = roleState.accumulatedText;
|
|
204
|
+
roleState.lastTurnDisplayBody = displayBody;
|
|
195
205
|
roleState.accumulatedText = '';
|
|
196
206
|
roleState.turnActive = false;
|
|
197
207
|
|
|
@@ -262,10 +272,13 @@ export async function processRoleOutput(session, roleName, roleQuery, roleState)
|
|
|
262
272
|
|
|
263
273
|
if (isNonPM && (hasActiveTask || hasRouteIntent)) {
|
|
264
274
|
// Non-PM role with active task OR routing intent but no ROUTE block:
|
|
265
|
-
// auto-forward to PM so the message doesn't get lost
|
|
275
|
+
// auto-forward to PM so the message doesn't get lost.
|
|
276
|
+
// task-328: forward parser-clean displayBody (ROUTE residue removed)
|
|
277
|
+
// so PM sees the actual prose, not stray END markers.
|
|
266
278
|
const reason = hasActiveTask ? 'has active task' : 'has routing intent';
|
|
267
279
|
console.log(`[Crew] ${roleName} turn ended without ROUTE (${reason}) — auto-forwarding to PM`);
|
|
268
|
-
const
|
|
280
|
+
const forwardSource = roleState.lastTurnDisplayBody || roleState.lastTurnText || '';
|
|
281
|
+
const autoSummary = `[auto-forward: ${roleName} turn 结束但未输出 ROUTE 块 (${reason})]\n${forwardSource.slice(-800).trim()}`;
|
|
269
282
|
await executeRoute(session, roleName, {
|
|
270
283
|
to: session.decisionMaker,
|
|
271
284
|
summary: autoSummary,
|
package/crew/routing.js
CHANGED
|
@@ -33,82 +33,257 @@ function _appendTextToContent(content, text) {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
* 从累积文本中解析所有 ROUTE 块(支持多 ROUTE + task
|
|
37
|
-
*
|
|
36
|
+
* 从累积文本中解析所有 ROUTE 块(支持多 ROUTE + task 字段)。
|
|
37
|
+
*
|
|
38
|
+
* task-328 — Returns a structured result:
|
|
39
|
+
* { routes, displayBody }
|
|
40
|
+
*
|
|
41
|
+
* - `routes` — Array<{to, summary, taskId, taskTitle}> (same shape as before)
|
|
42
|
+
* - `displayBody` — original text MINUS the exact matched ROUTE ranges
|
|
43
|
+
* (including any surrounding ```fence``` that wraps the
|
|
44
|
+
* ROUTE block), preserving everything else verbatim.
|
|
45
|
+
*
|
|
46
|
+
* Backward compatibility: the returned object is also iterable as an array
|
|
47
|
+
* of routes for any legacy caller that does `for (const r of parseRoutes(...))`
|
|
48
|
+
* or `parseRoutes(...).length` — we attach `[Symbol.iterator]`, `length`, and
|
|
49
|
+
* numeric index properties mirroring `routes`. New callers should use the
|
|
50
|
+
* named `.routes` / `.displayBody` fields.
|
|
51
|
+
*
|
|
52
|
+
* Scope A — ROUTE parser tolerance (task-328):
|
|
53
|
+
* (1) Markdown fence-wrapped ROUTE blocks are still parsed AND the fence
|
|
54
|
+
* lines are stripped from displayBody (so the user doesn't see an
|
|
55
|
+
* empty ```…```).
|
|
56
|
+
* (2) END variants accepted: ---END_ROUTE--- / ---END ROUTE--- /
|
|
57
|
+
* ---END--- / ---END:--- / ---END-ROUTE--- / ---endroute---.
|
|
58
|
+
* (3) `to:` accepts: `to:` `to :` `to:` `TO:` with any casing.
|
|
59
|
+
* (4) Phase 2 soft-end is a STRUCTURAL signal (blank line + `---`, or
|
|
60
|
+
* `<kanban>` / `<recent-routes>` / `<task-context>`), NOT a bare blank
|
|
61
|
+
* line — so multi-paragraph summaries are not truncated.
|
|
62
|
+
* (5) Pre-pass: fenced code is MASKED (positions preserved) but a fence
|
|
63
|
+
* that contains `---ROUTE---` is NOT masked — the ROUTE inside the
|
|
64
|
+
* fence is the real one (matches what users write).
|
|
65
|
+
*
|
|
66
|
+
* Scope B — non-ROUTE body preservation (task-328):
|
|
67
|
+
* - `displayBody` = original minus the EXACT matched ROUTE ranges.
|
|
68
|
+
* No greedy "strip-to-EOF" anymore — post-ROUTE text survives.
|
|
69
|
+
*
|
|
70
|
+
* @param {string} text - Raw role output (may contain 0+ ROUTE blocks)
|
|
71
|
+
* @returns {{ routes: Array<{to:string,summary:string,taskId:string|null,taskTitle:string|null}>, displayBody: string } & Iterable}
|
|
38
72
|
*/
|
|
39
73
|
export function parseRoutes(text) {
|
|
74
|
+
const input = typeof text === 'string' ? text : '';
|
|
40
75
|
const routes = [];
|
|
76
|
+
// Exact character ranges (in ORIGINAL `input`) to remove from displayBody.
|
|
77
|
+
// Each entry: { start, end } — half-open, end exclusive.
|
|
78
|
+
const strippedRanges = [];
|
|
79
|
+
|
|
80
|
+
if (!input) return _wrapParseResult(routes, '', strippedRanges);
|
|
41
81
|
|
|
42
|
-
// ─── Pre-pass:
|
|
43
|
-
//
|
|
44
|
-
|
|
82
|
+
// ─── Pre-pass §2: mask fenced code WITHOUT stripping from original ──
|
|
83
|
+
// We build a boolean mask the same length as `input`. Fences are walked
|
|
84
|
+
// left-to-right. A fence containing `---ROUTE---` is SKIPPED (not masked)
|
|
85
|
+
// so the real ROUTE inside it can be parsed by Phase 1. Non-ROUTE fences
|
|
86
|
+
// are masked so any ```example``` won't pollute Phase 1/2/3 matching.
|
|
87
|
+
//
|
|
88
|
+
// We also remember the start/end of each "ROUTE-carrying fence" so the
|
|
89
|
+
// displayBody calculation can extend a ROUTE match to cover its fence
|
|
90
|
+
// lines — otherwise the user would see an empty ```…``` left behind.
|
|
91
|
+
const masked = _maskNonRouteFences(input);
|
|
92
|
+
const maskedText = masked.text; // original chars or ' ' for masked regions
|
|
93
|
+
const routeFences = masked.routeFences; // [{start, end, innerStart, innerEnd}, ...]
|
|
45
94
|
|
|
46
95
|
// ─── Phase 1: Standard ROUTE blocks (with closing marker) ─────
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
const
|
|
96
|
+
// Accept END variants: END_ROUTE | END ROUTE | END-ROUTE | END: | END | endroute
|
|
97
|
+
// Body capture uses negative lookahead to avoid crossing another opener.
|
|
98
|
+
// We run regex on `maskedText` so quoted examples (in non-ROUTE fences)
|
|
99
|
+
// don't match, but we use match.index to index into the ORIGINAL input
|
|
100
|
+
// when computing the strip range.
|
|
101
|
+
const closedRegex = /---\s*ROUTE\s*---\s*\r?\n((?:(?!---\s*ROUTE\s*---)[\s\S])*?)---\s*(?:END[_ \-]?ROUTE|ENDROUTE|END)\s*:?\s*---/gi;
|
|
53
102
|
let match;
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
while ((match = regex.exec(text)) !== null) {
|
|
57
|
-
matchedRanges.push({ start: match.index, end: match.index + match[0].length });
|
|
103
|
+
while ((match = closedRegex.exec(maskedText)) !== null) {
|
|
58
104
|
const parsed = _parseRouteBlock(match[1]);
|
|
105
|
+
let rangeStart = match.index;
|
|
106
|
+
let rangeEnd = match.index + match[0].length;
|
|
107
|
+
// §5: if this match lives inside a ROUTE-carrying fence, extend the
|
|
108
|
+
// strip to cover the fence lines (so the UI doesn't see empty ```…```).
|
|
109
|
+
const fence = routeFences.find(f => rangeStart >= f.innerStart && rangeEnd <= f.innerEnd);
|
|
110
|
+
if (fence) { rangeStart = fence.start; rangeEnd = fence.end; }
|
|
111
|
+
strippedRanges.push({ start: rangeStart, end: rangeEnd });
|
|
59
112
|
if (parsed) routes.push(parsed);
|
|
60
113
|
}
|
|
61
114
|
|
|
62
|
-
// ─── Phase 2: Fallback — ROUTE block
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
115
|
+
// ─── Phase 2: Fallback — ROUTE block with no closing marker ──
|
|
116
|
+
// Soft-end uses a STRUCTURAL signal, not a bare blank line. A summary
|
|
117
|
+
// can span multiple paragraphs — it ends only when we see:
|
|
118
|
+
// (a) another ---ROUTE--- opener, or
|
|
119
|
+
// (b) EOF, or
|
|
120
|
+
// (c) a structural separator after ≥2 consecutive newlines:
|
|
121
|
+
// `\n\s*\n+---` (blank line + ---)
|
|
122
|
+
// `\n\s*\n+<(kanban|recent-routes|task-context|EOF)`
|
|
123
|
+
// (d) the 2048-char hard cap (safety valve for runaway blocks).
|
|
124
|
+
const openRegex = /---\s*ROUTE\s*---\s*\r?\n/gi;
|
|
125
|
+
while ((match = openRegex.exec(maskedText)) !== null) {
|
|
126
|
+
const openStart = match.index;
|
|
127
|
+
// Skip if this opener was already consumed by a Phase 1 match.
|
|
128
|
+
if (strippedRanges.some(r => openStart >= r.start && openStart < r.end)) continue;
|
|
129
|
+
|
|
130
|
+
const blockStart = openStart + match[0].length;
|
|
131
|
+
// (a) next opener?
|
|
132
|
+
const nextOpen = maskedText.indexOf('---ROUTE---', blockStart);
|
|
133
|
+
const hardEnd = nextOpen !== -1 ? nextOpen : maskedText.length;
|
|
134
|
+
const scope = maskedText.slice(blockStart, hardEnd);
|
|
135
|
+
|
|
136
|
+
// (c) structural cutoff — scan for the first structural signal after
|
|
137
|
+
// ≥2 consecutive newlines (blank line + structure).
|
|
138
|
+
const SOFT_END_RE = /\n[ \t]*\n+(?:---(?!\s*ROUTE)|<(?:kanban|recent-routes|task-context)\b)/;
|
|
139
|
+
const softMatch = scope.match(SOFT_END_RE);
|
|
140
|
+
let blockEnd = hardEnd;
|
|
141
|
+
if (softMatch && softMatch.index != null) {
|
|
142
|
+
blockEnd = blockStart + softMatch.index;
|
|
143
|
+
}
|
|
73
144
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const hardEnd = nextRoute !== -1 ? nextRoute : text.length;
|
|
78
|
-
// Soft end: first blank line (two or more newlines with only whitespace in between).
|
|
79
|
-
const blank = text.slice(blockStart, hardEnd).search(/\n[ \t]*\n/);
|
|
80
|
-
const blockEnd = blank !== -1 ? blockStart + blank : hardEnd;
|
|
81
|
-
const block = text.slice(blockStart, blockEnd);
|
|
145
|
+
// (d) 2048-char hard cap — protect against runaway unclosed blocks.
|
|
146
|
+
const SUMMARY_CAP = 2048;
|
|
147
|
+
if (blockEnd - blockStart > SUMMARY_CAP) blockEnd = blockStart + SUMMARY_CAP;
|
|
82
148
|
|
|
149
|
+
const block = maskedText.slice(blockStart, blockEnd);
|
|
83
150
|
const parsed = _parseRouteBlock(block);
|
|
151
|
+
|
|
152
|
+
let rangeStart = openStart;
|
|
153
|
+
let rangeEnd = blockEnd;
|
|
154
|
+
// Extend to fence if wrapped.
|
|
155
|
+
const fence = routeFences.find(f => rangeStart >= f.innerStart && rangeEnd <= f.innerEnd);
|
|
156
|
+
if (fence) { rangeStart = fence.start; rangeEnd = fence.end; }
|
|
157
|
+
strippedRanges.push({ start: rangeStart, end: rangeEnd });
|
|
84
158
|
if (parsed) routes.push(parsed);
|
|
85
159
|
}
|
|
86
160
|
|
|
87
161
|
// ─── Phase 3: Shorthand — "ROUTE → target" / "ROUTE: target" ─
|
|
88
|
-
//
|
|
89
|
-
//
|
|
162
|
+
// Only matches a single line and only outside any ROUTE block. We also
|
|
163
|
+
// run this on maskedText so shorthand inside quoted fences is ignored.
|
|
90
164
|
const shorthandRegex = /^ROUTE\s*[→:]\s*(\S+)[,:\s]*(.*)$/gm;
|
|
91
|
-
while ((match = shorthandRegex.exec(
|
|
92
|
-
// Skip if inside an already-matched ROUTE block range
|
|
165
|
+
while ((match = shorthandRegex.exec(maskedText)) !== null) {
|
|
93
166
|
const pos = match.index;
|
|
94
|
-
if (
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
const
|
|
167
|
+
if (strippedRanges.some(r => pos >= r.start && pos < r.end)) continue;
|
|
168
|
+
|
|
169
|
+
// Also skip if inside an open ---ROUTE--- block (even unclosed).
|
|
170
|
+
const precedingText = maskedText.slice(0, pos);
|
|
171
|
+
const lastRouteOpen = precedingText.search(/---\s*ROUTE\s*---(?![\s\S]*---\s*ROUTE\s*---)/i);
|
|
172
|
+
const lastRouteOpenIdx = precedingText.lastIndexOf('---ROUTE---');
|
|
173
|
+
const lastRouteCloseIdx = Math.max(
|
|
99
174
|
precedingText.lastIndexOf('---END_ROUTE---'),
|
|
100
175
|
precedingText.lastIndexOf('---END ROUTE---'),
|
|
101
|
-
precedingText.lastIndexOf('---END---')
|
|
176
|
+
precedingText.lastIndexOf('---END-ROUTE---'),
|
|
177
|
+
precedingText.lastIndexOf('---END---'),
|
|
102
178
|
);
|
|
103
|
-
if (
|
|
179
|
+
if (lastRouteOpenIdx > lastRouteCloseIdx) continue;
|
|
180
|
+
void lastRouteOpen; // silence unused
|
|
104
181
|
|
|
105
182
|
const toRaw = match[1].trim().toLowerCase().replace(/[,;:!?。,;:!?]+$/, '');
|
|
106
183
|
const summary = match[2] ? match[2].trim() : '[该角色未提供消息摘要]';
|
|
107
184
|
|
|
108
185
|
routes.push({ to: toRaw, summary, taskId: null, taskTitle: null });
|
|
186
|
+
// Shorthand is a single line — strip the whole line.
|
|
187
|
+
const lineEnd = maskedText.indexOf('\n', pos);
|
|
188
|
+
strippedRanges.push({
|
|
189
|
+
start: pos,
|
|
190
|
+
end: lineEnd === -1 ? maskedText.length : lineEnd,
|
|
191
|
+
});
|
|
109
192
|
}
|
|
110
193
|
|
|
111
|
-
|
|
194
|
+
const displayBody = _removeRanges(input, strippedRanges);
|
|
195
|
+
return _wrapParseResult(routes, displayBody, strippedRanges);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Wrap the parse result in an object that is ALSO iterable as an array
|
|
200
|
+
* of routes (for legacy `for (const r of parseRoutes(x))` callers) and
|
|
201
|
+
* supports `.length` / numeric index. New fields: `.routes`, `.displayBody`.
|
|
202
|
+
* @private
|
|
203
|
+
*/
|
|
204
|
+
function _wrapParseResult(routes, displayBody, rangesForDebug) {
|
|
205
|
+
// Start from a real Array so `Array.isArray()` and iteration/indexing
|
|
206
|
+
// "just work". Decorate with named fields that new callers prefer.
|
|
207
|
+
const arr = routes.slice();
|
|
208
|
+
Object.defineProperty(arr, 'routes', { value: routes, enumerable: false });
|
|
209
|
+
Object.defineProperty(arr, 'displayBody', { value: displayBody, enumerable: false });
|
|
210
|
+
Object.defineProperty(arr, 'strippedRanges', { value: rangesForDebug, enumerable: false });
|
|
211
|
+
return arr;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* §2 helper — build a mask of `input` that replaces non-ROUTE fenced
|
|
216
|
+
* code with spaces (length-preserving), and records the positions of
|
|
217
|
+
* fences that DO contain a ROUTE opener (so Phase 1/2 can extend their
|
|
218
|
+
* strip range to swallow the fence lines).
|
|
219
|
+
*
|
|
220
|
+
* @param {string} input
|
|
221
|
+
* @returns {{ text: string, routeFences: Array<{start:number,end:number,innerStart:number,innerEnd:number}> }}
|
|
222
|
+
* @private
|
|
223
|
+
*/
|
|
224
|
+
function _maskNonRouteFences(input) {
|
|
225
|
+
const FENCE_RE = /```[^\n]*\n([\s\S]*?)```/g;
|
|
226
|
+
let m;
|
|
227
|
+
let out = '';
|
|
228
|
+
let lastIdx = 0;
|
|
229
|
+
const routeFences = [];
|
|
230
|
+
while ((m = FENCE_RE.exec(input)) !== null) {
|
|
231
|
+
const fenceStart = m.index;
|
|
232
|
+
const fenceEnd = m.index + m[0].length;
|
|
233
|
+
const innerStart = fenceStart + m[0].indexOf('\n') + 1;
|
|
234
|
+
const innerEnd = fenceEnd - 3; // strip trailing ```
|
|
235
|
+
const fenceContent = m[1];
|
|
236
|
+
const hasRoute = /---\s*ROUTE\s*---/i.test(fenceContent);
|
|
237
|
+
// Copy unchanged text up to fence start
|
|
238
|
+
out += input.slice(lastIdx, fenceStart);
|
|
239
|
+
if (hasRoute) {
|
|
240
|
+
// Keep the fence content intact so Phase 1 sees the ROUTE; record
|
|
241
|
+
// the fence range for the displayBody extender.
|
|
242
|
+
out += input.slice(fenceStart, fenceEnd);
|
|
243
|
+
routeFences.push({ start: fenceStart, end: fenceEnd, innerStart, innerEnd });
|
|
244
|
+
} else {
|
|
245
|
+
// Mask entire fence (including markers) with spaces of equal length
|
|
246
|
+
// so positions line up with the original string.
|
|
247
|
+
out += ' '.repeat(fenceEnd - fenceStart);
|
|
248
|
+
}
|
|
249
|
+
lastIdx = fenceEnd;
|
|
250
|
+
}
|
|
251
|
+
out += input.slice(lastIdx);
|
|
252
|
+
return { text: out, routeFences };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Remove a list of (possibly overlapping) character ranges from `input`.
|
|
257
|
+
* Also trims leading/trailing whitespace from the resulting blocks so the
|
|
258
|
+
* displayBody doesn't keep lonely blank lines where a ROUTE used to be.
|
|
259
|
+
*
|
|
260
|
+
* @param {string} input
|
|
261
|
+
* @param {Array<{start:number, end:number}>} ranges
|
|
262
|
+
* @returns {string}
|
|
263
|
+
* @private
|
|
264
|
+
*/
|
|
265
|
+
function _removeRanges(input, ranges) {
|
|
266
|
+
if (!ranges || ranges.length === 0) return input;
|
|
267
|
+
// Merge overlapping/adjacent ranges.
|
|
268
|
+
const sorted = ranges.slice().sort((a, b) => a.start - b.start);
|
|
269
|
+
const merged = [sorted[0]];
|
|
270
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
271
|
+
const prev = merged[merged.length - 1];
|
|
272
|
+
const cur = sorted[i];
|
|
273
|
+
if (cur.start <= prev.end) prev.end = Math.max(prev.end, cur.end);
|
|
274
|
+
else merged.push({ ...cur });
|
|
275
|
+
}
|
|
276
|
+
// Build output by keeping the gaps between merged ranges.
|
|
277
|
+
let out = '';
|
|
278
|
+
let cursor = 0;
|
|
279
|
+
for (const r of merged) {
|
|
280
|
+
out += input.slice(cursor, r.start);
|
|
281
|
+
cursor = r.end;
|
|
282
|
+
}
|
|
283
|
+
out += input.slice(cursor);
|
|
284
|
+
// Collapse 3+ consecutive newlines (left by a removal) to a double newline.
|
|
285
|
+
out = out.replace(/\n{3,}/g, '\n\n');
|
|
286
|
+
return out.trim();
|
|
112
287
|
}
|
|
113
288
|
|
|
114
289
|
/**
|
|
@@ -117,7 +292,10 @@ export function parseRoutes(text) {
|
|
|
117
292
|
* @returns {{ to: string, summary: string, taskId: string|null, taskTitle: string|null } | null}
|
|
118
293
|
*/
|
|
119
294
|
function _parseRouteBlock(block) {
|
|
120
|
-
|
|
295
|
+
// task-328 §3: tolerate Chinese full-width colon (`to:` / `task:` / `summary:`)
|
|
296
|
+
// and stray whitespace before the colon (`to :`). All field separators accept
|
|
297
|
+
// either ASCII `:` or Chinese `:`.
|
|
298
|
+
const toMatch = block.match(/to\s*[::]\s*(.+)/i);
|
|
121
299
|
if (!toMatch) return null;
|
|
122
300
|
|
|
123
301
|
// ★ Clean `to` value: take only the first word (strip parenthetical notes, extra text)
|
|
@@ -126,10 +304,11 @@ function _parseRouteBlock(block) {
|
|
|
126
304
|
// Strip trailing punctuation (commas, semicolons, colons, etc.)
|
|
127
305
|
const toClean = toRaw.split(/[\s(]/)[0].replace(/[,;:!?。,;:!?]+$/, '');
|
|
128
306
|
|
|
129
|
-
// ★ summary: match until next known field (task:/taskTitle:) or end of block
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
const
|
|
307
|
+
// ★ summary: match until next known field (task:/taskTitle:) or end of block.
|
|
308
|
+
// Field separator accepts ASCII `:` or Chinese `:`.
|
|
309
|
+
const summaryMatch = block.match(/summary\s*[::]\s*([\s\S]+?)(?=\n\s*(?:task|taskTitle)\s*[::]|$)/i);
|
|
310
|
+
const taskMatch = block.match(/^task\s*[::]\s*(.+)/im);
|
|
311
|
+
const taskTitleMatch = block.match(/^taskTitle\s*[::]\s*(.+)/im);
|
|
133
312
|
|
|
134
313
|
let summary = summaryMatch ? summaryMatch[1].trim() : '';
|
|
135
314
|
|
|
@@ -137,7 +316,7 @@ function _parseRouteBlock(block) {
|
|
|
137
316
|
// just write the message as free text AFTER the known fields. Collect
|
|
138
317
|
// everything that is NOT a recognised field line as the body.
|
|
139
318
|
if (!summary) {
|
|
140
|
-
const KNOWN_FIELD = /^\s*(?:to|task|taskTitle|summary)\s
|
|
319
|
+
const KNOWN_FIELD = /^\s*(?:to|task|taskTitle|summary)\s*[::]/i;
|
|
141
320
|
const bare = block
|
|
142
321
|
.split(/\r?\n/)
|
|
143
322
|
.filter(line => !KNOWN_FIELD.test(line))
|
package/package.json
CHANGED
package/unify/engine.js
CHANGED
|
@@ -26,6 +26,7 @@ import { buildMemoryInjection } from './memory/layout.js';
|
|
|
26
26
|
import { runStopHooks } from './stop-hooks.js';
|
|
27
27
|
import { getThreadStore, MAIN_THREAD_ID } from './threads/store.js';
|
|
28
28
|
import { pickEffort, parseEffortPrefix } from './effort.js';
|
|
29
|
+
import { normalizeEffort } from './models.js';
|
|
29
30
|
|
|
30
31
|
/**
|
|
31
32
|
* task-324 — Turn cap removed.
|
|
@@ -446,9 +447,12 @@ export class Engine {
|
|
|
446
447
|
|
|
447
448
|
// task-327b: `/max` / `/high` / `/medium` / `/low` prefix override.
|
|
448
449
|
// Explicit caller-supplied userEffort wins over the prefix.
|
|
450
|
+
// task-327c nit: defensively normalize caller-supplied userEffort BEFORE
|
|
451
|
+
// the merge, so an invalid caller value (e.g. 'ULTRA') does not shadow a
|
|
452
|
+
// valid prompt prefix.
|
|
449
453
|
const parsed = parseEffortPrefix(prompt);
|
|
450
454
|
const effectivePrompt = parsed.cleanedPrompt;
|
|
451
|
-
const effectiveUserEffort = userEffort || parsed.effort || null;
|
|
455
|
+
const effectiveUserEffort = normalizeEffort(userEffort) || parsed.effort || null;
|
|
452
456
|
|
|
453
457
|
// ─── task-325a: engine-owned AbortController ─────────────
|
|
454
458
|
// We create our own controller for this query run so `engine.abort()`
|
package/unify/llm/anthropic.js
CHANGED
|
@@ -297,8 +297,13 @@ export class AnthropicAdapter extends LLMAdapter {
|
|
|
297
297
|
|
|
298
298
|
/**
|
|
299
299
|
* Non-streaming call for side queries.
|
|
300
|
+
*
|
|
301
|
+
* task-327c: accepts `effort` for internal scenario-tagged calls
|
|
302
|
+
* (consolidate/dream/recall/light). Guards mirror stream() — unsupported
|
|
303
|
+
* models silently drop the param. max_tokens auto-widens to budget+1024
|
|
304
|
+
* when needed.
|
|
300
305
|
*/
|
|
301
|
-
async call({ model, system, messages, maxTokens = 4096, signal }) {
|
|
306
|
+
async call({ model, system, messages, maxTokens = 4096, effort, signal }) {
|
|
302
307
|
if (signal?.aborted) throw new LLMAbortError();
|
|
303
308
|
|
|
304
309
|
const body = {
|
|
@@ -308,6 +313,20 @@ export class AnthropicAdapter extends LLMAdapter {
|
|
|
308
313
|
messages: this.#translateMessages(messages),
|
|
309
314
|
};
|
|
310
315
|
|
|
316
|
+
// task-327c: mirror stream()'s thinking injection for side queries.
|
|
317
|
+
const normEffort = normalizeEffort(effort);
|
|
318
|
+
if (thinkingV1Enabled() && normEffort) {
|
|
319
|
+
const cap = getThinkingCapability(model);
|
|
320
|
+
if (cap.supportsThinking && cap.thinkingProtocol === 'anthropic') {
|
|
321
|
+
const budget = thinkingBudgetForEffort(model, normEffort);
|
|
322
|
+
if (budget && budget > 0) {
|
|
323
|
+
const minMax = budget + 1024;
|
|
324
|
+
if (body.max_tokens < minMax) body.max_tokens = minMax;
|
|
325
|
+
body.thinking = { type: 'enabled', budget_tokens: budget };
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
311
330
|
const response = await fetch(`${this.#baseUrl}/v1/messages`, {
|
|
312
331
|
method: 'POST',
|
|
313
332
|
headers: {
|
|
@@ -350,8 +350,12 @@ export class ChatCompletionsAdapter extends LLMAdapter {
|
|
|
350
350
|
|
|
351
351
|
/**
|
|
352
352
|
* Non-streaming call for side queries.
|
|
353
|
+
*
|
|
354
|
+
* task-327c: accepts `effort` for internal scenario-tagged calls
|
|
355
|
+
* (consolidate/dream/recall/light). Feature-flag + capability guards
|
|
356
|
+
* mirror stream() exactly; unsupported models silently drop the param.
|
|
353
357
|
*/
|
|
354
|
-
async call({ model, system, messages, maxTokens = 4096, extraBody, signal }) {
|
|
358
|
+
async call({ model, system, messages, maxTokens = 4096, effort, extraBody, signal }) {
|
|
355
359
|
if (signal?.aborted) throw new LLMAbortError();
|
|
356
360
|
|
|
357
361
|
const body = {
|
|
@@ -360,6 +364,18 @@ export class ChatCompletionsAdapter extends LLMAdapter {
|
|
|
360
364
|
...this.#maxTokensBody(model, maxTokens),
|
|
361
365
|
};
|
|
362
366
|
|
|
367
|
+
// task-327c: mirror stream()'s reasoning.effort injection for side queries.
|
|
368
|
+
const normEffort = normalizeEffort(effort);
|
|
369
|
+
if (thinkingV1Enabled() && normEffort) {
|
|
370
|
+
const cap = getThinkingCapability(model);
|
|
371
|
+
if (cap.supportsThinking && cap.thinkingProtocol === 'openai-reasoning') {
|
|
372
|
+
const reasoningEffort = mapEffortToOpenAIReasoning(normEffort);
|
|
373
|
+
if (reasoningEffort) {
|
|
374
|
+
body.reasoning = { effort: reasoningEffort };
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
363
379
|
// extraBody allows callers to pass through any additional/override parameters
|
|
364
380
|
if (extraBody) Object.assign(body, extraBody);
|
|
365
381
|
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { extractMemories } from './extract.js';
|
|
18
|
+
import { pickEffort } from '../effort.js';
|
|
18
19
|
|
|
19
20
|
// ─── Constants ──────────────────────────────────────────────────
|
|
20
21
|
|
|
@@ -103,6 +104,11 @@ async function generateSummary(messages, adapter, config) {
|
|
|
103
104
|
system,
|
|
104
105
|
messages: [{ role: 'user', content: `Summarize this conversation:\n\n${conversation}` }],
|
|
105
106
|
maxTokens: 1024,
|
|
107
|
+
// task-327c: consolidate is a high-complexity side-query; flag as
|
|
108
|
+
// 'max' effort so supported models use extended thinking / reasoning.
|
|
109
|
+
// Router/adapter silently drops the param for models that don't
|
|
110
|
+
// support thinking, or when UNIFY_THINKING_V1 is off.
|
|
111
|
+
effort: pickEffort({ scenario: 'consolidate' }),
|
|
106
112
|
});
|
|
107
113
|
return result.text.trim();
|
|
108
114
|
} catch {
|
package/unify/memory/dream.js
CHANGED
|
@@ -16,6 +16,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, unlink
|
|
|
16
16
|
import { join } from 'path';
|
|
17
17
|
import { scanEntries, findStaleEntries, findDuplicateGroups, summarizeScan } from './scan.js';
|
|
18
18
|
import { MAX_ENTRIES } from './store.js';
|
|
19
|
+
import { pickEffort } from '../effort.js';
|
|
19
20
|
import {
|
|
20
21
|
ensureLayout,
|
|
21
22
|
renderIndex,
|
|
@@ -399,6 +400,9 @@ async function llmCall(adapter, config, system, prompt) {
|
|
|
399
400
|
system,
|
|
400
401
|
messages: [{ role: 'user', content: prompt }],
|
|
401
402
|
maxTokens: 4096,
|
|
403
|
+
// task-327c: dream is self-reflective memory maintenance — flag 'max'
|
|
404
|
+
// so supported models use the full thinking budget.
|
|
405
|
+
effort: pickEffort({ scenario: 'dream' }),
|
|
402
406
|
});
|
|
403
407
|
|
|
404
408
|
const text = result.text.trim();
|
|
@@ -761,6 +765,9 @@ Write the narrative as Markdown with:
|
|
|
761
765
|
system,
|
|
762
766
|
messages: [{ role: 'user', content: prompt }],
|
|
763
767
|
maxTokens: 2048,
|
|
768
|
+
// task-327c: dream narrative synthesis — same 'max' tier as the
|
|
769
|
+
// dream phase above; both pass through dream's self-reflection loop.
|
|
770
|
+
effort: pickEffort({ scenario: 'dream' }),
|
|
764
771
|
});
|
|
765
772
|
const text = (result?.text || '').trim();
|
|
766
773
|
if (!text) return null;
|
package/unify/memory/extract.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { MEMORY_KINDS } from './store.js';
|
|
12
|
+
import { pickEffort } from '../effort.js';
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Build the extraction prompt.
|
|
@@ -69,6 +70,9 @@ export async function extractMemories({ messages, adapter, config }) {
|
|
|
69
70
|
system,
|
|
70
71
|
messages: [{ role: 'user', content: extractionPrompt }],
|
|
71
72
|
maxTokens: 2048,
|
|
73
|
+
// task-327c: extract runs inside the consolidate pipeline — the
|
|
74
|
+
// JSON-structured output benefits from the same 'max' thinking tier.
|
|
75
|
+
effort: pickEffort({ scenario: 'consolidate' }),
|
|
72
76
|
});
|
|
73
77
|
|
|
74
78
|
const text = result.text.trim();
|
package/unify/memory/recall.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { createHash } from 'crypto';
|
|
17
|
+
import { pickEffort } from '../effort.js';
|
|
17
18
|
|
|
18
19
|
// ─── Constants ──────────────────────────────────────────────────
|
|
19
20
|
|
|
@@ -148,6 +149,9 @@ Select the ${MAX_RECALL_RESULTS} most relevant entries. Return a JSON array of e
|
|
|
148
149
|
system,
|
|
149
150
|
messages,
|
|
150
151
|
maxTokens: 512,
|
|
152
|
+
// task-327c: recall step-3 is a cheap classifier pass (pick N out of
|
|
153
|
+
// 15 candidates). Flag 'low' so supported models skip deep reasoning.
|
|
154
|
+
effort: pickEffort({ scenario: 'recall' }),
|
|
151
155
|
});
|
|
152
156
|
|
|
153
157
|
// Parse the JSON array from the response
|