Extract familiaros memory store seam
This commit is contained in:
parent
1d19ae9ca1
commit
29bf11afae
5 changed files with 335 additions and 271 deletions
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
266
apps/desktop/src/familiaros-memory-store.ts
Normal file
266
apps/desktop/src/familiaros-memory-store.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
33
apps/desktop/tests/familiaros-memory-store.test.ts
Normal file
33
apps/desktop/tests/familiaros-memory-store.test.ts
Normal 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.");
|
||||
Loading…
Add table
Reference in a new issue