@gethmy/mcp 2.24.0 → 2.25.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.
- package/dist/cli.js +182 -74
- package/dist/index.js +182 -74
- package/dist/lib/api-client.js +59 -70
- package/package.json +1 -1
- package/src/api-client.ts +165 -5
- package/src/playbook-metric-warnings.ts +56 -0
- package/src/prompt-builder.ts +9 -120
- package/src/read-consumer.ts +16 -0
- package/src/server.ts +41 -11
package/dist/lib/api-client.js
CHANGED
|
@@ -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 (
|
|
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
|
|
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 =
|
|
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
|
|
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,9 @@ 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/gateConfigError.js
|
|
955
|
+
var GATE_CONFIG_ERROR_KEY = "configError";
|
|
956
|
+
var GATE_CONFIG_ERROR_MARK = Object.freeze({ [GATE_CONFIG_ERROR_KEY]: true });
|
|
1009
957
|
// ../harmony-shared/dist/playbookStage.js
|
|
1010
958
|
var STAGE_DAEMON_OWNED_TOOLS = [
|
|
1011
959
|
"mcp__harmony__harmony_end_agent_session",
|
|
@@ -1046,6 +994,19 @@ function getRetryDelay(attempt) {
|
|
|
1046
994
|
return Math.round(delay + delay * 0.25 * (Math.random() * 2 - 1));
|
|
1047
995
|
}
|
|
1048
996
|
var sleep2 = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
997
|
+
function buildMemoryQuery(title, description) {
|
|
998
|
+
const DESCRIPTION_CAP = 600;
|
|
999
|
+
const trimmedTitle = title.trim();
|
|
1000
|
+
const trimmedBody = (description ?? "").trim();
|
|
1001
|
+
if (!trimmedTitle && !trimmedBody)
|
|
1002
|
+
return "";
|
|
1003
|
+
if (!trimmedTitle)
|
|
1004
|
+
return trimmedBody.slice(0, DESCRIPTION_CAP);
|
|
1005
|
+
if (!trimmedBody)
|
|
1006
|
+
return trimmedTitle;
|
|
1007
|
+
return `${trimmedTitle}
|
|
1008
|
+
${trimmedBody.slice(0, DESCRIPTION_CAP)}`;
|
|
1009
|
+
}
|
|
1049
1010
|
|
|
1050
1011
|
class Semaphore {
|
|
1051
1012
|
permits;
|
|
@@ -1516,6 +1477,14 @@ class HarmonyApiClient {
|
|
|
1516
1477
|
params.set("sinceSeq", String(sinceSeq));
|
|
1517
1478
|
return this.request("GET", `/cards/${cardId}/agent-messages?${params.toString()}`);
|
|
1518
1479
|
}
|
|
1480
|
+
async postBudgetDecision(cardId, data) {
|
|
1481
|
+
return this.request("POST", `/cards/${cardId}/budget-decisions`, data);
|
|
1482
|
+
}
|
|
1483
|
+
async getBudgetDecisions(cardId, sinceIso) {
|
|
1484
|
+
const params = new URLSearchParams;
|
|
1485
|
+
params.set("sinceIso", sinceIso);
|
|
1486
|
+
return this.request("GET", `/cards/${cardId}/budget-decisions?${params.toString()}`);
|
|
1487
|
+
}
|
|
1519
1488
|
async updateAgentProgress(cardId, data) {
|
|
1520
1489
|
return this.request("POST", `/cards/${cardId}/agent-context`, data);
|
|
1521
1490
|
}
|
|
@@ -1561,6 +1530,8 @@ class HarmonyApiClient {
|
|
|
1561
1530
|
params.set("offset", String(options.offset));
|
|
1562
1531
|
if (options.include_superseded)
|
|
1563
1532
|
params.set("include_superseded", "true");
|
|
1533
|
+
if (options.consumer)
|
|
1534
|
+
params.set("consumer", options.consumer);
|
|
1564
1535
|
if (options.include_episodes)
|
|
1565
1536
|
params.set("include_episodes", "true");
|
|
1566
1537
|
return this.request("GET", `/memory/entities?${params.toString()}`);
|
|
@@ -1616,6 +1587,12 @@ class HarmonyApiClient {
|
|
|
1616
1587
|
if (options.topK !== undefined) {
|
|
1617
1588
|
entities = entities.slice(0, options.topK);
|
|
1618
1589
|
}
|
|
1590
|
+
if (options.consumer) {
|
|
1591
|
+
const deliveredIds = entities.map((e) => e.id).filter((id) => typeof id === "string");
|
|
1592
|
+
if (deliveredIds.length > 0) {
|
|
1593
|
+
this.batchTouchMemoryEntities(deliveredIds, options.consumer).catch(() => {});
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1619
1596
|
return { entities };
|
|
1620
1597
|
}
|
|
1621
1598
|
async deleteMemoryEntity(entityId) {
|
|
@@ -1624,9 +1601,10 @@ class HarmonyApiClient {
|
|
|
1624
1601
|
async touchMemoryEntity(entityId) {
|
|
1625
1602
|
return this.request("POST", `/memory/entities/${entityId}/touch`);
|
|
1626
1603
|
}
|
|
1627
|
-
async batchTouchMemoryEntities(entityIds) {
|
|
1604
|
+
async batchTouchMemoryEntities(entityIds, consumer) {
|
|
1628
1605
|
return this.request("POST", "/memory/entities/batch-touch", {
|
|
1629
|
-
entity_ids: entityIds
|
|
1606
|
+
entity_ids: entityIds,
|
|
1607
|
+
...consumer ? { consumer } : {}
|
|
1630
1608
|
});
|
|
1631
1609
|
}
|
|
1632
1610
|
async createMemoryRelation(data) {
|
|
@@ -1652,8 +1630,12 @@ class HarmonyApiClient {
|
|
|
1652
1630
|
params.append("tags", tag);
|
|
1653
1631
|
if (options?.include_superseded)
|
|
1654
1632
|
params.set("include_superseded", "true");
|
|
1633
|
+
if (options?.consumer)
|
|
1634
|
+
params.set("consumer", options.consumer);
|
|
1655
1635
|
if (options?.include_episodes)
|
|
1656
1636
|
params.set("include_episodes", "true");
|
|
1637
|
+
if (options?.assembly_id)
|
|
1638
|
+
params.set("assembly_id", options.assembly_id);
|
|
1657
1639
|
return this.request("GET", `/memory/search?${params.toString()}`);
|
|
1658
1640
|
}
|
|
1659
1641
|
async getVaultIndex(options) {
|
|
@@ -1665,6 +1647,8 @@ class HarmonyApiClient {
|
|
|
1665
1647
|
params.set("type", options.type);
|
|
1666
1648
|
if (options.limit !== undefined)
|
|
1667
1649
|
params.set("limit", String(options.limit));
|
|
1650
|
+
if (options.consumer)
|
|
1651
|
+
params.set("consumer", options.consumer);
|
|
1668
1652
|
if (options.include_episodes)
|
|
1669
1653
|
params.set("include_episodes", "true");
|
|
1670
1654
|
return this.request("GET", `/memory/index?${params.toString()}`);
|
|
@@ -1678,6 +1662,8 @@ class HarmonyApiClient {
|
|
|
1678
1662
|
params.set("type", options.type);
|
|
1679
1663
|
if (options.limit !== undefined)
|
|
1680
1664
|
params.set("limit", String(options.limit));
|
|
1665
|
+
if (options.consumer)
|
|
1666
|
+
params.set("consumer", options.consumer);
|
|
1681
1667
|
if (options.include_episodes)
|
|
1682
1668
|
params.set("include_episodes", "true");
|
|
1683
1669
|
return this.requestRaw("GET", `/memory/index?${params.toString()}`, undefined, {
|
|
@@ -1737,6 +1723,8 @@ class HarmonyApiClient {
|
|
|
1737
1723
|
params.set("type", options.type);
|
|
1738
1724
|
if (options?.limit !== undefined)
|
|
1739
1725
|
params.set("limit", String(options.limit));
|
|
1726
|
+
if (options?.consumer)
|
|
1727
|
+
params.set("consumer", options.consumer);
|
|
1740
1728
|
if (options?.include_episodes)
|
|
1741
1729
|
params.set("include_episodes", "true");
|
|
1742
1730
|
return this.requestRaw("GET", `/memory/search?${params.toString()}`, undefined, {
|
|
@@ -1842,14 +1830,15 @@ class HarmonyApiClient {
|
|
|
1842
1830
|
} catch {}
|
|
1843
1831
|
}
|
|
1844
1832
|
const variant = options.variant || "execute";
|
|
1845
|
-
const
|
|
1846
|
-
const assemblyId = undefined;
|
|
1833
|
+
const assemblyId = randomUUID2();
|
|
1847
1834
|
let memories;
|
|
1848
1835
|
try {
|
|
1849
1836
|
if (options.workspaceId && cardData.title) {
|
|
1850
|
-
const memoryResult = await this.searchMemoryEntities(options.workspaceId, cardData.title, {
|
|
1837
|
+
const memoryResult = await this.searchMemoryEntities(options.workspaceId, buildMemoryQuery(cardData.title, cardData.description), {
|
|
1851
1838
|
project_id: options.projectId,
|
|
1852
|
-
limit: 5
|
|
1839
|
+
limit: 5,
|
|
1840
|
+
consumer: "agent-prompt",
|
|
1841
|
+
assembly_id: assemblyId
|
|
1853
1842
|
});
|
|
1854
1843
|
if (memoryResult.entities?.length > 0) {
|
|
1855
1844
|
memories = memoryResult.entities.map((e) => ({
|
|
@@ -1873,7 +1862,6 @@ class HarmonyApiClient {
|
|
|
1873
1862
|
contextOptions: options.contextOptions,
|
|
1874
1863
|
customConstraints: options.customConstraints,
|
|
1875
1864
|
memories,
|
|
1876
|
-
assembledContext: assembledContextStr,
|
|
1877
1865
|
assemblyId
|
|
1878
1866
|
});
|
|
1879
1867
|
try {
|
|
@@ -1983,6 +1971,7 @@ export {
|
|
|
1983
1971
|
resetClient,
|
|
1984
1972
|
requestWithBearer,
|
|
1985
1973
|
getClient,
|
|
1974
|
+
buildMemoryQuery,
|
|
1986
1975
|
HarmonyUnauthorizedError,
|
|
1987
1976
|
HarmonyApiClient
|
|
1988
1977
|
};
|
package/package.json
CHANGED
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: {
|
|
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
|
}
|
|
@@ -1097,6 +1130,15 @@ export class HarmonyApiClient {
|
|
|
1097
1130
|
* working); the MCP tool dispatch defaults to `interactive`.
|
|
1098
1131
|
*/
|
|
1099
1132
|
driver?: "daemon" | "interactive" | "script";
|
|
1133
|
+
/**
|
|
1134
|
+
* When a human budget decision is pending on this session, as an ISO
|
|
1135
|
+
* timestamp (card #915). A run START passes `null` explicitly: the route
|
|
1136
|
+
* REUSES a live session row, so a card whose previous run parked would
|
|
1137
|
+
* otherwise inherit that park's deadline — the panel offering Continue
|
|
1138
|
+
* over a run that is already going again, and the sweep exemption
|
|
1139
|
+
* masking a genuinely dead run for the rest of the window.
|
|
1140
|
+
*/
|
|
1141
|
+
awaitingDecisionUntil?: string | null;
|
|
1100
1142
|
},
|
|
1101
1143
|
): Promise<{
|
|
1102
1144
|
session: unknown;
|
|
@@ -1147,6 +1189,53 @@ export class HarmonyApiClient {
|
|
|
1147
1189
|
);
|
|
1148
1190
|
}
|
|
1149
1191
|
|
|
1192
|
+
/**
|
|
1193
|
+
* Post a human's durable answer to a parked run's budget question (card
|
|
1194
|
+
* #915): grant more turns and continue, or stop and hand the card back.
|
|
1195
|
+
*
|
|
1196
|
+
* Unlike pause/resume/stop — an ephemeral Realtime broadcast nothing hears
|
|
1197
|
+
* once the daemon is offline — this MUST survive a daemon restart hours
|
|
1198
|
+
* later, so it lands as a `budget_decision` row rather than a broadcast.
|
|
1199
|
+
* The server resolves which run to decide for from the card's one active
|
|
1200
|
+
* session; the caller does not (and, deciding from the board, cannot) name
|
|
1201
|
+
* a session id.
|
|
1202
|
+
*/
|
|
1203
|
+
async postBudgetDecision(
|
|
1204
|
+
cardId: string,
|
|
1205
|
+
data: {
|
|
1206
|
+
decision: "continue" | "stop";
|
|
1207
|
+
extraTurns: number;
|
|
1208
|
+
message?: string;
|
|
1209
|
+
},
|
|
1210
|
+
): Promise<{ id: string; seq: number; createdAt: string }> {
|
|
1211
|
+
return this.request("POST", `/cards/${cardId}/budget-decisions`, data);
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/**
|
|
1215
|
+
* Drain `budget_decision` events for a card since `sinceIso` (card #915).
|
|
1216
|
+
* Keyed on card + time, not session + seq like `getPendingMessages`: the
|
|
1217
|
+
* daemon asking on startup may not know the session id of a run it parked
|
|
1218
|
+
* hours or days earlier, but it always knows the card.
|
|
1219
|
+
*/
|
|
1220
|
+
async getBudgetDecisions(
|
|
1221
|
+
cardId: string,
|
|
1222
|
+
sinceIso: string,
|
|
1223
|
+
): Promise<{
|
|
1224
|
+
decisions: Array<{
|
|
1225
|
+
decision: "continue" | "stop";
|
|
1226
|
+
extraTurns: number;
|
|
1227
|
+
message?: string;
|
|
1228
|
+
createdAt: string;
|
|
1229
|
+
}>;
|
|
1230
|
+
}> {
|
|
1231
|
+
const params = new URLSearchParams();
|
|
1232
|
+
params.set("sinceIso", sinceIso);
|
|
1233
|
+
return this.request(
|
|
1234
|
+
"GET",
|
|
1235
|
+
`/cards/${cardId}/budget-decisions?${params.toString()}`,
|
|
1236
|
+
);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1150
1239
|
async updateAgentProgress(
|
|
1151
1240
|
cardId: string,
|
|
1152
1241
|
data: {
|
|
@@ -1205,6 +1294,16 @@ export class HarmonyApiClient {
|
|
|
1205
1294
|
* deferred the respawn by 10 minutes (#770). Reply carries `stopped: true`.
|
|
1206
1295
|
*/
|
|
1207
1296
|
implicitCreate?: boolean;
|
|
1297
|
+
/**
|
|
1298
|
+
* Set while the session waits for a human budget decision (#915),
|
|
1299
|
+
* cleared (null) once one arrives. Exempts the row from the daemon's
|
|
1300
|
+
* stale-session sweep until this instant — a parked run's deliberate
|
|
1301
|
+
* silence would otherwise read as abandonment well before a person can
|
|
1302
|
+
* answer. ISO timestamp string; the daemon-side mirror
|
|
1303
|
+
* (`StateStore.parkRun`'s `awaitingDecisionUntil`) is the same instant
|
|
1304
|
+
* in epoch ms, not this string.
|
|
1305
|
+
*/
|
|
1306
|
+
awaitingDecisionUntil?: string | null;
|
|
1208
1307
|
},
|
|
1209
1308
|
): Promise<{
|
|
1210
1309
|
session: unknown;
|
|
@@ -1351,6 +1450,9 @@ export class HarmonyApiClient {
|
|
|
1351
1450
|
// Opt in to agent-run episodes (default excluded, #677). Only the daemon's
|
|
1352
1451
|
// own rolling-episode lookup should set this.
|
|
1353
1452
|
include_episodes?: boolean;
|
|
1453
|
+
/** Read-accounting label (task 4) — omitted means the read is not
|
|
1454
|
+
* recorded. Callers that know their own identity should pass it. */
|
|
1455
|
+
consumer?: ReadConsumer;
|
|
1354
1456
|
}): Promise<{ entities: unknown[]; count: number }> {
|
|
1355
1457
|
const params = new URLSearchParams();
|
|
1356
1458
|
params.set("workspace_id", options.workspace_id);
|
|
@@ -1367,6 +1469,7 @@ export class HarmonyApiClient {
|
|
|
1367
1469
|
if (options.offset !== undefined)
|
|
1368
1470
|
params.set("offset", String(options.offset));
|
|
1369
1471
|
if (options.include_superseded) params.set("include_superseded", "true");
|
|
1472
|
+
if (options.consumer) params.set("consumer", options.consumer);
|
|
1370
1473
|
if (options.include_episodes) params.set("include_episodes", "true");
|
|
1371
1474
|
return this.request("GET", `/memory/entities?${params.toString()}`);
|
|
1372
1475
|
}
|
|
@@ -1419,9 +1522,17 @@ export class HarmonyApiClient {
|
|
|
1419
1522
|
// Opt in to agent-run episodes (default excluded server-side, #677). The
|
|
1420
1523
|
// daemon sets this when looking up its own rolling implement/review episode.
|
|
1421
1524
|
includeEpisodes?: boolean;
|
|
1525
|
+
/** Read-accounting label (task 4) — omitted means the read is not
|
|
1526
|
+
* recorded. Callers that know their own identity should pass it. */
|
|
1527
|
+
consumer?: ReadConsumer;
|
|
1422
1528
|
}): Promise<{ entities: unknown[] }> {
|
|
1423
1529
|
// Over-fetch beyond topK so client-side filters (multi-type, memory_tier,
|
|
1424
1530
|
// tags) have headroom — matches the MCP server's recall path (server.ts).
|
|
1531
|
+
// `consumer` is deliberately NOT forwarded on this over-fetch: the server
|
|
1532
|
+
// would record read-accounting for every over-fetched row, not just the
|
|
1533
|
+
// ones actually delivered. Instead we record the delivered set below,
|
|
1534
|
+
// after client-side filtering and the topK trim (mirrors the mcp-tool
|
|
1535
|
+
// path in server.ts).
|
|
1425
1536
|
const fetchLimit = Math.max(options.topK ?? 3, 50);
|
|
1426
1537
|
let entities: Array<Record<string, unknown>> = [];
|
|
1427
1538
|
|
|
@@ -1483,6 +1594,21 @@ export class HarmonyApiClient {
|
|
|
1483
1594
|
entities = entities.slice(0, options.topK);
|
|
1484
1595
|
}
|
|
1485
1596
|
|
|
1597
|
+
// Read accounting: record only the entities actually delivered to the
|
|
1598
|
+
// caller, after all client-side filtering and the topK trim — never the
|
|
1599
|
+
// larger over-fetched candidate pool the calls above deliberately left
|
|
1600
|
+
// unrecorded. Best-effort; a recording failure must not fail the recall.
|
|
1601
|
+
if (options.consumer) {
|
|
1602
|
+
const deliveredIds = entities
|
|
1603
|
+
.map((e) => e.id)
|
|
1604
|
+
.filter((id): id is string => typeof id === "string");
|
|
1605
|
+
if (deliveredIds.length > 0) {
|
|
1606
|
+
this.batchTouchMemoryEntities(deliveredIds, options.consumer).catch(
|
|
1607
|
+
() => {},
|
|
1608
|
+
);
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1486
1612
|
return { entities };
|
|
1487
1613
|
}
|
|
1488
1614
|
|
|
@@ -1496,9 +1622,16 @@ export class HarmonyApiClient {
|
|
|
1496
1622
|
|
|
1497
1623
|
async batchTouchMemoryEntities(
|
|
1498
1624
|
entityIds: string[],
|
|
1625
|
+
/** Read-accounting label (task 4, fix round 1). Omitted → the route keeps
|
|
1626
|
+
* its original increment-only `batch_touch_knowledge_entities` behaviour
|
|
1627
|
+
* (legacy context-assembly callers). Given → the route instead records
|
|
1628
|
+
* through `record_entity_reads`, the same path the search chokepoint
|
|
1629
|
+
* uses, so this touch is properly attributed rather than anonymous. */
|
|
1630
|
+
consumer?: ReadConsumer,
|
|
1499
1631
|
): Promise<{ success: boolean; count: number }> {
|
|
1500
1632
|
return this.request("POST", "/memory/entities/batch-touch", {
|
|
1501
1633
|
entity_ids: entityIds,
|
|
1634
|
+
...(consumer ? { consumer } : {}),
|
|
1502
1635
|
});
|
|
1503
1636
|
}
|
|
1504
1637
|
|
|
@@ -1533,6 +1666,16 @@ export class HarmonyApiClient {
|
|
|
1533
1666
|
tags?: string[];
|
|
1534
1667
|
include_superseded?: boolean;
|
|
1535
1668
|
include_episodes?: boolean;
|
|
1669
|
+
/** Read-accounting label (task 4) — omitted means the read is not
|
|
1670
|
+
* recorded (there is deliberately no server-side default: it used to
|
|
1671
|
+
* default to "search" and inflated access_count on every
|
|
1672
|
+
* `harmony_remember` write-side probe). Callers that know their own
|
|
1673
|
+
* identity (harmony_recall) should pass it explicitly. */
|
|
1674
|
+
consumer?: ReadConsumer;
|
|
1675
|
+
/** Per-prompt-build id (task 6b) — shared by every read row recorded
|
|
1676
|
+
* during one `generateCardPrompt` call, so a card can later show which
|
|
1677
|
+
* memories shaped its prompt. */
|
|
1678
|
+
assembly_id?: string;
|
|
1536
1679
|
},
|
|
1537
1680
|
): Promise<{ entities: unknown[]; count: number }> {
|
|
1538
1681
|
const params = new URLSearchParams();
|
|
@@ -1546,7 +1689,9 @@ export class HarmonyApiClient {
|
|
|
1546
1689
|
// matches against the canonical `tags_normalized` column (#299).
|
|
1547
1690
|
for (const tag of options?.tags ?? []) params.append("tags", tag);
|
|
1548
1691
|
if (options?.include_superseded) params.set("include_superseded", "true");
|
|
1692
|
+
if (options?.consumer) params.set("consumer", options.consumer);
|
|
1549
1693
|
if (options?.include_episodes) params.set("include_episodes", "true");
|
|
1694
|
+
if (options?.assembly_id) params.set("assembly_id", options.assembly_id);
|
|
1550
1695
|
return this.request("GET", `/memory/search?${params.toString()}`);
|
|
1551
1696
|
}
|
|
1552
1697
|
|
|
@@ -1558,12 +1703,16 @@ export class HarmonyApiClient {
|
|
|
1558
1703
|
type?: string;
|
|
1559
1704
|
limit?: number;
|
|
1560
1705
|
include_episodes?: boolean;
|
|
1706
|
+
/** Read-accounting label (task 4) — omitted means the read is not
|
|
1707
|
+
* recorded. Callers that know their own identity should pass it. */
|
|
1708
|
+
consumer?: ReadConsumer;
|
|
1561
1709
|
}): Promise<{ entities: unknown[]; count: number }> {
|
|
1562
1710
|
const params = new URLSearchParams();
|
|
1563
1711
|
params.set("workspace_id", options.workspace_id);
|
|
1564
1712
|
if (options.project_id) params.set("project_id", options.project_id);
|
|
1565
1713
|
if (options.type) params.set("type", options.type);
|
|
1566
1714
|
if (options.limit !== undefined) params.set("limit", String(options.limit));
|
|
1715
|
+
if (options.consumer) params.set("consumer", options.consumer);
|
|
1567
1716
|
if (options.include_episodes) params.set("include_episodes", "true");
|
|
1568
1717
|
return this.request("GET", `/memory/index?${params.toString()}`);
|
|
1569
1718
|
}
|
|
@@ -1574,12 +1723,16 @@ export class HarmonyApiClient {
|
|
|
1574
1723
|
type?: string;
|
|
1575
1724
|
limit?: number;
|
|
1576
1725
|
include_episodes?: boolean;
|
|
1726
|
+
/** Read-accounting label (task 4) — omitted means the read is not
|
|
1727
|
+
* recorded. Callers that know their own identity should pass it. */
|
|
1728
|
+
consumer?: ReadConsumer;
|
|
1577
1729
|
}): Promise<string> {
|
|
1578
1730
|
const params = new URLSearchParams();
|
|
1579
1731
|
params.set("workspace_id", options.workspace_id);
|
|
1580
1732
|
if (options.project_id) params.set("project_id", options.project_id);
|
|
1581
1733
|
if (options.type) params.set("type", options.type);
|
|
1582
1734
|
if (options.limit !== undefined) params.set("limit", String(options.limit));
|
|
1735
|
+
if (options.consumer) params.set("consumer", options.consumer);
|
|
1583
1736
|
if (options.include_episodes) params.set("include_episodes", "true");
|
|
1584
1737
|
return this.requestRaw(
|
|
1585
1738
|
"GET",
|
|
@@ -1668,6 +1821,9 @@ export class HarmonyApiClient {
|
|
|
1668
1821
|
type?: string;
|
|
1669
1822
|
limit?: number;
|
|
1670
1823
|
include_episodes?: boolean;
|
|
1824
|
+
/** Read-accounting label (task 4) — omitted means the read is not
|
|
1825
|
+
* recorded. Callers that know their own identity should pass it. */
|
|
1826
|
+
consumer?: ReadConsumer;
|
|
1671
1827
|
},
|
|
1672
1828
|
): Promise<string> {
|
|
1673
1829
|
const params = new URLSearchParams();
|
|
@@ -1677,6 +1833,7 @@ export class HarmonyApiClient {
|
|
|
1677
1833
|
if (options?.type) params.set("type", options.type);
|
|
1678
1834
|
if (options?.limit !== undefined)
|
|
1679
1835
|
params.set("limit", String(options.limit));
|
|
1836
|
+
if (options?.consumer) params.set("consumer", options.consumer);
|
|
1680
1837
|
if (options?.include_episodes) params.set("include_episodes", "true");
|
|
1681
1838
|
return this.requestRaw(
|
|
1682
1839
|
"GET",
|
|
@@ -1978,18 +2135,22 @@ export class HarmonyApiClient {
|
|
|
1978
2135
|
// Phase 0 (memory architecture v2): full context assembly removed.
|
|
1979
2136
|
// Use the basic memory search path so callers still get _some_ memory
|
|
1980
2137
|
// hints. Phase 1 will reintroduce a session-scoped working memory layer.
|
|
1981
|
-
|
|
1982
|
-
|
|
2138
|
+
|
|
2139
|
+
// One id per prompt build. Every read row recorded during this build carries
|
|
2140
|
+
// it, which is what lets a card show the memories that shaped its prompt.
|
|
2141
|
+
const assemblyId = randomUUID();
|
|
1983
2142
|
let memories: MemoryItem[] | undefined;
|
|
1984
2143
|
|
|
1985
2144
|
try {
|
|
1986
2145
|
if (options.workspaceId && cardData.title) {
|
|
1987
2146
|
const memoryResult = await this.searchMemoryEntities(
|
|
1988
2147
|
options.workspaceId,
|
|
1989
|
-
cardData.title,
|
|
2148
|
+
buildMemoryQuery(cardData.title, cardData.description),
|
|
1990
2149
|
{
|
|
1991
2150
|
project_id: options.projectId,
|
|
1992
2151
|
limit: 5,
|
|
2152
|
+
consumer: "agent-prompt",
|
|
2153
|
+
assembly_id: assemblyId,
|
|
1993
2154
|
},
|
|
1994
2155
|
);
|
|
1995
2156
|
if (memoryResult.entities?.length > 0) {
|
|
@@ -2015,7 +2176,6 @@ export class HarmonyApiClient {
|
|
|
2015
2176
|
contextOptions: options.contextOptions,
|
|
2016
2177
|
customConstraints: options.customConstraints,
|
|
2017
2178
|
memories,
|
|
2018
|
-
assembledContext: assembledContextStr,
|
|
2019
2179
|
assemblyId,
|
|
2020
2180
|
});
|
|
2021
2181
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {
|
|
2
|
+
declaredGateMetricsFromAgents,
|
|
3
|
+
referencedGateMetrics,
|
|
4
|
+
type WorkspaceAgent,
|
|
5
|
+
} from "@harmony/shared";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Non-blocking warnings for a playbook write whose `custom` gates name metrics
|
|
9
|
+
* no reporting agent in the workspace declares (card #922). The write always
|
|
10
|
+
* succeeds — a metric name can legitimately precede its declaration — but the
|
|
11
|
+
* author learns about the mismatch NOW, not after the first bound card burns a
|
|
12
|
+
* stage run on it. Inert until at least one agent has reported its declared
|
|
13
|
+
* names (an older daemon, or none running, must never raise a false alarm).
|
|
14
|
+
*/
|
|
15
|
+
export function playbookMetricWarnings(
|
|
16
|
+
agents: ReadonlyArray<Pick<WorkspaceAgent, "declared_gate_metrics">>,
|
|
17
|
+
steps: unknown,
|
|
18
|
+
): string[] {
|
|
19
|
+
if (!Array.isArray(steps)) return [];
|
|
20
|
+
const declared = declaredGateMetricsFromAgents(agents);
|
|
21
|
+
if (!declared.known) return [];
|
|
22
|
+
|
|
23
|
+
const warnings: string[] = [];
|
|
24
|
+
const seen = new Set<string>();
|
|
25
|
+
for (const ref of referencedGateMetrics({ steps, steps_version: 2 })) {
|
|
26
|
+
if (declared.names.has(ref.metric) || seen.has(ref.metric)) continue;
|
|
27
|
+
seen.add(ref.metric);
|
|
28
|
+
warnings.push(
|
|
29
|
+
`Gate metric "${ref.metric}" (stage "${ref.stageName}") is not declared by any agent in this workspace — a stage run gating on it will hold until a daemon declares it under agent.playbooks.metrics.${ref.metric}.`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
return warnings;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Fetch the workspace's agents and compute the warnings, swallowing every
|
|
37
|
+
* error: the playbook write already succeeded, and a warning lookup must never
|
|
38
|
+
* turn that success into a tool failure.
|
|
39
|
+
*/
|
|
40
|
+
export async function collectPlaybookMetricWarnings(
|
|
41
|
+
client: {
|
|
42
|
+
listWorkspaceAgents(
|
|
43
|
+
workspaceId: string,
|
|
44
|
+
): Promise<{ agents: WorkspaceAgent[] }>;
|
|
45
|
+
},
|
|
46
|
+
workspaceId: string | undefined,
|
|
47
|
+
steps: unknown,
|
|
48
|
+
): Promise<string[]> {
|
|
49
|
+
if (!workspaceId || !Array.isArray(steps)) return [];
|
|
50
|
+
try {
|
|
51
|
+
const { agents } = await client.listWorkspaceAgents(workspaceId);
|
|
52
|
+
return playbookMetricWarnings(agents, steps);
|
|
53
|
+
} catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
}
|