diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88bad4d..e930a8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,18 +38,7 @@ jobs: - run: npm test - run: npm run build - eval: - # Advisory recall benchmark (MemoryBench). Kept as a separate job pinned to a - # single runner (ubuntu + node 22) so the matrix stays fast and eval - # flakiness cannot block cross-platform test results. - name: eval (advisory) - continue-on-error: true - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - run: npm ci - - run: npm run eval +# The recall/leak benchmark (tests/memorybench.test.ts, thresholds recall >= 0.80, +# zero forgotten-fact leaks) runs inside the blocking matrix job via `npm test`, +# so no separate advisory eval job is needed. + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e236a2d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,33 @@ +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + gates: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm ci + - run: npm run build + - run: npm test + - run: npm run eval + + publish: + needs: gates + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + registry-url: https://registry.npmjs.org + - run: npm ci + - run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} diff --git a/.gitignore b/.gitignore index 8ecbd90..7c9b271 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,6 @@ GRAPH_REPORT.md # UI design-tournament artifacts (local only) .design/ + +# local npm pack artifacts +*.tgz diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ce7722..7ade647 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,33 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +## [1.4.0] - 2026-08-23 + +### Fixed + +- Checkpoints no longer overwrite the repo-local git identity; committer identity is passed per-invocation via `-c` flags, so your own commits are never reattributed to the bot +- The memory store is now truly contained: `.hypersabmemory/` is added to `.git/info/exclude` on setup, legacy tracked stores are untracked, and staging can never sweep the decision log into your commits +- Auto-commit logs no longer corrupt the MCP stdio JSON-RPC stream when the checkpoint tool runs inside an IDE +- Generated MCP configs wrap `npx` in `cmd /c` on Windows, fixing ENOENT first-run failures in Cursor/VS Code +- Checkpoints now refuse to fire during a conflicted merge or rebase (`operation-in-progress`) and refuse monorepo subdirectory sweeps (`subdir-of-repo`) +- Dashboard POST endpoints are hardened against CSRF and DNS rebinding (Host allowlist, JSON content-type, custom header) +- Concurrent engines (MCP + dashboard + CLI) no longer suffer last-writer-wins memory loss: all store mutations serialize through a file-lock CAS with atomic tmp+rename writes, dead-holder eviction, and a golden format safety net +- Swarm mesh locks close the check-then-act race: claim/release/broadcast reload under lock, `sync()` is read-only, sessions are garbage-collected, and lock keys are case-folded on Windows +- `remember()` performs one batched store save instead of one per superseded duplicate; watcher ignores `.venv/target/build/out/coverage/vendor` and debounces rapid writes +- README benchmark wording now matches CI reality (the recall gate runs inside the blocking matrix job) + +### Added + +- Cold-start bootstrap seeding: `setup` derives 5-10 facts from your README/package.json/git history with visible `[seeded]` provenance, never-supersede semantics, and a GENESIS ritual that prints the baseline checkpoint hash +- `handoff` command: paste-ready forward session brief (<=600 tokens) plus deterministic `--story` retrospective mode +- `doctor` now verifies the configured MCP launch command end-to-end via a real stdio handshake and reports measured counters (active/forgotten facts, checkpoints) +- Adversarial eval classes in MemoryBench: negation, superseded-stale, and artifact-linked query fixtures gated at >=0.80 recall with zero leaks across all retrieval paths +- MCP resources: context block and memory log exposed via resources/list + resources/read for resource-aware clients +- `remove` command for clean uninstall of injected sections, MCP entries, and the local store +- Swarm visibility cards on the dashboard: live file-lock TTLs and recent broadcasts via `/api/swarm` +- Ghost Paths: the header warns `[GHOST]` when current edits collide with files from rolled-back checkpoints (opt-out via `HYPERSABMEMORY_GHOST_PATHS=off`) +- Setup success output now prints per-host enable steps; funding file and tag-triggered release workflow added + ## [1.3.0] - 2026-08-22 ### Changed diff --git a/FUNDING.yml b/FUNDING.yml new file mode 100644 index 0000000..4740736 --- /dev/null +++ b/FUNDING.yml @@ -0,0 +1 @@ +github: framesxsab diff --git a/README.md b/README.md index 26f410f..19ad043 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ That command: 1. Injects editor rules (`AGENTS.md`, Cursor `.mdc`, `CLAUDE.md`, Copilot, Windsurf) without wiping your existing text 2. Writes MCP configs (`.cursor/mcp.json`, `.mcp.json`, `.vscode/mcp.json`) and keeps other servers intact -3. Initializes local memory under `.hypersabmemory/` (gitignored) +3. Initializes local memory under `.hypersabmemory/`, kept out of your commits automatically via `.git/info/exclude` Then **reload the IDE** and **enable** the `hypersabmemory` MCP server: @@ -93,8 +93,8 @@ Checkpoints stay **local**. HyperSABMemory never runs `git push`. ## Benchmarked in the open Recall is gated by a local benchmark (`npm run eval`) with published thresholds and -deterministic fixtures. No hosted numbers, no cherry-picking: CI runs the same gate on -every push. +deterministic fixtures. No hosted numbers, no cherry-picking: CI runs the same gate +inside its blocking test job on every push. | Gate | Threshold | |---|---| @@ -175,7 +175,7 @@ npm run eval npm run build ``` -CI runs the full matrix (Ubuntu + Windows, Node 20/22) plus the recall gate on every push. See [CONTRIBUTING.md](./CONTRIBUTING.md). +CI runs the full matrix (Ubuntu + Windows, Node 20/22) with the recall gate inside the blocking test job on every push. See [CONTRIBUTING.md](./CONTRIBUTING.md). Blueprint docs: [Architecture.md](./Architecture.md), [PRD.md](./PRD.md), [Rules.md](./Rules.md), [Phases.md](./Phases.md). diff --git a/package.json b/package.json index 09f9de5..39bfc7f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hypersabmemory", - "version": "1.3.0", + "version": "1.4.0", "description": "Open-source IDE context harness and persistent memory engine for AI coding agents", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -51,7 +51,9 @@ "dist", "README.md", "LICENSE", - "CONTRIBUTING.md" + "CONTRIBUTING.md", + "CHANGELOG.md", + "SECURITY.md" ], "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", diff --git a/src/cli/cliUtils.ts b/src/cli/cliUtils.ts index 46bb904..050421b 100644 --- a/src/cli/cliUtils.ts +++ b/src/cli/cliUtils.ts @@ -1,3 +1,7 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import type { MemoryRecord } from '../memory/sqliteStore.js'; + /** * Parses the --interval CLI option. Returns null for anything that is not a * number >= 1 (floats are floored) so it can never become a 0ms rapid-fire timer. @@ -8,3 +12,18 @@ export function parseIntervalMinutes(raw: string): number | null { if (!Number.isFinite(value) || value < 1) return null; return Math.floor(value); } + +/** + * Read-only peek at the local memory store. Returns [] when the store is + * absent or unreadable; never creates files or instantiates engines. + */ +export function readStoreRecords(workspaceDir: string): MemoryRecord[] { + const storePath = path.join(workspaceDir, '.hypersabmemory', 'memory_store.json'); + if (!fs.existsSync(storePath)) return []; + try { + const parsed: unknown = JSON.parse(fs.readFileSync(storePath, 'utf-8')); + return Array.isArray(parsed) ? (parsed as MemoryRecord[]) : []; + } catch { + return []; + } +} diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index de1315b..9af2f6c 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -1,8 +1,12 @@ -import { execFile } from 'child_process'; +import { execFile, spawn } from 'child_process'; import { promisify } from 'util'; import * as fs from 'fs'; import * as net from 'net'; import * as path from 'path'; +import type { McpLaunch } from '../harness/mcpAccess.js'; +import { TokenCompressor } from '../harness/tokenCompressor.js'; +import { buildContextBlock } from '../memory/contextBlock.js'; +import { readStoreRecords } from './cliUtils.js'; const execFileAsync = promisify(execFile); @@ -15,6 +19,19 @@ export interface DoctorCheck { export interface DoctorReport { checks: DoctorCheck[]; ok: boolean; + counters?: DoctorCounters; +} + +export interface DoctorCounters { + activeRecords: number; + forgottenRecords: number; + checkpointCommits: number | null; + lastRollbackHash?: string; + estTokensSavedPct?: number; +} + +export interface DoctorOptions { + verifyTimeoutMs?: number; } async function checkGitOnPath(): Promise { @@ -73,6 +90,165 @@ function checkMcpConfigs(cwd: string): DoctorCheck { }; } +function readConfiguredLaunch(cwd: string): McpLaunch | null { + const candidates: Array<{ file: string; key: 'mcpServers' | 'servers' }> = [ + { file: '.cursor/mcp.json', key: 'mcpServers' }, + { file: '.mcp.json', key: 'mcpServers' }, + { file: '.vscode/mcp.json', key: 'servers' } + ]; + for (const candidate of candidates) { + const abs = path.join(cwd, candidate.file); + if (!fs.existsSync(abs)) continue; + try { + const payload = JSON.parse(fs.readFileSync(abs, 'utf-8')) as Record; + const bucket = payload[candidate.key]; + if (!bucket || typeof bucket !== 'object' || Array.isArray(bucket)) continue; + const entry = (bucket as Record).hypersabmemory; + if (!entry || typeof entry !== 'object') continue; + const record = entry as Record; + if (typeof record.command !== 'string' || !Array.isArray(record.args)) continue; + return { command: record.command, args: record.args.map(String) }; + } catch { + continue; // corrupt configs are already reported by checkMcpConfigs + } + } + return null; +} + +/** + * Spawns the configured MCP launch command over stdio, performs the + * initialize + tools/list JSON-RPC handshake, and kills the child under a + * timeout guard. Catches broken launch configs (the B4 bug class). + */ +export function verifyMcpLaunch(cwd: string, timeoutMs: number = 5000): Promise { + const launch = readConfiguredLaunch(cwd); + if (!launch) { + return Promise.resolve({ + name: 'mcp-launch', + ok: true, + detail: 'no hypersabmemory MCP entry configured yet — live handshake skipped' + }); + } + + return new Promise((resolve) => { + let settled = false; + let buffer = ''; + // shell:false keeps the exact configured command/args; the win32 `cmd /c` + // wrapper written by connect() spawns correctly as spawn('cmd', ['/c', ...]). + const child = spawn(launch.command, launch.args, { shell: false, stdio: ['pipe', 'pipe', 'pipe'] }); + + const finish = (check: DoctorCheck): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + child.removeAllListeners(); + child.stdout?.removeAllListeners(); + try { + child.kill(); + } catch { + // already gone + } + resolve(check); + }; + + const timer = setTimeout(() => { + finish({ + name: 'mcp-launch', + ok: false, + detail: `no tools/list response within ${timeoutMs}ms — killed ${launch.command} ${launch.args.join(' ')}` + }); + }, timeoutMs); + + child.on('error', (err) => finish({ name: 'mcp-launch', ok: false, detail: `spawn failed: ${err.message}` })); + child.on('exit', () => finish({ + name: 'mcp-launch', + ok: false, + detail: `server exited before replying to tools/list (${launch.command} ${launch.args.join(' ')})` + })); + + child.stdout?.on('data', (chunk: Buffer) => { + buffer += chunk.toString('utf-8'); + for (const line of buffer.split('\n')) { + try { + const message = JSON.parse(line) as { id?: unknown; result?: { tools?: unknown[] } }; + if (message.id !== 2) continue; + const toolCount = Array.isArray(message.result?.tools) ? message.result.tools.length : 0; + finish({ + name: 'mcp-launch', + ok: true, + detail: `${launch.command} ${launch.args.join(' ')} replied with ${toolCount} tool(s)` + }); + } catch { + // partial or non-JSON line + } + } + }); + + const send = (payload: object): void => { + child.stdin?.write(`${JSON.stringify(payload)}\n`); + }; + send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'hypersabmemory-doctor', version: '0' } + } + }); + send({ jsonrpc: '2.0', method: 'notifications/initialized' }); + send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); + }); +} + +async function collectCounters(cwd: string): Promise { + const records = readStoreRecords(cwd); + const active = records.filter((record) => (record.status ?? 'active') === 'active'); + const counters: DoctorCounters = { + activeRecords: active.length, + forgottenRecords: records.filter((record) => record.status === 'forgotten').length, + checkpointCommits: null + }; + + try { + const { stdout } = await execFileAsync('git', ['rev-list', '--count', '--grep=^checkpoint:', 'HEAD'], { cwd }); + const count = Number.parseInt(stdout.trim(), 10); + counters.checkpointCommits = Number.isFinite(count) ? count : 0; + } catch { + // not a git work tree + } + + try { + const { stdout } = await execFileAsync( + 'git', + ['log', '-n1', '--grep=safety: pre-rollback', '--pretty=format:%h'], + { cwd } + ); + const hash = stdout.trim().split('\n')[0]; + if (hash) counters.lastRollbackHash = hash; + } catch { + // best effort only + } + + if (active.length > 0) { + const deliveredTokens = TokenCompressor.estimateTokens( + buildContextBlock(active, `${active.length} item(s) recorded.`) + ); + const naiveCorpusTokens = active.reduce( + (sum, record) => + sum + TokenCompressor.estimateTokens([record.content, (record.tags ?? []).join(' ')].filter(Boolean).join(' ')), + 0 + ); + if (naiveCorpusTokens > 0) { + counters.estTokensSavedPct = + Math.max(0, Math.round((1 - deliveredTokens / naiveCorpusTokens) * 1000) / 10); + } + } + + return counters; +} + function checkDashboardPort(port: number): Promise { return new Promise((resolve) => { const probe = net.createServer(); @@ -86,14 +262,16 @@ function checkDashboardPort(port: number): Promise { * Read-only health self-check. Never mutates workspace state; hard failures * are missing git and an unparseable store. */ -export async function runDoctor(cwd: string = process.cwd()): Promise { +export async function runDoctor(cwd: string = process.cwd(), options: DoctorOptions = {}): Promise { const checks: DoctorCheck[] = [ await checkGitOnPath(), await checkInsideWorkTree(cwd), checkStoreParseable(cwd), checkMcpConfigs(cwd), + await verifyMcpLaunch(cwd, options.verifyTimeoutMs ?? 5000), await checkDashboardPort(4321) ]; + const counters = await collectCounters(cwd); const ok = checks.every((check) => check.ok); - return { checks, ok }; + return { checks, ok, counters }; } diff --git a/src/cli/handoffCommand.ts b/src/cli/handoffCommand.ts new file mode 100644 index 0000000..c3b5b29 --- /dev/null +++ b/src/cli/handoffCommand.ts @@ -0,0 +1,157 @@ +/** + * HyperSABMemory — Handoff Command Engine + * Builds a paste-ready forward handoff brief (<= 600 tokens) or a deterministic + * captain's-log retrospective rendered purely from local ledgers (no LLM/network). + */ + +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import * as path from 'path'; +import { readStoreRecords } from './cliUtils.js'; +import { TokenCompressor } from '../harness/tokenCompressor.js'; +import { StructuralGraphEngine } from '../memory/structuralGraph.js'; +import type { MemoryRecord } from '../memory/sqliteStore.js'; + +const execFileAsync = promisify(execFile); + +const TOKEN_BUDGET = 600; +const FACT_LIMIT = 8; +const NEXT_STEP_LIMIT = 6; +const CHECKPOINT_LIMIT = 5; + +export interface HandoffOptions { + story?: boolean; +} + +const CATEGORY_RANK: Record = { + architecture: 0, + decision: 1, + bug: 2, + symbol: 3, + general: 4 +}; + +function isActive(record: MemoryRecord): boolean { + return (record.status ?? 'active') === 'active'; +} + +async function gitLines(workspaceDir: string, args: string[]): Promise { + try { + const { stdout } = await execFileAsync('git', args, { cwd: workspaceDir }); + return stdout.split('\n').map((line) => line.trim()).filter(Boolean); + } catch { + return []; + } +} + +async function focusLines(workspaceDir: string): Promise { + const branchLines = await gitLines(workspaceDir, ['rev-parse', '--abbrev-ref', 'HEAD']); + if (branchLines.length === 0) return []; + const statusLines = await gitLines(workspaceDir, ['status', '--porcelain']); + const files = statusLines.map((line) => line.slice(3).trim()).filter(Boolean); + const detail = files.length + ? `${files.length} uncommitted file(s): ${files.slice(0, 5).join(', ')}` + : 'working tree clean'; + return [`- branch ${branchLines[0]} | ${detail}`]; +} + +async function checkpointLines(workspaceDir: string, limit: number): Promise { + // Same `checkpoint:` subject convention the AutoCommitEngine writes. + const lines = await gitLines( + workspaceDir, + ['log', `-n${limit}`, '--grep=^checkpoint:', '--pretty=format:%h %s'] + ); + return lines.map((line) => `- ${line}`); +} + +async function countCheckpoints(workspaceDir: string): Promise { + const lines = await gitLines( + workspaceDir, + ['rev-list', '--count', '--grep=^checkpoint:', 'HEAD'] + ); + const count = Number.parseInt(lines[0] ?? '', 10); + return Number.isFinite(count) ? count : 0; +} + +function topFacts(records: MemoryRecord[]): MemoryRecord[] { + return records + .filter(isActive) + .sort((a, b) => { + const rank = CATEGORY_RANK[a.category] - CATEGORY_RANK[b.category]; + if (rank !== 0) return rank; + return b.timestamp.localeCompare(a.timestamp); + }) + .slice(0, FACT_LIMIT); +} + +function openNextSteps(records: MemoryRecord[]): string[] { + const steps = new Set(); + for (const record of records.filter(isActive)) { + const step = record.nextSteps?.trim(); + if (step) steps.add(step); + if (steps.size >= NEXT_STEP_LIMIT) break; + } + return Array.from(steps); +} + +async function buildForwardBrief(workspaceDir: string): Promise { + const records = readStoreRecords(workspaceDir); + const [focus, checkpoints] = await Promise.all([ + focusLines(workspaceDir), + checkpointLines(workspaceDir, CHECKPOINT_LIMIT) + ]); + const facts = topFacts(records); + const nextSteps = openNextSteps(records); + + const lines: string[] = ['# HyperSABMemory Handoff', '', '## Active focus']; + lines.push(...(focus.length > 0 ? focus : ['- no git state captured'])); + lines.push('', '## Top active facts'); + lines.push(...(facts.length > 0 + ? facts.map((r) => `- [${r.category}] ${r.content}`) + : ['- none recorded'])); + lines.push('', '## Open next steps'); + lines.push(...(nextSteps.length > 0 + ? nextSteps.map((step) => `- ${step}`) + : ['- none recorded'])); + lines.push('', '## Recent checkpoints'); + lines.push(...(checkpoints.length > 0 ? checkpoints : ['- none found'])); + + return TokenCompressor.compressText(lines.join('\n'), TOKEN_BUDGET); +} + +async function buildStoryBrief(workspaceDir: string): Promise { + const today = new Date().toISOString().slice(0, 10); + const records = readStoreRecords(workspaceDir); + const learnedToday = records.filter((r) => r.timestamp?.slice(0, 10) === today).length; + const forgottenToday = records.filter( + (r) => r.status === 'forgotten' && r.invalidAt?.slice(0, 10) === today + ).length; + const [checkpoints, graphNodes] = await Promise.all([ + countCheckpoints(workspaceDir), + Promise.resolve(new StructuralGraphEngine(workspaceDir).getGraphData().nodes.length) + ]); + + const lines: string[] = [ + `# Captain's Log — ${today}`, + '', + 'Rendered purely from local ledgers (store timestamps, git log, graph.json). No model calls.', + '', + `- Facts learned today: ${learnedToday}`, + `- Facts forgotten today: ${forgottenToday}`, + `- Checkpoints recorded: ${checkpoints}`, + `- Graph nodes mapped: ${graphNodes}` + ]; + + return TokenCompressor.compressText(lines.join('\n'), TOKEN_BUDGET); +} + +/** + * Builds the handoff brief. Forward mode is a paste-ready markdown brief under + * the token budget; story mode is a byte-stable retrospective of today's ledgers. + */ +export async function buildHandoffBrief( + workspaceDir: string, + options: HandoffOptions = {} +): Promise { + return options.story ? buildStoryBrief(workspaceDir) : buildForwardBrief(workspaceDir); +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 03ee664..4759924 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -16,7 +16,7 @@ import { FileWatcherHook } from '../hooks/fileWatcher.js'; import { AutoCommitEngine } from '../hooks/autoCommit.js'; import { HyperSABMemoryMcpServer } from '../mcp/server.js'; import { TemporalMemoryEngine } from '../memory/temporalMemory.js'; -import { OneShotSetupEngine } from './oneShotSetup.js'; +import { OneShotSetupEngine, printSetupOutcome } from './oneShotSetup.js'; import { DashboardServer } from '../server/dashboardServer.js'; import { UniversalAdaptersEngine } from '../harness/universalAdapters.js'; import { McpAccessEngine, MCP_TOOL_FLOW } from '../harness/mcpAccess.js'; @@ -25,6 +25,9 @@ import { parseIntervalMinutes } from './cliUtils.js'; import { registerMemoryCommands } from './memoryCommands.js'; import { runDoctor } from './doctor.js'; import { runEvalCommand } from './evalCommand.js'; +import { buildHandoffBrief } from './handoffCommand.js'; +import { removeInjection } from './removeCommand.js'; +import { setupNextStepsFlow } from './setupOutput.js'; const execFileAsync = promisify(execFile); @@ -74,15 +77,9 @@ program console.log(picocolors.cyan('\n[HyperSABMemory :: One-Shot Setup] Deploying complete ecosystem...')); const setupEngine = new OneShotSetupEngine(process.cwd()); const result = await setupEngine.executeSetup(); + printSetupOutcome(result); if (result.success) { - console.log(picocolors.green('✔ Structural knowledge graph integrated.')); - console.log(picocolors.green('✔ Temporal memory store initialized.')); - console.log(picocolors.green('✔ Minimal-change rules installed (.agents/skills/hypersabmemory/SKILL.md).')); - console.log(picocolors.green('✔ 3-5 Min Auto-Commit engine configured.')); - console.log(picocolors.green('✔ Direct MCP access written (Cursor, Claude Code, VS Code).')); - console.log(picocolors.cyan('\nFlow: reload the IDE → enable the hypersabmemory MCP server → call hypersabmemory_get_context.')); - } else { - console.log(picocolors.red('✖ Setup encountered an error. Check logs for details.')); + console.log(picocolors.cyan(`\n${setupNextStepsFlow(process.cwd())}`)); } }); @@ -166,6 +163,16 @@ program console.log(picocolors.gray(`\nPreview a rollback with: hypersabmemory diff `)); }); +// Command: handoff +program + .command('handoff') + .description('Print a paste-ready forward handoff brief (--story for a ledger-only retrospective)') + .option('--story', 'Deterministic captain\'s-log retrospective built purely from local ledgers', false) + .action(async (options) => { + const brief = await buildHandoffBrief(process.cwd(), { story: options.story === true }); + console.log(brief); + }); + // Command: diff program .command('diff') @@ -392,9 +399,44 @@ program process.exitCode = 1; return; } + if (report.counters) { + const counters = report.counters; + console.log(picocolors.cyan('\nMeasured counters:')); + console.log(` memory records: ${counters.activeRecords} active / ${counters.forgottenRecords} forgotten`); + console.log(` checkpoint commits: ${counters.checkpointCommits ?? 'unknown (not a git repo)'}`); + if (counters.lastRollbackHash) { + console.log(` last rollback: ${counters.lastRollbackHash} (safety checkpoint)`); + } + if (typeof counters.estTokensSavedPct === 'number') { + console.log(` est. tokens saved ~${counters.estTokensSavedPct}% (measured on this store)`); + } + } console.log(picocolors.green('\n✔ All checks passed.')); }); +// Command: remove +program + .command('remove') + .description('Clean uninstall: strips injected HyperSABMemory sections, MCP entries, and the local store') + .action(() => { + const result = removeInjection(process.cwd()); + if (result.cleanedFiles.length > 0) { + console.log(picocolors.green(`✔ Cleaned injected sections from ${result.cleanedFiles.length} file(s):`)); + for (const file of result.cleanedFiles) { + console.log(picocolors.gray(` ${path.relative(process.cwd(), file) || file}`)); + } + } + for (const file of result.removedFiles) { + console.log(picocolors.green(`✔ Removed generated file: ${path.relative(process.cwd(), file) || file}`)); + } + if (result.storeRemoved) { + console.log(picocolors.green('✔ Removed .hypersabmemory/ store directory.')); + } + if (result.cleanedFiles.length === 0 && result.removedFiles.length === 0 && !result.storeRemoved) { + console.log(picocolors.yellow('ℹ Nothing to remove — no HyperSABMemory artifacts found.')); + } + }); + registerMemoryCommands(program); program.parse(process.argv); diff --git a/src/cli/oneShotSetup.ts b/src/cli/oneShotSetup.ts index fdb9ddb..d9d102c 100644 --- a/src/cli/oneShotSetup.ts +++ b/src/cli/oneShotSetup.ts @@ -5,12 +5,59 @@ * in a single command for instant deployment across any codebase. */ +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import * as fs from 'fs'; +import * as path from 'path'; +import picocolors from 'picocolors'; import { TemporalMemoryEngine } from '../memory/temporalMemory.js'; import { StructuralGraphEngine } from '../memory/structuralGraph.js'; +import { seedWorkspace, writeGenesisRecord } from '../memory/seeding.js'; import { AutoCommitEngine } from '../hooks/autoCommit.js'; import { UniversalAdaptersEngine } from '../harness/universalAdapters.js'; import { McpAccessEngine } from '../harness/mcpAccess.js'; +const execFileAsync = promisify(execFile); + +const STORE_DIR = '.hypersabmemory'; +const EXCLUDE_LINE = `${STORE_DIR}/`; + +/** + * Keeps the memory store out of the user's commits: appends the store dir to + * .git/info/exclude (idempotent) and untracks it if an older setup already + * committed it. Silent no-op outside a git work tree. + */ +async function containStoreInGit(targetDir: string): Promise { + const gitDir = path.join(targetDir, '.git'); + if (!fs.existsSync(gitDir) || !fs.statSync(gitDir).isDirectory()) return; + + const infoDir = path.join(gitDir, 'info'); + fs.mkdirSync(infoDir, { recursive: true }); + const excludePath = path.join(infoDir, 'exclude'); + const current = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, 'utf-8') : ''; + const alreadyListed = current.split(/\r?\n/).some((line) => line.trim() === EXCLUDE_LINE); + if (!alreadyListed) { + const separator = current.length > 0 && !current.endsWith('\n') ? '\n' : ''; + fs.writeFileSync(excludePath, `${current}${separator}${EXCLUDE_LINE}\n`, 'utf-8'); + } + + try { + const { stdout } = await execFileAsync('git', ['ls-files', '--', STORE_DIR], { cwd: targetDir }); + if (stdout.trim().length > 0) { + await execFileAsync('git', ['rm', '-r', '--cached', '--quiet', STORE_DIR], { cwd: targetDir }); + } + } catch { + // git unavailable or not a work tree — the exclude rule still guards future commits + } +} + +export interface SetupOutcome { + success: boolean; + createdFiles: string[]; + genesisHash?: string; + error?: string; +} + export class OneShotSetupEngine { private targetDir: string; @@ -21,10 +68,13 @@ export class OneShotSetupEngine { /** * Executes the full one-shot setup sequence. */ - public async executeSetup(): Promise<{ success: boolean; createdFiles: string[] }> { + public async executeSetup(): Promise { let createdFiles: string[] = []; try { + // 0. Cold-start bootstrap: seed first-boot facts before the engine loads the store. + const seedResult = await seedWorkspace(this.targetDir); + // 1. Sync Universal IDE Adapters (Cursor, Claude Code, Windsurf, Copilot, Antigravity) const memory = new TemporalMemoryEngine(this.targetDir); memory.remember('architecture', 'Initialized HyperSABMemory one-shot setup with universal IDE adapters.'); @@ -39,6 +89,10 @@ export class OneShotSetupEngine { const connected = access.connect(); createdFiles = createdFiles.concat(connected.written); + await containStoreInGit(this.targetDir); + const gitDir = path.join(this.targetDir, '.git'); + const contained = fs.existsSync(gitDir) && fs.statSync(gitDir).isDirectory(); + const graph = new StructuralGraphEngine(this.targetDir); await graph.updateGraph(); @@ -48,10 +102,71 @@ export class OneShotSetupEngine { }); await autoCommit.createCheckpoint('HyperSABMemory One-Shot Setup Complete'); - return { success: true, createdFiles }; + // 5. GENESIS RITUAL — terminal step, fresh workspaces only (seeds already present => skip). + let genesisHash: string | undefined; + if (contained && seedResult.seeded > 0) { + genesisHash = await this.runGenesisRitual(memory, autoCommit); + } + + return { success: true, createdFiles, ...(genesisHash ? { genesisHash } : {}) }; } catch (err) { console.error('[HyperSABMemory :: OneShotSetup] Setup failed:', err); - return { success: false, createdFiles }; + return { success: false, createdFiles, error: err instanceof Error ? err.message : String(err) }; + } + } + + /** + * Writes the single genesis record via direct set, bootstraps Memory.md so the + * named checkpoint has a tracked artifact (the store itself is git-excluded), + * then prints the final GENESIS line with the checkpoint's short hash. + */ + private async runGenesisRitual( + memory: TemporalMemoryEngine, + autoCommit: AutoCommitEngine + ): Promise { + writeGenesisRecord(this.targetDir); + + const memoryMd = path.join(this.targetDir, 'Memory.md'); + if (!fs.existsSync(memoryMd)) { + fs.writeFileSync(memoryMd, '# Memory\n', 'utf-8'); + } + memory.syncToMemoryFile(); + + const checkpoint = await autoCommit.createCheckpoint('genesis: workspace memory baseline'); + let hash = checkpoint.hash; + if (!hash) { + try { + const { stdout } = await execFileAsync('git', ['rev-parse', '--short', 'HEAD'], { cwd: this.targetDir }); + hash = stdout.trim(); + } catch { + hash = 'unknown'; + } + } + console.log(`GENESIS ${hash} - this workspace now remembers.`); + return hash; + } +} + +/** + * Prints the setup result. Failures surface the real error message plus the + * partial createdFiles list instead of a generic "check logs" pointer. + */ +export function printSetupOutcome(outcome: SetupOutcome, workspaceDir: string = process.cwd()): void { + if (outcome.success) { + console.log(picocolors.green('✔ Structural knowledge graph integrated.')); + console.log(picocolors.green('✔ Temporal memory store initialized.')); + console.log(picocolors.green('✔ Minimal-change rules installed (.agents/skills/hypersabmemory/SKILL.md).')); + console.log(picocolors.green('✔ 3-5 Min Auto-Commit engine configured.')); + console.log(picocolors.green('✔ Direct MCP access written (Cursor, Claude Code, VS Code).')); + console.log(picocolors.cyan('\nFlow: reload the IDE → enable the hypersabmemory MCP server → call hypersabmemory_get_context.')); + return; + } + + console.log(picocolors.red(`✖ Setup failed: ${outcome.error ?? 'unknown error'}`)); + if (outcome.createdFiles.length > 0) { + console.log(picocolors.yellow(`Partially created files before failure (${outcome.createdFiles.length}):`)); + for (const file of outcome.createdFiles) { + console.log(picocolors.gray(` ${path.relative(workspaceDir, file) || file}`)); } } } diff --git a/src/cli/removeCommand.ts b/src/cli/removeCommand.ts new file mode 100644 index 0000000..3a20885 --- /dev/null +++ b/src/cli/removeCommand.ts @@ -0,0 +1,126 @@ +/** + * HyperSABMemory — Remove Command Engine + * Clean uninstall: strips injected marker sections from rule files, removes + * hypersabmemory entries from MCP configs (preserving other servers), and + * deletes the local store directory. Never touches user text outside markers. + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +// Marker literals mirror src/harness/universalAdapters.ts; redefined here because +// that module is owned by another workstream and exports no marker constants. +const START = ''; +const END = ''; + +const MARKER_RULE_FILES = [ + 'AGENTS.md', + 'CLAUDE.md', + '.windsurfrules', + path.join('.github', 'copilot-instructions.md'), + '.clinerules', + path.join('.agents', 'skills', 'hypersabmemory', 'SKILL.md') +]; + +const GENERATED_FILES = [ + path.join('.cursor', 'rules', 'hypersabmemory.mdc') +]; + +const MCP_TARGETS: Array<{ file: string; key: 'mcpServers' | 'servers' }> = [ + { file: path.join('.cursor', 'mcp.json'), key: 'mcpServers' }, + { file: '.mcp.json', key: 'mcpServers' }, + { file: path.join('.vscode', 'mcp.json'), key: 'servers' } +]; + +export interface RemoveResult { + cleanedFiles: string[]; + removedFiles: string[]; + storeRemoved: boolean; +} + +function stripInjectedSections(content: string): string { + const pattern = new RegExp(`\\n*${START}[\\s\\S]*?${END}\\n*`, 'g'); + return content.replace(pattern, ''); +} + +function stripRuleFile(workspaceDir: string, relativePath: string, cleaned: string[]): void { + const abs = path.join(workspaceDir, relativePath); + if (!fs.existsSync(abs)) return; + const original = fs.readFileSync(abs, 'utf-8'); + if (!original.includes(START)) return; + fs.writeFileSync(abs, stripInjectedSections(original), 'utf-8'); + cleaned.push(abs); +} + +function removeMcpEntry(workspaceDir: string, target: { file: string; key: 'mcpServers' | 'servers' }, cleaned: string[]): void { + const abs = path.join(workspaceDir, target.file); + if (!fs.existsSync(abs)) return; + let payload: Record; + try { + payload = JSON.parse(fs.readFileSync(abs, 'utf-8')) as Record; + } catch { + return; + } + const bucket = payload[target.key]; + if (!bucket || typeof bucket !== 'object' || Array.isArray(bucket)) return; + const entries = bucket as Record; + if (!('hypersabmemory' in entries)) return; + delete entries.hypersabmemory; + fs.writeFileSync(abs, `${JSON.stringify(payload, null, 2)}\n`, 'utf-8'); + cleaned.push(abs); +} + +function cleanClaudeJson(workspaceDir: string, cleaned: string[]): void { + const abs = path.join(workspaceDir, '.claude.json'); + if (!fs.existsSync(abs)) return; + let payload: Record; + try { + payload = JSON.parse(fs.readFileSync(abs, 'utf-8')) as Record; + } catch { + return; + } + const instructions = payload.instructions; + if (typeof instructions !== 'string' || !instructions.includes(START)) return; + const stripped = stripInjectedSections(instructions).trim(); + if (stripped) { + payload.instructions = stripped; + } else { + delete payload.instructions; + } + fs.writeFileSync(abs, `${JSON.stringify(payload, null, 2)}\n`, 'utf-8'); + cleaned.push(abs); +} + +/** + * Removes everything HyperSABMemory injected into the workspace. Idempotent + * and safe on workspaces where setup never ran. + */ +export function removeInjection(workspaceDir: string): RemoveResult { + const cleanedFiles: string[] = []; + const removedFiles: string[] = []; + + for (const relativePath of MARKER_RULE_FILES) { + stripRuleFile(workspaceDir, relativePath, cleanedFiles); + } + cleanClaudeJson(workspaceDir, cleanedFiles); + + for (const target of MCP_TARGETS) { + removeMcpEntry(workspaceDir, target, cleanedFiles); + } + + for (const relativePath of GENERATED_FILES) { + const abs = path.join(workspaceDir, relativePath); + if (fs.existsSync(abs)) { + fs.rmSync(abs, { force: true }); + removedFiles.push(abs); + } + } + + const storeDir = path.join(workspaceDir, '.hypersabmemory'); + const storeRemoved = fs.existsSync(storeDir); + if (storeRemoved) { + fs.rmSync(storeDir, { recursive: true, force: true }); + } + + return { cleanedFiles, removedFiles, storeRemoved }; +} diff --git a/src/cli/setupOutput.ts b/src/cli/setupOutput.ts new file mode 100644 index 0000000..5a695f7 --- /dev/null +++ b/src/cli/setupOutput.ts @@ -0,0 +1,11 @@ +/** + * HyperSABMemory — Setup Output Helpers + * Renders the per-host MCP enable steps shown after a successful setup. + */ + +import { McpAccessEngine, resolveMcpLaunch } from '../harness/mcpAccess.js'; + +export function setupNextStepsFlow(workspaceDir: string): string { + const access = new McpAccessEngine(workspaceDir); + return access.describeFlow({ launch: resolveMcpLaunch(workspaceDir), written: [] }); +} diff --git a/src/eval/memoryBench.ts b/src/eval/memoryBench.ts index 187c1c8..d162d4d 100644 --- a/src/eval/memoryBench.ts +++ b/src/eval/memoryBench.ts @@ -137,6 +137,145 @@ export function evaluateFusionPath(hybrid: HybridSearchEngine): FusionPathReport return { recall, forgottenLeaks, passed: recall >= RECALL_THRESHOLD && forgottenLeaks === 0 }; } +export type AdversarialClassName = 'negation' | 'superseded-stale' | 'artifact-linked'; + +export interface AdversarialRecord { + id: string; + category: MemoryRecord['category']; + content: string; + query: string; + role: 'expected' | 'stale' | 'chain'; + artifacts?: string[]; +} + +/** + * F4 adversarial fixtures. negation: active "X dropped, migrated to Y" facts + * plus a forgotten pure-X companion per query. superseded-stale: A←B←C chains + * (shared >48-char prefix triggers remember()'s auto-supersede in seed order; + * the last record per query is the chain tip C). artifact-linked: file-path + * queries over facts carrying artifacts entries (path also in content so the + * substring-based direct-engine path can match it). + */ +// allow: SIZE_OK — benchmark module is dominated by pure fixture data tables; +// splitting would break the single-module eval contract (concurrent-agent ownership). +export const ADVERSARIAL_CLASSES: Record = { + negation: [ + { id: 'n01', category: 'decision', content: 'GRPCOLD was dropped: we no longer use GRPCOLD, migrated to QUICSTREAM for transport.', query: 'GRPCOLD', role: 'expected' }, + { id: 'n01-stale', category: 'architecture', content: 'GRPCOLD powers the realtime transport layer.', query: 'GRPCOLD', role: 'stale' }, + { id: 'n02', category: 'decision', content: 'WEBPACKBUNDLE is dropped: we no longer use WEBPACKBUNDLE, migrated to ESBUILDKIT for bundling.', query: 'WEBPACKBUNDLE', role: 'expected' }, + { id: 'n02-stale', category: 'bug', content: 'WEBPACKBUNDLE rebuilds take ninety seconds.', query: 'WEBPACKBUNDLE', role: 'stale' } + ], + 'superseded-stale': [ + { id: 's01-a', category: 'decision', content: 'STATESTORE keeps harness state in JSON files since day one; v1 wrote flat snapshots.', query: 'STATESTORE', role: 'chain' }, + { id: 's01-b', category: 'decision', content: 'STATESTORE keeps harness state in JSON files since day one; v2 moved sessions to sqlite.', query: 'STATESTORE', role: 'chain' }, + { id: 's01-c', category: 'decision', content: 'STATESTORE keeps harness state in JSON files since day one; v3 moved sessions to duckdb.', query: 'STATESTORE', role: 'chain' }, + { id: 's02-a', category: 'symbol', content: 'LOGBRIDGE routes agent logs into a single sink since day one; v1 wrote plain text files.', query: 'LOGBRIDGE', role: 'chain' }, + { id: 's02-b', category: 'symbol', content: 'LOGBRIDGE routes agent logs into a single sink since day one; v2 rotated files hourly.', query: 'LOGBRIDGE', role: 'chain' }, + { id: 's02-c', category: 'symbol', content: 'LOGBRIDGE routes agent logs into a single sink since day one; v3 ships to ducklog.', query: 'LOGBRIDGE', role: 'chain' } + ], + 'artifact-linked': [ + { id: 'a01', category: 'architecture', content: 'REPORTBUILDER streams eval JSON from src/eval/reportBuilder.ts without buffering.', query: 'src/eval/reportBuilder.ts', role: 'expected', artifacts: ['src/eval/reportBuilder.ts'] }, + { id: 'a02', category: 'symbol', content: 'TOKENMETER renders live usage from src/dashboard/tokenMeter.ts bindings.', query: 'src/dashboard/tokenMeter.ts', role: 'expected', artifacts: ['src/dashboard/tokenMeter.ts'] }, + { id: 'a03', category: 'general', content: 'SWARMLOCK guards store writes through src/memory/fileLock.ts helpers.', query: 'src/memory/fileLock.ts', role: 'expected', artifacts: ['src/memory/fileLock.ts'] } + ] +}; + +export function seedAdversarialClasses(engine: TemporalMemoryEngine): void { + for (const records of Object.values(ADVERSARIAL_CLASSES)) { + for (const record of records) { + if (record.role === 'stale') continue; + engine.remember(record.category, record.content, [record.id], record.artifacts?.length ? { artifacts: record.artifacts } : undefined); + } + } + for (const records of Object.values(ADVERSARIAL_CLASSES)) { + for (const record of records) { + if (record.role === 'stale') engine.forget(engine.remember(record.category, record.content, [record.id]).id); + } + } +} + +interface AdversarialExpectation { + query: string; + expected: string; + stale: string[]; + requireTop: boolean; +} + +function buildAdversarialExpectations(name: AdversarialClassName): AdversarialExpectation[] { + const records = ADVERSARIAL_CLASSES[name]; + switch (name) { + case 'negation': + case 'artifact-linked': + return records + .filter((r) => r.role === 'expected') + .map((r) => ({ + query: r.query, + expected: r.content, + stale: records.filter((s) => s.role === 'stale' && s.query === r.query).map((s) => s.content), + requireTop: false + })); + case 'superseded-stale': { + const chains = new Map(); + for (const r of records) { + const chain = chains.get(r.query) ?? []; + chain.push(r); + chains.set(r.query, chain); + } + return Array.from(chains.values()).map((chain) => ({ + query: chain[0].query, + expected: chain[chain.length - 1].content, + stale: chain.slice(0, -1).map((r) => r.content), + requireTop: true + })); + } + } +} + +export interface AdversarialClassScore { + className: AdversarialClassName; + activeCases: number; + recalled: number; + recall: number; + staleLeaks: number; + contextBlockLeaks: number; + passed: boolean; +} + +export function scoreAdversarialClass( + name: AdversarialClassName, + queryFn: (q: string) => Array>, + contextBlock: string +): AdversarialClassScore { + const expectations = buildAdversarialExpectations(name); + const blockLower = contextBlock.toLowerCase(); + + let recalled = 0; + let staleLeaks = 0; + let contextBlockLeaks = 0; + for (const exp of expectations) { + const hitContents = queryFn(exp.query).map((h) => h.content.toLowerCase().trim()); + const topIsExpected = hitContents.length > 0 && hitContents[0] === exp.expected.toLowerCase().trim(); + const queryPresent = hitContents.some((h) => h.includes(exp.query.toLowerCase())); + if (exp.requireTop ? topIsExpected : queryPresent) recalled += 1; + + for (const stale of exp.stale) { + if (hitContents.includes(stale.toLowerCase().trim())) staleLeaks += 1; + if (blockLower.includes(stale.toLowerCase())) contextBlockLeaks += 1; + } + } + + const recall = expectations.length === 0 ? 1 : recalled / expectations.length; + return { + className: name, + activeCases: expectations.length, + recalled, + recall, + staleLeaks, + contextBlockLeaks, + passed: recall >= RECALL_THRESHOLD && staleLeaks === 0 && contextBlockLeaks === 0 + }; +} + export interface MemoryBenchPathResult { name: 'direct-engine' | 'keyword-bm25' | 'fused-memory-graph'; recall: number; @@ -145,11 +284,32 @@ export interface MemoryBenchPathResult { passed: boolean; } +export interface MemoryBenchClassPathResult { + name: MemoryBenchPathResult['name']; + recall: number; + staleLeaks: number; + contextBlockLeaks: number; + passed: boolean; +} + +export interface MemoryBenchClassResult { + name: AdversarialClassName; + paths: MemoryBenchClassPathResult[]; + passed: boolean; +} + export interface MemoryBenchReport { generatedAt: string; fixtureSize: number; threshold: number; paths: MemoryBenchPathResult[]; + /** + * --json only: F4 adversarial per-class breakdown. Each class reports the + * same three retrieval paths as `paths` (recall / staleLeaks / + * contextBlockLeaks / passed per path). Optional so legacy report + * constructors stay valid. + */ + classes?: MemoryBenchClassResult[]; allPassed: boolean; } @@ -163,6 +323,7 @@ export function runMemoryBenchReport(): MemoryBenchReport { try { const engine = new TemporalMemoryEngine(dir); seedMemoryBench(engine); + seedAdversarialClasses(engine); const hybrid = new HybridSearchEngine(dir, engine); const direct = evaluateMemoryEngine(engine); @@ -193,12 +354,30 @@ export function runMemoryBenchReport(): MemoryBenchReport { } ]; + const classNames: AdversarialClassName[] = ['negation', 'superseded-stale', 'artifact-linked']; + const classes: MemoryBenchClassResult[] = classNames.map((name) => { + const direct = scoreAdversarialClass(name, (q) => engine.query(q), engine.getContextBlock()); + const bm25 = scoreAdversarialClass(name, (q) => hybrid.searchRecords(q), engine.getContextBlock()); + const fusion = scoreAdversarialClass( + name, + (q) => hybrid.search(q).filter((h) => h.source === 'memory').map((h) => ({ content: h.snippet })), + '' + ); + const classPaths: MemoryBenchClassPathResult[] = [ + { name: 'direct-engine', recall: direct.recall, staleLeaks: direct.staleLeaks, contextBlockLeaks: direct.contextBlockLeaks, passed: direct.passed }, + { name: 'keyword-bm25', recall: bm25.recall, staleLeaks: bm25.staleLeaks, contextBlockLeaks: bm25.contextBlockLeaks, passed: bm25.passed }, + { name: 'fused-memory-graph', recall: fusion.recall, staleLeaks: fusion.staleLeaks, contextBlockLeaks: 0, passed: fusion.passed } + ]; + return { name, paths: classPaths, passed: classPaths.every((p) => p.passed) }; + }); + return { generatedAt: new Date().toISOString(), fixtureSize: MEMORYBENCH_FIXTURE.length, threshold: RECALL_THRESHOLD, paths, - allPassed: paths.every((p) => p.passed) + classes, + allPassed: paths.every((p) => p.passed) && classes.every((c) => c.passed) }; } finally { fs.rmSync(dir, { recursive: true, force: true }); diff --git a/src/harness/contextHarness.ts b/src/harness/contextHarness.ts index 5ea8980..0306f86 100644 --- a/src/harness/contextHarness.ts +++ b/src/harness/contextHarness.ts @@ -6,9 +6,11 @@ import { HashAnchorEngine } from './hashAnchor.js'; import { MinimalChangeEngine, type MinimalChangeIntensity } from './minimalChange.js'; import { TokenCompressor } from './tokenCompressor.js'; +import { resolveGhostLine } from './ghostPaths.js'; export interface HarnessState { workspaceName: string; + workspaceDir?: string; activeFile?: string; cursorLine?: number; cursorColumn?: number; @@ -69,6 +71,12 @@ export class ContextHarness { ? TokenCompressor.summarizeRead(state.activeFileContent, 16) : ''; + const ghostLine = resolveGhostLine({ + workspaceDir: state.workspaceDir, + activeFile: state.activeFile, + modifiedFiles: state.modifiedFiles + }); + const rawHeader = `[HYPERSABMEMORY :: CONTEXT HARNESS] ┌─ Workspace: ${state.workspaceName} | Branch: ${branchStr} | Checkpoint: ${commitStr} ├─ Active Focus: ${activeFileStr} @@ -83,7 +91,7 @@ ${rules.generateMinimalChangeHeader()} Ensure all edits maintain non-blocking execution, adhere to project Rules.md, and preserve persistent Memory.md state. -`; +${ghostLine ? `\n${ghostLine}` : ''}`; return TokenCompressor.compressText(rawHeader, this.tokenCeiling); } diff --git a/src/harness/ghostPaths.ts b/src/harness/ghostPaths.ts new file mode 100644 index 0000000..6a9b5e8 --- /dev/null +++ b/src/harness/ghostPaths.ts @@ -0,0 +1,106 @@ +/** + * HyperSABMemory — Ghost Paths Engine (F8) + * Deterministic memory of dead timelines: warns when currently-edited files + * overlap files captured in rolled-back safety snapshots. Read-only git calls, + * bounded to the 3 most recent snapshot commits; zero output when silent. + */ + +import { execFileSync } from 'child_process'; + +/** Opt-out flag: HYPERSABMEMORY_GHOST_PATHS=off disables the scan entirely. */ +export const GHOST_PATHS_ENV = 'HYPERSABMEMORY_GHOST_PATHS'; + +/** + * Exact subject of rollback-flow safety snapshots. AutoCommitEngine.rollback + * calls createCheckpoint('safety: pre-rollback snapshot'), which prefixes + * custom reasons with 'feat: ' — so the on-disk commit subject is this string. + */ +export const GHOST_SNAPSHOT_SUBJECT = 'feat: safety: pre-rollback snapshot'; + +const SNAPSHOT_WINDOW = 3; +const GIT_TIMEOUT_MS = 2000; +const LOCK_RETRY_WAIT_MS = 1000; +const LOCK_RETRY_MAX = 5; + +export interface GhostHit { + shortHash: string; +} + +export function formatGhostLine(hit: GhostHit): string { + return `[GHOST] files in this session match a rolled-back checkpoint (${hit.shortHash})`; +} + +function normalizeRel(p: string): string { + return p.replace(/\\/g, '/').replace(/^\.\//, ''); +} + +function isIndexLockError(err: unknown): boolean { + return err instanceof Error && /index\.lock/.test(err.message); +} + +function waitSync(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +/** Read-only git call; retries on index.lock contention, degrades to []. */ +function gitLines(workspaceDir: string, args: string[]): string[] { + for (let attempt = 1; ; attempt++) { + try { + const stdout = execFileSync('git', args, { + cwd: workspaceDir, + timeout: GIT_TIMEOUT_MS, + encoding: 'utf-8' + }); + return stdout.split('\n').map((l) => l.trim()).filter(Boolean); + } catch (err) { + if (attempt < LOCK_RETRY_MAX && isIndexLockError(err)) { + waitSync(LOCK_RETRY_WAIT_MS); + continue; + } + return []; + } + } +} + +/** + * Returns the most recent snapshot commit (within the bounded window) whose + * changed-file list intersects liveFiles, or null. Max git calls: + * 1 log + SNAPSHOT_WINDOW diff-tree. + */ +export function detectGhostCollision(workspaceDir: string, liveFiles: readonly string[]): GhostHit | null { + const live = new Set(liveFiles.map(normalizeRel).filter(Boolean)); + if (live.size === 0) return null; + + const hashes = gitLines(workspaceDir, [ + 'log', `-n${SNAPSHOT_WINDOW}`, '--fixed-strings', `--grep=${GHOST_SNAPSHOT_SUBJECT}`, '--pretty=%H' + ]); + + for (const hash of hashes.slice(0, SNAPSHOT_WINDOW)) { + const changed = gitLines(workspaceDir, [ + 'diff-tree', '--no-commit-id', '--name-only', '-r', '--root', hash + ]); + if (changed.some((f) => live.has(normalizeRel(f)))) { + return { shortHash: hash.slice(0, 7) }; + } + } + return null; +} + +export interface GhostPathsInput { + workspaceDir?: string; + activeFile?: string; + modifiedFiles?: readonly string[]; +} + +/** + * Live edit set = watcher-provided modifiedFiles + activeFile. Returns '' when + * opted out, when there is nothing live to check, or when no snapshot collides + * (byte-identical header; zero tokens). + */ +export function resolveGhostLine(input: GhostPathsInput): string { + if (process.env[GHOST_PATHS_ENV] === 'off') return ''; + const liveFiles = [...(input.modifiedFiles ?? []), ...(input.activeFile ? [input.activeFile] : [])]; + if (liveFiles.length === 0) return ''; + const hit = detectGhostCollision(input.workspaceDir || process.cwd(), liveFiles); + return hit ? formatGhostLine(hit) : ''; +} diff --git a/src/harness/mcpAccess.ts b/src/harness/mcpAccess.ts index 3f59511..54737ff 100644 --- a/src/harness/mcpAccess.ts +++ b/src/harness/mcpAccess.ts @@ -38,6 +38,9 @@ export function resolveMcpLaunch(workspaceDir: string): McpLaunch { if (fs.existsSync(localBin)) { return { command: 'node', args: ['dist/cli/index.js', 'mcp'] }; } + if (process.platform === 'win32') { + return { command: 'cmd', args: ['/c', 'npx', '-y', 'hypersabmemory', 'mcp'] }; + } return { command: 'npx', args: ['-y', 'hypersabmemory', 'mcp'] }; } diff --git a/src/hooks/autoCommit.ts b/src/hooks/autoCommit.ts index c220c10..7ba5fbf 100644 --- a/src/hooks/autoCommit.ts +++ b/src/hooks/autoCommit.ts @@ -5,6 +5,7 @@ import { execFile } from 'child_process'; import { promisify } from 'util'; +import * as fs from 'fs'; import * as path from 'path'; const execFileAsync = promisify(execFile); @@ -26,7 +27,7 @@ export interface WorkspaceSnapshot { recentDiff?: string; } -export type CheckpointStatus = 'committed' | 'clean' | 'not-a-repo' | 'failed'; +export type CheckpointStatus = 'committed' | 'clean' | 'not-a-repo' | 'operation-in-progress' | 'subdir-of-repo' | 'failed'; export interface CheckpointResult { committed: boolean; @@ -57,7 +58,7 @@ export class AutoCommitEngine { public start(): void { if (this.isRunning) return; this.isRunning = true; - console.log(`[HyperSABMemory :: AutoCommit] Engine active (Interval: ${this.intervalMs / 60000}m | Author: ${this.authorName} <${this.authorEmail}>)`); + console.error(`[HyperSABMemory :: AutoCommit] Engine active (Interval: ${this.intervalMs / 60000}m | Author: ${this.authorName} <${this.authorEmail}>)`); this.createCheckpoint(); this.timer = setInterval(() => { this.createCheckpoint(); @@ -84,20 +85,66 @@ export class AutoCommitEngine { public async createCheckpoint(customReason?: string): Promise { try { + let gitDirRaw: string; try { - await execFileAsync('git', ['rev-parse', '--git-dir'], { cwd: this.workspaceDir }); + ({ stdout: gitDirRaw } = await execFileAsync('git', ['rev-parse', '--git-dir'], { cwd: this.workspaceDir })); } catch { return { committed: false, status: 'not-a-repo' }; } + const gitDir = path.resolve(this.workspaceDir, gitDirRaw.trim()); - await this.configureGitIdentity(); + // Safety: staging mid-merge/rebase concludes the user's operation with conflict markers baked in. + if ( + fs.existsSync(path.join(gitDir, 'MERGE_HEAD')) || + fs.existsSync(path.join(gitDir, 'rebase-merge')) || + fs.existsSync(path.join(gitDir, 'rebase-apply')) + ) { + return { committed: false, status: 'operation-in-progress' }; + } + + // Safety: whole-tree add from a monorepo subdir sweeps teammates' WIP. + const normalize = (p: string): string => { + const resolved = path.resolve(p); + try { + // native resolves symlinks AND 8.3 short names (CI runners expose + // os.tmpdir() as C:\Users\RUNNER~1\... while git reports the long form). + return process.platform === 'win32' + ? fs.realpathSync.native(resolved) + : fs.realpathSync(resolved); + } catch { + return resolved; + } + }; + const { stdout: toplevelRaw } = await execFileAsync('git', ['rev-parse', '--show-toplevel'], { cwd: this.workspaceDir }); + const wsCanonical = normalize(this.workspaceDir); + const topCanonical = normalize(toplevelRaw.trim()); + const sameRoot = process.platform === 'win32' + ? wsCanonical.toLowerCase() === topCanonical.toLowerCase() + : wsCanonical === topCanonical; + if (!sameRoot) { + return { committed: false, status: 'subdir-of-repo' }; + } const { stdout: status } = await execFileAsync('git', ['status', '--porcelain'], { cwd: this.workspaceDir }); if (!status.trim()) { return { committed: false, status: 'clean' }; } + // Belt-and-braces: keep the memory store out of checkpoints even if ignore + // rules are bypassed. An exclude pathspec on add cannot be used here: naming + // the ignored dir trips advice.addIgnoredFile and git exits 1, aborting every + // checkpoint in contained workspaces. Instead: stage all, then restore the + // index to HEAD for store paths ONLY when a legacy tracked store exists + // (post-untrack stores must keep their deletion staged so setup commits it). await execFileAsync('git', ['add', '-A'], { cwd: this.workspaceDir }); + try { + const { stdout: trackedStore } = await execFileAsync('git', ['ls-files', '--', '.hypersabmemory'], { cwd: this.workspaceDir }); + if (trackedStore.trim()) { + await execFileAsync('git', ['reset', '-q', 'HEAD', '--', '.hypersabmemory'], { cwd: this.workspaceDir }); + } + } catch { + // Index inspection is best-effort; staging itself already succeeded. + } const fileFocus = this.workspaceDir ? path.basename(this.workspaceDir) : 'workspace'; const commitMessage = customReason @@ -107,7 +154,11 @@ export class AutoCommitEngine { const author = `${this.authorName} <${this.authorEmail}>`; await execFileAsync( 'git', - ['commit', `--author=${author}`, '-m', commitMessage, '--allow-empty'], + [ + '-c', `user.name=${this.authorName}`, + '-c', `user.email=${this.authorEmail}`, + 'commit', `--author=${author}`, '-m', commitMessage, '--allow-empty' + ], { cwd: this.workspaceDir } ); @@ -118,7 +169,7 @@ export class AutoCommitEngine { this.onCommit(hash, commitMessage); } - console.log(`[HyperSABMemory :: AutoCommit] Created checkpoint [${hash}] by ${this.authorName} -> ${commitMessage}`); + console.error(`[HyperSABMemory :: AutoCommit] Created checkpoint [${hash}] by ${this.authorName} -> ${commitMessage}`); return { committed: true, status: 'committed', hash }; } catch (err) { if (this.onError && err instanceof Error) { @@ -221,7 +272,7 @@ export class AutoCommitEngine { console.warn('[HyperSABMemory :: AutoCommit] Safety checkpoint failed — proceeding with rollback.', safetyErr); } await execFileAsync('git', ['reset', '--hard', hash], { cwd: this.workspaceDir }); - console.log(`[HyperSABMemory :: AutoCommit] Successfully rolled back to ${hash}`); + console.error(`[HyperSABMemory :: AutoCommit] Successfully rolled back to ${hash}`); return true; } catch (err) { console.error(`[HyperSABMemory :: AutoCommit] Failed rollback to ${hash}:`, err); diff --git a/src/hooks/fileWatcher.ts b/src/hooks/fileWatcher.ts index 02373e4..448c6e6 100644 --- a/src/hooks/fileWatcher.ts +++ b/src/hooks/fileWatcher.ts @@ -60,7 +60,13 @@ export class FileWatcherHook { '**/.git/**', '**/dist/**', '**/.hypersabmemory/**', - '**/Memory.md' + '**/Memory.md', + '**/.venv/**', + '**/target/**', + '**/build/**', + '**/out/**', + '**/coverage/**', + '**/vendor/**' ]; this.watcher = chokidar.watch(this.workspaceDir, { @@ -72,6 +78,10 @@ export class FileWatcherHook { typeof pattern === 'string' ? normalized.includes(pattern) : pattern.test(normalized) ); }, + awaitWriteFinish: { + stabilityThreshold: 400, + pollInterval: 100 + }, persistent: true, ignoreInitial: true }); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 049cb3b..2d8d40c 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -5,9 +5,12 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; import { CallToolRequestSchema, - ListToolsRequestSchema + ListToolsRequestSchema, + ListResourcesRequestSchema, + ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { TemporalMemoryEngine } from '../memory/temporalMemory.js'; @@ -136,7 +139,8 @@ export class HyperSABMemoryMcpServer { }, { capabilities: { - tools: {} + tools: {}, + resources: {} } } ); @@ -415,10 +419,52 @@ export class HyperSABMemoryMcpServer { return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true }; } }); + + // List exposed MCP resources (additive capability; tool responses unchanged) + this.server.setRequestHandler(ListResourcesRequestSchema, async () => { + return { + resources: [ + { + uri: 'hypersabmemory://context-block', + name: 'Context Block', + description: 'Token-packed Context Block of dated active facts (same content as the hypersabmemory_context_block tool).', + mimeType: 'text/plain' + }, + { + uri: 'hypersabmemory://memory-log', + name: 'Memory Log', + description: 'Persistent decision log (Memory.md) at the workspace root.', + mimeType: 'text/markdown' + } + ] + }; + }); + + this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => { const { uri } = request.params; + + if (uri === 'hypersabmemory://context-block') { + return { + contents: [{ uri, mimeType: 'text/plain', text: this.memory.getContextBlock() }] + }; + } + + if (uri === 'hypersabmemory://memory-log') { + const logPath = path.join(this.workspaceDir, 'Memory.md'); + const text = fs.existsSync(logPath) ? fs.readFileSync(logPath, 'utf-8') : ''; + return { + contents: [{ uri, mimeType: 'text/markdown', text }] + }; + } + + throw new Error(`Unknown resource: ${uri}`); + }); + } + + public connect(transport: Transport): Promise { + return this.server.connect(transport); } public async start(): Promise { - const transport = new StdioServerTransport(); - await this.server.connect(transport); + await this.connect(new StdioServerTransport()); } } diff --git a/src/memory/contextBlock.ts b/src/memory/contextBlock.ts index f83a23b..cccaa28 100644 --- a/src/memory/contextBlock.ts +++ b/src/memory/contextBlock.ts @@ -7,6 +7,7 @@ import { TokenCompressor } from '../harness/tokenCompressor.js'; import { MemoryRecord } from './sqliteStore.js'; +import { SEED_TAG } from './seeding.js'; const MAX_FACTS = 8; const BLOCK_TOKEN_BUDGET = 400; @@ -82,7 +83,8 @@ export function buildContextBlock(records: MemoryRecord[], projectSummary?: stri : ranked .map(({ record }) => { const day = record.timestamp.slice(0, 10); - return `- ${day} [${record.category}] ${record.content}${structuredSuffix(record)}`; + const seedMark = record.tags?.includes(SEED_TAG) ? '[seeded] ' : ''; + return `- ${day} [${record.category}] ${seedMark}${record.content}${structuredSuffix(record)}`; }) .join('\n'); diff --git a/src/memory/fileLock.ts b/src/memory/fileLock.ts new file mode 100644 index 0000000..fb24415 --- /dev/null +++ b/src/memory/fileLock.ts @@ -0,0 +1,117 @@ +/** + * HyperSABMemory — Synchronous cross-process file lock (CAS mutex). + * Zero-dependency, no open handles while waiting (Windows unlink requirement). + */ + +import * as fs from 'fs'; + +export class LockAcquireError extends Error { + public readonly lockPath: string; + + constructor(lockPath: string, budgetMs: number) { + super(`Could not acquire lock "${lockPath}" within ${budgetMs}ms budget`); + this.name = 'LockAcquireError'; + this.lockPath = lockPath; + } +} + +interface LockSentinel { + pid: number; + acquiredAt: number; +} + +const START_BACKOFF_MS = 5; +const MAX_BACKOFF_MS = 50; +const BUDGET_MS = 2000; +const UNPARSEABLE_GRACE_MS = 5000; + +function sleepSync(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function readSentinel(lockPath: string): LockSentinel | undefined { + try { + const parsed: unknown = JSON.parse(fs.readFileSync(lockPath, 'utf-8')); + if ( + typeof parsed === 'object' && parsed !== null && + typeof (parsed as LockSentinel).pid === 'number' && + typeof (parsed as LockSentinel).acquiredAt === 'number' + ) { + return parsed as LockSentinel; + } + return undefined; + } catch { + return undefined; + } +} + +function tryAcquire(lockPath: string): boolean { + const sentinel: LockSentinel = { pid: process.pid, acquiredAt: Date.now() }; + try { + fs.writeFileSync(lockPath, JSON.stringify(sentinel), { flag: 'wx' }); + return true; + } catch { + return false; + } +} + +function isHolderDead(lockPath: string): boolean { + const sentinel = readSentinel(lockPath); + if (sentinel === undefined) { + // Unparseable/garbage sentinel: only evict once it is clearly abandoned, + // so we never unlink under a live writer mid-write. + try { + return Date.now() - fs.statSync(lockPath).mtimeMs > UNPARSEABLE_GRACE_MS; + } catch { + return false; // vanished; next spin just acquires + } + } + if (sentinel.pid === process.pid) return true; // engines are recreated constantly in tests + try { + process.kill(sentinel.pid, 0); + return false; // holder alive + } catch (err) { + return (err as NodeJS.ErrnoException).code === 'ESRCH'; + } +} + +function evict(lockPath: string): void { + try { + fs.unlinkSync(lockPath); + } catch { + // already gone or momentarily held; the acquire loop is deadline-bounded + } +} + +export function acquireFileLock(lockPath: string, budgetMs: number = BUDGET_MS): void { + const deadline = Date.now() + budgetMs; + let backoffMs = START_BACKOFF_MS; + + while (true) { + if (tryAcquire(lockPath)) return; + if (Date.now() >= deadline) throw new LockAcquireError(lockPath, budgetMs); + if (isHolderDead(lockPath)) { + evict(lockPath); + continue; // immediate re-acquire attempt after eviction + } + sleepSync(Math.min(backoffMs, MAX_BACKOFF_MS)); + backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS); + } +} + +export function releaseFileLock(lockPath: string): void { + try { + fs.unlinkSync(lockPath); + } catch { + // best-effort: ENOENT means another holder's release/eviction won the race + } +} + +export function withFileLock(lockPath: string, fn: () => T): T { + acquireFileLock(lockPath); + try { + return fn(); + } finally { + releaseFileLock(lockPath); + } +} diff --git a/src/memory/seeding.ts b/src/memory/seeding.ts new file mode 100644 index 0000000..90a0a0b --- /dev/null +++ b/src/memory/seeding.ts @@ -0,0 +1,159 @@ +/** + * HyperSABMemory — Cold-Start Bootstrap Seeding (F1) + * Derives first-boot facts from the workspace itself (README intro, package.json + * stack, top-level layout, recent git subjects) and writes them straight through + * SqliteStore.set()'s CAS path — bypassing remember() so seeds are never deduped + * or superseded at write time. Seed ids are deterministic, so re-seeding is inert. + */ + +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import * as fs from 'fs'; +import * as path from 'path'; +import { SqliteStore, MemoryRecord } from './sqliteStore.js'; + +const execFileAsync = promisify(execFile); + +/** Tag marking bootstrap facts; also gates the never-supersede scan in remember(). */ +export const SEED_TAG = 'seeded'; + +type SeedCategory = 'architecture' | 'general'; + +interface SeedDraft { + id: string; + category: SeedCategory; + content: string; +} + +export type SeedRecord = Omit & { timestamp?: string }; + +const CONTENT_CLIP = 240; + +function clip(text: string): string { + const flat = text.replace(/\s+/g, ' ').trim(); + return flat.length > CONTENT_CLIP ? `${flat.slice(0, CONTENT_CLIP)}…` : flat; +} + +function draft(id: string, category: SeedCategory, content: string): SeedDraft { + return { id, category, content }; +} + +function toSeedRecord(seedDraft: SeedDraft): SeedRecord { + return { ...seedDraft, tags: [SEED_TAG], source: 'seed' }; +} + +function readReadmeIntro(workspaceDir: string): string | null { + for (const name of ['README.md', 'readme.md', 'Readme.md']) { + const filePath = path.join(workspaceDir, name); + if (!fs.existsSync(filePath)) continue; + const lines = fs.readFileSync(filePath, 'utf-8').split(/\r?\n/); + const heading = lines.find((l) => /^#{1,6}\s+\S/.test(l))?.replace(/^#{1,6}\s+/, '').trim(); + const body = lines + .map((l) => l.trim()) + .find((l) => l.length > 0 && !l.startsWith('#') && !l.startsWith('<') && !l.startsWith('![')); + const parts = [heading, body].filter((s): s is string => Boolean(s)); + return parts.length > 0 ? clip(parts.join(' — ')) : null; + } + return null; +} + +function readPackageFacts(workspaceDir: string): { project: string | null; stack: string | null } { + const filePath = path.join(workspaceDir, 'package.json'); + if (!fs.existsSync(filePath)) return { project: null, stack: null }; + try { + const pkg = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as { + name?: string; + description?: string; + type?: string; + bin?: unknown; + dependencies?: Record; + devDependencies?: Record; + }; + const depCount = Object.keys(pkg.dependencies ?? {}).length; + const language = + pkg.devDependencies?.typescript || fs.existsSync(path.join(workspaceDir, 'tsconfig.json')) + ? 'TypeScript' + : 'JavaScript'; + const moduleKind = pkg.type === 'module' ? 'ESM' : 'CommonJS'; + const kind = pkg.bin ? ' CLI' : ''; + const project = pkg.name ? clip([pkg.name, pkg.description].filter(Boolean).join(' — ')) : null; + return { project, stack: `${language} ${moduleKind}${kind}, ${depCount} runtime deps` }; + } catch { + return { project: null, stack: null }; + } +} + +function layoutDraft( + workspaceDir: string, + id: string, + role: string, + candidates: string[] +): SeedDraft | null { + const dirName = candidates.find((c) => { + const p = path.join(workspaceDir, c); + return fs.existsSync(p) && fs.statSync(p).isDirectory(); + }); + return dirName ? draft(id, 'general', `Top-level ${dirName}/ directory present (${role})`) : null; +} + +async function gitSubjectDigest(workspaceDir: string): Promise { + try { + const { stdout } = await execFileAsync('git', ['log', '-5', '--pretty=%s'], { cwd: workspaceDir }); + const subjects = stdout.split(/\r?\n/).filter(Boolean); + return subjects.length > 0 ? clip(subjects.join(' | ')) : null; + } catch { + return null; + } +} + +export async function deriveBootstrapFacts(workspaceDir: string): Promise { + const drafts: SeedDraft[] = []; + const pkg = readPackageFacts(workspaceDir); + if (pkg.project) drafts.push(draft('seed_project', 'architecture', `Project: ${pkg.project}`)); + if (pkg.stack) drafts.push(draft('seed_stack', 'architecture', `Stack: ${pkg.stack}`)); + + const readme = readReadmeIntro(workspaceDir); + if (readme) drafts.push(draft('seed_readme', 'architecture', `README: ${readme}`)); + + const layoutSpecs: Array<[string, string, string[]]> = [ + ['seed_dir_src', 'implementation code', ['src', 'lib']], + ['seed_dir_tests', 'tests', ['tests', 'test', '__tests__']], + ['seed_dir_docs', 'documentation', ['docs', 'doc']] + ]; + for (const [id, role, candidates] of layoutSpecs) { + const layout = layoutDraft(workspaceDir, id, role, candidates); + if (layout) drafts.push(layout); + } + + const gitDigest = await gitSubjectDigest(workspaceDir); + if (gitDigest) drafts.push(draft('seed_git', 'general', `Recent commits: ${gitDigest}`)); + + return drafts.map(toSeedRecord); +} + +export async function seedWorkspace( + workspaceDir: string, + store: SqliteStore = new SqliteStore(path.join(workspaceDir, '.hypersabmemory')) +): Promise<{ seeded: number; total: number }> { + let seeded = 0; + for (const fact of await deriveBootstrapFacts(workspaceDir)) { + if (store.get(fact.id)) continue; + store.set(fact); + seeded++; + } + const total = store.getAll().filter((r) => r.source === 'seed').length; + return { seeded, total }; +} + +export function writeGenesisRecord( + workspaceDir: string, + store: SqliteStore = new SqliteStore(path.join(workspaceDir, '.hypersabmemory')) +): MemoryRecord { + return store.set({ + id: 'seed_genesis', + category: 'decision', + content: `GENESIS: workspace memory initialized ${new Date().toISOString().slice(0, 10)}`, + tags: [SEED_TAG, 'genesis'], + source: 'seed' + }); +} diff --git a/src/memory/sqliteStore.ts b/src/memory/sqliteStore.ts index 012ce60..37d2f79 100644 --- a/src/memory/sqliteStore.ts +++ b/src/memory/sqliteStore.ts @@ -5,6 +5,7 @@ import * as fs from 'fs'; import * as path from 'path'; +import { withFileLock } from './fileLock.js'; export type MemoryStatus = 'active' | 'forgotten' | 'superseded'; @@ -24,6 +25,7 @@ export interface MemoryRecord extends StructuredMemory { status?: MemoryStatus; invalidAt?: string; supersedes?: string; + source?: 'seed' | 'auto' | 'user'; } export class SqliteStore { @@ -42,7 +44,11 @@ export class SqliteStore { } } - private loadStore(): void { + private get lockPath(): string { + return `${this.storagePath}.lock`; + } + + private loadStore(warnWhenMissing: boolean = true): void { const tmpPath = `${this.storagePath}.tmp`; const bakPath = `${this.storagePath}.bak`; @@ -62,8 +68,10 @@ export class SqliteStore { return; } - console.warn('[HyperSABMemory :: SqliteStore] No readable memory store found — starting clean.'); this.memoryMap.clear(); + if (warnWhenMissing) { + console.warn('[HyperSABMemory :: SqliteStore] No readable memory store found — starting clean.'); + } } private tryLoadFrom(file: string): boolean { @@ -88,20 +96,14 @@ export class SqliteStore { fs.writeFileSync(tmpPath, payload, 'utf-8'); try { - try { - fs.renameSync(tmpPath, this.storagePath); - } catch (renameErr) { - const code = (renameErr as NodeJS.ErrnoException).code; - if (code === 'EPERM' || code === 'EBUSY') { - fs.renameSync(tmpPath, this.storagePath); // single retry (Win AV locks) - } else { - throw renameErr; - } + fs.renameSync(tmpPath, this.storagePath); + } catch (renameErr) { + const code = (renameErr as NodeJS.ErrnoException).code; + if (code === 'EPERM' || code === 'EBUSY') { + fs.renameSync(tmpPath, this.storagePath); // single retry (Win AV locks) + } else { + throw renameErr; // payload stays in .tmp for loadStore recovery } - } catch { - // Non-atomic fallback rather than losing the save entirely. - fs.writeFileSync(this.storagePath, payload, 'utf-8'); - console.warn('[HyperSABMemory :: SqliteStore] Atomic rename failed — wrote store directly.'); } try { @@ -115,6 +117,13 @@ export class SqliteStore { } public set(record: Omit & { timestamp?: string }): MemoryRecord { + return withFileLock(this.lockPath, () => { + this.loadStore(false); + return this.applySetUnderLock(record); + }); + } + + private applySetUnderLock(record: Omit & { timestamp?: string }): MemoryRecord { const fullRecord: MemoryRecord = { ...record, status: record.status ?? 'active', @@ -147,28 +156,37 @@ export class SqliteStore { } public forget(id: string): MemoryRecord | undefined { - const existing = this.memoryMap.get(id); - if (!existing) return undefined; - return this.set({ - ...existing, - status: 'forgotten', - invalidAt: new Date().toISOString() + return withFileLock(this.lockPath, () => { + this.loadStore(false); + const existing = this.memoryMap.get(id); + if (!existing) return undefined; + return this.applySetUnderLock({ + ...existing, + status: 'forgotten', + invalidAt: new Date().toISOString() + }); }); } public supersede(id: string, successorId: string): MemoryRecord | undefined { - const existing = this.memoryMap.get(id); - if (!existing) return undefined; - return this.set({ - ...existing, - status: 'superseded', - invalidAt: new Date().toISOString(), - tags: [...(existing.tags ?? []), `superseded-by:${successorId}`] + return withFileLock(this.lockPath, () => { + this.loadStore(false); + const existing = this.memoryMap.get(id); + if (!existing) return undefined; + return this.applySetUnderLock({ + ...existing, + status: 'superseded', + invalidAt: new Date().toISOString(), + tags: [...(existing.tags ?? []), `superseded-by:${successorId}`] + }); }); } public clear(): void { - this.memoryMap.clear(); - this.saveStore(); + withFileLock(this.lockPath, () => { + this.loadStore(false); + this.memoryMap.clear(); + this.saveStore(); + }); } } diff --git a/src/memory/swarmMesh.ts b/src/memory/swarmMesh.ts index 4ca8532..4fe6be0 100644 --- a/src/memory/swarmMesh.ts +++ b/src/memory/swarmMesh.ts @@ -6,6 +6,7 @@ import * as fs from 'fs'; import * as path from 'path'; +import { withFileLock } from './fileLock.js'; export interface SwarmBroadcast { id: string; @@ -82,9 +83,38 @@ export class SwarmMeshEngine { return { broadcasts: [], fileLocks: [], sessions: [] }; } + private get lockPath(): string { + return `${this.swarmStatePath}.lock`; + } + + private canonicalKey(filePath: string): string { + const normalized = path.normalize(filePath); + return process.platform === 'win32' ? normalized.toLowerCase() : normalized; + } + + private mutate(fn: () => T): T { + return withFileLock(this.lockPath, () => { + this.state = this.loadState(); + return fn(); + }); + } + private saveState(): void { try { - fs.writeFileSync(this.swarmStatePath, JSON.stringify(this.state, null, 2), 'utf-8'); + const payload = JSON.stringify(this.state, null, 2); + const tmpPath = `${this.swarmStatePath}.tmp`; + + fs.writeFileSync(tmpPath, payload, 'utf-8'); + try { + fs.renameSync(tmpPath, this.swarmStatePath); + } catch (renameErr) { + const code = (renameErr as NodeJS.ErrnoException).code; + if (code === 'EPERM' || code === 'EBUSY') { + fs.renameSync(tmpPath, this.swarmStatePath); // single retry (Win AV locks) + } else { + throw renameErr; // payload stays in .tmp + } + } } catch (err) { console.error('[HyperSABMemory :: SwarmMesh] Failed to write swarm state:', err); } @@ -94,6 +124,10 @@ export class SwarmMeshEngine { const cutoff = this.now().getTime(); this.state.broadcasts = this.state.broadcasts.filter((b) => new Date(b.expiresAt).getTime() > cutoff); this.state.fileLocks = this.state.fileLocks.filter((l) => new Date(l.expiresAt).getTime() > cutoff); + const liveSessions = new Set(); + for (const b of this.state.broadcasts) liveSessions.add(b.sessionId); + for (const l of this.state.fileLocks) liveSessions.add(l.sessionId); + this.state.sessions = this.state.sessions.filter((s) => liveSessions.has(s)); } private registerSession(sessionId: string): void { @@ -107,88 +141,110 @@ export class SwarmMeshEngine { } public getOrCreateSessionId(sessionId?: string): string { - const id = sessionId?.trim() || randomId('swarm'); - this.registerSession(id); - this.saveState(); - return id; + return sessionId?.trim() || randomId('swarm'); } public broadcast(sessionId: string | undefined, message: string, category?: string): SwarmBroadcast { - const id = this.getOrCreateSessionId(sessionId); - const trimmed = message.trim(); - const nowMs = this.now().getTime(); - const expiresAt = this.toIso(nowMs + this.broadcastTtlMs); - - const existing = this.state.broadcasts.find( - (b) => b.message === trimmed && (b.category ?? undefined) === (category?.trim() || undefined) && new Date(b.expiresAt).getTime() > nowMs - ); - if (existing) return existing; - - const record: SwarmBroadcast = { - id: randomId('brc'), - sessionId: id, - message: trimmed, - category: category?.trim() || undefined, - createdAt: new Date(nowMs).toISOString(), - expiresAt - }; - this.state.broadcasts.push(record); - this.saveState(); - return record; + return this.mutate(() => { + this.pruneExpired(); + const id = this.getOrCreateSessionId(sessionId); + this.registerSession(id); + const trimmed = message.trim(); + const nowMs = this.now().getTime(); + const expiresAt = this.toIso(nowMs + this.broadcastTtlMs); + + const existing = this.state.broadcasts.find( + (b) => b.message === trimmed && (b.category ?? undefined) === (category?.trim() || undefined) && new Date(b.expiresAt).getTime() > nowMs + ); + if (existing) { + this.saveState(); + return existing; + } + + const record: SwarmBroadcast = { + id: randomId('brc'), + sessionId: id, + message: trimmed, + category: category?.trim() || undefined, + createdAt: new Date(nowMs).toISOString(), + expiresAt + }; + this.state.broadcasts.push(record); + this.saveState(); + return record; + }); } public claimFile(sessionId: string | undefined, filePath: string, ttlMs?: number): ClaimResult { - const id = this.getOrCreateSessionId(sessionId); - const nowMs = this.now().getTime(); - const expiresAt = this.toIso(nowMs + (ttlMs ?? this.lockTtlMs)); - const normalized = path.normalize(filePath); - - const existing = this.state.fileLocks.find((l) => l.filePath === normalized); - if (existing) { - if (existing.sessionId === id) { - existing.acquiredAt = new Date(nowMs).toISOString(); - existing.expiresAt = expiresAt; - this.saveState(); - return { ok: true, lock: existing }; - } - if (new Date(existing.expiresAt).getTime() > nowMs) { - return { ok: false, lock: existing, heldBy: existing.sessionId, reason: 'locked' }; + return this.mutate(() => { + this.pruneExpired(); + const id = this.getOrCreateSessionId(sessionId); + this.registerSession(id); + const nowMs = this.now().getTime(); + const expiresAt = this.toIso(nowMs + (ttlMs ?? this.lockTtlMs)); + const normalized = this.canonicalKey(filePath); + + const existing = this.state.fileLocks.find((l) => l.filePath === normalized); + if (existing) { + if (existing.sessionId === id) { + existing.acquiredAt = new Date(nowMs).toISOString(); + existing.expiresAt = expiresAt; + this.saveState(); + return { ok: true, lock: existing }; + } + if (new Date(existing.expiresAt).getTime() > nowMs) { + this.saveState(); + return { ok: false, lock: existing, heldBy: existing.sessionId, reason: 'locked' }; + } + this.state.fileLocks = this.state.fileLocks.filter((l) => l.filePath !== normalized); } - this.state.fileLocks = this.state.fileLocks.filter((l) => l.filePath !== normalized); - } - const lock: SwarmFileLock = { - filePath: normalized, - sessionId: id, - acquiredAt: new Date(nowMs).toISOString(), - expiresAt - }; - this.state.fileLocks.push(lock); - this.saveState(); - return { ok: true, lock }; + const lock: SwarmFileLock = { + filePath: normalized, + sessionId: id, + acquiredAt: new Date(nowMs).toISOString(), + expiresAt + }; + this.state.fileLocks.push(lock); + this.saveState(); + return { ok: true, lock }; + }); } public releaseFile(sessionId: string | undefined, filePath: string): ClaimResult { - const id = this.getOrCreateSessionId(sessionId); - const normalized = path.normalize(filePath); - const existing = this.state.fileLocks.find((l) => l.filePath === normalized); - if (!existing) return { ok: true, reason: 'not-locked' }; - if (existing.sessionId !== id) { - return { ok: false, heldBy: existing.sessionId, reason: 'not-owner' }; - } - this.state.fileLocks = this.state.fileLocks.filter((l) => l.filePath !== normalized); - this.saveState(); - return { ok: true }; + return this.mutate(() => { + this.pruneExpired(); + const id = this.getOrCreateSessionId(sessionId); + this.registerSession(id); + const normalized = this.canonicalKey(filePath); + const existing = this.state.fileLocks.find((l) => l.filePath === normalized); + if (!existing) { + this.saveState(); + return { ok: true, reason: 'not-locked' }; + } + if (existing.sessionId !== id) { + this.saveState(); + return { ok: false, heldBy: existing.sessionId, reason: 'not-owner' }; + } + this.state.fileLocks = this.state.fileLocks.filter((l) => l.filePath !== normalized); + this.saveState(); + return { ok: true }; + }); } public sync(sessionId?: string): { sessionId: string; broadcasts: SwarmBroadcast[]; fileLocks: SwarmFileLock[] } { - const id = this.getOrCreateSessionId(sessionId); - this.pruneExpired(); - this.saveState(); + // Read-only: atomic tmp+rename writes mean a plain read never sees torn + // state, so no lock is taken and nothing is persisted. Session ids are + // registered lazily on the next true mutation instead. + const id = sessionId?.trim() || randomId('swarm'); + const state = this.loadState(); + const cutoff = this.now().getTime(); return { sessionId: id, - broadcasts: [...this.state.broadcasts].sort((a, b) => b.createdAt.localeCompare(a.createdAt)), - fileLocks: [...this.state.fileLocks] + broadcasts: state.broadcasts + .filter((b) => new Date(b.expiresAt).getTime() > cutoff) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)), + fileLocks: state.fileLocks.filter((l) => new Date(l.expiresAt).getTime() > cutoff) }; } } \ No newline at end of file diff --git a/src/memory/temporalMemory.ts b/src/memory/temporalMemory.ts index cbaa436..261986c 100644 --- a/src/memory/temporalMemory.ts +++ b/src/memory/temporalMemory.ts @@ -8,6 +8,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { buildContextBlock } from './contextBlock.js'; import { SqliteStore, MemoryRecord, StructuredMemory } from './sqliteStore.js'; +import { SEED_TAG } from './seeding.js'; export class TemporalMemoryEngine { private store: SqliteStore; @@ -45,11 +46,26 @@ export class TemporalMemoryEngine { ...(structured?.nextSteps ? { nextSteps: structured.nextSteps } : {}) }); + // Perf: SqliteStore's single-record mutators each rewrite the whole store, + // so flip all duplicates in memory and persist once instead of calling + // supersede() per record (which would cost one full save each). + const toSupersede: MemoryRecord[] = []; for (const existing of this.store.getActive()) { if (existing.id === record.id) continue; if (existing.category !== category) continue; + if ((existing.tags ?? []).includes(SEED_TAG)) continue; if (!this.isDuplicateOrContradiction(existing.content, content)) continue; - this.store.supersede(existing.id, record.id); + toSupersede.push(existing); + } + + if (toSupersede.length > 0) { + const now = new Date().toISOString(); + for (const existing of toSupersede) { + existing.status = 'superseded'; + existing.invalidAt = now; + existing.tags = [...(existing.tags ?? []), `superseded-by:${record.id}`]; + } + this.store.saveStore(); } this.syncToMemoryFile(); diff --git a/src/server/dashboardServer.ts b/src/server/dashboardServer.ts index c4cd4ad..7ddfa58 100644 --- a/src/server/dashboardServer.ts +++ b/src/server/dashboardServer.ts @@ -11,6 +11,7 @@ import { TemporalMemoryEngine } from '../memory/temporalMemory.js'; import { StructuralGraphEngine } from '../memory/structuralGraph.js'; import { AutoCommitEngine } from '../hooks/autoCommit.js'; import { HybridSearchEngine } from '../memory/hybridSearch.js'; +import { SwarmMeshEngine } from '../memory/swarmMesh.js'; import { ContextHarness } from '../harness/contextHarness.js'; import { TokenCompressor } from '../harness/tokenCompressor.js'; @@ -18,16 +19,19 @@ const TOKEN_CEILING = 1500; export class DashboardServer { private port: number; + private boundPort: number; private workspaceDir: string; private server: http.Server | null = null; private memory: TemporalMemoryEngine; private graph: StructuralGraphEngine; private autoCommit: AutoCommitEngine; private hybridSearch: HybridSearchEngine; + private swarm: SwarmMeshEngine; private harness: ContextHarness; constructor(port: number = 4321, workspaceDir: string = process.cwd()) { this.port = port; + this.boundPort = port; this.workspaceDir = workspaceDir; this.memory = new TemporalMemoryEngine(workspaceDir); this.graph = new StructuralGraphEngine(workspaceDir); @@ -35,6 +39,7 @@ export class DashboardServer { workspaceDir }); this.hybridSearch = new HybridSearchEngine(workspaceDir, this.memory, this.graph); + this.swarm = new SwarmMeshEngine(workspaceDir); this.harness = new ContextHarness(TOKEN_CEILING); } @@ -64,6 +69,27 @@ export class DashboardServer { }); } + /** + * Guards state-changing POSTs against CSRF and DNS rebinding. Only loopback + * Host names pass (defeats rebinding to 127.0.0.1), and requiring both a + * JSON content-type and a custom header forces a CORS preflight that + * cross-origin no-cors requests cannot pass (defeats drive-by POSTs). + */ + private guardStateChange(req: http.IncomingMessage): { status: number; error: string } | null { + const host = req.headers.host || ''; + if (host !== `127.0.0.1:${this.boundPort}` && host !== `localhost:${this.boundPort}`) { + return { status: 403, error: 'Forbidden host' }; + } + const contentType = String(req.headers['content-type'] || '').toLowerCase(); + if (!contentType.includes('application/json')) { + return { status: 415, error: 'Content-Type must be application/json' }; + } + if (!req.headers['x-hypersabmemory']) { + return { status: 403, error: 'Missing x-hypersabmemory header' }; + } + return null; + } + /** * Starts the HTTP dashboard server. Resolves with the bound port. */ @@ -82,6 +108,16 @@ export class DashboardServer { return; } + // State-changing POSTs must pass the CSRF/DNS-rebinding guard first. + if (req.method === 'POST') { + const rejected = this.guardStateChange(req); + if (rejected) { + res.writeHead(rejected.status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: rejected.error })); + return; + } + } + // Endpoint: /api/status if (url.pathname === '/api/status') { res.writeHead(200, { 'Content-Type': 'application/json' }); @@ -137,6 +173,14 @@ export class DashboardServer { return; } + // Endpoint: /api/swarm (read-only snapshot; sync() persists nothing) + if (url.pathname === '/api/swarm') { + const snapshot = this.swarm.sync(); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ fileLocks: snapshot.fileLocks, broadcasts: snapshot.broadcasts })); + return; + } + // Endpoint: POST /api/rollback (guarded; bound to 127.0.0.1 only) if (url.pathname === '/api/rollback' && req.method === 'POST') { const body = await this.readBody(req); @@ -415,6 +459,13 @@ export class DashboardServer {
  • fetching checkpoints…
