@pi-unipi/memory 2.6.0 → 2.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/embedding.ts +0 -31
- package/mempalace.ts +0 -16
- package/package.json +10 -10
- package/storage.ts +56 -753
package/embedding.ts
CHANGED
|
@@ -31,12 +31,6 @@ function getConfig(): EmbeddingConfig {
|
|
|
31
31
|
return cachedConfig;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
/** Force refresh config cache */
|
|
35
|
-
export function refreshConfig(): void {
|
|
36
|
-
cachedConfig = null;
|
|
37
|
-
lastConfigLoad = 0;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
34
|
/**
|
|
41
35
|
* Generate an embedding for the given text via OpenRouter API.
|
|
42
36
|
* Returns null if not configured or on error.
|
|
@@ -216,28 +210,3 @@ export async function reembedAllMemories(ctx: ExtensionCommandContext): Promise<
|
|
|
216
210
|
return count;
|
|
217
211
|
}
|
|
218
212
|
|
|
219
|
-
/**
|
|
220
|
-
* Convert Float32Array to Buffer for SQLite storage.
|
|
221
|
-
*/
|
|
222
|
-
export function vectorToBuffer(vec: Float32Array): Buffer {
|
|
223
|
-
return Buffer.from(vec.buffer);
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/**
|
|
227
|
-
* Convert Buffer from SQLite to Float32Array.
|
|
228
|
-
*/
|
|
229
|
-
export function bufferToVector(buf: Buffer): Float32Array {
|
|
230
|
-
return new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* Check if embeddings are available (sqlite-vec loaded).
|
|
235
|
-
*/
|
|
236
|
-
export function hasEmbeddings(db: { prepare(sql: string): { get(...args: unknown[]): unknown } }): boolean {
|
|
237
|
-
try {
|
|
238
|
-
db.prepare("SELECT * FROM memories_vec LIMIT 1").get();
|
|
239
|
-
return true;
|
|
240
|
-
} catch {
|
|
241
|
-
return false;
|
|
242
|
-
}
|
|
243
|
-
}
|
package/mempalace.ts
CHANGED
|
@@ -94,11 +94,6 @@ function getBridgePath(): string | null {
|
|
|
94
94
|
return cachedBridgePath;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
/** Clear bridge discovery cache (primarily for recovery/tests). */
|
|
98
|
-
export function invalidateBridgePathCache(): void {
|
|
99
|
-
cachedBridgePath = undefined;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
97
|
export interface BridgeResponse<T> {
|
|
103
98
|
ok: boolean;
|
|
104
99
|
result?: T;
|
|
@@ -232,12 +227,6 @@ export function ensureMempalace(): MempalaceInstall | null {
|
|
|
232
227
|
return install;
|
|
233
228
|
}
|
|
234
229
|
|
|
235
|
-
/** Drop the cached install record (forces re-detection next session). */
|
|
236
|
-
export function invalidateInstallCache(): void {
|
|
237
|
-
try { if (fs.existsSync(INSTALL_FLAG)) fs.unlinkSync(INSTALL_FLAG); } catch { /* ignore */ }
|
|
238
|
-
invalidatePingVerified();
|
|
239
|
-
}
|
|
240
|
-
|
|
241
230
|
/** Was the palace ping-verified recently enough to trust without re-pinging? */
|
|
242
231
|
export function isPingVerified(): boolean {
|
|
243
232
|
try {
|
|
@@ -351,11 +340,6 @@ export function markMigrated(
|
|
|
351
340
|
}
|
|
352
341
|
}
|
|
353
342
|
|
|
354
|
-
/** Force re-migration by clearing the flag. */
|
|
355
|
-
export function clearMigratedFlag(flagPath = MIGRATED_FLAG): void {
|
|
356
|
-
try { if (fs.existsSync(flagPath)) fs.unlinkSync(flagPath); } catch { /* ignore */ }
|
|
357
|
-
}
|
|
358
|
-
|
|
359
343
|
/**
|
|
360
344
|
* Run one bridge command synchronously. Returns the parsed result, or null
|
|
361
345
|
* on any failure (timeout, non-zero exit, bad JSON, ok=false).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/memory",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.2",
|
|
4
4
|
"description": "Persistent cross-session memory with MemPalace backend (auto-installed) and SQLite fallback for Pi coding agent",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -28,7 +28,6 @@
|
|
|
28
28
|
"files": [
|
|
29
29
|
"index.ts",
|
|
30
30
|
"storage.ts",
|
|
31
|
-
"search.ts",
|
|
32
31
|
"embedding.ts",
|
|
33
32
|
"settings.ts",
|
|
34
33
|
"tools.ts",
|
|
@@ -40,24 +39,25 @@
|
|
|
40
39
|
"README.md"
|
|
41
40
|
],
|
|
42
41
|
"dependencies": {
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"js-yaml": "^4.1.0"
|
|
46
|
-
"@pi-unipi/core": "2.6.0",
|
|
47
|
-
"@pi-unipi/info-screen": "2.6.0"
|
|
42
|
+
"@pi-unipi/core": "2.6.1",
|
|
43
|
+
"@pi-unipi/info-screen": "2.6.2",
|
|
44
|
+
"js-yaml": "^4.1.0"
|
|
48
45
|
},
|
|
49
46
|
"peerDependencies": {
|
|
50
47
|
"@earendil-works/pi-coding-agent": "^0.80.0",
|
|
51
48
|
"typebox": "^1.1.38"
|
|
52
49
|
},
|
|
53
50
|
"devDependencies": {
|
|
54
|
-
"@types/better-sqlite3": "^7.6.0",
|
|
55
51
|
"@types/js-yaml": "^4.0.0",
|
|
56
52
|
"@types/node": "^25.6.0"
|
|
57
53
|
},
|
|
58
54
|
"pi": {
|
|
59
|
-
"extensions": [
|
|
60
|
-
|
|
55
|
+
"extensions": [
|
|
56
|
+
"./index.ts"
|
|
57
|
+
],
|
|
58
|
+
"skills": [
|
|
59
|
+
"./skills"
|
|
60
|
+
],
|
|
61
61
|
"prompts": [],
|
|
62
62
|
"themes": []
|
|
63
63
|
}
|
package/storage.ts
CHANGED
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @unipi/memory — Storage layer
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* legacy data
|
|
6
|
-
* unavailable, so memory never hard-fails. Markdown files remain the
|
|
4
|
+
* Backend: MemPalace (auto-installed via uv, auto-migrated from
|
|
5
|
+
* legacy SQLite/markdown data on first run). Markdown files remain the
|
|
7
6
|
* durable human-readable tier and the migration source.
|
|
8
7
|
*/
|
|
9
8
|
|
|
10
|
-
import Database from "better-sqlite3";
|
|
11
|
-
import * as sqliteVec from "sqlite-vec";
|
|
12
9
|
import * as yaml from "js-yaml";
|
|
13
10
|
import * as fs from "node:fs";
|
|
14
11
|
import * as path from "node:path";
|
|
@@ -33,7 +30,6 @@ import {
|
|
|
33
30
|
type MigrationResult,
|
|
34
31
|
} from "./mempalace.js";
|
|
35
32
|
|
|
36
|
-
export type MemoryBackend = "mempalace" | "sqlite";
|
|
37
33
|
|
|
38
34
|
/** Convert a MemPalace record (plain JSON) into a MemoryRecord. */
|
|
39
35
|
function toMemoryRecord(r: MempalaceRecord): MemoryRecord {
|
|
@@ -50,38 +46,6 @@ function toMemoryRecord(r: MempalaceRecord): MemoryRecord {
|
|
|
50
46
|
};
|
|
51
47
|
}
|
|
52
48
|
|
|
53
|
-
/** Whether MemPalace is detectable on this machine (for status display). */
|
|
54
|
-
export function isMempalaceAvailable(): boolean {
|
|
55
|
-
try {
|
|
56
|
-
return ensureMempalace() !== null;
|
|
57
|
-
} catch {
|
|
58
|
-
return false;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** Memory row from SQLite queries */
|
|
63
|
-
interface MemoryRow {
|
|
64
|
-
id: string;
|
|
65
|
-
title: string;
|
|
66
|
-
content?: string;
|
|
67
|
-
type?: string;
|
|
68
|
-
project?: string;
|
|
69
|
-
tags?: string;
|
|
70
|
-
created?: string;
|
|
71
|
-
updated?: string;
|
|
72
|
-
embedding?: { buffer: ArrayBuffer };
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** Search result row from vector queries */
|
|
76
|
-
interface SearchResultRow {
|
|
77
|
-
id: string;
|
|
78
|
-
title: string;
|
|
79
|
-
distance: number;
|
|
80
|
-
rowid?: number;
|
|
81
|
-
title_match?: number;
|
|
82
|
-
content_match?: number;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
49
|
/** Memory record interface */
|
|
86
50
|
export interface MemoryRecord {
|
|
87
51
|
id: string;
|
|
@@ -113,24 +77,6 @@ interface MemoryFrontmatter {
|
|
|
113
77
|
type: string;
|
|
114
78
|
}
|
|
115
79
|
|
|
116
|
-
const MEMORY_DB_NAME = "memory.db";
|
|
117
|
-
/**
|
|
118
|
-
* Get the configured embedding dimensions.
|
|
119
|
-
* Reads from config, falls back to 384.
|
|
120
|
-
*/
|
|
121
|
-
function getEmbeddingDims(): number {
|
|
122
|
-
try {
|
|
123
|
-
const configPath = path.join(os.homedir(), ".unipi", "memory", "config.json");
|
|
124
|
-
if (fs.existsSync(configPath)) {
|
|
125
|
-
const raw = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
126
|
-
if (typeof raw.dimensions === "number" && raw.dimensions >= 64) {
|
|
127
|
-
return raw.dimensions;
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
} catch { /* ignore */ }
|
|
131
|
-
return 384;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
80
|
/**
|
|
135
81
|
* Get the base memory directory (~/.unipi/memory/)
|
|
136
82
|
*/
|
|
@@ -248,10 +194,8 @@ ${record.content}
|
|
|
248
194
|
* MemoryStorage class — manages SQLite + markdown storage for a single project.
|
|
249
195
|
*/
|
|
250
196
|
export class MemoryStorage {
|
|
251
|
-
private db: Database.Database | null = null;
|
|
252
197
|
private projectName: string;
|
|
253
198
|
private scopeDir: string;
|
|
254
|
-
private backend: MemoryBackend = "sqlite";
|
|
255
199
|
private mempalaceInstall: MempalaceInstall | null = null;
|
|
256
200
|
private palacePath: string = DEFAULT_PALACE;
|
|
257
201
|
|
|
@@ -260,14 +204,9 @@ export class MemoryStorage {
|
|
|
260
204
|
this.scopeDir = getProjectDir(projectName);
|
|
261
205
|
}
|
|
262
206
|
|
|
263
|
-
/** Active backend ("mempalace" when available, else "sqlite"). */
|
|
264
|
-
getBackend(): MemoryBackend {
|
|
265
|
-
return this.backend;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
207
|
/** True when the MemPalace backend is active for this instance. */
|
|
269
208
|
isMempalace(): boolean {
|
|
270
|
-
return this.
|
|
209
|
+
return this.mempalaceInstall !== null;
|
|
271
210
|
}
|
|
272
211
|
|
|
273
212
|
/**
|
|
@@ -304,66 +243,29 @@ export class MemoryStorage {
|
|
|
304
243
|
* path (a Python spawn) actually needs to yield.
|
|
305
244
|
*/
|
|
306
245
|
async listAllAsync(): Promise<Array<{ id: string; title: string; type: string }>> {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
})) ?? [];
|
|
311
|
-
}
|
|
312
|
-
return this.listAll();
|
|
246
|
+
return (await this.memPalaceCallAsync<MempalaceListItem[]>("list", {
|
|
247
|
+
wing: this.projectName,
|
|
248
|
+
})) ?? [];
|
|
313
249
|
}
|
|
314
250
|
|
|
315
251
|
/**
|
|
316
252
|
* Initialize storage. Tries MemPalace first (auto-install + one-way
|
|
317
|
-
* auto-migration of legacy memories)
|
|
318
|
-
* is unavailable. Never throws for backend unavailability — only throws
|
|
319
|
-
* if the SQLite fallback itself fails to open.
|
|
253
|
+
* auto-migration of legacy memories). Throws if MemPalace is unavailable.
|
|
320
254
|
*/
|
|
321
255
|
init(): void {
|
|
322
|
-
// Ensure directory exists (used by both backends for markdown tier).
|
|
323
256
|
if (!fs.existsSync(this.scopeDir)) {
|
|
324
257
|
fs.mkdirSync(this.scopeDir, { recursive: true });
|
|
325
258
|
}
|
|
326
259
|
|
|
327
|
-
if (this.tryInitMempalace()) {
|
|
328
|
-
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
// Fallback: SQLite + sqlite-vec.
|
|
332
|
-
this.backend = "sqlite";
|
|
333
|
-
const dbPath = path.join(this.scopeDir, MEMORY_DB_NAME);
|
|
334
|
-
const maxRetries = 5;
|
|
335
|
-
|
|
336
|
-
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
337
|
-
try {
|
|
338
|
-
this.initDb(dbPath);
|
|
339
|
-
return; // Success
|
|
340
|
-
} catch (err: unknown) {
|
|
341
|
-
const errMsg = err instanceof Error ? err.message : "";
|
|
342
|
-
const errCode = (err instanceof Error && 'code' in err) ? (err as NodeJS.ErrnoException).code : undefined;
|
|
343
|
-
const isTransient =
|
|
344
|
-
errMsg.includes("disk I/O error") ||
|
|
345
|
-
errCode === "SQLITE_IOERR" ||
|
|
346
|
-
errCode === "SQLITE_BUSY" ||
|
|
347
|
-
errMsg.includes("database is locked");
|
|
348
|
-
|
|
349
|
-
this.close();
|
|
350
|
-
|
|
351
|
-
if (isTransient && attempt < maxRetries) {
|
|
352
|
-
const delayMs = 50 * Math.pow(2, attempt - 1); // 50, 100, 200, 400
|
|
353
|
-
const end = Date.now() + delayMs;
|
|
354
|
-
while (Date.now() < end) { /* busy wait */ }
|
|
355
|
-
continue;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
throw err;
|
|
359
|
-
}
|
|
260
|
+
if (!this.tryInitMempalace()) {
|
|
261
|
+
throw new Error("MemPalace backend unavailable. Ensure uv is installed.");
|
|
360
262
|
}
|
|
361
263
|
}
|
|
362
264
|
|
|
363
265
|
/**
|
|
364
266
|
* Attempt to initialize the MemPalace backend. Returns true on success.
|
|
365
267
|
* Handles auto-install and one-way auto-migration of legacy memories.
|
|
366
|
-
* Never throws — any failure returns false so
|
|
268
|
+
* Never throws — any failure returns false so init() can throw a clear error.
|
|
367
269
|
*/
|
|
368
270
|
private tryInitMempalace(): boolean {
|
|
369
271
|
let install: MempalaceInstall | null;
|
|
@@ -385,7 +287,6 @@ export class MemoryStorage {
|
|
|
385
287
|
}
|
|
386
288
|
|
|
387
289
|
this.mempalaceInstall = install;
|
|
388
|
-
this.backend = "mempalace";
|
|
389
290
|
|
|
390
291
|
// Idempotent migration + automatic catch-up. The source fingerprint turns
|
|
391
292
|
// the old one-shot timestamp into a resumable state: new/changed markdown
|
|
@@ -415,73 +316,12 @@ export class MemoryStorage {
|
|
|
415
316
|
return true;
|
|
416
317
|
}
|
|
417
318
|
|
|
418
|
-
/**
|
|
419
|
-
* Open database and set up schema. Called by init() with retry logic.
|
|
420
|
-
*/
|
|
421
|
-
private initDb(dbPath: string): void {
|
|
422
|
-
this.db = new Database(dbPath, { timeout: 5000 });
|
|
423
|
-
|
|
424
|
-
// Enable WAL mode for concurrent reads
|
|
425
|
-
this.db.pragma("journal_mode = WAL");
|
|
426
|
-
this.db.pragma("busy_timeout = 5000");
|
|
427
|
-
|
|
428
|
-
// Load sqlite-vec extension
|
|
429
|
-
try {
|
|
430
|
-
sqliteVec.load(this.db);
|
|
431
|
-
} catch (_err) {
|
|
432
|
-
// sqlite-vec unavailable — fuzzy-only mode. Silent startup.
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
// Create tables
|
|
436
|
-
this.db.exec(`
|
|
437
|
-
CREATE TABLE IF NOT EXISTS memories (
|
|
438
|
-
id TEXT PRIMARY KEY,
|
|
439
|
-
title TEXT NOT NULL,
|
|
440
|
-
content TEXT NOT NULL,
|
|
441
|
-
tags TEXT,
|
|
442
|
-
project TEXT,
|
|
443
|
-
type TEXT,
|
|
444
|
-
created TEXT,
|
|
445
|
-
updated TEXT,
|
|
446
|
-
embedding BLOB
|
|
447
|
-
)
|
|
448
|
-
`);
|
|
449
|
-
|
|
450
|
-
// Create vector table if sqlite-vec loaded
|
|
451
|
-
try {
|
|
452
|
-
this.db.exec(`
|
|
453
|
-
CREATE VIRTUAL TABLE IF NOT EXISTS memories_vec USING vec0(embedding float[${getEmbeddingDims()}])
|
|
454
|
-
`);
|
|
455
|
-
} catch {
|
|
456
|
-
// vec0 table may already exist or sqlite-vec not loaded
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
// Verify database is usable
|
|
460
|
-
this.db.prepare("SELECT 1 FROM memories LIMIT 0").get();
|
|
461
|
-
}
|
|
462
319
|
|
|
463
320
|
/**
|
|
464
321
|
* Close the database connection.
|
|
465
322
|
*/
|
|
466
323
|
close(): void {
|
|
467
|
-
|
|
468
|
-
this.db.close();
|
|
469
|
-
this.db = null;
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
/**
|
|
474
|
-
* Check if database is healthy.
|
|
475
|
-
*/
|
|
476
|
-
isHealthy(): boolean {
|
|
477
|
-
if (this.isMempalace()) return true;
|
|
478
|
-
if (!this.db) return false;
|
|
479
|
-
try {
|
|
480
|
-
this.db.prepare("SELECT 1").get();
|
|
481
|
-
return true;
|
|
482
|
-
} catch {
|
|
483
|
-
return false;
|
|
484
|
-
}
|
|
324
|
+
// MemPalace is processless; nothing to close.
|
|
485
325
|
}
|
|
486
326
|
|
|
487
327
|
/**
|
|
@@ -502,85 +342,8 @@ export class MemoryStorage {
|
|
|
502
342
|
// Set project if not provided
|
|
503
343
|
if (!record.project) record.project = this.projectName;
|
|
504
344
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
return;
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
if (!this.db) throw new Error("Storage not initialized");
|
|
511
|
-
|
|
512
|
-
// Prepare markdown content BEFORE transaction (fail fast)
|
|
513
|
-
const mdPath = path.join(this.scopeDir, `${record.id}.md`);
|
|
514
|
-
const frontmatter: MemoryFrontmatter = {
|
|
515
|
-
title: record.title,
|
|
516
|
-
tags: record.tags,
|
|
517
|
-
project: record.project,
|
|
518
|
-
created: record.created,
|
|
519
|
-
updated: record.updated,
|
|
520
|
-
type: record.type,
|
|
521
|
-
};
|
|
522
|
-
const mdContent = `---\n${yaml.dump(frontmatter, { lineWidth: -1 })}---\n\n${record.content}\n`;
|
|
523
|
-
|
|
524
|
-
// Use transaction for atomicity
|
|
525
|
-
const storeInTx = this.db.transaction(() => {
|
|
526
|
-
// Upsert into memories table
|
|
527
|
-
const stmt = this.db!.prepare(`
|
|
528
|
-
INSERT OR REPLACE INTO memories (id, title, content, tags, project, type, created, updated, embedding)
|
|
529
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
530
|
-
`);
|
|
531
|
-
|
|
532
|
-
const tagsJson = JSON.stringify(record.tags);
|
|
533
|
-
const embeddingBuf = record.embedding ? Buffer.from(record.embedding.buffer) : null;
|
|
534
|
-
|
|
535
|
-
stmt.run(
|
|
536
|
-
record.id,
|
|
537
|
-
record.title,
|
|
538
|
-
record.content,
|
|
539
|
-
tagsJson,
|
|
540
|
-
record.project,
|
|
541
|
-
record.type,
|
|
542
|
-
record.created,
|
|
543
|
-
record.updated,
|
|
544
|
-
embeddingBuf
|
|
545
|
-
);
|
|
546
|
-
|
|
547
|
-
// Update vector table
|
|
548
|
-
if (record.embedding) {
|
|
549
|
-
try {
|
|
550
|
-
// Delete old vector if exists
|
|
551
|
-
this.db!.prepare("DELETE FROM memories_vec WHERE rowid = ?").run(BigInt(this.idToRowid(record.id)));
|
|
552
|
-
} catch {
|
|
553
|
-
// Ignore if not found
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
try {
|
|
557
|
-
const vecStmt = this.db!.prepare(
|
|
558
|
-
"INSERT INTO memories_vec(rowid, embedding) VALUES (?, ?)"
|
|
559
|
-
);
|
|
560
|
-
vecStmt.run(
|
|
561
|
-
BigInt(this.idToRowid(record.id)),
|
|
562
|
-
Buffer.from(record.embedding.buffer)
|
|
563
|
-
);
|
|
564
|
-
} catch (_err) {
|
|
565
|
-
// Vector insert failure — memory still searchable via text/FTS.
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
});
|
|
569
|
-
|
|
570
|
-
// Execute transaction
|
|
571
|
-
storeInTx();
|
|
572
|
-
|
|
573
|
-
// Write markdown file AFTER successful DB write
|
|
574
|
-
try {
|
|
575
|
-
// Ensure directory exists
|
|
576
|
-
const dir = path.dirname(mdPath);
|
|
577
|
-
if (!fs.existsSync(dir)) {
|
|
578
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
579
|
-
}
|
|
580
|
-
fs.writeFileSync(mdPath, mdContent, "utf-8");
|
|
581
|
-
} catch (_err) {
|
|
582
|
-
// DB write succeeded but file write failed — memory still in DB and searchable.
|
|
583
|
-
}
|
|
345
|
+
this.storeMempalace(record);
|
|
346
|
+
return;
|
|
584
347
|
}
|
|
585
348
|
|
|
586
349
|
/**
|
|
@@ -620,80 +383,11 @@ export class MemoryStorage {
|
|
|
620
383
|
* Returns count of synced files.
|
|
621
384
|
*/
|
|
622
385
|
syncOrphanedFiles(): number {
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
return synced ?? 0;
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
if (!this.db) throw new Error("Storage not initialized");
|
|
632
|
-
|
|
633
|
-
const files = fs.readdirSync(this.scopeDir)
|
|
634
|
-
.filter(f => f.endsWith(".md") && !f.startsWith("."));
|
|
635
|
-
|
|
636
|
-
// Get existing IDs from DB
|
|
637
|
-
const existingIds = new Set(
|
|
638
|
-
(this.db.prepare("SELECT id FROM memories").all() as MemoryRow[])
|
|
639
|
-
.map(r => r.id)
|
|
640
|
-
);
|
|
641
|
-
|
|
642
|
-
let synced = 0;
|
|
643
|
-
for (const file of files) {
|
|
644
|
-
const filePath = path.join(this.scopeDir, file);
|
|
645
|
-
const record = parseMemoryFile(filePath);
|
|
646
|
-
if (!record) continue;
|
|
647
|
-
|
|
648
|
-
// New files preserve the authoritative store ID. Legacy files have no
|
|
649
|
-
// `id` frontmatter, so retain the historical title-derived fallback.
|
|
650
|
-
const id = record.id || record.title.toLowerCase().replace(/[^a-z0-9]+/g, "_");
|
|
651
|
-
|
|
652
|
-
if (existingIds.has(id)) continue; // Already in DB
|
|
653
|
-
|
|
654
|
-
// Insert into DB
|
|
655
|
-
try {
|
|
656
|
-
record.id = id;
|
|
657
|
-
const tagsJson = JSON.stringify(record.tags);
|
|
658
|
-
|
|
659
|
-
this.db.prepare(`
|
|
660
|
-
INSERT OR IGNORE INTO memories (id, title, content, tags, project, type, created, updated, embedding)
|
|
661
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)
|
|
662
|
-
`).run(
|
|
663
|
-
id,
|
|
664
|
-
record.title,
|
|
665
|
-
record.content,
|
|
666
|
-
tagsJson,
|
|
667
|
-
record.project || this.projectName,
|
|
668
|
-
record.type,
|
|
669
|
-
record.created,
|
|
670
|
-
record.updated
|
|
671
|
-
);
|
|
672
|
-
|
|
673
|
-
synced++;
|
|
674
|
-
// Removed console.warn — orphaned file sync is silent.
|
|
675
|
-
} catch (_err) {
|
|
676
|
-
// Sync failure — file remains as standalone markdown.
|
|
677
|
-
}
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
return synced;
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
/**
|
|
684
|
-
* Check if a memory with the given title already exists.
|
|
685
|
-
*/
|
|
686
|
-
hasByTitle(title: string): boolean {
|
|
687
|
-
if (this.isMempalace()) {
|
|
688
|
-
return this.memPalaceCall<boolean>("has_title", {
|
|
689
|
-
wing: this.projectName,
|
|
690
|
-
title,
|
|
691
|
-
}) ?? false;
|
|
692
|
-
}
|
|
693
|
-
if (!this.db) throw new Error("Storage not initialized");
|
|
694
|
-
const id = title.toLowerCase().replace(/[^a-z0-9]+/g, "_");
|
|
695
|
-
const row = this.db.prepare("SELECT 1 FROM memories WHERE id = ?").get(id);
|
|
696
|
-
return !!row;
|
|
386
|
+
const synced = this.memPalaceCall<number>("sync_orphaned", {
|
|
387
|
+
project_dir: this.scopeDir,
|
|
388
|
+
wing: this.projectName,
|
|
389
|
+
});
|
|
390
|
+
return synced ?? 0;
|
|
697
391
|
}
|
|
698
392
|
|
|
699
393
|
/**
|
|
@@ -701,165 +395,53 @@ export class MemoryStorage {
|
|
|
701
395
|
* Returns array of { record, similarity } sorted by similarity desc.
|
|
702
396
|
*/
|
|
703
397
|
findSimilarByTitle(title: string, threshold = 0.6): Array<{ record: MemoryRecord; similarity: number }> {
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
return rows.map((r) => ({ record: toMemoryRecord(r.record), similarity: r.similarity }));
|
|
710
|
-
}
|
|
711
|
-
if (!this.db) throw new Error("Storage not initialized");
|
|
712
|
-
|
|
713
|
-
const allRows = this.db.prepare("SELECT id, title FROM memories").all() as MemoryRow[];
|
|
714
|
-
const results: Array<{ record: MemoryRecord; similarity: number }> = [];
|
|
715
|
-
|
|
716
|
-
const normalizedTitle = title.toLowerCase().replace(/[^a-z0-9]+/g, " ");
|
|
717
|
-
const titleWords = new Set(normalizedTitle.split(/\s+/).filter((w: string) => w.length > 2));
|
|
718
|
-
|
|
719
|
-
for (const row of allRows) {
|
|
720
|
-
const normalizedRowTitle = row.title.toLowerCase().replace(/[^a-z0-9]+/g, " ");
|
|
721
|
-
const rowWords = new Set(normalizedRowTitle.split(/\s+/).filter((w: string) => w.length > 2));
|
|
722
|
-
|
|
723
|
-
// Calculate Jaccard similarity
|
|
724
|
-
const intersection = new Set([...titleWords].filter((w: string) => rowWords.has(w)));
|
|
725
|
-
const union = new Set([...titleWords, ...rowWords]);
|
|
726
|
-
const similarity = union.size > 0 ? intersection.size / union.size : 0;
|
|
727
|
-
|
|
728
|
-
if (similarity >= threshold) {
|
|
729
|
-
const record = this.getById(row.id);
|
|
730
|
-
if (record) {
|
|
731
|
-
results.push({ record, similarity });
|
|
732
|
-
}
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
return results.sort((a, b) => b.similarity - a.similarity);
|
|
398
|
+
const rows = this.memPalaceCall<Array<{ record: MempalaceRecord; similarity: number }>>(
|
|
399
|
+
"find_similar",
|
|
400
|
+
{ wing: this.projectName, title, threshold },
|
|
401
|
+
) ?? [];
|
|
402
|
+
return rows.map((r) => ({ record: toMemoryRecord(r.record), similarity: r.similarity }));
|
|
737
403
|
}
|
|
738
404
|
|
|
739
405
|
/**
|
|
740
406
|
* Get a memory record by ID.
|
|
741
407
|
*/
|
|
742
408
|
getById(id: string): MemoryRecord | null {
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
return rec ? toMemoryRecord(rec) : null;
|
|
746
|
-
}
|
|
747
|
-
if (!this.db) throw new Error("Storage not initialized");
|
|
748
|
-
|
|
749
|
-
const row = this.db.prepare("SELECT * FROM memories WHERE id = ?").get(id) as MemoryRow | undefined;
|
|
750
|
-
if (!row) return null;
|
|
751
|
-
|
|
752
|
-
return {
|
|
753
|
-
id: row.id,
|
|
754
|
-
title: row.title,
|
|
755
|
-
content: row.content ?? "",
|
|
756
|
-
tags: JSON.parse(row.tags || "[]"),
|
|
757
|
-
project: row.project ?? "",
|
|
758
|
-
type: (row.type ?? "summary") as MemoryRecord["type"],
|
|
759
|
-
created: row.created ?? "",
|
|
760
|
-
updated: row.updated ?? "",
|
|
761
|
-
embedding: row.embedding ? new Float32Array(row.embedding.buffer) : null,
|
|
762
|
-
};
|
|
409
|
+
const rec = this.memPalaceCall<MempalaceRecord | null>("get", { id });
|
|
410
|
+
return rec ? toMemoryRecord(rec) : null;
|
|
763
411
|
}
|
|
764
412
|
|
|
765
413
|
/**
|
|
766
414
|
* Get a memory record by title (fuzzy match).
|
|
767
415
|
*/
|
|
768
416
|
getByTitle(title: string): MemoryRecord | null {
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
return rec ? toMemoryRecord(rec) : null;
|
|
775
|
-
}
|
|
776
|
-
if (!this.db) throw new Error("Storage not initialized");
|
|
777
|
-
|
|
778
|
-
// Try exact match first
|
|
779
|
-
const exact = this.db.prepare("SELECT * FROM memories WHERE title = ?").get(title) as MemoryRow | undefined;
|
|
780
|
-
if (exact) {
|
|
781
|
-
return {
|
|
782
|
-
id: exact.id,
|
|
783
|
-
title: exact.title,
|
|
784
|
-
content: exact.content ?? "",
|
|
785
|
-
tags: JSON.parse(exact.tags || "[]"),
|
|
786
|
-
project: exact.project ?? "",
|
|
787
|
-
type: (exact.type ?? "summary") as MemoryRecord["type"],
|
|
788
|
-
created: exact.created ?? "",
|
|
789
|
-
updated: exact.updated ?? "",
|
|
790
|
-
embedding: exact.embedding ? new Float32Array(exact.embedding.buffer) : null,
|
|
791
|
-
};
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
// Try case-insensitive match
|
|
795
|
-
const row = this.db.prepare("SELECT * FROM memories WHERE LOWER(title) = LOWER(?)").get(title) as MemoryRow | undefined;
|
|
796
|
-
if (!row) return null;
|
|
797
|
-
|
|
798
|
-
return {
|
|
799
|
-
id: row.id,
|
|
800
|
-
title: row.title,
|
|
801
|
-
content: row.content ?? "",
|
|
802
|
-
tags: JSON.parse(row.tags || "[]"),
|
|
803
|
-
project: row.project ?? "",
|
|
804
|
-
type: (row.type ?? "summary") as MemoryRecord["type"],
|
|
805
|
-
created: row.created ?? "",
|
|
806
|
-
updated: row.updated ?? "",
|
|
807
|
-
embedding: row.embedding ? new Float32Array(row.embedding.buffer) : null,
|
|
808
|
-
};
|
|
417
|
+
const rec = this.memPalaceCall<MempalaceRecord | null>("get_by_title", {
|
|
418
|
+
wing: this.projectName,
|
|
419
|
+
title,
|
|
420
|
+
});
|
|
421
|
+
return rec ? toMemoryRecord(rec) : null;
|
|
809
422
|
}
|
|
810
423
|
|
|
811
424
|
/**
|
|
812
425
|
* List all memories (titles only).
|
|
813
426
|
*/
|
|
814
427
|
listAll(): Array<{ id: string; title: string; type: string }> {
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
return items;
|
|
820
|
-
}
|
|
821
|
-
if (!this.db) throw new Error("Storage not initialized");
|
|
822
|
-
|
|
823
|
-
const rows = this.db.prepare("SELECT id, title, type FROM memories ORDER BY updated DESC").all() as MemoryRow[];
|
|
824
|
-
return rows.map((r) => ({ id: r.id, title: r.title, type: r.type ?? "" }));
|
|
428
|
+
const items = this.memPalaceCall<MempalaceListItem[]>("list", {
|
|
429
|
+
wing: this.projectName,
|
|
430
|
+
}) ?? [];
|
|
431
|
+
return items;
|
|
825
432
|
}
|
|
826
433
|
|
|
827
434
|
/**
|
|
828
435
|
* Delete a memory by ID.
|
|
829
436
|
*/
|
|
830
437
|
delete(id: string): boolean {
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
// Also remove the markdown tier if present.
|
|
834
|
-
try {
|
|
835
|
-
const mdPath = path.join(this.scopeDir, `${id}.md`);
|
|
836
|
-
if (fs.existsSync(mdPath)) fs.unlinkSync(mdPath);
|
|
837
|
-
} catch { /* ignore */ }
|
|
838
|
-
return ok;
|
|
839
|
-
}
|
|
840
|
-
if (!this.db) throw new Error("Storage not initialized");
|
|
841
|
-
|
|
842
|
-
// Delete from vector table
|
|
843
|
-
try {
|
|
844
|
-
this.db.prepare("DELETE FROM memories_vec WHERE rowid = ?").run(BigInt(this.idToRowid(id)));
|
|
845
|
-
} catch {
|
|
846
|
-
// Ignore
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
// Delete from memories table
|
|
850
|
-
const result = this.db.prepare("DELETE FROM memories WHERE id = ?").run(id);
|
|
851
|
-
|
|
852
|
-
// Delete markdown file
|
|
853
|
-
const mdPath = path.join(this.scopeDir, `${id}.md`);
|
|
438
|
+
const ok = this.memPalaceCall<boolean>("delete", { id }) ?? false;
|
|
439
|
+
// Also remove the markdown tier if present.
|
|
854
440
|
try {
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
// Ignore
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
return result.changes > 0;
|
|
441
|
+
const mdPath = path.join(this.scopeDir, `${id}.md`);
|
|
442
|
+
if (fs.existsSync(mdPath)) fs.unlinkSync(mdPath);
|
|
443
|
+
} catch { /* ignore */ }
|
|
444
|
+
return ok;
|
|
863
445
|
}
|
|
864
446
|
|
|
865
447
|
/**
|
|
@@ -875,151 +457,18 @@ export class MemoryStorage {
|
|
|
875
457
|
* Search memories using hybrid approach.
|
|
876
458
|
*/
|
|
877
459
|
search(query: string, limit = 10, embedding?: Float32Array | null): SearchResult[] {
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
}));
|
|
889
|
-
}
|
|
890
|
-
if (!this.db) throw new Error("Storage not initialized");
|
|
891
|
-
|
|
892
|
-
const results: Map<string, SearchResult> = new Map();
|
|
893
|
-
|
|
894
|
-
// 1. Vector search (if embedding provided and vec table exists)
|
|
895
|
-
if (embedding) {
|
|
896
|
-
try {
|
|
897
|
-
const vecResults = this.db
|
|
898
|
-
.prepare(
|
|
899
|
-
`SELECT rowid, distance FROM memories_vec
|
|
900
|
-
WHERE embedding MATCH ?
|
|
901
|
-
ORDER BY distance
|
|
902
|
-
LIMIT ?`
|
|
903
|
-
)
|
|
904
|
-
.all(Buffer.from(embedding.buffer), limit * 2) as SearchResultRow[];
|
|
905
|
-
|
|
906
|
-
for (const vr of vecResults) {
|
|
907
|
-
const memoryId = this.rowidToId(Number(vr.rowid));
|
|
908
|
-
const record = this.getById(memoryId);
|
|
909
|
-
if (record) {
|
|
910
|
-
const score = 1 - Math.min(vr.distance, 1); // Normalize to 0-1
|
|
911
|
-
const snippet = this.extractSnippet(record.content, query);
|
|
912
|
-
results.set(record.id, { record, score, snippet });
|
|
913
|
-
}
|
|
914
|
-
}
|
|
915
|
-
} catch (err) {
|
|
916
|
-
// Vector search failed, continue with fuzzy
|
|
917
|
-
}
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
// 2. Fuzzy text search (split query into words)
|
|
921
|
-
const queryWords = query.toLowerCase().split(/\s+/).filter(w => w.length > 0);
|
|
922
|
-
|
|
923
|
-
// Build conditions: each word must match either title OR content
|
|
924
|
-
const wordConditions = queryWords.map(() =>
|
|
925
|
-
"(LOWER(title) LIKE LOWER(?) OR LOWER(content) LIKE LOWER(?))"
|
|
926
|
-
).join(" AND ");
|
|
927
|
-
|
|
928
|
-
const fuzzyResults = this.db
|
|
929
|
-
.prepare(
|
|
930
|
-
`SELECT id, title, content,
|
|
931
|
-
(CASE WHEN LOWER(title) LIKE LOWER(?) THEN 1 ELSE 0 END) as title_match,
|
|
932
|
-
(CASE WHEN LOWER(content) LIKE LOWER(?) THEN 1 ELSE 0 END) as content_match
|
|
933
|
-
FROM memories
|
|
934
|
-
WHERE ${wordConditions}
|
|
935
|
-
LIMIT ?`
|
|
936
|
-
)
|
|
937
|
-
.all(
|
|
938
|
-
`%${query}%`,
|
|
939
|
-
`%${query}%`,
|
|
940
|
-
...queryWords.flatMap(w => [`%${w}%`, `%${w}%`]),
|
|
941
|
-
limit * 2
|
|
942
|
-
) as SearchResultRow[];
|
|
943
|
-
|
|
944
|
-
for (const fr of fuzzyResults) {
|
|
945
|
-
const existing = results.get(fr.id);
|
|
946
|
-
const fuzzyScore = ((fr.title_match ?? 0) * 0.7 + (fr.content_match ?? 0) * 0.3);
|
|
947
|
-
const record = this.getById(fr.id);
|
|
948
|
-
if (record) {
|
|
949
|
-
const snippet = this.extractSnippet(record.content, query);
|
|
950
|
-
if (existing) {
|
|
951
|
-
// Boost score if found in both vector and fuzzy
|
|
952
|
-
existing.score = Math.min(existing.score + fuzzyScore * 0.3, 1);
|
|
953
|
-
} else {
|
|
954
|
-
results.set(fr.id, { record, score: fuzzyScore, snippet });
|
|
955
|
-
}
|
|
956
|
-
}
|
|
957
|
-
}
|
|
958
|
-
|
|
959
|
-
// 3. Sort by score and return top results
|
|
960
|
-
return Array.from(results.values())
|
|
961
|
-
.sort((a, b) => b.score - a.score)
|
|
962
|
-
.slice(0, limit);
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
/**
|
|
966
|
-
* Get the underlying database for advanced queries.
|
|
967
|
-
*/
|
|
968
|
-
getDb(): Database.Database | null {
|
|
969
|
-
return this.db;
|
|
970
|
-
}
|
|
971
|
-
|
|
972
|
-
/**
|
|
973
|
-
* Get the scope directory.
|
|
974
|
-
*/
|
|
975
|
-
getScopeDir(): string {
|
|
976
|
-
return this.scopeDir;
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
/**
|
|
980
|
-
* Extract a snippet around the query match.
|
|
981
|
-
*/
|
|
982
|
-
private extractSnippet(content: string, query: string, chars = 100): string {
|
|
983
|
-
const lowerContent = content.toLowerCase();
|
|
984
|
-
const lowerQuery = query.toLowerCase();
|
|
985
|
-
const idx = lowerContent.indexOf(lowerQuery);
|
|
986
|
-
|
|
987
|
-
if (idx === -1) {
|
|
988
|
-
// No match, return beginning
|
|
989
|
-
return content.slice(0, chars) + (content.length > chars ? "..." : "");
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
const start = Math.max(0, idx - chars / 2);
|
|
993
|
-
const end = Math.min(content.length, idx + query.length + chars / 2);
|
|
994
|
-
let snippet = content.slice(start, end);
|
|
995
|
-
|
|
996
|
-
if (start > 0) snippet = "..." + snippet;
|
|
997
|
-
if (end < content.length) snippet = snippet + "...";
|
|
998
|
-
|
|
999
|
-
return snippet;
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
/**
|
|
1003
|
-
* Convert string ID to numeric rowid for sqlite-vec.
|
|
1004
|
-
*/
|
|
1005
|
-
private idToRowid(id: string): number {
|
|
1006
|
-
// Simple hash: sum of char codes modulo 1M
|
|
1007
|
-
let hash = 0;
|
|
1008
|
-
for (let i = 0; i < id.length; i++) {
|
|
1009
|
-
hash = ((hash << 5) - hash + id.charCodeAt(i)) | 0;
|
|
1010
|
-
}
|
|
1011
|
-
return Math.abs(hash) % 1_000_000;
|
|
460
|
+
const rows = this.memPalaceCall<MempalaceSearchResult[]>("search", {
|
|
461
|
+
query,
|
|
462
|
+
wing: this.projectName,
|
|
463
|
+
limit,
|
|
464
|
+
}) ?? [];
|
|
465
|
+
return rows.map((r) => ({
|
|
466
|
+
record: toMemoryRecord(r),
|
|
467
|
+
score: r.score,
|
|
468
|
+
snippet: r.snippet,
|
|
469
|
+
}));
|
|
1012
470
|
}
|
|
1013
471
|
|
|
1014
|
-
/**
|
|
1015
|
-
* Convert numeric rowid back to string ID.
|
|
1016
|
-
*/
|
|
1017
|
-
private rowidToId(rowid: number): string {
|
|
1018
|
-
// Look up ID from memories table by rowid
|
|
1019
|
-
if (!this.db) return "";
|
|
1020
|
-
const row = this.db.prepare("SELECT id FROM memories LIMIT 1 OFFSET ?").get(rowid) as any;
|
|
1021
|
-
return row?.id || "";
|
|
1022
|
-
}
|
|
1023
472
|
}
|
|
1024
473
|
|
|
1025
474
|
/**
|
|
@@ -1044,29 +493,8 @@ export function searchAllProjects(
|
|
|
1044
493
|
}));
|
|
1045
494
|
}
|
|
1046
495
|
|
|
1047
|
-
// SQLite fallback: iterate project directories.
|
|
1048
|
-
const projectDirs = getAllProjectDirs();
|
|
1049
|
-
const allResults: SearchResult[] = [];
|
|
1050
|
-
|
|
1051
|
-
for (const { name: projectName, dir } of projectDirs) {
|
|
1052
|
-
const dbPath = path.join(dir, MEMORY_DB_NAME);
|
|
1053
|
-
if (!fs.existsSync(dbPath)) continue;
|
|
1054
496
|
|
|
1055
|
-
|
|
1056
|
-
const storage = new MemoryStorage(projectName);
|
|
1057
|
-
storage.init();
|
|
1058
|
-
const results = storage.search(query, limit);
|
|
1059
|
-
allResults.push(...results);
|
|
1060
|
-
storage.close();
|
|
1061
|
-
} catch {
|
|
1062
|
-
// Skip projects with corrupted DB
|
|
1063
|
-
}
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
|
-
// Sort by score and return top results
|
|
1067
|
-
return allResults
|
|
1068
|
-
.sort((a, b) => b.score - a.score)
|
|
1069
|
-
.slice(0, limit);
|
|
497
|
+
return [];
|
|
1070
498
|
}
|
|
1071
499
|
|
|
1072
500
|
/** Result shape shared by listAllProjects and its cached wrapper. */
|
|
@@ -1094,7 +522,7 @@ export function invalidateAllProjectsCache(): void {
|
|
|
1094
522
|
*
|
|
1095
523
|
* On a cache miss the MemPalace path spawns Python; doing that synchronously
|
|
1096
524
|
* froze the UI for ~1.1s. Only the bridge call is async — the SQLite fallback
|
|
1097
|
-
* is
|
|
525
|
+
* is async.
|
|
1098
526
|
*/
|
|
1099
527
|
export async function listAllProjectsCachedAsync(): Promise<AllProjectsEntry[]> {
|
|
1100
528
|
const now = Date.now();
|
|
@@ -1103,13 +531,9 @@ export async function listAllProjectsCachedAsync(): Promise<AllProjectsEntry[]>
|
|
|
1103
531
|
}
|
|
1104
532
|
|
|
1105
533
|
const install = ensureMempalace();
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
value = items.map((m) => ({ project: m.project, id: m.id, title: m.title, type: m.type }));
|
|
1110
|
-
} else {
|
|
1111
|
-
value = listAllProjects();
|
|
1112
|
-
}
|
|
534
|
+
if (!install) return [];
|
|
535
|
+
const items = (await runBridgeAsync<MempalaceListItemAll[]>(install, DEFAULT_PALACE, "list_all", {})) ?? [];
|
|
536
|
+
const value = items.map((m) => ({ project: m.project, id: m.id, title: m.title, type: m.type }));
|
|
1113
537
|
|
|
1114
538
|
allProjectsCache = { at: now, value };
|
|
1115
539
|
return value;
|
|
@@ -1132,128 +556,7 @@ export function listAllProjects(): AllProjectsEntry[] {
|
|
|
1132
556
|
}));
|
|
1133
557
|
}
|
|
1134
558
|
|
|
1135
|
-
// SQLite fallback: iterate project directories.
|
|
1136
|
-
const projectDirs = getAllProjectDirs();
|
|
1137
|
-
const allMemories: AllProjectsEntry[] = [];
|
|
1138
|
-
|
|
1139
|
-
for (const { name: projectName, dir } of projectDirs) {
|
|
1140
|
-
const dbPath = path.join(dir, MEMORY_DB_NAME);
|
|
1141
|
-
if (!fs.existsSync(dbPath)) continue;
|
|
1142
559
|
|
|
1143
|
-
|
|
1144
|
-
const storage = new MemoryStorage(projectName);
|
|
1145
|
-
storage.init();
|
|
1146
|
-
const memories = storage.listAll();
|
|
1147
|
-
allMemories.push(
|
|
1148
|
-
...memories.map((m) => ({
|
|
1149
|
-
project: projectName,
|
|
1150
|
-
id: m.id,
|
|
1151
|
-
title: m.title,
|
|
1152
|
-
type: m.type,
|
|
1153
|
-
}))
|
|
1154
|
-
);
|
|
1155
|
-
storage.close();
|
|
1156
|
-
} catch {
|
|
1157
|
-
// Skip projects with corrupted DB
|
|
1158
|
-
}
|
|
1159
|
-
}
|
|
1160
|
-
|
|
1161
|
-
return allMemories;
|
|
560
|
+
return [];
|
|
1162
561
|
}
|
|
1163
562
|
|
|
1164
|
-
/**
|
|
1165
|
-
* In-memory storage fallback when SQLite is unavailable.
|
|
1166
|
-
*/
|
|
1167
|
-
export class InMemoryStorage {
|
|
1168
|
-
private records: Map<string, MemoryRecord> = new Map();
|
|
1169
|
-
private projectName: string;
|
|
1170
|
-
private globalScope: boolean;
|
|
1171
|
-
|
|
1172
|
-
constructor(projectName: string, globalScope = false) {
|
|
1173
|
-
this.projectName = projectName;
|
|
1174
|
-
this.globalScope = globalScope;
|
|
1175
|
-
}
|
|
1176
|
-
|
|
1177
|
-
store(record: MemoryRecord): void {
|
|
1178
|
-
if (!record.id) {
|
|
1179
|
-
record.id = record.title.toLowerCase().replace(/[^a-z0-9]+/g, "_");
|
|
1180
|
-
}
|
|
1181
|
-
const now = new Date().toISOString();
|
|
1182
|
-
if (!record.created) record.created = now;
|
|
1183
|
-
record.updated = now;
|
|
1184
|
-
if (!record.project) record.project = this.projectName;
|
|
1185
|
-
this.records.set(record.id, record);
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1188
|
-
getById(id: string): MemoryRecord | null {
|
|
1189
|
-
return this.records.get(id) || null;
|
|
1190
|
-
}
|
|
1191
|
-
|
|
1192
|
-
getByTitle(title: string): MemoryRecord | null {
|
|
1193
|
-
for (const record of this.records.values()) {
|
|
1194
|
-
if (record.title.toLowerCase() === title.toLowerCase()) {
|
|
1195
|
-
return record;
|
|
1196
|
-
}
|
|
1197
|
-
}
|
|
1198
|
-
return null;
|
|
1199
|
-
}
|
|
1200
|
-
|
|
1201
|
-
listAll(): Array<{ id: string; title: string; type: string }> {
|
|
1202
|
-
return Array.from(this.records.values()).map((r) => ({
|
|
1203
|
-
id: r.id,
|
|
1204
|
-
title: r.title,
|
|
1205
|
-
type: r.type,
|
|
1206
|
-
}));
|
|
1207
|
-
}
|
|
1208
|
-
|
|
1209
|
-
delete(id: string): boolean {
|
|
1210
|
-
return this.records.delete(id);
|
|
1211
|
-
}
|
|
1212
|
-
|
|
1213
|
-
deleteByTitle(title: string): boolean {
|
|
1214
|
-
const record = this.getByTitle(title);
|
|
1215
|
-
if (!record) return false;
|
|
1216
|
-
return this.delete(record.id);
|
|
1217
|
-
}
|
|
1218
|
-
|
|
1219
|
-
search(query: string, limit = 10): SearchResult[] {
|
|
1220
|
-
const results: SearchResult[] = [];
|
|
1221
|
-
const lowerQuery = query.toLowerCase();
|
|
1222
|
-
|
|
1223
|
-
for (const record of this.records.values()) {
|
|
1224
|
-
const titleMatch = record.title.toLowerCase().includes(lowerQuery);
|
|
1225
|
-
const contentMatch = record.content.toLowerCase().includes(lowerQuery);
|
|
1226
|
-
|
|
1227
|
-
if (titleMatch || contentMatch) {
|
|
1228
|
-
const score = titleMatch ? 0.7 : 0.3;
|
|
1229
|
-
const snippet = this.extractSnippet(record.content, query);
|
|
1230
|
-
results.push({ record, score, snippet });
|
|
1231
|
-
}
|
|
1232
|
-
}
|
|
1233
|
-
|
|
1234
|
-
return results.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
|
-
close(): void {
|
|
1238
|
-
// No-op for in-memory
|
|
1239
|
-
}
|
|
1240
|
-
|
|
1241
|
-
private extractSnippet(content: string, query: string, chars = 100): string {
|
|
1242
|
-
const lowerContent = content.toLowerCase();
|
|
1243
|
-
const lowerQuery = query.toLowerCase();
|
|
1244
|
-
const idx = lowerContent.indexOf(lowerQuery);
|
|
1245
|
-
|
|
1246
|
-
if (idx === -1) {
|
|
1247
|
-
return content.slice(0, chars) + (content.length > chars ? "..." : "");
|
|
1248
|
-
}
|
|
1249
|
-
|
|
1250
|
-
const start = Math.max(0, idx - chars / 2);
|
|
1251
|
-
const end = Math.min(content.length, idx + query.length + chars / 2);
|
|
1252
|
-
let snippet = content.slice(start, end);
|
|
1253
|
-
|
|
1254
|
-
if (start > 0) snippet = "..." + snippet;
|
|
1255
|
-
if (end < content.length) snippet = snippet + "...";
|
|
1256
|
-
|
|
1257
|
-
return snippet;
|
|
1258
|
-
}
|
|
1259
|
-
}
|