Skip to content

Commit b747d38

Browse files
siglimumuniwaydelyle
authored andcommitted
feat: optional embedding-based dedup on reflection memory writes
Adds two new AppSettings — reflectionSemanticDedupEnabled (boolean, default false) and reflectionSemanticDedupThreshold (number, default 0.88) — that layer cosine-similarity dedup on top of the existing text-equality cross-run dedup in writeReflectionMemories. Motivation: the existing cross-run dedup catches reflections only when the LLM produces literally-identical normalized text. In practice the classifier rederives the same insight in different words across runs ("Always verify before acting" vs "Confirm state first"); text-equality misses these, and 95% of memory rows end up as routine reflection/*. Embedding-based dedup catches the near-duplicates. How it works: - In writeReflectionMemories, after the existing text-equality cross-run dedup loop, if reflectionSemanticDedupEnabled is set: pull recent reflection memories' embeddings (new memoryDb.recentReflectionEmbeddings method), embed each candidate note with the configured embedder, and skip notes whose cosine similarity to any recent reflection exceeds the threshold. - Falls back gracefully when embeddings aren't configured (getEmbedding returns null), when recent memories have no stored embeddings, or on any error — never blocks the write. - writeReflectionMemories becomes async (sole caller already in an async fn, so the change is contained). Why opt-in: each candidate note costs one embedder call (~10-20 ms local nomic-embed-text, more for remote providers). For deployments with the local Ollama setup running already, the cost is negligible; for OpenAI embedding users it adds tokens. Default false leaves behavior unchanged. Files: - src/types/app-settings.ts: 2 new optional fields - src/lib/server/memory/memory-db.ts: recentReflectionEmbeddings method - src/lib/server/autonomy/supervisor-reflection.ts: semantic dedup pass in writeReflectionMemories (now async; call site awaits)
1 parent 4d03800 commit b747d38

3 files changed

Lines changed: 74 additions & 15 deletions

File tree

src/lib/server/autonomy/supervisor-reflection.ts

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -744,7 +744,7 @@ function inferFollowUpAt(note: string, createdAt: number): number {
744744
return createdAt + 7 * 24 * 3600_000
745745
}
746746

747-
function writeReflectionMemories(params: {
747+
async function writeReflectionMemories(params: {
748748
reflectionId: string
749749
runId: string
750750
sessionId: string
@@ -761,7 +761,7 @@ function writeReflectionMemories(params: {
761761
profile: string[]
762762
boundaries: string[]
763763
openLoops: string[]
764-
}): string[] {
764+
}): Promise<string[]> {
765765
const memoryDb = getMemoryDb()
766766
const memoryIds: string[] = []
767767
const incidentIds = params.incidents.map((incident) => incident.id)
@@ -809,11 +809,53 @@ function writeReflectionMemories(params: {
809809
// dedup only rather than blocking the reflection write.
810810
}
811811

812+
// Semantic dedup (opt-in): on top of the text-equality cross-run dedup
813+
// above, compare each candidate note's embedding against recent reflection
814+
// memories' embeddings. Catches near-duplicates the LLM re-derives in
815+
// different words ("Always verify before acting" / "Confirm state first").
816+
// Falls back gracefully when embeddings aren't configured.
817+
let appSettings: AppSettings | null = null
818+
try { appSettings = loadSettings() } catch { appSettings = null }
819+
const semanticDedupEnabled = Boolean(appSettings?.reflectionSemanticDedupEnabled)
820+
const semanticDedupThreshold = typeof appSettings?.reflectionSemanticDedupThreshold === 'number'
821+
? appSettings.reflectionSemanticDedupThreshold
822+
: 0.88
823+
const semanticSkip = new Set<string>()
824+
if (semanticDedupEnabled && params.agentId) {
825+
try {
826+
const recentEmb = memoryDb.recentReflectionEmbeddings(params.agentId, crossRunDedupCutoff, 500)
827+
.filter((r) => Array.isArray(r.embedding) && r.embedding.length > 0) as Array<{ id: string; content: string; embedding: number[] }>
828+
if (recentEmb.length > 0) {
829+
const { getEmbedding, cosineSimilarity } = await import('@/lib/server/embeddings')
830+
for (const group of groups) {
831+
for (const note of group.notes) {
832+
const trimmed = (note || '').trim()
833+
if (!trimmed) continue
834+
const norm = normalizeNote(trimmed)
835+
if (!norm || seenNormalized.has(norm) || semanticSkip.has(norm)) continue
836+
const emb = await getEmbedding(trimmed)
837+
if (!emb) continue
838+
for (const r of recentEmb) {
839+
if (cosineSimilarity(emb, r.embedding) >= semanticDedupThreshold) {
840+
semanticSkip.add(norm)
841+
break
842+
}
843+
}
844+
}
845+
}
846+
}
847+
} catch {
848+
// Best-effort: any failure (embedder offline, DB blip) falls through to
849+
// the existing text-equality dedup. Never block the write.
850+
}
851+
}
852+
812853
for (const group of groups) {
813854
for (const note of group.notes) {
814855
const norm = normalizeNote(note)
815856
if (!norm) continue
816857
if (seenNormalized.has(norm)) continue
858+
if (semanticSkip.has(norm)) continue
817859
seenNormalized.add(norm)
818860
const metadata: Record<string, unknown> = {
819861
origin: 'autonomy-reflection',
@@ -1085,10 +1127,6 @@ export async function observeAutonomyRunOutcome(
10851127
if (parsed.skip) return { incidents, reflection: null }
10861128

10871129
const reflectionId = genId()
1088-
// Quality gate: if reflectionMinQuality is set above 0, only write memories
1089-
// when the reflection's qualityScore meets or exceeds it. Null/undefined
1090-
// qualityScore is admitted (we don't want a model that omits the score to
1091-
// silently lose all memory writes).
10921130
const minQuality = typeof settings.reflectionMinQuality === 'number' ? settings.reflectionMinQuality : 0
10931131
const qualityScore = parsed.qualityScore
10941132
const qualityGateOpen = minQuality <= 0
@@ -1100,7 +1138,7 @@ export async function observeAutonomyRunOutcome(
11001138
)
11011139
}
11021140
const autoMemoryIds = settings.reflectionAutoWriteMemory && qualityGateOpen
1103-
? writeReflectionMemories({
1141+
? await writeReflectionMemories({
11041142
reflectionId,
11051143
runId: input.runId,
11061144
sessionId: input.sessionId,

src/lib/server/memory/memory-db.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1270,6 +1270,30 @@ function initDb() {
12701270
return (stmts.listByAgent.all(agentId, safeLimit) as any[]).map(rowToEntry)
12711271
},
12721272

1273+
/** Return recent reflection/* memories with their embeddings deserialized
1274+
* for semantic dedup. Memories without an embedding (older rows, or
1275+
* embedding still being computed in background) are included with a
1276+
* null embedding so callers can fall back to text dedup. */
1277+
recentReflectionEmbeddings(
1278+
agentId: string,
1279+
sinceMs: number,
1280+
limit = 200,
1281+
): Array<{ id: string; content: string; embedding: number[] | null }> {
1282+
const safeLimit = Math.max(1, Math.min(500, Math.trunc(limit)))
1283+
const rows = db.prepare(
1284+
`SELECT id, content, embedding FROM memories
1285+
WHERE (agentId = ? OR sharedWith LIKE ?)
1286+
AND category LIKE 'reflection/%'
1287+
AND updatedAt >= ?
1288+
ORDER BY updatedAt DESC LIMIT ?`,
1289+
).all(agentId, `%"${agentId}"%`, sinceMs, safeLimit) as Array<{ id: string; content: string; embedding: Buffer | null }>
1290+
return rows.map((row) => ({
1291+
id: row.id,
1292+
content: row.content || '',
1293+
embedding: row.embedding ? deserializeEmbedding(row.embedding) : null,
1294+
}))
1295+
},
1296+
12731297
getFrequentlyAccessedByAgent(agentId: string, minAccessCount = 3, sinceDays = 7): MemoryEntry[] {
12741298
const cutoff = Date.now() - sinceDays * 86_400_000
12751299
const rows = stmts.frequentlyAccessedByAgent.all(agentId, minAccessCount, cutoff) as Record<string, unknown>[]

src/types/app-settings.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -111,15 +111,12 @@ export interface AppSettings {
111111
autonomyResumeApprovalsEnabled?: boolean
112112
reflectionEnabled?: boolean
113113
reflectionAutoWriteMemory?: boolean
114-
/** Minimum reflection quality score (0-1, as emitted by the reflection
115-
* classifier in `parsed.qualityScore`) required to auto-write memories.
116-
* Defaults to 0 (no gating, behavior unchanged). When raised, low-quality
117-
* reflections still produce a RunReflection record but skip the per-kind
118-
* memory writes — reducing the bloat where 95%+ of memory rows are
119-
* routine reflection/* entries that never get retrieved.
120-
* Reflections with a null qualityScore are admitted regardless (so an
121-
* upstream parse failure can't silently lose all memory writes). */
114+
/** Minimum reflection quality score (0-1) required to auto-write memories. */
122115
reflectionMinQuality?: number
116+
/** Enable embedding-based dedup for reflection memory writes. */
117+
reflectionSemanticDedupEnabled?: boolean
118+
/** Cosine threshold above which a reflection note is considered duplicate. */
119+
reflectionSemanticDedupThreshold?: number
123120
memoryReferenceDepth?: number
124121
maxMemoriesPerLookup?: number
125122
maxLinkedMemoriesExpanded?: number

0 commit comments

Comments
 (0)