@yeaft/webchat-agent 0.1.874 → 0.1.876

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.
Files changed (46) hide show
  1. package/connection/message-router.js +20 -13
  2. package/package.json +1 -1
  3. package/yeaft/attachments.js +2 -2
  4. package/yeaft/cli.js +13 -13
  5. package/yeaft/compact/compactor.js +20 -20
  6. package/yeaft/conversation/persist.js +95 -95
  7. package/yeaft/debug-trace.js +12 -12
  8. package/yeaft/dream-v2/apply.js +15 -15
  9. package/yeaft/dream-v2/merge.js +12 -12
  10. package/yeaft/dream-v2/prompts/{extract-group.md → extract-session.md} +12 -12
  11. package/yeaft/dream-v2/prompts/index.js +3 -3
  12. package/yeaft/dream-v2/prompts/triage-pass1.md +1 -1
  13. package/yeaft/dream-v2/runner.js +37 -37
  14. package/yeaft/dream-v2/segment.js +3 -3
  15. package/yeaft/dream-v2/session-wiring.js +22 -22
  16. package/yeaft/dream-v2/state.js +7 -7
  17. package/yeaft/dream-v2/triage.js +22 -22
  18. package/yeaft/engine.js +65 -65
  19. package/yeaft/memory/ams-registry.js +22 -22
  20. package/yeaft/memory/seed-backfill.js +9 -9
  21. package/yeaft/memory/store-v2.js +27 -27
  22. package/yeaft/prompts.js +9 -9
  23. package/yeaft/routing/loop-guard.js +14 -14
  24. package/yeaft/routing/router.js +5 -5
  25. package/yeaft/session.js +5 -5
  26. package/yeaft/sessions/coordinator.js +96 -20
  27. package/yeaft/{groups → sessions}/ids.js +3 -3
  28. package/yeaft/{groups → sessions}/index.js +28 -28
  29. package/yeaft/sessions/pre-flow.js +178 -42
  30. package/yeaft/{groups → sessions}/seed-default.js +19 -19
  31. package/yeaft/{groups/group-config.js → sessions/session-config.js} +29 -29
  32. package/yeaft/{groups/group-crud.js → sessions/session-crud.js} +113 -113
  33. package/yeaft/sessions/session-store.js +85 -154
  34. package/yeaft/stop-hooks.js +4 -4
  35. package/yeaft/tools/todo-write.js +1 -1
  36. package/yeaft/tools/types.js +2 -2
  37. package/yeaft/vp/registry.js +1 -1
  38. package/yeaft/vp/vp-crud.js +1 -1
  39. package/yeaft/vp-status-broker.js +28 -28
  40. package/yeaft/web-bridge.js +411 -398
  41. package/yeaft/groups/coordinator.js +0 -221
  42. package/yeaft/groups/group-store.js +0 -212
  43. package/yeaft/groups/pre-flow.js +0 -329
  44. /package/yeaft/{groups → sessions}/feature-flag.js +0 -0
  45. /package/yeaft/{groups → sessions}/project-doc.js +0 -0
  46. /package/yeaft/{groups → sessions}/roster.js +0 -0
@@ -1,33 +1,59 @@
1
1
  /**
2
- * sessions/coordinator.js — Session Coordinator.
2
+ * coordinator.js — Group Coordinator (task-334b).
3
3
  *
4
- * Ported from groups/coordinator.js as part of the chat+group session
5
- * unification. Persists envelopes to a SessionHandle's jsonl-log and
6
- * dispatches user-text turns to target VPs via pre-flow's selection
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
- * N=1 (the old "chat") and N>1 (the old "group") are handled by the same
10
- * logic chat is just the degenerate case where pre-flow's @-mention
11
- * matrix always falls back to the lone roster member.
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 does NOT run the engine. It only:
14
- * 1. Persists the message (via SessionHandle.appendMessage)
15
- * 2. Asks pre-flow which VPs should respond
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
- * @param {import('./session-store.js').SessionHandle} sessionHandle
25
- * @param {{ deliver?: (vpId:string, envelope:any) => void, perGroupFanOut?: number }} [options]
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(sessionHandle, options = {}) {
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 = sessionHandle.getMeta();
39
- if (!meta) throw new Error('session not initialised (call createSession first)');
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 = sessionHandle.appendMessage({
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
- session: sessionHandle,
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): groupId uses a slug, msgId uses ULID-ish
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 nextGroupId(slug = 'default') {
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, createGroup).
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/groups/coordinator.js for the dispatch contract.
12
+ * See agent/yeaft/sessions/coordinator.js for the dispatch contract.
13
13
  */
