Skip to content
Closed
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ All notable changes to engram are documented here. The format follows

## [Unreleased]

### Fixed
- Tenant-scope isolation batch (#106, #107, #108, #109, #110, #111, #112). `memory_suppressions` is now keyed on `(content_hash, scope)` — new checkpointed migration `suppression_scope_v1`, additive for an older build — so one tenant's `forget` no longer suppresses dream re-extraction of the same sentence for every tenant, and only a matching-scope `remember` (or a global one) lifts it; `filterSuppressedFacts` takes the conversation's scope, `clearSuppression`/`restore` delete only their own scope's row, and a `global` suppression still applies everywhere. `resolveCallScoping` treats `ENGRAM_READ_SCOPES` / `ENGRAM_SCOPE` as a ceiling: a call's `read_scopes` is intersected with the env list (no overlap → error) and a call's `scope` must be one of the readable scopes, so an env-pinned stdio child can no longer read or write another tenant with one argument (an unpinned server — the shared HTTP daemon — is unchanged). `forget`'s `scope: "global"` override is honoured only on a server the env does not pin. Recall sessions keep the scopes they were created with, so a refine cannot merge another tenant's results into a session. `linkMemoryToEntities` stamps new `related_to` edges with the caller's scope and widens an existing edge seen from a second scope, instead of always writing `global`. `scopeInClause` coalesces a NULL scope column to `'global'` like `scopeVisible` does. `ftsIndexedRowids` reads `memories_fts_docsize` instead of an `fts5vocab('instance')` scan, so a forgotten memory whose text tokenizes to nothing is audited and repaired by `engram validate --fix`.

## [0.4.0] - 2026-09-18

First release that ships the Claude Code plugin, the `engram mcp` daemon bridge, `engram mcp install`, `forget`, `engram setup`, `doctor --fix` and `update --rollback` (PRD issues #50 #53 #55 #58 #61 #63 #65) plus the Dependabot majors (#68–#73, #72). The 0.3.0 tarball on npm predates all of it, so the plugin's `npx -y @devinmlowe/engram@<version> mcp` pin only works from this version on. Windows + Node 22 note: see the better-sqlite3 13 entry below (#84).
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Shared infrastructure in `_core/` (config, db, types, embeddings, search, llm, c
- **show** — Retrieve full conversation or memory context
- **explore** — Fixed-depth graph traversal from an entity
- **reflect** — Graph analysis — communities, bridges, temporal patterns (health reports `stale_nodes` — entities flagged by `forget`, pruned on the next dream run)
- **forget** — Remove a memory the user says is wrong or stale (#55). `memory_id` (the `id` attribute on every recalled `<semantic>`) acts in one call; `query` runs semantic search and returns candidates with ids, acting only with `confirm: true` and exactly one match (a single result, or one whose content equals the query). Soft delete (#56): `is_active = 0` + `deleted_at`/`deleted_by`, vector + FTS rows removed immediately, a `memory_changes` row (`before` = content, actor = the MCP client's `clientInfo.name`), and a `memory_suppressions` content hash so dream extract does not re-extract it; the dream prune phase hard-deletes after `ENGRAM_FORGET_RETENTION_DAYS`. `hard: true` deletes outright. Graph (#57): decrements `mention_count` on evidenced entities and stamps `stale_since` at zero; never deletes graph rows (`pruneOrphanEntities` does, next run). `readOnlyHint: false`, `destructiveHint: true`; honours `read_scopes` unless `scope: "global"`.
- **forget** — Remove a memory the user says is wrong or stale (#55). `memory_id` (the `id` attribute on every recalled `<semantic>`) acts in one call; `query` runs semantic search and returns candidates with ids, acting only with `confirm: true` and exactly one match (a single result, or one whose content equals the query). Soft delete (#56): `is_active = 0` + `deleted_at`/`deleted_by`, vector + FTS rows removed immediately, a `memory_changes` row (`before` = content, actor = the MCP client's `clientInfo.name`), and a `memory_suppressions` row keyed `(content_hash, scope)` so dream extract does not re-extract it in that scope (#106; a `global` suppression applies everywhere); the dream prune phase hard-deletes after `ENGRAM_FORGET_RETENTION_DAYS`. `hard: true` deletes outright. Graph (#57): decrements `mention_count` on evidenced entities and stamps `stale_since` at zero; never deletes graph rows (`pruneOrphanEntities` does, next run). `readOnlyHint: false`, `destructiveHint: true`; honours `read_scopes` unless `scope: "global"` — and that override is ignored on a server the env pins to a tenant (#109).

### Memory & Knowledge (Phase 6 RLM)

Expand All @@ -46,7 +46,7 @@ Shared infrastructure in `_core/` (config, db, types, embeddings, search, llm, c

- **ingest_turn** — Record one user/assistant turn of an external agent session (`session_id`, `turn_index`, `scope`, `user_text`, `assistant_text`); idempotent upsert, extracted memories inherit the scope. 16 tools total; `src/interfaces/mcp/tool-names.ts` is canonical.

Recall tools (`recall`, `recall_session`, `recall_drill`) reinforce returned memories (FSRS bookkeeping only) and keep `readOnlyHint: true`; `reinforce: false` opts out. `recall`/`recall_session`/`remember`/`remember_batch`/`explore`/`explore_selective`/`commitments` accept per-call `scope` / `read_scopes` over the `ENGRAM_SCOPE` / `ENGRAM_READ_SCOPES` defaults. Since #25 `scope` lives on memories, conversations, exchanges, entities, relationships and commitments (`src/_core/db/scope.ts`): episodic recall, graph search, explore and the commitments ledger filter by `read_scopes`; dream extraction stamps entities/relationships/commitments with the conversation's scope, and a graph row seen from a second scope widens to `global`. `reflect` stays global.
Recall tools (`recall`, `recall_session`, `recall_drill`) reinforce returned memories (FSRS bookkeeping only) and keep `readOnlyHint: true`; `reinforce: false` opts out. `recall`/`recall_session`/`remember`/`remember_batch`/`explore`/`explore_selective`/`commitments` accept per-call `scope` / `read_scopes` over the `ENGRAM_SCOPE` / `ENGRAM_READ_SCOPES` defaults. #108: when the env sets them it is a ceiling, not a default — a call's `read_scopes` is intersected with it (no overlap → error) and a call's `scope` must be one of the readable scopes; with no env scoping the params are the tenant identity (shared HTTP daemon). A recall session keeps the scopes it was created with (#110). Since #25 `scope` lives on memories, conversations, exchanges, entities, relationships and commitments (`src/_core/db/scope.ts`): episodic recall, graph search, explore and the commitments ledger filter by `read_scopes`; dream extraction stamps entities/relationships/commitments with the conversation's scope, and a graph row seen from a second scope widens to `global`. `reflect` stays global.

### Recommended Workflow

Expand Down
2 changes: 2 additions & 0 deletions src/_core/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ export {
backfillEventTs,
migrateCommitments,
migrateForget,
migrateSuppressionScope,
COMMITMENTS_MIGRATION,
FORGET_MIGRATION,
SUPPRESSION_SCOPE_MIGRATION,
MEMORY_CHANGE_OPS,
EVENT_TS_SUBQUERY,
SCHEMA_MIGRATIONS,
Expand Down
54 changes: 50 additions & 4 deletions src/_core/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,7 @@ function createSchema(db: Database.Database, config: EngramConfig): void {
// the graph `stale_since` flags (#56/#57). Must run AFTER
// migrateExpandedTypes (entities/relationships rebuild).
migrateForget(db);
migrateSuppressionScope(db);

// FTS5 virtual tables (created separately — can't use IF NOT EXISTS)
createFtsIfNeeded(db, "exchanges_fts", `
Expand Down Expand Up @@ -785,17 +786,61 @@ export function migrateForget(db: Database.Database): boolean {
CREATE INDEX IF NOT EXISTS idx_memory_changes_at ON memory_changes(at);

CREATE TABLE IF NOT EXISTS memory_suppressions (
content_hash TEXT PRIMARY KEY,
content_hash TEXT NOT NULL,
memory_id TEXT,
scope TEXT DEFAULT 'global',
created_at TEXT NOT NULL
scope TEXT NOT NULL DEFAULT 'global',
created_at TEXT NOT NULL,
PRIMARY KEY (content_hash, scope)
);
`);
if (!hadChanges || !hadSuppressions) added = true;
db.prepare("INSERT OR IGNORE INTO schema_migrations (name) VALUES (?)").run(FORGET_MIGRATION);
return added;
}

/** Checkpoint name recorded when memory_suppressions becomes per-scope. */
export const SUPPRESSION_SCOPE_MIGRATION = "suppression_scope_v1";

/**
* #106: `memory_suppressions` was keyed on `content_hash` alone, so one
* tenant's forget suppressed dream re-extraction of the same sentence for
* every tenant (and any tenant's remember lifted it). Rebuild the table with
* `PRIMARY KEY (content_hash, scope)`, carrying the existing rows over.
* Additive for an older build: the columns are unchanged.
*/
export function migrateSuppressionScope(db: Database.Database): boolean {
db.exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at INTEGER DEFAULT (unixepoch())
)
`);
const ddl = db
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'memory_suppressions'")
.get() as { sql: string } | undefined;
let added = false;
if (ddl && !/PRIMARY KEY \(content_hash, scope\)/.test(ddl.sql)) {
db.transaction(() => {
db.exec(`
CREATE TABLE memory_suppressions_new (
content_hash TEXT NOT NULL,
memory_id TEXT,
scope TEXT NOT NULL DEFAULT 'global',
created_at TEXT NOT NULL,
PRIMARY KEY (content_hash, scope)
);
INSERT OR REPLACE INTO memory_suppressions_new (content_hash, memory_id, scope, created_at)
SELECT content_hash, memory_id, COALESCE(scope, 'global'), created_at FROM memory_suppressions;
DROP TABLE memory_suppressions;
ALTER TABLE memory_suppressions_new RENAME TO memory_suppressions;
`);
}).immediate();
added = true;
}
db.prepare("INSERT OR IGNORE INTO schema_migrations (name) VALUES (?)").run(SUPPRESSION_SCOPE_MIGRATION);
return added;
}

// ─── schema version (#65) ────────────────────────────────────────────

/**
Expand All @@ -810,9 +855,10 @@ export const SCHEMA_MIGRATIONS = [
CONVERSATIONS_SCOPE_MIGRATION,
EXCHANGES_AUTHOR_MIGRATION,
FORGET_MIGRATION,
SUPPRESSION_SCOPE_MIGRATION,
] as const;

/** Number of checkpointed migrations this build applies (5 as of `forget_v1`). */
/** Number of checkpointed migrations this build applies (6 as of `suppression_scope_v1`). */
export const SCHEMA_VERSION: number = SCHEMA_MIGRATIONS.length;

/**
Expand Down
19 changes: 14 additions & 5 deletions src/_core/db/scope.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
/**
* Tenant-scope helpers shared by every scoped table (#25).
*
* Rows carry `scope` ('global' or e.g. 'hermes:career'). A read with
* `scopes` set sees only rows whose scope is in the list; without it, every
* scope (single-tenant behaviour). Graph rows are shared knowledge: an
* Rows carry `scope` ('global' or e.g. 'hermes:career'; a NULL column reads
* as 'global'). A read with `scopes` set sees only rows whose scope is in the
* list; without it, every scope (single-tenant behaviour). Graph rows are shared knowledge: an
* entity or edge first seen from one profile and later from another is
* *widened* to 'global' rather than duplicated per tenant.
*/
Expand All @@ -16,10 +16,19 @@ export function scopeVisible(rowScope: string | null | undefined, scopes?: reado
return scopes.includes(rowScope ?? GLOBAL_SCOPE);
}

/** `col IN (?, ?)` fragment + params, or null when unfiltered. */
/**
* `COALESCE(col, 'global') IN (?, ?)` fragment + params, or null when
* unfiltered. #107: the COALESCE keeps this in step with `scopeVisible`,
* which reads a NULL scope as 'global'; a bare `col IN (...)` is never true
* for NULL, so such a row would be visible to one reader and hidden from the
* other.
*/
export function scopeInClause(col: string, scopes?: readonly string[]): { sql: string; params: string[] } | null {
if (!scopes || scopes.length === 0) return null;
return { sql: `${col} IN (${scopes.map(() => "?").join(", ")})`, params: [...scopes] };
return {
sql: `COALESCE(${col}, '${GLOBAL_SCOPE}') IN (${scopes.map(() => "?").join(", ")})`,
params: [...scopes],
};
}

/**
Expand Down
9 changes: 8 additions & 1 deletion src/_core/search/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ export interface RecallSession {
expandedIds: Set<string>;
totalBudgetUsed: number;
maxBudget: number;
/**
* Tenant scopes the session was created with (#110). Every refine reuses
* them, so results from another tenant can never be merged into a session
* a scoped caller later drills into. Undefined = unscoped (single-tenant).
*/
scopes?: string[];
}

export interface DrillResult {
Expand Down Expand Up @@ -79,7 +85,7 @@ export class SessionStore {
/**
* Create a new recall session.
*/
create(query: string, options?: { maxBudget?: number }): RecallSession {
create(query: string, options?: { maxBudget?: number; scopes?: string[] }): RecallSession {
this.evictExpired();

// Evict oldest (LRU) if at capacity
Expand All @@ -99,6 +105,7 @@ export class SessionStore {
expandedIds: new Set(),
totalBudgetUsed: 0,
maxBudget: options?.maxBudget ?? 3000,
scopes: options?.scopes,
};

this.sessions.set(id, session);
Expand Down
5 changes: 3 additions & 2 deletions src/dream/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -971,7 +971,8 @@ function getConversationScope(db: Database.Database, conversationId: string): st
/**
* #55: drop extracted facts whose content hash is in memory_suppressions — a
* statement the user forgot must not come back from the same exchanges on
* the next run. Counted in the report as `suppressedFacts`.
* the next run. Counted in the report as `suppressedFacts`. #106: only this
* conversation's own scope (and 'global') suppresses.
*/
function dropSuppressed(
db: Database.Database,
Expand All @@ -980,7 +981,7 @@ function dropSuppressed(
logPath: string,
report: DreamReport,
): ExtractedFact[] {
const { kept, suppressed } = filterSuppressedFacts(db, facts);
const { kept, suppressed } = filterSuppressedFacts(db, facts, getConversationScope(db, conversationId));
if (suppressed.length > 0) {
report.suppressedFacts = (report.suppressedFacts ?? 0) + suppressed.length;
logEntry(logPath, "extract", `Suppressed ${suppressed.length} forgotten fact(s) re-extracted from ${conversationId}`, {
Expand Down
19 changes: 18 additions & 1 deletion src/interfaces/mcp/scoping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ function requireScope(value: string, param: string): string {
* - `scope` overrides ENGRAM_SCOPE. When ENGRAM_READ_SCOPES is unset it also
* re-derives the read default to global + own, mirroring the env rule.
* - `read_scopes` overrides ENGRAM_READ_SCOPES (and the derived default).
*
* #108: the env is a ceiling, never a default the client may raise. When the
* env restricts reads, `read_scopes` is intersected with it (empty → throw)
* and `scope` must be one of the readable scopes (write only where you may
* read). With no env restriction the params are the only tenant identity
* (shared HTTP daemon) and apply as given.
*/
export function resolveCallScoping(
env: Record<string, string | undefined>,
Expand All @@ -77,12 +83,23 @@ export function resolveCallScoping(

const writeScope = params.scope !== undefined ? requireScope(params.scope, "scope") : base.writeScope;

let readScopes = base.readScopes;
const allowed = base.readScopes;
if (allowed && writeScope !== undefined && params.scope !== undefined && !allowed.includes(writeScope)) {
throw new Error(`scope "${writeScope}" is outside this server's read scopes (${allowed.join(", ")})`);
}

let readScopes = allowed;
if (params.read_scopes !== undefined) {
if (!Array.isArray(params.read_scopes) || params.read_scopes.length === 0) {
throw new Error("read_scopes must contain at least one scope");
}
readScopes = params.read_scopes.map((s) => requireScope(s, "read_scopes"));
if (allowed) {
readScopes = readScopes.filter((s) => allowed.includes(s));
if (readScopes.length === 0) {
throw new Error(`read_scopes has no scope in common with this server's read scopes (${allowed.join(", ")})`);
}
}
} else if (params.scope !== undefined && !env.ENGRAM_READ_SCOPES?.trim()) {
readScopes = ["global", writeScope as string];
}
Expand Down
17 changes: 12 additions & 5 deletions src/interfaces/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { updateHealthField } from "../cli/update-check.js";
import { escapeXml } from "../../_core/search/index.js";
import { initEmbeddings } from "../../_core/embeddings/index.js";
import { rememberFact, storeMemoryBatch } from "../shared/remember.js";
import { resolveCallScoping } from "./scoping.js";
import { getTenantScoping, resolveCallScoping } from "./scoping.js";
import { ingestTurn, DEFAULT_TURN_SOURCE } from "../../episodic/ingest-turn.js";
import {
forgetMemory,
Expand Down Expand Up @@ -1292,7 +1292,8 @@ export const MCP_TOOL_DEFINITIONS: Tool[] = [
"out of every recall path immediately, is logged in the change log with " +
"this client's name, and will not be re-extracted from the same " +
"conversation. hard: true deletes it outright. Only memories within " +
"read_scopes can be forgotten unless scope is \"global\".",
"read_scopes can be forgotten; scope \"global\" reaches any scope, but " +
"only on a server whose env does not pin it to one tenant.",
inputSchema: {
type: "object",
properties: {
Expand Down Expand Up @@ -1321,7 +1322,8 @@ export const MCP_TOOL_DEFINITIONS: Tool[] = [
minLength: 1,
description:
"Tenant identity for this call (e.g. \"hermes:career\"); reads default to " +
"global + this scope. \"global\" acts on a memory in any scope.",
"global + this scope. \"global\" acts on a memory in any scope " +
"(unless the server env pins it to one tenant).",
},
read_scopes: {
type: "array",
Expand Down Expand Up @@ -1894,14 +1896,19 @@ async function handleForget(params: ForgetParams, context?: ToolCallContext): Pr
const actor = context?.clientName ?? UNKNOWN_MCP_ACTOR;
const scoping = resolveCallScoping(process.env, params);
const db = getDb();
// #109: `scope: "global"` acts on a memory in any scope. Honour that only
// for a server the env does not pin to a tenant — otherwise an env-pinned
// child (Hermes stdio) could delete another tenant's memories with one arg.
const envPinned = getTenantScoping(process.env).readScopes !== undefined;
const callScope = envPinned ? undefined : params.scope;

const act = (memoryId: string): ToolResult => {
const result = forgetMemory(db, {
memoryId,
actor,
hard: params.hard,
readScopes: scoping.readScopes,
scope: params.scope,
scope: callScope,
});
return { content: [{ type: "text", text: formatForgottenXml(result, actor) }] };
};
Expand All @@ -1923,7 +1930,7 @@ async function handleForget(params: ForgetParams, context?: ToolCallContext): Pr
mode: "hybrid",
limit: FORGET_CANDIDATE_LIMIT,
budget: 4000,
scopes: params.scope === "global" ? undefined : scoping.readScopes,
scopes: callScope === "global" ? undefined : scoping.readScopes,
reinforce: false,
},
config,
Expand Down
Loading
Loading