Skip to content
Open
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
199 changes: 199 additions & 0 deletions packages/server/src/__tests__/session-end-sweep.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
22 changes: 22 additions & 0 deletions packages/server/src/cache/sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
Expand Down
13 changes: 13 additions & 0 deletions packages/server/src/tools/session-end.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,19 @@ 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 },
projectId: string,
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) : []
Expand Down Expand Up @@ -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 {
Expand Down