From 29bf11afaed544035fd857ce2d44e540bb26ff0d Mon Sep 17 00:00:00 2001 From: OpenPets Dev Date: Fri, 19 Jun 2026 05:40:27 +0000 Subject: [PATCH] Extract familiaros memory store seam --- apps/desktop/src/check-packaging-contract.ts | 6 + apps/desktop/src/codemap.md | 5 +- apps/desktop/src/familiaros-memory-store.ts | 266 ++++++++++++++++ apps/desktop/src/familiaros-memory.ts | 296 ++---------------- .../tests/familiaros-memory-store.test.ts | 33 ++ 5 files changed, 335 insertions(+), 271 deletions(-) create mode 100644 apps/desktop/src/familiaros-memory-store.ts create mode 100644 apps/desktop/tests/familiaros-memory-store.test.ts diff --git a/apps/desktop/src/check-packaging-contract.ts b/apps/desktop/src/check-packaging-contract.ts index 4c2ff5ca..80bdc084 100644 --- a/apps/desktop/src/check-packaging-contract.ts +++ b/apps/desktop/src/check-packaging-contract.ts @@ -157,6 +157,7 @@ const controlCenterIpcSettingsSource = readFileSync(join(appDir, "src", "control const controlCenterIpcSharedSource = readFileSync(join(appDir, "src", "control-center-ipc-shared.ts"), "utf8"); const promptWindowSource = readFileSync(join(appDir, "src", "prompt-window.ts"), "utf8"); const familiarosMemorySource = readFileSync(join(appDir, "src", "familiaros-memory.ts"), "utf8"); +const familiarosMemoryStoreSource = readFileSync(join(appDir, "src", "familiaros-memory-store.ts"), "utf8"); const familiarosMemorySearchSource = readFileSync(join(appDir, "src", "familiaros-memory-search.ts"), "utf8"); const appStateSource = readFileSync(join(appDir, "src", "app-state.ts"), "utf8"); const appStatePreferencesSource = readFileSync(join(appDir, "src", "app-state-preferences.ts"), "utf8"); @@ -393,7 +394,12 @@ assert.match(controlCenterPreloadSource, /saveOpenApiCredential/, "Control Cente assert.match(promptWindowPreloadSource, /submitPrompt/, "Prompt window preload must expose prompt submission."); assert.match(promptWindowSource, /sendOpenApiChatPrompt/, "Prompt window must submit prompts through the main-process OpenAPI chat service."); assert.match(promptWindowSource, /openControlCenterWindow\("settings"\)/, "Prompt window must be able to open Settings when the API key is missing."); +assert.match(familiarosMemorySource, /from "\.\/familiaros-memory-store(?:\.js)?"/, "familiaros-memory must import the extracted store seam."); assert.match(familiarosMemorySource, /from "\.\/familiaros-memory-search(?:\.js)?"/, "familiaros-memory must import the extracted search seam."); +assert.match(familiarosMemoryStoreSource, /export function getFamiliarOSMemoryStore/, "familiaros-memory store seam must export cached store reads."); +assert.match(familiarosMemoryStoreSource, /export function commitFamiliarOSMemoryStore/, "familiaros-memory store seam must export store commits."); +assert.match(familiarosMemoryStoreSource, /export function normalizeMemoryTags/, "familiaros-memory store seam must export tag normalization."); +assert.match(familiarosMemoryStoreSource, /export function pruneMemoryEntries/, "familiaros-memory store seam must export retention pruning."); assert.match(familiarosMemorySearchSource, /export function searchMemoryEntries/, "familiaros-memory search seam must export scored memory search."); assert.match(familiarosMemorySearchSource, /export function buildRelevantMemoryContextBlock/, "familiaros-memory search seam must export relevant memory context shaping."); assert.match(familiarosMemorySearchSource, /export function scoreRetentionPriority/, "familiaros-memory search seam must export retention scoring."); diff --git a/apps/desktop/src/codemap.md b/apps/desktop/src/codemap.md index 7e655f91..bde67c9f 100644 --- a/apps/desktop/src/codemap.md +++ b/apps/desktop/src/codemap.md @@ -120,7 +120,7 @@ familiar-window preload double-click or tray/chat entry → openPromptWindow() ├── prompt-window-render.ts builds the inline prompt window HTML document/data URL │ └── prompt-window-render-script.ts owns the injected prompt window client script ├── prompt-window-preload.cjs exposes the narrow prompt window bridge -└── openapi-chat.ts, familiaros-memory.ts, familiaros-memory-search.ts, openapi-chat-config-store.ts, openapi-chat-request-helpers.ts, openapi-chat-provider.ts, and knowledge-store.ts handle prompt submission, long-term memory recall, credential persistence, request shaping, provider routing, history, and attachment storage +└── openapi-chat.ts, familiaros-memory.ts, familiaros-memory-store.ts, familiaros-memory-search.ts, openapi-chat-config-store.ts, openapi-chat-request-helpers.ts, openapi-chat-provider.ts, and knowledge-store.ts handle prompt submission, long-term memory recall, credential persistence, request shaping, provider routing, history, and attachment storage ``` **Plugin Flow**: @@ -214,7 +214,8 @@ plugin-service-local-support.ts → plugin-local-loader.ts validates selected fo - `openapi-chat-prompt-flows.ts`: Extracted prompt submission flows, instruction-context assembly, provider fallback handling, and MCP-tool chat orchestration - `openapi-chat-settings.ts`: Extracted OpenAPI chat credential persistence, settings snapshot, model/endpoint normalization, and required-credential helpers - `openapi-chat-presentation.ts`: Extracted familiar/TTS pending, success, and failure presentation helpers for prompt-window chat -- `familiaros-memory.ts`: Local long-term memory store, memory CRUD, disk mirror persistence, and prompt-memory capture for chat, IPC, and knowledge-store flows +- `familiaros-memory.ts`: Public long-term memory API, CRUD orchestration, context shaping, and prompt-memory capture for chat, IPC, and knowledge-store flows +- `familiaros-memory-store.ts`: Extracted memory cache/persistence, JSON and markdown mirror writes, normalization, retention pruning, and mutation helpers - `familiaros-memory-search.ts`: Extracted pure memory query normalization, scoring, recall-intent fallback, chat-history relevance, and context-block shaping for FamiliarOS memory - `openapi-chat-tool-loop.ts`: Extracted chat-completions tool-call loop, history seeding, tool-result threading, and tool-aware instruction helpers for prompt-window OpenAPI chat - `openapi-chat-config-store.ts`: Extracted credential persistence, storage-mode selection, current/legacy config path handling, and stored-credential decode helpers for prompt-window OpenAPI chat diff --git a/apps/desktop/src/familiaros-memory-store.ts b/apps/desktop/src/familiaros-memory-store.ts new file mode 100644 index 00000000..034b23f7 --- /dev/null +++ b/apps/desktop/src/familiaros-memory-store.ts @@ -0,0 +1,266 @@ +import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import { app } from "electron"; + +import { normalizeMemoryText, scoreRetentionPriority } from "./familiaros-memory-search.js"; +import type { FamiliarOSMemoryKind } from "./local-ipc-protocol.js"; + +export interface FamiliarOSMemoryEntry { + readonly id: string; + readonly text: string; + readonly kind: FamiliarOSMemoryKind; + readonly tags: readonly string[]; + readonly importance: number; + readonly createdAt: number; + readonly updatedAt: number; + readonly lastAccessedAt: number; + readonly accessCount: number; + readonly source: "chat" | "mcp"; +} + +export interface StoredFamiliarOSMemoryV1 { + readonly version: 1; + readonly entries: readonly FamiliarOSMemoryEntry[]; +} + +export interface StoreMemoryInput { + readonly text: string; + readonly kind?: FamiliarOSMemoryKind; + readonly tags?: readonly string[]; + readonly importance?: number; + readonly source?: "chat" | "mcp"; +} + +const memoryStoreFileName = "familiaros-memory.json"; +const memoryMarkdownMirrorFileName = "familiaros-memory.md"; +const maxMemoryEntries = 200; +const maxMemoryTags = 8; +const defaultMemoryImportance = 3; + +let cachedMemoryStore: StoredFamiliarOSMemoryV1 | null = null; + +export function getFamiliarOSMemoryStore(): StoredFamiliarOSMemoryV1 { + if (cachedMemoryStore) { + return cachedMemoryStore; + } + + try { + const raw = JSON.parse(readFileSync(getMemoryStorePath(), "utf8")) as unknown; + cachedMemoryStore = normalizeStoredMemory(raw); + } catch { + cachedMemoryStore = { version: 1, entries: [] }; + } + return cachedMemoryStore; +} + +export function commitFamiliarOSMemoryStore(store: StoredFamiliarOSMemoryV1): void { + cachedMemoryStore = { + version: 1, + entries: store.entries.map(cloneMemoryEntry), + }; + writeMemoryStoreToDisk(cachedMemoryStore); +} + +export function getFamiliarOSMemoryEntryById(id: string): FamiliarOSMemoryEntry | undefined { + return getFamiliarOSMemoryStore().entries.find((entry) => entry.id === id); +} + +export function normalizeMemoryKind(value: unknown): FamiliarOSMemoryKind { + return value === "identity" || value === "preference" || value === "fact" ? value : "note"; +} + +export function normalizeMemoryTags(value: readonly string[] | undefined): readonly string[] { + if (!Array.isArray(value)) { + return []; + } + const tags: string[] = []; + for (const rawTag of value) { + if (typeof rawTag !== "string") { + continue; + } + const tag = rawTag + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + if (!tag || tag.length > 32 || tags.includes(tag)) { + continue; + } + tags.push(tag); + if (tags.length >= maxMemoryTags) { + break; + } + } + return tags; +} + +export function normalizeMemoryImportance(value: unknown): number { + if (typeof value === "number" && Number.isFinite(value)) { + return Math.min(Math.max(Math.round(value), 1), 5); + } + return defaultMemoryImportance; +} + +export function pruneMemoryEntries(entries: readonly FamiliarOSMemoryEntry[]): readonly FamiliarOSMemoryEntry[] { + if (entries.length <= maxMemoryEntries) { + return [...entries]; + } + const now = Date.now(); + return [...entries] + .sort( + (left, right) => + scoreRetentionPriority(right, now) - scoreRetentionPriority(left, now) || + right.updatedAt - left.updatedAt || + left.id.localeCompare(right.id), + ) + .slice(0, maxMemoryEntries); +} + +export function touchFamiliarOSMemoryEntries(ids: readonly string[], now: number): void { + if (ids.length === 0) { + return; + } + const idSet = new Set(ids); + const store = getFamiliarOSMemoryStore(); + let changed = false; + const nextEntries = store.entries.map((entry) => { + if (!idSet.has(entry.id)) { + return entry; + } + changed = true; + return { + ...entry, + lastAccessedAt: now, + accessCount: entry.accessCount + 1, + }; + }); + if (changed) { + commitFamiliarOSMemoryStore({ + version: 1, + entries: nextEntries, + }); + } +} + +export function cloneMemoryEntry(entry: FamiliarOSMemoryEntry): FamiliarOSMemoryEntry { + return { + ...entry, + tags: [...entry.tags], + }; +} + +export function mergeMemoryTags(left: readonly string[], right: readonly string[]): readonly string[] { + return normalizeMemoryTags([...left, ...right]); +} + +export function pickPreferredMemoryKind(left: FamiliarOSMemoryKind, right: FamiliarOSMemoryKind): FamiliarOSMemoryKind { + const rank = { note: 0, fact: 1, preference: 2, identity: 3 } satisfies Record; + return rank[right] >= rank[left] ? right : left; +} + +function getMemoryStorePath(): string { + return join(app.getPath("userData"), memoryStoreFileName); +} + +function getMemoryMarkdownMirrorPath(): string { + return join(app.getPath("userData"), memoryMarkdownMirrorFileName); +} + +function writeMemoryStoreToDisk(store: StoredFamiliarOSMemoryV1): void { + const path = getMemoryStorePath(); + mkdirSync(dirname(path), { recursive: true }); + const tempPath = `${path}.${process.pid}.tmp`; + writeFileSync(tempPath, `${JSON.stringify(store, null, 2)}\n`, "utf8"); + renameSync(tempPath, path); + + const markdownPath = getMemoryMarkdownMirrorPath(); + const markdownTempPath = `${markdownPath}.${process.pid}.tmp`; + writeFileSync(markdownTempPath, buildMemoryMarkdownMirror(store), "utf8"); + renameSync(markdownTempPath, markdownPath); +} + +function buildMemoryMarkdownMirror(store: StoredFamiliarOSMemoryV1): string { + const lines = [ + "# FamiliarOS Memory", + "", + "This file mirrors the local FamiliarOS memory store for inspection and backup.", + "", + ]; + + if (store.entries.length === 0) { + lines.push("No stored memories yet.", ""); + return `${lines.join("\n")}\n`; + } + + for (const entry of [...store.entries].sort((left, right) => right.updatedAt - left.updatedAt || left.id.localeCompare(right.id))) { + lines.push(`## ${entry.id}`); + lines.push(`- kind: ${entry.kind}`); + lines.push(`- importance: ${entry.importance}`); + lines.push(`- tags: ${entry.tags.length > 0 ? entry.tags.join(", ") : "none"}`); + lines.push(`- source: ${entry.source}`); + lines.push(`- updated: ${new Date(entry.updatedAt).toISOString()}`); + lines.push(`- accessed: ${new Date(entry.lastAccessedAt).toISOString()}`); + lines.push(""); + lines.push(entry.text); + lines.push(""); + } + + return `${lines.join("\n")}\n`; +} + +function normalizeStoredMemory(value: unknown): StoredFamiliarOSMemoryV1 { + const record = isRecord(value) ? value : {}; + const entries = Array.isArray(record.entries) + ? record.entries + .map((entry) => normalizeStoredMemoryEntry(entry)) + .filter((entry): entry is FamiliarOSMemoryEntry => Boolean(entry)) + : []; + return { + version: 1, + entries: pruneMemoryEntries(entries), + }; +} + +function normalizeStoredMemoryEntry(value: unknown): FamiliarOSMemoryEntry | undefined { + if (!isRecord(value) || typeof value.id !== "string") { + return undefined; + } + const text = normalizeMemoryText(value.text); + if (!text) { + return undefined; + } + const createdAt = normalizeTimestamp(value.createdAt); + const updatedAt = normalizeTimestamp(value.updatedAt) ?? createdAt; + const lastAccessedAt = normalizeTimestamp(value.lastAccessedAt) ?? updatedAt; + return { + id: value.id, + text, + kind: normalizeMemoryKind(value.kind), + tags: normalizeMemoryTags(Array.isArray(value.tags) ? value.tags : undefined), + importance: normalizeMemoryImportance(value.importance), + createdAt, + updatedAt, + lastAccessedAt, + accessCount: normalizeAccessCount(value.accessCount), + source: value.source === "mcp" ? "mcp" : "chat", + }; +} + +function normalizeTimestamp(value: unknown): number { + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + return Math.floor(value); + } + return Date.now(); +} + +function normalizeAccessCount(value: unknown): number { + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + return Math.floor(value); + } + return 1; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/apps/desktop/src/familiaros-memory.ts b/apps/desktop/src/familiaros-memory.ts index ed8f6eec..b8f9956a 100644 --- a/apps/desktop/src/familiaros-memory.ts +++ b/apps/desktop/src/familiaros-memory.ts @@ -1,63 +1,39 @@ import { randomUUID } from "node:crypto"; -import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; - -import { app } from "electron"; import { buildRelevantMemoryContextBlock, clampMemoryLimit, normalizeMemoryComparisonValue, normalizeMemoryText, - scoreRetentionPriority, searchMemoryEntries, type ChatHistorySearchEntry, } from "./familiaros-memory-search.js"; -import type { FamiliarOSMemoryKind } from "./local-ipc-protocol.js"; +import { + cloneMemoryEntry, + commitFamiliarOSMemoryStore, + getFamiliarOSMemoryEntryById, + getFamiliarOSMemoryStore, + mergeMemoryTags, + normalizeMemoryImportance, + normalizeMemoryKind, + normalizeMemoryTags, + pickPreferredMemoryKind, + pruneMemoryEntries, + touchFamiliarOSMemoryEntries, + type FamiliarOSMemoryEntry, + type StoreMemoryInput, +} from "./familiaros-memory-store.js"; import { extractPromptMemoryCandidates } from "./prompt-memory-extraction.js"; - -export interface FamiliarOSMemoryEntry { - readonly id: string; - readonly text: string; - readonly kind: FamiliarOSMemoryKind; - readonly tags: readonly string[]; - readonly importance: number; - readonly createdAt: number; - readonly updatedAt: number; - readonly lastAccessedAt: number; - readonly accessCount: number; - readonly source: "chat" | "mcp"; -} +export type { FamiliarOSMemoryEntry } from "./familiaros-memory-store.js"; export interface FamiliarOSMemorySearchHit { readonly entry: FamiliarOSMemoryEntry; readonly score: number; } -interface StoredFamiliarOSMemoryV1 { - readonly version: 1; - readonly entries: readonly FamiliarOSMemoryEntry[]; -} - -interface StoreMemoryInput { - readonly text: string; - readonly kind?: FamiliarOSMemoryKind; - readonly tags?: readonly string[]; - readonly importance?: number; - readonly source?: "chat" | "mcp"; -} - -const memoryStoreFileName = "familiaros-memory.json"; -const memoryMarkdownMirrorFileName = "familiaros-memory.md"; -const maxMemoryEntries = 200; -const maxMemoryTags = 8; -const defaultMemoryImportance = 3; - -let cachedMemoryStore: StoredFamiliarOSMemoryV1 | null = null; - export function listFamiliarOSMemories(limit = 20): readonly FamiliarOSMemoryEntry[] { const nextLimit = clampMemoryLimit(limit, 20); - const entries = [...getMemoryStore().entries] + const entries = [...getFamiliarOSMemoryStore().entries] .sort((left, right) => right.lastAccessedAt - left.lastAccessedAt || right.updatedAt - left.updatedAt || right.importance - left.importance || left.id.localeCompare(right.id)) .slice(0, nextLimit); return entries.map(cloneMemoryEntry); @@ -71,14 +47,14 @@ export function searchFamiliarOSMemories(query: string, limit = 8): readonly Fam } const now = Date.now(); - const hits = searchMemoryEntries(trimmedQuery, getMemoryStore().entries, { limit: nextLimit, now }); + const hits = searchMemoryEntries(trimmedQuery, getFamiliarOSMemoryStore().entries, { limit: nextLimit, now }); if (hits.length > 0) { - touchMemoryEntries(hits.map((hit) => hit.entry.id), now); + touchFamiliarOSMemoryEntries(hits.map((hit) => hit.entry.id), now); } return hits.map((hit) => ({ - entry: cloneMemoryEntry(getMemoryEntryById(hit.entry.id) ?? hit.entry), + entry: cloneMemoryEntry(getFamiliarOSMemoryEntryById(hit.entry.id) ?? hit.entry), score: hit.score, })); } @@ -95,7 +71,7 @@ export function storeFamiliarOSMemory(input: StoreMemoryInput): FamiliarOSMemory const source = input.source === "mcp" ? "mcp" : "chat"; const now = Date.now(); const normalizedText = normalizeMemoryComparisonValue(text); - const store = getMemoryStore(); + const store = getFamiliarOSMemoryStore(); const existing = store.entries.find((entry) => normalizeMemoryComparisonValue(entry.text) === normalizedText); if (existing) { const mergedEntry: FamiliarOSMemoryEntry = { @@ -109,7 +85,7 @@ export function storeFamiliarOSMemory(input: StoreMemoryInput): FamiliarOSMemory accessCount: existing.accessCount + 1, source, }; - commitMemoryStore({ + commitFamiliarOSMemoryStore({ version: 1, entries: pruneMemoryEntries(store.entries.map((entry) => entry.id === existing.id ? mergedEntry : entry)), }); @@ -128,7 +104,7 @@ export function storeFamiliarOSMemory(input: StoreMemoryInput): FamiliarOSMemory accessCount: 1, source, }; - commitMemoryStore({ + commitFamiliarOSMemoryStore({ version: 1, entries: pruneMemoryEntries([...store.entries, nextEntry]), }); @@ -147,7 +123,7 @@ export function updateFamiliarOSMemory(id: string, input: StoreMemoryInput): Fam const kind = normalizeMemoryKind(input.kind); const tags = normalizeMemoryTags(input.tags); const importance = normalizeMemoryImportance(input.importance); - const store = getMemoryStore(); + const store = getFamiliarOSMemoryStore(); const index = store.entries.findIndex((entry) => entry.id === trimmedId); if (index < 0) return null; const existing = store.entries[index]; @@ -159,7 +135,7 @@ export function updateFamiliarOSMemory(id: string, input: StoreMemoryInput): Fam importance, updatedAt: Date.now(), }; - commitMemoryStore({ + commitFamiliarOSMemoryStore({ version: 1, entries: store.entries.map((entry) => entry.id === trimmedId ? updated : entry), }); @@ -171,12 +147,12 @@ export function forgetFamiliarOSMemory(id: string): boolean { if (!trimmedId) { throw new Error("Memory id cannot be empty."); } - const store = getMemoryStore(); + const store = getFamiliarOSMemoryStore(); const nextEntries = store.entries.filter((entry) => entry.id !== trimmedId); if (nextEntries.length === store.entries.length) { return false; } - commitMemoryStore({ + commitFamiliarOSMemoryStore({ version: 1, entries: nextEntries, }); @@ -203,221 +179,3 @@ export function capturePromptMemories(prompt: string): readonly FamiliarOSMemory } return candidates.map((candidate) => storeFamiliarOSMemory({ ...candidate, source: "chat" })); } - -function getMemoryStore(): StoredFamiliarOSMemoryV1 { - if (cachedMemoryStore) { - return cachedMemoryStore; - } - - try { - const raw = JSON.parse(readFileSync(getMemoryStorePath(), "utf8")) as unknown; - cachedMemoryStore = normalizeStoredMemory(raw); - } catch { - cachedMemoryStore = { version: 1, entries: [] }; - } - return cachedMemoryStore; -} - -function commitMemoryStore(store: StoredFamiliarOSMemoryV1): void { - cachedMemoryStore = { - version: 1, - entries: store.entries.map(cloneMemoryEntry), - }; - writeMemoryStoreToDisk(cachedMemoryStore); -} - -function getMemoryStorePath(): string { - return join(app.getPath("userData"), memoryStoreFileName); -} - -function getMemoryMarkdownMirrorPath(): string { - return join(app.getPath("userData"), memoryMarkdownMirrorFileName); -} - -function writeMemoryStoreToDisk(store: StoredFamiliarOSMemoryV1): void { - const path = getMemoryStorePath(); - mkdirSync(dirname(path), { recursive: true }); - const tempPath = `${path}.${process.pid}.tmp`; - writeFileSync(tempPath, `${JSON.stringify(store, null, 2)}\n`, "utf8"); - renameSync(tempPath, path); - - const markdownPath = getMemoryMarkdownMirrorPath(); - const markdownTempPath = `${markdownPath}.${process.pid}.tmp`; - writeFileSync(markdownTempPath, buildMemoryMarkdownMirror(store), "utf8"); - renameSync(markdownTempPath, markdownPath); -} - -function buildMemoryMarkdownMirror(store: StoredFamiliarOSMemoryV1): string { - const lines = [ - "# FamiliarOS Memory", - "", - "This file mirrors the local FamiliarOS memory store for inspection and backup.", - "", - ]; - - if (store.entries.length === 0) { - lines.push("No stored memories yet.", ""); - return `${lines.join("\n")}\n`; - } - - for (const entry of [...store.entries].sort((left, right) => right.updatedAt - left.updatedAt || left.id.localeCompare(right.id))) { - lines.push(`## ${entry.id}`); - lines.push(`- kind: ${entry.kind}`); - lines.push(`- importance: ${entry.importance}`); - lines.push(`- tags: ${entry.tags.length > 0 ? entry.tags.join(", ") : "none"}`); - lines.push(`- source: ${entry.source}`); - lines.push(`- updated: ${new Date(entry.updatedAt).toISOString()}`); - lines.push(`- accessed: ${new Date(entry.lastAccessedAt).toISOString()}`); - lines.push(""); - lines.push(entry.text); - lines.push(""); - } - - return `${lines.join("\n")}\n`; -} - -function normalizeStoredMemory(value: unknown): StoredFamiliarOSMemoryV1 { - const record = isRecord(value) ? value : {}; - const entries = Array.isArray(record.entries) - ? record.entries.map((entry) => normalizeStoredMemoryEntry(entry)).filter((entry): entry is FamiliarOSMemoryEntry => Boolean(entry)) - : []; - return { - version: 1, - entries: pruneMemoryEntries(entries), - }; -} - -function normalizeStoredMemoryEntry(value: unknown): FamiliarOSMemoryEntry | undefined { - if (!isRecord(value) || typeof value.id !== "string") { - return undefined; - } - const text = normalizeMemoryText(value.text); - if (!text) { - return undefined; - } - const createdAt = normalizeTimestamp(value.createdAt); - const updatedAt = normalizeTimestamp(value.updatedAt) ?? createdAt; - const lastAccessedAt = normalizeTimestamp(value.lastAccessedAt) ?? updatedAt; - return { - id: value.id, - text, - kind: normalizeMemoryKind(value.kind), - tags: normalizeMemoryTags(Array.isArray(value.tags) ? value.tags : undefined), - importance: normalizeMemoryImportance(value.importance), - createdAt, - updatedAt, - lastAccessedAt, - accessCount: normalizeAccessCount(value.accessCount), - source: value.source === "mcp" ? "mcp" : "chat", - }; -} - -function normalizeTimestamp(value: unknown): number { - if (typeof value === "number" && Number.isFinite(value) && value > 0) { - return Math.floor(value); - } - return Date.now(); -} - -function normalizeAccessCount(value: unknown): number { - if (typeof value === "number" && Number.isFinite(value) && value > 0) { - return Math.floor(value); - } - return 1; -} - -function normalizeMemoryKind(value: unknown): FamiliarOSMemoryKind { - return value === "identity" || value === "preference" || value === "fact" ? value : "note"; -} - -function normalizeMemoryTags(value: readonly string[] | undefined): readonly string[] { - if (!Array.isArray(value)) { - return []; - } - const tags: string[] = []; - for (const rawTag of value) { - if (typeof rawTag !== "string") { - continue; - } - const tag = rawTag - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); - if (!tag || tag.length > 32 || tags.includes(tag)) { - continue; - } - tags.push(tag); - if (tags.length >= maxMemoryTags) { - break; - } - } - return tags; -} - -function normalizeMemoryImportance(value: unknown): number { - if (typeof value === "number" && Number.isFinite(value)) { - return Math.min(Math.max(Math.round(value), 1), 5); - } - return defaultMemoryImportance; -} - -function pruneMemoryEntries(entries: readonly FamiliarOSMemoryEntry[]): readonly FamiliarOSMemoryEntry[] { - if (entries.length <= maxMemoryEntries) { - return [...entries]; - } - const now = Date.now(); - return [...entries] - .sort((left, right) => scoreRetentionPriority(right, now) - scoreRetentionPriority(left, now) || right.updatedAt - left.updatedAt || left.id.localeCompare(right.id)) - .slice(0, maxMemoryEntries); -} - -function touchMemoryEntries(ids: readonly string[], now: number): void { - if (ids.length === 0) { - return; - } - const idSet = new Set(ids); - const store = getMemoryStore(); - let changed = false; - const nextEntries = store.entries.map((entry) => { - if (!idSet.has(entry.id)) { - return entry; - } - changed = true; - return { - ...entry, - lastAccessedAt: now, - accessCount: entry.accessCount + 1, - }; - }); - if (changed) { - commitMemoryStore({ - version: 1, - entries: nextEntries, - }); - } -} - -function getMemoryEntryById(id: string): FamiliarOSMemoryEntry | undefined { - return getMemoryStore().entries.find((entry) => entry.id === id); -} - -function cloneMemoryEntry(entry: FamiliarOSMemoryEntry): FamiliarOSMemoryEntry { - return { - ...entry, - tags: [...entry.tags], - }; -} - -function mergeMemoryTags(left: readonly string[], right: readonly string[]): readonly string[] { - return normalizeMemoryTags([...left, ...right]); -} - -function pickPreferredMemoryKind(left: FamiliarOSMemoryKind, right: FamiliarOSMemoryKind): FamiliarOSMemoryKind { - const rank = { note: 0, fact: 1, preference: 2, identity: 3 } satisfies Record; - return rank[right] >= rank[left] ? right : left; -} - -export function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/apps/desktop/tests/familiaros-memory-store.test.ts b/apps/desktop/tests/familiaros-memory-store.test.ts new file mode 100644 index 00000000..f49b069a --- /dev/null +++ b/apps/desktop/tests/familiaros-memory-store.test.ts @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + normalizeMemoryImportance, + normalizeMemoryTags, + pickPreferredMemoryKind, +} from "../src/familiaros-memory-store.js"; + +const desktopRoot = process.env.FAMILIAROS_DESKTOP_ROOT ?? resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const familiarosMemorySource = readFileSync(resolve(desktopRoot, "src/familiaros-memory.ts"), "utf8"); +const familiarosMemoryStoreSource = readFileSync(resolve(desktopRoot, "src/familiaros-memory-store.ts"), "utf8"); + +assert.match(familiarosMemorySource, /from "\.\/familiaros-memory-store(?:\.js)?"/, "familiaros-memory must import the extracted store seam."); +assert.match(familiarosMemoryStoreSource, /export function getFamiliarOSMemoryStore/, "familiaros-memory store seam must export cached store reads."); +assert.match(familiarosMemoryStoreSource, /export function commitFamiliarOSMemoryStore/, "familiaros-memory store seam must export store commits."); +assert.match(familiarosMemoryStoreSource, /export function normalizeMemoryTags/, "familiaros-memory store seam must export tag normalization."); +assert.match(familiarosMemoryStoreSource, /export function pruneMemoryEntries/, "familiaros-memory store seam must export retention pruning."); +assert.match(familiarosMemoryStoreSource, /export function pickPreferredMemoryKind/, "familiaros-memory store seam must export kind preference merging."); + +assert.deepEqual( + normalizeMemoryTags([" Tea Time ", "green", "tea time", "!!!", "x".repeat(40)]), + ["tea-time", "green"], + "memory tag normalization must trim, slugify, dedupe, and drop invalid tags.", +); +assert.equal(normalizeMemoryImportance(8), 5, "memory importance must clamp to the supported max."); +assert.equal(normalizeMemoryImportance(0), 1, "memory importance must clamp to the supported min."); +assert.equal(pickPreferredMemoryKind("note", "identity"), "identity", "memory kind ranking must prefer stronger kinds."); +assert.equal(pickPreferredMemoryKind("preference", "fact"), "preference", "memory kind ranking must keep the stronger existing kind."); + +console.error("FamiliarOS memory store seam validation passed.");