feat: unified task.json schema migration and multi-module updates

- Create task-schema.json (JSON Schema draft-07) with 10 field blocks fusing
  Unified JSONL, 6-field Task JSON, and Solution Schema advantages
- Migrate unified-execute-with-file from JSONL to .task/*.json directory scanning
- Migrate 3 producers (lite-plan, plan-converter, collaborative-plan) to
  .task/*.json multi-file output
- Add review-cycle Phase 7.5 export-to-tasks (FIX-*.json) and issue-resolve
  --export-tasks option
- Add schema compatibility annotations to action-planning-agent, workflow-plan,
  and tdd-plan
- Add spec-generator skill phases and templates
- Add memory v2 pipeline (consolidation, extraction, job scheduler, embedder)
- Add secret-redactor utility and core-memory enhancements
- Add codex-lens accuracy benchmarks and staged env config overrides
This commit is contained in:
catlog22
2026-02-11 17:40:56 +08:00
parent 7aa1038951
commit 99ee4e7d36
36 changed files with 7823 additions and 315 deletions

View File

@@ -11,6 +11,7 @@ import {
exportMemories,
importMemories
} from '../core/core-memory-store.js';
import { MemoryJobScheduler } from '../core/memory-job-scheduler.js';
import { notifyRefreshRequired } from '../tools/notifier.js';
interface CommandOptions {
@@ -664,6 +665,185 @@ async function searchAction(keyword: string, options: CommandOptions): Promise<v
}
}
// ============================================================================
// Memory V2 CLI Subcommands
// ============================================================================
/**
* Run batch extraction
*/
async function extractAction(options: CommandOptions): Promise<void> {
try {
const projectPath = getProjectPath();
console.log(chalk.cyan('\n Triggering memory extraction...\n'));
const { MemoryExtractionPipeline } = await import('../core/memory-extraction-pipeline.js');
const pipeline = new MemoryExtractionPipeline(projectPath);
// Scan eligible sessions first
const eligible = await pipeline.scanEligibleSessions();
console.log(chalk.white(` Eligible sessions: ${eligible.length}`));
if (eligible.length === 0) {
console.log(chalk.yellow(' No eligible sessions for extraction.\n'));
return;
}
// Run extraction (synchronous for CLI - shows progress)
console.log(chalk.cyan(' Running batch extraction...'));
await pipeline.runBatchExtraction();
const store = getCoreMemoryStore(projectPath);
const stage1Count = store.countStage1Outputs();
console.log(chalk.green(`\n Extraction complete.`));
console.log(chalk.white(` Total stage1 outputs: ${stage1Count}\n`));
notifyRefreshRequired('memory').catch(() => { /* ignore */ });
} catch (error) {
console.error(chalk.red(`Error: ${(error as Error).message}`));
process.exit(1);
}
}
/**
* Show extraction status
*/
async function extractStatusAction(options: CommandOptions): Promise<void> {
try {
const projectPath = getProjectPath();
const store = getCoreMemoryStore(projectPath);
const scheduler = new MemoryJobScheduler(store.getDb());
const stage1Count = store.countStage1Outputs();
const extractionJobs = scheduler.listJobs('extraction');
if (options.json) {
console.log(JSON.stringify({ total_stage1: stage1Count, jobs: extractionJobs }, null, 2));
return;
}
console.log(chalk.bold.cyan('\n Extraction Pipeline Status\n'));
console.log(chalk.white(` Stage 1 outputs: ${stage1Count}`));
console.log(chalk.white(` Extraction jobs: ${extractionJobs.length}`));
if (extractionJobs.length > 0) {
console.log(chalk.gray('\n ─────────────────────────────────────────────────────────────────'));
for (const job of extractionJobs) {
const statusColor = job.status === 'done' ? chalk.green
: job.status === 'error' ? chalk.red
: job.status === 'running' ? chalk.yellow
: chalk.gray;
console.log(chalk.cyan(` ${job.job_key}`) + chalk.white(` [${statusColor(job.status)}]`));
if (job.last_error) console.log(chalk.red(` Error: ${job.last_error}`));
if (job.started_at) console.log(chalk.gray(` Started: ${new Date(job.started_at * 1000).toLocaleString()}`));
if (job.finished_at) console.log(chalk.gray(` Finished: ${new Date(job.finished_at * 1000).toLocaleString()}`));
console.log(chalk.gray(' ─────────────────────────────────────────────────────────────────'));
}
}
console.log();
} catch (error) {
console.error(chalk.red(`Error: ${(error as Error).message}`));
process.exit(1);
}
}
/**
* Run consolidation
*/
async function consolidateAction(options: CommandOptions): Promise<void> {
try {
const projectPath = getProjectPath();
console.log(chalk.cyan('\n Triggering memory consolidation...\n'));
const { MemoryConsolidationPipeline } = await import('../core/memory-consolidation-pipeline.js');
const pipeline = new MemoryConsolidationPipeline(projectPath);
await pipeline.runConsolidation();
const memoryMd = pipeline.getMemoryMdContent();
console.log(chalk.green(' Consolidation complete.'));
if (memoryMd) {
console.log(chalk.white(` MEMORY.md generated (${memoryMd.length} chars)`));
}
console.log();
notifyRefreshRequired('memory').catch(() => { /* ignore */ });
} catch (error) {
console.error(chalk.red(`Error: ${(error as Error).message}`));
process.exit(1);
}
}
/**
* List all V2 pipeline jobs
*/
async function jobsAction(options: CommandOptions): Promise<void> {
try {
const projectPath = getProjectPath();
const store = getCoreMemoryStore(projectPath);
const scheduler = new MemoryJobScheduler(store.getDb());
const kind = options.type || undefined;
const jobs = scheduler.listJobs(kind);
if (options.json) {
console.log(JSON.stringify({ jobs, total: jobs.length }, null, 2));
return;
}
console.log(chalk.bold.cyan('\n Memory V2 Pipeline Jobs\n'));
if (jobs.length === 0) {
console.log(chalk.yellow(' No jobs found.\n'));
return;
}
// Summary counts
const byStatus: Record<string, number> = {};
for (const job of jobs) {
byStatus[job.status] = (byStatus[job.status] || 0) + 1;
}
const statusParts = Object.entries(byStatus)
.map(([s, c]) => `${s}: ${c}`)
.join(' | ');
console.log(chalk.white(` Summary: ${statusParts}`));
console.log(chalk.gray(' ─────────────────────────────────────────────────────────────────'));
for (const job of jobs) {
const statusColor = job.status === 'done' ? chalk.green
: job.status === 'error' ? chalk.red
: job.status === 'running' ? chalk.yellow
: chalk.gray;
console.log(
chalk.cyan(` [${job.kind}]`) +
chalk.white(` ${job.job_key}`) +
` [${statusColor(job.status)}]` +
chalk.gray(` retries: ${job.retry_remaining}`)
);
if (job.last_error) console.log(chalk.red(` Error: ${job.last_error}`));
console.log(chalk.gray(' ─────────────────────────────────────────────────────────────────'));
}
console.log(chalk.gray(`\n Total: ${jobs.length}\n`));
} catch (error) {
console.error(chalk.red(`Error: ${(error as Error).message}`));
process.exit(1);
}
}
/**
* Core Memory command entry point
*/
@@ -724,6 +904,23 @@ export async function coreMemoryCommand(
await listFromAction(textArg, options);
break;
// Memory V2 subcommands
case 'extract':
await extractAction(options);
break;
case 'extract-status':
await extractStatusAction(options);
break;
case 'consolidate':
await consolidateAction(options);
break;
case 'jobs':
await jobsAction(options);
break;
default:
console.log(chalk.bold.cyan('\n CCW Core Memory\n'));
console.log(' Manage core memory entries and session clusters.\n');
@@ -749,6 +946,12 @@ export async function coreMemoryCommand(
console.log(chalk.white(' load-cluster <id> ') + chalk.gray('Load cluster context'));
console.log(chalk.white(' search <keyword> ') + chalk.gray('Search sessions'));
console.log();
console.log(chalk.bold(' Memory V2 Pipeline:'));
console.log(chalk.white(' extract ') + chalk.gray('Run batch memory extraction'));
console.log(chalk.white(' extract-status ') + chalk.gray('Show extraction pipeline status'));
console.log(chalk.white(' consolidate ') + chalk.gray('Run memory consolidation'));
console.log(chalk.white(' jobs ') + chalk.gray('List all pipeline jobs'));
console.log();
console.log(chalk.bold(' Options:'));
console.log(chalk.gray(' --id <id> Memory ID (for export/summary)'));
console.log(chalk.gray(' --tool gemini|qwen AI tool for summary (default: gemini)'));
@@ -765,6 +968,10 @@ export async function coreMemoryCommand(
console.log(chalk.gray(' ccw core-memory list-from d--other-project'));
console.log(chalk.gray(' ccw core-memory cluster --auto'));
console.log(chalk.gray(' ccw core-memory cluster --dedup'));
console.log(chalk.gray(' ccw core-memory extract # Run memory extraction'));
console.log(chalk.gray(' ccw core-memory extract-status # Check extraction state'));
console.log(chalk.gray(' ccw core-memory consolidate # Run consolidation'));
console.log(chalk.gray(' ccw core-memory jobs # List pipeline jobs'));
console.log();
}
}

View File

@@ -375,6 +375,19 @@ export interface ProjectPaths {
config: string;
/** CLI config file */
cliConfig: string;
/** Memory V2 paths */
memoryV2: {
/** Root: <projectRoot>/core-memory/v2/ */
root: string;
/** Rollout summaries directory */
rolloutSummaries: string;
/** Concatenated raw memories file */
rawMemories: string;
/** Final consolidated memory file */
memoryMd: string;
/** Skills directory */
skills: string;
};
}
/**
@@ -434,6 +447,13 @@ export function getProjectPaths(projectPath: string): ProjectPaths {
dashboardCache: join(projectDir, 'cache', 'dashboard-data.json'),
config: join(projectDir, 'config'),
cliConfig: join(projectDir, 'config', 'cli-config.json'),
memoryV2: {
root: join(projectDir, 'core-memory', 'v2'),
rolloutSummaries: join(projectDir, 'core-memory', 'v2', 'rollout_summaries'),
rawMemories: join(projectDir, 'core-memory', 'v2', 'raw_memories.md'),
memoryMd: join(projectDir, 'core-memory', 'v2', 'MEMORY.md'),
skills: join(projectDir, 'core-memory', 'v2', 'skills'),
},
};
}
@@ -456,6 +476,13 @@ export function getProjectPathsById(projectId: string): ProjectPaths {
dashboardCache: join(projectDir, 'cache', 'dashboard-data.json'),
config: join(projectDir, 'config'),
cliConfig: join(projectDir, 'config', 'cli-config.json'),
memoryV2: {
root: join(projectDir, 'core-memory', 'v2'),
rolloutSummaries: join(projectDir, 'core-memory', 'v2', 'rollout_summaries'),
rawMemories: join(projectDir, 'core-memory', 'v2', 'raw_memories.md'),
memoryMd: join(projectDir, 'core-memory', 'v2', 'MEMORY.md'),
skills: join(projectDir, 'core-memory', 'v2', 'skills'),
},
};
}
@@ -682,4 +709,7 @@ export function initializeProjectStorage(projectPath: string): void {
ensureStorageDir(paths.memory);
ensureStorageDir(paths.cache);
ensureStorageDir(paths.config);
ensureStorageDir(paths.memoryV2.root);
ensureStorageDir(paths.memoryV2.rolloutSummaries);
ensureStorageDir(paths.memoryV2.skills);
}

View File

@@ -83,6 +83,17 @@ export interface ClaudeUpdateRecord {
metadata?: string;
}
/**
* Memory V2: Phase 1 extraction output row
*/
export interface Stage1Output {
thread_id: string;
source_updated_at: number;
raw_memory: string;
rollout_summary: string;
generated_at: number;
}
/**
* Core Memory Store using SQLite
*/
@@ -215,6 +226,40 @@ export class CoreMemoryStore {
CREATE INDEX IF NOT EXISTS idx_claude_history_path ON claude_update_history(file_path);
CREATE INDEX IF NOT EXISTS idx_claude_history_updated ON claude_update_history(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_claude_history_module ON claude_update_history(module_path);
-- Memory V2: Phase 1 extraction outputs
CREATE TABLE IF NOT EXISTS stage1_outputs (
thread_id TEXT PRIMARY KEY,
source_updated_at INTEGER NOT NULL,
raw_memory TEXT NOT NULL,
rollout_summary TEXT NOT NULL,
generated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_stage1_generated ON stage1_outputs(generated_at DESC);
CREATE INDEX IF NOT EXISTS idx_stage1_source_updated ON stage1_outputs(source_updated_at DESC);
-- Memory V2: Job scheduler
CREATE TABLE IF NOT EXISTS jobs (
kind TEXT NOT NULL,
job_key TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'running', 'done', 'error')),
worker_id TEXT,
ownership_token TEXT,
started_at INTEGER,
finished_at INTEGER,
lease_until INTEGER,
retry_at INTEGER,
retry_remaining INTEGER NOT NULL DEFAULT 3,
last_error TEXT,
input_watermark INTEGER DEFAULT 0,
last_success_watermark INTEGER DEFAULT 0,
PRIMARY KEY (kind, job_key)
);
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
CREATE INDEX IF NOT EXISTS idx_jobs_kind_status ON jobs(kind, status);
CREATE INDEX IF NOT EXISTS idx_jobs_lease ON jobs(lease_until);
`);
}
@@ -275,6 +320,14 @@ export class CoreMemoryStore {
}
}
/**
* Get the underlying database instance.
* Used by MemoryJobScheduler and other V2 components that share this DB.
*/
getDb(): Database.Database {
return this.db;
}
/**
* Generate timestamp-based ID for core memory
*/
@@ -1255,6 +1308,88 @@ ${memory.content}
return result.changes;
}
// ============================================================================
// Memory V2: Stage 1 Output CRUD Operations
// ============================================================================
/**
* Upsert a Phase 1 extraction output (idempotent by thread_id)
*/
upsertStage1Output(output: Stage1Output): void {
const stmt = this.db.prepare(`
INSERT INTO stage1_outputs (thread_id, source_updated_at, raw_memory, rollout_summary, generated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(thread_id) DO UPDATE SET
source_updated_at = excluded.source_updated_at,
raw_memory = excluded.raw_memory,
rollout_summary = excluded.rollout_summary,
generated_at = excluded.generated_at
`);
stmt.run(
output.thread_id,
output.source_updated_at,
output.raw_memory,
output.rollout_summary,
output.generated_at
);
}
/**
* Get a Phase 1 output by thread_id
*/
getStage1Output(threadId: string): Stage1Output | null {
const stmt = this.db.prepare(`SELECT * FROM stage1_outputs WHERE thread_id = ?`);
const row = stmt.get(threadId) as any;
if (!row) return null;
return {
thread_id: row.thread_id,
source_updated_at: row.source_updated_at,
raw_memory: row.raw_memory,
rollout_summary: row.rollout_summary,
generated_at: row.generated_at,
};
}
/**
* List all Phase 1 outputs, ordered by generated_at descending
*/
listStage1Outputs(limit?: number): Stage1Output[] {
const query = limit
? `SELECT * FROM stage1_outputs ORDER BY generated_at DESC LIMIT ?`
: `SELECT * FROM stage1_outputs ORDER BY generated_at DESC`;
const stmt = this.db.prepare(query);
const rows = (limit ? stmt.all(limit) : stmt.all()) as any[];
return rows.map(row => ({
thread_id: row.thread_id,
source_updated_at: row.source_updated_at,
raw_memory: row.raw_memory,
rollout_summary: row.rollout_summary,
generated_at: row.generated_at,
}));
}
/**
* Count Phase 1 outputs
*/
countStage1Outputs(): number {
const stmt = this.db.prepare(`SELECT COUNT(*) as count FROM stage1_outputs`);
const row = stmt.get() as { count: number };
return row.count;
}
/**
* Delete a Phase 1 output by thread_id
*/
deleteStage1Output(threadId: string): boolean {
const stmt = this.db.prepare(`DELETE FROM stage1_outputs WHERE thread_id = ?`);
const result = stmt.run(threadId);
return result.changes > 0;
}
/**
* Close database connection
*/

View File

@@ -0,0 +1,474 @@
/**
* Memory Consolidation Pipeline - Phase 2 Global Consolidation
*
* Orchestrates the global memory consolidation process:
* Lock -> Materialize -> Agent -> Monitor -> Done
*
* Phase 1 outputs (per-session extractions stored in stage1_outputs DB table)
* are materialized to disk as rollout_summaries/*.md + raw_memories.md,
* then a CLI agent (--mode write) reads those files and produces MEMORY.md.
*
* The pipeline uses lease-based locking via MemoryJobScheduler to ensure
* only one consolidation runs at a time, with heartbeat-based lease renewal.
*/
import { existsSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'fs';
import { join } from 'path';
import { MemoryJobScheduler, type ClaimResult } from './memory-job-scheduler.js';
import { getCoreMemoryStore, type Stage1Output } from './core-memory-store.js';
import { getProjectPaths, ensureStorageDir } from '../config/storage-paths.js';
import {
HEARTBEAT_INTERVAL_SECONDS,
MAX_RAW_MEMORIES_FOR_GLOBAL,
} from './memory-v2-config.js';
import {
CONSOLIDATION_SYSTEM_PROMPT,
buildConsolidationPrompt,
} from './memory-consolidation-prompts.js';
// -- Types --
export interface ConsolidationStatus {
status: 'idle' | 'running' | 'completed' | 'error';
lastRun?: number;
memoryMdExists: boolean;
inputCount: number;
lastError?: string;
}
export interface MaterializationResult {
summariesWritten: number;
summariesPruned: number;
rawMemoriesSize: number;
}
// -- Constants --
const JOB_KIND = 'memory_consolidate_global';
const JOB_KEY = 'global';
const MAX_CONCURRENT = 1;
const AGENT_TIMEOUT_MS = 300_000; // 5 minutes
const DEFAULT_CLI_TOOL = 'gemini';
// -- Utility --
/**
* Sanitize a thread ID for use as a filename.
* Replaces filesystem-unsafe characters with underscores.
*/
function sanitizeFilename(name: string): string {
return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_');
}
// -- Standalone Functions --
/**
* Write one .md file per stage1_output to rollout_summaries/, prune orphans.
*
* Each file is named {sanitized_thread_id}.md and contains the rollout_summary.
* Files in the directory that do not correspond to any DB row are deleted.
*/
export function syncRolloutSummaries(
memoryHome: string,
outputs: Stage1Output[]
): MaterializationResult {
const summariesDir = join(memoryHome, 'rollout_summaries');
ensureStorageDir(summariesDir);
// Build set of expected filenames
const expectedFiles = new Set<string>();
let summariesWritten = 0;
for (const output of outputs) {
const filename = `${sanitizeFilename(output.thread_id)}.md`;
expectedFiles.add(filename);
const filePath = join(summariesDir, filename);
// Write summary content with thread header
const content = [
`# Session: ${output.thread_id}`,
`> Generated: ${new Date(output.generated_at * 1000).toISOString()}`,
`> Source updated: ${new Date(output.source_updated_at * 1000).toISOString()}`,
'',
output.rollout_summary,
].join('\n');
writeFileSync(filePath, content, 'utf-8');
summariesWritten++;
}
// Prune orphan files not in DB
let summariesPruned = 0;
const existingFiles = readdirSync(summariesDir);
for (const file of existingFiles) {
if (file.endsWith('.md') && !expectedFiles.has(file)) {
unlinkSync(join(summariesDir, file));
summariesPruned++;
}
}
return {
summariesWritten,
summariesPruned,
rawMemoriesSize: 0, // Not applicable for this function
};
}
/**
* Concatenate the latest raw_memory entries into raw_memories.md with thread headers.
*
* Entries are sorted by generated_at descending, limited to maxCount.
* Format per entry:
* ## Thread: {thread_id}
* {raw_memory content}
* ---
*
* @returns The byte size of the written raw_memories.md file.
*/
export function rebuildRawMemories(
memoryHome: string,
outputs: Stage1Output[],
maxCount: number
): number {
ensureStorageDir(memoryHome);
// Sort by generated_at descending, take up to maxCount
const sorted = [...outputs]
.sort((a, b) => b.generated_at - a.generated_at)
.slice(0, maxCount);
const sections: string[] = [];
for (const output of sorted) {
sections.push(
`## Thread: ${output.thread_id}`,
`> Generated: ${new Date(output.generated_at * 1000).toISOString()}`,
'',
output.raw_memory,
'',
'---',
'',
);
}
const content = sections.join('\n');
const filePath = join(memoryHome, 'raw_memories.md');
writeFileSync(filePath, content, 'utf-8');
return Buffer.byteLength(content, 'utf-8');
}
/**
* Read MEMORY.md content for session prompt injection.
*
* @param projectPath - Project root path (used to resolve storage paths)
* @returns MEMORY.md content string, or null if the file does not exist
*/
export function getMemoryMdContent(projectPath: string): string | null {
const paths = getProjectPaths(projectPath);
const memoryMdPath = paths.memoryV2.memoryMd;
if (!existsSync(memoryMdPath)) {
return null;
}
try {
return readFileSync(memoryMdPath, 'utf-8');
} catch {
return null;
}
}
// -- Pipeline Class --
/**
* MemoryConsolidationPipeline orchestrates global memory consolidation:
* 1. Claim global lock via job scheduler
* 2. Materialize Phase 1 outputs to disk (rollout_summaries/ + raw_memories.md)
* 3. Invoke consolidation agent via executeCliTool --mode write
* 4. Monitor with heartbeat lease renewal
* 5. Mark job as succeeded or failed
*/
export class MemoryConsolidationPipeline {
private projectPath: string;
private store: ReturnType<typeof getCoreMemoryStore>;
private scheduler: MemoryJobScheduler;
private memoryHome: string;
private cliTool: string;
constructor(projectPath: string, cliTool?: string) {
this.projectPath = projectPath;
this.store = getCoreMemoryStore(projectPath);
this.scheduler = new MemoryJobScheduler(this.store.getDb());
this.memoryHome = getProjectPaths(projectPath).memoryV2.root;
this.cliTool = cliTool || DEFAULT_CLI_TOOL;
}
/**
* Attempt to claim the global consolidation lock.
*
* Before claiming, ensures the job row exists and checks dirtiness.
* The job scheduler handles the actual concurrency control.
*/
claimGlobalLock(): ClaimResult {
return this.scheduler.claimJob(JOB_KIND, JOB_KEY, MAX_CONCURRENT);
}
/**
* Materialize rollout summaries from DB to disk.
* Writes one .md file per stage1_output row, prunes orphan files.
*/
materializeSummaries(): MaterializationResult {
const outputs = this.store.listStage1Outputs();
return syncRolloutSummaries(this.memoryHome, outputs);
}
/**
* Rebuild raw_memories.md from DB entries.
* Concatenates the latest entries up to MAX_RAW_MEMORIES_FOR_GLOBAL.
*/
materializeRawMemories(): number {
const outputs = this.store.listStage1Outputs();
return rebuildRawMemories(
this.memoryHome,
outputs,
MAX_RAW_MEMORIES_FOR_GLOBAL
);
}
/**
* Run the consolidation agent via executeCliTool with --mode write.
* Starts a heartbeat timer to keep the lease alive during agent execution.
*
* @param token - Ownership token from claimGlobalLock
* @returns true if agent completed successfully, false otherwise
*/
async runConsolidationAgent(token: string): Promise<boolean> {
// Lazy import to avoid circular dependencies at module load time
const { executeCliTool } = await import('../tools/cli-executor-core.js');
// Determine input state for prompt
const summariesDir = join(this.memoryHome, 'rollout_summaries');
let summaryCount = 0;
if (existsSync(summariesDir)) {
summaryCount = readdirSync(summariesDir).filter(f => f.endsWith('.md')).length;
}
const hasExistingMemoryMd = existsSync(join(this.memoryHome, 'MEMORY.md'));
// Ensure skills directory exists
ensureStorageDir(join(this.memoryHome, 'skills'));
// Build the full prompt
const userPrompt = buildConsolidationPrompt(summaryCount, hasExistingMemoryMd);
const fullPrompt = `${CONSOLIDATION_SYSTEM_PROMPT}\n\n${userPrompt}`;
// Start heartbeat timer
const heartbeatMs = HEARTBEAT_INTERVAL_SECONDS * 1000;
const heartbeatTimer = setInterval(() => {
const renewed = this.scheduler.heartbeat(JOB_KIND, JOB_KEY, token);
if (!renewed) {
// Heartbeat rejected - lease was lost
clearInterval(heartbeatTimer);
}
}, heartbeatMs);
try {
// Execute the consolidation agent
const result = await Promise.race([
executeCliTool({
tool: this.cliTool,
prompt: fullPrompt,
mode: 'write',
cd: this.memoryHome,
category: 'internal',
}),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error('Consolidation agent timed out')),
AGENT_TIMEOUT_MS
)
),
]);
return result.success;
} finally {
clearInterval(heartbeatTimer);
}
}
/**
* Verify that Phase 1 artifacts were not modified by the agent.
* Compares file count in rollout_summaries/ before and after agent run.
*
* @param expectedSummaryCount - Number of summaries before agent run
* @returns true if artifacts are intact
*/
verifyPhase1Integrity(expectedSummaryCount: number): boolean {
const summariesDir = join(this.memoryHome, 'rollout_summaries');
if (!existsSync(summariesDir)) return expectedSummaryCount === 0;
const currentCount = readdirSync(summariesDir).filter(f => f.endsWith('.md')).length;
return currentCount === expectedSummaryCount;
}
/**
* Get the current consolidation status.
*/
getStatus(): ConsolidationStatus {
const jobStatus = this.scheduler.getJobStatus(JOB_KIND, JOB_KEY);
const inputCount = this.store.countStage1Outputs();
const memoryMdExists = existsSync(join(this.memoryHome, 'MEMORY.md'));
if (!jobStatus) {
return {
status: 'idle',
memoryMdExists,
inputCount,
};
}
const statusMap: Record<string, ConsolidationStatus['status']> = {
pending: 'idle',
running: 'running',
done: 'completed',
error: 'error',
};
return {
status: statusMap[jobStatus.status] || 'idle',
lastRun: jobStatus.finished_at,
memoryMdExists,
inputCount,
lastError: jobStatus.last_error,
};
}
/**
* Read MEMORY.md content for session prompt injection.
* Convenience method that delegates to the standalone function.
*/
getMemoryMdContent(): string | null {
return getMemoryMdContent(this.projectPath);
}
/**
* Run the full consolidation pipeline.
*
* Pipeline flow:
* 1. Check if there are inputs to process
* 2. Claim global lock
* 3. Materialize Phase 1 outputs to disk
* 4. Run consolidation agent with heartbeat
* 5. Verify Phase 1 integrity
* 6. Mark job as succeeded or failed
*
* @returns Final consolidation status
*/
async runConsolidation(): Promise<ConsolidationStatus> {
// Step 1: Check inputs
const inputCount = this.store.countStage1Outputs();
if (inputCount === 0) {
return {
status: 'idle',
memoryMdExists: existsSync(join(this.memoryHome, 'MEMORY.md')),
inputCount: 0,
};
}
// Step 2: Claim global lock
const claim = this.claimGlobalLock();
if (!claim.claimed || !claim.ownership_token) {
return {
status: claim.reason === 'already_running' ? 'running' : 'idle',
memoryMdExists: existsSync(join(this.memoryHome, 'MEMORY.md')),
inputCount,
lastError: claim.reason
? `Lock not acquired: ${claim.reason}`
: undefined,
};
}
const token = claim.ownership_token;
try {
// Step 3: Materialize Phase 1 outputs to disk
const matResult = this.materializeSummaries();
const rawMemoriesSize = this.materializeRawMemories();
const expectedSummaryCount = matResult.summariesWritten;
// Step 4: Run consolidation agent with heartbeat
const agentSuccess = await this.runConsolidationAgent(token);
if (!agentSuccess) {
this.scheduler.markFailed(
JOB_KIND,
JOB_KEY,
token,
'Consolidation agent returned failure'
);
return {
status: 'error',
memoryMdExists: existsSync(join(this.memoryHome, 'MEMORY.md')),
inputCount,
lastError: 'Consolidation agent returned failure',
};
}
// Step 5: Verify Phase 1 integrity
if (!this.verifyPhase1Integrity(expectedSummaryCount)) {
this.scheduler.markFailed(
JOB_KIND,
JOB_KEY,
token,
'Phase 1 artifacts were modified during consolidation'
);
return {
status: 'error',
memoryMdExists: existsSync(join(this.memoryHome, 'MEMORY.md')),
inputCount,
lastError: 'Phase 1 artifacts were modified during consolidation',
};
}
// Step 6: Check that MEMORY.md was actually produced
const memoryMdExists = existsSync(join(this.memoryHome, 'MEMORY.md'));
if (!memoryMdExists) {
this.scheduler.markFailed(
JOB_KIND,
JOB_KEY,
token,
'Agent completed but MEMORY.md was not produced'
);
return {
status: 'error',
memoryMdExists: false,
inputCount,
lastError: 'Agent completed but MEMORY.md was not produced',
};
}
// Step 7: Mark success with watermark
// Use the current input count as the success watermark
const watermark = inputCount;
this.scheduler.markSucceeded(JOB_KIND, JOB_KEY, token, watermark);
return {
status: 'completed',
lastRun: Math.floor(Date.now() / 1000),
memoryMdExists: true,
inputCount,
};
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : String(err);
this.scheduler.markFailed(JOB_KIND, JOB_KEY, token, errorMessage);
return {
status: 'error',
memoryMdExists: existsSync(join(this.memoryHome, 'MEMORY.md')),
inputCount,
lastError: errorMessage,
};
}
}
}

