hmem-mcp 2.0.3 → 2.2.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 +37 -0
- package/dist/cli-init.js +20 -4
- package/dist/cli-init.js.map +1 -1
- package/dist/hmem-config.d.ts +5 -32
- package/dist/hmem-config.js +5 -35
- package/dist/hmem-config.js.map +1 -1
- package/dist/hmem-store.d.ts +59 -9
- package/dist/hmem-store.js +340 -128
- package/dist/hmem-store.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp-server.js +284 -76
- package/dist/mcp-server.js.map +1 -1
- package/dist/session-cache.d.ts +66 -0
- package/dist/session-cache.js +100 -0
- package/dist/session-cache.js.map +1 -0
- package/hmem_developer.hmem +0 -0
- package/package.json +3 -2
- package/skills/hmem-config/SKILL.md +15 -19
- package/skills/hmem-curate/SKILL.md +17 -0
- package/skills/hmem-read/SKILL.md +23 -10
- package/skills/hmem-self-curate/SKILL.md +113 -0
- package/skills/hmem-setup/SKILL.md +6 -16
- package/skills/hmem-write/SKILL.md +24 -5
- package/skills/hmem-save/SKILL.md +0 -128
package/dist/hmem-store.js
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
import Database from "better-sqlite3";
|
|
28
28
|
import fs from "node:fs";
|
|
29
29
|
import path from "node:path";
|
|
30
|
-
import { DEFAULT_CONFIG, DEFAULT_PREFIX_DESCRIPTIONS
|
|
30
|
+
import { DEFAULT_CONFIG, DEFAULT_PREFIX_DESCRIPTIONS } from "./hmem-config.js";
|
|
31
31
|
// Prefixes are now loaded from config — see this.cfg.prefixes
|
|
32
32
|
const ROLE_LEVEL = {
|
|
33
33
|
worker: 0, al: 1, pl: 2, ceo: 3,
|
|
@@ -54,7 +54,8 @@ CREATE TABLE IF NOT EXISTS memories (
|
|
|
54
54
|
links TEXT,
|
|
55
55
|
min_role TEXT DEFAULT 'worker',
|
|
56
56
|
obsolete INTEGER DEFAULT 0,
|
|
57
|
-
favorite INTEGER DEFAULT 0
|
|
57
|
+
favorite INTEGER DEFAULT 0,
|
|
58
|
+
irrelevant INTEGER DEFAULT 0
|
|
58
59
|
);
|
|
59
60
|
CREATE INDEX IF NOT EXISTS idx_prefix ON memories(prefix);
|
|
60
61
|
CREATE INDEX IF NOT EXISTS idx_created ON memories(created_at);
|
|
@@ -85,6 +86,10 @@ const MIGRATIONS = [
|
|
|
85
86
|
"ALTER TABLE memories ADD COLUMN min_role TEXT DEFAULT 'worker'",
|
|
86
87
|
"ALTER TABLE memories ADD COLUMN obsolete INTEGER DEFAULT 0",
|
|
87
88
|
"ALTER TABLE memories ADD COLUMN favorite INTEGER DEFAULT 0",
|
|
89
|
+
"ALTER TABLE memories ADD COLUMN title TEXT",
|
|
90
|
+
"ALTER TABLE memory_nodes ADD COLUMN title TEXT",
|
|
91
|
+
"ALTER TABLE memories ADD COLUMN irrelevant INTEGER DEFAULT 0",
|
|
92
|
+
"ALTER TABLE memory_nodes ADD COLUMN favorite INTEGER DEFAULT 0",
|
|
88
93
|
];
|
|
89
94
|
// ---- HmemStore class ----
|
|
90
95
|
export class HmemStore {
|
|
@@ -153,7 +158,7 @@ export class HmemStore {
|
|
|
153
158
|
const seq = this.nextSeq(prefix);
|
|
154
159
|
const rootId = `${prefix}${String(seq).padStart(4, "0")}`;
|
|
155
160
|
const timestamp = new Date().toISOString();
|
|
156
|
-
const { level1, nodes } = this.parseTree(content, rootId);
|
|
161
|
+
const { title, level1, nodes } = this.parseTree(content, rootId);
|
|
157
162
|
if (!level1) {
|
|
158
163
|
throw new Error("Content must have at least one line (Level 1).");
|
|
159
164
|
}
|
|
@@ -170,18 +175,18 @@ export class HmemStore {
|
|
|
170
175
|
}
|
|
171
176
|
}
|
|
172
177
|
const insertRoot = this.db.prepare(`
|
|
173
|
-
INSERT INTO memories (id, prefix, seq, created_at, level_1, level_2, level_3, level_4, level_5, links, min_role, favorite)
|
|
174
|
-
VALUES (?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, ?, ?, ?)
|
|
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, ?, ?, ?)
|
|
175
180
|
`);
|
|
176
181
|
const insertNode = this.db.prepare(`
|
|
177
|
-
INSERT INTO memory_nodes (id, parent_id, root_id, depth, seq, content, created_at)
|
|
178
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
182
|
+
INSERT INTO memory_nodes (id, parent_id, root_id, depth, seq, title, content, created_at)
|
|
183
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
179
184
|
`);
|
|
180
185
|
// Run in a transaction
|
|
181
186
|
this.db.transaction(() => {
|
|
182
|
-
insertRoot.run(rootId, prefix, seq, timestamp, level1, links ? JSON.stringify(links) : null, minRole, favorite ? 1 : 0);
|
|
187
|
+
insertRoot.run(rootId, prefix, seq, timestamp, title, level1, links ? JSON.stringify(links) : null, minRole, favorite ? 1 : 0);
|
|
183
188
|
for (const node of nodes) {
|
|
184
|
-
insertNode.run(node.id, node.parent_id, rootId, node.depth, node.seq, node.content, timestamp);
|
|
189
|
+
insertNode.run(node.id, node.parent_id, rootId, node.depth, node.seq, node.title, node.content, timestamp);
|
|
185
190
|
}
|
|
186
191
|
})();
|
|
187
192
|
return { id: rootId, timestamp };
|
|
@@ -206,8 +211,14 @@ export class HmemStore {
|
|
|
206
211
|
if (!row)
|
|
207
212
|
return [];
|
|
208
213
|
this.bumpNodeAccess(opts.id);
|
|
209
|
-
const
|
|
210
|
-
|
|
214
|
+
const nodeDepth = row.depth ?? 2;
|
|
215
|
+
// expand: fetch requested depth + 1 extra level (for boundary titles)
|
|
216
|
+
const expandDepth = opts.expand ? (opts.depth || 5) + 1 : nodeDepth + 1;
|
|
217
|
+
const children = this.fetchChildrenDeep(opts.id, nodeDepth + 1, expandDepth);
|
|
218
|
+
const entry = this.nodeToEntry(this.rowToNode(row), children);
|
|
219
|
+
if (opts.expand)
|
|
220
|
+
entry.expanded = true;
|
|
221
|
+
return [entry];
|
|
211
222
|
}
|
|
212
223
|
else {
|
|
213
224
|
// Root ID — fetch from memories
|
|
@@ -215,30 +226,53 @@ export class HmemStore {
|
|
|
215
226
|
const row = this.db.prepare(sql).get(opts.id, ...roleFilter.params);
|
|
216
227
|
if (!row)
|
|
217
228
|
return [];
|
|
218
|
-
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
}
|
|
229
|
+
// ── Obsolete chain resolution ──
|
|
230
|
+
const shouldFollow = opts.followObsolete !== false; // default: true
|
|
231
|
+
if (shouldFollow && row.obsolete === 1) {
|
|
232
|
+
const { finalId, chain } = this.resolveObsoleteChain(opts.id);
|
|
233
|
+
if (chain.length > 1) {
|
|
234
|
+
// Chain resolved — return final entry (or full path)
|
|
235
|
+
if (opts.showObsoletePath) {
|
|
236
|
+
// Return ALL entries in the chain
|
|
237
|
+
const entries = [];
|
|
238
|
+
for (const chainId of chain) {
|
|
239
|
+
const chainRow = this.db.prepare(sql).get(chainId, ...roleFilter.params);
|
|
240
|
+
if (!chainRow)
|
|
241
|
+
continue;
|
|
242
|
+
const children = this.fetchChildren(chainId);
|
|
243
|
+
const entry = this.rowToEntry(chainRow, children);
|
|
244
|
+
entry.obsoleteChain = chain;
|
|
245
|
+
entries.push(entry);
|
|
246
|
+
}
|
|
247
|
+
// Bump access on the final (valid) entry only
|
|
248
|
+
this.bumpAccess(finalId);
|
|
249
|
+
return entries;
|
|
236
250
|
}
|
|
237
|
-
|
|
238
|
-
|
|
251
|
+
else {
|
|
252
|
+
// Return ONLY the final valid entry
|
|
253
|
+
this.bumpAccess(finalId);
|
|
254
|
+
const finalRow = this.db.prepare(sql).get(finalId, ...roleFilter.params);
|
|
255
|
+
if (!finalRow)
|
|
256
|
+
return []; // correction target inaccessible
|
|
257
|
+
const children = this.fetchChildren(finalId);
|
|
258
|
+
const entry = this.rowToEntry(finalRow, children);
|
|
259
|
+
entry.obsoleteChain = chain;
|
|
260
|
+
// Resolve links on the final entry
|
|
261
|
+
this.resolveEntryLinks(entry, opts);
|
|
262
|
+
return [entry];
|
|
239
263
|
}
|
|
240
|
-
}
|
|
264
|
+
}
|
|
265
|
+
// chain.length <= 1: no correction found, fall through to normal behavior
|
|
241
266
|
}
|
|
267
|
+
this.bumpAccess(opts.id);
|
|
268
|
+
// expand: fetch requested depth + 1 extra level (for boundary titles)
|
|
269
|
+
const expandDepth = opts.expand ? (opts.depth || 5) + 1 : 2;
|
|
270
|
+
const children = this.fetchChildrenDeep(opts.id, 2, expandDepth);
|
|
271
|
+
const entry = this.rowToEntry(row, children);
|
|
272
|
+
if (opts.expand)
|
|
273
|
+
entry.expanded = true;
|
|
274
|
+
// Auto-resolve links
|
|
275
|
+
this.resolveEntryLinks(entry, opts);
|
|
242
276
|
return [entry];
|
|
243
277
|
}
|
|
244
278
|
}
|
|
@@ -343,67 +377,20 @@ export class HmemStore {
|
|
|
343
377
|
for (const row of rows)
|
|
344
378
|
this.bumpAccess(row.id);
|
|
345
379
|
}
|
|
346
|
-
// Dispatch: V1 (legacy) or V2 (new default)
|
|
347
|
-
if (opts.recentDepthTiers) {
|
|
348
|
-
return this.readBulkV1(rows, opts);
|
|
349
|
-
}
|
|
350
380
|
return this.readBulkV2(rows, opts);
|
|
351
381
|
}
|
|
352
|
-
/**
|
|
353
|
-
* V1 bulk-read algorithm (legacy): recency gradient with depth tiers.
|
|
354
|
-
* Kept for backward compatibility when recentDepthTiers is explicitly passed.
|
|
355
|
-
*/
|
|
356
|
-
readBulkV1(rows, opts) {
|
|
357
|
-
const tiers = opts.recentDepthTiers ?? this.cfg.recentDepthTiers;
|
|
358
|
-
// Identify top-N entries by access_count ("organic favorites")
|
|
359
|
-
const topN = this.cfg.accessCountTopN ?? 5;
|
|
360
|
-
const topAccessIds = topN > 0
|
|
361
|
-
? new Set([...rows]
|
|
362
|
-
.filter(r => r.access_count > 0)
|
|
363
|
-
.sort((a, b) => b.access_count - a.access_count)
|
|
364
|
-
.slice(0, topN)
|
|
365
|
-
.map(r => r.id))
|
|
366
|
-
: new Set();
|
|
367
|
-
return rows.map((r, i) => {
|
|
368
|
-
let depth = resolveDepthForPosition(i, tiers);
|
|
369
|
-
let promoted;
|
|
370
|
-
if (r.favorite === 1) {
|
|
371
|
-
promoted = "favorite";
|
|
372
|
-
if (depth < 2)
|
|
373
|
-
depth = 2;
|
|
374
|
-
}
|
|
375
|
-
else if (topAccessIds.has(r.id)) {
|
|
376
|
-
promoted = "access";
|
|
377
|
-
if (depth < 2)
|
|
378
|
-
depth = 2;
|
|
379
|
-
}
|
|
380
|
-
let children;
|
|
381
|
-
let hiddenChildrenCount;
|
|
382
|
-
if (depth >= 2) {
|
|
383
|
-
const latest = this.fetchLatestChild(r.id, depth);
|
|
384
|
-
if (latest) {
|
|
385
|
-
children = [latest.node];
|
|
386
|
-
hiddenChildrenCount = latest.totalSiblings - 1;
|
|
387
|
-
}
|
|
388
|
-
else {
|
|
389
|
-
hiddenChildrenCount = 0;
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
const entry = this.rowToEntry(r, children);
|
|
393
|
-
entry.promoted = promoted;
|
|
394
|
-
entry.hiddenChildrenCount = hiddenChildrenCount;
|
|
395
|
-
return entry;
|
|
396
|
-
});
|
|
397
|
-
}
|
|
398
382
|
/**
|
|
399
383
|
* V2 bulk-read algorithm: per-prefix expansion, smart obsolete filtering,
|
|
400
384
|
* expanded entries with all L2 children + links.
|
|
401
385
|
*/
|
|
402
386
|
readBulkV2(rows, opts) {
|
|
403
387
|
const v2 = this.cfg.bulkReadV2;
|
|
388
|
+
// Step 0: Filter out irrelevant entries (never shown in bulk reads)
|
|
389
|
+
const irrelevantCount = rows.filter(r => r.irrelevant === 1).length;
|
|
390
|
+
const activeRows = rows.filter(r => r.irrelevant !== 1);
|
|
404
391
|
// Step 1: Separate obsolete from non-obsolete FIRST
|
|
405
|
-
const obsoleteRows =
|
|
406
|
-
const nonObsoleteRows =
|
|
392
|
+
const obsoleteRows = activeRows.filter(r => r.obsolete === 1);
|
|
393
|
+
const nonObsoleteRows = activeRows.filter(r => r.obsolete !== 1);
|
|
407
394
|
// Step 2: Group NON-OBSOLETE by prefix (obsolete must not steal expansion slots)
|
|
408
395
|
const byPrefix = new Map();
|
|
409
396
|
for (const r of nonObsoleteRows) {
|
|
@@ -413,46 +400,102 @@ export class HmemStore {
|
|
|
413
400
|
else
|
|
414
401
|
byPrefix.set(r.prefix, [r]);
|
|
415
402
|
}
|
|
403
|
+
// Session cache: filter out already-seen entries completely
|
|
404
|
+
const suppressed = opts.suppressedIds ?? new Set();
|
|
405
|
+
const hasCacheActive = suppressed.size > 0;
|
|
416
406
|
// Step 3: Build expansion set from non-obsolete rows
|
|
417
407
|
const expandedIds = new Set();
|
|
418
|
-
//
|
|
408
|
+
// Mode-based ratios: essentials shifts weight from newest to most-accessed
|
|
409
|
+
const isEssentials = opts.mode === "essentials";
|
|
410
|
+
const totalSlots = v2.topNewestCount + v2.topAccessCount; // 8 by default
|
|
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)
|
|
419
419
|
for (const [, prefixRows] of byPrefix) {
|
|
420
|
-
// Newest
|
|
421
|
-
|
|
420
|
+
// Newest: sliding window — only from unseen entries
|
|
421
|
+
const unseenRows = hasCacheActive
|
|
422
|
+
? prefixRows.filter(r => !suppressed.has(r.id))
|
|
423
|
+
: prefixRows;
|
|
424
|
+
for (const r of unseenRows.slice(0, newestCount)) {
|
|
422
425
|
expandedIds.add(r.id);
|
|
423
426
|
}
|
|
424
|
-
// Most-accessed
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
.
|
|
428
|
-
.
|
|
427
|
+
// Most-accessed: from unseen entries, excluding those already picked as newest.
|
|
428
|
+
// Minimum threshold: access_count >= 2 — a single access can be noise.
|
|
429
|
+
const mostAccessed = [...unseenRows]
|
|
430
|
+
.filter(r => r.access_count >= 2 && !expandedIds.has(r.id))
|
|
431
|
+
.sort((a, b) => this.weightedAccessScore(b) - this.weightedAccessScore(a))
|
|
432
|
+
.slice(0, accessCount);
|
|
429
433
|
for (const r of mostAccessed)
|
|
430
434
|
expandedIds.add(r.id);
|
|
431
435
|
}
|
|
432
|
-
// Global: all favorites
|
|
436
|
+
// Global: all unseen favorites
|
|
433
437
|
for (const r of nonObsoleteRows) {
|
|
434
|
-
if (r.favorite === 1)
|
|
438
|
+
if (r.favorite === 1 && !suppressed.has(r.id)) {
|
|
435
439
|
expandedIds.add(r.id);
|
|
440
|
+
}
|
|
436
441
|
}
|
|
437
|
-
// topAccess reference for promoted marker
|
|
442
|
+
// topAccess reference for promoted marker (time-weighted, min 2 accesses)
|
|
438
443
|
const topAccess = [...nonObsoleteRows]
|
|
439
|
-
.filter(r => r.access_count
|
|
440
|
-
.sort((a, b) => b
|
|
444
|
+
.filter(r => r.access_count >= 2)
|
|
445
|
+
.sort((a, b) => this.weightedAccessScore(b) - this.weightedAccessScore(a))
|
|
441
446
|
.slice(0, v2.topAccessCount);
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
}
|
|
446
|
-
else {
|
|
447
|
-
// Keep only top N most-accessed obsolete entries ("biggest mistakes")
|
|
448
|
-
visibleObsolete = [...obsoleteRows]
|
|
449
|
-
.sort((a, b) => b.access_count - a.access_count)
|
|
450
|
-
.slice(0, v2.topObsoleteCount);
|
|
451
|
-
}
|
|
452
|
-
const hiddenObsoleteCount = obsoleteRows.length - visibleObsolete.length;
|
|
453
|
-
// Step 4: Only show expanded entries + visible obsolete (non-expanded are hidden from bulk output)
|
|
447
|
+
// Obsolete entries: only shown when explicitly requested
|
|
448
|
+
const visibleObsolete = opts.showObsolete ? obsoleteRows : [];
|
|
449
|
+
// Step 4: Show expanded entries + visible obsolete (cached entries completely hidden)
|
|
454
450
|
const expandedNonObsolete = nonObsoleteRows.filter(r => expandedIds.has(r.id));
|
|
455
451
|
const visibleRows = [...expandedNonObsolete, ...visibleObsolete];
|
|
452
|
+
const visibleIds = new Set(visibleRows.map(r => r.id));
|
|
453
|
+
// titles_only: V2 selection applies, but skip link resolution
|
|
454
|
+
if (opts.titlesOnly) {
|
|
455
|
+
// Bulk-fetch L2 child counts (one query for all visible entries)
|
|
456
|
+
const allIds = visibleRows.map(r => r.id);
|
|
457
|
+
const childCounts = this.bulkChildCount(allIds);
|
|
458
|
+
return visibleRows.map(r => {
|
|
459
|
+
const isExpanded = expandedIds.has(r.id);
|
|
460
|
+
const totalChildren = childCounts.get(r.id) ?? 0;
|
|
461
|
+
let children;
|
|
462
|
+
let hiddenCount;
|
|
463
|
+
if (isExpanded && totalChildren > 0) {
|
|
464
|
+
// Fetch L2 children with V2 selection (top newest + accessed), no links
|
|
465
|
+
const allChildren = this.fetchChildren(r.id);
|
|
466
|
+
if (allChildren.length > v2.topNewestCount) {
|
|
467
|
+
const newestSet = new Set([...allChildren]
|
|
468
|
+
.sort((a, b) => (b.created_at || "").localeCompare(a.created_at || ""))
|
|
469
|
+
.slice(0, v2.topNewestCount)
|
|
470
|
+
.map(c => c.id));
|
|
471
|
+
const accessSet = new Set([...allChildren]
|
|
472
|
+
.filter(c => c.access_count >= 2)
|
|
473
|
+
.sort((a, b) => this.weightedAccessScore(b) - this.weightedAccessScore(a))
|
|
474
|
+
.slice(0, v2.topAccessCount)
|
|
475
|
+
.map(c => c.id));
|
|
476
|
+
const selectedIds = new Set([...newestSet, ...accessSet]);
|
|
477
|
+
children = allChildren.filter(c => selectedIds.has(c.id));
|
|
478
|
+
hiddenCount = allChildren.length - children.length;
|
|
479
|
+
}
|
|
480
|
+
else {
|
|
481
|
+
children = allChildren;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
else if (totalChildren > 0) {
|
|
485
|
+
hiddenCount = totalChildren;
|
|
486
|
+
}
|
|
487
|
+
const entry = this.rowToEntry(r, children);
|
|
488
|
+
if (r.favorite === 1)
|
|
489
|
+
entry.promoted = "favorite";
|
|
490
|
+
else if (topAccess.some(t => t.id === r.id))
|
|
491
|
+
entry.promoted = "access";
|
|
492
|
+
if (isExpanded)
|
|
493
|
+
entry.expanded = true;
|
|
494
|
+
if (hiddenCount !== undefined && hiddenCount > 0)
|
|
495
|
+
entry.hiddenChildrenCount = hiddenCount;
|
|
496
|
+
return entry;
|
|
497
|
+
});
|
|
498
|
+
}
|
|
456
499
|
return visibleRows.map(r => {
|
|
457
500
|
const isExpanded = expandedIds.has(r.id);
|
|
458
501
|
let promoted;
|
|
@@ -462,27 +505,64 @@ export class HmemStore {
|
|
|
462
505
|
promoted = "access";
|
|
463
506
|
let children;
|
|
464
507
|
let linkedEntries;
|
|
508
|
+
let hiddenChildrenCount;
|
|
509
|
+
let hiddenObsoleteLinks = 0;
|
|
510
|
+
let hiddenIrrelevantLinks = 0;
|
|
465
511
|
if (isExpanded) {
|
|
466
|
-
//
|
|
467
|
-
|
|
468
|
-
|
|
512
|
+
// Fetch all L2 children, then apply V2 selection (same params as L1)
|
|
513
|
+
const allChildren = this.fetchChildren(r.id);
|
|
514
|
+
if (allChildren.length > v2.topNewestCount) {
|
|
515
|
+
const newestSet = new Set([...allChildren]
|
|
516
|
+
.sort((a, b) => (b.created_at || "").localeCompare(a.created_at || ""))
|
|
517
|
+
.slice(0, v2.topNewestCount)
|
|
518
|
+
.map(c => c.id));
|
|
519
|
+
const accessSet = new Set([...allChildren]
|
|
520
|
+
.filter(c => c.access_count > 0)
|
|
521
|
+
.sort((a, b) => this.weightedAccessScore(b) - this.weightedAccessScore(a))
|
|
522
|
+
.slice(0, v2.topAccessCount)
|
|
523
|
+
.map(c => c.id));
|
|
524
|
+
const selectedIds = new Set([...newestSet, ...accessSet]);
|
|
525
|
+
children = allChildren.filter(c => selectedIds.has(c.id));
|
|
526
|
+
if (children.length < allChildren.length) {
|
|
527
|
+
hiddenChildrenCount = allChildren.length - children.length;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
else {
|
|
531
|
+
children = allChildren;
|
|
532
|
+
}
|
|
533
|
+
// Resolve links — skip entries already visible in bulk read
|
|
469
534
|
const links = r.links ? JSON.parse(r.links) : [];
|
|
470
535
|
if (links.length > 0) {
|
|
471
|
-
|
|
536
|
+
const allLinked = links.flatMap(linkId => {
|
|
537
|
+
if (visibleIds.has(linkId))
|
|
538
|
+
return []; // already shown in bulk read
|
|
472
539
|
try {
|
|
473
540
|
return this.read({ id: linkId, resolveLinks: false, linkDepth: 0 });
|
|
474
541
|
}
|
|
475
542
|
catch {
|
|
476
543
|
return [];
|
|
477
544
|
}
|
|
478
|
-
})
|
|
545
|
+
});
|
|
546
|
+
for (const e of allLinked) {
|
|
547
|
+
if (e.obsolete)
|
|
548
|
+
hiddenObsoleteLinks++;
|
|
549
|
+
else if (e.irrelevant)
|
|
550
|
+
hiddenIrrelevantLinks++;
|
|
551
|
+
}
|
|
552
|
+
linkedEntries = allLinked.filter(e => !e.obsolete && !e.irrelevant);
|
|
479
553
|
}
|
|
480
554
|
}
|
|
481
555
|
const entry = this.rowToEntry(r, children);
|
|
482
556
|
entry.promoted = promoted;
|
|
483
557
|
entry.expanded = isExpanded;
|
|
558
|
+
if (hiddenChildrenCount !== undefined)
|
|
559
|
+
entry.hiddenChildrenCount = hiddenChildrenCount;
|
|
484
560
|
if (linkedEntries && linkedEntries.length > 0)
|
|
485
561
|
entry.linkedEntries = linkedEntries;
|
|
562
|
+
if (hiddenObsoleteLinks > 0)
|
|
563
|
+
entry.hiddenObsoleteLinks = hiddenObsoleteLinks;
|
|
564
|
+
if (hiddenIrrelevantLinks > 0)
|
|
565
|
+
entry.hiddenIrrelevantLinks = hiddenIrrelevantLinks;
|
|
486
566
|
return entry;
|
|
487
567
|
});
|
|
488
568
|
}
|
|
@@ -570,7 +650,7 @@ export class HmemStore {
|
|
|
570
650
|
if (key === "links" && Array.isArray(val)) {
|
|
571
651
|
params.push(JSON.stringify(val));
|
|
572
652
|
}
|
|
573
|
-
else if (key === "obsolete" || key === "favorite") {
|
|
653
|
+
else if (key === "obsolete" || key === "favorite" || key === "irrelevant") {
|
|
574
654
|
params.push(val ? 1 : 0);
|
|
575
655
|
}
|
|
576
656
|
else {
|
|
@@ -600,7 +680,7 @@ export class HmemStore {
|
|
|
600
680
|
* For sub-nodes: updates node content only.
|
|
601
681
|
* Does NOT modify children — use appendChildren to extend the tree.
|
|
602
682
|
*/
|
|
603
|
-
updateNode(id, newContent, links, obsolete, favorite, curatorBypass) {
|
|
683
|
+
updateNode(id, newContent, links, obsolete, favorite, curatorBypass, irrelevant) {
|
|
604
684
|
this.guardCorrupted();
|
|
605
685
|
const trimmed = newContent.trim();
|
|
606
686
|
if (id.includes(".")) {
|
|
@@ -612,7 +692,14 @@ export class HmemStore {
|
|
|
612
692
|
if (trimmed.length > nodeLimit) {
|
|
613
693
|
throw new Error(`Content exceeds ${nodeLimit} character limit (${trimmed.length} chars) for L${nodeRow.depth}.`);
|
|
614
694
|
}
|
|
615
|
-
const
|
|
695
|
+
const sets = ["content = ?", "title = ?"];
|
|
696
|
+
const params = [trimmed, this.autoExtractTitle(trimmed)];
|
|
697
|
+
if (favorite !== undefined) {
|
|
698
|
+
sets.push("favorite = ?");
|
|
699
|
+
params.push(favorite ? 1 : 0);
|
|
700
|
+
}
|
|
701
|
+
params.push(id);
|
|
702
|
+
const result = this.db.prepare(`UPDATE memory_nodes SET ${sets.join(", ")} WHERE id = ?`).run(...params);
|
|
616
703
|
return result.changes > 0;
|
|
617
704
|
}
|
|
618
705
|
else {
|
|
@@ -651,8 +738,8 @@ export class HmemStore {
|
|
|
651
738
|
}
|
|
652
739
|
}
|
|
653
740
|
}
|
|
654
|
-
const sets = ["level_1 = ?"];
|
|
655
|
-
const params = [trimmed];
|
|
741
|
+
const sets = ["level_1 = ?", "title = ?"];
|
|
742
|
+
const params = [trimmed, this.autoExtractTitle(trimmed)];
|
|
656
743
|
if (links !== undefined) {
|
|
657
744
|
sets.push("links = ?");
|
|
658
745
|
params.push(links.length > 0 ? JSON.stringify(links) : null);
|
|
@@ -668,6 +755,10 @@ export class HmemStore {
|
|
|
668
755
|
sets.push("favorite = ?");
|
|
669
756
|
params.push(favorite ? 1 : 0);
|
|
670
757
|
}
|
|
758
|
+
if (irrelevant !== undefined) {
|
|
759
|
+
sets.push("irrelevant = ?");
|
|
760
|
+
params.push(irrelevant ? 1 : 0);
|
|
761
|
+
}
|
|
671
762
|
params.push(id);
|
|
672
763
|
const result = this.db.prepare(`UPDATE memories SET ${sets.join(", ")} WHERE id = ?`).run(...params);
|
|
673
764
|
return result.changes > 0;
|
|
@@ -713,13 +804,13 @@ export class HmemStore {
|
|
|
713
804
|
}
|
|
714
805
|
const timestamp = new Date().toISOString();
|
|
715
806
|
const insertNode = this.db.prepare(`
|
|
716
|
-
INSERT INTO memory_nodes (id, parent_id, root_id, depth, seq, content, created_at)
|
|
717
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
807
|
+
INSERT INTO memory_nodes (id, parent_id, root_id, depth, seq, title, content, created_at)
|
|
808
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
718
809
|
`);
|
|
719
810
|
const topLevelIds = [];
|
|
720
811
|
this.db.transaction(() => {
|
|
721
812
|
for (const node of nodes) {
|
|
722
|
-
insertNode.run(node.id, node.parent_id, rootId, node.depth, node.seq, node.content, timestamp);
|
|
813
|
+
insertNode.run(node.id, node.parent_id, rootId, node.depth, node.seq, this.autoExtractTitle(node.content), node.content, timestamp);
|
|
723
814
|
if (node.parent_id === parentId)
|
|
724
815
|
topLevelIds.push(node.id);
|
|
725
816
|
}
|
|
@@ -928,13 +1019,97 @@ export class HmemStore {
|
|
|
928
1019
|
const row = this.db.prepare("SELECT MAX(seq) as maxSeq FROM memories WHERE prefix = ?").get(prefix);
|
|
929
1020
|
return (row?.maxSeq || 0) + 1;
|
|
930
1021
|
}
|
|
1022
|
+
/** Auto-resolve linked entries on an entry (extracted for reuse in chain resolution). */
|
|
1023
|
+
resolveEntryLinks(entry, opts) {
|
|
1024
|
+
const linkDepth = opts.resolveLinks === false ? 0 : (opts.linkDepth ?? 1);
|
|
1025
|
+
if (linkDepth > 0 && entry.links && entry.links.length > 0) {
|
|
1026
|
+
const visited = opts._visitedLinks ?? new Set();
|
|
1027
|
+
visited.add(entry.id);
|
|
1028
|
+
const allLinked = entry.links.flatMap(linkId => {
|
|
1029
|
+
if (visited.has(linkId))
|
|
1030
|
+
return []; // cycle detected — skip
|
|
1031
|
+
try {
|
|
1032
|
+
return this.read({
|
|
1033
|
+
id: linkId,
|
|
1034
|
+
agentRole: opts.agentRole,
|
|
1035
|
+
linkDepth: linkDepth - 1,
|
|
1036
|
+
_visitedLinks: visited,
|
|
1037
|
+
followObsolete: false, // don't chain-resolve inside link resolution
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
catch {
|
|
1041
|
+
return [];
|
|
1042
|
+
}
|
|
1043
|
+
});
|
|
1044
|
+
let hiddenObsolete = 0;
|
|
1045
|
+
let hiddenIrrelevant = 0;
|
|
1046
|
+
for (const e of allLinked) {
|
|
1047
|
+
if (e.obsolete)
|
|
1048
|
+
hiddenObsolete++;
|
|
1049
|
+
else if (e.irrelevant)
|
|
1050
|
+
hiddenIrrelevant++;
|
|
1051
|
+
}
|
|
1052
|
+
entry.linkedEntries = allLinked.filter(e => !e.obsolete && !e.irrelevant);
|
|
1053
|
+
if (hiddenObsolete > 0)
|
|
1054
|
+
entry.hiddenObsoleteLinks = hiddenObsolete;
|
|
1055
|
+
if (hiddenIrrelevant > 0)
|
|
1056
|
+
entry.hiddenIrrelevantLinks = hiddenIrrelevant;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
931
1059
|
bumpAccess(id) {
|
|
932
1060
|
this.db.prepare("UPDATE memories SET access_count = access_count + 1, last_accessed = ? WHERE id = ?").run(new Date().toISOString(), id);
|
|
933
1061
|
}
|
|
934
1062
|
bumpNodeAccess(id) {
|
|
935
1063
|
this.db.prepare("UPDATE memory_nodes SET access_count = access_count + 1, last_accessed = ? WHERE id = ?").run(new Date().toISOString(), id);
|
|
936
1064
|
}
|
|
1065
|
+
/**
|
|
1066
|
+
* Follow the obsolete chain from an entry to its final valid correction.
|
|
1067
|
+
* Parses [✓ID] from level_1 of each obsolete entry and follows the chain.
|
|
1068
|
+
* Returns the final (non-obsolete) entry ID and the full chain of IDs traversed.
|
|
1069
|
+
*/
|
|
1070
|
+
resolveObsoleteChain(id) {
|
|
1071
|
+
const chain = [id];
|
|
1072
|
+
let currentId = id;
|
|
1073
|
+
const visited = new Set();
|
|
1074
|
+
for (let i = 0; i < 10; i++) { // max 10 hops
|
|
1075
|
+
visited.add(currentId);
|
|
1076
|
+
const row = this.db.prepare("SELECT id, level_1, obsolete FROM memories WHERE id = ?").get(currentId);
|
|
1077
|
+
if (!row || !row.obsolete)
|
|
1078
|
+
break; // not obsolete or not found → stop
|
|
1079
|
+
// Parse [✓ID] from level_1
|
|
1080
|
+
const match = row.level_1?.match(/\[✓([A-Z]\d{4}(?:\.\d+)*)\]/);
|
|
1081
|
+
if (!match)
|
|
1082
|
+
break; // no correction reference → stop
|
|
1083
|
+
const nextId = match[1];
|
|
1084
|
+
if (visited.has(nextId))
|
|
1085
|
+
break; // cycle detected → stop
|
|
1086
|
+
chain.push(nextId);
|
|
1087
|
+
currentId = nextId;
|
|
1088
|
+
}
|
|
1089
|
+
return { finalId: currentId, chain };
|
|
1090
|
+
}
|
|
937
1091
|
/** Fetch direct children of a node (root or compound), including their grandchild counts. */
|
|
1092
|
+
/** Bulk-fetch direct child counts for multiple parent IDs in one query. */
|
|
1093
|
+
bulkChildCount(parentIds) {
|
|
1094
|
+
if (parentIds.length === 0)
|
|
1095
|
+
return new Map();
|
|
1096
|
+
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);
|
|
1098
|
+
const map = new Map();
|
|
1099
|
+
for (const r of rows)
|
|
1100
|
+
map.set(r.parent_id, r.cnt);
|
|
1101
|
+
return map;
|
|
1102
|
+
}
|
|
1103
|
+
/**
|
|
1104
|
+
* Time-weighted access score: newer entries with fewer accesses can outrank
|
|
1105
|
+
* older entries with more accesses. Uses logarithmic age decay:
|
|
1106
|
+
* score = access_count / log2(age_in_days + 2)
|
|
1107
|
+
*/
|
|
1108
|
+
weightedAccessScore(row) {
|
|
1109
|
+
const ageMs = Date.now() - new Date(row.created_at).getTime();
|
|
1110
|
+
const ageDays = Math.max(ageMs / 86_400_000, 0);
|
|
1111
|
+
return (row.access_count || 0) / Math.log2(ageDays + 2);
|
|
1112
|
+
}
|
|
938
1113
|
fetchChildren(parentId) {
|
|
939
1114
|
return this.fetchChildrenDeep(parentId, 2, 2);
|
|
940
1115
|
}
|
|
@@ -978,10 +1153,12 @@ export class HmemStore {
|
|
|
978
1153
|
root_id: row.root_id,
|
|
979
1154
|
depth: row.depth,
|
|
980
1155
|
seq: row.seq,
|
|
1156
|
+
title: row.title ?? this.autoExtractTitle(row.content),
|
|
981
1157
|
content: row.content,
|
|
982
1158
|
created_at: row.created_at,
|
|
983
1159
|
access_count: row.access_count || 0,
|
|
984
1160
|
last_accessed: row.last_accessed || null,
|
|
1161
|
+
favorite: row.favorite === 1 ? true : undefined,
|
|
985
1162
|
child_count: childCount,
|
|
986
1163
|
};
|
|
987
1164
|
}
|
|
@@ -991,6 +1168,7 @@ export class HmemStore {
|
|
|
991
1168
|
prefix: row.prefix,
|
|
992
1169
|
seq: row.seq,
|
|
993
1170
|
created_at: row.created_at,
|
|
1171
|
+
title: row.title ?? this.autoExtractTitle(row.level_1),
|
|
994
1172
|
level_1: row.level_1,
|
|
995
1173
|
level_2: null, // always null post-migration
|
|
996
1174
|
level_3: null,
|
|
@@ -1002,6 +1180,7 @@ export class HmemStore {
|
|
|
1002
1180
|
min_role: row.min_role || "worker",
|
|
1003
1181
|
obsolete: row.obsolete === 1,
|
|
1004
1182
|
favorite: row.favorite === 1,
|
|
1183
|
+
irrelevant: row.irrelevant === 1,
|
|
1005
1184
|
children,
|
|
1006
1185
|
};
|
|
1007
1186
|
}
|
|
@@ -1016,6 +1195,7 @@ export class HmemStore {
|
|
|
1016
1195
|
prefix: node.root_id.match(/^([A-Z]+)/)?.[1] ?? "?",
|
|
1017
1196
|
seq: node.seq,
|
|
1018
1197
|
created_at: node.created_at,
|
|
1198
|
+
title: node.title,
|
|
1019
1199
|
level_1: node.content,
|
|
1020
1200
|
level_2: null,
|
|
1021
1201
|
level_3: null,
|
|
@@ -1029,7 +1209,28 @@ export class HmemStore {
|
|
|
1029
1209
|
};
|
|
1030
1210
|
}
|
|
1031
1211
|
/**
|
|
1032
|
-
*
|
|
1212
|
+
* Auto-extract a short title from text.
|
|
1213
|
+
* Priority: text before " — " > word-boundary truncation > hard truncation.
|
|
1214
|
+
*/
|
|
1215
|
+
autoExtractTitle(text) {
|
|
1216
|
+
const maxLen = this.cfg.maxTitleChars;
|
|
1217
|
+
const dashIdx = text.indexOf(" — ");
|
|
1218
|
+
if (dashIdx > 0 && dashIdx <= maxLen)
|
|
1219
|
+
return text.substring(0, dashIdx);
|
|
1220
|
+
if (text.length <= maxLen)
|
|
1221
|
+
return text;
|
|
1222
|
+
// Truncate at last word boundary before maxLen
|
|
1223
|
+
const lastSpace = text.lastIndexOf(" ", maxLen);
|
|
1224
|
+
if (lastSpace > maxLen * 0.4)
|
|
1225
|
+
return text.substring(0, lastSpace);
|
|
1226
|
+
return text.substring(0, maxLen);
|
|
1227
|
+
}
|
|
1228
|
+
/**
|
|
1229
|
+
* Parse tab-indented content into title + L1 text + a list of tree nodes.
|
|
1230
|
+
*
|
|
1231
|
+
* Title extraction:
|
|
1232
|
+
* - 2+ non-indented lines: first line = explicit title, rest = level_1
|
|
1233
|
+
* - 1 non-indented line: title = auto-extracted (~30 chars), level_1 = full line
|
|
1033
1234
|
*
|
|
1034
1235
|
* Algorithm:
|
|
1035
1236
|
* - seqAtParent: Map<parentId, number> — sibling counter per parent
|
|
@@ -1047,7 +1248,7 @@ export class HmemStore {
|
|
|
1047
1248
|
const seqAtParent = new Map();
|
|
1048
1249
|
const lastIdAtDepth = new Map();
|
|
1049
1250
|
const nodes = [];
|
|
1050
|
-
|
|
1251
|
+
const l1Lines = [];
|
|
1051
1252
|
// Auto-detect space indentation unit: use first indented line (if no tabs present)
|
|
1052
1253
|
const rawLines = content.split("\n").map(l => l.trimEnd()).filter(Boolean);
|
|
1053
1254
|
let spaceUnit = 4;
|
|
@@ -1078,8 +1279,7 @@ export class HmemStore {
|
|
|
1078
1279
|
}
|
|
1079
1280
|
const text = trimmedEnd.trim();
|
|
1080
1281
|
if (depth === 1) {
|
|
1081
|
-
|
|
1082
|
-
level1 = level1 ? level1 + " | " + text : text;
|
|
1282
|
+
l1Lines.push(text);
|
|
1083
1283
|
continue;
|
|
1084
1284
|
}
|
|
1085
1285
|
// L2+: determine parent and generate compound ID
|
|
@@ -1088,9 +1288,21 @@ export class HmemStore {
|
|
|
1088
1288
|
seqAtParent.set(parentId, seq);
|
|
1089
1289
|
const nodeId = `${parentId}.${seq}`;
|
|
1090
1290
|
lastIdAtDepth.set(depth, nodeId);
|
|
1091
|
-
nodes.push({ id: nodeId, parent_id: parentId, depth, seq, content: text });
|
|
1291
|
+
nodes.push({ id: nodeId, parent_id: parentId, depth, seq, content: text, title: this.autoExtractTitle(text) });
|
|
1292
|
+
}
|
|
1293
|
+
// Title: first L1 line (explicit). Content: remaining L1 lines joined.
|
|
1294
|
+
// If only 1 L1 line: title is auto-extracted, level1 = full line.
|
|
1295
|
+
let title;
|
|
1296
|
+
let level1;
|
|
1297
|
+
if (l1Lines.length >= 2) {
|
|
1298
|
+
title = l1Lines[0];
|
|
1299
|
+
level1 = l1Lines.slice(1).join(" | ");
|
|
1300
|
+
}
|
|
1301
|
+
else {
|
|
1302
|
+
level1 = l1Lines[0] ?? "";
|
|
1303
|
+
title = this.autoExtractTitle(level1);
|
|
1092
1304
|
}
|
|
1093
|
-
return { level1, nodes };
|
|
1305
|
+
return { title, level1, nodes };
|
|
1094
1306
|
}
|
|
1095
1307
|
/**
|
|
1096
1308
|
* Parse tab-indented content relative to a parent node.
|