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:
Mikael Hugo 2026-05-14 05:08:31 +02:00
parent f68ab20953
commit 21d9054611
11 changed files with 1331 additions and 39 deletions

View file

@ -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 {

View 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,
};
},
});
}

View file

@ -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)],

View 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;
}

View file

@ -7,7 +7,7 @@
import { readFileSync, statSync } from "node:fs";
import { delay } from "./atomic-write.js";
import {
applyMemoryActions,
applyConsolidationActions,
decayStaleMemories,
getActiveMemories,
isUnitProcessed,
@ -107,14 +107,19 @@ transcript and identify durable knowledge worth remembering for future sessions.
Categories: architecture, convention, gotcha, preference, environment, pattern
Actions (return JSON array):
- CREATE: {"action": "CREATE", "category": "<cat>", "content": "<text>", "confidence": <0.6-0.95>}
- UPDATE: {"action": "UPDATE", "id": "<MEM###>", "content": "<revised text>"}
- REINFORCE: {"action": "REINFORCE", "id": "<MEM###>"}
- SUPERSEDE: {"action": "SUPERSEDE", "id": "<MEM###>", "superseded_by": "<MEM###>"}
- add: {"action": "add", "category": "<cat>", "content": "<text>", "confidence": <0.6-0.95>}
- prune: {"action": "prune", "id": "<MEM###>"}
Rules:
Action rules:
- Use "add" to record new durable knowledge that will be useful in future sessions.
- Use "prune" to remove a memory that is now incorrect, outdated, or stale.
- Do NOT emit "update", "UPDATE", "supersede", or any other action kinds they are rejected.
- To change an existing memory entry: prune it and add a fresh replacement. Every
change must be visible as an explicit add or prune.
Content rules:
- Don't create memories for one-off bug fixes or temporary state
- Don't duplicate existing memories use REINFORCE or UPDATE
- Don't duplicate existing memories if an entry already covers it, skip
- Keep content to 1-3 sentences
- Confidence: 0.6 tentative, 0.8 solid, 0.95 well-confirmed
- Prefer fewer high-quality memories over many low-quality ones
@ -201,13 +206,13 @@ export function parseMemoryResponse(raw) {
for (const item of parsed) {
if (!item || typeof item !== "object" || !item.action) continue;
switch (item.action) {
case "CREATE":
case "add":
if (
typeof item.category === "string" &&
typeof item.content === "string"
) {
actions.push({
action: "CREATE",
action: "add",
category: item.category,
content: item.content,
confidence:
@ -217,35 +222,16 @@ export function parseMemoryResponse(raw) {
});
}
break;
case "UPDATE":
if (typeof item.id === "string" && typeof item.content === "string") {
actions.push({
action: "UPDATE",
id: item.id,
content: item.content,
confidence:
typeof item.confidence === "number"
? item.confidence
: undefined,
});
}
break;
case "REINFORCE":
case "prune":
if (typeof item.id === "string") {
actions.push({ action: "REINFORCE", id: item.id });
actions.push({ action: "prune", id: item.id });
}
break;
case "SUPERSEDE":
if (
typeof item.id === "string" &&
typeof item.superseded_by === "string"
) {
actions.push({
action: "SUPERSEDE",
id: item.id,
superseded_by: item.superseded_by,
});
}
// Silently drop any legacy uppercase action variants the model may emit
// despite the prompt instructions — they will be rejected downstream by
// applyConsolidationActions anyway, but filtering here keeps the error
// surface clean and avoids a throw on every legacy response.
default:
break;
}
}
@ -309,9 +295,9 @@ export async function extractMemoriesFromUnit(
const response = await llmCallFn(EXTRACTION_SYSTEM, userPrompt);
// Parse response
const actions = parseMemoryResponse(response);
// Apply actions
// Apply actions (consolidation-path only — add/prune discipline enforced)
if (actions.length > 0) {
applyMemoryActions(actions, unitType, unitId);
applyConsolidationActions(actions, unitType, unitId);
}
// Decay stale memories periodically
decayStaleMemories(20);
@ -324,7 +310,7 @@ export async function extractMemoriesFromUnit(
await delay(2000);
const response2 = await llmCallFn(EXTRACTION_SYSTEM, userPrompt);
const actions2 = parseMemoryResponse(response2);
if (actions2.length > 0) applyMemoryActions(actions2, unitType, unitId);
if (actions2.length > 0) applyConsolidationActions(actions2, unitType, unitId);
markUnitProcessed(unitKey, activityFile);
} catch {
// Non-fatal — memory extraction failure should never affect autonomous mode

View file

@ -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.

View file

@ -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

View 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");
});
});

View file

@ -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();
});
});

View 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 },
};
}

View file

@ -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 } : {}),
};
}