View File

@@ -0,0 +1,108 @@
/**
* Memory Consolidation Prompts - Phase 2 LLM Agent Prompt Templates
*
* System prompt and instruction templates for the consolidation agent that
* reads Phase 1 outputs (rollout_summaries/ + raw_memories.md) and produces
* MEMORY.md (and optional skills/ files).
*
* Design: The agent runs with --mode write in the MEMORY_HOME directory,
* using standard file read/write tools to produce output.
*/
/**
* System-level instructions for the consolidation agent.
* This is prepended to every consolidation prompt.
*/
export const CONSOLIDATION_SYSTEM_PROMPT = `You are a memory consolidation agent. Your job is to read phase-1 extraction artifacts and produce a consolidated MEMORY.md file that captures the most important, actionable knowledge from recent coding sessions.
## CRITICAL RULES
1. **Do NOT modify phase-1 artifacts.** The files in rollout_summaries/ and raw_memories.md are READ-ONLY inputs. Never edit, delete, or overwrite them.
2. **Output only to MEMORY.md** (and optionally skills/ directory). Do not create files outside these paths.
3. **Be concise and actionable.** Every line in MEMORY.md should help a future coding session be more productive.
4. **Resolve conflicts.** When rollout summaries and raw memories disagree, prefer the more recent or more specific information.
5. **Deduplicate.** Merge overlapping knowledge from different sessions into single authoritative entries.
6. **Preserve attribution.** When a piece of knowledge comes from a specific session thread, note it briefly.`;
/**
* Build the full consolidation prompt given the MEMORY_HOME directory path.
*
* The agent will be invoked with --cd pointing to memoryHome, so all file
* references are relative to that directory.
*
* @param inputSummaryCount - Number of rollout summary files available
* @param hasExistingMemoryMd - Whether a previous MEMORY.md exists to update
* @returns Complete prompt string for executeCliTool
*/
export function buildConsolidationPrompt(
inputSummaryCount: number,
hasExistingMemoryMd: boolean
): string {
const action = hasExistingMemoryMd ? 'UPDATE' : 'CREATE';
return `## Task: ${action} MEMORY.md
You have access to the following phase-1 artifacts in the current directory:
### Input Files (READ-ONLY - do NOT modify these)
- **rollout_summaries/*.md** - ${inputSummaryCount} per-session summary files. Each contains a concise summary of what was accomplished in that coding session. Use these for high-level routing and prioritization.
- **raw_memories.md** - Concatenated detailed memories from recent sessions, organized by thread. Use this for detailed knowledge extraction and cross-referencing.
${hasExistingMemoryMd ? '- **MEMORY.md** - The existing consolidated memory file. Update it with new knowledge while preserving still-relevant existing content.' : ''}
### Your Process
1. Read all files in rollout_summaries/ to understand the scope and themes of recent sessions.
2. Read raw_memories.md to extract detailed, actionable knowledge.
3. Cross-reference summaries with raw memories to identify:
- High-signal patterns and conventions discovered
- Architecture decisions and their rationale
- Common pitfalls and their solutions
- Key APIs, interfaces, and integration points
- Testing patterns and debugging approaches
${hasExistingMemoryMd ? '4. Read the existing MEMORY.md and merge new knowledge, removing stale entries.' : '4. Organize extracted knowledge into the output structure below.'}
5. Write the consolidated MEMORY.md file.
6. Optionally, if there are reusable code patterns or workflows worth extracting, create files in the skills/ directory.
### Output: MEMORY.md Structure
Write MEMORY.md with the following sections. Omit any section that has no relevant content.
\`\`\`markdown
# Project Memory
> Auto-generated by memory consolidation. Last updated: [current date]
> Sources: [number] session summaries, [number] raw memory entries
## Architecture & Structure
<!-- Key architectural decisions, module boundaries, data flow patterns -->
## Code Conventions
<!-- Naming conventions, import patterns, error handling approaches, formatting rules -->
## Common Patterns
<!-- Reusable patterns discovered across sessions: state management, API calls, testing approaches -->
## Key APIs & Interfaces
<!-- Important interfaces, function signatures, configuration schemas that are frequently referenced -->
## Known Issues & Gotchas
<!-- Pitfalls, edge cases, platform-specific behaviors, workarounds -->
## Recent Decisions
<!-- Decisions made in recent sessions with brief rationale. Remove when no longer relevant. -->
## Session Insights
<!-- High-value observations from individual sessions worth preserving -->
\`\`\`
### Output: skills/ Directory (Optional)
If you identify reusable code snippets, shell commands, or workflow templates that appear across multiple sessions, create files in the skills/ directory:
- skills/[name].md - Each file should be a self-contained, copy-paste ready reference
### Quality Criteria
- Every entry should be actionable (helps write better code or avoid mistakes)
- No vague platitudes - be specific with file paths, function names, config values
- Prefer concrete examples over abstract descriptions
- Keep total MEMORY.md under 5000 words - be ruthlessly concise
- Remove outdated information that contradicts newer findings`;
}