14
14
 
15
15
  export {
16
- openGroup,
17
- createGroup,
18
- loadGroupMeta,
19
- listGroups,
20
- } from './group-store.js';
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
- seedDefaultGroup,
38
- DEFAULT_GROUP_ID,
37
+ seedDefaultSession,
38
+ DEFAULT_SESSION_ID,
39
39
  } from './seed-default.js';
40
40
  export {
41
- GroupCrudError,
42
- makeGroupId,
43
- ensureDefaultGroupIfEmpty,
44
- createGroupFromSpec,
45
- renameGroup,
46
- archiveGroup,
47
- deleteGroup,
48
- purgeArchivedGroups,
41
+ SessionCrudError,
42
+ makeSessionId,
43
+ ensureDefaultSessionIfEmpty,
44
+ createSessionFromSpec,
45
+ renameSession,
46
+ archiveSession,
47
+ deleteSession,
48
+ purgeArchivedSessions,
49
49
  addMember,
50
50
  removeMember,
51
- setGroupDefaultVp,
52
- snapshotGroups,
53
- updateGroupConfig,
54
- updateGroupAnnouncement,
55
- } from './group-crud.js';
51
+ setSessionDefaultVp,
52
+ snapshotSessions,
53
+ updateSessionConfig,
54
+ updateSessionAnnouncement,
55
+ } from './session-crud.js';
56
56
  export {
57
- loadGroupConfig,
58
- saveGroupConfig,
59
- resolveGroupConfig,
60
- validateGroupConfig,
61
- GroupConfigError,
62
- } from './group-config.js';
57
+ loadSessionConfig,
58
+ saveSessionConfig,
59
+ resolveSessionConfig,
60
+ validateSessionConfig,
61
+ SessionConfigError,
62
+ } from './session-config.js';
63
63
  export {
64
64
  nextMsgId,
65
- nextGroupId,
65
+ nextSessionId,
66
66
  newUlidLite,
67
67
  isReservedVpId,
68
68
  RESERVED_VP_IDS,
@@ -1,21 +1,40 @@
1
1
  /**
2
- * sessions/pre-flow.js — explicit pre-flow stage for Yeaft (session-scoped).
2
+ * groups/pre-flow.js — explicit pre-flow stage for Yeaft.
3
3
  *
4
- * Ported from groups/pre-flow.js as part of the chat+group session
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
- * NOTE: the old groups/pre-flow.js is still in place callers (web-bridge,
10
- * coordinator) will switch over in Phase A6/A7. Do not delete the old file
11
- * until every importer has been migrated.
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
- * Pure VP-selection for a session. Returns the list of VP ids that should
36
- * respond to a user turn — mention / @all / fallback to first roster
37
- * member. (Sessions have no `defaultVpId` field; the first VP in `vpIds`
38
- * is the fallback.)
39
- *
40
- * @param {{
41
- * meta: { id: string, vpIds: string[] },
42
- * fromUser: boolean,
43
- * mentions: string[],
44
- * sender?: string,
45
- * fanOutCap?: number,
46
- * taskMembers?: string[],
47
- * }} input
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?.meta;
80
+ const meta = input.meta;
51
81
  if (!meta) {
52
- return { dispatched: [], fallback: null, errors: [{ error: 'no_session_meta' }], reason: 'no-default' };
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 expanded = roster.filter((v) => v !== input.sender).slice(0, fanOutCap);
70
- const scoped = taskMembers ? expanded.filter((v) => taskMembers.includes(v)) : expanded;
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
- if (!roster.includes(vpId)) {
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(vpId)) {
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(vpId)) dispatched.push(vpId);
127
+ if (!dispatched.includes(canonicalVpId)) dispatched.push(canonicalVpId);
93
128
  }
94
129
  return { dispatched, fallback: null, errors, reason: 'mention' };
95
130
  }
96
131
 
97
- const fallback = roster[0] || null;
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
- let m = /^session\/([^/]+)\/vp\/(.+)$/.exec(scope);
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
- * Canonical scope list for a session VP turn:
154
- * ['user', 'session/<id>', 'session/<id>/vp/<vp>']
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 {{ sessionId?: string, vpId?: string, extra?: string[] }} ctx
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 (sessionId) {
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
- if (vpId) scopes.push(`session/${sessionId}/vp/${vpId}`);
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
- const userSeg = (result.picked || []).find((p) => p.scope === 'user');
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 {