twining_archive
Archive old blackboard entries by moving entries before a cutoff timestamp to an archive file, preserving decisions and optionally posting a summary finding.
Instructions
Archive old blackboard entries. Moves entries older than a cutoff timestamp to an archive file, preserving decision entries. Optionally posts a summary finding.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| before | No | ISO timestamp cutoff — archive entries before this time (default: now) | |
| keep_decisions | No | Whether to keep decision entries in the blackboard (default: true) | |
| summarize | No | Whether to post a summary finding after archiving (default: true) |
Implementation Reference
- src/tools/lifecycle-tools.ts:178-216 (registration)Tool registration of 'twining_archive' — registers the tool on the MCP server with schema for 'before', 'keep_decisions', and 'summarize' parameters.
// twining_archive — Archive old blackboard entries server.registerTool( "twining_archive", { description: "Archive old blackboard entries. Moves entries older than a cutoff timestamp to an archive file, preserving decision entries. Optionally posts a summary finding.", inputSchema: { before: z .string() .refine((val) => !isNaN(Date.parse(val)), { message: "Must be a valid ISO 8601 timestamp", }) .optional() .describe("ISO timestamp cutoff — archive entries before this time (default: now)"), keep_decisions: z .boolean() .optional() .describe("Whether to keep decision entries in the blackboard (default: true)"), summarize: z .boolean() .optional() .describe("Whether to post a summary finding after archiving (default: true)"), }, }, async (args) => { try { const result = await archiver.archive(args); return toolResult(result); } catch (e) { if (e instanceof TwiningError) { return toolError(e.message, e.code); } return toolError( e instanceof Error ? e.message : "Unknown error", "INTERNAL_ERROR", ); } }, ); - src/tools/lifecycle-tools.ts:202-216 (handler)Tool handler for 'twining_archive' — delegates to archiver.archive() and returns the result (archived_count, archive_file, summary).
async (args) => { try { const result = await archiver.archive(args); return toolResult(result); } catch (e) { if (e instanceof TwiningError) { return toolError(e.message, e.code); } return toolError( e instanceof Error ? e.message : "Unknown error", "INTERNAL_ERROR", ); } }, ); - src/engine/archiver.ts:21-194 (helper)The Archiver class that implements the core archiving logic: reads blackboard entries, partitions by cutoff timestamp (keeping decisions if configured), writes archived entries to archive/{YYYY-MM-DD}-blackboard.jsonl, rewrites blackboard with kept entries, removes embeddings, and optionally posts a summary finding.
export class Archiver { private readonly twiningDir: string; private readonly blackboardStore: BlackboardStore; private readonly blackboardEngine: BlackboardEngine; private readonly indexManager: IndexManager | null; constructor( twiningDir: string, blackboardStore: BlackboardStore, blackboardEngine: BlackboardEngine, indexManager: IndexManager | null, ) { this.twiningDir = twiningDir; this.blackboardStore = blackboardStore; this.blackboardEngine = blackboardEngine; this.indexManager = indexManager; } /** * Archive old blackboard entries. * Decision entries are never archived (LIFE-03). * Archived entries are moved to archive/{YYYY-MM-DD}-blackboard.jsonl. * Optionally posts a summary finding (LIFE-02). */ async archive(options?: { before?: string; keep_decisions?: boolean; summarize?: boolean; }): Promise<{ archived_count: number; archive_file: string; summary?: string; }> { const cutoff = options?.before ?? new Date().toISOString(); const keepDecisions = options?.keep_decisions ?? true; const summarize = options?.summarize ?? true; const bbPath = path.join(this.twiningDir, "blackboard.jsonl"); // Ensure blackboard file exists for locking if (!fs.existsSync(bbPath)) { return { archived_count: 0, archive_file: "" }; } // Lock blackboard for the full read-partition-rewrite cycle if (!fs.existsSync(bbPath)) { fs.writeFileSync(bbPath, ""); } const release = await lockfile.lock(bbPath, LOCK_OPTIONS); let toArchive: BlackboardEntry[] = []; let toKeep: BlackboardEntry[] = []; let archiveFile = ""; try { // Read all entries (no lock needed since we hold it) const content = fs.readFileSync(bbPath, "utf-8"); const lines = content .split("\n") .filter((line) => line.trim().length > 0); const allEntries: BlackboardEntry[] = []; for (const line of lines) { try { allEntries.push(JSON.parse(line) as BlackboardEntry); } catch { // Skip corrupt lines } } // Partition: entries before cutoff go to archive, UNLESS they are decisions for (const entry of allEntries) { const isOldEnough = entry.timestamp < cutoff; const isDecision = entry.entry_type === "decision"; const shouldKeep = !isOldEnough || (keepDecisions && isDecision); if (shouldKeep) { toKeep.push(entry); } else { toArchive.push(entry); } } // If nothing to archive, release lock and return early if (toArchive.length === 0) { return { archived_count: 0, archive_file: "" }; } // Write archived entries to archive file BEFORE rewriting blackboard. // This ensures entries exist in the archive before being removed from // the blackboard, preventing data loss on crash. const archiveDir = path.join(this.twiningDir, "archive"); ensureDir(archiveDir); const dateStr = cutoff.slice(0, 10); // YYYY-MM-DD archiveFile = path.join(archiveDir, `${dateStr}-blackboard.jsonl`); // Direct append (no nested lock since we already hold the blackboard lock) const archiveContent = toArchive .map((e) => JSON.stringify(e)) .join("\n") + "\n"; fs.appendFileSync(archiveFile, archiveContent); // Now rewrite blackboard with only kept entries const keptContent = toKeep.length > 0 ? toKeep.map((e) => JSON.stringify(e)).join("\n") + "\n" : ""; fs.writeFileSync(bbPath, keptContent); } finally { await release(); } // Remove archived entry embeddings (best-effort) if (this.indexManager) { try { const archivedIds = toArchive.map((e) => e.id); await this.indexManager.removeEntries("blackboard", archivedIds); } catch { // Best-effort — don't fail archive if embedding cleanup fails } } // Build and post summary if requested let summaryText: string | undefined; if (summarize) { summaryText = this.buildSummary(toArchive); await this.blackboardEngine.post({ entry_type: "finding", summary: `Archive: ${toArchive.length} entries archived`, detail: summaryText, tags: ["archive"], scope: "project", }); } return { archived_count: toArchive.length, archive_file: archiveFile, summary: summaryText, }; } /** Build a human-readable summary of archived entries. */ private buildSummary(archived: BlackboardEntry[]): string { // Group by entry_type const groups = new Map<string, BlackboardEntry[]>(); for (const entry of archived) { if (!groups.has(entry.entry_type)) groups.set(entry.entry_type, []); groups.get(entry.entry_type)!.push(entry); } const parts: string[] = []; parts.push(`Archive summary: ${archived.length} entries archived.`); for (const [type, entries] of groups) { // Sort by timestamp descending for recency const sorted = entries.sort((a, b) => b.timestamp.localeCompare(a.timestamp), ); const topSummaries = sorted .slice(0, 3) .map((e) => e.summary) .join("; "); parts.push(`${type}: ${entries.length} entries (${topSummaries}).`); } let summary = parts.join(" "); // Cap at 2000 chars if (summary.length > 2000) { summary = summary.slice(0, 1997) + "..."; } return summary; } } - src/tools/lifecycle-tools.ts:184-200 (schema)Input schema using Zod: 'before' (ISO 8601 timestamp, optional), 'keep_decisions' (boolean, default true), 'summarize' (boolean, default true).
inputSchema: { before: z .string() .refine((val) => !isNaN(Date.parse(val)), { message: "Must be a valid ISO 8601 timestamp", }) .optional() .describe("ISO timestamp cutoff — archive entries before this time (default: now)"), keep_decisions: z .boolean() .optional() .describe("Whether to keep decision entries in the blackboard (default: true)"), summarize: z .boolean() .optional() .describe("Whether to post a summary finding after archiving (default: true)"), }, - src/server.ts:221-233 (registration)Registration call site — 'twining_archive' (via registerLifecycleTools) is only registered when toolMode is 'full'.
if (toolMode === "full") { registerLifecycleTools( server, twiningDir, blackboardStore, decisionStore, graphStore, archiver, config, agentStore, ); registerGraphTools(server, graphEngine); }