Skip to content
Merged
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
82 changes: 82 additions & 0 deletions src/main/presenter/agentRuntimePresenter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,11 @@ type PendingInteractionEntry = {
blockIndex: number
}

type MemoryInjectionAccessTurnEntry = {
ids: Set<string>
touchedAt: number
}

type ProcessPendingInputSource = PendingInputEnqueueSource | 'steer'

type PendingTapeViewContext = {
Expand Down Expand Up @@ -218,6 +223,8 @@ const PROVIDER_OVERFLOW_RETRY_EXTRA_RESERVE_CAP = 8_192
const AUTO_APPROVE_REVIEW_MAX_RECENT_MESSAGES = 8
const AUTO_APPROVE_REVIEW_MAX_CONTENT_CHARS = 2_000
const AUTO_APPROVE_REVIEW_TIMEOUT_MS = 30_000
const MEMORY_INJECTION_ACCESS_TURN_TTL_MS = 30 * 60 * 1000
const MEMORY_INJECTION_ACCESS_MAX_TURNS_PER_SESSION = 128

function normalizePermissionMode(mode: PermissionMode | null | undefined): PermissionMode {
return mode === 'default' || mode === 'auto_approve' ? mode : 'full_access'
Expand Down Expand Up @@ -644,6 +651,7 @@ export class AgentRuntimePresenter implements IAgentImplementation {
private readonly memoryPort?: MemoryRuntimePort
private readonly memoryExtractionChains = new Map<string, Promise<void>>()
private readonly memoryExtractionEpochs = new Map<string, number>()
private readonly memoryInjectionAccessByTurn = new Map<string, MemoryInjectionAccessTurnEntry>()
private readonly cacheImage?: (data: string) => Promise<string>
private readonly skillPresenter?: Pick<
ISkillPresenter,
Expand Down Expand Up @@ -928,6 +936,7 @@ export class AgentRuntimePresenter implements IAgentImplementation {
this.runtimeActivatedSkillsBySession.delete(sessionId)
this.sessionCompactionStates.delete(sessionId)
this.drainingPendingQueues.delete(sessionId)
this.clearMemoryInjectionAccessForSession(sessionId)
this.toolPresenter?.clearConversationToolMapping?.(sessionId)
}

Expand Down Expand Up @@ -2435,6 +2444,7 @@ export class AgentRuntimePresenter implements IAgentImplementation {
const injection = await this.memoryPort.buildInjection(agentId, query)
const assembled = appendMemorySectionWithManifest(systemPrompt, injection)
if (assembled.manifest) {
this.recordMemoryInjectionAccess(agentId, sessionId, assembled.manifest.selected, messageId)
try {
this.sqlitePresenter.deepchatTapeEntriesTable.appendAnchor({
sessionId,
Expand All @@ -2453,6 +2463,78 @@ export class AgentRuntimePresenter implements IAgentImplementation {
}
}

private recordMemoryInjectionAccess(
agentId: string,
sessionId: string,
selected: Array<{ id: string }>,
messageId?: string | null
): void {
if (!this.memoryPort || selected.length === 0) return
const selectedIds = [...new Set(selected.map((item) => item.id).filter(Boolean))]
if (!selectedIds.length) return

let idsToRecord = selectedIds
let seen: Set<string> | undefined
if (messageId) {
const now = Date.now()
this.pruneMemoryInjectionAccessForSession(sessionId, now)
const key = this.memoryInjectionAccessKey(sessionId, messageId)
let entry = this.memoryInjectionAccessByTurn.get(key)
if (!entry) {
entry = { ids: new Set(), touchedAt: now }
this.memoryInjectionAccessByTurn.set(key, entry)
this.pruneMemoryInjectionAccessForSession(sessionId, now)
} else {
entry.touchedAt = now
}
seen = entry.ids
const trackedIds = seen
idsToRecord = selectedIds.filter((id) => !trackedIds.has(id))
if (!idsToRecord.length) return
}

try {
this.memoryPort.recordInjectionAccess(agentId, idsToRecord)
if (seen) {
for (const id of idsToRecord) seen.add(id)
}
} catch (error) {
logger.warn(`[DeepChatAgent] memory access accounting skipped: ${String(error)}`)
}
}

private memoryInjectionAccessKey(sessionId: string, messageId: string): string {
return `${sessionId}\u0000${messageId}`
}

private clearMemoryInjectionAccessForSession(sessionId: string): void {
const prefix = `${sessionId}\u0000`
for (const key of this.memoryInjectionAccessByTurn.keys()) {
if (key.startsWith(prefix)) this.memoryInjectionAccessByTurn.delete(key)
}
}

private pruneMemoryInjectionAccessForSession(sessionId: string, now: number = Date.now()): void {
const prefix = `${sessionId}\u0000`
const entries: Array<{ key: string; touchedAt: number }> = []
for (const [key, entry] of this.memoryInjectionAccessByTurn) {
if (!key.startsWith(prefix)) continue
if (now - entry.touchedAt > MEMORY_INJECTION_ACCESS_TURN_TTL_MS) {
this.memoryInjectionAccessByTurn.delete(key)
continue
}
entries.push({ key, touchedAt: entry.touchedAt })
}
if (entries.length <= MEMORY_INJECTION_ACCESS_MAX_TURNS_PER_SESSION) return
entries.sort(
(left, right) => left.touchedAt - right.touchedAt || left.key.localeCompare(right.key)
)
const deleteCount = entries.length - MEMORY_INJECTION_ACCESS_MAX_TURNS_PER_SESSION
for (const entry of entries.slice(0, deleteCount)) {
this.memoryInjectionAccessByTurn.delete(entry.key)
}
}

private triggerMemoryExtractionFromCompaction(sessionId: string, intent: CompactionIntent): void {
if (!this.memoryPort) return
const agentId = this.getSessionAgentId(sessionId) ?? 'deepchat'
Expand Down
6 changes: 5 additions & 1 deletion src/main/presenter/memoryPresenter/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ export function isUniqueConstraintError(error: unknown): boolean {
export class MemoryRuntimeContext {
private disposed = false

constructor(readonly deps: MemoryPresenterDeps) {}
constructor(
readonly deps: MemoryPresenterDeps,
private readonly onAgentMemoryMutated?: (agentId: string) => void
) {}

get isDisposed(): boolean {
return this.disposed
Expand Down Expand Up @@ -70,6 +73,7 @@ export class MemoryRuntimeContext {
}

emitChanged(agentId: string, reason: MemoryUpdateReason, context?: MemoryUpdateContext): void {
this.onAgentMemoryMutated?.(agentId)
if (context) this.deps.onMemoryChanged?.(agentId, reason, context)
else this.deps.onMemoryChanged?.(agentId, reason)
}
Expand Down
4 changes: 4 additions & 0 deletions src/main/presenter/memoryPresenter/core/injectionPort.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ export interface MemoryInjectionPort {
// Adds extraction entry points on top of injection. Extraction is an independent cheap
// LLM call that never touches summarization.
export interface MemoryRuntimePort extends MemoryInjectionPort {
// Records memory rows that actually entered the assembled runtime prompt. Runtime owns the
// final manifest visibility; memory presenter still owns the storage mutation.
recordInjectionAccess(agentId: string, memoryIds: string[], accessedAt?: number): void

// Extracts memories from a span and writes them (status=pending_embedding).
// Resolves { ok:true, createdIds } (createdIds may be empty) or { ok:false } on failure.
// Never throws or blocks the caller; on ok:false the caller must keep its cursor for retry.
Expand Down
29 changes: 25 additions & 4 deletions src/main/presenter/memoryPresenter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,10 @@ export class MemoryPresenter implements MemoryRuntimePort {
private readonly management: ManagementService

constructor(deps: MemoryPresenterDeps) {
this.runtime = new MemoryRuntimeContext(deps)
let retrievalService: RetrievalService | null = null
this.runtime = new MemoryRuntimeContext(deps, (agentId) => {
retrievalService?.invalidateKeywordStats(agentId)
})
this.rows = new MemoryRowMutations(this.runtime)
this.vectorStore = new VectorStoreManager(this.runtime)
this.embedding = new EmbeddingPipeline(this.runtime, this.vectorStore, this.rows, {
Expand All @@ -92,6 +95,7 @@ export class MemoryPresenter implements MemoryRuntimePort {
memoryIds
)
})
retrievalService = this.retrieval

this.reflection = new ReflectionService(this.runtime, {
syncWorkingMemoryAfterMutation: (agentId) =>
Expand Down Expand Up @@ -128,8 +132,10 @@ export class MemoryPresenter implements MemoryRuntimePort {
warmVectorStore: (agentId, embedding) => this.embedding.warmVectorStore(agentId, embedding),
warmEmbeddingConnection: (agentId, embedding) =>
this.embedding.warmEmbeddingConnection(agentId, embedding),
maybeReflect: (agentId, model) => this.reflection.maybeReflect(agentId, model),
maybeEvolvePersona: (agentId, model) => this.persona.maybeEvolvePersona(agentId, model),
maybeReflect: (agentId, model) =>
this.reflection.runMaintenanceReflectionPass(agentId, model),
maybeEvolvePersona: (agentId, model) =>
this.persona.runMaintenancePersonaPass(agentId, model),
runChallengeResolutionPass: (agentId, model) =>
this.conflict.runChallengeResolutionPass(agentId, model),
runConsolidationPass: (agentId) => this.runConsolidationPass(agentId)
Expand Down Expand Up @@ -282,7 +288,9 @@ export class MemoryPresenter implements MemoryRuntimePort {
}

writeMemoriesSync(candidates: MemoryCandidate[], options: WriteMemoriesOptions): string[] {
return this.writeCoordinator.writeMemoriesSync(candidates, options)
const ids = this.writeCoordinator.writeMemoriesSync(candidates, options)
if (ids.length > 0) this.retrieval.invalidateKeywordStats(options.agentId)
return ids
}

processPendingEmbeddings(agentId: string, limit = 50): Promise<void> {
Expand Down Expand Up @@ -384,6 +392,19 @@ export class MemoryPresenter implements MemoryRuntimePort {
return this.retrieval.buildInjection(agentId, query)
}

recordInjectionAccess(
agentId: string,
memoryIds: string[],
accessedAt: number = Date.now()
): void {
if (!this.runtime.canReadAgentMemory(agentId)) return
const uniqueIds = [...new Set(memoryIds.map((id) => id.trim()).filter(Boolean))]
if (!uniqueIds.length) return
const ownedIds = this.runtime.deps.repository.listByIds(agentId, uniqueIds).map((row) => row.id)
if (!ownedIds.length) return
this.runtime.deps.repository.recordAccessBatch(ownedIds, accessedAt)
}

refreshWorkingMemory(agentId: string): void {
this.workingMemory.refreshWorkingMemory(agentId)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,9 @@ export class EmbeddingPipeline {
private async runBackfill(agentId: string): Promise<void> {
await Promise.resolve()
if (!this.ctx.canContinueAgentMemoryTask(agentId)) return
this.ctx.deps.repository.requeueForEmbedding(agentId, ['fts_only'])
if (this.ctx.deps.repository.listEmbeddingStatusIds(agentId, ['fts_only'], 1).length) {
this.ctx.deps.repository.requeueForEmbedding(agentId, ['fts_only'])
}
await this.drainUntilExhausted(agentId)
}

Expand Down
1 change: 1 addition & 0 deletions src/main/presenter/memoryPresenter/runtimeConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const DECISION_NEIGHBOR_TOP_S = 10

export const CONSOLIDATION_IDLE_MS = 5 * 60 * 1000
export const CONSOLIDATION_COOLDOWN_MS = 6 * 60 * 60 * 1000
export const CONSOLIDATION_FAILURE_COOLDOWN_MS = 30 * 60 * 1000
export const CONSOLIDATION_MAX_LLM_CALLS = 8
export const CONSOLIDATION_MAX_INPUT_TOKENS = 24000
export const CONSOLIDATION_MERGE_SIMILARITY = 0.85
Expand Down
18 changes: 15 additions & 3 deletions src/main/presenter/memoryPresenter/services/conflictService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ import {
} from '../core/decision'
import { normalizeMemoryCandidate } from '../core/candidates'
import { buildMemoryProvenanceKey } from '../core/scoring'
import type { AgentMemoryRow, MemoryConflictPair, MemoryConflictResolution } from '../types'
import type {
AgentMemoryRow,
MemoryConflictPair,
MemoryConflictResolution,
MemoryMaintenanceStepResult
} from '../types'
import { type MemoryModelRef, type MemoryRuntimeContext } from '../context'

interface ConflictResolutionOptions {
Expand Down Expand Up @@ -152,8 +157,13 @@ export class ConflictService {
.filter((row) => row.id !== excludeChallengerId && row.conflict_with === targetId)
}

async runChallengeResolutionPass(agentId: string, model: MemoryModelRef): Promise<boolean> {
async runChallengeResolutionPass(
agentId: string,
model: MemoryModelRef
): Promise<MemoryMaintenanceStepResult> {
let touched = false
let calls = 0
let failures = 0
for (const pair of this.listConflicts(agentId)) {
const promptCandidate = normalizeMemoryCandidate({
kind: pair.challenger.kind === 'episodic' ? 'episodic' : 'semantic',
Expand All @@ -165,9 +175,11 @@ export class ConflictService {
const prompt = buildDecisionPrompt(promptCandidate, [{ content: pair.target.content }])
let decision: MemoryDecision = ADD_DECISION
try {
calls += 1
const raw = await this.ctx.deps.generateText(model.providerId, model.modelId, prompt)
decision = parseDecision(raw, 1)
} catch (error) {
failures += 1
logger.warn(`[Memory] challenge decision failed: ${String(error)}`)
continue
}
Expand All @@ -190,6 +202,6 @@ export class ConflictService {
touched = true
}
}
return touched
return { touched, calls, failures }
}
}
Loading