@gethmy/mcp 2.8.3 → 2.8.5

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
 
@@ -308,6 +323,12 @@ export const TOOLS = {
308
323
  assigneeId: { type: "string", nullable: true },
309
324
  dueDate: { type: "string", nullable: true },
310
325
  done: { type: "boolean", description: "Mark card as done or not done" },
326
+ planId: {
327
+ type: "string",
328
+ nullable: true,
329
+ description:
330
+ "Plan ID to link this card to, or null to unlink. Sets cards.plan_id. The plan must exist and belong to the same project.",
331
+ },
311
332
  },
312
333
  required: ["cardId"],
313
334
  },
@@ -625,6 +646,26 @@ export const TOOLS = {
625
646
  required: ["subtaskId"],
626
647
  },
627
648
  },
649
+ harmony_update_subtask: {
650
+ description:
651
+ "Update a subtask: rename its title, set an explicit completion state, and/or reorder it. Unlike harmony_toggle_subtask (which flips completion), `completed` here sets an explicit value — safe to call idempotently. At least one of title/completed/position is required.",
652
+ inputSchema: {
653
+ type: "object",
654
+ properties: {
655
+ subtaskId: { type: "string" },
656
+ title: { type: "string", description: "New title (1–500 chars)" },
657
+ completed: {
658
+ type: "boolean",
659
+ description: "Explicit completion state (set, not toggle)",
660
+ },
661
+ position: {
662
+ type: "number",
663
+ description: "New position (0-based ordering within the card)",
664
+ },
665
+ },
666
+ required: ["subtaskId"],
667
+ },
668
+ },
628
669
  harmony_delete_subtask: {
629
670
  description: "Delete a subtask",
630
671
  inputSchema: {
@@ -1075,6 +1116,15 @@ export const TOOLS = {
1075
1116
  type: "string",
1076
1117
  description: "Project ID (optional, required if scope is 'project')",
1077
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
+ },
1078
1128
  },
1079
1129
  required: ["title", "content"],
1080
1130
  },
@@ -1256,6 +1306,52 @@ export const TOOLS = {
1256
1306
  required: ["sourceId", "targetId", "relationType"],
1257
1307
  },
1258
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
+ },
1259
1355
  harmony_memory_search: {
1260
1356
  description:
1261
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.",
@@ -1944,6 +2040,13 @@ async function handleToolCall(
1944
2040
 
1945
2041
  case "harmony_update_card": {
1946
2042
  const cardId = z.string().uuid().parse(args.cardId);
2043
+ // null = unlink, undefined = leave untouched, otherwise must be a UUID.
2044
+ const planId =
2045
+ args.planId === undefined
2046
+ ? undefined
2047
+ : args.planId === null
2048
+ ? null
2049
+ : z.string().uuid().parse(args.planId);
1947
2050
  const result = await client.updateCard(cardId, {
1948
2051
  title: args.title as string | undefined,
1949
2052
  description: args.description as string | undefined,
@@ -1951,6 +2054,7 @@ async function handleToolCall(
1951
2054
  assigneeId: args.assigneeId as string | null | undefined,
1952
2055
  dueDate: args.dueDate as string | null | undefined,
1953
2056
  done: args.done as boolean | undefined,
2057
+ planId,
1954
2058
  });
1955
2059
  return { success: true, ...result };
1956
2060
  }
@@ -2237,6 +2341,31 @@ async function handleToolCall(
2237
2341
  return { success: true, ...result };
2238
2342
  }
2239
2343
 
2344
+ case "harmony_update_subtask": {
2345
+ const subtaskId = z.string().uuid().parse(args.subtaskId);
2346
+ const updates: {
2347
+ title?: string;
2348
+ completed?: boolean;
2349
+ position?: number;
2350
+ } = {};
2351
+ if (args.title !== undefined) {
2352
+ updates.title = z.string().min(1).max(500).parse(args.title);
2353
+ }
2354
+ if (args.completed !== undefined) {
2355
+ updates.completed = z.boolean().parse(args.completed);
2356
+ }
2357
+ if (args.position !== undefined) {
2358
+ updates.position = z.number().int().min(0).parse(args.position);
2359
+ }
2360
+ if (Object.keys(updates).length === 0) {
2361
+ throw new Error(
2362
+ "harmony_update_subtask requires at least one of: title, completed, position",
2363
+ );
2364
+ }
2365
+ const result = await client.updateSubtask(subtaskId, updates);
2366
+ return { success: true, ...result };
2367
+ }
2368
+
2240
2369
  case "harmony_delete_subtask": {
2241
2370
  const subtaskId = z.string().uuid().parse(args.subtaskId);
2242
2371
  await client.deleteSubtask(subtaskId);
@@ -2714,7 +2843,13 @@ async function handleToolCall(
2714
2843
  );
2715
2844
  }
2716
2845
  const entityType = (args.type as string) || "context";
2717
- 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);
2718
2853
 
2719
2854
  // Working-memory binding (plan §12 Phase 1, §13.1 D3). The caller may
2720
2855
  // pass `scope: 'session'` as an alias; resolve it to the active
@@ -2745,35 +2880,116 @@ async function handleToolCall(
2745
2880
  );
2746
2881
  }
2747
2882
 
2748
- // Importance: caller-provided > per-type default (plan §7.1).
2749
- // auto_score_importance is documented as an LLM-rating opt-in but the
2750
- // actual LLM call is deferred to a follow-up commit; currently it falls
2751
- // through to the type default, same as omission.
2752
- 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 =
2753
2925
  args.importance !== undefined
2754
2926
  ? z.number().int().min(1).max(10).parse(args.importance)
2755
- : 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
+ }
2756
2950
 
2757
- // Use session's agent identifier if available, otherwise null
2758
2951
  const result = await client.createMemoryEntity({
2759
2952
  workspace_id: workspaceId,
2760
- project_id:
2761
- (args.projectId as string) || deps.getActiveProjectId() || undefined,
2953
+ project_id: projectIdForWrite,
2762
2954
  type: entityType,
2763
2955
  scope: entityScope,
2764
2956
  memory_tier: (args.tier as string) || undefined,
2765
2957
  title,
2766
2958
  content,
2767
- metadata: args.metadata as Record<string, unknown> | undefined,
2768
- confidence:
2769
- args.confidence !== undefined
2770
- ? z.number().min(0).max(1).parse(args.confidence)
2771
- : undefined,
2772
- importance: callerImportance,
2959
+ metadata: metadataWithOrigin,
2960
+ confidence,
2961
+ importance,
2773
2962
  tags: entityTags.length > 0 ? entityTags : undefined,
2774
2963
  agent_identifier: activeMemSession?.agentIdentifier || undefined,
2775
2964
  });
2776
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
+
2777
2993
  // Fire-and-forget graph expansion: link new entity to semantically similar ones
2778
2994
  const newEntityIdForGraph = (result.entity as any)?.id;
2779
2995
  if (newEntityIdForGraph) {
@@ -2784,7 +3000,7 @@ async function handleToolCall(
2784
3000
  content,
2785
3001
  entityTags,
2786
3002
  workspaceId,
2787
- (args.projectId as string) || deps.getActiveProjectId() || undefined,
3003
+ projectIdForWrite,
2788
3004
  ).catch(() => {});
2789
3005
  }
2790
3006
 
@@ -2800,6 +3016,15 @@ async function handleToolCall(
2800
3016
  return {
2801
3017
  success: true,
2802
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 } : {}),
2803
3028
  };
2804
3029
  }
2805
3030
 
@@ -2884,13 +3109,7 @@ async function handleToolCall(
2884
3109
  (e?.tags ?? []).some((t: string) => wanted.has(t)),
2885
3110
  );
2886
3111
  }
2887
- if (typeof minConfidence === "number") {
2888
- entities = entities.filter(
2889
- (e) =>
2890
- typeof e?.confidence === "number" &&
2891
- e.confidence >= minConfidence,
2892
- );
2893
- }
3112
+ entities = filterByMinConfidence(entities, minConfidence);
2894
3113
  if (!includeSuperseded) {
2895
3114
  entities = entities.filter((e) => !e?.superseded_at);
2896
3115
  }
@@ -2933,23 +3152,20 @@ async function handleToolCall(
2933
3152
  trimmed = fitToBudget(trimmed, budgetTokens);
2934
3153
  }
2935
3154
 
2936
- // Touch access timestamps for the entities we returned (basic Park
2937
- // recency trail). Non-blocking.
3155
+ // Touch access counters for the entities we returned (#273, task 4).
3156
+ // Previously this wrote `metadata._last_recall` via updateMemoryEntity,
3157
+ // which left `access_count` at 0 forever and never updated
3158
+ // `last_accessed_at` — the column the Park recency term actually reads.
3159
+ // Route through batch-touch, which calls the atomic
3160
+ // `batch_touch_knowledge_entities` RPC (access_count += 1, last_accessed_at
3161
+ // = now) in a single round-trip. Non-blocking; best-effort.
2938
3162
  if (trimmed.length > 0) {
2939
- Promise.all(
2940
- trimmed.map(async ({ entity }: { entity: any }) => {
2941
- try {
2942
- await client.updateMemoryEntity(entity.id, {
2943
- metadata: {
2944
- ...(entity.metadata || {}),
2945
- _last_recall: new Date().toISOString(),
2946
- },
2947
- });
2948
- } catch (_) {
2949
- // Non-critical
2950
- }
2951
- }),
2952
- ).catch(() => {});
3163
+ const touchIds = trimmed
3164
+ .map(({ entity }: { entity: any }) => entity?.id)
3165
+ .filter((id: unknown): id is string => typeof id === "string");
3166
+ if (touchIds.length > 0) {
3167
+ client.batchTouchMemoryEntities(touchIds).catch(() => {});
3168
+ }
2953
3169
  }
2954
3170
 
2955
3171
  // Working-memory prepend (plan §12 Phase 1). When a session is active
@@ -3027,7 +3243,15 @@ async function handleToolCall(
3027
3243
  updates.content = z.string().max(50000).parse(args.content);
3028
3244
  if (args.type !== undefined) updates.type = args.type;
3029
3245
  if (args.scope !== undefined) updates.scope = args.scope;
3030
- if (args.tags !== undefined) updates.tags = args.tags;
3246
+ // Tag taxonomy normalization (#274): same canonicalization + advisory
3247
+ // lint as harmony_remember. Normalized tags are persisted; suggestions
3248
+ // are non-fatal and surfaced in the response.
3249
+ let updateTagSuggestions: ReturnType<typeof lintTags> = [];
3250
+ if (args.tags !== undefined) {
3251
+ const updateRawTags = args.tags as string[];
3252
+ updates.tags = normalizeTags(updateRawTags);
3253
+ updateTagSuggestions = lintTags(updateRawTags);
3254
+ }
3031
3255
  if (args.confidence !== undefined)
3032
3256
  updates.confidence = z.number().min(0).max(1).parse(args.confidence);
3033
3257
  if (args.metadata !== undefined) updates.metadata = args.metadata;
@@ -3044,7 +3268,13 @@ async function handleToolCall(
3044
3268
  flushMemoryActions(client, updateMemSession.cardId).catch(() => {});
3045
3269
  }
3046
3270
 
3047
- return { success: true, ...result };
3271
+ return {
3272
+ success: true,
3273
+ ...result,
3274
+ ...(updateTagSuggestions.length > 0
3275
+ ? { tagSuggestions: updateTagSuggestions }
3276
+ : {}),
3277
+ };
3048
3278
  }
3049
3279
 
3050
3280
  case "harmony_forget": {
@@ -3114,6 +3344,112 @@ async function handleToolCall(
3114
3344
  return { success: true, ...result };
3115
3345
  }
3116
3346
 
3347
+ case "harmony_suggest_relations": {
3348
+ // Relation suggestions (card #281, task 3). Read-only: reuses the
3349
+ // existing hybrid similarity helper (findSimilarEntities, card #275) to
3350
+ // propose candidates the caller may explicitly relate via harmony_relate.
3351
+ // It NEVER writes a relation — surfacing only. Already-related entities
3352
+ // and the entity itself are excluded so suggestions are net-new edges.
3353
+ const entityId = z.string().uuid().parse(args.entityId);
3354
+ const workspaceId =
3355
+ (args.workspaceId as string) || deps.getActiveWorkspaceId();
3356
+ if (!workspaceId) {
3357
+ throw new Error(
3358
+ "No workspace specified. Use harmony_set_workspace_context or provide workspaceId.",
3359
+ );
3360
+ }
3361
+ const projectId =
3362
+ (args.projectId as string) || deps.getActiveProjectId() || undefined;
3363
+ const limit = (args.limit as number | undefined) ?? 5;
3364
+
3365
+ const { entity } = await client.getMemoryEntity(entityId);
3366
+ const src = entity as {
3367
+ title?: string;
3368
+ content?: string;
3369
+ } | null;
3370
+ if (!src) {
3371
+ throw new Error(`Entity not found: ${entityId}`);
3372
+ }
3373
+
3374
+ // Exclude the entity itself plus anything it is already related to, so
3375
+ // suggestions only ever propose new edges.
3376
+ const related = await client.getRelatedEntities(entityId);
3377
+ const excludeIds = new Set<string>([entityId]);
3378
+ for (const raw of [
3379
+ ...(related.outgoing || []),
3380
+ ...(related.incoming || []),
3381
+ ]) {
3382
+ const rel = raw as Record<string, unknown>;
3383
+ const target = rel.target as Record<string, unknown> | undefined;
3384
+ const source = rel.source as Record<string, unknown> | undefined;
3385
+ const otherId =
3386
+ (target?.id as string) ??
3387
+ (rel.target_id as string) ??
3388
+ (source?.id as string) ??
3389
+ (rel.source_id as string);
3390
+ if (otherId) excludeIds.add(otherId);
3391
+ }
3392
+
3393
+ const suggestions = await findSimilarEntities(
3394
+ client,
3395
+ src.title ?? "",
3396
+ src.content ?? "",
3397
+ workspaceId,
3398
+ {
3399
+ projectId,
3400
+ limit: limit + excludeIds.size,
3401
+ excludeIds: [...excludeIds],
3402
+ },
3403
+ );
3404
+
3405
+ return {
3406
+ success: true,
3407
+ suggestions: suggestions.slice(0, limit).map((s) => ({
3408
+ id: s.id,
3409
+ title: s.title,
3410
+ type: s.type,
3411
+ rrf_score: s.rrf_score,
3412
+ })),
3413
+ };
3414
+ }
3415
+
3416
+ case "harmony_recall_feedback": {
3417
+ // Record-success feedback loop (card #279, task 3). Non-destructive:
3418
+ // increments a counter in metadata.feedback and writes it back via the
3419
+ // standard metadata-merge update path. Affects RANKING ONLY (the Park
3420
+ // rescore reads metadata.feedback into effective importance); the memory
3421
+ // is never deleted, hidden, or superseded.
3422
+ const entityId = z.string().uuid().parse(args.entityId);
3423
+ const vote = z.enum(["up", "down"]).parse(args.vote);
3424
+
3425
+ const { entity } = await client.getMemoryEntity(entityId);
3426
+ const existingMeta =
3427
+ (entity as { metadata?: Record<string, unknown> } | null)?.metadata ??
3428
+ {};
3429
+ const existingFeedback = (existingMeta as { feedback?: MemoryFeedback })
3430
+ .feedback;
3431
+ const feedback = mergeFeedback(existingFeedback, vote);
3432
+
3433
+ // The harmony-api update REPLACES the metadata column (no server-side
3434
+ // merge), so we must send the full object: existing metadata (incl.
3435
+ // metadata.origin provenance from #273) plus the updated feedback delta.
3436
+ // Sending only { feedback } would wipe provenance and everything else.
3437
+ const result = await client.updateMemoryEntity(entityId, {
3438
+ metadata: { ...existingMeta, feedback },
3439
+ });
3440
+
3441
+ const fbMemSession = getActiveMemorySession();
3442
+ if (fbMemSession) {
3443
+ appendMemoryAction(
3444
+ fbMemSession.cardId,
3445
+ `Memory feedback: ${vote === "up" ? "👍" : "👎"} ${entityId.slice(0, 8)}`,
3446
+ );
3447
+ flushMemoryActions(client, fbMemSession.cardId).catch(() => {});
3448
+ }
3449
+
3450
+ return { success: true, feedback, ...result };
3451
+ }
3452
+
3117
3453
  case "harmony_memory_search": {
3118
3454
  const query = z.string().min(1).max(500).parse(args.query);
3119
3455
  const workspaceId =