@yemi33/minions 0.1.2368 → 0.1.2370

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.
@@ -5389,6 +5389,81 @@ function applyGoalInvalidation(sourceWiId, invalidates, config) {
5389
5389
  }
5390
5390
  }
5391
5391
 
5392
+ function captureTaskMemoryEpisode(dispatchItem, agentId, outcome, structuredCompletion, resultSummary, config) {
5393
+ if ((config?.engine?.memoryEpisodicCapture ?? ENGINE_DEFAULTS.memoryEpisodicCapture) !== true) return null;
5394
+ if (!dispatchItem || !dispatchItem.id) return null;
5395
+ if (structuredCompletion?.failure_class === FAILURE_CLASS.INJECTION_FLAGGED) return null;
5396
+
5397
+ const meta = dispatchItem.meta || {};
5398
+ const item = meta.item || {};
5399
+ const project = meta.project?.name
5400
+ || (item._source && item._source !== 'central' ? item._source : '')
5401
+ || dispatchItem.project
5402
+ || '';
5403
+ const files = structuredCompletion?.files_changed
5404
+ || structuredCompletion?.affected_files
5405
+ || [];
5406
+ const redact = typeof shared.redactSecrets === 'function' ? shared.redactSecrets : String;
5407
+ const fileList = (Array.isArray(files) ? files : String(files || '').split(','))
5408
+ .map(value => redact(String(value).trim()).slice(0, 300))
5409
+ .filter(Boolean)
5410
+ .slice(0, 20);
5411
+ const rawTests = structuredCompletion?.tests || 'N/A';
5412
+ const tests = Array.isArray(rawTests)
5413
+ ? rawTests.slice(0, 20).map(value => redact(String(value)).slice(0, 500))
5414
+ : redact(String(rawTests)).slice(0, 2000);
5415
+ const pr = redact(String(structuredCompletion?.pr || item._prUrl || item._pr || 'N/A')).slice(0, 1000);
5416
+ const failureClass = redact(String(structuredCompletion?.failure_class || 'N/A')).slice(0, 200);
5417
+ const safeSummary = redact(String(resultSummary || structuredCompletion?.summary || 'No summary reported')).slice(0, 4000);
5418
+ const title = redact(String(item.title || item.name || dispatchItem.title || item.id || dispatchItem.id)).slice(0, 500);
5419
+ const body = [
5420
+ `Outcome: ${outcome}`,
5421
+ `Work item: ${item.id || 'N/A'}`,
5422
+ `Work type: ${dispatchItem.type || item.type || 'N/A'}`,
5423
+ `Agent: ${agentId || dispatchItem.agent || 'unknown'}`,
5424
+ `Project: ${project || 'N/A'}`,
5425
+ `PR: ${pr}`,
5426
+ `Tests: ${Array.isArray(tests) ? tests.join(', ') : tests}`,
5427
+ `Failure class: ${failureClass}`,
5428
+ fileList.length ? `Files: ${fileList.join(', ')}` : 'Files: N/A',
5429
+ '',
5430
+ 'Summary:',
5431
+ safeSummary,
5432
+ ].join('\n');
5433
+
5434
+ try {
5435
+ return require('./memory-store').upsertMemoryRecord({
5436
+ memoryType: 'episodic',
5437
+ scopeType: project ? 'project' : (agentId ? 'agent' : 'global'),
5438
+ scopeKey: project || agentId || '',
5439
+ title: `${outcome}: ${title}`,
5440
+ body,
5441
+ tags: ['task-episode', dispatchItem.type || item.type || 'unknown', outcome, ...fileList.slice(0, 12)],
5442
+ sourceType: 'dispatch',
5443
+ sourceRef: String(dispatchItem.id),
5444
+ sourcePath: structuredCompletion?._path || null,
5445
+ trust: 'system',
5446
+ importance: outcome === 'failed' ? 0.85 : (outcome === 'partial' ? 0.75 : 0.6),
5447
+ confidence: structuredCompletion ? 0.8 : 0.55,
5448
+ metadata: {
5449
+ dispatchId: dispatchItem.id,
5450
+ workItemId: item.id || null,
5451
+ agent: agentId || dispatchItem.agent || null,
5452
+ project: project || null,
5453
+ outcome,
5454
+ workType: dispatchItem.type || item.type || null,
5455
+ pr,
5456
+ tests,
5457
+ failureClass,
5458
+ files: fileList,
5459
+ },
5460
+ });
5461
+ } catch (err) {
5462
+ log('warn', `Task memory episode write failed for ${dispatchItem.id}: ${err.message}`);
5463
+ return null;
5464
+ }
5465
+ }
5466
+
5392
5467
  async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config, opts) {
5393
5468
 
5394
5469
  const detectPhantom = !!(opts && opts.detectPhantom);
@@ -5577,6 +5652,12 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5577
5652
  log('info', `Auto-recovery: agent failed but created ${prsCreatedCount} PR(s) — upgrading ${meta.item.id} to done`);
5578
5653
  }
