@gethmy/mcp 2.8.4 → 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.
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Memory tag normalization + lint (card #274).
3
+ *
4
+ * Memory tags were inconsistent and broke tag-based recall: card references
5
+ * appeared as both `card:259` (colon) and `card-175` / `card 229` (dash/space),
6
+ * so `harmony_recall({tags:["card:259"]})` missed the variants.
7
+ *
8
+ * Canonical card-ref form is `card:<n>` (colon) — this aligns with the active
9
+ * programmatic write path: the agent daemon's `episode-writer.ts` already tags
10
+ * episodes with `` `card:${short_id}` ``. We normalize toward that, not away.
11
+ *
12
+ * Rules are intentionally small and deterministic (no LLM, no taxonomy):
13
+ * - trim surrounding whitespace
14
+ * - lowercase
15
+ * - collapse internal whitespace runs to a single space
16
+ * - canonicalize card refs: `card-<n>` / `card <n>` / `card#<n>` → `card:<n>`
17
+ */
18
+
19
+ /** Matches a card ref written with any common separator, e.g. `card-12`,
20
+ * `card 12`, `card#12`, `card:12`. Captures the numeric short id. */
21
+ const CARD_REF = /^card[\s:#-]+(\d+)$/;
22
+
23
+ /**
24
+ * Normalize a single tag to its canonical form.
25
+ * Pure: trims, lowercases, collapses internal whitespace, canonicalizes card refs.
26
+ */
27
+ export function normalizeTag(tag: string): string {
28
+ const cleaned = tag.trim().toLowerCase().replace(/\s+/g, " ");
29
+ const cardMatch = cleaned.match(CARD_REF);
30
+ if (cardMatch) {
31
+ return `card:${cardMatch[1]}`;
32
+ }
33
+ return cleaned;
34
+ }
35
+
36
+ /**
37
+ * Normalize a list of tags: map each through {@link normalizeTag}, drop empties,
38
+ * and dedupe while preserving first-seen order.
39
+ */
40
+ export function normalizeTags(tags: string[]): string[] {
41
+ const seen = new Set<string>();
42
+ const out: string[] = [];
43
+ for (const raw of tags) {
44
+ const norm = normalizeTag(raw);
45
+ if (!norm || seen.has(norm)) continue;
46
+ seen.add(norm);
47
+ out.push(norm);
48
+ }
49
+ return out;
50
+ }
51
+
52
+ /** A single advisory tag-lint suggestion. Non-fatal. */
53
+ export interface TagSuggestion {
54
+ /** The original tag as written by the caller. */
55
+ tag: string;
56
+ /** The canonical form we'd prefer. */
57
+ suggestion: string;
58
+ /** Short human-readable reason. */
59
+ reason: string;
60
+ }
61
+
62
+ /**
63
+ * Advisory tag lint (card #274). Returns non-fatal suggestions for tags that
64
+ * differ from their canonical form (case variants, dash/space/hash card refs,
65
+ * surrounding/internal whitespace). NEVER rejects — purely informational.
66
+ *
67
+ * Clean tags (already canonical) produce no suggestions, so a fully-normalized
68
+ * input list returns `[]`.
69
+ */
70
+ export function lintTags(tags: string[]): TagSuggestion[] {
71
+ const suggestions: TagSuggestion[] = [];
72
+ for (const tag of tags) {
73
+ const norm = normalizeTag(tag);
74
+ if (norm === tag) continue;
75
+ let reason: string;
76
+ if (CARD_REF.test(tag.trim().toLowerCase()) && norm.startsWith("card:")) {
77
+ reason = "non-canonical card ref; use 'card:<n>'";
78
+ } else if (tag !== tag.trim() || /\s{2,}/.test(tag)) {
79
+ reason = "whitespace; trim and collapse";
80
+ } else if (tag !== tag.toLowerCase()) {
81
+ reason = "case variant; use lowercase";
82
+ } else {
83
+ reason = "normalize to canonical form";
84
+ }
85
+ suggestions.push({ tag, suggestion: norm, reason });
86
+ }
87
+ return suggestions;
88
+ }
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
 
@@ -2943,13 +3109,7 @@ async function handleToolCall(
2943
3109
  (e?.tags ?? []).some((t: string) => wanted.has(t)),
2944
3110
  );
2945
3111
  }
2946
- if (typeof minConfidence === "number") {
2947
- entities = entities.filter(
2948
- (e) =>
2949
- typeof e?.confidence === "number" &&
2950
- e.confidence >= minConfidence,
2951
- );
2952
- }
3112
+ entities = filterByMinConfidence(entities, minConfidence);
2953
3113
  if (!includeSuperseded) {
2954
3114
  entities = entities.filter((e) => !e?.superseded_at);
2955
3115
  }
@@ -2992,23 +3152,20 @@ async function handleToolCall(
2992
3152
  trimmed = fitToBudget(trimmed, budgetTokens);
2993
3153
  }
2994
3154
 
2995
- // Touch access timestamps for the entities we returned (basic Park
2996
- // 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.
2997
3162
  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(() => {});
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
+ }
3012
3169
  }
3013
3170
 
3014
3171
  // Working-memory prepend (plan §12 Phase 1). When a session is active
@@ -3086,7 +3243,15 @@ async function handleToolCall(
3086
3243
  updates.content = z.string().max(50000).parse(args.content);
3087
3244
  if (args.type !== undefined) updates.type = args.type;
3088
3245
  if (args.scope !== undefined) updates.scope = args.scope;
3089
- 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
+ }
3090
3255
  if (args.confidence !== undefined)
3091
3256
  updates.confidence = z.number().min(0).max(1).parse(args.confidence);
3092
3257
  if (args.metadata !== undefined) updates.metadata = args.metadata;
@@ -3103,7 +3268,13 @@ async function handleToolCall(
3103
3268
  flushMemoryActions(client, updateMemSession.cardId).catch(() => {});
3104
3269
  }
3105
3270
 
3106
- return { success: true, ...result };
3271
+ return {
3272
+ success: true,
3273
+ ...result,
3274
+ ...(updateTagSuggestions.length > 0
3275
+ ? { tagSuggestions: updateTagSuggestions }
3276
+ : {}),
3277
+ };
3107
3278
  }
3108
3279
 
3109
3280
  case "harmony_forget": {
@@ -3173,6 +3344,112 @@ async function handleToolCall(
3173
3344
  return { success: true, ...result };
3174
3345
  }
3175
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
+
3176
3453
  case "harmony_memory_search": {
3177
3454
  const query = z.string().min(1).max(500).parse(args.query);
3178
3455
  const workspaceId =