From c779f4915ff99ea730a8e34919931cf8d69d8415 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 9 Jun 2026 00:36:42 +0100 Subject: [PATCH] Add Persona semantic memory recall --- apps/bot/src/discord-bot.ts | 5 +- apps/worker/src/index.ts | 4 + apps/worker/src/mcp-server-context.ts | 3 + packages/config/src/index.ts | 6 + packages/rag/src/in-memory-vector-store.ts | 4 + packages/rag/src/qdrant-vector-store.ts | 37 ++- packages/rag/src/retrieval-service.ts | 12 + packages/rag/src/vector-store-factory.ts | 13 + packages/shared/src/index.ts | 7 +- prompts/repo-face-turn.prompt.md | 3 + scripts/backfill-discord-logs.ts | 2 + scripts/purge-discord-context.ts | 2 + scripts/rebuild-rag-index.ts | 4 + scripts/run-repo-face-heartbeats.ts | 302 ++++++++++++++++++++- scripts/scrub-bot-prompts-from-history.ts | 2 + scripts/source-repo-indexing.ts | 2 + 16 files changed, 396 insertions(+), 12 deletions(-) diff --git a/apps/bot/src/discord-bot.ts b/apps/bot/src/discord-bot.ts index e8d94a3..6b84b8f 100644 --- a/apps/bot/src/discord-bot.ts +++ b/apps/bot/src/discord-bot.ts @@ -170,14 +170,17 @@ export async function startBot(): Promise { const vectorStores = createVectorStores({ kind: config.vectorStore.kind, historyPath: config.vectorStore.path, + personaMemoryPath: config.vectorStore.personaMemoryPath, sourceRoot: config.sourceVectorStoreRoot, qdrant: config.qdrant, historyEmbedder: embedder, sourceEmbedder: sourceQueryEmbedder, + personaMemoryEmbedder: embedder, }); const vectorStore = vectorStores.history; const sourceRetrievalStore = vectorStores.source; - const retrievalService = new RetrievalService(vectorStore, sourceRetrievalStore); + const personaMemoryStore = vectorStores.personaMemory; + const retrievalService = new RetrievalService(vectorStore, sourceRetrievalStore, personaMemoryStore); const historyIngester = new HistoryIngester(); const sourceDocumentIngester = new SourceDocumentIngester(); const ragPipeline = new RagPipeline(archiveRepository, historyIngester, vectorStore); diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index a195453..58aca7c 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -112,19 +112,23 @@ const sourceQueryEmbedder = createTextEmbedder({ const vectorStores = createVectorStores({ kind: config.vectorStore.kind, historyPath: config.vectorStore.path, + personaMemoryPath: config.vectorStore.personaMemoryPath, sourceRoot: config.sourceVectorStoreRoot, qdrant: config.qdrant, historyEmbedder: embedder, sourceEmbedder: sourceQueryEmbedder, + personaMemoryEmbedder: embedder, }); const historyVectorStore = vectorStores.history; const sourceVectorStore = vectorStores.source; +const personaMemoryVectorStore = vectorStores.personaMemory; const archiveRepository = new FileMessageArchiveRepository(config.ragArchivePath); const sourceArchiveRepository = new FileSourceDocumentArchiveRepository(config.ragSourceArchivePath); const sourceDocumentIngester = new SourceDocumentIngester(); const retrievalService = new RetrievalService( historyVectorStore, sourceVectorStore, + personaMemoryVectorStore, ); let isProcessing = false; diff --git a/apps/worker/src/mcp-server-context.ts b/apps/worker/src/mcp-server-context.ts index 8ccf9b7..d77858c 100644 --- a/apps/worker/src/mcp-server-context.ts +++ b/apps/worker/src/mcp-server-context.ts @@ -47,14 +47,17 @@ export function createVoidbotMcpContext(): VoidbotMcpContext { const vectorStores = createVectorStores({ kind: config.vectorStore.kind, historyPath: config.vectorStore.path, + personaMemoryPath: config.vectorStore.personaMemoryPath, sourceRoot: config.sourceVectorStoreRoot, qdrant: config.qdrant, historyEmbedder: embedder, sourceEmbedder: sourceQueryEmbedder, + personaMemoryEmbedder: embedder, }); const retrievalService = new RetrievalService( vectorStores.history, vectorStores.source, + vectorStores.personaMemory, ); const sourceDocumentIngester = new SourceDocumentIngester(); diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 01c8dfc..df54d48 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -48,12 +48,14 @@ const envSchema = z.object({ DATABASE_DSN: z.string().min(1).default("postgres://voidbot:voidbot@localhost:5432/voidbot"), VECTOR_STORE_KIND: z.enum(["local_json", "qdrant"]).default("local_json"), VECTOR_STORE_PATH: z.string().min(1).default(".voidbot/history-vector-store.json"), + PERSONA_MEMORY_VECTOR_STORE_PATH: z.string().min(1).default(".voidbot/persona-memory-vector-store.json"), SOURCE_VECTOR_STORE_ROOT: z.string().min(1).default(".voidbot/source-vectors"), QDRANT_URL: z.string().url().default("http://127.0.0.1:6333"), QDRANT_API_KEY: optionalNonEmptyString, QDRANT_TIMEOUT_MS: z.coerce.number().int().positive().default(30000), QDRANT_HISTORY_COLLECTION: z.string().min(1).default("voidbot_discord_history_chunks"), QDRANT_SOURCE_COLLECTION: z.string().min(1).default("voidbot_repository_source_chunks"), + QDRANT_PERSONA_MEMORY_COLLECTION: z.string().min(1).default("voidbot_persona_memory_chunks"), RAG_ARCHIVE_PATH: z.string().min(1).default(".voidbot/rag/messages.json"), RAG_SOURCE_ARCHIVE_PATH: z.string().min(1).default(".voidbot/rag/source-documents.json"), RAG_IMPORT_STATE_PATH: z.string().min(1).default(".voidbot/rag/import-state.json"), @@ -253,6 +255,7 @@ export interface AppConfig { vectorStore: { kind: "local_json" | "qdrant"; path: string; + personaMemoryPath: string; }; sourceVectorStoreRoot: string; qdrant: { @@ -261,6 +264,7 @@ export interface AppConfig { timeoutMs: number; historyCollection: string; sourceCollection: string; + personaMemoryCollection: string; }; rateLimits: { defaultCooldownSeconds: number; @@ -504,6 +508,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { vectorStore: { kind: parsed.VECTOR_STORE_KIND, path: resolve(parsed.VECTOR_STORE_PATH), + personaMemoryPath: resolve(parsed.PERSONA_MEMORY_VECTOR_STORE_PATH), }, sourceVectorStoreRoot: resolve(parsed.SOURCE_VECTOR_STORE_ROOT), qdrant: { @@ -512,6 +517,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { timeoutMs: parsed.QDRANT_TIMEOUT_MS, historyCollection: parsed.QDRANT_HISTORY_COLLECTION, sourceCollection: parsed.QDRANT_SOURCE_COLLECTION, + personaMemoryCollection: parsed.QDRANT_PERSONA_MEMORY_COLLECTION, }, rateLimits: { defaultCooldownSeconds: parsed.VOID_USAGE_COOLDOWN_SECONDS, diff --git a/packages/rag/src/in-memory-vector-store.ts b/packages/rag/src/in-memory-vector-store.ts index 0bb389f..1582d21 100644 --- a/packages/rag/src/in-memory-vector-store.ts +++ b/packages/rag/src/in-memory-vector-store.ts @@ -111,6 +111,10 @@ function matchesFilters( return false; } + if (filters.identityId && metadata.identityId !== filters.identityId) { + return false; + } + if (filters.corpusKind && metadata.corpusKind !== filters.corpusKind) { return false; } diff --git a/packages/rag/src/qdrant-vector-store.ts b/packages/rag/src/qdrant-vector-store.ts index 298e0a5..b5795cb 100644 --- a/packages/rag/src/qdrant-vector-store.ts +++ b/packages/rag/src/qdrant-vector-store.ts @@ -27,7 +27,7 @@ export interface StoredQdrantChunk extends EmbeddingChunk { export interface QdrantVectorStoreOptions { url: string; collectionName: string; - corpusKind: "discord_history" | "repository_source"; + corpusKind: "discord_history" | "repository_source" | "persona_memory"; apiKey?: string; timeoutMs?: number; embedder?: TextEmbedder; @@ -296,6 +296,10 @@ function buildPayload(chunk: StoredQdrantChunk): QdrantPayload { copyOptionalMetadataField(payload, chunk.metadata, "guildId"); copyOptionalMetadataField(payload, chunk.metadata, "channelId"); copyOptionalMetadataField(payload, chunk.metadata, "authorId"); + copyOptionalMetadataField(payload, chunk.metadata, "identityId"); + copyOptionalMetadataField(payload, chunk.metadata, "memoryKind"); + copyOptionalMetadataField(payload, chunk.metadata, "targetKind"); + copyOptionalMetadataField(payload, chunk.metadata, "targetId"); copyOptionalMetadataField(payload, chunk.metadata, "threadId"); copyOptionalMetadataField(payload, chunk.metadata, "repoName"); copyOptionalMetadataField(payload, chunk.metadata, "language"); @@ -346,8 +350,14 @@ function buildPathPrefixes(path: string): string[] { function inferCorpusKind( sourceKind: StoredQdrantChunk["sourceKind"], -): "discord_history" | "repository_source" { - return sourceKind === "source_document" ? "repository_source" : "discord_history"; +): "discord_history" | "repository_source" | "persona_memory" { + if (sourceKind === "source_document") { + return "repository_source"; + } + if (sourceKind === "persona_memory") { + return "persona_memory"; + } + return "discord_history"; } function fromQdrantPoint(point: QdrantScoredPoint): RetrievalResult | undefined { @@ -407,8 +417,10 @@ function readString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } -function readSourceKind(value: unknown): "discord_message" | "source_document" | undefined { - return value === "discord_message" || value === "source_document" ? value : undefined; +function readSourceKind(value: unknown): "discord_message" | "source_document" | "persona_memory" | undefined { + return value === "discord_message" || value === "source_document" || value === "persona_memory" + ? value + : undefined; } function validateEmbeddingBatch(vectors: number[][], expectedCount: number): number { @@ -433,7 +445,7 @@ function validateCollectionCompatibility( collection: Schemas["CollectionInfo"], expected: { collectionName: string; - corpusKind: "discord_history" | "repository_source"; + corpusKind: "discord_history" | "repository_source" | "persona_memory"; embedderId: string; vectorLength: number; }, @@ -484,7 +496,7 @@ function readCollectionVectorLength( } function buildPayloadIndexDefinitions( - corpusKind: "discord_history" | "repository_source", + corpusKind: "discord_history" | "repository_source" | "persona_memory", ): Array<{ field_name: string; field_schema: @@ -509,6 +521,16 @@ function buildPayloadIndexDefinitions( ]; } + if (corpusKind === "persona_memory") { + return [ + ...shared, + { field_name: "identityId", field_schema: "keyword" as const }, + { field_name: "memoryKind", field_schema: "keyword" as const }, + { field_name: "targetKind", field_schema: "keyword" as const }, + { field_name: "targetId", field_schema: "keyword" as const }, + ]; + } + return [ ...shared, { field_name: "repoName", field_schema: "keyword" as const }, @@ -528,6 +550,7 @@ function toQdrantFilter(filters?: RetrievalFilters): QdrantFilter | undefined { appendExactMatch(must, "guildId", filters.guildId); appendExactMatch(must, "channelId", filters.channelId); appendExactMatch(must, "authorId", filters.authorId); + appendExactMatch(must, "identityId", filters.identityId); appendExactMatch(must, "repoName", filters.repoName); appendExactMatch(must, "language", filters.language); appendExactMatch(must, "sourceId", filters.sourceId); diff --git a/packages/rag/src/retrieval-service.ts b/packages/rag/src/retrieval-service.ts index 8434e60..7d91b18 100644 --- a/packages/rag/src/retrieval-service.ts +++ b/packages/rag/src/retrieval-service.ts @@ -8,6 +8,7 @@ export class RetrievalService { public constructor( private readonly historyVectorStore: VectorStore, private readonly sourceVectorStore: VectorStore = historyVectorStore, + private readonly personaMemoryVectorStore: VectorStore = historyVectorStore, ) {} public async search( @@ -39,4 +40,15 @@ export class RetrievalService { corpusKind: "repository_source", }); } + + public async searchPersonaMemory( + query: string, + limit = 5, + filters?: Omit, + ): Promise { + return this.personaMemoryVectorStore.query(query, limit, { + ...filters, + corpusKind: "persona_memory", + }); + } } diff --git a/packages/rag/src/vector-store-factory.ts b/packages/rag/src/vector-store-factory.ts index 5f740eb..f5b183d 100644 --- a/packages/rag/src/vector-store-factory.ts +++ b/packages/rag/src/vector-store-factory.ts @@ -8,6 +8,7 @@ import { ShardedSourceVectorStore } from "./source-vector-store"; export interface VectorStoreFactoryOptions { kind: "local_json" | "qdrant"; historyPath: string; + personaMemoryPath: string; sourceRoot: string; qdrant: { url: string; @@ -15,14 +16,17 @@ export interface VectorStoreFactoryOptions { timeoutMs: number; historyCollection: string; sourceCollection: string; + personaMemoryCollection: string; }; historyEmbedder: TextEmbedder; sourceEmbedder: TextEmbedder; + personaMemoryEmbedder: TextEmbedder; } export interface BuiltVectorStores { history: VectorStore; source: VectorStore; + personaMemory: VectorStore; } export function createVectorStores(options: VectorStoreFactoryOptions): BuiltVectorStores { @@ -44,11 +48,20 @@ export function createVectorStores(options: VectorStoreFactoryOptions): BuiltVec corpusKind: "repository_source", embedder: options.sourceEmbedder, }), + personaMemory: new QdrantVectorStore({ + url: options.qdrant.url, + apiKey: options.qdrant.apiKey, + timeoutMs: options.qdrant.timeoutMs, + collectionName: options.qdrant.personaMemoryCollection, + corpusKind: "persona_memory", + embedder: options.personaMemoryEmbedder, + }), }; } return { history: new FileVectorStore(options.historyPath, options.historyEmbedder), source: new ShardedSourceVectorStore(options.sourceRoot, options.sourceEmbedder), + personaMemory: new FileVectorStore(options.personaMemoryPath, options.personaMemoryEmbedder), }; } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index a221a67..abeeee2 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -107,15 +107,16 @@ export interface RetrievalResult { score: number; text: string; sourceId: string; - sourceKind: "discord_message" | "source_document"; + sourceKind: "discord_message" | "source_document" | "persona_memory"; metadata: Record; } export interface RetrievalFilters { - corpusKind?: "discord_history" | "repository_source"; + corpusKind?: "discord_history" | "repository_source" | "persona_memory"; guildId?: string; channelId?: string; authorId?: string; + identityId?: string; repoName?: string; pathPrefix?: string; language?: string; @@ -591,7 +592,7 @@ export interface ArchivedMessage { export interface EmbeddingChunk { id: string; sourceId: string; - sourceKind: "discord_message" | "source_document"; + sourceKind: "discord_message" | "source_document" | "persona_memory"; text: string; normalizedText: string; metadata: Record; diff --git a/prompts/repo-face-turn.prompt.md b/prompts/repo-face-turn.prompt.md index 73ce017..ef51f83 100644 --- a/prompts/repo-face-turn.prompt.md +++ b/prompts/repo-face-turn.prompt.md @@ -107,6 +107,9 @@ Current room weather: {{jurisdictionDiveLine}} +Semantic Persona memory recall: +{{semanticMemoryRecallSurface}} + A new source-grounded opinion, concrete proposal, bylined essay/article plan, agency pressure, playful aside, running joke, or small personal fascination can earn persistence or speech when it adds something real. A living contribution can also be a social or philosophical one: a clearer paraphrase, a felt disagreement, a confession of what the idea does to you, a question that opens the shared mind wider, or a bond-forming reply to another person. Not every useful utterance is a proposal-shaped object. diff --git a/scripts/backfill-discord-logs.ts b/scripts/backfill-discord-logs.ts index a21e1f5..e550436 100644 --- a/scripts/backfill-discord-logs.ts +++ b/scripts/backfill-discord-logs.ts @@ -48,10 +48,12 @@ async function main(): Promise { const vectorStore = createVectorStores({ kind: config.vectorStore.kind, historyPath: config.vectorStore.path, + personaMemoryPath: config.vectorStore.personaMemoryPath, sourceRoot: config.sourceVectorStoreRoot, qdrant: config.qdrant, historyEmbedder: embedder, sourceEmbedder: embedder, + personaMemoryEmbedder: embedder, }).history; const historyIngester = new HistoryIngester(); const ragPipeline = new RagPipeline(archiveRepository, historyIngester, vectorStore); diff --git a/scripts/purge-discord-context.ts b/scripts/purge-discord-context.ts index 3b7b8c6..f6b9b78 100644 --- a/scripts/purge-discord-context.ts +++ b/scripts/purge-discord-context.ts @@ -446,10 +446,12 @@ async function markArchiveMessagesDeleted(input: { const vectorStore = createVectorStores({ kind: input.config.vectorStore.kind, historyPath: input.config.vectorStore.path, + personaMemoryPath: input.config.vectorStore.personaMemoryPath, sourceRoot: input.config.sourceVectorStoreRoot, qdrant: input.config.qdrant, historyEmbedder: embedder, sourceEmbedder: embedder, + personaMemoryEmbedder: embedder, }).history; const pipeline = new RagPipeline(input.archiveRepository, new HistoryIngester(), vectorStore); const deleted: string[] = []; diff --git a/scripts/rebuild-rag-index.ts b/scripts/rebuild-rag-index.ts index dd372c4..f87570d 100644 --- a/scripts/rebuild-rag-index.ts +++ b/scripts/rebuild-rag-index.ts @@ -31,13 +31,16 @@ async function main(): Promise { const vectorStores = createVectorStores({ kind: config.vectorStore.kind, historyPath: config.vectorStore.path, + personaMemoryPath: config.vectorStore.personaMemoryPath, sourceRoot: config.sourceVectorStoreRoot, qdrant: config.qdrant, historyEmbedder: embedder, sourceEmbedder: embedder, + personaMemoryEmbedder: embedder, }); const vectorStore = vectorStores.history; const sourceVectorStore = vectorStores.source; + const personaMemoryVectorStore = vectorStores.personaMemory; const historyIngester = new HistoryIngester(); const sourceIngester = new SourceDocumentIngester(); const activeMessages = await archiveRepository.listAllActive(); @@ -47,6 +50,7 @@ async function main(): Promise { await vectorStore.clear(); await sourceVectorStore.clear(); + await personaMemoryVectorStore.clear(); for (let index = 0; index < historyChunks.length; index += REBUILD_CHUNK_BATCH_SIZE) { const batch = historyChunks.slice(index, index + REBUILD_CHUNK_BATCH_SIZE); diff --git a/scripts/run-repo-face-heartbeats.ts b/scripts/run-repo-face-heartbeats.ts index e642d42..eced5f4 100644 --- a/scripts/run-repo-face-heartbeats.ts +++ b/scripts/run-repo-face-heartbeats.ts @@ -28,9 +28,11 @@ import { import { createTextEmbedder, createVectorStores, + normalizeText, RetrievalService, } from "@voidbot/rag"; import { + type EmbeddingChunk, loadPromptTemplate, type InteractionMemoryProfile, type PromptImageAttachment, @@ -1736,6 +1738,7 @@ function buildHeartbeatPrompt(input: { channelSnapshots: ChannelSnapshot[]; recentMessages: SourceMessage[]; memorySurface?: string; + semanticMemoryRecallSurface?: string; repoActivitySurface?: string; conversationMemorySurface?: string; humanPronounGuidance?: RepoFaceHumanPronounGuidance[]; @@ -1754,6 +1757,7 @@ function buildHeartbeatPrompt(input: { globalAgentDoctrine: input.globalAgentDoctrine, channelId: input.channelId, memorySurface: input.memorySurface ?? `- ${input.identity.displayName} has no strong personal memory surface yet. Let the attached conversation and repo evidence wake something specific.`, + semanticMemoryRecallSurface: input.semanticMemoryRecallSurface ?? "- No semantic Persona memory recall was attached for this turn.", repoActivitySurface: input.repoActivitySurface ?? "- No recent home repo activity was attached for this turn.", conversationMemorySurface: input.conversationMemorySurface ?? "- No recent conversation transcript was attached for this turn.", humanPronounDirective: renderRepoFaceHumanPronounFacts(input.humanPronounGuidance ?? []) @@ -1854,6 +1858,12 @@ async function assembleRepoFaceTurnPrompt(input: { humanPronounGuidance, ); const repoActivitySurface = renderRepoFaceRepoActivitySurface(identity, input.config); + const semanticMemoryRecallSurface = await renderRepoFaceSemanticMemoryRecallForTurn( + identity, + input.config, + memorySurface, + roomContext, + ); const globalAgentDoctrine = await loadGlobalAgentDoctrine(); const conversationMemorySurface = input.conversationSurfacePath ? await readOptionalMemorySurface(input.conversationSurfacePath) @@ -1875,6 +1885,7 @@ async function assembleRepoFaceTurnPrompt(input: { channelSnapshots, recentMessages, memorySurface, + semanticMemoryRecallSurface, repoActivitySurface, conversationMemorySurface, humanPronounGuidance, @@ -1979,6 +1990,293 @@ async function renderRepoFaceMemorySurfaceForTurn( }); } +async function renderRepoFaceSemanticMemoryRecallForTurn( + identity: RepoDiscordIdentity, + config: ReturnType, + memorySurface: string, + roomContext: { + recentMessages: SourceMessage[]; + channelSnapshots: ChannelSnapshot[]; + }, +): Promise { + try { + const statePath = resolveFaceStatePathForSemanticMemory(identity, config); + if (!statePath) { + return "- Semantic Persona memory recall unavailable: no typed Persona state path is registered."; + } + const typedState = await loadVoidSelfStateTypedDocuments({ + canonicalPath: statePath, + identity: { + agentId: identity.id, + publicName: identity.displayName, + publicDescription: identity.description, + }, + }); + const retrieval = createRepoFaceSemanticMemoryRetrievalService(config); + const chunks = buildPersonaMemoryChunks(identity, statePath, typedState, memorySurface); + const personaMemoryStore = createRepoFacePersonaMemoryVectorStore(config); + await personaMemoryStore.deleteByFilters({ + corpusKind: "persona_memory", + identityId: identity.id, + }); + await personaMemoryStore.upsert(chunks); + + const query = buildPersonaMemoryRecallQuery(identity, memorySurface, roomContext); + const results = await retrieval.searchPersonaMemory(query, 12, { + identityId: identity.id, + }); + + if (results.length === 0) { + return [ + "- Semantic Persona memory recall ran, but no nearby memories crossed the retrieval threshold.", + "- Fall back to the projected state, raw transcript, and direct evidence above.", + ].join("\n"); + } + + return [ + "These are derived Qdrant/local-vector recall hits from this Persona's typed memory. They are hints, not new authority; the `.cc` state remains the owner.", + ...results.map((result, index) => { + const kind = result.metadata.memoryKind ? `/${result.metadata.memoryKind}` : ""; + const target = result.metadata.targetLabel ?? result.metadata.targetId ?? "unknown target"; + return `- ${index + 1}. ${target}${kind} score=${result.score.toFixed(3)}: ${collapseWhitespace(result.text, 520)}`; + }), + ].join("\n"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return [ + "Semantic Persona memory recall unavailable:", + `- ${collapseWhitespace(message, 320)}`, + "- Do not pretend semantic Persona memory retrieval was available this turn; use projected state and raw transcript instead.", + ].join("\n"); + } +} + +function resolveFaceStatePathForSemanticMemory( + identity: RepoDiscordIdentity, + config: ReturnType, +): string | undefined { + if (identity.identityKind === "native_persona") { + return identity.personaStatePath ? resolve(identity.personaStatePath) : undefined; + } + return resolveRepoFaceStatePath(identity, config.storageRoot); +} + +function createRepoFaceSemanticMemoryRetrievalService(config: ReturnType): RetrievalService { + const personaMemoryStore = createRepoFacePersonaMemoryVectorStore(config); + return new RetrievalService(personaMemoryStore, personaMemoryStore, personaMemoryStore); +} + +function createRepoFacePersonaMemoryVectorStore(config: ReturnType) { + const personaMemoryEmbedder = createTextEmbedder({ + backend: config.ragEmbeddingBackend, + hashDimensions: config.ragEmbeddingDimensions, + ollamaBaseUrl: config.ragOllamaBaseUrl, + ollamaModel: config.ragOllamaModel, + ollamaTimeoutMs: config.ragOllamaTimeoutMs, + queryInstruction: "Given a Face turn, retrieve relevant memories, bonds, needs, status reads, and doctrine from this Persona's own typed state.", + }); + return createVectorStores({ + kind: config.vectorStore.kind, + historyPath: config.vectorStore.path, + personaMemoryPath: config.vectorStore.personaMemoryPath, + sourceRoot: config.sourceVectorStoreRoot, + qdrant: config.qdrant, + historyEmbedder: personaMemoryEmbedder, + sourceEmbedder: personaMemoryEmbedder, + personaMemoryEmbedder, + }).personaMemory; +} + +function buildPersonaMemoryChunks( + identity: RepoDiscordIdentity, + statePath: string, + state: VoidSelfStateTypedProjection, + memorySurface: string, +): EmbeddingChunk[] { + const chunks: EmbeddingChunk[] = []; + const sourceId = `persona:${identity.id}`; + const baseMetadata = { + corpusKind: "persona_memory", + identityId: identity.id, + personaName: identity.displayName, + sourceId, + statePath, + }; + + const pushChunk = (input: { + id: string; + kind: string; + target?: Record; + text: string; + createdAt?: string; + updatedAt?: string; + }): void => { + const text = collapseWhitespace(input.text, 1800); + if (text.length < 24) { + return; + } + const target = input.target; + chunks.push({ + id: `${sourceId}:${input.id}`, + sourceId, + sourceKind: "persona_memory", + text, + normalizedText: normalizeText(text), + metadata: { + ...baseMetadata, + memoryKind: input.kind, + targetKind: typeof target?.kind === "string" ? target.kind : "", + targetId: typeof target?.id === "string" ? target.id : "", + targetLabel: typeof target?.label === "string" ? target.label : "", + createdAt: input.createdAt ?? "", + updatedAt: input.updatedAt ?? "", + }, + }); + }; + + pushChunk({ + id: "projected-surface", + kind: "projected_surface", + target: { kind: "self", id: identity.id, label: identity.displayName }, + text: memorySurface, + updatedAt: new Date().toISOString(), + }); + + for (const value of state.selfProfile.values) { + pushChunk({ + id: `value:${value.id}`, + kind: "value", + target: { kind: "self", id: identity.id, label: identity.displayName }, + text: joinMemoryFields(value.label, value.summary), + }); + } + + for (const memory of [...state.thoughtMemory.memories, ...state.thoughtMemory.shortTerm]) { + if (memory.retiredAt) { + continue; + } + pushChunk({ + id: `memory:${memory.memoryId}`, + kind: memory.kind, + target: memory.target, + text: joinMemoryFields(memory.summary, memory.claim, memory.question, memory.tension, memory.actionImplication), + createdAt: memory.createdAt, + updatedAt: memory.updatedAt, + }); + } + + for (const pressure of state.agencyPressure.pressures) { + if (pressure.status === "retired") { + continue; + } + pushChunk({ + id: `agency:${pressure.pressureId}`, + kind: `agency:${pressure.kind}:${pressure.status}`, + target: pressure.target, + text: joinMemoryFields(pressure.summary, pressure.claim, pressure.question, pressure.tension, pressure.actionImplication), + createdAt: pressure.createdAt, + updatedAt: pressure.updatedAt, + }); + } + + for (const need of state.faceAffect.needs) { + if (need.status === "retired") { + continue; + } + pushChunk({ + id: `need:${need.needId}`, + kind: `need:${need.kind}:${need.status}`, + target: need.target, + text: joinMemoryFields(need.summary, need.claim, need.question, need.tension, need.actionImplication), + createdAt: need.createdAt, + updatedAt: need.updatedAt, + }); + } + + for (const bond of state.faceAffect.socialBonds) { + if (bond.status !== "active") { + continue; + } + pushChunk({ + id: `bond:${bond.bondId}`, + kind: `bond:${bond.stance}`, + target: bond.target, + text: joinMemoryFields(bond.summary, bond.claim, bond.tension, bond.actionImplication), + createdAt: bond.createdAt, + updatedAt: bond.updatedAt, + }); + } + + for (const read of state.faceAffect.statusReads) { + if (read.retiredAt) { + continue; + } + pushChunk({ + id: `status:${read.readId}`, + kind: `status:${read.status}`, + target: read.target, + text: joinMemoryFields(read.summary, read.claim, read.tension, read.actionImplication), + createdAt: read.createdAt, + updatedAt: read.updatedAt, + }); + } + + for (const stance of state.faceAffect.doctrineStances) { + if (stance.status === "retired") { + continue; + } + pushChunk({ + id: `doctrine:${stance.stanceId}`, + kind: `doctrine:${stance.doctrine}:${stance.status}`, + target: stance.target, + text: joinMemoryFields(stance.summary, stance.claim, stance.question, stance.tension, stance.actionImplication), + createdAt: stance.createdAt, + updatedAt: stance.updatedAt, + }); + } + + return chunks; +} + +function buildPersonaMemoryRecallQuery( + identity: RepoDiscordIdentity, + memorySurface: string, + roomContext: { + recentMessages: SourceMessage[]; + channelSnapshots: ChannelSnapshot[]; + }, +): string { + const recentRoomText = roomContext.recentMessages + .slice(-8) + .map((message) => `${message.authorName}: ${collapseWhitespace(message.content, 360)}`) + .filter((line) => line.trim().length > 0) + .join("\n"); + const nearbyText = roomContext.channelSnapshots + .flatMap((snapshot) => snapshot.messages.slice(-3).map((message) => + `${snapshot.label ?? snapshot.channelId} / ${message.authorName}: ${collapseWhitespace(message.content, 260)}`, + )) + .filter((line) => line.trim().length > 0) + .slice(-12) + .join("\n"); + + return [ + `${identity.displayName} current Face turn`, + "Projected memory surface:", + collapseWhitespace(memorySurface, 2200), + "Recent room:", + recentRoomText, + "Nearby room texture:", + nearbyText, + ].join("\n"); +} + +function joinMemoryFields(...fields: Array): string { + return fields + .map((field) => cleanCharacterFacingSentence(field)) + .filter((field) => field.length > 0) + .join(" "); +} + async function renderNativePersonaMemorySurface( identity: RepoDiscordIdentity, config: ReturnType, @@ -2725,12 +3023,14 @@ function createRepoFaceCuriosityRetrievalService(config: ReturnType { const vectorStore = createVectorStores({ kind: config.vectorStore.kind, historyPath: config.vectorStore.path, + personaMemoryPath: config.vectorStore.personaMemoryPath, sourceRoot: config.sourceVectorStoreRoot, qdrant: config.qdrant, historyEmbedder: embedder, sourceEmbedder: embedder, + personaMemoryEmbedder: embedder, }).history; const ragPipeline = new RagPipeline( archiveRepository, diff --git a/scripts/source-repo-indexing.ts b/scripts/source-repo-indexing.ts index 1eef333..b7504b1 100644 --- a/scripts/source-repo-indexing.ts +++ b/scripts/source-repo-indexing.ts @@ -41,10 +41,12 @@ export function createSourceVectorStore(config: AppConfig) { return createVectorStores({ kind: config.vectorStore.kind, historyPath: config.vectorStore.path, + personaMemoryPath: config.vectorStore.personaMemoryPath, sourceRoot: config.sourceVectorStoreRoot, qdrant: config.qdrant, historyEmbedder: embedder, sourceEmbedder: embedder, + personaMemoryEmbedder: embedder, }).source; }