Extract familiaros memory store seam

This commit is contained in:
OpenPets Dev 2026-06-19 05:40:27 +00:00
parent 1d19ae9ca1
commit 29bf11afae
5 changed files with 335 additions and 271 deletions

View file

@ -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.");

View file

@ -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

View file

@ -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<FamiliarOSMemoryKind, number>;
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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View file

@ -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<FamiliarOSMemoryKind, number>;
return rank[right] >= rank[left] ? right : left;
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View file

@ -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.");