@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/src/server.ts CHANGED
@@ -34,19 +34,34 @@ import {
34
34
  setActiveProject,
35
35
  setActiveWorkspace,
36
36
  } from "./config.js";
37
- import { autoExpandGraph } from "./graph-expansion.js";
37
+ import {
38
+ autoExpandGraph,
39
+ findSimilarEntities,
40
+ findSupersedeCandidates,
41
+ type SupersedeCandidate,
42
+ } from "./graph-expansion.js";
38
43
  import { validateMemoryQuality } from "./memory-floor.js";
39
44
  import {
45
+ filterByMinConfidence,
40
46
  fitToBudget,
47
+ type MemoryFeedback,
48
+ mergeFeedback,
41
49
  type ParkInput,
42
50
  relevanceFromRank,
43
51
  rescore,
44
52
  } from "./memory-park.js";
53
+ import {
54
+ buildOrigin,
55
+ defaultConfidenceForSource,
56
+ importanceWithSignalBump,
57
+ type MemorySource,
58
+ } from "./memory-provenance.js";
45
59
  import {
46
60
  isSessionScope,
47
61
  resolveSessionScope,
48
62
  sessionScopeFor,
49
63
  } from "./memory-session.js";
64
+ import { lintTags, normalizeTags } from "./memory-tags.js";
50
65
  import { onboardNewUser } from "./onboard.js";
51
66
  import { stripSkillPreamble } from "./skills.js";
52
67
 
@@ -1101,6 +1116,15 @@ export const TOOLS = {
1101
1116
  type: "string",
1102
1117
  description: "Project ID (optional, required if scope is 'project')",
1103
1118
  },
1119
+ supersedeId: {
1120
+ type: "string",
1121
+ description:
1122
+ "Optional. ID of an existing memory entity to soft-supersede with " +
1123
+ "this new one (sets its superseded_by/superseded_at). Use after a " +
1124
+ "previous harmony_remember returned a `similar` candidate you want " +
1125
+ "to replace. Reversible; never deletes or merges content. The new " +
1126
+ "entity is always created regardless.",
1127
+ },
1104
1128
  },
1105
1129
  required: ["title", "content"],
1106
1130
  },
@@ -1282,6 +1306,52 @@ export const TOOLS = {
1282
1306
  required: ["sourceId", "targetId", "relationType"],
1283
1307
  },
1284
1308
  },
1309
+ harmony_suggest_relations: {
1310
+ description:
1311
+ "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.",
1312
+ inputSchema: {
1313
+ type: "object",
1314
+ properties: {
1315
+ entityId: {
1316
+ type: "string",
1317
+ description: "The entity UUID to find related candidates for",
1318
+ },
1319
+ limit: {
1320
+ type: "number",
1321
+ description: "Max suggestions to return (default: 5)",
1322
+ },
1323
+ workspaceId: {
1324
+ type: "string",
1325
+ description: "Workspace ID (optional if context set)",
1326
+ },
1327
+ projectId: {
1328
+ type: "string",
1329
+ description: "Project ID (optional)",
1330
+ },
1331
+ },
1332
+ required: ["entityId"],
1333
+ },
1334
+ },
1335
+ harmony_recall_feedback: {
1336
+ description:
1337
+ "Record a 👍/👎 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').",
1338
+ inputSchema: {
1339
+ type: "object",
1340
+ properties: {
1341
+ entityId: {
1342
+ type: "string",
1343
+ description: "Memory entity UUID that was recalled",
1344
+ },
1345
+ vote: {
1346
+ type: "string",
1347
+ enum: ["up", "down"],
1348
+ description:
1349
+ "'up' if the memory was useful, 'down' if it was misleading or irrelevant",
1350
+ },
1351
+ },
1352
+ required: ["entityId", "vote"],
1353
+ },
1354
+ },
1285
1355
  harmony_memory_search: {
1286
1356
  description:
1287
1357
  "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.",
@@ -2773,7 +2843,13 @@ async function handleToolCall(
2773
2843
  );
2774
2844
  }
2775
2845
  const entityType = (args.type as string) || "context";
