From 9d54467008bdc6258ae6c692faf223842fbe3c05 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Fri, 15 May 2026 14:48:32 -0600 Subject: [PATCH 1/3] feat(T2): add pending memory auto-promotion sweep at session end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `SqliteCache.promoteMemories(projectId, threshold)` — a single UPDATE that promotes all pending memories with confidence >= threshold to live, setting verified_at and dirty=1. Idempotent: the WHERE clause filters on status='pending' so already-live rows are never touched. Wires the sweep into `handleSessionEnd` via an optional `autoSaveThreshold` parameter (null = no sweep, preserving existing call-site behavior). The guard `if (autoSaveThreshold != null)` matches the pattern in index.ts and observe.ts; index.ts can pass the threshold in a follow-up without touching this file. 8 tests in session-end-sweep.test.ts cover: below-threshold stays pending, at/above-threshold promoted with verifiedAt set, already-live not re-promoted (idempotent, two-run check), empty pending set is a no-op, null threshold skips sweep entirely. Co-Authored-By: Claude Sonnet 4.6 --- .../src/__tests__/session-end-sweep.test.ts | 199 ++++++++++++++++++ packages/server/src/cache/sqlite.ts | 22 ++ packages/server/src/tools/session-end.ts | 13 ++ 3 files changed, 234 insertions(+) create mode 100644 packages/server/src/__tests__/session-end-sweep.test.ts diff --git a/packages/server/src/__tests__/session-end-sweep.test.ts b/packages/server/src/__tests__/session-end-sweep.test.ts new file mode 100644 index 0000000..6f99627 --- /dev/null +++ b/packages/server/src/__tests__/session-end-sweep.test.ts @@ -0,0 +1,199 @@ +/** + * T2: Pending memory auto-promotion sweep — session-end integration tests. + * + * Covers: + * (a) Below-threshold pending memory stays pending after session-end sweep. + * (b) At/above-threshold pending memory is promoted to live. + * (c) Already-live memory is not double-promoted (idempotency). + * (d) Empty pending set is a no-op. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { SqliteCache } from '../cache/sqlite' +import { handleSessionEnd } from '../tools/session-end' +import { randomUUID } from 'crypto' +import type { Memory } from '@tages/shared' +import * as fs from 'fs' +import * as path from 'path' +import * as os from 'os' + +const TEST_PROJECT = 'test-project-session-sweep' + +function makePendingMemory(confidence: number, key?: string): Memory { + return { + id: randomUUID(), + projectId: TEST_PROJECT, + key: key ?? `sweep-key-${Date.now()}-${Math.random().toString(36).slice(2)}`, + value: 'Auto-promotion sweep test memory', + type: 'convention', + source: 'agent', + status: 'pending', + confidence, + filePaths: [], + tags: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } +} + +describe('handleSessionEnd — pending memory auto-promotion sweep (T2)', () => { + let cache: SqliteCache + let dbPath: string + + beforeEach(() => { + dbPath = path.join(os.tmpdir(), `tages-sweep-test-${Date.now()}.db`) + cache = new SqliteCache(dbPath) + }) + + afterEach(() => { + cache.close() + try { fs.unlinkSync(dbPath) } catch { /* ignore */ } + }) + + // (a) Below-threshold pending stays pending + it('(a) pending memory below threshold stays pending after session-end with sweep', async () => { + const mem = makePendingMemory(0.6) + cache.upsertMemory(mem, false) + + await handleSessionEnd( + { summary: 'Done for today.', extractMemories: false }, + TEST_PROJECT, cache, null, + undefined, + 0.8, // threshold — 0.6 < 0.8, so no promotion + ) + + const stored = cache.getByKey(TEST_PROJECT, mem.key) + expect(stored?.status).toBe('pending') + }) + + // (b) At/above-threshold pending gets promoted to live + it('(b) pending memory at threshold is promoted to live', async () => { + const at = makePendingMemory(0.8, 'sweep-at-threshold') + const above = makePendingMemory(0.95, 'sweep-above-threshold') + cache.upsertMemory(at, false) + cache.upsertMemory(above, false) + + await handleSessionEnd( + { summary: 'Done for today.', extractMemories: false }, + TEST_PROJECT, cache, null, + undefined, + 0.8, // threshold + ) + + expect(cache.getByKey(TEST_PROJECT, 'sweep-at-threshold')?.status).toBe('live') + expect(cache.getByKey(TEST_PROJECT, 'sweep-above-threshold')?.status).toBe('live') + }) + + it('(b) pending memory above threshold has verifiedAt set after promotion', async () => { + const mem = makePendingMemory(0.9, 'sweep-verified-at') + cache.upsertMemory(mem, false) + + const before = new Date().toISOString() + await handleSessionEnd( + { summary: 'Done.', extractMemories: false }, + TEST_PROJECT, cache, null, + undefined, + 0.8, + ) + const after = new Date().toISOString() + + const stored = cache.getByKey(TEST_PROJECT, 'sweep-verified-at') + expect(stored?.status).toBe('live') + expect(stored?.verifiedAt).toBeTruthy() + expect(stored!.verifiedAt! >= before).toBe(true) + expect(stored!.verifiedAt! <= after).toBe(true) + }) + + // (c) Already-promoted (live) memory is not re-promoted — idempotency + it('(c) already-live memory is not double-promoted (idempotent)', async () => { + const liveKey = 'sweep-already-live' + const liveMem = makePendingMemory(0.95, liveKey) + // Seed as live directly + cache.upsertMemory({ ...liveMem, status: 'live' }, false) + + const storedBefore = cache.getByKey(TEST_PROJECT, liveKey) + expect(storedBefore?.status).toBe('live') + const verifiedAtBefore = storedBefore?.verifiedAt + + await handleSessionEnd( + { summary: 'Done.', extractMemories: false }, + TEST_PROJECT, cache, null, + undefined, + 0.8, + ) + + const storedAfter = cache.getByKey(TEST_PROJECT, liveKey) + // Status stays live — not re-promoted or reverted + expect(storedAfter?.status).toBe('live') + // verifiedAt should not have been overwritten by the sweep + expect(storedAfter?.verifiedAt).toBe(verifiedAtBefore) + }) + + it('(c) running sweep twice on same data does not change outcome', async () => { + const mem = makePendingMemory(0.9, 'sweep-idempotent') + cache.upsertMemory(mem, false) + + // First sweep + await handleSessionEnd( + { summary: 'Done.', extractMemories: false }, + TEST_PROJECT, cache, null, + undefined, + 0.8, + ) + const afterFirst = cache.getByKey(TEST_PROJECT, 'sweep-idempotent') + expect(afterFirst?.status).toBe('live') + + // Second sweep — same threshold, same data + await handleSessionEnd( + { summary: 'Done again.', extractMemories: false }, + TEST_PROJECT, cache, null, + undefined, + 0.8, + ) + const afterSecond = cache.getByKey(TEST_PROJECT, 'sweep-idempotent') + expect(afterSecond?.status).toBe('live') + // verifiedAt should not have changed on second sweep + expect(afterSecond?.verifiedAt).toBe(afterFirst?.verifiedAt) + }) + + // (d) Empty pending set is a no-op + it('(d) empty pending set is a no-op — no error, no state change', async () => { + // No memories seeded — project is empty + await expect( + handleSessionEnd( + { summary: 'Done.', extractMemories: false }, + TEST_PROJECT, cache, null, + undefined, + 0.8, + ) + ).resolves.toBeDefined() + + const all = cache.getAllForProject(TEST_PROJECT) + expect(all).toHaveLength(0) + }) + + it('(d) sweep with no pending memories returns valid session result', async () => { + const result = await handleSessionEnd( + { summary: 'Done for today.', extractMemories: false }, + TEST_PROJECT, cache, null, + undefined, + 0.5, + ) + expect(result.content[0].type).toBe('text') + expect(typeof result.content[0].text).toBe('string') + }) + + // Guard: null threshold means sweep is skipped entirely + it('null threshold skips sweep — high-confidence pending stays pending', async () => { + const mem = makePendingMemory(0.99, 'sweep-null-threshold') + cache.upsertMemory(mem, false) + + await handleSessionEnd( + { summary: 'Done.', extractMemories: false }, + TEST_PROJECT, cache, null, + undefined, + null, // no threshold + ) + + expect(cache.getByKey(TEST_PROJECT, 'sweep-null-threshold')?.status).toBe('pending') + }) +}) diff --git a/packages/server/src/cache/sqlite.ts b/packages/server/src/cache/sqlite.ts index 78f958b..a42dcb0 100644 --- a/packages/server/src/cache/sqlite.ts +++ b/packages/server/src/cache/sqlite.ts @@ -401,6 +401,28 @@ export class SqliteCache { return rows.map(rowToMemory) } + /** + * T2: Pending memory auto-promotion sweep. + * Promotes all pending memories for the project whose confidence >= threshold + * to live status. Idempotent — already-live memories are not matched by + * the WHERE clause, so running twice on the same data is safe. + * + * Returns the number of memories promoted. + */ + promoteMemories(projectId: string, threshold: number): number { + const now = new Date().toISOString() + const result = this.db.prepare(` + UPDATE memories + SET status = 'live', verified_at = ?, dirty = 1, updated_at = ? + WHERE project_id = ? AND status = 'pending' AND confidence >= ? + `).run(now, now, projectId, threshold) + const promoted = result.changes + if (promoted > 0) { + console.error(`[tages] promoteMemories: promoted ${promoted} pending ${promoted === 1 ? 'memory' : 'memories'} (threshold=${threshold})`) + } + return promoted + } + updateMemoryStatus(id: string, status: MemoryStatus, verifiedAt?: string): void { if (verifiedAt) { this.db.prepare( diff --git a/packages/server/src/tools/session-end.ts b/packages/server/src/tools/session-end.ts index 4cdfff5..11550b6 100644 --- a/packages/server/src/tools/session-end.ts +++ b/packages/server/src/tools/session-end.ts @@ -7,6 +7,11 @@ import { extractMemoriesFromSummary } from './session-extract' /** * Parses a session summary and auto-extracts memories. * Agents call this at the end of a session with a summary of what was built/decided. + * + * If autoSaveThreshold is provided (non-null), a promotion sweep runs after extraction: + * any pending memory for the project with confidence >= threshold is promoted to live. + * Session-extracted memories are written at confidence=0.8 and will be swept if the + * threshold is <= 0.8. Pass null (or omit) to skip the sweep entirely (default). */ export async function handleSessionEnd( args: { summary: string; extractMemories?: boolean }, @@ -14,6 +19,7 @@ export async function handleSessionEnd( cache: SqliteCache, sync: SupabaseSync | null, callerUserId?: string, + autoSaveThreshold: number | null = null, ): Promise<{ content: Array<{ type: 'text'; text: string }> }> { const shouldExtract = args.extractMemories !== false const extracted = shouldExtract ? extractMemoriesFromSummary(args.summary) : [] @@ -44,6 +50,13 @@ export async function handleSessionEnd( } } + // Run the auto-promotion sweep over all pending memories for this project + // (including pre-existing ones from observe calls, and just-written session extracts). + // Guard: only runs when the caller supplies a non-null threshold — opt-in per the spec. + if (autoSaveThreshold != null) { + cache.promoteMemories(projectId, autoSaveThreshold) + } + if (extracted.length > 0) { const lines = extracted.map(m => `- [${m.type}] ${m.key}: ${m.value}`) return { From 2c415b08dd8995fede334640e47d381a9b11d644 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Fri, 15 May 2026 14:50:43 -0600 Subject: [PATCH 2/3] fix(server): wire autoSaveThreshold into session_end handler handleSessionEnd accepted autoSaveThreshold (6th param) after 9d54467 but the MCP tool registration in index.ts:378 dropped it on the floor, making the pending-memory promotion sweep unreachable end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/server/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 1dd52eb..772f731 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -375,7 +375,7 @@ async function main() { extractMemories: SessionEndSchema.shape.extractMemories, }, async (args) => { - const result = await handleSessionEnd(args, projectId, cache, sync, callerUserId) + const result = await handleSessionEnd(args, projectId, cache, sync, callerUserId, autoSaveThreshold) await tracker.endSession() return result }, From 89bf3674e65f03ff1bb002a0d9923f29c5caeb3e Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Fri, 15 May 2026 14:56:44 -0600 Subject: [PATCH 3/3] docs(readme): release notes for T2 auto-save sweep Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 0dcb997..6c7d884 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,10 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines. ## Release Notes +### 2026-05-15 — T2: pending memory auto-promotion sweep + +- **T2 — `auto_save_threshold` now drives pending→live promotion at session end**: `auto_save_threshold` (added in migration 0054) was previously written by the CLI and stored in Postgres but never read at session end — the sweep path was missing. This PR wires it end-to-end: `cache/sqlite.ts` gains `promoteMemories(projectId, threshold)` (single atomic SQL UPDATE); `tools/session-end.ts` accepts an optional `autoSaveThreshold` param and runs the sweep only when non-null (preserving the prior opt-out default for callers that don't pass it); `index.ts` wires the already-in-scope `autoSaveThreshold` value to the call site. 8 new tests cover below/at/above threshold, idempotency, empty pending, and null-threshold opt-out. 640 server tests pass. + ### 2026-04-29 — Week 1 housekeeping (governance unghost, action-setup v6, drop provenance user_id) - **A1 — Governance page indexed**: removed `robots: 'noindex, nofollow'` from `/governance` metadata. The page is now crawlable and eligible for Google Search Console indexing. Added `/governance` link to both the desktop nav (after Security, before GitHub) and the mobile menu using the same styling as adjacent links.