@yemi33/minions 0.1.551 → 0.1.552

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/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.552 (2026-04-07)
4
+
5
+ ### Features
6
+ - temporary pin-to-top for inbox and KB items
7
+
3
8
  ## 0.1.551 (2026-04-07)
4
9
 
5
10
  ### Fixes
@@ -9,6 +9,23 @@ let _ccAbortController = null;
9
9
  // Clear stale sending state on page load — SSE streams don't survive refresh
10
10
  try { localStorage.removeItem('cc-sending'); } catch {}
11
11
 
12
+ function _ccFindPinTarget(query) {
13
+ for (var i = 0; i < (inboxData || []).length; i++) {
14
+ if (inboxData[i].name.toLowerCase().includes(query)) {
15
+ return { key: inboxPinKey(inboxData[i].name), label: inboxData[i].name };
16
+ }
17
+ }
18
+ for (var [cat, items] of Object.entries(_kbData || {})) {
19
+ if (!Array.isArray(items)) continue;
20
+ for (var j = 0; j < items.length; j++) {
21
+ if ((items[j].title || '').toLowerCase().includes(query) || (items[j].file || '').toLowerCase().includes(query)) {
22
+ return { key: kbPinKey(cat, items[j].file), label: items[j].title || items[j].file };
23
+ }
24
+ }
25
+ }
26
+ return null;
27
+ }
28
+
12
29
  function ccAbort() {
13
30
  if (_ccAbortController) {
14
31
  _ccAbortController.abort();
@@ -165,6 +182,26 @@ function _renderQueueIndicator() {
165
182
  }
166
183
 
167
184
  async function _ccDoSend(message, skipUserMsg) {
185
+ // Client-side /pin and /unpin — no LLM round-trip needed
186
+ var pinMatch = message.match(/^\/(pin|unpin)\s+(.+)/i);
187
+ if (pinMatch) {
188
+ if (!skipUserMsg) ccAddMessage('user', escHtml(message));
189
+ var pinAction = pinMatch[1].toLowerCase();
190
+ var pinQuery = pinMatch[2].toLowerCase().trim();
191
+ var found = _ccFindPinTarget(pinQuery);
192
+ if (found) {
193
+ var wasPinned = isPinned(found.key);
194
+ if (pinAction === 'pin' && !wasPinned) { togglePin(found.key); ccAddMessage('assistant', 'Pinned <strong>' + escHtml(found.label) + '</strong> to top'); }
195
+ else if (pinAction === 'unpin' && wasPinned) { togglePin(found.key); ccAddMessage('assistant', 'Unpinned <strong>' + escHtml(found.label) + '</strong>'); }
196
+ else { ccAddMessage('assistant', '<strong>' + escHtml(found.label) + '</strong> is already ' + (wasPinned ? 'pinned' : 'unpinned')); }
197
+ showToast('cmd-toast', pinAction === 'pin' ? 'Pinned to top' : 'Unpinned', true);
198
+ renderInbox(inboxData); renderKnowledgeBase();
199
+ } else {
200
+ ccAddMessage('assistant', 'No inbox or KB item matching "' + escHtml(pinQuery) + '"');
201
+ }
202
+ return;
203
+ }
204
+
168
205
  _ccSending = true;
169
206
  _ccAbortController = new AbortController();
170
207
  try { localStorage.setItem('cc-sending', JSON.stringify({ sending: true, startedAt: Date.now() })); } catch {}
@@ -7,7 +7,12 @@ function _inboxPrev() { if (_inboxPage > 0) { _inboxPage--; renderInbox(inboxDat
7
7
  function _inboxNext() { _inboxPage++; renderInbox(inboxData); }
8
8
 
9
9
  function renderInbox(inbox) {
10
+ invalidatePinsCache();
10
11
  inbox = inbox.filter(function(item) { return !isDeleted('inbox:' + item.name); });
12
+ // Stable sort — pinned items float to top
13
+ inbox.sort(function(a, b) {
14
+ return (isPinned(inboxPinKey(a.name)) ? 0 : 1) - (isPinned(inboxPinKey(b.name)) ? 0 : 1);
15
+ });
11
16
  inboxData = inbox;
12
17
  const list = document.getElementById('inbox-list');
13
18
  const count = document.getElementById('inbox-count');
@@ -22,12 +27,15 @@ function renderInbox(inbox) {
22
27
 
23
28
  list.innerHTML = pageInbox.map((item, i) => {
24
29
  const idx = inboxStart + i;
25
- return `<div class="inbox-item" data-file="notes/inbox/${escHtml(item.name)}">
30
+ const pk = inboxPinKey(item.name);
31
+ const pinned = isPinned(pk);
32
+ return `<div class="inbox-item${pinned ? ' item-pinned' : ''}" data-file="notes/inbox/${escHtml(item.name)}">
26
33
  <div class="inbox-name" onclick="openModal(${idx})" style="cursor:pointer">
27
34
  <span>${escHtml(item.name)}</span><span>${escHtml(item.age || '')}</span>
28
35
  </div>
29
36
  <div class="inbox-preview" onclick="openModal(${idx})" style="cursor:pointer">${escHtml(item.content.slice(0,200))}</div>
30
37
  <div style="display:flex;gap:6px;margin-top:6px;align-items:center">
38
+ <button class="pr-pager-btn pin-btn${pinned ? ' pinned' : ''}" style="font-size:9px;padding:2px 8px" data-pin-key="${escHtml(pk)}" onclick="event.stopPropagation();_togglePinAndRefresh(this.dataset.pinKey,'inbox')">${pinned ? 'Unpin' : 'Pin'}</button>
31
39
  <button class="pr-pager-btn" style="font-size:9px;padding:2px 8px" data-inbox-name="${escHtml(item.name)}" onclick="event.stopPropagation();promoteToKB(this.dataset.inboxName)">Add to Knowledge Base</button>
32
40
  <button class="pr-pager-btn" style="font-size:9px;padding:2px 8px" data-inbox-name="${escHtml(item.name)}" onclick="event.stopPropagation();openInboxInExplorer(this.dataset.inboxName)">Open in Explorer</button>
33
41
  <button class="pr-pager-btn" style="font-size:9px;padding:2px 8px;color:var(--red)" data-inbox-name="${escHtml(item.name)}" onclick="event.stopPropagation();deleteInboxItem(this.dataset.inboxName)">Delete</button>
@@ -32,40 +32,61 @@ function renderKnowledgeBase() {
32
32
  const listEl = document.getElementById('kb-list');
33
33
  const countEl = document.getElementById('kb-count');
34
34
 
35
- // Count total (skip non-array keys like lastSwept)
36
- let total = 0;
37
- for (const [k, v] of Object.entries(_kbData)) if (Array.isArray(v)) total += v.length;
38
- countEl.textContent = total;
35
+ invalidatePinsCache();
36
+
37
+ // Single pass: flatten all KB items and count pinned
38
+ const allItems = [];
39
+ let pinnedCount = 0;
40
+ for (const [cat, catItems] of Object.entries(_kbData)) {
41
+ if (!Array.isArray(catItems)) continue;
42
+ for (const item of catItems) {
43
+ const entry = { ...item, category: cat };
44
+ allItems.push(entry);
45
+ if (isPinned(kbPinKey(cat, item.file))) pinnedCount++;
46
+ }
47
+ }
48
+ countEl.textContent = allItems.length;
39
49
 
40
- // Last swept timestamp
41
50
  const sweptEl = document.getElementById('kb-swept-time');
42
51
  if (sweptEl) sweptEl.textContent = _kbData.lastSwept ? 'swept ' + timeSinceStr(new Date(_kbData.lastSwept)) : '';
43
52
 
44
- if (total === 0) {
53
+ if (allItems.length === 0) {
45
54
  tabsEl.innerHTML = '';
46
55
  listEl.innerHTML = '<p class="empty">No knowledge entries yet. Notes are classified here after consolidation.</p>';
47
56
  return;
48
57
  }
49
58
 
59
+ if (_kbActiveTab === 'pinned' && pinnedCount === 0) _kbActiveTab = 'all';
60
+
50
61
  // Render tabs
51
- let tabsHtml = '<button class="kb-tab ' + (_kbActiveTab === 'all' ? 'active' : '') + '" onclick="kbSetTab(\'all\')">All <span class="badge">' + total + '</span></button>';
52
- for (const [cat, items] of Object.entries(_kbData)) {
53
- if (!Array.isArray(items) || items.length === 0) continue;
62
+ let tabsHtml = '';
63
+ if (pinnedCount > 0) {
64
+ tabsHtml += '<button class="kb-tab ' + (_kbActiveTab === 'pinned' ? 'active' : '') + '" style="color:var(--yellow)" onclick="kbSetTab(\'pinned\')">Pinned <span class="badge">' + pinnedCount + '</span></button>';
65
+ }
66
+ tabsHtml += '<button class="kb-tab ' + (_kbActiveTab === 'all' ? 'active' : '') + '" onclick="kbSetTab(\'all\')">All <span class="badge">' + allItems.length + '</span></button>';
67
+ for (const [cat, catArr] of Object.entries(_kbData)) {
68
+ if (!Array.isArray(catArr) || catArr.length === 0) continue;
54
69
  const label = KB_CAT_LABELS[cat] || cat;
55
- tabsHtml += '<button class="kb-tab ' + (_kbActiveTab === cat ? 'active' : '') + '" onclick="kbSetTab(\'' + cat + '\')">' + label + ' <span class="badge">' + items.length + '</span></button>';
70
+ tabsHtml += '<button class="kb-tab ' + (_kbActiveTab === cat ? 'active' : '') + '" onclick="kbSetTab(\'' + cat + '\')">' + label + ' <span class="badge">' + catArr.length + '</span></button>';
56
71
  }
57
72
  tabsEl.innerHTML = tabsHtml;
58
73
 
59
- // Collect items for active tab
60
- let items = [];
61
- if (_kbActiveTab === 'all') {
62
- for (const [cat, catItems] of Object.entries(_kbData)) {
63
- if (!Array.isArray(catItems)) continue;
64
- for (const item of catItems) items.push({ ...item, category: cat });
65
- }
74
+ // Filter items for active tab
75
+ let items;
76
+ if (_kbActiveTab === 'pinned') {
77
+ items = allItems.filter(i => isPinned(kbPinKey(i.category, i.file)));
78
+ } else if (_kbActiveTab === 'all') {
79
+ items = allItems.slice();
66
80
  items.sort((a, b) => (b.date || '').localeCompare(a.date || ''));
67
81
  } else {
68
- items = (_kbData[_kbActiveTab] || []).map(i => ({ ...i, category: _kbActiveTab }));
82
+ items = allItems.filter(i => i.category === _kbActiveTab);
83
+ }
84
+
85
+ // Stable sort — pinned items float to top (skip on pinned tab where all are pinned)
86
+ if (_kbActiveTab !== 'pinned') {
87
+ items.sort(function(a, b) {
88
+ return (isPinned(kbPinKey(a.category, a.file)) ? 0 : 1) - (isPinned(kbPinKey(b.category, b.file)) ? 0 : 1);
89
+ });
69
90
  }
70
91
 
71
92
  if (items.length === 0) {
@@ -82,9 +103,13 @@ function renderKnowledgeBase() {
82
103
  listEl.innerHTML = pageItems.map(item => {
83
104
  const icon = KB_CAT_ICONS[item.category] || '\u{1F4C4}';
84
105
  const label = KB_CAT_LABELS[item.category] || item.category;
85
- return '<div class="kb-item" data-file="knowledge/' + escHtml(item.category) + '/' + escHtml(item.file) + '" onclick="kbOpenItem(\'' + escHtml(item.category) + '\', \'' + escHtml(item.file) + '\')">' +
106
+ var pinKey = kbPinKey(item.category, item.file);
107
+ var pinned = isPinned(pinKey);
108
+ return '<div class="kb-item' + (pinned ? ' item-pinned' : '') + '" data-file="knowledge/' + escHtml(item.category) + '/' + escHtml(item.file) + '" onclick="kbOpenItem(\'' + escHtml(item.category) + '\', \'' + escHtml(item.file) + '\')">' +
86
109
  '<div class="kb-item-body">' +
87
- '<div class="kb-item-title">' + icon + ' ' + escHtml(item.title) + '</div>' +
110
+ '<div class="kb-item-title">' + icon + ' ' + escHtml(item.title) +
111
+ ' <button class="pr-pager-btn pin-btn' + (pinned ? ' pinned' : '') + '" style="font-size:9px;padding:1px 6px;margin-left:6px;vertical-align:middle" data-pin-key="' + escHtml(pinKey) + '" onclick="event.stopPropagation();_togglePinAndRefresh(this.dataset.pinKey,\'kb\')">' + (pinned ? 'Unpin' : 'Pin') + '</button>' +
112
+ '</div>' +
88
113
  '<div class="kb-item-meta">' +
89
114
  '<span>' + label + '</span>' +
90
115
  (item.agent ? '<span>' + escHtml(item.agent) + '</span>' : '') +
@@ -8,6 +8,33 @@ const _deletedIds = new Map(); // key → expiry timestamp
8
8
  function markDeleted(key) { _deletedIds.set(key, Date.now() + 10000); } // suppress for 10s
9
9
  function isDeleted(key) { const exp = _deletedIds.get(key); if (!exp) return false; if (Date.now() > exp) { _deletedIds.delete(key); return false; } return true; }
10
10
 
11
+ // Temporary pin-to-top — UI-only, stored in localStorage, does not affect agents
12
+ const PINS_KEY = 'minions-pinned-items';
13
+ let _pinsCache = null;
14
+ function invalidatePinsCache() { _pinsCache = null; }
15
+ function getPinnedItems() {
16
+ if (_pinsCache) return _pinsCache;
17
+ try { _pinsCache = JSON.parse(localStorage.getItem(PINS_KEY) || '[]'); } catch { _pinsCache = []; }
18
+ return _pinsCache;
19
+ }
20
+ function isPinned(key) { return getPinnedItems().includes(key); }
21
+ function togglePin(key) {
22
+ const pins = getPinnedItems();
23
+ const idx = pins.indexOf(key);
24
+ if (idx >= 0) pins.splice(idx, 1); else pins.unshift(key);
25
+ localStorage.setItem(PINS_KEY, JSON.stringify(pins));
26
+ invalidatePinsCache();
27
+ return idx < 0; // true if now pinned
28
+ }
29
+ function inboxPinKey(name) { return 'notes/inbox/' + name; }
30
+ function kbPinKey(cat, file) { return 'knowledge/' + cat + '/' + file; }
31
+ function _togglePinAndRefresh(key, source) {
32
+ var pinned = togglePin(key);
33
+ showToast('cmd-toast', pinned ? 'Pinned to top' : 'Unpinned', true);
34
+ if (source === 'inbox') renderInbox(inboxData);
35
+ else if (source === 'kb') renderKnowledgeBase();
36
+ }
37
+
11
38
  function escHtml(s) {
12
39
  return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
13
40
  }
@@ -217,12 +244,19 @@ function _renderMdChunked(fullText) {
217
244
  var target = pos + MD_CHUNK_SIZE;
218
245
  var searchStart = Math.max(pos + Math.floor(MD_CHUNK_SIZE * 0.7), pos);
219
246
  var searchEnd = Math.min(target + 500, fullText.length);
220
- var slice = fullText.slice(searchStart, searchEnd);
221
- var lastBlank = slice.lastIndexOf('\n\n');
222
- var best;
223
- if (lastBlank !== -1) {
224
- best = searchStart + lastBlank + 2;
225
- } else {
247
+ // Find the last \n\n in [searchStart, searchEnd] that isn't inside a code fence
248
+ var fencesBefore = (fullText.slice(pos, searchStart).match(/```/g) || []).length;
249
+ var inFence = (fencesBefore % 2) === 1;
250
+ var best = null;
251
+ for (var si = searchStart; si < searchEnd - 1; si++) {
252
+ if (fullText[si] === '`' && fullText[si + 1] === '`' && fullText[si + 2] === '`') {
253
+ inFence = !inFence;
254
+ si += 2;
255
+ } else if (!inFence && fullText[si] === '\n' && fullText[si + 1] === '\n') {
256
+ best = si + 2;
257
+ }
258
+ }
259
+ if (best === null) {
226
260
  var nl = fullText.indexOf('\n', target);
227
261
  best = (nl !== -1 && nl - target < 500) ? nl + 1 : target;
228
262
  }
@@ -154,6 +154,7 @@
154
154
  .kb-item { display: flex; align-items: flex-start; gap: var(--space-5); padding: var(--space-4) 0; border-bottom: 1px solid var(--border); cursor: pointer; transition: background var(--transition-fast); }
155
155
  .kb-item:hover { background: var(--surface2); }
156
156
  .kb-item:last-child { border-bottom: none; }
157
+ .kb-item.item-pinned { border-left: 3px solid var(--yellow); padding-left: var(--space-4); background: rgba(210, 153, 34, 0.05); }
157
158
  .kb-item-body { flex: 1; min-width: 0; }
158
159
  .kb-item-title { font-size: var(--text-md); color: var(--text); font-weight: 500; }
159
160
  .kb-item-meta { font-size: var(--text-sm); color: var(--muted); margin-top: var(--space-1); display: flex; gap: var(--space-4); }
@@ -211,6 +212,10 @@
211
212
  .notes-preview:hover { border-color: var(--blue); }
212
213
  .inbox-item { background: var(--surface2); border: 1px solid var(--border); border-left: 3px solid var(--purple); border-radius: var(--radius-sm); padding: var(--space-5) var(--space-6); cursor: pointer; }
213
214
  .inbox-item:hover { border-color: var(--blue); border-left-color: var(--blue); }
215
+ .item-pinned { border-left-color: var(--yellow); background: rgba(210, 153, 34, 0.05); }
216
+ .item-pinned:hover { border-left-color: var(--yellow); }
217
+ .pin-btn { color: var(--muted); border-color: var(--border); }
218
+ .pin-btn.pinned { color: var(--yellow); border-color: var(--yellow); }
214
219
  .inbox-name { font-weight: 500; font-size: var(--text-md); color: var(--purple); margin-bottom: var(--space-2); display: flex; justify-content: space-between; }
215
220
  .inbox-preview { font-size: var(--text-base); color: var(--muted); line-height: 1.5; max-height: 60px; overflow: hidden; }
216
221
 
@@ -0,0 +1,431 @@
1
+ # Design: Replacing File-Based State with a Structured Database
2
+
3
+ > Author: Rebecca (Architect) | Date: 2026-04-07 | Status: Proposal
4
+
5
+ ## Executive Summary
6
+
7
+ Minions persists all runtime state as flat JSON files guarded by file-lock-based concurrency (`mutateJsonFileLocked`). This analysis evaluates five options for migrating to a structured database, benchmarks each against the current approach, and delivers a phased recommendation.
8
+
9
+ **Verdict:** Stay with improved file-based state short-term. Adopt `node:sqlite` (`DatabaseSync`) as the medium-term target once it exits experimental status, migrating the highest-pain state files first.
10
+
11
+ ---
12
+
13
+ ## 1. Current State Architecture
14
+
15
+ ### 1.1 State Files Inventory
16
+
17
+ | File | Size (live) | Records | Access Pattern | Contention | Pain Level |
18
+ |------|-------------|---------|----------------|------------|------------|
19
+ | `engine/dispatch.json` | 380 KB | 102 completed + 2 active | R/W every tick; 10+ reads/tick (2s cache) | **High** — engine + dashboard + lifecycle | High |
20
+ | `engine/log.json` | 292 KB | 2,162 entries | Append-only (buffered flush every 500ms) | Medium — log buffer serializes | Medium |
21
+ | `engine/cooldowns.json` | 511 KB | 125 keys | R/W on dispatch failure + retry | Low — infrequent writes | High (bloated) |
22
+ | `engine/metrics.json` | 5 KB | Per-agent stats | R/W on PR approval/merge | Low | Low |
23
+ | `engine/control.json` | 169 B | Single object | R/W on start/stop/heartbeat | Low | Low |
24
+ | `projects/*/work-items.json` | 370 KB | 180 items | R/W every 1-2 ticks; dashboard reads on-demand | **High** — engine + lifecycle + dashboard | High |
25
+ | `projects/*/pull-requests.json` | 241 KB | 128 PRs | R/W every 6 ticks (polling); lifecycle writes | Medium | Medium |
26
+ | `engine/pipeline-runs.json` | 36 KB | Pipeline state | R/W on pipeline execution | Low | Low |
27
+ | `engine/schedule-runs.json` | 115 B | Last-run times | R every 10 ticks; W on schedule execution | Low | Low |
28
+
29
+ **Total live state:** ~1.8 MB across 9+ JSON files.
30
+
31
+ (source: `engine/shared.js:233-252` for locking, `engine/queries.js:57-61` for paths, live file sizes from `ls -la engine/*.json`)
32
+
33
+ ### 1.2 Concurrency Model
34
+
35
+ All mutations go through `mutateJsonFileLocked()` (source: `engine/shared.js:233-252`):
36
+
37
+ ```
38
+ acquire .lock file (exclusive create via fs.openSync 'wx')
39
+ → read JSON file (full parse)
40
+ → apply mutation function
41
+ → write entire file (atomic rename via safeWrite)
42
+ → create .backup sidecar
43
+ release .lock file
44
+ ```
45
+
46
+ Key properties:
47
+ - **Synchronous blocking** — `withFileLock` spins with `sleepMs(25)` until lock acquired or 5s timeout (source: `engine/shared.js:175-231`)
48
+ - **Whole-file granularity** — updating one field in one work item rewrites all 180 items (370 KB)
49
+ - **Stale lock recovery** — locks older than 60s are force-removed (source: `engine/shared.js:173`, `LOCK_STALE_MS`)
50
+ - **Read caching** — only `dispatch.json` has a 2s TTL cache (source: `engine/queries.js:82-91`)
51
+
52
+ ### 1.3 Read vs Write Ratio
53
+
54
+ | Consumer | Reads/tick | Writes/tick | Pattern |
55
+ |----------|-----------|-------------|---------|
56
+ | Engine tick cycle | ~15 | ~3 | Heavy read, selective write |
57
+ | Dashboard (per page load) | ~8 | 0 | Read-only display |
58
+ | Dashboard (user action) | ~2 | ~2 | Read-modify-write |
59
+ | PR polling (every 6 ticks) | ~4 | ~2 | Batch read-modify-write |
60
+ | Consolidation (every 10 ticks) | ~3 | ~2 | Read inbox files, write notes.md |
61
+
62
+ **Read:write ratio is approximately 8:1.** This strongly favors a system that can serve reads without locking (e.g., WAL mode).
63
+
64
+ ---
65
+
66
+ ## 2. Option Evaluation
67
+
68
+ ### 2.1 `node:sqlite` (DatabaseSync) — Node 22.5+
69
+
70
+ **Current status:** Available in Node v24.12.0 (this machine). Marked `ExperimentalWarning`. Synchronous API via `DatabaseSync`.
71
+
72
+ **Benchmark results** (measured on this machine):
73
+
74
+ | Operation | `node:sqlite` | File-based (current) |
75
+ |-----------|---------------|---------------------|
76
+ | Insert 1,000 rows (transaction) | 4.6 ms | N/A (no equivalent) |
77
+ | Single SELECT by status (200 rows) | 0.13 ms | 2.9 ms (full parse) + 0.06 ms (filter) |
78
+ | 100 individual UPDATEs (no txn) | 48.7 ms | ~270 ms (100x full rewrite) |
79
+ | 100 UPDATEs in transaction | 0.7 ms | N/A |
80
+ | SELECT all 1,000 rows | 0.7 ms | 2.9 ms (parse 370 KB) |
81
+
82
+ **Dependency story:** Zero npm dependencies. Ships with Node.js. No native addon build step.
83
+
84
+ **Cross-platform:** SQLite is compiled into Node itself — works identically on Windows, macOS, Linux.
85
+
86
+ **Concurrency model:**
87
+ - WAL mode allows concurrent readers with one writer (verified working — source: benchmark tests above)
88
+ - `DatabaseSync` is synchronous, matching the current blocking model exactly
89
+ - Transactions replace file locks — `BEGIN EXCLUSIVE` provides the same mutual exclusion
90
+ - Row-level updates eliminate whole-file rewrites
91
+
92
+ **Migration complexity:** High. ~40+ `safeJson` read sites across `engine.js`, `dashboard.js`, `queries.js`, `lifecycle.js`. ~20+ `mutateJsonFileLocked` write sites. Each needs conversion to prepared statements.
93
+
94
+ **JSON support:** SQLite JSON1 extension works — `json_extract()`, `json_each()` verified functional for dependency resolution queries.
95
+
96
+ **Pros:**
97
+ - Zero dependencies (built into Node)
98
+ - WAL mode eliminates read-write contention
99
+ - Row-level operations (no more 370 KB rewrites for one field change)
100
+ - Indexed queries (find pending items by status without scanning all items)
101
+ - Transactions provide stronger atomicity than file locks
102
+ - Single `.db` file replaces 9+ JSON files + 9 `.backup` + 9 `.lock` files
103
+
104
+ **Cons:**
105
+ - **Experimental API** — could change between Node versions with no migration path
106
+ - State files become opaque (can't `cat dispatch.json` for debugging)
107
+ - Schema migrations needed as data model evolves
108
+ - No async API yet (acceptable — current code is sync anyway)
109
+ - `ExperimentalWarning` printed on first import (suppressible with `--no-warnings=ExperimentalWarning`)
110
+
111
+ **Risk assessment:** The experimental status is the **only** serious blocker. The API surface is small (`DatabaseSync`, `prepare`, `exec`, `get`, `all`, `run`) and mirrors `better-sqlite3` closely — likely to stabilize without breaking changes. But "likely" is not "guaranteed."
112
+
113
+ ### 2.2 LevelDB / LMDB (Embedded Key-Value)
114
+
115
+ **Dependency story:** npm packages with native C/C++ addons. `level` (LevelDB wrapper) or `lmdb-js`.
116
+
117
+ **Cross-platform:** Requires native build toolchain (node-gyp, C++ compiler). Windows support historically fragile.
118
+
119
+ **Concurrency model:**
120
+ - LMDB: MVCC with zero-copy reads — excellent read performance
121
+ - LevelDB: Single-process lock; read-free but writes serialize
122
+
123
+ **Migration complexity:** Medium-high. Key-value model doesn't naturally support the relational queries needed (e.g., "find all pending work items for project X with unmet dependencies").
124
+
125
+ **Pros:**
126
+ - Battle-tested in production
127
+ - LMDB: extremely fast reads with memory-mapped I/O
128
+ - Supports ordered iteration (useful for log entries)
129
+
130
+ **Cons:**
131
+ - **Breaks zero-dependency principle** (hard stop)
132
+ - Native addon build complexity on Windows
133
+ - Key-value model is a poor fit for relational queries on work items
134
+ - Adds ~15-50 MB to install footprint
135
+
136
+ **Verdict:** Disqualified by the zero-dependency constraint.
137
+
138
+ ### 2.3 Better-SQLite3
139
+
140
+ **Dependency story:** npm package with native C addon. Prebuilt binaries available for most platforms via `prebuild-install`.
141
+
142
+ **Cross-platform:** Good — prebuilt binaries for Windows/macOS/Linux. Fallback to node-gyp if no prebuild.
143
+
144
+ **Concurrency model:** Identical to `node:sqlite` — synchronous, WAL mode, same SQLite engine underneath.
145
+
146
+ **Migration complexity:** Same as `node:sqlite` — the API is nearly identical (`db.prepare().run()`, `.get()`, `.all()`).
147
+
148
+ **Pros:**
149
+ - Most popular SQLite binding for Node.js (14M weekly downloads)
150
+ - Stable, well-maintained, extensive documentation
151
+ - Synchronous API (perfect match)
152
+ - Full SQLite feature set including JSON1
153
+
154
+ **Cons:**
155
+ - **Breaks zero-dependency principle** (hard stop)
156
+ - Native addon (prebuilt binaries help but don't eliminate all build issues)
157
+ - ~8 MB added to node_modules
158
+
159
+ **Verdict:** Disqualified by the zero-dependency constraint. However, if `node:sqlite` stabilizes with an API modeled on `better-sqlite3` (which it is), then `better-sqlite3` serves as a proven reference implementation.
160
+
161
+ ### 2.4 Improved File-Based Approach
162
+
163
+ Keep JSON files but address the worst pain points structurally.
164
+
165
+ **Specific improvements:**
166
+
167
+ | Improvement | Targets | Effort | Impact |
168
+ |-------------|---------|--------|--------|
169
+ | **Split dispatch.json** into `dispatch/pending.json`, `dispatch/active.json`, `dispatch/completed/` (per-entry files) | dispatch.json (380 KB) | Medium | Reduces lock contention — pending/active/completed are independently lockable |
170
+ | **Cap and rotate cooldowns.json** — delete entries older than 7 days | cooldowns.json (511 KB) | Low | 511 KB → ~20 KB |
171
+ | **Per-entity files for completed dispatches** — `dispatch/completed/{id}.json` | dispatch.json completed array | Medium | Eliminates growing array; reads become `readdir + filter` |
172
+ | **Add read caches** — extend 2s TTL cache from dispatch.json to work-items.json and pull-requests.json | queries.js | Low | 60-80% fewer disk reads per tick |
173
+ | **JSON Lines for log.json** — append-only `.jsonl` format | log.json (292 KB) | Low | Eliminates parse-entire-file-to-append; rotation becomes `tail -n 2000` |
174
+ | **Structured directory layout** — `state/{entity}/{id}.json` | All state files | High | Per-entity locking, but dramatically increases file count |
175
+
176
+ **Dependency story:** Zero — pure Node.js.
177
+
178
+ **Cross-platform:** Identical to current.
179
+
180
+ **Concurrency model:** Same file locks, but with finer granularity (per-entity instead of per-file).
181
+
182
+ **Migration complexity:** Low-medium. Read/write APIs stay the same shape; internal storage layout changes.
183
+
184
+ **Pros:**
185
+ - Zero risk — no new dependencies or experimental APIs
186
+ - Human-readable state (critical for debugging)
187
+ - Incremental migration (one file at a time)
188
+ - Preserves existing backup/restore pattern
189
+
190
+ **Cons:**
191
+ - Doesn't solve the fundamental problem: whole-file read-modify-write for arrays
192
+ - Per-entity files create thousands of small files (OS inode pressure on large deployments)
193
+ - No indexed queries — filtering still requires scanning all files
194
+ - Read caches add TTL staleness risk
195
+
196
+ ### 2.5 Hybrid: JSON Lines + Indexed Views
197
+
198
+ A creative zero-dep option: use append-only JSON Lines files as the write log, with periodic compaction into indexed JSON snapshots.
199
+
200
+ **How it works:**
201
+ 1. Writes append to `state/{entity}.jsonl` (no locking needed for appends)
202
+ 2. Reads come from a cached in-memory index (rebuilt from JSONL on startup)
203
+ 3. Periodic compaction rewrites the JSONL file, discarding superseded entries
204
+
205
+ **Pros:**
206
+ - Append-only writes are naturally lock-free
207
+ - In-memory index serves reads instantly
208
+ - Human-readable (JSONL is `cat`-able)
209
+ - Zero dependencies
210
+
211
+ **Cons:**
212
+ - Requires custom index implementation (error-prone)
213
+ - Compaction logic is complex to get right under concurrent access
214
+ - Crash recovery requires replaying the full JSONL file
215
+ - Reinventing a (bad) database
216
+
217
+ **Verdict:** Too much complexity for marginal gain. If we're going to build database-like infrastructure, use an actual database.
218
+
219
+ ---
220
+
221
+ ## 3. Recommendation
222
+
223
+ ### Phase 1: Quick Wins (Now — 1-2 days effort)
224
+
225
+ Stay with files. Fix the two highest-pain issues immediately:
226
+
227
+ 1. **Cap `cooldowns.json`** — Add a cleanup sweep that deletes entries older than 7 days. This file is 511 KB with 125 keys, most of which are stale. Implement in `engine/cooldown.js` cleanup function. (source: `engine/cooldown.js`)
228
+
229
+ 2. **Cap `dispatch.json` completed array** more aggressively — Currently capped at 100 entries (source: `engine/dispatch.js:112-114`). Reduce to 50 or archive to `dispatch/completed/` directory. The 380 KB file is mostly completed entries.
230
+
231
+ 3. **Add read caches to `work-items.json` and `pull-requests.json`** — Same 2s TTL pattern as dispatch.json (source: `engine/queries.js:82-91`). These are read 8+ times per tick but only written 1-2 times.
232
+
233
+ 4. **Convert `log.json` to append-only JSONL** — Eliminates the parse-entire-file-to-append pattern in `_flushLogBuffer()` (source: `engine/shared.js:49-59`). Log rotation becomes `readFile → keep last 2000 lines → writeFile` instead of `parse JSON array → splice → stringify → write`.
234
+
235
+ ### Phase 2: `node:sqlite` Migration (When API stabilizes — estimated Node 26 LTS)
236
+
237
+ Monitor `node:sqlite` stability. When it exits experimental:
238
+
239
+ **Migration order** (highest pain first):
240
+
241
+ | Priority | State File | Why First |
242
+ |----------|-----------|-----------|
243
+ | 1 | `engine/log.json` | Append-heavy, benefits most from indexed queries, lowest risk (read-only by dashboard) |
244
+ | 2 | `engine/dispatch.json` | Most contended file, benefits from row-level operations, eliminates completed array growth |
245
+ | 3 | `engine/cooldowns.json` | Bloated key-value store, natural fit for TTL-indexed table |
246
+ | 4 | `projects/*/work-items.json` | Core entity, benefits from indexed status/project/type queries |
247
+ | 5 | `projects/*/pull-requests.json` | Similar to work items, lower contention |
248
+ | 6 | `engine/metrics.json` | Small, low contention — migrate for consistency |
249
+ | 7 | `engine/control.json` | Tiny, single-object — migrate last for consistency |
250
+
251
+ **Migration architecture:**
252
+
253
+ ```
254
+ ┌─────────────────────────────────────────────────┐
255
+ │ StateStore API │
256
+ │ (drop-in replacement for mutateJsonFileLocked) │
257
+ ├─────────────────────────────────────────────────┤
258
+ │ getWorkItems(filter?) → WorkItem[] │
259
+ │ mutateWorkItem(id, fn) → WorkItem │
260
+ │ getDispatch() → DispatchQueue │
261
+ │ mutateDispatch(fn) → DispatchQueue │
262
+ │ appendLog(entry) → void │
263
+ │ ... │
264
+ ├─────────────────────────────────────────────────┤
265
+ │ Backend: SQLite (DatabaseSync) │
266
+ │ ┌─────────┐ ┌──────────┐ ┌──────────────────┐ │
267
+ │ │ WAL mode│ │ Prepared │ │ JSON data column │ │
268
+ │ │ │ │statements│ │ + indexed columns │ │
269
+ │ └─────────┘ └──────────┘ └──────────────────┘ │
270
+ └─────────────────────────────────────────────────┘
271
+ ```
272
+
273
+ **Key design decisions for the SQLite schema:**
274
+
275
+ 1. **Hybrid column strategy** — Store frequently-queried fields as indexed columns (`id`, `status`, `type`, `project`), keep the full object in a `data TEXT` column as JSON. This allows SQL `WHERE` on hot fields while preserving schema flexibility.
276
+
277
+ 2. **Single database file** — All state in one `.db` file in `engine/minions.db`. WAL mode enables concurrent reads. Transactions replace file locks.
278
+
279
+ 3. **Prepared statement cache** — Create all prepared statements at startup, reuse throughout process lifetime. This avoids the 0.7ms-per-prepare overhead measured in benchmarks.
280
+
281
+ 4. **Migration layer** — On first startup with SQLite, read existing JSON files, populate tables, rename JSON files to `.json.migrated`. On rollback, the `.migrated` files can be renamed back.
282
+
283
+ 5. **Debug tooling** — Add `minions db` CLI command that opens an interactive SQLite shell on `engine/minions.db` for debugging (replaces `cat dispatch.json`).
284
+
285
+ **Proposed schema (core tables):**
286
+
287
+ ```sql
288
+ -- Dispatch queue entries
289
+ CREATE TABLE dispatch (
290
+ id TEXT PRIMARY KEY,
291
+ queue TEXT NOT NULL CHECK(queue IN ('pending','active','completed')),
292
+ type TEXT NOT NULL,
293
+ agent TEXT,
294
+ task TEXT,
295
+ meta TEXT, -- JSON blob
296
+ created_at TEXT,
297
+ started_at TEXT,
298
+ completed_at TEXT,
299
+ result TEXT,
300
+ reason TEXT
301
+ );
302
+ CREATE INDEX idx_dispatch_queue ON dispatch(queue);
303
+ CREATE INDEX idx_dispatch_agent ON dispatch(agent);
304
+
305
+ -- Work items
306
+ CREATE TABLE work_items (
307
+ id TEXT PRIMARY KEY,
308
+ project TEXT NOT NULL,
309
+ status TEXT NOT NULL,
310
+ type TEXT,
311
+ title TEXT,
312
+ priority TEXT,
313
+ data TEXT, -- Full JSON blob
314
+ created TEXT,
315
+ updated_at TEXT DEFAULT (datetime('now'))
316
+ );
317
+ CREATE INDEX idx_wi_status ON work_items(status);
318
+ CREATE INDEX idx_wi_project ON work_items(project);
319
+ CREATE INDEX idx_wi_project_status ON work_items(project, status);
320
+
321
+ -- Pull requests
322
+ CREATE TABLE pull_requests (
323
+ id TEXT PRIMARY KEY,
324
+ project TEXT NOT NULL,
325
+ status TEXT,
326
+ branch TEXT,
327
+ agent TEXT,
328
+ data TEXT, -- Full JSON blob
329
+ created TEXT,
330
+ updated_at TEXT DEFAULT (datetime('now'))
331
+ );
332
+ CREATE INDEX idx_pr_status ON pull_requests(status);
333
+ CREATE INDEX idx_pr_project ON pull_requests(project);
334
+
335
+ -- Engine log (append-only)
336
+ CREATE TABLE engine_log (
337
+ rowid INTEGER PRIMARY KEY AUTOINCREMENT,
338
+ timestamp TEXT NOT NULL,
339
+ level TEXT NOT NULL,
340
+ message TEXT,
341
+ meta TEXT -- JSON blob
342
+ );
343
+ CREATE INDEX idx_log_timestamp ON engine_log(timestamp);
344
+ CREATE INDEX idx_log_level ON engine_log(level);
345
+
346
+ -- Cooldowns (TTL-based)
347
+ CREATE TABLE cooldowns (
348
+ key TEXT PRIMARY KEY,
349
+ failures INTEGER DEFAULT 0,
350
+ last_failure TEXT,
351
+ cooldown_until TEXT,
352
+ data TEXT
353
+ );
354
+ CREATE INDEX idx_cd_until ON cooldowns(cooldown_until);
355
+
356
+ -- Metrics
357
+ CREATE TABLE metrics (
358
+ agent TEXT PRIMARY KEY,
359
+ data TEXT -- JSON blob with token usage, quality, etc.
360
+ );
361
+
362
+ -- Key-value store for small state (control, schedule-runs, etc.)
363
+ CREATE TABLE kv (
364
+ key TEXT PRIMARY KEY,
365
+ value TEXT
366
+ );
367
+ ```
368
+
369
+ ### Phase 3: Advanced Optimizations (Post-migration)
370
+
371
+ Once on SQLite, unlock capabilities impossible with file-based state:
372
+
373
+ 1. **Dashboard SSE from SQLite triggers** — Use `sqlite3_update_hook` (if exposed) or polling with `WHERE updated_at > ?` instead of file-watching
374
+ 2. **Dependency resolution via SQL** — Replace in-memory graph traversal with recursive CTEs
375
+ 3. **Metrics aggregation** — `SELECT agent, SUM(tokens) FROM dispatch WHERE completed_at > ? GROUP BY agent`
376
+ 4. **Log analysis** — `SELECT level, COUNT(*) FROM engine_log WHERE timestamp > ? GROUP BY level`
377
+ 5. **Automatic compaction** — `DELETE FROM engine_log WHERE rowid < (SELECT MAX(rowid) - 2000 FROM engine_log)`
378
+
379
+ ---
380
+
381
+ ## 4. What NOT to Do
382
+
383
+ 1. **Don't migrate to SQLite while the API is experimental.** A Node.js upgrade that changes `DatabaseSync` parameters or removes the module would be catastrophic for a state storage layer.
384
+
385
+ 2. **Don't use an async SQLite API** (if one is added to Node). The current codebase is fundamentally synchronous — `mutateJsonFileLocked` is called from synchronous code paths. Mixing sync and async state access is a recipe for race conditions.
386
+
387
+ 3. **Don't split into per-entity files** at scale. Going from 9 files to potentially thousands (one per work item, per dispatch entry) creates inode pressure, `readdir` performance issues, and makes atomic multi-entity operations harder (need to lock multiple files).
388
+
389
+ 4. **Don't add npm dependencies** for state storage. The zero-dependency principle is a genuine architectural strength — it means `git clone && node engine.js` works everywhere with zero setup.
390
+
391
+ 5. **Don't build a custom database.** The JSON Lines + compaction + in-memory index approach is literally reimplementing SQLite badly. Use the real thing when it's stable.
392
+
393
+ ---
394
+
395
+ ## 5. Risk Analysis
396
+
397
+ | Risk | Probability | Impact | Mitigation |
398
+ |------|-------------|--------|------------|
399
+ | `node:sqlite` API breaks on Node upgrade | Medium (experimental) | High — state inaccessible | Phase 2 only after API stabilizes; keep JSON export/import |
400
+ | File-based approach hits scaling limit | Low (current data tiny) | Medium — slower ticks | Phase 1 caching + capping buys years of headroom |
401
+ | SQLite `.db` corruption | Very low (SQLite is ACID) | High — state lost | WAL mode + periodic `.db` backup to `.db.backup` |
402
+ | Migration bugs lose state | Medium | High | Dual-write period: write to both JSON and SQLite for 1 week |
403
+ | Dashboard performance degrades during migration | Low | Low | Read API stays the same shape; backend changes only |
404
+
405
+ ---
406
+
407
+ ## 6. Decision Matrix
408
+
409
+ | Criterion | Files (current) | Files (improved) | `node:sqlite` | `better-sqlite3` | LevelDB/LMDB |
410
+ |-----------|----------------|------------------|---------------|-------------------|---------------|
411
+ | Zero dependencies | **Yes** | **Yes** | **Yes** | No | No |
412
+ | Cross-platform | **Yes** | **Yes** | **Yes** | Mostly | Fragile on Win |
413
+ | Concurrency | File locks | File locks (finer) | WAL + transactions | WAL + transactions | MVCC |
414
+ | Row-level ops | No | Partial | **Yes** | **Yes** | Yes (KV) |
415
+ | Indexed queries | No | No | **Yes** | **Yes** | No |
416
+ | Human-readable | **Yes** | **Yes** | No | No | No |
417
+ | API stability | Stable | Stable | **Experimental** | Stable | Stable |
418
+ | Migration effort | None | Low | High | High | High |
419
+ | Debugging ease | **Excellent** | **Excellent** | Needs tooling | Needs tooling | Poor |
420
+
421
+ ---
422
+
423
+ ## 7. Summary
424
+
425
+ **Short-term (this week):** Implement Phase 1 quick wins — cap cooldowns, add read caches, reduce dispatch completed cap. Zero risk, immediate improvement.
426
+
427
+ **Medium-term (Node 26 LTS timeframe):** Adopt `node:sqlite` with the hybrid column schema. Migrate log.json first (lowest risk), then dispatch.json (highest pain), then work-items.json. Use a dual-write period for safety.
428
+
429
+ **The current file-based system is not broken.** At 180 work items and 128 PRs, we're well within the comfortable range for JSON files. The biggest issues (511 KB cooldowns.json, 380 KB dispatch.json) are capping/rotation problems, not fundamental architecture problems. Fix those first.
430
+
431
+ SQLite is the right long-term answer, but only when `node:sqlite` is no longer experimental. The API is excellent, the performance is superior, and it maintains the zero-dependency principle. Patience here avoids a painful migration if the API changes.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.551",
3
+ "version": "0.1.552",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"