2776
- const entityTags = (args.tags as string[]) || [];
2846
+ const rawTags = (args.tags as string[]) || [];
2847
+ // Tag taxonomy normalization (#274): canonicalize before any downstream
2848
+ // use so the Floor, the write, and graph expansion all see the same
2849
+ // canonical card refs / casing. Lint is advisory — surfaced in the
2850
+ // response, never rejecting.
2851
+ const entityTags = normalizeTags(rawTags);
2852
+ const tagSuggestions = lintTags(rawTags);
2777
2853
 
2778
2854
  // Working-memory binding (plan §12 Phase 1, §13.1 D3). The caller may
2779
2855
  // pass `scope: 'session'` as an alias; resolve it to the active
@@ -2804,35 +2880,116 @@ async function handleToolCall(
2804
2880
  );
2805
2881
  }
2806
2882
 
2807
- // Importance: caller-provided > per-type default (plan §7.1).
2808
- // auto_score_importance is documented as an LLM-rating opt-in but the
2809
- // actual LLM call is deferred to a follow-up commit; currently it falls
2810
- // through to the type default, same as omission.
2811
- const callerImportance =
2883
+ // Provenance (#273, task 2). Infer the source from context: an active
2884
+ // memory session means the write came from an agent run (or the board
2885
+ // assistant proxying for the user). With no session it's a manual,
2886
+ // human-curated capture. The origin record rides inside metadata.origin
2887
+ // there is no dedicated provenance column.
2888
+ const sourceTrust = args.source_trust as string | undefined;
2889
+ const source: MemorySource = activeMemSession
2890
+ ? activeMemSession.agentIdentifier === "assistant"
2891
+ ? "assistant"
2892
+ : "agent-run"
2893
+ : "manual";
2894
+ const origin = buildOrigin({
2895
+ source,
2896
+ source_card_id: activeMemSession?.cardId,
2897
+ source_session_id: activeMemSession?.agentSessionId,
2898
+ author: activeMemSession?.agentIdentifier ?? "user",
2899
+ source_trust: sourceTrust,
2900
+ });
2901
+ const callerMetadata = args.metadata as
2902
+ | Record<string, unknown>
2903
+ | undefined;
2904
+ const metadataWithOrigin: Record<string, unknown> = {
2905
+ ...(callerMetadata ?? {}),
2906
+ origin,
2907
+ };
2908
+
2909
+ // Confidence (#273, task 5). An explicit confidence always wins. When
2910
+ // omitted, derive a non-uniform default from source/trust so
2911
+ // minConfidence filtering on recall actually bites: manual/curated stays
2912
+ // high, agent auto-extract / low-trust starts moderate. (Was: edge fn
2913
+ // defaulted everything to 1.0, making the whole vault uniform.)
2914
+ const confidence =
2915
+ args.confidence !== undefined
2916
+ ? z.number().min(0).max(1).parse(args.confidence)
2917
+ : defaultConfidenceForSource({ source, source_trust: sourceTrust });
2918
+
2919
+ // Importance (#273, task 6). Caller-provided wins; auto_score_importance
2920
+ // (LLM rating) stays deferred per the 2026-05-08 decision (the AGP
2921
+ // auto-scorer was reverted in 43782a4 for poisoning the corpus). When
2922
+ // omitted, use the per-type default plus a bounded, deterministic
2923
+ // signal-density bump so the Park rescore has a non-flat importance term.
2924
+ const importance =
2812
2925
  args.importance !== undefined
2813
2926
  ? z.number().int().min(1).max(10).parse(args.importance)
