From 21d9054611d9f8fcd576279fa9b0a5db37c2e4b2 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Thu, 14 May 2026 05:08:31 +0200 Subject: [PATCH] feat(sf): rem-agent-inspired memory discipline + always-in-context invariants board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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::"). 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) --- .../extensions/sf/auto/phases-dispatch.js | 25 ++ .../sf/bootstrap/context-board-tool.js | 118 +++++ .../sf/bootstrap/register-extension.js | 2 + src/resources/extensions/sf/context-board.js | 155 +++++++ .../extensions/sf/memory-extractor.js | 62 +-- src/resources/extensions/sf/memory-store.js | 84 ++++ .../extensions/sf/sf-db/sf-db-schema.js | 45 +- .../extensions/sf/tests/context-board.test.ts | 316 +++++++++++++ .../memory-consolidation-discipline.test.ts | 417 ++++++++++++++++++ .../extensions/sf/tools/context-board-tool.js | 124 ++++++ .../extensions/sf/uok/swarm-dispatch.js | 22 + 11 files changed, 1331 insertions(+), 39 deletions(-) create mode 100644 src/resources/extensions/sf/bootstrap/context-board-tool.js create mode 100644 src/resources/extensions/sf/context-board.js create mode 100644 src/resources/extensions/sf/tests/context-board.test.ts create mode 100644 src/resources/extensions/sf/tests/memory-consolidation-discipline.test.ts create mode 100644 src/resources/extensions/sf/tools/context-board-tool.js diff --git a/src/resources/extensions/sf/auto/phases-dispatch.js b/src/resources/extensions/sf/auto/phases-dispatch.js index 9e9f539ec..ab2728e18 100644 --- a/src/resources/extensions/sf/auto/phases-dispatch.js +++ b/src/resources/extensions/sf/auto/phases-dispatch.js @@ -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 { diff --git a/src/resources/extensions/sf/bootstrap/context-board-tool.js b/src/resources/extensions/sf/bootstrap/context-board-tool.js new file mode 100644 index 000000000..2c84407d5 --- /dev/null +++ b/src/resources/extensions/sf/bootstrap/context-board-tool.js @@ -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, + }; + }, + }); +} diff --git a/src/resources/extensions/sf/bootstrap/register-extension.js b/src/resources/extensions/sf/bootstrap/register-extension.js index 6ce7d2958..9e91d7c6a 100644 --- a/src/resources/extensions/sf/bootstrap/register-extension.js +++ b/src/resources/extensions/sf/bootstrap/register-extension.js @@ -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)], diff --git a/src/resources/extensions/sf/context-board.js b/src/resources/extensions/sf/context-board.js new file mode 100644 index 000000000..7ce5db66d --- /dev/null +++ b/src/resources/extensions/sf/context-board.js @@ -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; +} diff --git a/src/resources/extensions/sf/memory-extractor.js b/src/resources/extensions/sf/memory-extractor.js index 7db81f25f..b44d589db 100644 --- a/src/resources/extensions/sf/memory-extractor.js +++ b/src/resources/extensions/sf/memory-extractor.js @@ -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": "", "content": "", "confidence": <0.6-0.95>} -- UPDATE: {"action": "UPDATE", "id": "", "content": ""} -- REINFORCE: {"action": "REINFORCE", "id": ""} -- SUPERSEDE: {"action": "SUPERSEDE", "id": "", "superseded_by": ""} +- add: {"action": "add", "category": "", "content": "", "confidence": <0.6-0.95>} +- prune: {"action": "prune", "id": ""} -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 diff --git a/src/resources/extensions/sf/memory-store.js b/src/resources/extensions/sf/memory-store.js index c681f4138..08578cf01 100644 --- a/src/resources/extensions/sf/memory-store.js +++ b/src/resources/extensions/sf/memory-store.js @@ -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. diff --git a/src/resources/extensions/sf/sf-db/sf-db-schema.js b/src/resources/extensions/sf/sf-db/sf-db-schema.js index 1dd57bed9..194688ae4 100644 --- a/src/resources/extensions/sf/sf-db/sf-db-schema.js +++ b/src/resources/extensions/sf/sf-db/sf-db-schema.js @@ -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 diff --git a/src/resources/extensions/sf/tests/context-board.test.ts b/src/resources/extensions/sf/tests/context-board.test.ts new file mode 100644 index 000000000..253545eb3 --- /dev/null +++ b/src/resources/extensions/sf/tests/context-board.test.ts @@ -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"); + }); +}); diff --git a/src/resources/extensions/sf/tests/memory-consolidation-discipline.test.ts b/src/resources/extensions/sf/tests/memory-consolidation-discipline.test.ts new file mode 100644 index 000000000..d49b3cc36 --- /dev/null +++ b/src/resources/extensions/sf/tests/memory-consolidation-discipline.test.ts @@ -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(); + 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 = {}) { + 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).mockReturnValue(true); + (sfDb.transaction as ReturnType).mockImplementation( + (fn: () => void) => fn(), + ); + (sfDb._getAdapter as ReturnType).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).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).mockReturnValue(true); + (sfDb.transaction as ReturnType).mockImplementation( + (fn: () => void) => fn(), + ); + (sfDb._getAdapter as ReturnType).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) + .mockReturnValue([]); + (memoryStore.formatMemoriesForPrompt as ReturnType) + .mockReturnValue(""); + }); + + it("includes memoryContext in dispatch result when parent has 3 active memories", async () => { + (memoryStore.getActiveMemoriesRanked as ReturnType) + .mockReturnValue(threeMemories); + (memoryStore.formatMemoriesForPrompt as ReturnType) + .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) + .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(); + }); +}); diff --git a/src/resources/extensions/sf/tools/context-board-tool.js b/src/resources/extensions/sf/tools/context-board-tool.js new file mode 100644 index 000000000..2f5b1a555 --- /dev/null +++ b/src/resources/extensions/sf/tools/context-board-tool.js @@ -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 }, + }; +} diff --git a/src/resources/extensions/sf/uok/swarm-dispatch.js b/src/resources/extensions/sf/uok/swarm-dispatch.js index b6e1f107a..35a45fc88 100644 --- a/src/resources/extensions/sf/uok/swarm-dispatch.js +++ b/src/resources/extensions/sf/uok/swarm-dispatch.js @@ -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 } : {}), }; }