@yemi33/minions 0.1.2424 → 0.1.2425
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/install-internal-minions.js +1209 -0
- package/bin/minions.js +75 -9
- package/dashboard/docs/typography.md +27 -12
- package/dashboard/js/command-center.js +13 -11
- package/dashboard/js/live-stream.js +1 -1
- package/dashboard/js/memory-search.js +388 -43
- package/dashboard/js/modal-qa.js +5 -5
- package/dashboard/js/qa.js +39 -26
- package/dashboard/js/refresh.js +37 -18
- package/dashboard/js/render-agents.js +14 -18
- package/dashboard/js/render-dispatch.js +14 -74
- package/dashboard/js/render-inbox.js +3 -3
- package/dashboard/js/render-meetings.js +8 -8
- package/dashboard/js/render-other.js +45 -20
- package/dashboard/js/render-pipelines.js +6 -6
- package/dashboard/js/render-plans.js +14 -14
- package/dashboard/js/render-prd.js +46 -46
- package/dashboard/js/render-prs.js +9 -65
- package/dashboard/js/render-schedules.js +5 -5
- package/dashboard/js/render-skills.js +6 -6
- package/dashboard/js/render-utils.js +4 -4
- package/dashboard/js/render-watches.js +4 -16
- package/dashboard/js/render-work-items.js +95 -107
- package/dashboard/js/settings.js +175 -74
- package/dashboard/layout.html +3 -2
- package/dashboard/pages/inbox.html +1 -1
- package/dashboard/pages/work.html +1 -1
- package/dashboard/shared/model-display.js +9 -0
- package/dashboard/shared/watches-source.js +43 -0
- package/dashboard/shared/welcome-popup.js +259 -0
- package/dashboard/slim/body.html +6 -6
- package/dashboard/slim/js/chat.js +4 -2
- package/dashboard/slim/js/history.js +10 -3
- package/dashboard/slim/js/modals-tiles.js +85 -16
- package/dashboard/slim/js/status.js +12 -16
- package/dashboard/slim/styles.css +24 -4
- package/dashboard/styles.css +62 -27
- package/dashboard-build.js +8 -2
- package/dashboard.js +3854 -2514
- package/docs/README.md +6 -4
- package/docs/api-errors.md +144 -0
- package/docs/auto-discovery.md +83 -61
- package/docs/capture-demos.js +173 -30
- package/docs/command-center.md +5 -1
- package/docs/completion-reports.md +127 -11
- package/docs/constants.md +15 -3
- package/docs/constellation-style-telemetry.md +2 -2
- package/docs/copilot-cli-schema.md +32 -22
- package/docs/cross-repo-plans.md +30 -30
- package/docs/demo/memory-system.html +1 -1
- package/docs/deprecated.json +132 -18
- package/docs/diagnostics-memory.md +8 -4
- package/docs/human-vs-automated.md +2 -2
- package/docs/internal-install.md +190 -0
- package/docs/kb-pr3223-cascade-archiving.md +16 -0
- package/docs/kb-pr696-merge-conflict-docs.md +23 -0
- package/docs/kb-sweep.md +2 -2
- package/docs/keep-processes.md +8 -1
- package/docs/live-checkout-mode.md +1 -1
- package/docs/managed-spawn.md +21 -3
- package/docs/named-agents.md +3 -2
- package/docs/plan-lifecycle.md +32 -32
- package/docs/pr-screenshots/pr-899/worker-pool-worktrees-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-899/worker-pool-worktrees-BEFORE.png +0 -0
- package/docs/pr-screenshots/pr-979/auto-fix-pane-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-979/auto-fix-pane-BEFORE.png +0 -0
- package/docs/pr-screenshots/pr-985/pr-column-em-dash-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-985/pr-column-em-dash-BEFORE.png +0 -0
- package/docs/qa-runbook-lifecycle.md +67 -51
- package/docs/qa-runbooks.md +2 -2
- package/docs/runtime-adapters.md +101 -50
- package/docs/security.md +28 -0
- package/docs/self-improvement.md +47 -13
- package/docs/skills.md +29 -0
- package/docs/slim-ux/concepts.md +2 -2
- package/docs/specs/agent-configurability.md +43 -41
- package/docs/specs/agent-rename.md +13 -13
- package/docs/team-memory.md +283 -14
- package/docs/tutorials/03-plan-driven-feature.md +3 -3
- package/docs/tutorials/04-runtimes-and-harness.md +18 -21
- package/docs/tutorials/08-operations-and-recovery.md +3 -2
- package/docs/tutorials/README.md +4 -3
- package/docs/visual-evidence-ci.md +103 -0
- package/docs/watches.md +35 -30
- package/docs/worktree-lifecycle.md +151 -13
- package/engine/acp-transport.js +1 -0
- package/engine/ado.js +80 -33
- package/engine/agent-api-validation.js +571 -0
- package/engine/agent-worker-pool.js +7 -6
- package/engine/api-contracts/agent-content.js +761 -0
- package/engine/api-contracts/cc-ops.js +219 -0
- package/engine/api-contracts/config-runtime.js +419 -0
- package/engine/api-contracts/core.js +138 -0
- package/engine/api-contracts/index.js +506 -0
- package/engine/api-contracts/orchestration.js +602 -0
- package/engine/api-contracts/pull-requests.js +323 -0
- package/engine/api-contracts/qa-process.js +764 -0
- package/engine/api-contracts/work-plan-prd.js +494 -0
- package/engine/api-validation.js +543 -0
- package/engine/cc-api-validation.js +586 -0
- package/engine/cc-worker-pool.js +9 -1
- package/engine/cleanup.js +393 -271
- package/engine/cli.js +65 -7
- package/engine/comment-classifier.js +70 -5
- package/engine/consolidation.js +123 -5
- package/engine/content-api-validation.js +606 -0
- package/engine/create-pr-worktree.js +22 -1
- package/engine/db/migrations/027-review-learning-lifecycle.js +162 -0
- package/engine/dispatch.js +33 -0
- package/engine/distribution.js +189 -0
- package/engine/execution-model.js +59 -0
- package/engine/features.js +12 -0
- package/engine/github.js +67 -25
- package/engine/harness-context.js +313 -0
- package/engine/inbox-store.js +1 -1
- package/engine/kb-sweep.js +161 -38
- package/engine/keep-process-sweep.js +43 -3
- package/engine/lifecycle.js +1066 -165
- package/engine/llm.js +4 -0
- package/engine/managed-spawn.js +463 -48
- package/engine/meeting.js +197 -34
- package/engine/memory-retrieval.js +280 -0
- package/engine/memory-store.js +821 -38
- package/engine/model-discovery.js +17 -3
- package/engine/pipeline.js +802 -14
- package/engine/plan-prd-validation.js +745 -0
- package/engine/playbook.js +206 -17
- package/engine/pooled-agent-process.js +6 -1
- package/engine/pr-action.js +1 -1
- package/engine/pr-issue-validation.js +653 -0
- package/engine/pr-resolve.js +75 -13
- package/engine/prd-store.js +39 -6
- package/engine/preflight.js +7 -2
- package/engine/process-utils.js +79 -31
- package/engine/projects.js +198 -12
- package/engine/promotion.js +371 -0
- package/engine/qa-from-prd.js +3 -0
- package/engine/qa-process-validation.js +572 -0
- package/engine/qa-runbooks.js +41 -37
- package/engine/qa-runners/maestro.js +11 -7
- package/engine/qa-runners/playwright.js +37 -14
- package/engine/qa-runners.js +9 -10
- package/engine/qa-runs.js +124 -11
- package/engine/qa-sessions.js +139 -40
- package/engine/quarantine-refs.js +81 -11
- package/engine/queries.js +163 -9
- package/engine/review-learning-backfill.js +482 -0
- package/engine/review-learning.js +1236 -0
- package/engine/runtimes/claude.js +60 -2
- package/engine/runtimes/codex.js +57 -16
- package/engine/runtimes/contract.js +21 -0
- package/engine/runtimes/copilot.js +51 -3
- package/engine/runtimes/index.js +1 -0
- package/engine/scheduler.js +201 -32
- package/engine/settings-validation.js +909 -0
- package/engine/shared.js +705 -208
- package/engine/spawn-agent.js +48 -21
- package/engine/steering-constraints.js +31 -0
- package/engine/steering-store.js +10 -2
- package/engine/steering.js +14 -2
- package/engine/timeout.js +404 -111
- package/engine/untrusted-fence.js +24 -10
- package/engine/watch-actions.js +7 -2
- package/engine/watches.js +353 -51
- package/engine/work-item-validation.js +561 -0
- package/engine/work-items-store.js +61 -1
- package/engine/worktree-gc.js +656 -37
- package/engine/worktree-preflight.js +154 -0
- package/engine.js +2068 -342
- package/package.json +1 -1
- package/playbooks/_pr-description-audit.md +88 -41
- package/playbooks/build-fix-complex.md +3 -3
- package/playbooks/docs.md +2 -2
- package/playbooks/fix.md +18 -18
- package/playbooks/implement.md +18 -18
- package/playbooks/qa-session-draft.md +10 -12
- package/playbooks/qa-session-execute.md +16 -12
- package/playbooks/qa-validate.md +8 -7
- package/playbooks/shared-rules.md +45 -0
- package/playbooks/test.md +4 -0
- package/prompts/cc-system.md +1 -1
- package/skills/check-self-authored-review-comment/SKILL.md +2 -2
package/engine/memory-store.js
CHANGED
|
@@ -3,12 +3,21 @@
|
|
|
3
3
|
const crypto = require('crypto');
|
|
4
4
|
const { getDb, withTransaction, prepareCached } = require('./db');
|
|
5
5
|
const { emitStateEvent } = require('./db-events');
|
|
6
|
+
const reviewLearning = require('./review-learning');
|
|
6
7
|
|
|
7
8
|
const MEMORY_TYPES = new Set(['semantic', 'episodic', 'procedural']);
|
|
8
9
|
const SCOPE_TYPES = new Set(['global', 'project', 'agent']);
|
|
9
|
-
const STATUSES = new Set(['active', 'superseded', 'retracted', 'expired']);
|
|
10
|
+
const STATUSES = new Set(['active', 'candidate', 'superseded', 'retracted', 'expired']);
|
|
11
|
+
const ACTIONS = new Set(['pin', 'retract', 'restore']);
|
|
10
12
|
const TRUST_LEVELS = new Set(['human', 'system', 'agent', 'external']);
|
|
11
|
-
|
|
13
|
+
// Source-bound identity fields that a candidate transition must never allow a
|
|
14
|
+
// caller to rewrite through `options` — they bind the stored candidate to its
|
|
15
|
+
// finding/PR/author provenance and are reapplied from the stored row.
|
|
16
|
+
const IMMUTABLE_CANDIDATE_METADATA = Object.freeze([
|
|
17
|
+
'kind', 'findingKey', 'prId', 'authorAgent', 'reviewerAgent', 'sourceHash',
|
|
18
|
+
]);
|
|
19
|
+
const MAX_RETRIEVAL_RUNS = reviewLearning.REVIEW_LEARNING_BOUNDS.retrievalRuns;
|
|
20
|
+
const MAX_LIFECYCLE_EVENTS = reviewLearning.REVIEW_LEARNING_BOUNDS.lifecycleEvents;
|
|
12
21
|
const QUERY_STOP_WORDS = new Set([
|
|
13
22
|
'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'for', 'from', 'in', 'is',
|
|
14
23
|
'it', 'of', 'on', 'or', 'that', 'the', 'this', 'to', 'with',
|
|
@@ -113,13 +122,24 @@ function upsertMemoryRecord(input) {
|
|
|
113
122
|
const record = _normalize(input);
|
|
114
123
|
const db = getDb();
|
|
115
124
|
withTransaction(db, () => {
|
|
116
|
-
if (
|
|
125
|
+
if (record.status === 'active') {
|
|
117
126
|
const prior = db.prepare(`
|
|
118
127
|
SELECT id FROM memory_records
|
|
119
|
-
WHERE source_type=? AND source_ref=? AND status='active'
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
128
|
+
WHERE source_type=? AND source_ref=? AND status='active'
|
|
129
|
+
AND content_hash<>? AND id<>?
|
|
130
|
+
ORDER BY updated_at DESC, id LIMIT 1
|
|
131
|
+
`).get(record.sourceType, record.sourceRef, record.contentHash, record.id);
|
|
132
|
+
if (!record.supersedesId && prior) record.supersedesId = prior.id;
|
|
133
|
+
if (prior) {
|
|
134
|
+
db.prepare(`
|
|
135
|
+
UPDATE memory_records
|
|
136
|
+
SET status='superseded', valid_to=?, superseded_by=?, updated_at=?
|
|
137
|
+
WHERE source_type=? AND source_ref=? AND status='active' AND id<>?
|
|
138
|
+
`).run(
|
|
139
|
+
record.validFrom, record.id, record.updatedAt,
|
|
140
|
+
record.sourceType, record.sourceRef, record.id,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
123
143
|
}
|
|
124
144
|
if (record.supersedesId) {
|
|
125
145
|
db.prepare(`
|
|
@@ -174,37 +194,97 @@ function getMemoryRecord(id) {
|
|
|
174
194
|
function updateMemoryRecordState(id, action) {
|
|
175
195
|
const recordId = String(id || '').trim();
|
|
176
196
|
if (!recordId) throw new TypeError('memory record id is required');
|
|
177
|
-
if (!
|
|
197
|
+
if (!ACTIONS.has(action)) {
|
|
178
198
|
throw new TypeError(`unsupported memory action: ${action}`);
|
|
179
199
|
}
|
|
180
|
-
|
|
200
|
+
const existing = getMemoryRecord(recordId);
|
|
201
|
+
if (!existing) return null;
|
|
202
|
+
if (existing.status === 'candidate' && action !== 'retract') {
|
|
203
|
+
throw new TypeError(`memory candidate ${recordId} requires a verified disposition transition`);
|
|
204
|
+
}
|
|
181
205
|
|
|
182
206
|
const db = getDb();
|
|
183
207
|
const now = Date.now();
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
208
|
+
// Detect whether the operator action is a real state transition. Idempotency
|
|
209
|
+
// of human actions (AC: "repeated operator actions do not duplicate lifecycle
|
|
210
|
+
// events") is provided by only emitting a lifecycle event when the record
|
|
211
|
+
// actually changed status/trust; re-applying the same action is a no-op event.
|
|
212
|
+
const changed = action === 'pin'
|
|
213
|
+
? !(existing.trust === 'human' && existing.status === 'active'
|
|
214
|
+
&& existing.validTo == null && existing.supersededBy == null)
|
|
215
|
+
: action === 'restore'
|
|
216
|
+
? (existing.status !== 'active' || existing.validTo != null || existing.supersededBy != null)
|
|
217
|
+
: existing.status !== 'retracted';
|
|
218
|
+
withTransaction(db, () => {
|
|
219
|
+
if (action === 'pin') {
|
|
220
|
+
_supersedeOtherActiveVersions(db, existing, now);
|
|
221
|
+
db.prepare(`
|
|
222
|
+
UPDATE memory_records
|
|
223
|
+
SET trust='human', importance=1, confidence=MAX(confidence, 0.9),
|
|
224
|
+
status='active', valid_to=NULL, superseded_by=NULL, updated_at=?
|
|
225
|
+
WHERE id=?
|
|
226
|
+
`).run(now, recordId);
|
|
227
|
+
} else if (action === 'restore') {
|
|
228
|
+
_supersedeOtherActiveVersions(db, existing, now);
|
|
229
|
+
db.prepare(`
|
|
230
|
+
UPDATE memory_records
|
|
231
|
+
SET status='active', valid_to=NULL, superseded_by=NULL, updated_at=?
|
|
232
|
+
WHERE id=?
|
|
233
|
+
`).run(now, recordId);
|
|
234
|
+
} else {
|
|
235
|
+
db.prepare(`
|
|
236
|
+
UPDATE memory_records
|
|
237
|
+
SET status='retracted', valid_to=?, updated_at=?
|
|
238
|
+
WHERE id=?
|
|
239
|
+
`).run(now, now, recordId);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
204
242
|
emitStateEvent('memory_records', { id: recordId, action });
|
|
243
|
+
// Every operator pin/retract/restore of a review-learning record emits one
|
|
244
|
+
// compact lifecycle event (pinned/retracted/restored). The `changed` gate
|
|
245
|
+
// provides idempotency (repeated actions do not duplicate events); tagging
|
|
246
|
+
// project/area from the record (area || 'general') keeps the retracted/restored
|
|
247
|
+
// events in the same (project, area) group as the original `created` event so
|
|
248
|
+
// the diagnostics rollout aggregates and operator modal timeline (P-1ed2fb5f)
|
|
249
|
+
// report accurate counts.
|
|
250
|
+
if (changed && existing.sourceType === reviewLearning.REVIEW_LEARNING_SOURCE_TYPE) {
|
|
251
|
+
// Best-effort: an audit event must never break the operator control itself.
|
|
252
|
+
try {
|
|
253
|
+
recordMemoryLifecycleEvent({
|
|
254
|
+
eventType: reviewLearning.REVIEW_LEARNING_HUMAN_ACTION_EVENTS[action],
|
|
255
|
+
memoryId: recordId,
|
|
256
|
+
findingKey: existing.sourceRef,
|
|
257
|
+
project: existing.scopeKey,
|
|
258
|
+
agent: 'operator',
|
|
259
|
+
area: existing.metadata?.area || 'general',
|
|
260
|
+
disposition: existing.metadata?.disposition || null,
|
|
261
|
+
// A per-transition timestamp keeps a legitimate retract→restore→retract
|
|
262
|
+
// cycle as distinct events while pure replays are gated out above.
|
|
263
|
+
identity: `operator:${action}:${now}`,
|
|
264
|
+
metadata: { action, trigger: 'operator', priorStatus: existing.status },
|
|
265
|
+
createdAt: now,
|
|
266
|
+
});
|
|
267
|
+
} catch (err) {
|
|
268
|
+
emitStateEvent('memory_records', { id: recordId, action, lifecycleEventError: err.message });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
205
271
|
return getMemoryRecord(recordId);
|
|
206
272
|
}
|
|
207
273
|
|
|
274
|
+
// Reactivating a versioned record (pin/restore) must preserve the
|
|
275
|
+
// single-active-version invariant that upsertMemoryRecord already enforces on
|
|
276
|
+
// insert: supersede any OTHER active version sharing the same source lineage
|
|
277
|
+
// (source_type + source_ref — e.g. a review-learning finding key) so restoring
|
|
278
|
+
// or pinning an older version can never leave two active rows recallable.
|
|
279
|
+
function _supersedeOtherActiveVersions(db, record, now) {
|
|
280
|
+
if (!record || record.sourceRef == null || String(record.sourceRef).trim() === '') return;
|
|
281
|
+
db.prepare(`
|
|
282
|
+
UPDATE memory_records
|
|
283
|
+
SET status='superseded', valid_to=?, superseded_by=?, updated_at=?
|
|
284
|
+
WHERE source_type=? AND source_ref=? AND status='active' AND id<>?
|
|
285
|
+
`).run(now, record.id, now, record.sourceType, record.sourceRef, record.id);
|
|
286
|
+
}
|
|
287
|
+
|
|
208
288
|
function listMemoryRecords(filters = {}) {
|
|
209
289
|
const where = [];
|
|
210
290
|
const args = [];
|
|
@@ -213,6 +293,8 @@ function listMemoryRecords(filters = {}) {
|
|
|
213
293
|
['memory_type', filters.memoryType],
|
|
214
294
|
['scope_type', filters.scopeType],
|
|
215
295
|
['scope_key', filters.scopeKey],
|
|
296
|
+
['source_type', filters.sourceType],
|
|
297
|
+
['source_ref', filters.sourceRef || filters.findingKey],
|
|
216
298
|
]) {
|
|
217
299
|
if (value == null || value === '') continue;
|
|
218
300
|
where.push(`${column}=?`);
|
|
@@ -242,6 +324,14 @@ function searchMemoryRecords(query, options = {}) {
|
|
|
242
324
|
"(m.memory_type<>'procedural' OR m.trust IN ('human','system'))",
|
|
243
325
|
];
|
|
244
326
|
const args = [match];
|
|
327
|
+
// Review-learning records are recalled exclusively through the dedicated
|
|
328
|
+
// deterministic project+audience path (retrieveApplicableReviewLearnings).
|
|
329
|
+
// They are unconditionally excluded from generic FTS-driven recall so a
|
|
330
|
+
// review lesson can never leak into the generic Relevant Memory pack. The
|
|
331
|
+
// operator inspection endpoint opts back in via includeReviewLearning.
|
|
332
|
+
if (!options.includeReviewLearning) {
|
|
333
|
+
where.push(`m.source_type<>'${reviewLearning.REVIEW_LEARNING_SOURCE_TYPE}'`);
|
|
334
|
+
}
|
|
245
335
|
const statuses = Array.isArray(options.statuses)
|
|
246
336
|
? options.statuses.filter(status => STATUSES.has(status))
|
|
247
337
|
: ['active'];
|
|
@@ -267,6 +357,24 @@ function searchMemoryRecords(query, options = {}) {
|
|
|
267
357
|
args.push(...types);
|
|
268
358
|
}
|
|
269
359
|
}
|
|
360
|
+
// Diagnostics filters (P-1ed2fb5f). Applied inside the SQL WHERE so the LIMIT
|
|
361
|
+
// is honored after narrowing — post-filtering would silently drop matches.
|
|
362
|
+
if (options.sourceType) {
|
|
363
|
+
where.push('m.source_type=?');
|
|
364
|
+
args.push(String(options.sourceType));
|
|
365
|
+
}
|
|
366
|
+
if (options.findingKey) {
|
|
367
|
+
where.push('m.source_ref=?');
|
|
368
|
+
args.push(String(options.findingKey));
|
|
369
|
+
}
|
|
370
|
+
if (options.author) {
|
|
371
|
+
where.push("json_extract(m.metadata,'$.authorAgent')=?");
|
|
372
|
+
args.push(String(options.author));
|
|
373
|
+
}
|
|
374
|
+
if (options.audience) {
|
|
375
|
+
where.push("json_extract(m.metadata,'$.audience')=?");
|
|
376
|
+
args.push(String(options.audience));
|
|
377
|
+
}
|
|
270
378
|
const limit = Math.max(1, Math.min(200, Number(options.limit) || 50));
|
|
271
379
|
args.push(limit);
|
|
272
380
|
// CROSS JOIN pins FTS as the outer loop; Node 22's SQLite otherwise prefers
|
|
@@ -320,25 +428,109 @@ function supersedeMatchingRecords(options = {}) {
|
|
|
320
428
|
return changed;
|
|
321
429
|
}
|
|
322
430
|
|
|
323
|
-
function
|
|
431
|
+
function _boundedRetrievalText(value, maxBytes, label) {
|
|
432
|
+
if (value == null || String(value).trim() === '') return null;
|
|
433
|
+
return reviewLearning.redactBoundedText(value, maxBytes, label);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function _retrievalRow(row) {
|
|
437
|
+
if (!row) return null;
|
|
438
|
+
return {
|
|
439
|
+
id: row.id,
|
|
440
|
+
workItemId: row.work_item_id,
|
|
441
|
+
dispatchId: row.dispatch_id,
|
|
442
|
+
project: row.project,
|
|
443
|
+
agent: row.agent,
|
|
444
|
+
retrievalKind: row.retrieval_kind || 'general',
|
|
445
|
+
query: row.query,
|
|
446
|
+
candidateCount: row.candidate_count,
|
|
447
|
+
selectedCount: row.selected_count,
|
|
448
|
+
selectedBytes: row.selected_bytes,
|
|
449
|
+
durationMs: row.duration_ms,
|
|
450
|
+
shadow: row.shadow === 1,
|
|
451
|
+
selectedIds: _parseJson(row.selected_ids, []),
|
|
452
|
+
decisionTrace: _parseJson(row.decision_trace, []),
|
|
453
|
+
createdAt: row.created_at,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function recordRetrievalRun(run = {}) {
|
|
458
|
+
reviewLearning.assertNoProhibitedReviewPayload(run);
|
|
324
459
|
const db = getDb();
|
|
460
|
+
const selectedIds = reviewLearning.normalizeSelectedIds(run.selectedIds);
|
|
461
|
+
const decisionTrace = reviewLearning.normalizeDecisionTrace(run.decisionTrace);
|
|
462
|
+
const retrievalKind = reviewLearning.normalizeRetrievalKind(run.retrievalKind);
|
|
463
|
+
const createdAt = Number.isFinite(Number(run.createdAt)) ? Number(run.createdAt) : Date.now();
|
|
464
|
+
let insertedId;
|
|
325
465
|
withTransaction(db, () => {
|
|
326
|
-
db.prepare(`
|
|
466
|
+
const result = db.prepare(`
|
|
327
467
|
INSERT INTO memory_retrieval_runs (
|
|
328
|
-
work_item_id, project, agent,
|
|
329
|
-
selected_bytes, duration_ms, shadow,
|
|
330
|
-
|
|
468
|
+
work_item_id, dispatch_id, project, agent, retrieval_kind, query,
|
|
469
|
+
candidate_count, selected_count, selected_bytes, duration_ms, shadow,
|
|
470
|
+
selected_ids, decision_trace, created_at
|
|
471
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
331
472
|
`).run(
|
|
332
|
-
run.workItemId
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
473
|
+
_boundedRetrievalText(run.workItemId, 320, 'retrieval work item id'),
|
|
474
|
+
_boundedRetrievalText(run.dispatchId, 320, 'retrieval dispatch id'),
|
|
475
|
+
_boundedRetrievalText(run.project, 120, 'retrieval project'),
|
|
476
|
+
_boundedRetrievalText(run.agent, 96, 'retrieval agent'),
|
|
477
|
+
retrievalKind,
|
|
478
|
+
reviewLearning.redactBoundedText(
|
|
479
|
+
run.query || '',
|
|
480
|
+
reviewLearning.REVIEW_LEARNING_BOUNDS.queryBytes,
|
|
481
|
+
'retrieval query',
|
|
482
|
+
),
|
|
483
|
+
Math.max(0, Math.min(1000000, Math.floor(Number(run.candidateCount) || 0))),
|
|
484
|
+
Math.max(0, Math.min(1000000, Math.floor(Number(run.selectedCount) || 0))),
|
|
485
|
+
Math.max(0, Math.min(100000000, Math.floor(Number(run.selectedBytes) || 0))),
|
|
486
|
+
Math.max(0, Math.min(3600000, Number(run.durationMs) || 0)),
|
|
487
|
+
run.shadow ? 1 : 0,
|
|
488
|
+
_json(selectedIds, []),
|
|
489
|
+
_json(decisionTrace, []),
|
|
490
|
+
createdAt,
|
|
336
491
|
);
|
|
492
|
+
insertedId = result.lastInsertRowid;
|
|
337
493
|
db.prepare(`
|
|
338
494
|
DELETE FROM memory_retrieval_runs
|
|
339
495
|
WHERE id NOT IN (SELECT id FROM memory_retrieval_runs ORDER BY id DESC LIMIT ?)
|
|
340
496
|
`).run(MAX_RETRIEVAL_RUNS);
|
|
341
497
|
});
|
|
498
|
+
return _retrievalRow(db.prepare('SELECT * FROM memory_retrieval_runs WHERE id=?').get(insertedId));
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function getLatestRetrievalTrace(filters = {}) {
|
|
502
|
+
const where = [];
|
|
503
|
+
const args = [];
|
|
504
|
+
for (const [column, value] of [
|
|
505
|
+
['dispatch_id', filters.dispatchId],
|
|
506
|
+
['work_item_id', filters.workItemId],
|
|
507
|
+
['retrieval_kind', filters.retrievalKind],
|
|
508
|
+
['project', filters.project],
|
|
509
|
+
]) {
|
|
510
|
+
if (value == null || String(value).trim() === '') continue;
|
|
511
|
+
where.push(`${column}=?`);
|
|
512
|
+
args.push(String(value));
|
|
513
|
+
}
|
|
514
|
+
const sql = `SELECT * FROM memory_retrieval_runs${where.length ? ` WHERE ${where.join(' AND ')}` : ''} ORDER BY id DESC LIMIT 1`;
|
|
515
|
+
return _retrievalRow(prepareCached(getDb(), sql).get(...args));
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function listRetrievalTraces(filters = {}) {
|
|
519
|
+
const where = [];
|
|
520
|
+
const args = [];
|
|
521
|
+
for (const [column, value] of [
|
|
522
|
+
['dispatch_id', filters.dispatchId],
|
|
523
|
+
['work_item_id', filters.workItemId],
|
|
524
|
+
['retrieval_kind', filters.retrievalKind],
|
|
525
|
+
['project', filters.project],
|
|
526
|
+
]) {
|
|
527
|
+
if (value == null || String(value).trim() === '') continue;
|
|
528
|
+
where.push(`${column}=?`);
|
|
529
|
+
args.push(String(value));
|
|
530
|
+
}
|
|
531
|
+
const limit = Math.max(1, Math.min(200, Number(filters.limit) || 50));
|
|
532
|
+
const sql = `SELECT * FROM memory_retrieval_runs${where.length ? ` WHERE ${where.join(' AND ')}` : ''} ORDER BY id DESC LIMIT ?`;
|
|
533
|
+
return prepareCached(getDb(), sql).all(...args, limit).map(_retrievalRow);
|
|
342
534
|
}
|
|
343
535
|
|
|
344
536
|
function getRetrievalStats() {
|
|
@@ -352,10 +544,585 @@ function getRetrievalStats() {
|
|
|
352
544
|
`).get();
|
|
353
545
|
}
|
|
354
546
|
|
|
547
|
+
function upsertReviewLearningCandidate(input) {
|
|
548
|
+
const record = _normalize(reviewLearning.buildCandidateMemoryRecord(input));
|
|
549
|
+
const db = getDb();
|
|
550
|
+
let result;
|
|
551
|
+
withTransaction(db, () => {
|
|
552
|
+
const existing = _row(prepareCached(db, 'SELECT * FROM memory_records WHERE id=?').get(record.id));
|
|
553
|
+
if (existing && existing.status !== 'candidate') {
|
|
554
|
+
result = existing;
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
if (existing) {
|
|
558
|
+
record.createdAt = existing.createdAt;
|
|
559
|
+
record.validFrom = existing.validFrom;
|
|
560
|
+
record.validTo = existing.validTo;
|
|
561
|
+
record.supersedesId = existing.supersedesId;
|
|
562
|
+
record.supersededBy = existing.supersededBy;
|
|
563
|
+
record.metadata = {
|
|
564
|
+
...existing.metadata,
|
|
565
|
+
...record.metadata,
|
|
566
|
+
candidateState: existing.metadata.candidateState || record.metadata.candidateState,
|
|
567
|
+
dispatchId: existing.metadata.dispatchId || record.metadata.dispatchId || null,
|
|
568
|
+
workItemId: existing.metadata.workItemId || record.metadata.workItemId || null,
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
result = upsertMemoryRecord(record);
|
|
572
|
+
});
|
|
573
|
+
return result;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function upsertReviewLearningLesson(input) {
|
|
577
|
+
const record = _normalize(reviewLearning.buildLessonMemoryRecord(input));
|
|
578
|
+
const existing = getMemoryRecord(record.id);
|
|
579
|
+
if (existing?.status === 'active') {
|
|
580
|
+
record.createdAt = existing.createdAt;
|
|
581
|
+
record.validFrom = existing.validFrom;
|
|
582
|
+
record.supersedesId = existing.supersedesId;
|
|
583
|
+
record.supersededBy = existing.supersededBy;
|
|
584
|
+
}
|
|
585
|
+
return upsertMemoryRecord(record);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function listReviewLearningCandidates(filters = {}) {
|
|
589
|
+
const records = listMemoryRecords({
|
|
590
|
+
status: 'candidate',
|
|
591
|
+
scopeType: filters.project ? 'project' : filters.scopeType,
|
|
592
|
+
scopeKey: filters.project || filters.scopeKey,
|
|
593
|
+
limit: Math.max(1, Math.min(500, Number(filters.limit) || 100)),
|
|
594
|
+
});
|
|
595
|
+
return records.filter(record => (
|
|
596
|
+
record.sourceType === reviewLearning.REVIEW_LEARNING_SOURCE_TYPE
|
|
597
|
+
|| record.metadata.legacyUnverified === true
|
|
598
|
+
) && (!filters.findingKey
|
|
599
|
+
|| record.sourceRef === filters.findingKey
|
|
600
|
+
|| record.metadata.findingKey === filters.findingKey));
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function listActiveReviewLearnings(filters = {}) {
|
|
604
|
+
const where = [
|
|
605
|
+
"source_type='review-learning'",
|
|
606
|
+
"memory_type='procedural'",
|
|
607
|
+
"scope_type='project'",
|
|
608
|
+
"status='active'",
|
|
609
|
+
];
|
|
610
|
+
const args = [];
|
|
611
|
+
if (filters.project) {
|
|
612
|
+
where.push('scope_key=?');
|
|
613
|
+
args.push(reviewLearning.normalizeProjectKey(filters.project));
|
|
614
|
+
}
|
|
615
|
+
if (filters.findingKey) {
|
|
616
|
+
where.push('source_ref=?');
|
|
617
|
+
args.push(reviewLearning.normalizeFindingKey(filters.findingKey));
|
|
618
|
+
}
|
|
619
|
+
const limit = Math.max(1, Math.min(500, Number(filters.limit) || 100));
|
|
620
|
+
const sql = `SELECT * FROM memory_records WHERE ${where.join(' AND ')} ORDER BY updated_at DESC, id LIMIT ?`;
|
|
621
|
+
const records = prepareCached(getDb(), sql).all(...args, limit).map(_row);
|
|
622
|
+
if (!filters.audience) return records;
|
|
623
|
+
return records.filter(record => record.metadata.audience === filters.audience);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function transitionReviewLearningCandidate(findingKey, disposition, options = {}) {
|
|
627
|
+
reviewLearning.assertNoProhibitedReviewPayload(options);
|
|
628
|
+
const normalizedFindingKey = reviewLearning.normalizeFindingKey(findingKey);
|
|
629
|
+
const transition = reviewLearning.candidateTransitionFor(disposition);
|
|
630
|
+
const lessonId = String(options.lessonId || '').trim();
|
|
631
|
+
if (transition.requiresLesson && !lessonId) {
|
|
632
|
+
throw new TypeError(`${transition.disposition} candidate transition requires a lesson id`);
|
|
633
|
+
}
|
|
634
|
+
const db = getDb();
|
|
635
|
+
const [findingProject] = normalizedFindingKey.split('|');
|
|
636
|
+
const now = Date.now();
|
|
637
|
+
const updated = [];
|
|
638
|
+
withTransaction(db, () => {
|
|
639
|
+
const rows = db.prepare(`
|
|
640
|
+
SELECT * FROM memory_records
|
|
641
|
+
WHERE source_type='review-learning' AND source_ref=? AND status='candidate'
|
|
642
|
+
AND scope_type='project' AND scope_key=?
|
|
643
|
+
ORDER BY updated_at DESC, id
|
|
644
|
+
`).all(normalizedFindingKey, findingProject);
|
|
645
|
+
if (!rows.length) return;
|
|
646
|
+
if (transition.requiresLesson) {
|
|
647
|
+
const lesson = db.prepare(`
|
|
648
|
+
SELECT * FROM memory_records
|
|
649
|
+
WHERE id=? AND source_type='review-learning' AND memory_type='procedural'
|
|
650
|
+
AND scope_type='project' AND scope_key=? AND source_ref=? AND status='active'
|
|
651
|
+
`).get(lessonId, findingProject, normalizedFindingKey);
|
|
652
|
+
const lessonMetadata = lesson ? _parseJson(lesson.metadata, {}) : {};
|
|
653
|
+
if (
|
|
654
|
+
!lesson
|
|
655
|
+
|| lessonMetadata.kind !== 'review-learning-lesson'
|
|
656
|
+
|| lessonMetadata.findingKey !== normalizedFindingKey
|
|
657
|
+
) {
|
|
658
|
+
throw new TypeError(
|
|
659
|
+
`${transition.disposition} candidate transition requires an active review-learning lesson for the same finding and project`,
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
const statement = db.prepare(`
|
|
664
|
+
UPDATE memory_records
|
|
665
|
+
SET status=?, valid_to=?, superseded_by=?, metadata=?, updated_at=?
|
|
666
|
+
WHERE id=? AND status='candidate'
|
|
667
|
+
`);
|
|
668
|
+
for (const row of rows) {
|
|
669
|
+
const storedMetadata = _parseJson(row.metadata, {});
|
|
670
|
+
const provenance = {};
|
|
671
|
+
for (const key of IMMUTABLE_CANDIDATE_METADATA) {
|
|
672
|
+
if (storedMetadata[key] !== undefined) provenance[key] = storedMetadata[key];
|
|
673
|
+
}
|
|
674
|
+
const metadata = reviewLearning.normalizeReviewMetadata({
|
|
675
|
+
...storedMetadata,
|
|
676
|
+
...options,
|
|
677
|
+
...(transition.requiresLesson ? { lessonId } : {}),
|
|
678
|
+
disposition: transition.disposition,
|
|
679
|
+
candidateState: transition.candidateState,
|
|
680
|
+
// Reapply source-bound identity last so caller `options` can never
|
|
681
|
+
// rewrite the candidate's immutable provenance during a transition.
|
|
682
|
+
...provenance,
|
|
683
|
+
});
|
|
684
|
+
const result = statement.run(
|
|
685
|
+
transition.status,
|
|
686
|
+
transition.terminal ? now : row.valid_to,
|
|
687
|
+
transition.requiresLesson ? lessonId : row.superseded_by,
|
|
688
|
+
_json(metadata, {}),
|
|
689
|
+
now,
|
|
690
|
+
row.id,
|
|
691
|
+
);
|
|
692
|
+
if (result.changes) updated.push(row.id);
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
if (updated.length) {
|
|
696
|
+
emitStateEvent('memory_records', {
|
|
697
|
+
findingKey: normalizedFindingKey,
|
|
698
|
+
disposition: transition.disposition,
|
|
699
|
+
changed: updated.length,
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
return updated.map(getMemoryRecord);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// Quarantine an active review-learning lesson through the retracted lifecycle.
|
|
706
|
+
// Used by evidence-backed contradictions (agent-driven) and distinct from the
|
|
707
|
+
// generic operator control in updateMemoryRecordState. Provenance is retained:
|
|
708
|
+
// only status/valid_to move and the contradiction detail is merged into
|
|
709
|
+
// metadata without dropping existing keys. Idempotent — retracting an
|
|
710
|
+
// already-retracted lesson is a no-op.
|
|
711
|
+
function retractReviewLearningLesson(lessonId, options = {}) {
|
|
712
|
+
reviewLearning.assertNoProhibitedReviewPayload(options);
|
|
713
|
+
const recordId = String(lessonId || '').trim();
|
|
714
|
+
if (!recordId) throw new TypeError('review-learning lesson id is required');
|
|
715
|
+
const db = getDb();
|
|
716
|
+
const now = Date.now();
|
|
717
|
+
let changed = false;
|
|
718
|
+
let mergedMetadata = null;
|
|
719
|
+
withTransaction(db, () => {
|
|
720
|
+
const row = db.prepare(`
|
|
721
|
+
SELECT * FROM memory_records
|
|
722
|
+
WHERE id=? AND source_type='review-learning' AND memory_type='procedural'
|
|
723
|
+
AND status='active'
|
|
724
|
+
`).get(recordId);
|
|
725
|
+
if (!row) return;
|
|
726
|
+
const metadata = reviewLearning.normalizeReviewMetadata({
|
|
727
|
+
..._parseJson(row.metadata, {}),
|
|
728
|
+
retractReason: options.reason || 'contradicted',
|
|
729
|
+
...(options.contradictedBy ? { contradictedBy: options.contradictedBy } : {}),
|
|
730
|
+
...(options.dispatchId ? { retractDispatchId: options.dispatchId } : {}),
|
|
731
|
+
...(options.workItemId ? { retractWorkItemId: options.workItemId } : {}),
|
|
732
|
+
...(Array.isArray(options.evidenceKinds) && options.evidenceKinds.length
|
|
733
|
+
? { retractEvidenceKinds: options.evidenceKinds }
|
|
734
|
+
: {}),
|
|
735
|
+
});
|
|
736
|
+
const result = db.prepare(`
|
|
737
|
+
UPDATE memory_records
|
|
738
|
+
SET status='retracted', valid_to=?, metadata=?, updated_at=?
|
|
739
|
+
WHERE id=? AND status='active'
|
|
740
|
+
`).run(now, _json(metadata, {}), now, recordId);
|
|
741
|
+
changed = result.changes > 0;
|
|
742
|
+
if (changed) mergedMetadata = metadata;
|
|
743
|
+
});
|
|
744
|
+
if (changed) {
|
|
745
|
+
emitStateEvent('memory_records', {
|
|
746
|
+
id: recordId,
|
|
747
|
+
status: 'retracted',
|
|
748
|
+
reason: options.reason || 'contradicted',
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
return { record: getMemoryRecord(recordId), changed, metadata: mergedMetadata };
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function _lifecycleRow(row) {
|
|
755
|
+
if (!row) return null;
|
|
756
|
+
return {
|
|
757
|
+
id: row.id,
|
|
758
|
+
eventKey: row.event_key,
|
|
759
|
+
eventType: row.event_type,
|
|
760
|
+
memoryId: row.memory_id,
|
|
761
|
+
findingKey: row.finding_key,
|
|
762
|
+
project: row.project,
|
|
763
|
+
agent: row.agent,
|
|
764
|
+
area: row.area,
|
|
765
|
+
disposition: row.disposition,
|
|
766
|
+
workItemId: row.work_item_id,
|
|
767
|
+
dispatchId: row.dispatch_id,
|
|
768
|
+
metadata: _parseJson(row.metadata, {}),
|
|
769
|
+
createdAt: row.created_at,
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function recordMemoryLifecycleEvent(input) {
|
|
774
|
+
const event = reviewLearning.normalizeLifecycleEvent(input);
|
|
775
|
+
const db = getDb();
|
|
776
|
+
let inserted = false;
|
|
777
|
+
withTransaction(db, () => {
|
|
778
|
+
const result = db.prepare(`
|
|
779
|
+
INSERT OR IGNORE INTO memory_lifecycle_events (
|
|
780
|
+
event_key, event_type, memory_id, finding_key, project, agent, area,
|
|
781
|
+
disposition, work_item_id, dispatch_id, metadata, created_at
|
|
782
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
783
|
+
`).run(
|
|
784
|
+
event.eventKey, event.eventType, event.memoryId, event.findingKey,
|
|
785
|
+
event.project, event.agent, event.area, event.disposition,
|
|
786
|
+
event.workItemId, event.dispatchId, _json(event.metadata, {}),
|
|
787
|
+
event.createdAt,
|
|
788
|
+
);
|
|
789
|
+
inserted = result.changes > 0;
|
|
790
|
+
db.prepare(`
|
|
791
|
+
DELETE FROM memory_lifecycle_events
|
|
792
|
+
WHERE id NOT IN (
|
|
793
|
+
SELECT id FROM memory_lifecycle_events ORDER BY id DESC LIMIT ?
|
|
794
|
+
)
|
|
795
|
+
`).run(MAX_LIFECYCLE_EVENTS);
|
|
796
|
+
});
|
|
797
|
+
const stored = _lifecycleRow(
|
|
798
|
+
db.prepare('SELECT * FROM memory_lifecycle_events WHERE event_key=?').get(event.eventKey),
|
|
799
|
+
);
|
|
800
|
+
if (inserted) emitStateEvent('memory_lifecycle_events', { eventKey: event.eventKey, eventType: event.eventType });
|
|
801
|
+
return stored;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function listMemoryLifecycleEvents(filters = {}) {
|
|
805
|
+
const where = [];
|
|
806
|
+
const args = [];
|
|
807
|
+
for (const [column, value] of [
|
|
808
|
+
['event_type', filters.eventType],
|
|
809
|
+
['memory_id', filters.memoryId],
|
|
810
|
+
['finding_key', filters.findingKey],
|
|
811
|
+
['project', filters.project],
|
|
812
|
+
['agent', filters.agent],
|
|
813
|
+
['area', filters.area],
|
|
814
|
+
['disposition', filters.disposition],
|
|
815
|
+
['work_item_id', filters.workItemId],
|
|
816
|
+
['dispatch_id', filters.dispatchId],
|
|
817
|
+
]) {
|
|
818
|
+
if (value == null || String(value).trim() === '') continue;
|
|
819
|
+
where.push(`${column}=?`);
|
|
820
|
+
args.push(String(value));
|
|
821
|
+
}
|
|
822
|
+
const limit = Math.max(1, Math.min(500, Number(filters.limit) || 100));
|
|
823
|
+
const sql = `SELECT * FROM memory_lifecycle_events${where.length ? ` WHERE ${where.join(' AND ')}` : ''} ORDER BY id DESC LIMIT ?`;
|
|
824
|
+
return prepareCached(getDb(), sql).all(...args, limit).map(_lifecycleRow);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function pruneMemoryLifecycleEvents(options = {}) {
|
|
828
|
+
const maxRows = Math.max(1, Math.min(
|
|
829
|
+
MAX_LIFECYCLE_EVENTS,
|
|
830
|
+
Math.floor(Number(options.maxRows) || MAX_LIFECYCLE_EVENTS),
|
|
831
|
+
));
|
|
832
|
+
// Compact lifecycle events are retained for at most one year (in addition to
|
|
833
|
+
// the row cap) and are deliberately decoupled from KB file TTL (P-1ed2fb5f).
|
|
834
|
+
const maxAgeMs = Number.isFinite(Number(options.maxAgeMs))
|
|
835
|
+
? Math.max(0, Number(options.maxAgeMs))
|
|
836
|
+
: reviewLearning.REVIEW_LEARNING_BOUNDS.lifecycleEventMaxAgeMs;
|
|
837
|
+
const now = Number.isFinite(Number(options.now)) ? Number(options.now) : Date.now();
|
|
838
|
+
const db = getDb();
|
|
839
|
+
let changed = 0;
|
|
840
|
+
withTransaction(db, () => {
|
|
841
|
+
changed += Number(db.prepare(`
|
|
842
|
+
DELETE FROM memory_lifecycle_events
|
|
843
|
+
WHERE id NOT IN (
|
|
844
|
+
SELECT id FROM memory_lifecycle_events ORDER BY id DESC LIMIT ?
|
|
845
|
+
)
|
|
846
|
+
`).run(maxRows).changes) || 0;
|
|
847
|
+
if (maxAgeMs > 0) {
|
|
848
|
+
changed += Number(db.prepare(`
|
|
849
|
+
DELETE FROM memory_lifecycle_events WHERE created_at < ?
|
|
850
|
+
`).run(now - maxAgeMs).changes) || 0;
|
|
851
|
+
}
|
|
852
|
+
});
|
|
853
|
+
if (changed) emitStateEvent('memory_lifecycle_events', { pruned: changed });
|
|
854
|
+
return changed;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// Unresolved review-learning candidates (still in the `candidate` state) expire
|
|
858
|
+
// after 180 days so a never-promoted finding does not linger forever. Active
|
|
859
|
+
// procedural lessons are untouched — they persist until superseded or retracted
|
|
860
|
+
// (P-1ed2fb5f, acceptance criterion 5).
|
|
861
|
+
function expireStaleReviewLearningCandidates(options = {}) {
|
|
862
|
+
const maxAgeMs = Number.isFinite(Number(options.maxAgeMs))
|
|
863
|
+
? Math.max(0, Number(options.maxAgeMs))
|
|
864
|
+
: reviewLearning.REVIEW_LEARNING_BOUNDS.candidateExpiryMs;
|
|
865
|
+
const now = Number.isFinite(Number(options.now)) ? Number(options.now) : Date.now();
|
|
866
|
+
const cutoff = now - maxAgeMs;
|
|
867
|
+
const db = getDb();
|
|
868
|
+
let changed = 0;
|
|
869
|
+
withTransaction(db, () => {
|
|
870
|
+
changed = Number(db.prepare(`
|
|
871
|
+
UPDATE memory_records
|
|
872
|
+
SET status='expired', valid_to=?, updated_at=?
|
|
873
|
+
WHERE status='candidate'
|
|
874
|
+
AND source_type='review-learning'
|
|
875
|
+
AND updated_at < ?
|
|
876
|
+
`).run(now, now, cutoff).changes) || 0;
|
|
877
|
+
});
|
|
878
|
+
if (changed) emitStateEvent('memory_records', { expiredCandidates: changed });
|
|
879
|
+
return changed;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// Bounded per-finding / per-memory event-type counters for the operator modal.
|
|
883
|
+
function getReviewLearningLifecycleSummary(filters = {}) {
|
|
884
|
+
const where = [];
|
|
885
|
+
const args = [];
|
|
886
|
+
for (const [column, value] of [
|
|
887
|
+
['finding_key', filters.findingKey],
|
|
888
|
+
['memory_id', filters.memoryId],
|
|
889
|
+
['project', filters.project],
|
|
890
|
+
['agent', filters.agent],
|
|
891
|
+
['area', filters.area],
|
|
892
|
+
]) {
|
|
893
|
+
if (value == null || String(value).trim() === '') continue;
|
|
894
|
+
where.push(`${column}=?`);
|
|
895
|
+
args.push(String(value));
|
|
896
|
+
}
|
|
897
|
+
const rows = prepareCached(getDb(), `
|
|
898
|
+
SELECT event_type, COUNT(*) AS count
|
|
899
|
+
FROM memory_lifecycle_events
|
|
900
|
+
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
|
901
|
+
GROUP BY event_type
|
|
902
|
+
ORDER BY event_type
|
|
903
|
+
LIMIT 64
|
|
904
|
+
`).all(...args);
|
|
905
|
+
const counters = {};
|
|
906
|
+
let total = 0;
|
|
907
|
+
for (const row of rows) {
|
|
908
|
+
counters[row.event_type] = Number(row.count) || 0;
|
|
909
|
+
total += Number(row.count) || 0;
|
|
910
|
+
}
|
|
911
|
+
return { counters, total };
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function getReviewLearningRolloutAggregates(options = {}) {
|
|
915
|
+
const where = [];
|
|
916
|
+
const args = [];
|
|
917
|
+
if (options.project) {
|
|
918
|
+
where.push('project=?');
|
|
919
|
+
args.push(reviewLearning.normalizeProjectKey(options.project));
|
|
920
|
+
}
|
|
921
|
+
if (options.area) {
|
|
922
|
+
where.push('area=?');
|
|
923
|
+
args.push(reviewLearning.normalizeReviewArea(options.area));
|
|
924
|
+
}
|
|
925
|
+
if (Number.isFinite(Number(options.from))) {
|
|
926
|
+
where.push('created_at>=?');
|
|
927
|
+
args.push(Number(options.from));
|
|
928
|
+
}
|
|
929
|
+
if (Number.isFinite(Number(options.to))) {
|
|
930
|
+
where.push('created_at<?');
|
|
931
|
+
args.push(Number(options.to));
|
|
932
|
+
}
|
|
933
|
+
const positive = [...reviewLearning.POSITIVE_DISPOSITIONS];
|
|
934
|
+
const sql = `
|
|
935
|
+
SELECT project, area,
|
|
936
|
+
COUNT(DISTINCT CASE
|
|
937
|
+
WHEN event_type='created' AND disposition IN (${positive.map(() => '?').join(',')})
|
|
938
|
+
THEN finding_key END
|
|
939
|
+
) AS created_findings,
|
|
940
|
+
COUNT(DISTINCT CASE
|
|
941
|
+
WHEN event_type='retrieved' THEN finding_key END
|
|
942
|
+
) AS retrieved_findings,
|
|
943
|
+
COUNT(DISTINCT CASE
|
|
944
|
+
WHEN event_type='applied' THEN finding_key END
|
|
945
|
+
) AS applied_findings,
|
|
946
|
+
COUNT(DISTINCT CASE
|
|
947
|
+
WHEN event_type='retracted' THEN finding_key END
|
|
948
|
+
) AS retracted_findings,
|
|
949
|
+
COUNT(DISTINCT CASE
|
|
950
|
+
WHEN event_type='repeated-finding' THEN finding_key END
|
|
951
|
+
) AS repeated_findings
|
|
952
|
+
FROM memory_lifecycle_events
|
|
953
|
+
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
|
954
|
+
GROUP BY project, area
|
|
955
|
+
ORDER BY project, area
|
|
956
|
+
LIMIT 500
|
|
957
|
+
`;
|
|
958
|
+
const minimumSample = Math.max(1, Math.min(10000, Math.floor(Number(options.minimumSample) || 20)));
|
|
959
|
+
return prepareCached(getDb(), sql).all(...positive, ...args).map(row => {
|
|
960
|
+
const createdFindings = Number(row.created_findings) || 0;
|
|
961
|
+
const retrievedFindings = Number(row.retrieved_findings) || 0;
|
|
962
|
+
const appliedFindings = Number(row.applied_findings) || 0;
|
|
963
|
+
const retractedFindings = Number(row.retracted_findings) || 0;
|
|
964
|
+
const repeatedFindings = Number(row.repeated_findings) || 0;
|
|
965
|
+
const sampleSufficient = createdFindings >= minimumSample;
|
|
966
|
+
return {
|
|
967
|
+
project: row.project,
|
|
968
|
+
area: row.area,
|
|
969
|
+
createdFindings,
|
|
970
|
+
retrievedFindings,
|
|
971
|
+
appliedFindings,
|
|
972
|
+
retractedFindings,
|
|
973
|
+
repeatedFindings,
|
|
974
|
+
// Plan's project-and-area rollout formula: recurrence rate is the share of
|
|
975
|
+
// distinct created findings that recurred as a repeated-finding event.
|
|
976
|
+
rate: createdFindings ? repeatedFindings / createdFindings : null,
|
|
977
|
+
minimumSample,
|
|
978
|
+
sampleSufficient,
|
|
979
|
+
status: sampleSufficient ? 'measured' : 'insufficient-sample',
|
|
980
|
+
};
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// Staged-rollout success gate (P-507157f8). Compares a capture-only baseline
|
|
985
|
+
// window against the first active-recall window per project and normalized area.
|
|
986
|
+
// The rollout may only claim success for a (project, area) when BOTH windows have
|
|
987
|
+
// at least `minimumSample` (default 20) resolved valid findings AND the repeated
|
|
988
|
+
// valid-finding rate fell by at least `minReduction` (default 0.25 = 25%). Any
|
|
989
|
+
// window short of the sample floor is reported `insufficient-sample`, never a
|
|
990
|
+
// success. This is the measurement the docs' expansion criteria depend on: it is
|
|
991
|
+
// deliberately conservative so a small or noisy sample can never green-light
|
|
992
|
+
// broadening the recall canary.
|
|
993
|
+
const ROLLOUT_DEFAULT_MIN_SAMPLE = 20;
|
|
994
|
+
const ROLLOUT_DEFAULT_MIN_REDUCTION = 0.25;
|
|
995
|
+
|
|
996
|
+
function _rolloutAggregateKey(row) {
|
|
997
|
+
return `${row.project}\u0000${row.area}`;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function getReviewLearningRolloutComparison(options = {}) {
|
|
1001
|
+
const minimumSample = Math.max(
|
|
1002
|
+
1,
|
|
1003
|
+
Math.min(10000, Math.floor(Number(options.minimumSample) || ROLLOUT_DEFAULT_MIN_SAMPLE)),
|
|
1004
|
+
);
|
|
1005
|
+
const rawReduction = Number(options.minReduction);
|
|
1006
|
+
const minReduction = Number.isFinite(rawReduction)
|
|
1007
|
+
? Math.max(0, Math.min(1, rawReduction))
|
|
1008
|
+
: ROLLOUT_DEFAULT_MIN_REDUCTION;
|
|
1009
|
+
const shared = { project: options.project, area: options.area, minimumSample };
|
|
1010
|
+
const baselineRows = getReviewLearningRolloutAggregates({
|
|
1011
|
+
...shared,
|
|
1012
|
+
from: options.baselineFrom,
|
|
1013
|
+
to: options.baselineTo,
|
|
1014
|
+
});
|
|
1015
|
+
const activeRows = getReviewLearningRolloutAggregates({
|
|
1016
|
+
...shared,
|
|
1017
|
+
from: options.activeFrom,
|
|
1018
|
+
to: options.activeTo,
|
|
1019
|
+
});
|
|
1020
|
+
const baselineByKey = new Map(baselineRows.map(row => [_rolloutAggregateKey(row), row]));
|
|
1021
|
+
const activeByKey = new Map(activeRows.map(row => [_rolloutAggregateKey(row), row]));
|
|
1022
|
+
const keys = new Set([...baselineByKey.keys(), ...activeByKey.keys()]);
|
|
1023
|
+
const results = [];
|
|
1024
|
+
for (const key of keys) {
|
|
1025
|
+
const baseline = baselineByKey.get(key) || null;
|
|
1026
|
+
const active = activeByKey.get(key) || null;
|
|
1027
|
+
const [project, area] = key.split('\u0000');
|
|
1028
|
+
const baselineSufficient = !!(baseline && baseline.sampleSufficient);
|
|
1029
|
+
const activeSufficient = !!(active && active.sampleSufficient);
|
|
1030
|
+
const baselineRate = baseline ? baseline.rate : null;
|
|
1031
|
+
const activeRate = active ? active.rate : null;
|
|
1032
|
+
let reduction = null;
|
|
1033
|
+
let success = false;
|
|
1034
|
+
let status = 'insufficient-sample';
|
|
1035
|
+
if (baselineSufficient && activeSufficient) {
|
|
1036
|
+
// A zero baseline rate means there was nothing to improve on; that can
|
|
1037
|
+
// never satisfy a "rate fell by >=25%" claim, so it is reported as a
|
|
1038
|
+
// measured non-improvement rather than a divide-by-zero success.
|
|
1039
|
+
if (baselineRate > 0) {
|
|
1040
|
+
reduction = (baselineRate - activeRate) / baselineRate;
|
|
1041
|
+
} else {
|
|
1042
|
+
reduction = 0;
|
|
1043
|
+
}
|
|
1044
|
+
success = baselineRate > 0 && reduction >= minReduction;
|
|
1045
|
+
status = success ? 'success' : 'no-improvement';
|
|
1046
|
+
}
|
|
1047
|
+
results.push({
|
|
1048
|
+
project,
|
|
1049
|
+
area,
|
|
1050
|
+
minimumSample,
|
|
1051
|
+
minReduction,
|
|
1052
|
+
baseline: baseline
|
|
1053
|
+
? { createdFindings: baseline.createdFindings, repeatedFindings: baseline.repeatedFindings, rate: baselineRate, sampleSufficient: baselineSufficient }
|
|
1054
|
+
: { createdFindings: 0, repeatedFindings: 0, rate: null, sampleSufficient: false },
|
|
1055
|
+
active: active
|
|
1056
|
+
? { createdFindings: active.createdFindings, repeatedFindings: active.repeatedFindings, rate: activeRate, sampleSufficient: activeSufficient }
|
|
1057
|
+
: { createdFindings: 0, repeatedFindings: 0, rate: null, sampleSufficient: false },
|
|
1058
|
+
reduction,
|
|
1059
|
+
success,
|
|
1060
|
+
status,
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
results.sort((a, b) => (a.project === b.project ? a.area.localeCompare(b.area) : a.project.localeCompare(b.project)));
|
|
1064
|
+
return results;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// Distinct valid findings backing a review-learning lesson (P-192d3660).
|
|
1068
|
+
// A single lesson record owns exactly one finding key (its source_ref), but the
|
|
1069
|
+
// same generalized rule can recur across multiple PRs/findings. Those
|
|
1070
|
+
// recurrences are captured as `repeated-finding` lifecycle events that reference
|
|
1071
|
+
// the original lesson through `metadata.priorLessonId`. Promotion eligibility
|
|
1072
|
+
// requires at least two distinct valid findings, so we union:
|
|
1073
|
+
// 1. the lesson's own source_ref finding key,
|
|
1074
|
+
// 2. finding keys of every lifecycle event stamped with this memory id, and
|
|
1075
|
+
// 3. finding keys of every `repeated-finding` event pointing back at it.
|
|
1076
|
+
// The related memory ids (prior + newer lessons in the same cluster) are
|
|
1077
|
+
// returned so the queued work item can cite stable originating memory ids.
|
|
1078
|
+
function getReviewLearningFindingSupport(memoryId) {
|
|
1079
|
+
const id = String(memoryId || '').trim();
|
|
1080
|
+
const empty = { memoryId: id, findingKeys: [], distinctFindings: 0, repeatedOccurrences: 0, memoryIds: id ? [id] : [] };
|
|
1081
|
+
if (!id) return empty;
|
|
1082
|
+
const db = getDb();
|
|
1083
|
+
const lesson = db.prepare('SELECT source_ref, metadata FROM memory_records WHERE id=?').get(id);
|
|
1084
|
+
if (!lesson) return empty;
|
|
1085
|
+
const findingKeys = new Set();
|
|
1086
|
+
const memoryIds = new Set([id]);
|
|
1087
|
+
if (lesson.source_ref) findingKeys.add(lesson.source_ref);
|
|
1088
|
+
const priorOfLesson = _parseJson(lesson.metadata, {})?.priorLessonId;
|
|
1089
|
+
if (priorOfLesson) memoryIds.add(String(priorOfLesson));
|
|
1090
|
+
|
|
1091
|
+
const direct = db.prepare(`
|
|
1092
|
+
SELECT finding_key, metadata FROM memory_lifecycle_events
|
|
1093
|
+
WHERE memory_id=? AND finding_key IS NOT NULL AND finding_key<>''
|
|
1094
|
+
`).all(id);
|
|
1095
|
+
for (const row of direct) {
|
|
1096
|
+
findingKeys.add(row.finding_key);
|
|
1097
|
+
const prior = _parseJson(row.metadata, {})?.priorLessonId;
|
|
1098
|
+
if (prior) memoryIds.add(String(prior));
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
const repeated = db.prepare(`
|
|
1102
|
+
SELECT finding_key, memory_id FROM memory_lifecycle_events
|
|
1103
|
+
WHERE event_type='repeated-finding'
|
|
1104
|
+
AND json_extract(metadata,'$.priorLessonId')=?
|
|
1105
|
+
AND finding_key IS NOT NULL AND finding_key<>''
|
|
1106
|
+
`).all(id);
|
|
1107
|
+
for (const row of repeated) {
|
|
1108
|
+
findingKeys.add(row.finding_key);
|
|
1109
|
+
if (row.memory_id) memoryIds.add(String(row.memory_id));
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
return {
|
|
1113
|
+
memoryId: id,
|
|
1114
|
+
findingKeys: [...findingKeys].sort(),
|
|
1115
|
+
distinctFindings: findingKeys.size,
|
|
1116
|
+
repeatedOccurrences: repeated.length,
|
|
1117
|
+
memoryIds: [...memoryIds].sort(),
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
|
|
355
1121
|
module.exports = {
|
|
356
1122
|
MEMORY_TYPES,
|
|
357
1123
|
SCOPE_TYPES,
|
|
358
1124
|
STATUSES,
|
|
1125
|
+
ACTIONS,
|
|
359
1126
|
upsertMemoryRecord,
|
|
360
1127
|
getMemoryRecord,
|
|
361
1128
|
updateMemoryRecordState,
|
|
@@ -364,6 +1131,22 @@ module.exports = {
|
|
|
364
1131
|
transitionMemoryRecord,
|
|
365
1132
|
supersedeMatchingRecords,
|
|
366
1133
|
recordRetrievalRun,
|
|
1134
|
+
getLatestRetrievalTrace,
|
|
1135
|
+
listRetrievalTraces,
|
|
367
1136
|
getRetrievalStats,
|
|
1137
|
+
upsertReviewLearningCandidate,
|
|
1138
|
+
upsertReviewLearningLesson,
|
|
1139
|
+
listReviewLearningCandidates,
|
|
1140
|
+
listActiveReviewLearnings,
|
|
1141
|
+
transitionReviewLearningCandidate,
|
|
1142
|
+
retractReviewLearningLesson,
|
|
1143
|
+
recordMemoryLifecycleEvent,
|
|
1144
|
+
listMemoryLifecycleEvents,
|
|
1145
|
+
pruneMemoryLifecycleEvents,
|
|
1146
|
+
expireStaleReviewLearningCandidates,
|
|
1147
|
+
getReviewLearningLifecycleSummary,
|
|
1148
|
+
getReviewLearningRolloutAggregates,
|
|
1149
|
+
getReviewLearningRolloutComparison,
|
|
1150
|
+
getReviewLearningFindingSupport,
|
|
368
1151
|
_matchQuery,
|
|
369
1152
|
};
|