hmem-mcp 2.2.0 → 2.4.0
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/README.md +83 -50
- package/dist/hmem-config.d.ts +9 -2
- package/dist/hmem-config.js +19 -0
- package/dist/hmem-config.js.map +1 -1
- package/dist/hmem-store.d.ts +88 -8
- package/dist/hmem-store.js +656 -52
- package/dist/hmem-store.js.map +1 -1
- package/dist/mcp-server.js +187 -50
- package/dist/mcp-server.js.map +1 -1
- package/dist/session-cache.d.ts +20 -40
- package/dist/session-cache.js +42 -47
- package/dist/session-cache.js.map +1 -1
- package/package.json +1 -1
- package/skills/hmem-read/SKILL.md +13 -12
package/dist/hmem-store.js
CHANGED
|
@@ -90,14 +90,26 @@ const MIGRATIONS = [
|
|
|
90
90
|
"ALTER TABLE memory_nodes ADD COLUMN title TEXT",
|
|
91
91
|
"ALTER TABLE memories ADD COLUMN irrelevant INTEGER DEFAULT 0",
|
|
92
92
|
"ALTER TABLE memory_nodes ADD COLUMN favorite INTEGER DEFAULT 0",
|
|
93
|
+
"ALTER TABLE memory_nodes ADD COLUMN irrelevant INTEGER DEFAULT 0",
|
|
94
|
+
// Hashtag support: join table for cross-cutting tags on entries and nodes
|
|
95
|
+
"CREATE TABLE IF NOT EXISTS memory_tags (entry_id TEXT NOT NULL, tag TEXT NOT NULL, PRIMARY KEY (entry_id, tag))",
|
|
96
|
+
"CREATE INDEX IF NOT EXISTS idx_tags_tag ON memory_tags(tag)",
|
|
97
|
+
// Pinned: super-favorites that show full L2 content in bulk reads
|
|
98
|
+
"ALTER TABLE memories ADD COLUMN pinned INTEGER DEFAULT 0",
|
|
93
99
|
];
|
|
94
100
|
// ---- HmemStore class ----
|
|
95
101
|
export class HmemStore {
|
|
96
102
|
db;
|
|
97
103
|
dbPath;
|
|
104
|
+
getDbPath() { return this.dbPath; }
|
|
98
105
|
cfg;
|
|
99
106
|
/** True if integrity_check found errors on open (read-only mode recommended). */
|
|
100
107
|
corrupted;
|
|
108
|
+
/**
|
|
109
|
+
* Char-limit tolerance: configured limits are the "recommended" target shown in skills/errors.
|
|
110
|
+
* Actual hard reject is at limit * CHAR_LIMIT_TOLERANCE (25% buffer to avoid wasted retries).
|
|
111
|
+
*/
|
|
112
|
+
static CHAR_LIMIT_TOLERANCE = 1.25;
|
|
101
113
|
constructor(hmemPath, config) {
|
|
102
114
|
this.dbPath = hmemPath;
|
|
103
115
|
this.cfg = config ?? { ...DEFAULT_CONFIG };
|
|
@@ -107,6 +119,7 @@ export class HmemStore {
|
|
|
107
119
|
}
|
|
108
120
|
this.db = new Database(hmemPath);
|
|
109
121
|
this.db.pragma("journal_mode = WAL");
|
|
122
|
+
this.db.pragma("busy_timeout = 5000");
|
|
110
123
|
// Integrity check — detect corruption before any writes
|
|
111
124
|
this.corrupted = false;
|
|
112
125
|
try {
|
|
@@ -147,7 +160,7 @@ export class HmemStore {
|
|
|
147
160
|
* Each indented line → its own memory_nodes row with compound ID
|
|
148
161
|
* Multiple lines at the same indent depth → siblings (new capability)
|
|
149
162
|
*/
|
|
150
|
-
write(prefix, content, links, minRole = "worker", favorite) {
|
|
163
|
+
write(prefix, content, links, minRole = "worker", favorite, tags, pinned) {
|
|
151
164
|
this.guardCorrupted();
|
|
152
165
|
prefix = prefix.toUpperCase();
|
|
153
166
|
if (!this.cfg.prefixes[prefix]) {
|
|
@@ -163,31 +176,37 @@ export class HmemStore {
|
|
|
163
176
|
throw new Error("Content must have at least one line (Level 1).");
|
|
164
177
|
}
|
|
165
178
|
const l1Limit = this.cfg.maxCharsPerLevel[0];
|
|
166
|
-
|
|
179
|
+
const t = HmemStore.CHAR_LIMIT_TOLERANCE;
|
|
180
|
+
if (level1.length > l1Limit * t) {
|
|
167
181
|
throw new Error(`Level 1 exceeds ${l1Limit} character limit (${level1.length} chars). Keep L1 compact.`);
|
|
168
182
|
}
|
|
169
183
|
for (const node of nodes) {
|
|
170
184
|
// depth 2-5 → index 1-4
|
|
171
185
|
const nodeLimit = this.cfg.maxCharsPerLevel[Math.min(node.depth - 1, this.cfg.maxCharsPerLevel.length - 1)];
|
|
172
|
-
if (node.content.length > nodeLimit) {
|
|
186
|
+
if (node.content.length > nodeLimit * t) {
|
|
173
187
|
throw new Error(`L${node.depth} content exceeds ${nodeLimit} character limit ` +
|
|
174
188
|
`(${node.content.length} chars). Split into multiple write_memory calls or use file references.`);
|
|
175
189
|
}
|
|
176
190
|
}
|
|
177
191
|
const insertRoot = this.db.prepare(`
|
|
178
|
-
INSERT INTO memories (id, prefix, seq, created_at, title, level_1, level_2, level_3, level_4, level_5, links, min_role, favorite)
|
|
179
|
-
VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, ?, ?, ?)
|
|
192
|
+
INSERT INTO memories (id, prefix, seq, created_at, title, level_1, level_2, level_3, level_4, level_5, links, min_role, favorite, pinned)
|
|
193
|
+
VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, ?, ?, ?, ?)
|
|
180
194
|
`);
|
|
181
195
|
const insertNode = this.db.prepare(`
|
|
182
196
|
INSERT INTO memory_nodes (id, parent_id, root_id, depth, seq, title, content, created_at)
|
|
183
197
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
184
198
|
`);
|
|
199
|
+
// Validate tags before transaction
|
|
200
|
+
const validatedTags = tags && tags.length > 0 ? this.validateTags(tags) : [];
|
|
185
201
|
// Run in a transaction
|
|
186
202
|
this.db.transaction(() => {
|
|
187
|
-
insertRoot.run(rootId, prefix, seq, timestamp, title, level1, links ? JSON.stringify(links) : null, minRole, favorite ? 1 : 0);
|
|
203
|
+
insertRoot.run(rootId, prefix, seq, timestamp, title, level1, links ? JSON.stringify(links) : null, minRole, favorite ? 1 : 0, pinned ? 1 : 0);
|
|
188
204
|
for (const node of nodes) {
|
|
189
205
|
insertNode.run(node.id, node.parent_id, rootId, node.depth, node.seq, node.title, node.content, timestamp);
|
|
190
206
|
}
|
|
207
|
+
if (validatedTags.length > 0) {
|
|
208
|
+
this.setTags(rootId, validatedTags);
|
|
209
|
+
}
|
|
191
210
|
})();
|
|
192
211
|
return { id: rootId, timestamp };
|
|
193
212
|
}
|
|
@@ -218,6 +237,15 @@ export class HmemStore {
|
|
|
218
237
|
const entry = this.nodeToEntry(this.rowToNode(row), children);
|
|
219
238
|
if (opts.expand)
|
|
220
239
|
entry.expanded = true;
|
|
240
|
+
// Load tags for this node + its children
|
|
241
|
+
const allNodeIds = [opts.id, ...children.map(c => c.id)];
|
|
242
|
+
const tagMap = this.fetchTagsBulk(allNodeIds);
|
|
243
|
+
if (tagMap.has(opts.id))
|
|
244
|
+
entry.tags = tagMap.get(opts.id);
|
|
245
|
+
for (const child of children) {
|
|
246
|
+
if (tagMap.has(child.id))
|
|
247
|
+
child.tags = tagMap.get(child.id);
|
|
248
|
+
}
|
|
221
249
|
return [entry];
|
|
222
250
|
}
|
|
223
251
|
else {
|
|
@@ -273,6 +301,26 @@ export class HmemStore {
|
|
|
273
301
|
entry.expanded = true;
|
|
274
302
|
// Auto-resolve links
|
|
275
303
|
this.resolveEntryLinks(entry, opts);
|
|
304
|
+
// Load tags for entry + children, find related entries
|
|
305
|
+
const allIds = [opts.id, ...this.collectNodeIds(children)];
|
|
306
|
+
const tagMap = this.fetchTagsBulk(allIds);
|
|
307
|
+
if (tagMap.has(opts.id))
|
|
308
|
+
entry.tags = tagMap.get(opts.id);
|
|
309
|
+
for (const child of children) {
|
|
310
|
+
if (tagMap.has(child.id))
|
|
311
|
+
child.tags = tagMap.get(child.id);
|
|
312
|
+
if (child.children) {
|
|
313
|
+
for (const gc of child.children) {
|
|
314
|
+
if (tagMap.has(gc.id))
|
|
315
|
+
gc.tags = tagMap.get(gc.id);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
// Related entries: find other entries sharing 2+ tags
|
|
320
|
+
const entryTags = entry.tags ?? [];
|
|
321
|
+
if (entryTags.length >= 2) {
|
|
322
|
+
entry.relatedEntries = this.findRelated(opts.id, entryTags, 5);
|
|
323
|
+
}
|
|
276
324
|
return [entry];
|
|
277
325
|
}
|
|
278
326
|
}
|
|
@@ -313,6 +361,12 @@ export class HmemStore {
|
|
|
313
361
|
const nodeLimitClause = limit !== undefined ? ` LIMIT ${limit}` : "";
|
|
314
362
|
const nodeRows = this.db.prepare(`SELECT DISTINCT root_id FROM memory_nodes WHERE content LIKE ?${nodeLimitClause}`).all(pattern);
|
|
315
363
|
const nodeRootIds = new Set(nodeRows.map(r => r.root_id));
|
|
364
|
+
// Also search tags (e.g. search="#hmem" matches tag "#hmem")
|
|
365
|
+
const tagRows = this.db.prepare("SELECT entry_id FROM memory_tags WHERE tag LIKE ?").all(pattern);
|
|
366
|
+
for (const row of tagRows) {
|
|
367
|
+
const eid = row.entry_id;
|
|
368
|
+
nodeRootIds.add(eid.includes(".") ? eid.split(".")[0] : eid);
|
|
369
|
+
}
|
|
316
370
|
const memLimitClause = limit !== undefined ? ` LIMIT ${limit}` : "";
|
|
317
371
|
const memRows = this.db.prepare(`SELECT * FROM memories ${where} ORDER BY created_at DESC${memLimitClause}`).all(pattern, ...roleFilter.params);
|
|
318
372
|
// Merge: include any roots found in node search too
|
|
@@ -358,6 +412,15 @@ export class HmemStore {
|
|
|
358
412
|
conditions.push("created_at <= ?");
|
|
359
413
|
params.push(end.toISOString());
|
|
360
414
|
}
|
|
415
|
+
// Tag-based filtering: restrict to entries that have the specified tag
|
|
416
|
+
if (opts.tag) {
|
|
417
|
+
const tagRootIds = this.getRootIdsByTag(opts.tag.toLowerCase());
|
|
418
|
+
if (tagRootIds.size === 0)
|
|
419
|
+
return [];
|
|
420
|
+
const placeholders = [...tagRootIds].map(() => "?").join(", ");
|
|
421
|
+
conditions.push(`id IN (${placeholders})`);
|
|
422
|
+
params.push(...tagRootIds);
|
|
423
|
+
}
|
|
361
424
|
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
362
425
|
// Sort by effective_date: the most recent of root created_at OR latest child node created_at.
|
|
363
426
|
// This ensures entries with recently appended L2 nodes surface alongside genuinely new entries.
|
|
@@ -379,6 +442,31 @@ export class HmemStore {
|
|
|
379
442
|
}
|
|
380
443
|
return this.readBulkV2(rows, opts);
|
|
381
444
|
}
|
|
445
|
+
/**
|
|
446
|
+
* Calculate V2 selection slot counts based on the number of relevant entries.
|
|
447
|
+
* Uses percentage-based scaling with min/max caps when configured,
|
|
448
|
+
* falls back to fixed topNewestCount/topAccessCount otherwise.
|
|
449
|
+
*/
|
|
450
|
+
calcV2Slots(relevantCount, isEssentials = false, fraction = 1.0) {
|
|
451
|
+
const v2 = this.cfg.bulkReadV2;
|
|
452
|
+
let newest, access;
|
|
453
|
+
if (v2.newestPercent !== undefined) {
|
|
454
|
+
const effNewest = v2.newestPercent * fraction;
|
|
455
|
+
const effAccess = (v2.accessPercent ?? 10) * fraction;
|
|
456
|
+
newest = Math.min(v2.newestMax ?? 15, Math.max(v2.newestMin ?? 5, Math.ceil(relevantCount * (effNewest / 100))));
|
|
457
|
+
access = Math.min(v2.accessMax ?? 8, Math.max(v2.accessMin ?? 3, Math.ceil(relevantCount * (effAccess / 100))));
|
|
458
|
+
}
|
|
459
|
+
else {
|
|
460
|
+
newest = Math.max(1, Math.round(v2.topNewestCount * fraction));
|
|
461
|
+
access = Math.max(1, Math.round(v2.topAccessCount * fraction));
|
|
462
|
+
}
|
|
463
|
+
if (isEssentials) {
|
|
464
|
+
const total = newest + access;
|
|
465
|
+
newest = Math.max(1, Math.floor(newest * 0.4));
|
|
466
|
+
access = total - newest;
|
|
467
|
+
}
|
|
468
|
+
return { newestCount: newest, accessCount: access };
|
|
469
|
+
}
|
|
382
470
|
/**
|
|
383
471
|
* V2 bulk-read algorithm: per-prefix expansion, smart obsolete filtering,
|
|
384
472
|
* expanded entries with all L2 children + links.
|
|
@@ -400,78 +488,110 @@ export class HmemStore {
|
|
|
400
488
|
else
|
|
401
489
|
byPrefix.set(r.prefix, [r]);
|
|
402
490
|
}
|
|
403
|
-
//
|
|
404
|
-
|
|
405
|
-
|
|
491
|
+
// === Curation mode: show ALL entries, bypass V2 + session cache, depth 3 children ===
|
|
492
|
+
if (opts.showAll) {
|
|
493
|
+
const visibleObsolete = opts.showObsolete ? obsoleteRows : [];
|
|
494
|
+
const allVisible = [...nonObsoleteRows, ...visibleObsolete];
|
|
495
|
+
const visibleIds = new Set(allVisible.map(r => r.id));
|
|
496
|
+
const entries = allVisible.map(r => {
|
|
497
|
+
// Fetch children to depth 3 (L2 + L3), no V2 selection, filter irrelevant
|
|
498
|
+
const allChildren = this.fetchChildrenDeep(r.id, 2, 4)
|
|
499
|
+
.filter(c => !c.irrelevant);
|
|
500
|
+
// Resolve links
|
|
501
|
+
let linkedEntries;
|
|
502
|
+
const links = r.links ? JSON.parse(r.links) : [];
|
|
503
|
+
if (links.length > 0) {
|
|
504
|
+
linkedEntries = links.flatMap(linkId => {
|
|
505
|
+
if (visibleIds.has(linkId))
|
|
506
|
+
return [];
|
|
507
|
+
try {
|
|
508
|
+
return this.read({ id: linkId, resolveLinks: false, linkDepth: 0, followObsolete: false });
|
|
509
|
+
}
|
|
510
|
+
catch {
|
|
511
|
+
return [];
|
|
512
|
+
}
|
|
513
|
+
}).filter(e => !e.obsolete && !e.irrelevant);
|
|
514
|
+
}
|
|
515
|
+
const entry = this.rowToEntry(r, allChildren);
|
|
516
|
+
entry.expanded = true;
|
|
517
|
+
if (r.favorite === 1)
|
|
518
|
+
entry.promoted = "favorite";
|
|
519
|
+
if (linkedEntries && linkedEntries.length > 0)
|
|
520
|
+
entry.linkedEntries = linkedEntries;
|
|
521
|
+
return entry;
|
|
522
|
+
});
|
|
523
|
+
this.assignBulkTags(entries);
|
|
524
|
+
return entries;
|
|
525
|
+
}
|
|
526
|
+
// === Normal mode: V2 selection + session cache ===
|
|
527
|
+
// Session cache: two phases — hidden (< 5 min, excluded) and cached (5-30 min, title-only)
|
|
528
|
+
const cached = opts.cachedIds ?? new Set();
|
|
529
|
+
const hidden = opts.hiddenIds ?? new Set();
|
|
530
|
+
const fraction = opts.slotFraction ?? 1.0;
|
|
406
531
|
// Step 3: Build expansion set from non-obsolete rows
|
|
407
532
|
const expandedIds = new Set();
|
|
408
|
-
// Mode-based ratios: essentials shifts weight from newest to most-accessed
|
|
409
533
|
const isEssentials = opts.mode === "essentials";
|
|
410
|
-
|
|
411
|
-
const baseNewest = isEssentials ? Math.max(1, Math.floor(v2.topNewestCount * 0.4)) : v2.topNewestCount;
|
|
412
|
-
const baseAccess = isEssentials ? totalSlots - baseNewest : v2.topAccessCount;
|
|
413
|
-
// Fibonacci-limited counts (when cache is active, use separate Fibonacci values)
|
|
414
|
-
const newestCount = (hasCacheActive && opts.maxNewNewest !== undefined)
|
|
415
|
-
? opts.maxNewNewest : baseNewest;
|
|
416
|
-
const accessCount = (hasCacheActive && opts.maxNewAccess !== undefined)
|
|
417
|
-
? opts.maxNewAccess : baseAccess;
|
|
418
|
-
// Per prefix: top N newest (sliding window) + top M most-accessed (backfill)
|
|
534
|
+
// Per prefix: top N newest + top M most-accessed — slot counts scale with prefix size
|
|
419
535
|
for (const [, prefixRows] of byPrefix) {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
for (const r of unseenRows.slice(0, newestCount)) {
|
|
536
|
+
const { newestCount, accessCount } = this.calcV2Slots(prefixRows.length, isEssentials, fraction);
|
|
537
|
+
// Newest: skip cached AND hidden entries, fill from fresh entries only
|
|
538
|
+
const uncachedRows = prefixRows.filter(r => !cached.has(r.id) && !hidden.has(r.id));
|
|
539
|
+
for (const r of uncachedRows.slice(0, newestCount)) {
|
|
425
540
|
expandedIds.add(r.id);
|
|
426
541
|
}
|
|
427
|
-
// Most-accessed: from
|
|
542
|
+
// Most-accessed: from uncached entries, excluding those already picked as newest.
|
|
428
543
|
// Minimum threshold: access_count >= 2 — a single access can be noise.
|
|
429
|
-
const mostAccessed = [...
|
|
544
|
+
const mostAccessed = [...uncachedRows]
|
|
430
545
|
.filter(r => r.access_count >= 2 && !expandedIds.has(r.id))
|
|
431
546
|
.sort((a, b) => this.weightedAccessScore(b) - this.weightedAccessScore(a))
|
|
432
547
|
.slice(0, accessCount);
|
|
433
548
|
for (const r of mostAccessed)
|
|
434
549
|
expandedIds.add(r.id);
|
|
435
550
|
}
|
|
436
|
-
// Global: all
|
|
551
|
+
// Global: all uncached+unhidden favorites
|
|
437
552
|
for (const r of nonObsoleteRows) {
|
|
438
|
-
if (r.favorite === 1 && !
|
|
553
|
+
if ((r.favorite === 1 || r.pinned === 1) && !cached.has(r.id) && !hidden.has(r.id)) {
|
|
439
554
|
expandedIds.add(r.id);
|
|
440
555
|
}
|
|
441
556
|
}
|
|
442
557
|
// topAccess reference for promoted marker (time-weighted, min 2 accesses)
|
|
558
|
+
const { accessCount: globalAccessSlots } = this.calcV2Slots(nonObsoleteRows.length);
|
|
443
559
|
const topAccess = [...nonObsoleteRows]
|
|
444
560
|
.filter(r => r.access_count >= 2)
|
|
445
561
|
.sort((a, b) => this.weightedAccessScore(b) - this.weightedAccessScore(a))
|
|
446
|
-
.slice(0,
|
|
562
|
+
.slice(0, globalAccessSlots);
|
|
447
563
|
// Obsolete entries: only shown when explicitly requested
|
|
448
564
|
const visibleObsolete = opts.showObsolete ? obsoleteRows : [];
|
|
449
|
-
// Step 4:
|
|
565
|
+
// Step 4: Build visible rows (hidden entries completely excluded)
|
|
566
|
+
// - Expanded entries: full content with children
|
|
567
|
+
// - Cached entries: title-only (no expansion, no children)
|
|
450
568
|
const expandedNonObsolete = nonObsoleteRows.filter(r => expandedIds.has(r.id));
|
|
451
|
-
const
|
|
569
|
+
const cachedVisible = nonObsoleteRows.filter(r => cached.has(r.id) && !expandedIds.has(r.id) && !hidden.has(r.id));
|
|
570
|
+
const visibleRows = [...expandedNonObsolete, ...cachedVisible, ...visibleObsolete];
|
|
452
571
|
const visibleIds = new Set(visibleRows.map(r => r.id));
|
|
453
572
|
// titles_only: V2 selection applies, but skip link resolution
|
|
454
573
|
if (opts.titlesOnly) {
|
|
455
574
|
// Bulk-fetch L2 child counts (one query for all visible entries)
|
|
456
575
|
const allIds = visibleRows.map(r => r.id);
|
|
457
576
|
const childCounts = this.bulkChildCount(allIds);
|
|
458
|
-
|
|
577
|
+
const entries = visibleRows.map(r => {
|
|
459
578
|
const isExpanded = expandedIds.has(r.id);
|
|
460
579
|
const totalChildren = childCounts.get(r.id) ?? 0;
|
|
461
580
|
let children;
|
|
462
581
|
let hiddenCount;
|
|
463
582
|
if (isExpanded && totalChildren > 0) {
|
|
464
|
-
// Fetch L2 children with V2 selection (
|
|
465
|
-
const allChildren = this.fetchChildren(r.id);
|
|
466
|
-
|
|
583
|
+
// Fetch L2 children with V2 selection (percentage-based), no links
|
|
584
|
+
const allChildren = this.fetchChildren(r.id).filter(c => !c.irrelevant);
|
|
585
|
+
const childSlots = this.calcV2Slots(allChildren.length);
|
|
586
|
+
if (allChildren.length > childSlots.newestCount) {
|
|
467
587
|
const newestSet = new Set([...allChildren]
|
|
468
588
|
.sort((a, b) => (b.created_at || "").localeCompare(a.created_at || ""))
|
|
469
|
-
.slice(0,
|
|
589
|
+
.slice(0, childSlots.newestCount)
|
|
470
590
|
.map(c => c.id));
|
|
471
591
|
const accessSet = new Set([...allChildren]
|
|
472
592
|
.filter(c => c.access_count >= 2)
|
|
473
593
|
.sort((a, b) => this.weightedAccessScore(b) - this.weightedAccessScore(a))
|
|
474
|
-
.slice(0,
|
|
594
|
+
.slice(0, childSlots.accessCount)
|
|
475
595
|
.map(c => c.id));
|
|
476
596
|
const selectedIds = new Set([...newestSet, ...accessSet]);
|
|
477
597
|
children = allChildren.filter(c => selectedIds.has(c.id));
|
|
@@ -495,8 +615,10 @@ export class HmemStore {
|
|
|
495
615
|
entry.hiddenChildrenCount = hiddenCount;
|
|
496
616
|
return entry;
|
|
497
617
|
});
|
|
618
|
+
this.assignBulkTags(entries);
|
|
619
|
+
return entries;
|
|
498
620
|
}
|
|
499
|
-
|
|
621
|
+
const entries = visibleRows.map(r => {
|
|
500
622
|
const isExpanded = expandedIds.has(r.id);
|
|
501
623
|
let promoted;
|
|
502
624
|
if (r.favorite === 1)
|
|
@@ -509,17 +631,18 @@ export class HmemStore {
|
|
|
509
631
|
let hiddenObsoleteLinks = 0;
|
|
510
632
|
let hiddenIrrelevantLinks = 0;
|
|
511
633
|
if (isExpanded) {
|
|
512
|
-
// Fetch all L2 children, then apply V2 selection (
|
|
513
|
-
const allChildren = this.fetchChildren(r.id);
|
|
514
|
-
|
|
634
|
+
// Fetch all L2 children, then apply V2 selection (percentage-based)
|
|
635
|
+
const allChildren = this.fetchChildren(r.id).filter(c => !c.irrelevant);
|
|
636
|
+
const childSlots = this.calcV2Slots(allChildren.length);
|
|
637
|
+
if (allChildren.length > childSlots.newestCount) {
|
|
515
638
|
const newestSet = new Set([...allChildren]
|
|
516
639
|
.sort((a, b) => (b.created_at || "").localeCompare(a.created_at || ""))
|
|
517
|
-
.slice(0,
|
|
640
|
+
.slice(0, childSlots.newestCount)
|
|
518
641
|
.map(c => c.id));
|
|
519
642
|
const accessSet = new Set([...allChildren]
|
|
520
643
|
.filter(c => c.access_count > 0)
|
|
521
644
|
.sort((a, b) => this.weightedAccessScore(b) - this.weightedAccessScore(a))
|
|
522
|
-
.slice(0,
|
|
645
|
+
.slice(0, childSlots.accessCount)
|
|
523
646
|
.map(c => c.id));
|
|
524
647
|
const selectedIds = new Set([...newestSet, ...accessSet]);
|
|
525
648
|
children = allChildren.filter(c => selectedIds.has(c.id));
|
|
@@ -537,7 +660,7 @@ export class HmemStore {
|
|
|
537
660
|
if (visibleIds.has(linkId))
|
|
538
661
|
return []; // already shown in bulk read
|
|
539
662
|
try {
|
|
540
|
-
return this.read({ id: linkId, resolveLinks: false, linkDepth: 0 });
|
|
663
|
+
return this.read({ id: linkId, resolveLinks: false, linkDepth: 0, followObsolete: false });
|
|
541
664
|
}
|
|
542
665
|
catch {
|
|
543
666
|
return [];
|
|
@@ -565,6 +688,8 @@ export class HmemStore {
|
|
|
565
688
|
entry.hiddenIrrelevantLinks = hiddenIrrelevantLinks;
|
|
566
689
|
return entry;
|
|
567
690
|
});
|
|
691
|
+
this.assignBulkTags(entries);
|
|
692
|
+
return entries;
|
|
568
693
|
}
|
|
569
694
|
/**
|
|
570
695
|
* Get all Level 1 entries for injection at agent startup.
|
|
@@ -627,6 +752,317 @@ export class HmemStore {
|
|
|
627
752
|
}
|
|
628
753
|
return md;
|
|
629
754
|
}
|
|
755
|
+
/**
|
|
756
|
+
* Export memory to a new .hmem SQLite file.
|
|
757
|
+
* Creates a standalone copy that can be opened with HmemStore or hmem.py.
|
|
758
|
+
*/
|
|
759
|
+
exportPublicToHmem(outputPath) {
|
|
760
|
+
if (fs.existsSync(outputPath))
|
|
761
|
+
fs.unlinkSync(outputPath);
|
|
762
|
+
if (fs.existsSync(outputPath + "-wal"))
|
|
763
|
+
fs.unlinkSync(outputPath + "-wal");
|
|
764
|
+
if (fs.existsSync(outputPath + "-shm"))
|
|
765
|
+
fs.unlinkSync(outputPath + "-shm");
|
|
766
|
+
const exportDb = new Database(outputPath);
|
|
767
|
+
exportDb.pragma("journal_mode = WAL");
|
|
768
|
+
exportDb.exec(SCHEMA);
|
|
769
|
+
for (const sql of MIGRATIONS) {
|
|
770
|
+
try {
|
|
771
|
+
exportDb.exec(sql);
|
|
772
|
+
}
|
|
773
|
+
catch { }
|
|
774
|
+
}
|
|
775
|
+
// Determine export-compatible columns (source may have extra columns)
|
|
776
|
+
const memCols = exportDb.pragma("table_info(memories)").map((c) => c.name);
|
|
777
|
+
const nodeCols = exportDb.pragma("table_info(memory_nodes)").map((c) => c.name);
|
|
778
|
+
// Copy all entries (only columns the export schema knows)
|
|
779
|
+
const rows = this.db.prepare(`SELECT ${memCols.join(", ")} FROM memories WHERE seq > 0 ORDER BY prefix, seq`).all();
|
|
780
|
+
if (rows.length > 0) {
|
|
781
|
+
const placeholders = memCols.map(() => "?").join(", ");
|
|
782
|
+
const insertMem = exportDb.prepare(`INSERT INTO memories (${memCols.join(", ")}) VALUES (${placeholders})`);
|
|
783
|
+
const txn = exportDb.transaction((entries) => {
|
|
784
|
+
for (const r of entries)
|
|
785
|
+
insertMem.run(...memCols.map(c => r[c]));
|
|
786
|
+
});
|
|
787
|
+
txn(rows);
|
|
788
|
+
}
|
|
789
|
+
// Copy all nodes
|
|
790
|
+
const allNodes = this.db.prepare(`SELECT ${nodeCols.join(", ")} FROM memory_nodes ORDER BY root_id, depth, seq`).all();
|
|
791
|
+
if (allNodes.length > 0) {
|
|
792
|
+
const placeholders = nodeCols.map(() => "?").join(", ");
|
|
793
|
+
const insertNode = exportDb.prepare(`INSERT INTO memory_nodes (${nodeCols.join(", ")}) VALUES (${placeholders})`);
|
|
794
|
+
const txn = exportDb.transaction((nodes) => {
|
|
795
|
+
for (const n of nodes)
|
|
796
|
+
insertNode.run(...nodeCols.map(c => n[c]));
|
|
797
|
+
});
|
|
798
|
+
txn(allNodes);
|
|
799
|
+
}
|
|
800
|
+
// Copy all tags
|
|
801
|
+
const allTags = this.db.prepare("SELECT * FROM memory_tags").all();
|
|
802
|
+
if (allTags.length > 0) {
|
|
803
|
+
const insertTag = exportDb.prepare("INSERT INTO memory_tags (entry_id, tag) VALUES (?, ?)");
|
|
804
|
+
const txn = exportDb.transaction((tags) => {
|
|
805
|
+
for (const t of tags)
|
|
806
|
+
insertTag.run(t.entry_id, t.tag);
|
|
807
|
+
});
|
|
808
|
+
txn(allTags);
|
|
809
|
+
}
|
|
810
|
+
exportDb.pragma("wal_checkpoint(TRUNCATE)");
|
|
811
|
+
exportDb.close();
|
|
812
|
+
return { entries: rows.length, nodes: allNodes.length, tags: allTags.length };
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Import entries from another .hmem file with L1 deduplication and ID remapping.
|
|
816
|
+
*/
|
|
817
|
+
importFromHmem(sourcePath, dryRun = false) {
|
|
818
|
+
if (!fs.existsSync(sourcePath)) {
|
|
819
|
+
throw new Error(`Source file not found: ${sourcePath}`);
|
|
820
|
+
}
|
|
821
|
+
const sourceDb = new Database(sourcePath, { readonly: true });
|
|
822
|
+
try {
|
|
823
|
+
return this._doImport(sourceDb, dryRun);
|
|
824
|
+
}
|
|
825
|
+
finally {
|
|
826
|
+
sourceDb.close();
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
_doImport(sourceDb, dryRun) {
|
|
830
|
+
// ---- Phase 1: Analyse ----
|
|
831
|
+
const srcEntries = sourceDb.prepare("SELECT * FROM memories WHERE seq > 0 ORDER BY prefix, seq").all();
|
|
832
|
+
const srcNodes = sourceDb.prepare("SELECT * FROM memory_nodes ORDER BY root_id, depth, seq").all();
|
|
833
|
+
let srcTags = [];
|
|
834
|
+
try {
|
|
835
|
+
srcTags = sourceDb.prepare("SELECT * FROM memory_tags").all();
|
|
836
|
+
}
|
|
837
|
+
catch { /* table may not exist in older exports */ }
|
|
838
|
+
const srcNodesByRoot = new Map();
|
|
839
|
+
for (const n of srcNodes) {
|
|
840
|
+
const arr = srcNodesByRoot.get(n.root_id);
|
|
841
|
+
if (arr)
|
|
842
|
+
arr.push(n);
|
|
843
|
+
else
|
|
844
|
+
srcNodesByRoot.set(n.root_id, [n]);
|
|
845
|
+
}
|
|
846
|
+
const srcTagsByEntry = new Map();
|
|
847
|
+
for (const t of srcTags) {
|
|
848
|
+
const arr = srcTagsByEntry.get(t.entry_id);
|
|
849
|
+
if (arr)
|
|
850
|
+
arr.push(t.tag);
|
|
851
|
+
else
|
|
852
|
+
srcTagsByEntry.set(t.entry_id, [t.tag]);
|
|
853
|
+
}
|
|
854
|
+
const actions = [];
|
|
855
|
+
let conflicts = 0;
|
|
856
|
+
for (const src of srcEntries) {
|
|
857
|
+
const existing = this.db.prepare("SELECT id FROM memories WHERE prefix = ? AND level_1 = ? AND seq > 0").get(src.prefix, src.level_1);
|
|
858
|
+
if (existing) {
|
|
859
|
+
actions.push({ type: "duplicate", srcEntry: src, targetId: existing.id });
|
|
860
|
+
}
|
|
861
|
+
else {
|
|
862
|
+
actions.push({ type: "new", srcEntry: src });
|
|
863
|
+
const conflict = this.db.prepare("SELECT id FROM memories WHERE id = ?").get(src.id);
|
|
864
|
+
if (conflict)
|
|
865
|
+
conflicts++;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
const needsRemap = conflicts > 0;
|
|
869
|
+
let totalNodesToInsert = 0;
|
|
870
|
+
let totalNodesToSkip = 0;
|
|
871
|
+
for (const action of actions) {
|
|
872
|
+
if (action.type === "duplicate") {
|
|
873
|
+
const srcChildren = (srcNodesByRoot.get(action.srcEntry.id) ?? [])
|
|
874
|
+
.filter((n) => n.depth === 2 && n.parent_id === action.srcEntry.id);
|
|
875
|
+
const targetChildren = this.db.prepare("SELECT content FROM memory_nodes WHERE parent_id = ? AND depth = 2").all(action.targetId);
|
|
876
|
+
const targetContents = new Set(targetChildren.map((c) => c.content));
|
|
877
|
+
for (const sc of srcChildren) {
|
|
878
|
+
const descendants = (srcNodesByRoot.get(action.srcEntry.id) ?? [])
|
|
879
|
+
.filter((n) => n.id.startsWith(sc.id + ".") || n.id === sc.id);
|
|
880
|
+
if (targetContents.has(sc.content)) {
|
|
881
|
+
totalNodesToSkip += descendants.length;
|
|
882
|
+
}
|
|
883
|
+
else {
|
|
884
|
+
totalNodesToInsert += descendants.length;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
else {
|
|
889
|
+
totalNodesToInsert += (srcNodesByRoot.get(action.srcEntry.id) ?? []).length;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
const newCount = actions.filter(a => a.type === "new").length;
|
|
893
|
+
const dupeCount = actions.filter(a => a.type === "duplicate").length;
|
|
894
|
+
if (dryRun) {
|
|
895
|
+
return {
|
|
896
|
+
inserted: newCount, merged: dupeCount,
|
|
897
|
+
nodesInserted: totalNodesToInsert, nodesSkipped: totalNodesToSkip,
|
|
898
|
+
tagsImported: srcTags.length, remapped: needsRemap, conflicts,
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
// ---- Phase 2: ID Remapping ----
|
|
902
|
+
const idMap = new Map();
|
|
903
|
+
if (needsRemap) {
|
|
904
|
+
const usedSeqs = new Map();
|
|
905
|
+
for (const action of actions) {
|
|
906
|
+
if (action.type === "new") {
|
|
907
|
+
const prefix = action.srcEntry.prefix;
|
|
908
|
+
const baseSeq = this.nextSeq(prefix);
|
|
909
|
+
const offset = usedSeqs.get(prefix) ?? 0;
|
|
910
|
+
const seq = baseSeq + offset;
|
|
911
|
+
usedSeqs.set(prefix, offset + 1);
|
|
912
|
+
idMap.set(action.srcEntry.id, `${prefix}${String(seq).padStart(4, "0")}`);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
for (const action of actions) {
|
|
917
|
+
if (action.type === "duplicate") {
|
|
918
|
+
idMap.set(action.srcEntry.id, action.targetId);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
const remapId = (id) => {
|
|
922
|
+
if (!id)
|
|
923
|
+
return id;
|
|
924
|
+
const rootId = id.split(".")[0];
|
|
925
|
+
const newRootId = idMap.get(rootId);
|
|
926
|
+
if (!newRootId)
|
|
927
|
+
return id;
|
|
928
|
+
return newRootId + id.substring(rootId.length);
|
|
929
|
+
};
|
|
930
|
+
const remapLinks = (linksJson) => {
|
|
931
|
+
if (!linksJson)
|
|
932
|
+
return linksJson;
|
|
933
|
+
try {
|
|
934
|
+
const links = JSON.parse(linksJson);
|
|
935
|
+
return JSON.stringify(links.map(remapId));
|
|
936
|
+
}
|
|
937
|
+
catch {
|
|
938
|
+
return linksJson;
|
|
939
|
+
}
|
|
940
|
+
};
|
|
941
|
+
const remapContent = (content) => {
|
|
942
|
+
if (!content)
|
|
943
|
+
return content;
|
|
944
|
+
return content.replace(/\[✓([A-Z]\d{4}(?:\.\d+)*)\]/g, (match, id) => {
|
|
945
|
+
const newId = remapId(id);
|
|
946
|
+
return newId !== id ? `[✓${newId}]` : match;
|
|
947
|
+
});
|
|
948
|
+
};
|
|
949
|
+
// ---- Phase 3: Insert/Merge ----
|
|
950
|
+
const result = {
|
|
951
|
+
inserted: 0, merged: 0, nodesInserted: 0, nodesSkipped: 0,
|
|
952
|
+
tagsImported: 0, remapped: needsRemap, conflicts,
|
|
953
|
+
};
|
|
954
|
+
const memCols = this.db.pragma("table_info(memories)").map((c) => c.name);
|
|
955
|
+
const nodeCols = this.db.pragma("table_info(memory_nodes)").map((c) => c.name);
|
|
956
|
+
const srcMemCols = (() => { try {
|
|
957
|
+
return sourceDb.pragma("table_info(memories)").map((c) => c.name);
|
|
958
|
+
}
|
|
959
|
+
catch {
|
|
960
|
+
return [];
|
|
961
|
+
} })();
|
|
962
|
+
const srcNodeCols = (() => { try {
|
|
963
|
+
return sourceDb.pragma("table_info(memory_nodes)").map((c) => c.name);
|
|
964
|
+
}
|
|
965
|
+
catch {
|
|
966
|
+
return [];
|
|
967
|
+
} })();
|
|
968
|
+
const commonMemCols = memCols.filter(c => srcMemCols.includes(c));
|
|
969
|
+
const commonNodeCols = nodeCols.filter(c => srcNodeCols.includes(c));
|
|
970
|
+
this.db.transaction(() => {
|
|
971
|
+
for (const action of actions) {
|
|
972
|
+
if (action.type !== "new")
|
|
973
|
+
continue;
|
|
974
|
+
const src = action.srcEntry;
|
|
975
|
+
const newId = idMap.get(src.id) ?? src.id;
|
|
976
|
+
const values = {};
|
|
977
|
+
for (const col of commonMemCols)
|
|
978
|
+
values[col] = src[col];
|
|
979
|
+
values.id = newId;
|
|
980
|
+
if (needsRemap) {
|
|
981
|
+
values.links = remapLinks(src.links);
|
|
982
|
+
values.level_1 = remapContent(src.level_1);
|
|
983
|
+
}
|
|
984
|
+
this.db.prepare(`INSERT INTO memories (${commonMemCols.join(", ")}) VALUES (${commonMemCols.map(() => "?").join(", ")})`).run(...commonMemCols.map(c => values[c]));
|
|
985
|
+
const entryNodes = srcNodesByRoot.get(src.id) ?? [];
|
|
986
|
+
for (const node of entryNodes) {
|
|
987
|
+
const nv = {};
|
|
988
|
+
for (const col of commonNodeCols)
|
|
989
|
+
nv[col] = node[col];
|
|
990
|
+
nv.id = remapId(node.id);
|
|
991
|
+
nv.parent_id = remapId(node.parent_id);
|
|
992
|
+
nv.root_id = newId;
|
|
993
|
+
if (needsRemap) {
|
|
994
|
+
nv.links = remapLinks(node.links);
|
|
995
|
+
nv.content = remapContent(node.content);
|
|
996
|
+
}
|
|
997
|
+
this.db.prepare(`INSERT INTO memory_nodes (${commonNodeCols.join(", ")}) VALUES (${commonNodeCols.map(() => "?").join(", ")})`).run(...commonNodeCols.map(c => nv[c]));
|
|
998
|
+
result.nodesInserted++;
|
|
999
|
+
}
|
|
1000
|
+
const entryTags = srcTagsByEntry.get(src.id) ?? [];
|
|
1001
|
+
for (const tag of entryTags) {
|
|
1002
|
+
this.db.prepare("INSERT OR IGNORE INTO memory_tags (entry_id, tag) VALUES (?, ?)").run(newId, tag);
|
|
1003
|
+
result.tagsImported++;
|
|
1004
|
+
}
|
|
1005
|
+
for (const node of entryNodes) {
|
|
1006
|
+
for (const tag of (srcTagsByEntry.get(node.id) ?? [])) {
|
|
1007
|
+
this.db.prepare("INSERT OR IGNORE INTO memory_tags (entry_id, tag) VALUES (?, ?)").run(remapId(node.id), tag);
|
|
1008
|
+
result.tagsImported++;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
result.inserted++;
|
|
1012
|
+
}
|
|
1013
|
+
for (const action of actions) {
|
|
1014
|
+
if (action.type !== "duplicate")
|
|
1015
|
+
continue;
|
|
1016
|
+
const src = action.srcEntry;
|
|
1017
|
+
const targetId = action.targetId;
|
|
1018
|
+
const targetChildren = this.db.prepare("SELECT content FROM memory_nodes WHERE parent_id = ? AND depth = 2").all(targetId);
|
|
1019
|
+
const targetContents = new Set(targetChildren.map((c) => c.content));
|
|
1020
|
+
const srcAllNodes = srcNodesByRoot.get(src.id) ?? [];
|
|
1021
|
+
const srcL2 = srcAllNodes.filter((n) => n.depth === 2 && n.parent_id === src.id);
|
|
1022
|
+
const maxSeqRow = this.db.prepare("SELECT MAX(seq) as maxSeq FROM memory_nodes WHERE parent_id = ?").get(targetId);
|
|
1023
|
+
let nextChildSeq = (maxSeqRow?.maxSeq ?? 0) + 1;
|
|
1024
|
+
for (const l2 of srcL2) {
|
|
1025
|
+
if (targetContents.has(l2.content)) {
|
|
1026
|
+
result.nodesSkipped += srcAllNodes.filter((n) => n.id === l2.id || n.id.startsWith(l2.id + ".")).length;
|
|
1027
|
+
continue;
|
|
1028
|
+
}
|
|
1029
|
+
const descendants = srcAllNodes.filter((n) => n.id === l2.id || n.id.startsWith(l2.id + "."));
|
|
1030
|
+
const l2NewId = `${targetId}.${nextChildSeq}`;
|
|
1031
|
+
nextChildSeq++;
|
|
1032
|
+
for (const desc of descendants) {
|
|
1033
|
+
const nv = {};
|
|
1034
|
+
for (const col of commonNodeCols)
|
|
1035
|
+
nv[col] = desc[col];
|
|
1036
|
+
const oldPrefix = l2.id;
|
|
1037
|
+
const newPrefix = l2NewId;
|
|
1038
|
+
nv.id = desc.id === l2.id ? l2NewId : newPrefix + desc.id.substring(oldPrefix.length);
|
|
1039
|
+
nv.parent_id = desc.parent_id === src.id ? targetId
|
|
1040
|
+
: desc.parent_id === l2.id ? l2NewId
|
|
1041
|
+
: newPrefix + desc.parent_id.substring(oldPrefix.length);
|
|
1042
|
+
nv.root_id = targetId;
|
|
1043
|
+
nv.content = remapContent(desc.content);
|
|
1044
|
+
nv.links = remapLinks(desc.links);
|
|
1045
|
+
if (desc.id === l2.id)
|
|
1046
|
+
nv.seq = nextChildSeq - 1;
|
|
1047
|
+
if (!nv.title)
|
|
1048
|
+
nv.title = (nv.content || "").substring(0, this.cfg.maxTitleChars || 50);
|
|
1049
|
+
this.db.prepare(`INSERT INTO memory_nodes (${commonNodeCols.join(", ")}) VALUES (${commonNodeCols.map(() => "?").join(", ")})`).run(...commonNodeCols.map(c => nv[c]));
|
|
1050
|
+
result.nodesInserted++;
|
|
1051
|
+
for (const tag of (srcTagsByEntry.get(desc.id) ?? [])) {
|
|
1052
|
+
this.db.prepare("INSERT OR IGNORE INTO memory_tags (entry_id, tag) VALUES (?, ?)").run(nv.id, tag);
|
|
1053
|
+
result.tagsImported++;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
for (const tag of (srcTagsByEntry.get(src.id) ?? [])) {
|
|
1058
|
+
this.db.prepare("INSERT OR IGNORE INTO memory_tags (entry_id, tag) VALUES (?, ?)").run(targetId, tag);
|
|
1059
|
+
result.tagsImported++;
|
|
1060
|
+
}
|
|
1061
|
+
result.merged++;
|
|
1062
|
+
}
|
|
1063
|
+
})();
|
|
1064
|
+
return result;
|
|
1065
|
+
}
|
|
630
1066
|
/**
|
|
631
1067
|
* Get statistics about the memory store.
|
|
632
1068
|
*/
|
|
@@ -636,7 +1072,10 @@ export class HmemStore {
|
|
|
636
1072
|
const byPrefix = {};
|
|
637
1073
|
for (const r of rows)
|
|
638
1074
|
byPrefix[r.prefix] = r.c;
|
|
639
|
-
|
|
1075
|
+
// Total characters across all entries + nodes (for token estimation)
|
|
1076
|
+
const memChars = this.db.prepare("SELECT COALESCE(SUM(LENGTH(level_1)),0) as c FROM memories WHERE seq > 0").get().c;
|
|
1077
|
+
const nodeChars = this.db.prepare("SELECT COALESCE(SUM(LENGTH(content)),0) as c FROM memory_nodes").get().c;
|
|
1078
|
+
return { total, byPrefix, totalChars: memChars + nodeChars };
|
|
640
1079
|
}
|
|
641
1080
|
/**
|
|
642
1081
|
* Update specific fields of an existing root entry (curator use only).
|
|
@@ -669,6 +1108,8 @@ export class HmemStore {
|
|
|
669
1108
|
*/
|
|
670
1109
|
delete(id) {
|
|
671
1110
|
this.guardCorrupted();
|
|
1111
|
+
// Delete tags for root + all child nodes
|
|
1112
|
+
this.db.prepare("DELETE FROM memory_tags WHERE entry_id = ? OR entry_id LIKE ?").run(id, `${id}.%`);
|
|
672
1113
|
// Delete nodes first (no CASCADE in older SQLite)
|
|
673
1114
|
this.db.prepare("DELETE FROM memory_nodes WHERE root_id = ?").run(id);
|
|
674
1115
|
const result = this.db.prepare("DELETE FROM memories WHERE id = ?").run(id);
|
|
@@ -680,7 +1121,7 @@ export class HmemStore {
|
|
|
680
1121
|
* For sub-nodes: updates node content only.
|
|
681
1122
|
* Does NOT modify children — use appendChildren to extend the tree.
|
|
682
1123
|
*/
|
|
683
|
-
updateNode(id, newContent, links, obsolete, favorite, curatorBypass, irrelevant) {
|
|
1124
|
+
updateNode(id, newContent, links, obsolete, favorite, curatorBypass, irrelevant, tags, pinned) {
|
|
684
1125
|
this.guardCorrupted();
|
|
685
1126
|
const trimmed = newContent.trim();
|
|
686
1127
|
if (id.includes(".")) {
|
|
@@ -689,7 +1130,7 @@ export class HmemStore {
|
|
|
689
1130
|
if (!nodeRow)
|
|
690
1131
|
return false;
|
|
691
1132
|
const nodeLimit = this.cfg.maxCharsPerLevel[Math.min(nodeRow.depth - 1, this.cfg.maxCharsPerLevel.length - 1)];
|
|
692
|
-
if (trimmed.length > nodeLimit) {
|
|
1133
|
+
if (trimmed.length > nodeLimit * HmemStore.CHAR_LIMIT_TOLERANCE) {
|
|
693
1134
|
throw new Error(`Content exceeds ${nodeLimit} character limit (${trimmed.length} chars) for L${nodeRow.depth}.`);
|
|
694
1135
|
}
|
|
695
1136
|
const sets = ["content = ?", "title = ?"];
|
|
@@ -698,14 +1139,21 @@ export class HmemStore {
|
|
|
698
1139
|
sets.push("favorite = ?");
|
|
699
1140
|
params.push(favorite ? 1 : 0);
|
|
700
1141
|
}
|
|
1142
|
+
if (irrelevant !== undefined) {
|
|
1143
|
+
sets.push("irrelevant = ?");
|
|
1144
|
+
params.push(irrelevant ? 1 : 0);
|
|
1145
|
+
}
|
|
701
1146
|
params.push(id);
|
|
702
1147
|
const result = this.db.prepare(`UPDATE memory_nodes SET ${sets.join(", ")} WHERE id = ?`).run(...params);
|
|
1148
|
+
if (result.changes > 0 && tags !== undefined) {
|
|
1149
|
+
this.setTags(id, tags.length > 0 ? this.validateTags(tags) : []);
|
|
1150
|
+
}
|
|
703
1151
|
return result.changes > 0;
|
|
704
1152
|
}
|
|
705
1153
|
else {
|
|
706
1154
|
// Root entry in memories — check L1 char limit
|
|
707
1155
|
const l1Limit = this.cfg.maxCharsPerLevel[0];
|
|
708
|
-
if (trimmed.length > l1Limit) {
|
|
1156
|
+
if (trimmed.length > l1Limit * HmemStore.CHAR_LIMIT_TOLERANCE) {
|
|
709
1157
|
throw new Error(`Level 1 exceeds ${l1Limit} character limit (${trimmed.length} chars). Keep L1 compact.`);
|
|
710
1158
|
}
|
|
711
1159
|
// Obsolete enforcement: require [✓ID] correction reference
|
|
@@ -759,8 +1207,15 @@ export class HmemStore {
|
|
|
759
1207
|
sets.push("irrelevant = ?");
|
|
760
1208
|
params.push(irrelevant ? 1 : 0);
|
|
761
1209
|
}
|
|
1210
|
+
if (pinned !== undefined) {
|
|
1211
|
+
sets.push("pinned = ?");
|
|
1212
|
+
params.push(pinned ? 1 : 0);
|
|
1213
|
+
}
|
|
762
1214
|
params.push(id);
|
|
763
1215
|
const result = this.db.prepare(`UPDATE memories SET ${sets.join(", ")} WHERE id = ?`).run(...params);
|
|
1216
|
+
if (result.changes > 0 && tags !== undefined) {
|
|
1217
|
+
this.setTags(id, tags.length > 0 ? this.validateTags(tags) : []);
|
|
1218
|
+
}
|
|
764
1219
|
return result.changes > 0;
|
|
765
1220
|
}
|
|
766
1221
|
}
|
|
@@ -794,10 +1249,11 @@ export class HmemStore {
|
|
|
794
1249
|
const nodes = this.parseRelativeTree(content, parentId, parentDepth, startSeq);
|
|
795
1250
|
if (nodes.length === 0)
|
|
796
1251
|
return { count: 0, ids: [] };
|
|
797
|
-
// Validate char limits before writing
|
|
1252
|
+
// Validate char limits before writing (with tolerance buffer)
|
|
1253
|
+
const t = HmemStore.CHAR_LIMIT_TOLERANCE;
|
|
798
1254
|
for (const node of nodes) {
|
|
799
1255
|
const nodeLimit = this.cfg.maxCharsPerLevel[Math.min(node.depth - 1, this.cfg.maxCharsPerLevel.length - 1)];
|
|
800
|
-
if (node.content.length > nodeLimit) {
|
|
1256
|
+
if (node.content.length > nodeLimit * t) {
|
|
801
1257
|
throw new Error(`L${node.depth} content exceeds ${nodeLimit} character limit ` +
|
|
802
1258
|
`(${node.content.length} chars). Split into multiple calls or use file references.`);
|
|
803
1259
|
}
|
|
@@ -866,6 +1322,152 @@ export class HmemStore {
|
|
|
866
1322
|
this.db.close();
|
|
867
1323
|
}
|
|
868
1324
|
// ---- Private helpers ----
|
|
1325
|
+
// ---- Tag helpers ----
|
|
1326
|
+
static TAG_REGEX = /^#[a-z0-9_-]{1,49}$/;
|
|
1327
|
+
static MAX_TAGS_PER_ENTRY = 10;
|
|
1328
|
+
/** Validate and normalize tags: lowercase, must match #word pattern. */
|
|
1329
|
+
validateTags(tags) {
|
|
1330
|
+
if (tags.length > HmemStore.MAX_TAGS_PER_ENTRY) {
|
|
1331
|
+
throw new Error(`Too many tags (${tags.length}). Maximum is ${HmemStore.MAX_TAGS_PER_ENTRY}.`);
|
|
1332
|
+
}
|
|
1333
|
+
const normalized = tags.map(t => t.toLowerCase());
|
|
1334
|
+
for (const tag of normalized) {
|
|
1335
|
+
if (!HmemStore.TAG_REGEX.test(tag)) {
|
|
1336
|
+
throw new Error(`Invalid tag "${tag}". Tags must match #word (lowercase, a-z 0-9 _ -).`);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
return [...new Set(normalized)]; // deduplicate
|
|
1340
|
+
}
|
|
1341
|
+
/** Replace all tags on an entry/node. Pass empty array to clear. */
|
|
1342
|
+
setTags(entryId, tags) {
|
|
1343
|
+
this.db.prepare("DELETE FROM memory_tags WHERE entry_id = ?").run(entryId);
|
|
1344
|
+
if (tags.length === 0)
|
|
1345
|
+
return;
|
|
1346
|
+
const insert = this.db.prepare("INSERT OR IGNORE INTO memory_tags (entry_id, tag) VALUES (?, ?)");
|
|
1347
|
+
for (const tag of tags) {
|
|
1348
|
+
insert.run(entryId, tag);
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
/** Get tags for a single entry/node. */
|
|
1352
|
+
fetchTags(entryId) {
|
|
1353
|
+
return this.db.prepare("SELECT tag FROM memory_tags WHERE entry_id = ? ORDER BY tag").all(entryId)
|
|
1354
|
+
.map(r => r.tag);
|
|
1355
|
+
}
|
|
1356
|
+
/** Bulk-fetch tags for multiple IDs at once. */
|
|
1357
|
+
fetchTagsBulk(ids) {
|
|
1358
|
+
if (ids.length === 0)
|
|
1359
|
+
return new Map();
|
|
1360
|
+
const map = new Map();
|
|
1361
|
+
// Process in chunks of 500 to avoid SQLite variable limits
|
|
1362
|
+
for (let i = 0; i < ids.length; i += 500) {
|
|
1363
|
+
const chunk = ids.slice(i, i + 500);
|
|
1364
|
+
const placeholders = chunk.map(() => "?").join(", ");
|
|
1365
|
+
const rows = this.db.prepare(`SELECT entry_id, tag FROM memory_tags WHERE entry_id IN (${placeholders}) ORDER BY entry_id, tag`).all(...chunk);
|
|
1366
|
+
for (const row of rows) {
|
|
1367
|
+
const arr = map.get(row.entry_id);
|
|
1368
|
+
if (arr)
|
|
1369
|
+
arr.push(row.tag);
|
|
1370
|
+
else
|
|
1371
|
+
map.set(row.entry_id, [row.tag]);
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
return map;
|
|
1375
|
+
}
|
|
1376
|
+
/**
|
|
1377
|
+
* Find entries sharing 2+ tags with the given entry.
|
|
1378
|
+
* Returns title-only results sorted by number of shared tags (descending).
|
|
1379
|
+
*/
|
|
1380
|
+
findRelated(entryId, tags, limit = 5) {
|
|
1381
|
+
if (tags.length < 2)
|
|
1382
|
+
return [];
|
|
1383
|
+
const placeholders = tags.map(() => "?").join(", ");
|
|
1384
|
+
// Find all entry_ids sharing at least 2 tags (exclude self)
|
|
1385
|
+
const rows = this.db.prepare(`
|
|
1386
|
+
SELECT entry_id, COUNT(*) as shared
|
|
1387
|
+
FROM memory_tags
|
|
1388
|
+
WHERE tag IN (${placeholders}) AND entry_id != ?
|
|
1389
|
+
GROUP BY entry_id
|
|
1390
|
+
HAVING COUNT(*) >= 2
|
|
1391
|
+
ORDER BY shared DESC
|
|
1392
|
+
LIMIT ?
|
|
1393
|
+
`).all(...tags, entryId, limit * 3); // fetch extra to account for node→root dedup
|
|
1394
|
+
if (rows.length === 0)
|
|
1395
|
+
return [];
|
|
1396
|
+
// Resolve node IDs to root entries, dedup
|
|
1397
|
+
const seen = new Set();
|
|
1398
|
+
const results = [];
|
|
1399
|
+
for (const row of rows) {
|
|
1400
|
+
if (results.length >= limit)
|
|
1401
|
+
break;
|
|
1402
|
+
const eid = row.entry_id;
|
|
1403
|
+
const isNode = eid.includes(".");
|
|
1404
|
+
const rootId = isNode ? eid.split(".")[0] : eid;
|
|
1405
|
+
if (seen.has(rootId) || rootId === entryId || rootId === entryId.split(".")[0])
|
|
1406
|
+
continue;
|
|
1407
|
+
seen.add(rootId);
|
|
1408
|
+
// Fetch root entry title
|
|
1409
|
+
const rootRow = this.db.prepare("SELECT title, level_1, created_at, irrelevant, obsolete FROM memories WHERE id = ?").get(rootId);
|
|
1410
|
+
if (!rootRow || rootRow.irrelevant === 1 || rootRow.obsolete === 1)
|
|
1411
|
+
continue;
|
|
1412
|
+
const title = rootRow.title || this.autoExtractTitle(rootRow.level_1);
|
|
1413
|
+
const entryTags = this.fetchTags(rootId);
|
|
1414
|
+
results.push({ id: rootId, title, created_at: rootRow.created_at, tags: entryTags });
|
|
1415
|
+
}
|
|
1416
|
+
return results;
|
|
1417
|
+
}
|
|
1418
|
+
/** Bulk-assign tags to entries + their children from a single fetchTagsBulk call. */
|
|
1419
|
+
assignBulkTags(entries) {
|
|
1420
|
+
const allIds = [];
|
|
1421
|
+
for (const e of entries) {
|
|
1422
|
+
allIds.push(e.id);
|
|
1423
|
+
if (e.children)
|
|
1424
|
+
allIds.push(...this.collectNodeIds(e.children));
|
|
1425
|
+
}
|
|
1426
|
+
if (allIds.length === 0)
|
|
1427
|
+
return;
|
|
1428
|
+
const tagMap = this.fetchTagsBulk(allIds);
|
|
1429
|
+
for (const e of entries) {
|
|
1430
|
+
if (tagMap.has(e.id))
|
|
1431
|
+
e.tags = tagMap.get(e.id);
|
|
1432
|
+
if (e.children) {
|
|
1433
|
+
for (const child of e.children) {
|
|
1434
|
+
if (tagMap.has(child.id))
|
|
1435
|
+
child.tags = tagMap.get(child.id);
|
|
1436
|
+
if (child.children) {
|
|
1437
|
+
for (const gc of child.children) {
|
|
1438
|
+
if (tagMap.has(gc.id))
|
|
1439
|
+
gc.tags = tagMap.get(gc.id);
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
/** Recursively collect all node IDs from a tree of MemoryNodes. */
|
|
1447
|
+
collectNodeIds(nodes) {
|
|
1448
|
+
const ids = [];
|
|
1449
|
+
for (const node of nodes) {
|
|
1450
|
+
ids.push(node.id);
|
|
1451
|
+
if (node.children)
|
|
1452
|
+
ids.push(...this.collectNodeIds(node.children));
|
|
1453
|
+
}
|
|
1454
|
+
return ids;
|
|
1455
|
+
}
|
|
1456
|
+
/** Get root IDs that have a specific tag (for bulk-read filtering). */
|
|
1457
|
+
getRootIdsByTag(tag) {
|
|
1458
|
+
const rows = this.db.prepare("SELECT entry_id FROM memory_tags WHERE tag = ?").all(tag);
|
|
1459
|
+
const rootIds = new Set();
|
|
1460
|
+
for (const row of rows) {
|
|
1461
|
+
const eid = row.entry_id;
|
|
1462
|
+
if (eid.includes(".")) {
|
|
1463
|
+
rootIds.add(eid.split(".")[0]);
|
|
1464
|
+
}
|
|
1465
|
+
else {
|
|
1466
|
+
rootIds.add(eid);
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
return rootIds;
|
|
1470
|
+
}
|
|
869
1471
|
migrate() {
|
|
870
1472
|
for (const sql of MIGRATIONS) {
|
|
871
1473
|
try {
|
|
@@ -1094,7 +1696,7 @@ export class HmemStore {
|
|
|
1094
1696
|
if (parentIds.length === 0)
|
|
1095
1697
|
return new Map();
|
|
1096
1698
|
const placeholders = parentIds.map(() => "?").join(", ");
|
|
1097
|
-
const rows = this.db.prepare(`SELECT parent_id, COUNT(*) as cnt FROM memory_nodes WHERE parent_id IN (${placeholders}) GROUP BY parent_id`).all(...parentIds);
|
|
1699
|
+
const rows = this.db.prepare(`SELECT parent_id, COUNT(*) as cnt FROM memory_nodes WHERE parent_id IN (${placeholders}) AND COALESCE(irrelevant, 0) != 1 GROUP BY parent_id`).all(...parentIds);
|
|
1098
1700
|
const map = new Map();
|
|
1099
1701
|
for (const r of rows)
|
|
1100
1702
|
map.set(r.parent_id, r.cnt);
|
|
@@ -1159,6 +1761,7 @@ export class HmemStore {
|
|
|
1159
1761
|
access_count: row.access_count || 0,
|
|
1160
1762
|
last_accessed: row.last_accessed || null,
|
|
1161
1763
|
favorite: row.favorite === 1 ? true : undefined,
|
|
1764
|
+
irrelevant: row.irrelevant === 1 ? true : undefined,
|
|
1162
1765
|
child_count: childCount,
|
|
1163
1766
|
};
|
|
1164
1767
|
}
|
|
@@ -1181,6 +1784,7 @@ export class HmemStore {
|
|
|
1181
1784
|
obsolete: row.obsolete === 1,
|
|
1182
1785
|
favorite: row.favorite === 1,
|
|
1183
1786
|
irrelevant: row.irrelevant === 1,
|
|
1787
|
+
pinned: row.pinned === 1,
|
|
1184
1788
|
children,
|
|
1185
1789
|
};
|
|
1186
1790
|
}
|
|
@@ -1213,7 +1817,7 @@ export class HmemStore {
|
|
|
1213
1817
|
* Priority: text before " — " > word-boundary truncation > hard truncation.
|
|
1214
1818
|
*/
|
|
1215
1819
|
autoExtractTitle(text) {
|
|
1216
|
-
const maxLen = this.cfg.maxTitleChars;
|
|
1820
|
+
const maxLen = Math.floor(this.cfg.maxTitleChars * HmemStore.CHAR_LIMIT_TOLERANCE);
|
|
1217
1821
|
const dashIdx = text.indexOf(" — ");
|
|
1218
1822
|
if (dashIdx > 0 && dashIdx <= maxLen)
|
|
1219
1823
|
return text.substring(0, dashIdx);
|