@gethmy/mcp 2.24.0 → 2.26.0

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.
@@ -488,15 +488,7 @@ ${lines.join(`
488
488
  `)}`;
489
489
  }
490
490
  function generatePrompt(options) {
491
- const {
492
- card,
493
- column,
494
- variant,
495
- customConstraints,
496
- memories,
497
- assembledContext,
498
- assemblyId
499
- } = options;
491
+ const { card, column, variant, customConstraints, memories, assemblyId } = options;
500
492
  const contextOpts = {
501
493
  includeTitle: true,
502
494
  includeDescription: true,
@@ -574,10 +566,7 @@ ${card.description}`);
574
566
  roleFraming.outputSuggestions.forEach((s) => {
575
567
  sections.push(`- ${s}`);
576
568
  });
577
- if (assembledContext) {
578
- sections.push(`
579
- ${assembledContext}`);
580
- } else if (memories && memories.length > 0) {
569
+ if (memories && memories.length > 0) {
581
570
  sections.push(`
582
571
  ## Relevant Memories`);
583
572
  sections.push(`*${memories.length} memories recalled from knowledge graph:*`);
@@ -588,7 +577,7 @@ ${assembledContext}`);
588
577
  sections.push(memory.content);
589
578
  }
590
579
  }
591
- const oneThingLine = synthesizeOneThing(card, subtasks, links, assembledContext);
580
+ const oneThingLine = synthesizeOneThing(card, subtasks, links);
592
581
  if (oneThingLine) {
593
582
  sections.push(`
594
583
  ## Recommended Next Step
@@ -615,7 +604,7 @@ ${customConstraints}`);
615
604
  *Card #${card.short_id} | Generated for ${variant} mode*`);
616
605
  const prompt = sections.join(`
617
606
  `);
618
- const memoryCount = assembledContext ? (assembledContext.match(/^### /gm) || []).length : memories?.length || 0;
607
+ const memoryCount = memories?.length ?? 0;
619
608
  return {
620
609
  prompt,
621
610
  variant,
@@ -636,40 +625,7 @@ ${customConstraints}`);
636
625
  version: PROMPT_TEMPLATE_VERSION
637
626
  };
638
627
  }
639
- function extractSessionInsights(assembledContext) {
640
- const result = {
641
- lastSessionStatus: null,
642
- lastSessionTask: null,
643
- lastSessionProgress: null,
644
- blockers: [],
645
- procedureNextStep: null
646
- };
647
- const sessionMatches = assembledContext.match(/### Session:.*?\n([\s\S]*?)(?=\n###|\n## |\n---|\n\*Assembly|$)/g);
648
- if (sessionMatches && sessionMatches.length > 0) {
649
- const latest = sessionMatches[0];
650
- if (/Completed work on/i.test(latest)) {
651
- result.lastSessionStatus = "completed";
652
- } else if (/Paused work on|status:\s*paused/i.test(latest)) {
653
- result.lastSessionStatus = "paused";
654
- }
655
- const taskMatch = latest.match(/Final task:\s*(.+)/);
656
- if (taskMatch)
657
- result.lastSessionTask = taskMatch[1].trim();
658
- const progressMatch = latest.match(/Progress:\s*(\d+)%/);
659
- if (progressMatch)
660
- result.lastSessionProgress = parseInt(progressMatch[1], 10);
661
- }
662
- const blockerMatches = assembledContext.match(/(?:blocker|blocked by|blocking):\s*(.+)/gi);
663
- if (blockerMatches) {
664
- result.blockers = blockerMatches.map((m) => m.replace(/(?:blocker|blocked by|blocking):\s*/i, "").trim());
665
- }
666
- const stepMatches = assembledContext.match(/^\d+\.\s+(?!.*\*\*\[key step\]\*\*.*✓)(.+?)(?:\s*\*\*\[key step\]\*\*)?$/gm);
667
- if (stepMatches && stepMatches.length > 0) {
668
- result.procedureNextStep = stepMatches[0].replace(/^\d+\.\s+/, "").replace(/\s*\*\*\[key step\]\*\*.*$/, "").trim();
669
- }
670
- return result;
671
- }
672
- function synthesizeOneThing(card, subtasks, links, assembledContext) {
628
+ function synthesizeOneThing(card, subtasks, links) {
673
629
  if (card.done)
674
630
  return null;
675
631
  const blockers = links.filter((l) => l.display_type === "is_blocked_by" && l.direction === "incoming");
@@ -677,14 +633,6 @@ function synthesizeOneThing(card, subtasks, links, assembledContext) {
677
633
  const blocker = blockers[0];
678
634
  return `Unblock first: resolve #${blocker.target_card.short_id} "${blocker.target_card.title}" which is blocking this card.`;
679
635
  }
680
- const session = assembledContext ? extractSessionInsights(assembledContext) : null;
681
- if (session?.blockers && session.blockers.length > 0) {
682
- return `Resolve blocker: ${session.blockers[0]}`;
683
- }
684
- if (session?.lastSessionStatus === "paused" && session.lastSessionTask) {
685
- const progress = session.lastSessionProgress ? ` (was ${session.lastSessionProgress}% complete)` : "";
686
- return `Resume previous session${progress}: "${session.lastSessionTask}".`;
687
- }
688
636
  if (subtasks.length > 0) {
689
637
  const completed = subtasks.filter((s) => s.completed).length;
690
638
  if (completed === subtasks.length) {
@@ -695,12 +643,6 @@ function synthesizeOneThing(card, subtasks, links, assembledContext) {
695
643
  return `Work on next subtask: "${nextSubtask.title}" (${completed}/${subtasks.length} done).`;
696
644
  }
697
645
  }
698
- if (session?.procedureNextStep) {
699
- return `Follow procedure: ${session.procedureNextStep}`;
700
- }
701
- if (session?.lastSessionStatus === "completed" && session.lastSessionTask) {
702
- return `Previous session completed ("${session.lastSessionTask}"). Review results and continue with remaining work.`;
703
- }
704
646
  if (card.due_date && (card.priority === "urgent" || card.priority === "high")) {
705
647
  return `High-priority task with deadline ${card.due_date}. Start implementation immediately.`;
706
648
  }
@@ -885,6 +827,9 @@ var init_prompt_builder = __esm(() => {
885
827
  execute: `EXECUTE MODE: Implement this task completely. Write production-ready code following best practices. Include necessary tests and documentation.`
886
828
  };
887
829
  });
830
+
831
+ // src/api-client.ts
832
+ import { randomUUID as randomUUID2 } from "node:crypto";
888
833
  // ../harmony-shared/dist/agentStaleness.js
889
834
  var AGENT_HEARTBEAT_LIVENESS_MS = 5 * 60 * 1000;
890
835
  var AGENT_MILESTONE_LIVENESS_MS = 30 * 60 * 1000;
@@ -1006,6 +951,12 @@ var TIMINGS = {
1006
951
  QUERY_STALE_TIME: 1000 * 60 * 5,
1007
952
  QUERY_GC_TIME: 1000 * 60 * 60 * 24
1008
953
  };
954
+ // ../harmony-shared/dist/fanoutSource.js
955
+ var FANOUT_KEY_MARKER = "harmony:fanout-item";
956
+ var FANOUT_KEY_RE = new RegExp(`^\\[${FANOUT_KEY_MARKER}\\]:\\s*#(\\S+)\\s*$`, "m");
957
+ // ../harmony-shared/dist/gateConfigError.js
958
+ var GATE_CONFIG_ERROR_KEY = "configError";
959
+ var GATE_CONFIG_ERROR_MARK = Object.freeze({ [GATE_CONFIG_ERROR_KEY]: true });
1009
960
  // ../harmony-shared/dist/playbookStage.js
1010
961
  var STAGE_DAEMON_OWNED_TOOLS = [
1011
962
  "mcp__harmony__harmony_end_agent_session",
@@ -1046,6 +997,19 @@ function getRetryDelay(attempt) {
1046
997
  return Math.round(delay + delay * 0.25 * (Math.random() * 2 - 1));
1047
998
  }
1048
999
  var sleep2 = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
1000
+ function buildMemoryQuery(title, description) {
1001
+ const DESCRIPTION_CAP = 600;
1002
+ const trimmedTitle = title.trim();
1003
+ const trimmedBody = (description ?? "").trim();
1004
+ if (!trimmedTitle && !trimmedBody)
1005
+ return "";
1006
+ if (!trimmedTitle)
1007
+ return trimmedBody.slice(0, DESCRIPTION_CAP);
1008
+ if (!trimmedBody)
1009
+ return trimmedTitle;
1010
+ return `${trimmedTitle}
1011
+ ${trimmedBody.slice(0, DESCRIPTION_CAP)}`;
1012
+ }
1049
1013
 
1050
1014
  class Semaphore {
1051
1015
  permits;
@@ -1351,8 +1315,12 @@ class HarmonyApiClient {
1351
1315
  async updateCard(cardId, updates) {
1352
1316
  return this.request("PATCH", `/cards/${cardId}`, updates);
1353
1317
  }
1354
- async claimCard(cardId, agentId) {
1355
- const res = await this.request("PATCH", `/cards/${cardId}`, { assignedAgentId: agentId, ifAssignedAgentNull: true });
1318
+ async claimCard(cardId, agentId, opts) {
1319
+ const res = await this.request("PATCH", `/cards/${cardId}`, {
1320
+ assignedAgentId: agentId,
1321
+ ifAssignedAgentNull: true,
1322
+ ...opts?.requireUnassigned ? { ifAssigneeNull: true } : {}
1323
+ });
1356
1324
  return { claimed: res.claimed !== false };
1357
1325
  }
1358
1326
  async moveCard(cardId, columnId, position) {
@@ -1516,6 +1484,14 @@ class HarmonyApiClient {
1516
1484
  params.set("sinceSeq", String(sinceSeq));
1517
1485
  return this.request("GET", `/cards/${cardId}/agent-messages?${params.toString()}`);
1518
1486
  }
1487
+ async postBudgetDecision(cardId, data) {
1488
+ return this.request("POST", `/cards/${cardId}/budget-decisions`, data);
1489
+ }
1490
+ async getBudgetDecisions(cardId, sinceIso) {
1491
+ const params = new URLSearchParams;
1492
+ params.set("sinceIso", sinceIso);
1493
+ return this.request("GET", `/cards/${cardId}/budget-decisions?${params.toString()}`);
1494
+ }
1519
1495
  async updateAgentProgress(cardId, data) {
1520
1496
  return this.request("POST", `/cards/${cardId}/agent-context`, data);
1521
1497
  }
@@ -1561,6 +1537,8 @@ class HarmonyApiClient {
1561
1537
  params.set("offset", String(options.offset));
1562
1538
  if (options.include_superseded)
1563
1539
  params.set("include_superseded", "true");
1540
+ if (options.consumer)
1541
+ params.set("consumer", options.consumer);
1564
1542
  if (options.include_episodes)
1565
1543
  params.set("include_episodes", "true");
1566
1544
  return this.request("GET", `/memory/entities?${params.toString()}`);
@@ -1616,6 +1594,12 @@ class HarmonyApiClient {
1616
1594
  if (options.topK !== undefined) {
1617
1595
  entities = entities.slice(0, options.topK);
1618
1596
  }
1597
+ if (options.consumer) {
1598
+ const deliveredIds = entities.map((e) => e.id).filter((id) => typeof id === "string");
1599
+ if (deliveredIds.length > 0) {
1600
+ this.batchTouchMemoryEntities(deliveredIds, options.consumer).catch(() => {});
1601
+ }
1602
+ }
1619
1603
  return { entities };
1620
1604
  }
1621
1605
  async deleteMemoryEntity(entityId) {
@@ -1624,9 +1608,10 @@ class HarmonyApiClient {
1624
1608
  async touchMemoryEntity(entityId) {
1625
1609
  return this.request("POST", `/memory/entities/${entityId}/touch`);
1626
1610
  }
1627
- async batchTouchMemoryEntities(entityIds) {
1611
+ async batchTouchMemoryEntities(entityIds, consumer) {
1628
1612
  return this.request("POST", "/memory/entities/batch-touch", {
1629
- entity_ids: entityIds
1613
+ entity_ids: entityIds,
1614
+ ...consumer ? { consumer } : {}
1630
1615
  });
1631
1616
  }
1632
1617
  async createMemoryRelation(data) {
@@ -1652,8 +1637,12 @@ class HarmonyApiClient {
1652
1637
  params.append("tags", tag);
1653
1638
  if (options?.include_superseded)
1654
1639
  params.set("include_superseded", "true");
1640
+ if (options?.consumer)
1641
+ params.set("consumer", options.consumer);
1655
1642
  if (options?.include_episodes)
1656
1643
  params.set("include_episodes", "true");
1644
+ if (options?.assembly_id)
1645
+ params.set("assembly_id", options.assembly_id);
1657
1646
  return this.request("GET", `/memory/search?${params.toString()}`);
1658
1647
  }
1659
1648
  async getVaultIndex(options) {
@@ -1665,6 +1654,8 @@ class HarmonyApiClient {
1665
1654
  params.set("type", options.type);
1666
1655
  if (options.limit !== undefined)
1667
1656
  params.set("limit", String(options.limit));
1657
+ if (options.consumer)
1658
+ params.set("consumer", options.consumer);
1668
1659
  if (options.include_episodes)
1669
1660
  params.set("include_episodes", "true");
1670
1661
  return this.request("GET", `/memory/index?${params.toString()}`);
@@ -1678,6 +1669,8 @@ class HarmonyApiClient {
1678
1669
  params.set("type", options.type);
1679
1670
  if (options.limit !== undefined)
1680
1671
  params.set("limit", String(options.limit));
1672
+ if (options.consumer)
1673
+ params.set("consumer", options.consumer);
1681
1674
  if (options.include_episodes)
1682
1675
  params.set("include_episodes", "true");
1683
1676
  return this.requestRaw("GET", `/memory/index?${params.toString()}`, undefined, {
@@ -1737,6 +1730,8 @@ class HarmonyApiClient {
1737
1730
  params.set("type", options.type);
1738
1731
  if (options?.limit !== undefined)
1739
1732
  params.set("limit", String(options.limit));
1733
+ if (options?.consumer)
1734
+ params.set("consumer", options.consumer);
1740
1735
  if (options?.include_episodes)
1741
1736
  params.set("include_episodes", "true");
1742
1737
  return this.requestRaw("GET", `/memory/search?${params.toString()}`, undefined, {
@@ -1842,14 +1837,15 @@ class HarmonyApiClient {
1842
1837
  } catch {}
1843
1838
  }
1844
1839
  const variant = options.variant || "execute";
1845
- const assembledContextStr = undefined;
1846
- const assemblyId = undefined;
1840
+ const assemblyId = randomUUID2();
1847
1841
  let memories;
1848
1842
  try {
1849
1843
  if (options.workspaceId && cardData.title) {
1850
- const memoryResult = await this.searchMemoryEntities(options.workspaceId, cardData.title, {
1844
+ const memoryResult = await this.searchMemoryEntities(options.workspaceId, buildMemoryQuery(cardData.title, cardData.description), {
1851
1845
  project_id: options.projectId,
1852
- limit: 5
1846
+ limit: 5,
1847
+ consumer: "agent-prompt",
1848
+ assembly_id: assemblyId
1853
1849
  });
1854
1850
  if (memoryResult.entities?.length > 0) {
1855
1851
  memories = memoryResult.entities.map((e) => ({
@@ -1873,7 +1869,6 @@ class HarmonyApiClient {
1873
1869
  contextOptions: options.contextOptions,
1874
1870
  customConstraints: options.customConstraints,
1875
1871
  memories,
1876
- assembledContext: assembledContextStr,
1877
1872
  assemblyId
1878
1873
  });
1879
1874
  try {
@@ -1983,6 +1978,7 @@ export {
1983
1978
  resetClient,
1984
1979
  requestWithBearer,
1985
1980
  getClient,
1981
+ buildMemoryQuery,
1986
1982
  HarmonyUnauthorizedError,
1987
1983
  HarmonyApiClient
1988
1984
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethmy/mcp",
3
- "version": "2.24.0",
3
+ "version": "2.26.0",
4
4
  "description": "MCP server for Harmony, the shared surface for human–agent teams — agents claim cards, report progress, and move work on your board.",
5
5
  "publishConfig": {
6
6
  "access": "public"
package/src/api-client.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import {
2
3
  type AgentRunEventDraft,
3
4
  type Comment,
@@ -9,6 +10,7 @@ import {
9
10
  type WorkspaceAgent,
10
11
  } from "@harmony/shared";
11
12
  import { getApiKey, getApiUrl } from "./config.js";
13
+ import type { ReadConsumer } from "./read-consumer.js";
12
14
 
13
15
  export interface ApiResponse<T = unknown> {
14
16
  success?: boolean;
@@ -49,6 +51,27 @@ function getRetryDelay(attempt: number): number {
49
51
 
50
52
  const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
51
53
 
54
+ /**
55
+ * Build the recall query for a card. The title alone was the whole query, which
56
+ * is too thin to rank against. The description is capped because a card body
57
+ * runs to 10,000 characters, the embedding truncates at END, and an uncapped
58
+ * body drowns the title it is meant to sharpen.
59
+ */
60
+ export function buildMemoryQuery(
61
+ title: string,
62
+ description: string | null | undefined,
63
+ ): string {
64
+ const DESCRIPTION_CAP = 600;
65
+ const trimmedTitle = title.trim();
66
+ const trimmedBody = (description ?? "").trim();
67
+
68
+ if (!trimmedTitle && !trimmedBody) return "";
69
+ if (!trimmedTitle) return trimmedBody.slice(0, DESCRIPTION_CAP);
70
+ if (!trimmedBody) return trimmedTitle;
71
+
72
+ return `${trimmedTitle}\n${trimmedBody.slice(0, DESCRIPTION_CAP)}`;
73
+ }
74
+
52
75
  // Semaphore for concurrency control
53
76
  class Semaphore {
54
77
  private permits: number;
@@ -532,7 +555,17 @@ export class HarmonyApiClient {
532
555
  /** Register/upsert this daemon's virtual agent. Idempotent by (workspace, identifier). */
533
556
  async registerWorkspaceAgent(
534
557
  workspaceId: string,
535
- data: { identifier: string; name: string; color?: string },
558
+ data: {
559
+ identifier: string;
560
+ name: string;
561
+ color?: string;
562
+ /**
563
+ * Gate metric NAMES the daemon declares under `agent.playbooks.metrics`
564
+ * (#922) — names only, never the commands behind them. Omit to leave the
565
+ * stored report untouched.
566
+ */
567
+ declaredGateMetrics?: string[];
568
+ },
536
569
  ): Promise<{ agent: WorkspaceAgent }> {
537
570
  return this.request("POST", `/workspaces/${workspaceId}/agents`, data);
538
571
  }
@@ -691,15 +724,27 @@ export class HarmonyApiClient {
691
724
  * Compare-and-set claim of a card for a virtual agent: sets assigned_agent_id
692
725
  * only if it is currently NULL (server-side guard). Returns `{ claimed: false }`
693
726
  * when another daemon already owns it. Used by the agent-agnostic review pickup.
727
+ *
728
+ * `requireUnassigned` additionally guards `assignee_id IS NULL`, so the claim
729
+ * also loses to a PERSON who took the card. Self-selection (sweep, card #978)
730
+ * needs it: this write clears `assignee_id` as a side effect, so the agent-only
731
+ * guard does not merely let the daemon co-own a person's card — it lets the
732
+ * daemon erase their assignment. Default false keeps the review-strand claim
733
+ * (which reaches only cards no one holds) byte-unchanged.
694
734
  */
695
735
  async claimCard(
696
736
  cardId: string,
697
737
  agentId: string,
738
+ opts?: { requireUnassigned?: boolean },
698
739
  ): Promise<{ claimed: boolean }> {
699
740
  const res = await this.request<{ card?: unknown; claimed?: boolean }>(
700
741
  "PATCH",
701
742
  `/cards/${cardId}`,
702
- { assignedAgentId: agentId, ifAssignedAgentNull: true },
743
+ {
744
+ assignedAgentId: agentId,
745
+ ifAssignedAgentNull: true,
746
+ ...(opts?.requireUnassigned ? { ifAssigneeNull: true } : {}),
747
+ },
703
748
  );
704
749
  // Success body is `{ card }` (no `claimed` key) → won. Lost-race body is
705
750
  // `{ claimed: false }`.
@@ -1097,6 +1142,15 @@ export class HarmonyApiClient {
1097
1142
  * working); the MCP tool dispatch defaults to `interactive`.
1098
1143
  */
1099
1144
  driver?: "daemon" | "interactive" | "script";
1145
+ /**
1146
+ * When a human budget decision is pending on this session, as an ISO
1147
+ * timestamp (card #915). A run START passes `null` explicitly: the route
1148
+ * REUSES a live session row, so a card whose previous run parked would
1149
+ * otherwise inherit that park's deadline — the panel offering Continue
1150
+ * over a run that is already going again, and the sweep exemption
1151
+ * masking a genuinely dead run for the rest of the window.
1152
+ */
1153
+ awaitingDecisionUntil?: string | null;
1100
1154
  },
1101
1155
  ): Promise<{
1102
1156
  session: unknown;
@@ -1147,6 +1201,53 @@ export class HarmonyApiClient {
1147
1201
  );
1148
1202
  }
1149
1203
 
1204
+ /**
1205
+ * Post a human's durable answer to a parked run's budget question (card
1206
+ * #915): grant more turns and continue, or stop and hand the card back.
1207
+ *
1208
+ * Unlike pause/resume/stop — an ephemeral Realtime broadcast nothing hears
1209
+ * once the daemon is offline — this MUST survive a daemon restart hours
1210
+ * later, so it lands as a `budget_decision` row rather than a broadcast.
1211
+ * The server resolves which run to decide for from the card's one active
1212
+ * session; the caller does not (and, deciding from the board, cannot) name
1213
+ * a session id.
1214
+ */
1215
+ async postBudgetDecision(
1216
+ cardId: string,
1217
+ data: {
1218
+ decision: "continue" | "stop";
1219
+ extraTurns: number;
1220
+ message?: string;
1221
+ },
1222
+ ): Promise<{ id: string; seq: number; createdAt: string }> {
1223
+ return this.request("POST", `/cards/${cardId}/budget-decisions`, data);
1224
+ }
1225
+
1226
+ /**
1227
+ * Drain `budget_decision` events for a card since `sinceIso` (card #915).
1228
+ * Keyed on card + time, not session + seq like `getPendingMessages`: the
1229
+ * daemon asking on startup may not know the session id of a run it parked
1230
+ * hours or days earlier, but it always knows the card.
1231
+ */
1232
+ async getBudgetDecisions(
1233
+ cardId: string,
1234
+ sinceIso: string,
1235
+ ): Promise<{
1236
+ decisions: Array<{
1237
+ decision: "continue" | "stop";
1238
+ extraTurns: number;
1239
+ message?: string;
1240
+ createdAt: string;
1241
+ }>;
1242
+ }> {
1243
+ const params = new URLSearchParams();
1244
+ params.set("sinceIso", sinceIso);
1245
+ return this.request(
1246
+ "GET",
1247
+ `/cards/${cardId}/budget-decisions?${params.toString()}`,
1248
+ );
1249
+ }
1250
+
1150
1251
  async updateAgentProgress(
1151
1252
  cardId: string,
1152
1253
  data: {
@@ -1205,6 +1306,16 @@ export class HarmonyApiClient {
1205
1306
  * deferred the respawn by 10 minutes (#770). Reply carries `stopped: true`.
1206
1307
  */
1207
1308
  implicitCreate?: boolean;
1309
+ /**
1310
+ * Set while the session waits for a human budget decision (#915),
1311
+ * cleared (null) once one arrives. Exempts the row from the daemon's
1312
+ * stale-session sweep until this instant — a parked run's deliberate
1313
+ * silence would otherwise read as abandonment well before a person can
1314
+ * answer. ISO timestamp string; the daemon-side mirror
1315
+ * (`StateStore.parkRun`'s `awaitingDecisionUntil`) is the same instant
1316
+ * in epoch ms, not this string.
1317
+ */
1318
+ awaitingDecisionUntil?: string | null;
1208
1319
  },
1209
1320
  ): Promise<{
1210
1321
  session: unknown;
@@ -1351,6 +1462,9 @@ export class HarmonyApiClient {
1351
1462
  // Opt in to agent-run episodes (default excluded, #677). Only the daemon's
1352
1463
  // own rolling-episode lookup should set this.
1353
1464
  include_episodes?: boolean;
1465
+ /** Read-accounting label (task 4) — omitted means the read is not
1466
+ * recorded. Callers that know their own identity should pass it. */
1467
+ consumer?: ReadConsumer;
1354
1468
  }): Promise<{ entities: unknown[]; count: number }> {
1355
1469
  const params = new URLSearchParams();
1356
1470
  params.set("workspace_id", options.workspace_id);
@@ -1367,6 +1481,7 @@ export class HarmonyApiClient {
1367
1481
  if (options.offset !== undefined)
1368
1482
  params.set("offset", String(options.offset));
1369
1483
  if (options.include_superseded) params.set("include_superseded", "true");
1484
+ if (options.consumer) params.set("consumer", options.consumer);
1370
1485
  if (options.include_episodes) params.set("include_episodes", "true");
1371
1486
  return this.request("GET", `/memory/entities?${params.toString()}`);
1372
1487
  }
@@ -1419,9 +1534,17 @@ export class HarmonyApiClient {
1419
1534
  // Opt in to agent-run episodes (default excluded server-side, #677). The
1420
1535
  // daemon sets this when looking up its own rolling implement/review episode.
1421
1536
  includeEpisodes?: boolean;
1537
+ /** Read-accounting label (task 4) — omitted means the read is not
1538
+ * recorded. Callers that know their own identity should pass it. */
1539
+ consumer?: ReadConsumer;
1422
1540
  }): Promise<{ entities: unknown[] }> {
1423
1541
  // Over-fetch beyond topK so client-side filters (multi-type, memory_tier,
1424
1542
  // tags) have headroom — matches the MCP server's recall path (server.ts).
1543
+ // `consumer` is deliberately NOT forwarded on this over-fetch: the server
1544
+ // would record read-accounting for every over-fetched row, not just the
1545
+ // ones actually delivered. Instead we record the delivered set below,
1546
+ // after client-side filtering and the topK trim (mirrors the mcp-tool
1547
+ // path in server.ts).
1425
1548
  const fetchLimit = Math.max(options.topK ?? 3, 50);
1426
1549
  let entities: Array<Record<string, unknown>> = [];
1427
1550
 
@@ -1483,6 +1606,21 @@ export class HarmonyApiClient {
1483
1606
  entities = entities.slice(0, options.topK);
1484
1607
  }
1485
1608
 
1609
+ // Read accounting: record only the entities actually delivered to the
1610
+ // caller, after all client-side filtering and the topK trim — never the
1611
+ // larger over-fetched candidate pool the calls above deliberately left
1612
+ // unrecorded. Best-effort; a recording failure must not fail the recall.
1613
+ if (options.consumer) {
1614
+ const deliveredIds = entities
1615
+ .map((e) => e.id)
1616
+ .filter((id): id is string => typeof id === "string");
1617
+ if (deliveredIds.length > 0) {
1618
+ this.batchTouchMemoryEntities(deliveredIds, options.consumer).catch(
1619
+ () => {},
1620
+ );
1621
+ }
1622
+ }
1623
+
1486
1624
  return { entities };
1487
1625
  }
1488
1626
 
@@ -1496,9 +1634,16 @@ export class HarmonyApiClient {
1496
1634
 
1497
1635
  async batchTouchMemoryEntities(
1498
1636
  entityIds: string[],
1637
+ /** Read-accounting label (task 4, fix round 1). Omitted → the route keeps
1638
+ * its original increment-only `batch_touch_knowledge_entities` behaviour
1639
+ * (legacy context-assembly callers). Given → the route instead records
1640
+ * through `record_entity_reads`, the same path the search chokepoint
1641
+ * uses, so this touch is properly attributed rather than anonymous. */
1642
+ consumer?: ReadConsumer,
1499
1643
  ): Promise<{ success: boolean; count: number }> {
1500
1644
  return this.request("POST", "/memory/entities/batch-touch", {
1501
1645
  entity_ids: entityIds,
1646
+ ...(consumer ? { consumer } : {}),
1502
1647
  });
1503
1648
  }
1504
1649
 
@@ -1533,6 +1678,16 @@ export class HarmonyApiClient {
1533
1678
  tags?: string[];
1534
1679
  include_superseded?: boolean;
1535
1680
  include_episodes?: boolean;
1681
+ /** Read-accounting label (task 4) — omitted means the read is not
1682
+ * recorded (there is deliberately no server-side default: it used to
1683
+ * default to "search" and inflated access_count on every
1684
+ * `harmony_remember` write-side probe). Callers that know their own
1685
+ * identity (harmony_recall) should pass it explicitly. */
1686
+ consumer?: ReadConsumer;
1687
+ /** Per-prompt-build id (task 6b) — shared by every read row recorded
1688
+ * during one `generateCardPrompt` call, so a card can later show which
1689
+ * memories shaped its prompt. */
1690
+ assembly_id?: string;
1536
1691
  },
1537
1692
  ): Promise<{ entities: unknown[]; count: number }> {
1538
1693
  const params = new URLSearchParams();
@@ -1546,7 +1701,9 @@ export class HarmonyApiClient {
1546
1701
  // matches against the canonical `tags_normalized` column (#299).
1547
1702
  for (const tag of options?.tags ?? []) params.append("tags", tag);
1548
1703
  if (options?.include_superseded) params.set("include_superseded", "true");
1704
+ if (options?.consumer) params.set("consumer", options.consumer);
1549
1705
  if (options?.include_episodes) params.set("include_episodes", "true");
1706
+ if (options?.assembly_id) params.set("assembly_id", options.assembly_id);
1550
1707
  return this.request("GET", `/memory/search?${params.toString()}`);
1551
1708
  }
1552
1709
 
@@ -1558,12 +1715,16 @@ export class HarmonyApiClient {
1558
1715
  type?: string;
1559
1716
  limit?: number;
1560
1717
  include_episodes?: boolean;
1718
+ /** Read-accounting label (task 4) — omitted means the read is not
1719
+ * recorded. Callers that know their own identity should pass it. */
1720
+ consumer?: ReadConsumer;
1561
1721
  }): Promise<{ entities: unknown[]; count: number }> {
1562
1722
  const params = new URLSearchParams();
1563
1723
  params.set("workspace_id", options.workspace_id);
1564
1724
  if (options.project_id) params.set("project_id", options.project_id);
1565
1725
  if (options.type) params.set("type", options.type);
1566
1726
  if (options.limit !== undefined) params.set("limit", String(options.limit));
1727
+ if (options.consumer) params.set("consumer", options.consumer);
1567
1728
  if (options.include_episodes) params.set("include_episodes", "true");
1568
1729
  return this.request("GET", `/memory/index?${params.toString()}`);
1569
1730
  }
@@ -1574,12 +1735,16 @@ export class HarmonyApiClient {
1574
1735
  type?: string;
1575
1736
  limit?: number;
1576
1737
  include_episodes?: boolean;
1738
+ /** Read-accounting label (task 4) — omitted means the read is not
1739
+ * recorded. Callers that know their own identity should pass it. */
1740
+ consumer?: ReadConsumer;
1577
1741
  }): Promise<string> {
1578
1742
  const params = new URLSearchParams();
1579
1743
  params.set("workspace_id", options.workspace_id);
1580
1744
  if (options.project_id) params.set("project_id", options.project_id);
1581
1745
  if (options.type) params.set("type", options.type);
1582
1746
  if (options.limit !== undefined) params.set("limit", String(options.limit));
1747
+ if (options.consumer) params.set("consumer", options.consumer);
1583
1748
  if (options.include_episodes) params.set("include_episodes", "true");
1584
1749
  return this.requestRaw(
1585
1750
  "GET",
@@ -1668,6 +1833,9 @@ export class HarmonyApiClient {
1668
1833
  type?: string;
1669
1834
  limit?: number;
1670
1835
  include_episodes?: boolean;
1836
+ /** Read-accounting label (task 4) — omitted means the read is not
1837
+ * recorded. Callers that know their own identity should pass it. */
1838
+ consumer?: ReadConsumer;
1671
1839
  },
1672
1840
  ): Promise<string> {
1673
1841
  const params = new URLSearchParams();
@@ -1677,6 +1845,7 @@ export class HarmonyApiClient {
1677
1845
  if (options?.type) params.set("type", options.type);
1678
1846
  if (options?.limit !== undefined)
1679
1847
  params.set("limit", String(options.limit));
1848
+ if (options?.consumer) params.set("consumer", options.consumer);
1680
1849
  if (options?.include_episodes) params.set("include_episodes", "true");
1681
1850
  return this.requestRaw(
1682
1851
  "GET",
@@ -1978,18 +2147,22 @@ export class HarmonyApiClient {
1978
2147
  // Phase 0 (memory architecture v2): full context assembly removed.
1979
2148
  // Use the basic memory search path so callers still get _some_ memory
1980
2149
  // hints. Phase 1 will reintroduce a session-scoped working memory layer.
1981
- const assembledContextStr: string | undefined = undefined;
1982
- const assemblyId: string | undefined = undefined;
2150
+
2151
+ // One id per prompt build. Every read row recorded during this build carries
2152
+ // it, which is what lets a card show the memories that shaped its prompt.
2153
+ const assemblyId = randomUUID();
1983
2154
  let memories: MemoryItem[] | undefined;
1984
2155
 
1985
2156
  try {
1986
2157
  if (options.workspaceId && cardData.title) {
1987
2158
  const memoryResult = await this.searchMemoryEntities(
1988
2159
  options.workspaceId,
1989
- cardData.title,
2160
+ buildMemoryQuery(cardData.title, cardData.description),
1990
2161
  {
1991
2162
  project_id: options.projectId,
1992
2163
  limit: 5,
2164
+ consumer: "agent-prompt",
2165
+ assembly_id: assemblyId,
1993
2166
  },
1994
2167
  );
1995
2168
  if (memoryResult.entities?.length > 0) {
@@ -2015,7 +2188,6 @@ export class HarmonyApiClient {
2015
2188
  contextOptions: options.contextOptions,
2016
2189
  customConstraints: options.customConstraints,
2017
2190
  memories,
2018
- assembledContext: assembledContextStr,
2019
2191
  assemblyId,
2020
2192
  });
2021
2193