Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@ All notable changes to **tre-mem** are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed

- **SessionStart banner no longer vanishes on a project's first session.** Two
causes, both fixed: (1) the sidecar DB opened with no `busy_timeout`, so when the
hook, the MCP server, and the background web daemon all touched
`~/.tre-mem/tre-mem.db` at once on the seeding first session, SQLite failed
_instantly_ with `SQLITE_BUSY` — silently dropping the banner and tripping the
MCP "setup issue". All connections now wait up to 5 s for the lock. (2) The
banner defer (so tre-mem renders below claude-mem) was a fixed 250 ms, too short
for claude-mem's slower first-run onboarding banner; it now defers longer
(1200 ms) on the first session and 250 ms once a project has memory. Override
with `TRE_MEM_HOOK_DELAY_MS`.

## [0.11.0] — 2026-06-07

**Facts you can take back.** Memory was append-only — a graduated or pinned fact
Expand Down
14 changes: 10 additions & 4 deletions docs/plan/PLAN-PHASE8.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,16 @@ session-start`); `dedupeSessionStartHooks(cwd, keep, files?)` collapses to one
(keeps the global copy by default). Both take an injectable file list for tests.
- **`tre doctor [--fix-hooks]`** — reports the duplicate (naming each file) and, with
the flag, collapses it.
- **Ordering** (`src/cli.ts` `runSessionStartHookCli`) — defers the banner by
`sessionHookDelayMs(format)`: env `TRE_MEM_HOOK_DELAY_MS`, default **250 ms** when
claude-mem is present on Claude Code, else 0. Best-effort so tre-mem renders below
claude-mem (which renders in completion order); fully tunable.
- **Ordering** (`src/hooks/defer.ts` `sessionDeferMs`, wired in `src/cli.ts`
`runSessionStartHookCli`) — defers the banner so tre-mem renders below claude-mem
(which renders in completion order). On Claude Code with claude-mem present:
**250 ms** steady state, but **1200 ms** on a project's first session — claude-mem's
longer first-run onboarding banner is slower to render, so the steady delay let
tre-mem race _above_ it (the v0.11.x edge case). "First session" = the project has
no claude-mem memory yet (`ClaudeMemAdapter.listProjects()`). 0 on non-Claude
harnesses or when claude-mem is absent. Env `TRE_MEM_HOOK_DELAY_MS` overrides all
(0 disables). The decision function is pure (probes injected) and unit-tested in
`test/hook-defer.test.ts`.

## Compatibility

Expand Down
3 changes: 2 additions & 1 deletion src/adapter/claude-mem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Database from 'better-sqlite3';
import { existsSync } from 'node:fs';

import { log } from '../log/logger.js';
import { CLAUDE_MEM_DB_PATH } from '../store/paths.js';
import { CLAUDE_MEM_DB_PATH, DB_BUSY_TIMEOUT_MS } from '../store/paths.js';

import type { ListQuery, Observation, PendingMessage, SessionSummary } from './types.js';
import { toSecondsEpoch } from './types.js';
Expand Down Expand Up @@ -42,6 +42,7 @@ export class ClaudeMemAdapter {
);
}
this.db = new Database(this.dbPath, { readonly: true, fileMustExist: true });
this.db.pragma(`busy_timeout = ${DB_BUSY_TIMEOUT_MS}`);
this.db.pragma('query_only = ON');
this.assertSchema();
this.assertColumns();
Expand Down
36 changes: 24 additions & 12 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
promptEnvelope,
sessionStartEnvelope,
} from './hooks/envelope.js';
import { sessionDeferMs } from './hooks/defer.js';
import { type SessionStartInput, runSessionStartHook } from './hooks/session-start.js';
import { type UserPromptSubmitInput, runUserPromptSubmitHook } from './hooks/user-prompt-submit.js';
import { log, logError, logFilePath } from './log/logger.js';
Expand Down Expand Up @@ -1366,18 +1367,23 @@ function pinnedFacts(repo: TreMemRepo, project: string, branch: string): PinnedF
}

/**
* How long to defer emitting the SessionStart banner. claude-mem also prints at
* session start (Claude Code only); deferring briefly makes tre-mem render BELOW
* it instead of racing. Override with `TRE_MEM_HOOK_DELAY_MS` (0 disables).
* Default: 250ms when claude-mem is present on Claude Code, otherwise 0.
* Does claude-mem already hold memory for this project? When it doesn't,
* claude-mem prints its longer first-run onboarding banner on SessionStart, which
* needs more defer time for tre-mem to land below it. Best-effort: any read
* failure maps to `true` (steady-state defer) and never throws.
*/
function sessionHookDelayMs(format: HookFormat): number {
const raw = process.env.TRE_MEM_HOOK_DELAY_MS;
if (raw !== undefined && raw.trim() !== '') {
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) && n >= 0 ? n : 0;
function claudeMemHasProject(project: string): boolean {
const cm = diagnoseClaudeMem();
if (!cm.installed || !cm.compatible) return true;
let adapter: ClaudeMemAdapter | null = null;
try {
adapter = new ClaudeMemAdapter();
return adapter.listProjects().includes(project);
} catch {
return true;
} finally {
adapter?.close();
}
return format === 'claude' && existsSync(CLAUDE_MEM_DB_PATH) ? 250 : 0;
}

async function runSessionStartHookCli(format: HookFormat): Promise<void> {
Expand All @@ -1393,8 +1399,14 @@ async function runSessionStartHookCli(format: HookFormat): Promise<void> {
pinned: ({ project, branch }) => pinnedFacts(repo, project, branch),
dashboardUrl,
});
// Defer so claude-mem's banner lands first (see sessionHookDelayMs).
const delay = sessionHookDelayMs(format);
// Defer so claude-mem's banner lands first — longer on the first session,
// whose onboarding banner is slower to render (see sessionDeferMs).
const delay = sessionDeferMs({
format,
claudeMemPresent: existsSync(CLAUDE_MEM_DB_PATH),
claudeMemHasProject: claudeMemHasProject(result.project),
envOverride: process.env.TRE_MEM_HOOK_DELAY_MS,
});
if (delay > 0) await new Promise((r) => setTimeout(r, delay));
process.stdout.write(
`${JSON.stringify(sessionStartEnvelope(format, result.message, result.display))}\n`,
Expand Down
43 changes: 43 additions & 0 deletions src/hooks/defer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { HookFormat } from './envelope.js';

/**
* Steady-state defer (ms). Once a project has claude-mem memory, its SessionStart
* banner renders fast, so a short defer is enough for tre-mem to land below it.
*/
export const STEADY_DEFER_MS = 250;

/**
* First-session defer (ms). With no memory yet, claude-mem prints its longer
* first-run onboarding banner, which takes more than the steady delay to render —
* so tre-mem must wait longer to reliably land BELOW it rather than racing above.
*/
export const FIRST_SESSION_DEFER_MS = 1200;

export interface DeferInput {
format: HookFormat;
/** claude-mem is present on disk (its banner will print on Claude Code). */
claudeMemPresent: boolean;
/** claude-mem already holds memory for this project (steady-state, fast banner). */
claudeMemHasProject: boolean;
/** Raw `TRE_MEM_HOOK_DELAY_MS` value, when set. */
envOverride?: string;
}

/**
* How long to defer the SessionStart banner so tre-mem renders BELOW claude-mem.
*
* Pure by design — all disk/env probing happens in the caller and is passed in,
* so the ordering policy is unit-testable. An explicit `TRE_MEM_HOOK_DELAY_MS`
* always wins (0 disables; blank/malformed is ignored). Otherwise only Claude
* Code with claude-mem present defers at all, and the first session of a project
* (claude-mem's slower onboarding banner) defers longer than steady state.
*/
export function sessionDeferMs(input: DeferInput): number {
const raw = input.envOverride;
if (raw !== undefined && raw.trim() !== '') {
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) && n >= 0 ? n : 0;
}
if (input.format !== 'claude' || !input.claudeMemPresent) return 0;
return input.claudeMemHasProject ? STEADY_DEFER_MS : FIRST_SESSION_DEFER_MS;
}
3 changes: 2 additions & 1 deletion src/store/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { mkdirSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

import { TRE_MEM_DB_PATH } from './paths.js';
import { DB_BUSY_TIMEOUT_MS, TRE_MEM_DB_PATH } from './paths.js';

const __dirname = dirname(fileURLToPath(import.meta.url));

Expand Down Expand Up @@ -124,6 +124,7 @@ export function migrate(dbPath: string = TRE_MEM_DB_PATH): MigrateResult {

const db = new Database(dbPath);
try {
db.pragma(`busy_timeout = ${DB_BUSY_TIMEOUT_MS}`);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');

Expand Down
10 changes: 10 additions & 0 deletions src/store/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,13 @@ export const CLAUDE_MEM_HOME =

export const CLAUDE_MEM_DB_PATH = join(CLAUDE_MEM_HOME, 'claude-mem.db');
export const CLAUDE_MEM_CHROMA_DIR = join(CLAUDE_MEM_HOME, 'chroma');

/**
* How long a SQLite connection waits for a lock before giving up with
* `SQLITE_BUSY`. At session start several short-lived `tre` processes touch the
* sidecar DB at once — the SessionStart hook (migrate + branch_state write), the
* MCP server, and the background web daemon (WAL checkpoint). Without a busy
* timeout SQLite fails instantly, which silently drops the banner and trips the
* MCP "setup issue". A few seconds of patient waiting absorbs that contention.
*/
export const DB_BUSY_TIMEOUT_MS = 5000;
8 changes: 7 additions & 1 deletion src/store/repo.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Database from 'better-sqlite3';

import { TRE_MEM_DB_PATH } from './paths.js';
import { DB_BUSY_TIMEOUT_MS, TRE_MEM_DB_PATH } from './paths.js';

export interface BranchState {
cwd: string;
Expand Down Expand Up @@ -73,6 +73,7 @@ export class TreMemRepo {
constructor(opts: RepoOptions = {}) {
this.dbPath = opts.dbPath ?? TRE_MEM_DB_PATH;
this.db = new Database(this.dbPath);
this.db.pragma(`busy_timeout = ${DB_BUSY_TIMEOUT_MS}`);
this.db.pragma('journal_mode = WAL');
this.db.pragma('foreign_keys = ON');
}
Expand All @@ -83,6 +84,11 @@ export class TreMemRepo {
this.closed = true;
}

/** Effective `busy_timeout` (ms) on this connection — lets tests assert the lock-wait. */
busyTimeoutMs(): number {
return this.db.pragma('busy_timeout', { simple: true }) as number;
}

upsertBranchState(state: BranchState): void {
this.db
.prepare(
Expand Down
45 changes: 45 additions & 0 deletions test/hook-defer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';

import { FIRST_SESSION_DEFER_MS, STEADY_DEFER_MS, sessionDeferMs } from '../src/hooks/defer.js';

const base = {
format: 'claude' as const,
claudeMemPresent: true,
claudeMemHasProject: true,
};

describe('sessionDeferMs', () => {
it('defers the steady delay on Claude Code once a project has claude-mem memory', () => {
expect(sessionDeferMs(base)).toBe(STEADY_DEFER_MS);
});

it('defers longer on the first session — claude-mem prints its slower onboarding banner', () => {
expect(sessionDeferMs({ ...base, claudeMemHasProject: false })).toBe(FIRST_SESSION_DEFER_MS);
});

it('does not defer when claude-mem is absent (nothing to render below)', () => {
expect(sessionDeferMs({ ...base, claudeMemPresent: false })).toBe(0);
expect(sessionDeferMs({ ...base, claudeMemPresent: false, claudeMemHasProject: false })).toBe(
0,
);
});

it('does not defer on non-Claude harnesses (codex/gemini have no racing banner)', () => {
expect(sessionDeferMs({ ...base, format: 'codex' })).toBe(0);
expect(sessionDeferMs({ ...base, format: 'gemini', claudeMemHasProject: false })).toBe(0);
});

it('honours an explicit TRE_MEM_HOOK_DELAY_MS override over every heuristic', () => {
expect(sessionDeferMs({ ...base, envOverride: '750' })).toBe(750);
// Override wins even on the first-session path…
expect(sessionDeferMs({ ...base, claudeMemHasProject: false, envOverride: '0' })).toBe(0);
// …and even when claude-mem is absent.
expect(sessionDeferMs({ ...base, claudeMemPresent: false, envOverride: '500' })).toBe(500);
});

it('treats a blank or malformed override as no override / disabled', () => {
expect(sessionDeferMs({ ...base, envOverride: ' ' })).toBe(STEADY_DEFER_MS);
expect(sessionDeferMs({ ...base, envOverride: 'nope' })).toBe(0);
expect(sessionDeferMs({ ...base, envOverride: '-5' })).toBe(0);
});
});
9 changes: 9 additions & 0 deletions test/repo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { migrate } from '../src/store/migrate.js';
import { DB_BUSY_TIMEOUT_MS } from '../src/store/paths.js';
import { TreMemRepo } from '../src/store/repo.js';

describe('TreMemRepo branch_tag', () => {
Expand All @@ -22,6 +23,14 @@ describe('TreMemRepo branch_tag', () => {
rmSync(tmp, { recursive: true, force: true });
});

it('opens with a busy_timeout so concurrent tre processes wait for the lock', () => {
// Without this, the SessionStart hook, MCP server, and web daemon hitting the
// sidecar DB at once fail instantly with SQLITE_BUSY — dropping the banner and
// tripping the MCP "setup issue".
expect(repo.busyTimeoutMs()).toBe(DB_BUSY_TIMEOUT_MS);
expect(repo.busyTimeoutMs()).toBeGreaterThan(0);
});

it('upsertBranchTag inserts new rows and reports hasBranchTag', () => {
expect(repo.hasBranchTag(42)).toBe(false);
repo.upsertBranchTag({
Expand Down
Loading