5579
5654
  const effectiveSuccess = !nonceMismatch && ((isSuccess && !agentReportedFailure) || autoRecovered);
5655
+ if (!nonceMismatch) {
5656
+ const episodeOutcome = effectiveSuccess
5657
+ ? (autoRecovered || completionStatus.startsWith('partial') ? 'partial' : 'success')
5658
+ : 'failed';
5659
+ captureTaskMemoryEpisode(dispatchItem, agentId, episodeOutcome, structuredCompletion, completionGateSummary, config);
5660
+ }
5580
5661
 
5581
5662
  let nonCleanReportWritten = false;
5582
5663
  if (completionStatus.startsWith('partial') || autoRecovered || (agentReportedFailure && isSuccess)) {
@@ -7106,6 +7187,8 @@ module.exports = {
7106
7187
  // the grounded harness footprint in as a section instead of emitting a
7107
7188
  // standalone harness-usage-* inbox note.
7108
7189
  writeNonCleanAgentReport,
7190
+ // exported for testing
7191
+ captureTaskMemoryEpisode,
7109
7192
  normalizeCompletionArtifacts,
7110
7193
  completionArtifactToNoteEntry,
7111
7194
  mergeArtifactNotes,
@@ -0,0 +1,165 @@
1
+ // engine/memory-retrieval.js — deterministic task-aware memory selection.
2
+
3
+ const memoryStore = require('./memory-store');
4
+
5
+ const DEFAULT_TOP_K = 8;
6
+ const DEFAULT_MAX_BYTES = 8 * 1024;
7
+ const DEFAULT_CANDIDATES = 50;
8
+
9
+ function buildTaskMemoryQuery(context = {}) {
10
+ const references = Array.isArray(context.references)
11
+ ? context.references.map(ref => {
12
+ if (typeof ref === 'string') return ref;
13
+ if (!ref || typeof ref !== 'object') return '';
14
+ return [ref.path, ref.url, ref.title, ref.type].filter(Boolean).join(' ');
15
+ }).join(' ')
16
+ : String(context.references || '');
17
+ return [
18
+ context.title,
19
+ context.description,
20
+ context.type,
21
+ context.failureClass,
22
+ context.sourcePlan,
23
+ references,
24
+ context.prTitle,
25
+ context.prBranch,
26
+ ].filter(Boolean).join('\n').trim();
27
+ }
28
+
29
+ function _trustBoost(trust) {
30
+ if (trust === 'human') return 12;
31
+ if (trust === 'system') return 8;
32
+ if (trust === 'agent') return 3;
33
+ return 0;
34
+ }
35
+
36
+ function _scopeBoost(record, context) {
37
+ if (record.scopeType === 'project' && record.scopeKey === context.project) return 10;
38
+ if (record.scopeType === 'agent' && record.scopeKey === context.agent) return 7;
39
+ return 2;
40
+ }
41
+
42
+ function _pathBoost(record, query) {
43
+ const source = String(record.sourcePath || '').toLowerCase();
44
+ if (!source) return 0;
45
+ const pathTokens = String(query || '').toLowerCase().match(/[a-z0-9_.-]+[\\/][a-z0-9_./\\-]+/g) || [];
46
+ return pathTokens.some(token => source.includes(token.replace(/\\/g, '/'))) ? 15 : 0;
47
+ }
48
+
49
+ function _recencyBoost(updatedAt, now) {
50
+ const ageDays = Math.max(0, (now - Number(updatedAt || 0)) / 86400000);
51
+ if (ageDays <= 7) return 4;
52
+ if (ageDays <= 30) return 2;
53
+ if (ageDays <= 90) return 1;
54
+ return 0;
55
+ }
56
+
57
+ function rankMemoryCandidates(records, context = {}, query = '') {
58
+ const now = Date.now();
59
+ return records.map((record, index) => ({
60
+ ...record,
61
+ score:
62
+ ((records.length - index) * 10) +
63
+ _scopeBoost(record, context) +
64
+ _trustBoost(record.trust) +
65
+ _pathBoost(record, query) +
66
+ (_recencyBoost(record.updatedAt, now)) +
67
+ (Number(record.importance || 0) * 5) +
68
+ (Number(record.confidence || 0) * 3),
69
+ })).sort((a, b) =>
70
+ b.score - a.score ||
71
+ Number(b.updatedAt || 0) - Number(a.updatedAt || 0) ||
72
+ String(a.id).localeCompare(String(b.id))
73
+ );
74
+ }
75
+
76
+ function dedupeMemoryCandidates(records) {
77
+ const seen = new Set();
78
+ return records.filter(record => {
79
+ const key = record.contentHash || `${record.sourceType}\0${record.sourceRef}`;
80
+ if (seen.has(key)) return false;
81
+ seen.add(key);
82
+ return true;
83
+ });
84
+ }
85
+
86
+ function _truncateUtf8(text, maxBytes) {
87
+ const input = String(text || '');
88
+ if (Buffer.byteLength(input, 'utf8') <= maxBytes) return input;
89
+ let low = 0;
90
+ let high = input.length;
91
+ while (low < high) {
92
+ const mid = Math.ceil((low + high) / 2);
93
+ if (Buffer.byteLength(input.slice(0, mid), 'utf8') <= maxBytes) low = mid;
94
+ else high = mid - 1;
95
+ }
96
+ return input.slice(0, low);
97
+ }
98
+
99
+ function _formatRecord(record, bodyBytes) {
100
+ const provenance = [
101
+ `id=${record.id}`,
102
+ `type=${record.memoryType}`,
103
+ `scope=${record.scopeType}${record.scopeKey ? `:${record.scopeKey}` : ''}`,
104
+ `date=${new Date(record.validFrom || record.createdAt).toISOString().slice(0, 10)}`,
105
+ `source=${record.sourcePath || record.sourceRef}`,
106
+ ].join(' | ');
107
+ const body = _truncateUtf8(record.body, bodyBytes);
108
+ return `### ${record.title}\n_${provenance}_\n\n${body}${body.length < record.body.length ? '\n\n_[excerpt truncated]_' : ''}`;
109
+ }
110
+
111
+ function packMemoryResults(records, options = {}) {
112
+ const topK = Math.max(1, Math.min(30, Number(options.topK) || DEFAULT_TOP_K));
113
+ const maxBytes = Math.max(1024, Math.min(64 * 1024, Number(options.maxBytes) || DEFAULT_MAX_BYTES));
114
+ const selected = [];
115
+ const blocks = [];
116
+ let used = 0;
117
+ for (const record of records.slice(0, topK)) {
118
+ const remaining = maxBytes - used;
119
+ if (remaining < 256) break;
120
+ const block = _formatRecord(record, Math.max(128, remaining - 256));
121
+ const separator = blocks.length ? '\n\n---\n\n' : '';
122
+ const bytes = Buffer.byteLength(separator + block, 'utf8');
123
+ if (bytes > remaining) continue;
124
+ blocks.push(block);
125
+ selected.push(record);
126
+ used += bytes;
127
+ }
128
+ return { text: blocks.join('\n\n---\n\n'), selected, bytes: used };
129
+ }
130
+
131
+ function retrieveRelevantMemory(context = {}, options = {}) {
132
+ const started = process.hrtime.bigint();
133
+ const query = buildTaskMemoryQuery(context);
134
+ if (!query) {
135
+ return { query: '', candidates: [], selected: [], text: '', bytes: 0, durationMs: 0 };
136
+ }
137
+ const candidates = memoryStore.searchMemoryRecords(query, {
138
+ project: context.project,
139
+ agent: context.agent,
140
+ limit: Number(options.candidateLimit) || DEFAULT_CANDIDATES,
141
+ });
142
+ const ranked = dedupeMemoryCandidates(rankMemoryCandidates(candidates, context, query));
143
+ const packed = packMemoryResults(ranked, options);
144
+ const durationMs = Number(process.hrtime.bigint() - started) / 1e6;
145
+ return {
146
+ query,
147
+ candidates: ranked,
148
+ selected: packed.selected,
149
+ text: packed.text,
150
+ bytes: packed.bytes,
151
+ durationMs,
152
+ };
153
+ }
154
+
155
+ module.exports = {
156
+ DEFAULT_TOP_K,
157
+ DEFAULT_MAX_BYTES,
158
+ DEFAULT_CANDIDATES,
159
+ buildTaskMemoryQuery,
160
+ rankMemoryCandidates,
161
+ dedupeMemoryCandidates,
162
+ packMemoryResults,
163
+ retrieveRelevantMemory,
164
+ _truncateUtf8,
165
+ };
@@ -0,0 +1,365 @@
1
+ // engine/memory-store.js — SQL-backed long-term memory records and retrieval.
2
+
3
+ const crypto = require('crypto');
4
+ const { getDb, withTransaction } = require('./db');
5
+ const { emitStateEvent } = require('./db-events');
6
+
7
+ const MEMORY_TYPES = new Set(['semantic', 'episodic', 'procedural']);
8
+ const SCOPE_TYPES = new Set(['global', 'project', 'agent']);
9
+ const STATUSES = new Set(['active', 'superseded', 'retracted', 'expired']);
10
+ const TRUST_LEVELS = new Set(['human', 'system', 'agent', 'external']);
11
+ const MAX_RETRIEVAL_RUNS = 5000;
12
+ const QUERY_STOP_WORDS = new Set([
13
+ 'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'for', 'from', 'in', 'is',
14
+ 'it', 'of', 'on', 'or', 'that', 'the', 'this', 'to', 'with',
15
+ 'add', 'change', 'code', 'file', 'fix', 'implement', 'issue', 'project',
16
+ 'task', 'test', 'update', 'work', 'item', 'agent',
17
+ ]);
18
+
19
+ function _clamp(value, fallback) {
20
+ const n = Number(value);
21
+ return Number.isFinite(n) ? Math.max(0, Math.min(1, n)) : fallback;
22
+ }
23
+
24
+ function _json(value, fallback) {
25
+ try { return JSON.stringify(value == null ? fallback : value); }
26
+ catch { return JSON.stringify(fallback); }
27
+ }
28
+
29
+ function _parseJson(value, fallback) {
30
+ try { return JSON.parse(value); } catch { return fallback; }
31
+ }
32
+
33
+ function _hash(value) {
34
+ return crypto.createHash('sha256').update(String(value || '')).digest('hex');
35
+ }
36
+
37
+ function _idFor(record, contentHash) {
38
+ if (record.id) return String(record.id);
39
+ return `M-${_hash(`${record.sourceType}\0${record.sourceRef}\0${contentHash}`).slice(0, 24)}`;
40
+ }
41
+
42
+ function _normalize(record) {
43
+ if (!record || typeof record !== 'object') throw new TypeError('memory record is required');
44
+ const memoryType = MEMORY_TYPES.has(record.memoryType) ? record.memoryType : 'semantic';
45
+ const scopeType = SCOPE_TYPES.has(record.scopeType) ? record.scopeType : 'global';
46
+ const scopeKey = scopeType === 'global' ? '' : String(record.scopeKey || '').trim();
47
+ if (scopeType !== 'global' && !scopeKey) throw new TypeError(`memory scopeKey required for ${scopeType} scope`);
48
+ const title = String(record.title || '').trim();
49
+ const body = String(record.body || '').trim();
50
+ if (!title) throw new TypeError('memory title is required');
51
+ if (!body) throw new TypeError('memory body is required');
52
+ const sourceType = String(record.sourceType || '').trim();
53
+ const sourceRef = String(record.sourceRef || '').trim();
54
+ if (!sourceType || !sourceRef) throw new TypeError('memory sourceType and sourceRef are required');
55
+ const contentHash = _hash(body);
56
+ const now = Date.now();
57
+ return {
58
+ id: _idFor(record, contentHash),
59
+ memoryType,
60
+ scopeType,
61
+ scopeKey,
62
+ title,
63
+ body,
64
+ tags: Array.isArray(record.tags) ? record.tags.map(String).filter(Boolean) : [],
65
+ sourceType,
66
+ sourcePath: record.sourcePath ? String(record.sourcePath) : null,
67
+ sourceRef,
68
+ trust: TRUST_LEVELS.has(record.trust) ? record.trust : 'agent',
69
+ importance: _clamp(record.importance, 0.5),
70
+ confidence: _clamp(record.confidence, 0.5),
71
+ status: STATUSES.has(record.status) ? record.status : 'active',
72
+ validFrom: Number.isFinite(Number(record.validFrom)) ? Number(record.validFrom) : now,
73
+ validTo: Number.isFinite(Number(record.validTo)) ? Number(record.validTo) : null,
74
+ supersedesId: record.supersedesId ? String(record.supersedesId) : null,
75
+ supersededBy: record.supersededBy ? String(record.supersededBy) : null,
76
+ contentHash,
77
+ metadata: record.metadata && typeof record.metadata === 'object' ? record.metadata : {},
78
+ createdAt: Number.isFinite(Number(record.createdAt)) ? Number(record.createdAt) : now,
79
+ updatedAt: now,
80
+ };
81
+ }
82
+
83
+ function _row(row) {
84
+ if (!row) return null;
85
+ return {
86
+ id: row.id,
87
+ memoryType: row.memory_type,
88
+ scopeType: row.scope_type,
89
+ scopeKey: row.scope_key,
90
+ title: row.title,
91
+ body: row.body,
92
+ tags: _parseJson(row.tags, []),
93
+ sourceType: row.source_type,
94
+ sourcePath: row.source_path,
95
+ sourceRef: row.source_ref,
96
+ trust: row.trust,
97
+ importance: row.importance,
98
+ confidence: row.confidence,
99
+ status: row.status,
100
+ validFrom: row.valid_from,
101
+ validTo: row.valid_to,
102
+ supersedesId: row.supersedes_id,
103
+ supersededBy: row.superseded_by,
104
+ contentHash: row.content_hash,
105
+ metadata: _parseJson(row.metadata, {}),
106
+ createdAt: row.created_at,
107
+ updatedAt: row.updated_at,
108
+ ftsRank: row.fts_rank,
109
+ };
110
+ }
111
+
112
+ function upsertMemoryRecord(input) {
113
+ const record = _normalize(input);
114
+ const db = getDb();
115
+ withTransaction(db, () => {
116
+ if (!record.supersedesId) {
117
+ const prior = db.prepare(`
118
+ SELECT id FROM memory_records
119
+ WHERE source_type=? AND source_ref=? AND status='active' AND content_hash<>?
120
+ ORDER BY updated_at DESC LIMIT 1
121
+ `).get(record.sourceType, record.sourceRef, record.contentHash);
122
+ if (prior) record.supersedesId = prior.id;
123
+ }
124
+ if (record.supersedesId) {
125
+ db.prepare(`
126
+ UPDATE memory_records
127
+ SET status='superseded', valid_to=?, superseded_by=?, updated_at=?
128
+ WHERE id=? AND status='active'
129
+ `).run(record.validFrom, record.id, record.updatedAt, record.supersedesId);
130
+ }
131
+ db.prepare(`
132
+ INSERT INTO memory_records (
133
+ id, memory_type, scope_type, scope_key, title, body, tags,
134
+ source_type, source_path, source_ref, trust, importance, confidence,
135
+ status, valid_from, valid_to, supersedes_id, superseded_by,
136
+ content_hash, metadata, created_at, updated_at
137
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
138
+ ON CONFLICT(id) DO UPDATE SET
139
+ memory_type=excluded.memory_type,
140
+ scope_type=excluded.scope_type,
141
+ scope_key=excluded.scope_key,
142
+ title=excluded.title,
143
+ body=excluded.body,
144
+ tags=excluded.tags,
145
+ source_path=excluded.source_path,
146
+ trust=excluded.trust,
147
+ importance=excluded.importance,
148
+ confidence=excluded.confidence,
149
+ status=excluded.status,
150
+ valid_from=excluded.valid_from,
151
+ valid_to=excluded.valid_to,
152
+ supersedes_id=excluded.supersedes_id,
153
+ superseded_by=excluded.superseded_by,
154
+ metadata=excluded.metadata,
155
+ updated_at=excluded.updated_at
156
+ `).run(
157
+ record.id, record.memoryType, record.scopeType, record.scopeKey,
158
+ record.title, record.body, _json(record.tags, []),
159
+ record.sourceType, record.sourcePath, record.sourceRef, record.trust,
160
+ record.importance, record.confidence, record.status, record.validFrom,
161
+ record.validTo, record.supersedesId, record.supersededBy,
162
+ record.contentHash, _json(record.metadata, {}), record.createdAt, record.updatedAt,
163
+ );
164
+ });
165
+ emitStateEvent('memory_records', { id: record.id, status: record.status });
166
+ return getMemoryRecord(record.id);
167
+ }
168
+
169
+ function getMemoryRecord(id) {
170
+ return _row(getDb().prepare('SELECT * FROM memory_records WHERE id=?').get(String(id)));
171
+ }
172
+
173
+ function updateMemoryRecordState(id, action) {
174
+ const recordId = String(id || '').trim();
175
+ if (!recordId) throw new TypeError('memory record id is required');
176
+ if (!['pin', 'retract', 'restore'].includes(action)) {
177
+ throw new TypeError(`unsupported memory action: ${action}`);
178
+ }
179
+ if (!getMemoryRecord(recordId)) return null;
180
+
181
+ const db = getDb();
182
+ const now = Date.now();
183
+ if (action === 'pin') {
184
+ db.prepare(`
185
+ UPDATE memory_records
186
+ SET trust='human', importance=1, confidence=MAX(confidence, 0.9),
187
+ status='active', valid_to=NULL, superseded_by=NULL, updated_at=?
188
+ WHERE id=?
189
+ `).run(now, recordId);
190
+ } else if (action === 'restore') {
191
+ db.prepare(`
192
+ UPDATE memory_records
193
+ SET status='active', valid_to=NULL, superseded_by=NULL, updated_at=?
194
+ WHERE id=?
195
+ `).run(now, recordId);
196
+ } else {
197
+ db.prepare(`
198
+ UPDATE memory_records
199
+ SET status='retracted', valid_to=?, updated_at=?
200
+ WHERE id=?
201
+ `).run(now, now, recordId);
202
+ }
203
+ emitStateEvent('memory_records', { id: recordId, action });
204
+ return getMemoryRecord(recordId);
205
+ }
206
+
207
+ function listMemoryRecords(filters = {}) {
208
+ const where = [];
209
+ const args = [];
210
+ for (const [column, value] of [
211
+ ['status', filters.status],
212
+ ['memory_type', filters.memoryType],
213
+ ['scope_type', filters.scopeType],
214
+ ['scope_key', filters.scopeKey],
215
+ ]) {
216
+ if (value == null || value === '') continue;
217
+ where.push(`${column}=?`);
218
+ args.push(String(value));
219
+ }
220
+ const limit = Math.max(1, Math.min(500, Number(filters.limit) || 100));
221
+ const sql = `SELECT * FROM memory_records${where.length ? ` WHERE ${where.join(' AND ')}` : ''} ORDER BY updated_at DESC LIMIT ?`;
222
+ return getDb().prepare(sql).all(...args, limit).map(_row);
223
+ }
224
+
225
+ function _matchQuery(query) {
226
+ const tokens = String(query || '')
227
+ .toLowerCase()
228
+ .match(/[a-z0-9][a-z0-9_.-]{1,63}/g);
229
+ if (!tokens) return '';
230
+ const unique = [...new Set(tokens)]
231
+ .filter(token => !QUERY_STOP_WORDS.has(token))
232
+ .slice(0, 32);
233
+ return unique.map(token => `"${token.replace(/"/g, '""')}"`).join(' OR ');
234
+ }
235
+
236
+ function searchMemoryRecords(query, options = {}) {
237
+ const match = _matchQuery(query);
238
+ if (!match) return [];
239
+ const where = [
240
+ 'memory_records_fts MATCH ?',
241
+ "(m.memory_type<>'procedural' OR m.trust IN ('human','system'))",
242
+ ];
243
+ const args = [match];
244
+ const statuses = Array.isArray(options.statuses)
245
+ ? options.statuses.filter(status => STATUSES.has(status))
246
+ : ['active'];
247
+ if (!statuses.length) return [];
248
+ where.push(`m.status IN (${statuses.map(() => '?').join(',')})`);
249
+ args.push(...statuses);
250
+ if (!options.allScopes) {
251
+ const scopes = ["m.scope_type='global'"];
252
+ if (options.project) {
253
+ scopes.push("(m.scope_type='project' AND m.scope_key=?)");
254
+ args.push(String(options.project));
255
+ }
256
+ if (options.agent) {
257
+ scopes.push("(m.scope_type='agent' AND m.scope_key=?)");
258
+ args.push(String(options.agent));
259
+ }
260
+ where.push(`(${scopes.join(' OR ')})`);
261
+ }
262
+ if (Array.isArray(options.memoryTypes) && options.memoryTypes.length) {
263
+ const types = options.memoryTypes.filter(t => MEMORY_TYPES.has(t));
264
+ if (types.length) {
265
+ where.push(`m.memory_type IN (${types.map(() => '?').join(',')})`);
266
+ args.push(...types);
267
+ }
268
+ }
269
+ const limit = Math.max(1, Math.min(200, Number(options.limit) || 50));
270
+ args.push(limit);
271
+ return getDb().prepare(`
272
+ SELECT m.*, bm25(memory_records_fts, 5.0, 1.0, 2.0, 3.0) AS fts_rank
273
+ FROM memory_records_fts
274
+ JOIN memory_records m ON m.rowid=memory_records_fts.rowid
275
+ WHERE ${where.join(' AND ')}
276
+ ORDER BY fts_rank
277
+ LIMIT ?
278
+ `).all(...args).map(_row);
279
+ }
280
+
281
+ function transitionMemoryRecord(id, status, options = {}) {
282
+ if (!STATUSES.has(status) || status === 'active') throw new TypeError(`invalid memory status: ${status}`);
283
+ const now = Date.now();
284
+ const result = getDb().prepare(`
285
+ UPDATE memory_records
286
+ SET status=?, valid_to=?, superseded_by=COALESCE(?, superseded_by), updated_at=?
287
+ WHERE id=?
288
+ `).run(status, Number(options.validTo) || now, options.supersededBy || null, now, String(id));
289
+ if (result.changes) emitStateEvent('memory_records', { id: String(id), status });
290
+ return result.changes > 0;
291
+ }
292
+
293
+ function supersedeMatchingRecords(options = {}) {
294
+ const texts = Array.isArray(options.oldTexts) ? options.oldTexts.map(String).filter(Boolean) : [];
295
+ if (!texts.length || !options.supersededBy) return 0;
296
+ const db = getDb();
297
+ const now = Date.now();
298
+ let changed = 0;
299
+ withTransaction(db, () => {
300
+ for (const text of texts) {
301
+ const rows = db.prepare(`
302
+ SELECT id FROM memory_records
303
+ WHERE status='active' AND scope_type=? AND scope_key=? AND instr(body, ?) > 0
304
+ `).all(options.scopeType || 'global', options.scopeKey || '', text);
305
+ for (const row of rows) {
306
+ const result = db.prepare(`
307
+ UPDATE memory_records
308
+ SET status='superseded', valid_to=?, superseded_by=?, updated_at=?
309
+ WHERE id=? AND status='active'
310
+ `).run(now, String(options.supersededBy), now, row.id);
311
+ changed += Number(result.changes) || 0;
312
+ }
313
+ }
314
+ });
315
+ if (changed) emitStateEvent('memory_records', { supersededBy: String(options.supersededBy), changed });
316
+ return changed;
317
+ }
318
+
319
+ function recordRetrievalRun(run) {
320
+ const db = getDb();
321
+ withTransaction(db, () => {
322
+ db.prepare(`
323
+ INSERT INTO memory_retrieval_runs (
324
+ work_item_id, project, agent, query, candidate_count, selected_count,
325
+ selected_bytes, duration_ms, shadow, created_at
326
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
327
+ `).run(
328
+ run.workItemId || null, run.project || null, run.agent || null,
329
+ String(run.query || ''), Number(run.candidateCount) || 0,
330
+ Number(run.selectedCount) || 0, Number(run.selectedBytes) || 0,
331
+ Number(run.durationMs) || 0, run.shadow ? 1 : 0, Date.now(),
332
+ );
333
+ db.prepare(`
334
+ DELETE FROM memory_retrieval_runs
335
+ WHERE id NOT IN (SELECT id FROM memory_retrieval_runs ORDER BY id DESC LIMIT ?)
336
+ `).run(MAX_RETRIEVAL_RUNS);
337
+ });
338
+ }
339
+
340
+ function getRetrievalStats() {
341
+ return getDb().prepare(`
342
+ SELECT COUNT(*) AS runs,
343
+ COALESCE(AVG(duration_ms), 0) AS avg_duration_ms,
344
+ COALESCE(AVG(selected_count), 0) AS avg_selected_count,
345
+ COALESCE(AVG(selected_bytes), 0) AS avg_selected_bytes,
346
+ COALESCE(MAX(duration_ms), 0) AS max_duration_ms
347
+ FROM memory_retrieval_runs
348
+ `).get();
349
+ }
350
+
351
+ module.exports = {
352
+ MEMORY_TYPES,
353
+ SCOPE_TYPES,
354
+ STATUSES,
355
+ upsertMemoryRecord,
356
+ getMemoryRecord,
357
+ updateMemoryRecordState,
358
+ listMemoryRecords,
359
+ searchMemoryRecords,
360
+ transitionMemoryRecord,
361
+ supersedeMatchingRecords,
362
+ recordRetrievalRun,
363
+ getRetrievalStats,
364
+ _matchQuery,
365
+ };