View File

@@ -9,6 +9,7 @@
* - JSON protocol communication
* - Three commands: embed, search, status
* - Automatic availability checking
* - Stage1 output embedding for V2 pipeline
*/
import { spawn } from 'child_process';
@@ -16,6 +17,9 @@ import { join, dirname } from 'path';
import { existsSync } from 'fs';
import { fileURLToPath } from 'url';
import { getCodexLensPython } from '../utils/codexlens-path.js';
import { getCoreMemoryStore } from './core-memory-store.js';
import type { Stage1Output } from './core-memory-store.js';
import { StoragePaths } from '../config/storage-paths.js';
// Get directory of this module
const __filename = fileURLToPath(import.meta.url);
@@ -256,3 +260,111 @@ export async function getEmbeddingStatus(dbPath: string): Promise<EmbeddingStatu
};
}
}
// ============================================================================
// Memory V2: Stage1 Output Embedding
// ============================================================================
/** Result of stage1 embedding operation */
export interface Stage1EmbedResult {
success: boolean;
chunksCreated: number;
chunksEmbedded: number;
error?: string;
}
/**
* Chunk and embed stage1_outputs (raw_memory + rollout_summary) for semantic search.
*
* Reads all stage1_outputs from the DB, chunks their raw_memory and rollout_summary
* content, inserts chunks into memory_chunks with source_type='cli_history' and
* metadata indicating the V2 origin, then triggers embedding generation.
*
* Uses source_id format: "s1:{thread_id}" to differentiate from regular cli_history chunks.
*
* @param projectPath - Project root path
* @param force - Force re-chunking even if chunks exist
* @returns Embedding result
*/
export async function embedStage1Outputs(
projectPath: string,
force: boolean = false
): Promise<Stage1EmbedResult> {
try {
const store = getCoreMemoryStore(projectPath);
const stage1Outputs = store.listStage1Outputs();
if (stage1Outputs.length === 0) {
return { success: true, chunksCreated: 0, chunksEmbedded: 0 };
}
let totalChunksCreated = 0;
for (const output of stage1Outputs) {
const sourceId = `s1:${output.thread_id}`;
// Check if already chunked
const existingChunks = store.getChunks(sourceId);
if (existingChunks.length > 0 && !force) continue;
// Delete old chunks if force
if (force && existingChunks.length > 0) {
store.deleteChunks(sourceId);
}
// Combine raw_memory and rollout_summary for richer semantic content
const combinedContent = [
output.rollout_summary ? `## Summary\n${output.rollout_summary}` : '',
output.raw_memory ? `## Raw Memory\n${output.raw_memory}` : '',
].filter(Boolean).join('\n\n');
if (!combinedContent.trim()) continue;
// Chunk using the store's built-in chunking
const chunks = store.chunkContent(combinedContent, sourceId, 'cli_history');
// Insert chunks with V2 metadata
for (let i = 0; i < chunks.length; i++) {
store.insertChunk({
source_id: sourceId,
source_type: 'cli_history',
chunk_index: i,
content: chunks[i],
metadata: JSON.stringify({
v2_source: 'stage1_output',
thread_id: output.thread_id,
generated_at: output.generated_at,
}),
created_at: new Date().toISOString(),
});
totalChunksCreated++;
}
}
// If we created chunks, generate embeddings
let chunksEmbedded = 0;
if (totalChunksCreated > 0) {
const paths = StoragePaths.project(projectPath);
const dbPath = join(paths.root, 'core-memory', 'core_memory.db');
const embedResult = await generateEmbeddings(dbPath, { force: false });
if (embedResult.success) {
chunksEmbedded = embedResult.chunks_processed;
}
}
return {
success: true,
chunksCreated: totalChunksCreated,
chunksEmbedded,
};
} catch (err) {
return {
success: false,
chunksCreated: 0,
chunksEmbedded: 0,
error: (err as Error).message,
};
}
}

