@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.
- package/dashboard/js/memory-search.js +112 -0
- package/dashboard/js/render-kb.js +15 -0
- package/dashboard/js/settings.js +15 -0
- package/dashboard/pages/inbox.html +1 -1
- package/dashboard.js +68 -1
- package/docs/team-memory.md +18 -4
- package/engine/consolidation.js +72 -1
- package/engine/db/migrations/017-agent-memory.js +208 -0
- package/engine/features.js +6 -0
- package/engine/lifecycle.js +83 -0
- package/engine/memory-retrieval.js +165 -0
- package/engine/memory-store.js +365 -0
- package/engine/playbook.js +56 -2
- package/engine/shared.js +5 -0
- package/package.json +1 -1
|
@@ -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 =
|
package/dashboard/js/settings.js
CHANGED
|
@@ -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 & 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
|
-
|
|
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 });
|
package/docs/team-memory.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Team Memory
|
|
2
2
|
|
|
3
|
-
Minions agents share knowledge through a layered
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
package/engine/consolidation.js
CHANGED
|
@@ -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)
|
|
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
|
+
};
|
package/engine/features.js
CHANGED
|
@@ -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']);
|