@gethmy/agent 1.7.0 → 1.7.1
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/README.md +8 -1
- package/dist/budget.d.ts +20 -28
- package/dist/budget.js +24 -112
- package/dist/cli.d.ts +0 -2
- package/dist/cli.js +0 -64
- package/dist/completion.d.ts +5 -1
- package/dist/completion.js +20 -2
- package/dist/episode-writer.d.ts +32 -0
- package/dist/episode-writer.js +120 -3
- package/dist/git-diff-stat.d.ts +24 -0
- package/dist/git-diff-stat.js +56 -0
- package/dist/http-server.d.ts +1 -14
- package/dist/http-server.js +1 -19
- package/dist/index.js +1 -9
- package/dist/pool.d.ts +4 -3
- package/dist/pool.js +19 -18
- package/dist/progress-tracker.d.ts +3 -0
- package/dist/progress-tracker.js +15 -0
- package/dist/prompt.d.ts +5 -0
- package/dist/prompt.js +44 -1
- package/dist/review-completion.d.ts +0 -5
- package/dist/review-completion.js +63 -62
- package/dist/state-store.d.ts +8 -7
- package/dist/state-store.js +14 -23
- package/dist/types.d.ts +33 -6
- package/dist/types.js +0 -3
- package/dist/worker.d.ts +1 -0
- package/dist/worker.js +47 -4
- package/package.json +1 -1
package/dist/episode-writer.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { log } from "./log.js";
|
|
2
2
|
const TAG = "episode-writer";
|
|
3
3
|
const MAX_APPROACH_SUMMARY_CHARS = 400;
|
|
4
|
+
// Richer approach summary cap (#272). The single-turn trim used a 400-char cap;
|
|
5
|
+
// the assembled multi-block summary is allowed to run longer but stays bounded
|
|
6
|
+
// so it doesn't bloat the recall prompt.
|
|
7
|
+
const MAX_RICH_APPROACH_CHARS = 1500;
|
|
8
|
+
// Cap on the changed-file list persisted per episode (#272).
|
|
9
|
+
const MAX_CHANGED_FILES = 30;
|
|
4
10
|
/**
|
|
5
11
|
* Rule-derived quality score (0..1) for an implement run. Failures default to 0.
|
|
6
12
|
* Plan §"Quality score": +0.4 if build passed, +0.2 if lint passed, +0.2 if no
|
|
@@ -41,6 +47,75 @@ export function trimApproachSummary(text) {
|
|
|
41
47
|
return trimmed;
|
|
42
48
|
return `${trimmed.slice(0, MAX_APPROACH_SUMMARY_CHARS - 1).trimEnd()}…`;
|
|
43
49
|
}
|
|
50
|
+
// Review rationale cap (#272 task 5). The reviewer's verdict reasoning is the
|
|
51
|
+
// signal we want recallable, so it gets a far higher cap than the 400-char
|
|
52
|
+
// implement trim (the upstream review summary is already sliced to ~2000).
|
|
53
|
+
const MAX_REVIEW_RATIONALE_CHARS = 2000;
|
|
54
|
+
/**
|
|
55
|
+
* Cap the review rationale ("why approved / rejected") richly (#272 task 5)
|
|
56
|
+
* rather than re-trimming to the 400-char implement bound. Empty input
|
|
57
|
+
* collapses to a marker so the episode still surfaces as a recallable hit.
|
|
58
|
+
*/
|
|
59
|
+
export function trimReviewRationale(text) {
|
|
60
|
+
const trimmed = text.trim();
|
|
61
|
+
if (trimmed.length === 0)
|
|
62
|
+
return "(no review rationale captured)";
|
|
63
|
+
if (trimmed.length <= MAX_REVIEW_RATIONALE_CHARS)
|
|
64
|
+
return trimmed;
|
|
65
|
+
return `${trimmed.slice(0, MAX_REVIEW_RATIONALE_CHARS - 1).trimEnd()}…`;
|
|
66
|
+
}
|
|
67
|
+
// Lines that read like a root-cause / gotcha / lesson — used to surface a cheap
|
|
68
|
+
// deterministic `key_insight` without an LLM call (#272 task 3).
|
|
69
|
+
const INSIGHT_RE = /\b(root cause|turned out|the (?:issue|problem|bug) (?:was|is)|the fix (?:was|is)|gotcha|caused by|because|the key (?:was|insight)|note that|caveat|the trick (?:was|is))\b/i;
|
|
70
|
+
/**
|
|
71
|
+
* Assemble a richer approach summary from the collected assistant text blocks
|
|
72
|
+
* (#272). Joins the trailing blocks (most relevant context lives near the end
|
|
73
|
+
* of a run) up to a longer bounded cap than the single-turn trim. Falls back to
|
|
74
|
+
* the last-turn `fallback` text when no blocks were collected. No LLM call.
|
|
75
|
+
*/
|
|
76
|
+
export function buildRichApproachSummary(blocks, fallback) {
|
|
77
|
+
const cleaned = (blocks ?? [])
|
|
78
|
+
.map((b) => b.trim())
|
|
79
|
+
.filter((b) => b.length > 0);
|
|
80
|
+
if (cleaned.length === 0)
|
|
81
|
+
return trimApproachSummary(fallback);
|
|
82
|
+
// Walk backwards accumulating blocks until we hit the cap, then restore order.
|
|
83
|
+
const picked = [];
|
|
84
|
+
let total = 0;
|
|
85
|
+
for (let i = cleaned.length - 1; i >= 0; i--) {
|
|
86
|
+
const block = cleaned[i];
|
|
87
|
+
const cost = block.length + (picked.length > 0 ? 2 : 0);
|
|
88
|
+
if (total + cost > MAX_RICH_APPROACH_CHARS && picked.length > 0)
|
|
89
|
+
break;
|
|
90
|
+
picked.unshift(block);
|
|
91
|
+
total += cost;
|
|
92
|
+
if (total >= MAX_RICH_APPROACH_CHARS)
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
const joined = picked.join("\n\n").trim();
|
|
96
|
+
if (joined.length === 0)
|
|
97
|
+
return trimApproachSummary(fallback);
|
|
98
|
+
if (joined.length <= MAX_RICH_APPROACH_CHARS)
|
|
99
|
+
return joined;
|
|
100
|
+
return `${joined.slice(0, MAX_RICH_APPROACH_CHARS - 1).trimEnd()}…`;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Extract a cheap deterministic "key insight" line from the run's assistant
|
|
104
|
+
* text (#272 task 3). Scans for a sentence/line matching a root-cause / gotcha
|
|
105
|
+
* pattern. Returns undefined when nothing matches — never fabricated, no LLM.
|
|
106
|
+
*/
|
|
107
|
+
export function extractKeyInsight(blocks, fallback) {
|
|
108
|
+
const source = blocks && blocks.length > 0 ? blocks.join("\n") : fallback;
|
|
109
|
+
const lines = source
|
|
110
|
+
.split(/\n|(?<=[.!?])\s+/)
|
|
111
|
+
.map((l) => l.trim())
|
|
112
|
+
.filter((l) => l.length >= 20 && l.length <= 280);
|
|
113
|
+
for (const line of lines) {
|
|
114
|
+
if (INSIGHT_RE.test(line))
|
|
115
|
+
return line;
|
|
116
|
+
}
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
44
119
|
/**
|
|
45
120
|
* Build the entity payload for one episode. Pure — returned object can be
|
|
46
121
|
* snapshotted in tests without hitting the network.
|
|
@@ -53,7 +128,11 @@ export function buildEpisodePayload(input, projectId) {
|
|
|
53
128
|
});
|
|
54
129
|
const type = input.outcome === "success" ? "solution" : "error";
|
|
55
130
|
const importance = input.outcome === "success" ? 7 : 5;
|
|
56
|
-
|
|
131
|
+
// Richer approach summary (#272): assemble from all collected assistant
|
|
132
|
+
// blocks when available; fall back to the single last-turn trim otherwise.
|
|
133
|
+
const approachSummary = buildRichApproachSummary(input.approachBlocks, input.approachSummary);
|
|
134
|
+
const keyInsight = extractKeyInsight(input.approachBlocks, input.approachSummary);
|
|
135
|
+
const changedFiles = (input.changedFiles ?? []).slice(0, MAX_CHANGED_FILES);
|
|
57
136
|
const outcomeRationale = input.outcome === "success"
|
|
58
137
|
? `Build ${input.result.buildErrors.length === 0 ? "passed" : "failed"}, lint ${input.result.lintWarnings.length === 0 ? "clean" : "issues"}.`
|
|
59
138
|
: `Verification failed: ${input.errorMessage ?? "see findings"}.`;
|
|
@@ -72,11 +151,32 @@ export function buildEpisodePayload(input, projectId) {
|
|
|
72
151
|
},
|
|
73
152
|
files_touched: input.filesEdited,
|
|
74
153
|
num_turns: input.cost?.numTurns ?? 0,
|
|
154
|
+
// Provenance (#273, task 3). Agent episodes are always agent-run; stamp
|
|
155
|
+
// the card + session ids we already carry so downstream hygiene + UI can
|
|
156
|
+
// tell auto-written episodes from human-curated patterns.
|
|
157
|
+
origin: {
|
|
158
|
+
source: "agent-run",
|
|
159
|
+
source_card_id: input.card.id,
|
|
160
|
+
author: "harmony-agent",
|
|
161
|
+
...(input.agentSessionId
|
|
162
|
+
? { source_session_id: input.agentSessionId }
|
|
163
|
+
: {}),
|
|
164
|
+
},
|
|
75
165
|
};
|
|
76
166
|
if (input.errorMessage)
|
|
77
167
|
metadata.error = input.errorMessage;
|
|
168
|
+
if (changedFiles.length > 0)
|
|
169
|
+
metadata.changed_files = changedFiles;
|
|
170
|
+
if (input.churn)
|
|
171
|
+
metadata.churn = input.churn;
|
|
172
|
+
if (keyInsight)
|
|
173
|
+
metadata.key_insight = keyInsight;
|
|
78
174
|
if (input.agentSessionId)
|
|
79
175
|
metadata.agent_session_id = input.agentSessionId;
|
|
176
|
+
const changedFilesLine = changedFiles.length > 0
|
|
177
|
+
? `\n\nChanged files (${changedFiles.length}): ${changedFiles.join(", ")}`
|
|
178
|
+
: "";
|
|
179
|
+
const keyInsightLine = keyInsight ? `\n\nKey insight: ${keyInsight}` : "";
|
|
80
180
|
return {
|
|
81
181
|
workspace_id: input.workspaceId,
|
|
82
182
|
project_id: projectId,
|
|
@@ -84,7 +184,7 @@ export function buildEpisodePayload(input, projectId) {
|
|
|
84
184
|
memory_tier: "episode",
|
|
85
185
|
scope: "project",
|
|
86
186
|
title: `Agent run implement — #${input.card.short_id}: ${input.card.title}`,
|
|
87
|
-
content: `${approachSummary}\n\nOutcome: ${outcomeRationale}`,
|
|
187
|
+
content: `${approachSummary}\n\nOutcome: ${outcomeRationale}${keyInsightLine}${changedFilesLine}`,
|
|
88
188
|
metadata,
|
|
89
189
|
importance,
|
|
90
190
|
confidence: clampConfidence(qualityScore),
|
|
@@ -94,7 +194,7 @@ export function buildEpisodePayload(input, projectId) {
|
|
|
94
194
|
}
|
|
95
195
|
// Review episode
|
|
96
196
|
const qualityScore = input.verdict === "approved" ? 1 : 0.4;
|
|
97
|
-
const summary =
|
|
197
|
+
const summary = trimReviewRationale(input.summary || "");
|
|
98
198
|
const metadata = {
|
|
99
199
|
episode_kind: "review",
|
|
100
200
|
card_short_id: input.card.short_id,
|
|
@@ -110,6 +210,19 @@ export function buildEpisodePayload(input, projectId) {
|
|
|
110
210
|
},
|
|
111
211
|
files_touched: 0,
|
|
112
212
|
num_turns: input.cost?.numTurns ?? 0,
|
|
213
|
+
// Provenance (#273, task 3). Review episodes are agent-run too. Prefer the
|
|
214
|
+
// review session id as the originating session when present.
|
|
215
|
+
origin: {
|
|
216
|
+
source: "agent-run",
|
|
217
|
+
source_card_id: input.card.id,
|
|
218
|
+
author: "harmony-agent",
|
|
219
|
+
...((input.reviewSessionId ?? input.agentSessionId)
|
|
220
|
+
? {
|
|
221
|
+
source_session_id: (input.reviewSessionId ??
|
|
222
|
+
input.agentSessionId),
|
|
223
|
+
}
|
|
224
|
+
: {}),
|
|
225
|
+
},
|
|
113
226
|
};
|
|
114
227
|
if (input.agentSessionId)
|
|
115
228
|
metadata.agent_session_id = input.agentSessionId;
|
|
@@ -132,6 +245,10 @@ export function buildEpisodePayload(input, projectId) {
|
|
|
132
245
|
agent_identifier: "harmony-agent",
|
|
133
246
|
};
|
|
134
247
|
}
|
|
248
|
+
// DEFERRED (#272 task 6, on-plan): an optional best-effort Claude-CLI
|
|
249
|
+
// distillation of the approach summary / key insight is explicitly out of scope
|
|
250
|
+
// for this pass — no Anthropic SDK, no extra LLM spawn. The deterministic
|
|
251
|
+
// assembly above is the v1 surface; revisit distillation in a later card.
|
|
135
252
|
/**
|
|
136
253
|
* Write one episode entity. Best-effort: any failure is logged and swallowed
|
|
137
254
|
* so the calling pipeline can complete (plan D8: episode writes never block
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Default cap on the number of changed-file paths captured per episode. */
|
|
2
|
+
export declare const MAX_CHANGED_FILES = 30;
|
|
3
|
+
export interface DiffStat {
|
|
4
|
+
/** Changed file paths (authoritative — derived from the diff itself). */
|
|
5
|
+
files: string[];
|
|
6
|
+
/** Total lines added across the diff (best-effort, may be 0). */
|
|
7
|
+
insertions: number;
|
|
8
|
+
/** Total lines removed across the diff (best-effort, may be 0). */
|
|
9
|
+
deletions: number;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Parse `git diff --numstat` output into a changed-file list + churn totals.
|
|
13
|
+
* Pure — exported for testing. numstat lines look like:
|
|
14
|
+
* `12\t3\tpath/to/file.ts`
|
|
15
|
+
* Binary files report `-\t-\t<path>`; those contribute 0 churn but still count
|
|
16
|
+
* as a changed file. The file list is capped at `maxFiles`.
|
|
17
|
+
*/
|
|
18
|
+
export declare function parseNumstat(raw: string, maxFiles?: number): DiffStat;
|
|
19
|
+
/**
|
|
20
|
+
* Capture a changed-file list + churn for the work on a branch versus its base,
|
|
21
|
+
* via `git diff --numstat`. Best-effort and guarded: returns null on any
|
|
22
|
+
* failure so callers can fall back to tracked paths (#272). Never throws.
|
|
23
|
+
*/
|
|
24
|
+
export declare function captureDiffStat(worktreePath: string, baseBranch: string, maxFiles?: number): DiffStat | null;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { log } from "./log.js";
|
|
3
|
+
const TAG = "git-diff-stat";
|
|
4
|
+
/** Default cap on the number of changed-file paths captured per episode. */
|
|
5
|
+
export const MAX_CHANGED_FILES = 30;
|
|
6
|
+
/**
|
|
7
|
+
* Parse `git diff --numstat` output into a changed-file list + churn totals.
|
|
8
|
+
* Pure — exported for testing. numstat lines look like:
|
|
9
|
+
* `12\t3\tpath/to/file.ts`
|
|
10
|
+
* Binary files report `-\t-\t<path>`; those contribute 0 churn but still count
|
|
11
|
+
* as a changed file. The file list is capped at `maxFiles`.
|
|
12
|
+
*/
|
|
13
|
+
export function parseNumstat(raw, maxFiles = MAX_CHANGED_FILES) {
|
|
14
|
+
const files = [];
|
|
15
|
+
let insertions = 0;
|
|
16
|
+
let deletions = 0;
|
|
17
|
+
for (const line of raw.split("\n")) {
|
|
18
|
+
const trimmed = line.trim();
|
|
19
|
+
if (trimmed.length === 0)
|
|
20
|
+
continue;
|
|
21
|
+
const parts = trimmed.split("\t");
|
|
22
|
+
if (parts.length < 3)
|
|
23
|
+
continue;
|
|
24
|
+
const [add, del, ...pathParts] = parts;
|
|
25
|
+
const path = pathParts.join("\t");
|
|
26
|
+
if (!path)
|
|
27
|
+
continue;
|
|
28
|
+
const addN = Number.parseInt(add, 10);
|
|
29
|
+
const delN = Number.parseInt(del, 10);
|
|
30
|
+
if (Number.isFinite(addN))
|
|
31
|
+
insertions += addN;
|
|
32
|
+
if (Number.isFinite(delN))
|
|
33
|
+
deletions += delN;
|
|
34
|
+
if (files.length < maxFiles)
|
|
35
|
+
files.push(path);
|
|
36
|
+
}
|
|
37
|
+
return { files, insertions, deletions };
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Capture a changed-file list + churn for the work on a branch versus its base,
|
|
41
|
+
* via `git diff --numstat`. Best-effort and guarded: returns null on any
|
|
42
|
+
* failure so callers can fall back to tracked paths (#272). Never throws.
|
|
43
|
+
*/
|
|
44
|
+
export function captureDiffStat(worktreePath, baseBranch, maxFiles = MAX_CHANGED_FILES) {
|
|
45
|
+
try {
|
|
46
|
+
const raw = execFileSync("git", ["diff", "--numstat", `${baseBranch}...HEAD`], { cwd: worktreePath, encoding: "utf-8", timeout: 30_000 });
|
|
47
|
+
return parseNumstat(raw, maxFiles);
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
log.warn(TAG, "git diff --numstat failed", {
|
|
51
|
+
event: "diff_stat_failed",
|
|
52
|
+
error: err instanceof Error ? err.message : String(err),
|
|
53
|
+
});
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
package/dist/http-server.d.ts
CHANGED
|
@@ -30,12 +30,6 @@ export interface StatusSnapshot {
|
|
|
30
30
|
priority: number;
|
|
31
31
|
enqueuedAt: number;
|
|
32
32
|
}>;
|
|
33
|
-
dlq: Array<{
|
|
34
|
-
cardId: string;
|
|
35
|
-
reason: string;
|
|
36
|
-
attempts: number;
|
|
37
|
-
totalCostCents: number;
|
|
38
|
-
}>;
|
|
39
33
|
budget: {
|
|
40
34
|
todayCents: number;
|
|
41
35
|
dailyCapCents: number;
|
|
@@ -48,12 +42,6 @@ export interface HttpServerOptions {
|
|
|
48
42
|
getStatus: () => StatusSnapshot;
|
|
49
43
|
getHealth: () => HealthSnapshot;
|
|
50
44
|
handleCommand: (cmd: Command, cardId: string) => Promise<void>;
|
|
51
|
-
/**
|
|
52
|
-
* Clear a card's DLQ marker. Routed through the daemon (not CLI
|
|
53
|
-
* direct-write) so we never have two processes racing to persist
|
|
54
|
-
* the same state-store file.
|
|
55
|
-
*/
|
|
56
|
-
clearDlq: (cardId: string) => Promise<void>;
|
|
57
45
|
}
|
|
58
46
|
/**
|
|
59
47
|
* Tiny introspection + control surface for the running daemon.
|
|
@@ -61,7 +49,7 @@ export interface HttpServerOptions {
|
|
|
61
49
|
*
|
|
62
50
|
* GET /health — 200 when healthy, 503 otherwise. Simple liveness check
|
|
63
51
|
* suitable for process supervisors (systemd, pm2, docker healthcheck).
|
|
64
|
-
* GET /status — full JSON snapshot: workers, queues,
|
|
52
|
+
* GET /status — full JSON snapshot: workers, queues, budget.
|
|
65
53
|
* POST /pause/:cardId, /resume/:cardId, /stop/:cardId — command path
|
|
66
54
|
* so an operator can nudge a stuck worker without killing the daemon.
|
|
67
55
|
*/
|
|
@@ -72,7 +60,6 @@ export declare class HttpServer {
|
|
|
72
60
|
start(): Promise<void>;
|
|
73
61
|
stop(): Promise<void>;
|
|
74
62
|
private route;
|
|
75
|
-
private respondDlqClear;
|
|
76
63
|
private respondHealth;
|
|
77
64
|
private respondStatus;
|
|
78
65
|
private respondCommand;
|
package/dist/http-server.js
CHANGED
|
@@ -7,7 +7,7 @@ const TAG = "http";
|
|
|
7
7
|
*
|
|
8
8
|
* GET /health — 200 when healthy, 503 otherwise. Simple liveness check
|
|
9
9
|
* suitable for process supervisors (systemd, pm2, docker healthcheck).
|
|
10
|
-
* GET /status — full JSON snapshot: workers, queues,
|
|
10
|
+
* GET /status — full JSON snapshot: workers, queues, budget.
|
|
11
11
|
* POST /pause/:cardId, /resume/:cardId, /stop/:cardId — command path
|
|
12
12
|
* so an operator can nudge a stuck worker without killing the daemon.
|
|
13
13
|
*/
|
|
@@ -53,10 +53,6 @@ export class HttpServer {
|
|
|
53
53
|
return this.respondStatus(res);
|
|
54
54
|
}
|
|
55
55
|
if (method === "POST") {
|
|
56
|
-
const dlq = path.match(/^\/dlq\/clear\/([^/]+)$/);
|
|
57
|
-
if (dlq) {
|
|
58
|
-
return this.respondDlqClear(res, decodeURIComponent(dlq[1]));
|
|
59
|
-
}
|
|
60
56
|
const cmd = parseCommand(path);
|
|
61
57
|
if (cmd) {
|
|
62
58
|
return this.respondCommand(res, cmd.command, cmd.cardId);
|
|
@@ -65,20 +61,6 @@ export class HttpServer {
|
|
|
65
61
|
res.writeHead(404, { "content-type": "application/json" });
|
|
66
62
|
res.end(JSON.stringify({ error: "not_found", path }));
|
|
67
63
|
}
|
|
68
|
-
async respondDlqClear(res, cardId) {
|
|
69
|
-
try {
|
|
70
|
-
await this.opts.clearDlq(cardId);
|
|
71
|
-
res.writeHead(200, { "content-type": "application/json" });
|
|
72
|
-
res.end(JSON.stringify({ ok: true, cardId }));
|
|
73
|
-
}
|
|
74
|
-
catch (err) {
|
|
75
|
-
res.writeHead(500, { "content-type": "application/json" });
|
|
76
|
-
res.end(JSON.stringify({
|
|
77
|
-
error: "clear_dlq_failed",
|
|
78
|
-
detail: err instanceof Error ? err.message : String(err),
|
|
79
|
-
}));
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
64
|
respondHealth(res) {
|
|
83
65
|
const health = this.opts.getHealth();
|
|
84
66
|
res.writeHead(health.healthy ? 200 : 503, {
|
package/dist/index.js
CHANGED
|
@@ -173,12 +173,6 @@ export async function main() {
|
|
|
173
173
|
},
|
|
174
174
|
getStatus: () => {
|
|
175
175
|
const queues = pool.snapshotQueues();
|
|
176
|
-
const dlq = stateStore.listDlq().map((c) => ({
|
|
177
|
-
cardId: c.cardId,
|
|
178
|
-
reason: c.dlqReason ?? "unknown",
|
|
179
|
-
attempts: c.attempts,
|
|
180
|
-
totalCostCents: c.totalCostCents,
|
|
181
|
-
}));
|
|
182
176
|
return {
|
|
183
177
|
daemonId,
|
|
184
178
|
daemonPid: process.pid,
|
|
@@ -200,7 +194,6 @@ export async function main() {
|
|
|
200
194
|
priority: i.priority,
|
|
201
195
|
enqueuedAt: i.enqueuedAt,
|
|
202
196
|
})),
|
|
203
|
-
dlq,
|
|
204
197
|
budget: {
|
|
205
198
|
todayCents: stateStore.getDailyCostCents(),
|
|
206
199
|
dailyCapCents: config.agent.budget.dailyBudgetCents,
|
|
@@ -208,7 +201,6 @@ export async function main() {
|
|
|
208
201
|
};
|
|
209
202
|
},
|
|
210
203
|
handleCommand: (cmd, cardId) => pool.handleAgentCommand(cardId, cmd),
|
|
211
|
-
clearDlq: (cardId) => stateStore.clearDlq(cardId),
|
|
212
204
|
})
|
|
213
205
|
: null;
|
|
214
206
|
// Create watcher (broadcast events from harmony-api + agent commands from UI)
|
|
@@ -371,4 +363,4 @@ async function tryEnqueueCard(cardId, client, pool, config) {
|
|
|
371
363
|
await pool.enqueue(card, column, cardLabels, subtasks, mode);
|
|
372
364
|
}
|
|
373
365
|
// The daemon is now launched from cli.ts so other subcommands
|
|
374
|
-
// (status, doctor, gc
|
|
366
|
+
// (status, doctor, gc) can load this module without side effects.
|
package/dist/pool.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { StateStore } from "./state-store.js";
|
|
|
5
5
|
import { type AgentConfig, type WorkMode } from "./types.js";
|
|
6
6
|
export declare class Pool {
|
|
7
7
|
private client;
|
|
8
|
+
private stateStore;
|
|
8
9
|
private implWorkers;
|
|
9
10
|
private reviewWorkers;
|
|
10
11
|
private implQueue;
|
|
@@ -14,9 +15,9 @@ export declare class Pool {
|
|
|
14
15
|
/**
|
|
15
16
|
* Enqueue a card for processing with the given mode.
|
|
16
17
|
*
|
|
17
|
-
* Returns async so callers can await the
|
|
18
|
-
* Budget
|
|
19
|
-
* and manual API calls all go through the same gate.
|
|
18
|
+
* Returns async so callers can await the give-up comment / waiting
|
|
19
|
+
* signal on skip. Budget checks happen here so the reconciler, realtime
|
|
20
|
+
* watcher, and manual API calls all go through the same gate.
|
|
20
21
|
*/
|
|
21
22
|
enqueue(card: Card, column: Column, labels: Label[], subtasks: Subtask[], mode?: WorkMode): Promise<void>;
|
|
22
23
|
/**
|
package/dist/pool.js
CHANGED
|
@@ -7,6 +7,7 @@ import { Worker } from "./worker.js";
|
|
|
7
7
|
const TAG = "pool";
|
|
8
8
|
export class Pool {
|
|
9
9
|
client;
|
|
10
|
+
stateStore;
|
|
10
11
|
implWorkers = [];
|
|
11
12
|
reviewWorkers = [];
|
|
12
13
|
implQueue;
|
|
@@ -14,9 +15,10 @@ export class Pool {
|
|
|
14
15
|
budget;
|
|
15
16
|
constructor(config, client, userEmail, workspaceId, projectId, stateStore) {
|
|
16
17
|
this.client = client;
|
|
18
|
+
this.stateStore = stateStore;
|
|
17
19
|
this.implQueue = new PriorityQueue(config);
|
|
18
20
|
this.reviewQueue = new PriorityQueue(config);
|
|
19
|
-
this.budget = new BudgetGuard(config.budget, stateStore);
|
|
21
|
+
this.budget = new BudgetGuard(config.budget, this.stateStore);
|
|
20
22
|
// Create implementation workers
|
|
21
23
|
for (let i = 0; i < config.poolSize; i++) {
|
|
22
24
|
this.implWorkers.push(new Worker(i, config, client, userEmail, () => {
|
|
@@ -38,9 +40,9 @@ export class Pool {
|
|
|
38
40
|
/**
|
|
39
41
|
* Enqueue a card for processing with the given mode.
|
|
40
42
|
*
|
|
41
|
-
* Returns async so callers can await the
|
|
42
|
-
* Budget
|
|
43
|
-
* and manual API calls all go through the same gate.
|
|
43
|
+
* Returns async so callers can await the give-up comment / waiting
|
|
44
|
+
* signal on skip. Budget checks happen here so the reconciler, realtime
|
|
45
|
+
* watcher, and manual API calls all go through the same gate.
|
|
44
46
|
*/
|
|
45
47
|
async enqueue(card, column, labels, subtasks, mode = "implement") {
|
|
46
48
|
// Don't enqueue if already in any queue or actively being worked on
|
|
@@ -55,22 +57,18 @@ export class Pool {
|
|
|
55
57
|
if (mode === "implement") {
|
|
56
58
|
const decision = this.budget.check(card.id);
|
|
57
59
|
if (!decision.allow) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (this.budget.isTerminal(decision.reason)) {
|
|
64
|
-
await this.budget.markDlq(this.client, card, decision.reason, decision.detail);
|
|
65
|
-
}
|
|
66
|
-
else if (decision.reason === "daily_budget") {
|
|
67
|
-
// Soft pause — surface as `waiting` so the board signals that
|
|
68
|
-
// the daemon will pick this card up after the budget resets.
|
|
69
|
-
await this.emitWaiting(card.id, `Daily budget reached — waiting for reset (${decision.detail})`);
|
|
70
|
-
}
|
|
60
|
+
if (decision.reason === "daily_budget") {
|
|
61
|
+
// Soft pause — surface as `waiting` so the board signals that
|
|
62
|
+
// the daemon will pick this card up after the budget resets.
|
|
63
|
+
log.warn(TAG, `#${card.short_id} skipped (daily_budget): ${decision.detail}`);
|
|
64
|
+
await this.emitWaiting(card.id, `Daily budget reached — waiting for reset (${decision.detail})`);
|
|
71
65
|
}
|
|
72
66
|
else {
|
|
73
|
-
|
|
67
|
+
// max_attempts: the daemon has given up on this card. The worker
|
|
68
|
+
// already posted the one-shot give-up comment at the crossing, so
|
|
69
|
+
// every later reconcile tick is expected noise — stay quiet until
|
|
70
|
+
// the card is reassigned (which resets the attempt counter).
|
|
71
|
+
log.debug(TAG, `#${card.short_id} gave up: ${decision.detail}`);
|
|
74
72
|
}
|
|
75
73
|
return;
|
|
76
74
|
}
|
|
@@ -113,6 +111,9 @@ export class Pool {
|
|
|
113
111
|
* Remove a card from any queue or cancel an active worker.
|
|
114
112
|
*/
|
|
115
113
|
async removeCard(cardId) {
|
|
114
|
+
// Unassigning is the human's "try again" signal — clear any exhausted
|
|
115
|
+
// attempt counter so a reassign starts the card fresh (no DLQ to clear).
|
|
116
|
+
await this.stateStore.resetAttempts(cardId);
|
|
116
117
|
// Try both queues
|
|
117
118
|
for (const queue of [this.implQueue, this.reviewQueue]) {
|
|
118
119
|
const removed = queue.remove(cardId);
|
|
@@ -31,6 +31,7 @@ export declare class ProgressTracker {
|
|
|
31
31
|
private logBuffer;
|
|
32
32
|
private sessionId;
|
|
33
33
|
private lastAssistantText;
|
|
34
|
+
private assistantTextBlocks;
|
|
34
35
|
constructor(client: HarmonyApiClient, cardId: string, workerId: number, subtasks: {
|
|
35
36
|
completed: boolean;
|
|
36
37
|
}[]);
|
|
@@ -46,10 +47,12 @@ export declare class ProgressTracker {
|
|
|
46
47
|
/** Get a summary of the session stats. */
|
|
47
48
|
get stats(): {
|
|
48
49
|
filesEdited: number;
|
|
50
|
+
filesEditedPaths: string[];
|
|
49
51
|
filesRead: number;
|
|
50
52
|
toolCalls: number;
|
|
51
53
|
cost: CostUpdate | null;
|
|
52
54
|
lastAssistantText: string;
|
|
55
|
+
assistantTextBlocks: string[];
|
|
53
56
|
};
|
|
54
57
|
private onToolStart;
|
|
55
58
|
private onToolEnd;
|
package/dist/progress-tracker.js
CHANGED
|
@@ -5,6 +5,9 @@ const THROTTLE_MS = 5_000;
|
|
|
5
5
|
const HEARTBEAT_MS = 60_000;
|
|
6
6
|
const MAX_TASK_LENGTH = 120;
|
|
7
7
|
const MAX_LOG_BUFFER = 500;
|
|
8
|
+
// Cap on retained assistant text blocks so a long run can't grow the buffer
|
|
9
|
+
// unbounded (#272). The episode write hook reads from the tail.
|
|
10
|
+
const MAX_TEXT_BLOCKS = 40;
|
|
8
11
|
// Hoisted regexes — avoids recompilation on every call
|
|
9
12
|
const SENTENCE_SPLIT = /\.\s|\n/;
|
|
10
13
|
const ACTION_PREFIX = /^(Let me|I'll|I need to|Now|First|Next|Looking|Checking|Creating|Adding|Updating|Fixing|Refactoring|Moving|The |This )/i;
|
|
@@ -65,6 +68,9 @@ export class ProgressTracker {
|
|
|
65
68
|
// Last assistant text block — used by the episode write hook to
|
|
66
69
|
// capture an approach summary without re-running an LLM (plan §"Write hook").
|
|
67
70
|
lastAssistantText = "";
|
|
71
|
+
// All non-trivial assistant text blocks, in order, used to assemble a richer
|
|
72
|
+
// structured approach summary at episode-write time (#272). Bounded.
|
|
73
|
+
assistantTextBlocks = [];
|
|
68
74
|
constructor(client, cardId, workerId, subtasks) {
|
|
69
75
|
this.client = client;
|
|
70
76
|
this.cardId = cardId;
|
|
@@ -129,10 +135,12 @@ export class ProgressTracker {
|
|
|
129
135
|
get stats() {
|
|
130
136
|
return {
|
|
131
137
|
filesEdited: this.filesEdited.size,
|
|
138
|
+
filesEditedPaths: [...this.filesEdited],
|
|
132
139
|
filesRead: this.filesRead.size,
|
|
133
140
|
toolCalls: this.toolCallCount,
|
|
134
141
|
cost: this.lastCost,
|
|
135
142
|
lastAssistantText: this.lastAssistantText,
|
|
143
|
+
assistantTextBlocks: [...this.assistantTextBlocks],
|
|
136
144
|
};
|
|
137
145
|
}
|
|
138
146
|
onToolStart(name, input) {
|
|
@@ -212,6 +220,13 @@ export class ProgressTracker {
|
|
|
212
220
|
// Always remember the latest non-trivial assistant turn for the episode
|
|
213
221
|
// write hook — last-turn trim, no LLM rewrite (plan §"Write hook").
|
|
214
222
|
this.lastAssistantText = trimmed;
|
|
223
|
+
// Accumulate all non-trivial assistant blocks so the episode write hook can
|
|
224
|
+
// assemble a richer approach summary than the last turn alone (#272).
|
|
225
|
+
// Bounded: drop the oldest once over the cap.
|
|
226
|
+
this.assistantTextBlocks.push(trimmed);
|
|
227
|
+
if (this.assistantTextBlocks.length > MAX_TEXT_BLOCKS) {
|
|
228
|
+
this.assistantTextBlocks.shift();
|
|
229
|
+
}
|
|
215
230
|
// Extract first sentence or line as a brief description
|
|
216
231
|
const end = trimmed.search(SENTENCE_SPLIT);
|
|
217
232
|
const firstLine = (end === -1 ? trimmed : trimmed.slice(0, end)).trim();
|
package/dist/prompt.d.ts
CHANGED
|
@@ -10,6 +10,11 @@ import type { EnrichedCard } from "./types.js";
|
|
|
10
10
|
* Falls back to a minimal local prompt if the API call fails.
|
|
11
11
|
*/
|
|
12
12
|
export declare function buildPrompt(enriched: EnrichedCard, branchName: string, worktreePath: string, client: HarmonyApiClient, workspaceId: string, projectId?: string): Promise<string>;
|
|
13
|
+
/**
|
|
14
|
+
* Fetch and serialize the card's comment thread for the agent prompt. Returns
|
|
15
|
+
* the empty string on no comments or fetch failure — never throws.
|
|
16
|
+
*/
|
|
17
|
+
export declare function renderCommentsSection(client: HarmonyApiClient, cardId: string): Promise<string>;
|
|
13
18
|
/**
|
|
14
19
|
* Recall similar past episodes (implement solution/error type) and render them
|
|
15
20
|
* as a "Similar past tasks" section. Returns the empty string on no hits or
|
package/dist/prompt.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { serializeCommentThread } from "@harmony/shared";
|
|
1
2
|
import { log } from "./log.js";
|
|
2
3
|
const TAG = "prompt";
|
|
3
4
|
/**
|
|
@@ -26,15 +27,42 @@ Do NOT push to main. All your work stays on \`${branchName}\`.
|
|
|
26
27
|
When finished, call harmony_end_agent_session with status="completed".`,
|
|
27
28
|
});
|
|
28
29
|
log.info(TAG, `Generated prompt for #${card.short_id} — ${result.contextSummary.memoryCount} memories, ${result.tokenEstimate} tokens`);
|
|
30
|
+
// generateCardPrompt already appends the comment thread centrally.
|
|
29
31
|
return result.prompt + pastEpisodesSection;
|
|
30
32
|
}
|
|
31
33
|
catch (err) {
|
|
32
34
|
const msg = err instanceof Error ? err.message : String(err);
|
|
33
35
|
log.warn(TAG, `Failed to generate prompt via API, using fallback: ${msg}`);
|
|
36
|
+
// Fallback bypasses generateCardPrompt, so inject the thread here.
|
|
37
|
+
const commentsSection = await renderCommentsSection(client, card.id);
|
|
34
38
|
return (buildFallbackPrompt(enriched, branchName, worktreePath) +
|
|
39
|
+
commentsSection +
|
|
35
40
|
pastEpisodesSection);
|
|
36
41
|
}
|
|
37
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Fetch and serialize the card's comment thread for the agent prompt. Returns
|
|
45
|
+
* the empty string on no comments or fetch failure — never throws.
|
|
46
|
+
*/
|
|
47
|
+
export async function renderCommentsSection(client, cardId) {
|
|
48
|
+
try {
|
|
49
|
+
const { comments } = await client.getComments(cardId, { limit: 200 });
|
|
50
|
+
if (!Array.isArray(comments) || comments.length === 0)
|
|
51
|
+
return "";
|
|
52
|
+
const section = serializeCommentThread(comments, {
|
|
53
|
+
heading: "Comments",
|
|
54
|
+
maxComments: 40,
|
|
55
|
+
});
|
|
56
|
+
return section ? `\n\n${section}` : "";
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
log.warn(TAG, "comment-thread fetch failed", {
|
|
60
|
+
event: "comment_fetch_failed",
|
|
61
|
+
error: err instanceof Error ? err.message : String(err),
|
|
62
|
+
});
|
|
63
|
+
return "";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
38
66
|
/**
|
|
39
67
|
* Recall similar past episodes (implement solution/error type) and render them
|
|
40
68
|
* as a "Similar past tasks" section. Returns the empty string on no hits or
|
|
@@ -62,7 +90,22 @@ export async function renderPastEpisodesSection(client, title, description, work
|
|
|
62
90
|
const meta = e.metadata ?? {};
|
|
63
91
|
const outcomeTag = meta.outcome ? `[${meta.outcome}]` : "[?]";
|
|
64
92
|
const approach = meta.approach_summary ?? "";
|
|
65
|
-
|
|
93
|
+
const lines = [
|
|
94
|
+
`- ${outcomeTag} ${e.title ?? "(untitled episode)"}`,
|
|
95
|
+
` Approach: ${approach}`,
|
|
96
|
+
];
|
|
97
|
+
// Surface the cheap deterministic root-cause line when present (#272).
|
|
98
|
+
if (meta.key_insight)
|
|
99
|
+
lines.push(` Key insight: ${meta.key_insight}`);
|
|
100
|
+
// Show a compact changed-files list — cap the rendered count so the
|
|
101
|
+
// prompt stays small even if the stored list is long (#272).
|
|
102
|
+
if (meta.changed_files && meta.changed_files.length > 0) {
|
|
103
|
+
const shown = meta.changed_files.slice(0, 8);
|
|
104
|
+
const extra = meta.changed_files.length - shown.length;
|
|
105
|
+
const suffix = extra > 0 ? ` (+${extra} more)` : "";
|
|
106
|
+
lines.push(` Changed files: ${shown.join(", ")}${suffix}`);
|
|
107
|
+
}
|
|
108
|
+
return lines.join("\n");
|
|
66
109
|
})
|
|
67
110
|
.join("\n");
|
|
68
111
|
return `\n\n## Similar past tasks\n${bullets}`;
|
|
@@ -32,9 +32,4 @@ export interface ReviewResult {
|
|
|
32
32
|
* bouncing it to To Do for a parse failure that isn't a code quality signal.
|
|
33
33
|
*/
|
|
34
34
|
export declare function parseReviewOutput(stdout: string): ReviewResult;
|
|
35
|
-
/**
|
|
36
|
-
* Post-review completion pipeline.
|
|
37
|
-
* Handles approved/rejected verdicts, creates subtasks for findings,
|
|
38
|
-
* and moves the card to the appropriate column.
|
|
39
|
-
*/
|
|
40
35
|
export declare function runReviewCompletion(client: HarmonyApiClient, card: Card, result: ReviewResult, config: AgentConfig, worktreePath: string, branchName: string | null, sessionStats: SessionStats | null | undefined, runLogPath: string | null | undefined, workspaceId: string | undefined, agentSessionId: string | null | undefined, stateStore: StateStore): Promise<void>;
|