diff --git a/README.md b/README.md index 3001c74..f50be80 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,35 @@ All three systems share the same mental model: Memories are deduplicated at write time. Conflicting memories (same topic, different content) are automatically resolved — the newer one supersedes the older one. +### Time-based confidence decay + +Stale memories don't sit at 0.5 forever. At recall time, confidence decays exponentially based on how recently the memory was created or last recalled: + +``` +decayedConfidence = storedConfidence × 0.5^(daysSince / 90) +``` + +A memory untouched for 90 days has its effective confidence halved. Recalling a memory resets its decay clock — actively used knowledge stays fresh. Stored confidence is never mutated by decay; only `confirm()` and `reject()` change the stored value. + +### Agent attribution + +Track which agent stored a memory with the optional `source` parameter: + +```ts +await mem.remember('always use pnpm', { source: 'deploy-agent' }) +``` + +### Anti-patterns (deja-local & deja-edge) + +When a memory is rejected enough that its confidence drops below 0.15, it auto-inverts into an **anti-pattern** — a warning that actively surfaces during recall: + +``` +Before: "use eval for JSON parsing" (confidence: 0.05, type: "memory") +After: "KNOWN PITFALL: use eval for JSON parsing" (confidence: 0.5, type: "anti-pattern") +``` + +Negative knowledge is as valuable as positive knowledge. Anti-patterns participate in recall normally and warn agents away from known mistakes. + ## Hosted service API The hosted service adds features beyond basic memory: diff --git a/marketing/src/content/patterns/memory-hygiene.mdx b/marketing/src/content/patterns/memory-hygiene.mdx index eec392d..e258804 100644 --- a/marketing/src/content/patterns/memory-hygiene.mdx +++ b/marketing/src/content/patterns/memory-hygiene.mdx @@ -32,6 +32,34 @@ Deja uses vector similarity to match memories. As the store grows, semantically Memories that haven't been recalled gradually lose confidence, making room for fresher knowledge. +**deja-local and deja-edge now have built-in time-based decay.** No cron jobs, no manual scripts — decay is computed automatically at recall time using an exponential half-life formula: + +``` +decayedConfidence = storedConfidence × 0.5^(daysSince / 90) +``` + +- A memory untouched for 90 days has its effective confidence halved +- Recalling a memory resets its decay clock — actively used knowledge stays fresh +- Stored confidence is never mutated by decay; only `confirm()` and `reject()` change the stored value +- No background jobs or cron — decay is a pure read-side computation + +```typescript +// deja-local: decay is automatic — just use recall() normally +import { createMemory } from 'deja-local' + +const mem = createMemory({ path: './agent.db' }) + +// This memory will naturally decay over time if not recalled +await mem.remember('always run migrations before deploying') + +// 90 days later, this memory's effective confidence is halved in recall results +// But recalling it resets the decay clock: +const results = await mem.recall('deploying to staging') +// results[0].confidence is the stored value; the score reflects decay +``` + +For the **hosted service**, you can still run manual decay for more control: + ```typescript import { deja } from "deja-client"; @@ -305,6 +333,41 @@ crons = ["0 3 * * *"] # Daily at 3 AM UTC | Human-taught memory | **Exempt** from decay | Human teachings are curated and intentional | | Postmortem learning | **Slow decay** only | Incident knowledge stays relevant for months | +## Anti-Patterns: When Bad Memories Become Useful Warnings + +In deja-local and deja-edge, heavily rejected memories don't just fade away — they transform into **anti-patterns** that actively warn agents. + +When `reject()` drops a memory's confidence below 0.15: +1. The memory's type flips from `"memory"` to `"anti-pattern"` +2. Confidence resets to 0.5 (it's now a useful warning, not a failed memory) +3. Text is prefixed with `"KNOWN PITFALL: "` + +```typescript +import { createMemory } from 'deja-local' + +const mem = createMemory({ path: './agent.db' }) + +// Agent learns something wrong +const m = await mem.remember('use eval() to parse JSON strings') + +// Other agents or humans reject it +await mem.reject(m.id) // 0.5 → 0.35 +await mem.reject(m.id) // 0.35 → 0.2 +await mem.reject(m.id) // 0.2 → 0.05 → auto-inverts! + +// Now it's an anti-pattern that actively warns agents: +const list = mem.list() +// list[0].text = "KNOWN PITFALL: use eval() to parse JSON strings" +// list[0].type = "anti-pattern" +// list[0].confidence = 0.5 + +// Anti-patterns appear in recall results normally +const results = await mem.recall('parsing JSON') +// results[0].text = "KNOWN PITFALL: use eval() to parse JSON strings" +``` + +Anti-patterns are exempt from further inversion — they won't double-prefix. You can `confirm()` an anti-pattern to boost its confidence, making the warning more prominent. + ## Monitoring Memory Health ```typescript diff --git a/packages/deja-edge/README.md b/packages/deja-edge/README.md index 0c97e2e..b6b0740 100644 --- a/packages/deja-edge/README.md +++ b/packages/deja-edge/README.md @@ -41,13 +41,16 @@ Your wrangler config needs a DO with SQLite: All methods are synchronous — no async needed, no network calls. Everything runs in the DO's SQLite. -### `remember(text)` +### `remember(text, options?)` Store a memory. ```ts memory.remember('Redis must be running before starting the API server') memory.remember('Use pnpm, not npm') + +// With agent attribution +memory.remember('always check wrangler.toml', { source: 'deploy-agent' }) ``` Dedup: near-identical text is detected and skipped. @@ -112,12 +115,18 @@ Routes: `POST /remember`, `POST /recall`, `POST /confirm/:id`, `POST /reject/:id ## How it works -**Search**: SQLite FTS5 with Porter stemming tokenizer. Queries are decomposed into keywords, matched with `OR`, and ranked by BM25. Scores are normalized to 0-1 and blended with confidence (70% relevance, 30% confidence). +**Search**: SQLite FTS5 with Porter stemming tokenizer. Queries are decomposed into keywords, matched with `OR`, and ranked by BM25. Scores are normalized to 0-1 and blended with decayed confidence (70% relevance, 30% confidence). + +**Time-based decay**: Confidence decays exponentially at recall time (half-life: 90 days). Memories that are recalled frequently stay fresh — each recall resets the decay clock. Stored confidence is only changed by `confirm()` and `reject()`. **Dedup**: Trigram Jaccard similarity. Memories above 0.85 similarity are considered duplicates. **Conflict resolution**: Memories between 0.5-0.85 similarity are about the same topic but say different things. The old memory's confidence drops to 30%, the new one takes priority. +**Anti-patterns**: When `reject()` drops a memory's confidence below 0.15, it auto-inverts into an anti-pattern — prefixed with "KNOWN PITFALL: ", confidence resets to 0.5, and it surfaces in recall as a warning. Negative knowledge actively warns agents away from known mistakes. + +**Agent attribution**: Pass `{ source: 'agent-name' }` to `remember()` to track which agent stored a memory. + ## Configuration ```ts diff --git a/packages/deja-edge/src/index.ts b/packages/deja-edge/src/index.ts index a3fafa9..5f0c5c0 100644 --- a/packages/deja-edge/src/index.ts +++ b/packages/deja-edge/src/index.ts @@ -30,6 +30,8 @@ export interface Memory { confidence: number supersedes?: string createdAt: string + source?: string + type: 'memory' | 'anti-pattern' } export interface RecallResult { @@ -49,7 +51,7 @@ export interface RecallLogEntry { export interface EdgeMemoryStore { /** Store a memory. Deduplicates automatically via FTS5 similarity. */ - remember(text: string): Memory + remember(text: string, options?: { source?: string }): Memory /** Find relevant memories via FTS5 full-text search. */ recall(context: string, options?: RecallOptions): RecallResult[] @@ -151,6 +153,14 @@ const CONFIDENCE_BOOST = 0.1 const CONFIDENCE_DECAY = 0.15 const CONFIDENCE_MIN = 0.01 const CONFIDENCE_MAX = 1.0 +const HALF_LIFE_DAYS = 90 +const ANTI_PATTERN_THRESHOLD = 0.15 +const ANTI_PATTERN_PREFIX = 'KNOWN PITFALL: ' + +/** Strip anti-pattern prefix for dedup/conflict comparison */ +function stripAntiPatternPrefix(text: string): string { + return text.startsWith(ANTI_PATTERN_PREFIX) ? text.slice(ANTI_PATTERN_PREFIX.length) : text +} function clampConfidence(c: number): number { return Math.min(CONFIDENCE_MAX, Math.max(CONFIDENCE_MIN, Math.round(c * 1000) / 1000)) @@ -175,7 +185,10 @@ function initSchema(sql: DurableObjectState['storage']['sql']) { text TEXT NOT NULL, confidence REAL NOT NULL DEFAULT 0.5, supersedes TEXT, - created_at TEXT NOT NULL + created_at TEXT NOT NULL, + last_recalled_at TEXT, + source TEXT, + type TEXT NOT NULL DEFAULT 'memory' ); CREATE INDEX IF NOT EXISTS idx_memories_created ON memories(created_at); CREATE INDEX IF NOT EXISTS idx_memories_confidence ON memories(confidence); @@ -206,6 +219,24 @@ function initSchema(sql: DurableObjectState['storage']['sql']) { ); CREATE INDEX IF NOT EXISTS idx_recall_log_ts ON recall_log(timestamp); `) + + // Migration: add missing columns for DOs created before this version + migrateSchema(sql) +} + +function migrateSchema(sql: DurableObjectState['storage']['sql']) { + // Check which columns exist on the memories table + const cols = [...sql.exec<{ name: string }>('PRAGMA table_info(memories)')] + const colNames = new Set(cols.map(c => c.name)) + if (!colNames.has('last_recalled_at')) { + sql.exec('ALTER TABLE memories ADD COLUMN last_recalled_at TEXT') + } + if (!colNames.has('source')) { + sql.exec('ALTER TABLE memories ADD COLUMN source TEXT') + } + if (!colNames.has('type')) { + sql.exec("ALTER TABLE memories ADD COLUMN type TEXT NOT NULL DEFAULT 'memory'") + } } // ============================================================================ @@ -225,17 +256,18 @@ export function createEdgeMemory( // Initialize schema (idempotent) initSchema(sql) - function remember(text: string): Memory { + function remember(text: string, options?: { source?: string }): Memory { const trimmed = text.trim() if (!trimmed) throw new Error('Memory text cannot be empty') + const source = options?.source // Check for dedup/conflict against existing memories via FTS5 const keywords = extractKeywords(trimmed) if (keywords.length > 0) { const ftsQuery = keywords.map(k => `"${k}"`).join(' OR ') const candidates = [ - ...sql.exec<{ id: string; text: string; confidence: number; supersedes: string | null; created_at: string }>( - `SELECT m.id, m.text, m.confidence, m.supersedes, m.created_at + ...sql.exec<{ id: string; text: string; confidence: number; supersedes: string | null; created_at: string; source: string | null; type: string }>( + `SELECT m.id, m.text, m.confidence, m.supersedes, m.created_at, m.source, m.type FROM memories m JOIN memories_fts ON memories_fts.rowid = m.rowid WHERE memories_fts MATCH ? @@ -247,14 +279,15 @@ export function createEdgeMemory( let bestSim = 0 let bestCandidate: typeof candidates[0] | null = null for (const c of candidates) { - const sim = trigramSimilarity(trimmed, c.text) + // Strip anti-pattern prefix so "KNOWN PITFALL: X" still deduplicates against "X" + const sim = trigramSimilarity(trimmed, stripAntiPatternPrefix(c.text)) if (sim > bestSim) { bestSim = sim bestCandidate = c } } - // Dedup: near-identical + // Dedup: near-identical (including anti-pattern matches) if (bestCandidate && bestSim >= dedupeThreshold) { return { id: bestCandidate.id, @@ -262,6 +295,8 @@ export function createEdgeMemory( confidence: bestCandidate.confidence, createdAt: bestCandidate.created_at, supersedes: bestCandidate.supersedes ?? undefined, + source: bestCandidate.source ?? undefined, + type: (bestCandidate.type as 'memory' | 'anti-pattern') ?? 'memory', } } @@ -272,28 +307,32 @@ export function createEdgeMemory( const id = createId() const createdAt = new Date().toISOString() + const type: 'memory' | 'anti-pattern' = 'memory' sql.exec( - `INSERT INTO memories (id, text, confidence, supersedes, created_at) VALUES (?, ?, ?, ?, ?)`, - id, trimmed, CONFIDENCE_DEFAULT, bestCandidate.id, createdAt, + `INSERT INTO memories (id, text, confidence, supersedes, created_at, source, type) VALUES (?, ?, ?, ?, ?, ?, ?)`, + id, trimmed, CONFIDENCE_DEFAULT, bestCandidate.id, createdAt, source ?? null, type, ) - return { id, text: trimmed, confidence: CONFIDENCE_DEFAULT, supersedes: bestCandidate.id, createdAt } + return { id, text: trimmed, confidence: CONFIDENCE_DEFAULT, supersedes: bestCandidate.id, createdAt, source, type } } } // New memory — no dedup or conflict const id = createId() const createdAt = new Date().toISOString() + const type: 'memory' | 'anti-pattern' = 'memory' sql.exec( - `INSERT INTO memories (id, text, confidence, supersedes, created_at) VALUES (?, ?, ?, ?, ?)`, - id, trimmed, CONFIDENCE_DEFAULT, null, createdAt, + `INSERT INTO memories (id, text, confidence, supersedes, created_at, source, type) VALUES (?, ?, ?, ?, ?, ?, ?)`, + id, trimmed, CONFIDENCE_DEFAULT, null, createdAt, source ?? null, type, ) - return { id, text: trimmed, confidence: CONFIDENCE_DEFAULT, createdAt } + return { id, text: trimmed, confidence: CONFIDENCE_DEFAULT, createdAt, source, type } } function recall(context: string, options: RecallOptions = {}): RecallResult[] { const limit = options.limit ?? 5 const minConf = options.minConfidence ?? defaultMinConfidence const threshold = options.threshold ?? 0 + const now = new Date().toISOString() + const nowMs = Date.now() const ftsQuery = buildFtsQuery(context) if (!ftsQuery) return [] @@ -306,9 +345,10 @@ export function createEdgeMemory( text: string confidence: number created_at: string + last_recalled_at: string | null rank: number }>( - `SELECT m.id, m.text, m.confidence, m.created_at, bm25(memories_fts) as rank + `SELECT m.id, m.text, m.confidence, m.created_at, m.last_recalled_at, bm25(memories_fts) as rank FROM memories_fts JOIN memories m ON memories_fts.rowid = m.rowid WHERE memories_fts MATCH ? @@ -321,7 +361,7 @@ export function createEdgeMemory( if (rows.length === 0) return [] - // Normalize BM25 scores to 0-1 range, then blend with confidence + // Normalize BM25 scores to 0-1 range, then blend with decayed confidence const maxRank = Math.max(...rows.map(r => -r.rank)) const minRank = Math.min(...rows.map(r => -r.rank)) const range = maxRank - minRank @@ -329,7 +369,13 @@ export function createEdgeMemory( const results: RecallResult[] = rows.map(r => { // When all ranks are identical (single result or tied scores), treat as full relevance const normalizedRelevance = range === 0 ? 1.0 : (-r.rank - minRank) / range - const blended = normalizedRelevance * 0.7 + r.confidence * 0.3 + + // Apply time-based confidence decay at recall time + const lastActiveAt = r.last_recalled_at ?? r.created_at + const daysSince = (nowMs - new Date(lastActiveAt).getTime()) / 86400000 + const decayedConfidence = r.confidence * Math.pow(0.5, daysSince / HALF_LIFE_DAYS) + + const blended = normalizedRelevance * 0.7 + decayedConfidence * 0.3 return { id: r.id, text: r.text, @@ -342,8 +388,12 @@ export function createEdgeMemory( results.sort((a, b) => b.score - a.score) const topResults = results.filter(r => r.score >= threshold).slice(0, limit) + // Update last_recalled_at for returned results + for (const r of topResults) { + sql.exec('UPDATE memories SET last_recalled_at = ? WHERE id = ?', now, r.id) + } + // Audit log - const now = new Date().toISOString() const logData = topResults.map(r => ({ memoryId: r.id, score: r.score })) sql.exec( `INSERT INTO recall_log (context, results, timestamp) VALUES (?, ?, ?)`, @@ -371,10 +421,20 @@ export function createEdgeMemory( }, reject(id: string): boolean { - const rows = [...sql.exec<{ confidence: number }>('SELECT confidence FROM memories WHERE id = ?', id)] + const rows = [...sql.exec<{ confidence: number; type: string }>('SELECT confidence, type FROM memories WHERE id = ?', id)] if (rows.length === 0) return false - const newConf = clampConfidence(rows[0].confidence - CONFIDENCE_DECAY) + let newConf = clampConfidence(rows[0].confidence - CONFIDENCE_DECAY) sql.exec('UPDATE memories SET confidence = ? WHERE id = ?', newConf, id) + + // Auto-invert to anti-pattern when confidence drops below threshold + if (newConf < ANTI_PATTERN_THRESHOLD && rows[0].type !== 'anti-pattern') { + const textRows = [...sql.exec<{ text: string }>('SELECT text FROM memories WHERE id = ?', id)] + if (textRows.length > 0) { + const newText = ANTI_PATTERN_PREFIX + textRows[0].text + sql.exec('UPDATE memories SET text = ?, type = ?, confidence = ? WHERE id = ?', newText, 'anti-pattern', CONFIDENCE_DEFAULT, id) + } + } + return true }, @@ -388,8 +448,8 @@ export function createEdgeMemory( const limit = options.limit ?? 1000 const offset = options.offset ?? 0 return [ - ...sql.exec<{ id: string; text: string; confidence: number; supersedes: string | null; created_at: string }>( - 'SELECT id, text, confidence, supersedes, created_at FROM memories ORDER BY created_at DESC LIMIT ? OFFSET ?', + ...sql.exec<{ id: string; text: string; confidence: number; supersedes: string | null; created_at: string; source: string | null; type: string }>( + 'SELECT id, text, confidence, supersedes, created_at, source, type FROM memories ORDER BY created_at DESC LIMIT ? OFFSET ?', limit, offset, ), ].map(r => ({ @@ -398,6 +458,8 @@ export function createEdgeMemory( confidence: r.confidence, supersedes: r.supersedes ?? undefined, createdAt: r.created_at, + source: r.source ?? undefined, + type: (r.type as 'memory' | 'anti-pattern') ?? 'memory', })) }, @@ -447,7 +509,7 @@ export class DejaEdge implements EdgeMemoryStore { this.store = createEdgeMemory(ctx, opts) } - remember(text: string) { return this.store.remember(text) } + remember(text: string, options?: { source?: string }) { return this.store.remember(text, options) } recall(context: string, options?: RecallOptions) { return this.store.recall(context, options) } confirm(id: string) { return this.store.confirm(id) } reject(id: string) { return this.store.reject(id) } diff --git a/packages/deja-edge/test/edge-memory.test.ts b/packages/deja-edge/test/edge-memory.test.ts index 2fb62b3..49bd040 100644 --- a/packages/deja-edge/test/edge-memory.test.ts +++ b/packages/deja-edge/test/edge-memory.test.ts @@ -284,4 +284,216 @@ describe('deja-edge: createEdgeMemory', () => { expect(r.confidence).toBeGreaterThanOrEqual(0.4) } }) + + // ============================================================================ + // TIME-BASED CONFIDENCE DECAY + // ============================================================================ + + test('old memories score lower than fresh ones with same text similarity', () => { + freshMemory() + memory.remember('deploy tip about production servers') + memory.remember('deploy tip about staging servers') + + // Manually backdate one memory + const list = memory.list() + const oldId = list[1].id // oldest + const freshId = list[0].id // newest + const oldDate = new Date(Date.now() - 180 * 86400000).toISOString() + ctx.state.storage.sql.exec('UPDATE memories SET created_at = ? WHERE id = ?', oldDate, oldId) + + const results = memory.recall('deploy tip servers') + expect(results.length).toBe(2) + // Fresh memory should rank first due to decay penalizing old one + expect(results[0].id).toBe(freshId) + }) + + test('recently recalled memories resist decay', () => { + freshMemory() + const m = memory.remember('deploy tip about cloud servers') + + // Backdate created_at but set recent last_recalled_at + const oldDate = new Date(Date.now() - 180 * 86400000).toISOString() + const recentDate = new Date().toISOString() + ctx.state.storage.sql.exec('UPDATE memories SET created_at = ?, last_recalled_at = ? WHERE id = ?', oldDate, recentDate, m.id) + + const results = memory.recall('deploy tip cloud servers') + expect(results.length).toBeGreaterThan(0) + // Should still score well because last_recalled_at is recent + expect(results[0].score).toBeGreaterThan(0.3) + }) + + test('confirm still boosts stored confidence independent of decay', () => { + freshMemory() + const m = memory.remember('decay confirm test item') + memory.confirm(m.id) + const list = memory.list() + expect(list[0].confidence).toBe(0.6) + }) + + // ============================================================================ + // AGENT ATTRIBUTION + // ============================================================================ + + test('source is stored and returned when provided', () => { + freshMemory() + const m = memory.remember('attributed memory about code', { source: 'agent-beta' }) + expect(m.source).toBe('agent-beta') + const list = memory.list() + expect(list[0].source).toBe('agent-beta') + }) + + test('source is undefined when not provided (backward compat)', () => { + freshMemory() + const m = memory.remember('unattributed memory about code') + expect(m.source).toBeUndefined() + const list = memory.list() + expect(list[0].source).toBeUndefined() + }) + + // ============================================================================ + // ANTI-PATTERN TRACKING + // ============================================================================ + + test('memory auto-inverts to anti-pattern after enough rejections', () => { + freshMemory() + const m = memory.remember('use var for all variable declarations') + // 0.5 -> 0.35 -> 0.2 -> 0.05 (below 0.15) + memory.reject(m.id) + memory.reject(m.id) + memory.reject(m.id) + const list = memory.list() + expect(list[0].type).toBe('anti-pattern') + }) + + test('anti-pattern has reset confidence and KNOWN PITFALL prefix', () => { + freshMemory() + const m = memory.remember('use eval for parsing JSON data') + memory.reject(m.id) + memory.reject(m.id) + memory.reject(m.id) + const list = memory.list() + expect(list[0].confidence).toBe(0.5) + expect(list[0].text).toBe('KNOWN PITFALL: use eval for parsing JSON data') + expect(list[0].type).toBe('anti-pattern') + }) + + test('anti-pattern appears in recall results normally', () => { + freshMemory() + const m = memory.remember('use eval for parsing JSON response') + memory.reject(m.id) + memory.reject(m.id) + memory.reject(m.id) + const results = memory.recall('parsing JSON') + expect(results.length).toBeGreaterThan(0) + expect(results[0].text).toContain('KNOWN PITFALL') + }) + + test('confirming an anti-pattern still boosts its confidence', () => { + freshMemory() + const m = memory.remember('never use goto statements in code') + memory.reject(m.id) + memory.reject(m.id) + memory.reject(m.id) + // Now anti-pattern with confidence 0.5 + memory.confirm(m.id) + const list = memory.list() + expect(list[0].confidence).toBe(0.6) + expect(list[0].type).toBe('anti-pattern') + }) + + test('already-inverted anti-pattern does not double-invert', () => { + freshMemory() + const m = memory.remember('use document.write for HTML output') + memory.reject(m.id) + memory.reject(m.id) + memory.reject(m.id) + // Reject more — should NOT double-invert + for (let i = 0; i < 5; i++) memory.reject(m.id) + const list = memory.list() + expect(list[0].type).toBe('anti-pattern') + expect(list[0].text).toBe('KNOWN PITFALL: use document.write for HTML output') + // No double prefix + expect(list[0].text.indexOf('KNOWN PITFALL')).toBe(0) + expect(list[0].text.indexOf('KNOWN PITFALL', 1)).toBe(-1) + }) + + test('memories start with type memory', () => { + freshMemory() + const m = memory.remember('normal type test memory') + expect(m.type).toBe('memory') + }) + + test('re-remembering anti-pattern text deduplicates instead of inserting duplicate', () => { + freshMemory() + const m = memory.remember('use eval for parsing JSON safely') + // Invert to anti-pattern + memory.reject(m.id) + memory.reject(m.id) + memory.reject(m.id) + expect(memory.size).toBe(1) + // Now try to remember the same original text — should dedup against the anti-pattern + const m2 = memory.remember('use eval for parsing JSON safely') + expect(memory.size).toBe(1) // no duplicate + expect(m2.id).toBe(m.id) // same memory returned + expect(m2.type).toBe('anti-pattern') // still an anti-pattern + }) + + // ============================================================================ + // SCHEMA MIGRATION + // ============================================================================ + + test('existing DOs without new columns are migrated', () => { + // Create a DO with old schema (no source, type, last_recalled_at) + const oldCtx = createMockCtx() + oldCtx.state.storage.sql.exec(` + CREATE TABLE IF NOT EXISTS memories ( + id TEXT PRIMARY KEY, + text TEXT NOT NULL, + confidence REAL NOT NULL DEFAULT 0.5, + supersedes TEXT, + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_memories_created ON memories(created_at); + CREATE INDEX IF NOT EXISTS idx_memories_confidence ON memories(confidence); + CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5( + text, + content='memories', + content_rowid='rowid', + tokenize='porter unicode61' + ); + CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN + INSERT INTO memories_fts(rowid, text) VALUES (new.rowid, new.text); + END; + CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN + INSERT INTO memories_fts(memories_fts, rowid, text) VALUES('delete', old.rowid, old.text); + END; + CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN + INSERT INTO memories_fts(memories_fts, rowid, text) VALUES('delete', old.rowid, old.text); + INSERT INTO memories_fts(rowid, text) VALUES (new.rowid, new.text); + END; + CREATE TABLE IF NOT EXISTS recall_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + context TEXT NOT NULL, + results TEXT NOT NULL, + timestamp TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_recall_log_ts ON recall_log(timestamp); + `) + // Insert a memory in old schema + oldCtx.state.storage.sql.exec( + "INSERT INTO memories (id, text, confidence, created_at) VALUES ('old-1', 'old memory about deployment', 0.5, '2026-01-01T00:00:00Z')" + ) + + // Now create edge memory on top of old schema — should migrate + const mem = createEdgeMemory(oldCtx.state) + expect(mem.size).toBe(1) + const list = mem.list() + expect(list[0].type).toBe('memory') + expect(list[0].source).toBeUndefined() + + // New features should work on migrated schema + const m = mem.remember('new memory about testing code', { source: 'test-agent' }) + expect(m.source).toBe('test-agent') + expect(m.type).toBe('memory') + }) }) diff --git a/packages/deja-local/README.md b/packages/deja-local/README.md index 9440709..fa7b820 100644 --- a/packages/deja-local/README.md +++ b/packages/deja-local/README.md @@ -22,13 +22,16 @@ This package uses `bun:sqlite` and requires the Bun runtime. It will not work in ## API -### `remember(text)` +### `remember(text, options?)` Store a memory. Dedup and conflict resolution happen automatically. ```ts await mem.remember('Use pnpm, not npm -- the lockfile breaks otherwise') await mem.remember('Redis must be running before starting the API server') + +// With agent attribution +await mem.remember('always check wrangler.toml', { source: 'deploy-agent' }) ``` If a new memory contradicts an existing one, the old memory's confidence drops and the new one takes priority. @@ -72,10 +75,14 @@ const mem = createMemory({ **Embeddings**: all-MiniLM-L6-v2 via ONNX, runs on CPU (~23MB model, cached locally). In-memory vector index loaded at startup. -**Scoring**: `relevance * 0.7 + confidence * 0.3`. Confirm/reject adjusts confidence. Over thousands of memories, this separates signal from noise. +**Scoring**: `relevance * 0.7 + decayedConfidence * 0.3`. Confidence decays exponentially over time (half-life: 90 days), computed at recall time. Recalling a memory resets its decay clock. Stored confidence is only changed by `confirm()` and `reject()`. **Dedup**: >= 0.95 similarity at write time is skipped. 0.6-0.95 similarity triggers conflict resolution — old memory's confidence drops to 30%. +**Anti-patterns**: When `reject()` drops a memory's confidence below 0.15, it auto-inverts into an anti-pattern — the text is prefixed with "KNOWN PITFALL: ", confidence resets to 0.5, and it continues to appear in recall as a warning. This means negative knowledge ("don't do X") actively surfaces instead of silently fading away. + +**Agent attribution**: Pass `{ source: 'agent-name' }` to `remember()` to track which agent stored a memory. Returned in `list()` and recall results. + ## License MIT diff --git a/packages/deja-local/src/index.ts b/packages/deja-local/src/index.ts index a5ae2ee..c0202b9 100644 --- a/packages/deja-local/src/index.ts +++ b/packages/deja-local/src/index.ts @@ -26,6 +26,8 @@ export interface Memory { confidence: number createdAt: string supersedes?: string + source?: string + type: 'memory' | 'anti-pattern' } export interface RecallResult { @@ -45,7 +47,7 @@ export interface RecallLogEntry { export interface MemoryStore { /** Store a memory. Deduplicates and resolves conflicts automatically. */ - remember(text: string): Promise + remember(text: string, options?: { source?: string }): Promise /** Find relevant memories. Decomposes complex queries for better recall. */ recall(context: string, options?: { limit?: number; threshold?: number; minConfidence?: number }): Promise @@ -73,7 +75,7 @@ export interface MemoryStore { // Backward compat /** @deprecated Use remember() */ - learn(text: string): Promise + learn(text: string, options?: { source?: string }): Promise } export interface CreateMemoryOptions { @@ -179,6 +181,14 @@ const CONFIDENCE_BOOST = 0.1 const CONFIDENCE_DECAY = 0.15 const CONFIDENCE_MIN = 0.01 const CONFIDENCE_MAX = 1.0 +const HALF_LIFE_DAYS = 90 +const ANTI_PATTERN_THRESHOLD = 0.15 +const ANTI_PATTERN_PREFIX = 'KNOWN PITFALL: ' + +/** Strip anti-pattern prefix for dedup/conflict comparison */ +function stripAntiPatternPrefix(text: string): string { + return text.startsWith(ANTI_PATTERN_PREFIX) ? text.slice(ANTI_PATTERN_PREFIX.length) : text +} function clampConfidence(c: number): number { return Math.min(CONFIDENCE_MAX, Math.max(CONFIDENCE_MIN, Math.round(c * 1000) / 1000)) @@ -195,7 +205,10 @@ const SCHEMA = ` embedding BLOB NOT NULL, confidence REAL NOT NULL DEFAULT 0.5, supersedes TEXT, - created_at TEXT NOT NULL + created_at TEXT NOT NULL, + last_recalled_at TEXT, + source TEXT, + type TEXT NOT NULL DEFAULT 'memory' ); CREATE TABLE IF NOT EXISTS recall_log ( @@ -209,7 +222,7 @@ const SCHEMA = ` CREATE INDEX IF NOT EXISTS idx_recall_log_ts ON recall_log(timestamp); ` -// Migration: add confidence column if missing (for DBs created before this version) +// Migration: add missing columns for DBs created before this version function migrateSchema(db: Database) { const cols = db.prepare("PRAGMA table_info(memories)").all() as Array<{ name: string }> const colNames = new Set(cols.map(c => c.name)) @@ -219,6 +232,15 @@ function migrateSchema(db: Database) { if (!colNames.has('supersedes')) { db.exec('ALTER TABLE memories ADD COLUMN supersedes TEXT') } + if (!colNames.has('last_recalled_at')) { + db.exec('ALTER TABLE memories ADD COLUMN last_recalled_at TEXT') + } + if (!colNames.has('source')) { + db.exec('ALTER TABLE memories ADD COLUMN source TEXT') + } + if (!colNames.has('type')) { + db.exec("ALTER TABLE memories ADD COLUMN type TEXT NOT NULL DEFAULT 'memory'") + } } // ============================================================================ @@ -240,22 +262,24 @@ export function createMemory(opts: CreateMemoryOptions): MemoryStore { // Prepared statements const insertMemory = db.prepare( - 'INSERT INTO memories (id, text, embedding, confidence, supersedes, created_at) VALUES (?, ?, ?, ?, ?, ?)' + 'INSERT INTO memories (id, text, embedding, confidence, supersedes, created_at, source, type) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' ) const deleteMemory = db.prepare('DELETE FROM memories WHERE id = ?') const updateConfidence = db.prepare('UPDATE memories SET confidence = ? WHERE id = ?') - const selectAll = db.prepare('SELECT id, text, embedding, confidence, supersedes, created_at FROM memories') + const updateLastRecalledAt = db.prepare('UPDATE memories SET last_recalled_at = ? WHERE id = ?') + const updateMemoryTextAndType = db.prepare('UPDATE memories SET text = ?, type = ?, confidence = ? WHERE id = ?') + const selectAll = db.prepare('SELECT id, text, embedding, confidence, supersedes, created_at, last_recalled_at, source, type FROM memories') const countMemories = db.prepare('SELECT COUNT(*) as count FROM memories') const insertRecall = db.prepare( 'INSERT INTO recall_log (context, results, timestamp) VALUES (?, ?, ?)' ) // In-memory vector index — loaded from DB on startup - interface IndexEntry { id: string; text: string; vec: number[]; confidence: number; supersedes?: string; createdAt: string } + interface IndexEntry { id: string; text: string; vec: number[]; confidence: number; supersedes?: string; createdAt: string; lastRecalledAt?: string; source?: string; type: 'memory' | 'anti-pattern' } const index: IndexEntry[] = [] // Load existing memories into index - for (const row of selectAll.all() as Array<{ id: string; text: string; embedding: Buffer; confidence: number; supersedes: string | null; created_at: string }>) { + for (const row of selectAll.all() as Array<{ id: string; text: string; embedding: Buffer; confidence: number; supersedes: string | null; created_at: string; last_recalled_at: string | null; source: string | null; type: string }>) { index.push({ id: row.id, text: row.text, @@ -263,11 +287,15 @@ export function createMemory(opts: CreateMemoryOptions): MemoryStore { confidence: row.confidence, supersedes: row.supersedes ?? undefined, createdAt: row.created_at, + lastRecalledAt: row.last_recalled_at ?? undefined, + source: row.source ?? undefined, + type: (row.type as 'memory' | 'anti-pattern') ?? 'memory', }) } - async function remember(text: string): Promise { + async function remember(text: string, options?: { source?: string }): Promise { const vec = await embed(text) + const source = options?.source // Scan for dedup or conflict let bestSimilarity = 0 @@ -283,7 +311,7 @@ export function createMemory(opts: CreateMemoryOptions): MemoryStore { // Dedup: near-identical memory exists, skip if (bestEntry && bestSimilarity >= dedupeThreshold) { - return { id: bestEntry.id, text: bestEntry.text, confidence: bestEntry.confidence, createdAt: bestEntry.createdAt } + return { id: bestEntry.id, text: bestEntry.text, confidence: bestEntry.confidence, createdAt: bestEntry.createdAt, source: bestEntry.source, type: bestEntry.type } } // Conflict: same topic, different content — supersede the old memory @@ -300,17 +328,20 @@ export function createMemory(opts: CreateMemoryOptions): MemoryStore { const createdAt = new Date().toISOString() const confidence = CONFIDENCE_DEFAULT const buf = vecToBuffer(vec) + const type: 'memory' | 'anti-pattern' = 'memory' - insertMemory.run(id, text, buf, confidence, supersedes ?? null, createdAt) - index.push({ id, text, vec, confidence, supersedes, createdAt }) + insertMemory.run(id, text, buf, confidence, supersedes ?? null, createdAt, source ?? null, type) + index.push({ id, text, vec, confidence, supersedes, createdAt, source, type }) - return { id, text, confidence, createdAt, supersedes } + return { id, text, confidence, createdAt, supersedes, source, type } } async function recall(context: string, options: { limit?: number; threshold?: number; minConfidence?: number } = {}): Promise { const limit = options.limit ?? 5 const min = options.threshold ?? threshold const minConf = options.minConfidence ?? 0 + const now = new Date().toISOString() + const nowMs = Date.now() // Decompose complex queries into sub-queries const subQueries = decomposeQuery(context) @@ -329,8 +360,13 @@ export function createMemory(opts: CreateMemoryOptions): MemoryStore { } if (bestScore >= min) { - // Blend relevance with confidence: 70% relevance, 30% confidence - const blended = bestScore * 0.7 + entry.confidence * 0.3 + // Apply time-based confidence decay at recall time + const lastActiveAt = entry.lastRecalledAt ?? entry.createdAt + const daysSince = (nowMs - new Date(lastActiveAt).getTime()) / 86400000 + const decayedConfidence = entry.confidence * Math.pow(0.5, daysSince / HALF_LIFE_DAYS) + + // Blend relevance with decayed confidence: 70% relevance, 30% confidence + const blended = bestScore * 0.7 + decayedConfidence * 0.3 const existing = scoreMap.get(entry.id) if (!existing || existing.score < blended) { scoreMap.set(entry.id, { @@ -348,8 +384,14 @@ export function createMemory(opts: CreateMemoryOptions): MemoryStore { results.sort((a, b) => b.score - a.score) const topResults = results.slice(0, limit) + // Update last_recalled_at for returned results + for (const r of topResults) { + updateLastRecalledAt.run(now, r.id) + const entry = index.find(e => e.id === r.id) + if (entry) entry.lastRecalledAt = now + } + // Audit: log every recall - const now = new Date().toISOString() const logData = topResults.map(r => ({ memoryId: r.id, score: r.score })) insertRecall.run(context, JSON.stringify(logData), now) @@ -376,6 +418,15 @@ export function createMemory(opts: CreateMemoryOptions): MemoryStore { if (!entry) return false entry.confidence = clampConfidence(entry.confidence - CONFIDENCE_DECAY) updateConfidence.run(entry.confidence, id) + + // Auto-invert to anti-pattern when confidence drops below threshold + if (entry.confidence < ANTI_PATTERN_THRESHOLD && entry.type !== 'anti-pattern') { + entry.type = 'anti-pattern' + entry.confidence = CONFIDENCE_DEFAULT + entry.text = ANTI_PATTERN_PREFIX + entry.text + updateMemoryTextAndType.run(entry.text, entry.type, entry.confidence, id) + } + return true }, @@ -393,9 +444,9 @@ export function createMemory(opts: CreateMemoryOptions): MemoryStore { const limit = options.limit ?? 1000 const offset = options.offset ?? 0 const rows = db.prepare( - 'SELECT id, text, confidence, supersedes, created_at FROM memories ORDER BY created_at DESC LIMIT ? OFFSET ?' - ).all(limit, offset) as Array<{ id: string; text: string; confidence: number; supersedes: string | null; created_at: string }> - return rows.map(r => ({ id: r.id, text: r.text, confidence: r.confidence, supersedes: r.supersedes ?? undefined, createdAt: r.created_at })) + 'SELECT id, text, confidence, supersedes, created_at, source, type FROM memories ORDER BY created_at DESC LIMIT ? OFFSET ?' + ).all(limit, offset) as Array<{ id: string; text: string; confidence: number; supersedes: string | null; created_at: string; source: string | null; type: string }> + return rows.map(r => ({ id: r.id, text: r.text, confidence: r.confidence, supersedes: r.supersedes ?? undefined, createdAt: r.created_at, source: r.source ?? undefined, type: (r.type as 'memory' | 'anti-pattern') ?? 'memory' })) }, recallLog(options = {}) { @@ -442,7 +493,7 @@ export class DejaLocal implements MemoryStore { this.store = createMemory({ ...opts, path }) } - remember(text: string) { return this.store.remember(text) } + remember(text: string, options?: { source?: string }) { return this.store.remember(text, options) } recall(context: string, options?: Parameters[1]) { return this.store.recall(context, options) } confirm(id: string) { return this.store.confirm(id) } reject(id: string) { return this.store.reject(id) } @@ -452,7 +503,7 @@ export class DejaLocal implements MemoryStore { get size() { return this.store.size } close() { return this.store.close() } /** @deprecated Use remember() */ - learn(text: string) { return this.store.learn(text) } + learn(text: string, options?: { source?: string }) { return this.store.learn(text, options) } } export { createModelEmbed } diff --git a/packages/deja-local/test/index.test.ts b/packages/deja-local/test/index.test.ts index c4513ae..4a58fd9 100644 --- a/packages/deja-local/test/index.test.ts +++ b/packages/deja-local/test/index.test.ts @@ -542,3 +542,179 @@ describe('backward compatibility', () => { m.close() }) }) + +// ============================================================================ +// TIME-BASED CONFIDENCE DECAY +// ============================================================================ + +describe('time-based confidence decay', () => { + test('old memories score lower than fresh ones with same text similarity', async () => { + const p = tmpDb() + cleanup.push(p) + + const { Database } = await import('bun:sqlite') + + // Create memory store and add a memory + const m = createMemory({ path: p, embed: testEmbed, threshold: 0.1 }) + const fresh = await m.remember('deploy tip about production') + const old = await m.remember('deploy tip about staging environment') + m.close() + + // Manually backdate the "old" memory's created_at to 180 days ago + const rawDb = new Database(p) + const oldDate = new Date(Date.now() - 180 * 86400000).toISOString() + rawDb.exec(`UPDATE memories SET created_at = '${oldDate}' WHERE id = '${old.id}'`) + rawDb.close() + + // Reopen and recall — fresh memory should score higher due to decay + const m2 = createMemory({ path: p, embed: testEmbed, threshold: 0.1 }) + const results = await m2.recall('deploy tip') + expect(results.length).toBe(2) + // Fresh memory should rank first (old one decayed) + expect(results[0].id).toBe(fresh.id) + m2.close() + }) + + test('recently recalled memories resist decay', async () => { + const p = tmpDb() + cleanup.push(p) + + const { Database } = await import('bun:sqlite') + + const m = createMemory({ path: p, embed: testEmbed, threshold: 0.1 }) + const memory = await m.remember('deploy tip about servers') + m.close() + + // Backdate created_at to 180 days ago, but set last_recalled_at to now + const rawDb = new Database(p) + const oldDate = new Date(Date.now() - 180 * 86400000).toISOString() + const recentDate = new Date().toISOString() + rawDb.exec(`UPDATE memories SET created_at = '${oldDate}', last_recalled_at = '${recentDate}' WHERE id = '${memory.id}'`) + rawDb.close() + + // Reopen — memory should still score well because it was recently recalled + const m2 = createMemory({ path: p, embed: testEmbed, threshold: 0.1 }) + const results = await m2.recall('deploy tip about servers') + expect(results.length).toBe(1) + // Score should be high since last_recalled_at is recent + expect(results[0].score).toBeGreaterThan(0.3) + m2.close() + }) + + test('confirm still boosts stored confidence independent of decay', async () => { + const m = mem() + const memory = await m.remember('decay and confirm test') + await m.confirm(memory.id) + const listed = m.list() + // Stored confidence should be boosted regardless of decay + expect(listed[0].confidence).toBe(0.6) + m.close() + }) +}) + +// ============================================================================ +// AGENT ATTRIBUTION +// ============================================================================ + +describe('agent attribution', () => { + test('source is stored and returned when provided', async () => { + const m = mem() + const memory = await m.remember('attributed memory', { source: 'agent-alpha' }) + expect(memory.source).toBe('agent-alpha') + const listed = m.list() + expect(listed[0].source).toBe('agent-alpha') + m.close() + }) + + test('source is undefined when not provided (backward compat)', async () => { + const m = mem() + const memory = await m.remember('unattributed memory') + expect(memory.source).toBeUndefined() + const listed = m.list() + expect(listed[0].source).toBeUndefined() + m.close() + }) +}) + +// ============================================================================ +// ANTI-PATTERN TRACKING +// ============================================================================ + +describe('anti-pattern tracking', () => { + test('memory auto-inverts to anti-pattern after enough rejections', async () => { + const m = mem() + const memory = await m.remember('use var for all variables') + // Reject enough times to drop below 0.15 threshold + // 0.5 -> 0.35 -> 0.2 -> 0.05 (below 0.15, triggers inversion) + await m.reject(memory.id) + await m.reject(memory.id) + await m.reject(memory.id) + const listed = m.list() + expect(listed[0].type).toBe('anti-pattern') + m.close() + }) + + test('anti-pattern has reset confidence and KNOWN PITFALL prefix', async () => { + const m = mem() + const memory = await m.remember('use eval for parsing JSON') + await m.reject(memory.id) + await m.reject(memory.id) + await m.reject(memory.id) + const listed = m.list() + expect(listed[0].confidence).toBe(0.5) + expect(listed[0].text).toBe('KNOWN PITFALL: use eval for parsing JSON') + expect(listed[0].type).toBe('anti-pattern') + m.close() + }) + + test('anti-pattern appears in recall results normally', async () => { + const m = mem() + const memory = await m.remember('use eval for parsing JSON data') + await m.reject(memory.id) + await m.reject(memory.id) + await m.reject(memory.id) + const results = await m.recall('parsing JSON') + expect(results.length).toBeGreaterThan(0) + expect(results[0].text).toContain('KNOWN PITFALL') + m.close() + }) + + test('confirming an anti-pattern still boosts its confidence', async () => { + const m = mem() + const memory = await m.remember('never use goto statements') + await m.reject(memory.id) + await m.reject(memory.id) + await m.reject(memory.id) + // Now it's an anti-pattern with confidence 0.5 + await m.confirm(memory.id) + const listed = m.list() + expect(listed[0].confidence).toBe(0.6) + expect(listed[0].type).toBe('anti-pattern') + m.close() + }) + + test('already-inverted anti-pattern does not double-invert', async () => { + const m = mem() + const memory = await m.remember('use document.write for output') + // Invert it + await m.reject(memory.id) + await m.reject(memory.id) + await m.reject(memory.id) + // Now reject the anti-pattern further — should NOT double-invert + for (let i = 0; i < 5; i++) await m.reject(memory.id) + const listed = m.list() + expect(listed[0].type).toBe('anti-pattern') + expect(listed[0].text).toBe('KNOWN PITFALL: use document.write for output') + // Should NOT have "KNOWN PITFALL: KNOWN PITFALL: ..." + expect(listed[0].text.indexOf('KNOWN PITFALL')).toBe(0) + expect(listed[0].text.indexOf('KNOWN PITFALL', 1)).toBe(-1) + m.close() + }) + + test('memories start with type memory', async () => { + const m = mem() + const memory = await m.remember('normal memory type test') + expect(memory.type).toBe('memory') + m.close() + }) +}) diff --git a/src/do/memory.ts b/src/do/memory.ts index a74ac9c..964de73 100644 --- a/src/do/memory.ts +++ b/src/do/memory.ts @@ -127,8 +127,22 @@ export async function injectMemories( const learnings = dbLearnings.map((learning: any) => ctx.convertDbLearning(learning)); const now = new Date().toISOString(); + const nowMs = Date.now(); + + // Apply time-based confidence decay for ranking (read-side only, stored values unchanged) + const HALF_LIFE_DAYS = 90; + const rankedLearnings = [...learnings].sort((a: Learning, b: Learning) => { + const aLastActive = a.lastRecalledAt ?? a.createdAt; + const bLastActive = b.lastRecalledAt ?? b.createdAt; + const aDays = (nowMs - new Date(aLastActive).getTime()) / 86400000; + const bDays = (nowMs - new Date(bLastActive).getTime()) / 86400000; + const aDecayed = (a.confidence ?? 1.0) * Math.pow(0.5, aDays / HALF_LIFE_DAYS); + const bDecayed = (b.confidence ?? 1.0) * Math.pow(0.5, bDays / HALF_LIFE_DAYS); + return bDecayed - aDecayed; + }); + await Promise.all( - learnings.map((learning: Learning) => + rankedLearnings.map((learning: Learning) => db .update(schema.learnings) .set({ @@ -141,14 +155,14 @@ export async function injectMemories( if (format === 'prompt') { return { - prompt: learnings + prompt: rankedLearnings .map((learning: Learning) => `When ${learning.trigger}, ${learning.learning}`) .join('\n'), - learnings, + learnings: rankedLearnings, }; } - return { prompt: '', learnings }; + return { prompt: '', learnings: rankedLearnings }; } catch (error) { console.error('Inject error:', error); return { prompt: '', learnings: [] };