hmem-mcp 6.3.1 → 6.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -31,6 +31,20 @@ import os from "node:os";
31
31
  import path from "node:path";
32
32
  import { DEFAULT_CONFIG, DEFAULT_PREFIX_DESCRIPTIONS } from "./hmem-config.js";
33
33
  import { readSessionMarker, writeSessionMarker } from "./session-state.js";
34
+ /**
35
+ * Thrown by write_memory when similar existing entries are detected.
36
+ * Handled specially by callers — surfaced as a non-error hint so the
37
+ * agent can decide whether to append to an existing entry or retry with
38
+ * force=true, without the UI flagging it in red.
39
+ */
40
+ export class SimilarEntriesError extends Error {
41
+ bestMatch;
42
+ constructor(message, bestMatch) {
43
+ super(message);
44
+ this.name = "SimilarEntriesError";
45
+ this.bestMatch = bestMatch;
46
+ }
47
+ }
34
48
  // Prefixes are now loaded from config — see this.cfg.prefixes
35
49
  // (limits are now instance-level via this.cfg.maxCharsPerLevel)
36
50
  const SCHEMA = `
@@ -270,7 +284,10 @@ export class HmemStore {
270
284
  throw new Error("Tags are required. Provide at least 1 tag (3+ recommended) for discoverability. Example: tags=['#hmem', '#sqlite', '#bug']");
271
285
  }
272
286
  const validatedTags = this.validateTags(tags);
273
- // Duplicate detection: check for existing entries with significant tag overlap
287
+ // Duplicate detection: check for existing entries with significant tag overlap.
288
+ // Threshold: require at least 3 shared tags for a tag-only match — project/framework
289
+ // tags alone (2 overlapping generic tags like #python+#bug) are not enough to block
290
+ // a new entry. See issue #12.
274
291
  if (prefix !== "O" && !force) { // O-entries are auto-generated, skip check
275
292
  const tagPlaceholders = validatedTags.map(() => "?").join(", ");
276
293
  const overlapRows = this.db.prepare(`
@@ -290,7 +307,7 @@ export class HmemStore {
290
307
  AND m.obsolete != 1
291
308
  AND m.irrelevant != 1
292
309
  GROUP BY root_id
293
- HAVING shared >= 2
310
+ HAVING shared >= 3
294
311
  ORDER BY shared DESC
295
312
  LIMIT 3
296
313
  `).all(...validatedTags, prefix);
@@ -336,9 +353,9 @@ export class HmemStore {
336
353
  parts.push(`Similar titles:\n${ftsHits}`);
337
354
  }
338
355
  const bestMatch = overlapRows[0]?.root_id ?? ftsMatches[0]?.root_id;
339
- throw new Error(`Similar ${prefix}-entries already exist:\n${parts.join("\n")}\n\n` +
356
+ throw new SimilarEntriesError(`Similar ${prefix}-entries already exist:\n${parts.join("\n")}\n\n` +
340
357
  `If this belongs to an existing entry, use: append_memory(id="${bestMatch}", content="...")\n` +
341
- `If this is intentionally a NEW entry, retry with: force=true`);
358
+ `If this is intentionally a NEW entry, retry with: force=true`, bestMatch);
342
359
  }
343
360
  }
344
361
  // Run in a transaction
@@ -3162,20 +3179,7 @@ export class HmemStore {
3162
3179
  }
3163
3180
  catch { /* malformed JSON — skip */ }
3164
3181
  }
3165
- // Scan memory_nodes.links
3166
- const nodeRows = this.db.prepare("SELECT id, links FROM memory_nodes WHERE links IS NOT NULL AND links LIKE ?").all(`%"${obsoleteId}"%`);
3167
- for (const row of nodeRows) {
3168
- try {
3169
- const arr = JSON.parse(row.links);
3170
- if (!arr.includes(obsoleteId))
3171
- continue;
3172
- const updated = arr.map(l => l === obsoleteId ? correctionId : l);
3173
- const deduped = [...new Set(updated)];
3174
- this.db.prepare("UPDATE memory_nodes SET links = ? WHERE id = ?")
3175
- .run(JSON.stringify(deduped), row.id);
3176
- }
3177
- catch { /* malformed JSON — skip */ }
3178
- }
3182
+ // memory_nodes has no `links` column — only root entries (memories) carry links.
3179
3183
  }
3180
3184
  /** Fetch direct children of a node (root or compound), including their grandchild counts. */
3181
3185
  /** Bulk-fetch direct child counts for multiple parent IDs in one query. */
@@ -4112,25 +4116,6 @@ export class HmemStore {
4112
4116
  return { moved: subtree.length, newId: newSourceId, idMap: Object.fromEntries(idMap) };
4113
4117
  }
4114
4118
  }
4115
- // ---- Convenience: resolve .hmem path for an agent ----
4116
- /**
4117
- * @deprecated Use resolveHmemPath() (no args) instead. Will be removed in v7.0.
4118
- */
4119
- export function resolveHmemPathLegacy(projectDir, templateName) {
4120
- console.error("[hmem] DEPRECATED: resolveHmemPathLegacy(projectDir, templateName) — use HMEM_PATH env var instead");
4121
- // No agent name configured → use memory.hmem directly in project root
4122
- if (!templateName || templateName === "UNKNOWN") {
4123
- return path.join(projectDir, "memory.hmem");
4124
- }
4125
- // Named agent → Agents/NAME/NAME.hmem (check Assistenten/ as fallback)
4126
- let agentDir = path.join(projectDir, "Agents", templateName);
4127
- if (!fs.existsSync(agentDir)) {
4128
- const alt = path.join(projectDir, "Assistenten", templateName);
4129
- if (fs.existsSync(alt))
4130
- agentDir = alt;
4131
- }
4132
- return path.join(agentDir, `${templateName}.hmem`);
4133
- }
4134
4119
  /**
4135
4120
  * Resolve the path to the personal .hmem database file.
4136
4121
  * Priority: HMEM_PATH env var > CWD discovery > ~/.hmem/memory.hmem
@@ -4186,14 +4171,6 @@ export function resolveHmemPath(cwdOverride) {
4186
4171
  // Priority 4: default fallback
4187
4172
  return path.resolve(safeHomedir(), ".hmem", "memory.hmem");
4188
4173
  }
4189
- /**
4190
- * @deprecated Use `new HmemStore(resolveHmemPath(), config)` instead.
4191
- */
4192
- export function openAgentMemory(projectDir, templateName, config) {
4193
- console.error("[hmem] DEPRECATED: openAgentMemory() — use new HmemStore(resolveHmemPath(), config)");
4194
- const hmemPath = resolveHmemPathLegacy(projectDir, templateName);
4195
- return new HmemStore(hmemPath, config);
4196
- }
4197
4174
  /**
4198
4175
  * Open (or create) the shared company knowledge store (company.hmem).
4199
4176
  */
@@ -4201,146 +4178,4 @@ export function openCompanyMemory(projectDir, config) {
4201
4178
  const hmemPath = path.join(projectDir, "company.hmem");
4202
4179
  return new HmemStore(hmemPath, config);
4203
4180
  }
4204
- /**
4205
- * Route a task to the best-matching agent based on memory content.
4206
- * @deprecated This function scans the Agents/ directory structure which is being phased out.
4207
- * Future versions will use a config-based file list instead.
4208
- *
4209
- * Scans all agent .hmem files in the project directory and scores them
4210
- * against the provided tags and/or search keywords.
4211
- *
4212
- * Scoring: for each agent store, find entries sharing the given tags
4213
- * with tier-weighted scoring (rare=3, medium=2, common=1).
4214
- * FTS5 keyword matching supplements tag scoring.
4215
- */
4216
- export function routeTask(projectDir, tags, keywords, limit = 5, config) {
4217
- // Discover all agent .hmem files — requires multi-agent setup
4218
- const agentsDir = path.join(projectDir, "Agents");
4219
- if (!fs.existsSync(agentsDir))
4220
- return [];
4221
- const agentDirs = fs.readdirSync(agentsDir).filter(name => {
4222
- const agentPath = path.join(agentsDir, name);
4223
- return fs.statSync(agentPath).isDirectory() &&
4224
- fs.existsSync(path.join(agentPath, `${name}.hmem`));
4225
- });
4226
- const results = [];
4227
- for (const agentName of agentDirs) {
4228
- const hmemPath = path.join(agentsDir, agentName, `${agentName}.hmem`);
4229
- let db;
4230
- try {
4231
- db = new Database(hmemPath, { readonly: true });
4232
- }
4233
- catch {
4234
- continue;
4235
- }
4236
- try {
4237
- let agentScore = 0;
4238
- const topEntries = [];
4239
- // Phase 1: Tag-based scoring
4240
- if (tags.length > 0) {
4241
- // Get global tag frequencies for THIS store
4242
- const freqRows = db.prepare(`
4243
- SELECT tag, COUNT(DISTINCT
4244
- CASE WHEN entry_id LIKE '%.%'
4245
- THEN SUBSTR(entry_id, 1, INSTR(entry_id, '.') - 1)
4246
- ELSE entry_id END
4247
- ) as freq FROM memory_tags GROUP BY tag
4248
- `).all();
4249
- const tagFreq = new Map();
4250
- for (const r of freqRows)
4251
- tagFreq.set(r.tag, r.freq);
4252
- // Find entries sharing any of the given tags
4253
- const normalizedTags = tags.map(t => t.startsWith("#") ? t.toLowerCase() : `#${t.toLowerCase()}`);
4254
- const placeholders = normalizedTags.map(() => "?").join(", ");
4255
- const matchRows = db.prepare(`
4256
- SELECT
4257
- CASE WHEN entry_id LIKE '%.%'
4258
- THEN SUBSTR(entry_id, 1, INSTR(entry_id, '.') - 1)
4259
- ELSE entry_id END as root_id,
4260
- tag
4261
- FROM memory_tags WHERE tag IN (${placeholders})
4262
- `).all(...normalizedTags);
4263
- // Group by root and score
4264
- const byRoot = new Map();
4265
- for (const r of matchRows) {
4266
- if (r.root_id.startsWith("O"))
4267
- continue; // skip O-entries
4268
- let set = byRoot.get(r.root_id);
4269
- if (!set) {
4270
- set = new Set();
4271
- byRoot.set(r.root_id, set);
4272
- }
4273
- set.add(r.tag);
4274
- }
4275
- for (const [rootId, matchedTags] of byRoot) {
4276
- let entryScore = 0;
4277
- for (const tag of matchedTags) {
4278
- const freq = tagFreq.get(tag) ?? 999;
4279
- if (freq <= 5)
4280
- entryScore += 3;
4281
- else if (freq <= 20)
4282
- entryScore += 2;
4283
- else
4284
- entryScore += 1;
4285
- }
4286
- agentScore += entryScore;
4287
- // Get entry title
4288
- const row = db.prepare("SELECT title, level_1 FROM memories WHERE id = ? AND obsolete != 1 AND irrelevant != 1").get(rootId);
4289
- if (row) {
4290
- topEntries.push({
4291
- id: rootId,
4292
- title: row.title || row.level_1?.substring(0, 50) || rootId,
4293
- score: entryScore,
4294
- });
4295
- }
4296
- }
4297
- }
4298
- // Phase 2: FTS5 keyword supplement
4299
- if (keywords && keywords.trim().length > 0) {
4300
- try {
4301
- const words = keywords.trim().split(/\s+/).filter(w => w.length > 3).slice(0, 5);
4302
- if (words.length > 0) {
4303
- const orQuery = words.join(" OR ");
4304
- const ftsRows = db.prepare(`
4305
- SELECT rm.root_id, rm.node_id FROM hmem_fts_rowid_map rm
4306
- JOIN hmem_fts f ON f.rowid = rm.fts_rowid
4307
- WHERE hmem_fts MATCH ? LIMIT 20
4308
- `).all(orQuery);
4309
- const ftsRoots = new Set(ftsRows.map(r => r.root_id).filter(id => !id.startsWith("O")));
4310
- for (const rootId of ftsRoots) {
4311
- if (topEntries.some(e => e.id === rootId))
4312
- continue; // already scored via tags
4313
- const row = db.prepare("SELECT title, level_1 FROM memories WHERE id = ? AND obsolete != 1 AND irrelevant != 1").get(rootId);
4314
- if (row) {
4315
- agentScore += 1; // FTS matches get 1 point each
4316
- topEntries.push({
4317
- id: rootId,
4318
- title: row.title || row.level_1?.substring(0, 50) || rootId,
4319
- score: 1,
4320
- });
4321
- }
4322
- }
4323
- }
4324
- }
4325
- catch { /* FTS5 may not exist in all stores */ }
4326
- }
4327
- if (agentScore > 0) {
4328
- // Sort entries by score, keep top 5
4329
- topEntries.sort((a, b) => b.score - a.score);
4330
- results.push({
4331
- agent: agentName,
4332
- score: agentScore,
4333
- entryCount: topEntries.length,
4334
- topEntries: topEntries.slice(0, 5),
4335
- });
4336
- }
4337
- }
4338
- finally {
4339
- db.close();
4340
- }
4341
- }
4342
- // Sort agents by score
4343
- results.sort((a, b) => b.score - a.score);
4344
- return results.slice(0, limit);
4345
- }
4346
4181
  //# sourceMappingURL=hmem-store.js.map