@gethmy/mcp 2.8.4 → 2.8.6

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/index.js CHANGED
@@ -934,10 +934,14 @@ function getDisplayLinkType(linkType, direction) {
934
934
  }
935
935
  // ../harmony-shared/dist/commentSerializer.js
936
936
  var CONFLICT_INSTRUCTION = "When two comments conflict, prefer the latest created_at, UNLESS a later " + "comment explicitly confirms or restates the earlier finding. Evaluate " + "substance, not just recency. Cite the comment id(s) you relied on.";
937
+ function sanitizeHeaderField(value) {
938
+ return value.replace(/[\]\r\n|<>]/g, " ").trim() || "—";
939
+ }
937
940
  function authorLabel(c) {
938
941
  if (c.author_type === "agent")
939
942
  return "AI agent";
940
- return c.author?.full_name || c.author?.email || "teammate";
943
+ const raw = c.author?.full_name || "teammate";
944
+ return sanitizeHeaderField(raw);
941
945
  }
942
946
  function criticalIds(comments) {
943
947
  const keep = new Set;
@@ -994,9 +998,15 @@ function serializeCommentThread(comments, options = {}) {
994
998
  if (c.resolved_at)
995
999
  tags.push("resolved");
996
1000
  const tagStr = tags.length ? ` | ${tags.join(" | ")}` : "";
997
- const header = `[${ref(c.id)} | ${c.author_type} | ${authorLabel(c)} | ${c.comment_type} | ${c.created_at}${tagStr}]`;
998
- lines.push({ at: c.created_at, text: `${header}
999
- ${c.body.trim()}` });
1001
+ const header = `[${sanitizeHeaderField(ref(c.id))} | ${sanitizeHeaderField(c.author_type)} | ${authorLabel(c)} | ${sanitizeHeaderField(c.comment_type)} | ${sanitizeHeaderField(c.created_at)}${tagStr}]`;
1002
+ const fencedBody = c.body.trim().replaceAll("<", "&lt;").replaceAll(">", "&gt;");
1003
+ lines.push({
1004
+ at: c.created_at,
1005
+ text: `${header}
1006
+ <comment-body>
1007
+ ${fencedBody}
1008
+ </comment-body>`
1009
+ });
1000
1010
  }
1001
1011
  for (const a of activity) {
1002
1012
  const actor = a.actor ? `${a.actor} ` : "";
@@ -1706,6 +1716,10 @@ class HarmonyApiClient {
1706
1716
  params.set("type", options.type);
1707
1717
  if (options?.limit !== undefined)
1708
1718
  params.set("limit", String(options.limit));
1719
+ for (const tag of options?.tags ?? [])
1720
+ params.append("tags", tag);
1721
+ if (options?.include_superseded)
1722
+ params.set("include_superseded", "true");
1709
1723
  return this.request("GET", `/memory/search?${params.toString()}`);
1710
1724
  }
1711
1725
  async getVaultIndex(options) {
@@ -2023,7 +2037,6 @@ function resolveAgentIdentity(info) {
2023
2037
  }
2024
2038
  var AUTO_START_TRIGGERS = new Set([
2025
2039
  "harmony_generate_prompt",
2026
- "harmony_update_card",
2027
2040
  "harmony_create_subtask",
2028
2041
  "harmony_toggle_subtask",
2029
2042
  "harmony_update_subtask"
@@ -2055,6 +2068,10 @@ async function trackActivity(cardId, options) {
2055
2068
  const client3 = options?.client ?? clientGetter?.();
2056
2069
  if (!client3)
2057
2070
  return;
2071
+ const info = clientInfoGetter?.() ?? null;
2072
+ if (!info?.name)
2073
+ return;
2074
+ const { agentIdentifier, agentName } = resolveAgentIdentity(info);
2058
2075
  const toEnd = [];
2059
2076
  for (const [otherCardId, session] of activeSessions) {
2060
2077
  if (otherCardId !== cardId && !session.isExplicit) {
@@ -2064,8 +2081,6 @@ async function trackActivity(cardId, options) {
2064
2081
  for (const otherCardId of toEnd) {
2065
2082
  await autoEndSession(client3, otherCardId, "completed");
2066
2083
  }
2067
- const info = clientInfoGetter?.() ?? null;
2068
- const { agentIdentifier, agentName } = resolveAgentIdentity(info);
2069
2084
  try {
2070
2085
  await client3.startAgentSession(cardId, {
2071
2086
  agentIdentifier,
@@ -2185,6 +2200,90 @@ async function autoExpandGraph(client3, entityId, title, content, _tags, workspa
2185
2200
  return { relationsCreated: 0 };
2186
2201
  }
2187
2202
  }
2203
+ async function findSimilarEntities(client3, title, content, workspaceId, options) {
2204
+ const contentSnippet = content.slice(0, 200).trim();
2205
+ const query = [title, contentSnippet].filter(Boolean).join(" ");
2206
+ try {
2207
+ const { entities } = await client3.searchMemoryEntities(workspaceId, query, {
2208
+ project_id: options?.projectId,
2209
+ limit: options?.limit ?? 20,
2210
+ type: options?.type
2211
+ });
2212
+ const minScore = options?.minRrfScore ?? 0;
2213
+ const excludeSet = new Set(options?.excludeIds || []);
2214
+ return entities.filter((e) => {
2215
+ if (excludeSet.has(e.id))
2216
+ return false;
2217
+ if (minScore > 0 && (e.rrf_score ?? 0) < minScore)
2218
+ return false;
2219
+ return true;
2220
+ });
2221
+ } catch {
2222
+ return [];
2223
+ }
2224
+ }
2225
+ var SUPERSEDE_RRF_THRESHOLD = 0.029;
2226
+ var SUPERSEDE_TITLE_OVERLAP = 0.5;
2227
+ var TITLE_STOPWORDS = new Set([
2228
+ "a",
2229
+ "an",
2230
+ "the",
2231
+ "and",
2232
+ "or",
2233
+ "of",
2234
+ "to",
2235
+ "in",
2236
+ "on",
2237
+ "for",
2238
+ "with",
2239
+ "is",
2240
+ "are",
2241
+ "be",
2242
+ "by",
2243
+ "at",
2244
+ "as"
2245
+ ]);
2246
+ function significantTitleTokens(title) {
2247
+ return new Set(title.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((t) => t.length > 2 && !TITLE_STOPWORDS.has(t)));
2248
+ }
2249
+ function jaccard(a, b) {
2250
+ if (a.size === 0 || b.size === 0)
2251
+ return 0;
2252
+ let intersection = 0;
2253
+ for (const t of a)
2254
+ if (b.has(t))
2255
+ intersection++;
2256
+ return intersection / (a.size + b.size - intersection);
2257
+ }
2258
+ async function findSupersedeCandidates(client3, title, content, type, workspaceId, options) {
2259
+ const rrfThreshold = options?.rrfThreshold ?? SUPERSEDE_RRF_THRESHOLD;
2260
+ const titleOverlap = options?.titleOverlap ?? SUPERSEDE_TITLE_OVERLAP;
2261
+ const candidateTokens = significantTitleTokens(title);
2262
+ try {
2263
+ const hits = await findSimilarEntities(client3, title, content, workspaceId, {
2264
+ projectId: options?.projectId,
2265
+ type,
2266
+ limit: options?.limit ?? 10,
2267
+ minRrfScore: rrfThreshold
2268
+ });
2269
+ return hits.filter((e) => {
2270
+ if (options?.scope && e.scope !== undefined) {
2271
+ if (e.scope !== options.scope)
2272
+ return false;
2273
+ }
2274
+ if (e.superseded_at) {
2275
+ return false;
2276
+ }
2277
+ return jaccard(candidateTokens, significantTitleTokens(e.title)) >= titleOverlap;
2278
+ }).map((e) => ({
2279
+ id: e.id,
2280
+ title: e.title,
2281
+ score: e.rrf_score ?? 0
2282
+ }));
2283
+ } catch {
2284
+ return [];
2285
+ }
2286
+ }
2188
2287
 
2189
2288
  // src/memory-floor.ts
2190
2289
  var STOP_WORDS = new Set([
@@ -2323,6 +2422,10 @@ var DEFAULT_WEIGHTS = {
2323
2422
  recency: 0.25,
2324
2423
  importance: 0.2
2325
2424
  };
2425
+ var USAGE_BUMP_SCALE = 0.6;
2426
+ var USAGE_BUMP_MAX = 2;
2427
+ var FEEDBACK_BUMP_SCALE = 0.8;
2428
+ var FEEDBACK_BUMP_MAX = 2;
2326
2429
  var TYPE_TAU_SECONDS = {
2327
2430
  preference: Number.POSITIVE_INFINITY,
2328
2431
  pattern: 60 * 60 * 24 * 180,
@@ -2378,8 +2481,37 @@ function recencyDecay(lastAccessedAt, createdAt, type, now) {
2378
2481
  const dtSec = Math.max(0, (now.getTime() - t) / 1000);
2379
2482
  return clamp01(Math.exp(-dtSec / tau));
2380
2483
  }
2381
- function importanceNorm(raw, type) {
2484
+ function baseImportance(raw, type) {
2382
2485
  let v = typeof raw === "number" ? raw : TYPE_IMPORTANCE_DEFAULT[type] ?? 5;
2486
+ if (v < 1)
2487
+ v = 1;
2488
+ if (v > 10)
2489
+ v = 10;
2490
+ return v;
2491
+ }
2492
+ function usageBump(accessCount) {
2493
+ const n = typeof accessCount === "number" && accessCount > 0 ? accessCount : 0;
2494
+ if (n === 0)
2495
+ return 0;
2496
+ return Math.min(USAGE_BUMP_MAX, USAGE_BUMP_SCALE * Math.log(1 + n));
2497
+ }
2498
+ function feedbackBump(feedback) {
2499
+ const up = typeof feedback?.up === "number" ? feedback.up : 0;
2500
+ const down = typeof feedback?.down === "number" ? feedback.down : 0;
2501
+ const net = up - down;
2502
+ if (net === 0)
2503
+ return 0;
2504
+ const raw = FEEDBACK_BUMP_SCALE * Math.sign(net) * Math.log(1 + Math.abs(net));
2505
+ if (raw > FEEDBACK_BUMP_MAX)
2506
+ return FEEDBACK_BUMP_MAX;
2507
+ if (raw < -FEEDBACK_BUMP_MAX)
2508
+ return -FEEDBACK_BUMP_MAX;
2509
+ return raw;
2510
+ }
2511
+ function effectiveImportance(entity) {
2512
+ const base = baseImportance(entity.importance, entity.type);
2513
+ const bump = usageBump(entity.access_count) + feedbackBump(entity.metadata?.feedback);
2514
+ let v = base + bump;
2383
2515
  if (v < 1)
2384
2516
  v = 1;
2385
2517
  if (v > 10)
@@ -2393,7 +2525,7 @@ function rescore(entities, options = {}) {
2393
2525
  const scored = entities.map((entity) => {
2394
2526
  const relevance = clamp01(relevanceMap.get(entity.id ?? "") ?? 0.5);
2395
2527
  const recency = recencyDecay(entity.last_accessed_at, entity.created_at, entity.type, now);
2396
- const importance = importanceNorm(entity.importance, entity.type);
2528
+ const importance = effectiveImportance(entity);
2397
2529
  const score = w.relevance * relevance + w.recency * recency + w.importance * importance;
2398
2530
  return { entity, relevance, recency, importance, score };
2399
2531
  });
@@ -2406,6 +2538,11 @@ function rescore(entities, options = {}) {
2406
2538
  });
2407
2539
  return scored;
2408
2540
  }
2541
+ function filterByMinConfidence(entities, minConfidence) {
2542
+ if (typeof minConfidence !== "number")
2543
+ return entities;
2544
+ return entities.filter((e) => typeof e.confidence === "number" && e.confidence >= minConfidence);
2545
+ }
2409
2546
  function relevanceFromRank(ranked, decay = 10) {
2410
2547
  const out = new Map;
2411
2548
  ranked.forEach((entity, rank) => {
@@ -2438,6 +2575,82 @@ function fitToBudget(scored, budgetTokens) {
2438
2575
  }
2439
2576
  return out;
2440
2577
  }
2578
+ function mergeFeedback(existing, vote) {
2579
+ const up = typeof existing?.up === "number" && existing.up > 0 ? existing.up : 0;
2580
+ const down = typeof existing?.down === "number" && existing.down > 0 ? existing.down : 0;
2581
+ return vote === "up" ? { up: up + 1, down } : { up, down: down + 1 };
2582
+ }
2583
+
2584
+ // src/memory-provenance.ts
2585
+ function buildOrigin(input) {
2586
+ const origin = { source: input.source };
2587
+ if (input.source_card_id)
2588
+ origin.source_card_id = input.source_card_id;
2589
+ if (input.source_session_id)
2590
+ origin.source_session_id = input.source_session_id;
2591
+ if (input.author)
2592
+ origin.author = input.author;
2593
+ if (input.source_trust)
2594
+ origin.source_trust = input.source_trust;
2595
+ return origin;
2596
+ }
2597
+ function defaultConfidenceForSource(input) {
2598
+ if (input.source_trust === "document")
2599
+ return 0.9;
2600
+ if (input.source_trust === "manual")
2601
+ return 0.9;
2602
+ switch (input.source) {
2603
+ case "manual":
2604
+ return 0.9;
2605
+ case "assistant":
2606
+ return 0.8;
2607
+ case "agent-run":
2608
+ return 0.6;
2609
+ case "import":
2610
+ return 0.6;
2611
+ default:
2612
+ return 0.7;
2613
+ }
2614
+ }
2615
+ var TYPE_IMPORTANCE_DEFAULT2 = {
2616
+ preference: 9,
2617
+ lesson: 8,
2618
+ decision: 8,
2619
+ pattern: 7,
2620
+ solution: 7,
2621
+ procedure: 7,
2622
+ error: 5,
2623
+ context: 5,
2624
+ task: 5,
2625
+ agent: 5,
2626
+ relationship: 6,
2627
+ commitment: 7,
2628
+ project: 6,
2629
+ handoff: 6
2630
+ };
2631
+ var FILE_PATH = /(?:[\w.-]+\/){1,}[\w.-]+\.[a-z]{1,4}\b|(?:[\w-]+\/){2,}[\w-]+/i;
2632
+ var GUIDANCE_SECTION = /\b(why|how to apply|how to use|takeaway|root cause)\b/i;
2633
+ var PROPER_NOUN = /`[^`]+`|\b[A-Z][a-z0-9]+[A-Z][A-Za-z0-9]*\b|\b[a-z]+_[a-z][\w]*\b/;
2634
+ function signalDensity(content) {
2635
+ let n = 0;
2636
+ if (FILE_PATH.test(content))
2637
+ n += 1;
2638
+ if (GUIDANCE_SECTION.test(content))
2639
+ n += 1;
2640
+ if (PROPER_NOUN.test(content))
2641
+ n += 1;
2642
+ return n;
2643
+ }
2644
+ function importanceWithSignalBump(type, content) {
2645
+ const base = TYPE_IMPORTANCE_DEFAULT2[type] ?? 5;
2646
+ const bump = Math.min(2, signalDensity(content));
2647
+ const v = base + bump;
2648
+ if (v < 1)
2649
+ return 1;
2650
+ if (v > 10)
2651
+ return 10;
2652
+ return v;
2653
+ }
2441
2654
 
2442
2655
  // src/memory-session.ts
2443
2656
  var SESSION_SCOPE_PREFIX = "session:";
@@ -2458,6 +2671,49 @@ function sessionScopeFor(agentSessionId) {
2458
2671
  return `${SESSION_SCOPE_PREFIX}${agentSessionId}`;
2459
2672
  }
2460
2673
 
2674
+ // src/memory-tags.ts
2675
+ var CARD_REF = /^card[\s:#-]+(\d+)$/;
2676
+ function normalizeTag(tag) {
2677
+ const cleaned = tag.trim().toLowerCase().replace(/\s+/g, " ");
2678
+ const cardMatch = cleaned.match(CARD_REF);
2679
+ if (cardMatch) {
2680
+ return `card:${cardMatch[1]}`;
2681
+ }
2682
+ return cleaned;
2683
+ }
2684
+ function normalizeTags(tags) {
2685
+ const seen = new Set;
2686
+ const out = [];
2687
+ for (const raw of tags) {
2688
+ const norm = normalizeTag(raw);
2689
+ if (!norm || seen.has(norm))
2690
+ continue;
2691
+ seen.add(norm);
2692
+ out.push(norm);
2693
+ }
2694
+ return out;
2695
+ }
2696
+ function lintTags(tags) {
2697
+ const suggestions = [];
2698
+ for (const tag of tags) {
2699
+ const norm = normalizeTag(tag);
2700
+ if (norm === tag)
2701
+ continue;
2702
+ let reason;
2703
+ if (CARD_REF.test(tag.trim().toLowerCase()) && norm.startsWith("card:")) {
2704
+ reason = "non-canonical card ref; use 'card:<n>'";
2705
+ } else if (tag !== tag.trim() || /\s{2,}/.test(tag)) {
2706
+ reason = "whitespace; trim and collapse";
2707
+ } else if (tag !== tag.toLowerCase()) {
2708
+ reason = "case variant; use lowercase";
2709
+ } else {
2710
+ reason = "normalize to canonical form";
2711
+ }
2712
+ suggestions.push({ tag, suggestion: norm, reason });
2713
+ }
2714
+ return suggestions;
2715
+ }
2716
+
2461
2717
  // src/onboard.ts
2462
2718
  async function onboardNewUser(params) {
2463
2719
  const {
@@ -3675,6 +3931,10 @@ var TOOLS = {
3675
3931
  projectId: {
3676
3932
  type: "string",
3677
3933
  description: "Project ID (optional, required if scope is 'project')"
3934
+ },
3935
+ supersedeId: {
3936
+ type: "string",
3937
+ description: "Optional. ID of an existing memory entity to soft-supersede with " + "this new one (sets its superseded_by/superseded_at). Use after a " + "previous harmony_remember returned a `similar` candidate you want " + "to replace. Reversible; never deletes or merges content. The new " + "entity is always created regardless."
3678
3938
  }
3679
3939
  },
3680
3940
  required: ["title", "content"]
@@ -3848,6 +4108,49 @@ var TOOLS = {
3848
4108
  required: ["sourceId", "targetId", "relationType"]
3849
4109
  }
3850
4110
  },
4111
+ harmony_suggest_relations: {
4112
+ description: "Suggest existing memory entities that look related to a given entity, using the same hybrid vector+FTS similarity as recall (card #281). Read-only: it NEVER creates relations — it only proposes candidates for a caller (human/agent) to relate explicitly via harmony_relate. Use to surface 'these look related — link?' candidates that would activate graph-walk retrieval.",
4113
+ inputSchema: {
4114
+ type: "object",
4115
+ properties: {
4116
+ entityId: {
4117
+ type: "string",
4118
+ description: "The entity UUID to find related candidates for"
4119
+ },
4120
+ limit: {
4121
+ type: "number",
4122
+ description: "Max suggestions to return (default: 5)"
4123
+ },
4124
+ workspaceId: {
4125
+ type: "string",
4126
+ description: "Workspace ID (optional if context set)"
4127
+ },
4128
+ projectId: {
4129
+ type: "string",
4130
+ description: "Project ID (optional)"
4131
+ }
4132
+ },
4133
+ required: ["entityId"]
4134
+ }
4135
+ },
4136
+ harmony_recall_feedback: {
4137
+ description: "Record a \uD83D\uDC4D/\uD83D\uDC4E on a recalled memory so future recalls can rank it better or worse. Non-destructive: stores a counter at metadata.feedback={up,down} and only affects ranking — it never deletes, hides, or supersedes the memory. Call after a recall when a memory proved useful (vote 'up') or misleading/irrelevant (vote 'down').",
4138
+ inputSchema: {
4139
+ type: "object",
4140
+ properties: {
4141
+ entityId: {
4142
+ type: "string",
4143
+ description: "Memory entity UUID that was recalled"
4144
+ },
4145
+ vote: {
4146
+ type: "string",
4147
+ enum: ["up", "down"],
4148
+ description: "'up' if the memory was useful, 'down' if it was misleading or irrelevant"
4149
+ }
4150
+ },
4151
+ required: ["entityId", "vote"]
4152
+ }
4153
+ },
3851
4154
  harmony_memory_search: {
3852
4155
  description: "Full-text and semantic search across the knowledge base. Uses hybrid vector+FTS search (when embeddings are available) for best results. Returns entities ranked by relevance.",
3853
4156
  inputSchema: {
@@ -4925,7 +5228,9 @@ async function handleToolCall(name, args, deps) {
4925
5228
  throw new Error("No workspace specified. Use harmony_set_workspace_context or provide workspaceId.");
4926
5229
  }
4927
5230
  const entityType = args.type || "context";
4928
- const entityTags = args.tags || [];
5231
+ const rawTags = args.tags || [];
5232
+ const entityTags = normalizeTags(rawTags);
5233
+ const tagSuggestions = lintTags(rawTags);
4929
5234
  const activeMemSession = getActiveMemorySession();
4930
5235
  const entityScope = resolveSessionScope(args.scope, activeMemSession?.agentSessionId) || "project";
4931
5236
  const floorRejection = validateMemoryQuality({
@@ -4939,24 +5244,59 @@ async function handleToolCall(name, args, deps) {
4939
5244
  if (floorRejection) {
4940
5245
  throw new Error(`Memory rejected by Utility Floor [${floorRejection.rule}]: ${floorRejection.message}`);
4941
5246
  }
4942
- const callerImportance = args.importance !== undefined ? z.number().int().min(1).max(10).parse(args.importance) : undefined;
5247
+ const sourceTrust = args.source_trust;
5248
+ const source = activeMemSession ? activeMemSession.agentIdentifier === "assistant" ? "assistant" : "agent-run" : "manual";
5249
+ const origin = buildOrigin({
5250
+ source,
5251
+ source_card_id: activeMemSession?.cardId,
5252
+ source_session_id: activeMemSession?.agentSessionId,
5253
+ author: activeMemSession?.agentIdentifier ?? "user",
5254
+ source_trust: sourceTrust
5255
+ });
5256
+ const callerMetadata = args.metadata;
5257
+ const metadataWithOrigin = {
5258
+ ...callerMetadata ?? {},
5259
+ origin
5260
+ };
5261
+ const confidence = args.confidence !== undefined ? z.number().min(0).max(1).parse(args.confidence) : defaultConfidenceForSource({ source, source_trust: sourceTrust });
5262
+ const importance = args.importance !== undefined ? z.number().int().min(1).max(10).parse(args.importance) : importanceWithSignalBump(entityType, content);
5263
+ const projectIdForWrite = args.projectId || deps.getActiveProjectId() || undefined;
5264
+ let similar = [];
5265
+ if (!isSessionScope(entityScope)) {
5266
+ similar = await findSupersedeCandidates(client3, title, content, entityType, workspaceId, { projectId: projectIdForWrite, scope: entityScope });
5267
+ }
4943
5268
  const result = await client3.createMemoryEntity({
4944
5269
  workspace_id: workspaceId,
4945
- project_id: args.projectId || deps.getActiveProjectId() || undefined,
5270
+ project_id: projectIdForWrite,
4946
5271
  type: entityType,
4947
5272
  scope: entityScope,
4948
5273
  memory_tier: args.tier || undefined,
4949
5274
  title,
4950
5275
  content,
4951
- metadata: args.metadata,
4952
- confidence: args.confidence !== undefined ? z.number().min(0).max(1).parse(args.confidence) : undefined,
4953
- importance: callerImportance,
5276
+ metadata: metadataWithOrigin,
5277
+ confidence,
5278
+ importance,
4954
5279
  tags: entityTags.length > 0 ? entityTags : undefined,
4955
5280
  agent_identifier: activeMemSession?.agentIdentifier || undefined
4956
5281
  });
5282
+ const supersedeId = args.supersedeId;
5283
+ const newEntityId = result.entity?.id;
5284
+ let superseded;
5285
+ if (supersedeId && newEntityId) {
5286
+ try {
5287
+ await client3.updateMemoryEntity(supersedeId, {
5288
+ superseded_by: newEntityId,
5289
+ superseded_at: new Date().toISOString()
5290
+ });
5291
+ superseded = supersedeId;
5292
+ } catch (err) {
5293
+ const msg = err instanceof Error ? err.message : String(err);
5294
+ console.debug(`[harmony_remember] supersede ${supersedeId} failed: ${msg}`);
5295
+ }
5296
+ }
4957
5297
  const newEntityIdForGraph = result.entity?.id;
4958
5298
  if (newEntityIdForGraph) {
4959
- autoExpandGraph(client3, newEntityIdForGraph, title, content, entityTags, workspaceId, args.projectId || deps.getActiveProjectId() || undefined).catch(() => {});
5299
+ autoExpandGraph(client3, newEntityIdForGraph, title, content, entityTags, workspaceId, projectIdForWrite).catch(() => {});
4960
5300
  }
4961
5301
  if (activeMemSession) {
4962
5302
  appendMemoryAction(activeMemSession.cardId, `Stored memory: ${title}`);
@@ -4964,7 +5304,10 @@ async function handleToolCall(name, args, deps) {
4964
5304
  }
4965
5305
  return {
4966
5306
  success: true,
4967
- ...result
5307
+ ...result,
5308
+ ...similar.length > 0 ? { similar } : {},
5309
+ ...superseded ? { superseded } : {},
5310
+ ...tagSuggestions.length > 0 ? { tagSuggestions } : {}
4968
5311
  };
4969
5312
  }
4970
5313
  case "harmony_recall": {
@@ -4986,26 +5329,22 @@ async function handleToolCall(name, args, deps) {
4986
5329
  let entities;
4987
5330
  let relevanceMap;
4988
5331
  if (queryText) {
5332
+ const requestedTags = args.tags;
4989
5333
  const searchResult = await client3.searchMemoryEntities(workspaceId, queryText, {
4990
5334
  project_id: projectId,
4991
5335
  type: args.type,
4992
- limit: fetchLimit
5336
+ limit: fetchLimit,
5337
+ tags: requestedTags && requestedTags.length > 0 ? requestedTags : undefined,
5338
+ include_superseded: includeSuperseded
4993
5339
  });
4994
5340
  entities = searchResult.entities ?? [];
4995
- const requestedTags = args.tags;
4996
5341
  const minConfidence = args.minConfidence;
4997
5342
  if (userScopeFilter) {
4998
5343
  entities = entities.filter((e) => e?.scope === userScopeFilter);
4999
5344
  } else if (excludeSessionFromLongTerm) {
5000
5345
  entities = entities.filter((e) => !isSessionScope(e?.scope));
5001
5346
  }
5002
- if (requestedTags && requestedTags.length > 0) {
5003
- const wanted = new Set(requestedTags);
5004
- entities = entities.filter((e) => (e?.tags ?? []).some((t) => wanted.has(t)));
5005
- }
5006
- if (typeof minConfidence === "number") {
5007
- entities = entities.filter((e) => typeof e?.confidence === "number" && e.confidence >= minConfidence);
5008
- }
5347
+ entities = filterByMinConfidence(entities, minConfidence);
5009
5348
  if (!includeSuperseded) {
5010
5349
  entities = entities.filter((e) => !e?.superseded_at);
5011
5350
  }
@@ -5036,16 +5375,10 @@ async function handleToolCall(name, args, deps) {
5036
5375
  trimmed = fitToBudget(trimmed, budgetTokens);
5037
5376
  }
5038
5377
  if (trimmed.length > 0) {
5039
- Promise.all(trimmed.map(async ({ entity }) => {
5040
- try {
5041
- await client3.updateMemoryEntity(entity.id, {
5042
- metadata: {
5043
- ...entity.metadata || {},
5044
- _last_recall: new Date().toISOString()
5045
- }
5046
- });
5047
- } catch (_) {}
5048
- })).catch(() => {});
5378
+ const touchIds = trimmed.map(({ entity }) => entity?.id).filter((id) => typeof id === "string");
5379
+ if (touchIds.length > 0) {
5380
+ client3.batchTouchMemoryEntities(touchIds).catch(() => {});
5381
+ }
5049
5382
  }
5050
5383
  let sessionEntities = [];
5051
5384
  if (excludeSessionFromLongTerm && recallMemSession?.agentSessionId) {
@@ -5112,8 +5445,12 @@ async function handleToolCall(name, args, deps) {
5112
5445
  updates.type = args.type;
5113
5446
  if (args.scope !== undefined)
5114
5447
  updates.scope = args.scope;
5115
- if (args.tags !== undefined)
5116
- updates.tags = args.tags;
5448
+ let updateTagSuggestions = [];
5449
+ if (args.tags !== undefined) {
5450
+ const updateRawTags = args.tags;
5451
+ updates.tags = normalizeTags(updateRawTags);
5452
+ updateTagSuggestions = lintTags(updateRawTags);
5453
+ }
5117
5454
  if (args.confidence !== undefined)
5118
5455
  updates.confidence = z.number().min(0).max(1).parse(args.confidence);
5119
5456
  if (args.metadata !== undefined)
@@ -5125,7 +5462,11 @@ async function handleToolCall(name, args, deps) {
5125
5462
  appendMemoryAction(updateMemSession.cardId, `Updated memory: ${updateTitle}`);
5126
5463
  flushMemoryActions(client3, updateMemSession.cardId).catch(() => {});
5127
5464
  }
5128
- return { success: true, ...result };
5465
+ return {
5466
+ success: true,
5467
+ ...result,
5468
+ ...updateTagSuggestions.length > 0 ? { tagSuggestions: updateTagSuggestions } : {}
5469
+ };
5129
5470
  }
5130
5471
  case "harmony_forget": {
5131
5472
  const entityId = z.string().uuid().parse(args.entityId);
@@ -5170,6 +5511,64 @@ async function handleToolCall(name, args, deps) {
5170
5511
  }
5171
5512
  return { success: true, ...result };
5172
5513
  }
5514
+ case "harmony_suggest_relations": {
5515
+ const entityId = z.string().uuid().parse(args.entityId);
5516
+ const workspaceId = args.workspaceId || deps.getActiveWorkspaceId();
5517
+ if (!workspaceId) {
5518
+ throw new Error("No workspace specified. Use harmony_set_workspace_context or provide workspaceId.");
5519
+ }
5520
+ const projectId = args.projectId || deps.getActiveProjectId() || undefined;
5521
+ const limit = args.limit ?? 5;
5522
+ const { entity } = await client3.getMemoryEntity(entityId);
5523
+ const src = entity;
5524
+ if (!src) {
5525
+ throw new Error(`Entity not found: ${entityId}`);
5526
+ }
5527
+ const related = await client3.getRelatedEntities(entityId);
5528
+ const excludeIds = new Set([entityId]);
5529
+ for (const raw of [
5530
+ ...related.outgoing || [],
5531
+ ...related.incoming || []
5532
+ ]) {
5533
+ const rel = raw;
5534
+ const target = rel.target;
5535
+ const source = rel.source;
5536
+ const otherId = target?.id ?? rel.target_id ?? source?.id ?? rel.source_id;
5537
+ if (otherId)
5538
+ excludeIds.add(otherId);
5539
+ }
5540
+ const suggestions = await findSimilarEntities(client3, src.title ?? "", src.content ?? "", workspaceId, {
5541
+ projectId,
5542
+ limit: limit + excludeIds.size,
5543
+ excludeIds: [...excludeIds]
5544
+ });
5545
+ return {
5546
+ success: true,
5547
+ suggestions: suggestions.slice(0, limit).map((s) => ({
5548
+ id: s.id,
5549
+ title: s.title,
5550
+ type: s.type,
5551
+ rrf_score: s.rrf_score
5552
+ }))
5553
+ };
5554
+ }
5555
+ case "harmony_recall_feedback": {
5556
+ const entityId = z.string().uuid().parse(args.entityId);
5557
+ const vote = z.enum(["up", "down"]).parse(args.vote);
5558
+ const { entity } = await client3.getMemoryEntity(entityId);
5559
+ const existingMeta = entity?.metadata ?? {};
5560
+ const existingFeedback = existingMeta.feedback;
5561
+ const feedback = mergeFeedback(existingFeedback, vote);
5562
+ const result = await client3.updateMemoryEntity(entityId, {
5563
+ metadata: { ...existingMeta, feedback }
5564
+ });
5565
+ const fbMemSession = getActiveMemorySession();
5566
+ if (fbMemSession) {
5567
+ appendMemoryAction(fbMemSession.cardId, `Memory feedback: ${vote === "up" ? "\uD83D\uDC4D" : "\uD83D\uDC4E"} ${entityId.slice(0, 8)}`);
5568
+ flushMemoryActions(client3, fbMemSession.cardId).catch(() => {});
5569
+ }
5570
+ return { success: true, feedback, ...result };
5571
+ }
5173
5572
  case "harmony_memory_search": {
5174
5573
  const query = z.string().min(1).max(500).parse(args.query);
5175
5574
  const workspaceId = args.workspaceId || deps.getActiveWorkspaceId();