@yagni-app/code 0.3.4 → 1.0.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/extension/askAdvisorTool.js +2 -0
- package/dist/extension/askUserQuestionTool.d.ts +54 -0
- package/dist/extension/askUserQuestionTool.js +621 -0
- package/dist/extension/branding.d.ts +39 -0
- package/dist/extension/branding.js +76 -0
- package/dist/extension/cmux/index.d.ts +17 -1
- package/dist/extension/cmux/index.js +47 -8
- package/dist/extension/cmux/state.d.ts +5 -1
- package/dist/extension/cmux/state.js +15 -8
- package/dist/extension/crashReport.js +12 -0
- package/dist/extension/decisionCapture.js +3 -0
- package/dist/extension/decisions.js +4 -0
- package/dist/extension/diagnostics.d.ts +31 -0
- package/dist/extension/diagnostics.js +53 -55
- package/dist/extension/errorSink.d.ts +64 -0
- package/dist/extension/errorSink.js +180 -0
- package/dist/extension/feedbackCommand.d.ts +38 -0
- package/dist/extension/feedbackCommand.js +151 -0
- package/dist/extension/hooks.js +12 -12
- package/dist/extension/index.d.ts +1 -0
- package/dist/extension/index.js +97 -40
- package/dist/extension/mineBeat.js +13 -0
- package/dist/extension/pipeline/goCommand.js +2 -0
- package/dist/extension/pipeline/personas.js +9 -0
- package/dist/extension/pipeline/runner.js +9 -0
- package/dist/extension/sessionTitle/summarize.d.ts +40 -0
- package/dist/extension/sessionTitle/summarize.js +63 -0
- package/dist/extension/sessionTitle/title.d.ts +27 -0
- package/dist/extension/sessionTitle/title.js +57 -0
- package/dist/extension/silentTurnReminder.d.ts +109 -0
- package/dist/extension/silentTurnReminder.js +221 -0
- package/dist/extension/turnLog.d.ts +14 -0
- package/dist/extension/turnLog.js +22 -47
- package/dist/extension/webFetch.d.ts +85 -0
- package/dist/extension/webFetch.js +192 -0
- package/dist/extension/webFetchTool.d.ts +34 -0
- package/dist/extension/webFetchTool.js +104 -0
- package/package.json +4 -3
- package/dist/extension/cmux/naming.d.ts +0 -5
- package/dist/extension/cmux/naming.js +0 -23
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import { execFileSync } from "node:child_process";
|
|
21
21
|
import * as fs from "node:fs";
|
|
22
22
|
import { dirname, join } from "node:path";
|
|
23
|
+
import { logEvent } from "./errorSink.js";
|
|
23
24
|
import { METERED_POST_FETCH_POLICY, resilientFetch } from "./resilientFetch.js";
|
|
24
25
|
/**
|
|
25
26
|
* Client-side bounds on the mining corpus — mirror of the backend's
|
|
@@ -173,6 +174,12 @@ export async function maybeOfferMiningBeat(ctx, opts) {
|
|
|
173
174
|
}, { fetchImpl: opts.fetchImpl, policy: METERED_POST_FETCH_POLICY });
|
|
174
175
|
if (!res.ok) {
|
|
175
176
|
// No marker: the offer stays available next session.
|
|
177
|
+
logEvent({
|
|
178
|
+
source: "mine-beat",
|
|
179
|
+
level: "error",
|
|
180
|
+
event: "mine_failed",
|
|
181
|
+
fields: { status: res.status },
|
|
182
|
+
});
|
|
176
183
|
ctx.ui.notify("Seeding the decision ledger failed; YAGNI Code will offer again next session.", "error");
|
|
177
184
|
return { offered: true, accepted: true };
|
|
178
185
|
}
|
|
@@ -186,6 +193,12 @@ export async function maybeOfferMiningBeat(ctx, opts) {
|
|
|
186
193
|
return { offered: true, accepted: true, banked };
|
|
187
194
|
}
|
|
188
195
|
catch {
|
|
196
|
+
logEvent({
|
|
197
|
+
source: "mine-beat",
|
|
198
|
+
level: "error",
|
|
199
|
+
event: "mine_failed",
|
|
200
|
+
fields: { kind: "network" },
|
|
201
|
+
});
|
|
189
202
|
ctx.ui.notify("Seeding the decision ledger failed; YAGNI Code will offer again next session.", "error");
|
|
190
203
|
return { offered: true, accepted: true };
|
|
191
204
|
}
|
|
@@ -71,6 +71,7 @@ import { formatRunCostTable } from "./runCostTable.js";
|
|
|
71
71
|
import { makeCombinedCheckpointStore, makeFileCheckpointStore, makePiJournalCheckpointStore, } from "./checkpoint.js";
|
|
72
72
|
import { getToken as defaultGetToken, resolveBaseUrl } from "../config.js";
|
|
73
73
|
import { makeCrashReporter } from "../crashReport.js";
|
|
74
|
+
import { logEvent } from "../errorSink.js";
|
|
74
75
|
import { scrubSecrets } from "./scrubSecrets.js";
|
|
75
76
|
import { isDesktopSurface } from "../surface.js";
|
|
76
77
|
import { runFinish as defaultRunFinish, verifyTrailerValue, } from "./finish.js";
|
|
@@ -1104,6 +1105,7 @@ export function registerGoCommand(pi, deps = {}) {
|
|
|
1104
1105
|
// return results instead) — report it, fire-and-forget. The default
|
|
1105
1106
|
// reporter never rejects; the catch guards an injected one.
|
|
1106
1107
|
void reportCrash(err, "go", runCwd).catch(() => { });
|
|
1108
|
+
logEvent({ source: "go", level: "error", event: "go_failed", fields: { runId } });
|
|
1107
1109
|
// The stopReason travels to the backend run record; scrub it like
|
|
1108
1110
|
// every other captured text (a raw error can echo a connection
|
|
1109
1111
|
// string or key).
|
|
@@ -184,6 +184,14 @@ Output ONLY a JSON object with this exact shape:
|
|
|
184
184
|
{"outcome":"allow"|"ask"|"deny","riskLevel":"low"|"medium"|"high"|"critical","rationale":"see rationale rules"}
|
|
185
185
|
|
|
186
186
|
Do not output anything else after the JSON. No markdown fences, only the JSON object.`;
|
|
187
|
+
const TITLE_BODY = `You produce a session title from a user's prompt. Output ONLY a concise, sentence-case title of 3-7 words that captures the main topic or goal. Capitalize only the first word and proper nouns. Do not include a ticket code in the title text itself (the caller prepends it). No markdown, no prose, no quotes — just the title on one line.
|
|
188
|
+
|
|
189
|
+
Good:
|
|
190
|
+
Fix the login page
|
|
191
|
+
Add OAuth authentication
|
|
192
|
+
|
|
193
|
+
Bad (too vague): Code changes
|
|
194
|
+
Bad (too long): Investigate and fix the login button not responding on mobile devices`;
|
|
187
195
|
/** Persona body keyed by the agent name referenced in `stages.ts`. */
|
|
188
196
|
export const PERSONA_BODIES = {
|
|
189
197
|
scout: SCOUT_BODY,
|
|
@@ -192,6 +200,7 @@ export const PERSONA_BODIES = {
|
|
|
192
200
|
reviewer: REVIEWER_BODY,
|
|
193
201
|
advisor: ADVISOR_BODY,
|
|
194
202
|
guardian: GUARDIAN_BODY,
|
|
203
|
+
title: TITLE_BODY,
|
|
195
204
|
orchestrator: [ORCHESTRATOR_BODY, PARTITION_CONTRACT].join("\n\n"),
|
|
196
205
|
synthesizer: SYNTHESIZER_BODY,
|
|
197
206
|
};
|
|
@@ -25,6 +25,7 @@ import * as path from "node:path";
|
|
|
25
25
|
import { fileURLToPath } from "node:url";
|
|
26
26
|
import { trackChild } from "./childRegistry.js";
|
|
27
27
|
import { finalOutputFrom, foldEvent, newEventAccumulator } from "./events.js";
|
|
28
|
+
import { logEvent } from "../errorSink.js";
|
|
28
29
|
import { buildStageInvocation, groundedChildArgv } from "./invocation.js";
|
|
29
30
|
import { personaBody } from "./personas.js";
|
|
30
31
|
import { clampTier, resolveTierCap } from "./tierCap.js";
|
|
@@ -311,6 +312,14 @@ export async function runStage(stage, ctx, deps) {
|
|
|
311
312
|
final_output: finalOut,
|
|
312
313
|
}, null, 2));
|
|
313
314
|
dbgLog("stage_end");
|
|
315
|
+
if (overlongLinesDropped > 0) {
|
|
316
|
+
logEvent({
|
|
317
|
+
source: "pipeline",
|
|
318
|
+
level: "debug",
|
|
319
|
+
event: "overlong_lines_dropped",
|
|
320
|
+
fields: { stage: stage.id, dropped: overlongLinesDropped },
|
|
321
|
+
});
|
|
322
|
+
}
|
|
314
323
|
}
|
|
315
324
|
catch { /* ignore */ }
|
|
316
325
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The I/O half of session titling: summarize a prompt into a title via a
|
|
3
|
+
* locked-down efficient-tier child (same `runStage` seam the Guardian and
|
|
4
|
+
* advisor use). Fully optional — any failure returns undefined so the caller
|
|
5
|
+
* falls back to the heuristic title without ever blocking a turn.
|
|
6
|
+
*/
|
|
7
|
+
import { runStage as defaultRunStage } from "../pipeline/runner.js";
|
|
8
|
+
import type { PipelineStage } from "../pipeline/types.js";
|
|
9
|
+
/** The model tier the title child runs on (read-only, like the Guardian). */
|
|
10
|
+
export declare const TITLE_MODEL_TIER = "efficient";
|
|
11
|
+
/** The read-only tools the title child may use (only `read`, for context). */
|
|
12
|
+
export declare const TITLE_TOOLS: string[];
|
|
13
|
+
/** Default wall-clock timeout for the title consult (matches Guardian). */
|
|
14
|
+
export declare const DEFAULT_TITLE_TIMEOUT_MS = 15000;
|
|
15
|
+
/**
|
|
16
|
+
* The synthetic stage a title consult runs as. Borrows the `plan` StageId (same
|
|
17
|
+
* pattern as the advisor/guardian) so it doesn't ripple into /go feed/reducers.
|
|
18
|
+
* The agent name selects the `title` persona from PERSONA_BODIES.
|
|
19
|
+
*/
|
|
20
|
+
export declare function titleStage(modelTier?: string): PipelineStage;
|
|
21
|
+
export interface SummarizeTitleDeps {
|
|
22
|
+
runStage?: typeof defaultRunStage;
|
|
23
|
+
timeoutMs?: number;
|
|
24
|
+
cwd?: string;
|
|
25
|
+
}
|
|
26
|
+
/** The outcome of a title consult: a prefixed title, or a failure reason. */
|
|
27
|
+
export type SummarizeTitleResult = {
|
|
28
|
+
ok: true;
|
|
29
|
+
title: string;
|
|
30
|
+
} | {
|
|
31
|
+
ok: false;
|
|
32
|
+
reason: string;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Summarize a prompt into a title. Returns `{ title }` on success, or
|
|
36
|
+
* `{ reason }` on any failure (timeout, abort, non-zero exit, empty or unusable
|
|
37
|
+
* output) — the caller falls back to the heuristic and logs the reason.
|
|
38
|
+
*/
|
|
39
|
+
export declare function summarizeTitle(prompt: string, deps?: SummarizeTitleDeps): Promise<SummarizeTitleResult>;
|
|
40
|
+
//# sourceMappingURL=summarize.d.ts.map
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The I/O half of session titling: summarize a prompt into a title via a
|
|
3
|
+
* locked-down efficient-tier child (same `runStage` seam the Guardian and
|
|
4
|
+
* advisor use). Fully optional — any failure returns undefined so the caller
|
|
5
|
+
* falls back to the heuristic title without ever blocking a turn.
|
|
6
|
+
*/
|
|
7
|
+
import { runStage as defaultRunStage } from "../pipeline/runner.js";
|
|
8
|
+
import { parseSummarizedTitle } from "./title.js";
|
|
9
|
+
/** The model tier the title child runs on (read-only, like the Guardian). */
|
|
10
|
+
export const TITLE_MODEL_TIER = "efficient";
|
|
11
|
+
/** The read-only tools the title child may use (only `read`, for context). */
|
|
12
|
+
export const TITLE_TOOLS = ["read"];
|
|
13
|
+
/** Default wall-clock timeout for the title consult (matches Guardian). */
|
|
14
|
+
export const DEFAULT_TITLE_TIMEOUT_MS = 15_000;
|
|
15
|
+
/**
|
|
16
|
+
* The synthetic stage a title consult runs as. Borrows the `plan` StageId (same
|
|
17
|
+
* pattern as the advisor/guardian) so it doesn't ripple into /go feed/reducers.
|
|
18
|
+
* The agent name selects the `title` persona from PERSONA_BODIES.
|
|
19
|
+
*/
|
|
20
|
+
export function titleStage(modelTier = TITLE_MODEL_TIER) {
|
|
21
|
+
return {
|
|
22
|
+
id: "plan",
|
|
23
|
+
agent: "title",
|
|
24
|
+
model: modelTier,
|
|
25
|
+
tools: TITLE_TOOLS,
|
|
26
|
+
taskTemplate: "{ticket}",
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Summarize a prompt into a title. Returns `{ title }` on success, or
|
|
31
|
+
* `{ reason }` on any failure (timeout, abort, non-zero exit, empty or unusable
|
|
32
|
+
* output) — the caller falls back to the heuristic and logs the reason.
|
|
33
|
+
*/
|
|
34
|
+
export async function summarizeTitle(prompt, deps = {}) {
|
|
35
|
+
const runStage = deps.runStage ?? defaultRunStage;
|
|
36
|
+
const timeoutMs = deps.timeoutMs ?? DEFAULT_TITLE_TIMEOUT_MS;
|
|
37
|
+
const controller = new AbortController();
|
|
38
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
39
|
+
timer.unref?.();
|
|
40
|
+
try {
|
|
41
|
+
const result = await runStage(titleStage(), { ticket: `Summarize this session into a short title:\n\n${prompt}` }, {
|
|
42
|
+
cwd: deps.cwd ?? process.cwd(),
|
|
43
|
+
signal: controller.signal,
|
|
44
|
+
callerLabel: "title",
|
|
45
|
+
});
|
|
46
|
+
if (result.exitCode !== 0) {
|
|
47
|
+
const stderr = result.stderr?.trim().slice(0, 200);
|
|
48
|
+
return { ok: false, reason: stderr ? `exit=${result.exitCode} ${stderr}` : `exit=${result.exitCode}` };
|
|
49
|
+
}
|
|
50
|
+
const title = parseSummarizedTitle(prompt, result.finalOutput);
|
|
51
|
+
if (title)
|
|
52
|
+
return { ok: true, title };
|
|
53
|
+
return { ok: false, reason: `empty_output output=${JSON.stringify(result.finalOutput.slice(0, 200))}` };
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
57
|
+
return { ok: false, reason: `threw ${msg.slice(0, 200)}` };
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=summarize.js.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure session-title derivation — the reusable half of naming an agent session.
|
|
3
|
+
* No cmux, no workspace, no I/O: just "given a prompt, what's a good title".
|
|
4
|
+
*
|
|
5
|
+
* cmux is the current (and only) consumer, but this module is deliberately
|
|
6
|
+
* cmux-free so a future consumer (terminal title, a resume hint, the Feed) can
|
|
7
|
+
* reuse it without importing cmux vocabulary.
|
|
8
|
+
*/
|
|
9
|
+
/** Extract a ticket code from a prompt, or null when none is present. */
|
|
10
|
+
export declare function ticketCode(prompt: string): string | null;
|
|
11
|
+
/** The heuristic fallback title: the first 8 words, capped at 60 chars. */
|
|
12
|
+
export declare function titleFromPrompt(prompt: string): string | undefined;
|
|
13
|
+
/**
|
|
14
|
+
* Compose a session title from a prompt, prepending the ticket code when one is
|
|
15
|
+
* present. The code is stripped from the derived body so it doesn't appear
|
|
16
|
+
* twice ("YAG-532: work on YAG-532 …").
|
|
17
|
+
*/
|
|
18
|
+
export declare function sessionTitle(prompt: string, body?: string): string | undefined;
|
|
19
|
+
/**
|
|
20
|
+
* Normalize a model-produced title into the same shape `sessionTitle` emits.
|
|
21
|
+
* Returns undefined when the model's output is empty, whitespace, or otherwise
|
|
22
|
+
* unusable — the caller then falls back to the heuristic.
|
|
23
|
+
*/
|
|
24
|
+
export declare function parseSummarizedTitle(prompt: string, raw: string): string | undefined;
|
|
25
|
+
/** The maximum chars `parseSummarizedTitle` may return (the same cap). */
|
|
26
|
+
export declare const SESSION_TITLE_MAX_LEN = 60;
|
|
27
|
+
//# sourceMappingURL=title.d.ts.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure session-title derivation — the reusable half of naming an agent session.
|
|
3
|
+
* No cmux, no workspace, no I/O: just "given a prompt, what's a good title".
|
|
4
|
+
*
|
|
5
|
+
* cmux is the current (and only) consumer, but this module is deliberately
|
|
6
|
+
* cmux-free so a future consumer (terminal title, a resume hint, the Feed) can
|
|
7
|
+
* reuse it without importing cmux vocabulary.
|
|
8
|
+
*/
|
|
9
|
+
/** The ticket-code shape ("YAG-532", "PROJ-123"), matching the backend's IDENTIFIER_RE. */
|
|
10
|
+
const TICKET_ID_RE = /\b[A-Z][A-Z0-9]*-\d+\b/i;
|
|
11
|
+
const MAX_TITLE_LEN = 60;
|
|
12
|
+
/** Extract a ticket code from a prompt, or null when none is present. */
|
|
13
|
+
export function ticketCode(prompt) {
|
|
14
|
+
return prompt.match(TICKET_ID_RE)?.[0]?.toUpperCase() ?? null;
|
|
15
|
+
}
|
|
16
|
+
/** The heuristic fallback title: the first 8 words, capped at 60 chars. */
|
|
17
|
+
export function titleFromPrompt(prompt) {
|
|
18
|
+
const words = prompt.trim().split(/\s+/).filter(Boolean);
|
|
19
|
+
if (words.length === 0)
|
|
20
|
+
return undefined;
|
|
21
|
+
return words.slice(0, 8).join(" ").slice(0, MAX_TITLE_LEN) || undefined;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Compose a session title from a prompt, prepending the ticket code when one is
|
|
25
|
+
* present. The code is stripped from the derived body so it doesn't appear
|
|
26
|
+
* twice ("YAG-532: work on YAG-532 …").
|
|
27
|
+
*/
|
|
28
|
+
export function sessionTitle(prompt, body) {
|
|
29
|
+
const code = ticketCode(prompt);
|
|
30
|
+
// Derive the body from the prompt with the ticket code removed (unless the
|
|
31
|
+
// caller supplied an already-summarized body, in which case prefer it).
|
|
32
|
+
const effectiveBody = body ?? titleFromPrompt(code ? prompt.replace(TICKET_ID_RE, "") : prompt);
|
|
33
|
+
if (!code)
|
|
34
|
+
return effectiveBody;
|
|
35
|
+
if (!effectiveBody)
|
|
36
|
+
return code;
|
|
37
|
+
return `${code}: ${effectiveBody}`.slice(0, MAX_TITLE_LEN);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Normalize a model-produced title into the same shape `sessionTitle` emits.
|
|
41
|
+
* Returns undefined when the model's output is empty, whitespace, or otherwise
|
|
42
|
+
* unusable — the caller then falls back to the heuristic.
|
|
43
|
+
*/
|
|
44
|
+
export function parseSummarizedTitle(prompt, raw) {
|
|
45
|
+
const cleaned = raw.trim().replace(/\s+/g, " ").replace(/^["']|["']$/g, "");
|
|
46
|
+
if (!cleaned)
|
|
47
|
+
return undefined;
|
|
48
|
+
// Strip any ticket-code token the model echoed back into its summary (the
|
|
49
|
+
// prompt carries the code, and models repeat it) so the composed title never
|
|
50
|
+
// doubles the prefix ("YAG-485: YAG-485 …"). Deterministic, not prompt hygiene.
|
|
51
|
+
const code = ticketCode(prompt);
|
|
52
|
+
const body = code ? cleaned.replace(TICKET_ID_RE, "").trim().replace(/\s+/g, " ") : cleaned;
|
|
53
|
+
return sessionTitle(prompt, body);
|
|
54
|
+
}
|
|
55
|
+
/** The maximum chars `parseSummarizedTitle` may return (the same cap). */
|
|
56
|
+
export const SESSION_TITLE_MAX_LEN = MAX_TITLE_LEN;
|
|
57
|
+
//# sourceMappingURL=title.js.map
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Silent-turn reminder — the harness fix for YAG-574.
|
|
3
|
+
*
|
|
4
|
+
* The failure behind that ticket was a termination failure, not a discovery
|
|
5
|
+
* one: the model had assembled the full answer in thinking, re-derived it 15
|
|
6
|
+
* times, and never converted "I have an answer" into "the turn ends." Nothing
|
|
7
|
+
* in the harness nudges it across that boundary when it goes quiet.
|
|
8
|
+
*
|
|
9
|
+
* This module is that nudge. It counts assistant turns and wall-clock time
|
|
10
|
+
* since the model last *spoke* (a non-empty visible text block, or a call to a
|
|
11
|
+
* user-facing tool), and when that silence passes a configurable threshold it
|
|
12
|
+
* appends a one-line reminder to the next tool result — the same
|
|
13
|
+
* result-modification seam the todos staleness reminder and ambient recall
|
|
14
|
+
* already ride, so it reaches the model mid-run without spending a turn.
|
|
15
|
+
*
|
|
16
|
+
* Structure mirrors Claude Code's `silent_turn_reminder` (the problem is real
|
|
17
|
+
* and already solved upstream): same trigger semantics, same single-fixed-
|
|
18
|
+
* sentence escalation, same "suppress on the turn after a user message" rule.
|
|
19
|
+
* Two deliberate deviations, both justified on the ticket:
|
|
20
|
+
* - trigger is wall-clock (90s default), not turn-count, because turn-count
|
|
21
|
+
* thresholds are tuned to a ~5s turn and ours drifted to ~34s;
|
|
22
|
+
* - no per-stretch nudge cap, because the cap is the exact thing that silences
|
|
23
|
+
* the mechanism during the silence it exists for.
|
|
24
|
+
*/
|
|
25
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
26
|
+
/**
|
|
27
|
+
* Default silence trigger, in milliseconds. Calibrated (see the ticket's
|
|
28
|
+
* trigger table) to nudge at roughly Claude Code's on-observed density while
|
|
29
|
+
* still covering the broken session's 25-minute dead zone.
|
|
30
|
+
*/
|
|
31
|
+
export declare const DEFAULT_SILENT_INTERVAL_MS = 90000;
|
|
32
|
+
/**
|
|
33
|
+
* Minimum assistant turns of silence before a reminder is eligible. Guards a
|
|
34
|
+
* single long tool call from counting as "the user is being ignored" when the
|
|
35
|
+
* model simply spent a while on one tool.
|
|
36
|
+
*/
|
|
37
|
+
export declare const MIN_SILENT_TURNS = 2;
|
|
38
|
+
/**
|
|
39
|
+
* Tools that count as "speaking to the user." Claude Code's whitelist maps to
|
|
40
|
+
* `AskUserQuestion`, `SendUserMessage`, `Brief`, `ExitPlanMode`,
|
|
41
|
+
* `SendUserFile`; ours are the three tools that surface something directly to
|
|
42
|
+
* the human this session serves. A call to one of these is a substantive
|
|
43
|
+
* utterance even when the assistant emitted no visible text block.
|
|
44
|
+
*/
|
|
45
|
+
export declare const USER_FACING_TOOLS: Set<string>;
|
|
46
|
+
export declare const SILENT_REMINDER_ENV = "YAGNI_SILENT_TURN_REMINDER";
|
|
47
|
+
export declare const SILENT_REMINDER_SECONDS_ENV = "YAGNI_SILENT_TURN_REMINDER_SECONDS";
|
|
48
|
+
/** Disable the whole mechanism ("0"/"off"), or resolve the trigger override. */
|
|
49
|
+
export declare function silentReminderDisabled(env?: NodeJS.ProcessEnv): boolean;
|
|
50
|
+
/** Resolve the trigger interval: env seconds override wins, else the default. */
|
|
51
|
+
export declare function resolveSilentIntervalMs(env?: NodeJS.ProcessEnv): number;
|
|
52
|
+
/** The minimal slice of an assistant message the reminder logic cares about. */
|
|
53
|
+
interface AssistantLike {
|
|
54
|
+
content?: Array<{
|
|
55
|
+
type?: string;
|
|
56
|
+
text?: string;
|
|
57
|
+
name?: string;
|
|
58
|
+
}>;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* PURE: whether the assistant emitted any non-empty, visible text block this
|
|
62
|
+
* turn. `thinking` blocks deliberately do NOT count — 525k chars of thinking
|
|
63
|
+
* is still silence to the user.
|
|
64
|
+
*/
|
|
65
|
+
export declare function hasSubstantiveText(message: AssistantLike): boolean;
|
|
66
|
+
/**
|
|
67
|
+
* PURE: whether the assistant "spoke" this turn — visible text, OR a call to a
|
|
68
|
+
* user-facing tool (which is itself an utterance to the user, even without a
|
|
69
|
+
* text block).
|
|
70
|
+
*/
|
|
71
|
+
export declare function didSpeak(message: AssistantLike): boolean;
|
|
72
|
+
/**
|
|
73
|
+
* PURE: is a reminder due? The turn count must pass {@link MIN_SILENT_TURNS},
|
|
74
|
+
* the wall-clock silence must pass the configured interval, and the spacing
|
|
75
|
+
* between nudges must pass the same interval. `lastNudgeAt` is `null` before
|
|
76
|
+
* any nudge has fired (still eligible once the other gates pass).
|
|
77
|
+
*/
|
|
78
|
+
export declare function shouldRemind(input: {
|
|
79
|
+
msSinceSpoke: number;
|
|
80
|
+
turnsSinceSpoke: number;
|
|
81
|
+
msSinceLastNudge: number | null;
|
|
82
|
+
intervalMs: number;
|
|
83
|
+
}): boolean;
|
|
84
|
+
/**
|
|
85
|
+
* The injected payload. Claude Code's wording plus one clause for the failure
|
|
86
|
+
* we actually saw (the model holding an assembled answer and never giving it).
|
|
87
|
+
* Wrapped in `<system-reminder>` tags — the framing branding.ts teaches the
|
|
88
|
+
* model to parse as system-injected, not part of the tool output.
|
|
89
|
+
*/
|
|
90
|
+
export declare function formatSilentReminder(): string;
|
|
91
|
+
export interface RegisterSilentTurnReminderDeps {
|
|
92
|
+
/** Only the interactive driver is nudged; children/subagents/advisor/no-op. */
|
|
93
|
+
isDriver?: boolean;
|
|
94
|
+
/** Eval mode (headless evals, scoping sessions) never nudges. */
|
|
95
|
+
evalMode?: boolean;
|
|
96
|
+
/** Env seam (defaults to process.env). */
|
|
97
|
+
env?: NodeJS.ProcessEnv;
|
|
98
|
+
/** Clock seam (defaults to Date.now). */
|
|
99
|
+
now?: () => number;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Wire the silent-turn reminder. No handlers are registered for non-driver
|
|
103
|
+
* processes, eval mode, or when disabled — so children, subagents, advisor
|
|
104
|
+
* consults, and headless evals are untouched, and the kill switch is a true
|
|
105
|
+
* no-op.
|
|
106
|
+
*/
|
|
107
|
+
export declare function registerSilentTurnReminder(pi: ExtensionAPI, deps?: RegisterSilentTurnReminderDeps): void;
|
|
108
|
+
export {};
|
|
109
|
+
//# sourceMappingURL=silentTurnReminder.d.ts.map
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Silent-turn reminder — the harness fix for YAG-574.
|
|
3
|
+
*
|
|
4
|
+
* The failure behind that ticket was a termination failure, not a discovery
|
|
5
|
+
* one: the model had assembled the full answer in thinking, re-derived it 15
|
|
6
|
+
* times, and never converted "I have an answer" into "the turn ends." Nothing
|
|
7
|
+
* in the harness nudges it across that boundary when it goes quiet.
|
|
8
|
+
*
|
|
9
|
+
* This module is that nudge. It counts assistant turns and wall-clock time
|
|
10
|
+
* since the model last *spoke* (a non-empty visible text block, or a call to a
|
|
11
|
+
* user-facing tool), and when that silence passes a configurable threshold it
|
|
12
|
+
* appends a one-line reminder to the next tool result — the same
|
|
13
|
+
* result-modification seam the todos staleness reminder and ambient recall
|
|
14
|
+
* already ride, so it reaches the model mid-run without spending a turn.
|
|
15
|
+
*
|
|
16
|
+
* Structure mirrors Claude Code's `silent_turn_reminder` (the problem is real
|
|
17
|
+
* and already solved upstream): same trigger semantics, same single-fixed-
|
|
18
|
+
* sentence escalation, same "suppress on the turn after a user message" rule.
|
|
19
|
+
* Two deliberate deviations, both justified on the ticket:
|
|
20
|
+
* - trigger is wall-clock (90s default), not turn-count, because turn-count
|
|
21
|
+
* thresholds are tuned to a ~5s turn and ours drifted to ~34s;
|
|
22
|
+
* - no per-stretch nudge cap, because the cap is the exact thing that silences
|
|
23
|
+
* the mechanism during the silence it exists for.
|
|
24
|
+
*/
|
|
25
|
+
import { logTurnLifecycle } from "./turnLog.js";
|
|
26
|
+
/**
|
|
27
|
+
* Default silence trigger, in milliseconds. Calibrated (see the ticket's
|
|
28
|
+
* trigger table) to nudge at roughly Claude Code's on-observed density while
|
|
29
|
+
* still covering the broken session's 25-minute dead zone.
|
|
30
|
+
*/
|
|
31
|
+
export const DEFAULT_SILENT_INTERVAL_MS = 90_000;
|
|
32
|
+
/**
|
|
33
|
+
* Minimum assistant turns of silence before a reminder is eligible. Guards a
|
|
34
|
+
* single long tool call from counting as "the user is being ignored" when the
|
|
35
|
+
* model simply spent a while on one tool.
|
|
36
|
+
*/
|
|
37
|
+
export const MIN_SILENT_TURNS = 2;
|
|
38
|
+
/**
|
|
39
|
+
* Tools that count as "speaking to the user." Claude Code's whitelist maps to
|
|
40
|
+
* `AskUserQuestion`, `SendUserMessage`, `Brief`, `ExitPlanMode`,
|
|
41
|
+
* `SendUserFile`; ours are the three tools that surface something directly to
|
|
42
|
+
* the human this session serves. A call to one of these is a substantive
|
|
43
|
+
* utterance even when the assistant emitted no visible text block.
|
|
44
|
+
*/
|
|
45
|
+
export const USER_FACING_TOOLS = new Set([
|
|
46
|
+
"todo_write",
|
|
47
|
+
"file_ticket",
|
|
48
|
+
"update_ticket_status",
|
|
49
|
+
]);
|
|
50
|
+
export const SILENT_REMINDER_ENV = "YAGNI_SILENT_TURN_REMINDER";
|
|
51
|
+
export const SILENT_REMINDER_SECONDS_ENV = "YAGNI_SILENT_TURN_REMINDER_SECONDS";
|
|
52
|
+
/** Disable the whole mechanism ("0"/"off"), or resolve the trigger override. */
|
|
53
|
+
export function silentReminderDisabled(env = process.env) {
|
|
54
|
+
const v = env[SILENT_REMINDER_ENV];
|
|
55
|
+
return v === "0" || v?.toLowerCase() === "off";
|
|
56
|
+
}
|
|
57
|
+
/** Resolve the trigger interval: env seconds override wins, else the default. */
|
|
58
|
+
export function resolveSilentIntervalMs(env = process.env) {
|
|
59
|
+
const raw = env[SILENT_REMINDER_SECONDS_ENV];
|
|
60
|
+
if (raw === undefined || raw.trim() === "")
|
|
61
|
+
return DEFAULT_SILENT_INTERVAL_MS;
|
|
62
|
+
const seconds = Number(raw);
|
|
63
|
+
if (!Number.isFinite(seconds) || seconds <= 0)
|
|
64
|
+
return DEFAULT_SILENT_INTERVAL_MS;
|
|
65
|
+
return Math.round(seconds * 1000);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* PURE: whether the assistant emitted any non-empty, visible text block this
|
|
69
|
+
* turn. `thinking` blocks deliberately do NOT count — 525k chars of thinking
|
|
70
|
+
* is still silence to the user.
|
|
71
|
+
*/
|
|
72
|
+
export function hasSubstantiveText(message) {
|
|
73
|
+
const content = message.content ?? [];
|
|
74
|
+
return content.some((c) => c.type === "text" && typeof c.text === "string" && c.text.trim().length > 0);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* PURE: whether the assistant "spoke" this turn — visible text, OR a call to a
|
|
78
|
+
* user-facing tool (which is itself an utterance to the user, even without a
|
|
79
|
+
* text block).
|
|
80
|
+
*/
|
|
81
|
+
export function didSpeak(message) {
|
|
82
|
+
const content = message.content ?? [];
|
|
83
|
+
for (const c of content) {
|
|
84
|
+
if (c.type === "text" && typeof c.text === "string" && c.text.trim().length > 0) {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
if (c.type === "toolCall" && typeof c.name === "string" && USER_FACING_TOOLS.has(c.name)) {
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* PURE: is a reminder due? The turn count must pass {@link MIN_SILENT_TURNS},
|
|
95
|
+
* the wall-clock silence must pass the configured interval, and the spacing
|
|
96
|
+
* between nudges must pass the same interval. `lastNudgeAt` is `null` before
|
|
97
|
+
* any nudge has fired (still eligible once the other gates pass).
|
|
98
|
+
*/
|
|
99
|
+
export function shouldRemind(input) {
|
|
100
|
+
const { msSinceSpoke, turnsSinceSpoke, msSinceLastNudge, intervalMs } = input;
|
|
101
|
+
if (turnsSinceSpoke < MIN_SILENT_TURNS)
|
|
102
|
+
return false;
|
|
103
|
+
if (msSinceSpoke < intervalMs)
|
|
104
|
+
return false;
|
|
105
|
+
return msSinceLastNudge === null || msSinceLastNudge >= intervalMs;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The injected payload. Claude Code's wording plus one clause for the failure
|
|
109
|
+
* we actually saw (the model holding an assembled answer and never giving it).
|
|
110
|
+
* Wrapped in `<system-reminder>` tags — the framing branding.ts teaches the
|
|
111
|
+
* model to parse as system-injected, not part of the tool output.
|
|
112
|
+
*/
|
|
113
|
+
export function formatSilentReminder() {
|
|
114
|
+
return ("<system-reminder>\n" +
|
|
115
|
+
"The user hasn't heard from you in a while. As you continue, keep them updated " +
|
|
116
|
+
"when there's something to tell — a finding, a change of plan. If you already " +
|
|
117
|
+
"have an answer, give it now with your confidence and what's still open.\n" +
|
|
118
|
+
"</system-reminder>");
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Wire the silent-turn reminder. No handlers are registered for non-driver
|
|
122
|
+
* processes, eval mode, or when disabled — so children, subagents, advisor
|
|
123
|
+
* consults, and headless evals are untouched, and the kill switch is a true
|
|
124
|
+
* no-op.
|
|
125
|
+
*/
|
|
126
|
+
export function registerSilentTurnReminder(pi, deps = {}) {
|
|
127
|
+
const env = deps.env ?? process.env;
|
|
128
|
+
if (deps.evalMode)
|
|
129
|
+
return;
|
|
130
|
+
if (deps.isDriver === false)
|
|
131
|
+
return;
|
|
132
|
+
if (silentReminderDisabled(env))
|
|
133
|
+
return;
|
|
134
|
+
const now = deps.now ?? (() => Date.now());
|
|
135
|
+
const intervalMs = resolveSilentIntervalMs(env);
|
|
136
|
+
let lastSpokeAt = now();
|
|
137
|
+
let turnsSinceSpoke = 0;
|
|
138
|
+
let nudgeCount = 0; // per-stretch ordinal, reset when the silence clock resets
|
|
139
|
+
let lastNudgeAt = null;
|
|
140
|
+
const reset = (at) => {
|
|
141
|
+
lastSpokeAt = at;
|
|
142
|
+
turnsSinceSpoke = 0;
|
|
143
|
+
nudgeCount = 0;
|
|
144
|
+
};
|
|
145
|
+
// One tick per finalized assistant message: did it speak (reset the clock),
|
|
146
|
+
// or not (extend the silence)? The observability counters are logged here so
|
|
147
|
+
// the recorded values are identical to the ones that drive the decision.
|
|
148
|
+
pi.on("message_end", async (event) => {
|
|
149
|
+
try {
|
|
150
|
+
const msg = event.message;
|
|
151
|
+
if (msg?.role !== "assistant")
|
|
152
|
+
return;
|
|
153
|
+
const spoke = didSpeak(msg);
|
|
154
|
+
if (spoke) {
|
|
155
|
+
reset(now());
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
turnsSinceSpoke += 1;
|
|
159
|
+
}
|
|
160
|
+
logTurnLifecycle({
|
|
161
|
+
kind: "silent_turn",
|
|
162
|
+
secondsSinceSpoke: Math.round((now() - lastSpokeAt) / 1000),
|
|
163
|
+
turnsSinceSpoke,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// A reminder must never break a turn.
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
// Any user message resets the silence clock — the "suppressed on the turn
|
|
171
|
+
// right after a user message" rule, implemented as a clock reset.
|
|
172
|
+
pi.on("input", async () => {
|
|
173
|
+
try {
|
|
174
|
+
reset(now());
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// Never break input handling.
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
// The nudge rides the `context` event, which pi fires before each provider
|
|
181
|
+
// request (transformContext) on a throwaway copy of the message array. That
|
|
182
|
+
// makes it reach the model as the most recent message — and, unlike the old
|
|
183
|
+
// `tool_result` seam, it is never rendered to the user and never persisted
|
|
184
|
+
// to the session file. Appending (not replacing) preserves prior `context`
|
|
185
|
+
// handlers (e.g. gate.ts's mode-context filter), and fail-soft means a
|
|
186
|
+
// reminder must never break a turn.
|
|
187
|
+
pi.on("context", async (event) => {
|
|
188
|
+
try {
|
|
189
|
+
const messages = Array.isArray(event.messages) ? event.messages : [];
|
|
190
|
+
if (!shouldRemind({
|
|
191
|
+
msSinceSpoke: now() - lastSpokeAt,
|
|
192
|
+
turnsSinceSpoke,
|
|
193
|
+
msSinceLastNudge: lastNudgeAt === null ? null : now() - lastNudgeAt,
|
|
194
|
+
intervalMs,
|
|
195
|
+
})) {
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
nudgeCount += 1;
|
|
199
|
+
lastNudgeAt = now();
|
|
200
|
+
logTurnLifecycle({
|
|
201
|
+
kind: "silent_turn_nudge",
|
|
202
|
+
nudgesInStretch: nudgeCount,
|
|
203
|
+
secondsSinceSpoke: Math.round((now() - lastSpokeAt) / 1000),
|
|
204
|
+
});
|
|
205
|
+
return {
|
|
206
|
+
messages: [
|
|
207
|
+
...messages,
|
|
208
|
+
{
|
|
209
|
+
role: "user",
|
|
210
|
+
content: [{ type: "text", text: formatSilentReminder() }],
|
|
211
|
+
timestamp: now(),
|
|
212
|
+
},
|
|
213
|
+
],
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
//# sourceMappingURL=silentTurnReminder.js.map
|
|
@@ -26,6 +26,20 @@ export type TurnLifecycleEvent = {
|
|
|
26
26
|
stopReason?: string;
|
|
27
27
|
/** Time since the matching turn_start, ms. */
|
|
28
28
|
elapsedMs?: number;
|
|
29
|
+
} | {
|
|
30
|
+
kind: "silent_turn";
|
|
31
|
+
sessionId?: string;
|
|
32
|
+
/** Whole seconds since the model last spoke (visible text or a user-facing tool). */
|
|
33
|
+
secondsSinceSpoke?: number;
|
|
34
|
+
/** Assistant turns since the model last spoke. */
|
|
35
|
+
turnsSinceSpoke?: number;
|
|
36
|
+
} | {
|
|
37
|
+
kind: "silent_turn_nudge";
|
|
38
|
+
sessionId?: string;
|
|
39
|
+
/** 1-indexed fire count within the current silence stretch. */
|
|
40
|
+
nudgesInStretch?: number;
|
|
41
|
+
/** Whole seconds of silence at the moment the nudge fired. */
|
|
42
|
+
secondsSinceSpoke?: number;
|
|
29
43
|
};
|
|
30
44
|
/**
|
|
31
45
|
* Append one sanitized lifecycle record. Fail-soft — a logging failure must
|