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,
|
recordAutonomousSolverMissingCheckpointRetry,
|
||||||
} from "../autonomous-solver.js";
|
} from "../autonomous-solver.js";
|
||||||
import { resumeAutoAfterProviderDelay } from "../bootstrap/provider-error-resume.js";
|
import { resumeAutoAfterProviderDelay } from "../bootstrap/provider-error-resume.js";
|
||||||
|
import {
|
||||||
|
formatBoardForPrompt,
|
||||||
|
getBoardEntries,
|
||||||
|
} from "../context-board.js";
|
||||||
import { debugLog } from "../debug-logger.js";
|
import { debugLog } from "../debug-logger.js";
|
||||||
import { PROJECT_FILES } from "../detection.js";
|
import { PROJECT_FILES } from "../detection.js";
|
||||||
import { MergeConflictError } from "../git-service.js";
|
import { MergeConflictError } from "../git-service.js";
|
||||||
|
import { nativeGetCurrentBranch } from "../native-git-bridge.js";
|
||||||
import { recordLearnedOutcome } from "../learning/runtime.js";
|
import { recordLearnedOutcome } from "../learning/runtime.js";
|
||||||
import { sfRoot } from "../paths.js";
|
import { sfRoot } from "../paths.js";
|
||||||
import { resolvePersistModelChanges } from "../preferences.js";
|
import { resolvePersistModelChanges } from "../preferences.js";
|
||||||
|
|
@ -260,6 +265,26 @@ export async function runDispatch(ic, preData, loopState) {
|
||||||
const unitId = dispatchResult.unitId;
|
const unitId = dispatchResult.unitId;
|
||||||
let prompt = dispatchResult.prompt;
|
let prompt = dispatchResult.prompt;
|
||||||
const pauseAfterUatDispatch = dispatchResult.pauseAfterDispatch ?? false;
|
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 ──────────────────────────────────────
|
// ── Reasoning assist injection ──────────────────────────────────────
|
||||||
if (isReasoningAssistEnabled(unitType)) {
|
if (isReasoningAssistEnabled(unitType)) {
|
||||||
try {
|
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 { registerExecTools } from "./exec-tools.js";
|
||||||
import { registerJournalTools } from "./journal-tools.js";
|
import { registerJournalTools } from "./journal-tools.js";
|
||||||
import { registerJudgmentTools } from "./judgment-tools.js";
|
import { registerJudgmentTools } from "./judgment-tools.js";
|
||||||
|
import { registerContextBoardTool } from "./context-board-tool.js";
|
||||||
import { registerMemoryTools } from "./memory-tools.js";
|
import { registerMemoryTools } from "./memory-tools.js";
|
||||||
import { registerProductAuditTool } from "./product-audit-tool.js";
|
import { registerProductAuditTool } from "./product-audit-tool.js";
|
||||||
import { registerQueryTools } from "./query-tools.js";
|
import { registerQueryTools } from "./query-tools.js";
|
||||||
|
|
@ -88,6 +89,7 @@ export function registerSfExtension(pi) {
|
||||||
["db-tools", () => registerDbTools(pi)],
|
["db-tools", () => registerDbTools(pi)],
|
||||||
["exec-tools", () => registerExecTools(pi)],
|
["exec-tools", () => registerExecTools(pi)],
|
||||||
["memory-tools", () => registerMemoryTools(pi)],
|
["memory-tools", () => registerMemoryTools(pi)],
|
||||||
|
["context-board-tool", () => registerContextBoardTool(pi)],
|
||||||
["product-audit-tool", () => registerProductAuditTool(pi)],
|
["product-audit-tool", () => registerProductAuditTool(pi)],
|
||||||
["journal-tools", () => registerJournalTools(pi)],
|
["journal-tools", () => registerJournalTools(pi)],
|
||||||
["judgment-tools", () => registerJudgmentTools(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 { readFileSync, statSync } from "node:fs";
|
||||||
import { delay } from "./atomic-write.js";
|
import { delay } from "./atomic-write.js";
|
||||||
import {
|
import {
|
||||||
applyMemoryActions,
|
applyConsolidationActions,
|
||||||
decayStaleMemories,
|
decayStaleMemories,
|
||||||
getActiveMemories,
|
getActiveMemories,
|
||||||
isUnitProcessed,
|
isUnitProcessed,
|
||||||
|
|
@ -107,14 +107,19 @@ transcript and identify durable knowledge worth remembering for future sessions.
|
||||||
Categories: architecture, convention, gotcha, preference, environment, pattern
|
Categories: architecture, convention, gotcha, preference, environment, pattern
|
||||||
|
|
||||||
Actions (return JSON array):
|
Actions (return JSON array):
|
||||||
- CREATE: {"action": "CREATE", "category": "<cat>", "content": "<text>", "confidence": <0.6-0.95>}
|
- add: {"action": "add", "category": "<cat>", "content": "<text>", "confidence": <0.6-0.95>}
|
||||||
- UPDATE: {"action": "UPDATE", "id": "<MEM###>", "content": "<revised text>"}
|
- prune: {"action": "prune", "id": "<MEM###>"}
|
||||||
- REINFORCE: {"action": "REINFORCE", "id": "<MEM###>"}
|
|
||||||
- SUPERSEDE: {"action": "SUPERSEDE", "id": "<MEM###>", "superseded_by": "<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 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
|
- Keep content to 1-3 sentences
|
||||||
- Confidence: 0.6 tentative, 0.8 solid, 0.95 well-confirmed
|
- Confidence: 0.6 tentative, 0.8 solid, 0.95 well-confirmed
|
||||||
- Prefer fewer high-quality memories over many low-quality ones
|
- Prefer fewer high-quality memories over many low-quality ones
|
||||||
|
|
@ -201,13 +206,13 @@ export function parseMemoryResponse(raw) {
|
||||||
for (const item of parsed) {
|
for (const item of parsed) {
|
||||||
if (!item || typeof item !== "object" || !item.action) continue;
|
if (!item || typeof item !== "object" || !item.action) continue;
|
||||||
switch (item.action) {
|
switch (item.action) {
|
||||||
case "CREATE":
|
case "add":
|
||||||
if (
|
if (
|
||||||
typeof item.category === "string" &&
|
typeof item.category === "string" &&
|
||||||
typeof item.content === "string"
|
typeof item.content === "string"
|
||||||
) {
|
) {
|
||||||
actions.push({
|
actions.push({
|
||||||
action: "CREATE",
|
action: "add",
|
||||||
category: item.category,
|
category: item.category,
|
||||||
content: item.content,
|
content: item.content,
|
||||||
confidence:
|
confidence:
|
||||||
|
|
@ -217,35 +222,16 @@ export function parseMemoryResponse(raw) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "UPDATE":
|
case "prune":
|
||||||
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":
|
|
||||||
if (typeof item.id === "string") {
|
if (typeof item.id === "string") {
|
||||||
actions.push({ action: "REINFORCE", id: item.id });
|
actions.push({ action: "prune", id: item.id });
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "SUPERSEDE":
|
// Silently drop any legacy uppercase action variants the model may emit
|
||||||
if (
|
// despite the prompt instructions — they will be rejected downstream by
|
||||||
typeof item.id === "string" &&
|
// applyConsolidationActions anyway, but filtering here keeps the error
|
||||||
typeof item.superseded_by === "string"
|
// surface clean and avoids a throw on every legacy response.
|
||||||
) {
|
default:
|
||||||
actions.push({
|
|
||||||
action: "SUPERSEDE",
|
|
||||||
id: item.id,
|
|
||||||
superseded_by: item.superseded_by,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -309,9 +295,9 @@ export async function extractMemoriesFromUnit(
|
||||||
const response = await llmCallFn(EXTRACTION_SYSTEM, userPrompt);
|
const response = await llmCallFn(EXTRACTION_SYSTEM, userPrompt);
|
||||||
// Parse response
|
// Parse response
|
||||||
const actions = parseMemoryResponse(response);
|
const actions = parseMemoryResponse(response);
|
||||||
// Apply actions
|
// Apply actions (consolidation-path only — add/prune discipline enforced)
|
||||||
if (actions.length > 0) {
|
if (actions.length > 0) {
|
||||||
applyMemoryActions(actions, unitType, unitId);
|
applyConsolidationActions(actions, unitType, unitId);
|
||||||
}
|
}
|
||||||
// Decay stale memories periodically
|
// Decay stale memories periodically
|
||||||
decayStaleMemories(20);
|
decayStaleMemories(20);
|
||||||
|
|
@ -324,7 +310,7 @@ export async function extractMemoriesFromUnit(
|
||||||
await delay(2000);
|
await delay(2000);
|
||||||
const response2 = await llmCallFn(EXTRACTION_SYSTEM, userPrompt);
|
const response2 = await llmCallFn(EXTRACTION_SYSTEM, userPrompt);
|
||||||
const actions2 = parseMemoryResponse(response2);
|
const actions2 = parseMemoryResponse(response2);
|
||||||
if (actions2.length > 0) applyMemoryActions(actions2, unitType, unitId);
|
if (actions2.length > 0) applyConsolidationActions(actions2, unitType, unitId);
|
||||||
markUnitProcessed(unitKey, activityFile);
|
markUnitProcessed(unitKey, activityFile);
|
||||||
} catch {
|
} catch {
|
||||||
// Non-fatal — memory extraction failure should never affect autonomous mode
|
// Non-fatal — memory extraction failure should never affect autonomous mode
|
||||||
|
|
|
||||||
|
|
@ -470,6 +470,90 @@ export function enforceMemoryCap(max = 50) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ─── Action Application ─────────────────────────────────────────────────────
|
// ─── 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.
|
* Process an array of memory actions in a transaction.
|
||||||
* Calls enforceMemoryCap at the end.
|
* 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) {
|
function indexExists(db, name) {
|
||||||
return !!db
|
return !!db
|
||||||
.prepare(
|
.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) {
|
function ensureSpecSchemaTables(db) {
|
||||||
// Tier 1.3: Spec/Runtime/Evidence schema separation
|
// Tier 1.3: Spec/Runtime/Evidence schema separation
|
||||||
// Creates 9 normalized tables for milestone, slice, task entities
|
// Creates 9 normalized tables for milestone, slice, task entities
|
||||||
|
|
@ -1169,6 +1184,7 @@ export function initSchema(db, fileBacked, options = {}) {
|
||||||
ensureTriageTables(db);
|
ensureTriageTables(db);
|
||||||
ensureRuntimeCounterTable(db);
|
ensureRuntimeCounterTable(db);
|
||||||
ensureValidationAttentionMarkersTable(db);
|
ensureValidationAttentionMarkersTable(db);
|
||||||
|
ensureContextBoardTable(db);
|
||||||
db.exec(
|
db.exec(
|
||||||
`CREATE VIEW IF NOT EXISTS active_decisions AS SELECT * FROM decisions WHERE superseded_by IS NULL`,
|
`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 (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
|
// Post-migration assertion: ensure critical tables created by historical
|
||||||
// migrations are actually present. If a prior migration claimed success but
|
// 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 { AgentSwarm } from "./agent-swarm.js";
|
||||||
import { MessageBus } from "./message-bus.js";
|
import { MessageBus } from "./message-bus.js";
|
||||||
import { createDefaultSwarm } from "./swarm-roles.js";
|
import { createDefaultSwarm } from "./swarm-roles.js";
|
||||||
|
import {
|
||||||
|
formatMemoriesForPrompt,
|
||||||
|
getActiveMemoriesRanked,
|
||||||
|
} from "../memory-store.js";
|
||||||
|
|
||||||
// Module-level cache keyed by `${basePath}:${swarmName}`
|
// Module-level cache keyed by `${basePath}:${swarmName}`
|
||||||
const _cache = new Map();
|
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 from = `dispatch:${envelope.scope}:${envelope.unitId}`;
|
||||||
const to = `agent:${target.identity.name}`;
|
const to = `agent:${target.identity.name}`;
|
||||||
const metadata = {
|
const metadata = {
|
||||||
unitId: envelope.unitId,
|
unitId: envelope.unitId,
|
||||||
unitType: envelope.unitType,
|
unitType: envelope.unitType,
|
||||||
workMode: envelope.workMode,
|
workMode: envelope.workMode,
|
||||||
|
...(memoryContext ? { memoryContext } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const messageId = this._bus.send(from, to, envelope.payload, metadata);
|
const messageId = this._bus.send(from, to, envelope.payload, metadata);
|
||||||
|
|
@ -168,6 +189,7 @@ export class SwarmDispatchLayer {
|
||||||
targetAgent: target.identity.name,
|
targetAgent: target.identity.name,
|
||||||
swarmName: this._swarmName,
|
swarmName: this._swarmName,
|
||||||
envelope,
|
envelope,
|
||||||
|
...(memoryContext ? { memoryContext } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue