Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
d7cfafa
fix(B2): contain memory store via .git/info/exclude + untrack + hones…
framesxsab Aug 23, 2026
589051c
fix(B4): emit cmd /c wrapper for npx MCP launch on win32
framesxsab Aug 23, 2026
906f736
fix(B6): harden dashboard POSTs against CSRF/DNS-rebind (host+content…
framesxsab Aug 23, 2026
7fe77df
chore(T1): drop redundant advisory eval job from CI
framesxsab Aug 23, 2026
792bc56
docs(T1): align README benchmark wording with blocking CI gate
framesxsab Aug 23, 2026
6266cb0
fix(B1): stop overwriting repo-local git identity; use per-commit -c …
framesxsab Aug 23, 2026
ccff921
fix(B3): route autocommit logs away from MCP stdio stdout
framesxsab Aug 23, 2026
9f16fb3
fix(B5): bail checkpoints during merge/rebase; refuse monorepo subdir…
framesxsab Aug 23, 2026
4e59f69
fix(B2): exclude .hypersabmemory from staged adds via pathspec
framesxsab Aug 23, 2026
b5560d0
test(C3): golden memory_store.json format safety net
framesxsab Aug 23, 2026
7c71d1b
perf(C4): single store save per remember instead of per-supersede
framesxsab Aug 23, 2026
d2eb6f7
fix(C2): serialize memory store mutations via file-lock CAS
framesxsab Aug 23, 2026
a775635
perf(C4): extend watcher ignores + awaitWriteFinish to cut event flood
framesxsab Aug 23, 2026
eda5b21
fix(C2): serialize swarm mesh state via file-lock CAS; read-only sync
framesxsab Aug 23, 2026
c89a0cb
feat(F7): expose context block and memory log as MCP resources
framesxsab Aug 23, 2026
30dc466
chore(H2): ship CHANGELOG and SECURITY in npm tarball
framesxsab Aug 23, 2026
5244717
chore(H3): funding file + tag-triggered release workflow
framesxsab Aug 23, 2026
8fe89a4
feat(F2): handoff command with forward brief and --story mode
framesxsab Aug 23, 2026
cacc755
feat(F4): adversarial eval classes - negation, superseded-stale, arti…
framesxsab Aug 23, 2026
337a2bb
feat(F3): doctor verifies MCP launch via stdio handshake + factual co…
framesxsab Aug 23, 2026
b8a7ef8
feat(H1): remove command for clean uninstall
framesxsab Aug 23, 2026
1622490
chore(H4): print per-host enable steps from setup success output
framesxsab Aug 23, 2026
50730aa
feat(F1): cold-start bootstrap seeding + genesis ritual
framesxsab Aug 23, 2026
6051da1
test(B2): explicit timeouts for full-setup integration tests
framesxsab Aug 23, 2026
5a179a9
feat(F5): swarm visibility cards - /api/swarm endpoint + dashboard cards
framesxsab Aug 23, 2026
42f5ce4
feat(F8): ghost paths - warn when edits collide with rolled-back chec…
framesxsab Aug 23, 2026
14e6754
chore(release): v1.4.0
framesxsab Aug 23, 2026
9c810e5
chore: ignore local npm pack tarballs
framesxsab Aug 23, 2026
cd4b936
fix(B5): canonicalize repo-root comparison - native realpath resolves…
framesxsab Aug 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 4 additions & 15 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.

33 changes: 33 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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 }}
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,6 @@ GRAPH_REPORT.md

# UI design-tournament artifacts (local only)
.design/

# local npm pack artifacts
*.tgz
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions FUNDING.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
github: framesxsab
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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 |
|---|---|
Expand Down Expand Up @@ -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).

Expand Down
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -51,7 +51,9 @@
"dist",
"README.md",
"LICENSE",
"CONTRIBUTING.md"
"CONTRIBUTING.md",
"CHANGELOG.md",
"SECURITY.md"
],
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
Expand Down
19 changes: 19 additions & 0 deletions src/cli/cliUtils.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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 [];
}
}
184 changes: 181 additions & 3 deletions src/cli/doctor.ts
Original file line number Diff line number Diff line change
@@ -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);

Expand All @@ -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<DoctorCheck> {
Expand Down Expand Up @@ -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<string, unknown>;
const bucket = payload[candidate.key];
if (!bucket || typeof bucket !== 'object' || Array.isArray(bucket)) continue;
const entry = (bucket as Record<string, unknown>).hypersabmemory;
if (!entry || typeof entry !== 'object') continue;
const record = entry as Record<string, unknown>;
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<DoctorCheck> {
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
}
}
});
Comment on lines +169 to +185

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<DoctorCounters> {
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<DoctorCheck> {
return new Promise((resolve) => {
const probe = net.createServer();
Expand All @@ -86,14 +262,16 @@ function checkDashboardPort(port: number): Promise<DoctorCheck> {
* 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<DoctorReport> {
export async function runDoctor(cwd: string = process.cwd(), options: DoctorOptions = {}): Promise<DoctorReport> {
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 };
}
Loading
Loading