diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 52fabecd..d68235a9 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -33,6 +33,7 @@ "@radix-ui/react-slot": "^1.3.3", "@sinm/react-chrome-tabs": "^2.6.3", "@tanstack/react-query": "^5.101.4", + "@tanstack/react-virtual": "catalog:", "@uiw/codemirror-theme-basic": "^4.25.10", "@uiw/codemirror-theme-github": "^4.25.10", "@uiw/codemirror-theme-monokai": "^4.25.10", diff --git a/apps/desktop/src/bun/memory/index.ts b/apps/desktop/src/bun/memory/index.ts new file mode 100644 index 00000000..c23a1258 --- /dev/null +++ b/apps/desktop/src/bun/memory/index.ts @@ -0,0 +1,226 @@ +/** + * Bun-side access to the bundled Memory plugin's store, exposed to the + * Settings → Memory page over RPC. + * + * The store is plain JSON lines at `/data/plugins/@llm-space/memory/`. + * This module deliberately keeps its own path resolution instead of importing + * the plugin sources: those run inside the isolated plugin subprocess, where + * `@llm-space/core` resolution is not guaranteed for runtime values. Both + * sides write through the same atomic `.tmp` + rename routine, and every + * mutation here re-reads first so a concurrent plugin write is not clobbered. + */ + +import { + existsSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import path from "node:path"; + +import { getLlmSpaceHomePath } from "@llm-space/core/server"; + +import type { + MemoryListParams, + MemoryListResult, + MemoryMutationResult, + MemoryRecordView, +} from "../../shared/memory"; + +/** Mirrors the bundled plugin's `MAX_TOTAL_MEMORIES`. */ +const MAX_TOTAL_MEMORIES = 1000; + +type StoredRecord = MemoryRecordView & Record; + +function memoryDir(): string { + return path.join( + getLlmSpaceHomePath(), + "data", + "plugins", + "@llm-space", + "memory" + ); +} + +function storePath(): string { + return path.join(memoryDir(), "memories.jsonl"); +} + +function archivePath(): string { + return path.join(memoryDir(), "memories.archive.jsonl"); +} + +function readRecords(): StoredRecord[] { + const file = storePath(); + if (!existsSync(file)) { + return []; + } + const records: StoredRecord[] = []; + for (const line of readFileSync(file, "utf8").split("\n")) { + if (!line.trim()) { + continue; + } + try { + const parsed = JSON.parse(line) as StoredRecord; + if ( + parsed && + typeof parsed.id === "string" && + typeof parsed.content === "string" + ) { + records.push(parsed); + } + } catch { + // Skip malformed lines instead of failing the whole store. + } + } + return records; +} + +/** Same contract as the plugin: atomic replace so a crash cannot truncate. */ +function writeRecords(records: StoredRecord[]): void { + const file = storePath(); + mkdirSync(path.dirname(file), { recursive: true }); + const body = records.map((record) => JSON.stringify(record)).join("\n"); + const temporary = file + ".tmp"; + writeFileSync(temporary, body ? body + "\n" : "", "utf8"); + renameSync(temporary, file); +} + +function toView(record: StoredRecord): MemoryRecordView { + return { + id: record.id, + content: record.content, + tags: Array.isArray(record.tags) + ? record.tags.filter((tag): tag is string => typeof tag === "string") + : [], + origin: typeof record.origin === "string" ? record.origin : null, + createdAt: + typeof record.createdAt === "string" + ? record.createdAt + : new Date(0).toISOString(), + ...(typeof record.updatedAt === "string" + ? { updatedAt: record.updatedAt } + : {}), + }; +} + +function countArchive(): number { + const file = archivePath(); + if (!existsSync(file)) { + return 0; + } + return readFileSync(file, "utf8") + .split("\n") + .filter((line) => line.trim().length > 0).length; +} + +/** + * Browsing filter: a normalized substring match over content, tags and + * project. Ranking (per-script tokenization, recency decay, project boost) + * stays in the plugin tool — this page is for finding and cleaning up, not + * for scoring. + */ +function normalizeForSearch(value: string): string { + return value.normalize("NFKC").toLowerCase().replace(/\s+/gu, " ").trim(); +} + +export function listMemories( + params: MemoryListParams = {} +): MemoryListResult { + const records = readRecords(); + const query = normalizeForSearch(params.query ?? ""); + const project = typeof params.project === "string" ? params.project : ""; + + const matched = records.filter((record) => { + if (project && (record.origin ?? "") !== project) { + return false; + } + if (!query) { + return true; + } + const haystack = normalizeForSearch( + [record.content, (record.tags ?? []).join(" "), record.origin ?? ""].join( + " " + ) + ); + return haystack.includes(query); + }); + + const offset = Math.max(0, params.offset ?? 0); + const limit = Math.min(Math.max(params.limit ?? 200, 1), 1000); + const projects = Array.from( + new Set( + records + .map((record) => record.origin) + .filter((origin): origin is string => typeof origin === "string") + ) + ).sort(); + + return { + memories: matched.slice(offset, offset + limit).map(toView), + total: records.length, + matched: matched.length, + max: MAX_TOTAL_MEMORIES, + archiveCount: countArchive(), + projects, + }; +} + +export function deleteMemory(id: string): MemoryMutationResult { + const records = readRecords(); + const next = records.filter((record) => record.id !== id); + if (next.length === records.length) { + return { ok: false, memories: records.map(toView), total: records.length }; + } + writeRecords(next); + return { ok: true, memories: next.map(toView), total: next.length }; +} + +export function updateMemory(params: { + id: string; + content: string; + tags?: string[]; +}): MemoryMutationResult { + const content = params.content.trim(); + const records = readRecords(); + const index = records.findIndex((record) => record.id === params.id); + if (index < 0 || !content) { + return { ok: false, memories: records.map(toView), total: records.length }; + } + const tags = (params.tags ?? []) + .filter((tag): tag is string => typeof tag === "string") + .map((tag) => tag.trim()) + .filter(Boolean) + .slice(0, 8); + records[index] = { + ...records[index], + content, + tags, + updatedAt: new Date().toISOString(), + }; + writeRecords(records); + return { ok: true, memories: records.map(toView), total: records.length }; +} + +/** + * Write every memory to a JSON file beside the store and return its path so + * the caller can reveal it. The file is unencrypted — the UI must say so. + */ +export function exportMemories(): { path: string; count: number } { + const records = readRecords().map(toView); + const target = path.join( + memoryDir(), + "memories-export-" + new Date().toISOString().replace(/[:.]/g, "-") + ".json" + ); + mkdirSync(path.dirname(target), { recursive: true }); + writeFileSync(target, JSON.stringify(records, null, 2) + "\n", "utf8"); + return { path: target, count: records.length }; +} + +export function clearArchive(): { removed: number } { + const removed = countArchive(); + rmSync(archivePath(), { force: true }); + return { removed }; +} diff --git a/apps/desktop/src/bun/plugins/memory-plugin-files.ts b/apps/desktop/src/bun/plugins/memory-plugin-files.ts index 33d8a649..2cdf85c4 100644 --- a/apps/desktop/src/bun/plugins/memory-plugin-files.ts +++ b/apps/desktop/src/bun/plugins/memory-plugin-files.ts @@ -10,6 +10,24 @@ export const MEMORY_PLUGIN_ID = "@llm-space/memory"; +/** + * Content hashes of the 1.0.0 bundled files. Used only to recognise an + * untouched 1.0.0 installation (it has no seed marker yet) so it can be + * upgraded in place — see `seedDefaultPlugins`. + */ +export const LEGACY_MEMORY_PLUGIN_HASHES: Readonly> = { + "package.json": + "9dfc20e86769676df1bc321c87fc2d428c2aaaad48411ec5a21fcc468c0a6371", + "tools/memory-save.ts": + "29b91b2f62911aa0a335cd0efeda0d4cf168953d27172abc57fc14047306e267", + "tools/memory-search.ts": + "4909b8d8059a8426a8b730f41eb53e3ea2b6d61b991f8995fa6cd9ef9cedc585", + "tools/memory-forget.ts": + "ceeb25144c9f941474c62e7b46faf9e86ab7e366481dd5be3c32088658224ef7", + "skills/memory/SKILL.md": + "f2ff2bec29a8591a5b8dce4811e963d28f53b78476e6e7361ed7d452c83cabd4", +}; + export interface MemoryPluginFile { /** Path relative to the plugin root, using forward slashes. */ path: string; @@ -18,10 +36,10 @@ export interface MemoryPluginFile { const PACKAGE_JSON = `{ "name": "${MEMORY_PLUGIN_ID}", - "version": "1.0.0", + "version": "1.1.0", "type": "module", "displayName": "Memory", - "description": "Built-in cross-project memory. Gives the agent tools to save durable facts, preferences, and decisions, and to recall them in any project.", + "description": "Built-in cross-project memory. Gives the agent tools to save durable facts, preferences, and decisions, and to recall them in any project and language.", "author": "LLM Space Contributors", "license": "MIT", "homepage": "https://github.com/deer-flow/llm-space", @@ -31,9 +49,53 @@ const PACKAGE_JSON = `{ } `; +const CONFIG_SCHEMA_JSON = `{ + "type": "object", + "title": "Memory Settings", + "description": "Tuning knobs for cross-project memory retrieval and retention.", + "properties": { + "projectBoost": { + "type": "number", + "title": "Current project boost", + "description": "Extra score added to memories saved from the project you are working in.", + "default": 4 + }, + "decayHalfLifeDays": { + "type": "number", + "title": "Recency half-life (days)", + "description": "A memory loses half of its recency weight after this many days. 0 disables decay.", + "default": 90 + }, + "duplicateSimilarity": { + "type": "number", + "title": "Duplicate similarity threshold", + "description": "A new memory closer than this (0-1) to an existing one is reported as a duplicate instead of being saved.", + "default": 0.9 + }, + "archiveEnabled": { + "type": "boolean", + "title": "Archive instead of discard", + "description": "When the 1000 memory limit is hit, move the oldest entries to memories.archive.jsonl instead of dropping them.", + "default": true + }, + "scopeDefault": { + "type": "string", + "title": "Default search scope", + "description": "auto boosts the current project but still searches everywhere; project only returns memories from the current project; all disables the boost.", + "enum": ["auto", "project", "all"], + "default": "auto" + } + } +} +`; + // Shared by every tool: the memory store is one JSON-lines file under the // plugin data directory, which survives installs, updates, and reloads and // is shared by every workspace — that is what makes the memory cross-project. +// +// The tokenizer is shared too: it is script-agnostic (Unicode property based) +// so Japanese, Korean, Chinese, Cyrillic, Arabic and every other script are +// searchable, instead of only Latin words plus CJK ideographs. const STORE_HELPERS = String.raw`import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -44,10 +106,17 @@ interface MemoryRecord { tags: string[]; origin: string | null; createdAt: string; + updatedAt?: string; +} + +interface Tokenized { + words: string[]; + grams: string[]; } const MAX_CONTENT_LENGTH = 8000; const MAX_TOTAL_MEMORIES = 1000; +const MAX_ARCHIVED_MEMORIES = 5000; function dataFilePath(): string { const home = @@ -62,6 +131,10 @@ function dataFilePath(): string { ); } +function archiveFilePath(): string { + return dataFilePath().replace(/memories\.jsonl$/, "memories.archive.jsonl"); +} + function readRecords(): MemoryRecord[] { const file = dataFilePath(); if (!fs.existsSync(file)) { @@ -97,6 +170,268 @@ function writeRecords(records: MemoryRecord[]): void { fs.writeFileSync(temporary, body ? body + "\n" : "", "utf8"); fs.renameSync(temporary, file); } + +/** Move evicted records to the archive instead of dropping them silently. */ +function appendArchive(records: MemoryRecord[]): void { + if (records.length === 0) { + return; + } + const file = archiveFilePath(); + fs.mkdirSync(path.dirname(file), { recursive: true }); + const stamp = new Date().toISOString(); + const body = records + .map((record) => JSON.stringify({ ...record, archivedAt: stamp })) + .join("\n"); + fs.appendFileSync(file, body + "\n", "utf8"); + trimArchive(); +} + +function trimArchive(): void { + const file = archiveFilePath(); + if (!fs.existsSync(file)) { + return; + } + const lines = fs + .readFileSync(file, "utf8") + .split("\n") + .filter((line) => line.trim().length > 0); + if (lines.length <= MAX_ARCHIVED_MEMORIES) { + return; + } + const kept = lines.slice(lines.length - MAX_ARCHIVED_MEMORIES); + fs.writeFileSync(file, kept.join("\n") + "\n", "utf8"); +} + +// --------------------------------------------------------------------------- +// Tokenization: script-agnostic. +// +// Words are runs of letters / numbers / combining marks, so every script is +// covered by construction. Runs written without spaces (Han, Kana, Hangul, +// Thai, ...) are further split into sub-terms; the rest stay whole words. +// --------------------------------------------------------------------------- + +const WORD_RUN = /[\p{L}\p{N}\p{M}]+/gu; + +/** Scripts that are written without spaces between words. */ +const CONTINUOUS_SCRIPT = + /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}]/u; +const ARABIC_SCRIPT = /\p{Script=Arabic}/u; +const HEBREW_SCRIPT = /\p{Script=Hebrew}/u; + +function unique(values: string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const value of values) { + if (value && !seen.has(value)) { + seen.add(value); + out.push(value); + } + } + return out; +} + +/** NFKC folds full-width forms, half-width kana and Hangul compatibility jamo. */ +function normalizeText(value: string): string { + return value.normalize("NFKC").toLowerCase(); +} + +/** + * Drop combining marks (Arabic harakat, Hebrew niqqud) and the Arabic + * tatweel. Indic scripts are deliberately excluded: their marks carry vowel + * information and removing them mangles the word. + */ +function stripOptionalMarks(run: string): string { + return run.replace(/\p{M}/gu, "").replace(/ـ/g, ""); +} + +/** NFKC does not unify the Arabic alef variants, so fold them by hand. */ +function foldArabicVariants(run: string): string { + return run + .replace(/[أإآٱ]/g, "ا") + .replace(/ى/g, "ي") + .replace(/ئ/g, "ي") + .replace(/ؤ/g, "و"); +} + +/** ICU word segmentation when available; empty when it is not. */ +function segmentWords(run: string): string[] { + try { + const ctor = (Intl as unknown as { Segmenter?: unknown }).Segmenter; + if (typeof ctor !== "function") { + return []; + } + const SegmenterCtor = ctor as new ( + locales?: string | string[], + options?: { granularity?: "grapheme" | "word" | "sentence" } + ) => { + segment( + input: string + ): Iterable<{ segment: string; isWordLike?: boolean }>; + }; + const out: string[] = []; + for (const part of new SegmenterCtor(undefined, { + granularity: "word", + }).segment(run)) { + if (part.isWordLike) { + out.push(part.segment); + } + } + return out; + } catch { + return []; + } +} + +function bigrams(run: string): string[] { + const out: string[] = []; + for (let i = 0; i + 1 < run.length; i++) { + out.push(run.slice(i, i + 2)); + } + return out; +} + +/** + * Sub-terms for a script written without spaces: ICU words when available, + * always plus bigrams. The union keeps recall when ICU splits differently + * than expected ("東京都" -> "東京" + "都"). + */ +function subTerms(run: string): string[] { + if (run.length <= 1) { + return [run]; + } + return unique(bigrams(run).concat(segmentWords(run))); +} + +function tokenize(value: string): Tokenized { + const text = normalizeText(value); + const words: string[] = []; + const grams: string[] = []; + const runs = text.match(WORD_RUN) || []; + for (const raw of runs) { + let run = raw; + if (ARABIC_SCRIPT.test(run) || HEBREW_SCRIPT.test(run)) { + run = stripOptionalMarks(run); + if (ARABIC_SCRIPT.test(run)) { + run = foldArabicVariants(run); + } + } + if (!run) { + continue; + } + if (CONTINUOUS_SCRIPT.test(run)) { + for (const part of subTerms(run)) { + grams.push(part); + } + } else { + words.push(run); + } + } + return { words: unique(words), grams: unique(grams) }; +} + +function compareKeyOf(content: string): string { + return normalizeText(content).replace(/\s+/gu, " ").trim(); +} + +interface RecordTokens { + key: string; + tags: string[]; + content: string; + words: Set; + grams: Set; +} + +const _tokenCache = new Map(); + +/** Cached per record; the cache lives as long as the plugin process does. */ +function recordTokens(record: MemoryRecord): RecordTokens { + const key = compareKeyOf(record.content); + const cached = _tokenCache.get(record.id); + if (cached && cached.key === key) { + return cached; + } + const tokens = tokenize(record.content); + const value: RecordTokens = { + key, + tags: (record.tags || []).map((tag) => normalizeText(tag)), + content: key, + words: new Set(tokens.words), + grams: new Set(tokens.grams), + }; + _tokenCache.set(record.id, value); + return value; +} + +/** Jaccard similarity over the full term sets, used to spot near-duplicates. */ +function similarityOf(a: string, b: string): number { + const left = tokenize(a); + const right = tokenize(b); + const leftSet = new Set(left.words.concat(left.grams)); + const rightSet = new Set(right.words.concat(right.grams)); + if (leftSet.size === 0 || rightSet.size === 0) { + return 0; + } + let shared = 0; + for (const term of leftSet) { + if (rightSet.has(term)) { + shared++; + } + } + return shared / (leftSet.size + rightSet.size - shared); +} + +interface ToolSettings { + projectBoost: number; + decayHalfLifeDays: number; + duplicateSimilarity: number; + archiveEnabled: boolean; + scopeDefault: string; +} + +function settingsOf(context: unknown): ToolSettings { + const bag = + context && typeof (context as { settings?: unknown }).settings === "object" + ? (context as { settings: Record }).settings + : {}; + const number = (key: string, fallback: number): number => { + const value = bag[key]; + return typeof value === "number" && Number.isFinite(value) ? value : fallback; + }; + const flag = (key: string, fallback: boolean): boolean => { + const value = bag[key]; + return typeof value === "boolean" ? value : fallback; + }; + const scope = bag.scopeDefault; + return { + projectBoost: number("projectBoost", 4), + decayHalfLifeDays: number("decayHalfLifeDays", 90), + duplicateSimilarity: number("duplicateSimilarity", 0.9), + archiveEnabled: flag("archiveEnabled", true), + scopeDefault: typeof scope === "string" ? scope : "auto", + }; +} + +/** Best-effort user notification; never fails a tool call. */ +function notifyUser(context: unknown, message: string): void { + try { + const notify = (context as { notify?: unknown })?.notify; + if (typeof notify === "function") { + void Promise.resolve( + (notify as (value: string) => unknown).call(context, message) + ).catch(() => undefined); + } + } catch { + // Notifications are best effort. + } +} + +function currentProjectOf(context: unknown): string { + const variables = (context as { variables?: unknown })?.variables as + | Record + | undefined; + const cwd = variables?.current_working_directory; + return typeof cwd === "string" ? cwd.replace(/\/+$/, "") : ""; +} `; const MEMORY_SAVE_TS = String.raw`import type { @@ -108,7 +443,7 @@ ${STORE_HELPERS} export default class MemorySaveTool implements PluginToolExtension { name = "memory_save"; description = - "Persist a durable memory to long-term storage that is shared across all projects and sessions on this machine. Save user preferences, project conventions, decisions and their rationale, environment facts, and corrections. Write the content self-contained so a future session can understand it without extra context. Never save secrets such as API keys, tokens, or passwords."; + "Persist a durable memory to long-term storage that is shared across all projects and sessions on this machine. Save user preferences, project conventions, decisions and their rationale, environment facts, and corrections. Write the content self-contained so a future session can understand it without extra context. Pass an existing id to update that memory in place instead of deleting and re-saving it. Never save secrets such as API keys, tokens, or passwords."; parameters = { type: "object", properties: { @@ -123,6 +458,11 @@ export default class MemorySaveTool implements PluginToolExtension { description: 'Optional short topic tags, for example ["preference", "testing"].', }, + id: { + type: "string", + description: + "Optional id of an existing memory to update in place (from memory_save or memory_search). Omit to create a new memory.", + }, }, required: ["content"], additionalProperties: false, @@ -132,7 +472,7 @@ export default class MemorySaveTool implements PluginToolExtension { context: PluginToolContext, args: Record ): JsonValue { - void context; + const settings = settingsOf(context); const content = typeof args.content === "string" ? args.content.trim() : ""; if (!content) { return { saved: false, error: "content must be a non-empty string." }; @@ -149,12 +489,51 @@ export default class MemorySaveTool implements PluginToolExtension { .map((tag) => tag.trim()) .filter(Boolean) .slice(0, 8); - const variables = context.variables as Record | undefined; - const origin = - variables && typeof variables.current_working_directory === "string" - ? variables.current_working_directory - : null; + const id = typeof args.id === "string" ? args.id.trim() : ""; + const origin = currentProjectOf(context) || null; const records = readRecords(); + + if (id) { + const index = records.findIndex((record) => record.id === id); + if (index < 0) { + return { saved: false, error: "No memory found with id " + id + "." }; + } + records[index] = { + ...records[index], + content, + tags, + updatedAt: new Date().toISOString(), + }; + writeRecords(records); + return { saved: true, id, updated: true }; + } + + const key = compareKeyOf(content); + const exact = records.find( + (record) => compareKeyOf(record.content) === key + ); + if (exact) { + return { saved: true, id: exact.id, duplicate: true }; + } + + const near = records.find( + (record) => + similarityOf(record.content, content) >= settings.duplicateSimilarity + ); + if (near) { + return { + saved: false, + duplicate: true, + existingId: near.id, + existingContent: near.content, + similarity: Number(similarityOf(near.content, content).toFixed(3)), + error: + "A very similar memory already exists (" + + near.id + + "). Update it by passing its id instead of saving a duplicate.", + }; + } + const record: MemoryRecord = { id: "m_" + @@ -166,11 +545,59 @@ export default class MemorySaveTool implements PluginToolExtension { origin, createdAt: new Date().toISOString(), }; + const before = records.length; records.push(record); + + const evicted: MemoryRecord[] = []; while (records.length > MAX_TOTAL_MEMORIES) { - records.shift(); + const oldest = records.shift(); + if (oldest) { + evicted.push(oldest); + } } writeRecords(records); + + const warnAt = Math.floor(MAX_TOTAL_MEMORIES * 0.9); + if ( + before < warnAt && + records.length >= warnAt && + records.length <= MAX_TOTAL_MEMORIES + ) { + notifyUser( + context, + "Memory store is nearly full: " + + records.length + + " of " + + MAX_TOTAL_MEMORIES + + " memories." + ); + } + + if (evicted.length > 0) { + let archived = false; + if (settings.archiveEnabled) { + try { + appendArchive(evicted); + archived = true; + } catch { + archived = false; + } + } + notifyUser( + context, + "Memory store reached its " + + MAX_TOTAL_MEMORIES + + " limit: " + + evicted.length + + " oldest " + + (evicted.length === 1 ? "memory was" : "memories were") + + (archived + ? " archived to memories.archive.jsonl." + : " discarded (archiving is disabled).") + ); + return { saved: true, id: record.id, evicted: evicted.length, archived }; + } + return { saved: true, id: record.id }; } } @@ -182,50 +609,100 @@ const MEMORY_SEARCH_TS = String.raw`import type { PluginToolExtension, } from "@llm-space/core"; ${STORE_HELPERS} -function tokenize(query: string): string[] { - return query.toLowerCase().split(/[^a-z0-9\u4e00-\u9fff]+/); +interface ScoreOptions { + project: string; + projectBoost: number; + halfLifeDays: number; + now: number; } -function scoreRecord(record: MemoryRecord, terms: string[]): number { +function scoreRecord( + record: MemoryRecord, + query: Tokenized, + options: ScoreOptions +): number { + const tokens = recordTokens(record); let score = 0; - const content = record.content.toLowerCase(); - const tags = record.tags.map((tag) => tag.toLowerCase()); - for (const term of terms) { + for (const term of query.words) { if (!term) { continue; } if (record.id === term) { score += 10; } - if (tags.some((tag) => tag === term)) { + if (tokens.tags.some((tag) => tag === term)) { score += 5; } - if (tags.some((tag) => tag.includes(term))) { + if (tokens.tags.some((tag) => tag.includes(term))) { score += 2; } - if (content.includes(term)) { + if (tokens.words.has(term)) { + score += 3; + } else if (tokens.content.includes(term)) { score += 1; } } + if (query.grams.length > 0) { + let matched = 0; + for (const gram of query.grams) { + if (tokens.grams.has(gram)) { + matched++; + } + } + const coverage = matched / query.grams.length; + if (coverage >= 0.5) { + score += coverage * 8; + } + } + if (score === 0) { + return 0; + } + if ( + options.projectBoost !== 0 && + options.project && + typeof record.origin === "string" && + record.origin.replace(/\/+$/, "") === options.project + ) { + score += options.projectBoost; + } + if (options.halfLifeDays > 0) { + const created = Date.parse(record.createdAt); + const ageDays = Number.isFinite(created) + ? (options.now - created) / 86400000 + : 0; + const decay = Math.pow( + 2, + -(ageDays > 0 ? ageDays : 0) / options.halfLifeDays + ); + // Older memories keep at least 30% of their weight: durable facts such as + // identity or long-lived preferences must not fade away completely. + score = score * (0.3 + 0.7 * decay); + } return score; } export default class MemorySearchTool implements PluginToolExtension { name = "memory_search"; description = - "Search persistent long-term memory that is shared across all projects and sessions on this machine. Returns the best-matching memories for a query, or the most recent memories when no query is given. Use it at the start of a task to recall relevant user preferences, project conventions, and past decisions."; + "Search persistent long-term memory that is shared across all projects and sessions on this machine. Returns the best-matching memories for a query in any language and script, or the most recent memories when no query is given. Use it at the start of a task to recall relevant user preferences, project conventions, and past decisions."; parameters = { type: "object", properties: { query: { type: "string", description: - "Keywords to look for, in any language. Omit to list the most recent memories.", + "Keywords to look for, in any language or script. Omit to list the most recent memories.", }, limit: { type: "number", description: "Maximum number of memories to return (1-50, default 5).", }, + scope: { + type: "string", + description: + "auto (default) boosts memories saved in the current project, project only searches those, all disables the boost.", + enum: ["auto", "project", "all"], + }, }, required: [], additionalProperties: false, @@ -235,18 +712,40 @@ export default class MemorySearchTool implements PluginToolExtension { context: PluginToolContext, args: Record ): JsonValue { - void context; + const settings = settingsOf(context); const query = typeof args.query === "string" ? args.query.trim() : ""; const limit = typeof args.limit === "number" && Number.isFinite(args.limit) ? Math.min(Math.max(Math.floor(args.limit), 1), 50) : 5; + const scope = + typeof args.scope === "string" && args.scope.length > 0 + ? args.scope + : settings.scopeDefault; + const project = currentProjectOf(context); const records = readRecords(); let matches = records; if (query) { - const terms = tokenize(query); - matches = records - .map((record) => ({ record, score: scoreRecord(record, terms) })) + const tokens = tokenize(query); + const options: ScoreOptions = { + project, + projectBoost: scope === "all" ? 0 : settings.projectBoost, + halfLifeDays: settings.decayHalfLifeDays, + now: Date.now(), + }; + const pool = + scope === "project" && project + ? records.filter( + (record) => + typeof record.origin === "string" && + record.origin.replace(/\/+$/, "") === project + ) + : records; + matches = pool + .map((record) => ({ + record, + score: scoreRecord(record, tokens, options), + })) .filter((entry) => entry.score > 0) .sort( (a, b) => @@ -259,6 +758,7 @@ export default class MemorySearchTool implements PluginToolExtension { } return { query: query || null, + scope, total: records.length, returned: Math.min(matches.length, limit), memories: matches.slice(0, limit), @@ -276,7 +776,7 @@ ${STORE_HELPERS} export default class MemoryForgetTool implements PluginToolExtension { name = "memory_forget"; description = - "Delete one memory by id from the persistent long-term memory that is shared across all projects on this machine. Use it when the user points out that a saved memory is outdated or wrong, then save the corrected version with memory_save."; + "Delete one memory by id from the persistent long-term memory that is shared across all projects on this machine. To correct a memory instead of removing it, prefer memory_save with its id so the id and origin are preserved."; parameters = { type: "object", properties: { @@ -322,6 +822,10 @@ You have persistent memory tools (memory_save, memory_search, memory_forget) backed by on-disk storage that is shared across **all projects and sessions** on this machine. +Search works in any language and script: queries are matched per script, so +Japanese, Korean, Chinese, Cyrillic and Arabic content are all searchable +with terms written in the same language. + ## When to search (memory_search) - At the start of any non-trivial task, search for the project name, the @@ -330,6 +834,9 @@ projects and sessions** on this machine. "remember?", "as we agreed". - When a convention is unclear, prefer the choice recorded in memory over guessing. +- Memories saved in the project you are working in rank higher by default. + Pass scope "all" to search without that preference, or scope "project" to + stay inside the current project. ## When to save (memory_save) @@ -353,15 +860,29 @@ other sensitive personal data. - One fact per memory, with short tags such as ["preference"], ["project:llm-space"], ["convention"]. +## Updating and duplicates + +- To correct a memory, call memory_save with its **id**: the content is + replaced, the id stays the same, and nothing else is lost. +- Saving the exact same content again returns the existing id with + duplicate: true and writes nothing. +- Saving something very similar to an existing memory is refused with + duplicate: true plus existingId. Update that id instead of adding a + near-copy. +- When the store is full (1000 memories), the oldest entries are moved to + memories.archive.jsonl and the user is notified — nothing is deleted + silently. + ## When to forget (memory_forget) -When the user points out that a memory is outdated or wrong, delete it by -id (find the id with memory_search) and save the corrected version. +When the user points out that a memory is outdated or wrong, prefer +updating it by id. Use memory_forget only to delete it outright. `; /** Every file of the bundled Memory plugin, ready to write to disk. */ export const MEMORY_PLUGIN_FILES: readonly MemoryPluginFile[] = [ { path: "package.json", content: PACKAGE_JSON }, + { path: "config.schema.json", content: CONFIG_SCHEMA_JSON }, { path: "tools/memory-save.ts", content: MEMORY_SAVE_TS }, { path: "tools/memory-search.ts", content: MEMORY_SEARCH_TS }, { path: "tools/memory-forget.ts", content: MEMORY_FORGET_TS }, diff --git a/apps/desktop/src/bun/plugins/seed.test.ts b/apps/desktop/src/bun/plugins/seed.test.ts index 8cef0df2..2de0cf43 100644 --- a/apps/desktop/src/bun/plugins/seed.test.ts +++ b/apps/desktop/src/bun/plugins/seed.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { existsSync, + mkdirSync, mkdtempSync, readFileSync, rmSync, @@ -26,10 +27,139 @@ interface PluginToolLike { execute(context: PluginToolContextLike, args: Record): unknown; } +interface StoredRecord { + id: string; + content: string; + tags?: string[]; + origin?: string | null; + createdAt?: string; + updatedAt?: string; + [key: string]: unknown; +} + +interface SearchResult { + returned: number; + total: number; + memories: StoredRecord[]; +} + +interface SaveResult { + saved: boolean; + id?: string; + updated?: boolean; + duplicate?: boolean; + existingId?: string; + evicted?: number; + archived?: boolean; +} + +interface Harness { + save: PluginToolLike; + search: PluginToolLike; + forget: PluginToolLike; + notifications: string[]; + context(cwd?: string): PluginToolContextLike; + writeStore(records: StoredRecord[]): void; + readStore(): StoredRecord[]; + archivePath(): string; +} + function _makeTempDir(): string { return mkdtempSync(path.join(os.tmpdir(), "llm-space-memory-seed-")); } +function _storeFile(): string { + const home = process.env.LLM_SPACE_HOME!; + return path.join( + home, + "data", + "plugins", + "@llm-space", + "memory", + "memories.jsonl" + ); +} + +async function _withHarness( + run: (harness: Harness) => void | Promise +): Promise { + const pluginsDir = _makeTempDir(); + const homeDir = _makeTempDir(); + const previousHome = process.env.LLM_SPACE_HOME; + process.env.LLM_SPACE_HOME = homeDir; + const notifications: string[] = []; + try { + seedDefaultPlugins(pluginsDir); + const toolsDir = path.join( + pluginsDir, + ...MEMORY_PLUGIN_ID.split("/"), + "tools" + ); + // Import the seeded source files exactly like the plugin runner does: + // Bun compiles TypeScript at import time. + const stamp = Date.now() + "-" + Math.random().toString(36).slice(2); + const loadTool = async (fileName: string): Promise => { + const module = (await import( + pathToFileURL(path.join(toolsDir, fileName)).href + "?t=" + stamp + )) as unknown as { default: new () => PluginToolLike }; + return new module.default(); + }; + const save = await loadTool("memory-save.ts"); + const search = await loadTool("memory-search.ts"); + const forget = await loadTool("memory-forget.ts"); + const harness: Harness = { + save, + search, + forget, + notifications, + context(cwd = "/tmp/project-a") { + return { + variables: { current_working_directory: cwd }, + settings: {}, + notify: (message: string) => { + notifications.push(message); + return Promise.resolve(); + }, + } as unknown as PluginToolContextLike; + }, + writeStore(records: StoredRecord[]) { + const file = _storeFile(); + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync( + file, + records.map((record) => JSON.stringify(record)).join("\n") + "\n", + "utf8" + ); + }, + readStore() { + const file = _storeFile(); + if (!existsSync(file)) { + return []; + } + return readFileSync(file, "utf8") + .split("\n") + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line) as StoredRecord); + }, + archivePath() { + return _storeFile().replace( + /memories\.jsonl$/, + "memories.archive.jsonl" + ); + }, + }; + await run(harness); + } finally { + if (previousHome === undefined) { + delete process.env.LLM_SPACE_HOME; + } else { + process.env.LLM_SPACE_HOME = previousHome; + } + rmSync(pluginsDir, { recursive: true, force: true }); + rmSync(homeDir, { recursive: true, force: true }); + } +} + describe("seedDefaultPlugins", () => { test("writes every memory plugin file under the scoped plugin id", () => { const pluginsDir = _makeTempDir(); @@ -66,6 +196,20 @@ describe("seedDefaultPlugins", () => { rmSync(pluginsDir, { recursive: true, force: true }); } }); + + test("restores a missing bundled file when the install is untouched", () => { + const pluginsDir = _makeTempDir(); + try { + seedDefaultPlugins(pluginsDir); + const pluginRoot = path.join(pluginsDir, ...MEMORY_PLUGIN_ID.split("/")); + const target = path.join(pluginRoot, "config.schema.json"); + rmSync(target); + seedDefaultPlugins(pluginsDir); + expect(existsSync(target)).toBe(true); + } finally { + rmSync(pluginsDir, { recursive: true, force: true }); + } + }); }); describe("memory plugin tools", () => { @@ -81,8 +225,6 @@ describe("memory plugin tools", () => { ...MEMORY_PLUGIN_ID.split("/"), "tools" ); - // Import the seeded source files exactly like the plugin runner does: - // Bun compiles TypeScript at import time. const stamp = Date.now(); const loadTool = async ( fileName: string @@ -149,3 +291,213 @@ describe("memory plugin tools", () => { } }); }); + +describe("memory retrieval across scripts", () => { + test("recalls Japanese, Korean, Arabic, Cyrillic and Chinese memories", async () => { + await _withHarness((h) => { + const entries: [string, string, string][] = [ + ["これを覚えて:テストは mise で実行する", "テスト 実行", "テスト"], + ["プロジェクトでは bun を使う", "bun を使う", "bun"], + ["البحث في الذاكرة يحتاج محرك بحث", "الذاكرة", "الذاكرة"], + ["память поиск использует bun", "память", "память"], + ["專案使用 bun 管理依賴", "依賴", "依賴"], + ]; + for (const [content] of entries) { + h.save.execute(h.context(), { content }); + } + for (const [, query, marker] of entries) { + const result = h.search.execute(h.context(), { query }) as SearchResult; + expect(result.returned).toBeGreaterThan(0); + expect(result.memories[0].content).toContain(marker); + } + }); + }); + + test("script-agnostic tokenizer also covers scripts without UI translations", async () => { + await _withHarness((h) => { + h.save.execute(h.context(), { content: "ค้นหาความจำ ใช้ bun เสมอ" }); + h.save.execute(h.context(), { content: "חיפוש זיכרון משתמש ב-bun" }); + expect( + (h.search.execute(h.context(), { query: "ความจำ" }) as SearchResult) + .returned + ).toBeGreaterThan(0); + expect( + (h.search.execute(h.context(), { query: "זיכרון" }) as SearchResult) + .returned + ).toBeGreaterThan(0); + }); + }); + + test("matches continuous scripts by sub-terms, not only exact substrings", async () => { + await _withHarness((h) => { + h.save.execute(h.context(), { + content: "東京都の設定は bun で管理する", + }); + const result = h.search.execute(h.context(), { + query: "東京", + }) as SearchResult; + expect(result.returned).toBe(1); + expect(result.memories[0].content).toContain("東京都"); + }); + }); + + test("normalizes Arabic diacritics and alef variants", async () => { + await _withHarness((h) => { + h.save.execute(h.context(), { content: "البحث في الذاكرة يحتاج محرك" }); + const withMarks = h.search.execute(h.context(), { + query: "البَحْثُ", + }) as SearchResult; + expect(withMarks.returned).toBe(1); + const withVariant = h.search.execute(h.context(), { + query: "ألبحث", + }) as SearchResult; + expect(withVariant.returned).toBe(1); + }); + }); + + test("prefers whole-word matches over accidental substrings", async () => { + await _withHarness((h) => { + h.save.execute(h.context(), { content: "we started the chart project" }); + h.save.execute(h.context(), { content: "art project lives here" }); + const result = h.search.execute(h.context(), { + query: "art", + }) as SearchResult; + expect(result.memories[0].content).toBe("art project lives here"); + }); + }); +}); + +describe("memory storage governance", () => { + test("archives the oldest memories instead of dropping them", async () => { + await _withHarness((h) => { + const seeded: StoredRecord[] = []; + for (let i = 0; i < 1000; i++) { + seeded.push({ + id: "m_seed_" + i, + content: "seed memory number " + i, + tags: [], + origin: null, + createdAt: new Date().toISOString(), + }); + } + h.writeStore(seeded); + const oldest = seeded[0].content; + const result = h.save.execute(h.context(), { + content: "brand new memory", + }) as SaveResult; + expect(result.saved).toBe(true); + expect(result.evicted).toBe(1); + expect(result.archived).toBe(true); + + const store = h.readStore(); + expect(store.length).toBe(1000); + expect(store.some((record) => record.content === oldest)).toBe(false); + expect(readFileSync(h.archivePath(), "utf8")).toContain(oldest); + expect( + h.notifications.some((message) => + message.includes("memories.archive.jsonl") + ) + ).toBe(true); + }); + }); + + test("saving identical content returns the existing id", async () => { + await _withHarness((h) => { + const first = h.save.execute(h.context(), { + content: "Vincent uses bun, never npm.", + }) as SaveResult; + const second = h.save.execute(h.context(), { + content: "Vincent uses bun, never npm.", + }) as SaveResult; + expect(second.duplicate).toBe(true); + expect(second.id).toBe(first.id); + expect(h.readStore().length).toBe(1); + }); + }); + + test("reports near-duplicates instead of stacking copies", async () => { + await _withHarness((h) => { + const first = h.save.execute(h.context(), { + content: "The team runs tasks with mise, never with make.", + }) as SaveResult; + const second = h.save.execute(h.context(), { + content: "The team runs tasks with mise, never with make!", + }) as SaveResult; + expect(second.saved).toBe(false); + expect(second.duplicate).toBe(true); + expect(second.existingId).toBe(first.id); + expect(h.readStore().length).toBe(1); + }); + }); + + test("updates a memory in place when an id is given", async () => { + await _withHarness((h) => { + const first = h.save.execute(h.context(), { + content: "Prefers tabs over spaces.", + }) as SaveResult; + const updated = h.save.execute(h.context(), { + id: first.id, + content: "Prefers spaces over tabs.", + tags: ["style"], + }) as SaveResult; + expect(updated.updated).toBe(true); + expect(updated.id).toBe(first.id); + const store = h.readStore(); + expect(store.length).toBe(1); + expect(store[0].content).toBe("Prefers spaces over tabs."); + expect(typeof store[0].updatedAt).toBe("string"); + }); + }); + + test("boosts the current project and honours the project scope", async () => { + await _withHarness((h) => { + h.save.execute(h.context("/tmp/project-a"), { + content: "Uses vitest for tests.", + }); + h.save.execute(h.context("/tmp/project-b"), { + content: "Uses vitest for tests too.", + }); + const scoped = h.search.execute(h.context(), { + query: "vitest tests", + scope: "project", + }) as SearchResult; + expect(scoped.returned).toBe(1); + expect(scoped.memories[0].origin).toBe("/tmp/project-a"); + + const auto = h.search.execute(h.context(), { + query: "vitest tests", + }) as SearchResult; + expect(auto.returned).toBe(2); + expect(auto.memories[0].origin).toBe("/tmp/project-a"); + }); + }); + + test("fades old memories but never below the decay floor", async () => { + await _withHarness((h) => { + const longAgo = new Date(Date.now() - 1000 * 86400000).toISOString(); + h.writeStore([ + { + // Only matches through its tag: a higher base score than m_new, so + // it wins without decay (5 + 2) and loses with it (floor 30%). + id: "m_old", + content: "the package manager is decided by team convention", + tags: ["bun"], + origin: null, + createdAt: longAgo, + }, + { + id: "m_new", + content: "bun is the package manager here", + tags: [], + origin: null, + createdAt: new Date().toISOString(), + }, + ]); + const result = h.search.execute(h.context(), { + query: "bun", + }) as SearchResult; + expect(result.returned).toBe(2); + expect(result.memories[0].id).toBe("m_new"); + }); + }); +}); diff --git a/apps/desktop/src/bun/plugins/seed.ts b/apps/desktop/src/bun/plugins/seed.ts index 99134cc4..92fbe649 100644 --- a/apps/desktop/src/bun/plugins/seed.ts +++ b/apps/desktop/src/bun/plugins/seed.ts @@ -1,30 +1,141 @@ -import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { getLlmSpaceHomePath } from "@llm-space/core/server"; -import { MEMORY_PLUGIN_FILES, MEMORY_PLUGIN_ID } from "./memory-plugin-files"; +import { + LEGACY_MEMORY_PLUGIN_HASHES, + MEMORY_PLUGIN_FILES, + MEMORY_PLUGIN_ID, +} from "./memory-plugin-files"; + +/** Records exactly which bundled files were written, so upgrades stay safe. */ +const SEED_MARKER_FILE = ".llm-space-seed.json"; + +type HashMap = Record; + +function sha256(value: string): string { + return createHash("sha256").update(value, "utf8").digest("hex"); +} /** The llm-space-managed plugins discovery root (`/plugins`). */ export function getManagedPluginsDir(): string { return path.join(getLlmSpaceHomePath(), "plugins"); } +function bundledHashes(): HashMap { + const hashes: HashMap = {}; + for (const file of MEMORY_PLUGIN_FILES) { + hashes[file.path] = sha256(file.content); + } + return hashes; +} + +function markerPath(pluginRoot: string): string { + return path.join(pluginRoot, SEED_MARKER_FILE); +} + +function readMarker(pluginRoot: string): HashMap | null { + try { + const parsed = JSON.parse( + readFileSync(markerPath(pluginRoot), "utf8") + ) as { files?: HashMap }; + const files = parsed?.files; + return files && typeof files === "object" ? files : null; + } catch { + return null; + } +} + +function writeMarker(pluginRoot: string, files: HashMap): void { + writeFileSync( + markerPath(pluginRoot), + JSON.stringify({ plugin: MEMORY_PLUGIN_ID, files }, null, 2) + "\n", + "utf8" + ); +} + +function writePluginFiles(pluginRoot: string): void { + for (const file of MEMORY_PLUGIN_FILES) { + const target = path.join(pluginRoot, ...file.path.split("/")); + mkdirSync(path.dirname(target), { recursive: true }); + writeFileSync(target, file.content, "utf8"); + } +} + +/** Hashes of the files currently on disk, or null when one is missing. */ +function hashesOnDisk(pluginRoot: string, files: string[]): HashMap | null { + const hashes: HashMap = {}; + for (const file of files) { + const target = path.join(pluginRoot, ...file.split("/")); + if (!existsSync(target)) { + return null; + } + hashes[file] = sha256(readFileSync(target, "utf8")); + } + return hashes; +} + +/** True when every entry of `known` matches the file currently on disk. */ +function matchesKnownHashes(known: HashMap, onDisk: HashMap): boolean { + const keys = Object.keys(known); + if (keys.length === 0) { + return false; + } + return keys.every((file) => onDisk[file] === known[file]); +} + /** * Seed the bundled default Memory plugin into the plugins discovery root so - * every install has cross-project memory out of the box. No-op when the - * plugin directory already exists — a user who removed, replaced, or edited - * the plugin is never overwritten (mirroring `seedSkills`). + * every install has cross-project memory out of the box. + * + * The plugin is rewritten only when every file on disk still matches what + * this build (or the known 1.0.0 bundle) previously wrote — a user who + * removed, replaced, or edited the plugin is never overwritten (mirroring + * `seedSkills`). */ export function seedDefaultPlugins(pluginsDir?: string): void { const root = pluginsDir ?? getManagedPluginsDir(); const pluginRoot = path.join(root, ...MEMORY_PLUGIN_ID.split("/")); - if (existsSync(pluginRoot)) { + const desired = bundledHashes(); + + if (!existsSync(pluginRoot)) { + writePluginFiles(pluginRoot); + writeMarker(pluginRoot, desired); return; } - for (const file of MEMORY_PLUGIN_FILES) { - const target = path.join(pluginRoot, ...file.path.split("/")); - mkdirSync(path.dirname(target), { recursive: true }); - writeFileSync(target, file.content, "utf8"); + + const onDisk = hashesOnDisk(pluginRoot, Object.keys(desired)); + + if (onDisk && matchesKnownHashes(desired, onDisk)) { + // Already up to date; backfill the marker for installs predating it. + if (!readMarker(pluginRoot)) { + writeMarker(pluginRoot, desired); + } + return; + } + + if (!onDisk) { + // A file is missing; only rewrite when our marker says we own this copy. + if (readMarker(pluginRoot)) { + writePluginFiles(pluginRoot); + writeMarker(pluginRoot, desired); + } + return; + } + + const marker = readMarker(pluginRoot); + const untouchedSinceLastSeed = marker + ? matchesKnownHashes(marker, onDisk) + : false; + const untouchedLegacyInstall = matchesKnownHashes( + LEGACY_MEMORY_PLUGIN_HASHES, + onDisk + ); + + if (untouchedSinceLastSeed || untouchedLegacyInstall) { + writePluginFiles(pluginRoot); + writeMarker(pluginRoot, desired); } } diff --git a/apps/desktop/src/bun/rpc/index.ts b/apps/desktop/src/bun/rpc/index.ts index 0fe9639c..9ddd2989 100644 --- a/apps/desktop/src/bun/rpc/index.ts +++ b/apps/desktop/src/bun/rpc/index.ts @@ -19,6 +19,7 @@ import { writeProjectFile, } from "../fs"; import type { LocalStorageManager } from "../local-storage"; +import * as memoryStore from "../memory"; import type { PluginCommandExecutionController } from "../plugins/plugin-command-execution-controller"; import { dismissGithubStarReminder, @@ -298,6 +299,19 @@ export function createMainWindowRPC({ // which maps it to friendly copy. Each call creates a fresh gist (no id // reuse), so a re-share yields a new link. shareThread: createShareThreadHandler({ getRuntime, gistWriter }), + // Machine-wide memory store: not runtime-scoped, and every mutation + // re-reads before writing so a concurrent plugin write survives. + memoryList: ({ query, project, limit, offset }) => + Promise.resolve( + memoryStore.listMemories({ query, project, limit, offset }) + ), + memoryDelete: ({ id }) => + Promise.resolve(memoryStore.deleteMemory(id)), + memoryUpdate: ({ id, content, tags }) => + Promise.resolve(memoryStore.updateMemory({ id, content, tags })), + memoryExport: () => Promise.resolve(memoryStore.exportMemories()), + memoryClearArchive: () => + Promise.resolve(memoryStore.clearArchive()), fsReveal: async ({ path }) => { await fsReveal(path, { skillsManager }); return null; diff --git a/apps/desktop/src/client/memory.ts b/apps/desktop/src/client/memory.ts new file mode 100644 index 00000000..8812a6d3 --- /dev/null +++ b/apps/desktop/src/client/memory.ts @@ -0,0 +1,44 @@ +import { electrobun } from "@/lib/electrobun"; +import type { + MemoryListParams, + MemoryListResult, + MemoryMutationResult, +} from "@/shared/memory"; + +function _rpc() { + if (!electrobun.rpc) { + throw new Error("Electrobun RPC is not initialized"); + } + return electrobun.rpc; +} + +/** Page through the memory store (newest first is the store's natural order). */ +export function listMemories( + params: MemoryListParams = {} +): Promise { + return _rpc().request.memoryList(params); +} + +export function deleteMemory(id: string): Promise { + return _rpc().request.memoryDelete({ id }); +} + +export function updateMemory(params: { + id: string; + content: string; + tags?: string[]; +}): Promise { + return _rpc().request.memoryUpdate(params); +} + +/** + * Writes an unencrypted JSON export beside the store. Resolves to the written + * path; callers should reveal it (and warn that the file is plain text). + */ +export function exportMemories(): Promise<{ path: string; count: number }> { + return _rpc().request.memoryExport({}); +} + +export function clearMemoryArchive(): Promise<{ removed: number }> { + return _rpc().request.memoryClearArchive({}); +} diff --git a/apps/desktop/src/components/settings/memory-page.tsx b/apps/desktop/src/components/settings/memory-page.tsx new file mode 100644 index 00000000..7cebccf5 --- /dev/null +++ b/apps/desktop/src/components/settings/memory-page.tsx @@ -0,0 +1,438 @@ +"use client"; + +import { ConfirmDialog } from "@llm-space/ui/components/confirm-dialog"; +import { Button } from "@llm-space/ui/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@llm-space/ui/ui/dialog"; +import { Input } from "@llm-space/ui/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@llm-space/ui/ui/select"; +import { Textarea } from "@llm-space/ui/ui/textarea"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { + Download, + Eye, + EyeOff, + Loader2, + Pencil, + Trash2, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; + +import { fsReveal } from "@/client/built-in-tools"; +import { + clearMemoryArchive, + deleteMemory, + exportMemories, + listMemories, + updateMemory, +} from "@/client/memory"; +import { useI18n } from "@/i18n/i18n-provider"; +import { formatMessage } from "@/i18n/messages"; +import type { MemoryListResult, MemoryRecordView } from "@/shared/memory"; + +import { SettingsPage } from "./settings-page"; + +/** Rows are variable height; this only seeds the virtualizer's estimate. */ +const ROW_ESTIMATE = 96; + +function _projectLabel(origin: string | null): string { + if (!origin) { + return "—"; + } + const normalized = origin.replace(/\/+$/, ""); + const parts = normalized.split("/"); + return parts[parts.length - 1] || normalized; +} + +function _formatDate(iso: string): string { + const date = new Date(iso); + return Number.isNaN(date.getTime()) + ? "—" + : date.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +export function MemoryPage() { + const { t } = useI18n(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [query, setQuery] = useState(""); + const [debounced, setDebounced] = useState(""); + const [project, setProject] = useState(""); + // Privacy: contents stay masked until the reader opts in. + const [revealAll, setRevealAll] = useState(false); + const [revealedIds, setRevealedIds] = useState([]); + const [editing, setEditing] = useState(null); + const [draftContent, setDraftContent] = useState(""); + const [draftTags, setDraftTags] = useState(""); + const [pendingDelete, setPendingDelete] = useState( + null + ); + const [confirmExport, setConfirmExport] = useState(false); + const [confirmArchive, setConfirmArchive] = useState(false); + const [busy, setBusy] = useState(false); + const scrollRef = useRef(null); + + useEffect(() => { + const timer = setTimeout(() => setDebounced(query), 200); + return () => clearTimeout(timer); + }, [query]); + + const load = useCallback(async () => { + setLoading(true); + try { + setData(await listMemories({ query: debounced, project })); + } catch { + toast.error(t.memory.loadFailed); + } finally { + setLoading(false); + } + }, [debounced, project, t.memory.loadFailed]); + + useEffect(() => { + void load(); + }, [load]); + + const memories = useMemo(() => data?.memories ?? [], [data]); + + // eslint-disable-next-line react-hooks/incompatible-library + const virtualizer = useVirtualizer({ + count: memories.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => ROW_ESTIMATE, + overscan: 8, + }); + + const isRevealed = useCallback( + (id: string) => revealAll || revealedIds.includes(id), + [revealAll, revealedIds] + ); + + async function _confirmDelete() { + if (!pendingDelete) { + return; + } + setBusy(true); + try { + const result = await deleteMemory(pendingDelete.id); + if (!result.ok) { + toast.error(t.memory.loadFailed); + return; + } + toast.success(t.memory.deleted); + setPendingDelete(null); + await load(); + } finally { + setBusy(false); + } + } + + async function _saveEdit() { + if (!editing) { + return; + } + setBusy(true); + try { + const result = await updateMemory({ + id: editing.id, + content: draftContent, + tags: draftTags + .split(",") + .map((tag) => tag.trim()) + .filter(Boolean), + }); + if (!result.ok) { + toast.error(t.memory.loadFailed); + return; + } + toast.success(t.memory.updated); + setEditing(null); + await load(); + } finally { + setBusy(false); + } + } + + async function _runExport() { + setConfirmExport(false); + try { + const { path, count } = await exportMemories(); + toast.success(formatMessage(t.memory.exported, { count: String(count) })); + await fsReveal(path); + } catch { + toast.error(t.memory.loadFailed); + } + } + + async function _runClearArchive() { + setConfirmArchive(false); + try { + const { removed } = await clearMemoryArchive(); + toast.success( + formatMessage(t.memory.archiveCleared, { count: String(removed) }) + ); + await load(); + } catch { + toast.error(t.memory.loadFailed); + } + } + + const usage = data + ? formatMessage(t.memory.usage, { + used: String(data.total), + max: String(data.max), + }) + : ""; + const archived = data + ? formatMessage(t.memory.archived, { count: String(data.archiveCount) }) + : ""; + + return ( + +
+
+ setQuery(event.target.value)} + /> + +
+ + + +
+ +
+ {loading ? ( + + ) : ( + <> + {usage} + · + {archived} + + )} +
+ +
+ {!loading && memories.length === 0 ? ( +
+ {data && data.total > 0 ? t.memory.noMatch : t.memory.empty} +
+ ) : ( +
+ {virtualizer.getVirtualItems().map((row) => { + const memory = memories[row.index]; + if (!memory) { + return null; + } + const revealed = isRevealed(memory.id); + return ( +
+
+
+

+ {revealed ? memory.content : "• ".repeat(24).trim()} +

+
+ {_projectLabel(memory.origin)} + {_formatDate(memory.createdAt)} + {memory.tags.length > 0 ? ( + #{memory.tags.join(" #")} + ) : null} +
+
+
+ + + +
+
+
+ ); + })} +
+ )} +
+
+ + !open && setEditing(null)} + > + + + {t.memory.editTitle} + +