@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.
@@ -726,10 +726,14 @@ function getDisplayLinkType(linkType, direction) {
726
726
  }
727
727
  // ../harmony-shared/dist/commentSerializer.js
728
728
  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.";
729
+ function sanitizeHeaderField(value) {
730
+ return value.replace(/[\]\r\n|<>]/g, " ").trim() || "—";
731
+ }
729
732
  function authorLabel(c) {
730
733
  if (c.author_type === "agent")
731
734
  return "AI agent";
732
- return c.author?.full_name || c.author?.email || "teammate";
735
+ const raw = c.author?.full_name || "teammate";
736
+ return sanitizeHeaderField(raw);
733
737
  }
734
738
  function criticalIds(comments) {
735
739
  const keep = new Set;
@@ -786,9 +790,15 @@ function serializeCommentThread(comments, options = {}) {
786
790
  if (c.resolved_at)
787
791
  tags.push("resolved");
788
792
  const tagStr = tags.length ? ` | ${tags.join(" | ")}` : "";
789
- const header = `[${ref(c.id)} | ${c.author_type} | ${authorLabel(c)} | ${c.comment_type} | ${c.created_at}${tagStr}]`;
790
- lines.push({ at: c.created_at, text: `${header}
791
- ${c.body.trim()}` });
793
+ const header = `[${sanitizeHeaderField(ref(c.id))} | ${sanitizeHeaderField(c.author_type)} | ${authorLabel(c)} | ${sanitizeHeaderField(c.comment_type)} | ${sanitizeHeaderField(c.created_at)}${tagStr}]`;
794
+ const fencedBody = c.body.trim().replaceAll("<", "&lt;").replaceAll(">", "&gt;");
795
+ lines.push({
796
+ at: c.created_at,
797
+ text: `${header}
798
+ <comment-body>
799
+ ${fencedBody}
800
+ </comment-body>`
801
+ });
792
802
  }
793
803
  for (const a of activity) {
794
804
  const actor = a.actor ? `${a.actor} ` : "";
@@ -1154,6 +1164,9 @@ class HarmonyApiClient {
1154
1164
  async toggleSubtask(subtaskId) {
1155
1165
  return this.request("POST", `/subtasks/${subtaskId}/toggle`);
1156
1166
  }
1167
+ async updateSubtask(subtaskId, updates) {
1168
+ return this.request("PATCH", `/subtasks/${subtaskId}`, updates);
1169
+ }
1157
1170
  async deleteSubtask(subtaskId) {
1158
1171
  return this.request("DELETE", `/subtasks/${subtaskId}`);
1159
1172
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethmy/mcp",
3
- "version": "2.8.3",
3
+ "version": "2.8.5",
4
4
  "description": "MCP server for Harmony Kanban board - enables AI coding agents to manage your boards",
5
5
  "publishConfig": {
6
6
  "access": "public"
package/src/api-client.ts CHANGED
@@ -474,6 +474,7 @@ export class HarmonyApiClient {
474
474
  dueDate?: string | null;
475
475
  done?: boolean;
476
476
  archivedAt?: string | null;
477
+ planId?: string | null;
477
478
  },
478
479
  ): Promise<{ card: unknown }> {
479
480
  return this.request("PATCH", `/cards/${cardId}`, updates);
@@ -617,6 +618,13 @@ export class HarmonyApiClient {
617
618
  return this.request("POST", `/subtasks/${subtaskId}/toggle`);
618
619
  }
619
620
 
621
+ async updateSubtask(
622
+ subtaskId: string,
623
+ updates: { title?: string; completed?: boolean; position?: number },
624
+ ): Promise<{ subtask: unknown }> {
625
+ return this.request("PATCH", `/subtasks/${subtaskId}`, updates);
626
+ }
627
+
620
628
  async deleteSubtask(subtaskId: string): Promise<{ success: boolean }> {
621
629
  return this.request("DELETE", `/subtasks/${subtaskId}`);
622
630
  }
@@ -68,16 +68,21 @@ export function resolveAgentIdentity(info: ClientInfo | null): {
68
68
  /**
69
69
  * Tools that trigger auto-start of a session.
70
70
  *
71
- * Restricted to tools that signal real work on a card. Board-management ops
72
- * (move, label add/remove) are excluded — they're routinely used for triage
73
- * and would create false-positive sessions whose side effect (the auto-added
74
- * `agent` label on the card) confuses both UI and humans.
71
+ * Restricted to tools that signal real work on a card. Triage/board-management
72
+ * ops are excluded — they're routinely used for sorting and card creation, not
73
+ * implementation, and would create false-positive sessions whose side effect
74
+ * (the auto-added `agent` label on the card) confuses both UI and humans.
75
+ *
76
+ * `harmony_update_card` is deliberately NOT a trigger: editing a card's
77
+ * title/description/priority is metadata editing (used during `/hmy` create and
78
+ * triage), not work. Including it spawned phantom sessions on freshly-created
79
+ * cards (card #295), the same reason move/label ops are excluded.
75
80
  */
76
81
  export const AUTO_START_TRIGGERS = new Set([
77
82
  "harmony_generate_prompt",
78
- "harmony_update_card",
79
83
  "harmony_create_subtask",
80
84
  "harmony_toggle_subtask",
85
+ "harmony_update_subtask",
81
86
  ]);
82
87
 
83
88
  export const INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
@@ -133,6 +138,15 @@ export async function trackActivity(
133
138
  const client = options?.client ?? clientGetter?.();
134
139
  if (!client) return;
135
140
 
141
+ // Resolve agent identity from the MCP `initialize` handshake. Never auto-start
142
+ // an anonymous session: if we can't say WHO is working, we don't fabricate a
143
+ // phantom "Unknown Agent" session (card #295). Identified clients only — this
144
+ // bail happens BEFORE ending other sessions so an unidentified call can't tear
145
+ // down a legitimate tracked session.
146
+ const info = clientInfoGetter?.() ?? null;
147
+ if (!info?.name) return;
148
+ const { agentIdentifier, agentName } = resolveAgentIdentity(info);
149
+
136
150
  // Collect auto-sessions on other cards to end (avoid mutating map during iteration)
137
151
  const toEnd: string[] = [];
138
152
  for (const [otherCardId, session] of activeSessions) {
@@ -144,10 +158,6 @@ export async function trackActivity(
144
158
  await autoEndSession(client, otherCardId, "completed");
145
159
  }
146
160
 
147
- // Resolve agent identity from MCP client info
148
- const info = clientInfoGetter?.() ?? null;
149
- const { agentIdentifier, agentName } = resolveAgentIdentity(info);
150
-
151
161
  // Start a new auto-session
152
162
  try {
153
163
  await client.startAgentSession(cardId, {
@@ -138,6 +138,159 @@ export async function findSimilarEntities(
138
138
  }
139
139
  }
140
140
 
141
+ // ============ WRITE-TIME SEMANTIC DEDUP (card #275) ============
142
+
143
+ /**
144
+ * RRF-score floor for treating a hybrid-search hit as a *supersede candidate*
145
+ * at write time. The hybrid_search RPC fuses FTS + vector ranks via Reciprocal
146
+ * Rank Fusion: score = 1/(k+fts_rank) + 1/(k+semantic_rank), k=50. A row that
147
+ * ranks #1 in BOTH lists tops out near 2/51 ≈ 0.039; #1 in a single list is
148
+ * ≈ 0.0196. RRF rank is NOT cosine similarity, so this threshold alone is a
149
+ * weak signal — it is paired with a lexical title-overlap guard below so we
150
+ * only ever surface genuinely near-duplicate titles. Deliberately
151
+ * conservative: dedup must never produce false "this already exists" noise.
152
+ *
153
+ * Tuning note: the cross-type causal linker (linkCrossTypeNeighbors) uses
154
+ * minRrfScore 0.04 for a comparable "strongly related" bar; we sit just under
155
+ * it because dedup probes the SAME type and wants the top fused hit.
156
+ */
157
+ export const SUPERSEDE_RRF_THRESHOLD = 0.029;
158
+
159
+ /**
160
+ * Minimum Jaccard overlap of significant title tokens required, in addition to
161
+ * the RRF floor, before a hit counts as a supersede candidate. Guards against
162
+ * semantic-only matches (e.g. two different patterns about "BoardContext")
163
+ * being flagged as duplicates. 0.5 = at least half the significant tokens are
164
+ * shared.
165
+ */
166
+ export const SUPERSEDE_TITLE_OVERLAP = 0.5;
167
+
168
+ const TITLE_STOPWORDS = new Set([
169
+ "a",
170
+ "an",
171
+ "the",
172
+ "and",
173
+ "or",
174
+ "of",
175
+ "to",
176
+ "in",
177
+ "on",
178
+ "for",
179
+ "with",
180
+ "is",
181
+ "are",
182
+ "be",
183
+ "by",
184
+ "at",
185
+ "as",
186
+ ]);
187
+
188
+ function significantTitleTokens(title: string): Set<string> {
189
+ return new Set(
190
+ title
191
+ .toLowerCase()
192
+ .replace(/[^a-z0-9\s]/g, " ")
193
+ .split(/\s+/)
194
+ .filter((t) => t.length > 2 && !TITLE_STOPWORDS.has(t)),
195
+ );
196
+ }
197
+
198
+ /** Jaccard similarity of two token sets. Returns 0 when either set is empty. */
199
+ function jaccard(a: Set<string>, b: Set<string>): number {
200
+ if (a.size === 0 || b.size === 0) return 0;
201
+ let intersection = 0;
202
+ for (const t of a) if (b.has(t)) intersection++;
203
+ return intersection / (a.size + b.size - intersection);
204
+ }
205
+
206
+ export interface SupersedeCandidate {
207
+ id: string;
208
+ title: string;
209
+ /** RRF score from the hybrid search (higher = more relevant). */
210
+ score: number;
211
+ }
212
+
213
+ /**
214
+ * Write-time dedup probe (card #275). BEFORE inserting a new memory, find
215
+ * existing, non-superseded entities of the SAME type + scope that look like
216
+ * near-duplicates of the candidate title+content.
217
+ *
218
+ * Reuses the existing hybrid-search path (searchMemoryEntities → the
219
+ * hybrid_search_knowledge_entities RPC) — no new embedding pipeline. A hit must
220
+ * clear BOTH the RRF floor AND a lexical title-overlap guard, so this is a
221
+ * conservative "these are probably the same memory" signal, never a silent
222
+ * merge.
223
+ *
224
+ * Non-fatal and non-blocking: any failure returns [] so the write still
225
+ * proceeds. The caller ALWAYS inserts; this only surfaces candidates for the
226
+ * caller (agent / assistant / human) to optionally supersede.
227
+ */
228
+ export async function findSupersedeCandidates(
229
+ client: HarmonyApiClient,
230
+ title: string,
231
+ content: string,
232
+ type: string,
233
+ workspaceId: string,
234
+ options?: {
235
+ projectId?: string;
236
+ scope?: string;
237
+ limit?: number;
238
+ rrfThreshold?: number;
239
+ titleOverlap?: number;
240
+ },
241
+ ): Promise<SupersedeCandidate[]> {
242
+ const rrfThreshold = options?.rrfThreshold ?? SUPERSEDE_RRF_THRESHOLD;
243
+ const titleOverlap = options?.titleOverlap ?? SUPERSEDE_TITLE_OVERLAP;
244
+ const candidateTokens = significantTitleTokens(title);
245
+
246
+ try {
247
+ const hits = await findSimilarEntities(
248
+ client,
249
+ title,
250
+ content,
251
+ workspaceId,
252
+ {
253
+ projectId: options?.projectId,
254
+ // Filter to the same type server-side — dedup only applies within a type.
255
+ type,
256
+ limit: options?.limit ?? 10,
257
+ minRrfScore: rrfThreshold,
258
+ },
259
+ );
260
+
261
+ return hits
262
+ .filter((e) => {
263
+ // Same scope only — a project memory shouldn't supersede a global one.
264
+ if (options?.scope && (e as { scope?: string }).scope !== undefined) {
265
+ if ((e as { scope?: string }).scope !== options.scope) return false;
266
+ }
267
+ // Skip already-superseded rows when the field is present. NOTE: the
268
+ // hybrid-search RPC does not return `superseded_at`, so this only fires
269
+ // on the FTS-fallback path; on the embedding path an already-retired row
270
+ // can still surface as a *candidate*. That is non-destructive — the
271
+ // `similar` list is advisory and the caller decides explicitly whether
272
+ // to supersede. A complete fix needs the RPC to return/filter the column
273
+ // (migration + deploy); tracked in docs/memory.md.
274
+ if ((e as { superseded_at?: string | null }).superseded_at) {
275
+ return false;
276
+ }
277
+ // Lexical guard: require real title-token overlap on top of RRF.
278
+ return (
279
+ jaccard(candidateTokens, significantTitleTokens(e.title)) >=
280
+ titleOverlap
281
+ );
282
+ })
283
+ .map((e) => ({
284
+ id: e.id,
285
+ title: e.title,
286
+ score: e.rrf_score ?? 0,
287
+ }));
288
+ } catch {
289
+ // Never block a write because the dedup probe failed.
290
+ return [];
291
+ }
292
+ }
293
+
141
294
  /**
142
295
  * Causal lookup table: maps an entity type to the target types it should
143
296
  * be linked to, along with the relation type and direction.
@@ -11,9 +11,18 @@
11
11
  * baseline so recency + importance still differentiate.
12
12
  * recency_decay — exp(-Δt_seconds / τ_type) clamped to [0, 1].
13
13
  * τ depends on memory type per plan §4.
14
- * importance_norm — importance / 10, clamped to [0, 1].
14
+ * importance_norm — effective_importance / 10, clamped to [0, 1], where
15
+ * effective_importance folds in two bounded, deterministic
16
+ * signals on top of the stored importance (card #279):
17
+ * + usage bump — proven-useful memories (recalled
18
+ * often) rank above never-recalled ones
19
+ * + feedback bump — 👍/👎 stored in metadata.feedback
20
+ * See `effectiveImportance` below. This is RANKING ONLY:
21
+ * nothing is stored, deleted, or mutated.
15
22
  *
16
- * defaults: α=0.55, β=0.25, γ=0.20 (sum to 1.0).
23
+ * defaults: α=0.55, β=0.25, γ=0.20 (sum to 1.0). Weights are NOT re-tuned by
24
+ * #279 — usage + feedback fold into the existing γ·importance term so the
25
+ * formula stays a stable 3-weight model.
17
26
  *
18
27
  * The function is pure. Hot-path-safe — no LLM calls, no DB reads.
19
28
  */
@@ -28,6 +37,37 @@ export const DEFAULT_WEIGHTS = {
28
37
  importance: 0.2,
29
38
  } as const;
30
39
 
40
+ // ---------------------------------------------------------------------------
41
+ // Usage + feedback bumps (card #279) — fold into effective importance.
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /**
45
+ * Usage bump (card #279, task 2). A bounded, log-scaled lift to importance for
46
+ * memories that have actually been recalled. Proven-useful memories outrank
47
+ * never-recalled ones at equal relevance/recency; never-used ones get +0 and
48
+ * gently sink relative to their used peers.
49
+ *
50
+ * bump = USAGE_BUMP_SCALE · ln(1 + access_count), capped at USAGE_BUMP_MAX
51
+ *
52
+ * Log scaling keeps the lift gentle and diminishing: the jump from 0→1 recall
53
+ * matters most, runaway counts can't dominate. Capped so usage never swamps the
54
+ * stored importance signal. Pure + deterministic — no LLM, no storage change.
55
+ */
56
+ export const USAGE_BUMP_SCALE = 0.6;
57
+ export const USAGE_BUMP_MAX = 2;
58
+
59
+ /**
60
+ * Feedback bump (card #279, task 3). Net 👍/👎 stored non-destructively in
61
+ * `metadata.feedback = { up, down }` shifts importance up (positive net) or
62
+ * down (negative net), bounded and symmetric. Feedback affects RANKING ONLY —
63
+ * it never deletes or supersedes a memory.
64
+ *
65
+ * bump = FEEDBACK_BUMP_SCALE · sign(net) · ln(1 + |net|),
66
+ * clamped to ±FEEDBACK_BUMP_MAX
67
+ */
68
+ export const FEEDBACK_BUMP_SCALE = 0.8;
69
+ export const FEEDBACK_BUMP_MAX = 2;
70
+
31
71
  // Per-type recency time constant τ in seconds.
32
72
  // `Infinity` = never decays (preferences shouldn't fade with disuse).
33
73
  export const TYPE_TAU_SECONDS: Record<string, number> = {
@@ -71,17 +111,28 @@ export const TYPE_IMPORTANCE_DEFAULT: Record<string, number> = {
71
111
  // Types
72
112
  // ---------------------------------------------------------------------------
73
113
 
114
+ /** 👍/👎 counters stored non-destructively at `metadata.feedback`. */
115
+ export interface MemoryFeedback {
116
+ up?: number;
117
+ down?: number;
118
+ }
119
+
74
120
  export interface ParkInput {
75
121
  type: string;
76
122
  importance?: number | null;
77
123
  last_accessed_at?: string | null;
78
124
  created_at?: string | null;
125
+ /** Recall counter maintained by batch_touch_knowledge_entities (#273). */
126
+ access_count?: number | null;
127
+ /** Carries `metadata.feedback` (#279). Other metadata keys are ignored. */
128
+ metadata?: { feedback?: MemoryFeedback | null } | null;
79
129
  }
80
130
 
81
131
  export interface ParkScored<T extends ParkInput> {
82
132
  entity: T;
83
133
  relevance: number;
84
134
  recency: number;
135
+ /** Effective importance term, normalised to [0,1] (post usage + feedback). */
85
136
  importance: number;
86
137
  score: number;
87
138
  }
@@ -124,10 +175,59 @@ function recencyDecay(
124
175
  return clamp01(Math.exp(-dtSec / tau));
125
176
  }
126
177
 
127
- function importanceNorm(raw: number | null | undefined, type: string): number {
178
+ /** Resolve the stored (base) importance, clamped to [1,10]. */
179
+ function baseImportance(raw: number | null | undefined, type: string): number {
128
180
  let v = typeof raw === "number" ? raw : (TYPE_IMPORTANCE_DEFAULT[type] ?? 5);
129
181
  if (v < 1) v = 1;
130
182
  if (v > 10) v = 10;
183
+ return v;
184
+ }
185
+
186
+ /**
187
+ * Bounded, log-scaled usage lift (card #279, task 2). +0 for never-recalled.
188
+ * Pure; reads only `access_count`.
189
+ */
190
+ export function usageBump(accessCount: number | null | undefined): number {
191
+ const n =
192
+ typeof accessCount === "number" && accessCount > 0 ? accessCount : 0;
193
+ if (n === 0) return 0;
194
+ return Math.min(USAGE_BUMP_MAX, USAGE_BUMP_SCALE * Math.log(1 + n));
195
+ }
196
+
197
+ /**
198
+ * Bounded, symmetric feedback shift (card #279, task 3). Positive net 👍 lifts,
199
+ * negative net 👎 demotes. Pure; reads only `metadata.feedback`.
200
+ */
201
+ export function feedbackBump(
202
+ feedback: MemoryFeedback | null | undefined,
203
+ ): number {
204
+ const up = typeof feedback?.up === "number" ? feedback.up : 0;
205
+ const down = typeof feedback?.down === "number" ? feedback.down : 0;
206
+ const net = up - down;
207
+ if (net === 0) return 0;
208
+ const raw =
209
+ FEEDBACK_BUMP_SCALE * Math.sign(net) * Math.log(1 + Math.abs(net));
210
+ if (raw > FEEDBACK_BUMP_MAX) return FEEDBACK_BUMP_MAX;
211
+ if (raw < -FEEDBACK_BUMP_MAX) return -FEEDBACK_BUMP_MAX;
212
+ return raw;
213
+ }
214
+
215
+ /**
216
+ * Effective importance = base importance + usage bump + feedback bump, clamped
217
+ * to [1,10], then normalised to [0,1] (card #279, task 2 + 3).
218
+ *
219
+ * Folding usage + feedback into the existing γ·importance term — instead of
220
+ * adding new weights — keeps the Park formula a stable 3-weight model. This is
221
+ * a RANKING-ONLY transform: it reads `access_count` and `metadata.feedback`
222
+ * but never writes, deletes, or supersedes anything.
223
+ */
224
+ export function effectiveImportance(entity: ParkInput): number {
225
+ const base = baseImportance(entity.importance, entity.type);
226
+ const bump =
227
+ usageBump(entity.access_count) + feedbackBump(entity.metadata?.feedback);
228
+ let v = base + bump;
229
+ if (v < 1) v = 1;
230
+ if (v > 10) v = 10;
131
231
  return v / 10;
132
232
  }
133
233
 
@@ -160,7 +260,7 @@ export function rescore<T extends ParkInput & { id?: string }>(
160
260
  entity.type,
161
261
  now,
162
262
  );
163
- const importance = importanceNorm(entity.importance, entity.type);
263
+ const importance = effectiveImportance(entity);
164
264
  const score =
165
265
  w.relevance * relevance + w.recency * recency + w.importance * importance;
166
266
  return { entity, relevance, recency, importance, score };
@@ -176,6 +276,28 @@ export function rescore<T extends ParkInput & { id?: string }>(
176
276
  return scored;
177
277
  }
178
278
 
279
+ // ---------------------------------------------------------------------------
280
+ // minConfidence filter (#273)
281
+ // ---------------------------------------------------------------------------
282
+
283
+ /**
284
+ * Keep only entities whose confidence meets the threshold. Entities with a
285
+ * non-numeric confidence are dropped (we can't prove they clear the bar).
286
+ *
287
+ * Pure + exported so the recall path's `minConfidence` semantics are
288
+ * unit-testable. Once writes set non-uniform confidence (#273), a low
289
+ * threshold yields a strictly smaller set than passing no threshold.
290
+ */
291
+ export function filterByMinConfidence<T extends { confidence?: number | null }>(
292
+ entities: T[],
293
+ minConfidence: number | undefined,
294
+ ): T[] {
295
+ if (typeof minConfidence !== "number") return entities;
296
+ return entities.filter(
297
+ (e) => typeof e.confidence === "number" && e.confidence >= minConfidence,
298
+ );
299
+ }
300
+
179
301
  // ---------------------------------------------------------------------------
180
302
  // Rank-to-relevance helper (Phase 1 hybrid retrieval bridge)
181
303
  // ---------------------------------------------------------------------------
@@ -250,3 +372,60 @@ export function fitToBudget<
250
372
  }
251
373
  return out;
252
374
  }
375
+
376
+ // ---------------------------------------------------------------------------
377
+ // Stale / never-recalled signal (card #279, task 4)
378
+ // ---------------------------------------------------------------------------
379
+
380
+ export interface StaleUnusedInput {
381
+ access_count?: number | null;
382
+ last_accessed_at?: string | null;
383
+ created_at?: string | null;
384
+ }
385
+
386
+ /**
387
+ * Returns true when a memory has NEVER been recalled (access_count 0/absent)
388
+ * AND has existed longer than `thresholdDays`. SIGNAL ONLY — card #280's
389
+ * prune-suggestion digest consumes this to surface candidates for human
390
+ * review. It deletes/modifies NOTHING; non-destructive by construction.
391
+ *
392
+ * Age is measured from `created_at` (a never-recalled memory has no meaningful
393
+ * `last_accessed_at`; #273 only stamps it on recall). A memory that has been
394
+ * recalled even once is never stale-unused, regardless of age.
395
+ */
396
+ export function isStaleUnused(
397
+ entity: StaleUnusedInput,
398
+ now: Date,
399
+ thresholdDays: number,
400
+ ): boolean {
401
+ const recalled =
402
+ typeof entity.access_count === "number" && entity.access_count > 0;
403
+ if (recalled) return false;
404
+ const createdRaw = entity.created_at ?? null;
405
+ if (!createdRaw) return false; // Unknown age: don't flag.
406
+ const created = Date.parse(createdRaw);
407
+ if (Number.isNaN(created)) return false;
408
+ const ageDays = (now.getTime() - created) / (1000 * 60 * 60 * 24);
409
+ return ageDays > thresholdDays;
410
+ }
411
+
412
+ // ---------------------------------------------------------------------------
413
+ // Feedback merge (card #279, task 3) — non-destructive counter increment
414
+ // ---------------------------------------------------------------------------
415
+
416
+ /**
417
+ * Merge a single 👍/👎 vote into an existing feedback counter, returning a NEW
418
+ * object (input is never mutated). Used by the recall-feedback record path to
419
+ * compute the `metadata.feedback` patch before persisting. Pure + bounded to
420
+ * non-negative integers.
421
+ */
422
+ export function mergeFeedback(
423
+ existing: MemoryFeedback | null | undefined,
424
+ vote: "up" | "down",
425
+ ): MemoryFeedback {
426
+ const up =
427
+ typeof existing?.up === "number" && existing.up > 0 ? existing.up : 0;
428
+ const down =
429
+ typeof existing?.down === "number" && existing.down > 0 ? existing.down : 0;
430
+ return vote === "up" ? { up: up + 1, down } : { up, down: down + 1 };
431
+ }