+
+
+ Swarm Mesh + syncing… +
+
  • fetching swarm…
+
`); @@ -525,6 +607,7 @@ export class DashboardServer { this.server.listen(this.port, '127.0.0.1', () => { const addr = this.server?.address(); const boundPort = typeof addr === 'object' && addr ? addr.port : this.port; + this.boundPort = boundPort; console.log(`[HyperSABMemory :: Dashboard] Server running at http://127.0.0.1:${boundPort}`); resolve(boundPort); }); diff --git a/tests/autoCommit.test.ts b/tests/autoCommit.test.ts index b0a3c6f..ed9d64a 100644 --- a/tests/autoCommit.test.ts +++ b/tests/autoCommit.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { AutoCommitEngine } from '../src/hooks/autoCommit.js'; -import { execSync } from 'child_process'; +import { execSync, execFileSync } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -9,18 +9,26 @@ function run(dir: string, command: string): string { return execSync(command, { cwd: dir, encoding: 'utf-8' }); } -function initRepo(): string { +// Shell-less git call: needed for formats containing % (cmd.exe mangles them). +function runGit(dir: string, args: string[]): string { + return execFileSync('git', args, { cwd: dir, encoding: 'utf-8' }); +} + +function initRepo(withIdentity: boolean = true): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hm-ac-')); run(dir, 'git init'); - run(dir, 'git config user.email test@example.com'); - run(dir, 'git config user.name test'); + if (withIdentity) { + run(dir, 'git config user.email test@example.com'); + run(dir, 'git config user.name test'); + } return dir; } function commitFile(dir: string, name: string, content: string, message: string): void { fs.writeFileSync(path.join(dir, name), content, 'utf-8'); run(dir, 'git add -A'); - run(dir, `git commit -m "${message}"`); + // Per-invocation identity: fixtures must commit even with no git identity configured (CI). + run(dir, `git -c user.name=seed -c user.email=seed@example.com commit -m "${message}"`); } function shortHead(dir: string): string { @@ -126,3 +134,115 @@ describe('HyperSABMemory — Safe Rollback + History + Diff (P11)', () => { expect(diff!.stat).toContain('a.txt'); }); }); + +describe('HyperSABMemory — Checkpoint hygiene (v1.4.0 repair floor)', () => { + function localConfig(dir: string, key: string): string | undefined { + try { + return run(dir, `git config --local ${key}`).trim(); + } catch { + return undefined; + } + } + + it('B1: checkpoint leaves repo-local git identity unset and signs via per-invocation -c flags', async () => { + const dir = initRepo(false); + dirs.push(dir); + commitFile(dir, 'a.txt', 'v1', 'first'); + + fs.writeFileSync(path.join(dir, 'b.txt'), 'dirty', 'utf-8'); + const result = await new AutoCommitEngine({ workspaceDir: dir }).createCheckpoint(); + + expect(result.committed).toBe(true); + expect(localConfig(dir, 'user.name')).toBeUndefined(); + expect(localConfig(dir, 'user.email')).toBeUndefined(); + expect(runGit(dir, ['log', '-1', '--format=%cn|%ce']).trim()).toBe( + 'hypersabmemory-bot|hypersabmemory@users.noreply.github.com' + ); + }); + + it('B3: checkpoint logging never writes to stdio stdout (MCP JSON-RPC safety)', async () => { + const dir = initRepo(); + dirs.push(dir); + commitFile(dir, 'a.txt', 'v1', 'first'); + fs.writeFileSync(path.join(dir, 'b.txt'), 'dirty', 'utf-8'); + + // Vitest intercepts console.* before raw streams, so assert both layers (raw stdout = production invariant). + const outSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const result = await new AutoCommitEngine({ workspaceDir: dir }).createCheckpoint(); + + expect(result.committed).toBe(true); + expect(outSpy).not.toHaveBeenCalled(); + expect(logSpy).not.toHaveBeenCalled(); + expect(errorSpy.mock.calls.some((c) => String(c[0]).includes('Created checkpoint'))).toBe(true); + }); + + it('B5: refuses to checkpoint while a merge is in progress (MERGE_HEAD present)', async () => { + const dir = initRepo(); + dirs.push(dir); + commitFile(dir, 'a.txt', 'v1', 'first'); + const before = shortHead(dir); + + fs.writeFileSync(path.join(dir, '.git', 'MERGE_HEAD'), '1234567890abcdef1234567890abcdef12345678', 'utf-8'); + fs.writeFileSync(path.join(dir, 'conflicted.txt'), '<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> other\n', 'utf-8'); + + const result = await new AutoCommitEngine({ workspaceDir: dir }).createCheckpoint(); + + expect(result.committed).toBe(false); + expect(result.status).toBe('operation-in-progress'); + expect(shortHead(dir)).toBe(before); + }); + + it('B5: refuses to sweep the whole tree when workspace is a subdir of the repo', async () => { + const dir = initRepo(); + dirs.push(dir); + commitFile(dir, 'a.txt', 'v1', 'first'); + const before = shortHead(dir); + + const sub = path.join(dir, 'packages', 'app'); + fs.mkdirSync(sub, { recursive: true }); + fs.writeFileSync(path.join(sub, 'wip.ts'), 'teammate wip', 'utf-8'); + + const result = await new AutoCommitEngine({ workspaceDir: sub }).createCheckpoint(); + + expect(result.committed).toBe(false); + expect(result.status).toBe('subdir-of-repo'); + expect(shortHead(dir)).toBe(before); + }); + + it('B5: same repo root spelled with different case or separators is not a subdir', async () => { + const dir = initRepo(); + dirs.push(dir); + commitFile(dir, 'a.txt', 'v1', 'first'); + fs.writeFileSync(path.join(dir, 'b.txt'), 'v2', 'utf-8'); + + const variant = process.platform === 'win32' + ? dir.replace(/\\/g, '/').toLowerCase() + : path.join(dir, 'nested', '..'); + + const result = await new AutoCommitEngine({ workspaceDir: variant }).createCheckpoint(); + + expect(result.status).not.toBe('subdir-of-repo'); + expect(result.committed).toBe(true); + }); + + it('B2: tracked .hypersabmemory files are never staged into checkpoints', async () => { + const dir = initRepo(); + dirs.push(dir); + commitFile(dir, 'a.txt', 'v1', 'first'); + fs.mkdirSync(path.join(dir, '.hypersabmemory'), { recursive: true }); + commitFile(dir, path.join('.hypersabmemory', 'memory.json'), '{"facts":[]}', 'seed memory store'); + + fs.writeFileSync(path.join(dir, '.hypersabmemory', 'memory.json'), '{"facts":["x"]}', 'utf-8'); + fs.writeFileSync(path.join(dir, 'a.txt'), 'v2', 'utf-8'); + + const result = await new AutoCommitEngine({ workspaceDir: dir }).createCheckpoint(); + + expect(result.committed).toBe(true); + const changed = runGit(dir, ['show', '--name-only', '--format=', 'HEAD']); + expect(changed).toContain('a.txt'); + expect(changed).not.toContain('.hypersabmemory'); + }); +}); diff --git a/tests/autoCommitGuards.test.ts b/tests/autoCommitGuards.test.ts new file mode 100644 index 0000000..a6a51f0 --- /dev/null +++ b/tests/autoCommitGuards.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi } from 'vitest'; + +/** + * Argv-level guard tests for AutoCommitEngine. + * child_process.execFile is mocked here so we can assert the EXACT git argv + * the engine issues (real-git functional tests live in autoCommit.test.ts). + */ + +const { calls } = vi.hoisted(() => ({ + calls: [] as Array<{ file: string; args: string[] }> +})); + +vi.mock('child_process', () => ({ + execFile: ( + file: string, + args: string[], + _opts: unknown, + cb: (err: unknown, out: { stdout: string; stderr: string }) => void + ) => { + let stdout = ''; + if (args[0] === 'status') stdout = ' M tracked.txt\n'; + else if (args.includes('--short')) stdout = 'abc1234\n'; + else if (args.includes('--git-dir')) stdout = '.git\n'; + else if (args.includes('--show-toplevel')) stdout = 'X:\\does-not-matter\n'; + calls.push({ file, args: [...args] }); + cb(null, { stdout, stderr: '' }); + } +})); + +// eslint-disable-next-line import/first +import { AutoCommitEngine } from '../src/hooks/autoCommit.js'; + +describe('AutoCommitEngine — git argv guards', () => { + it('B1: never runs `git config`; signs the commit via leading -c identity flags, keeps --author', async () => { + calls.length = 0; + const engine = new AutoCommitEngine({ workspaceDir: 'X:\\does-not-matter' }); + const result = await engine.createCheckpoint(); + + expect(result.status).toBe('committed'); + + // Identity hijack must be gone: no repo-local `git config user.*` writes. + expect(calls.some((c) => c.args[0] === 'config')).toBe(false); + + // Commit carries bot identity per-invocation via -c pairs (leading args). + const commit = calls.find((c) => c.args.includes('commit')); + expect(commit).toBeDefined(); + expect(commit!.args.slice(0, 4)).toEqual([ + '-c', + 'user.name=hypersabmemory-bot', + '-c', + 'user.email=hypersabmemory@users.noreply.github.com' + ]); + + // Existing --author flag stays untouched. + expect(commit!.args.some((a) => a.startsWith('--author='))).toBe(true); + }); + + it('B2: staging never leaves .hypersabmemory changes in checkpoints (add -A, then tracked-store reset)', async () => { + calls.length = 0; + const engine = new AutoCommitEngine({ workspaceDir: 'X:\\does-not-matter' }); + await engine.createCheckpoint(); + + const add = calls.find((c) => c.args[0] === 'add'); + expect(add).toBeDefined(); + expect(add!.args).toEqual(['add', '-A']); + + // Legacy tracked stores are detected via ls-files and reset to HEAD so no + // store change can enter the commit; untracked stores leave no trace at all. + const inspect = calls.find((c) => c.args[0] === 'ls-files' && c.args.includes('.hypersabmemory')); + expect(inspect).toBeDefined(); + + const inspectIndex = calls.indexOf(inspect!); + const commitIndex = calls.findIndex((c) => c.args.includes('commit')); + expect(inspectIndex).toBeGreaterThan(calls.indexOf(add!)); + expect(inspectIndex).toBeLessThan(commitIndex); + }); +}); diff --git a/tests/concurrency.test.ts b/tests/concurrency.test.ts new file mode 100644 index 0000000..f30f7e8 --- /dev/null +++ b/tests/concurrency.test.ts @@ -0,0 +1,221 @@ +import { describe, it, expect, beforeEach, afterEach, beforeAll, vi } from 'vitest'; +import { SqliteStore } from '../src/memory/sqliteStore.js'; +import { SwarmMeshEngine } from '../src/memory/swarmMesh.js'; +import { spawn, execSync, type ChildProcess } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { fileURLToPath, pathToFileURL } from 'url'; + +// Non-literal specifier on purpose: until src/memory/fileLock.ts exists, only +// the test that imports it fails (module not found), not the whole file. +const FILE_LOCK_SPECIFIER = '../src/memory/fileLock.js'; + +const repoRoot = fileURLToPath(new URL('..', import.meta.url)); +const distCliEntry = path.join(repoRoot, 'dist', 'cli', 'index.js'); +const distStoreEntry = path.join(repoRoot, 'dist', 'memory', 'sqliteStore.js'); + +function uniqueSuffix(): string { + return `${Date.now().toString(36)}-${Math.floor(Math.random() * 1e9).toString(36)}`; +} + +function waitForExit(child: ChildProcess): Promise { + return new Promise((resolve, reject) => { + child.on('exit', (code) => resolve(code ?? -1)); + child.on('error', reject); + }); +} + +function childScript(): string { + return [ + "const { SqliteStore } = await import(process.argv[1]);", + "const store = new SqliteStore(process.argv[2]);", + "for (let i = 0; i < 5; i++) {", + " store.set({ id: process.argv[3] + '-' + i, category: 'general', content: process.argv[4] + '-' + i });", + "}" + ].join('\n'); +} + +function spawnDeadPid(): Promise { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, ['-e', 'console.log(process.pid)'], { stdio: ['ignore', 'pipe', 'ignore'] }); + let out = ''; + child.stdout?.on('data', (chunk: Buffer) => { out += chunk.toString(); }); + child.on('exit', () => { + const pid = Number.parseInt(out.trim(), 10); + if (Number.isInteger(pid) && pid > 0) resolve(pid); + else reject(new Error(`could not parse dead pid from output: '${out.trim()}'`)); + }); + child.on('error', reject); + }); +} + +describe('HyperSABMemory — Concurrent store access (C2/C3)', () => { + const dirs: string[] = []; + + function tmpDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hm-conc-')); + dirs.push(dir); + return dir; + } + + function readRawStore(dir: string): string { + return fs.readFileSync(path.join(dir, 'memory_store.json'), 'utf-8'); + } + + afterEach(() => { + vi.restoreAllMocks(); + for (const dir of dirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + beforeAll(() => { + if (!fs.existsSync(distCliEntry)) { + execSync('npm run build', { cwd: repoRoot, stdio: 'ignore' }); + } + }, 120_000); + + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + it('(a-store) two store instances on one workspace dir both persist their facts', () => { + const dir = tmpDir(); + const rand = uniqueSuffix(); + const alpha = `alpha-${rand}`; + const beta = `beta-${rand}`; + + const engineA = new SqliteStore(dir); + const engineB = new SqliteStore(dir); + engineA.set({ id: `mem-a-${rand}`, category: 'decision', content: alpha }); + engineB.set({ id: `mem-b-${rand}`, category: 'decision', content: beta }); + + const raw = readRawStore(dir); + expect(raw).toContain(alpha); + expect(raw).toContain(beta); + }); + + it('(a-swarm) two swarm engines on one dir: exactly one file-claim holder, no lost updates', () => { + const dir = tmpDir(); + const engineA = new SwarmMeshEngine(dir); + const engineB = new SwarmMeshEngine(dir); + + // Lost-update probe: A mutates after B cached its construction-time state. + engineA.broadcast('agent-a', 'alpha finding'); + const claimB = engineB.claimFile('agent-b', 'src/foo.ts'); + expect(claimB.ok).toBe(true); + + const raw = fs.readFileSync(path.join(dir, '.hypersabmemory', 'swarm_state.json'), 'utf-8'); + const persisted = JSON.parse(raw) as { broadcasts: Array<{ message: string }>; fileLocks: unknown[] }; + expect(persisted.broadcasts.some((b) => b.message === 'alpha finding')).toBe(true); // B's claim must not clobber A's broadcast + expect(persisted.fileLocks).toHaveLength(1); + + // Double-acquire of the same path: second claimant is denied. + const claimA = engineA.claimFile('agent-a', 'src/foo.ts'); + expect(claimA.ok).toBe(false); + expect(claimA.heldBy).toBe('agent-b'); + expect(claimA.reason).toBe('locked'); + + // Case-insensitive variant collides on win32 (canonical lock keys). + if (process.platform === 'win32') { + const upper = engineA.claimFile('agent-a', 'src/Foo.ts'); + expect(upper.ok).toBe(false); + expect(upper.reason).toBe('locked'); + expect(upper.heldBy).toBe('agent-b'); + } + }); + + it('(b) two real child processes writing to one dir keep all 10 facts', async () => { + const dir = tmpDir(); + const rand = uniqueSuffix(); + const script = childScript(); + + const children = (['procA', 'procB'] as const).map((prefix) => spawn( + process.execPath, + [ + '--input-type=module', + '-e', + script, + pathToFileURL(distStoreEntry).href, + dir, + prefix, + `${prefix}-content-${rand}` + ], + { stdio: 'ignore' } + )); + + const exitCodes = await Promise.all(children.map(waitForExit)); + expect(exitCodes).toEqual([0, 0]); + + const raw = readRawStore(dir); + for (let i = 0; i < 5; i++) { + expect(raw).toContain(`procA-content-${rand}-${i}`); + expect(raw).toContain(`procB-content-${rand}-${i}`); + } + }, 30_000); + + it('(c) evicts a lock sentinel held by a dead pid within the 2s budget', async () => { + const lockModule = (await import(FILE_LOCK_SPECIFIER)) as { + withFileLock: (lockPath: string, fn: () => T) => T; + }; + + const deadPid = await spawnDeadPid(); + expect(() => process.kill(deadPid, 0)).toThrow(); + + const dir = tmpDir(); + const lockPath = path.join(dir, 'memory_store.json.lock'); + fs.writeFileSync(lockPath, JSON.stringify({ pid: deadPid, acquiredAt: Date.now() }), 'utf-8'); + + const started = Date.now(); + let ranInside = false; + expect(() => lockModule.withFileLock(lockPath, () => { ranInside = true; })).not.toThrow(); + expect(ranInside).toBe(true); + expect(Date.now() - started).toBeLessThan(2000); + }, 10_000); + + it('(d) golden: memory_store.json stays an array of MemoryRecord-shaped records after forget', () => { + const dir = tmpDir(); + const store = new SqliteStore(dir); + store.set({ id: 'golden-active', category: 'decision', content: 'Active fact' }); + store.set({ id: 'golden-old', category: 'bug', content: 'Old fact' }); + store.forget('golden-old'); + + const parsed: unknown = JSON.parse(readRawStore(dir)); + expect(Array.isArray(parsed)).toBe(true); + + // Exact current MemoryRecord field vocabulary (src/memory/sqliteStore.ts). + const allowedKeys = [ + 'id', 'category', 'content', 'timestamp', + 'tags', 'status', 'invalidAt', 'supersedes', + 'intent', 'artifacts', 'errorsAndFixes', 'nextSteps' + ]; + const records = parsed as Array>; + for (const record of records) { + for (const key of Object.keys(record)) { + expect(allowedKeys).toContain(key); + } + expect(record.id).toEqual(expect.any(String)); + expect(record.category).toEqual(expect.any(String)); + expect(record.content).toEqual(expect.any(String)); + expect(record.timestamp).toEqual(expect.any(String)); + } + + const byId = new Map(records.map((r) => [String(r.id), r])); + + const active = byId.get('golden-active'); + expect(active).toBeDefined(); + expect(active?.category).toBe('decision'); + expect(active?.content).toBe('Active fact'); + expect(active?.status).toBe('active'); + + // Forgotten record keeps its fields; only status flips + invalidAt set. + const forgotten = byId.get('golden-old'); + expect(forgotten).toBeDefined(); + expect(forgotten?.category).toBe('bug'); + expect(forgotten?.content).toBe('Old fact'); + expect(forgotten?.timestamp).toEqual(expect.any(String)); + expect(forgotten?.status).toBe('forgotten'); + expect(forgotten?.invalidAt).toEqual(expect.any(String)); + }); +}); diff --git a/tests/contextBlock.test.ts b/tests/contextBlock.test.ts index 6fb1e29..25e5359 100644 --- a/tests/contextBlock.test.ts +++ b/tests/contextBlock.test.ts @@ -24,6 +24,51 @@ function noiseFact(i: number, msAgo: number): MemoryRecord { }; } +describe('HyperSABMemory — seeded provenance marker (F1)', () => { + it('prefixes seeded fact lines with [seeded] and leaves normal facts unmarked', () => { + const now = new Date().toISOString(); + const records: MemoryRecord[] = [ + { + id: 'seed-1', + category: 'architecture', + content: 'Seeded stack summary fact', + timestamp: now, + tags: ['seeded'], + source: 'seed', + status: 'active' + }, + { + id: 'user-1', + category: 'decision', + content: 'User decision fact', + timestamp: now, + status: 'active' + } + ]; + + const block = buildContextBlock(records); + const seedLine = block.split('\n').find((line) => line.includes('Seeded stack summary fact'))!; + expect(seedLine).toContain('[seeded]'); + const userLine = block.split('\n').find((line) => line.includes('User decision fact'))!; + expect(userLine).not.toContain('[seeded]'); + }); + + it('holds the 400-token ceiling when every rendered fact carries the [seeded] marker', () => { + const records: MemoryRecord[] = Array.from({ length: 20 }, (_, i) => ({ + id: `seeded-${i}`, + category: 'architecture' as const, + content: 'x'.repeat(200), + timestamp: iso((i + 1) * HOUR_MS), + tags: ['seeded'], + source: 'seed' as const, + status: 'active' as const + })); + + const block = buildContextBlock(records); + expect(TokenCompressor.estimateTokens(block)).toBeLessThanOrEqual(400); + }); +}); + describe('HyperSABMemory — Noise-aware Context Block (P6)', () => { it('ranks a fresh decision above fresh watcher noise and evicts noise, not the decision', () => { const records: MemoryRecord[] = [ diff --git a/tests/dashboard.test.ts b/tests/dashboard.test.ts index 0dd151f..2ad1583 100644 --- a/tests/dashboard.test.ts +++ b/tests/dashboard.test.ts @@ -1,8 +1,11 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import * as fs from 'fs'; +import * as http from 'http'; +import * as net from 'net'; import * as os from 'os'; import * as path from 'path'; import { DashboardServer } from '../src/server/dashboardServer.js'; +import { SwarmMeshEngine } from '../src/memory/swarmMesh.js'; describe('HyperSABMemory — Dashboard Server API', () => { let server: DashboardServer; @@ -74,7 +77,7 @@ describe('HyperSABMemory — Dashboard Server API', () => { it('rejects rollback with invalid hash', async () => { const res = await fetch(`${baseUrl}/api/rollback`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', 'x-hypersabmemory': 'test' }, body: JSON.stringify({ hash: 'not-a-valid-hash!!!' }) }); expect(res.status).toBe(200); @@ -85,11 +88,156 @@ describe('HyperSABMemory — Dashboard Server API', () => { it('rejects rollback with malformed body', async () => { const res = await fetch(`${baseUrl}/api/rollback`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', 'x-hypersabmemory': 'test' }, body: '{broken json' }); expect(res.status).toBe(400); const data = await res.json(); expect(data.ok).toBe(false); }); + + // Raw http.request helper: undici's fetch strips forbidden headers like + // Host, so Host-validation tests must drive the socket directly. + function postRaw(headers: Record): Promise { + return new Promise((resolve, reject) => { + const req = http.request( + { hostname: '127.0.0.1', port, path: '/api/rollback', method: 'POST', headers }, + (res) => { res.resume(); resolve(res.statusCode ?? 0); } + ); + req.on('error', reject); + req.end(JSON.stringify({ hash: 'deadbeef' })); + }); + } + + // HTTP/1.0 raw-socket POST: llhttp rejects Host-less HTTP/1.1 requests with + // 400 before any handler runs, so only 1.0 reaches the app-level guard. + function postRawHttp10(request: string): Promise { + return new Promise((resolve, reject) => { + const socket = net.connect(port, '127.0.0.1', () => { socket.end(request); }); + let data = ''; + socket.on('data', (chunk: Buffer) => { data += chunk.toString(); }); + socket.on('close', () => { + const match = /^HTTP\/1\.[01] (\d{3})/.exec(data); + if (match) resolve(Number(match[1])); else reject(new Error(`no status line: ${data}`)); + }); + socket.on('error', reject); + }); + } + + it('rejects POST with a non-loopback Host header (DNS rebinding)', async () => { + const status = await postRaw({ + host: 'attacker.example.com', + 'content-type': 'application/json', + 'x-hypersabmemory': 'test' + }); + expect(status).toBe(403); + }); + + it('rejects POST with a missing Host header', async () => { + const body = JSON.stringify({ hash: 'deadbeef' }); + const request = [ + 'POST /api/rollback HTTP/1.0', + 'Content-Type: application/json', + 'x-hypersabmemory: test', + `Content-Length: ${Buffer.byteLength(body)}`, + '', + body + ].join('\r\n'); + const status = await postRawHttp10(request); + expect(status).toBe(403); + }); + + it('rejects POST with a non-JSON content-type (CORS simple request)', async () => { + const res = await fetch(`${baseUrl}/api/rollback`, { + method: 'POST', + headers: { 'Content-Type': 'text/plain', 'x-hypersabmemory': 'test' }, + body: JSON.stringify({ hash: 'deadbeef' }) + }); + expect(res.status).toBe(415); + }); + + it('rejects POST without the x-hypersabmemory header', async () => { + const res = await fetch(`${baseUrl}/api/rollback`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hash: 'deadbeef' }) + }); + expect(res.status).toBe(403); + }); + + it('accepts a well-formed POST (json + custom header + loopback host)', async () => { + const res = await fetch(`${baseUrl}/api/rollback`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-hypersabmemory': 'dashboard' }, + body: JSON.stringify({ hash: 'not-a-valid-hash!!!' }) + }); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.ok).toBe(false); // handler ran; hash is simply invalid + }); +}); + +describe('HyperSABMemory — Dashboard Swarm Visibility (F5)', () => { + let server: DashboardServer; + let port: number; + let dir: string; + let baseUrl: string; + + beforeAll(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hm-dashboard-swarm-')); + // Seed live swarm state through the real engine BEFORE the server starts, + // so the dashboard reads persisted locks + broadcasts from disk. + const mesh = new SwarmMeshEngine(dir); + const claim = mesh.claimFile('sess-A', path.join(dir, 'src', 'server', 'dashboardServer.ts')); + expect(claim.ok).toBe(true); + mesh.broadcast('sess-B', 'F5 swarm cards wired into dashboard', 'general'); + server = new DashboardServer(0, dir); + port = await server.start(); + baseUrl = `http://127.0.0.1:${port}`; + }); + + afterAll(() => { + server.stop(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('returns seeded locks and broadcasts from /api/swarm', async () => { + const res = await fetch(`${baseUrl}/api/swarm`); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('application/json'); + const data = await res.json(); + expect(Array.isArray(data.fileLocks)).toBe(true); + expect(Array.isArray(data.broadcasts)).toBe(true); + expect(data.fileLocks).toHaveLength(1); + // win32 canonicalization lowercases stored paths + expect(String(data.fileLocks[0].filePath).toLowerCase()).toContain('dashboardserver.ts'); + expect(data.fileLocks[0].sessionId).toBe('sess-A'); + expect(typeof data.fileLocks[0].expiresAt).toBe('string'); + expect(data.broadcasts).toHaveLength(1); + expect(data.broadcasts[0].message).toBe('F5 swarm cards wired into dashboard'); + expect(data.broadcasts[0].category).toBe('general'); + expect(typeof data.broadcasts[0].createdAt).toBe('string'); + }); + + it('returns empty arrays from /api/swarm when no swarm state exists yet', async () => { + const freshDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hm-dashboard-fresh-')); + const freshServer = new DashboardServer(0, freshDir); + const freshPort = await freshServer.start(); + try { + const res = await fetch(`http://127.0.0.1:${freshPort}/api/swarm`); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.fileLocks).toEqual([]); + expect(data.broadcasts).toEqual([]); + } finally { + freshServer.stop(); + fs.rmSync(freshDir, { recursive: true, force: true }); + } + }); + + it('renders the swarm card section in the dashboard HTML', async () => { + const res = await fetch(`${baseUrl}/`); + expect(res.status).toBe(200); + expect(await res.text()).toContain('id="swarm-card"'); + }); }); \ No newline at end of file diff --git a/tests/doctorVerify.test.ts b/tests/doctorVerify.test.ts new file mode 100644 index 0000000..889ca37 --- /dev/null +++ b/tests/doctorVerify.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { runDoctor } from '../src/cli/doctor.js'; +import { printSetupOutcome } from '../src/cli/oneShotSetup.js'; +import { TemporalMemoryEngine } from '../src/memory/temporalMemory.js'; + +const dirs: string[] = []; + +function tmpDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hm-doctor-')); + dirs.push(dir); + return dir; +} + +// Minimal newline-delimited JSON-RPC peer: answers initialize (id 1) and tools/list (id 2). +const FAKE_SERVER = [ + "let buf='';process.stdin.on('data',(d)=>{", + "buf+=d.toString();const ls=buf.split('\\n');buf=ls.pop()||'';", + 'for(const l of ls){if(!l.trim())continue;', + 'try{const m=JSON.parse(l);', + "if(m.id===1)process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:1,result:{protocolVersion:'2024-11-05',capabilities:{},serverInfo:{name:'fake',version:'0'}}})+'\\n');", + "if(m.id===2)process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:2,result:{tools:[{name:'t1'}]}})+'\\n');", + '}catch{}}});' +].join(''); + +function writeCursorConfig(dir: string, command: string, args: string[]): void { + fs.mkdirSync(path.join(dir, '.cursor'), { recursive: true }); + fs.writeFileSync( + path.join(dir, '.cursor', 'mcp.json'), + `${JSON.stringify({ mcpServers: { hypersabmemory: { command, args } } }, null, 2)}\n` + ); +} + +afterEach(() => { + vi.restoreAllMocks(); + for (const dir of dirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('HyperSABMemory — doctor MCP launch verification + factual counters (F3)', () => { + it('verifies a working configured launch via initialize + tools/list handshake', async () => { + const dir = tmpDir(); + writeCursorConfig(dir, process.execPath, ['-e', FAKE_SERVER]); + + const report = await runDoctor(dir); + + const check = report.checks.find((c) => c.name === 'mcp-launch'); + expect(check?.ok).toBe(true); + expect(check?.detail).toContain('replied with 1 tool(s)'); + }); + + it('times out and kills a hung server, failing gracefully', async () => { + const dir = tmpDir(); + writeCursorConfig(dir, process.execPath, ['-e', 'setInterval(()=>{},10000)']); + + const startedAt = Date.now(); + const report = await runDoctor(dir, { verifyTimeoutMs: 700 }); + + const check = report.checks.find((c) => c.name === 'mcp-launch'); + expect(check?.ok).toBe(false); + expect(check?.detail).toContain('no tools/list response within 700ms'); + expect(Date.now() - startedAt).toBeLessThan(5000); + }); + + it('reports the real spawn error for a broken launch command', async () => { + const dir = tmpDir(); + writeCursorConfig(dir, 'definitely-not-a-real-command-xyz', []); + + const report = await runDoctor(dir); + + const check = report.checks.find((c) => c.name === 'mcp-launch'); + expect(check?.ok).toBe(false); + expect(check?.detail.toLowerCase()).toContain('spawn failed'); + expect(check?.detail.toLowerCase()).not.toContain('check logs'); + }); + + it('prints real err.message plus partial createdFiles, never "check logs"', () => { + const logged: string[] = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + logged.push(args.map(String).join(' ')); + }); + + printSetupOutcome({ + success: false, + createdFiles: [path.join('AGENTS.md'), path.join('.cursor', 'rules', 'hypersabmemory.mdc')], + error: 'EACCES: permission denied, open AGENTS.md' + }); + + const output = logged.join('\n'); + expect(output).toContain('EACCES: permission denied, open AGENTS.md'); + expect(output).toContain('AGENTS.md'); + expect(output).toContain('hypersabmemory.mdc'); + expect(output.toLowerCase()).not.toContain('check logs'); + }); + + it('measures counters and stays byte-read-only on a populated store', async () => { + const dir = tmpDir(); + const engine = new TemporalMemoryEngine(dir); + const bulkFact = (i: number): string => + `Doctor fixture bulk fact ${i}: enough body text per record that the naive corpus dwarfs the capped context block.`; + for (let i = 0; i < 40; i += 1) { + engine.remember(i % 2 === 0 ? 'architecture' : 'decision', bulkFact(i)); + } + + const storePath = path.join(dir, '.hypersabmemory', 'memory_store.json'); + const storeBytesBefore = fs.readFileSync(storePath, 'utf-8'); + writeCursorConfig(dir, process.execPath, ['-e', FAKE_SERVER]); + const listingBefore = fs.readdirSync(dir).sort(); + + const report = await runDoctor(dir, { verifyTimeoutMs: 3000 }); + + expect(fs.readFileSync(storePath, 'utf-8')).toBe(storeBytesBefore); + expect(fs.readdirSync(dir).sort()).toEqual(listingBefore); + expect(report.counters?.activeRecords).toBe(40); + expect(report.counters?.forgottenRecords).toBe(0); + expect(typeof report.counters?.checkpointCommits === 'number' || report.counters?.checkpointCommits === null).toBe(true); + expect(report.counters?.estTokensSavedPct).toBeGreaterThan(0); + expect(report.checks.find((c) => c.name === 'mcp-launch')?.ok).toBe(true); + }); +}); diff --git a/tests/fileWatcher.test.ts b/tests/fileWatcher.test.ts new file mode 100644 index 0000000..746c330 --- /dev/null +++ b/tests/fileWatcher.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import chokidar from 'chokidar'; +import type { FSWatcher } from 'chokidar'; +import { FileWatcherHook } from '../src/hooks/fileWatcher.js'; +import { TemporalMemoryEngine } from '../src/memory/temporalMemory.js'; + +vi.mock('chokidar', () => ({ + default: { watch: vi.fn(), FSWatcher: class {} }, + FSWatcher: class {} +})); + +// The real AutoCommitEngine spawns fire-and-forget git children whose cwd is +// the workspace; on Windows that briefly locks the tmp dir (EBUSY on cleanup). +vi.mock('../src/hooks/autoCommit.js', () => ({ + AutoCommitEngine: class { + constructor(_options?: unknown) {} + start(): void {} + stop(): void {} + } +})); + +const dirs: string[] = []; +const hooks: FileWatcherHook[] = []; +let fakeWatcher: { on: ReturnType; close: ReturnType }; + +beforeEach(() => { + fakeWatcher = { on: vi.fn().mockReturnThis(), close: vi.fn() }; + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + for (const hook of hooks.splice(0)) hook.stop(); + vi.restoreAllMocks(); + // maxRetries: the hook's fire-and-forget initial git checkpoint can briefly + // hold the tmp dir as its cwd on Windows (EBUSY) after stop(). + for (const dir of dirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + } +}); + +function tmpWorkspace(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hm-c4b-')); + dirs.push(dir); + return dir; +} + +function startHook(options: ConstructorParameters[0] = {}): string { + const watchMock = vi.mocked(chokidar.watch); + watchMock.mockReset(); + watchMock.mockReturnValue(fakeWatcher as unknown as FSWatcher); + const workspace = tmpWorkspace(); + hooks.push(new FileWatcherHook({ workspaceDir: workspace, ...options })); + hooks[hooks.length - 1].start(); + return workspace; +} + +function watcherOptions(): chokidar.ChokidarOptions { + const call = vi.mocked(chokidar.watch).mock.calls[0]; + if (!call) throw new Error('chokidar.watch was not called'); + return call[1] as chokidar.ChokidarOptions; +} + +describe('FileWatcherHook — event flood taming (C4b)', () => { + it('ignores .venv, build, target, out, coverage and vendor directories', () => { + // Given: a started watcher with the default ignore configuration + const workspace = startHook(); + const ignored = watcherOptions().ignored; + if (typeof ignored !== 'function') throw new Error('ignored must stay a function matcher'); + + // When: paths inside dependency/build output directories are checked + // Then: every flood-prone directory is ignored (OS-native separators) + const ignoredDirs = ['.venv', 'build', 'target', 'out', 'coverage', 'vendor']; + for (const dir of ignoredDirs) { + expect(ignored(path.join(workspace, dir, 'somefile.txt'))).toBe(true); + } + + // And: regular sources still pass through + expect(ignored(path.join(workspace, 'src', 'app.ts'))).toBe(false); + }); + + it('keeps node_modules ignored and honors user-supplied patterns (Windows separator regression guard)', () => { + // Given: a watcher with an extra user pattern + const workspace = startHook({ ignoredPatterns: ['scratch-space'] }); + const ignored = watcherOptions().ignored; + if (typeof ignored !== 'function') throw new Error('ignored must stay a function matcher'); + + // When: Windows-style backslash paths are normalized through the matcher + // Then: existing defaults and custom patterns still match + expect(ignored(path.join(workspace, 'node_modules', 'pkg', 'index.js'))).toBe(true); + expect(ignored(path.join(workspace, '.git', 'HEAD'))).toBe(true); + expect(ignored(path.join(workspace, 'scratch-space', 'notes.md'))).toBe(true); + expect(ignored(path.join(workspace, 'README.md'))).toBe(false); + }); + + it('configures awaitWriteFinish to debounce rapid successive writes', () => { + // Given: a started watcher + startHook(); + + // When: the options passed to chokidar.watch are inspected + const awf = watcherOptions().awaitWriteFinish; + + // Then: write-finish debouncing is configured within the agreed bounds + if (typeof awf !== 'object' || awf === null) throw new Error('awaitWriteFinish must be an options object'); + expect(awf.stabilityThreshold).toBeGreaterThanOrEqual(300); + expect(awf.stabilityThreshold).toBeLessThanOrEqual(500); + expect(awf.pollInterval).toBe(100); + }); + + it('emits at most one remember per file within the cooldown window', () => { + // Given: a started watcher and a spy on the memory engine + const workspace = startHook(); + const rememberSpy = vi.spyOn(TemporalMemoryEngine.prototype, 'remember'); + const changeCall = fakeWatcher.on.mock.calls.find((args) => args[0] === 'change'); + if (!changeCall) throw new Error('watcher did not subscribe to change events'); + const onChange = changeCall[1] as (filePath: string) => void; + + // When: two rapid change events arrive for one file, then one for another + const fileA = path.join(workspace, 'src', 'a.ts'); + const fileB = path.join(workspace, 'src', 'b.ts'); + onChange(fileA); + onChange(fileA); + onChange(fileB); + + // Then: the second burst on fileA is suppressed by the per-file cooldown + expect(rememberSpy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/ghostPaths.test.ts b/tests/ghostPaths.test.ts new file mode 100644 index 0000000..6691910 --- /dev/null +++ b/tests/ghostPaths.test.ts @@ -0,0 +1,174 @@ +/** + * HyperSABMemory — Ghost Paths (F8) + * Warns when currently-edited files overlap files captured in rolled-back + * safety snapshots ('feat: safety: pre-rollback snapshot' commits). + */ + +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import { ContextHarness } from '../src/harness/contextHarness.js'; +import { + detectGhostCollision, + formatGhostLine, + resolveGhostLine, + GHOST_PATHS_ENV, + GHOST_SNAPSHOT_SUBJECT +} from '../src/harness/ghostPaths.js'; + +const execFileAsync = promisify(execFile); + +const GHOST_LINE_RE = /^\[GHOST\] files in this session match a rolled-back checkpoint \([0-9a-f]{7,}\)$/; + +async function initFixtureRepo(prefix: string): Promise { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + await execFileAsync('git', ['init'], { cwd: dir }); + await execFileAsync('git', ['config', 'user.email', 'test@example.com'], { cwd: dir }); + await execFileAsync('git', ['config', 'user.name', 'Test'], { cwd: dir }); + fs.writeFileSync(path.join(dir, 'base.txt'), 'base\n'); + await execFileAsync('git', ['add', '-A'], { cwd: dir }); + await execFileAsync('git', ['commit', '-m', 'initial'], { cwd: dir }); + return dir; +} + +/** Simulates the exact commit AutoCommitEngine.rollback's safety checkpoint writes. */ +async function commitSnapshot(dir: string, file: string, content: string): Promise { + fs.writeFileSync(path.join(dir, file), content); + await execFileAsync('git', ['add', '-A'], { cwd: dir }); + await execFileAsync('git', ['commit', '-m', GHOST_SNAPSHOT_SUBJECT], { cwd: dir }); +} + +describe('HyperSABMemory — Ghost Paths (F8)', () => { + let dir: string; + let savedEnv: string | undefined; + + beforeEach(() => { + savedEnv = process.env[GHOST_PATHS_ENV]; + delete process.env[GHOST_PATHS_ENV]; + }); + + afterEach(() => { + if (savedEnv === undefined) delete process.env[GHOST_PATHS_ENV]; + else process.env[GHOST_PATHS_ENV] = savedEnv; + }); + + beforeAll(async () => { + dir = await initFixtureRepo('hm-ghost-'); + // Rolled-back timeline: snapshot commit touching ghosted.txt + a decoy + // checkpoint that must NOT be scanned. + await commitSnapshot(dir, 'ghosted.txt', 'v1\n'); + fs.writeFileSync(path.join(dir, 'decoy.txt'), 'decoy\n'); + await execFileAsync('git', ['add', '-A'], { cwd: dir }); + await execFileAsync('git', ['commit', '-m', 'checkpoint: auto snapshot (decoy)'], { cwd: dir }); + }); + + afterAll(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('formatGhostLine renders the exact locked format', () => { + expect(formatGhostLine({ shortHash: 'abc1234' })).toBe( + '[GHOST] files in this session match a rolled-back checkpoint (abc1234)' + ); + }); + + it('detectGhostCollision matches an edited file against the snapshot changed-file list', () => { + const hit = detectGhostCollision(dir, ['ghosted.txt']); + expect(hit).not.toBeNull(); + expect(hit?.shortHash).toMatch(/^[0-9a-f]{7,}$/); + }); + + it('detectGhostCollision returns null when no live file overlaps the snapshot', () => { + expect(detectGhostCollision(dir, ['unrelated.ts'])).toBeNull(); + }); + + it('detectGhostCollision returns null outside a git repo', () => { + const plainDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hm-ghost-nogit-')); + try { + expect(detectGhostCollision(plainDir, ['x.txt'])).toBeNull(); + expect(resolveGhostLine({ workspaceDir: plainDir, activeFile: 'x.txt' })).toBe(''); + } finally { + fs.rmSync(plainDir, { recursive: true, force: true }); + } + }); + + it('RED: header appends exactly one [GHOST] line when activeFile collides (modifiedFiles empty)', () => { + const harness = new ContextHarness(1500); + const header = harness.generateHeader({ + workspaceName: path.basename(dir), + activeFile: 'ghosted.txt', + modifiedFiles: [], + workspaceDir: dir + }); + const ghostLines = header.split('\n').filter((l) => l.startsWith('[GHOST]')); + expect(ghostLines).toHaveLength(1); + expect(ghostLines[0]).toMatch(GHOST_LINE_RE); + }); + + it('header appends exactly one [GHOST] line when modifiedFiles collide', () => { + const harness = new ContextHarness(1500); + const header = harness.generateHeader({ + workspaceName: path.basename(dir), + activeFile: 'other-live.ts', + modifiedFiles: ['decoy.txt', 'ghosted.txt'], + workspaceDir: dir + }); + const ghostLines = header.split('\n').filter((l) => l.startsWith('[GHOST]')); + expect(ghostLines).toHaveLength(1); + expect(ghostLines[0]).toMatch(GHOST_LINE_RE); + }); + + it('GREEN-by-design safety net: non-collision header is byte-identical with and without the scan enabled', () => { + const harness = new ContextHarness(1500); + const state = { + workspaceName: path.basename(dir), + activeFile: 'unrelated.ts', + modifiedFiles: ['also-unrelated.ts'], + workspaceDir: dir + }; + process.env[GHOST_PATHS_ENV] = 'off'; + const disabled = harness.generateHeader(state); + delete process.env[GHOST_PATHS_ENV]; + const enabled = harness.generateHeader(state); + // Passes pre-change by construction; post-change it locks zero-token silence. + expect(enabled).toBe(disabled); + expect(enabled).not.toContain('[GHOST]'); + }); + + it('HYPERSABMEMORY_GHOST_PATHS=off disables the warning even on collision', () => { + const harness = new ContextHarness(1500); + process.env[GHOST_PATHS_ENV] = 'off'; + const header = harness.generateHeader({ + workspaceName: path.basename(dir), + activeFile: 'ghosted.txt', + modifiedFiles: [], + workspaceDir: dir + }); + expect(header).not.toContain('[GHOST]'); + }); + + it('scan is bounded to the 3 most recent snapshots (4th-oldest collision stays silent)', async () => { + const boundedDir = await initFixtureRepo('hm-ghost-bounded-'); + try { + // Oldest snapshot touches ghosted.txt, then 3 newer snapshot-subject + // commits push it outside the bounded window. + await commitSnapshot(boundedDir, 'ghosted.txt', 'v1\n'); + for (let i = 0; i < 3; i++) { + await commitSnapshot(boundedDir, `filler${i}.txt`, `filler ${i}\n`); + } + const harness = new ContextHarness(1500); + const header = harness.generateHeader({ + workspaceName: path.basename(boundedDir), + activeFile: 'ghosted.txt', + modifiedFiles: [], + workspaceDir: boundedDir + }); + expect(header).not.toContain('[GHOST]'); + } finally { + fs.rmSync(boundedDir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/handoffCommand.test.ts b/tests/handoffCommand.test.ts new file mode 100644 index 0000000..dcf6571 --- /dev/null +++ b/tests/handoffCommand.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { execSync, execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { buildHandoffBrief } from '../src/cli/handoffCommand.js'; +import { TokenCompressor } from '../src/harness/tokenCompressor.js'; +import { TemporalMemoryEngine } from '../src/memory/temporalMemory.js'; + +const dirs: string[] = []; + +function tmpDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hm-handoff-')); + dirs.push(dir); + return dir; +} + +function initFixtureRepo(dir: string): void { + execSync('git init', { cwd: dir, stdio: 'ignore' }); + execSync('git config user.email test@local', { cwd: dir }); + execSync('git config user.name test', { cwd: dir }); + execSync('git commit --allow-empty -m "checkpoint: auto snapshot (fixture one)"', { cwd: dir, stdio: 'ignore' }); + execSync('git commit --allow-empty -m "unrelated user commit"', { cwd: dir, stdio: 'ignore' }); + execSync('git commit --allow-empty -m "checkpoint: auto snapshot (fixture two)"', { cwd: dir, stdio: 'ignore' }); +} + +function seedStore(dir: string): void { + const engine = new TemporalMemoryEngine(dir); + engine.remember('architecture', 'Handoff fixture architecture fact about the store layout'); + engine.remember( + 'decision', + 'Handoff fixture decision fact about rollback guards', + [], + { nextSteps: 'Verify the rollback guard on a monorepo subdir' } + ); +} + +afterEach(() => { + for (const dir of dirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('HyperSABMemory — handoff command (F2)', () => { + it('keeps the forward brief paste-ready and under the 600-token budget', async () => { + const dir = tmpDir(); + initFixtureRepo(dir); + seedStore(dir); + + const brief = await buildHandoffBrief(dir); + + expect(brief).toContain('# HyperSABMemory Handoff'); + expect(brief).toContain('Handoff fixture architecture fact'); + expect(brief).toContain('Verify the rollback guard on a monorepo subdir'); + // Repo token estimator (TokenCompressor, ~4 chars/token) is the documented heuristic. + expect(TokenCompressor.estimateTokens(brief)).toBeLessThanOrEqual(600); + }); + + it('only cites checkpoint hashes that resolve in the fixture repo', async () => { + const dir = tmpDir(); + initFixtureRepo(dir); + seedStore(dir); + + const brief = await buildHandoffBrief(dir); + const section = brief.split('## Recent checkpoints')[1] ?? ''; + const hashes = Array.from(section.matchAll(/^- ([0-9a-f]{7,40}) /gm)).map((m) => m[1]); + + expect(hashes.length).toBeGreaterThanOrEqual(1); + const unresolvable: string[] = []; + for (const hash of hashes) { + try { + execFileSync('git', ['cat-file', '-e', `${hash}^{commit}`], { cwd: dir, stdio: 'ignore' }); + } catch { + unresolvable.push(hash); + } + } + expect(unresolvable).toEqual([]); + // The grep convention must exclude plain user commits. + expect(section).not.toContain('unrelated user commit'); + }); + + it('renders --story byte-identically across two runs on the same state', async () => { + const dir = tmpDir(); + initFixtureRepo(dir); + seedStore(dir); + + const first = await buildHandoffBrief(dir, { story: true }); + const second = await buildHandoffBrief(dir, { story: true }); + + expect(first).toBe(second); + expect(first).toContain("Captain's Log"); + expect(first).toContain('Facts learned today: 2'); + expect(first).toContain('Facts forgotten today: 0'); + expect(first).toContain('Checkpoints recorded: 2'); + }); + + it('produces a valid minimal brief in an empty non-git workspace', async () => { + const dir = tmpDir(); + + const brief = await buildHandoffBrief(dir); + const story = await buildHandoffBrief(dir, { story: true }); + + expect(brief).toContain('# HyperSABMemory Handoff'); + expect(TokenCompressor.estimateTokens(brief)).toBeLessThanOrEqual(600); + expect(story).toContain("Captain's Log"); + expect(story).toContain('Facts learned today: 0'); + }); +}); diff --git a/tests/harness.test.ts b/tests/harness.test.ts index 0114505..f1f5e21 100644 --- a/tests/harness.test.ts +++ b/tests/harness.test.ts @@ -5,7 +5,7 @@ import { HashAnchorEngine } from '../src/harness/hashAnchor.js'; import { StructuralGraphEngine } from '../src/memory/structuralGraph.js'; import { MinimalChangeEngine } from '../src/harness/minimalChange.js'; import { UniversalAdaptersEngine } from '../src/harness/universalAdapters.js'; -import { McpAccessEngine } from '../src/harness/mcpAccess.js'; +import { McpAccessEngine, resolveMcpLaunch } from '../src/harness/mcpAccess.js'; import { HybridSearchEngine } from '../src/memory/hybridSearch.js'; import { buildContextBlock } from '../src/memory/contextBlock.js'; import { SqliteStore } from '../src/memory/sqliteStore.js'; @@ -170,4 +170,32 @@ describe('HyperSABMemory — Context Harness Tests', () => { expect(await engine.rollback('HEAD; rm -rf /')).toBe(false); expect(await engine.rollback('../.git')).toBe(false); }); + + it('should wrap npx in cmd /c on win32 so shell-less hosts can spawn it', () => { + withPlatform('win32', () => { + const launch = resolveMcpLaunch(os.tmpdir()); + expect(launch.command).toBe('cmd'); + expect(launch.args).toEqual(['/c', 'npx', '-y', 'hypersabmemory', 'mcp']); + }); + }); + + it('should keep the bare npx launch shape on darwin and linux', () => { + for (const platform of ['darwin', 'linux'] as const) { + withPlatform(platform, () => { + const launch = resolveMcpLaunch(os.tmpdir()); + expect(launch.command).toBe('npx'); + expect(launch.args).toEqual(['-y', 'hypersabmemory', 'mcp']); + }); + } + }); }); + +function withPlatform(platform: NodeJS.Platform, fn: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value: platform, configurable: true }); + try { + return fn(); + } finally { + Object.defineProperty(process, 'platform', descriptor as PropertyDescriptor); + } +} diff --git a/tests/mcpResources.test.ts b/tests/mcpResources.test.ts new file mode 100644 index 0000000..7b8e109 --- /dev/null +++ b/tests/mcpResources.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { HyperSABMemoryMcpServer } from '../src/mcp/server.js'; +import { TemporalMemoryEngine } from '../src/memory/temporalMemory.js'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const dirs: string[] = []; + +function tmpDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hm-mcpr-')); + dirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of dirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +async function connectServer(dir: string): Promise { + const mcp = new HyperSABMemoryMcpServer(dir); + const client = new Client({ name: 'mcp-resources-test', version: '0.0.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([mcp.connect(serverTransport), client.connect(clientTransport)]); + return client; +} + +describe('HyperSABMemory — MCP Resources (F7)', () => { + it('lists exactly two resources with the context-block and memory-log URIs', async () => { + const client = await connectServer(tmpDir()); + + const { resources } = await client.listResources(); + + expect(resources).toHaveLength(2); + const uris = resources.map((r) => r.uri).sort(); + expect(uris).toEqual(['hypersabmemory://context-block', 'hypersabmemory://memory-log']); + }); + + it('reads context-block byte-identical to the hypersabmemory_context_block tool output', async () => { + const dir = tmpDir(); + const memory = new TemporalMemoryEngine(dir); + memory.remember('decision', 'Ship MCP resources additively'); + memory.remember('bug', 'Resource read must round-trip bytes'); + const client = await connectServer(dir); + + const resource = await client.readResource({ uri: 'hypersabmemory://context-block' }); + const tool = await client.callTool({ name: 'hypersabmemory_context_block', arguments: {} }); + + expect(resource.contents).toHaveLength(1); + expect(resource.contents[0].uri).toBe('hypersabmemory://context-block'); + expect(resource.contents[0].text).toBe(tool.content[0].text); + }); + + it('reads memory-log equal to the fixture Memory.md file bytes', async () => { + const dir = tmpDir(); + const logBytes = '# Decision Log\n\n- 2026-08-23 [decision] keep tarball minimal\n'; + fs.writeFileSync(path.join(dir, 'Memory.md'), logBytes, 'utf-8'); + const client = await connectServer(dir); + + const resource = await client.readResource({ uri: 'hypersabmemory://memory-log' }); + + expect(resource.contents).toHaveLength(1); + expect(resource.contents[0].uri).toBe('hypersabmemory://memory-log'); + expect(resource.contents[0].text).toBe(logBytes); + }); + + it('reads memory-log as an empty string when Memory.md is absent', async () => { + const client = await connectServer(tmpDir()); + + const resource = await client.readResource({ uri: 'hypersabmemory://memory-log' }); + + expect(resource.contents[0].text).toBe(''); + }); +}); diff --git a/tests/memorybench.test.ts b/tests/memorybench.test.ts index ba2b819..13db62d 100644 --- a/tests/memorybench.test.ts +++ b/tests/memorybench.test.ts @@ -12,8 +12,13 @@ import { evaluateMemoryEngine, evaluateSearchRecordsPath, evaluateFusionPath, - seedMemoryBench + seedMemoryBench, + ADVERSARIAL_CLASSES, + scoreAdversarialClass, + seedAdversarialClasses, + runMemoryBenchReport } from '../src/eval/memoryBench.js'; +import type { MemoryRecord } from '../src/memory/sqliteStore.js'; describe('HyperSABMemory — MemoryBench local eval (OSS)', () => { it('meets recall ≥ 0.8 on active facts and leaks zero forgotten facts', () => { @@ -103,3 +108,101 @@ describe('HyperSABMemory — MemoryBench local eval (OSS)', () => { } }); }); + +describe('HyperSABMemory — F4 adversarial eval classes', () => { + function seedAll(dir: string): { engine: TemporalMemoryEngine; hybrid: HybridSearchEngine } { + const engine = new TemporalMemoryEngine(dir); + seedMemoryBench(engine); + seedAdversarialClasses(engine); + const hybrid = new HybridSearchEngine(dir, engine); + return { engine, hybrid }; + } + + function fusionQueryFn(hybrid: HybridSearchEngine): (q: string) => MemoryRecord[] { + return (q) => hybrid.search(q).filter((h) => h.source === 'memory').map((h) => ({ content: h.snippet })); + } + + it('negation class: migration facts recalled on all three paths with zero stale pure-X leaks', () => { + expect(ADVERSARIAL_CLASSES.negation.length).toBeGreaterThanOrEqual(2); + + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hm-bench-neg-')); + const { engine, hybrid } = seedAll(dir); + try { + const scores = [ + scoreAdversarialClass('negation', (q) => engine.query(q), engine.getContextBlock()), + scoreAdversarialClass('negation', (q) => hybrid.searchRecords(q), engine.getContextBlock()), + scoreAdversarialClass('negation', fusionQueryFn(hybrid), '') + ]; + for (const score of scores) { + expect(score.recall).toBeGreaterThanOrEqual(RECALL_THRESHOLD); + expect(score.staleLeaks).toBe(0); + expect(score.contextBlockLeaks).toBe(0); + expect(score.passed).toBe(true); + } + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('superseded-stale class: chain tip C is top hit on all three paths with zero A/B stale leaks', () => { + expect(ADVERSARIAL_CLASSES['superseded-stale'].length).toBeGreaterThanOrEqual(2); + + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hm-bench-sup-')); + const { engine, hybrid } = seedAll(dir); + try { + const scores = [ + scoreAdversarialClass('superseded-stale', (q) => engine.query(q), engine.getContextBlock()), + scoreAdversarialClass('superseded-stale', (q) => hybrid.searchRecords(q), engine.getContextBlock()), + scoreAdversarialClass('superseded-stale', fusionQueryFn(hybrid), '') + ]; + for (const score of scores) { + expect(score.recall).toBeGreaterThanOrEqual(RECALL_THRESHOLD); + expect(score.staleLeaks).toBe(0); + expect(score.contextBlockLeaks).toBe(0); + expect(score.passed).toBe(true); + } + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('artifact-linked class: file-path queries recall linked facts on all three paths', () => { + expect(ADVERSARIAL_CLASSES['artifact-linked'].length).toBeGreaterThanOrEqual(3); + + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hm-bench-art-')); + const { engine, hybrid } = seedAll(dir); + try { + const scores = [ + scoreAdversarialClass('artifact-linked', (q) => engine.query(q), engine.getContextBlock()), + scoreAdversarialClass('artifact-linked', (q) => hybrid.searchRecords(q), engine.getContextBlock()), + scoreAdversarialClass('artifact-linked', fusionQueryFn(hybrid), '') + ]; + for (const score of scores) { + expect(score.recall).toBeGreaterThanOrEqual(RECALL_THRESHOLD); + expect(score.passed).toBe(true); + } + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('report adds per-class breakdown across all three paths while keeping legacy fields intact', () => { + const report = runMemoryBenchReport(); + + expect(report.paths).toHaveLength(3); + expect(report.threshold).toBe(RECALL_THRESHOLD); + expect(report.allPassed).toBe(true); + + expect(report.classes).toHaveLength(3); + expect(report.classes?.map((c) => c.name)).toEqual(['negation', 'superseded-stale', 'artifact-linked']); + for (const cls of report.classes ?? []) { + expect(cls.paths.map((p) => p.name)).toEqual(['direct-engine', 'keyword-bm25', 'fused-memory-graph']); + for (const path of cls.paths) { + expect(path.recall).toBeGreaterThanOrEqual(RECALL_THRESHOLD); + expect(path.staleLeaks).toBe(0); + expect(path.passed).toBe(true); + } + expect(cls.passed).toBe(true); + } + }); +}); diff --git a/tests/removeCommand.test.ts b/tests/removeCommand.test.ts new file mode 100644 index 0000000..abdf4b8 --- /dev/null +++ b/tests/removeCommand.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { removeInjection } from '../src/cli/removeCommand.js'; +import { UniversalAdaptersEngine } from '../src/harness/universalAdapters.js'; +import { McpAccessEngine } from '../src/harness/mcpAccess.js'; +import { TemporalMemoryEngine } from '../src/memory/temporalMemory.js'; + +const dirs: string[] = []; + +function tmpDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hm-remove-')); + dirs.push(dir); + return dir; +} + +interface Fixture { + dir: string; + agentsMd: string; + cursorMcp: string; +} + +function seedFixtureWorkspace(): Fixture { + const dir = tmpDir(); + + const agentsMd = path.join(dir, 'AGENTS.md'); + fs.writeFileSync(agentsMd, '# My Project\n\nCustom agent rules live here.\n'); + + const cursorDir = path.join(dir, '.cursor'); + fs.mkdirSync(cursorDir, { recursive: true }); + const cursorMcp = path.join(cursorDir, 'mcp.json'); + fs.writeFileSync( + cursorMcp, + `${JSON.stringify({ mcpServers: { 'other-server': { command: 'uvx', args: ['some-server'] } } }, null, 2)}\n` + ); + + const memory = new TemporalMemoryEngine(dir); + memory.remember('architecture', 'Remove fixture fact about the store'); + + const adapters = new UniversalAdaptersEngine(dir); + adapters.syncAllAdapters({ contextBlock: memory.getContextBlock() }); + + const access = new McpAccessEngine(dir); + access.connect(); + + // Simulate user text written outside/below the injected marker block after setup. + fs.appendFileSync(agentsMd, '\nUser footnote added after setup.\n'); + + return { dir, agentsMd, cursorMcp }; +} + +afterEach(() => { + for (const dir of dirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('HyperSABMemory — remove command (H1)', () => { + it('strips injected sections but preserves user text outside the markers', () => { + const fixture = seedFixtureWorkspace(); + + const result = removeInjection(fixture.dir); + + const agentsContent = fs.readFileSync(fixture.agentsMd, 'utf-8'); + expect(agentsContent).toContain('# My Project'); + expect(agentsContent).toContain('Custom agent rules live here.'); + expect(agentsContent).toContain('User footnote added after setup.'); + expect(agentsContent).not.toContain('HYPERSABMEMORY:START'); + expect(agentsContent).not.toContain('HYPERSABMEMORY:END'); + expect(agentsContent).not.toContain('HyperSABMemory AI Agent Harness Protocol'); + + expect(result.cleanedFiles.some((f) => f.endsWith('AGENTS.md'))).toBe(true); + }); + + it('keeps other MCP servers while dropping the hypersabmemory entries', () => { + const fixture = seedFixtureWorkspace(); + + removeInjection(fixture.dir); + + const cursorConfig = JSON.parse(fs.readFileSync(fixture.cursorMcp, 'utf-8')) as { + mcpServers: Record; + }; + expect(cursorConfig.mcpServers['other-server']).toEqual({ command: 'uvx', args: ['some-server'] }); + expect(cursorConfig.mcpServers.hypersabmemory).toBeUndefined(); + + for (const rel of ['.mcp.json', path.join('.vscode', 'mcp.json')]) { + const config = JSON.parse(fs.readFileSync(path.join(fixture.dir, rel), 'utf-8')) as { + mcpServers?: Record; + servers?: Record; + }; + expect(config.mcpServers?.hypersabmemory ?? config.servers?.hypersabmemory).toBeUndefined(); + } + }); + + it('removes generated adapter files and the whole store directory', () => { + const fixture = seedFixtureWorkspace(); + + removeInjection(fixture.dir); + + expect(fs.existsSync(path.join(fixture.dir, '.hypersabmemory'))).toBe(false); + expect(fs.existsSync(path.join(fixture.dir, '.cursor', 'rules', 'hypersabmemory.mdc'))).toBe(false); + expect(fs.existsSync(path.join(fixture.dir, 'AGENTS.md'))).toBe(true); + }); + + it('is a safe no-op on an empty workspace without injected files', () => { + const dir = tmpDir(); + + expect(() => removeInjection(dir)).not.toThrow(); + expect(fs.existsSync(path.join(dir, '.hypersabmemory'))).toBe(false); + }); + + it('cleans the injected instructions field out of .claude.json', () => { + const fixture = seedFixtureWorkspace(); + const claudeJson = path.join(fixture.dir, '.claude.json'); + + removeInjection(fixture.dir); + + const payload = JSON.parse(fs.readFileSync(claudeJson, 'utf-8')) as { instructions?: string }; + expect(payload.instructions === undefined || !payload.instructions.includes('HYPERSABMEMORY:START')).toBe(true); + }); +}); diff --git a/tests/seeding.test.ts b/tests/seeding.test.ts new file mode 100644 index 0000000..63026ce --- /dev/null +++ b/tests/seeding.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { deriveBootstrapFacts, seedWorkspace } from '../src/memory/seeding.js'; +import { SqliteStore } from '../src/memory/sqliteStore.js'; +import { OneShotSetupEngine } from '../src/cli/oneShotSetup.js'; + +const dirs: string[] = []; + +afterEach(() => { + vi.restoreAllMocks(); + for (const dir of dirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +function git(cwd: string, args: string[]): string[] { + return execFileSync('git', args, { cwd, encoding: 'utf-8' }) + .split(/\r?\n/) + .filter(Boolean); +} + +/** Fixture workspace: README + package.json + src/tests/docs + git history (2 commits). */ +function makeFixture(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hm-f1-')); + dirs.push(dir); + fs.writeFileSync( + path.join(dir, 'README.md'), + '# Fixture Project\n\nFixture projects remember their first boot.\n', + 'utf-8' + ); + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify( + { + name: 'fixture-project', + description: 'A fixture workspace for seeding tests', + type: 'module', + dependencies: { left: '1.0.0', right: '1.0.0', up: '1.0.0' }, + devDependencies: { typescript: '5.0.0' } + }, + null, + 2 + ), + 'utf-8' + ); + for (const d of ['src', 'tests', 'docs']) { + fs.mkdirSync(path.join(dir, d)); + } + fs.writeFileSync(path.join(dir, 'src', 'a.ts'), 'export const a = 1;\n', 'utf-8'); + git(dir, ['init']); + git(dir, ['config', 'user.email', 'f1@test']); + git(dir, ['config', 'user.name', 'f1']); + git(dir, ['add', '-A']); + git(dir, ['commit', '-m', 'feat: fixture initial commit']); + fs.writeFileSync(path.join(dir, 'README.md'), '# Fixture Project\n\nUpdated paragraph.\n', 'utf-8'); + git(dir, ['add', '-A']); + git(dir, ['commit', '-m', 'docs: update readme']); + return dir; +} + +function revCount(dir: string): number { + return Number(git(dir, ['rev-list', '--count', 'HEAD'])[0]); +} + +describe('F1 deriveBootstrapFacts — cold-start derivation', () => { + it('derives 5-10 tagged seed facts from a fixture workspace', async () => { + // Given: a fixture workspace with README, package.json, layout dirs and git log + const fx = makeFixture(); + + // When: bootstrap facts are derived + const facts = await deriveBootstrapFacts(fx); + + // Then: 5-10 records, every one tagged seeded with source 'seed' + expect(facts.length).toBeGreaterThanOrEqual(5); + expect(facts.length).toBeLessThanOrEqual(10); + for (const fact of facts) { + expect(fact.tags).toContain('seeded'); + expect(fact.source).toBe('seed'); + expect(['architecture', 'general']).toContain(fact.category); + expect(fact.content.trim().length).toBeGreaterThan(0); + } + const all = facts.map((f) => f.content).join('\n'); + expect(all).toContain('fixture-project'); // package.json name + expect(all).toContain('runtime deps'); // stack summary + expect(all).toContain('feat: fixture initial commit'); // git digest + }); +}); + +describe('F1 seedWorkspace — idempotent direct-CAS writes', () => { + it('is idempotent: double-run yields equal counts and the second run writes nothing', async () => { + // Given: a fixture workspace + const fx = makeFixture(); + + // When: seeding runs twice + const first = await seedWorkspace(fx); + const second = await seedWorkspace(fx); + + // Then: first run seeds >=5, second run is inert, totals match + expect(first.seeded).toBeGreaterThanOrEqual(5); + expect(second.seeded).toBe(0); + expect(second.total).toBe(first.total); + }); + + it('never supersedes pre-existing user facts written alongside seeds (reverse direction)', async () => { + // Given: a store already holding an active user fact about the layout + const fx = makeFixture(); + const store = new SqliteStore(path.join(fx, '.hypersabmemory')); + store.set({ + id: 'user_1', + category: 'general', + content: 'fixture-project keeps guides in the docs directory', + status: 'active' + }); + + // When: seeding runs over the same store + await seedWorkspace(fx, store); + + // Then: the user fact stays active and untagged by any supersede + const after = store.get('user_1'); + expect(after?.status).toBe('active'); + expect(after?.tags ?? []).toEqual([]); + }); +}); + +describe('F1 genesis ritual — terminal setup step', () => { + it('writes exactly one genesis record, creates the named checkpoint, prints a valid GENESIS hash', async () => { + // Given: a fresh fixture repo and a captured stdout + const fx = makeFixture(); + const before = revCount(fx); + const logs: string[] = []; + vi.spyOn(console, 'log').mockImplementation((m) => { + logs.push(String(m)); + }); + + // When: one-shot setup executes + const result = await new OneShotSetupEngine(fx).executeSetup(); + + // Then: setup succeeds with exactly one genesis record via direct set + expect(result.success).toBe(true); + const store = new SqliteStore(path.join(fx, '.hypersabmemory')); + const genesis = store.getAll().filter((r) => r.tags?.includes('genesis')); + expect(genesis).toHaveLength(1); + expect(genesis[0].category).toBe('decision'); + expect(genesis[0].content).toMatch(/^GENESIS: workspace memory initialized \d{4}-\d{2}-\d{2}$/); + expect(genesis[0].tags).toContain('seeded'); + expect(genesis[0].source).toBe('seed'); + + // And: exactly ONE genesis-named checkpoint commit exists and rev-list advanced + expect(revCount(fx)).toBeGreaterThan(before); + const genesisSubjects = git(fx, ['log', '--pretty=%s']).filter((s) => + s.includes('genesis: workspace memory baseline') + ); + expect(genesisSubjects).toHaveLength(1); + + // And: the final printed line carries that commit's short hash + const genesisLine = logs.find((l) => l.startsWith('GENESIS ')); + expect(genesisLine).toMatch(/^GENESIS [0-9a-f]{7,40} - this workspace now remembers\.$/); + const printedHash = genesisLine!.split(' ')[1]; + const commitHash = git(fx, [ + 'log', + '--grep=genesis: workspace memory baseline', + '--format=%h', + '-n', + '1' + ])[0]; + expect(printedHash).toBe(commitHash); + }, 60000); + + it('skips the whole ritual on rerun: no new seed/genesis records, no GENESIS output', async () => { + // Given: a workspace that already went through setup once + const fx = makeFixture(); + await new OneShotSetupEngine(fx).executeSetup(); + const storePath = path.join(fx, '.hypersabmemory'); + const before = new SqliteStore(storePath).getAll(); + const logs: string[] = []; + vi.spyOn(console, 'log').mockImplementation((m) => { + logs.push(String(m)); + }); + + // When: setup runs a second time + await new OneShotSetupEngine(fx).executeSetup(); + + // Then: no GENESIS output and the seeded corpus is unchanged + const after = new SqliteStore(storePath).getAll(); + expect(logs.some((l) => l.includes('GENESIS'))).toBe(false); + expect(after.filter((r) => r.tags?.includes('genesis'))).toHaveLength(1); + expect(after.filter((r) => r.source === 'seed')).toHaveLength( + before.filter((r) => r.source === 'seed').length + ); + }, 60000); +}); diff --git a/tests/setupOutput.test.ts b/tests/setupOutput.test.ts new file mode 100644 index 0000000..1e66c32 --- /dev/null +++ b/tests/setupOutput.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import { setupNextStepsFlow } from '../src/cli/setupOutput.js'; + +describe('HyperSABMemory — setup success next-steps flow (H4)', () => { + it('renders the per-host enable steps including the Cursor host mention', () => { + const flow = setupNextStepsFlow(process.cwd()); + + expect(flow).toContain('Direct access is wired'); + expect(flow).toContain('Cursor (.cursor/mcp.json)'); + expect(flow).toContain('Claude Code (.mcp.json)'); + expect(flow).toContain('VS Code (.vscode/mcp.json)'); + }); + + it('is wired into the setup command success output', () => { + const cliSource = fs.readFileSync( + path.resolve(process.cwd(), 'src', 'cli', 'index.ts'), + 'utf-8' + ); + const setupSection = cliSource.split('// Command: setup')[1]?.split('// Command:')[0] ?? ''; + expect(setupSection).toContain('setupNextStepsFlow'); + }); +}); diff --git a/tests/storeContainment.test.ts b/tests/storeContainment.test.ts new file mode 100644 index 0000000..66ee84e --- /dev/null +++ b/tests/storeContainment.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, afterAll } from 'vitest'; +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { OneShotSetupEngine } from '../src/cli/oneShotSetup.js'; + +const dirs: string[] = []; + +function tmpDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hm-contain-')); + dirs.push(dir); + return dir; +} + +function git(cwd: string, args: string[]): string { + return execFileSync('git', args, { cwd, encoding: 'utf-8' }); +} + +function excludeLines(dir: string): string[] { + const raw = fs.readFileSync(path.join(dir, '.git', 'info', 'exclude'), 'utf-8'); + return raw.split(/\r?\n/).filter((line) => line.trim() === '.hypersabmemory/'); +} + +afterAll(() => { + for (const dir of dirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('HyperSABMemory — store containment via .git/info/exclude (B2)', () => { + it('adds .hypersabmemory/ to .git/info/exclude exactly once across double setup', async () => { + // Given: a target project that is a git repository + const dir = tmpDir(); + git(dir, ['init']); + + // When: setup runs twice + const first = await new OneShotSetupEngine(dir).executeSetup(); + const second = await new OneShotSetupEngine(dir).executeSetup(); + + // Then: the exclude rule exists exactly once and setup still succeeds + expect(first.success).toBe(true); + expect(second.success).toBe(true); + expect(excludeLines(dir)).toHaveLength(1); + }, 30000); + + it('succeeds silently when the target has no .git directory', async () => { + // Given: a plain directory with no git repo + const dir = tmpDir(); + + // When: setup runs + const result = await new OneShotSetupEngine(dir).executeSetup(); + + // Then: it completes without crashing + expect(result.success).toBe(true); + }, 30000); + + it('untracks an already-committed .hypersabmemory directory', async () => { + // Given: a repo where a dummy store file is already tracked + const dir = tmpDir(); + git(dir, ['init']); + fs.mkdirSync(path.join(dir, '.hypersabmemory'), { recursive: true }); + fs.writeFileSync(path.join(dir, '.hypersabmemory', 'x.txt'), 'leak', 'utf-8'); + git(dir, ['add', '-A']); + git(dir, ['-c', 'user.email=test@test', '-c', 'user.name=test', 'commit', '-m', 'seed']); + expect(git(dir, ['ls-files', '--', '.hypersabmemory'])).toContain('x.txt'); + + // When: setup runs + const result = await new OneShotSetupEngine(dir).executeSetup(); + + // Then: the store files are no longer tracked + expect(result.success).toBe(true); + expect(git(dir, ['ls-files', '--', '.hypersabmemory']).trim()).toBe(''); + }, 30000); +}); diff --git a/tests/swarmMesh.test.ts b/tests/swarmMesh.test.ts index eabd49b..840d444 100644 --- a/tests/swarmMesh.test.ts +++ b/tests/swarmMesh.test.ts @@ -112,6 +112,43 @@ describe('HyperSABMemory — Swarm Shared Memory Mesh', () => { } }); + it('sync() is read-only: no bytes written and no session persisted', () => { + const dir = makeDir(); + try { + const writer = makeEngine(dir, 1_000).engine; + writer.broadcast('agent-a', 'seed broadcast'); + + const statePath = path.join(dir, '.hypersabmemory', 'swarm_state.json'); + const before = fs.readFileSync(statePath, 'utf-8'); + + const reader = new SwarmMeshEngine(dir, { now: () => new Date(2_000) }); + reader.sync(); + + const after = fs.readFileSync(statePath, 'utf-8'); + expect(after).toBe(before); + const parsed = JSON.parse(after) as { sessions: string[] }; + expect(parsed.sessions).toEqual(['agent-a']); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('prunes dead sessions from persisted state during a mutation', () => { + const dir = makeDir(); + try { + const { engine, advance } = makeEngine(dir, 1_000); + engine.broadcast('agent-a', 'short-lived finding'); + advance(120_000); + engine.broadcast('agent-b', 'replacement finding'); + + const statePath = path.join(dir, '.hypersabmemory', 'swarm_state.json'); + const parsed = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as { sessions: string[] }; + expect(parsed.sessions).toEqual(['agent-b']); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + it('persists state across engine instances on the same workspace', () => { const dir = makeDir(); try { diff --git a/tests/temporalMemory.test.ts b/tests/temporalMemory.test.ts new file mode 100644 index 0000000..baed75d --- /dev/null +++ b/tests/temporalMemory.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { SqliteStore } from '../src/memory/sqliteStore.js'; +import { TemporalMemoryEngine } from '../src/memory/temporalMemory.js'; + +const dirs: string[] = []; + +afterEach(() => { + vi.restoreAllMocks(); + for (const dir of dirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +function tmpWorkspace(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hm-c4a-')); + dirs.push(dir); + return dir; +} + +describe('TemporalMemoryEngine.remember — seeded facts are never superseded (F1)', () => { + it('keeps an active seeded fact untouched when a near-duplicate user fact arrives', () => { + // Given: a seeded architecture fact about topic X in the store + const workspace = tmpWorkspace(); + const seeder = new SqliteStore(path.join(workspace, '.hypersabmemory')); + seeder.set({ + id: 'seed_1', + category: 'architecture', + content: 'Watcher floods the store with architecture facts per changed file', + tags: ['seeded'], + source: 'seed', + status: 'active' + }); + const engine = new TemporalMemoryEngine(workspace); + + // When: a near-duplicate user fact about the same topic is remembered + const user = engine.remember( + 'architecture', + 'Watcher floods the store with architecture facts per changed file (v2)' + ); + + // Then: the seed stays ACTIVE and untouched; the user fact sits alongside it + const persisted = new SqliteStore(path.join(workspace, '.hypersabmemory')); + const seed = persisted.get('seed_1'); + expect(seed?.status).toBe('active'); + expect(seed?.tags).toEqual(['seeded']); + expect(seed?.invalidAt).toBeUndefined(); + expect(persisted.get(user.id)?.status).toBe('active'); + }); +}); + +describe('TemporalMemoryEngine.remember — batched persistence (C4a)', () => { + it('saves the store exactly once for all supersedes when one remember supersedes multiple records', () => { + // Given: three active architecture facts that all duplicate the incoming content + const workspace = tmpWorkspace(); + const seeder = new SqliteStore(path.join(workspace, '.hypersabmemory')); + for (let i = 0; i < 3; i++) { + seeder.set({ + id: `old_${i}`, + category: 'architecture', + content: `Watcher floods the store with architecture facts per changed file (variant ${i})`, + status: 'active' + }); + } + const engine = new TemporalMemoryEngine(workspace); + const saveSpy = vi.spyOn(SqliteStore.prototype, 'saveStore'); + + // When: a single remember() call supersedes all three duplicates + const record = engine.remember( + 'architecture', + 'Watcher floods the store with architecture facts per changed file' + ); + + // Then: exactly ONE save happens after the supersede loop completes. + // The first call is set()'s own inherent persist (pinned contract: set() + // keeps persisting on every call); everything after the loop must be one + // batched save, not one save per superseded record. + expect(saveSpy.mock.calls.length - 1).toBe(1); + + // And semantics are preserved: the single batched save persisted every flip + const persisted = new SqliteStore(path.join(workspace, '.hypersabmemory')); + for (let i = 0; i < 3; i++) { + const old = persisted.get(`old_${i}`); + expect(old?.status).toBe('superseded'); + expect(old?.tags).toContain(`superseded-by:${record.id}`); + expect(old?.invalidAt).toBeTruthy(); + } + expect(persisted.get(record.id)?.status).toBe('active'); + }); + + it('keeps set() as the only save when remember finds no duplicates', () => { + // Given: an empty store and a fresh engine + const workspace = tmpWorkspace(); + const engine = new TemporalMemoryEngine(workspace); + const saveSpy = vi.spyOn(SqliteStore.prototype, 'saveStore'); + + // When: a remember() with no duplicate/contradiction matches runs + const record = engine.remember('decision', 'Unique QUANTUMWIDGET routing fact with no twins'); + + // Then: only set()'s own save happened and the record is active + expect(saveSpy).toHaveBeenCalledTimes(1); + expect(record.status).toBe('active'); + }); +});