2814
- : undefined;
2927
+ : importanceWithSignalBump(entityType, content);
2928
+
2929
+ const projectIdForWrite =
2930
+ (args.projectId as string) || deps.getActiveProjectId() || undefined;
2931
+
2932
+ // Write-time semantic dedup probe (card #275). BEFORE inserting, look for
2933
+ // existing non-superseded entities of the same type + scope that look
2934
+ // like near-duplicates, reusing the hybrid-search path (no new embedding
2935
+ // pipeline). This NEVER blocks the write — the result is surfaced in the
2936
+ // `similar` response field so the caller can choose to supersede later
2937
+ // via the `supersedeId` param. Session-scoped (ephemeral) writes skip the
2938
+ // probe entirely: working memory is short-lived by design.
2939
+ let similar: SupersedeCandidate[] = [];
2940
+ if (!isSessionScope(entityScope)) {
2941
+ similar = await findSupersedeCandidates(
2942
+ client,
2943
+ title,
2944
+ content,
2945
+ entityType,
2946
+ workspaceId,
2947
+ { projectId: projectIdForWrite, scope: entityScope },
2948
+ );
2949
+ }
2815
2950
 
2816
- // Use session's agent identifier if available, otherwise null
2817
2951
  const result = await client.createMemoryEntity({
2818
2952
  workspace_id: workspaceId,
2819
- project_id:
2820
- (args.projectId as string) || deps.getActiveProjectId() || undefined,
2953
+ project_id: projectIdForWrite,
2821
2954
  type: entityType,
2822
2955
  scope: entityScope,
2823
2956
  memory_tier: (args.tier as string) || undefined,
2824
2957
  title,
2825
2958
  content,
2826
- metadata: args.metadata as Record<string, unknown> | undefined,
2827
- confidence:
2828
- args.confidence !== undefined
2829
- ? z.number().min(0).max(1).parse(args.confidence)
2830
- : undefined,
2831
- importance: callerImportance,
2959
+ metadata: metadataWithOrigin,
2960
+ confidence,
2961
+ importance,
2832
2962
  tags: entityTags.length > 0 ? entityTags : undefined,
2833
2963
  agent_identifier: activeMemSession?.agentIdentifier || undefined,
2834
2964
  });
2835
2965
 
2966
+ // Explicit supersede branch (card #275). When the caller passes
2967
+ // `supersedeId`, soft-tombstone that existing entity by pointing its
2968
+ // superseded_by at the row we just created. This is the ONLY mutation of
2969
+ // an existing memory and it is always caller-driven — never automatic.
2970
+ // Soft + reversible (clearing superseded_by restores it); no hard delete,
2971
+ // no content merge. The AGP auto-consolidation layer was reverted
2972
+ // (43782a4) for autonomously merging — this keeps the decision explicit.
2973
+ const supersedeId = args.supersedeId as string | undefined;
2974
+ const newEntityId = (result.entity as { id?: string } | null)?.id;
2975
+ let superseded: string | undefined;
2976
+ if (supersedeId && newEntityId) {
2977
+ try {
2978
+ await client.updateMemoryEntity(supersedeId, {
2979
+ superseded_by: newEntityId,
2980
+ superseded_at: new Date().toISOString(),
2981
+ });
2982
+ superseded = supersedeId;
2983
+ } catch (err) {
2984
+ // A failed supersede must not fail the write — the new entity already
2985
+ // exists. Surface the failure in logs; caller can retry the tombstone.
2986
+ const msg = err instanceof Error ? err.message : String(err);
2987
+ console.debug(
2988
+ `[harmony_remember] supersede ${supersedeId} failed: ${msg}`,
2989
+ );
2990
+ }
2991
+ }
2992
+
2836
2993
  // Fire-and-forget graph expansion: link new entity to semantically similar ones
2837
2994
  const newEntityIdForGraph = (result.entity as any)?.id;
2838
2995
  if (newEntityIdForGraph) {
@@ -2843,7 +3000,7 @@ async function handleToolCall(
2843
3000
  content,
2844
3001
  entityTags,
2845
3002
  workspaceId,
2846
- (args.projectId as string) || deps.getActiveProjectId() || undefined,
3003
+ projectIdForWrite,
2847
3004
  ).catch(() => {});
2848
3005
  }
2849
3006
 
