@shadowforge0/aquifer-memory 1.6.0 → 1.8.0

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 (44) hide show
  1. package/.env.example +8 -0
  2. package/README.md +72 -0
  3. package/README_CN.md +17 -0
  4. package/README_TW.md +4 -0
  5. package/aquifer.config.example.json +19 -0
  6. package/consumers/cli.js +259 -12
  7. package/consumers/codex-active-checkpoint.js +186 -0
  8. package/consumers/codex-current-memory.js +106 -0
  9. package/consumers/codex-handoff.js +551 -6
  10. package/consumers/codex.js +209 -25
  11. package/consumers/mcp.js +144 -6
  12. package/consumers/shared/config.js +60 -1
  13. package/consumers/shared/factory.js +10 -3
  14. package/core/aquifer.js +357 -838
  15. package/core/backends/capabilities.js +89 -0
  16. package/core/backends/local.js +430 -0
  17. package/core/legacy-bootstrap.js +140 -0
  18. package/core/mcp-manifest.js +66 -2
  19. package/core/memory-bootstrap.js +20 -8
  20. package/core/memory-consolidation.js +365 -11
  21. package/core/memory-promotion.js +157 -26
  22. package/core/memory-recall.js +341 -22
  23. package/core/memory-records.js +347 -11
  24. package/core/memory-serving.js +132 -0
  25. package/core/postgres-migrations.js +533 -0
  26. package/core/public-session-filter.js +40 -0
  27. package/core/recall-runtime.js +115 -0
  28. package/core/scope-attribution.js +279 -0
  29. package/core/session-checkpoint-producer.js +412 -0
  30. package/core/session-checkpoints.js +432 -0
  31. package/core/session-finalization.js +98 -2
  32. package/core/storage-checkpoints.js +546 -0
  33. package/core/storage.js +121 -8
  34. package/docs/getting-started.md +6 -0
  35. package/docs/setup.md +66 -3
  36. package/package.json +8 -4
  37. package/schema/014-v1-checkpoint-runs.sql +349 -0
  38. package/schema/015-v1-evidence-items.sql +92 -0
  39. package/schema/016-v1-evidence-ref-multi-item.sql +19 -0
  40. package/schema/017-v1-memory-record-embeddings.sql +25 -0
  41. package/schema/018-v1-finalization-candidate-envelope.sql +39 -0
  42. package/scripts/codex-checkpoint-commands.js +464 -0
  43. package/scripts/codex-checkpoint-runtime.js +520 -0
  44. package/scripts/codex-recovery.js +246 -1
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+
3
+ const BACKEND_KINDS = new Set(['postgres', 'local']);
4
+
5
+ const CAPABILITY_PROFILES = {
6
+ postgres: {
7
+ kind: 'postgres',
8
+ profile: 'full',
9
+ label: 'PostgreSQL full backend',
10
+ summary: 'Full Aquifer backend with PostgreSQL, pgvector, full-text search, migrations, and operator workflows.',
11
+ capabilities: {
12
+ zeroConfig: 'unsupported',
13
+ persistence: 'full',
14
+ evidenceWrite: 'full',
15
+ evidenceRecallLexical: 'full',
16
+ evidenceRecallVectorSummary: 'full',
17
+ evidenceRecallVectorTurn: 'full',
18
+ curatedBootstrap: 'full',
19
+ curatedRecall: 'full',
20
+ finalizationLedger: 'full',
21
+ operatorCompaction: 'full',
22
+ operatorCheckpoint: 'full',
23
+ multiProcessClaims: 'full',
24
+ migrationHandshake: 'full',
25
+ exportSnapshot: 'full',
26
+ },
27
+ upgradeHint: null,
28
+ },
29
+ local: {
30
+ kind: 'local',
31
+ profile: 'starter',
32
+ label: 'Local starter backend',
33
+ summary: 'Zero-config starter backend lane. This profile is explicit and degraded; PostgreSQL remains the full backend.',
34
+ capabilities: {
35
+ zeroConfig: 'full',
36
+ persistence: 'full',
37
+ evidenceWrite: 'full',
38
+ evidenceRecallLexical: 'degraded',
39
+ evidenceRecallVectorSummary: 'unsupported',
40
+ evidenceRecallVectorTurn: 'unsupported',
41
+ sessionBootstrap: 'degraded',
42
+ curatedBootstrap: 'unsupported',
43
+ curatedRecall: 'unsupported',
44
+ finalizationLedger: 'unsupported',
45
+ operatorCompaction: 'unsupported',
46
+ operatorCheckpoint: 'unsupported',
47
+ multiProcessClaims: 'unsupported',
48
+ migrationHandshake: 'unsupported',
49
+ exportSnapshot: 'full',
50
+ },
51
+ upgradeHint: 'Use the PostgreSQL quickstart for full semantic recall, migrations, and operator workflows.',
52
+ },
53
+ };
54
+
55
+ function normalizeBackendKind(value) {
56
+ const kind = String(value || 'postgres').trim().toLowerCase();
57
+ if (!BACKEND_KINDS.has(kind)) {
58
+ throw new Error(`Invalid Aquifer backend: "${value}". Must be one of: ${[...BACKEND_KINDS].join(', ')}`);
59
+ }
60
+ return kind;
61
+ }
62
+
63
+ function backendCapabilities(kind) {
64
+ return JSON.parse(JSON.stringify(CAPABILITY_PROFILES[normalizeBackendKind(kind)]));
65
+ }
66
+
67
+ function unsupportedCapabilityError(kind, capability, operation) {
68
+ const profile = backendCapabilities(kind);
69
+ const status = profile.capabilities[capability] || 'unsupported';
70
+ const err = new Error(
71
+ `${operation || capability} is not available on Aquifer backend "${profile.kind}" `
72
+ + `(capability ${capability}: ${status}). ${profile.upgradeHint || ''}`.trim()
73
+ );
74
+ err.code = 'AQ_BACKEND_CAPABILITY_UNSUPPORTED';
75
+ err.backendKind = profile.kind;
76
+ err.backendProfile = profile.profile;
77
+ err.capability = capability;
78
+ err.capabilityStatus = status;
79
+ err.upgradeHint = profile.upgradeHint;
80
+ return err;
81
+ }
82
+
83
+ module.exports = {
84
+ BACKEND_KINDS,
85
+ CAPABILITY_PROFILES,
86
+ normalizeBackendKind,
87
+ backendCapabilities,
88
+ unsupportedCapabilityError,
89
+ };
@@ -0,0 +1,430 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs/promises');
4
+ const path = require('path');
5
+ const { backendCapabilities, unsupportedCapabilityError } = require('./capabilities');
6
+
7
+ function emptyStore() {
8
+ const now = new Date().toISOString();
9
+ return {
10
+ version: 1,
11
+ createdAt: now,
12
+ updatedAt: now,
13
+ nextId: 1,
14
+ sessions: [],
15
+ };
16
+ }
17
+
18
+ async function readStore(filePath) {
19
+ try {
20
+ const raw = await fs.readFile(filePath, 'utf8');
21
+ const parsed = JSON.parse(raw);
22
+ return {
23
+ ...emptyStore(),
24
+ ...parsed,
25
+ sessions: Array.isArray(parsed.sessions) ? parsed.sessions : [],
26
+ nextId: Number.isInteger(parsed.nextId) && parsed.nextId > 0 ? parsed.nextId : 1,
27
+ };
28
+ } catch (err) {
29
+ if (err.code === 'ENOENT') return emptyStore();
30
+ throw err;
31
+ }
32
+ }
33
+
34
+ async function writeStore(filePath, store) {
35
+ const dir = path.dirname(filePath);
36
+ await fs.mkdir(dir, { recursive: true });
37
+ const next = {
38
+ ...store,
39
+ version: 1,
40
+ updatedAt: new Date().toISOString(),
41
+ };
42
+ const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
43
+ await fs.writeFile(tmp, `${JSON.stringify(next, null, 2)}\n`);
44
+ await fs.rename(tmp, filePath);
45
+ return next;
46
+ }
47
+
48
+ function normalizeMessagesPayload(messages, opts = {}) {
49
+ return opts.rawMessages || { normalized: messages };
50
+ }
51
+
52
+ function normalizedMessages(payload) {
53
+ if (!payload) return [];
54
+ if (Array.isArray(payload)) return payload;
55
+ if (Array.isArray(payload.normalized)) return payload.normalized;
56
+ if (Array.isArray(payload.messages)) return payload.messages;
57
+ return [];
58
+ }
59
+
60
+ function messageText(message) {
61
+ const content = message?.content ?? message?.text ?? '';
62
+ if (Array.isArray(content)) {
63
+ return content
64
+ .map(part => {
65
+ if (typeof part === 'string') return part;
66
+ if (part && typeof part.text === 'string') return part.text;
67
+ return '';
68
+ })
69
+ .filter(Boolean)
70
+ .join(' ');
71
+ }
72
+ return typeof content === 'string' ? content : JSON.stringify(content);
73
+ }
74
+
75
+ function sessionText(session) {
76
+ return normalizedMessages(session.messages)
77
+ .map(messageText)
78
+ .filter(Boolean)
79
+ .join('\n');
80
+ }
81
+
82
+ function firstMatchingTurn(session, queryTerms) {
83
+ const messages = normalizedMessages(session.messages);
84
+ for (const message of messages) {
85
+ const text = messageText(message);
86
+ const lower = text.toLowerCase();
87
+ if (queryTerms.some(term => lower.includes(term))) return text;
88
+ }
89
+ return messages.length > 0 ? messageText(messages[0]) : '';
90
+ }
91
+
92
+ function tokenizeQuery(query) {
93
+ return String(query || '')
94
+ .toLowerCase()
95
+ .split(/[^\p{L}\p{N}_]+/u)
96
+ .map(s => s.trim())
97
+ .filter(Boolean);
98
+ }
99
+
100
+ function lexicalScore(text, query) {
101
+ const lower = text.toLowerCase();
102
+ const phrase = String(query || '').trim().toLowerCase();
103
+ const terms = tokenizeQuery(query);
104
+ if (!phrase && terms.length === 0) return 0;
105
+ let score = phrase && lower.includes(phrase) ? 2 : 0;
106
+ for (const term of terms) {
107
+ let pos = 0;
108
+ while (term && pos < lower.length) {
109
+ const found = lower.indexOf(term, pos);
110
+ if (found === -1) break;
111
+ score += 1;
112
+ pos = found + term.length;
113
+ }
114
+ }
115
+ return score;
116
+ }
117
+
118
+ function inDateRange(session, opts = {}) {
119
+ const started = session.startedAt ? new Date(session.startedAt).getTime() : null;
120
+ if (opts.dateFrom) {
121
+ const from = new Date(`${opts.dateFrom}T00:00:00.000Z`).getTime();
122
+ if (Number.isFinite(from) && Number.isFinite(started) && started < from) return false;
123
+ }
124
+ if (opts.dateTo) {
125
+ const to = new Date(`${opts.dateTo}T23:59:59.999Z`).getTime();
126
+ if (Number.isFinite(to) && Number.isFinite(started) && started > to) return false;
127
+ }
128
+ return true;
129
+ }
130
+
131
+ function matchesSessionFilters(session, opts = {}) {
132
+ if (opts.agentId && session.agentId !== opts.agentId) return false;
133
+ if (Array.isArray(opts.agentIds) && opts.agentIds.length > 0 && !opts.agentIds.includes(session.agentId)) return false;
134
+ if (opts.source && session.source !== opts.source) return false;
135
+ return inDateRange(session, opts);
136
+ }
137
+
138
+ function sessionResult(session, score, queryTerms) {
139
+ const matchedTurnText = firstMatchingTurn(session, queryTerms);
140
+ const title = matchedTurnText || session.sessionId;
141
+ return {
142
+ id: session.id,
143
+ sessionId: session.sessionId,
144
+ agentId: session.agentId,
145
+ source: session.source,
146
+ startedAt: session.startedAt,
147
+ summaryText: title,
148
+ structuredSummary: {
149
+ title: `Local session ${session.sessionId}`,
150
+ overview: title,
151
+ },
152
+ matchedTurnText,
153
+ score,
154
+ backendKind: 'local',
155
+ degraded: true,
156
+ };
157
+ }
158
+
159
+ function bootstrapText(sessions, maxChars) {
160
+ const lines = sessions.flatMap(session => {
161
+ const text = sessionText(session).replace(/\s+/g, ' ').trim();
162
+ return [
163
+ `### ${session.sessionId} (${session.startedAt || 'unknown'}, ${session.agentId || 'agent'})`,
164
+ text || '(no text)',
165
+ '',
166
+ ];
167
+ });
168
+ let text = lines.join('\n').trim();
169
+ if (text.length > maxChars) text = `${text.slice(0, Math.max(0, maxChars - 12)).trimEnd()}\n[truncated]`;
170
+ return text;
171
+ }
172
+
173
+ function createLocalAquifer(config = {}) {
174
+ const capabilities = backendCapabilities('local');
175
+ const storage = config.storage || {};
176
+ const local = storage.local || {};
177
+ const backendPath = path.resolve(process.cwd(), local.path || '.aquifer/aquifer.local.json');
178
+ const tenantId = config.tenantId || 'default';
179
+ const memoryServingMode = config.memory?.servingMode || 'legacy';
180
+
181
+ function unsupported(operation, capability) {
182
+ throw unsupportedCapabilityError('local', capability, operation);
183
+ }
184
+
185
+ return {
186
+ async init() {
187
+ const store = await readStore(backendPath);
188
+ await writeStore(backendPath, store);
189
+ return { ready: true, status: 'ok', backend: capabilities, path: backendPath };
190
+ },
191
+ async migrate() {
192
+ return { status: 'skipped', backendKind: 'local', reason: 'local backend has no SQL migrations' };
193
+ },
194
+ async commit(sessionId, messages, opts = {}) {
195
+ if (!sessionId) throw new Error('sessionId is required');
196
+ if (!messages || !Array.isArray(messages)) throw new Error('messages must be an array');
197
+ const agentId = opts.agentId || 'agent';
198
+ const source = opts.source || 'api';
199
+ const now = new Date().toISOString();
200
+ const payload = normalizeMessagesPayload(messages, opts);
201
+ const msgCount = messages.length;
202
+ const userCount = messages.filter(m => m.role === 'user').length;
203
+ const assistantCount = messages.filter(m => m.role === 'assistant').length;
204
+ const store = await readStore(backendPath);
205
+ const idx = store.sessions.findIndex(s => (
206
+ s.tenantId === tenantId && s.agentId === agentId && s.sessionId === sessionId
207
+ ));
208
+ const isNew = idx === -1;
209
+ const previous = isNew ? null : store.sessions[idx];
210
+ const session = {
211
+ id: previous?.id || store.nextId++,
212
+ tenantId,
213
+ sessionId,
214
+ sessionKey: opts.sessionKey || previous?.sessionKey || null,
215
+ agentId,
216
+ source,
217
+ messages: payload,
218
+ msgCount,
219
+ userCount,
220
+ assistantCount,
221
+ model: opts.model || null,
222
+ tokensIn: opts.tokensIn || 0,
223
+ tokensOut: opts.tokensOut || 0,
224
+ startedAt: opts.startedAt || previous?.startedAt || now,
225
+ endedAt: opts.lastMessageAt || now,
226
+ lastMessageAt: opts.lastMessageAt || now,
227
+ processingStatus: 'ready',
228
+ };
229
+ if (isNew) store.sessions.push(session);
230
+ else store.sessions[idx] = session;
231
+ await writeStore(backendPath, store);
232
+ return { id: session.id, sessionId, isNew };
233
+ },
234
+ async enrich(sessionId, opts = {}) {
235
+ const agentId = opts.agentId || 'agent';
236
+ const store = await readStore(backendPath);
237
+ const session = store.sessions.find(s => (
238
+ s.tenantId === tenantId && s.agentId === agentId && s.sessionId === sessionId
239
+ ));
240
+ if (!session) throw new Error(`Session not found: ${sessionId} (agentId=${agentId})`);
241
+ session.processingStatus = 'ready';
242
+ await writeStore(backendPath, store);
243
+ return {
244
+ sessionId,
245
+ turnsEmbedded: 0,
246
+ entitiesFound: 0,
247
+ warnings: ['local backend uses lexical recall only; embeddings are not created'],
248
+ backendKind: 'local',
249
+ degraded: true,
250
+ };
251
+ },
252
+ async recall(query, opts = {}) {
253
+ if (opts.mode === 'vector') unsupported('recall', 'evidenceRecallVectorTurn');
254
+ if (opts.entities) unsupported('recall entities filter', 'curatedRecall');
255
+ const store = await readStore(backendPath);
256
+ const terms = tokenizeQuery(query);
257
+ const limit = Math.max(1, Math.min(20, opts.limit || 5));
258
+ return store.sessions
259
+ .filter(s => s.tenantId === tenantId)
260
+ .filter(s => matchesSessionFilters(s, opts))
261
+ .map(s => ({ session: s, score: lexicalScore(sessionText(s), query) }))
262
+ .filter(item => item.score > 0)
263
+ .sort((a, b) => b.score - a.score || String(b.session.startedAt).localeCompare(String(a.session.startedAt)))
264
+ .slice(0, limit)
265
+ .map(item => sessionResult(item.session, item.score, terms));
266
+ },
267
+ async memoryRecall() {
268
+ unsupported('memoryRecall', 'curatedRecall');
269
+ },
270
+ async historicalRecall(query, opts = {}) {
271
+ return this.recall(query, opts);
272
+ },
273
+ async evidenceRecall(query, opts = {}) {
274
+ return this.recall(query, opts);
275
+ },
276
+ async bootstrap(opts = {}) {
277
+ if (opts.memoryMode === 'curated' || opts.servingMode === 'curated' || memoryServingMode === 'curated') {
278
+ unsupported('bootstrap curated memory', 'curatedBootstrap');
279
+ }
280
+ const limit = Math.max(1, Math.min(20, opts.limit || 5));
281
+ const maxChars = Math.max(200, opts.maxChars || 4000);
282
+ const store = await readStore(backendPath);
283
+ const sessions = store.sessions
284
+ .filter(s => s.tenantId === tenantId)
285
+ .filter(s => matchesSessionFilters(s, opts))
286
+ .sort((a, b) => String(b.startedAt).localeCompare(String(a.startedAt)))
287
+ .slice(0, limit);
288
+ const result = {
289
+ sessions: sessions.map(s => sessionResult(s, 1, [])),
290
+ meta: {
291
+ backendKind: 'local',
292
+ degraded: true,
293
+ maxChars,
294
+ },
295
+ };
296
+ if (opts.format !== 'structured') result.text = bootstrapText(sessions, maxChars);
297
+ return result;
298
+ },
299
+ async memoryBootstrap() {
300
+ unsupported('memoryBootstrap', 'curatedBootstrap');
301
+ },
302
+ async historicalBootstrap(opts = {}) {
303
+ return this.bootstrap(opts);
304
+ },
305
+ async getStats() {
306
+ const store = await readStore(backendPath);
307
+ const sessions = store.sessions.filter(s => s.tenantId === tenantId);
308
+ const counts = {};
309
+ for (const session of sessions) {
310
+ const status = session.processingStatus || 'ready';
311
+ counts[status] = (counts[status] || 0) + 1;
312
+ }
313
+ const dates = sessions.map(s => s.startedAt).filter(Boolean).sort();
314
+ return {
315
+ backendKind: 'local',
316
+ backendProfile: capabilities.profile,
317
+ serving: {
318
+ mode: memoryServingMode,
319
+ activeScopeKey: null,
320
+ activeScopePath: null,
321
+ },
322
+ sessions: counts,
323
+ sessionTotal: sessions.length,
324
+ summaries: 0,
325
+ turnEmbeddings: 0,
326
+ entities: 0,
327
+ memoryRecords: {
328
+ available: false,
329
+ total: 0,
330
+ active: 0,
331
+ visibleInBootstrap: 0,
332
+ visibleInRecall: 0,
333
+ earliest: null,
334
+ latest: null,
335
+ },
336
+ sessionFinalizations: {
337
+ available: false,
338
+ total: 0,
339
+ statuses: {},
340
+ latestFinalizedAt: null,
341
+ latestUpdatedAt: null,
342
+ },
343
+ earliest: dates[0] || null,
344
+ latest: dates[dates.length - 1] || null,
345
+ degraded: true,
346
+ capabilities: capabilities.capabilities,
347
+ };
348
+ },
349
+ async getPendingSessions() {
350
+ return [];
351
+ },
352
+ async exportSessions(opts = {}) {
353
+ const limit = Math.max(1, opts.limit || 1000);
354
+ const store = await readStore(backendPath);
355
+ return store.sessions
356
+ .filter(s => s.tenantId === tenantId)
357
+ .filter(s => matchesSessionFilters(s, opts))
358
+ .sort((a, b) => String(b.startedAt).localeCompare(String(a.startedAt)))
359
+ .slice(0, limit)
360
+ .map(s => ({
361
+ session_id: s.sessionId,
362
+ agent_id: s.agentId,
363
+ source: s.source,
364
+ started_at: s.startedAt,
365
+ msg_count: s.msgCount,
366
+ processing_status: s.processingStatus || 'ready',
367
+ summary_text: null,
368
+ structured_summary: null,
369
+ messages: s.messages,
370
+ backendKind: 'local',
371
+ }));
372
+ },
373
+ async deleteSession(sessionId, opts = {}) {
374
+ const agentId = opts.agentId || 'agent';
375
+ const store = await readStore(backendPath);
376
+ const before = store.sessions.length;
377
+ store.sessions = store.sessions.filter(s => !(
378
+ s.tenantId === tenantId && s.agentId === agentId && s.sessionId === sessionId
379
+ ));
380
+ await writeStore(backendPath, store);
381
+ return { sessionId, deleted: before - store.sessions.length };
382
+ },
383
+ async feedback() {
384
+ unsupported('feedback', 'finalizationLedger');
385
+ },
386
+ async memoryFeedback() {
387
+ unsupported('memoryFeedback', 'curatedRecall');
388
+ },
389
+ async feedbackStats() {
390
+ return {
391
+ totalFeedback: 0,
392
+ helpfulCount: 0,
393
+ unhelpfulCount: 0,
394
+ feedbackSessions: 0,
395
+ totalSessions: (await this.getStats()).sessionTotal,
396
+ trustScoreAvg: null,
397
+ trustScoreMin: null,
398
+ trustScoreMax: null,
399
+ };
400
+ },
401
+ getConfig() {
402
+ return {
403
+ schema: null,
404
+ tenantId,
405
+ memoryServingMode,
406
+ memoryActiveScopeKey: null,
407
+ memoryActiveScopePath: null,
408
+ backendKind: 'local',
409
+ backendProfile: capabilities.profile,
410
+ backendPath,
411
+ capabilities: capabilities.capabilities,
412
+ };
413
+ },
414
+ getCapabilities() {
415
+ return backendCapabilities('local');
416
+ },
417
+ getPool() {
418
+ return null;
419
+ },
420
+ getLlmFn() {
421
+ return null;
422
+ },
423
+ getEmbedFn() {
424
+ return null;
425
+ },
426
+ async close() {},
427
+ };
428
+ }
429
+
430
+ module.exports = { createLocalAquifer };
@@ -0,0 +1,140 @@
1
+ 'use strict';
2
+
3
+ const { qi } = require('./postgres-migrations');
4
+
5
+ function normalizeSessionRow(row = {}) {
6
+ const ss = row.structured_summary || {};
7
+ const hasStructuredSummary = ss.title || ss.overview;
8
+ const summaryText = row.summary_text || '';
9
+ return {
10
+ sessionId: row.session_id,
11
+ agentId: row.agent_id,
12
+ source: row.source,
13
+ startedAt: row.started_at,
14
+ title: ss.title || (hasStructuredSummary ? null : summaryText.slice(0, 60).trim() || null),
15
+ overview: ss.overview || (hasStructuredSummary ? null : summaryText.slice(0, 200).trim() || null),
16
+ topics: Array.isArray(ss.topics) ? ss.topics : [],
17
+ decisions: Array.isArray(ss.decisions) ? ss.decisions : [],
18
+ openLoops: Array.isArray(ss.open_loops) ? ss.open_loops : [],
19
+ importantFacts: Array.isArray(ss.important_facts) ? ss.important_facts : [],
20
+ };
21
+ }
22
+
23
+ function collectOpenLoops(sessions = []) {
24
+ const sentinels = new Set(['無', 'none', 'n/a', 'na', 'done', '']);
25
+ const seen = new Set();
26
+ const openLoops = [];
27
+
28
+ for (const session of sessions) {
29
+ for (const loop of session.openLoops) {
30
+ const raw = typeof loop === 'string' ? loop : (loop.item || '');
31
+ const normalized = raw.trim().replace(/\s+/g, ' ').toLowerCase();
32
+ if (sentinels.has(normalized) || !normalized || seen.has(normalized)) continue;
33
+ seen.add(normalized);
34
+ openLoops.push({
35
+ item: raw.trim(),
36
+ fromSession: session.sessionId,
37
+ latestStartedAt: session.startedAt,
38
+ });
39
+ }
40
+ }
41
+
42
+ return openLoops;
43
+ }
44
+
45
+ function collectRecentDecisions(sessions = []) {
46
+ const seen = new Set();
47
+ const recentDecisions = [];
48
+
49
+ for (const session of sessions) {
50
+ for (const decision of session.decisions) {
51
+ const raw = typeof decision === 'string' ? decision : (decision.decision || '');
52
+ const normalized = raw.trim().replace(/\s+/g, ' ').toLowerCase();
53
+ if (!normalized || seen.has(normalized)) continue;
54
+ seen.add(normalized);
55
+ recentDecisions.push({
56
+ decision: raw.trim(),
57
+ reason: decision.reason || null,
58
+ fromSession: session.sessionId,
59
+ });
60
+ }
61
+ }
62
+
63
+ return recentDecisions;
64
+ }
65
+
66
+ function createLegacyBootstrap({ pool, schema, tenantId, formatBootstrapText }) {
67
+ const qSchema = qi(schema);
68
+ const visibleSummary = `NOT (
69
+ COALESCE(ss.summary_text, '') ~* '(空測試會話|x 字元填充|placeholder)'
70
+ OR COALESCE(ss.structured_summary::text, '') ~* '(空測試會話|x 字元填充|placeholder)'
71
+ )`;
72
+
73
+ return async function legacyBootstrap(opts = {}) {
74
+ const agentId = opts.agentId || null;
75
+ const source = opts.source || null;
76
+ const limit = Math.max(1, Math.min(20, opts.limit || 5));
77
+ const lookbackDays = opts.lookbackDays || 14;
78
+ const maxChars = opts.maxChars || 4000;
79
+ const format = opts.format || 'structured';
80
+
81
+ // 'partial' sessions have a summary plus enrich warnings; they are
82
+ // user-visible content, unlike pending/processing sessions.
83
+ const where = [
84
+ `s.tenant_id = $1`,
85
+ `s.processing_status IN ('succeeded', 'partial')`,
86
+ visibleSummary,
87
+ ];
88
+ const params = [tenantId];
89
+
90
+ if (agentId) {
91
+ params.push(agentId);
92
+ where.push(`s.agent_id = $${params.length}`);
93
+ }
94
+ if (source) {
95
+ params.push(source);
96
+ where.push(`s.source = $${params.length}`);
97
+ }
98
+
99
+ params.push(lookbackDays);
100
+ // upsertSession sets ended_at on every commit; started_at/last_message_at
101
+ // can be absent when callers did not supply explicit timestamps.
102
+ where.push(`COALESCE(s.last_message_at, s.ended_at, s.started_at) > now() - ($${params.length} || ' days')::interval`);
103
+
104
+ params.push(limit);
105
+
106
+ const result = await pool.query(
107
+ `SELECT s.session_id, s.agent_id, s.source, s.started_at, s.msg_count,
108
+ ss.summary_text, ss.structured_summary
109
+ FROM ${qSchema}.sessions s
110
+ JOIN ${qSchema}.session_summaries ss ON ss.session_row_id = s.id
111
+ WHERE ${where.join(' AND ')}
112
+ ORDER BY COALESCE(s.last_message_at, s.ended_at, s.started_at) DESC
113
+ LIMIT $${params.length}`,
114
+ params
115
+ );
116
+
117
+ const sessions = result.rows.map(normalizeSessionRow);
118
+ const structured = {
119
+ sessions,
120
+ openLoops: collectOpenLoops(sessions),
121
+ recentDecisions: collectRecentDecisions(sessions),
122
+ meta: { lookbackDays, count: sessions.length, maxChars, truncated: false },
123
+ };
124
+
125
+ if (format === 'text' || format === 'both') {
126
+ const textResult = formatBootstrapText(structured, maxChars);
127
+ structured.text = textResult.text;
128
+ structured.meta.truncated = textResult.truncated;
129
+ }
130
+
131
+ return structured;
132
+ };
133
+ }
134
+
135
+ module.exports = {
136
+ collectOpenLoops,
137
+ collectRecentDecisions,
138
+ createLegacyBootstrap,
139
+ normalizeSessionRow,
140
+ };