View File

@@ -0,0 +1,466 @@
/**
* Memory Extraction Pipeline - Phase 1 per-session extraction
*
* Orchestrates the full extraction flow for each CLI session:
* Filter transcript -> Truncate -> LLM Extract -> SecretRedact -> PostProcess -> Store
*
* Uses CliHistoryStore for transcript access, executeCliTool for LLM invocation,
* CoreMemoryStore for stage1_outputs storage, and MemoryJobScheduler for
* concurrency control.
*/
import type { ConversationRecord } from '../tools/cli-history-store.js';
import { getHistoryStore } from '../tools/cli-history-store.js';
import { getCoreMemoryStore, type Stage1Output } from './core-memory-store.js';
import { MemoryJobScheduler } from './memory-job-scheduler.js';
import {
MAX_SESSION_AGE_DAYS,
MIN_IDLE_HOURS,
MAX_ROLLOUT_BYTES_FOR_PROMPT,
MAX_RAW_MEMORY_CHARS,
MAX_SUMMARY_CHARS,
MAX_SESSIONS_PER_STARTUP,
PHASE_ONE_CONCURRENCY,
} from './memory-v2-config.js';
import { EXTRACTION_SYSTEM_PROMPT, buildExtractionUserPrompt } from './memory-extraction-prompts.js';
import { redactSecrets } from '../utils/secret-redactor.js';
// -- Types --
export interface ExtractionInput {
sessionId: string;
transcript: string;
sourceUpdatedAt: number;
}
export interface ExtractionOutput {
raw_memory: string;
rollout_summary: string;
}
export interface TranscriptFilterOptions {
/** Bitmask for turn type selection. Default ALL = 0x7FF */
bitmask: number;
/** Maximum bytes for the transcript sent to LLM */
maxBytes: number;
}
export interface BatchExtractionResult {
processed: number;
succeeded: number;
failed: number;
skipped: number;
errors: Array<{ sessionId: string; error: string }>;
}
// -- Turn type bitmask constants --
/** All turn types included */
export const TURN_TYPE_ALL = 0x7FF;
// Individual turn type bits (for future filtering granularity)
export const TURN_TYPE_USER_PROMPT = 0x001;
export const TURN_TYPE_STDOUT = 0x002;
export const TURN_TYPE_STDERR = 0x004;
export const TURN_TYPE_PARSED = 0x008;
// -- Truncation marker --
const TRUNCATION_MARKER = '\n\n[... CONTENT TRUNCATED ...]\n\n';
// -- Job kind constant --
const JOB_KIND_EXTRACTION = 'phase1_extraction';
// -- Pipeline --
export class MemoryExtractionPipeline {
private projectPath: string;
/** Optional: override the LLM tool used for extraction. Defaults to 'gemini'. */
private tool: string;
/** Optional: current session ID to exclude from scanning */
private currentSessionId?: string;
constructor(projectPath: string, options?: { tool?: string; currentSessionId?: string }) {
this.projectPath = projectPath;
this.tool = options?.tool || 'gemini';
this.currentSessionId = options?.currentSessionId;
}
// ========================================================================
// Eligibility scanning
// ========================================================================
/**
* Scan CLI history for sessions eligible for memory extraction.
*
* Eligibility criteria (from design spec section 4.1):
* - Session age <= MAX_SESSION_AGE_DAYS (30 days)
* - Session idle >= MIN_IDLE_HOURS (12 hours) since last update
* - Not an ephemeral/internal session (category !== 'internal')
* - Not the currently active session
* - Has at least one turn with content
*
* @returns Array of eligible ConversationRecord objects, capped at MAX_SESSIONS_PER_STARTUP
*/
scanEligibleSessions(maxSessions?: number): ConversationRecord[] {
const historyStore = getHistoryStore(this.projectPath);
const now = Date.now();
const maxAgeMs = MAX_SESSION_AGE_DAYS * 24 * 60 * 60 * 1000;
const minIdleMs = MIN_IDLE_HOURS * 60 * 60 * 1000;
// Fetch recent conversations (generous limit to filter in-memory)
const { executions } = historyStore.getHistory({ limit: 500 });
const eligible: ConversationRecord[] = [];
for (const entry of executions) {
// Skip current session
if (this.currentSessionId && entry.id === this.currentSessionId) continue;
// Age check: created within MAX_SESSION_AGE_DAYS
const createdAt = new Date(entry.timestamp).getTime();
if (now - createdAt > maxAgeMs) continue;
// Idle check: last updated at least MIN_IDLE_HOURS ago
const updatedAt = new Date(entry.updated_at || entry.timestamp).getTime();
if (now - updatedAt < minIdleMs) continue;
// Skip internal/ephemeral sessions
if (entry.category === 'internal') continue;
// Must have at least 1 turn
if (!entry.turn_count || entry.turn_count < 1) continue;
// Load full conversation to include in result
const conv = historyStore.getConversation(entry.id);
if (!conv) continue;
eligible.push(conv);
if (eligible.length >= (maxSessions || MAX_SESSIONS_PER_STARTUP)) break;
}
return eligible;
}
// ========================================================================
// Transcript filtering
// ========================================================================
/**
* Extract transcript text from a ConversationRecord, keeping only turn types
* that match the given bitmask.
*
* Default bitmask (ALL=0x7FF) includes all turn content: prompt, stdout, stderr, parsed.
*
* @param record - The conversation record to filter
* @param bitmask - Bitmask for type selection (default: TURN_TYPE_ALL)
* @returns Combined transcript text
*/
filterTranscript(record: ConversationRecord, bitmask: number = TURN_TYPE_ALL): string {
const parts: string[] = [];
for (const turn of record.turns) {
const turnParts: string[] = [];
if (bitmask & TURN_TYPE_USER_PROMPT) {
if (turn.prompt) {
turnParts.push(`[USER] ${turn.prompt}`);
}
}
if (bitmask & TURN_TYPE_STDOUT) {
const stdout = turn.output?.parsed_output || turn.output?.stdout;
if (stdout) {
turnParts.push(`[ASSISTANT] ${stdout}`);
}
}
if (bitmask & TURN_TYPE_STDERR) {
if (turn.output?.stderr) {
turnParts.push(`[STDERR] ${turn.output.stderr}`);
}
}
if (bitmask & TURN_TYPE_PARSED) {
// Use final_output if available and not already captured
if (turn.output?.final_output && !(bitmask & TURN_TYPE_STDOUT)) {
turnParts.push(`[FINAL] ${turn.output.final_output}`);
}
}
if (turnParts.length > 0) {
parts.push(`--- Turn ${turn.turn} ---\n${turnParts.join('\n')}`);
}
}
return parts.join('\n\n');
}
// ========================================================================
// Truncation
// ========================================================================
/**
* Truncate transcript content to fit within LLM context limit.
*
* Strategy: Keep head 33% + truncation marker + tail 67%.
* This preserves the session opening context and the most recent work.
*
* @param content - The full transcript text
* @param maxBytes - Maximum allowed size in bytes (default: MAX_ROLLOUT_BYTES_FOR_PROMPT)
* @returns Truncated content, or original if within limit
*/
truncateTranscript(content: string, maxBytes: number = MAX_ROLLOUT_BYTES_FOR_PROMPT): string {
const contentBytes = Buffer.byteLength(content, 'utf-8');
if (contentBytes <= maxBytes) {
return content;
}
// Calculate split sizes accounting for the marker
const markerBytes = Buffer.byteLength(TRUNCATION_MARKER, 'utf-8');
const availableBytes = maxBytes - markerBytes;
const headBytes = Math.floor(availableBytes * 0.33);
const tailBytes = availableBytes - headBytes;
// Convert to character-based approximation (safe for multi-byte)
// Use Buffer slicing for byte-accurate truncation
const buf = Buffer.from(content, 'utf-8');
const headBuf = buf.subarray(0, headBytes);
const tailBuf = buf.subarray(buf.length - tailBytes);
// Decode back to strings, trimming at character boundaries
const head = headBuf.toString('utf-8').replace(/[\uFFFD]$/, '');
const tail = tailBuf.toString('utf-8').replace(/^[\uFFFD]/, '');
return head + TRUNCATION_MARKER + tail;
}
// ========================================================================
// LLM extraction
// ========================================================================
/**
* Call the LLM to extract structured memory from a session transcript.
*
* Uses executeCliTool with the extraction prompts. The LLM is expected
* to return a JSON object with raw_memory and rollout_summary fields.
*
* @param sessionId - Session ID for prompt context
* @param transcript - The filtered and truncated transcript
* @returns Raw LLM output string
*/
async extractMemory(sessionId: string, transcript: string): Promise<string> {
const { executeCliTool } = await import('../tools/cli-executor-core.js');
const userPrompt = buildExtractionUserPrompt(sessionId, transcript);
const fullPrompt = `${EXTRACTION_SYSTEM_PROMPT}\n\n${userPrompt}`;
const result = await executeCliTool({
tool: this.tool,
prompt: fullPrompt,
mode: 'analysis',
cd: this.projectPath,
category: 'internal',
});
// Prefer parsedOutput (extracted text from stream JSON) over raw stdout
const output = result.parsedOutput?.trim() || result.stdout?.trim() || '';
return output;
}
// ========================================================================
// Post-processing
// ========================================================================
/**
* Parse LLM output into structured ExtractionOutput.
*
* Supports 3 parsing modes:
* 1. Pure JSON: Output is a valid JSON object
* 2. Fenced JSON block: JSON wrapped in ```json ... ``` markers
* 3. Text extraction: Non-conforming output wrapped in fallback structure
*
* Applies secret redaction and size limit enforcement.
*
* @param llmOutput - Raw text output from the LLM
* @returns Validated ExtractionOutput with raw_memory and rollout_summary
*/
postProcess(llmOutput: string): ExtractionOutput {
let parsed: { raw_memory?: string; rollout_summary?: string } | null = null;
// Mode 1: Pure JSON
try {
parsed = JSON.parse(llmOutput);
} catch {
// Not pure JSON, try next mode
}
// Mode 2: Fenced JSON block
if (!parsed) {
const fencedMatch = llmOutput.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
if (fencedMatch) {
try {
parsed = JSON.parse(fencedMatch[1]);
} catch {
// Fenced content is not valid JSON either
}
}
}
// Mode 3: Text extraction fallback
if (!parsed || typeof parsed.raw_memory !== 'string') {
parsed = {
raw_memory: `# summary\n${llmOutput}\n\nMemory context:\n- Extracted from unstructured LLM output\n\nUser preferences:\n- (none detected)`,
rollout_summary: llmOutput.substring(0, 200).replace(/\n/g, ' ').trim(),
};
}
// Apply secret redaction
let rawMemory = redactSecrets(parsed.raw_memory || '');
let rolloutSummary = redactSecrets(parsed.rollout_summary || '');
// Enforce size limits
if (rawMemory.length > MAX_RAW_MEMORY_CHARS) {
rawMemory = rawMemory.substring(0, MAX_RAW_MEMORY_CHARS);
}
if (rolloutSummary.length > MAX_SUMMARY_CHARS) {
rolloutSummary = rolloutSummary.substring(0, MAX_SUMMARY_CHARS);
}
return { raw_memory: rawMemory, rollout_summary: rolloutSummary };
}
// ========================================================================
// Single session extraction
// ========================================================================
/**
* Run the full extraction pipeline for a single session.
*
* Pipeline stages: Filter -> Truncate -> LLM Extract -> PostProcess -> Store
*
* @param sessionId - The session to extract from
* @returns The stored Stage1Output, or null if extraction failed
*/
async runExtractionJob(sessionId: string): Promise<Stage1Output | null> {
const historyStore = getHistoryStore(this.projectPath);
const record = historyStore.getConversation(sessionId);
if (!record) {
throw new Error(`Session not found: ${sessionId}`);
}
// Stage 1: Filter transcript
const transcript = this.filterTranscript(record);
if (!transcript.trim()) {
return null; // Empty transcript, nothing to extract
}
// Stage 2: Truncate
const truncated = this.truncateTranscript(transcript);
// Stage 3: LLM extraction
const llmOutput = await this.extractMemory(sessionId, truncated);
if (!llmOutput) {
throw new Error(`LLM returned empty output for session: ${sessionId}`);
}
// Stage 4: Post-process (parse + redact + validate)
const extracted = this.postProcess(llmOutput);
// Stage 5: Store result
const sourceUpdatedAt = Math.floor(new Date(record.updated_at).getTime() / 1000);
const generatedAt = Math.floor(Date.now() / 1000);
const output: Stage1Output = {
thread_id: sessionId,
source_updated_at: sourceUpdatedAt,
raw_memory: extracted.raw_memory,
rollout_summary: extracted.rollout_summary,
generated_at: generatedAt,
};
const store = getCoreMemoryStore(this.projectPath);
store.upsertStage1Output(output);
return output;
}
// ========================================================================
// Batch orchestration
// ========================================================================
/**
* Run extraction for all eligible sessions with concurrency control.
*
* Uses MemoryJobScheduler to claim jobs and enforce PHASE_ONE_CONCURRENCY.
* Failed extractions are recorded in the scheduler for retry.
*
* @returns BatchExtractionResult with counts and error details
*/
async runBatchExtraction(options?: { maxSessions?: number }): Promise<BatchExtractionResult> {
const store = getCoreMemoryStore(this.projectPath);
const scheduler = new MemoryJobScheduler(store.getDb());
// Scan eligible sessions
const eligibleSessions = this.scanEligibleSessions(options?.maxSessions);
const result: BatchExtractionResult = {
processed: 0,
succeeded: 0,
failed: 0,
skipped: 0,
errors: [],
};
if (eligibleSessions.length === 0) {
return result;
}
// Enqueue all eligible sessions
for (const session of eligibleSessions) {
const watermark = Math.floor(new Date(session.updated_at).getTime() / 1000);
scheduler.enqueueJob(JOB_KIND_EXTRACTION, session.id, watermark);
}
// Process with concurrency control using Promise.all with batching
const batchSize = PHASE_ONE_CONCURRENCY;
for (let i = 0; i < eligibleSessions.length; i += batchSize) {
const batch = eligibleSessions.slice(i, i + batchSize);
const promises = batch.map(async (session) => {
// Try to claim the job
const claim = scheduler.claimJob(JOB_KIND_EXTRACTION, session.id, batchSize);
if (!claim.claimed) {
result.skipped++;
return;
}
result.processed++;
const token = claim.ownership_token!;
try {
const output = await this.runExtractionJob(session.id);
if (output) {
const watermark = output.source_updated_at;
scheduler.markSucceeded(JOB_KIND_EXTRACTION, session.id, token, watermark);
result.succeeded++;
} else {
// Empty transcript - mark as done (nothing to extract)
scheduler.markSucceeded(JOB_KIND_EXTRACTION, session.id, token, 0);
result.skipped++;
}
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
scheduler.markFailed(JOB_KIND_EXTRACTION, session.id, token, errorMsg);
result.failed++;
result.errors.push({ sessionId: session.id, error: errorMsg });
}
});
await Promise.all(promises);
}
return result;
}
}

