@yeaft/webchat-agent 0.1.874 → 0.1.875
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/connection/message-router.js +20 -13
- package/package.json +1 -1
- package/yeaft/attachments.js +2 -2
- package/yeaft/cli.js +13 -13
- package/yeaft/compact/compactor.js +20 -20
- package/yeaft/conversation/persist.js +95 -95
- package/yeaft/debug-trace.js +12 -12
- package/yeaft/dream-v2/apply.js +15 -15
- package/yeaft/dream-v2/merge.js +12 -12
- package/yeaft/dream-v2/prompts/{extract-group.md → extract-session.md} +12 -12
- package/yeaft/dream-v2/prompts/index.js +3 -3
- package/yeaft/dream-v2/prompts/triage-pass1.md +1 -1
- package/yeaft/dream-v2/runner.js +37 -37
- package/yeaft/dream-v2/segment.js +3 -3
- package/yeaft/dream-v2/session-wiring.js +22 -22
- package/yeaft/dream-v2/state.js +7 -7
- package/yeaft/dream-v2/triage.js +22 -22
- package/yeaft/engine.js +65 -65
- package/yeaft/memory/ams-registry.js +22 -22
- package/yeaft/memory/seed-backfill.js +9 -9
- package/yeaft/memory/store-v2.js +27 -27
- package/yeaft/prompts.js +9 -9
- package/yeaft/routing/loop-guard.js +14 -14
- package/yeaft/routing/router.js +5 -5
- package/yeaft/session.js +5 -5
- package/yeaft/sessions/coordinator.js +96 -20
- package/yeaft/{groups → sessions}/ids.js +3 -3
- package/yeaft/{groups → sessions}/index.js +28 -28
- package/yeaft/sessions/pre-flow.js +178 -42
- package/yeaft/{groups → sessions}/seed-default.js +19 -19
- package/yeaft/{groups/group-config.js → sessions/session-config.js} +29 -29
- package/yeaft/{groups/group-crud.js → sessions/session-crud.js} +113 -113
- package/yeaft/sessions/session-store.js +85 -154
- package/yeaft/stop-hooks.js +4 -4
- package/yeaft/tools/todo-write.js +1 -1
- package/yeaft/tools/types.js +2 -2
- package/yeaft/vp/registry.js +1 -1
- package/yeaft/vp/vp-crud.js +1 -1
- package/yeaft/vp-status-broker.js +28 -28
- package/yeaft/web-bridge.js +411 -398
- package/yeaft/groups/coordinator.js +0 -221
- package/yeaft/groups/group-store.js +0 -212
- package/yeaft/groups/pre-flow.js +0 -329
- /package/yeaft/{groups → sessions}/feature-flag.js +0 -0
- /package/yeaft/{groups → sessions}/project-doc.js +0 -0
- /package/yeaft/{groups → sessions}/roster.js +0 -0
|
@@ -1,33 +1,59 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* coordinator.js — Group Coordinator (task-334b).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* matrix.
|
|
4
|
+
* Consumes user/VP messages, persists them to the group's 334o jsonl-log,
|
|
5
|
+
* and dispatches user-text turns to target RoleInstances'
|
|
6
|
+
* `inputQueue`.
|
|
8
7
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* As of GC.1 Commit B, VP-selection (parseMentions + dispatch matrix:
|
|
9
|
+
* mention / @all / fallback / vp-author no-op) lives in
|
|
10
|
+
* `groups/pre-flow.js` so the same logic can be invoked directly by
|
|
11
|
+
* the parallel fan-out path in web-bridge.js. Coordinator's job is now
|
|
12
|
+
* narrower: persist the message and translate the selection result
|
|
13
|
+
* into deliver() calls.
|
|
12
14
|
*
|
|
13
|
-
* This module
|
|
14
|
-
* 1. Persists the message (via
|
|
15
|
-
* 2. Asks pre-flow
|
|
16
|
-
* 3. Calls deliver(vpId, envelope) per target
|
|
15
|
+
* This module DOES NOT run the engine. It only:
|
|
16
|
+
* 1. Persists the message (via GroupHandle.appendMessage)
|
|
17
|
+
* 2. Asks pre-flow for the list of target vpIds
|
|
18
|
+
* 3. Calls a user-supplied deliver(vpId, envelope) per target
|
|
17
19
|
*/
|
|
18
20
|
|
|
19
21
|
import { parseMentions, selectRespondingVps } from './pre-flow.js';
|
|
20
22
|
|
|
23
|
+
// Re-export so existing importers (`createCoordinator(...).parseMentions`,
|
|
24
|
+
// or modules importing `parseMentions` from coordinator) keep working
|
|
25
|
+
// without churn. New code should import from `./pre-flow.js` directly.
|
|
21
26
|
export { parseMentions };
|
|
22
27
|
|
|
23
28
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
29
|
+
* Build a Group Coordinator bound to a single GroupHandle.
|
|
30
|
+
*
|
|
31
|
+
* @param {import('./session-store.js').GroupHandle} group
|
|
32
|
+
* @param {Object} [options]
|
|
33
|
+
* @param {(vpId:string, envelope:any)=>void} [options.deliver] called per target
|
|
34
|
+
* @param {number} [options.perGroupFanOut=16] @all cap (arch §5.3)
|
|
35
|
+
* @returns {GroupCoordinator}
|
|
26
36
|
*/
|
|
27
|
-
export function createCoordinator(
|
|
37
|
+
export function createCoordinator(group, options = {}) {
|
|
28
38
|
const deliver = options.deliver || (() => {});
|
|
29
39
|
const fanOutCap = options.perGroupFanOut ?? 16;
|
|
30
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Ingest one message. Returns a dispatch report describing what would/did
|
|
43
|
+
* go out to RoleInstances.
|
|
44
|
+
*
|
|
45
|
+
* @param {{
|
|
46
|
+
* from: string, // 'user' | vpId
|
|
47
|
+
* role?: 'user'|'assistant',
|
|
48
|
+
* text: string,
|
|
49
|
+
* taskId?: string|null,
|
|
50
|
+
* meta?: any,
|
|
51
|
+
* id?: string, ts?: string,
|
|
52
|
+
* }} input
|
|
53
|
+
* @param {{ taskMembers?: string[] }} [opts]
|
|
54
|
+
* When taskId is set, restricts dispatch to vps in taskMembers (334n owns
|
|
55
|
+
* the list). If omitted, coordinator will not filter.
|
|
56
|
+
*/
|
|
31
57
|
function ingest(input, opts = {}) {
|
|
32
58
|
if (!input || typeof input !== 'object') {
|
|
33
59
|
throw new Error('ingest: input required');
|
|
@@ -35,15 +61,37 @@ export function createCoordinator(sessionHandle, options = {}) {
|
|
|
35
61
|
if (typeof input.text !== 'string') {
|
|
36
62
|
throw new Error('ingest: input.text required (string)');
|
|
37
63
|
}
|
|
38
|
-
const meta =
|
|
39
|
-
if (!meta) throw new Error('
|
|
64
|
+
const meta = group.getMeta();
|
|
65
|
+
if (!meta) throw new Error('group not initialised (call createSession first)');
|
|
40
66
|
|
|
67
|
+
// `fromUser` drives `selectRespondingVps` — when true, the @-mention
|
|
68
|
+
// matrix runs (mention/broadcast/fallback). When false, VPs cannot
|
|
69
|
+
// text-@-route (VP-authored free text is surface noise per arch §6).
|
|
70
|
+
//
|
|
71
|
+
// route_forward injection is a special case: the message is VP-authored
|
|
72
|
+
// (role='assistant') but it MUST trigger dispatch (target VP needs to
|
|
73
|
+
// run). We detect it via `meta.injectedBy === 'route_forward'` and
|
|
74
|
+
// treat it as "user-like" for dispatch purposes only. Persistence still
|
|
75
|
+
// honours the caller's `role` field so the on-disk record correctly
|
|
76
|
+
// attributes the turn to the sending VP, not to the user.
|
|
41
77
|
const isRouteForwardInjection = input?.meta?.injectedBy === 'route_forward';
|
|
42
78
|
const fromUser = input.from === 'user'
|
|
43
79
|
|| input.role === 'user'
|
|
44
80
|
|| isRouteForwardInjection;
|
|
45
81
|
const mentions = parseMentions(input.text);
|
|
46
82
|
|
|
83
|
+
// Persist first — audit log / replay works even if dispatch has bugs.
|
|
84
|
+
//
|
|
85
|
+
// Convention: any field on `input` that starts with `_` is treated
|
|
86
|
+
// as ephemeral and is forwarded to the envelope (so per-turn driver
|
|
87
|
+
// payloads — image base64 blocks, prompt suffixes — reach the LLM
|
|
88
|
+
// call) but is NEVER passed to appendMessage. The jsonl-log must
|
|
89
|
+
// stay lean: base64 in audit history would blow up replay.
|
|
90
|
+
//
|
|
91
|
+
// The split is enforced structurally — see the assertion below the
|
|
92
|
+
// partition loop. Don't loosen it. If a new ephemeral key is added,
|
|
93
|
+
// it gets the `_` prefix at its source and inherits the protection
|
|
94
|
+
// for free; no allowlist to maintain.
|
|
47
95
|
const persistInput = {};
|
|
48
96
|
const ephemeral = {};
|
|
49
97
|
for (const [k, v] of Object.entries(input)) {
|
|
@@ -53,18 +101,22 @@ export function createCoordinator(sessionHandle, options = {}) {
|
|
|
53
101
|
persistInput[k] = v;
|
|
54
102
|
}
|
|
55
103
|
}
|
|
104
|
+
// Structural guarantee: nothing with a `_` prefix may reach the
|
|
105
|
+
// jsonl-log via `persistInput`. If this ever throws, the `_` rule
|
|
106
|
+
// got bypassed — fix the caller, not this assertion.
|
|
56
107
|
{
|
|
57
108
|
const leaked = Object.keys(persistInput).filter((k) => typeof k === 'string' && k.startsWith('_'));
|
|
58
109
|
if (leaked.length > 0) {
|
|
59
110
|
throw new Error(`coordinator.ingest: ephemeral fields leaked into persisted record: ${leaked.join(', ')}`);
|
|
60
111
|
}
|
|
61
112
|
}
|
|
62
|
-
const stored =
|
|
113
|
+
const stored = group.appendMessage({
|
|
63
114
|
...persistInput,
|
|
64
115
|
mentions,
|
|
65
116
|
role: input.role || (fromUser ? 'user' : 'assistant'),
|
|
66
117
|
});
|
|
67
118
|
|
|
119
|
+
// Ask pre-flow which VPs (if any) should respond.
|
|
68
120
|
const selection = selectRespondingVps({
|
|
69
121
|
meta,
|
|
70
122
|
fromUser,
|
|
@@ -74,6 +126,7 @@ export function createCoordinator(sessionHandle, options = {}) {
|
|
|
74
126
|
taskMembers: opts.taskMembers,
|
|
75
127
|
});
|
|
76
128
|
|
|
129
|
+
// VP-authored: persist but no dispatch.
|
|
77
130
|
if (selection.reason === 'vp-author-no-text-routing') {
|
|
78
131
|
return {
|
|
79
132
|
message: stored,
|
|
@@ -119,6 +172,7 @@ export function createCoordinator(sessionHandle, options = {}) {
|
|
|
119
172
|
};
|
|
120
173
|
}
|
|
121
174
|
|
|
175
|
+
// no-default / nothing to dispatch
|
|
122
176
|
return {
|
|
123
177
|
message: stored,
|
|
124
178
|
dispatched: [],
|
|
@@ -128,7 +182,7 @@ export function createCoordinator(sessionHandle, options = {}) {
|
|
|
128
182
|
}
|
|
129
183
|
|
|
130
184
|
return {
|
|
131
|
-
|
|
185
|
+
group,
|
|
132
186
|
ingest,
|
|
133
187
|
parseMentions,
|
|
134
188
|
};
|
|
@@ -139,7 +193,29 @@ function makeEnvelope(msg, meta, trigger, ephemeral = {}) {
|
|
|
139
193
|
sessionId: meta.id,
|
|
140
194
|
taskId: msg.taskId || null,
|
|
141
195
|
msg,
|
|
142
|
-
trigger,
|
|
196
|
+
trigger, // 'broadcast' | 'mention' | 'fallback'
|
|
197
|
+
// Ephemeral fields (any `_`-prefixed key on coord.ingest input).
|
|
198
|
+
// Used to ferry per-turn payloads (e.g. image base64 blocks) that
|
|
199
|
+
// must reach the driver but must NOT be persisted to the group log.
|
|
143
200
|
...ephemeral,
|
|
144
201
|
};
|
|
145
202
|
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* @typedef {Object} GroupCoordinator
|
|
206
|
+
* @property {import('./session-store.js').GroupHandle} group
|
|
207
|
+
* @property {(input:any, opts?:any)=>DispatchReport} ingest
|
|
208
|
+
* @property {(text:string)=>string[]} parseMentions
|
|
209
|
+
*/
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* @typedef {Object} DispatchReport
|
|
213
|
+
* @property {any} message
|
|
214
|
+
* @property {string[]} dispatched
|
|
215
|
+
* @property {string|null} fallback
|
|
216
|
+
* @property {Array<{vpId?:string, error:string}>} errors
|
|
217
|
+
* @property {boolean=} broadcast
|
|
218
|
+
* @property {boolean=} truncatedAtFanOutCap
|
|
219
|
+
* @property {string=} skipped
|
|
220
|
+
*/
|
|
221
|
+
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ids.js — ID generators for the groups slice.
|
|
3
3
|
*
|
|
4
|
-
* Per slice-spec §4 (ID format):
|
|
4
|
+
* Per slice-spec §4 (ID format): sessionId uses a slug, msgId uses ULID-ish
|
|
5
5
|
* lexicographic-sortable form. We implement a small crockford-base32 timestamp
|
|
6
6
|
* + randomness scheme that works cross-platform without external deps.
|
|
7
7
|
*/
|
|
@@ -37,7 +37,7 @@ export function nextMsgId() {
|
|
|
37
37
|
return `msg_${newUlidLite()}`;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
export function
|
|
40
|
+
export function nextSessionId(slug = 'default') {
|
|
41
41
|
// Slug-tolerant: lowercase a-z0-9_- only.
|
|
42
42
|
const safe = String(slug).toLowerCase().replace(/[^a-z0-9_-]+/g, '-').slice(0, 32) || 'group';
|
|
43
43
|
return `grp_${safe}`;
|
|
@@ -47,7 +47,7 @@ export function nextGroupId(slug = 'default') {
|
|
|
47
47
|
* Reserved vpIds that must never be used as actual VP identifiers — they
|
|
48
48
|
* collide with coordinator-level sentinels (`@all` broadcast, `user`/`system`
|
|
49
49
|
* sender roles) and would cause silent footguns (a vpId=`all` VP would be
|
|
50
|
-
* absorbed into broadcast). Enforced at CRUD boundaries (addVp,
|
|
50
|
+
* absorbed into broadcast). Enforced at CRUD boundaries (addVp, createSession).
|
|
51
51
|
*
|
|
52
52
|
* prev-1 nit #4 (blocker-fix): @foo/@all/@user are the mental bedrock of all
|
|
53
53
|
* future UI — protecting the names here prevents dirty data from reaching the
|
|
@@ -9,15 +9,15 @@
|
|
|
9
9
|
* - Feature flag reader for `yeaft.multiVp.enabled`
|
|
10
10
|
* - First-boot default group seeder
|
|
11
11
|
*
|
|
12
|
-
* See agent/yeaft/
|
|
12
|
+
* See agent/yeaft/sessions/coordinator.js for the dispatch contract.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
export {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
} from './
|
|
16
|
+
openSession,
|
|
17
|
+
createSession,
|
|
18
|
+
loadSessionMeta,
|
|
19
|
+
listSessions,
|
|
20
|
+
} from './session-store.js';
|
|
21
21
|
export {
|
|
22
22
|
addVp,
|
|
23
23
|
removeVp,
|
|
@@ -34,35 +34,35 @@ export {
|
|
|
34
34
|
setMultiVpEnabled,
|
|
35
35
|
} from './feature-flag.js';
|
|
36
36
|
export {
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
seedDefaultSession,
|
|
38
|
+
DEFAULT_SESSION_ID,
|
|
39
39
|
} from './seed-default.js';
|
|
40
40
|
export {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
41
|
+
SessionCrudError,
|
|
42
|
+
makeSessionId,
|
|
43
|
+
ensureDefaultSessionIfEmpty,
|
|
44
|
+
createSessionFromSpec,
|
|
45
|
+
renameSession,
|
|
46
|
+
archiveSession,
|
|
47
|
+
deleteSession,
|
|
48
|
+
purgeArchivedSessions,
|
|
49
49
|
addMember,
|
|
50
50
|
removeMember,
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
} from './
|
|
51
|
+
setSessionDefaultVp,
|
|
52
|
+
snapshotSessions,
|
|
53
|
+
updateSessionConfig,
|
|
54
|
+
updateSessionAnnouncement,
|
|
55
|
+
} from './session-crud.js';
|
|
56
56
|
export {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
} from './
|
|
57
|
+
loadSessionConfig,
|
|
58
|
+
saveSessionConfig,
|
|
59
|
+
resolveSessionConfig,
|
|
60
|
+
validateSessionConfig,
|
|
61
|
+
SessionConfigError,
|
|
62
|
+
} from './session-config.js';
|
|
63
63
|
export {
|
|
64
64
|
nextMsgId,
|
|
65
|
-
|
|
65
|
+
nextSessionId,
|
|
66
66
|
newUlidLite,
|
|
67
67
|
isReservedVpId,
|
|
68
68
|
RESERVED_VP_IDS,
|
|
@@ -1,21 +1,40 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* groups/pre-flow.js — explicit pre-flow stage for Yeaft.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* unification. Scope strings use the unified `session/<id>` /
|
|
6
|
-
* `session/<id>/vp/<vp>` shape instead of the legacy
|
|
7
|
-
* `group/<g>` / `chat/<c>` shapes.
|
|
4
|
+
* Pre-flow is the "before any VP runs" stage. It owns:
|
|
8
5
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
6
|
+
* (1) VP selection — which VP(s) respond to this user turn?
|
|
7
|
+
* Pure function `selectRespondingVps({meta, fromUser, mentions,
|
|
8
|
+
* sender, taskMembers, fanOutCap})` that mirrors the legacy
|
|
9
|
+
* coordinator dispatch matrix: mention → broadcast → fallback to
|
|
10
|
+
* defaultVpId, with VP-authored messages routed via the explicit
|
|
11
|
+
* route_forward tool instead of free-text @-mentions.
|
|
12
|
+
*
|
|
13
|
+
* (2) Memory recall — what memory gets pre-injected into each
|
|
14
|
+
* responding VP's prompt? Thin wrapper around
|
|
15
|
+
* memory/preflow.js's FTS5 recall.
|
|
16
|
+
*
|
|
17
|
+
* Commit C will flip the caller (web-bridge.js) to fan out responding
|
|
18
|
+
* VPs in parallel via Promise.all.
|
|
19
|
+
*
|
|
20
|
+
* Why one module: a single import surface for the full pre-flow stage,
|
|
21
|
+
* a stable seam for the engine, and a place to format FTS hits into the
|
|
22
|
+
* {profile, entries, formatted} shape the engine already consumes.
|
|
12
23
|
*/
|
|
13
24
|
|
|
14
25
|
import { runPreflow as runFtsPreflow } from '../memory/preflow.js';
|
|
26
|
+
import { resolveFallbackVp, resolveMemberId } from './roster.js';
|
|
15
27
|
|
|
16
28
|
/** Matches `@vp-id` where id is [A-Za-z0-9_-]+. Captures the id. */
|
|
17
29
|
const MENTION_RE = /(^|\s)@([A-Za-z0-9_][A-Za-z0-9_-]*)/g;
|
|
18
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Extract an ordered, unique list of @-mentions from a text string.
|
|
33
|
+
* Recognises the literal token `@all` as broadcast.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} text
|
|
36
|
+
* @returns {string[]}
|
|
37
|
+
*/
|
|
19
38
|
export function parseMentions(text) {
|
|
20
39
|
if (!text || typeof text !== 'string') return [];
|
|
21
40
|
const out = [];
|
|
@@ -32,30 +51,42 @@ export function parseMentions(text) {
|
|
|
32
51
|
}
|
|
33
52
|
|
|
34
53
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* @
|
|
41
|
-
*
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* }
|
|
54
|
+
* @typedef {object} SelectionInput
|
|
55
|
+
* @property {object} meta GroupHandle meta (roster + defaultVpId)
|
|
56
|
+
* @property {boolean} fromUser true = user-authored; false = VP-authored
|
|
57
|
+
* @property {string[]} mentions Already-parsed @-mentions
|
|
58
|
+
* @property {string=} sender VP id when fromUser=false
|
|
59
|
+
* @property {number} [fanOutCap=16]
|
|
60
|
+
* @property {string[]=} taskMembers When set, restricts dispatch to this list
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @typedef {object} SelectionResult
|
|
65
|
+
* @property {string[]} dispatched VP ids that should respond
|
|
66
|
+
* @property {string|null} fallback The fallback vp, if any
|
|
67
|
+
* @property {Array<{vpId?:string,error:string}>} errors
|
|
68
|
+
* @property {'mention'|'broadcast'|'fallback'|'vp-author-no-text-routing'|'no-default'} reason
|
|
69
|
+
* @property {boolean=} truncatedAtFanOutCap
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Pure VP-selection step of pre-flow. Returns ids only — caller owns
|
|
74
|
+
* persistence + envelope construction + deliver().
|
|
75
|
+
*
|
|
76
|
+
* @param {SelectionInput} input
|
|
77
|
+
* @returns {SelectionResult}
|
|
48
78
|
*/
|
|
49
79
|
export function selectRespondingVps(input) {
|
|
50
|
-
const meta = input
|
|
80
|
+
const meta = input.meta;
|
|
51
81
|
if (!meta) {
|
|
52
|
-
return { dispatched: [], fallback: null, errors: [{ error: '
|
|
82
|
+
return { dispatched: [], fallback: null, errors: [{ error: 'no_group_meta' }], reason: 'no-default' };
|
|
53
83
|
}
|
|
54
84
|
const fanOutCap = Number.isFinite(input.fanOutCap) ? input.fanOutCap : 16;
|
|
55
85
|
const taskMembers = Array.isArray(input.taskMembers) ? input.taskMembers : null;
|
|
56
86
|
const mentions = Array.isArray(input.mentions) ? input.mentions : [];
|
|
57
|
-
const roster = Array.isArray(meta.vpIds) ? meta.vpIds : [];
|
|
58
87
|
|
|
88
|
+
// VP-authored messages: never auto-route through @-mentions; VPs hand
|
|
89
|
+
// off through the explicit route_forward tool instead.
|
|
59
90
|
if (!input.fromUser) {
|
|
60
91
|
return {
|
|
61
92
|
dispatched: [],
|
|
@@ -65,36 +96,41 @@ export function selectRespondingVps(input) {
|
|
|
65
96
|
};
|
|
66
97
|
}
|
|
67
98
|
|
|
99
|
+
// @all broadcast — fan out to every roster member except the sender,
|
|
100
|
+
// honouring fanOutCap and taskMembers.
|
|
68
101
|
if (mentions.includes('all')) {
|
|
69
|
-
const
|
|
70
|
-
const scoped = taskMembers ?
|
|
102
|
+
const roster = meta.roster.filter((v) => v !== input.sender).slice(0, fanOutCap);
|
|
103
|
+
const scoped = taskMembers ? roster.filter((v) => taskMembers.includes(v)) : roster;
|
|
71
104
|
return {
|
|
72
105
|
dispatched: scoped,
|
|
73
106
|
fallback: null,
|
|
74
107
|
errors: [],
|
|
75
108
|
reason: 'broadcast',
|
|
76
|
-
truncatedAtFanOutCap: roster.length - 1 > fanOutCap,
|
|
109
|
+
truncatedAtFanOutCap: meta.roster.length - 1 > fanOutCap,
|
|
77
110
|
};
|
|
78
111
|
}
|
|
79
112
|
|
|
113
|
+
// Explicit @-mentions
|
|
80
114
|
if (mentions.length > 0) {
|
|
81
115
|
const dispatched = [];
|
|
82
116
|
const errors = [];
|
|
83
117
|
for (const vpId of mentions) {
|
|
84
|
-
|
|
118
|
+
const canonicalVpId = resolveMemberId(meta, vpId);
|
|
119
|
+
if (!canonicalVpId) {
|
|
85
120
|
errors.push({ vpId, error: 'not_in_roster' });
|
|
86
121
|
continue;
|
|
87
122
|
}
|
|
88
|
-
if (taskMembers && !taskMembers.includes(
|
|
123
|
+
if (taskMembers && !taskMembers.includes(canonicalVpId)) {
|
|
89
124
|
errors.push({ vpId, error: 'not_in_task_members' });
|
|
90
125
|
continue;
|
|
91
126
|
}
|
|
92
|
-
if (!dispatched.includes(
|
|
127
|
+
if (!dispatched.includes(canonicalVpId)) dispatched.push(canonicalVpId);
|
|
93
128
|
}
|
|
94
129
|
return { dispatched, fallback: null, errors, reason: 'mention' };
|
|
95
130
|
}
|
|
96
131
|
|
|
97
|
-
|
|
132
|
+
// No @-mention → fallback to defaultVpId (architecture G2)
|
|
133
|
+
const fallback = resolveFallbackVp(meta);
|
|
98
134
|
if (!fallback) {
|
|
99
135
|
return {
|
|
100
136
|
dispatched: [],
|
|
@@ -119,19 +155,63 @@ export function selectRespondingVps(input) {
|
|
|
119
155
|
};
|
|
120
156
|
}
|
|
121
157
|
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Build the heading for a single scope's formatted memory block.
|
|
161
|
+
*
|
|
162
|
+
* Heading style is the original recall-v2 format, kept so the system
|
|
163
|
+
* prompt the LLM sees stays stable across the FTS migration.
|
|
164
|
+
*
|
|
165
|
+
* @param {string} scope
|
|
166
|
+
* @returns {string}
|
|
167
|
+
*/
|
|
122
168
|
function scopeHeading(scope) {
|
|
123
169
|
if (scope === 'user') return '## Memory: User';
|
|
124
|
-
|
|
170
|
+
// Nested chat scopes first.
|
|
171
|
+
let m = /^chat\/([^/]+)\/vp\/(.+)$/.exec(scope);
|
|
172
|
+
if (m) return `## Memory: VP ${m[2]}`;
|
|
173
|
+
m = /^chat\/([^/]+)$/.exec(scope);
|
|
174
|
+
if (m) return `## Memory: Chat ${m[1]}`;
|
|
175
|
+
// Nested session scopes (current).
|
|
176
|
+
m = /^session\/([^/]+)\/vp\/(.+)$/.exec(scope);
|
|
125
177
|
if (m) return `## Memory: VP ${m[2]}`;
|
|
126
|
-
m = /^session\/([^/]+)$/.exec(scope);
|
|
127
|
-
if (m) return `## Memory: Session ${m[1]}`;
|
|
178
|
+
m = /^session\/([^/]+)\/user$/.exec(scope);
|
|
179
|
+
if (m) return `## Memory: Session ${m[1]} (user)`;
|
|
180
|
+
m = /^session\/([^/]+)\/feature\/(.+)$/.exec(scope);
|
|
181
|
+
if (m) return `## Memory: Feature ${m[2]}`;
|
|
182
|
+
m = /^session\/([^/]+)\/topic\/(.+)$/.exec(scope);
|
|
183
|
+
if (m) return `## Memory: Topic ${m[2]}`;
|
|
184
|
+
// Legacy nested group scopes (un-migrated data).
|
|
185
|
+
m = /^group\/([^/]+)\/vp\/(.+)$/.exec(scope);
|
|
186
|
+
if (m) return `## Memory: VP ${m[2]}`;
|
|
187
|
+
m = /^group\/([^/]+)\/user$/.exec(scope);
|
|
188
|
+
if (m) return `## Memory: Session ${m[1]} (user)`;
|
|
189
|
+
m = /^group\/([^/]+)\/feature\/(.+)$/.exec(scope);
|
|
190
|
+
if (m) return `## Memory: Feature ${m[2]}`;
|
|
191
|
+
m = /^group\/([^/]+)\/topic\/(.+)$/.exec(scope);
|
|
192
|
+
if (m) return `## Memory: Topic ${m[2]}`;
|
|
193
|
+
if (scope.startsWith('session/')) return `## Memory: Session ${scope.slice(8)}`;
|
|
194
|
+
if (scope.startsWith('group/')) return `## Memory: Session ${scope.slice(6)}`;
|
|
128
195
|
if (scope.startsWith('vp/')) return `## Memory: VP ${scope.slice(3)}`;
|
|
196
|
+
if (scope.startsWith('feature/')) return `## Memory: Feature ${scope.slice(8)}`;
|
|
197
|
+
if (scope.startsWith('topic/')) return `## Memory: Topic ${scope.slice(6)}`;
|
|
129
198
|
return `## Memory: ${scope}`;
|
|
130
199
|
}
|
|
131
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Format FTS picked segments into the prompt-ready string.
|
|
203
|
+
*
|
|
204
|
+
* Picked segments are grouped by scope (preserving the FTS rerank
|
|
205
|
+
* order within each scope group), then rendered as markdown blocks
|
|
206
|
+
* with one heading per scope.
|
|
207
|
+
*
|
|
208
|
+
* @param {Array<{scope: string, body: string, tags?: string[], kind?: string}>} picked
|
|
209
|
+
* @returns {string}
|
|
210
|
+
*/
|
|
132
211
|
export function formatPickedForInjection(picked) {
|
|
133
212
|
if (!picked || picked.length === 0) return '';
|
|
134
213
|
const byScope = new Map();
|
|
214
|
+
// Preserve insertion order (which is rerank order within each scope).
|
|
135
215
|
for (const seg of picked) {
|
|
136
216
|
const scope = seg.scope || 'unknown';
|
|
137
217
|
if (!byScope.has(scope)) byScope.set(scope, []);
|
|
@@ -144,22 +224,60 @@ export function formatPickedForInjection(picked) {
|
|
|
144
224
|
const body = (s.body || '').trim();
|
|
145
225
|
if (body) parts.push(body);
|
|
146
226
|
}
|
|
147
|
-
parts.push('');
|
|
227
|
+
parts.push(''); // blank line between scopes
|
|
148
228
|
}
|
|
149
229
|
return parts.join('\n').trim();
|
|
150
230
|
}
|
|
151
231
|
|
|
152
232
|
/**
|
|
153
|
-
*
|
|
154
|
-
*
|
|
233
|
+
* @typedef {object} MemoryPreflowOptions
|
|
234
|
+
* @property {string} userMsg The user's message
|
|
235
|
+
* @property {string} [sessionId] Active group, if any
|
|
236
|
+
* @property {string} [vpId] Responding VP id, if any
|
|
237
|
+
* @property {string} [featureId] Active feature, if any
|
|
238
|
+
* @property {string[]} [extraScopes] Additional scopes to include
|
|
239
|
+
* @property {string[]} [currentTags] Contextual tags for rerank
|
|
240
|
+
* @property {number} [topK] Max FTS rows fetched (default 50)
|
|
241
|
+
* @property {number} [budgetTokens] Token budget for picked segments
|
|
242
|
+
*/
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* @typedef {object} MemoryPreflowResult
|
|
246
|
+
* @property {string} profile User-scope summary (best-effort)
|
|
247
|
+
* @property {object[]} entries Picked segments (raw)
|
|
248
|
+
* @property {string} formatted Prompt-ready string
|
|
249
|
+
* @property {object} meta Raw FTS preflow metadata
|
|
250
|
+
*/
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Build the canonical scope list for a given (sessionId, vpId).
|
|
254
|
+
* Always includes 'user'. The order is significant — preflow.js's scope
|
|
255
|
+
* filter accepts/rejects by membership, and the formatter renders in
|
|
256
|
+
* order.
|
|
257
|
+
*
|
|
258
|
+
* (2026-05-13: `featureId` scope dropped along with the Feature system.)
|
|
155
259
|
*
|
|
156
|
-
* @param {{
|
|
260
|
+
* @param {{sessionId?: string, vpId?: string, extra?: string[]}} ctx
|
|
261
|
+
* @returns {string[]}
|
|
157
262
|
*/
|
|
158
|
-
export function buildRelevantScopes({ sessionId, vpId, extra } = {}) {
|
|
263
|
+
export function buildRelevantScopes({ sessionId, chatId, vpId, extra } = {}) {
|
|
159
264
|
const scopes = ['user'];
|
|
160
|
-
if (
|
|
265
|
+
if (chatId) {
|
|
266
|
+
scopes.push(`chat/${chatId}`);
|
|
267
|
+
if (vpId) scopes.push(`chat/${chatId}/vp/${vpId}`);
|
|
268
|
+
} else if (sessionId) {
|
|
269
|
+
// Read both legacy `group/<id>` and new `session/<id>` scopes so recall
|
|
270
|
+
// works regardless of which path the writers used for a given session.
|
|
271
|
+
// (Writers still emit `group/<id>` for back-compat; migration may have
|
|
272
|
+
// rewritten on-disk data to `session/<id>`.)
|
|
161
273
|
scopes.push(`session/${sessionId}`);
|
|
162
|
-
|
|
274
|
+
scopes.push(`session/${sessionId}/user`);
|
|
275
|
+
scopes.push(`group/${sessionId}`);
|
|
276
|
+
scopes.push(`group/${sessionId}/user`);
|
|
277
|
+
if (vpId) {
|
|
278
|
+
scopes.push(`session/${sessionId}/vp/${vpId}`);
|
|
279
|
+
scopes.push(`group/${sessionId}/vp/${vpId}`);
|
|
280
|
+
}
|
|
163
281
|
}
|
|
164
282
|
if (Array.isArray(extra)) {
|
|
165
283
|
for (const s of extra) {
|
|
@@ -169,6 +287,21 @@ export function buildRelevantScopes({ sessionId, vpId, extra } = {}) {
|
|
|
169
287
|
return scopes;
|
|
170
288
|
}
|
|
171
289
|
|
|
290
|
+
/**
|
|
291
|
+
* Run memory pre-flow for one VP turn. Thin wrapper around
|
|
292
|
+
* `memory/preflow.js::runPreflow` that:
|
|
293
|
+
*
|
|
294
|
+
* - resolves canonical scope list from {sessionId, vpId, featureId},
|
|
295
|
+
* - invokes FTS5 recall,
|
|
296
|
+
* - formats picked segments for prompt injection.
|
|
297
|
+
*
|
|
298
|
+
* Returns the engine-consumable {profile, entries, formatted, meta}
|
|
299
|
+
* shape so the existing recall pipeline can swap in without changes.
|
|
300
|
+
*
|
|
301
|
+
* @param {import('../memory/index-db.js').SegmentIndex} index
|
|
302
|
+
* @param {MemoryPreflowOptions} opts
|
|
303
|
+
* @returns {MemoryPreflowResult}
|
|
304
|
+
*/
|
|
172
305
|
export function runMemoryPreflow(index, opts) {
|
|
173
306
|
if (!index) {
|
|
174
307
|
return { profile: '', entries: [], formatted: '', meta: { skipped: 'no-index' } };
|
|
@@ -180,6 +313,7 @@ export function runMemoryPreflow(index, opts) {
|
|
|
180
313
|
|
|
181
314
|
const relevantScopes = buildRelevantScopes({
|
|
182
315
|
sessionId: opts.sessionId,
|
|
316
|
+
chatId: opts.chatId,
|
|
183
317
|
vpId: opts.vpId,
|
|
184
318
|
extra: opts.extraScopes,
|
|
185
319
|
});
|
|
@@ -193,8 +327,10 @@ export function runMemoryPreflow(index, opts) {
|
|
|
193
327
|
budgetTokens: opts.budgetTokens,
|
|
194
328
|
});
|
|
195
329
|
|
|
196
|
-
|
|
330
|
+
// Best-effort profile: pick any user-scope segment body.
|
|
331
|
+
const userSeg = (result.picked || []).find(p => p.scope === 'user');
|
|
197
332
|
const profile = userSeg ? (userSeg.body || '').trim() : '';
|
|
333
|
+
|
|
198
334
|
const formatted = formatPickedForInjection(result.picked || []);
|
|
199
335
|
|
|
200
336
|
return {
|