@rmrdeveloper/sideroom-pi 8.8.0 → 8.10.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/README.md +78 -14
- package/extensions/explain/guard.ts +22 -16
- package/extensions/explain/model.ts +58 -8
- package/extensions/guidelines/index.ts +12 -0
- package/extensions/guidelines/prompt.ts +1 -2
- package/extensions/guidelines/review.ts +115 -0
- package/extensions/jev/client.ts +137 -0
- package/extensions/jev/guard.ts +320 -0
- package/extensions/jev/index.ts +88 -0
- package/extensions/jev/key.ts +95 -0
- package/extensions/jev/model.ts +266 -0
- package/extensions/jev/ui.ts +282 -0
- package/extensions/persona/catalog.ts +8 -8
- package/extensions/persona/guard.ts +20 -16
- package/extensions/persona/index.ts +11 -9
- package/extensions/persona/model.ts +18 -2
- package/extensions/persona/prompt.ts +9 -0
- package/extensions/rules/guard.ts +1 -16
- package/extensions/shared/file-path.ts +26 -1
- package/extensions/shared/read-file.ts +18 -0
- package/extensions/shared/review-note.ts +14 -0
- package/extensions/shared/settle.ts +50 -0
- package/extensions/todo/guards.ts +2 -0
- package/package.json +3 -3
- package/skills/sideroom-persona/SKILL.md +18 -8
package/README.md
CHANGED
|
@@ -51,10 +51,11 @@ Sideroom answers each one with a small, opinionated surface:
|
|
|
51
51
|
| Silent guessing | `sideroom_ask` — questions with an opinion, one batch at a time |
|
|
52
52
|
| Invisible work | `sideroom_todo` — a live board above the editor, always one step in focus |
|
|
53
53
|
| Repo pollution | Session-branch state — the board and history die with the session, never with a commit |
|
|
54
|
-
| Style drift | A pre-edit gate + the on-demand `sideroom-guidelines` skill |
|
|
54
|
+
| Style drift | A pre-edit gate + the on-demand `sideroom-guidelines` skill + an end-of-turn review steer |
|
|
55
55
|
| Invisible monorepo skills | `monorepo-skills` — trusted child folders join Pi's available skill list |
|
|
56
56
|
| Vague plans | `sideroom-grill` — an interview that settles the words before the work |
|
|
57
57
|
| Unapplied guidelines | `sideroom_rules` — mechanical checks that block or flag the lines you add |
|
|
58
|
+
| Unchecked judgment rules | `jev` — asks a decision model about the six rules no check can decide |
|
|
58
59
|
| Unclear answers | `sideroom_persona` — one voice: direct, plain, and free of jargon it invented |
|
|
59
60
|
| Premature completion | `sideroom_done` — steers back to the project's check command before finishing |
|
|
60
61
|
| Work left unexplained | `explain` — offers a walkthrough and how to test it once the work settles |
|
|
@@ -157,7 +158,10 @@ full read of the packaged skill and, for supported targets, the one complete
|
|
|
157
158
|
language guide under `skills/sideroom-guidelines/references/languages/`.
|
|
158
159
|
Each guide mirrors all 19 rules in the canonical seed with idiomatic examples.
|
|
159
160
|
The seed, `assets/artifacts/GUIDELINES_TEMPLATE.md`, is never pasted into the
|
|
160
|
-
system prompt.
|
|
161
|
+
system prompt. After any successful mutation, one hidden review steer fires when
|
|
162
|
+
the turn settles and asks the agent to re-check every changed file against the
|
|
163
|
+
loaded guide and run the relevant formatter, linter, type checks, and tests —
|
|
164
|
+
so the contract is applied, not just read.
|
|
161
165
|
|
|
162
166
|
### Monorepo skills — child skills are still project skills
|
|
163
167
|
|
|
@@ -183,6 +187,28 @@ appended as notes to the tool result. A per-rule circuit breaker degrades a
|
|
|
183
187
|
repeatedly firing block to a note so the agent never dead-locks. No files
|
|
184
188
|
written, no config read: the catalog ships with the package.
|
|
185
189
|
|
|
190
|
+
### Jev — judgment where no check reaches
|
|
191
|
+
|
|
192
|
+
Reading the guide covers the mechanical rules. `jev` covers the other kind: one
|
|
193
|
+
optional call to TypeSafe's Jev decision model about the six guide rules a
|
|
194
|
+
single file can answer (guard clauses, fail fast, command/query separation,
|
|
195
|
+
null handling, immutability, validate once) on the file a `write` or `edit` just
|
|
196
|
+
changed. The answer arrives as a probability, and when it clears the cutoff the
|
|
197
|
+
note travels with the guidelines review at the end of the run instead of
|
|
198
|
+
interrupting each edit. READMEs, JSON, lockfiles, deletion-only edits, and files
|
|
199
|
+
outside the project are never sent.
|
|
200
|
+
|
|
201
|
+
It never blocks and never enters the system prompt. The state is the file and
|
|
202
|
+
the added lines and nothing else — no conversation, no session, no neighbours.
|
|
203
|
+
The footer counts the calls the session made, and `F10` shows the same count
|
|
204
|
+
beside the key source. Without a key nothing is called, and when Jev runs out of
|
|
205
|
+
quota, rejects the key,
|
|
206
|
+
or stops answering, the extension goes quiet without touching the turn. A rate
|
|
207
|
+
limit only pauses it until your next prompt, and Escape cuts a request in
|
|
208
|
+
flight. The key
|
|
209
|
+
lives in `TYPESAFE_API_KEY` or in your agent directory, and `F10` captures it
|
|
210
|
+
without ever writing it to the session.
|
|
211
|
+
|
|
186
212
|
### Persona — one voice, not a costume
|
|
187
213
|
|
|
188
214
|
`sideroom_persona` is the agent's voice, and there is exactly one of it. A
|
|
@@ -208,11 +234,14 @@ nothing is detectable, the gate does nothing.
|
|
|
208
234
|
|
|
209
235
|
When an implementation settles, `explain` steers the agent to offer a
|
|
210
236
|
walkthrough through `sideroom_ask`: explain the changes only, how to test them
|
|
211
|
-
only, both, or nothing. It fires on `
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
237
|
+
only, both, or nothing. It fires on `agent_before_settle`, after every retry
|
|
238
|
+
and compaction recovery, so the offer never lands on work that is about to be
|
|
239
|
+
redone, and it stays inside the same run. It waits one boundary so the
|
|
240
|
+
guidelines review runs first; both use the same event, and Pi would otherwise
|
|
241
|
+
order them by extension load. It stays quiet after Escape, after an error, and
|
|
242
|
+
while a message you typed is waiting. Once per turn, only when the turn touched at least five distinct
|
|
243
|
+
files, and only in the TUI, because `sideroom_ask` cannot run anywhere else. No
|
|
244
|
+
tool, no widget, no persisted state.
|
|
216
245
|
|
|
217
246
|
## Quick path
|
|
218
247
|
|
|
@@ -287,8 +316,14 @@ also fully read the packaged guide matching the target path. Reads with
|
|
|
287
316
|
`offset` or `limit`, failed reads, and same-named files elsewhere do not count.
|
|
288
317
|
The extension blocks the mutation until the required reads succeed; unsupported
|
|
289
318
|
languages use the shared table. Do not route around
|
|
290
|
-
the gate through Bash or another file-mutation path.
|
|
291
|
-
|
|
319
|
+
the gate through Bash or another file-mutation path. After any successful
|
|
320
|
+
`write`/`edit`, one hidden review steer fires on `agent_before_settle` (at most
|
|
321
|
+
once per turn) asking the agent to re-check every changed file against the
|
|
322
|
+
loaded guide and run the relevant project checks. It is skipped after Escape,
|
|
323
|
+
after an error, and while a user message is pending. Interactive input sent
|
|
324
|
+
while the agent is idle clears the steer flags; a message typed during a run
|
|
325
|
+
and `before_agent_start` do not, so a steer continuation never re-triggers
|
|
326
|
+
itself.
|
|
292
327
|
|
|
293
328
|
### Monorepo-skills contract
|
|
294
329
|
|
|
@@ -315,11 +350,36 @@ the diff against the loaded guide and run the relevant project checks.
|
|
|
315
350
|
- A rule that blocks three times in a run degrades to a warning until five
|
|
316
351
|
clean checks or a new interactive prompt reset it.
|
|
317
352
|
|
|
353
|
+
### Jev contract
|
|
354
|
+
|
|
355
|
+
- Off without a key: no request, and behavior identical to a package without
|
|
356
|
+
this extension.
|
|
357
|
+
- Warn only. A finding goes to the guidelines review on `sideroom:review-note`
|
|
358
|
+
and rides its end-of-run steer, with one follow-up per turn for notes that
|
|
359
|
+
arrive after the review fired; nothing blocks and nothing reaches the system
|
|
360
|
+
prompt.
|
|
361
|
+
- One request per successful `write`/`edit` of a project file in a supported
|
|
362
|
+
language that added lines, with six `noul` questions on one shared state of
|
|
363
|
+
the project-relative path, the file body, and the added lines.
|
|
364
|
+
- A serialized state over 60k characters is skipped rather than truncated. An
|
|
365
|
+
answer at or above `0.8` probability becomes a note.
|
|
366
|
+
- The request follows the run's abort signal; a cancelled request is not a
|
|
367
|
+
failure.
|
|
368
|
+
- Quota (`402`) and a rejected key (`401`, `403`) stop the calls immediately. A
|
|
369
|
+
rate limit (`429`) pauses them until the next idle prompt. Network errors and
|
|
370
|
+
other `4xx`/`5xx` stop after three consecutive failures. A success rearms the
|
|
371
|
+
breaker, and `F10` rearms it by hand.
|
|
372
|
+
- Every request increments a session call counter, shown in the footer and on
|
|
373
|
+
`F10`; it resets on `session_start` and is never persisted.
|
|
374
|
+
- The key comes from `TYPESAFE_API_KEY` or from
|
|
375
|
+
`getAgentDir()/sideroom.json` at mode `0600`, and never enters the session.
|
|
376
|
+
|
|
318
377
|
### Persona contract
|
|
319
378
|
|
|
320
379
|
- One built-in voice. No profiles, no switching, and no persisted profile
|
|
321
|
-
state; runtime guard counters stay in memory. The footer
|
|
322
|
-
|
|
380
|
+
state; runtime guard counters stay in memory. The footer shows the run's block
|
|
381
|
+
and steer counts (`persona: direct · 2 blocks · 1 steer`) and drops back to
|
|
382
|
+
`persona: direct` on the next run.
|
|
323
383
|
- Injection: a short reminder appended in `before_agent_start`, chained after
|
|
324
384
|
the other extensions and idempotent by heading, so it returns after
|
|
325
385
|
compaction.
|
|
@@ -343,9 +403,13 @@ the diff against the loaded guide and run the relevant project checks.
|
|
|
343
403
|
|
|
344
404
|
### Explain contract
|
|
345
405
|
|
|
346
|
-
- Trigger: `
|
|
347
|
-
last user prompt, in TUI mode
|
|
348
|
-
|
|
406
|
+
- Trigger: `agent_before_settle` with at least five distinct files mutated
|
|
407
|
+
since the last idle user prompt, in TUI mode, on a completed run with no user
|
|
408
|
+
message pending. File identity follows Pi's path aliases and canonicalizes
|
|
409
|
+
existing paths.
|
|
410
|
+
- One boundary of delay: the guidelines review steers on the same event, and Pi
|
|
411
|
+
runs both handlers in extension load order, so `explain` offers at the next
|
|
412
|
+
boundary.
|
|
349
413
|
- The steer names the intent; the agent writes the question in the user's
|
|
350
414
|
language with four options, one of them a decline, and one recommendation.
|
|
351
415
|
`sideroom_ask` still adds *Out of scope* and the custom answer.
|
|
@@ -4,12 +4,20 @@ import {
|
|
|
4
4
|
isWriteToolResult,
|
|
5
5
|
} from '@earendil-works/pi-coding-agent';
|
|
6
6
|
import {
|
|
7
|
+
acceptsSettleSteer,
|
|
8
|
+
startsUserTurn,
|
|
9
|
+
withHiddenSteer,
|
|
10
|
+
} from '../shared/settle.ts';
|
|
11
|
+
import {
|
|
12
|
+
applyOfferDecision,
|
|
13
|
+
decideOffer,
|
|
14
|
+
EXPLAIN_DECISION,
|
|
7
15
|
EXPLAIN_OFFER,
|
|
8
16
|
EXPLAIN_OFFER_TYPE,
|
|
9
17
|
type ExplainState,
|
|
10
18
|
mutationPathKey,
|
|
19
|
+
recordMutation,
|
|
11
20
|
resetExplainTurn,
|
|
12
|
-
shouldOfferExplanation,
|
|
13
21
|
} from './model.ts';
|
|
14
22
|
|
|
15
23
|
interface FileToolInput {
|
|
@@ -21,7 +29,7 @@ export function registerExplainGuard(
|
|
|
21
29
|
state: ExplainState,
|
|
22
30
|
): void {
|
|
23
31
|
pi.on('input', (event) => {
|
|
24
|
-
if (event
|
|
32
|
+
if (!startsUserTurn(event)) {
|
|
25
33
|
return;
|
|
26
34
|
}
|
|
27
35
|
resetExplainTurn(state);
|
|
@@ -40,23 +48,21 @@ export function registerExplainGuard(
|
|
|
40
48
|
if (pathKey === undefined) {
|
|
41
49
|
return;
|
|
42
50
|
}
|
|
43
|
-
state
|
|
51
|
+
recordMutation(state, pathKey);
|
|
44
52
|
});
|
|
45
53
|
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
54
|
+
// agent_before_settle runs after retries and compaction recovery, so the
|
|
55
|
+
// walkthrough is never offered over reworked work, and its continuation
|
|
56
|
+
// stays inside the same run instead of starting a second one.
|
|
57
|
+
pi.on('agent_before_settle', (event, ctx) => {
|
|
58
|
+
if (!acceptsSettleSteer(event)) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const decision = decideOffer(state, ctx.mode);
|
|
62
|
+
applyOfferDecision(state, decision);
|
|
63
|
+
if (decision !== EXPLAIN_DECISION.send) {
|
|
50
64
|
return;
|
|
51
65
|
}
|
|
52
|
-
|
|
53
|
-
pi.sendMessage(
|
|
54
|
-
{
|
|
55
|
-
customType: EXPLAIN_OFFER_TYPE,
|
|
56
|
-
content: EXPLAIN_OFFER,
|
|
57
|
-
display: false,
|
|
58
|
-
},
|
|
59
|
-
{ triggerTurn: true, deliverAs: 'steer' },
|
|
60
|
-
);
|
|
66
|
+
return withHiddenSteer(event, EXPLAIN_OFFER_TYPE, EXPLAIN_OFFER);
|
|
61
67
|
});
|
|
62
68
|
}
|
|
@@ -11,17 +11,35 @@ const TUI_MODE = 'tui';
|
|
|
11
11
|
export const EXPLAIN_OFFER =
|
|
12
12
|
"Use sideroom_ask for one question in the user's language with four parallel options: changes only, test steps only, both, or no explanation. Recommend changes only.";
|
|
13
13
|
|
|
14
|
+
export const EXPLAIN_DECISION = {
|
|
15
|
+
send: 'send',
|
|
16
|
+
defer: 'defer',
|
|
17
|
+
none: 'none',
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
20
|
+
export type ExplainDecision =
|
|
21
|
+
(typeof EXPLAIN_DECISION)[keyof typeof EXPLAIN_DECISION];
|
|
22
|
+
|
|
14
23
|
export interface ExplainState {
|
|
15
24
|
mutatedFilesThisTurn: Set<string>;
|
|
25
|
+
mutatedSinceSettle: boolean;
|
|
26
|
+
pendingOffer: boolean;
|
|
16
27
|
offeredThisTurn: boolean;
|
|
17
28
|
}
|
|
18
29
|
|
|
19
30
|
export function createExplainState(): ExplainState {
|
|
20
|
-
return {
|
|
31
|
+
return {
|
|
32
|
+
mutatedFilesThisTurn: new Set(),
|
|
33
|
+
mutatedSinceSettle: false,
|
|
34
|
+
pendingOffer: false,
|
|
35
|
+
offeredThisTurn: false,
|
|
36
|
+
};
|
|
21
37
|
}
|
|
22
38
|
|
|
23
39
|
export function resetExplainTurn(state: ExplainState): void {
|
|
24
40
|
state.mutatedFilesThisTurn.clear();
|
|
41
|
+
state.mutatedSinceSettle = false;
|
|
42
|
+
state.pendingOffer = false;
|
|
25
43
|
state.offeredThisTurn = false;
|
|
26
44
|
}
|
|
27
45
|
|
|
@@ -31,14 +49,46 @@ export function mutationPathKey(cwd: string, path: string): string | undefined {
|
|
|
31
49
|
return resolveFileToolPath(cwd, path);
|
|
32
50
|
}
|
|
33
51
|
|
|
34
|
-
|
|
35
|
-
|
|
52
|
+
export function recordMutation(state: ExplainState, pathKey: string): void {
|
|
53
|
+
state.mutatedFilesThisTurn.add(pathKey);
|
|
54
|
+
state.mutatedSinceSettle = true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The guidelines review steers at the first settle boundary after a mutation,
|
|
58
|
+
// and both handlers run in an extension load order Sideroom does not control.
|
|
59
|
+
// Explain holds its steer back one boundary so the review always runs first;
|
|
60
|
+
// the review's continuation is what brings the next boundary.
|
|
61
|
+
export function decideOffer(
|
|
36
62
|
state: ExplainState,
|
|
37
63
|
mode: string,
|
|
38
|
-
):
|
|
39
|
-
|
|
64
|
+
): ExplainDecision {
|
|
65
|
+
const thresholdReached =
|
|
40
66
|
mode === TUI_MODE &&
|
|
41
|
-
state.
|
|
42
|
-
|
|
43
|
-
)
|
|
67
|
+
!state.offeredThisTurn &&
|
|
68
|
+
state.mutatedFilesThisTurn.size >= EXPLAIN_FILE_THRESHOLD;
|
|
69
|
+
if (state.pendingOffer && thresholdReached) {
|
|
70
|
+
return EXPLAIN_DECISION.send;
|
|
71
|
+
}
|
|
72
|
+
if (
|
|
73
|
+
thresholdReached ||
|
|
74
|
+
(mode === TUI_MODE && state.mutatedSinceSettle && !state.offeredThisTurn)
|
|
75
|
+
) {
|
|
76
|
+
return EXPLAIN_DECISION.defer;
|
|
77
|
+
}
|
|
78
|
+
return EXPLAIN_DECISION.none;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function applyOfferDecision(
|
|
82
|
+
state: ExplainState,
|
|
83
|
+
decision: ExplainDecision,
|
|
84
|
+
): void {
|
|
85
|
+
if (decision === EXPLAIN_DECISION.send) {
|
|
86
|
+
state.offeredThisTurn = true;
|
|
87
|
+
state.pendingOffer = false;
|
|
88
|
+
} else if (decision === EXPLAIN_DECISION.defer) {
|
|
89
|
+
state.pendingOffer = true;
|
|
90
|
+
} else {
|
|
91
|
+
state.pendingOffer = false;
|
|
92
|
+
}
|
|
93
|
+
state.mutatedSinceSettle = false;
|
|
44
94
|
}
|
|
@@ -5,12 +5,24 @@ import {
|
|
|
5
5
|
resetGuidelineReadState,
|
|
6
6
|
} from './guard.ts';
|
|
7
7
|
import { appendGuidelinesReminder } from './prompt.ts';
|
|
8
|
+
import {
|
|
9
|
+
createReviewState,
|
|
10
|
+
registerReviewGuard,
|
|
11
|
+
resetReviewTurn,
|
|
12
|
+
} from './review.ts';
|
|
8
13
|
|
|
9
14
|
// Pi loads extensions/*/index.ts through export default.
|
|
10
15
|
export default function registerGuidelines(pi: ExtensionAPI): void {
|
|
11
16
|
const readState = createGuidelineReadState();
|
|
12
17
|
registerGuidelineGuard(pi, readState);
|
|
13
18
|
|
|
19
|
+
const reviewState = createReviewState();
|
|
20
|
+
registerReviewGuard(pi, reviewState);
|
|
21
|
+
pi.on('session_start', () => {
|
|
22
|
+
resetGuidelineReadState(readState);
|
|
23
|
+
resetReviewTurn(reviewState);
|
|
24
|
+
});
|
|
25
|
+
|
|
14
26
|
pi.on('before_agent_start', (event) => {
|
|
15
27
|
resetGuidelineReadState(readState);
|
|
16
28
|
const systemPrompt = appendGuidelinesReminder(event.systemPrompt);
|
|
@@ -15,8 +15,7 @@ export const GUIDELINES_REMINDER = `${GUIDELINES_REMINDER_HEADING}
|
|
|
15
15
|
Before the first write or edit in each agent run, use the read tool without offset or limit to load these files in full:
|
|
16
16
|
- ${GUIDELINE_SKILL_PATH}
|
|
17
17
|
- for the extension of the file you will change, one of: ${LANGUAGE_GUIDE_INDEX}
|
|
18
|
-
A required read counts only when it succeeds without truncation. Until all required reads succeed, do not call write or edit and do not mutate files through bash or another tool; the extension blocks those mutations
|
|
19
|
-
After changing files, review the code against the loaded guide and run the relevant formatter, linter, type checks, and tests.`;
|
|
18
|
+
A required read counts only when it succeeds without truncation. Until all required reads succeed, do not call write or edit and do not mutate files through bash or another tool; the extension blocks those mutations.`;
|
|
20
19
|
|
|
21
20
|
export function appendGuidelinesReminder(systemPrompt: string): string {
|
|
22
21
|
if (systemPrompt.includes(GUIDELINES_REMINDER_HEADING)) {
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ExtensionAPI,
|
|
3
|
+
isEditToolResult,
|
|
4
|
+
isWriteToolResult,
|
|
5
|
+
} from '@earendil-works/pi-coding-agent';
|
|
6
|
+
import { isReviewNote, REVIEW_NOTE_EVENT } from '../shared/review-note.ts';
|
|
7
|
+
import {
|
|
8
|
+
acceptsSettleSteer,
|
|
9
|
+
startsUserTurn,
|
|
10
|
+
withHiddenSteer,
|
|
11
|
+
} from '../shared/settle.ts';
|
|
12
|
+
|
|
13
|
+
export const REVIEW_STEER_TYPE = 'sideroom-guidelines-review';
|
|
14
|
+
|
|
15
|
+
export const REVIEW_STEER =
|
|
16
|
+
'Review every file this run changed against the loaded Sideroom language guide: fix any mechanical, structural, or architectural rule violations in those files, then run the relevant formatter, linter, type checks, and tests. If the guide is no longer in context, re-read it in full first.';
|
|
17
|
+
|
|
18
|
+
export const REVIEW_NOTES_STEER =
|
|
19
|
+
'Files changed during the review drew the semantic notes below. Verify each against the loaded Sideroom language guide, fix only real violations, then rerun the relevant checks.';
|
|
20
|
+
|
|
21
|
+
export interface ReviewState {
|
|
22
|
+
mutated: boolean;
|
|
23
|
+
steeredThisTurn: boolean;
|
|
24
|
+
notesSteeredThisTurn: boolean;
|
|
25
|
+
notes: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createReviewState(): ReviewState {
|
|
29
|
+
return {
|
|
30
|
+
mutated: false,
|
|
31
|
+
steeredThisTurn: false,
|
|
32
|
+
notesSteeredThisTurn: false,
|
|
33
|
+
notes: [],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function resetReviewTurn(state: ReviewState): void {
|
|
38
|
+
state.mutated = false;
|
|
39
|
+
state.steeredThisTurn = false;
|
|
40
|
+
state.notesSteeredThisTurn = false;
|
|
41
|
+
state.notes = [];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function shouldReview(state: ReviewState): boolean {
|
|
45
|
+
return state.mutated && !state.steeredThisTurn;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Notes that arrive after the review fired get one follow-up per turn, so a
|
|
49
|
+
// fix that draws new notes cannot keep the run going.
|
|
50
|
+
export function pendingReviewSteer(state: ReviewState): string | undefined {
|
|
51
|
+
if (shouldReview(state)) {
|
|
52
|
+
return withNotes(REVIEW_STEER, state.notes);
|
|
53
|
+
}
|
|
54
|
+
if (
|
|
55
|
+
state.steeredThisTurn &&
|
|
56
|
+
!state.notesSteeredThisTurn &&
|
|
57
|
+
state.notes.length > 0
|
|
58
|
+
) {
|
|
59
|
+
return withNotes(REVIEW_NOTES_STEER, state.notes);
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function markReviewSteered(state: ReviewState): void {
|
|
65
|
+
if (state.steeredThisTurn) {
|
|
66
|
+
state.notesSteeredThisTurn = true;
|
|
67
|
+
}
|
|
68
|
+
state.steeredThisTurn = true;
|
|
69
|
+
state.notes = [];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function withNotes(lead: string, notes: readonly string[]): string {
|
|
73
|
+
return [lead, ...notes].join('\n\n');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function registerReviewGuard(
|
|
77
|
+
pi: ExtensionAPI,
|
|
78
|
+
state: ReviewState,
|
|
79
|
+
): void {
|
|
80
|
+
pi.on('input', (event) => {
|
|
81
|
+
if (!startsUserTurn(event)) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
resetReviewTurn(state);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
pi.on('tool_result', (event) => {
|
|
88
|
+
if (event.isError) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (!isEditToolResult(event) && !isWriteToolResult(event)) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
state.mutated = true;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
pi.events.on(REVIEW_NOTE_EVENT, (payload) => {
|
|
98
|
+
if (isReviewNote(payload)) {
|
|
99
|
+
state.notes.push(payload.text);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// agent_before_settle runs after retries and compaction recovery, and its
|
|
104
|
+
// continuation stays inside the same run. agent_settled is notification-only:
|
|
105
|
+
// a steer sent there starts a second run after the user already saw the
|
|
106
|
+
// agent stop, and it fires even when the user pressed Escape.
|
|
107
|
+
pi.on('agent_before_settle', (event) => {
|
|
108
|
+
const steer = pendingReviewSteer(state);
|
|
109
|
+
if (steer === undefined || !acceptsSettleSteer(event)) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
markReviewSteered(state);
|
|
113
|
+
return withHiddenSteer(event, REVIEW_STEER_TYPE, steer);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildRequestBody,
|
|
3
|
+
JEV_ENDPOINT,
|
|
4
|
+
type JevFileState,
|
|
5
|
+
type JevFinding,
|
|
6
|
+
parseEnvelope,
|
|
7
|
+
readFindings,
|
|
8
|
+
} from './model.ts';
|
|
9
|
+
|
|
10
|
+
export const JEV_REQUEST_TIMEOUT_MS = 3_000;
|
|
11
|
+
|
|
12
|
+
export const JEV_FAILURE = {
|
|
13
|
+
quota: 'quota',
|
|
14
|
+
auth: 'auth',
|
|
15
|
+
rateLimited: 'rateLimited',
|
|
16
|
+
transient: 'transient',
|
|
17
|
+
cancelled: 'cancelled',
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
20
|
+
const HTTP_STATUS = {
|
|
21
|
+
unauthorized: 401,
|
|
22
|
+
paymentRequired: 402,
|
|
23
|
+
forbidden: 403,
|
|
24
|
+
tooManyRequests: 429,
|
|
25
|
+
firstClientError: 400,
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
export type JevFailure = (typeof JEV_FAILURE)[keyof typeof JEV_FAILURE];
|
|
29
|
+
|
|
30
|
+
export interface JevHttpRequest {
|
|
31
|
+
readonly apiKey: string;
|
|
32
|
+
readonly body: string;
|
|
33
|
+
readonly signal: AbortSignal | undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface JevHttpResponse {
|
|
37
|
+
readonly status: number;
|
|
38
|
+
readonly text: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type JevTransport = (
|
|
42
|
+
request: JevHttpRequest,
|
|
43
|
+
) => Promise<JevHttpResponse>;
|
|
44
|
+
|
|
45
|
+
export interface JevAttempt {
|
|
46
|
+
readonly findings: readonly JevFinding[] | undefined;
|
|
47
|
+
readonly model: string | undefined;
|
|
48
|
+
readonly failure: JevFailure | undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The endpoint is a package constant, never a value from the environment or the
|
|
52
|
+
// session, so the outbound URL cannot be steered by the repository being edited.
|
|
53
|
+
export function createFetchTransport(
|
|
54
|
+
timeoutMs: number = JEV_REQUEST_TIMEOUT_MS,
|
|
55
|
+
): JevTransport {
|
|
56
|
+
return async (request) => {
|
|
57
|
+
const response = await fetch(JEV_ENDPOINT, {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers: {
|
|
60
|
+
Authorization: `Bearer ${request.apiKey}`,
|
|
61
|
+
'Content-Type': 'application/json',
|
|
62
|
+
},
|
|
63
|
+
body: request.body,
|
|
64
|
+
signal: requestSignal(timeoutMs, request.signal),
|
|
65
|
+
});
|
|
66
|
+
return { status: response.status, text: await response.text() };
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Pi waits for every tool_result handler before the result reaches the model,
|
|
71
|
+
// so the user's Escape has to cut the request, not only the timeout.
|
|
72
|
+
function requestSignal(
|
|
73
|
+
timeoutMs: number,
|
|
74
|
+
runSignal: AbortSignal | undefined,
|
|
75
|
+
): AbortSignal {
|
|
76
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
77
|
+
if (runSignal === undefined) {
|
|
78
|
+
return timeout;
|
|
79
|
+
}
|
|
80
|
+
return AbortSignal.any([timeout, runSignal]);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function classifyStatus(status: number): JevFailure | undefined {
|
|
84
|
+
if (status === HTTP_STATUS.unauthorized || status === HTTP_STATUS.forbidden) {
|
|
85
|
+
return JEV_FAILURE.auth;
|
|
86
|
+
}
|
|
87
|
+
if (status === HTTP_STATUS.paymentRequired) {
|
|
88
|
+
return JEV_FAILURE.quota;
|
|
89
|
+
}
|
|
90
|
+
if (status === HTTP_STATUS.tooManyRequests) {
|
|
91
|
+
return JEV_FAILURE.rateLimited;
|
|
92
|
+
}
|
|
93
|
+
if (status >= HTTP_STATUS.firstClientError) {
|
|
94
|
+
return JEV_FAILURE.transient;
|
|
95
|
+
}
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// A Jev failure is a missing note, never an error: the mutation already
|
|
100
|
+
// succeeded and the mechanical rules already ran. So this returns a value
|
|
101
|
+
// instead of throwing, and the caller only decides whether to keep calling.
|
|
102
|
+
export async function askJev(
|
|
103
|
+
transport: JevTransport,
|
|
104
|
+
apiKey: string,
|
|
105
|
+
file: JevFileState,
|
|
106
|
+
signal: AbortSignal | undefined,
|
|
107
|
+
): Promise<JevAttempt> {
|
|
108
|
+
const body = JSON.stringify(buildRequestBody(file));
|
|
109
|
+
let response: JevHttpResponse;
|
|
110
|
+
try {
|
|
111
|
+
response = await transport({ apiKey, body, signal });
|
|
112
|
+
} catch {
|
|
113
|
+
return {
|
|
114
|
+
findings: undefined,
|
|
115
|
+
model: undefined,
|
|
116
|
+
failure:
|
|
117
|
+
signal?.aborted === true
|
|
118
|
+
? JEV_FAILURE.cancelled
|
|
119
|
+
: JEV_FAILURE.transient,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const failure = classifyStatus(response.status);
|
|
124
|
+
if (failure !== undefined) {
|
|
125
|
+
return { findings: undefined, model: undefined, failure };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const envelope = parseEnvelope(response.text);
|
|
129
|
+
if (envelope === undefined) {
|
|
130
|
+
return { findings: [], model: undefined, failure: undefined };
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
findings: readFindings(envelope),
|
|
134
|
+
model: envelope.model,
|
|
135
|
+
failure: undefined,
|
|
136
|
+
};
|
|
137
|
+
}
|