@@ -2859,6 +3016,15 @@ async function handleToolCall(
2859
3016
  return {
2860
3017
  success: true,
2861
3018
  ...result,
3019
+ // Near-duplicate existing entities (same type + scope) above threshold.
3020
+ // Always present; empty when nothing similar was found. Non-breaking:
3021
+ // existing callers ignore it. Callers may re-call harmony_remember with
3022
+ // `supersedeId` set to one of these ids to tombstone the older row.
3023
+ ...(similar.length > 0 ? { similar } : {}),
3024
+ ...(superseded ? { superseded } : {}),
3025
+ // Advisory tag-lint hints (#274). Present only when the caller's tags
3026
+ // were non-canonical; the tags were still normalized and written.
3027
+ ...(tagSuggestions.length > 0 ? { tagSuggestions } : {}),
2862
3028
  };
2863
3029
  }
2864
3030
 
@@ -2914,6 +3080,12 @@ async function handleToolCall(
2914
3080
  let relevanceMap: Map<string, number>;
2915
3081
 
2916
3082
  if (queryText) {
3083
+ const requestedTags = args.tags as string[] | undefined;
3084
+ // Tag + superseded filtering is now authoritative in the hybrid_search
3085
+ // RPC (#298/#299): tags match the canonical `tags_normalized` column at
3086
+ // the DB level (no client-side fetch-then-filter completeness gap), and
3087
+ // tombstoned rows are excluded unless include_superseded is set. Tags
3088
+ // are normalized server-side; we pass them through verbatim.
2917
3089
  const searchResult = await client.searchMemoryEntities(
2918
3090
  workspaceId,
2919
3091
  queryText,
@@ -2921,14 +3093,18 @@ async function handleToolCall(
2921
3093
  project_id: projectId,
2922
3094
  type: args.type as string | undefined,
2923
3095
  limit: fetchLimit,
3096
+ tags:
3097
+ requestedTags && requestedTags.length > 0
3098
+ ? requestedTags
3099
+ : undefined,
3100
+ include_superseded: includeSuperseded,
2924
3101
  },
2925
3102
  );
2926
3103
  entities = (searchResult.entities ?? []) as any[];
2927
3104
 
2928
- // Post-filter the rest of the params client-side. Hybrid search RPC
2929
- // exposes only project_id + type; tags / scope / minConfidence /
2930
- // include_superseded are applied here on the rank-ordered set.
2931
- const requestedTags = args.tags as string[] | undefined;
3105
+ // Post-filter the params the RPC does not handle. Scope + minConfidence
3106
+ // are applied here on the rank-ordered set. (Tags + include_superseded
3107
+ // are handled server-side above.)
2932
3108
  const minConfidence = args.minConfidence as number | undefined;
2933
3109
  if (userScopeFilter) {
2934
3110
  entities = entities.filter((e) => e?.scope === userScopeFilter);
@@ -2937,19 +3113,9 @@ async function handleToolCall(
2937
3113
  // out of the long-term mix so the same row never shows twice.
2938
3114
  entities = entities.filter((e) => !isSessionScope(e?.scope));
2939
3115
  }
2940
- if (requestedTags && requestedTags.length > 0) {
2941
- const wanted = new Set(requestedTags);
2942
- entities = entities.filter((e) =>
2943
- (e?.tags ?? []).some((t: string) => wanted.has(t)),
2944
- );
2945
- }
2946
- if (typeof minConfidence === "number") {
2947
- entities = entities.filter(
2948
- (e) =>
2949
- typeof e?.confidence === "number" &&
2950
- e.confidence >= minConfidence,
2951
- );
2952
- }
3116
+ entities = filterByMinConfidence(entities, minConfidence);
3117
+ // Belt-and-suspenders: the RPC already excludes tombstoned rows; this
3118
+ // also drops any if a stale/FTS path slipped one through.
2953
3119
  if (!includeSuperseded) {
2954
3120
  entities = entities.filter((e) => !e?.superseded_at);
2955
3121
  }
@@ -2992,23 +3158,20 @@ async function handleToolCall(
2992
3158
  trimmed = fitToBudget(trimmed, budgetTokens);
2993
3159
  }
2994
3160
 
2995
- // Touch access timestamps for the entities we returned (basic Park
2996
- // recency trail). Non-blocking.
3161
+ // Touch access counters for the entities we returned (#273, task 4).
3162
+ // Previously this wrote `metadata._last_recall` via updateMemoryEntity,
3163
+ // which left `access_count` at 0 forever and never updated
3164
+ // `last_accessed_at` — the column the Park recency term actually reads.
3165
+ // Route through batch-touch, which calls the atomic
3166
+ // `batch_touch_knowledge_entities` RPC (access_count += 1, last_accessed_at
3167
+ // = now) in a single round-trip. Non-blocking; best-effort.
2997
3168
  if (trimmed.length > 0) {
2998
- Promise.all(
2999
- trimmed.map(async ({ entity }: { entity: any }) => {
3000
- try {
3001
- await client.updateMemoryEntity(entity.id, {
3002
- metadata: {
3003
- ...(entity.metadata || {}),
3004
- _last_recall: new Date().toISOString(),
3005
- },
3006
- });
3007
- } catch (_) {
3008
- // Non-critical
3009
- }
3010
- }),
3011
- ).catch(() => {});
3169
+ const touchIds = trimmed
3170
+ .map(({ entity }: { entity: any }) => entity?.id)
3171
+ .filter((id: unknown): id is string => typeof id === "string");
3172
+ if (touchIds.length > 0) {
3173
+ client.batchTouchMemoryEntities(touchIds).catch(() => {});
3174
+ }
3012
3175
  }
3013
3176
 
3014
3177
  // Working-memory prepend (plan §12 Phase 1). When a session is active
@@ -3086,7 +3249,15 @@ async function handleToolCall(
3086
3249
  updates.content = z.string().max(50000).parse(args.content);
3087
3250
  if (args.type !== undefined) updates.type = args.type;
3088
3251
  if (args.scope !== undefined) updates.scope = args.scope;
3089
- if (args.tags !== undefined) updates.tags = args.tags;
3252
+ // Tag taxonomy normalization (#274): same canonicalization + advisory
3253
+ // lint as harmony_remember. Normalized tags are persisted; suggestions
3254
+ // are non-fatal and surfaced in the response.
3255
+ let updateTagSuggestions: ReturnType<typeof lintTags> = [];
3256
+ if (args.tags !== undefined) {
3257
+ const updateRawTags = args.tags as string[];
3258
+ updates.tags = normalizeTags(updateRawTags);
3259
+ updateTagSuggestions = lintTags(updateRawTags);
3260
+ }
3090
3261
  if (args.confidence !== undefined)
3091
3262
  updates.confidence = z.number().min(0).max(1).parse(args.confidence);
3092
3263
  if (args.metadata !== undefined) updates.metadata = args.metadata;
@@ -3103,7 +3274,13 @@ async function handleToolCall(
3103
3274
  flushMemoryActions(client, updateMemSession.cardId).catch(() => {});
3104
3275
  }
3105
3276
 
3106
- return { success: true, ...result };
3277
+ return {
3278
+ success: true,
3279
+ ...result,
3280
+ ...(updateTagSuggestions.length > 0
3281
+ ? { tagSuggestions: updateTagSuggestions }
3282
+ : {}),
3283
+ };
3107
3284
  }
3108
3285
 
3109
3286
  case "harmony_forget": {
@@ -3173,6 +3350,112 @@ async function handleToolCall(
3173
3350
  return { success: true, ...result };
3174
3351
  }
3175
3352
 
3353
+ case "harmony_suggest_relations": {
3354
+ // Relation suggestions (card #281, task 3). Read-only: reuses the
3355
+ // existing hybrid similarity helper (findSimilarEntities, card #275) to
3356
+ // propose candidates the caller may explicitly relate via harmony_relate.
3357
+ // It NEVER writes a relation — surfacing only. Already-related entities
3358
+ // and the entity itself are excluded so suggestions are net-new edges.
3359
+ const entityId = z.string().uuid().parse(args.entityId);
3360
+ const workspaceId =
3361
+ (args.workspaceId as string) || deps.getActiveWorkspaceId();
3362
+ if (!workspaceId) {
3363
+ throw new Error(
3364
+ "No workspace specified. Use harmony_set_workspace_context or provide workspaceId.",
3365
+ );
3366
+ }
3367
+ const projectId =
3368
+ (args.projectId as string) || deps.getActiveProjectId() || undefined;
3369
+ const limit = (args.limit as number | undefined) ?? 5;
3370
+
3371
+ const { entity } = await client.getMemoryEntity(entityId);
3372
+ const src = entity as {
3373
+ title?: string;
3374
+ content?: string;
3375
+ } | null;
3376
+ if (!src) {
3377
+ throw new Error(`Entity not found: ${entityId}`);
3378
+ }
3379
+
3380
+ // Exclude the entity itself plus anything it is already related to, so
3381
+ // suggestions only ever propose new edges.
3382
+ const related = await client.getRelatedEntities(entityId);
3383
+ const excludeIds = new Set<string>([entityId]);
3384
+ for (const raw of [
3385
+ ...(related.outgoing || []),
3386
+ ...(related.incoming || []),
3387
+ ]) {
3388
+ const rel = raw as Record<string, unknown>;
3389
+ const target = rel.target as Record<string, unknown> | undefined;
3390
+ const source = rel.source as Record<string, unknown> | undefined;
3391
+ const otherId =
3392
+ (target?.id as string) ??
3393
+ (rel.target_id as string) ??
3394
+ (source?.id as string) ??
3395
+ (rel.source_id as string);
3396
+ if (otherId) excludeIds.add(otherId);
3397
+ }
3398
+
3399
+ const suggestions = await findSimilarEntities(
3400
+ client,
3401
+ src.title ?? "",
3402
+ src.content ?? "",
3403
+ workspaceId,
3404
+ {
3405
+ projectId,
3406
+ limit: limit + excludeIds.size,
3407
+ excludeIds: [...excludeIds],
3408
+ },
3409
+ );
3410
+
3411
+ return {
3412
+ success: true,
3413
+ suggestions: suggestions.slice(0, limit).map((s) => ({
3414
+ id: s.id,
3415
+ title: s.title,
3416
+ type: s.type,
3417
+ rrf_score: s.rrf_score,
3418
+ })),
3419
+ };
3420
+ }
3421
+
3422
+ case "harmony_recall_feedback": {
3423
+ // Record-success feedback loop (card #279, task 3). Non-destructive:
3424
+ // increments a counter in metadata.feedback and writes it back via the
3425
+ // standard metadata-merge update path. Affects RANKING ONLY (the Park
3426
+ // rescore reads metadata.feedback into effective importance); the memory
3427
+ // is never deleted, hidden, or superseded.
3428
+ const entityId = z.string().uuid().parse(args.entityId);
3429
+ const vote = z.enum(["up", "down"]).parse(args.vote);
3430
+
3431
+ const { entity } = await client.getMemoryEntity(entityId);
3432
+ const existingMeta =
3433
+ (entity as { metadata?: Record<string, unknown> } | null)?.metadata ??
3434
+ {};
3435
+ const existingFeedback = (existingMeta as { feedback?: MemoryFeedback })
3436
+ .feedback;
3437
+ const feedback = mergeFeedback(existingFeedback, vote);
3438
+
3439
+ // The harmony-api update REPLACES the metadata column (no server-side
3440
+ // merge), so we must send the full object: existing metadata (incl.
3441
+ // metadata.origin provenance from #273) plus the updated feedback delta.
3442
+ // Sending only { feedback } would wipe provenance and everything else.
3443
+ const result = await client.updateMemoryEntity(entityId, {
3444
+ metadata: { ...existingMeta, feedback },
3445
+ });
3446
+
3447
+ const fbMemSession = getActiveMemorySession();
3448
+ if (fbMemSession) {
3449
+ appendMemoryAction(
3450
+ fbMemSession.cardId,
3451
+ `Memory feedback: ${vote === "up" ? "👍" : "👎"} ${entityId.slice(0, 8)}`,
3452
+ );
3453
+ flushMemoryActions(client, fbMemSession.cardId).catch(() => {});
3454
+ }
3455
+
3456
+ return { success: true, feedback, ...result };
3457
+ }
3458
+
3176
3459
  case "harmony_memory_search": {
3177
3460
  const query = z.string().min(1).max(500).parse(args.query);
3178
3461
  const workspaceId =