@yemi33/minions 0.1.2369 → 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.
@@ -0,0 +1,112 @@
1
+ (function() {
2
+ function el(tag, className, text) {
3
+ const node = document.createElement(tag);
4
+ if (className) node.className = className;
5
+ if (text != null) node.textContent = text;
6
+ return node;
7
+ }
8
+
9
+ function open() {
10
+ document.getElementById('modal-title').textContent = 'Structured Memory';
11
+ const body = document.getElementById('modal-body');
12
+ body.textContent = '';
13
+ const controls = el('div');
14
+ controls.style.cssText = 'display:flex;gap:8px;margin-bottom:12px';
15
+ const input = el('input');
16
+ input.id = 'memory-search-query';
17
+ input.placeholder = 'Search facts, episodes, paths, or lessons';
18
+ input.style.cssText = 'flex:1;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text)';
19
+ const status = el('select');
20
+ status.id = 'memory-search-status';
21
+ for (const value of ['active', 'superseded', 'retracted', 'expired']) {
22
+ const option = el('option', '', value);
23
+ option.value = value;
24
+ status.appendChild(option);
25
+ }
26
+ const button = el('button', 'btn-primary-lg', 'Search');
27
+ button.addEventListener('click', search);
28
+ input.addEventListener('keydown', event => { if (event.key === 'Enter') search(); });
29
+ controls.append(input, status, button);
30
+ body.append(controls, el('div', 'empty', 'Searches structured memory across all scopes. Results include provenance and lifecycle status.'));
31
+ body.lastChild.id = 'memory-search-results';
32
+ document.getElementById('modal').classList.add('open');
33
+ input.focus();
34
+ }
35
+
36
+ async function search() {
37
+ const input = document.getElementById('memory-search-query');
38
+ const status = document.getElementById('memory-search-status');
39
+ const results = document.getElementById('memory-search-results');
40
+ const query = input && input.value.trim();
41
+ if (!query || !results) return;
42
+ results.textContent = 'Searching...';
43
+ try {
44
+ const url = '/api/memory-records/search?q=' + encodeURIComponent(query)
45
+ + '&status=' + encodeURIComponent(status ? status.value : 'active') + '&limit=30';
46
+ const response = await fetch(url);
47
+ const data = await response.json();
48
+ if (!response.ok) throw new Error(data.error || 'search failed');
49
+ render(data.records || []);
50
+ } catch (error) {
51
+ results.textContent = 'Memory search failed: ' + error.message;
52
+ }
53
+ }
54
+
55
+ function render(records) {
56
+ const results = document.getElementById('memory-search-results');
57
+ if (!results) return;
58
+ results.textContent = '';
59
+ results.className = records.length ? '' : 'empty';
60
+ if (!records.length) {
61
+ results.textContent = 'No matching memory records.';
62
+ return;
63
+ }
64
+ for (const record of records) {
65
+ const item = el('div', 'kb-item');
66
+ item.style.marginBottom = '8px';
67
+ const body = el('div', 'kb-item-body');
68
+ body.appendChild(el('div', 'kb-item-title', record.title || record.id));
69
+ const meta = el('div', 'kb-item-meta');
70
+ const fields = [
71
+ record.memoryType,
72
+ record.scopeType + (record.scopeKey ? ':' + record.scopeKey : ''),
73
+ record.trust,
74
+ record.status,
75
+ record.sourceRef || record.sourcePath || record.sourceType,
76
+ Number.isFinite(record.ftsRank) ? 'rank ' + record.ftsRank.toFixed(2) : '',
77
+ ].filter(Boolean);
78
+ for (const field of fields) meta.appendChild(el('span', '', field));
79
+ body.appendChild(meta);
80
+ body.appendChild(el('div', 'kb-item-preview',
81
+ (record.body || '') + (record.bodyTruncated ? '\n\n[preview truncated]' : '')));
82
+ const actions = el('div');
83
+ actions.style.cssText = 'display:flex;gap:6px;margin-top:8px';
84
+ for (const action of ['pin', record.status === 'active' ? 'retract' : 'restore']) {
85
+ const button = el('button', 'pr-pager-btn', action);
86
+ button.addEventListener('click', () => update(record.id, action));
87
+ actions.appendChild(button);
88
+ }
89
+ body.appendChild(actions);
90
+ item.appendChild(body);
91
+ results.appendChild(item);
92
+ }
93
+ }
94
+
95
+ async function update(id, action) {
96
+ try {
97
+ const response = await fetch('/api/memory-records/' + encodeURIComponent(id) + '/action', {
98
+ method: 'POST',
99
+ headers: { 'Content-Type': 'application/json' },
100
+ body: JSON.stringify({ action }),
101
+ });
102
+ const data = await response.json();
103
+ if (!response.ok) throw new Error(data.error || action + ' failed');
104
+ showToast('kb-sweep-toast', 'Memory record ' + action + ' complete', true);
105
+ search();
106
+ } catch (error) {
107
+ showToast('kb-sweep-toast', 'Memory update failed: ' + error.message, false);
108
+ }
109
+ }
110
+
111
+ window.MinionsMemorySearch = { open };
112
+ })();
@@ -204,6 +204,21 @@ async function kbSweep() {
204
204
  }
