@yemi33/minions 0.1.2369 → 0.1.2371
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/bin/minions.js +38 -11
- package/dashboard/js/memory-search.js +112 -0
- package/dashboard/js/render-kb.js +15 -0
- package/dashboard/js/render-other.js +2 -30
- package/dashboard/js/render-work-items.js +0 -34
- package/dashboard/js/settings.js +27 -27
- package/dashboard/pages/inbox.html +1 -1
- package/dashboard/pages/tools.html +1 -1
- package/dashboard.js +148 -258
- package/docs/README.md +2 -3
- package/docs/architecture.excalidraw +2 -2
- package/docs/auto-discovery.md +3 -3
- package/docs/completion-reports.md +0 -121
- package/docs/copilot-cli-schema.md +9 -17
- package/docs/deprecated.json +0 -51
- package/docs/design-state-storage.md +4 -4
- package/docs/harness-propagation.md +33 -263
- package/docs/human-vs-automated.md +1 -1
- package/docs/named-agents.md +0 -2
- package/docs/plan-lifecycle.md +5 -6
- package/docs/qa-runbook-lifecycle.md +10 -25
- package/docs/shared-lifecycle-module-map.md +2 -10
- package/docs/team-memory.md +18 -4
- package/engine/ado-comment.js +5 -11
- package/engine/ado.js +10 -5
- package/engine/cleanup.js +16 -36
- package/engine/cli.js +13 -175
- package/engine/comment-format.js +8 -182
- package/engine/consolidation.js +72 -1
- package/engine/db/index.js +60 -10
- package/engine/db/migrations/017-agent-memory.js +208 -0
- package/engine/db/migrations/018-sql-only-cutover.js +56 -0
- package/engine/dispatch-store.js +0 -114
- package/engine/dispatch.js +1 -6
- package/engine/features.js +6 -0
- package/engine/gh-comment.js +2 -10
- package/engine/github.js +8 -6
- package/engine/lifecycle.js +141 -173
- package/engine/llm.js +9 -11
- package/engine/managed-spawn.js +1 -6
- package/engine/memory-retrieval.js +165 -0
- package/engine/memory-store.js +367 -0
- package/engine/metrics-store.js +4 -128
- package/engine/pipeline.js +4 -7
- package/engine/playbook.js +64 -198
- package/engine/prd-store.js +115 -141
- package/engine/preflight.js +8 -55
- package/engine/projects.js +34 -41
- package/engine/pull-requests-store.js +9 -234
- package/engine/qa-runs.js +4 -15
- package/engine/qa-sessions.js +5 -17
- package/engine/queries.js +23 -103
- package/engine/routing.js +1 -1
- package/engine/runtimes/claude.js +1 -2
- package/engine/runtimes/codex.js +0 -2
- package/engine/runtimes/copilot.js +1 -4
- package/engine/shared-branch-pr-reconcile.js +2 -5
- package/engine/shared.js +179 -533
- package/engine/small-state-store.js +12 -647
- package/engine/spawn-agent.js +5 -96
- package/engine/state-operations.js +166 -0
- package/engine/supervisor.js +0 -1
- package/engine/watch-actions.js +2 -3
- package/engine/watches-store.js +2 -128
- package/engine/work-items-store.js +3 -254
- package/engine.js +102 -334
- package/package.json +2 -2
- package/playbooks/build-fix-complex.md +0 -4
- package/playbooks/fix.md +0 -4
- package/playbooks/implement-shared.md +0 -4
- package/playbooks/implement.md +0 -4
- package/playbooks/plan-to-prd.md +16 -11
- package/playbooks/plan.md +0 -4
- package/playbooks/review.md +0 -6
- package/playbooks/shared-rules.md +0 -65
- package/docs/harness-transparency.md +0 -199
- package/docs/project-skills.md +0 -193
- package/engine/discover-project-skills.js +0 -673
- package/engine/playbook-intents.js +0 -76
package/engine/comment-format.js
CHANGED
|
@@ -1,48 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* engine/comment-format.js — platform-neutral PR-comment body fragments.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* dashboard renderer and the consolidation path respectively. This module owns
|
|
7
|
-
* the *PR-comment* surface.
|
|
8
|
-
*
|
|
9
|
-
* `buildHarnessUsedSection(harnessUsed)` renders the grounded harness-usage
|
|
10
|
-
* record (the canonical `{ skills, mcpServers, commands, docs }` shape produced
|
|
11
|
-
* by `shared.groundHarnessUsed` / `shared.normalizeHarnessUsed`; each entry may
|
|
12
|
-
* carry `grounded: true | false`) as a collapsible Markdown `<details>` block.
|
|
13
|
-
*
|
|
14
|
-
* It is deliberately platform-NEUTRAL: the output is plain Markdown with no
|
|
15
|
-
* GitHub- or ADO-specific syntax, so the GitHub comment path
|
|
16
|
-
* (`engine/gh-comment.js buildMinionsCommentBody`) and the ADO comment path
|
|
17
|
-
* (agent-authored via `az repos pr comment` per `playbooks/shared-rules.md`)
|
|
18
|
-
* fold in a **byte-identical** section. Both consume this one helper — there is
|
|
19
|
-
* no second renderer to drift.
|
|
20
|
-
*
|
|
21
|
-
* Returns '' when the record is absent, malformed, or has no renderable entries
|
|
22
|
-
* so callers can append it unconditionally without emitting an empty section.
|
|
4
|
+
* Owns the platform-neutral Minions marker and brand-link formatting shared by
|
|
5
|
+
* GitHub and Azure DevOps comment transports.
|
|
23
6
|
*/
|
|
24
7
|
|
|
25
|
-
// Kind metadata mirrors the dashboard work-item modal (P-d5a6f7c4,
|
|
26
|
-
// dashboard/js/render-work-items.js): same four kinds, same icons, same
|
|
27
|
-
// primary/secondary field mapping, so the PR-comment and modal readouts stay
|
|
28
|
-
// visually consistent.
|
|
29
|
-
const HARNESS_KINDS = [
|
|
30
|
-
{ key: 'skills', icon: '\u{1F9E9}', primary: 'name', secondary: 'source' }, // 🧩
|
|
31
|
-
{ key: 'mcpServers', icon: '\u{1F50C}', primary: 'name', secondary: 'scope' }, // 🔌
|
|
32
|
-
{ key: 'commands', icon: '\u{2318}', primary: 'name', secondary: 'scope' }, // ⌘
|
|
33
|
-
{ key: 'docs', icon: '\u{1F4C4}', primary: 'path', secondary: 'why' }, // 📄
|
|
34
|
-
];
|
|
35
|
-
|
|
36
|
-
const HARNESS_SUMMARY_ICON = '\u{1F9F0}'; // 🧰
|
|
37
|
-
const HARNESS_WARN_ICON = '⚠️'; // ⚠️
|
|
38
|
-
|
|
39
|
-
// W-mr287sc0000uc214 — the visible "skipped project skill" callout. The label is
|
|
40
|
-
// a stable literal so buildMinionsCommentBody can dedupe (never stack two
|
|
41
|
-
// callouts) and tests can assert on it. Clamps mirror the harness field caps.
|
|
42
|
-
const SKILL_SKIPPED_LABEL = '**Skipped project skill:**';
|
|
43
|
-
const SKILL_SKIPPED_MAX_NAME_LEN = 200;
|
|
44
|
-
const SKILL_SKIPPED_MAX_REASON_LEN = 1000;
|
|
45
|
-
|
|
46
8
|
// Canonical Minions site URL. shared-rules.md ("Brand link (MANDATORY)") requires
|
|
47
9
|
// the human-readable "Minions" mention in a PR/ADO comment to render as a markdown
|
|
48
10
|
// hyperlink to this destination. Keep this in sync with the literal in
|
|
@@ -76,118 +38,10 @@ function linkifyBrandTrailer(text) {
|
|
|
76
38
|
return text.replace(BRAND_TRAILER_RE, `by [Minions](${MINIONS_BRAND_URL})`);
|
|
77
39
|
}
|
|
78
40
|
|
|
79
|
-
// Collapse internal whitespace/newlines to single spaces and neutralize
|
|
80
|
-
// backticks so each entry renders on one line and can't break the inline-code
|
|
81
|
-
// span. Values arrive already trimmed + length-clamped by normalizeHarnessUsed,
|
|
82
|
-
// but may still contain internal newlines.
|
|
83
|
-
function _clean(value) {
|
|
84
|
-
return String(value).replace(/`/g, "'").replace(/\s+/g, ' ').trim();
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Render the grounded harnessUsed record as a collapsible Markdown section.
|
|
89
|
-
* @param {object|null} harnessUsed canonical { skills, mcpServers, commands, docs }
|
|
90
|
-
* @returns {string} '' when empty, else a `<details>` block (no trailing newline)
|
|
91
|
-
*/
|
|
92
|
-
function buildHarnessUsedSection(harnessUsed) {
|
|
93
|
-
if (!harnessUsed || typeof harnessUsed !== 'object' || Array.isArray(harnessUsed)) return '';
|
|
94
|
-
|
|
95
|
-
const lines = [];
|
|
96
|
-
let anyUngrounded = false;
|
|
97
|
-
|
|
98
|
-
for (const kind of HARNESS_KINDS) {
|
|
99
|
-
const entries = Array.isArray(harnessUsed[kind.key]) ? harnessUsed[kind.key] : [];
|
|
100
|
-
for (const entry of entries) {
|
|
101
|
-
if (!entry || typeof entry !== 'object') continue;
|
|
102
|
-
const primaryRaw = entry[kind.primary];
|
|
103
|
-
if (typeof primaryRaw !== 'string' || !primaryRaw.trim()) continue;
|
|
104
|
-
const primary = _clean(primaryRaw);
|
|
105
|
-
|
|
106
|
-
const grounded = entry.grounded === true;
|
|
107
|
-
if (!grounded) anyUngrounded = true;
|
|
108
|
-
|
|
109
|
-
const secondaryRaw = entry[kind.secondary];
|
|
110
|
-
const secondary = (typeof secondaryRaw === 'string' && secondaryRaw.trim())
|
|
111
|
-
? _clean(secondaryRaw) : '';
|
|
112
|
-
|
|
113
|
-
const tail = secondary ? ` — ${secondary}` : ''; // em dash
|
|
114
|
-
const warn = grounded ? '' : ` ${HARNESS_WARN_ICON}`;
|
|
115
|
-
lines.push(`- ${kind.icon} \`${primary}\`${tail}${warn}`);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
if (lines.length === 0) return '';
|
|
120
|
-
|
|
121
|
-
const legend = anyUngrounded
|
|
122
|
-
? `\n\n> ${HARNESS_WARN_ICON} agent-reported, not verified by the engine`
|
|
123
|
-
: '';
|
|
124
|
-
|
|
125
|
-
return `<details>\n<summary>${HARNESS_SUMMARY_ICON} Harnesses used (${lines.length})</summary>\n\n`
|
|
126
|
-
+ `${lines.join('\n')}${legend}\n\n</details>`;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Extract a canonical `{ name, reason }` skip record from a self-reported skip
|
|
131
|
-
* value. Accepts the raw `{ name, reason }` shape directly, or a `meta` wrapper
|
|
132
|
-
* that carries it — `meta.skill` (`{ skipped: { name, reason } }`) or
|
|
133
|
-
* `meta.review` (`{ skillSkipped: { name, reason } }`) — so callers can pass the
|
|
134
|
-
* completion-report block verbatim. Returns null when no non-empty `name` is
|
|
135
|
-
* present so callers treat "no skip" as a single falsy case.
|
|
136
|
-
* @param {*} input
|
|
137
|
-
* @returns {{name:string, reason:string}|null}
|
|
138
|
-
*/
|
|
139
|
-
function _extractSkillSkipped(input) {
|
|
140
|
-
if (!input || typeof input !== 'object' || Array.isArray(input)) return null;
|
|
141
|
-
let node = input;
|
|
142
|
-
if (input.skipped && typeof input.skipped === 'object' && !Array.isArray(input.skipped)) {
|
|
143
|
-
node = input.skipped; // meta.skill shape
|
|
144
|
-
} else if (input.skillSkipped && typeof input.skillSkipped === 'object' && !Array.isArray(input.skillSkipped)) {
|
|
145
|
-
node = input.skillSkipped; // meta.review shape
|
|
146
|
-
}
|
|
147
|
-
const name = typeof node.name === 'string' ? node.name.trim() : '';
|
|
148
|
-
if (!name) return null;
|
|
149
|
-
const reason = typeof node.reason === 'string' ? node.reason.trim() : '';
|
|
150
|
-
return { name, reason };
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* Render the "skipped project skill" self-report as a VISIBLE Markdown
|
|
155
|
-
* blockquote callout (W-mr287sc0000uc214), e.g.:
|
|
156
|
-
* > ⚠️ **Skipped project skill:** `FMF-review-context` — small 2-file diff…
|
|
157
|
-
*
|
|
158
|
-
* A review/fix agent may self-report that it intentionally skipped an available,
|
|
159
|
-
* in-scope project (review) skill via `meta.review.skillSkipped` /
|
|
160
|
-
* `meta.skill.skipped` (shape `{name, reason}`). That skip used to live ONLY in
|
|
161
|
-
* the completion-report JSON — invisible to a human reading the PR before merge.
|
|
162
|
-
* This is a sibling of buildHarnessUsedSection (same neutral chokepoint, same
|
|
163
|
-
* `_clean` hygiene) but a VISIBLE blockquote rather than a collapsed <details>:
|
|
164
|
-
* the whole point is that a human reviewing the PR sees the skip rationale in the
|
|
165
|
-
* same comment as the APPROVE / REQUEST_CHANGES verdict, before merge.
|
|
166
|
-
*
|
|
167
|
-
* Deliberately platform-NEUTRAL Markdown so the GitHub and ADO posters fold in a
|
|
168
|
-
* byte-identical callout — there is no second renderer to drift. Accepts either
|
|
169
|
-
* the raw `{name, reason}` record or a `meta.skill` / `meta.review` wrapper.
|
|
170
|
-
*
|
|
171
|
-
* Returns '' when the record is absent/malformed (no skip name), so callers can
|
|
172
|
-
* append it unconditionally without emitting a stray callout — reports without
|
|
173
|
-
* meta.review.skillSkipped / meta.skill.skipped render identically to today.
|
|
174
|
-
*
|
|
175
|
-
* @param {object|null} skillSkipped `{name, reason}` or a meta.skill/meta.review wrapper
|
|
176
|
-
* @returns {string} '' when empty, else a one-line `> ⚠️ …` blockquote
|
|
177
|
-
*/
|
|
178
|
-
function buildSkippedSkillSection(skillSkipped) {
|
|
179
|
-
const parsed = _extractSkillSkipped(skillSkipped);
|
|
180
|
-
if (!parsed) return '';
|
|
181
|
-
const name = _clean(parsed.name).slice(0, SKILL_SKIPPED_MAX_NAME_LEN);
|
|
182
|
-
const reasonClean = parsed.reason ? _clean(parsed.reason).slice(0, SKILL_SKIPPED_MAX_REASON_LEN) : '';
|
|
183
|
-
const tail = reasonClean ? ` — ${reasonClean}` : ''; // em dash
|
|
184
|
-
return `> ${HARNESS_WARN_ICON} ${SKILL_SKIPPED_LABEL} \`${name}\`${tail}`;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
41
|
// ── Minions marker + neutral comment-body builder ───────────────────────────
|
|
188
42
|
//
|
|
189
43
|
// The hidden HTML-comment marker and the body composition (marker + harness
|
|
190
|
-
//
|
|
44
|
+
// brand link) are platform-NEUTRAL: GitHub (engine/gh-comment.js) and
|
|
191
45
|
// Azure DevOps (engine/ado-comment.js) both post the byte-identical body built
|
|
192
46
|
// here, so there is one builder and no drift. The host modules own only the
|
|
193
47
|
// transport (gh CLI vs ADO REST) and host-specific validation (repo slug vs
|
|
@@ -246,42 +100,21 @@ function _buildMarker({ agentId, kind, workItemId }) {
|
|
|
246
100
|
}
|
|
247
101
|
|
|
248
102
|
/**
|
|
249
|
-
* Compose a minions PR-comment body: prepend the hidden marker
|
|
250
|
-
*
|
|
251
|
-
* trailer. Platform-neutral — GitHub and ADO posters both call this.
|
|
103
|
+
* Compose a minions PR-comment body: prepend the hidden marker and hyperlink the
|
|
104
|
+
* first bare brand trailer. Platform-neutral — GitHub and ADO posters both call this.
|
|
252
105
|
*
|
|
253
106
|
* Idempotency: if `body` already starts with a minions marker it is returned
|
|
254
|
-
* with the
|
|
107
|
+
* with the brand transform applied but no second marker prepended.
|
|
255
108
|
*
|
|
256
|
-
* @param {{agentId:string, kind:string, workItemId?:string, body?:string
|
|
109
|
+
* @param {{agentId:string, kind:string, workItemId?:string, body?:string}} args
|
|
257
110
|
* @returns {string} the final comment body
|
|
258
111
|
*/
|
|
259
|
-
function buildMinionsCommentBody({ agentId, kind, workItemId, body
|
|
112
|
+
function buildMinionsCommentBody({ agentId, kind, workItemId, body }) {
|
|
260
113
|
// Validate the inputs even when the body is pre-marked, so callers can't
|
|
261
114
|
// silently bypass validation by pre-marking their body.
|
|
262
115
|
_validateMarkerInputs({ agentId, kind, workItemId });
|
|
263
116
|
let safeBody = body == null ? '' : String(body);
|
|
264
117
|
|
|
265
|
-
// Fold in the VISIBLE "skipped project skill" callout (W-mr287sc0000uc214)
|
|
266
|
-
// BEFORE the collapsed harness section so a human sees the skip rationale in
|
|
267
|
-
// the same comment as the verdict — not buried in a <details> or reachable
|
|
268
|
-
// only via a separate API call. Dedup on the stable label so a re-marked body
|
|
269
|
-
// already carrying the callout doesn't stack a second one. Absent/malformed
|
|
270
|
-
// skip → '' → no-op (byte-identical to today).
|
|
271
|
-
const skipSection = buildSkippedSkillSection(skillSkipped);
|
|
272
|
-
if (skipSection && !safeBody.includes(SKILL_SKIPPED_LABEL)) {
|
|
273
|
-
safeBody = safeBody ? `${safeBody}\n\n${skipSection}` : skipSection;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
// Fold the grounded harness-usage digest in as a collapsible <details>
|
|
277
|
-
// section. Append at the END so it survives the marker-idempotency path;
|
|
278
|
-
// dedup on the summary line so a re-marked body that already carries the
|
|
279
|
-
// section doesn't stack a second one.
|
|
280
|
-
const harnessSection = buildHarnessUsedSection(harnessUsed);
|
|
281
|
-
if (harnessSection && !safeBody.includes('<summary>\u{1F9F0} Harnesses used')) {
|
|
282
|
-
safeBody = safeBody ? `${safeBody}\n\n${harnessSection}` : harnessSection;
|
|
283
|
-
}
|
|
284
|
-
|
|
285
118
|
// Brand-link safety net: deterministically hyperlink the first bare
|
|
286
119
|
// "… by Minions" signature trailer (no-op when the agent already linked it).
|
|
287
120
|
safeBody = linkifyBrandTrailer(safeBody);
|
|
@@ -307,8 +140,6 @@ function parseMinionsMarker(body) {
|
|
|
307
140
|
}
|
|
308
141
|
|
|
309
142
|
module.exports = {
|
|
310
|
-
buildHarnessUsedSection,
|
|
311
|
-
buildSkippedSkillSection,
|
|
312
143
|
linkifyBrandTrailer,
|
|
313
144
|
MINIONS_BRAND_URL,
|
|
314
145
|
// Neutral marker + body builder (consumed by gh-comment.js and ado-comment.js).
|
|
@@ -322,9 +153,4 @@ module.exports = {
|
|
|
322
153
|
// Internal helpers exported for tests / host posters.
|
|
323
154
|
_buildMarker,
|
|
324
155
|
_validateMarkerInputs,
|
|
325
|
-
// Exported for tests / advanced callers that want to mirror the kind mapping.
|
|
326
|
-
HARNESS_KINDS,
|
|
327
|
-
// W-mr287sc0000uc214 — skipped-project-skill callout helpers.
|
|
328
|
-
_extractSkillSkipped,
|
|
329
|
-
SKILL_SKIPPED_LABEL,
|
|
330
156
|
};
|
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,
|
package/engine/db/index.js
CHANGED
|
@@ -21,6 +21,45 @@ let _db = null;
|
|
|
21
21
|
let _dbPath = null;
|
|
22
22
|
let _dbInitError = null;
|
|
23
23
|
let _exitCheckpointRegistered = false;
|
|
24
|
+
let _cleanedDbPath = null;
|
|
25
|
+
|
|
26
|
+
function _cleanupRetiredJsonFiles(db, dbPath) {
|
|
27
|
+
if (_cleanedDbPath === dbPath) return;
|
|
28
|
+
const marker = db.prepare("SELECT value FROM state_markers WHERE key='sql_only_cutover'").get();
|
|
29
|
+
if (!marker) return;
|
|
30
|
+
const minionsDir = path.dirname(path.dirname(dbPath));
|
|
31
|
+
const engineDir = path.join(minionsDir, 'engine');
|
|
32
|
+
const engineFiles = [
|
|
33
|
+
'dispatch.json', 'log.json', 'metrics.json', 'watches.json',
|
|
34
|
+
'schedule-runs.json', 'pipeline-runs.json', 'managed-processes.json',
|
|
35
|
+
'worktree-pool.json', 'qa-runs.json', 'qa-sessions.json', 'pr-links.json',
|
|
36
|
+
'cooldowns.json', 'pending-rebases.json', 'cc-sessions.json', 'doc-sessions.json',
|
|
37
|
+
];
|
|
38
|
+
const targets = engineFiles.map(name => path.join(engineDir, name));
|
|
39
|
+
targets.push(path.join(minionsDir, 'work-items.json'), path.join(minionsDir, 'pull-requests.json'));
|
|
40
|
+
try {
|
|
41
|
+
for (const project of fs.readdirSync(path.join(minionsDir, 'projects'), { withFileTypes: true })) {
|
|
42
|
+
if (!project.isDirectory() || project.name === '.archived') continue;
|
|
43
|
+
targets.push(
|
|
44
|
+
path.join(minionsDir, 'projects', project.name, 'work-items.json'),
|
|
45
|
+
path.join(minionsDir, 'projects', project.name, 'pull-requests.json'),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
} catch { /* projects directory is optional */ }
|
|
49
|
+
for (const dir of [path.join(minionsDir, 'prd'), path.join(minionsDir, 'prd', 'archive')]) {
|
|
50
|
+
try {
|
|
51
|
+
for (const name of fs.readdirSync(dir)) {
|
|
52
|
+
if (name.endsWith('.json')) targets.push(path.join(dir, name));
|
|
53
|
+
}
|
|
54
|
+
} catch { /* PRD directory is optional */ }
|
|
55
|
+
}
|
|
56
|
+
for (const target of targets) {
|
|
57
|
+
for (const suffix of ['', '.backup', '.lock']) {
|
|
58
|
+
try { fs.unlinkSync(target + suffix); } catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
_cleanedDbPath = dbPath;
|
|
62
|
+
}
|
|
24
63
|
|
|
25
64
|
// DATA-LOSS GUARD: register a one-time process-exit hook that checkpoints +
|
|
26
65
|
// closes the DB. Registered by getDb() on first successful open so EVERY DB
|
|
@@ -68,14 +107,6 @@ function _installExperimentalWarningFilter() {
|
|
|
68
107
|
}
|
|
69
108
|
|
|
70
109
|
function getDb() {
|
|
71
|
-
// Hard opt-out (issue #3035, option #3): MINIONS_FORCE_JSON=1 makes
|
|
72
|
-
// every getDb() call throw, forcing the entire stack onto the JSON
|
|
73
|
-
// fallback path. Useful for users who can't enable
|
|
74
|
-
// --experimental-sqlite on Node 22.x and for regression tests that
|
|
75
|
-
// need to exercise the JSON-fallback branches deterministically.
|
|
76
|
-
if (process.env.MINIONS_FORCE_JSON === '1') {
|
|
77
|
-
throw new Error('engine/db: SQL disabled via MINIONS_FORCE_JSON=1');
|
|
78
|
-
}
|
|
79
110
|
// Re-resolve the DB path on every call so tests that swap MINIONS_TEST_DIR
|
|
80
111
|
// (or production-side configuration that changes MINIONS_HOME between
|
|
81
112
|
// operations) get a fresh connection to the new location instead of
|
|
@@ -143,12 +174,13 @@ function getDb() {
|
|
|
143
174
|
if (lastErr) throw lastErr;
|
|
144
175
|
const { runMigrations } = require('./migrate');
|
|
145
176
|
runMigrations(_db);
|
|
177
|
+
_cleanupRetiredJsonFiles(_db, dbPath);
|
|
146
178
|
_registerExitCheckpoint();
|
|
147
179
|
return _db;
|
|
148
180
|
} catch (e) {
|
|
149
181
|
const nodeMajor = parseInt(String(process.versions.node).split('.')[0], 10);
|
|
150
182
|
const flagHint = nodeMajor === 22
|
|
151
|
-
? ` On Node 22.x, node:sqlite is gated behind --experimental-sqlite. Restart with NODE_OPTIONS=--experimental-sqlite
|
|
183
|
+
? ` On Node 22.x, node:sqlite is gated behind --experimental-sqlite. Restart with NODE_OPTIONS=--experimental-sqlite or upgrade to Node 24+.`
|
|
152
184
|
: ` Node 22.5+ (with --experimental-sqlite on 22.x) or 24+ required for built-in 'node:sqlite' support.`;
|
|
153
185
|
_dbInitError = new Error(`engine/db: failed to open SQLite — ${e.message}.${flagHint}`);
|
|
154
186
|
throw _dbInitError;
|
|
@@ -200,4 +232,22 @@ function withTransaction(db, fn) {
|
|
|
200
232
|
}
|
|
201
233
|
}
|
|
202
234
|
|
|
203
|
-
|
|
235
|
+
// Cache compiled statements on the db connection itself so hot, fixed-shape
|
|
236
|
+
// read paths (e.g. FTS5 memory search run repeatedly per dispatch) don't pay
|
|
237
|
+
// SQLite's parse/plan cost on every call — `db.prepare()` recompiles from
|
|
238
|
+
// scratch each time it's invoked, node:sqlite does not dedupe by text. Keyed
|
|
239
|
+
// per-connection (not module-level) so closeDb()/getDb() reopening against a
|
|
240
|
+
// new path (test isolation) naturally starts with an empty cache instead of
|
|
241
|
+
// holding stale statements bound to a closed handle.
|
|
242
|
+
function prepareCached(db, sql) {
|
|
243
|
+
if (!db) throw new Error('engine/db.prepareCached: db is required');
|
|
244
|
+
if (!db._minionsStmtCache) db._minionsStmtCache = new Map();
|
|
245
|
+
let stmt = db._minionsStmtCache.get(sql);
|
|
246
|
+
if (!stmt) {
|
|
247
|
+
stmt = db.prepare(sql);
|
|
248
|
+
db._minionsStmtCache.set(sql, stmt);
|
|
249
|
+
}
|
|
250
|
+
return stmt;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
module.exports = { getDb, closeDb, withTransaction, prepareCached };
|
|
@@ -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
|
+
};
|