View File

@@ -0,0 +1,91 @@
/**
* Memory Extraction Prompts - LLM prompt templates for Phase 1 extraction
*
* Provides system and user prompt templates for extracting structured memory
* from CLI session transcripts. The LLM output must conform to a JSON schema
* with raw_memory and rollout_summary fields.
*
* Design spec section 4.4: Prompt structure with outcome triage rules.
*/
/**
* System prompt for the extraction LLM call.
*
* Instructs the model to:
* - Produce a JSON object with raw_memory and rollout_summary
* - Follow structure markers in raw_memory (# summary, Memory context, etc.)
* - Apply outcome triage rules for categorizing task results
* - Keep rollout_summary concise (1-2 sentences)
*/
export const EXTRACTION_SYSTEM_PROMPT = `You are a memory extraction agent. Your job is to read a CLI session transcript and produce structured memory output.
You MUST respond with a valid JSON object containing exactly two fields:
{
"raw_memory": "<structured memory text>",
"rollout_summary": "<1-2 sentence summary>"
}
## raw_memory format
The raw_memory field must follow this structure:
# summary
<One paragraph high-level summary of what was accomplished in this session>
Memory context:
- Project: <project name or path if identifiable>
- Tools used: <CLI tools, frameworks, languages mentioned>
- Key files: <important files created or modified>
User preferences:
- <Any coding style preferences, conventions, or patterns the user demonstrated>
- <Tool preferences, workflow habits>
## Task: <task title or description>
Outcome: <success | partial | failed | abandoned>
<Detailed description of what was done, decisions made, and results>
### Key decisions
- <Important architectural or design decisions>
- <Trade-offs considered>
### Lessons learned
- <What worked well>
- <What did not work and why>
- <Gotchas or pitfalls discovered>
## Outcome Triage Rules
- **success**: Task was completed as intended, tests pass, code works
- **partial**: Some progress made but not fully complete; note what remains
- **failed**: Attempted but could not achieve the goal; document root cause
- **abandoned**: User switched direction or cancelled; note the reason
## rollout_summary format
A concise 1-2 sentence summary capturing:
- What the session was about (the goal)
- The outcome (success/partial/failed)
- The most important takeaway
Do NOT include markdown code fences in your response. Return raw JSON only.`;
/**
* Build the user prompt by injecting the session transcript.
*
* @param sessionId - The session/conversation ID for reference
* @param transcript - The filtered and truncated transcript text
* @returns The complete user prompt string
*/
export function buildExtractionUserPrompt(sessionId: string, transcript: string): string {
return `Extract structured memory from the following CLI session transcript.
Session ID: ${sessionId}
--- BEGIN TRANSCRIPT ---
${transcript}
--- END TRANSCRIPT ---
Respond with a JSON object containing "raw_memory" and "rollout_summary" fields.`;
}

View File

@@ -0,0 +1,335 @@
/**
* Memory Job Scheduler - Lease-based job scheduling backed by SQLite
*
* Provides atomic claim/release/heartbeat operations for coordinating
* concurrent memory extraction and consolidation jobs.
*
* All state lives in the `jobs` table of the CoreMemoryStore database.
* Concurrency control uses ownership_token + lease_until for distributed-safe
* (but single-process) job dispatch.
*/
import type Database from 'better-sqlite3';
import { randomUUID } from 'crypto';
import { LEASE_SECONDS, MAX_RETRIES, RETRY_DELAY_SECONDS } from './memory-v2-config.js';
// -- Types --
export type JobStatus = 'pending' | 'running' | 'done' | 'error';
export interface JobRecord {
kind: string;
job_key: string;
status: JobStatus;
worker_id?: string;
ownership_token?: string;
started_at?: number;
finished_at?: number;
lease_until?: number;
retry_at?: number;
retry_remaining: number;
last_error?: string;
input_watermark: number;
last_success_watermark: number;
}
export interface ClaimResult {
claimed: boolean;
ownership_token?: string;
reason?: 'already_running' | 'retry_exhausted' | 'retry_pending' | 'concurrency_limit';
}
// -- Scheduler --
export class MemoryJobScheduler {
private db: Database.Database;
constructor(db: Database.Database) {
this.db = db;
}
/**
* Atomically claim a job for processing.
*
* Logic:
* 1. If job does not exist, insert it as 'running' with a fresh token.
* 2. If job exists and is 'pending', transition to 'running'.
* 3. If job exists and is 'running' but lease expired, reclaim it.
* 4. If job exists and is 'error' with retry_remaining > 0 and retry_at <= now, reclaim it.
* 5. Otherwise, return not claimed with reason.
*
* Respects maxConcurrent: total running jobs of this `kind` must not exceed limit.
*/
claimJob(kind: string, jobKey: string, maxConcurrent: number, workerId?: string): ClaimResult {
const now = Math.floor(Date.now() / 1000);
const token = randomUUID();
const leaseUntil = now + LEASE_SECONDS;
// Use a transaction for atomicity
const result = this.db.transaction(() => {
// Check concurrency limit for this kind
const runningCount = this.db.prepare(
`SELECT COUNT(*) as cnt FROM jobs WHERE kind = ? AND status = 'running' AND lease_until > ?`
).get(kind, now) as { cnt: number };
const existing = this.db.prepare(
`SELECT * FROM jobs WHERE kind = ? AND job_key = ?`
).get(kind, jobKey) as any | undefined;
if (!existing) {
// No job row yet - check concurrency before inserting
if (runningCount.cnt >= maxConcurrent) {
return { claimed: false, reason: 'concurrency_limit' as const };
}
this.db.prepare(`
INSERT INTO jobs (kind, job_key, status, worker_id, ownership_token, started_at, lease_until, retry_remaining, input_watermark, last_success_watermark)
VALUES (?, ?, 'running', ?, ?, ?, ?, ?, 0, 0)
`).run(kind, jobKey, workerId || null, token, now, leaseUntil, MAX_RETRIES);
return { claimed: true, ownership_token: token };
}
// Job exists - check status transitions
if (existing.status === 'done') {
// Already done - check dirty (input_watermark > last_success_watermark)
if (existing.input_watermark <= existing.last_success_watermark) {
return { claimed: false, reason: 'already_running' as const };
}
// Dirty - re-run
if (runningCount.cnt >= maxConcurrent) {
return { claimed: false, reason: 'concurrency_limit' as const };
}
this.db.prepare(`
UPDATE jobs SET status = 'running', worker_id = ?, ownership_token = ?,
started_at = ?, lease_until = ?, finished_at = NULL, last_error = NULL,
retry_remaining = ?
WHERE kind = ? AND job_key = ?
`).run(workerId || null, token, now, leaseUntil, MAX_RETRIES, kind, jobKey);
return { claimed: true, ownership_token: token };
}
if (existing.status === 'running') {
// Running - check lease expiry
if (existing.lease_until > now) {
return { claimed: false, reason: 'already_running' as const };
}
// Lease expired - reclaim if concurrency allows
// The expired job doesn't count towards running total (lease_until <= now),
// so runningCount already excludes it.
if (runningCount.cnt >= maxConcurrent) {
return { claimed: false, reason: 'concurrency_limit' as const };
}
this.db.prepare(`
UPDATE jobs SET worker_id = ?, ownership_token = ?, started_at = ?,
lease_until = ?, last_error = NULL
WHERE kind = ? AND job_key = ?
`).run(workerId || null, token, now, leaseUntil, kind, jobKey);
return { claimed: true, ownership_token: token };
}
if (existing.status === 'pending') {
if (runningCount.cnt >= maxConcurrent) {
return { claimed: false, reason: 'concurrency_limit' as const };
}
this.db.prepare(`
UPDATE jobs SET status = 'running', worker_id = ?, ownership_token = ?,
started_at = ?, lease_until = ?
WHERE kind = ? AND job_key = ?
`).run(workerId || null, token, now, leaseUntil, kind, jobKey);
return { claimed: true, ownership_token: token };
}
if (existing.status === 'error') {
if (existing.retry_remaining <= 0) {
return { claimed: false, reason: 'retry_exhausted' as const };
}
if (existing.retry_at && existing.retry_at > now) {
return { claimed: false, reason: 'retry_pending' as const };
}
if (runningCount.cnt >= maxConcurrent) {
return { claimed: false, reason: 'concurrency_limit' as const };
}
this.db.prepare(`
UPDATE jobs SET status = 'running', worker_id = ?, ownership_token = ?,
started_at = ?, lease_until = ?, last_error = NULL,
retry_remaining = retry_remaining - 1
WHERE kind = ? AND job_key = ?
`).run(workerId || null, token, now, leaseUntil, kind, jobKey);
return { claimed: true, ownership_token: token };
}
return { claimed: false, reason: 'already_running' as const };
})();
return result;
}
/**
* Release a job, marking it as done or error.
* Only succeeds if the ownership_token matches.
*/
releaseJob(kind: string, jobKey: string, token: string, status: 'done' | 'error', error?: string): boolean {
const now = Math.floor(Date.now() / 1000);
const result = this.db.prepare(`
UPDATE jobs SET
status = ?,
finished_at = ?,
lease_until = NULL,
ownership_token = NULL,
worker_id = NULL,
last_error = ?
WHERE kind = ? AND job_key = ? AND ownership_token = ?
`).run(status, now, error || null, kind, jobKey, token);
return result.changes > 0;
}
/**
* Renew the lease for an active job.
* Returns false if ownership_token does not match or job is not running.
*/
heartbeat(kind: string, jobKey: string, token: string, leaseSeconds: number = LEASE_SECONDS): boolean {
const now = Math.floor(Date.now() / 1000);
const newLeaseUntil = now + leaseSeconds;
const result = this.db.prepare(`
UPDATE jobs SET lease_until = ?
WHERE kind = ? AND job_key = ? AND ownership_token = ? AND status = 'running'
`).run(newLeaseUntil, kind, jobKey, token);
return result.changes > 0;
}
/**
* Enqueue a job or update its input_watermark.
* Uses MAX(existing, new) for watermark to ensure monotonicity.
* If job doesn't exist, creates it in 'pending' status.
*/
enqueueJob(kind: string, jobKey: string, inputWatermark: number): void {
this.db.prepare(`
INSERT INTO jobs (kind, job_key, status, retry_remaining, input_watermark, last_success_watermark)
VALUES (?, ?, 'pending', ?, ?, 0)
ON CONFLICT(kind, job_key) DO UPDATE SET
input_watermark = MAX(jobs.input_watermark, excluded.input_watermark)
`).run(kind, jobKey, MAX_RETRIES, inputWatermark);
}
/**
* Mark a job as successfully completed and update success watermark.
* Only succeeds if ownership_token matches.
*/
markSucceeded(kind: string, jobKey: string, token: string, watermark: number): boolean {
const now = Math.floor(Date.now() / 1000);
const result = this.db.prepare(`
UPDATE jobs SET
status = 'done',
finished_at = ?,
lease_until = NULL,
ownership_token = NULL,
worker_id = NULL,
last_error = NULL,
last_success_watermark = ?
WHERE kind = ? AND job_key = ? AND ownership_token = ?
`).run(now, watermark, kind, jobKey, token);
return result.changes > 0;
}
/**
* Mark a job as failed with error message and schedule retry.
* Only succeeds if ownership_token matches.
*/
markFailed(kind: string, jobKey: string, token: string, error: string, retryDelay: number = RETRY_DELAY_SECONDS): boolean {
const now = Math.floor(Date.now() / 1000);
const retryAt = now + retryDelay;
const result = this.db.prepare(`
UPDATE jobs SET
status = 'error',
finished_at = ?,
lease_until = NULL,
ownership_token = NULL,
worker_id = NULL,
last_error = ?,
retry_at = ?
WHERE kind = ? AND job_key = ? AND ownership_token = ?
`).run(now, error, retryAt, kind, jobKey, token);
return result.changes > 0;
}
/**
* Get current status of a specific job.
*/
getJobStatus(kind: string, jobKey: string): JobRecord | null {
const row = this.db.prepare(
`SELECT * FROM jobs WHERE kind = ? AND job_key = ?`
).get(kind, jobKey) as any;
if (!row) return null;
return this.rowToJobRecord(row);
}
/**
* List jobs, optionally filtered by kind and/or status.
*/
listJobs(kind?: string, status?: JobStatus): JobRecord[] {
let query = 'SELECT * FROM jobs';
const params: any[] = [];
const conditions: string[] = [];
if (kind) {
conditions.push('kind = ?');
params.push(kind);
}
if (status) {
conditions.push('status = ?');
params.push(status);
}
if (conditions.length > 0) {
query += ' WHERE ' + conditions.join(' AND ');
}
query += ' ORDER BY kind, job_key';
const rows = this.db.prepare(query).all(...params) as any[];
return rows.map(row => this.rowToJobRecord(row));
}
/**
* Check if a job is dirty (input_watermark > last_success_watermark).
*/
isDirty(kind: string, jobKey: string): boolean {
const row = this.db.prepare(
`SELECT input_watermark, last_success_watermark FROM jobs WHERE kind = ? AND job_key = ?`
).get(kind, jobKey) as any;
if (!row) return false;
return row.input_watermark > row.last_success_watermark;
}
/**
* Convert a database row to a typed JobRecord.
*/
private rowToJobRecord(row: any): JobRecord {
return {
kind: row.kind,
job_key: row.job_key,
status: row.status,
worker_id: row.worker_id || undefined,
ownership_token: row.ownership_token || undefined,
started_at: row.started_at || undefined,
finished_at: row.finished_at || undefined,
lease_until: row.lease_until || undefined,
retry_at: row.retry_at || undefined,
retry_remaining: row.retry_remaining,
last_error: row.last_error || undefined,
input_watermark: row.input_watermark ?? 0,
last_success_watermark: row.last_success_watermark ?? 0,
};
}
}

View File

@@ -0,0 +1,84 @@
/**
* Memory V2 Configuration Constants
*
* All tuning parameters for the two-phase memory extraction and consolidation pipeline.
* Phase 1: Per-session extraction (transcript -> structured memory)
* Phase 2: Global consolidation (structured memories -> MEMORY.md)
*/
// -- Batch orchestration --
/** Maximum sessions to process per startup/trigger */
export const MAX_SESSIONS_PER_STARTUP = 64;
/** Maximum concurrent Phase 1 extraction jobs */
export const PHASE_ONE_CONCURRENCY = 64;
// -- Session eligibility --
/** Maximum session age in days to consider for extraction */
export const MAX_SESSION_AGE_DAYS = 30;
/** Minimum idle hours before a session becomes eligible */
export const MIN_IDLE_HOURS = 12;
// -- Job scheduler --
/** Default lease duration in seconds (1 hour) */
export const LEASE_SECONDS = 3600;
/** Delay in seconds before retrying a failed job */
export const RETRY_DELAY_SECONDS = 3600;
/** Maximum retry attempts for a failed job */
export const MAX_RETRIES = 3;
/** Interval in seconds between heartbeat renewals */
export const HEARTBEAT_INTERVAL_SECONDS = 30;
// -- Content size limits --
/** Maximum characters for raw_memory field in stage1_outputs */
export const MAX_RAW_MEMORY_CHARS = 300_000;
/** Maximum characters for rollout_summary field in stage1_outputs */
export const MAX_SUMMARY_CHARS = 1200;
/** Maximum bytes of transcript to send to LLM for extraction */
export const MAX_ROLLOUT_BYTES_FOR_PROMPT = 1_000_000;
/** Maximum number of raw memories included in global consolidation input */
export const MAX_RAW_MEMORIES_FOR_GLOBAL = 64;
// -- Typed configuration object --
export interface MemoryV2Config {
MAX_SESSIONS_PER_STARTUP: number;
PHASE_ONE_CONCURRENCY: number;
MAX_SESSION_AGE_DAYS: number;
MIN_IDLE_HOURS: number;
LEASE_SECONDS: number;
RETRY_DELAY_SECONDS: number;
MAX_RETRIES: number;
HEARTBEAT_INTERVAL_SECONDS: number;
MAX_RAW_MEMORY_CHARS: number;
MAX_SUMMARY_CHARS: number;
MAX_ROLLOUT_BYTES_FOR_PROMPT: number;
MAX_RAW_MEMORIES_FOR_GLOBAL: number;
}
/** Default configuration object - use individual exports for direct access */
export const MEMORY_V2_DEFAULTS: Readonly<MemoryV2Config> = {
MAX_SESSIONS_PER_STARTUP,
PHASE_ONE_CONCURRENCY,
MAX_SESSION_AGE_DAYS,
MIN_IDLE_HOURS,
LEASE_SECONDS,
RETRY_DELAY_SECONDS,
MAX_RETRIES,
MAX_RAW_MEMORY_CHARS,
MAX_SUMMARY_CHARS,
MAX_ROLLOUT_BYTES_FOR_PROMPT,
MAX_RAW_MEMORIES_FOR_GLOBAL,
HEARTBEAT_INTERVAL_SECONDS,
} as const;

View File

@@ -4,6 +4,8 @@ import { getCoreMemoryStore } from '../core-memory-store.js';
import type { CoreMemory, SessionCluster, ClusterMember, ClusterRelation } from '../core-memory-store.js';
import { getEmbeddingStatus, generateEmbeddings } from '../memory-embedder-bridge.js';
import { checkSemanticStatus } from '../../tools/codex-lens.js';
import { MemoryJobScheduler } from '../memory-job-scheduler.js';
import type { JobStatus } from '../memory-job-scheduler.js';
import { StoragePaths } from '../../config/storage-paths.js';
import { join } from 'path';
import { getDefaultTool } from '../../tools/claude-cli-tools.js';
@@ -233,6 +235,199 @@ export async function handleCoreMemoryRoutes(ctx: RouteContext): Promise<boolean
return true;
}
// ============================================================
// Memory V2 Pipeline API Endpoints
// ============================================================
// API: Trigger batch extraction (fire-and-forget)
if (pathname === '/api/core-memory/extract' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { maxSessions, path: projectPath } = body;
const basePath = projectPath || initialPath;
try {
const { MemoryExtractionPipeline } = await import('../memory-extraction-pipeline.js');
const pipeline = new MemoryExtractionPipeline(basePath);
// Broadcast start event
broadcastToClients({
type: 'MEMORY_EXTRACTION_STARTED',
payload: {
timestamp: new Date().toISOString(),
maxSessions: maxSessions || 'default',
}
});
// Fire-and-forget: trigger async, notify on completion
const batchPromise = pipeline.runBatchExtraction();
batchPromise.then(() => {
broadcastToClients({
type: 'MEMORY_EXTRACTION_COMPLETED',
payload: { timestamp: new Date().toISOString() }
});
}).catch((err: Error) => {
broadcastToClients({
type: 'MEMORY_EXTRACTION_FAILED',
payload: {
timestamp: new Date().toISOString(),
error: err.message,
}
});
});
// Scan eligible sessions to report count
const eligible = pipeline.scanEligibleSessions();
return {
success: true,
triggered: true,
eligibleCount: eligible.length,
message: `Extraction triggered for ${eligible.length} eligible sessions`,
};
} catch (error: unknown) {
return { error: (error as Error).message, status: 500 };
}
});
return true;
}
// API: Get extraction pipeline status
if (pathname === '/api/core-memory/extract/status' && req.method === 'GET') {
const projectPath = url.searchParams.get('path') || initialPath;
try {
const store = getCoreMemoryStore(projectPath);
const scheduler = new MemoryJobScheduler(store.getDb());
const stage1Count = store.countStage1Outputs();
const extractionJobs = scheduler.listJobs('phase1_extraction');
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
total_stage1: stage1Count,
jobs: extractionJobs.map(j => ({
job_key: j.job_key,
status: j.status,
started_at: j.started_at,
finished_at: j.finished_at,
last_error: j.last_error,
retry_remaining: j.retry_remaining,
})),
}));
} catch (error: unknown) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (error as Error).message }));
}
return true;
}
// API: Trigger consolidation (fire-and-forget)
if (pathname === '/api/core-memory/consolidate' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { path: projectPath } = body;
const basePath = projectPath || initialPath;
try {
const { MemoryConsolidationPipeline } = await import('../memory-consolidation-pipeline.js');
const pipeline = new MemoryConsolidationPipeline(basePath);
// Broadcast start event
broadcastToClients({
type: 'MEMORY_CONSOLIDATION_STARTED',
payload: { timestamp: new Date().toISOString() }
});
// Fire-and-forget
const consolidatePromise = pipeline.runConsolidation();
consolidatePromise.then(() => {
broadcastToClients({
type: 'MEMORY_CONSOLIDATION_COMPLETED',
payload: { timestamp: new Date().toISOString() }
});
}).catch((err: Error) => {
broadcastToClients({
type: 'MEMORY_CONSOLIDATION_FAILED',
payload: {
timestamp: new Date().toISOString(),
error: err.message,
}
});
});
return {
success: true,
triggered: true,
message: 'Consolidation triggered',
};
} catch (error: unknown) {
return { error: (error as Error).message, status: 500 };
}
});
return true;
}
// API: Get consolidation status
if (pathname === '/api/core-memory/consolidate/status' && req.method === 'GET') {
const projectPath = url.searchParams.get('path') || initialPath;
try {
const { MemoryConsolidationPipeline } = await import('../memory-consolidation-pipeline.js');
const pipeline = new MemoryConsolidationPipeline(projectPath);
const status = pipeline.getStatus();
const memoryMd = pipeline.getMemoryMdContent();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
status: status?.status || 'unknown',
memoryMdAvailable: !!memoryMd,
memoryMdPreview: memoryMd ? memoryMd.substring(0, 500) : undefined,
}));
} catch (error: unknown) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
status: 'unavailable',
memoryMdAvailable: false,
error: (error as Error).message,
}));
}
return true;
}
// API: List all V2 pipeline jobs
if (pathname === '/api/core-memory/jobs' && req.method === 'GET') {
const projectPath = url.searchParams.get('path') || initialPath;
const kind = url.searchParams.get('kind') || undefined;
const statusFilter = url.searchParams.get('status') as JobStatus | undefined;
try {
const store = getCoreMemoryStore(projectPath);
const scheduler = new MemoryJobScheduler(store.getDb());
const jobs = scheduler.listJobs(kind, statusFilter);
// Compute byStatus counts
const byStatus: Record<string, number> = {};
for (const job of jobs) {
byStatus[job.status] = (byStatus[job.status] || 0) + 1;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
jobs,
total: jobs.length,
byStatus,
}));
} catch (error: unknown) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (error as Error).message }));
}
return true;
}
// ============================================================
// Session Clustering API Endpoints
// ============================================================

View File

@@ -7,12 +7,17 @@ import { z } from 'zod';
import type { ToolSchema, ToolResult } from '../types/tool.js';
import { getCoreMemoryStore, findMemoryAcrossProjects } from '../core/core-memory-store.js';
import * as MemoryEmbedder from '../core/memory-embedder-bridge.js';
import { MemoryJobScheduler } from '../core/memory-job-scheduler.js';
import type { JobRecord, JobStatus } from '../core/memory-job-scheduler.js';
import { StoragePaths } from '../config/storage-paths.js';
import { join } from 'path';
import { getProjectRoot } from '../utils/path-validator.js';
// Zod schemas
const OperationEnum = z.enum(['list', 'import', 'export', 'summary', 'embed', 'search', 'embed_status']);
const OperationEnum = z.enum([
'list', 'import', 'export', 'summary', 'embed', 'search', 'embed_status',
'extract', 'extract_status', 'consolidate', 'consolidate_status', 'jobs',
]);
const ParamsSchema = z.object({
operation: OperationEnum,
@@ -31,6 +36,11 @@ const ParamsSchema = z.object({
source_id: z.string().optional(),
batch_size: z.number().optional().default(8),
force: z.boolean().optional().default(false),
// V2 extract parameters
max_sessions: z.number().optional(),
// V2 jobs parameters
kind: z.string().optional(),
status_filter: z.enum(['pending', 'running', 'done', 'error']).optional(),
});
type Params = z.infer<typeof ParamsSchema>;
@@ -105,7 +115,44 @@ interface EmbedStatusResult {
by_type: Record<string, { total: number; embedded: number }>;
}
type OperationResult = ListResult | ImportResult | ExportResult | SummaryResult | EmbedResult | SearchResult | EmbedStatusResult;
// -- Memory V2 operation result types --
interface ExtractResult {
operation: 'extract';
triggered: boolean;
jobIds: string[];
message: string;
}
interface ExtractStatusResult {
operation: 'extract_status';
total_stage1: number;
jobs: Array<{ job_key: string; status: string; last_error?: string }>;
}
interface ConsolidateResult {
operation: 'consolidate';
triggered: boolean;
message: string;
}
interface ConsolidateStatusResult {
operation: 'consolidate_status';
status: string;
memoryMdAvailable: boolean;
memoryMdPreview?: string;
}
interface JobsResult {
operation: 'jobs';
jobs: JobRecord[];
total: number;
byStatus: Record<string, number>;
}
type OperationResult = ListResult | ImportResult | ExportResult | SummaryResult
| EmbedResult | SearchResult | EmbedStatusResult
| ExtractResult | ExtractStatusResult | ConsolidateResult | ConsolidateStatusResult | JobsResult;
/**
* Get project path - uses explicit path if provided, otherwise falls back to current working directory
@@ -333,6 +380,165 @@ async function executeEmbedStatus(params: Params): Promise<EmbedStatusResult> {
};
}
// ============================================================================
// Memory V2 Operation Handlers
// ============================================================================
/**
* Operation: extract
* Trigger batch extraction (fire-and-forget). Returns job IDs immediately.
*/
async function executeExtract(params: Params): Promise<ExtractResult> {
const { max_sessions, path } = params;
const projectPath = getProjectPath(path);
try {
const { MemoryExtractionPipeline } = await import('../core/memory-extraction-pipeline.js');
const pipeline = new MemoryExtractionPipeline(projectPath);
// Fire-and-forget: trigger batch extraction asynchronously
const batchPromise = pipeline.runBatchExtraction({ maxSessions: max_sessions });
// Don't await - let it run in background
batchPromise.catch((err: Error) => {
// Log errors but don't throw - fire-and-forget
console.error(`[memory-v2] Batch extraction error: ${err.message}`);
});
// Scan eligible sessions to report count
const eligible = pipeline.scanEligibleSessions(max_sessions);
const sessionIds = eligible.map(s => s.id);
return {
operation: 'extract',
triggered: true,
jobIds: sessionIds,
message: `Extraction triggered for ${eligible.length} eligible sessions (max: ${max_sessions || 'default'})`,
};
} catch (err) {
return {
operation: 'extract',
triggered: false,
jobIds: [],
message: `Failed to trigger extraction: ${(err as Error).message}`,
};
}
}
/**
* Operation: extract_status
* Get extraction pipeline state.
*/
async function executeExtractStatus(params: Params): Promise<ExtractStatusResult> {
const { path } = params;
const projectPath = getProjectPath(path);
const store = getCoreMemoryStore(projectPath);
const scheduler = new MemoryJobScheduler(store.getDb());
const stage1Count = store.countStage1Outputs();
const extractionJobs = scheduler.listJobs('extraction');
return {
operation: 'extract_status',
total_stage1: stage1Count,
jobs: extractionJobs.map(j => ({
job_key: j.job_key,
status: j.status,
last_error: j.last_error,
})),
};
}
/**
* Operation: consolidate
* Trigger consolidation (fire-and-forget).
*/
async function executeConsolidate(params: Params): Promise<ConsolidateResult> {
const { path } = params;
const projectPath = getProjectPath(path);
try {
const { MemoryConsolidationPipeline } = await import('../core/memory-consolidation-pipeline.js');
const pipeline = new MemoryConsolidationPipeline(projectPath);
// Fire-and-forget: trigger consolidation asynchronously
const consolidatePromise = pipeline.runConsolidation();
consolidatePromise.catch((err: Error) => {
console.error(`[memory-v2] Consolidation error: ${err.message}`);
});
return {
operation: 'consolidate',
triggered: true,
message: 'Consolidation triggered',
};
} catch (err) {
return {
operation: 'consolidate',
triggered: false,
message: `Failed to trigger consolidation: ${(err as Error).message}`,
};
}
}
/**
* Operation: consolidate_status
* Get consolidation pipeline state.
*/
async function executeConsolidateStatus(params: Params): Promise<ConsolidateStatusResult> {
const { path } = params;
const projectPath = getProjectPath(path);
try {
const { MemoryConsolidationPipeline } = await import('../core/memory-consolidation-pipeline.js');
const pipeline = new MemoryConsolidationPipeline(projectPath);
const status = pipeline.getStatus();
const memoryMd = pipeline.getMemoryMdContent();
return {
operation: 'consolidate_status',
status: status?.status || 'unknown',
memoryMdAvailable: !!memoryMd,
memoryMdPreview: memoryMd ? memoryMd.substring(0, 500) : undefined,
};
} catch {
return {
operation: 'consolidate_status',
status: 'unavailable',
memoryMdAvailable: false,
};
}
}
/**
* Operation: jobs
* List all V2 jobs with optional kind filter.
*/
function executeJobs(params: Params): JobsResult {
const { kind, status_filter, path } = params;
const projectPath = getProjectPath(path);
const store = getCoreMemoryStore(projectPath);
const scheduler = new MemoryJobScheduler(store.getDb());
const jobs = scheduler.listJobs(kind, status_filter as JobStatus | undefined);
// Compute byStatus counts
const byStatus: Record<string, number> = {};
for (const job of jobs) {
byStatus[job.status] = (byStatus[job.status] || 0) + 1;
}
return {
operation: 'jobs',
jobs,
total: jobs.length,
byStatus,
};
}
/**
* Route to appropriate operation handler
*/
@@ -354,9 +560,19 @@ async function execute(params: Params): Promise<OperationResult> {
return executeSearch(params);
case 'embed_status':
return executeEmbedStatus(params);
case 'extract':
return executeExtract(params);
case 'extract_status':
return executeExtractStatus(params);
case 'consolidate':
return executeConsolidate(params);
case 'consolidate_status':
return executeConsolidateStatus(params);
case 'jobs':
return executeJobs(params);
default:
throw new Error(
`Unknown operation: ${operation}. Valid operations: list, import, export, summary, embed, search, embed_status`
`Unknown operation: ${operation}. Valid operations: list, import, export, summary, embed, search, embed_status, extract, extract_status, consolidate, consolidate_status, jobs`
);
}
}
@@ -374,6 +590,11 @@ Usage:
core_memory(operation="embed", source_id="CMEM-xxx") # Generate embeddings for memory
core_memory(operation="search", query="authentication") # Search memories semantically
core_memory(operation="embed_status") # Check embedding status
core_memory(operation="extract") # Trigger batch memory extraction (V2)
core_memory(operation="extract_status") # Check extraction pipeline status
core_memory(operation="consolidate") # Trigger memory consolidation (V2)
core_memory(operation="consolidate_status") # Check consolidation status
core_memory(operation="jobs") # List all V2 pipeline jobs
Path parameter (highest priority):
core_memory(operation="list", path="/path/to/project") # Use specific project path
@@ -384,7 +605,10 @@ Memory IDs use format: CMEM-YYYYMMDD-HHMMSS`,
properties: {
operation: {
type: 'string',
enum: ['list', 'import', 'export', 'summary', 'embed', 'search', 'embed_status'],
enum: [
'list', 'import', 'export', 'summary', 'embed', 'search', 'embed_status',
'extract', 'extract_status', 'consolidate', 'consolidate_status', 'jobs',
],
description: 'Operation to perform',
},
path: {
@@ -437,6 +661,19 @@ Memory IDs use format: CMEM-YYYYMMDD-HHMMSS`,
type: 'boolean',
description: 'Force re-embedding even if embeddings exist (default: false)',
},
max_sessions: {
type: 'number',
description: 'Max sessions to extract in one batch (for extract operation)',
},
kind: {
type: 'string',
description: 'Filter jobs by kind (for jobs operation, e.g. "extraction" or "consolidation")',
},
status_filter: {
type: 'string',
enum: ['pending', 'running', 'done', 'error'],
description: 'Filter jobs by status (for jobs operation)',
},
},
required: ['operation'],
},

View File

@@ -0,0 +1,50 @@
/**
* Secret Redactor - Regex-based secret pattern detection and replacement
*
* Scans text for common secret patterns (API keys, tokens, credentials)
* and replaces them with [REDACTED_SECRET] to prevent leakage into
* memory extraction outputs.
*
* Patterns are intentionally specific (prefix-based) to minimize false positives.
*/
const REDACTED = '[REDACTED_SECRET]';
/**
* Secret patterns with named regex for each category.
* Each pattern targets a specific, well-known secret format.
*/
const SECRET_PATTERNS: ReadonlyArray<{ name: string; regex: RegExp }> = [
// OpenAI API keys: sk-<20+ alphanumeric chars>
{ name: 'openai_key', regex: /sk-[A-Za-z0-9]{20,}/g },
// AWS Access Key IDs: AKIA<16 uppercase alphanumeric chars>
{ name: 'aws_key', regex: /AKIA[0-9A-Z]{16}/g },
// Bearer tokens: Bearer <16+ token chars>
{ name: 'bearer_token', regex: /Bearer\s+[A-Za-z0-9._\-]{16,}/g },
// Secret assignments: key=value or key:value patterns for known secret variable names
{ name: 'secret_assignment', regex: /(?:api_key|token|secret|password)[:=]\S+/gi },
];
/**
* Apply regex-based secret pattern matching and replacement.
*
* Scans the input text for 4 pattern categories:
* 1. OpenAI API keys (sk-...)
* 2. AWS Access Key IDs (AKIA...)
* 3. Bearer tokens (Bearer ...)
* 4. Secret variable assignments (api_key=..., token:..., etc.)
*
* @param text - Input text to scan for secrets
* @returns Text with all matched secrets replaced by [REDACTED_SECRET]
*/
export function redactSecrets(text: string): string {
if (!text) return text;
let result = text;
for (const { regex } of SECRET_PATTERNS) {
// Reset lastIndex for global regexes to ensure fresh match on each call
regex.lastIndex = 0;
result = result.replace(regex, REDACTED);
}
return result;
}