205
205
  }
206
206
 
207
+ let _memorySearchUiLoading = false;
208
+ function openMemorySearchModal() {
209
+ if (window.MinionsMemorySearch) return window.MinionsMemorySearch.open();
210
+ if (_memorySearchUiLoading) return;
211
+ _memorySearchUiLoading = true;
212
+ const script = document.createElement('script');
213
+ script.src = '/assets/memory-search.js';
214
+ script.onload = () => window.MinionsMemorySearch.open();
215
+ script.onerror = () => {
216
+ _memorySearchUiLoading = false;
217
+ showToast('kb-sweep-toast', 'Memory search UI failed to load', false);
218
+ };
219
+ document.head.appendChild(script);
220
+ }
221
+
207
222
  function openCreateKbModal() {
208
223
  document.getElementById('modal-title').textContent = 'New Knowledge Base Entry';
209
224
  document.getElementById('modal-body').innerHTML =
@@ -595,6 +595,16 @@ async function openSettings() {
595
595
  settingsField('Consolidation Threshold', 'set-inboxConsolidateThreshold', e.inboxConsolidateThreshold || 5, 'notes', 'Inbox notes before auto-consolidation') +
596
596
  settingsField('Version Check Interval', 'set-versionCheckInterval', e.versionCheckInterval || 3600000, 'ms', 'How often to check npm for updates (default: 1 hour)') +
597
597
  '</div>' +
598
+ '<h4>Agent Memory</h4>' +
599
+ '<div class="settings-stack">' +
600
+ settingsToggle('Shadow mode', 'set-memoryRetrievalShadowMode', e.memoryRetrievalShadowMode !== false, 'Measure retrieval without changing prompts') +
601
+ settingsToggle('Capture task episodes', 'set-memoryEpisodicCapture', !!e.memoryEpisodicCapture, 'Store compact outcomes, never transcripts') +
602
+ '</div>' +
603
+ '<div class="settings-grid-2">' +
604
+ settingsField('Retrieved records', 'set-memoryRetrievalTopK', e.memoryRetrievalTopK || 8, 'records', '1–30') +
605
+ settingsField('Prompt budget', 'set-memoryRetrievalMaxBytes', e.memoryRetrievalMaxBytes || 8192, 'bytes', '1024–65536') +
606
+ settingsField('Candidate pool', 'set-memoryRetrievalCandidateLimit', e.memoryRetrievalCandidateLimit || 50, 'records', '5–200') +
607
+ '</div>' +
598
608
  '<h4>Operator &amp; Comments</h4>' +
599
609
  '<div class="settings-grid-2">' +
600
610
  settingsField('Operator login (used in branch names)', 'set-operatorLogin', e.operatorLogin || '', '', 'Override the human operator login used in user/<loginname>/<wi-id>-<slug> branches. Empty = auto-resolve via gh / git email / OS username (currently resolves to: ' + (e._resolvedOperatorLogin || 'unknown') + ')') +
@@ -1089,6 +1099,11 @@ async function saveSettings() {
1089
1099
  tickInterval: document.getElementById('set-tickInterval').value,
1090
1100
  maxConcurrent: document.getElementById('set-maxConcurrent').value,
1091
1101
  inboxConsolidateThreshold: document.getElementById('set-inboxConsolidateThreshold').value,
1102
+ memoryRetrievalShadowMode: document.getElementById('set-memoryRetrievalShadowMode').checked,
1103
+ memoryEpisodicCapture: document.getElementById('set-memoryEpisodicCapture').checked,
1104
+ memoryRetrievalTopK: document.getElementById('set-memoryRetrievalTopK').value,
1105
+ memoryRetrievalMaxBytes: document.getElementById('set-memoryRetrievalMaxBytes').value,
1106
+ memoryRetrievalCandidateLimit: document.getElementById('set-memoryRetrievalCandidateLimit').value,
1092
1107
  agentTimeout: document.getElementById('set-agentTimeout').value,
1093
1108
  maxTurns: document.getElementById('set-maxTurns').value,
1094
1109
  heartbeatTimeout: document.getElementById('set-heartbeatTimeout').value,
@@ -7,7 +7,7 @@
7
7
  <div id="notes-list">Loading...</div>
8
8
  </section>
9
9
  <section>
10
- <h2>Knowledge Base <span class="count" id="kb-count">0</span> <button class="btn-add" style="margin-left:8px" onclick="openCreateKbModal()">+ New</button><button id="kb-sweep-btn" onclick="kbSweep()" style="font-size:var(--text-xs);padding:2px 8px;background:var(--surface2);border:1px solid var(--border);color:var(--muted);border-radius:4px;cursor:pointer;margin-left:8px;vertical-align:middle">sweep</button><span id="kb-swept-time" style="font-size:var(--text-sm);color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0;margin-left:8px"></span></h2>
10
+ <h2>Knowledge Base <span class="count" id="kb-count">0</span> <button class="btn-add" style="margin-left:8px" onclick="openCreateKbModal()">+ New</button><button class="btn-add" style="margin-left:8px" onclick="openMemorySearchModal()">Memory search</button><button id="kb-sweep-btn" onclick="kbSweep()" style="font-size:var(--text-xs);padding:2px 8px;background:var(--surface2);border:1px solid var(--border);color:var(--muted);border-radius:4px;cursor:pointer;margin-left:8px;vertical-align:middle">sweep</button><span id="kb-swept-time" style="font-size:var(--text-sm);color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0;margin-left:8px"></span></h2>
11
11
  <div id="kb-sweep-toast" class="cmd-toast cmd-toast-inline" style="margin:6px 0"></div>
12
12
  <div class="kb-tabs" id="kb-tabs"></div>
13
13
  <div class="kb-list" id="kb-list"><p class="empty">No knowledge entries yet. Notes are classified here after consolidation.</p></div>
package/dashboard.js CHANGED
@@ -65,6 +65,7 @@ const prTrack = require('./engine/pr-track');
65
65
  const features = require('./engine/features');
66
66
  const ccWorkerPool = require('./engine/cc-worker-pool');
67
67
  const diagnosticsMemory = require('./engine/diagnostics-memory');
68
+ const memoryStore = require('./engine/memory-store');
68
69
  const os = require('os');
69
70
 
70
71
  const { safeRead, safeReadOrNull, safeReadDir, safeWrite, safeJson, safeJsonObj, safeJsonArr, safeJsonNoRestore, safeUnlink, mutateJsonFileLocked, mutateTextFileLocked, mutateControl, mutateCooldowns, mutateWorkItems, getProjects: _getProjects, DONE_STATUSES, WI_STATUS, WORK_TYPE, WORKTREE_REQUIRING_TYPES, reopenWorkItem } = shared;
@@ -7896,6 +7897,46 @@ const server = http.createServer(async (req, res) => {
7896
7897
  return jsonReply(res, 200, result);
7897
7898
  }
7898
7899
 
7900
+ function handleMemoryRecordsSearch(req, res) {
7901
+ const url = new URL(req.url, 'http://localhost');
7902
+ const query = String(url.searchParams.get('q') || '').trim();
7903
+ if (!query) return jsonReply(res, 400, { error: 'q is required' });
7904
+ if (query.length > 500) return jsonReply(res, 400, { error: 'q must be 500 characters or fewer' });
7905
+ const limitRaw = Number.parseInt(url.searchParams.get('limit') || '20', 10);
7906
+ const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(100, limitRaw)) : 20;
7907
+ const status = url.searchParams.get('status') || 'active';
7908
+ if (!memoryStore.STATUSES.has(status)) return jsonReply(res, 400, { error: 'invalid status' });
7909
+ try {
7910
+ const records = memoryStore.searchMemoryRecords(query, {
7911
+ allScopes: true,
7912
+ statuses: [status],
7913
+ limit,
7914
+ });
7915
+ const boundedRecords = records.map(record => ({
7916
+ ...record,
7917
+ body: String(record.body || '').slice(0, 4000),
7918
+ bodyTruncated: String(record.body || '').length > 4000,
7919
+ }));
7920
+ return jsonReply(res, 200, { query, count: boundedRecords.length, records: boundedRecords });
7921
+ } catch (e) {
7922
+ return jsonReply(res, 400, { error: e.message });
7923
+ }
7924
+ }
7925
+
7926
+ async function handleMemoryRecordAction(req, res, match) {
7927
+ const id = decodeURIComponent((match && match[1]) || '').trim();
7928
+ if (!id) return jsonReply(res, 400, { error: 'memory record id is required' });
7929
+ try {
7930
+ const body = await readBody(req);
7931
+ const record = memoryStore.updateMemoryRecordState(id, String(body.action || '').trim());
7932
+ if (!record) return jsonReply(res, 404, { error: 'memory record not found' });
7933
+ invalidateStatusCache();
7934
+ return jsonReply(res, 200, { ok: true, record });
7935
+ } catch (e) {
7936
+ return jsonReply(res, 400, { error: e.message });
7937
+ }
7938
+ }
7939
+
7899
7940
  async function handleKnowledgeRead(req, res, match) {
7900
7941
  const cat = match[1];
7901
7942
  // P-bfa2b-kb-path-traversal — whitelist the category param BEFORE any
@@ -11360,6 +11401,9 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11360
11401
  // 1 floor (sequential fallback) and 20 ceiling (above this the LLM
11361
11402
  // provider's per-second rate limits dominate; throughput gains taper).
11362
11403
  preDispatchEvalConcurrency: [1, 20],
11404
+ memoryRetrievalTopK: [1, 30],
11405
+ memoryRetrievalMaxBytes: [1024, 65536],
11406
+ memoryRetrievalCandidateLimit: [5, 200],
11363
11407
  };
11364
11408
  for (const [key, [min, max]] of Object.entries(numericFields)) {
11365
11409
  if (e[key] !== undefined) {
@@ -14560,7 +14604,16 @@ What would you like to discuss or change? When you're happy, say "approve" and I
14560
14604
  } },
14561
14605
 
14562
14606
  // Knowledge base
14607
+ { method: 'GET', path: '/assets/memory-search.js', desc: 'Serve the lazy-loaded structured memory search UI', handler: (req, res) => {
14608
+ const content = safeRead(path.join(MINIONS_DIR, 'dashboard', 'js', 'memory-search.js'));
14609
+ if (content == null) { res.statusCode = 404; return res.end('not found'); }
14610
+ res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
14611
+ res.setHeader('Cache-Control', 'no-cache');
14612
+ res.end(content);
14613
+ } },
14563
14614
  { method: 'GET', path: '/api/knowledge', desc: 'List all knowledge base entries grouped by category', handler: handleKnowledgeList },
14615
+ { method: 'GET', path: /^\/api\/memory-records\/search(?:\?.*)?$/, template: '/api/memory-records/search', desc: 'Search structured memory records with provenance and lifecycle status', params: 'q, status?, limit?', handler: handleMemoryRecordsSearch },
14616
+ { method: 'POST', path: /^\/api\/memory-records\/([^/?]+)\/action$/, template: '/api/memory-records/<id>/action', desc: 'Pin, retract, or restore a structured memory record', params: 'action (pin|retract|restore)', handler: handleMemoryRecordAction },
14564
14617
  { method: 'POST', path: '/api/knowledge', desc: 'Create a knowledge base entry', params: 'category, title, content', handler: async (req, res) => {
14565
14618
  const body = await readBody(req);
14566
14619
  const { category, title, content } = body;
@@ -14572,7 +14625,21 @@ What would you like to discuss or change? When you're happy, say "approve" and I
14572
14625
  const dir = path.dirname(filePath);
14573
14626
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
14574
14627
  const header = '# ' + title + '\n\n*Created by human teammate on ' + new Date().toISOString().slice(0, 10) + '*\n\n';
14575
- safeWrite(filePath, header + content);
14628
+ const fullContent = header + content;
14629
+ safeWrite(filePath, fullContent);
14630
+ memoryStore.upsertMemoryRecord({
14631
+ memoryType: 'semantic',
14632
+ scopeType: 'global',
14633
+ title,
14634
+ body: fullContent,
14635
+ tags: [category],
14636
+ sourceType: 'knowledge-base',
14637
+ sourcePath: path.relative(MINIONS_DIR, filePath).replace(/\\/g, '/'),
14638
+ sourceRef: path.relative(MINIONS_DIR, filePath).replace(/\\/g, '/'),
14639
+ trust: 'human',
14640
+ importance: 0.8,
14641
+ confidence: 1,
14642
+ });
14576
14643
  queries.invalidateKnowledgeBaseCache();
14577
14644
  invalidateStatusCache();
14578
14645
  recordCcTurnIfPresent(req, { kind: 'knowledge', title, path: filePath });
@@ -1,6 +1,6 @@
1
1
  # Team Memory
2
2
 
3
- Minions agents share knowledge through a layered memory system. Before an agent does any independent research — web searches, broad codebase exploration, external doc reading — it is expected to check what the team already knows.
3
+ Minions agents share knowledge through a layered Markdown system plus a structured SQLite retrieval index. Before an agent does any independent research — web searches, broad codebase exploration, external doc reading — it is expected to check what the team already knows.
4
4
 
5
5
  This document describes the four-tier read order the engine enforces in dispatch prompts, the rationale, and the constraint that `knowledge/` is **sweep-write-only**.
6
6
 
@@ -12,7 +12,21 @@ Multiple agents work the same repository in parallel and across days. Without sh
12
12
  - Agents re-litigate decisions the team already made (drift, regressions).
13
13
  - Human-flagged warnings (e.g., "do not self-approve PRs") get ignored because they live outside the prompt.
14
14
 
15
- Team memory closes the loop. The engine injects pinned context and recent team notes into every dispatch prompt automatically, and the playbooks instruct agents to consult `knowledge/` and `notes/inbox/` on disk before going wider. This keeps work cheap, decisions sticky, and prior learnings reusable.
15
+ Team memory closes the loop. The engine always injects pinned context. By default it also injects recent team and personal notes; when structured retrieval is enabled outside shadow mode, it replaces those broad appendices with a bounded task-relevant memory pack. Playbooks still instruct agents to consult `knowledge/` and `notes/inbox/` on disk before going wider.
16
+
17
+ ## Structured memory and retrieval
18
+
19
+ Migration `017-agent-memory.js` backfills the existing Markdown sources into `memory_records` and an FTS5 index without deleting or rewriting those files. Records carry a type (`semantic`, `episodic`, or `procedural`), scope, trust level, source reference, confidence, importance, validity timestamps, and lifecycle status (`active`, `superseded`, `retracted`, or `expired`).
20
+
21
+ `engine/memory-retrieval.js` searches active records in global, current-project, and assigned-agent scopes. It reranks lexical matches by scope, trust, path match, recency, importance, and confidence, then packs the best records into a strict byte budget. Agent- or external-authored procedural records are never recalled as instructions. Every injected pack remains inside an `<UNTRUSTED-INPUT>` fence.
22
+
23
+ Rollout is controlled by the default-off `memoryRetrieval` feature flag:
24
+
25
+ - **Flag off:** legacy prompt behavior.
26
+ - **Flag on + `memoryRetrievalShadowMode: true` (default):** compute retrieval and telemetry, but keep legacy prompts.
27
+ - **Flag on + shadow mode off:** inject `Relevant Memory` when retrieval finds evidence; otherwise fall back to legacy notes and personal memory.
28
+
29
+ Settings expose top-k, candidate count, byte budget, shadow mode, and episodic capture. The Inbox page's **Memory search** control shows provenance and status and supports explicit pin, retract, and restore actions.
16
30
 
17
31
  ## The four-tier read order
18
32
 
@@ -54,7 +68,7 @@ Agents read `knowledge/` directly with grep/glob/view. They **do not** write to
54
68
 
55
69
  `notes.md` is the rolling team notebook. The consolidation sweep periodically merges high-signal findings from the inbox into dated `### YYYY-MM-DD` sections at the top of `notes.md`, with links back to the underlying KB entries.
56
70
 
57
- The engine injects `notes.md` into every prompt under a "Team Notes (MUST READ)" heading. When the file exceeds `ENGINE_DEFAULTS.maxNotesPromptBytes`, the engine keeps the most recent 10 sections, truncates the body, and appends a footer noting how many older sections live on disk so agents can read the full file when they need depth.
71
+ In legacy and shadow modes, the engine injects `notes.md` under a "Team Notes (MUST READ)" heading. When active retrieval returns evidence, the bounded `Relevant Memory` pack replaces this broad appendix. When the file exceeds `ENGINE_DEFAULTS.maxNotesPromptBytes`, the legacy path keeps the most recent 10 sections, truncates the body, and appends a footer noting how many older sections live on disk.
58
72
 
59
73
  ### 4. `notes/inbox/` — fresh agent findings
60
74
 
@@ -70,7 +84,7 @@ The consolidation sweep is what eventually promotes inbox notes into `knowledge/
70
84
 
71
85
  #### Failure-path exception
72
86
 
73
- Inbox writes are **success artifacts only**. If a task fails, is blocked, is cancelled, or ends partial, the agent must **not** create an inbox note the failure is reported through the completion JSON, the PR/work-item comment, or the task-specific failure channel instead. This keeps the inbox a clean signal and prevents noise from drowning out durable findings.
87
+ Inbox writes are **success artifacts only**. If a task fails, is blocked, is cancelled, or ends partial, the agent must **not** create an inbox note. When `memoryEpisodicCapture` is enabled, lifecycle records a compact structured outcome for successful, partial, and failed dispatches; it never stores transcripts or chain-of-thought and skips nonce-mismatched or injection-flagged reports.
74
88
 
75
89
  ## Rationale
76
90
 
@@ -149,6 +149,44 @@ function extractInboxAgent(item) {
149
149
  return null;
150
150
  }
151
151
 
152
+ function extractInboxProject(item) {
153
+ const content = String(item?.content || '');
154
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
155
+ if (!fmMatch) return '';
156
+ const projectLine = fmMatch[1].split('\n').find(l => /^project:\s*/i.test(l));
157
+ return projectLine ? projectLine.replace(/^project:\s*/i, '').trim() : '';
158
+ }
159
+
160
+ function _indexMemoryRecord(record) {
161
+ try {
162
+ return require('./memory-store').upsertMemoryRecord(record);
163
+ } catch (err) {
164
+ log('warn', `Structured memory index write failed (${record?.sourceRef || 'unknown'}): ${err.message}`);
165
+ return null;
166
+ }
167
+ }
168
+
169
+ function _indexAgentMemoryItem(item, agent) {
170
+ const content = String(item?.content || '').trim();
171
+ if (!content) return null;
172
+ const titleMatch = content.match(/^#\s+(.+)/m);
173
+ return _indexMemoryRecord({
174
+ memoryType: 'semantic',
175
+ scopeType: 'agent',
176
+ scopeKey: agent,
177
+ title: titleMatch ? titleMatch[1].trim() : String(item.name || 'Agent learning').replace(/\.md$/, ''),
178
+ body: content,
179
+ tags: ['agent-memory', agent],
180
+ sourceType: 'inbox',
181
+ sourcePath: `notes/inbox/${item.name}`,
182
+ sourceRef: String(item.name),
183
+ trust: 'agent',
184
+ importance: 0.6,
185
+ confidence: 0.6,
186
+ metadata: { agent, project: extractInboxProject(item) || null },
187
+ });
188
+ }
189
+
152
190
  /**
153
191
  * Append an inbox item to its author's personal memory file when the agent
154
192
  * is a known team member (must be present in `knownAgents`) and not a
@@ -207,6 +245,7 @@ function appendToAgentMemory(item, knownAgents, config) {
207
245
  const next = pruneAgentMemoryToBudget(existing + entry, agent, _pruneOptsFromConfig(config));
208
246
  safeWrite(memPath, next);
209
247
  });
248
+ _indexAgentMemoryItem(item, agent);
210
249
  return true;
211
250
  } catch (err) {
212
251
  log('warn', `Failed to append to knowledge/agents/${agent}.md: ${err.message}`);
@@ -571,7 +610,22 @@ function reconcileAndAppendToAgentMemory(item, knownAgents, config) {
571
610
  lockErr = err;
572
611
  }
573
612
 
574
- if (reconciled) return true;
613
+ if (reconciled) {
614
+ const indexed = _indexAgentMemoryItem(item, agent);
615
+ if (indexed) {
616
+ try {
617
+ require('./memory-store').supersedeMatchingRecords({
618
+ scopeType: 'agent',
619
+ scopeKey: agent,
620
+ oldTexts: edits.map(edit => edit.old_text),
621
+ supersededBy: indexed.id,
622
+ });
623
+ } catch (err) {
624
+ log('warn', `Structured memory reconcile transition failed for ${agent}: ${err.message}`);
625
+ }
626
+ }
627
+ return true;
628
+ }
575
629
  if (lockErr) log('warn', `agent-memory reconcile: lock/write error for ${agent}: ${lockErr.message} — plain append`);
576
630
  return appendToAgentMemory(item, knownAgents, config);
577
631
  }).catch((err) => {
@@ -1291,6 +1345,22 @@ async function classifyToKnowledgeBase(items, config) {
1291
1345
  const frontmatter = `---\nsource: ${item.name}\nagent: ${agent}\ncategory: ${category}\ndate: ${dateStamp()}\nreusable: ${reusable}${isSystemAlert ? `\nalertHash: ${alertHash}` : ''}\n---\n\n`;
1292
1346
  try {
1293
1347
  safeWrite(kbPath, frontmatter + kbBody);
1348
+ const project = extractInboxProject(item);
1349
+ _indexMemoryRecord({
1350
+ memoryType: 'semantic',
1351
+ scopeType: project ? 'project' : 'global',
1352
+ scopeKey: project || '',
1353
+ title: titleMatch ? titleMatch[1].trim() : titleSlug,
1354
+ body: frontmatter + kbBody,
1355
+ tags: [category, agent, reusable ? 'reusable' : 'reference'],
1356
+ sourceType: 'knowledge-base',
1357
+ sourcePath: path.relative(shared.MINIONS_DIR, kbPath).replace(/\\/g, '/'),
1358
+ sourceRef: item.name,
1359
+ trust: 'agent',
1360
+ importance: reusable ? 0.8 : 0.4,
1361
+ confidence: 0.6,
1362
+ metadata: { category, agent, reusable, inbox: item.name },
1363
+ });
1294
1364
  classified++;
1295
1365
  } catch (err) {
1296
1366
  log('warn', `Failed to classify ${item.name} to knowledge base: ${err.message}`);
@@ -1430,6 +1500,7 @@ module.exports = {
1430
1500
  checkDuplicateHash,
1431
1501
  // per-agent memory routing
1432
1502
  extractInboxAgent,
1503
+ extractInboxProject,
1433
1504
  appendToAgentMemory,
1434
1505
  reconcileAndAppendToAgentMemory,
1435
1506
  pruneAgentMemoryToBudget,
@@ -0,0 +1,208 @@
1
+ // engine/db/migrations/017-agent-memory.js
2
+ //
3
+ // Structured long-term memory for task-aware retrieval. Markdown remains the
4
+ // operator-facing source/mirror during rollout; these rows are the searchable,
5
+ // provenance-preserving representation used by dispatch.
6
+
7
+ const crypto = require('crypto');
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+
11
+ const KB_CATEGORIES = [
12
+ 'architecture', 'conventions', 'project-notes', 'build-reports', 'reviews',
13
+ 'learnings', 'decisions', 'incidents', 'api-notes',
14
+ ];
15
+
16
+ function _resolveMinionsDir() {
17
+ return process.env.MINIONS_TEST_DIR
18
+ || process.env.MINIONS_HOME
19
+ || path.resolve(__dirname, '..', '..', '..');
20
+ }
21
+
22
+ function _read(filePath) {
23
+ try { return fs.readFileSync(filePath, 'utf8'); } catch { return ''; }
24
+ }
25
+
26
+ function _title(content, fallback) {
27
+ const match = String(content || '').match(/^#\s+(.+)/m);
28
+ return match ? match[1].trim() : fallback;
29
+ }
30
+
31
+ function _recordId(sourceType, sourceRef, content) {
32
+ const hash = crypto.createHash('sha256').update(`${sourceType}\0${sourceRef}\0${content}`).digest('hex');
33
+ return `M-${hash.slice(0, 24)}`;
34
+ }
35
+
36
+ function _contentHash(content) {
37
+ return crypto.createHash('sha256').update(String(content || '')).digest('hex');
38
+ }
39
+
40
+ function _splitAgentMemory(content) {
41
+ const boundary = /(?=\n---\n\n### \d{4}-\d{2}-\d{2}:)/g;
42
+ const parts = String(content || '').split(boundary).map(s => s.trim()).filter(Boolean);
43
+ return parts.length ? parts : [String(content || '')];
44
+ }
45
+
46
+ function _splitTeamNotes(content) {
47
+ const parts = String(content || '').split(/(?=^### \d{4}-\d{2}-\d{2})/m).map(s => s.trim()).filter(Boolean);
48
+ return parts.length ? parts : [String(content || '')];
49
+ }
50
+
51
+ module.exports = {
52
+ version: 17,
53
+ description: 'structured agent memory records, FTS5 retrieval, and retrieval telemetry',
54
+ up(db) {
55
+ db.exec(`
56
+ CREATE TABLE memory_records (
57
+ id TEXT PRIMARY KEY,
58
+ memory_type TEXT NOT NULL,
59
+ scope_type TEXT NOT NULL,
60
+ scope_key TEXT NOT NULL DEFAULT '',
61
+ title TEXT NOT NULL,
62
+ body TEXT NOT NULL,
63
+ tags TEXT NOT NULL DEFAULT '[]',
64
+ source_type TEXT NOT NULL,
65
+ source_path TEXT,
66
+ source_ref TEXT NOT NULL,
67
+ trust TEXT NOT NULL DEFAULT 'agent',
68
+ importance REAL NOT NULL DEFAULT 0.5,
69
+ confidence REAL NOT NULL DEFAULT 0.5,
70
+ status TEXT NOT NULL DEFAULT 'active',
71
+ valid_from INTEGER,
72
+ valid_to INTEGER,
73
+ supersedes_id TEXT,
74
+ superseded_by TEXT,
75
+ content_hash TEXT NOT NULL,
76
+ metadata TEXT NOT NULL DEFAULT '{}',
77
+ created_at INTEGER NOT NULL,
78
+ updated_at INTEGER NOT NULL
79
+ );
80
+ CREATE UNIQUE INDEX idx_memory_source_content
81
+ ON memory_records(source_type, source_ref, content_hash);
82
+ CREATE INDEX idx_memory_scope_status
83
+ ON memory_records(scope_type, scope_key, status);
84
+ CREATE INDEX idx_memory_type_status
85
+ ON memory_records(memory_type, status);
86
+ CREATE INDEX idx_memory_updated
87
+ ON memory_records(updated_at DESC);
88
+ CREATE INDEX idx_memory_supersedes
89
+ ON memory_records(supersedes_id);
90
+
91
+ CREATE VIRTUAL TABLE memory_records_fts USING fts5(
92
+ title,
93
+ body,
94
+ tags,
95
+ source_path,
96
+ content='memory_records',
97
+ content_rowid='rowid'
98
+ );
99
+
100
+ CREATE TRIGGER memory_records_ai AFTER INSERT ON memory_records BEGIN
101
+ INSERT INTO memory_records_fts(rowid, title, body, tags, source_path)
102
+ VALUES (new.rowid, new.title, new.body, new.tags, COALESCE(new.source_path, ''));
103
+ END;
104
+ CREATE TRIGGER memory_records_ad AFTER DELETE ON memory_records BEGIN
105
+ INSERT INTO memory_records_fts(memory_records_fts, rowid, title, body, tags, source_path)
106
+ VALUES ('delete', old.rowid, old.title, old.body, old.tags, COALESCE(old.source_path, ''));
107
+ END;
108
+ CREATE TRIGGER memory_records_au AFTER UPDATE OF title, body, tags, source_path ON memory_records BEGIN
109
+ INSERT INTO memory_records_fts(memory_records_fts, rowid, title, body, tags, source_path)
110
+ VALUES ('delete', old.rowid, old.title, old.body, old.tags, COALESCE(old.source_path, ''));
111
+ INSERT INTO memory_records_fts(rowid, title, body, tags, source_path)
112
+ VALUES (new.rowid, new.title, new.body, new.tags, COALESCE(new.source_path, ''));
113
+ END;
114
+
115
+ CREATE TABLE memory_retrieval_runs (
116
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
117
+ work_item_id TEXT,
118
+ project TEXT,
119
+ agent TEXT,
120
+ query TEXT NOT NULL,
121
+ candidate_count INTEGER NOT NULL,
122
+ selected_count INTEGER NOT NULL,
123
+ selected_bytes INTEGER NOT NULL,
124
+ duration_ms REAL NOT NULL,
125
+ shadow INTEGER NOT NULL DEFAULT 1,
126
+ created_at INTEGER NOT NULL
127
+ );
128
+ CREATE INDEX idx_memory_retrieval_created
129
+ ON memory_retrieval_runs(created_at DESC);
130
+ `);
131
+
132
+ const minionsDir = _resolveMinionsDir();
133
+ if (!minionsDir) return;
134
+
135
+ const insert = db.prepare(`
136
+ INSERT OR IGNORE INTO memory_records (
137
+ id, memory_type, scope_type, scope_key, title, body, tags,
138
+ source_type, source_path, source_ref, trust, importance, confidence,
139
+ status, valid_from, content_hash, metadata, created_at, updated_at
140
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)
141
+ `);
142
+ const now = Date.now();
143
+ const add = ({ type = 'semantic', scopeType = 'global', scopeKey = '', title, body,
144
+ tags = [], sourceType = 'legacy-markdown', sourcePath, sourceRef,
145
+ trust = 'agent', importance = 0.5, metadata = {} }) => {
146
+ if (!body || !String(body).trim()) return;
147
+ let mtime = now;
148
+ try { mtime = Math.round(fs.statSync(path.join(minionsDir, sourcePath)).mtimeMs); } catch {}
149
+ insert.run(
150
+ _recordId(sourceType, sourceRef, body),
151
+ type, scopeType, scopeKey, title || sourceRef, body, JSON.stringify(tags),
152
+ sourceType, sourcePath, sourceRef, trust, importance, 0.5,
153
+ mtime, _contentHash(body), JSON.stringify(metadata), mtime, now,
154
+ );
155
+ };
156
+
157
+ for (const category of KB_CATEGORIES) {
158
+ const dir = path.join(minionsDir, 'knowledge', category);
159
+ let files = [];
160
+ try { files = fs.readdirSync(dir).filter(f => f.endsWith('.md')); } catch {}
161
+ for (const file of files) {
162
+ const sourcePath = path.join('knowledge', category, file).replace(/\\/g, '/');
163
+ const body = _read(path.join(minionsDir, sourcePath));
164
+ add({
165
+ title: _title(body, file.replace(/\.md$/, '')),
166
+ body,
167
+ tags: [category],
168
+ sourceType: 'knowledge-base',
169
+ sourcePath,
170
+ sourceRef: sourcePath,
171
+ metadata: { category },
172
+ });
173
+ }
174
+ }
175
+
176
+ const agentDir = path.join(minionsDir, 'knowledge', 'agents');
177
+ let agentFiles = [];
178
+ try { agentFiles = fs.readdirSync(agentDir).filter(f => f.endsWith('.md') && f !== 'README.md'); } catch {}
179
+ for (const file of agentFiles) {
180
+ const agent = file.replace(/\.md$/, '').toLowerCase();
181
+ const sourcePath = path.join('knowledge', 'agents', file).replace(/\\/g, '/');
182
+ const body = _read(path.join(minionsDir, sourcePath));
183
+ _splitAgentMemory(body).forEach((section, index) => add({
184
+ scopeType: 'agent',
185
+ scopeKey: agent,
186
+ title: _title(section, `${agent} memory ${index + 1}`),
187
+ body: section,
188
+ tags: ['agent-memory', agent],
189
+ sourceType: 'agent-memory',
190
+ sourcePath,
191
+ sourceRef: `${sourcePath}#${index + 1}`,
192
+ metadata: { agent, section: index + 1 },
193
+ }));
194
+ }
195
+
196
+ const notesPath = 'notes.md';
197
+ const notes = _read(path.join(minionsDir, notesPath));
198
+ _splitTeamNotes(notes).forEach((section, index) => add({
199
+ title: _title(section, `Team notes ${index + 1}`),
200
+ body: section,
201
+ tags: ['team-notes'],
202
+ sourceType: 'team-notes',
203
+ sourcePath: notesPath,
204
+ sourceRef: `${notesPath}#${index + 1}`,
205
+ metadata: { section: index + 1 },
206
+ }));
207
+ },
208
+ };
@@ -95,6 +95,12 @@ const FEATURES = {
95
95
  addedIn: '0.1.2232',
96
96
  expires: '2026-12-01',
97
97
  },
98
+ 'memoryRetrieval': {
99
+ description: 'Select bounded task-relevant long-term memory with SQLite FTS5 instead of injecting only recent team and personal notes. Starts in shadow mode by default.',
100
+ default: false,
101
+ addedIn: '0.1.2233',
102
+ expires: '2027-01-31',
103
+ },
98
104
  };
99
105
 
100
106
  const ENV_TRUTHY = new Set(['1', 'true', 'on', 'yes']);