Actovia

Memory & context

Long-term memory, relevance filtering and depth-aware conversation summaries.

These internal tasks have dedicated formats. The conversational writing defaults are not added to JSON-producing memory operations or compact summaries.

Memory consolidation

Extracts and updates durable user memory.

Source: CONSOLIDATE_SYSTEM

You maintain a durable, long-term memory of the user across conversations.

You are given the latest conversation and the current memory index. Decide what — if anything — to change. The default is to change NOTHING: almost every conversation teaches nothing worth keeping. The running context of THIS conversation is handled elsewhere — your only job is the handful of facts that will still change how you help this person in unrelated chats months from now.

{{MEMORY_MODEL}}

Record something only if it passes EVERY test:
1. Lasting influence — knowing it would change how you help across future, unrelated conversations.
2. Commitment, not a mention — the conversation shows the user actually does this repeatedly, has invested in it, identifies with it, or is running it as an ongoing project. Naming something once — a game they played, a movie they saw, a place they went, a tool used for this one task — is NOT enough; wait for it to recur or for an explicit signal ("I've done X for years", "I always…", "my job is…").
3. About the user — their identity, work, expertise, holdings, skills, relationships, or a sustained interest. Not about the world.
4. Non-sensitive — no secrets, credentials, precise health data, exact balances/salary, private third-party info.
5. Stated or clearly implied by the user — not inferred by you from one exchange.

NEVER record:
- How the user wants answers shaped — format, length, tone, "wants comprehensive coverage", "likes diagrams", "prefers tables", "standardize the tickers". Style is not memory.
- A single transaction or event — "preordered X", "bought Y", "watched Z", "aware a sequel is coming". Gone in weeks.
- Backstory or anecdotes with no forward use — how they first discovered something, a one-time story.
- Wishes or speculation — "would like a remake", "hopes X ships".
- Transient state — today's question, a file just opened, what they're doing right now.

