@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 CHANGED
@@ -16,13 +16,20 @@ Built for **failsafe auto mode**: crashed daemons recover on restart, misconfigu
16
16
 
17
17
  ```bash
18
18
  # Run directly (works with any package manager)
19
- npx @gethmy/agent
19
+ npx @gethmy/agent@latest
20
20
 
21
21
  # Or install globally
22
22
  npm install -g @gethmy/agent
23
23
  harmony-agent
24
24
  ```
25
25
 
26
+ > **Always pin `@latest`.** A bare `npx @gethmy/agent` reuses any previously
27
+ > cached version that satisfies the spec — so an old install in `~/.npm/_npx`
28
+ > can shadow the current release and you'll get stale startup logs and CLI
29
+ > behavior. `npx @gethmy/agent@latest` re-resolves to the newest published
30
+ > version. If you ever suspect a stale run, clear the cache with
31
+ > `rm -rf ~/.npm/_npx` (or install globally to skip npx caching entirely).
32
+
26
33
  ## Configuration
27
34
 
28
35
  1. Set up the MCP server first:
package/dist/budget.d.ts CHANGED
@@ -1,6 +1,4 @@
1
- import type { HarmonyApiClient } from "@gethmy/mcp/src/api-client.js";
2
- import type { Card } from "@harmony/shared";
3
- import type { StateStore } from "./state-store.js";
1
+ import type { FailureSummaryRecord, StateStore } from "./state-store.js";
4
2
  import type { AgentConfig } from "./types.js";
