feat(sf): rem-agent-inspired memory discipline + always-in-context invariants board
Two patterns lifted from Copilot CLI 1.0.47's rem-agent design.
1. add/prune-only consolidation surface (memory-store, memory-extractor)
- applyConsolidationActions(): new export that gates the extractor path to
two action kinds only — "add" (→ CREATE) and "prune" (→ SUPERSEDE with
sentinel superseded_by = "pruned:<unitType>:<unitId>"). UPDATE / REINFORCE /
SUPERSEDE actions are rejected with a descriptive error from the
consolidation path; manual paths still use applyMemoryActions and keep
full action surface.
- memory-extractor.js EXTRACTION_SYSTEM prompt updated: model is told to
emit add/prune only and to fix wrong entries by prune+readd, not edit.
- Discipline win: every consolidation change is visible as an addition or
removal — no silent revisions.
2. swarm member inheritance of parent memory view (swarm-dispatch)
- SwarmDispatchLayer.dispatch() now fetches getActiveMemoriesRanked(30)
and formatMemoriesForPrompt(memories, 2000, false) at dispatch time,
attaches as memoryContext on both bus metadata and DispatchResult.
- Snapshot semantics — members get the view at dispatch time, no live
updates mid-task.
- Resolves the TODO at swarm-dispatch.js:22.
3. always-in-context invariants board (new capability)
- New src/resources/extensions/sf/context-board.js — SQLite-backed,
per-repo/per-branch entries. Two ops: addBoardEntry, pruneBoardEntry
(no update — same discipline as #1). 4 KB byte cap in
formatBoardForPrompt with truncation marker.
- New src/resources/extensions/sf/tools/context-board-tool.js +
bootstrap/context-board-tool.js — registered via pi.registerTool with
two ops: add(content, category?) and prune(id). Repository + branch
auto-filled from git context.
- Schema migration v62 → v63 in sf-db-schema.js adds context_board table
+ idx_context_board_repo_branch index. ensureContextBoardTable wired
into initSchema for fresh databases.
- System-prompt injection at auto/phases-dispatch.js runDispatch right
after dispatchResult.prompt resolution: prepends board snapshot under
a labeled section. Try/catch fail-open — board errors never break
dispatch. Sidecar/custom-engine paths intentionally not covered (carry
full unit context already + low frequency).
Why these complement existing infra rather than replace:
- memory-store remains queryable (recall on demand) for facts the agent
references sometimes.
- context_board is always-rendered (small, prompt-injected) for invariants
the agent should never operate without — current milestone scope,
architectural rules, known-broken paths, in-flight migrations.
Comparison to Copilot rem-agent:
- We have what they have on consolidation (add/prune + board) plus what
SF already had (queue + drain + memory-extractor + SLEEPTIME swarm
topology that's richer than their single-agent rem-agent).
Tests: 40/40 pass across memory-consolidation-discipline.test.ts (18) and
context-board.test.ts (22). Full test:unit deferred — see follow-up.
Two parallel Sonnet 4.6 sub-agents in isolated worktrees produced the
work; integration adapted for the modular sf-db split (schema went into
sf-db/sf-db-schema.js, prompt injection into auto/phases-dispatch.js,
both of which got pulled out of their original files since the swarms
launched).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f68ab20953
commit
21d9054611
11 changed files with 1331 additions and 39 deletions
|
|
@ -35,9 +35,14 @@ import {
|
|||
recordAutonomousSolverMissingCheckpointRetry,
|
||||
} from "../autonomous-solver.js";
|
||||
import { resumeAutoAfterProviderDelay } from "../bootstrap/provider-error-resume.js";
|
||||
import {
|
||||
formatBoardForPrompt,
|
||||
getBoardEntries,
|
||||
} from "../context-board.js";
|
||||
import { debugLog } from "../debug-logger.js";
|
||||
import { PROJECT_FILES } from "../detection.js";
|
||||
import { MergeConflictError } from "../git-service.js";
|
||||
import { nativeGetCurrentBranch } from "../native-git-bridge.js";
|
||||
import { recordLearnedOutcome } from "../learning/runtime.js";
|
||||
import { sfRoot } from "../paths.js";
|
||||
import { resolvePersistModelChanges } from "../preferences.js";
|
||||
|
|
@ -260,6 +265,26 @@ export async function runDispatch(ic, preData, loopState) {
|
|||
const unitId = dispatchResult.unitId;
|
||||
let prompt = dispatchResult.prompt;
|
||||
const pauseAfterUatDispatch = dispatchResult.pauseAfterDispatch ?? false;
|
||||
// ── Context board injection (always-in-context invariants) ─────────
|
||||
// Prepend the repo/branch invariants board to every dispatch prompt so
|
||||
// the agent never operates without it. Non-blocking: any failure falls
|
||||
// through silently to avoid breaking dispatch.
|
||||
try {
|
||||
if (isDbAvailable()) {
|
||||
const boardBasePath = s.basePath;
|
||||
const boardBranch = nativeGetCurrentBranch(boardBasePath);
|
||||
const boardEntries = getBoardEntries({
|
||||
repository: boardBasePath,
|
||||
branch: boardBranch || "unknown",
|
||||
});
|
||||
const boardBlock = formatBoardForPrompt(boardEntries);
|
||||
if (boardBlock) {
|
||||
prompt = `${boardBlock}\n\n---\n\n${prompt}`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fail open — board errors must never break dispatch.
|
||||
}
|
||||
// ── Reasoning assist injection ──────────────────────────────────────
|
||||
if (isReasoningAssistEnabled(unitType)) {
|
||||
try {
|
||||
|
|
|
|||
118
src/resources/extensions/sf/bootstrap/context-board-tool.js
Normal file
118
src/resources/extensions/sf/bootstrap/context-board-tool.js
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
/**
|
||||
* context-board-tool.js — Registration for the context_board tool.
|
||||
*
|
||||
* Exposes add + prune operations as a single `context_board` tool. The tool's
|
||||
* `add` operation auto-fills `repository` and `branch` from the current git
|
||||
* context so the LLM never sets those fields directly.
|
||||
*
|
||||
* Pattern mirrors bootstrap/memory-tools.js.
|
||||
*/
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import {
|
||||
executeContextBoardAdd,
|
||||
executeContextBoardPrune,
|
||||
} from "../tools/context-board-tool.js";
|
||||
import { nativeGetCurrentBranch } from "../native-git-bridge.js";
|
||||
import { ensureDbOpen } from "./dynamic-tools.js";
|
||||
|
||||
/** Resolve a stable repository identifier (absolute project root path). */
|
||||
function resolveRepository(basePath) {
|
||||
return basePath;
|
||||
}
|
||||
|
||||
/** Resolve the current branch; falls back to "unknown" if git is unavailable. */
|
||||
function resolveBranch(basePath) {
|
||||
try {
|
||||
const branch = nativeGetCurrentBranch(basePath);
|
||||
return branch || "unknown";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
export function registerContextBoardTool(pi) {
|
||||
pi.registerTool({
|
||||
name: "context_board",
|
||||
label: "Context Board",
|
||||
description:
|
||||
"Manage the always-in-context invariants board for this repo/branch. " +
|
||||
"Use 'add' to record an invariant (architectural rule, in-flight migration state, " +
|
||||
"known broken path, etc.) that should appear in every system prompt. " +
|
||||
"Use 'prune' to remove a stale entry by its id. " +
|
||||
"Add and prune only — to change an entry, prune it then add a new one.",
|
||||
promptSnippet:
|
||||
"Add or prune an invariant on the always-in-context board (op: add | prune)",
|
||||
promptGuidelines: [
|
||||
"Use 'add' for invariants the agent must never operate without: arch rules, deprecation notices, in-flight migration state.",
|
||||
"Keep board entries short — 1-3 sentences each. The board has a 4 KB cap.",
|
||||
"Use 'prune' when an invariant is no longer true or no longer relevant.",
|
||||
"Do NOT add task-specific details or one-off facts — use capture_thought for those.",
|
||||
"The board is scoped per repo and per branch. Different branches have separate boards.",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
op: Type.Union([Type.Literal("add"), Type.Literal("prune")], {
|
||||
description: "Operation: 'add' to create an entry, 'prune' to remove one by id",
|
||||
}),
|
||||
content: Type.Optional(
|
||||
Type.String({
|
||||
description: "Invariant text (1-3 sentences). Required for op=add.",
|
||||
}),
|
||||
),
|
||||
category: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Optional label (e.g. milestone, arch, gotcha, migration, deprecation)",
|
||||
}),
|
||||
),
|
||||
id: Type.Optional(
|
||||
Type.String({
|
||||
description: "Entry id to remove. Required for op=prune.",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
||||
const ok = await ensureDbOpen();
|
||||
if (!ok) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Error: SF database is not available. context_board requires an initialized .sf/ project.",
|
||||
},
|
||||
],
|
||||
details: { operation: "context_board", error: "db_unavailable" },
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const { op } = params;
|
||||
|
||||
if (op === "add") {
|
||||
const basePath = process.cwd();
|
||||
const repository = resolveRepository(basePath);
|
||||
const branch = resolveBranch(basePath);
|
||||
return executeContextBoardAdd({
|
||||
content: params.content ?? "",
|
||||
category: params.category ?? null,
|
||||
repository,
|
||||
branch,
|
||||
});
|
||||
}
|
||||
|
||||
if (op === "prune") {
|
||||
return executeContextBoardPrune({ id: params.id ?? "" });
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error: unknown op "${op}". Must be "add" or "prune".`,
|
||||
},
|
||||
],
|
||||
details: { operation: "context_board", error: "unknown_op" },
|
||||
isError: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import { registerDynamicTools } from "./dynamic-tools.js";
|
|||
import { registerExecTools } from "./exec-tools.js";
|
||||
import { registerJournalTools } from "./journal-tools.js";
|
||||
import { registerJudgmentTools } from "./judgment-tools.js";
|
||||
import { registerContextBoardTool } from "./context-board-tool.js";
|
||||
import { registerMemoryTools } from "./memory-tools.js";
|
||||
import { registerProductAuditTool } from "./product-audit-tool.js";
|
||||
import { registerQueryTools } from "./query-tools.js";
|
||||
|
|
@ -88,6 +89,7 @@ export function registerSfExtension(pi) {
|
|||
["db-tools", () => registerDbTools(pi)],
|
||||
["exec-tools", () => registerExecTools(pi)],
|
||||
["memory-tools", () => registerMemoryTools(pi)],
|
||||
["context-board-tool", () => registerContextBoardTool(pi)],
|
||||
["product-audit-tool", () => registerProductAuditTool(pi)],
|
||||
["journal-tools", () => registerJournalTools(pi)],
|
||||
["judgment-tools", () => registerJudgmentTools(pi)],
|
||||
|
|
|
|||
155
src/resources/extensions/sf/context-board.js
Normal file
155
src/resources/extensions/sf/context-board.js
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
/**
|
||||
* context-board.js — Always-in-context invariants board for SF.
|
||||
*
|
||||
* Persists a small, agent-maintained board of repo/branch-scoped invariants
|
||||
* into the `context_board` SQLite table. The board is rendered in every system
|
||||
* prompt so the agent never operates without it (unlike queryable memory which
|
||||
* must be explicitly fetched).
|
||||
*
|
||||
* Discipline (same as Copilot rem-agent): add + prune only. No update.
|
||||
* If an entry needs changing, prune it and add a new one.
|
||||
*
|
||||
* Consumer: auto/phases.js (system-prompt injection), context-board-tool.js.
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getDatabase, isDbAvailable, withQueryTimeout } from "./sf-db.js";
|
||||
|
||||
/** Maximum byte budget for formatBoardForPrompt (default 4 KB). */
|
||||
const DEFAULT_MAX_BYTES = 4096;
|
||||
|
||||
/**
|
||||
* Add a new invariant entry to the board.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.content — The invariant text (1-3 sentences).
|
||||
* @param {string} [opts.category] — Optional label (e.g. "milestone", "arch", "gotcha").
|
||||
* @param {string} opts.repository — Repo identifier (e.g. project root path or remote URL).
|
||||
* @param {string} opts.branch — Git branch name.
|
||||
* @returns {string|null} The new entry id, or null on failure.
|
||||
*/
|
||||
export function addBoardEntry({ content, category = null, repository, branch }) {
|
||||
if (!isDbAvailable()) return null;
|
||||
const db = getDatabase();
|
||||
const id = randomUUID().replace(/-/g, "").slice(0, 16);
|
||||
const added_at = new Date().toISOString();
|
||||
try {
|
||||
db.prepare(
|
||||
`INSERT INTO context_board (id, content, category, added_at, repository, branch)
|
||||
VALUES (:id, :content, :category, :added_at, :repository, :branch)`,
|
||||
).run({
|
||||
":id": id,
|
||||
":content": String(content ?? "").trim(),
|
||||
":category": category ? String(category).trim() : null,
|
||||
":added_at": added_at,
|
||||
":repository": String(repository ?? ""),
|
||||
":branch": String(branch ?? ""),
|
||||
});
|
||||
return id;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an entry from the board by id.
|
||||
*
|
||||
* @param {string} id — Entry id returned by addBoardEntry.
|
||||
* @returns {boolean} true if a row was deleted.
|
||||
*/
|
||||
export function pruneBoardEntry(id) {
|
||||
if (!isDbAvailable()) return false;
|
||||
const db = getDatabase();
|
||||
try {
|
||||
const result = db.prepare(`DELETE FROM context_board WHERE id = ?`).run(id);
|
||||
return (result?.changes ?? 0) > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all board entries for a given (repository, branch), ordered by added_at ASC.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.repository
|
||||
* @param {string} opts.branch
|
||||
* @returns {Array<{id:string, content:string, category:string|null, added_at:string}>}
|
||||
*/
|
||||
export function getBoardEntries({ repository, branch }) {
|
||||
if (!isDbAvailable()) return [];
|
||||
const db = getDatabase();
|
||||
try {
|
||||
const rows = withQueryTimeout(
|
||||
() =>
|
||||
db
|
||||
.prepare(
|
||||
`SELECT id, content, category, added_at
|
||||
FROM context_board
|
||||
WHERE repository = ? AND branch = ?
|
||||
ORDER BY added_at ASC`,
|
||||
)
|
||||
.all(String(repository ?? ""), String(branch ?? "")),
|
||||
[],
|
||||
);
|
||||
return Array.isArray(rows) ? rows : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render board entries as a Markdown block suitable for injection into a
|
||||
* system prompt. Entries are included oldest-first. If the total byte size
|
||||
* would exceed maxBytes, the oldest entries are truncated first and an
|
||||
* "[older entries truncated]" marker is prepended.
|
||||
*
|
||||
* @param {Array} entries — from getBoardEntries()
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.maxBytes=4096]
|
||||
* @returns {string} Rendered Markdown block, or empty string if no entries.
|
||||
*/
|
||||
export function formatBoardForPrompt(entries, { maxBytes = DEFAULT_MAX_BYTES } = {}) {
|
||||
if (!entries || entries.length === 0) return "";
|
||||
|
||||
const header = "### Invariants for this repo/branch\n\n";
|
||||
const footer =
|
||||
"\n\n> These invariants are always in context. " +
|
||||
"Use `context_board` tool to add (add) or remove (prune) entries.";
|
||||
|
||||
/** Format a single entry line */
|
||||
function formatEntry(e) {
|
||||
const cat = e.category ? ` [${e.category}]` : "";
|
||||
return `- **${e.id}**${cat}: ${e.content}`;
|
||||
}
|
||||
|
||||
const allLines = entries.map(formatEntry);
|
||||
const TRUNCATION_MARKER = "- *[older entries truncated — board exceeded byte cap]*";
|
||||
|
||||
// Build from newest end first, then reverse to restore oldest-first order
|
||||
const encoder = new TextEncoder();
|
||||
const headerBytes = encoder.encode(header + footer).length;
|
||||
const markerBytes = encoder.encode(TRUNCATION_MARKER + "\n").length;
|
||||
const budget = maxBytes - headerBytes;
|
||||
|
||||
let usedBytes = 0;
|
||||
const kept = [];
|
||||
let truncated = false;
|
||||
|
||||
// Walk from newest to oldest so we keep the most recent entries under cap
|
||||
for (let i = allLines.length - 1; i >= 0; i--) {
|
||||
const lineBytes = encoder.encode(allLines[i] + "\n").length;
|
||||
if (usedBytes + lineBytes + (truncated || i === 0 ? 0 : markerBytes) > budget) {
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
usedBytes += lineBytes;
|
||||
kept.unshift(allLines[i]);
|
||||
}
|
||||
|
||||
if (truncated) {
|
||||
kept.unshift(TRUNCATION_MARKER);
|
||||
}
|
||||
|
||||
if (kept.length === 0) return "";
|
||||
return header + kept.join("\n") + footer;
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
import { readFileSync, statSync } from "node:fs";
|
||||
import { delay } from "./atomic-write.js";
|
||||
import {
|
||||
applyMemoryActions,
|
||||
applyConsolidationActions,
|
||||
decayStaleMemories,
|
||||
getActiveMemories,
|
||||
isUnitProcessed,
|
||||
|
|
@ -107,14 +107,19 @@ transcript and identify durable knowledge worth remembering for future sessions.
|
|||
Categories: architecture, convention, gotcha, preference, environment, pattern
|
||||
|
||||
Actions (return JSON array):
|
||||
- CREATE: {"action": "CREATE", "category": "<cat>", "content": "<text>", "confidence": <0.6-0.95>}
|
||||
- UPDATE: {"action": "UPDATE", "id": "<MEM###>", "content": "<revised text>"}
|
||||
- REINFORCE: {"action": "REINFORCE", "id": "<MEM###>"}
|
||||
- SUPERSEDE: {"action": "SUPERSEDE", "id": "<MEM###>", "superseded_by": "<MEM###>"}
|
||||
- add: {"action": "add", "category": "<cat>", "content": "<text>", "confidence": <0.6-0.95>}
|
||||
- prune: {"action": "prune", "id": "<MEM###>"}
|
||||
|
||||
Rules:
|
||||
Action rules:
|
||||
- Use "add" to record new durable knowledge that will be useful in future sessions.
|
||||
- Use "prune" to remove a memory that is now incorrect, outdated, or stale.
|
||||
- Do NOT emit "update", "UPDATE", "supersede", or any other action kinds — they are rejected.
|
||||
- To change an existing memory entry: prune it and add a fresh replacement. Every
|
||||
change must be visible as an explicit add or prune.
|
||||
|
||||
Content rules:
|
||||
- Don't create memories for one-off bug fixes or temporary state
|
||||
- Don't duplicate existing memories — use REINFORCE or UPDATE
|
||||
- Don't duplicate existing memories — if an entry already covers it, skip
|
||||
- Keep content to 1-3 sentences
|
||||
- Confidence: 0.6 tentative, 0.8 solid, 0.95 well-confirmed
|
||||
- Prefer fewer high-quality memories over many low-quality ones
|
||||
|
|
@ -201,13 +206,13 @@ export function parseMemoryResponse(raw) {
|
|||
for (const item of parsed) {
|
||||
if (!item || typeof item !== "object" || !item.action) continue;
|
||||
switch (item.action) {
|
||||
case "CREATE":
|
||||
case "add":
|
||||
if (
|
||||
typeof item.category === "string" &&
|
||||
typeof item.content === "string"
|
||||
) {
|
||||
actions.push({
|
||||
action: "CREATE",
|
||||
action: "add",
|
||||
category: item.category,
|
||||
content: item.content,
|
||||
confidence:
|
||||
|
|
@ -217,35 +222,16 @@ export function parseMemoryResponse(raw) {
|
|||
});
|
||||
}
|
||||
break;
|
||||
case "UPDATE":
|
||||
if (typeof item.id === "string" && typeof item.content === "string") {
|
||||
actions.push({
|
||||
action: "UPDATE",
|
||||
id: item.id,
|
||||
content: item.content,
|
||||
confidence:
|
||||
typeof item.confidence === "number"
|
||||
? item.confidence
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case "REINFORCE":
|
||||
case "prune":
|
||||
if (typeof item.id === "string") {
|
||||
actions.push({ action: "REINFORCE", id: item.id });
|
||||
actions.push({ action: "prune", id: item.id });
|
||||
}
|
||||
break;
|
||||
case "SUPERSEDE":
|
||||
if (
|
||||
typeof item.id === "string" &&
|
||||
typeof item.superseded_by === "string"
|
||||
) {
|
||||
actions.push({
|
||||
action: "SUPERSEDE",
|
||||
id: item.id,
|
||||
superseded_by: item.superseded_by,
|
||||
});
|
||||
}
|
||||
// Silently drop any legacy uppercase action variants the model may emit
|
||||
// despite the prompt instructions — they will be rejected downstream by
|
||||
// applyConsolidationActions anyway, but filtering here keeps the error
|
||||
// surface clean and avoids a throw on every legacy response.
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -309,9 +295,9 @@ export async function extractMemoriesFromUnit(
|
|||
const response = await llmCallFn(EXTRACTION_SYSTEM, userPrompt);
|
||||
// Parse response
|
||||
const actions = parseMemoryResponse(response);
|
||||
// Apply actions
|
||||
// Apply actions (consolidation-path only — add/prune discipline enforced)
|
||||
if (actions.length > 0) {
|
||||
applyMemoryActions(actions, unitType, unitId);
|
||||
applyConsolidationActions(actions, unitType, unitId);
|
||||
}
|
||||
// Decay stale memories periodically
|
||||
decayStaleMemories(20);
|
||||
|
|
@ -324,7 +310,7 @@ export async function extractMemoriesFromUnit(
|
|||
await delay(2000);
|
||||
const response2 = await llmCallFn(EXTRACTION_SYSTEM, userPrompt);
|
||||
const actions2 = parseMemoryResponse(response2);
|
||||
if (actions2.length > 0) applyMemoryActions(actions2, unitType, unitId);
|
||||
if (actions2.length > 0) applyConsolidationActions(actions2, unitType, unitId);
|
||||
markUnitProcessed(unitKey, activityFile);
|
||||
} catch {
|
||||
// Non-fatal — memory extraction failure should never affect autonomous mode
|
||||
|
|
|
|||
|
|
@ -470,6 +470,90 @@ export function enforceMemoryCap(max = 50) {
|
|||
}
|
||||
}
|
||||
// ─── Action Application ─────────────────────────────────────────────────────
|
||||
/**
|
||||
* Restricted action applicator for the memory consolidation (extractor) path.
|
||||
*
|
||||
* Only accepts `"add"` (alias for CREATE) and `"prune"` (alias for SUPERSEDE —
|
||||
* marks the memory superseded so it is excluded from active queries; no new
|
||||
* memory is created, which is the correct semantics for "this entry is now
|
||||
* stale/wrong").
|
||||
*
|
||||
* Rejects `"update"`, `"supersede"`, `"reinforce"`, and any other action with
|
||||
* an error. The model must prune-then-add instead of updating in place, so
|
||||
* every change is visible as an explicit add or remove.
|
||||
*
|
||||
* Other callers that need the full action surface should use applyMemoryActions.
|
||||
*
|
||||
* @param {Array<{action: string, [key: string]: unknown}>} actions
|
||||
* @param {string} unitType
|
||||
* @param {string} unitId
|
||||
*/
|
||||
export function applyConsolidationActions(actions, unitType, unitId) {
|
||||
if (!isDbAvailable() || actions.length === 0) return;
|
||||
// Validate all actions before touching the DB — fail fast on any violation.
|
||||
for (const action of actions) {
|
||||
const kind = action.action;
|
||||
if (kind !== "add" && kind !== "prune") {
|
||||
throw new Error(
|
||||
`applyConsolidationActions: action "${kind}" is not allowed on the consolidation path. ` +
|
||||
`Only "add" and "prune" are permitted. ` +
|
||||
`To change an existing memory, prune it and add a fresh one so the change is explicit.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
try {
|
||||
transaction(() => {
|
||||
const createdInBatch = [];
|
||||
for (const action of actions) {
|
||||
if (action.action === "add") {
|
||||
const id = createMemory({
|
||||
category: action.category,
|
||||
content: action.content,
|
||||
confidence: action.confidence,
|
||||
source_unit_type: unitType,
|
||||
source_unit_id: unitId,
|
||||
});
|
||||
if (id) createdInBatch.push(id);
|
||||
} else if (action.action === "prune") {
|
||||
// "prune" supersedes the target memory with a sentinel value indicating
|
||||
// it was pruned during consolidation. Using the unitId as superseded_by
|
||||
// keeps the audit trail readable without requiring a new memory ID.
|
||||
const sentinelId = `pruned:${unitType}:${unitId}`;
|
||||
supersedeMemory(action.id, sentinelId);
|
||||
}
|
||||
}
|
||||
// Link co-created memories from the same consolidation pass (same rationale
|
||||
// as applyMemoryActions — they share narrative context).
|
||||
if (createdInBatch.length > 1) {
|
||||
try {
|
||||
for (let i = 0; i < createdInBatch.length; i++) {
|
||||
for (let j = i + 1; j < createdInBatch.length; j++) {
|
||||
createMemoryRelation(
|
||||
createdInBatch[i],
|
||||
createdInBatch[j],
|
||||
"related_to",
|
||||
0.5,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Relation linkage is additive; skip on failure.
|
||||
}
|
||||
}
|
||||
enforceMemoryCap();
|
||||
});
|
||||
} catch (err) {
|
||||
// Re-throw validation errors (action-kind rejections) so the caller sees them.
|
||||
// Swallow DB-level errors as non-fatal (consistent with applyMemoryActions).
|
||||
if (
|
||||
err instanceof Error &&
|
||||
err.message.startsWith("applyConsolidationActions:")
|
||||
) {
|
||||
throw err;
|
||||
}
|
||||
// non-fatal — transaction will have rolled back
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Process an array of memory actions in a transaction.
|
||||
* Calls enforceMemoryCap at the end.
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ function defaultQueryTimeout(operation, fallbackValue) {
|
|||
}
|
||||
}
|
||||
|
||||
const SCHEMA_VERSION = 62;
|
||||
const SCHEMA_VERSION = 63;
|
||||
function indexExists(db, name) {
|
||||
return !!db
|
||||
.prepare(
|
||||
|
|
@ -571,6 +571,21 @@ function ensureValidationAttentionMarkersTable(db) {
|
|||
)
|
||||
`);
|
||||
}
|
||||
function ensureContextBoardTable(db) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS context_board (
|
||||
id TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
category TEXT,
|
||||
added_at TEXT NOT NULL,
|
||||
repository TEXT NOT NULL,
|
||||
branch TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
db.exec(
|
||||
"CREATE INDEX IF NOT EXISTS idx_context_board_repo_branch ON context_board(repository, branch, added_at ASC)",
|
||||
);
|
||||
}
|
||||
function ensureSpecSchemaTables(db) {
|
||||
// Tier 1.3: Spec/Runtime/Evidence schema separation
|
||||
// Creates 9 normalized tables for milestone, slice, task entities
|
||||
|
|
@ -1169,6 +1184,7 @@ export function initSchema(db, fileBacked, options = {}) {
|
|||
ensureTriageTables(db);
|
||||
ensureRuntimeCounterTable(db);
|
||||
ensureValidationAttentionMarkersTable(db);
|
||||
ensureContextBoardTable(db);
|
||||
db.exec(
|
||||
`CREATE VIEW IF NOT EXISTS active_decisions AS SELECT * FROM decisions WHERE superseded_by IS NULL`,
|
||||
);
|
||||
|
|
@ -3239,6 +3255,33 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) {
|
|||
});
|
||||
if (ok) appliedVersion = 62;
|
||||
}
|
||||
if (appliedVersion < 63) {
|
||||
const ok = runMigrationStep("v63", () => {
|
||||
// Schema v63: context_board — always-in-context invariants board.
|
||||
// Per-repo/per-branch entries rendered into every dispatch system
|
||||
// prompt. Add/prune-only discipline enforced at the tool layer.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS context_board (
|
||||
id TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
category TEXT,
|
||||
added_at TEXT NOT NULL,
|
||||
repository TEXT NOT NULL,
|
||||
branch TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
db.exec(
|
||||
"CREATE INDEX IF NOT EXISTS idx_context_board_repo_branch ON context_board(repository, branch, added_at ASC)",
|
||||
);
|
||||
db.prepare(
|
||||
"INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)",
|
||||
).run({
|
||||
":version": 63,
|
||||
":applied_at": new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
if (ok) appliedVersion = 63;
|
||||
}
|
||||
|
||||
// Post-migration assertion: ensure critical tables created by historical
|
||||
// migrations are actually present. If a prior migration claimed success but
|
||||
|
|
|
|||
316
src/resources/extensions/sf/tests/context-board.test.ts
Normal file
316
src/resources/extensions/sf/tests/context-board.test.ts
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
/**
|
||||
* context-board.test.ts — Tests for the always-in-context invariants board.
|
||||
*
|
||||
* Covers:
|
||||
* - SQL migration: table exists after sf-db initialization
|
||||
* - addBoardEntry + getBoardEntries round-trip per (repository, branch)
|
||||
* - pruneBoardEntry removes only the targeted entry
|
||||
* - formatBoardForPrompt respects byte cap with truncation marker
|
||||
* - Tool invocations: executeContextBoardAdd and executeContextBoardPrune
|
||||
*/
|
||||
|
||||
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
addBoardEntry,
|
||||
formatBoardForPrompt,
|
||||
getBoardEntries,
|
||||
pruneBoardEntry,
|
||||
} from "../context-board.js";
|
||||
import { closeDatabase, openDatabase } from "../sf-db.js";
|
||||
import {
|
||||
executeContextBoardAdd,
|
||||
executeContextBoardPrune,
|
||||
} from "../tools/context-board-tool.js";
|
||||
|
||||
const tmpDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
closeDatabase();
|
||||
while (tmpDirs.length > 0) {
|
||||
const dir = tmpDirs.pop();
|
||||
if (dir) rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function makeProject(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "sf-context-board-"));
|
||||
tmpDirs.push(dir);
|
||||
mkdirSync(join(dir, ".sf"), { recursive: true });
|
||||
openDatabase(join(dir, ".sf", "sf.db"));
|
||||
return dir;
|
||||
}
|
||||
|
||||
// ─── SQL migration ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("SQL migration", () => {
|
||||
it("context_board table exists after sf-db initialization", () => {
|
||||
makeProject();
|
||||
// addBoardEntry would throw or return null if the table doesn't exist
|
||||
const id = addBoardEntry({
|
||||
content: "migration check",
|
||||
repository: "/repo",
|
||||
branch: "main",
|
||||
});
|
||||
expect(id).toBeTruthy();
|
||||
expect(typeof id).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── addBoardEntry + getBoardEntries round-trip ────────────────────────────────
|
||||
|
||||
describe("addBoardEntry / getBoardEntries", () => {
|
||||
it("round-trips an entry for a given (repository, branch)", () => {
|
||||
makeProject();
|
||||
const id = addBoardEntry({
|
||||
content: "Do not edit generated files",
|
||||
category: "arch",
|
||||
repository: "/my/repo",
|
||||
branch: "main",
|
||||
});
|
||||
expect(id).toBeTruthy();
|
||||
|
||||
const entries = getBoardEntries({ repository: "/my/repo", branch: "main" });
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].id).toBe(id);
|
||||
expect(entries[0].content).toBe("Do not edit generated files");
|
||||
expect(entries[0].category).toBe("arch");
|
||||
expect(entries[0].added_at).toBeTruthy();
|
||||
});
|
||||
|
||||
it("entries are scoped per (repository, branch) — different branch sees empty board", () => {
|
||||
makeProject();
|
||||
addBoardEntry({
|
||||
content: "main-only invariant",
|
||||
repository: "/my/repo",
|
||||
branch: "main",
|
||||
});
|
||||
|
||||
const mainEntries = getBoardEntries({ repository: "/my/repo", branch: "main" });
|
||||
const featureEntries = getBoardEntries({ repository: "/my/repo", branch: "feature/x" });
|
||||
|
||||
expect(mainEntries).toHaveLength(1);
|
||||
expect(featureEntries).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("entries are scoped per (repository, branch) — different repo sees empty board", () => {
|
||||
makeProject();
|
||||
addBoardEntry({
|
||||
content: "repo-a invariant",
|
||||
repository: "/repo-a",
|
||||
branch: "main",
|
||||
});
|
||||
|
||||
const repoAEntries = getBoardEntries({ repository: "/repo-a", branch: "main" });
|
||||
const repoBEntries = getBoardEntries({ repository: "/repo-b", branch: "main" });
|
||||
|
||||
expect(repoAEntries).toHaveLength(1);
|
||||
expect(repoBEntries).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns multiple entries ordered by added_at ASC", () => {
|
||||
makeProject();
|
||||
const id1 = addBoardEntry({
|
||||
content: "first invariant",
|
||||
repository: "/repo",
|
||||
branch: "main",
|
||||
});
|
||||
const id2 = addBoardEntry({
|
||||
content: "second invariant",
|
||||
repository: "/repo",
|
||||
branch: "main",
|
||||
});
|
||||
|
||||
const entries = getBoardEntries({ repository: "/repo", branch: "main" });
|
||||
expect(entries).toHaveLength(2);
|
||||
// Both IDs should be present (order may vary for same-millisecond adds)
|
||||
const ids = entries.map((e) => e.id);
|
||||
expect(ids).toContain(id1);
|
||||
expect(ids).toContain(id2);
|
||||
});
|
||||
|
||||
it("returns empty array when db is not open", () => {
|
||||
// Do NOT call makeProject() — db is closed
|
||||
const entries = getBoardEntries({ repository: "/repo", branch: "main" });
|
||||
expect(entries).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("addBoardEntry returns null when db is not open", () => {
|
||||
// Do NOT call makeProject()
|
||||
const id = addBoardEntry({ content: "test", repository: "/repo", branch: "main" });
|
||||
expect(id).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── pruneBoardEntry ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("pruneBoardEntry", () => {
|
||||
it("removes only the targeted entry", () => {
|
||||
makeProject();
|
||||
const id1 = addBoardEntry({ content: "keep this", repository: "/repo", branch: "main" });
|
||||
const id2 = addBoardEntry({ content: "prune this", repository: "/repo", branch: "main" });
|
||||
|
||||
expect(id1).toBeTruthy();
|
||||
expect(id2).toBeTruthy();
|
||||
|
||||
const removed = pruneBoardEntry(id2!);
|
||||
expect(removed).toBe(true);
|
||||
|
||||
const remaining = getBoardEntries({ repository: "/repo", branch: "main" });
|
||||
expect(remaining).toHaveLength(1);
|
||||
expect(remaining[0].id).toBe(id1);
|
||||
});
|
||||
|
||||
it("returns false when entry does not exist", () => {
|
||||
makeProject();
|
||||
const removed = pruneBoardEntry("nonexistent-id");
|
||||
expect(removed).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when db is not open", () => {
|
||||
const removed = pruneBoardEntry("some-id");
|
||||
expect(removed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── formatBoardForPrompt ─────────────────────────────────────────────────────
|
||||
|
||||
describe("formatBoardForPrompt", () => {
|
||||
it("returns empty string for empty entries", () => {
|
||||
const result = formatBoardForPrompt([]);
|
||||
expect(result).toBe("");
|
||||
});
|
||||
|
||||
it("includes header, entries, and footer", () => {
|
||||
const entries = [
|
||||
{ id: "abc123", content: "Use vitest not jest", category: "convention", added_at: "2026-05-13T00:00:00Z" },
|
||||
];
|
||||
const result = formatBoardForPrompt(entries);
|
||||
expect(result).toContain("### Invariants for this repo/branch");
|
||||
expect(result).toContain("abc123");
|
||||
expect(result).toContain("Use vitest not jest");
|
||||
expect(result).toContain("[convention]");
|
||||
expect(result).toContain("context_board");
|
||||
});
|
||||
|
||||
it("renders entries without category", () => {
|
||||
const entries = [
|
||||
{ id: "noCat1", content: "No category entry", category: null, added_at: "2026-05-13T00:00:00Z" },
|
||||
];
|
||||
const result = formatBoardForPrompt(entries);
|
||||
expect(result).toContain("noCat1");
|
||||
expect(result).not.toContain("[null]");
|
||||
expect(result).not.toContain("[]");
|
||||
});
|
||||
|
||||
it("truncates oldest entries when byte cap is exceeded, preserving truncation marker", () => {
|
||||
// Create many entries that together exceed 512 bytes
|
||||
const entries = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: `entry${String(i).padStart(3, "0")}`,
|
||||
content: `This is a board entry with enough text to consume space in the output buffer. Entry number ${i}.`,
|
||||
category: "arch",
|
||||
added_at: `2026-05-${String(i + 1).padStart(2, "0")}T00:00:00Z`,
|
||||
}));
|
||||
|
||||
const result = formatBoardForPrompt(entries, { maxBytes: 512 });
|
||||
|
||||
// Should include truncation marker since we exceeded the cap
|
||||
expect(result).toContain("[older entries truncated");
|
||||
// Should still include at least the newest entry
|
||||
expect(result).toContain("entry019");
|
||||
});
|
||||
|
||||
it("does not truncate when entries fit within cap", () => {
|
||||
const entries = [
|
||||
{ id: "short1", content: "Short entry", category: null, added_at: "2026-05-13T00:00:00Z" },
|
||||
];
|
||||
const result = formatBoardForPrompt(entries, { maxBytes: 4096 });
|
||||
expect(result).not.toContain("truncated");
|
||||
expect(result).toContain("short1");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tool invocations ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("executeContextBoardAdd", () => {
|
||||
it("adds an entry and returns its id", () => {
|
||||
makeProject();
|
||||
const result = executeContextBoardAdd({
|
||||
content: "Never merge PRs on Fridays",
|
||||
category: "gotcha",
|
||||
repository: "/repo",
|
||||
branch: "main",
|
||||
});
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(result.details?.id).toBeTruthy();
|
||||
expect(result.content[0].text).toContain("Board entry added");
|
||||
// Verify it was actually stored
|
||||
const entries = getBoardEntries({ repository: "/repo", branch: "main" });
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].content).toBe("Never merge PRs on Fridays");
|
||||
});
|
||||
|
||||
it("returns error when content is missing", () => {
|
||||
makeProject();
|
||||
const result = executeContextBoardAdd({
|
||||
content: "",
|
||||
repository: "/repo",
|
||||
branch: "main",
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain("content is required");
|
||||
});
|
||||
|
||||
it("returns error when db unavailable", () => {
|
||||
// DB not open
|
||||
const result = executeContextBoardAdd({
|
||||
content: "test",
|
||||
repository: "/repo",
|
||||
branch: "main",
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.details?.error).toBe("db_unavailable");
|
||||
});
|
||||
});
|
||||
|
||||
describe("executeContextBoardPrune", () => {
|
||||
it("removes an existing entry", () => {
|
||||
makeProject();
|
||||
const id = addBoardEntry({
|
||||
content: "to be pruned",
|
||||
repository: "/repo",
|
||||
branch: "main",
|
||||
});
|
||||
expect(id).toBeTruthy();
|
||||
|
||||
const result = executeContextBoardPrune({ id: id! });
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(result.details?.removed).toBe(true);
|
||||
expect(result.content[0].text).toContain("pruned");
|
||||
|
||||
const remaining = getBoardEntries({ repository: "/repo", branch: "main" });
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns non-error result (not found) for missing id", () => {
|
||||
makeProject();
|
||||
const result = executeContextBoardPrune({ id: "ghost-id" });
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(result.details?.removed).toBe(false);
|
||||
});
|
||||
|
||||
it("returns error when id is empty", () => {
|
||||
makeProject();
|
||||
const result = executeContextBoardPrune({ id: "" });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain("id is required");
|
||||
});
|
||||
|
||||
it("returns error when db unavailable", () => {
|
||||
const result = executeContextBoardPrune({ id: "some-id" });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.details?.error).toBe("db_unavailable");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,417 @@
|
|||
/**
|
||||
* memory-consolidation-discipline.test.ts
|
||||
*
|
||||
* Verify the add/prune-only discipline on the consolidation (extractor) path:
|
||||
* - applyConsolidationActions accepts "add" and "prune" actions.
|
||||
* - applyConsolidationActions rejects "update", "supersede", and other kinds.
|
||||
* - applyMemoryActions still accepts the full action surface (unaffected).
|
||||
* - swarm-dispatch passes a formatted memory snapshot in the result when
|
||||
* the parent has active memories.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ─── Top-level mocks (hoisted by vitest) ─────────────────────────────────────
|
||||
|
||||
vi.mock("../sf-db.js", () => ({
|
||||
isDbAvailable: vi.fn().mockReturnValue(true),
|
||||
_getAdapter: vi.fn().mockReturnValue(null),
|
||||
transaction: vi.fn((fn: () => void) => fn()),
|
||||
insertMemoryRow: vi.fn(),
|
||||
rewriteMemoryId: vi.fn(),
|
||||
updateMemoryContentRow: vi.fn(),
|
||||
incrementMemoryHitCount: vi.fn(),
|
||||
supersedeMemoryRow: vi.fn(),
|
||||
decayMemoriesBefore: vi.fn(),
|
||||
supersedeLowestRankedMemories: vi.fn(),
|
||||
markMemoryUnitProcessed: vi.fn(),
|
||||
deleteMemoryEmbedding: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../memory-relations.js", () => ({
|
||||
createMemoryRelation: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../sync-scheduler.js", () => ({
|
||||
queueMemorySync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../sm-client.js", () => ({
|
||||
querySmMemories: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
// memory-store mock used by swarm-dispatch tests — only overrides the two
|
||||
// functions touched at the dispatch boundary; the real implementations are
|
||||
// used everywhere else (including the consolidation-action tests).
|
||||
vi.mock("../memory-store.js", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("../memory-store.js")>();
|
||||
return {
|
||||
...original,
|
||||
getActiveMemoriesRanked: vi.fn().mockReturnValue([]),
|
||||
formatMemoriesForPrompt: vi.fn().mockReturnValue(""),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../uok/agent-swarm.js", () => ({
|
||||
AgentSwarm: {
|
||||
load: vi.fn().mockReturnValue({
|
||||
getAll: vi.fn().mockReturnValue([{ identity: { name: "worker-1" } }]),
|
||||
route: vi.fn().mockReturnValue({ identity: { name: "worker-1" } }),
|
||||
getTopology: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../uok/message-bus.js", () => {
|
||||
const MockMessageBus = vi.fn().mockImplementation(function () {
|
||||
this.send = vi.fn().mockReturnValue("msg-abc");
|
||||
});
|
||||
return { MessageBus: MockMessageBus };
|
||||
});
|
||||
|
||||
vi.mock("../uok/swarm-roles.js", () => ({
|
||||
createDefaultSwarm: vi.fn().mockResolvedValue({
|
||||
swarm: {
|
||||
getAll: vi
|
||||
.fn()
|
||||
.mockReturnValue([{ identity: { name: "worker-1" } }]),
|
||||
route: vi
|
||||
.fn()
|
||||
.mockReturnValue({ identity: { name: "worker-1" } }),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// ─── Imports (after mocks) ────────────────────────────────────────────────────
|
||||
|
||||
import * as sfDb from "../sf-db.js";
|
||||
import {
|
||||
applyConsolidationActions,
|
||||
applyMemoryActions,
|
||||
} from "../memory-store.js";
|
||||
import * as memoryStore from "../memory-store.js";
|
||||
import { SwarmDispatchLayer } from "../uok/swarm-dispatch.js";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeAddAction(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
action: "add",
|
||||
category: "convention",
|
||||
content: "Use vitest for all unit tests.",
|
||||
confidence: 0.8,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makePruneAction(id = "MEM001") {
|
||||
return { action: "prune", id };
|
||||
}
|
||||
|
||||
/** Shared DB adapter that simulates a successful memory insert. */
|
||||
function makeMockAdapter() {
|
||||
return {
|
||||
prepare: vi.fn().mockReturnValue({
|
||||
get: vi.fn().mockReturnValue({ seq: 1 }),
|
||||
all: vi.fn().mockReturnValue([]),
|
||||
run: vi.fn(),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const testEnvelope = {
|
||||
unitId: "T001",
|
||||
unitType: "task" as const,
|
||||
workMode: "build" as const,
|
||||
payload: { goal: "write tests" },
|
||||
priority: 5,
|
||||
scope: "project",
|
||||
};
|
||||
|
||||
// ─── applyConsolidationActions ────────────────────────────────────────────────
|
||||
|
||||
describe("applyConsolidationActions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(sfDb.isDbAvailable as ReturnType<typeof vi.fn>).mockReturnValue(true);
|
||||
(sfDb.transaction as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
(fn: () => void) => fn(),
|
||||
);
|
||||
(sfDb._getAdapter as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
makeMockAdapter(),
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts an "add" action without throwing', () => {
|
||||
expect(() =>
|
||||
applyConsolidationActions([makeAddAction()], "task", "T001"),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts a "prune" action without throwing', () => {
|
||||
expect(() =>
|
||||
applyConsolidationActions([makePruneAction("MEM007")], "task", "T001"),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts a mixed batch of "add" and "prune" without throwing', () => {
|
||||
expect(() =>
|
||||
applyConsolidationActions(
|
||||
[makeAddAction(), makePruneAction("MEM003")],
|
||||
"slice",
|
||||
"S01",
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects an "update" action with a descriptive error', () => {
|
||||
expect(() =>
|
||||
applyConsolidationActions(
|
||||
[{ action: "update", id: "MEM001", content: "new text" }],
|
||||
"task",
|
||||
"T001",
|
||||
),
|
||||
).toThrow(/applyConsolidationActions.*update.*not allowed/i);
|
||||
});
|
||||
|
||||
it('rejects an "UPDATE" (uppercase) action with a descriptive error', () => {
|
||||
expect(() =>
|
||||
applyConsolidationActions(
|
||||
[{ action: "UPDATE", id: "MEM001", content: "new text" }],
|
||||
"task",
|
||||
"T001",
|
||||
),
|
||||
).toThrow(/applyConsolidationActions.*UPDATE.*not allowed/i);
|
||||
});
|
||||
|
||||
it('rejects a "supersede" action with a descriptive error', () => {
|
||||
expect(() =>
|
||||
applyConsolidationActions(
|
||||
[{ action: "supersede", id: "MEM001", superseded_by: "MEM002" }],
|
||||
"task",
|
||||
"T001",
|
||||
),
|
||||
).toThrow(/applyConsolidationActions.*supersede.*not allowed/i);
|
||||
});
|
||||
|
||||
it('rejects a "SUPERSEDE" (uppercase) action with a descriptive error', () => {
|
||||
expect(() =>
|
||||
applyConsolidationActions(
|
||||
[{ action: "SUPERSEDE", id: "MEM001", superseded_by: "MEM002" }],
|
||||
"task",
|
||||
"T001",
|
||||
),
|
||||
).toThrow(/applyConsolidationActions.*SUPERSEDE.*not allowed/i);
|
||||
});
|
||||
|
||||
it('rejects a "reinforce" action with a descriptive error', () => {
|
||||
expect(() =>
|
||||
applyConsolidationActions(
|
||||
[{ action: "reinforce", id: "MEM001" }],
|
||||
"task",
|
||||
"T001",
|
||||
),
|
||||
).toThrow(/applyConsolidationActions.*reinforce.*not allowed/i);
|
||||
});
|
||||
|
||||
it("error message instructs the model to prune-then-add instead of updating", () => {
|
||||
let errorMsg = "";
|
||||
try {
|
||||
applyConsolidationActions(
|
||||
[{ action: "update", id: "MEM001", content: "revised" }],
|
||||
"task",
|
||||
"T001",
|
||||
);
|
||||
} catch (err) {
|
||||
errorMsg = (err as Error).message;
|
||||
}
|
||||
expect(errorMsg).toMatch(/prune.*add/i);
|
||||
});
|
||||
|
||||
it("does nothing and does not throw for an empty actions array", () => {
|
||||
expect(() =>
|
||||
applyConsolidationActions([], "task", "T001"),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("does nothing and does not throw when DB is unavailable", () => {
|
||||
(sfDb.isDbAvailable as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
||||
expect(() =>
|
||||
applyConsolidationActions([makeAddAction()], "task", "T001"),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── applyMemoryActions (full surface, unaffected) ────────────────────────────
|
||||
|
||||
describe("applyMemoryActions — full action surface remains intact", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(sfDb.isDbAvailable as ReturnType<typeof vi.fn>).mockReturnValue(true);
|
||||
(sfDb.transaction as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
(fn: () => void) => fn(),
|
||||
);
|
||||
(sfDb._getAdapter as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
makeMockAdapter(),
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts "CREATE" without throwing', () => {
|
||||
expect(() =>
|
||||
applyMemoryActions(
|
||||
[
|
||||
{
|
||||
action: "CREATE",
|
||||
category: "convention",
|
||||
content: "Use npm only.",
|
||||
confidence: 0.8,
|
||||
},
|
||||
],
|
||||
"task",
|
||||
"T002",
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts "UPDATE" without throwing', () => {
|
||||
expect(() =>
|
||||
applyMemoryActions(
|
||||
[{ action: "UPDATE", id: "MEM001", content: "revised text" }],
|
||||
"task",
|
||||
"T002",
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts "REINFORCE" without throwing', () => {
|
||||
expect(() =>
|
||||
applyMemoryActions(
|
||||
[{ action: "REINFORCE", id: "MEM001" }],
|
||||
"task",
|
||||
"T002",
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts "SUPERSEDE" without throwing', () => {
|
||||
expect(() =>
|
||||
applyMemoryActions(
|
||||
[{ action: "SUPERSEDE", id: "MEM001", superseded_by: "MEM002" }],
|
||||
"task",
|
||||
"T002",
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── swarm-dispatch memory inheritance ───────────────────────────────────────
|
||||
|
||||
describe("swarm-dispatch — memory snapshot inheritance", () => {
|
||||
const threeMemories = [
|
||||
{
|
||||
seq: 1,
|
||||
id: "MEM001",
|
||||
category: "convention",
|
||||
content: "Use vitest.",
|
||||
confidence: 0.9,
|
||||
hit_count: 2,
|
||||
tags: [],
|
||||
source_unit_type: null,
|
||||
source_unit_id: null,
|
||||
created_at: "2026-01-01",
|
||||
updated_at: "2026-01-01",
|
||||
superseded_by: null,
|
||||
},
|
||||
{
|
||||
seq: 2,
|
||||
id: "MEM002",
|
||||
category: "architecture",
|
||||
content: "SF uses SQLite.",
|
||||
confidence: 0.85,
|
||||
hit_count: 1,
|
||||
tags: [],
|
||||
source_unit_type: null,
|
||||
source_unit_id: null,
|
||||
created_at: "2026-01-01",
|
||||
updated_at: "2026-01-01",
|
||||
superseded_by: null,
|
||||
},
|
||||
{
|
||||
seq: 3,
|
||||
id: "MEM003",
|
||||
category: "gotcha",
|
||||
content: "Never use bun.",
|
||||
confidence: 0.95,
|
||||
hit_count: 3,
|
||||
tags: [],
|
||||
source_unit_type: null,
|
||||
source_unit_id: null,
|
||||
created_at: "2026-01-01",
|
||||
updated_at: "2026-01-01",
|
||||
superseded_by: null,
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset to empty by default; individual tests override as needed.
|
||||
(memoryStore.getActiveMemoriesRanked as ReturnType<typeof vi.fn>)
|
||||
.mockReturnValue([]);
|
||||
(memoryStore.formatMemoriesForPrompt as ReturnType<typeof vi.fn>)
|
||||
.mockReturnValue("");
|
||||
});
|
||||
|
||||
it("includes memoryContext in dispatch result when parent has 3 active memories", async () => {
|
||||
(memoryStore.getActiveMemoriesRanked as ReturnType<typeof vi.fn>)
|
||||
.mockReturnValue(threeMemories);
|
||||
(memoryStore.formatMemoriesForPrompt as ReturnType<typeof vi.fn>)
|
||||
.mockReturnValue(
|
||||
"## Project Memory (auto-learned)\n- Use vitest.\n- SF uses SQLite.\n- Never use bun.",
|
||||
);
|
||||
|
||||
const layer = new SwarmDispatchLayer("/tmp/fake-project", {
|
||||
autoCreate: false,
|
||||
});
|
||||
const result = await layer.dispatch(testEnvelope);
|
||||
|
||||
// Result should carry the memoryContext field
|
||||
expect(result.memoryContext).toBeDefined();
|
||||
expect(result.memoryContext).toContain("Project Memory");
|
||||
|
||||
// getActiveMemoriesRanked should have been called with limit 30
|
||||
expect(memoryStore.getActiveMemoriesRanked).toHaveBeenCalledWith(30);
|
||||
|
||||
// formatMemoriesForPrompt should have received the three memories
|
||||
expect(memoryStore.formatMemoriesForPrompt).toHaveBeenCalledWith(
|
||||
threeMemories,
|
||||
2000,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("omits memoryContext from result when no memories exist", async () => {
|
||||
// Already set to [] by beforeEach
|
||||
const layer = new SwarmDispatchLayer("/tmp/fake-project-2", {
|
||||
autoCreate: false,
|
||||
});
|
||||
const result = await layer.dispatch(testEnvelope);
|
||||
|
||||
expect(result.memoryContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it("dispatch succeeds even when getActiveMemoriesRanked throws (fail-open)", async () => {
|
||||
(memoryStore.getActiveMemoriesRanked as ReturnType<typeof vi.fn>)
|
||||
.mockImplementation(() => {
|
||||
throw new Error("DB offline");
|
||||
});
|
||||
|
||||
const layer = new SwarmDispatchLayer("/tmp/fake-project-3", {
|
||||
autoCreate: false,
|
||||
});
|
||||
|
||||
// Should not throw — memory context is fail-open
|
||||
const result = await layer.dispatch(testEnvelope);
|
||||
expect(result.messageId).toBe("msg-abc");
|
||||
expect(result.memoryContext).toBeUndefined();
|
||||
});
|
||||
});
|
||||
124
src/resources/extensions/sf/tools/context-board-tool.js
Normal file
124
src/resources/extensions/sf/tools/context-board-tool.js
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
/**
|
||||
* context-board-tool.js — Executor for the context_board tool.
|
||||
*
|
||||
* Two operations: add (add an invariant) and prune (remove by id).
|
||||
* The `repository` and `branch` fields are auto-filled from the git context
|
||||
* provided by the caller — the LLM never sets them directly.
|
||||
*
|
||||
* Pattern mirrors memory-tools.js: pure executor functions that the bootstrap
|
||||
* registration layer wraps in pi.registerTool().
|
||||
*/
|
||||
import {
|
||||
addBoardEntry,
|
||||
pruneBoardEntry,
|
||||
} from "../context-board.js";
|
||||
import { isDbAvailable } from "../sf-db.js";
|
||||
|
||||
function dbUnavailable(operation) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Error: SF database is not available. context_board requires an initialized .sf/ project.",
|
||||
},
|
||||
],
|
||||
details: { operation, error: "db_unavailable" },
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a context_board add operation.
|
||||
*
|
||||
* @param {object} params
|
||||
* @param {string} params.content — Invariant text.
|
||||
* @param {string} [params.category] — Optional category label.
|
||||
* @param {string} params.repository — Resolved by caller from git context.
|
||||
* @param {string} params.branch — Resolved by caller from git context.
|
||||
*/
|
||||
export function executeContextBoardAdd(params) {
|
||||
if (!isDbAvailable()) return dbUnavailable("context_board_add");
|
||||
|
||||
const content = (params.content ?? "").trim();
|
||||
if (!content) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: content is required." }],
|
||||
details: { operation: "context_board_add", error: "missing_content" },
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const id = addBoardEntry({
|
||||
content,
|
||||
category: params.category ?? null,
|
||||
repository: params.repository ?? "",
|
||||
branch: params.branch ?? "",
|
||||
});
|
||||
|
||||
if (!id) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: failed to add board entry." }],
|
||||
details: { operation: "context_board_add", error: "insert_failed" },
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const catLabel = params.category ? ` [${params.category}]` : "";
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Board entry added: ${id}${catLabel}\n${content}\n\nThis invariant will appear in every subsequent system prompt for ${params.branch ?? "(current branch)"}.`,
|
||||
},
|
||||
],
|
||||
details: {
|
||||
operation: "context_board_add",
|
||||
id,
|
||||
category: params.category ?? null,
|
||||
repository: params.repository,
|
||||
branch: params.branch,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a context_board prune operation.
|
||||
*
|
||||
* @param {object} params
|
||||
* @param {string} params.id — Entry id to remove.
|
||||
*/
|
||||
export function executeContextBoardPrune(params) {
|
||||
if (!isDbAvailable()) return dbUnavailable("context_board_prune");
|
||||
|
||||
const id = (params.id ?? "").trim();
|
||||
if (!id) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: id is required." }],
|
||||
details: { operation: "context_board_prune", error: "missing_id" },
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const removed = pruneBoardEntry(id);
|
||||
if (!removed) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `No board entry found with id "${id}". It may have already been pruned.`,
|
||||
},
|
||||
],
|
||||
details: { operation: "context_board_prune", id, removed: false },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Board entry "${id}" pruned. It will no longer appear in system prompts.`,
|
||||
},
|
||||
],
|
||||
details: { operation: "context_board_prune", id, removed: true },
|
||||
};
|
||||
}
|
||||
|
|
@ -25,6 +25,10 @@
|
|||
import { AgentSwarm } from "./agent-swarm.js";
|
||||
import { MessageBus } from "./message-bus.js";
|
||||
import { createDefaultSwarm } from "./swarm-roles.js";
|
||||
import {
|
||||
formatMemoriesForPrompt,
|
||||
getActiveMemoriesRanked,
|
||||
} from "../memory-store.js";
|
||||
|
||||
// Module-level cache keyed by `${basePath}:${swarmName}`
|
||||
const _cache = new Map();
|
||||
|
|
@ -153,12 +157,29 @@ export class SwarmDispatchLayer {
|
|||
);
|
||||
}
|
||||
|
||||
// ── Memory inheritance: snapshot the parent's active memory view ──────
|
||||
// Fetch the top-30 ranked memories at dispatch time so the member agent
|
||||
// starts with the same knowledge context as the orchestrator. Snapshot
|
||||
// semantics: the member sees the world as it was when dispatched and does
|
||||
// not receive live updates mid-task. Fail-open: if getActiveMemoriesRanked
|
||||
// throws or returns nothing the dispatch still proceeds without context.
|
||||
let memoryContext = "";
|
||||
try {
|
||||
const memories = getActiveMemoriesRanked(30);
|
||||
if (memories.length > 0) {
|
||||
memoryContext = formatMemoriesForPrompt(memories, 2000, false);
|
||||
}
|
||||
} catch {
|
||||
// Memory context is additive — never block dispatch on a read failure.
|
||||
}
|
||||
|
||||
const from = `dispatch:${envelope.scope}:${envelope.unitId}`;
|
||||
const to = `agent:${target.identity.name}`;
|
||||
const metadata = {
|
||||
unitId: envelope.unitId,
|
||||
unitType: envelope.unitType,
|
||||
workMode: envelope.workMode,
|
||||
...(memoryContext ? { memoryContext } : {}),
|
||||
};
|
||||
|
||||
const messageId = this._bus.send(from, to, envelope.payload, metadata);
|
||||
|
|
@ -168,6 +189,7 @@ export class SwarmDispatchLayer {
|
|||
targetAgent: target.identity.name,
|
||||
swarmName: this._swarmName,
|
||||
envelope,
|
||||
...(memoryContext ? { memoryContext } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue