@yagni-app/code 0.3.5 → 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/cmux/state.js +9 -16
- 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 +91 -40
- package/dist/extension/mineBeat.js +13 -0
- package/dist/extension/pipeline/goCommand.js +2 -0
- package/dist/extension/pipeline/runner.js +9 -0
- package/dist/extension/silentTurnReminder.js +18 -14
- package/dist/extension/turnLog.js +17 -46
- 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 +3 -2
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
import { spawn } from "node:child_process";
|
|
28
28
|
import { scrubSecrets } from "./pipeline/scrubSecrets.js";
|
|
29
29
|
import { isDesktopSurface } from "./surface.js";
|
|
30
|
+
import { logEvent } from "./errorSink.js";
|
|
30
31
|
const defaultSpawn = (command, args, options) => spawn(command, args, options);
|
|
31
32
|
export const CRASH_REPORT_DISABLE_ENV = "YAGNI_DISABLE_CRASH_REPORTS";
|
|
32
33
|
export const CRASH_REPORT_TIMEOUT_MS = 1_500;
|
|
@@ -267,6 +268,17 @@ export function reportFatalCrash(error, opts, context) {
|
|
|
267
268
|
export function installUncaughtExceptionMonitor(opts, proc = process) {
|
|
268
269
|
proc.on("uncaughtExceptionMonitor", (err) => {
|
|
269
270
|
reportFatalCrash(err, opts, "uncaught-exception");
|
|
271
|
+
// Also seed the local error trail (best-effort): the crash report is a
|
|
272
|
+
// sanitized POST, but the ON-DISK trail is what /feedback binds for a
|
|
273
|
+
// report someone files next session.
|
|
274
|
+
logEvent({
|
|
275
|
+
source: "tool",
|
|
276
|
+
level: "error",
|
|
277
|
+
event: "uncaught_exception",
|
|
278
|
+
sessionId: process.env.YAGNI_SESSION_ID ?? undefined,
|
|
279
|
+
flush: "sync",
|
|
280
|
+
fields: { errorClass: err instanceof Error ? err.name || "Error" : "Error" },
|
|
281
|
+
});
|
|
270
282
|
});
|
|
271
283
|
}
|
|
272
284
|
//# sourceMappingURL=crashReport.js.map
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* never affects the tool call that triggered it.
|
|
18
18
|
*/
|
|
19
19
|
import { bankDecision } from "./decisions.js";
|
|
20
|
+
import { logEvent } from "./errorSink.js";
|
|
20
21
|
/** At most one capture prompt per this window (spec: 10 minutes). */
|
|
21
22
|
export const CAPTURE_DEBOUNCE_MS = 10 * 60 * 1000;
|
|
22
23
|
/** Build a session-scoped decision capture (holds the debounce timestamp). */
|
|
@@ -54,10 +55,12 @@ export function makeDecisionCapture(deps) {
|
|
|
54
55
|
ctx.ui.notify("Saved the decision locally; it will sync automatically.", "info");
|
|
55
56
|
}
|
|
56
57
|
else {
|
|
58
|
+
logEvent({ source: "decision-capture", level: "error", event: "capture_failed", fields: { kind: outcome.kind } });
|
|
57
59
|
ctx.ui.notify(outcome.message, "error");
|
|
58
60
|
}
|
|
59
61
|
}
|
|
60
62
|
catch {
|
|
63
|
+
logEvent({ source: "decision-capture", level: "error", event: "capture_failed", fields: { kind: "threw" } });
|
|
61
64
|
/* fail-soft: a capture failure never affects the tool call */
|
|
62
65
|
}
|
|
63
66
|
},
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import { randomUUID } from "node:crypto";
|
|
21
21
|
import { friendlyFetchError, METERED_POST_FETCH_POLICY, resilientFetch } from "./resilientFetch.js";
|
|
22
22
|
import { sendOrSpool } from "./spool.js";
|
|
23
|
+
import { logEvent } from "./errorSink.js";
|
|
23
24
|
/**
|
|
24
25
|
* Bank a `cli_correction` decision durably. Every logical write carries a
|
|
25
26
|
* generated idempotencyKey; the backend dedups on it, so a spool replay or a
|
|
@@ -151,6 +152,7 @@ export function registerDecisionCommands(pi, opts) {
|
|
|
151
152
|
notify(notice.message, notice.type);
|
|
152
153
|
}
|
|
153
154
|
catch (err) {
|
|
155
|
+
logEvent({ source: "decisions", level: "error", event: "decide_failed", fields: { op: "decide" } });
|
|
154
156
|
notify(`Could not record the decision: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
155
157
|
}
|
|
156
158
|
},
|
|
@@ -183,6 +185,7 @@ export function registerDecisionCommands(pi, opts) {
|
|
|
183
185
|
notify(`Superseded decision ${shortId(id)}.`, "info");
|
|
184
186
|
}
|
|
185
187
|
catch (err) {
|
|
188
|
+
logEvent({ source: "decisions", level: "error", event: "supersede_failed", fields: { op: "supersede" } });
|
|
186
189
|
notify(`Could not supersede decision: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
187
190
|
}
|
|
188
191
|
return;
|
|
@@ -192,6 +195,7 @@ export function registerDecisionCommands(pi, opts) {
|
|
|
192
195
|
await pi.sendUserMessage(formatDecisionsList(items));
|
|
193
196
|
}
|
|
194
197
|
catch (err) {
|
|
198
|
+
logEvent({ source: "decisions", level: "error", event: "list_failed", fields: { op: "list" } });
|
|
195
199
|
notify(`Could not list decisions: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
196
200
|
}
|
|
197
201
|
},
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
* A support flow can tail this file and POST it with the user's consent;
|
|
13
13
|
* nothing is uploaded automatically.
|
|
14
14
|
*/
|
|
15
|
+
import { readSessionTrail } from "./errorSink.js";
|
|
15
16
|
export declare function _setDiagnosticsHomeForTest(dir: string | null): void;
|
|
17
|
+
/** Active unified-sink path (one rotating per-day JSONL for all sources). */
|
|
16
18
|
export declare function diagnosticsLogPath(): string;
|
|
17
19
|
/** Layer A: verbose mode. `YAGNI_DEBUG=1` (or "true") turns on extra detail. */
|
|
18
20
|
export declare function isDebug(env?: NodeJS.ProcessEnv): boolean;
|
|
@@ -34,8 +36,37 @@ export interface ImagePasteEvent {
|
|
|
34
36
|
* prompt. `detail` is included only when YAGNI_DEBUG is on.
|
|
35
37
|
*/
|
|
36
38
|
export declare function logImagePaste(ev: ImagePasteEvent): void;
|
|
39
|
+
export interface AskQuestionEvent {
|
|
40
|
+
/** Stable event name: "key" | "finish" | "render" | "navigate" | "other_key". */
|
|
41
|
+
event: string;
|
|
42
|
+
/** Human-readable description; NEVER user-typed text or question content. */
|
|
43
|
+
detail?: string;
|
|
44
|
+
selectedIndex?: number;
|
|
45
|
+
otherIndex?: number;
|
|
46
|
+
otherLen?: number;
|
|
47
|
+
/** Raw key bytes, escaped (only for debug). */
|
|
48
|
+
key?: string;
|
|
49
|
+
/** Resolution status when the tool finishes: selected | other | cancelled. */
|
|
50
|
+
status?: string;
|
|
51
|
+
/** Whether the question is multiSelect at the time of the event. */
|
|
52
|
+
multi?: boolean;
|
|
53
|
+
/** 0-based index of the question within this tool call (multi-question runs). */
|
|
54
|
+
qIndex?: number;
|
|
55
|
+
/** Whether the "Other" row auto-check is on (multi-select). */
|
|
56
|
+
checked?: boolean;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Append one sanitized ask-user-question event to the unified sink
|
|
60
|
+
* (source:"ask-question").
|
|
61
|
+
* Fail-soft and hermetically gated under `node --test` exactly like
|
|
62
|
+
* `logImagePaste`. Never logs user-typed text, question text, or option labels
|
|
63
|
+
* — only indices, lengths, key bytes (escaped), and resolution status.
|
|
64
|
+
*/
|
|
65
|
+
export declare function logAskQuestion(ev: AskQuestionEvent): void;
|
|
37
66
|
/** Read the most recent log content (for a user-triggered report). */
|
|
38
67
|
export declare function readRecentDiagnostics(maxBytes?: number): string;
|
|
39
68
|
/** List existing diagnostic log files (active + rotations), for a report. */
|
|
40
69
|
export declare function listDiagnosticFiles(): string[];
|
|
70
|
+
/** Re-export the session-scoped trail reader for the /feedback flow. */
|
|
71
|
+
export { readSessionTrail };
|
|
41
72
|
//# sourceMappingURL=diagnostics.d.ts.map
|
|
@@ -12,22 +12,19 @@
|
|
|
12
12
|
* A support flow can tail this file and POST it with the user's consent;
|
|
13
13
|
* nothing is uploaded automatically.
|
|
14
14
|
*/
|
|
15
|
-
import {
|
|
15
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
16
16
|
import { dirname, join, basename } from "node:path";
|
|
17
|
-
import {
|
|
17
|
+
import { _setErrorSinkHomeForTest, logEvent, errorSinkPath, readSessionTrail, } from "./errorSink.js";
|
|
18
18
|
/** Test seam (mirrors `_setYagniCodeHomeForTest`): point the log at a tmpdir. */
|
|
19
19
|
let homeOverride = null;
|
|
20
20
|
export function _setDiagnosticsHomeForTest(dir) {
|
|
21
21
|
homeOverride = dir;
|
|
22
|
+
_setErrorSinkHomeForTest(dir);
|
|
22
23
|
}
|
|
23
|
-
|
|
24
|
-
return codeStateHome(homeOverride);
|
|
25
|
-
}
|
|
24
|
+
/** Active unified-sink path (one rotating per-day JSONL for all sources). */
|
|
26
25
|
export function diagnosticsLogPath() {
|
|
27
|
-
return
|
|
26
|
+
return errorSinkPath();
|
|
28
27
|
}
|
|
29
|
-
const MAX_LOG_BYTES = 256 * 1024; // rotate the active file past this
|
|
30
|
-
const KEEP_ROTATIONS = 2; // keep image-paste.log.1 and .2 alongside the active file
|
|
31
28
|
/** Layer A: verbose mode. `YAGNI_DEBUG=1` (or "true") turns on extra detail. */
|
|
32
29
|
export function isDebug(env = process.env) {
|
|
33
30
|
const v = env.YAGNI_DEBUG;
|
|
@@ -38,56 +35,55 @@ export function isDebug(env = process.env) {
|
|
|
38
35
|
* prompt. `detail` is included only when YAGNI_DEBUG is on.
|
|
39
36
|
*/
|
|
40
37
|
export function logImagePaste(ev) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
};
|
|
58
|
-
const path = diagnosticsLogPath();
|
|
59
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
60
|
-
rotateIfNeeded(path);
|
|
61
|
-
appendFileSync(path, JSON.stringify(line) + "\n", "utf8");
|
|
62
|
-
}
|
|
63
|
-
catch {
|
|
64
|
-
/* logging must never throw into the editor */
|
|
65
|
-
}
|
|
38
|
+
// The unified sink: content-free fields are always-on, the debug-only
|
|
39
|
+
// `detail` rides level:debug (never bound by /feedback).
|
|
40
|
+
const fields = {
|
|
41
|
+
...(ev.outcome !== undefined ? { outcome: ev.outcome } : {}),
|
|
42
|
+
...(ev.mimeType !== undefined ? { mimeType: ev.mimeType } : {}),
|
|
43
|
+
...(ev.bytes !== undefined ? { bytes: ev.bytes } : {}),
|
|
44
|
+
...(ev.imageCount !== undefined ? { imageCount: ev.imageCount } : {}),
|
|
45
|
+
...(ev.file !== undefined ? { file: basename(ev.file) } : {}),
|
|
46
|
+
...(ev.detail !== undefined && isDebug() ? { detail: ev.detail } : {}),
|
|
47
|
+
};
|
|
48
|
+
logEvent({
|
|
49
|
+
source: "image-paste",
|
|
50
|
+
level: ev.detail !== undefined && isDebug() ? "debug" : "info",
|
|
51
|
+
event: ev.event,
|
|
52
|
+
fields,
|
|
53
|
+
});
|
|
66
54
|
}
|
|
67
|
-
/**
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Append one sanitized ask-user-question event to the unified sink
|
|
57
|
+
* (source:"ask-question").
|
|
58
|
+
* Fail-soft and hermetically gated under `node --test` exactly like
|
|
59
|
+
* `logImagePaste`. Never logs user-typed text, question text, or option labels
|
|
60
|
+
* — only indices, lengths, key bytes (escaped), and resolution status.
|
|
61
|
+
*/
|
|
62
|
+
export function logAskQuestion(ev) {
|
|
63
|
+
// Raw key bytes (`key`) and `detail` are content-ish: gate them behind DEBUG.
|
|
64
|
+
const debug = isDebug();
|
|
65
|
+
const fields = {
|
|
66
|
+
...(ev.selectedIndex !== undefined ? { selectedIndex: ev.selectedIndex } : {}),
|
|
67
|
+
...(ev.otherIndex !== undefined ? { otherIndex: ev.otherIndex } : {}),
|
|
68
|
+
...(ev.otherLen !== undefined ? { otherLen: ev.otherLen } : {}),
|
|
69
|
+
...(ev.status !== undefined ? { status: ev.status } : {}),
|
|
70
|
+
...(ev.multi !== undefined ? { multi: ev.multi } : {}),
|
|
71
|
+
...(ev.qIndex !== undefined ? { qIndex: ev.qIndex } : {}),
|
|
72
|
+
...(ev.checked !== undefined ? { checked: ev.checked } : {}),
|
|
73
|
+
...(ev.key !== undefined && debug ? { key: ev.key } : {}),
|
|
74
|
+
...(ev.detail !== undefined && debug ? { detail: ev.detail } : {}),
|
|
75
|
+
};
|
|
76
|
+
logEvent({
|
|
77
|
+
source: "ask-question",
|
|
78
|
+
level: debug ? "debug" : "info",
|
|
79
|
+
event: ev.event,
|
|
80
|
+
fields,
|
|
81
|
+
});
|
|
86
82
|
}
|
|
87
83
|
/** Read the most recent log content (for a user-triggered report). */
|
|
88
84
|
export function readRecentDiagnostics(maxBytes = 64 * 1024) {
|
|
89
85
|
try {
|
|
90
|
-
const data = readFileSync(
|
|
86
|
+
const data = readFileSync(errorSinkPath(), "utf8");
|
|
91
87
|
return data.length > maxBytes ? data.slice(data.length - maxBytes) : data;
|
|
92
88
|
}
|
|
93
89
|
catch {
|
|
@@ -97,9 +93,9 @@ export function readRecentDiagnostics(maxBytes = 64 * 1024) {
|
|
|
97
93
|
/** List existing diagnostic log files (active + rotations), for a report. */
|
|
98
94
|
export function listDiagnosticFiles() {
|
|
99
95
|
try {
|
|
100
|
-
const dir = dirname(
|
|
96
|
+
const dir = dirname(errorSinkPath());
|
|
101
97
|
return readdirSync(dir)
|
|
102
|
-
.filter((f) => f.startsWith("
|
|
98
|
+
.filter((f) => f.startsWith("errors-"))
|
|
103
99
|
.sort()
|
|
104
100
|
.map((f) => join(dir, f));
|
|
105
101
|
}
|
|
@@ -107,4 +103,6 @@ export function listDiagnosticFiles() {
|
|
|
107
103
|
return [];
|
|
108
104
|
}
|
|
109
105
|
}
|
|
106
|
+
/** Re-export the session-scoped trail reader for the /feedback flow. */
|
|
107
|
+
export { readSessionTrail };
|
|
110
108
|
//# sourceMappingURL=diagnostics.js.map
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The unified local error/log sink for YAGNI Code (YAG-580).
|
|
3
|
+
*
|
|
4
|
+
* Replaces the previous ~9 hand-rolled JSONL writers (image-paste.log,
|
|
5
|
+
* ask-question.log, turn-lifecycle.log, guardian.log, cost-divergence.log,
|
|
6
|
+
* auth-events.log, cmux-bridge.log, hooks.log, *.stream.log) with ONE sink.
|
|
7
|
+
*
|
|
8
|
+
* Two storage layers, purpose-named so their roles stay clear:
|
|
9
|
+
*
|
|
10
|
+
* 1. The DURABLE TRAIL — one rotating per-day JSONL under
|
|
11
|
+
* `~/.yagni-code/logs/errors-<date>.jsonl` (size-capped + 2 rotations).
|
|
12
|
+
* This is the crash-survivable WAL: `turn_start` without a matching
|
|
13
|
+
* `turn_end` still leaves a record even if the process is killed. Critical
|
|
14
|
+
* events append SYNCHRONOUSLY for exactly that reason.
|
|
15
|
+
*
|
|
16
|
+
* 2. The IN-MEMORY CAPTURE — a byte-budgeted ring buffer (not line-counted).
|
|
17
|
+
* This is the `/feedback` binding convenience, NOT durability (an in-memory
|
|
18
|
+
* ring does not survive a crash). Mirrors Codex's CodexFeedback ring and
|
|
19
|
+
* Claude's inMemoryErrorLog.
|
|
20
|
+
*
|
|
21
|
+
* Every line carries a REQUIRED `sessionId` and a `source`/`level`/`event`
|
|
22
|
+
* triple so a shared file stays filterable: `jq 'select(.source=="guardian")'`
|
|
23
|
+
* reproduces today's per-file tail exactly, and /feedback reads the trail
|
|
24
|
+
* filtered by sessionId (never the raw file) so one session's report never
|
|
25
|
+
* leaks another session's errors.
|
|
26
|
+
*
|
|
27
|
+
* Default-on vs DEBUG invariant (the thing that makes "log everything by
|
|
28
|
+
* default" safe): default-on == scrub-safe == upload-safe. Any field carrying
|
|
29
|
+
* raw content (tool arguments, partial/result bodies, provider payloads, raw
|
|
30
|
+
* key bytes) must be gated behind YAGNI_DEBUG, and the /feedback reader refuses
|
|
31
|
+
* to bind any `level: debug` line. DEBUG == may-contain-content == never-uploads.
|
|
32
|
+
*/
|
|
33
|
+
export type SinkLevel = "error" | "warn" | "info" | "debug";
|
|
34
|
+
export interface SinkEvent {
|
|
35
|
+
/** Former filename / subsystem: tool, turn, guardian, image-paste, ask-question, cost, auth, cmux, hooks. */
|
|
36
|
+
source: string;
|
|
37
|
+
level: SinkLevel;
|
|
38
|
+
/** Stable machine name (e.g. "turn_start", "bash.exit_1", "denied"). */
|
|
39
|
+
event: string;
|
|
40
|
+
/** Additional structured fields. NEVER raw content on a non-debug line. */
|
|
41
|
+
fields?: Record<string, unknown>;
|
|
42
|
+
/** Session id so the trail is filterable and scoped per feedback. Defaults to YAGNI_SESSION_ID. */
|
|
43
|
+
sessionId?: string;
|
|
44
|
+
/** "sync" flushes immediately (critical events); "buffered" is fine for high-volume debug. */
|
|
45
|
+
flush?: "sync" | "buffered";
|
|
46
|
+
}
|
|
47
|
+
export declare function _setErrorSinkHomeForTest(dir: string | null): void;
|
|
48
|
+
export declare function errorSinkPath(now?: Date): string;
|
|
49
|
+
export declare function _clearErrorSinkRingForTest(): void;
|
|
50
|
+
export declare function errorSinkInMemory(): string;
|
|
51
|
+
/**
|
|
52
|
+
* Append one event to both the ring and the durable trail. Fail-soft: a logging
|
|
53
|
+
* failure must never break the session. `flush: "sync"` (default for
|
|
54
|
+
* error-level events and lifecycle turns) bypasses any future buffering so a
|
|
55
|
+
* turn that starts but never ends still leaves a durable `turn_start`.
|
|
56
|
+
*/
|
|
57
|
+
export declare function logEvent(ev: SinkEvent): void;
|
|
58
|
+
/**
|
|
59
|
+
* Read recent trail lines for ONE session, filtered by `sessionId`, up to
|
|
60
|
+
* `maxBytes`. Never returns `level: debug` lines — the default-on tier is the
|
|
61
|
+
* upload-safe tier, and DEBUG may contain content that must not leave the machine.
|
|
62
|
+
*/
|
|
63
|
+
export declare function readSessionTrail(sessionId: string, maxBytes?: number): string;
|
|
64
|
+
//# sourceMappingURL=errorSink.d.ts.map
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The unified local error/log sink for YAGNI Code (YAG-580).
|
|
3
|
+
*
|
|
4
|
+
* Replaces the previous ~9 hand-rolled JSONL writers (image-paste.log,
|
|
5
|
+
* ask-question.log, turn-lifecycle.log, guardian.log, cost-divergence.log,
|
|
6
|
+
* auth-events.log, cmux-bridge.log, hooks.log, *.stream.log) with ONE sink.
|
|
7
|
+
*
|
|
8
|
+
* Two storage layers, purpose-named so their roles stay clear:
|
|
9
|
+
*
|
|
10
|
+
* 1. The DURABLE TRAIL — one rotating per-day JSONL under
|
|
11
|
+
* `~/.yagni-code/logs/errors-<date>.jsonl` (size-capped + 2 rotations).
|
|
12
|
+
* This is the crash-survivable WAL: `turn_start` without a matching
|
|
13
|
+
* `turn_end` still leaves a record even if the process is killed. Critical
|
|
14
|
+
* events append SYNCHRONOUSLY for exactly that reason.
|
|
15
|
+
*
|
|
16
|
+
* 2. The IN-MEMORY CAPTURE — a byte-budgeted ring buffer (not line-counted).
|
|
17
|
+
* This is the `/feedback` binding convenience, NOT durability (an in-memory
|
|
18
|
+
* ring does not survive a crash). Mirrors Codex's CodexFeedback ring and
|
|
19
|
+
* Claude's inMemoryErrorLog.
|
|
20
|
+
*
|
|
21
|
+
* Every line carries a REQUIRED `sessionId` and a `source`/`level`/`event`
|
|
22
|
+
* triple so a shared file stays filterable: `jq 'select(.source=="guardian")'`
|
|
23
|
+
* reproduces today's per-file tail exactly, and /feedback reads the trail
|
|
24
|
+
* filtered by sessionId (never the raw file) so one session's report never
|
|
25
|
+
* leaks another session's errors.
|
|
26
|
+
*
|
|
27
|
+
* Default-on vs DEBUG invariant (the thing that makes "log everything by
|
|
28
|
+
* default" safe): default-on == scrub-safe == upload-safe. Any field carrying
|
|
29
|
+
* raw content (tool arguments, partial/result bodies, provider payloads, raw
|
|
30
|
+
* key bytes) must be gated behind YAGNI_DEBUG, and the /feedback reader refuses
|
|
31
|
+
* to bind any `level: debug` line. DEBUG == may-contain-content == never-uploads.
|
|
32
|
+
*/
|
|
33
|
+
import { appendFileSync, mkdirSync, readFileSync, renameSync, statSync } from "node:fs";
|
|
34
|
+
import { dirname, join } from "node:path";
|
|
35
|
+
import { codeStateHome } from "./stateHome.js";
|
|
36
|
+
import { scrubSecrets } from "./pipeline/scrubSecrets.js";
|
|
37
|
+
const MAX_LOG_BYTES = 256 * 1024;
|
|
38
|
+
const KEEP_ROTATIONS = 2;
|
|
39
|
+
const RING_MAX_BYTES = 256 * 1024;
|
|
40
|
+
/** Test seam: point the log at a tmpdir (mirrors _setDiagnosticsHomeForTest). */
|
|
41
|
+
let homeOverride = null;
|
|
42
|
+
export function _setErrorSinkHomeForTest(dir) {
|
|
43
|
+
homeOverride = dir;
|
|
44
|
+
}
|
|
45
|
+
function logDir() {
|
|
46
|
+
return join(codeStateHome(homeOverride), "logs");
|
|
47
|
+
}
|
|
48
|
+
function dayStamp(now = new Date()) {
|
|
49
|
+
return now.toISOString().slice(0, 10); // YYYY-MM-DD
|
|
50
|
+
}
|
|
51
|
+
export function errorSinkPath(now = new Date()) {
|
|
52
|
+
return join(logDir(), `errors-${dayStamp(now)}.jsonl`);
|
|
53
|
+
}
|
|
54
|
+
/** In-memory ring buffer, byte-budgeted (trailing bytes kept when over cap). */
|
|
55
|
+
class RingBuffer {
|
|
56
|
+
maxBytes;
|
|
57
|
+
chunks = [];
|
|
58
|
+
bytes = 0;
|
|
59
|
+
constructor(maxBytes) {
|
|
60
|
+
this.maxBytes = maxBytes;
|
|
61
|
+
}
|
|
62
|
+
push(line) {
|
|
63
|
+
const b = Buffer.byteLength(line, "utf8");
|
|
64
|
+
if (b >= this.maxBytes) {
|
|
65
|
+
this.chunks = [line];
|
|
66
|
+
this.bytes = b;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
while (this.bytes + b > this.maxBytes && this.chunks.length > 0) {
|
|
70
|
+
const dropped = this.chunks.shift();
|
|
71
|
+
this.bytes -= Buffer.byteLength(dropped, "utf8");
|
|
72
|
+
}
|
|
73
|
+
this.chunks.push(line);
|
|
74
|
+
this.bytes += b;
|
|
75
|
+
}
|
|
76
|
+
snapshot() {
|
|
77
|
+
return this.chunks.join("");
|
|
78
|
+
}
|
|
79
|
+
clear() {
|
|
80
|
+
this.chunks = [];
|
|
81
|
+
this.bytes = 0;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const ring = new RingBuffer(RING_MAX_BYTES);
|
|
85
|
+
export function _clearErrorSinkRingForTest() {
|
|
86
|
+
ring.clear();
|
|
87
|
+
}
|
|
88
|
+
export function errorSinkInMemory() {
|
|
89
|
+
return ring.snapshot();
|
|
90
|
+
}
|
|
91
|
+
function rotateIfNeeded(path) {
|
|
92
|
+
try {
|
|
93
|
+
if (!statSync(path).isFile() || statSync(path).size < MAX_LOG_BYTES)
|
|
94
|
+
return;
|
|
95
|
+
for (let i = KEEP_ROTATIONS; i >= 1; i--) {
|
|
96
|
+
const from = i === 1 ? path : `${path}.${i - 1}`;
|
|
97
|
+
const to = `${path}.${i}`;
|
|
98
|
+
try {
|
|
99
|
+
renameSync(from, to);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
/* absent source — fine */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
/* rotation is best-effort */
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function isDebug(env = process.env) {
|
|
111
|
+
const v = env.YAGNI_DEBUG;
|
|
112
|
+
return v === "1" || v === "true";
|
|
113
|
+
}
|
|
114
|
+
function sessionIdFor(ev) {
|
|
115
|
+
return ev.sessionId ?? process.env.YAGNI_SESSION_ID ?? "";
|
|
116
|
+
}
|
|
117
|
+
function serialize(ev) {
|
|
118
|
+
const line = {
|
|
119
|
+
ts: new Date().toISOString(),
|
|
120
|
+
source: ev.source,
|
|
121
|
+
level: ev.level,
|
|
122
|
+
event: ev.event,
|
|
123
|
+
sessionId: sessionIdFor(ev),
|
|
124
|
+
...(ev.fields ?? {}),
|
|
125
|
+
};
|
|
126
|
+
return JSON.stringify(line) + "\n";
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Append one event to both the ring and the durable trail. Fail-soft: a logging
|
|
130
|
+
* failure must never break the session. `flush: "sync"` (default for
|
|
131
|
+
* error-level events and lifecycle turns) bypasses any future buffering so a
|
|
132
|
+
* turn that starts but never ends still leaves a durable `turn_start`.
|
|
133
|
+
*/
|
|
134
|
+
export function logEvent(ev) {
|
|
135
|
+
try {
|
|
136
|
+
if (process.env.NODE_TEST_CONTEXT && homeOverride === null)
|
|
137
|
+
return;
|
|
138
|
+
const line = serialize(ev);
|
|
139
|
+
ring.push(line);
|
|
140
|
+
const path = errorSinkPath();
|
|
141
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
142
|
+
rotateIfNeeded(path);
|
|
143
|
+
appendFileSync(path, line, "utf8");
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
/* logging must never throw into the editor */
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Read recent trail lines for ONE session, filtered by `sessionId`, up to
|
|
151
|
+
* `maxBytes`. Never returns `level: debug` lines — the default-on tier is the
|
|
152
|
+
* upload-safe tier, and DEBUG may contain content that must not leave the machine.
|
|
153
|
+
*/
|
|
154
|
+
export function readSessionTrail(sessionId, maxBytes = 64 * 1024) {
|
|
155
|
+
try {
|
|
156
|
+
const data = readFileSync(errorSinkPath(), "utf8");
|
|
157
|
+
// Defense-in-depth: scrub each kept line so /diagnostics and /feedback
|
|
158
|
+
// never surface a secret or local path, even if a future caller slipped a
|
|
159
|
+
// content-bearing value onto an always-on line.
|
|
160
|
+
const lines = data
|
|
161
|
+
.split("\n")
|
|
162
|
+
.filter((l) => l.length > 0)
|
|
163
|
+
.filter((l) => {
|
|
164
|
+
try {
|
|
165
|
+
const obj = JSON.parse(l);
|
|
166
|
+
return obj.sessionId === sessionId && obj.level !== "debug";
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
})
|
|
172
|
+
.map((l) => scrubSecrets(l))
|
|
173
|
+
.join("\n");
|
|
174
|
+
return lines.length > maxBytes ? lines.slice(lines.length - maxBytes) : lines;
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return "";
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
//# sourceMappingURL=errorSink.js.map
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `/feedback` (alias `/bug`) command + the `/diagnostics` companion
|
|
3
|
+
* (YAG-580).
|
|
4
|
+
*
|
|
5
|
+
* `/feedback` captures the session transcript (pi's own append-only JSONL, read
|
|
6
|
+
* via `ctx.sessionManager` — never reconstructed from YAGNI_SESSION_ID, which is
|
|
7
|
+
* the proxy-attribution id), the session-scoped error trail (from the unified
|
|
8
|
+
* sink), any child `/go` run transcripts, a sanitized `yagni doctor` report, and
|
|
9
|
+
* git metadata — sanitizes the whole bundle with the shared scrub contract, and
|
|
10
|
+
* POSTs it to the YAGNI backend (opt-in, gated, named-human).
|
|
11
|
+
*
|
|
12
|
+
* `/diagnostics` is the read-only companion: it prints the last N sink lines for
|
|
13
|
+
* THIS session so the user can see what failed before deciding to attach it.
|
|
14
|
+
*
|
|
15
|
+
* Both treat the transcript as best-effort enrichment: pi's flush-to-file timing
|
|
16
|
+
* at command-invoke is not guaranteed, so a report never claims the reporting
|
|
17
|
+
* turn is captured unless it verifiably is.
|
|
18
|
+
*/
|
|
19
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
export interface FeedbackDeps {
|
|
21
|
+
baseUrl: string;
|
|
22
|
+
getToken: () => string | undefined;
|
|
23
|
+
fetchImpl?: typeof fetch;
|
|
24
|
+
env?: NodeJS.ProcessEnv;
|
|
25
|
+
/** Sanitized `yagni doctor` output (string), or undefined to omit. */
|
|
26
|
+
getDoctorReport?: () => Promise<string | undefined>;
|
|
27
|
+
/** Git facts for the report; undefined fields are omitted. */
|
|
28
|
+
getGitState?: (cwd: string) => Promise<{
|
|
29
|
+
branch?: string;
|
|
30
|
+
commit?: string;
|
|
31
|
+
remote?: string;
|
|
32
|
+
dirty?: boolean;
|
|
33
|
+
}>;
|
|
34
|
+
/** Child `/go` run transcripts keyed by run id, for the current run tree. */
|
|
35
|
+
getChildTranscripts?: (cwd: string, sessionFile: string | undefined) => Promise<Record<string, string>>;
|
|
36
|
+
}
|
|
37
|
+
export declare function registerFeedbackCommands(pi: ExtensionAPI, deps: FeedbackDeps): void;
|
|
38
|
+
//# sourceMappingURL=feedbackCommand.d.ts.map
|