hmem-mcp 2.5.4 → 2.7.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/dist/hmem-store.d.ts +27 -3
- package/dist/hmem-store.js +257 -9
- package/dist/hmem-store.js.map +1 -1
- package/dist/mcp-server.js +124 -28
- package/dist/mcp-server.js.map +1 -1
- package/package.json +1 -1
package/dist/hmem-store.d.ts
CHANGED
|
@@ -48,6 +48,8 @@ export interface MemoryEntry {
|
|
|
48
48
|
favorite?: boolean;
|
|
49
49
|
/** True if the agent marked this entry as irrelevant. Hidden from bulk reads, no correction needed. */
|
|
50
50
|
irrelevant?: boolean;
|
|
51
|
+
/** True if this entry is actively relevant (root-only). When any entry in a prefix has active=1, only active entries of that prefix are expanded in bulk reads. */
|
|
52
|
+
active?: boolean;
|
|
51
53
|
/** True if this entry was already delivered in a previous bulk read (session cache). */
|
|
52
54
|
suppressed?: boolean;
|
|
53
55
|
/**
|
|
@@ -153,6 +155,10 @@ export interface ReadOptions {
|
|
|
153
155
|
tag?: string;
|
|
154
156
|
/** Show entries not accessed in the last N days (stale detection). Sorted oldest-access first. */
|
|
155
157
|
staleDays?: number;
|
|
158
|
+
/** Find all entries related to a given entry via per-node tag scoring + direct links. */
|
|
159
|
+
contextFor?: string;
|
|
160
|
+
/** Minimum weighted tag score for context_for matches. Default: 4. Tier weights: rare(<=5)=3, medium(6-20)=2, common(>20)=1. */
|
|
161
|
+
minTagScore?: number;
|
|
156
162
|
}
|
|
157
163
|
export interface WriteResult {
|
|
158
164
|
id: string;
|
|
@@ -245,7 +251,7 @@ export declare class HmemStore {
|
|
|
245
251
|
/**
|
|
246
252
|
* Update specific fields of an existing root entry (curator use only).
|
|
247
253
|
*/
|
|
248
|
-
update(id: string, fields: Partial<Pick<MemoryEntry, "level_1" | "level_2" | "level_3" | "level_4" | "level_5" | "links" | "min_role" | "obsolete" | "favorite" | "irrelevant">>): boolean;
|
|
254
|
+
update(id: string, fields: Partial<Pick<MemoryEntry, "level_1" | "level_2" | "level_3" | "level_4" | "level_5" | "links" | "min_role" | "obsolete" | "favorite" | "irrelevant" | "active">>): boolean;
|
|
249
255
|
/**
|
|
250
256
|
* Delete an entry by ID (curator use only).
|
|
251
257
|
* Also deletes all associated memory_nodes.
|
|
@@ -257,7 +263,7 @@ export declare class HmemStore {
|
|
|
257
263
|
* For sub-nodes: updates node content only.
|
|
258
264
|
* Does NOT modify children — use appendChildren to extend the tree.
|
|
259
265
|
*/
|
|
260
|
-
updateNode(id: string, newContent: string, links?: string[], obsolete?: boolean, favorite?: boolean, curatorBypass?: boolean, irrelevant?: boolean, tags?: string[], pinned?: boolean): boolean;
|
|
266
|
+
updateNode(id: string, newContent: string, links?: string[], obsolete?: boolean, favorite?: boolean, curatorBypass?: boolean, irrelevant?: boolean, tags?: string[], pinned?: boolean, active?: boolean): boolean;
|
|
261
267
|
/**
|
|
262
268
|
* Append new child nodes under an existing entry (root or node).
|
|
263
269
|
* Content is tab-indented relative to the parent:
|
|
@@ -301,7 +307,7 @@ export declare class HmemStore {
|
|
|
301
307
|
tags: string[];
|
|
302
308
|
}[];
|
|
303
309
|
/** Bulk-assign tags to entries + their children from a single fetchTagsBulk call. */
|
|
304
|
-
|
|
310
|
+
assignBulkTags(entries: MemoryEntry[]): void;
|
|
305
311
|
/** Recursively collect all node IDs from a tree of MemoryNodes. */
|
|
306
312
|
private collectNodeIds;
|
|
307
313
|
/** Get root IDs that have a specific tag (for bulk-read filtering). */
|
|
@@ -462,6 +468,24 @@ export declare class HmemStore {
|
|
|
462
468
|
created_at: string;
|
|
463
469
|
tags: string[];
|
|
464
470
|
}[];
|
|
471
|
+
/**
|
|
472
|
+
* Find all entries contextually related to a given entry.
|
|
473
|
+
* Uses per-node weighted tag scoring: for each node of the source entry,
|
|
474
|
+
* compute weighted overlap with each candidate entry's full tag set.
|
|
475
|
+
* Tier weights: rare (<=5 entries) = 3, medium (6-20) = 2, common (>20) = 1.
|
|
476
|
+
* A candidate matches if ANY source node scores >= minTagScore against it.
|
|
477
|
+
* Bidirectional direct links are always included.
|
|
478
|
+
*/
|
|
479
|
+
findContext(entryId: string, minTagScore?: number, limit?: number): {
|
|
480
|
+
linked: MemoryEntry[];
|
|
481
|
+
tagRelated: {
|
|
482
|
+
entry: MemoryEntry;
|
|
483
|
+
score: number;
|
|
484
|
+
matchNode: string;
|
|
485
|
+
}[];
|
|
486
|
+
};
|
|
487
|
+
/** Resolve bidirectional direct links for an entry, filtering obsolete/irrelevant. */
|
|
488
|
+
private resolveDirectLinks;
|
|
465
489
|
/** Audit report: broken links, orphaned entries, stale favorites, broken obsolete chains, tag orphans. */
|
|
466
490
|
healthCheck(): {
|
|
467
491
|
brokenLinks: {
|
package/dist/hmem-store.js
CHANGED
|
@@ -157,6 +157,8 @@ const MIGRATIONS = [
|
|
|
157
157
|
// Sync support: track last content modification (separate from last_accessed)
|
|
158
158
|
"ALTER TABLE memories ADD COLUMN updated_at TEXT",
|
|
159
159
|
"ALTER TABLE memory_nodes ADD COLUMN updated_at TEXT",
|
|
160
|
+
// Active flag: marks entries as currently relevant — non-active entries in same prefix shown title-only
|
|
161
|
+
"ALTER TABLE memories ADD COLUMN active INTEGER DEFAULT 0",
|
|
160
162
|
];
|
|
161
163
|
// ---- HmemStore class ----
|
|
162
164
|
export class HmemStore {
|
|
@@ -258,8 +260,11 @@ export class HmemStore {
|
|
|
258
260
|
INSERT INTO memory_nodes (id, parent_id, root_id, depth, seq, title, content, created_at, updated_at)
|
|
259
261
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
260
262
|
`);
|
|
261
|
-
//
|
|
262
|
-
|
|
263
|
+
// Tags are mandatory — at least 1 required for discoverability
|
|
264
|
+
if (!tags || tags.length === 0) {
|
|
265
|
+
throw new Error("Tags are required. Provide at least 1 tag (3+ recommended) for discoverability. Example: tags=['#hmem', '#sqlite', '#bug']");
|
|
266
|
+
}
|
|
267
|
+
const validatedTags = this.validateTags(tags);
|
|
263
268
|
// Run in a transaction
|
|
264
269
|
this.db.transaction(() => {
|
|
265
270
|
insertRoot.run(rootId, prefix, seq, timestamp, timestamp, title, level1, links ? JSON.stringify(links) : null, minRole, favorite ? 1 : 0, pinned ? 1 : 0);
|
|
@@ -558,6 +563,75 @@ export class HmemStore {
|
|
|
558
563
|
// Step 0: Filter out irrelevant entries (never shown in bulk reads)
|
|
559
564
|
const irrelevantCount = rows.filter(r => r.irrelevant === 1).length;
|
|
560
565
|
const activeRows = rows.filter(r => r.irrelevant !== 1);
|
|
566
|
+
// Step 0.5: Detect active-prefixes — prefixes where at least one entry has active=1.
|
|
567
|
+
// Non-active entries in these prefixes are still shown (as compact titles) but don't get expansion slots.
|
|
568
|
+
const activePrefixes = new Set();
|
|
569
|
+
for (const r of activeRows) {
|
|
570
|
+
if (r.active === 1)
|
|
571
|
+
activePrefixes.add(r.prefix);
|
|
572
|
+
}
|
|
573
|
+
// Step 0.6: Cascading related-suppression — entries in OTHER prefixes that are thematically
|
|
574
|
+
// related ONLY to suppressed (non-active) entries get demoted to title-only too.
|
|
575
|
+
// Logic: collect tags from non-active entries, subtract tags from active entries,
|
|
576
|
+
// then find entries in other prefixes that share ONLY the remaining "suppressed" tags.
|
|
577
|
+
const relatedSuppressed = new Set();
|
|
578
|
+
if (activePrefixes.size > 0) {
|
|
579
|
+
const activeEntryIds = [];
|
|
580
|
+
const nonActiveEntryIds = [];
|
|
581
|
+
for (const r of activeRows) {
|
|
582
|
+
if (activePrefixes.has(r.prefix)) {
|
|
583
|
+
if (r.active === 1)
|
|
584
|
+
activeEntryIds.push(r.id);
|
|
585
|
+
else
|
|
586
|
+
nonActiveEntryIds.push(r.id);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
if (nonActiveEntryIds.length > 0 && activeEntryIds.length > 0) {
|
|
590
|
+
// Fetch tags for active and non-active entries (root-level tags + child tags)
|
|
591
|
+
const activeTagRows = activeEntryIds.length > 0
|
|
592
|
+
? this.db.prepare(`SELECT DISTINCT tag FROM memory_tags WHERE ${activeEntryIds.map(() => "entry_id = ? OR entry_id LIKE ?").join(" OR ")}`).all(...activeEntryIds.flatMap(id => [id, `${id}.%`]))
|
|
593
|
+
: [];
|
|
594
|
+
const activeTags = new Set(activeTagRows.map(r => r.tag));
|
|
595
|
+
const nonActiveTagRows = this.db.prepare(`SELECT DISTINCT tag FROM memory_tags WHERE ${nonActiveEntryIds.map(() => "entry_id = ? OR entry_id LIKE ?").join(" OR ")}`).all(...nonActiveEntryIds.flatMap(id => [id, `${id}.%`]));
|
|
596
|
+
// Suppressed tags = tags that appear in non-active entries but NOT in active entries
|
|
597
|
+
const suppressedTags = nonActiveTagRows.map(r => r.tag).filter(t => !activeTags.has(t));
|
|
598
|
+
if (suppressedTags.length > 0) {
|
|
599
|
+
// Find root entries in OTHER prefixes that have ONLY suppressed tags (no active tags)
|
|
600
|
+
const prefixList = [...activePrefixes];
|
|
601
|
+
const otherEntries = activeRows.filter(r => !activePrefixes.has(r.prefix) && r.obsolete !== 1);
|
|
602
|
+
if (otherEntries.length > 0) {
|
|
603
|
+
const otherIds = otherEntries.map(r => r.id);
|
|
604
|
+
const otherTagMap = this.fetchTagsBulk(otherIds);
|
|
605
|
+
// Also fetch child tags for better coverage
|
|
606
|
+
const otherChildIds = otherIds.flatMap(id => {
|
|
607
|
+
const children = this.db.prepare("SELECT id FROM memory_nodes WHERE root_id = ?").all(id);
|
|
608
|
+
return children.map(c => c.id);
|
|
609
|
+
});
|
|
610
|
+
const childTagMap = otherChildIds.length > 0 ? this.fetchTagsBulk(otherChildIds) : new Map();
|
|
611
|
+
// Merge child tags into parent
|
|
612
|
+
for (const e of otherEntries) {
|
|
613
|
+
const rootTags = otherTagMap.get(e.id) ?? [];
|
|
614
|
+
const childNodes = this.db.prepare("SELECT id FROM memory_nodes WHERE root_id = ?").all(e.id);
|
|
615
|
+
const allTags = new Set(rootTags);
|
|
616
|
+
for (const cn of childNodes) {
|
|
617
|
+
const ct = childTagMap.get(cn.id);
|
|
618
|
+
if (ct)
|
|
619
|
+
ct.forEach(t => allTags.add(t));
|
|
620
|
+
}
|
|
621
|
+
if (allTags.size === 0)
|
|
622
|
+
continue;
|
|
623
|
+
// Check: does this entry have ANY active tags?
|
|
624
|
+
const hasActiveTags = [...allTags].some(t => activeTags.has(t));
|
|
625
|
+
const hasSuppressedTags = [...allTags].some(t => suppressedTags.includes(t));
|
|
626
|
+
// Only suppress if entry shares suppressed tags but NO active tags
|
|
627
|
+
if (hasSuppressedTags && !hasActiveTags) {
|
|
628
|
+
relatedSuppressed.add(e.id);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
561
635
|
// Step 1: Separate obsolete from non-obsolete FIRST
|
|
562
636
|
const obsoleteRows = activeRows.filter(r => r.obsolete === 1);
|
|
563
637
|
const nonObsoleteRows = activeRows.filter(r => r.obsolete !== 1);
|
|
@@ -614,10 +688,15 @@ export class HmemStore {
|
|
|
614
688
|
const expandedIds = new Set();
|
|
615
689
|
const isEssentials = opts.mode === "essentials";
|
|
616
690
|
// Per prefix: top N newest + top M most-accessed — slot counts scale with prefix size
|
|
617
|
-
for (const [, prefixRows] of byPrefix) {
|
|
618
|
-
|
|
691
|
+
for (const [prefix, prefixRows] of byPrefix) {
|
|
692
|
+
// In active-prefixes, only active entries compete for expansion slots.
|
|
693
|
+
// Related-suppressed entries in OTHER prefixes also don't compete.
|
|
694
|
+
const candidateRows = activePrefixes.has(prefix)
|
|
695
|
+
? prefixRows.filter(r => r.active === 1)
|
|
696
|
+
: prefixRows.filter(r => !relatedSuppressed.has(r.id));
|
|
697
|
+
const { newestCount, accessCount } = this.calcV2Slots(candidateRows.length, isEssentials, fraction);
|
|
619
698
|
// Newest: skip cached AND hidden entries, fill from fresh entries only
|
|
620
|
-
const uncachedRows =
|
|
699
|
+
const uncachedRows = candidateRows.filter(r => !cached.has(r.id) && !hidden.has(r.id));
|
|
621
700
|
for (const r of uncachedRows.slice(0, newestCount)) {
|
|
622
701
|
expandedIds.add(r.id);
|
|
623
702
|
}
|
|
@@ -630,9 +709,18 @@ export class HmemStore {
|
|
|
630
709
|
for (const r of mostAccessed)
|
|
631
710
|
expandedIds.add(r.id);
|
|
632
711
|
}
|
|
633
|
-
// Global:
|
|
712
|
+
// Global: uncached+unhidden favorites/pinned + all active entries
|
|
634
713
|
for (const r of nonObsoleteRows) {
|
|
635
714
|
if ((r.favorite === 1 || r.pinned === 1) && !cached.has(r.id) && !hidden.has(r.id)) {
|
|
715
|
+
// In active-prefixes, only active entries get expansion even if favorite/pinned
|
|
716
|
+
if (!activePrefixes.has(r.prefix) || r.active === 1) {
|
|
717
|
+
// Related-suppressed entries don't get expansion even if favorite/pinned
|
|
718
|
+
if (!relatedSuppressed.has(r.id)) {
|
|
719
|
+
expandedIds.add(r.id);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
if (r.active === 1) {
|
|
636
724
|
expandedIds.add(r.id);
|
|
637
725
|
}
|
|
638
726
|
}
|
|
@@ -643,6 +731,13 @@ export class HmemStore {
|
|
|
643
731
|
const nodeCounts = this.db.prepare("SELECT root_id, COUNT(*) as cnt FROM memory_nodes GROUP BY root_id ORDER BY cnt DESC LIMIT ?").all(topSubnodeCount);
|
|
644
732
|
for (const row of nodeCounts) {
|
|
645
733
|
if (!hidden.has(row.root_id)) {
|
|
734
|
+
// In active-prefixes, don't expand non-active entries even if they have many sub-nodes
|
|
735
|
+
const entryRow = nonObsoleteRows.find(r => r.id === row.root_id);
|
|
736
|
+
if (entryRow && activePrefixes.has(entryRow.prefix) && entryRow.active !== 1)
|
|
737
|
+
continue;
|
|
738
|
+
// Related-suppressed entries don't get topSubnode expansion either
|
|
739
|
+
if (relatedSuppressed.has(row.root_id))
|
|
740
|
+
continue;
|
|
646
741
|
expandedIds.add(row.root_id);
|
|
647
742
|
topSubnodeIds.add(row.root_id);
|
|
648
743
|
}
|
|
@@ -659,9 +754,17 @@ export class HmemStore {
|
|
|
659
754
|
// Step 4: Build visible rows (hidden entries completely excluded)
|
|
660
755
|
// - Expanded entries: full content with children
|
|
661
756
|
// - Cached entries: title-only (no expansion, no children)
|
|
757
|
+
// - Non-active in active-prefixes: title-only
|
|
758
|
+
// - Related-suppressed in other prefixes: title-only
|
|
662
759
|
const expandedNonObsolete = nonObsoleteRows.filter(r => expandedIds.has(r.id));
|
|
663
760
|
const cachedVisible = nonObsoleteRows.filter(r => cached.has(r.id) && !expandedIds.has(r.id) && !hidden.has(r.id));
|
|
664
|
-
const
|
|
761
|
+
const nonActiveVisible = activePrefixes.size > 0
|
|
762
|
+
? nonObsoleteRows.filter(r => activePrefixes.has(r.prefix) && r.active !== 1 && !expandedIds.has(r.id) && !cached.has(r.id) && !hidden.has(r.id))
|
|
763
|
+
: [];
|
|
764
|
+
const relatedSuppressedVisible = relatedSuppressed.size > 0
|
|
765
|
+
? nonObsoleteRows.filter(r => relatedSuppressed.has(r.id) && !expandedIds.has(r.id) && !cached.has(r.id) && !hidden.has(r.id))
|
|
766
|
+
: [];
|
|
767
|
+
const visibleRows = [...expandedNonObsolete, ...cachedVisible, ...nonActiveVisible, ...relatedSuppressedVisible, ...visibleObsolete];
|
|
665
768
|
const visibleIds = new Set(visibleRows.map(r => r.id));
|
|
666
769
|
// titles_only: V2 selection applies, but skip link resolution
|
|
667
770
|
if (opts.titlesOnly) {
|
|
@@ -1187,7 +1290,7 @@ export class HmemStore {
|
|
|
1187
1290
|
if (key === "links" && Array.isArray(val)) {
|
|
1188
1291
|
params.push(JSON.stringify(val));
|
|
1189
1292
|
}
|
|
1190
|
-
else if (key === "obsolete" || key === "favorite" || key === "irrelevant") {
|
|
1293
|
+
else if (key === "obsolete" || key === "favorite" || key === "irrelevant" || key === "active") {
|
|
1191
1294
|
params.push(val ? 1 : 0);
|
|
1192
1295
|
}
|
|
1193
1296
|
else {
|
|
@@ -1221,7 +1324,7 @@ export class HmemStore {
|
|
|
1221
1324
|
* For sub-nodes: updates node content only.
|
|
1222
1325
|
* Does NOT modify children — use appendChildren to extend the tree.
|
|
1223
1326
|
*/
|
|
1224
|
-
updateNode(id, newContent, links, obsolete, favorite, curatorBypass, irrelevant, tags, pinned) {
|
|
1327
|
+
updateNode(id, newContent, links, obsolete, favorite, curatorBypass, irrelevant, tags, pinned, active) {
|
|
1225
1328
|
this.guardCorrupted();
|
|
1226
1329
|
const trimmed = newContent.trim();
|
|
1227
1330
|
if (id.includes(".")) {
|
|
@@ -1338,6 +1441,10 @@ export class HmemStore {
|
|
|
1338
1441
|
sets.push("pinned = ?");
|
|
1339
1442
|
params.push(pinned ? 1 : 0);
|
|
1340
1443
|
}
|
|
1444
|
+
if (active !== undefined) {
|
|
1445
|
+
sets.push("active = ?");
|
|
1446
|
+
params.push(active ? 1 : 0);
|
|
1447
|
+
}
|
|
1341
1448
|
if (sets.length === 0) {
|
|
1342
1449
|
// Only tags to update — no SQL UPDATE needed
|
|
1343
1450
|
if (tags !== undefined) {
|
|
@@ -1987,6 +2094,7 @@ export class HmemStore {
|
|
|
1987
2094
|
obsolete: row.obsolete === 1,
|
|
1988
2095
|
favorite: row.favorite === 1,
|
|
1989
2096
|
irrelevant: row.irrelevant === 1,
|
|
2097
|
+
active: row.active === 1,
|
|
1990
2098
|
pinned: row.pinned === 1,
|
|
1991
2099
|
children,
|
|
1992
2100
|
};
|
|
@@ -2308,6 +2416,146 @@ export class HmemStore {
|
|
|
2308
2416
|
return [];
|
|
2309
2417
|
}
|
|
2310
2418
|
}
|
|
2419
|
+
/**
|
|
2420
|
+
* Find all entries contextually related to a given entry.
|
|
2421
|
+
* Uses per-node weighted tag scoring: for each node of the source entry,
|
|
2422
|
+
* compute weighted overlap with each candidate entry's full tag set.
|
|
2423
|
+
* Tier weights: rare (<=5 entries) = 3, medium (6-20) = 2, common (>20) = 1.
|
|
2424
|
+
* A candidate matches if ANY source node scores >= minTagScore against it.
|
|
2425
|
+
* Bidirectional direct links are always included.
|
|
2426
|
+
*/
|
|
2427
|
+
findContext(entryId, minTagScore = 4, limit = 30) {
|
|
2428
|
+
this.guardCorrupted();
|
|
2429
|
+
// 1. Source node IDs
|
|
2430
|
+
const childRows = this.db.prepare("SELECT id FROM memory_nodes WHERE root_id = ?").all(entryId);
|
|
2431
|
+
const nodeIds = [entryId, ...childRows.map(r => r.id)];
|
|
2432
|
+
// 2. Tags per source node
|
|
2433
|
+
const nodeTagMap = this.fetchTagsBulk(nodeIds);
|
|
2434
|
+
// 3. All unique source tags
|
|
2435
|
+
const allSourceTags = new Set();
|
|
2436
|
+
for (const tags of nodeTagMap.values()) {
|
|
2437
|
+
if (tags)
|
|
2438
|
+
tags.forEach(t => allSourceTags.add(t));
|
|
2439
|
+
}
|
|
2440
|
+
if (allSourceTags.size === 0) {
|
|
2441
|
+
return { linked: this.resolveDirectLinks(entryId), tagRelated: [] };
|
|
2442
|
+
}
|
|
2443
|
+
// 4. Global tag frequencies (count distinct root entries per tag)
|
|
2444
|
+
const freqRows = this.db.prepare(`
|
|
2445
|
+
SELECT tag, COUNT(DISTINCT
|
|
2446
|
+
CASE WHEN entry_id LIKE '%.%'
|
|
2447
|
+
THEN SUBSTR(entry_id, 1, INSTR(entry_id, '.') - 1)
|
|
2448
|
+
ELSE entry_id END
|
|
2449
|
+
) as freq
|
|
2450
|
+
FROM memory_tags GROUP BY tag
|
|
2451
|
+
`).all();
|
|
2452
|
+
const tagFreq = new Map();
|
|
2453
|
+
for (const r of freqRows)
|
|
2454
|
+
tagFreq.set(r.tag, r.freq);
|
|
2455
|
+
// 5. Find candidate entries sharing any source tag
|
|
2456
|
+
const srcTagArr = [...allSourceTags];
|
|
2457
|
+
const placeholders = srcTagArr.map(() => "?").join(", ");
|
|
2458
|
+
const candidateRows = this.db.prepare(`
|
|
2459
|
+
SELECT
|
|
2460
|
+
CASE WHEN entry_id LIKE '%.%'
|
|
2461
|
+
THEN SUBSTR(entry_id, 1, INSTR(entry_id, '.') - 1)
|
|
2462
|
+
ELSE entry_id END as root_id,
|
|
2463
|
+
tag
|
|
2464
|
+
FROM memory_tags
|
|
2465
|
+
WHERE tag IN (${placeholders})
|
|
2466
|
+
`).all(...srcTagArr);
|
|
2467
|
+
// 6. Group candidate tags by root_id (skip self)
|
|
2468
|
+
const candidateTagMap = new Map();
|
|
2469
|
+
for (const r of candidateRows) {
|
|
2470
|
+
if (r.root_id === entryId)
|
|
2471
|
+
continue;
|
|
2472
|
+
let set = candidateTagMap.get(r.root_id);
|
|
2473
|
+
if (!set) {
|
|
2474
|
+
set = new Set();
|
|
2475
|
+
candidateTagMap.set(r.root_id, set);
|
|
2476
|
+
}
|
|
2477
|
+
set.add(r.tag);
|
|
2478
|
+
}
|
|
2479
|
+
// 7. Score each candidate per source node
|
|
2480
|
+
const scored = [];
|
|
2481
|
+
for (const [candidateId, candidateTags] of candidateTagMap) {
|
|
2482
|
+
let bestScore = 0;
|
|
2483
|
+
let bestNode = "";
|
|
2484
|
+
for (const [nodeId, nodeTags] of nodeTagMap) {
|
|
2485
|
+
if (!nodeTags)
|
|
2486
|
+
continue;
|
|
2487
|
+
let score = 0;
|
|
2488
|
+
for (const tag of nodeTags) {
|
|
2489
|
+
if (candidateTags.has(tag)) {
|
|
2490
|
+
const freq = tagFreq.get(tag) ?? 999;
|
|
2491
|
+
if (freq <= 5)
|
|
2492
|
+
score += 3;
|
|
2493
|
+
else if (freq <= 20)
|
|
2494
|
+
score += 2;
|
|
2495
|
+
else
|
|
2496
|
+
score += 1;
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
2499
|
+
if (score > bestScore) {
|
|
2500
|
+
bestScore = score;
|
|
2501
|
+
bestNode = nodeId;
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2504
|
+
if (bestScore >= minTagScore) {
|
|
2505
|
+
scored.push({ id: candidateId, score: bestScore, matchNode: bestNode });
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
// Sort by score DESC
|
|
2509
|
+
scored.sort((a, b) => b.score - a.score);
|
|
2510
|
+
const topScored = scored.slice(0, limit);
|
|
2511
|
+
// 8. Fetch full entries, filter obsolete + irrelevant
|
|
2512
|
+
const tagRelated = [];
|
|
2513
|
+
for (const s of topScored) {
|
|
2514
|
+
const row = this.db.prepare("SELECT * FROM memories WHERE id = ? AND obsolete != 1 AND irrelevant != 1").get(s.id);
|
|
2515
|
+
if (!row)
|
|
2516
|
+
continue;
|
|
2517
|
+
const children = this.fetchChildren(row.id);
|
|
2518
|
+
const entry = this.rowToEntry(row, children);
|
|
2519
|
+
entry.tags = this.fetchTags(row.id);
|
|
2520
|
+
tagRelated.push({ entry, score: s.score, matchNode: s.matchNode });
|
|
2521
|
+
}
|
|
2522
|
+
// 9. Direct links (bidirectional)
|
|
2523
|
+
const linked = this.resolveDirectLinks(entryId);
|
|
2524
|
+
return { linked, tagRelated };
|
|
2525
|
+
}
|
|
2526
|
+
/** Resolve bidirectional direct links for an entry, filtering obsolete/irrelevant. */
|
|
2527
|
+
resolveDirectLinks(entryId) {
|
|
2528
|
+
const linkIds = new Set();
|
|
2529
|
+
// Forward links
|
|
2530
|
+
const row = this.db.prepare("SELECT links FROM memories WHERE id = ?").get(entryId);
|
|
2531
|
+
if (row?.links) {
|
|
2532
|
+
try {
|
|
2533
|
+
JSON.parse(row.links).forEach((id) => linkIds.add(id));
|
|
2534
|
+
}
|
|
2535
|
+
catch { }
|
|
2536
|
+
}
|
|
2537
|
+
// Reverse links: entries whose links field contains this ID
|
|
2538
|
+
const reverseRows = this.db.prepare("SELECT id, links FROM memories WHERE links LIKE ? AND id != ?").all(`%${entryId}%`, entryId);
|
|
2539
|
+
for (const r of reverseRows) {
|
|
2540
|
+
try {
|
|
2541
|
+
if (JSON.parse(r.links).includes(entryId))
|
|
2542
|
+
linkIds.add(r.id);
|
|
2543
|
+
}
|
|
2544
|
+
catch { }
|
|
2545
|
+
}
|
|
2546
|
+
// Fetch entries
|
|
2547
|
+
const results = [];
|
|
2548
|
+
for (const lid of linkIds) {
|
|
2549
|
+
const lr = this.db.prepare("SELECT * FROM memories WHERE id = ? AND obsolete != 1 AND irrelevant != 1").get(lid);
|
|
2550
|
+
if (!lr)
|
|
2551
|
+
continue;
|
|
2552
|
+
const children = this.fetchChildren(lr.id);
|
|
2553
|
+
const entry = this.rowToEntry(lr, children);
|
|
2554
|
+
entry.tags = this.fetchTags(lr.id);
|
|
2555
|
+
results.push(entry);
|
|
2556
|
+
}
|
|
2557
|
+
return results;
|
|
2558
|
+
}
|
|
2311
2559
|
/** Audit report: broken links, orphaned entries, stale favorites, broken obsolete chains, tag orphans. */
|
|
2312
2560
|
healthCheck() {
|
|
2313
2561
|
const result = {
|