hmem-mcp 3.3.0 → 3.5.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.
@@ -32,7 +32,7 @@ export const DEFAULT_PREFIX_DESCRIPTIONS = {
32
32
  O: "(O)riginal context — raw session history with progressive summarization (auto-generated by flush_context)",
33
33
  };
34
34
  export const DEFAULT_CONFIG = {
35
- maxCharsPerLevel: [120, 2_500, 10_000, 25_000, 50_000],
35
+ maxCharsPerLevel: [200, 2_500, 10_000, 25_000, 50_000],
36
36
  maxDepth: 5,
37
37
  defaultReadLimit: 100,
38
38
  prefixes: { ...DEFAULT_PREFIXES },
@@ -196,7 +196,7 @@ export declare class HmemStore {
196
196
  * Each indented line → its own memory_nodes row with compound ID
197
197
  * Multiple lines at the same indent depth → siblings (new capability)
198
198
  */
199
- write(prefix: string, content: string, links?: string[], minRole?: AgentRole, favorite?: boolean, tags?: string[], pinned?: boolean): WriteResult;
199
+ write(prefix: string, content: string, links?: string[], minRole?: AgentRole, favorite?: boolean, tags?: string[], pinned?: boolean, force?: boolean): WriteResult;
200
200
  /**
201
201
  * Write a linear entry with explicit content at each level (no tree branching).
202
202
  * Used by flush_context for O-prefix entries. Each level is a single node forming
@@ -379,6 +379,8 @@ export declare class HmemStore {
379
379
  private parseTimeWindow;
380
380
  private buildRoleFilter;
381
381
  private nextSeq;
382
+ /** Clear all active markers — called at MCP server start so each session starts neutral. */
383
+ clearAllActive(): void;
382
384
  /** Auto-resolve linked entries on an entry (extracted for reuse in chain resolution). */
383
385
  private resolveEntryLinks;
384
386
  /** Get child nodes created after a given ISO timestamp (for "new since last session" detection). */
@@ -224,7 +224,7 @@ export class HmemStore {
224
224
  * Each indented line → its own memory_nodes row with compound ID
225
225
  * Multiple lines at the same indent depth → siblings (new capability)
226
226
  */
227
- write(prefix, content, links, minRole = "worker", favorite, tags, pinned) {
227
+ write(prefix, content, links, minRole = "worker", favorite, tags, pinned, force) {
228
228
  this.guardCorrupted();
229
229
  prefix = prefix.toUpperCase();
230
230
  if (!this.cfg.prefixes[prefix]) {
@@ -265,6 +265,77 @@ export class HmemStore {
265
265
  throw new Error("Tags are required. Provide at least 1 tag (3+ recommended) for discoverability. Example: tags=['#hmem', '#sqlite', '#bug']");
266
266
  }
267
267
  const validatedTags = this.validateTags(tags);
268
+ // Duplicate detection: check for existing entries with significant tag overlap
269
+ if (prefix !== "O" && !force) { // O-entries are auto-generated, skip check
270
+ const tagPlaceholders = validatedTags.map(() => "?").join(", ");
271
+ const overlapRows = this.db.prepare(`
272
+ SELECT
273
+ CASE WHEN mt.entry_id LIKE '%.%'
274
+ THEN SUBSTR(mt.entry_id, 1, INSTR(mt.entry_id, '.') - 1)
275
+ ELSE mt.entry_id END as root_id,
276
+ COUNT(DISTINCT mt.tag) as shared
277
+ FROM memory_tags mt
278
+ JOIN memories m ON m.id = (
279
+ CASE WHEN mt.entry_id LIKE '%.%'
280
+ THEN SUBSTR(mt.entry_id, 1, INSTR(mt.entry_id, '.') - 1)
281
+ ELSE mt.entry_id END
282
+ )
283
+ WHERE mt.tag IN (${tagPlaceholders})
284
+ AND m.prefix = ?
285
+ AND m.obsolete != 1
286
+ AND m.irrelevant != 1
287
+ GROUP BY root_id
288
+ HAVING shared >= 2
289
+ ORDER BY shared DESC
290
+ LIMIT 3
291
+ `).all(...validatedTags, prefix);
292
+ // Phase 2: FTS5 title similarity (fallback for entries with different tags)
293
+ let ftsMatches = [];
294
+ if (overlapRows.length === 0) {
295
+ const words = level1.replace(/[^a-zA-Z0-9äöüÄÖÜß\s-]/g, " ")
296
+ .split(/\s+/)
297
+ .filter(w => w.length > 3)
298
+ .slice(0, 4);
299
+ if (words.length >= 2) {
300
+ try {
301
+ const andQuery = words.join(" AND ");
302
+ const ftsHits = this.db.prepare(`
303
+ SELECT rm.root_id FROM hmem_fts_rowid_map rm
304
+ JOIN hmem_fts f ON f.rowid = rm.fts_rowid
305
+ WHERE hmem_fts MATCH ? LIMIT 5
306
+ `).all(andQuery);
307
+ const seen = new Set();
308
+ for (const hit of ftsHits) {
309
+ if (seen.has(hit.root_id))
310
+ continue;
311
+ seen.add(hit.root_id);
312
+ const row = this.db.prepare("SELECT id, title, prefix FROM memories WHERE id = ? AND prefix = ? AND obsolete != 1 AND irrelevant != 1").get(hit.root_id, prefix);
313
+ if (row)
314
+ ftsMatches.push({ root_id: row.id, title: row.title });
315
+ }
316
+ }
317
+ catch { /* FTS5 might not exist */ }
318
+ }
319
+ }
320
+ if (overlapRows.length > 0 || ftsMatches.length > 0) {
321
+ const parts = [];
322
+ if (overlapRows.length > 0) {
323
+ const tagHits = overlapRows.map(r => {
324
+ const row = this.db.prepare("SELECT title FROM memories WHERE id = ?").get(r.root_id);
325
+ return ` ${r.root_id} (${r.shared}/${validatedTags.length} shared tags) ${row?.title ?? ""}`;
326
+ }).join("\n");
327
+ parts.push(`Tag overlap:\n${tagHits}`);
328
+ }
329
+ if (ftsMatches.length > 0) {
330
+ const ftsHits = ftsMatches.map(r => ` ${r.root_id} ${r.title}`).join("\n");
331
+ parts.push(`Similar titles:\n${ftsHits}`);
332
+ }
333
+ const bestMatch = overlapRows[0]?.root_id ?? ftsMatches[0]?.root_id;
334
+ throw new Error(`Similar ${prefix}-entries already exist:\n${parts.join("\n")}\n\n` +
335
+ `If this belongs to an existing entry, use: append_memory(id="${bestMatch}", content="...")\n` +
336
+ `If this is intentionally a NEW entry, retry with: force=true`);
337
+ }
338
+ }
268
339
  // Run in a transaction
269
340
  this.db.transaction(() => {
270
341
  insertRoot.run(rootId, prefix, seq, timestamp, timestamp, title, level1, links ? JSON.stringify(links) : null, minRole, favorite ? 1 : 0, pinned ? 1 : 0);
@@ -1995,6 +2066,10 @@ export class HmemStore {
1995
2066
  const row = this.db.prepare("SELECT MAX(seq) as maxSeq FROM memories WHERE prefix = ?").get(prefix);
1996
2067
  return (row?.maxSeq || 0) + 1;
1997
2068
  }
2069
+ /** Clear all active markers — called at MCP server start so each session starts neutral. */
2070
+ clearAllActive() {
2071
+ this.db.prepare("UPDATE memories SET active = 0 WHERE active = 1").run();
2072
+ }
1998
2073
  /** Auto-resolve linked entries on an entry (extracted for reuse in chain resolution). */
1999
2074
  resolveEntryLinks(entry, opts) {
2000
2075
  const linkDepth = opts.resolveLinks === false ? 0 : (opts.linkDepth ?? 1);
@@ -2044,9 +2119,13 @@ export class HmemStore {
2044
2119
  const row = this.db.prepare("SELECT id FROM memories WHERE prefix = 'O' AND active = 1 AND obsolete != 1 AND irrelevant != 1 LIMIT 1").get();
2045
2120
  if (row)
2046
2121
  return row.id;
2047
- // Create new O-entry for today's session
2122
+ // Find active project for context
2123
+ const activeProject = this.db.prepare("SELECT id, title FROM memories WHERE prefix = 'P' AND active = 1 AND obsolete != 1 LIMIT 1").get();
2048
2124
  const today = new Date().toISOString().substring(0, 10);
2049
- const result = this.writeLinear("O", { l1: `Session ${today}` }, ["#session"]);
2125
+ const projectName = activeProject?.title?.split("|")[0]?.trim() ?? "unassigned";
2126
+ const tags = ["#session"];
2127
+ const links = activeProject ? [activeProject.id] : undefined;
2128
+ const result = this.writeLinear("O", { l1: `${projectName} — Session ${today}` }, tags, links);
2050
2129
  this.db.prepare("UPDATE memories SET active = 1, updated_at = ? WHERE id = ?")
2051
2130
  .run(new Date().toISOString(), result.id);
2052
2131
  return result.id;