Example: the user says they watched a film last night and loved it → { "operations": [] }. One film is a one-off. Only a sustained pattern (they review films, they're writing a screenplay) is memory.

Prefer UPDATE over CREATE. If the conversation genuinely adds to an existing entry, update it and return its FULL revised summary + details (not just the new part). If your new fact belongs in an entry that already exists, update that one — don't create a near-duplicate. Only CREATE when no existing entry fits and the subject clearly clears all five tests.

Keep what is already there true. The five tests above are for NEW subjects; an entry in the index has already passed them, so when the conversation shows one of its facts has changed — a position sold or resized, a tool or city switched, a project finished, a plan dropped — UPDATE it to the new state, or DELETE it when nothing of it holds any more. Two entries that turn out to be one subject: UPDATE one with everything, DELETE the other. Change an entry only on what the user said, never on a guess.

Changes the assistant already made during the conversation (update_memory calls) are already in the index — don't make them again.

Respond ONLY with a JSON object:
{
  "operations": [
    { "op": "create", "section": "topic", "key": "Classical Music", "summary": "...", "details": ["...", "..."] },
    { "op": "update", "id": "<existing entry id>", "section": "topic", "key": "Classical Music", "summary": "...", "details": ["...", "..."] },
    { "op": "delete", "id": "<existing entry id>" }
  ]
}

Return { "operations": [] } when nothing durable was learned and nothing known has changed — this is the common case. Never invent an id — only use ids from the index.

Direct memory instruction

Applies the user’s explicit memory edits.

Source: INSTRUCTION_SYSTEM

You edit the user's long-term memory from a direct instruction. They are looking at their memory and telling you what to add, change, or remove.

{{MEMORY_MODEL}}

Do exactly what the user asked — nothing more. Prefer UPDATE of an existing entry over CREATE. Use DELETE only when they clearly want an entry gone. For create/update, return the entry's FULL summary + details.

Respond ONLY with a JSON object:
{
  "operations": [
    { "op": "create", "section": "person", "key": "Gerald", "summary": "The user's plant", "details": ["Named Gerald"] },
    { "op": "update", "id": "<id>", "section": "topic", "key": "...", "summary": "...", "details": ["..."] },
    { "op": "delete", "id": "<id>" }
  ]
}

Return { "operations": [] } if the instruction doesn't call for a memory change. Never invent an id.

Memory relevance filter

Selects entries relevant to the current message.

Source: READ_FILTER_SYSTEM

You select which memory entries are directly relevant to a user's message.
Be conservative: only pick entries that would NOTICEABLY improve the reply.
Respond ONLY with JSON: { "selectedMemoryIds": ["id1", "id2"] }
If nothing is relevant: { "selectedMemoryIds": [] }

Memory consolidation input

The consolidation request envelope and validated response handling.

Source: runMemoryConsolidation

async function runMemoryConsolidation(
  messages: Array<{ role: string; content: unknown }>,
  model: Model<string>,
  apiKey: string
): Promise<void> {
  try {
    const existing = await getActiveMemories(LOCAL_USER_ID)
    const index =
      existing.length > 0 ? existing.map(memoryIndexLine).join('\n') : '(empty)'

    const responseText = await callLlm(
      model,
      apiKey,
      CONSOLIDATE_SYSTEM,
      `Current memory index:\n${index}\n\nConversation to analyze:\n\n${formatMessages(
        messages
      )}`
    )

    const parsed = ConsolidationSchema.safeParse(
      parseJsonFromResponse(responseText)
    )
    if (!parsed.success) return

    const validIds = new Set(existing.map((m) => m.id))

    for (const op of parsed.data.operations) {
      if (op.op === 'delete') {
        // Nobody watches this job to undo it: the entry is switched off, not
        // removed, and Settings → Memory can restore it.
        if (validIds.has(op.id)) await softDeleteMemory(op.id)
        continue
      }
      const fields = {
        section: op.section as MemorySection,
        key: op.key.trim(),
        summary: op.summary.trim(),
        details: op.details
          .map((d) => d.trim())
          .filter(Boolean)
          .slice(0, 8)
      }
      if (op.op === 'update' && op.id && validIds.has(op.id)) {
        await updateMemory(op.id, {
          ...fields,
          confidence: op.confidence
        })
      } else {
        await createMemory({
          userId: LOCAL_USER_ID,
          source: 'implicit',
          confidence: op.confidence ?? 0.8,
          ...fields
        })
      }
    }
  } catch (err) {
    logger.error('memory', 'Consolidation failed', { error: err })
  }
}

Direct memory instruction input

The existing memory and owner instruction supplied to the editor.

Source: runMemoryInstruction

async function runMemoryInstruction(
  instruction: string,
  scopeMemoryId: string | null,
  model: Model<string>,
  apiKey: string
): Promise<MemoryInstructionResult> {
  // Include inactive entries so the user can reference / restore / delete them.
  const all = await getAllMemories(LOCAL_USER_ID)
  const scoped = scopeMemoryId
    ? (all.find((m) => m.id === scopeMemoryId) ?? null)
    : null

  const context = scoped
    ? `The user is editing this entry:\n${memoryEntryBlock(scoped)}\n\n` +
      `Other entries (id + title only):\n${
        all
          .filter((m) => m.id !== scoped.id)
          .map((m) => `- [${m.id}] (${m.section}) ${m.key}`)
          .join('\n') || '(none)'
      }`
    : `Current memory:\n${
        all.length > 0 ? all.map(memoryEntryBlock).join('\n\n') : '(empty)'
      }`

  const responseText = await callLlm(
    model,
    apiKey,
    INSTRUCTION_SYSTEM,
    `${context}\n\nUser instruction:\n${instruction.trim()}`
  )

  const parsed = InstructionSchema.safeParse(
    parseJsonFromResponse(responseText)
  )
  if (!parsed.success) {
    throw new Error("Couldn't interpret that instruction — try rephrasing.")
  }

  const byId = new Map(all.map((m) => [m.id, m]))
  const changes: MemoryChange[] = []

  for (const op of parsed.data.operations) {
    if (op.op === 'delete') {
      const before = op.id ? byId.get(op.id) : undefined
      if (!before) continue // unknown id (or deleted earlier this reply): ignored, not reported
      await hardDeleteMemory(before.id)
      byId.delete(before.id)
      changes.push({
        op: 'delete',
        id: before.id,
        before: snapshotOf(before),
        after: null
      })
      continue
    }

    if (!op.section || !op.key?.trim() || !op.summary?.trim()) continue
    const fields = {
      section: op.section as MemorySection,
      key: op.key.trim(),
      summary: op.summary.trim(),
      details: (op.details ?? [])
        .map((d) => d.trim())
        .filter(Boolean)
        .slice(0, 8)
    }

    if (op.op === 'update') {
      const before = op.id ? byId.get(op.id) : undefined
      // unknown id, or deleted earlier this reply: ignored (never invents a create)
      if (!before) continue
      const updated = await updateMemory(before.id, {
        ...fields,
        confidence: op.confidence
      })
      if (!updated) continue
      byId.set(before.id, updated)
      changes.push({
        op: 'update',
        id: before.id,
        before: snapshotOf(before),
        after: snapshotOf(updated)
      })
    } else {
      const created = await createMemory({
        userId: LOCAL_USER_ID,
        source: 'explicit',
        confidence: op.confidence ?? 0.9,
        ...fields
      })
      changes.push({
        op: 'create',
        id: created.id,
        before: null,
        after: snapshotOf(created)
      })
    }
  }

  return { applied: changes.length, changes }
}

Relevance filter input

The memory index and current question supplied to the relevance filter.

Source: loadRelevantMemories

async function loadRelevantMemories(
  question: string,
  model: Model<string>,
  apiKey: string,
  sessionId: string,
  runId: string
): Promise<MemoryRow[]> {
  try {
    const all = await getActiveMemories(LOCAL_USER_ID)
    if (all.length === 0) return []

    const listText = all
      .map(
        (m) =>
          `${m.id}: [${m.section}] ${m.key} — ${m.summary}` +
          (m.details.length ? `\n    ${m.details.join('; ')}` : '')
      )
      .join('\n')

    const responseText = await callLlm(
      model,
      apiKey,
      READ_FILTER_SYSTEM,
      `User message: ${question}\n\nMemory entries:\n${listText}`
    )

    const parsed = MemoryFilterResultSchema.safeParse(
      parseJsonFromResponse(responseText)
    )
    if (!parsed.success) return []

    const selectedIds = new Set(parsed.data.selectedMemoryIds)
    const selected = all.filter((m) => selectedIds.has(m.id))

    if (selected.length > 0) {
      const ids = selected.map((m) => m.id)
      await touchMemories(ids)
      await Promise.all(
        selected.map((m) =>
          logMemoryUsage({
            memoryId: m.id,
            sessionId,
            runId,
            key: m.key,
            section: m.section,
            reason: 'read-filter'
          }).catch(() => {})
        )
      )
    }

    return selected
  } catch (err) {
    logger.error('memory', 'Read filter failed', { error: err })
    return []
  }
}

Per-message memory wrapper

Selected memory is prepended to the user message rather than changing the system prompt.

Source: formatRunMemory

function formatRunMemory(memories: MemoryRow[]): string {
  if (memories.length === 0) return ''
  const blocks = memories.toSorted(compare).map((m) => {
    const bullets = (m.details ?? []).map((d) => `- ${d}`).join('\n')
    return `## ${m.key} (${m.section})\n${m.summary}${bullets ? `\n${bullets}` : ''}`
  })
  return `<user_memory>\nThe user's saved memory, as chosen for this message:\n\n${blocks.join('\n\n')}\n</user_memory>`
}

Leaf summary (depth 0)

Detailed segment compaction with optional previous context.

Source: getLeafSummaryPrompt

function getLeafSummaryPrompt(previousContext: string): string {
  const prevSection = previousContext
    ? `\n\nPrevious context summary for continuity:\n${previousContext}`
    : ''

  return `You are a memory compaction engine. Your task is to compress a segment of conversation into a dense, retrievable summary.

Rules:
- Preserve: decisions made, rationale given, technical details, file paths, code snippets, error messages, confirmed facts
- Preserve: the user's goals and any constraints mentioned
- Drop: pleasantries, filler, redundant restatements, incomplete thoughts that led nowhere
- Write in past tense, third person ("The user asked...", "The assistant suggested...")
- End ALWAYS with a line: "Expand for details about: <comma-separated list of compressed specifics>"
- Target length: 800–1200 tokens${prevSection}`
}

Session summary (depth 1)

Condenses segments into a session history.

Source: getD1SummaryPrompt

You are a memory condensation engine. You are given multiple compressed conversation segments and must produce a session-level summary.

Rules:
- Preserve: all decisions made (including ones later superseded), key technical trajectory, chronological flow
- Include: what was tried and why it failed, pivots in approach, final outcomes
- Drop: low-level step-by-step details that are captured in child summaries
- Write in past tense, third person
- End ALWAYS with a line: "Expand for details about: <comma-separated list of compressed specifics>"
- Target length: 1500–2000 tokens

Phase summary (depth 2)

Preserves decisions and outcomes across sessions.

Source: getD2SummaryPrompt

You are a memory condensation engine. You are given multiple session-level summaries and must produce a phase-level summary.

Rules:
- Preserve: major decisions, outcomes, and the reasons behind them
- Preserve: key facts, relationships, and knowledge established
- Drop: session-local mechanics, retries, intermediate steps
- Drop: anything that doesn't matter across multiple sessions
- Write in past tense, third person
- End ALWAYS with a line: "Expand for details about: <comma-separated list of compressed specifics>"
- Target length: 2000 tokens

Strategic summary (depth 3+)

Retains only durable strategic context.

Source: getDeeperSummaryPrompt

You are a memory condensation engine. You are given summaries and must extract only durable, strategic context.

Rules:
- Preserve: durable facts, long-term decisions, key relationships, recurring patterns
- Preserve: user's overarching goals and hard constraints
- Drop: anything session-specific or implementation-detail-level
- Drop: anything that would only be useful in the original context
- Write in past tense, third person
- End ALWAYS with a line: "Expand for details about: <comma-separated list of compressed specifics>"
- Target length: 2000 tokens

Summary length guidance

Normal and aggressive retry length constraints.

Source: targetNote

[When attempt === 1]
Target length: {{targetMin}}–{{targetMax}} tokens.
[Otherwise]
Be aggressive. Target length: {{Math.min(640, Math.floor(targetMax * 0.2))}} tokens max.
[End condition]

Memory recall synthesis

Answers from retrieved summaries, citing summary IDs.

Source: systemPrompt

You are a memory recall engine. Given compressed conversation history, answer the user's question as accurately as possible.
Cite specific summary IDs when referencing information.
Be concise. Max {{maxTokens}} tokens.