@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.
@@ -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} ` : "";
@@ -1313,6 +1323,10 @@ class HarmonyApiClient {
1313
1323
  params.set("type", options.type);
1314
1324
  if (options?.limit !== undefined)
1315
1325
  params.set("limit", String(options.limit));
1326
+ for (const tag of options?.tags ?? [])
1327
+ params.append("tags", tag);
1328
+ if (options?.include_superseded)
1329
+ params.set("include_superseded", "true");
1316
1330
  return this.request("GET", `/memory/search?${params.toString()}`);
1317
1331
  }
1318
1332
  async getVaultIndex(options) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethmy/mcp",
3
- "version": "2.8.4",
3
+ "version": "2.8.6",
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
@@ -1003,6 +1003,8 @@ export class HarmonyApiClient {
1003
1003
  project_id?: string;
1004
1004
  type?: string;
1005
1005
  limit?: number;
1006
+ tags?: string[];
1007
+ include_superseded?: boolean;
1006
1008
  },
1007
1009
  ): Promise<{ entities: unknown[]; count: number }> {
1008
1010
  const params = new URLSearchParams();
@@ -1012,6 +1014,10 @@ export class HarmonyApiClient {
1012
1014
  if (options?.type) params.set("type", options.type);
1013
1015
  if (options?.limit !== undefined)
1014
1016
  params.set("limit", String(options.limit));
1017
+ // Repeated `tags` params — the search endpoint reads them via getAll and
1018
+ // matches against the canonical `tags_normalized` column (#299).
1019
+ for (const tag of options?.tags ?? []) params.append("tags", tag);
1020
+ if (options?.include_superseded) params.set("include_superseded", "true");
1015
1021
  return this.request("GET", `/memory/search?${params.toString()}`);
1016
1022
  }
1017
1023
 
@@ -68,14 +68,18 @@ 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",
81
85
  "harmony_update_subtask",
@@ -134,6 +138,15 @@ export async function trackActivity(
134
138
  const client = options?.client ?? clientGetter?.();
135
139
  if (!client) return;
136
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
+
137
150
  // Collect auto-sessions on other cards to end (avoid mutating map during iteration)
138
151
  const toEnd: string[] = [];
139
152
  for (const [otherCardId, session] of activeSessions) {
@@ -145,10 +158,6 @@ export async function trackActivity(
145
158
  await autoEndSession(client, otherCardId, "completed");
146
159
  }
147
160
 
148
- // Resolve agent identity from MCP client info
149
- const info = clientInfoGetter?.() ?? null;
150
- const { agentIdentifier, agentName } = resolveAgentIdentity(info);
151
-
152
161
  // Start a new auto-session
153
162
  try {
154
163
  await client.startAgentSession(cardId, {
@@ -138,6 +138,157 @@ 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. The hybrid-search RPC now both returns
268
+ // `superseded_at` and excludes tombstoned rows by default (#298), so
269
+ // retired rows no longer surface as candidates on the embedding path.
270
+ // Kept as belt-and-suspenders for the FTS fallback and any caller that
271
+ // opts into include_superseded.
272
+ if ((e as { superseded_at?: string | null }).superseded_at) {
273
+ return false;
274
+ }
275
+ // Lexical guard: require real title-token overlap on top of RRF.
276
+ return (
277
+ jaccard(candidateTokens, significantTitleTokens(e.title)) >=
278
+ titleOverlap
279
+ );
280
+ })
281
+ .map((e) => ({
282
+ id: e.id,
283
+ title: e.title,
284
+ score: e.rrf_score ?? 0,
285
+ }));
286
+ } catch {
287
+ // Never block a write because the dedup probe failed.
288
+ return [];
289
+ }
290
+ }
291
+
141
292
  /**
142
293
  * Causal lookup table: maps an entity type to the target types it should
143
294
  * 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
+ }