5
3
  export type GuardDecision = {
6
4
  allow: true;
@@ -9,39 +7,33 @@ export type GuardDecision = {
9
7
  reason: GuardReason;
10
8
  detail: string;
11
9
  };
12
- export type GuardReason = "dlq" | "max_attempts" | "card_cost_cap" | "daily_budget";
10
+ export type GuardReason = "max_attempts" | "daily_budget";
13
11
  /**
14
- * BudgetGuard is consulted on every pickup and on every run start.
15
- * It protects the daemon from three failure modes:
16
- * 1. Cards that can never succeed (DLQ after N failed attempts).
17
- * 2. Cards that burn unbounded tokens on a single attempt.
18
- * 3. Runaway daily spend across the entire daemon.
12
+ * BudgetGuard is consulted on every implement pickup. It protects the
13
+ * daemon from two failure modes:
14
+ * 1. Cards that can never succeed after N failed attempts the daemon
15
+ * gives up quietly (`max_attempts`) and pings once via a comment.
16
+ * 2. Runaway daily spend across the entire daemon (`daily_budget`).
19
17
  *
20
- * The guard is advisory for the hot path (returns a decision); the
21
- * caller is responsible for marking DLQ and skipping the enqueue.
18
+ * Both are recoverable: `max_attempts` resets when the card is reassigned
19
+ * (see `StateStore.resetAttempts`), and `daily_budget` resets at UTC
20
+ * midnight. There is no per-card cost cap and no permanent dead-letter
21
+ * quarantine — the guard never blocks a card forever.
22
22
  */
23
23
  export declare class BudgetGuard {
24
24
  private config;
25
25
  private store;
26
26
  constructor(config: AgentConfig["budget"], store: StateStore);
27
27
  /**
28
- * Inspect a card before we commit to picking it up. If any threshold
29
- * is already exceeded, return a skip decision the caller should
30
- * apply the DLQ label (for `dlq`/`max_attempts`/`card_cost_cap`) or
31
- * simply hold until the daily budget resets (`daily_budget`).
28
+ * Inspect a card before we commit to picking it up. `max_attempts` means
29
+ * the daemon has given up (the worker has already posted a comment);
30
+ * `daily_budget` is a soft pause until the UTC day rolls over.
32
31
  */
33
32
  check(cardId: string): GuardDecision;
34
- /**
35
- * Does the guard's decision warrant a permanent DLQ marker? The daily
36
- * budget is *not* permanent — it resets at UTC midnight — so we only
37
- * DLQ for terminal states.
38
- */
39
- isTerminal(reason: GuardReason): boolean;
40
- /**
41
- * Apply the DLQ label to a card, persist the reason, and append a
42
- * post-mortem block to the card description listing the last 3 failure
43
- * summaries. Safe to call repeatedly — labels are idempotent and the
44
- * description block is delimited so reruns replace rather than stack.
45
- */
46
- markDlq(client: HarmonyApiClient, card: Card, reason: GuardReason, detail: string): Promise<void>;
47
33
  }
34
+ /**
35
+ * Build the one-shot "agent gave up" comment posted when a card exhausts
36
+ * its attempt budget. Lists the recent failure summaries so a human has a
37
+ * post-mortem trail and a recovery branch to check out.
38
+ */
39
+ export declare function buildGaveUpComment(maxAttempts: number, failures: FailureSummaryRecord[]): string;
package/dist/budget.js CHANGED
@@ -1,15 +1,14 @@
1
- import { log } from "./log.js";
2
- import { runTransition } from "./transitions.js";
3
- const TAG = "budget";
4
1
  /**
5
- * BudgetGuard is consulted on every pickup and on every run start.
6
- * It protects the daemon from three failure modes:
7
- * 1. Cards that can never succeed (DLQ after N failed attempts).
8
- * 2. Cards that burn unbounded tokens on a single attempt.
9
- * 3. Runaway daily spend across the entire daemon.
2
+ * BudgetGuard is consulted on every implement pickup. It protects the
3
+ * daemon from two failure modes:
4
+ * 1. Cards that can never succeed after N failed attempts the daemon
5
+ * gives up quietly (`max_attempts`) and pings once via a comment.
6
+ * 2. Runaway daily spend across the entire daemon (`daily_budget`).
10
7
  *
11
- * The guard is advisory for the hot path (returns a decision); the
12
- * caller is responsible for marking DLQ and skipping the enqueue.
8
+ * Both are recoverable: `max_attempts` resets when the card is reassigned
9
+ * (see `StateStore.resetAttempts`), and `daily_budget` resets at UTC
10
+ * midnight. There is no per-card cost cap and no permanent dead-letter
11
+ * quarantine — the guard never blocks a card forever.
13
12
  */
14
13
  export class BudgetGuard {
15
14
  config;
@@ -19,37 +18,19 @@ export class BudgetGuard {
19
18
  this.store = store;
20
19
  }
21
20
  /**
22
- * Inspect a card before we commit to picking it up. If any threshold
23
- * is already exceeded, return a skip decision the caller should
24
- * apply the DLQ label (for `dlq`/`max_attempts`/`card_cost_cap`) or
25
- * simply hold until the daily budget resets (`daily_budget`).
21
+ * Inspect a card before we commit to picking it up. `max_attempts` means
22
+ * the daemon has given up (the worker has already posted a comment);
23
+ * `daily_budget` is a soft pause until the UTC day rolls over.
26
24
  */
27
25
  check(cardId) {
28
- if (this.store.isDlq(cardId)) {
29
- const rec = this.store.getCard(cardId);
26
+ const card = this.store.getCard(cardId);
27
+ if (card && card.attempts >= this.config.maxAttemptsPerCard) {
30
28
  return {
31
29
  allow: false,
32
- reason: "dlq",
33
- detail: rec?.dlqReason ?? "previously marked DLQ",
30
+ reason: "max_attempts",
31
+ detail: `${card.attempts} of ${this.config.maxAttemptsPerCard} attempts exhausted`,
34
32
  };
35
33
  }
36
- const card = this.store.getCard(cardId);
37
- if (card) {
38
- if (card.attempts >= this.config.maxAttemptsPerCard) {
39
- return {
40
- allow: false,
41
- reason: "max_attempts",
42
- detail: `${card.attempts} of ${this.config.maxAttemptsPerCard} attempts exhausted`,
43
- };
44
- }
45
- if (card.totalCostCents >= this.config.maxCentsPerCard) {
46
- return {
47
- allow: false,
48
- reason: "card_cost_cap",
49
- detail: `spent ${formatCents(card.totalCostCents)} of ${formatCents(this.config.maxCentsPerCard)} per-card cap`,
50
- };
51
- }
52
- }
53
34
  const dailySpent = this.store.getDailyCostCents();
54
35
  if (dailySpent >= this.config.dailyBudgetCents) {
55
36
  return {
@@ -60,60 +41,16 @@ export class BudgetGuard {
60
41
  }
61
42
  return { allow: true };
62
43
  }
63
- /**
64
- * Does the guard's decision warrant a permanent DLQ marker? The daily
65
- * budget is *not* permanent — it resets at UTC midnight — so we only
66
- * DLQ for terminal states.
67
- */
68
- isTerminal(reason) {
69
- return (reason === "dlq" ||
70
- reason === "max_attempts" ||
71
- reason === "card_cost_cap");
72
- }
73
- /**
74
- * Apply the DLQ label to a card, persist the reason, and append a
75
- * post-mortem block to the card description listing the last 3 failure
76
- * summaries. Safe to call repeatedly — labels are idempotent and the
77
- * description block is delimited so reruns replace rather than stack.
78
- */
79
- async markDlq(client, card, reason, detail) {
80
- await this.store.markDlq(card.id, `${reason}: ${detail}`);
81
- try {
82
- await runTransition(client, card, {
83
- addLabels: [
84
- { name: this.config.dlqLabel, color: this.config.dlqLabelColor },
85
- ],
86
- });
87
- }
88
- catch (err) {
89
- log.warn(TAG, `failed to add dlq label to #${card.short_id}: ${err instanceof Error ? err.message : err}`);
90
- }
91
- try {
92
- const recent = this.store.getRecentFailures(card.id, 3);
93
- const block = buildDlqDescriptionBlock(reason, detail, recent);
94
- const existing = card.description ?? "";
95
- const stripped = stripDlqBlock(existing);
96
- await client.updateCard(card.id, {
97
- description: `${stripped}${stripped ? "\n\n" : ""}${block}`,
98
- });
99
- }
100
- catch (err) {
101
- log.warn(TAG, `failed to post DLQ summary to #${card.short_id}: ${err instanceof Error ? err.message : err}`);
102
- }
103
- log.warn(TAG, `#${card.short_id} DLQ'd — ${reason}: ${detail}`);
104
- }
105
44
  }
106
- const DLQ_FENCE_START = "<!-- agent-dlq:start -->";
107
- const DLQ_FENCE_END = "<!-- agent-dlq:end -->";
108
- // Legacy marker pre-fence DLQ blocks written before 2026-05-23. Strip path
109
- // only; new blocks always emit the fenced form.
110
- const LEGACY_DLQ_MARKER = "---\n**Agent DLQ**";
111
- function buildDlqDescriptionBlock(reason, detail, failures) {
45
+ /**
46
+ * Build the one-shot "agent gave up" comment posted when a card exhausts
47
+ * its attempt budget. Lists the recent failure summaries so a human has a
48
+ * post-mortem trail and a recovery branch to check out.
49
+ */
50
+ export function buildGaveUpComment(maxAttempts, failures) {
112
51
  const lines = [
113
- DLQ_FENCE_START,
114
- "---",
115
- "**Agent DLQ**",
116
- `Cap hit: ${reason} — ${detail}`,
52
+ "**Agent gave up — needs a human.**",
53
+ `Stopped after ${maxAttempts} failed attempt${maxAttempts === 1 ? "" : "s"}. Reassign the card to try again.`,
117
54
  ];
118
55
  if (failures.length > 0) {
119
56
  lines.push("", "Recent failures:");
@@ -129,33 +66,8 @@ function buildDlqDescriptionBlock(reason, detail, failures) {
129
66
  else {
130
67
  lines.push("", "_No prior failure summaries recorded._");
131
68
  }
132
- lines.push(DLQ_FENCE_END);
133
69
  return lines.join("\n");
134
70
  }
135
- function stripDlqBlock(description) {
136
- const start = description.indexOf(DLQ_FENCE_START);
137
- if (start >= 0) {
138
- const end = description.indexOf(DLQ_FENCE_END, start);
139
- if (end < 0) {
140
- // Malformed: opening fence with no closer. Treat the rest of the
141
- // description as the block — safer than preserving an orphan fence.
142
- return description.slice(0, start).trimEnd();
143
- }
144
- const prefix = description.slice(0, start).trimEnd();
145
- const suffix = description
146
- .slice(end + DLQ_FENCE_END.length)
147
- .replace(/^\s+/, "");
148
- if (prefix && suffix)
149
- return `${prefix}\n\n${suffix}`;
150
- return prefix || suffix;
151
- }
152
- // Legacy unfenced block — match the original behavior (no suffix to
153
- // preserve, since the legacy emitter always wrote to end-of-description).
154
- const legacy = description.indexOf(LEGACY_DLQ_MARKER);
155
- if (legacy >= 0)
156
- return description.slice(0, legacy).trimEnd();
157
- return description.trimEnd();
158
- }
159
71
  function formatCents(cents) {
160
72
  return `$${(cents / 100).toFixed(2)}`;
161
73
  }
package/dist/cli.d.ts CHANGED
@@ -9,8 +9,6 @@
9
9
  * health — GET /health, exit 0 if healthy, 1 otherwise
10
10
  * doctor — run preflight checks without starting the daemon
11
11
  * gc — one-shot worktree garbage collection
12
- * dlq list — print DLQ entries
13
- * dlq clear <id> — clear a card's DLQ mark
14
12
  * help — show usage
15
13
  */
16
14
  export {};
package/dist/cli.js CHANGED
@@ -9,8 +9,6 @@
9
9
  * health — GET /health, exit 0 if healthy, 1 otherwise
10
10
  * doctor — run preflight checks without starting the daemon
11
11
  * gc — one-shot worktree garbage collection
12
- * dlq list — print DLQ entries
13
- * dlq clear <id> — clear a card's DLQ mark
14
12
  * help — show usage
15
13
  */
16
14
  import { log } from "./log.js";
@@ -23,8 +21,6 @@ Usage:
23
21
  harmony-agent health Exit 0 if daemon is healthy, 1 otherwise
24
22
  harmony-agent doctor Run preflight checks (don't start)
25
23
  harmony-agent gc One-shot worktree garbage collection
26
- harmony-agent dlq list List dead-lettered cards
27
- harmony-agent dlq clear <cardId> Clear a card's DLQ marker
28
24
  harmony-agent help Show this help
29
25
 
30
26
  Flags:
@@ -45,12 +41,6 @@ async function httpCall(path, init) {
45
41
  const url = `http://${cfg.agent.http.bindAddr}:${cfg.agent.http.port}${path}`;
46
42
  return fetch(url, init);
47
43
  }
48
- /** True if the error looks like "daemon is not running" (ECONNREFUSED). */
49
- function isDaemonDown(err) {
50
- const code = err?.cause?.code;
51
- const msg = err instanceof Error ? err.message : String(err);
52
- return (code === "ECONNREFUSED" || /ECONNREFUSED|fetch failed|connect/i.test(msg));
53
- }
54
44
  function printStatus(body) {
55
45
  const out = process.stdout;
56
46
  const uptime = formatDuration(body.uptimeMs);
@@ -70,10 +60,6 @@ function printStatus(body) {
70
60
  for (const q of body.reviewQueue) {
71
61
  out.write(` #${q.shortId} priority=${q.priority}\n`);
72
62
  }
73
- out.write(`dlq (${body.dlq.length})\n`);
74
- for (const d of body.dlq) {
75
- out.write(` ${d.cardId} attempts=${d.attempts} cost=$${(d.totalCostCents / 100).toFixed(2)} reason=${d.reason}\n`);
76
- }
77
63
  }
78
64
  function formatDuration(ms) {
79
65
  const s = Math.floor(ms / 1000);
@@ -155,54 +141,6 @@ async function gcCommand() {
155
141
  }
156
142
  return 0;
157
143
  }
158
- async function dlqCommand(args) {
159
- const sub = args[0];
160
- const { StateStore } = await import("./state-store.js");
161
- const store = StateStore.open();
162
- if (!sub || sub === "list") {
163
- const entries = store.listDlq();
164
- if (entries.length === 0) {
165
- process.stdout.write("DLQ is empty\n");
166
- return 0;
167
- }
168
- for (const c of entries) {
169
- process.stdout.write(`${c.cardId} attempts=${c.attempts} cost=$${(c.totalCostCents / 100).toFixed(2)} reason=${c.dlqReason ?? "(unknown)"}\n`);
170
- }
171
- return 0;
172
- }
173
- if (sub === "clear") {
174
- const cardId = args[1];
175
- if (!cardId) {
176
- process.stderr.write("usage: harmony-agent dlq clear <cardId>\n");
177
- return 2;
178
- }
179
- // Prefer the running daemon if present — direct file writes race
180
- // the daemon's own in-memory state-store and silently lose data.
181
- try {
182
- const res = await httpCall(`/dlq/clear/${encodeURIComponent(cardId)}`, {
183
- method: "POST",
184
- });
185
- if (res.ok) {
186
- process.stdout.write(`cleared DLQ for ${cardId} (via daemon)\n`);
187
- return 0;
188
- }
189
- process.stderr.write(`daemon returned ${res.status} ${res.statusText}\n`);
190
- return 1;
191
- }
192
- catch (err) {
193
- if (!isDaemonDown(err)) {
194
- process.stderr.write(`daemon HTTP error: ${err instanceof Error ? err.message : err}\n`);
195
- return 1;
196
- }
197
- // Daemon offline → safe to write directly.
198
- await store.clearDlq(cardId);
199
- process.stdout.write(`cleared DLQ for ${cardId} (daemon offline, wrote directly)\n`);
200
- return 0;
201
- }
202
- }
203
- process.stderr.write(`unknown dlq subcommand: ${sub}\n`);
204
- return 2;
205
- }
206
144
  async function dispatch(argv) {
207
145
  // Strip node, script, and any global flags we own.
208
146
  const args = argv.filter((a) => a !== "--pretty" && a !== "--json");
@@ -221,8 +159,6 @@ async function dispatch(argv) {
221
159
  return doctorCommand();
222
160
  case "gc":
223
161
  return gcCommand();
224
- case "dlq":
225
- return dlqCommand(args.slice(1));
226
162
  case "help":
227
163
  case "--help":
228
164
  case "-h":
@@ -5,11 +5,15 @@ import type { CostUpdate } from "./stream-parser.js";
5
5
  import { type AgentConfig } from "./types.js";
6
6
  export interface SessionStats {
7
7
  filesEdited: number;
8
+ /** Edited file paths tracked by the ProgressTracker (#272). */
9
+ filesEditedPaths?: string[];
8
10
  filesRead: number;
9
11
  toolCalls: number;
10
12
  cost: CostUpdate | null;
11
13
  /** Trimmed last assistant text — feeds the episode write hook (Phase 1.5). */
12
14
  lastAssistantText?: string;
15
+ /** All non-trivial assistant text blocks — richer summary source (#272). */
16
+ assistantTextBlocks?: string[];
13
17
  }
14
18
  export declare function buildTokenPayload(stats?: SessionStats | null): {
15
19
  costCents?: undefined;
@@ -29,4 +33,4 @@ export declare function buildTokenPayload(stats?: SessionStats | null): {
29
33
  /**
30
34
  * Post-work pipeline: push branch, create PR, move card, post summary.
31
35
  */
32
- export declare function runCompletion(client: HarmonyApiClient, card: Card, branchName: string, worktreePath: string, config: AgentConfig, workerId: number, sessionStats: SessionStats | undefined, workspaceId: string | undefined, agentSessionId: string | null | undefined, stateStore: StateStore): Promise<void>;
36
+ export declare function runCompletion(client: HarmonyApiClient, card: Card, branchName: string, worktreePath: string, config: AgentConfig, workerId: number, sessionStats: SessionStats | undefined, workspaceId: string | undefined, agentSessionId: string | null | undefined, stateStore: StateStore): Promise<boolean>;
@@ -1,6 +1,7 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { moveCardToColumn } from "./board-helpers.js";
3
3
  import { writeEpisode } from "./episode-writer.js";
4
+ import { captureDiffStat } from "./git-diff-stat.js";
4
5
  import { createPullRequest, detectGitProvider, getBranchWebUrl, pushBranch, } from "./git-pr.js";
5
6
  import { log } from "./log.js";
6
7
  import { AGENT_NAME, agentIdentifier } from "./types.js";
@@ -47,7 +48,7 @@ export async function runCompletion(client, card, branchName, worktreePath, conf
47
48
  ...buildTokenPayload(sessionStats),
48
49
  });
49
50
  cleanupWorktree(worktreePath, branchName);
50
- return;
51
+ return true; // nothing to verify — not a failed attempt
51
52
  }
52
53
  // 1. Push branch FIRST so commits are durable on origin regardless of
53
54
  // verification outcome. A failed verify (below) then preserves the work
@@ -160,7 +161,7 @@ export async function runCompletion(client, card, branchName, worktreePath, conf
160
161
  // Local-only cleanup. The remote ref under `agent-attempts/*` stays
161
162
  // up; the GC sweep (worktree-gc.ts) prunes it after retention.
162
163
  cleanupWorktree(worktreePath, branchName);
163
- return;
164
+ return false; // verification failed — counts as a failed attempt
164
165
  }
165
166
  log.info(TAG, `Verification passed for #${card.short_id}`);
166
167
  }
@@ -194,21 +195,38 @@ export async function runCompletion(client, card, branchName, worktreePath, conf
194
195
  // a separate write hook into the pre-return path, which D8 intentionally
195
196
  // omits ("daemon crashes ≠ task outcome").
196
197
  if (workspaceId) {
198
+ // Capture changed files + churn from the diff (#272). Best-effort + guarded:
199
+ // a null result just falls back to the ProgressTracker-tracked edit paths.
200
+ // The diff's file list is authoritative (it reflects what actually landed,
201
+ // including renames/deletes the tracker can't see) so prefer it.
202
+ const diffStat = captureDiffStat(worktreePath, config.worktree.baseBranch);
203
+ const changedFiles = diffStat && diffStat.files.length > 0
204
+ ? diffStat.files
205
+ : (sessionStats?.filesEditedPaths ?? []);
197
206
  await writeEpisode(client, {
198
207
  kind: "implement",
199
208
  card,
200
209
  workspaceId,
201
210
  outcome: "success",
202
211
  approachSummary: sessionStats?.lastAssistantText ?? "",
212
+ approachBlocks: sessionStats?.assistantTextBlocks,
203
213
  result: verificationResult,
204
214
  cost: sessionStats?.cost ?? null,
205
215
  filesEdited: sessionStats?.filesEdited ?? 0,
216
+ changedFiles,
217
+ churn: diffStat
218
+ ? {
219
+ insertions: diffStat.insertions,
220
+ deletions: diffStat.deletions,
221
+ }
222
+ : undefined,
206
223
  agentSessionId: agentSessionId ?? null,
207
224
  });
208
225
  }
209
226
  // 7. Cleanup worktree
210
227
  cleanupWorktree(worktreePath, branchName);
211
228
  log.info(TAG, `Completion done for #${card.short_id}${prUrl ? ` — PR: ${prUrl}` : ""}`);
229
+ return true;
212
230
  }
213
231
  function buildVerificationFailureSummary(result, autoFixAttempts) {
214
232
  const counts = [];
@@ -9,9 +9,22 @@ interface ImplementEpisodeInput {
9
9
  workspaceId: string;
10
10
  outcome: EpisodeOutcome;
11
11
  approachSummary: string;
12
+ /**
13
+ * All non-trivial assistant text blocks from the run (#272). When present,
14
+ * used to assemble a richer approach summary + extract a key insight. Falls
15
+ * back to `approachSummary` (last turn) when empty.
16
+ */
17
+ approachBlocks?: string[];
12
18
  result: VerificationResult;
13
19
  cost: CostUpdate | null;
14
20
  filesEdited: number;
21
+ /** Changed file paths (#272): diff list when available, else tracked paths. */
22
+ changedFiles?: string[];
23
+ /** Line churn (#272), best-effort from `git diff --numstat`. */
24
+ churn?: {
25
+ insertions: number;
26
+ deletions: number;
27
+ };
15
28
  errorMessage?: string;
16
29
  agentSessionId?: string | null;
17
30
  }
@@ -43,6 +56,25 @@ export declare function computeQualityScore(result: VerificationResult, opts: {
43
56
  * as a recallable hit (rather than an empty bullet) in future prompts.
44
57
  */
45
58
  export declare function trimApproachSummary(text: string): string;
59
+ /**
60
+ * Cap the review rationale ("why approved / rejected") richly (#272 task 5)
61
+ * rather than re-trimming to the 400-char implement bound. Empty input
62
+ * collapses to a marker so the episode still surfaces as a recallable hit.
63
+ */
64
+ export declare function trimReviewRationale(text: string): string;
65
+ /**
66
+ * Assemble a richer approach summary from the collected assistant text blocks
67
+ * (#272). Joins the trailing blocks (most relevant context lives near the end
68
+ * of a run) up to a longer bounded cap than the single-turn trim. Falls back to
69
+ * the last-turn `fallback` text when no blocks were collected. No LLM call.
70
+ */
71
+ export declare function buildRichApproachSummary(blocks: string[] | undefined, fallback: string): string;
72
+ /**
73
+ * Extract a cheap deterministic "key insight" line from the run's assistant
74
+ * text (#272 task 3). Scans for a sentence/line matching a root-cause / gotcha
75
+ * pattern. Returns undefined when nothing matches — never fabricated, no LLM.
76
+ */
77
+ export declare function extractKeyInsight(blocks: string[] | undefined, fallback: string): string | undefined;
46
78
  /**
47
79
  * Build the entity payload for one episode. Pure — returned object can be
48
80
  * snapshotted in tests without hitting the network.