@yeaft/webchat-agent 0.1.656 → 0.1.658

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.656",
3
+ "version": "0.1.658",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/unify/config.js CHANGED
@@ -219,6 +219,8 @@ function loadLegacyConfig(dir, overrides) {
219
219
  unify: normaliseUnifySection(null),
220
220
  // DESIGN-v2 feature flag. Default true (PR-E flipped). Override wins.
221
221
  memoryV2: overrides.memoryV2 !== undefined ? !!overrides.memoryV2 : true,
222
+ // GC.1: FTS pre-flow flag (legacy fallback config — defaults true).
223
+ memoryPreflow: overrides.memoryPreflow !== undefined ? !!overrides.memoryPreflow : true,
222
224
  providers: null,
223
225
  primaryModel: null,
224
226
  fastModel: null,
@@ -323,6 +325,14 @@ export function loadConfig(overrides = {}) {
323
325
  memoryV2: overrides.memoryV2 !== undefined ? !!overrides.memoryV2
324
326
  : (jsonConfig.memoryV2 !== undefined ? !!jsonConfig.memoryV2 : true),
325
327
 
328
+ // GC.1 feature flag — route pre-turn memory recall through
329
+ // memory/preflow.js (SQLite FTS5) instead of memory/recall-v2.js
330
+ // (per-scope file reads). When OFF the engine falls back to v2.
331
+ // Default ON. Users can opt out via `"memoryPreflow": false` in
332
+ // ~/.yeaft/config.json. Only applies when memoryV2 is also ON.
333
+ memoryPreflow: overrides.memoryPreflow !== undefined ? !!overrides.memoryPreflow
334
+ : (jsonConfig.memoryPreflow !== undefined ? !!jsonConfig.memoryPreflow : true),
335
+
326
336
  // Legacy fields (null when using config.json)
327
337
  apiKey: overrides.apiKey || null,
328
338
  openaiApiKey: null,
package/unify/engine.js CHANGED
@@ -22,6 +22,7 @@ import { buildSystemPrompt, buildWorkerPrompt } from './prompts.js';
22
22
  import { LLMContextError, LLMAbortError } from './llm/adapter.js';
23
23
  import { recallR6, formatForInjection } from './memory/recall-r6.js';
24
24
  import { recallV2 } from './memory/recall-v2.js';
25
+ import { runMemoryPreflow } from './groups/pre-flow.js';
25
26
  import { shouldConsolidate, consolidate, partitionMessages } from './memory/consolidate.js';
26
27
  import { extractMemories } from './memory/extract.js';
27
28
  import { runCompact as runCompactOrchestrator } from './compact/orchestrator.js';
@@ -152,6 +153,9 @@ export class Engine {
152
153
  /** @type {object|null} — R6 memory shard store (task-334f) */
153
154
  #memoryShardStore;
154
155
 
156
+ /** @type {import('./memory/index-db.js').SegmentIndex|null} — GC.1: SQLite FTS5 segment index */
157
+ #memoryIndex;
158
+
155
159
  /** @type {import('./tools/registry.js').ToolRegistry|null} */
156
160
  #toolRegistry;
157
161
 
@@ -223,13 +227,14 @@ export class Engine {
223
227
  * config: object,
224
228
  * conversationStore?: import('./conversation/persist.js').ConversationStore,
225
229
  * memoryStore?: import('./memory/store.js').MemoryStore,
230
+ * memoryIndex?: import('./memory/index-db.js').SegmentIndex,
226
231
  * toolRegistry?: import('./tools/registry.js').ToolRegistry,
227
232
  * skillManager?: import('./skills.js').SkillManager,
228
233
  * mcpManager?: import('./mcp.js').MCPManager,
229
234
  * yeaftDir?: string,
230
235
  * }} params
231
236
  */
232
- constructor({ adapter, trace, config, conversationStore, memoryStore, memoryShardStore, toolRegistry, skillManager, mcpManager, yeaftDir }) {
237
+ constructor({ adapter, trace, config, conversationStore, memoryStore, memoryShardStore, memoryIndex, toolRegistry, skillManager, mcpManager, yeaftDir }) {
233
238
  this.#adapter = adapter;
234
239
  this.#trace = trace;
235
240
  this.#config = config;
@@ -238,6 +243,7 @@ export class Engine {
238
243
  this.#conversationStore = conversationStore || null;
239
244
  this.#memoryStore = memoryStore || null;
240
245
  this.#memoryShardStore = memoryShardStore || null;
246
+ this.#memoryIndex = memoryIndex || null;
241
247
  this.#toolRegistry = toolRegistry || null;
242
248
  this.#skillManager = skillManager || null;
243
249
  this.#mcpManager = mcpManager || null;
@@ -505,6 +511,27 @@ export class Engine {
505
511
  async #recallMemory(prompt, ctx = {}) {
506
512
  const memory = { profile: '', entries: [], formatted: '' };
507
513
 
514
+ // ─── GC.1: FTS5 pre-flow path ──────────────────────────────
515
+ // When the SegmentIndex is wired and the feature flag is on,
516
+ // route recall through groups/pre-flow.js → memory/preflow.js
517
+ // (SQLite FTS5). On any failure fall through to v2.
518
+ if (this.#memoryIndex && this.#config && this.#config.memoryPreflow) {
519
+ try {
520
+ const result = runMemoryPreflow(this.#memoryIndex, {
521
+ userMsg: prompt,
522
+ groupId: ctx.groupId,
523
+ vpId: ctx.vpId,
524
+ featureId: ctx.featureId,
525
+ });
526
+ memory.profile = result.profile || '';
527
+ memory.entries = result.entries || [];
528
+ memory.formatted = result.formatted || '';
529
+ return memory;
530
+ } catch {
531
+ // Fall through to v2 / R6 paths.
532
+ }
533
+ }
534
+
508
535
  // ─── v2 path (DESIGN-v2) ───────────────────────────────────
509
536
  if (this.#config && this.#config.memoryV2 && this.#yeaftDir) {
510
537
  try {
@@ -2,55 +2,28 @@
2
2
  * coordinator.js — Group Coordinator (task-334b).
3
3
  *
4
4
  * Consumes user/VP messages, persists them to the group's 334o jsonl-log,
5
- * parses @-mentions (user-posted only), and dispatches to target RoleInstances'
6
- * `inputQueue`. Aligned with architecture §5 / §6:
5
+ * and dispatches user-text turns to target RoleInstances'
6
+ * `inputQueue`.
7
7
  *
8
- * - Text @-mentions trigger routing ONLY for `role === 'user'` messages.
9
- * VP-authored @ mentions are surface noise; VPs dispatch via the
10
- * route_forward tool (334d), not free text.
11
- * - `@all` fans out to every roster member except the sender (perGroupFanOut
12
- * cap honoured via options).
13
- * - Unknown @-targets return `{ error: 'not_in_roster' }` in the dispatch
14
- * report; coordinator does not mutate roster on stranger mentions.
15
- * - No @-mention on a user message → falls back to `defaultVpId` via
16
- * resolveFallbackVp (architecture G2).
17
- * - taskId: if the inbound message carries `taskId`, dispatch is scoped to
18
- * task.members — passed in via `options.taskMembers` (task storage lives
19
- * in 334n; coordinator only enforces the filter when the caller provides
20
- * the member list).
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.
21
14
  *
22
15
  * This module DOES NOT run the engine. It only:
23
16
  * 1. Persists the message (via GroupHandle.appendMessage)
24
- * 2. Resolves the list of target vpIds
17
+ * 2. Asks pre-flow for the list of target vpIds
25
18
  * 3. Calls a user-supplied deliver(vpId, envelope) per target
26
- *
27
- * That lets 334c (RoleInstance/Engine) own how a target is actually woken up
28
- * (inputQueue push, status transition, etc) without coordinator owning it.
29
19
  */
30
20
 
31
- import { isMember, resolveFallbackVp } from './roster.js';
21
+ import { parseMentions, selectRespondingVps } from './pre-flow.js';
32
22
 
33
- /** Matches `@vp-id` where id is [A-Za-z0-9_-]+. Captures the id. */
34
- const MENTION_RE = /(^|\s)@([A-Za-z0-9_][A-Za-z0-9_-]*)/g;
35
-
36
- /**
37
- * Extract an ordered, unique list of @-mentions from a text string.
38
- * Recognises the literal token `@all` as broadcast.
39
- */
40
- export function parseMentions(text) {
41
- if (!text || typeof text !== 'string') return [];
42
- const out = [];
43
- const seen = new Set();
44
- MENTION_RE.lastIndex = 0;
45
- let m;
46
- while ((m = MENTION_RE.exec(text))) {
47
- const id = m[2];
48
- if (seen.has(id)) continue;
49
- seen.add(id);
50
- out.push(id);
51
- }
52
- return out;
53
- }
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.
26
+ export { parseMentions };
54
27
 
55
28
  /**
56
29
  * Build a Group Coordinator bound to a single GroupHandle.
@@ -101,8 +74,18 @@ export function createCoordinator(group, options = {}) {
101
74
  role: input.role || (fromUser ? 'user' : 'assistant'),
102
75
  });
103
76
 
104
- // VP-authored messages: persist but do NOT dispatch (§6: no text @ routing)
105
- if (!fromUser) {
77
+ // Ask pre-flow which VPs (if any) should respond.
78
+ const selection = selectRespondingVps({
79
+ meta,
80
+ fromUser,
81
+ mentions,
82
+ sender: input.from,
83
+ fanOutCap,
84
+ taskMembers: opts.taskMembers,
85
+ });
86
+
87
+ // VP-authored: persist but no dispatch.
88
+ if (selection.reason === 'vp-author-no-text-routing') {
106
89
  return {
107
90
  message: stored,
108
91
  dispatched: [],
@@ -112,63 +95,48 @@ export function createCoordinator(group, options = {}) {
112
95
  };
113
96
  }
114
97
 
115
- // @all broadcast
116
- if (mentions.includes('all')) {
117
- const roster = meta.roster.filter((v) => v !== input.from).slice(0, fanOutCap);
118
- const scoped = opts.taskMembers
119
- ? roster.filter((v) => opts.taskMembers.includes(v))
120
- : roster;
98
+ if (selection.reason === 'broadcast') {
121
99
  const envelope = makeEnvelope(stored, meta, 'broadcast');
122
- for (const vpId of scoped) deliver(vpId, envelope);
100
+ for (const vpId of selection.dispatched) deliver(vpId, envelope);
123
101
  return {
124
102
  message: stored,
125
- dispatched: scoped,
103
+ dispatched: selection.dispatched,
126
104
  fallback: null,
127
- errors: [],
105
+ errors: selection.errors,
128
106
  broadcast: true,
129
- truncatedAtFanOutCap: meta.roster.length - 1 > fanOutCap,
107
+ truncatedAtFanOutCap: !!selection.truncatedAtFanOutCap,
130
108
  };
131
109
  }
132
110
 
133
- // Explicit @-mentions
134
- if (mentions.length > 0) {
135
- const dispatched = [];
136
- const errors = [];
137
- for (const vpId of mentions) {
138
- if (!isMember(meta, vpId)) {
139
- errors.push({ vpId, error: 'not_in_roster' });
140
- continue;
141
- }
142
- if (opts.taskMembers && !opts.taskMembers.includes(vpId)) {
143
- errors.push({ vpId, error: 'not_in_task_members' });
144
- continue;
145
- }
146
- dispatched.push(vpId);
111
+ if (selection.reason === 'mention') {
112
+ for (const vpId of selection.dispatched) {
147
113
  deliver(vpId, makeEnvelope(stored, meta, 'mention'));
148
114
  }
149
- return { message: stored, dispatched, fallback: null, errors };
150
- }
151
-
152
- // No @-mention → fallback
153
- const fallback = resolveFallbackVp(meta);
154
- if (!fallback) {
155
115
  return {
156
116
  message: stored,
157
- dispatched: [],
117
+ dispatched: selection.dispatched,
158
118
  fallback: null,
159
- errors: [{ error: 'no_default_vp' }],
119
+ errors: selection.errors,
160
120
  };
161
121
  }
162
- if (opts.taskMembers && !opts.taskMembers.includes(fallback)) {
122
+
123
+ if (selection.reason === 'fallback' && selection.fallback) {
124
+ deliver(selection.fallback, makeEnvelope(stored, meta, 'fallback'));
163
125
  return {
164
126
  message: stored,
165
- dispatched: [],
166
- fallback: null,
167
- errors: [{ vpId: fallback, error: 'not_in_task_members' }],
127
+ dispatched: selection.dispatched,
128
+ fallback: selection.fallback,
129
+ errors: selection.errors,
168
130
  };
169
131
  }
170
- deliver(fallback, makeEnvelope(stored, meta, 'fallback'));
171
- return { message: stored, dispatched: [fallback], fallback, errors: [] };
132
+
133
+ // no-default / nothing to dispatch
134
+ return {
135
+ message: stored,
136
+ dispatched: [],
137
+ fallback: null,
138
+ errors: selection.errors,
139
+ };
172
140
  }
173
141
 
174
142
  return {
@@ -204,3 +172,4 @@ function makeEnvelope(msg, meta, trigger) {
204
172
  * @property {boolean=} truncatedAtFanOutCap
205
173
  * @property {string=} skipped
206
174
  */
175
+
@@ -0,0 +1,308 @@
1
+ /**
2
+ * groups/pre-flow.js — explicit pre-flow stage for Unify.
3
+ *
4
+ * Pre-flow is the "before any VP runs" stage. It owns:
5
+ *
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.
23
+ */
24
+
25
+ import { runPreflow as runFtsPreflow } from '../memory/preflow.js';
26
+ import { isMember, resolveFallbackVp } from './roster.js';
27
+
28
+ /** Matches `@vp-id` where id is [A-Za-z0-9_-]+. Captures the id. */
29
+ const MENTION_RE = /(^|\s)@([A-Za-z0-9_][A-Za-z0-9_-]*)/g;
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
+ */
38
+ export function parseMentions(text) {
39
+ if (!text || typeof text !== 'string') return [];
40
+ const out = [];
41
+ const seen = new Set();
42
+ MENTION_RE.lastIndex = 0;
43
+ let m;
44
+ while ((m = MENTION_RE.exec(text))) {
45
+ const id = m[2];
46
+ if (seen.has(id)) continue;
47
+ seen.add(id);
48
+ out.push(id);
49
+ }
50
+ return out;
51
+ }
52
+
53
+ /**
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}
78
+ */
79
+ export function selectRespondingVps(input) {
80
+ const meta = input.meta;
81
+ if (!meta) {
82
+ return { dispatched: [], fallback: null, errors: [{ error: 'no_group_meta' }], reason: 'no-default' };
83
+ }
84
+ const fanOutCap = Number.isFinite(input.fanOutCap) ? input.fanOutCap : 16;
85
+ const taskMembers = Array.isArray(input.taskMembers) ? input.taskMembers : null;
86
+ const mentions = Array.isArray(input.mentions) ? input.mentions : [];
87
+
88
+ // VP-authored messages: never auto-route through @-mentions; VPs hand
89
+ // off through the explicit route_forward tool instead.
90
+ if (!input.fromUser) {
91
+ return {
92
+ dispatched: [],
93
+ fallback: null,
94
+ errors: [],
95
+ reason: 'vp-author-no-text-routing',
96
+ };
97
+ }
98
+
99
+ // @all broadcast — fan out to every roster member except the sender,
100
+ // honouring fanOutCap and taskMembers.
101
+ if (mentions.includes('all')) {
102
+ const roster = meta.roster.filter((v) => v !== input.sender).slice(0, fanOutCap);
103
+ const scoped = taskMembers ? roster.filter((v) => taskMembers.includes(v)) : roster;
104
+ return {
105
+ dispatched: scoped,
106
+ fallback: null,
107
+ errors: [],
108
+ reason: 'broadcast',
109
+ truncatedAtFanOutCap: meta.roster.length - 1 > fanOutCap,
110
+ };
111
+ }
112
+
113
+ // Explicit @-mentions
114
+ if (mentions.length > 0) {
115
+ const dispatched = [];
116
+ const errors = [];
117
+ for (const vpId of mentions) {
118
+ if (!isMember(meta, vpId)) {
119
+ errors.push({ vpId, error: 'not_in_roster' });
120
+ continue;
121
+ }
122
+ if (taskMembers && !taskMembers.includes(vpId)) {
123
+ errors.push({ vpId, error: 'not_in_task_members' });
124
+ continue;
125
+ }
126
+ dispatched.push(vpId);
127
+ }
128
+ return { dispatched, fallback: null, errors, reason: 'mention' };
129
+ }
130
+
131
+ // No @-mention → fallback to defaultVpId (architecture G2)
132
+ const fallback = resolveFallbackVp(meta);
133
+ if (!fallback) {
134
+ return {
135
+ dispatched: [],
136
+ fallback: null,
137
+ errors: [{ error: 'no_default_vp' }],
138
+ reason: 'no-default',
139
+ };
140
+ }
141
+ if (taskMembers && !taskMembers.includes(fallback)) {
142
+ return {
143
+ dispatched: [],
144
+ fallback: null,
145
+ errors: [{ vpId: fallback, error: 'not_in_task_members' }],
146
+ reason: 'no-default',
147
+ };
148
+ }
149
+ return {
150
+ dispatched: [fallback],
151
+ fallback,
152
+ errors: [],
153
+ reason: 'fallback',
154
+ };
155
+ }
156
+
157
+
158
+ /**
159
+ * Build the heading for a single scope's formatted memory block.
160
+ *
161
+ * Mirrors recall-v2's formatRecallV2 heading style so the system
162
+ * prompt looks the same to the LLM whether recall came from FTS
163
+ * (here) or from per-scope file reads (recall-v2).
164
+ *
165
+ * @param {string} scope
166
+ * @returns {string}
167
+ */
168
+ function scopeHeading(scope) {
169
+ if (scope === 'user') return '## Memory: User';
170
+ if (scope.startsWith('group/')) return `## Memory: Group ${scope.slice(6)}`;
171
+ if (scope.startsWith('vp/')) return `## Memory: VP ${scope.slice(3)}`;
172
+ if (scope.startsWith('feature/')) return `## Memory: Feature ${scope.slice(8)}`;
173
+ if (scope.startsWith('topic/')) return `## Memory: Topic ${scope.slice(6)}`;
174
+ return `## Memory: ${scope}`;
175
+ }
176
+
177
+ /**
178
+ * Format FTS picked segments into the prompt-ready string.
179
+ *
180
+ * Picked segments are grouped by scope (preserving the FTS rerank
181
+ * order within each scope group), then rendered as markdown blocks
182
+ * with one heading per scope.
183
+ *
184
+ * @param {Array<{scope: string, body: string, tags?: string[], kind?: string}>} picked
185
+ * @returns {string}
186
+ */
187
+ export function formatPickedForInjection(picked) {
188
+ if (!picked || picked.length === 0) return '';
189
+ const byScope = new Map();
190
+ // Preserve insertion order (which is rerank order within each scope).
191
+ for (const seg of picked) {
192
+ const scope = seg.scope || 'unknown';
193
+ if (!byScope.has(scope)) byScope.set(scope, []);
194
+ byScope.get(scope).push(seg);
195
+ }
196
+ const parts = [];
197
+ for (const [scope, segs] of byScope.entries()) {
198
+ parts.push(scopeHeading(scope));
199
+ for (const s of segs) {
200
+ const body = (s.body || '').trim();
201
+ if (body) parts.push(body);
202
+ }
203
+ parts.push(''); // blank line between scopes
204
+ }
205
+ return parts.join('\n').trim();
206
+ }
207
+
208
+ /**
209
+ * @typedef {object} MemoryPreflowOptions
210
+ * @property {string} userMsg The user's message
211
+ * @property {string} [groupId] Active group, if any
212
+ * @property {string} [vpId] Responding VP id, if any
213
+ * @property {string} [featureId] Active feature, if any
214
+ * @property {string[]} [extraScopes] Additional scopes to include
215
+ * @property {string[]} [currentTags] Contextual tags for rerank
216
+ * @property {number} [topK] Max FTS rows fetched (default 50)
217
+ * @property {number} [budgetTokens] Token budget for picked segments
218
+ */
219
+
220
+ /**
221
+ * @typedef {object} MemoryPreflowResult
222
+ * @property {string} profile User-scope summary (best-effort)
223
+ * @property {object[]} entries Picked segments (raw)
224
+ * @property {string} formatted Prompt-ready string
225
+ * @property {object} meta Raw FTS preflow metadata
226
+ */
227
+
228
+ /**
229
+ * Build the canonical scope list for a given (groupId, vpId, featureId).
230
+ * Always includes 'user'. The order is significant — preflow.js's scope
231
+ * filter accepts/rejects by membership, and the formatter renders in
232
+ * order.
233
+ *
234
+ * @param {{groupId?: string, vpId?: string, featureId?: string, extra?: string[]}} ctx
235
+ * @returns {string[]}
236
+ */
237
+ export function buildRelevantScopes({ groupId, vpId, featureId, extra } = {}) {
238
+ const scopes = ['user'];
239
+ if (groupId) scopes.push(`group/${groupId}`);
240
+ if (vpId) scopes.push(`vp/${vpId}`);
241
+ if (featureId) scopes.push(`feature/${featureId}`);
242
+ if (Array.isArray(extra)) {
243
+ for (const s of extra) {
244
+ if (s && !scopes.includes(s)) scopes.push(s);
245
+ }
246
+ }
247
+ return scopes;
248
+ }
249
+
250
+ /**
251
+ * Run memory pre-flow for one VP turn. Thin wrapper around
252
+ * `memory/preflow.js::runPreflow` that:
253
+ *
254
+ * - resolves canonical scope list from {groupId, vpId, featureId},
255
+ * - invokes FTS5 recall,
256
+ * - formats picked segments for prompt injection.
257
+ *
258
+ * Returns the engine-consumable {profile, entries, formatted, meta}
259
+ * shape so the existing recall pipeline can swap in without changes.
260
+ *
261
+ * @param {import('../memory/index-db.js').SegmentIndex} index
262
+ * @param {MemoryPreflowOptions} opts
263
+ * @returns {MemoryPreflowResult}
264
+ */
265
+ export function runMemoryPreflow(index, opts) {
266
+ if (!index) {
267
+ return { profile: '', entries: [], formatted: '', meta: { skipped: 'no-index' } };
268
+ }
269
+ const userMsg = (opts?.userMsg || '').trim();
270
+ if (!userMsg) {
271
+ return { profile: '', entries: [], formatted: '', meta: { skipped: 'no-user-msg' } };
272
+ }
273
+
274
+ const relevantScopes = buildRelevantScopes({
275
+ groupId: opts.groupId,
276
+ vpId: opts.vpId,
277
+ featureId: opts.featureId,
278
+ extra: opts.extraScopes,
279
+ });
280
+
281
+ const result = runFtsPreflow(index, {
282
+ userMsg,
283
+ relevantScopes,
284
+ ownVpId: opts.vpId || null,
285
+ currentTags: opts.currentTags || [],
286
+ topK: opts.topK,
287
+ budgetTokens: opts.budgetTokens,
288
+ });
289
+
290
+ // Best-effort profile: pick any user-scope segment body.
291
+ const userSeg = (result.picked || []).find(p => p.scope === 'user');
292
+ const profile = userSeg ? (userSeg.body || '').trim() : '';
293
+
294
+ const formatted = formatPickedForInjection(result.picked || []);
295
+
296
+ return {
297
+ profile,
298
+ entries: result.picked || [],
299
+ formatted,
300
+ meta: {
301
+ keywords: result.keywords,
302
+ ftsQuery: result.ftsQuery,
303
+ pickedTokens: result.pickedTokens,
304
+ droppedCount: result.droppedCount,
305
+ hitCount: (result.hits || []).length,
306
+ },
307
+ };
308
+ }
package/unify/session.js CHANGED
@@ -26,13 +26,22 @@ import { createFullRegistry } from './tools/index.js';
26
26
  import { initFeatureStore } from './tools/feature-tools.js';
27
27
  import { Engine } from './engine.js';
28
28
  // H2.f.5: threads/, pipeline/dispatcher and input-queue retired. The
29
- // session now exposes a single Engine. Memory recall runs through
30
- // pre-flow (memory/preflow.js) + post-turn adjustMemory (memory/adjust.js).
29
+ // session now exposes a single Engine.
30
+ //
31
+ // GC.1 Commit A: when config.memoryV2 && config.memoryPreflow, the
32
+ // session opens a SegmentIndex (SQLite FTS5 over memory.md) and
33
+ // passes it to the Engine. The Engine's #recallMemory then routes
34
+ // pre-turn recall through groups/pre-flow.js → memory/preflow.js
35
+ // instead of the per-scope file reader (memory/recall-v2.js).
36
+ // Post-turn adjustMemory (memory/adjust.js) wiring lands in a later
37
+ // commit.
31
38
  import { ensureDefaultGroupIfEmpty } from './groups/group-crud.js';
32
39
  import { seedDefaultVps } from './vp/seed-defaults.js';
33
40
  import { createDreamScheduler } from './memory/dream-scheduler.js';
34
41
  import { createV2DreamScheduler } from './dream-v2/session-wiring.js';
35
42
  import { getUserMemoryStore } from './memory/user-memory-store.js';
43
+ import { openSegmentIndex } from './memory/index-db.js';
44
+ import { syncAll as syncSegmentIndex } from './memory/segment-sync.js';
36
45
  import { migrateR6toV2 } from './memory/migrate-r6-to-v2.js';
37
46
  import { join } from 'path';
38
47
  import { existsSync as existsSyncSafe, readFileSync as readFileSyncSafe, writeFileSync as writeFileSyncSafe } from 'fs';
@@ -213,6 +222,35 @@ export async function loadSession(options = {}) {
213
222
  console.warn(`[Yeaft] Failed to open R6 memory shard store: ${err?.message || err}`);
214
223
  }
215
224
 
225
+ // ─── 5-fts. (GC.1) Open SegmentIndex for FTS pre-flow ────
226
+ // When config.memoryV2 && config.memoryPreflow, build a SQLite
227
+ // FTS5 index over ~/.yeaft/memory/<scope>/memory.md and pass it
228
+ // to the Engine. Engine.#recallMemory uses it via
229
+ // groups/pre-flow.js → memory/preflow.js. Disk is the source of
230
+ // truth; on boot we reconcile disk → index via syncAll.
231
+ // Failure to open the index is non-fatal: the Engine falls back
232
+ // to recall-v2 transparently.
233
+ let memoryIndex = null;
234
+ if (config.memoryV2 && config.memoryPreflow && !config._readOnly) {
235
+ try {
236
+ const indexPath = join(yeaftDir, 'memory', 'index.db');
237
+ memoryIndex = openSegmentIndex(indexPath);
238
+ const memoryRoot = join(yeaftDir, 'memory');
239
+ try {
240
+ syncSegmentIndex(memoryRoot, memoryIndex);
241
+ } catch (syncErr) {
242
+ // Sync is best-effort; an empty / partial index just produces
243
+ // empty recall results, never an error.
244
+ if (config.debug) {
245
+ console.warn(`[Yeaft] FTS index sync warning: ${syncErr?.message || syncErr}`);
246
+ }
247
+ }
248
+ } catch (err) {
249
+ console.warn(`[Yeaft] Failed to open FTS segment index (preflow disabled): ${err?.message || err}`);
250
+ memoryIndex = null;
251
+ }
252
+ }
253
+
216
254
  // ─── 5a. Initialize feature store ──────────────────────
217
255
  initFeatureStore(yeaftDir, { readOnly: config._readOnly || false });
218
256
 
@@ -277,6 +315,7 @@ export async function loadSession(options = {}) {
277
315
  conversationStore,
278
316
  memoryStore,
279
317
  memoryShardStore,
318
+ memoryIndex,
280
319
  toolRegistry,
281
320
  skillManager,
282
321
  mcpManager,
@@ -355,6 +394,11 @@ export async function loadSession(options = {}) {
355
394
  } catch {
356
395
  // Trace might not have close() (NullTrace)
357
396
  }
397
+ try {
398
+ if (memoryIndex) memoryIndex.close();
399
+ } catch {
400
+ // Best-effort cleanup
401
+ }
358
402
  }
359
403
 
360
404
  return {