@rmrdeveloper/sideroom-pi 8.9.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 +41 -27
- package/extensions/explain/guard.ts +14 -12
- package/extensions/explain/model.ts +4 -3
- package/extensions/guidelines/review.ts +63 -18
- package/extensions/jev/client.ts +37 -6
- package/extensions/jev/guard.ts +87 -23
- package/extensions/jev/index.ts +2 -2
- package/extensions/jev/model.ts +12 -5
- package/extensions/jev/ui.ts +1 -0
- package/extensions/shared/file-path.ts +26 -1
- package/extensions/shared/review-note.ts +14 -0
- package/extensions/shared/settle.ts +50 -0
- package/package.json +3 -3
- package/skills/sideroom-persona/SKILL.md +6 -3
package/README.md
CHANGED
|
@@ -193,15 +193,19 @@ Reading the guide covers the mechanical rules. `jev` covers the other kind: one
|
|
|
193
193
|
optional call to TypeSafe's Jev decision model about the six guide rules a
|
|
194
194
|
single file can answer (guard clauses, fail fast, command/query separation,
|
|
195
195
|
null handling, immutability, validate once) on the file a `write` or `edit` just
|
|
196
|
-
changed. The answer arrives as a probability, and
|
|
197
|
-
|
|
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.
|
|
198
200
|
|
|
199
201
|
It never blocks and never enters the system prompt. The state is the file and
|
|
200
202
|
the added lines and nothing else — no conversation, no session, no neighbours.
|
|
201
203
|
The footer counts the calls the session made, and `F10` shows the same count
|
|
202
204
|
beside the key source. Without a key nothing is called, and when Jev runs out of
|
|
203
205
|
quota, rejects the key,
|
|
204
|
-
or stops answering, the extension goes quiet without touching the turn.
|
|
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
|
|
205
209
|
lives in `TYPESAFE_API_KEY` or in your agent directory, and `F10` captures it
|
|
206
210
|
without ever writing it to the session.
|
|
207
211
|
|
|
@@ -230,11 +234,12 @@ nothing is detectable, the gate does nothing.
|
|
|
230
234
|
|
|
231
235
|
When an implementation settles, `explain` steers the agent to offer a
|
|
232
236
|
walkthrough through `sideroom_ask`: explain the changes only, how to test them
|
|
233
|
-
only, both, or nothing. It fires on `
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
runs first; both
|
|
237
|
-
extension load.
|
|
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
|
|
238
243
|
files, and only in the TUI, because `sideroom_ask` cannot run anywhere else. No
|
|
239
244
|
tool, no widget, no persisted state.
|
|
240
245
|
|
|
@@ -312,10 +317,12 @@ also fully read the packaged guide matching the target path. Reads with
|
|
|
312
317
|
The extension blocks the mutation until the required reads succeed; unsupported
|
|
313
318
|
languages use the shared table. Do not route around
|
|
314
319
|
the gate through Bash or another file-mutation path. After any successful
|
|
315
|
-
`write`/`edit`, one hidden review steer fires on `
|
|
316
|
-
per turn) asking the agent to re-check every changed file against the
|
|
317
|
-
guide and run the relevant project checks.
|
|
318
|
-
|
|
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
|
|
319
326
|
itself.
|
|
320
327
|
|
|
321
328
|
### Monorepo-skills contract
|
|
@@ -347,15 +354,21 @@ itself.
|
|
|
347
354
|
|
|
348
355
|
- Off without a key: no request, and behavior identical to a package without
|
|
349
356
|
this extension.
|
|
350
|
-
- Warn only. A finding
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
-
|
|
355
|
-
|
|
356
|
-
-
|
|
357
|
-
|
|
358
|
-
|
|
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.
|
|
359
372
|
- Every request increments a session call counter, shown in the footer and on
|
|
360
373
|
`F10`; it resets on `session_start` and is never persisted.
|
|
361
374
|
- The key comes from `TYPESAFE_API_KEY` or from
|
|
@@ -390,12 +403,13 @@ itself.
|
|
|
390
403
|
|
|
391
404
|
### Explain contract
|
|
392
405
|
|
|
393
|
-
- Trigger: `
|
|
394
|
-
last user prompt, in TUI mode
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
next
|
|
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.
|
|
399
413
|
- The steer names the intent; the agent writes the question in the user's
|
|
400
414
|
language with four options, one of them a decline, and one recommendation.
|
|
401
415
|
`sideroom_ask` still adds *Out of scope* and the custom answer.
|
|
@@ -3,6 +3,11 @@ import {
|
|
|
3
3
|
isEditToolResult,
|
|
4
4
|
isWriteToolResult,
|
|
5
5
|
} from '@earendil-works/pi-coding-agent';
|
|
6
|
+
import {
|
|
7
|
+
acceptsSettleSteer,
|
|
8
|
+
startsUserTurn,
|
|
9
|
+
withHiddenSteer,
|
|
10
|
+
} from '../shared/settle.ts';
|
|
6
11
|
import {
|
|
7
12
|
applyOfferDecision,
|
|
8
13
|
decideOffer,
|
|
@@ -24,7 +29,7 @@ export function registerExplainGuard(
|
|
|
24
29
|
state: ExplainState,
|
|
25
30
|
): void {
|
|
26
31
|
pi.on('input', (event) => {
|
|
27
|
-
if (event
|
|
32
|
+
if (!startsUserTurn(event)) {
|
|
28
33
|
return;
|
|
29
34
|
}
|
|
30
35
|
resetExplainTurn(state);
|
|
@@ -46,21 +51,18 @@ export function registerExplainGuard(
|
|
|
46
51
|
recordMutation(state, pathKey);
|
|
47
52
|
});
|
|
48
53
|
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
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
|
+
}
|
|
52
61
|
const decision = decideOffer(state, ctx.mode);
|
|
53
62
|
applyOfferDecision(state, decision);
|
|
54
63
|
if (decision !== EXPLAIN_DECISION.send) {
|
|
55
64
|
return;
|
|
56
65
|
}
|
|
57
|
-
|
|
58
|
-
{
|
|
59
|
-
customType: EXPLAIN_OFFER_TYPE,
|
|
60
|
-
content: EXPLAIN_OFFER,
|
|
61
|
-
display: false,
|
|
62
|
-
},
|
|
63
|
-
{ triggerTurn: true, deliverAs: 'steer' },
|
|
64
|
-
);
|
|
66
|
+
return withHiddenSteer(event, EXPLAIN_OFFER_TYPE, EXPLAIN_OFFER);
|
|
65
67
|
});
|
|
66
68
|
}
|
|
@@ -54,9 +54,10 @@ export function recordMutation(state: ExplainState, pathKey: string): void {
|
|
|
54
54
|
state.mutatedSinceSettle = true;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
// The guidelines review steers at the first settle after a mutation,
|
|
58
|
-
//
|
|
59
|
-
// holds its
|
|
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.
|
|
60
61
|
export function decideOffer(
|
|
61
62
|
state: ExplainState,
|
|
62
63
|
mode: string,
|
|
@@ -3,36 +3,82 @@ import {
|
|
|
3
3
|
isEditToolResult,
|
|
4
4
|
isWriteToolResult,
|
|
5
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';
|
|
6
12
|
|
|
7
13
|
export const REVIEW_STEER_TYPE = 'sideroom-guidelines-review';
|
|
8
14
|
|
|
9
15
|
export const REVIEW_STEER =
|
|
10
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.';
|
|
11
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
|
+
|
|
12
21
|
export interface ReviewState {
|
|
13
22
|
mutated: boolean;
|
|
14
23
|
steeredThisTurn: boolean;
|
|
24
|
+
notesSteeredThisTurn: boolean;
|
|
25
|
+
notes: string[];
|
|
15
26
|
}
|
|
16
27
|
|
|
17
28
|
export function createReviewState(): ReviewState {
|
|
18
|
-
return {
|
|
29
|
+
return {
|
|
30
|
+
mutated: false,
|
|
31
|
+
steeredThisTurn: false,
|
|
32
|
+
notesSteeredThisTurn: false,
|
|
33
|
+
notes: [],
|
|
34
|
+
};
|
|
19
35
|
}
|
|
20
36
|
|
|
21
37
|
export function resetReviewTurn(state: ReviewState): void {
|
|
22
38
|
state.mutated = false;
|
|
23
39
|
state.steeredThisTurn = false;
|
|
40
|
+
state.notesSteeredThisTurn = false;
|
|
41
|
+
state.notes = [];
|
|
24
42
|
}
|
|
25
43
|
|
|
26
44
|
export function shouldReview(state: ReviewState): boolean {
|
|
27
45
|
return state.mutated && !state.steeredThisTurn;
|
|
28
46
|
}
|
|
29
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
|
+
|
|
30
76
|
export function registerReviewGuard(
|
|
31
77
|
pi: ExtensionAPI,
|
|
32
78
|
state: ReviewState,
|
|
33
79
|
): void {
|
|
34
80
|
pi.on('input', (event) => {
|
|
35
|
-
if (event
|
|
81
|
+
if (!startsUserTurn(event)) {
|
|
36
82
|
return;
|
|
37
83
|
}
|
|
38
84
|
resetReviewTurn(state);
|
|
@@ -48,23 +94,22 @@ export function registerReviewGuard(
|
|
|
48
94
|
state.mutated = true;
|
|
49
95
|
});
|
|
50
96
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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)) {
|
|
58
110
|
return;
|
|
59
111
|
}
|
|
60
|
-
state
|
|
61
|
-
|
|
62
|
-
{
|
|
63
|
-
customType: REVIEW_STEER_TYPE,
|
|
64
|
-
content: REVIEW_STEER,
|
|
65
|
-
display: false,
|
|
66
|
-
},
|
|
67
|
-
{ triggerTurn: true, deliverAs: 'steer' },
|
|
68
|
-
);
|
|
112
|
+
markReviewSteered(state);
|
|
113
|
+
return withHiddenSteer(event, REVIEW_STEER_TYPE, steer);
|
|
69
114
|
});
|
|
70
115
|
}
|
package/extensions/jev/client.ts
CHANGED
|
@@ -12,7 +12,17 @@ export const JEV_REQUEST_TIMEOUT_MS = 3_000;
|
|
|
12
12
|
export const JEV_FAILURE = {
|
|
13
13
|
quota: 'quota',
|
|
14
14
|
auth: 'auth',
|
|
15
|
+
rateLimited: 'rateLimited',
|
|
15
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,
|
|
16
26
|
} as const;
|
|
17
27
|
|
|
18
28
|
export type JevFailure = (typeof JEV_FAILURE)[keyof typeof JEV_FAILURE];
|
|
@@ -20,6 +30,7 @@ export type JevFailure = (typeof JEV_FAILURE)[keyof typeof JEV_FAILURE];
|
|
|
20
30
|
export interface JevHttpRequest {
|
|
21
31
|
readonly apiKey: string;
|
|
22
32
|
readonly body: string;
|
|
33
|
+
readonly signal: AbortSignal | undefined;
|
|
23
34
|
}
|
|
24
35
|
|
|
25
36
|
export interface JevHttpResponse {
|
|
@@ -50,20 +61,36 @@ export function createFetchTransport(
|
|
|
50
61
|
'Content-Type': 'application/json',
|
|
51
62
|
},
|
|
52
63
|
body: request.body,
|
|
53
|
-
signal:
|
|
64
|
+
signal: requestSignal(timeoutMs, request.signal),
|
|
54
65
|
});
|
|
55
66
|
return { status: response.status, text: await response.text() };
|
|
56
67
|
};
|
|
57
68
|
}
|
|
58
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
|
+
|
|
59
83
|
export function classifyStatus(status: number): JevFailure | undefined {
|
|
60
|
-
if (status ===
|
|
84
|
+
if (status === HTTP_STATUS.unauthorized || status === HTTP_STATUS.forbidden) {
|
|
61
85
|
return JEV_FAILURE.auth;
|
|
62
86
|
}
|
|
63
|
-
if (status ===
|
|
87
|
+
if (status === HTTP_STATUS.paymentRequired) {
|
|
64
88
|
return JEV_FAILURE.quota;
|
|
65
89
|
}
|
|
66
|
-
if (status
|
|
90
|
+
if (status === HTTP_STATUS.tooManyRequests) {
|
|
91
|
+
return JEV_FAILURE.rateLimited;
|
|
92
|
+
}
|
|
93
|
+
if (status >= HTTP_STATUS.firstClientError) {
|
|
67
94
|
return JEV_FAILURE.transient;
|
|
68
95
|
}
|
|
69
96
|
return undefined;
|
|
@@ -76,16 +103,20 @@ export async function askJev(
|
|
|
76
103
|
transport: JevTransport,
|
|
77
104
|
apiKey: string,
|
|
78
105
|
file: JevFileState,
|
|
106
|
+
signal: AbortSignal | undefined,
|
|
79
107
|
): Promise<JevAttempt> {
|
|
80
108
|
const body = JSON.stringify(buildRequestBody(file));
|
|
81
109
|
let response: JevHttpResponse;
|
|
82
110
|
try {
|
|
83
|
-
response = await transport({ apiKey, body });
|
|
111
|
+
response = await transport({ apiKey, body, signal });
|
|
84
112
|
} catch {
|
|
85
113
|
return {
|
|
86
114
|
findings: undefined,
|
|
87
115
|
model: undefined,
|
|
88
|
-
failure:
|
|
116
|
+
failure:
|
|
117
|
+
signal?.aborted === true
|
|
118
|
+
? JEV_FAILURE.cancelled
|
|
119
|
+
: JEV_FAILURE.transient,
|
|
89
120
|
};
|
|
90
121
|
}
|
|
91
122
|
|
package/extensions/jev/guard.ts
CHANGED
|
@@ -6,8 +6,13 @@ import {
|
|
|
6
6
|
isWriteToolResult,
|
|
7
7
|
type ToolCallEvent,
|
|
8
8
|
} from '@earendil-works/pi-coding-agent';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
projectRelativePath,
|
|
11
|
+
resolveFileToolPath,
|
|
12
|
+
} from '../shared/file-path.ts';
|
|
10
13
|
import { readFileIfExists } from '../shared/read-file.ts';
|
|
14
|
+
import { REVIEW_NOTE_EVENT, type ReviewNote } from '../shared/review-note.ts';
|
|
15
|
+
import { startsUserTurn } from '../shared/settle.ts';
|
|
11
16
|
import {
|
|
12
17
|
askJev,
|
|
13
18
|
JEV_FAILURE,
|
|
@@ -21,6 +26,7 @@ import {
|
|
|
21
26
|
buildFileState,
|
|
22
27
|
findingKey,
|
|
23
28
|
formatFindings,
|
|
29
|
+
isReviewablePath,
|
|
24
30
|
type JevFinding,
|
|
25
31
|
stateFitsBudget,
|
|
26
32
|
} from './model.ts';
|
|
@@ -37,13 +43,18 @@ export const JEV_STATUS = {
|
|
|
37
43
|
noKey: 'noKey',
|
|
38
44
|
quota: 'quota',
|
|
39
45
|
auth: 'auth',
|
|
46
|
+
rateLimited: 'rateLimited',
|
|
40
47
|
unavailable: 'unavailable',
|
|
41
48
|
} as const;
|
|
42
49
|
|
|
43
50
|
export type JevStatus = (typeof JEV_STATUS)[keyof typeof JEV_STATUS];
|
|
44
51
|
|
|
45
|
-
interface
|
|
46
|
-
readonly
|
|
52
|
+
interface MutationTarget {
|
|
53
|
+
readonly absolutePath: string;
|
|
54
|
+
readonly relativePath: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface PendingMutation extends MutationTarget {
|
|
47
58
|
readonly kind: string;
|
|
48
59
|
readonly lines: readonly AddedLine[];
|
|
49
60
|
}
|
|
@@ -51,6 +62,7 @@ interface PendingMutation {
|
|
|
51
62
|
export interface JevGuardState {
|
|
52
63
|
halted: JevStatus | undefined;
|
|
53
64
|
transientFailures: number;
|
|
65
|
+
rateLimitedThisTurn: boolean;
|
|
54
66
|
lastModel: string | undefined;
|
|
55
67
|
calls: number;
|
|
56
68
|
readonly pending: Map<string, PendingMutation>;
|
|
@@ -67,6 +79,7 @@ export function createJevGuardState(): JevGuardState {
|
|
|
67
79
|
return {
|
|
68
80
|
halted: undefined,
|
|
69
81
|
transientFailures: 0,
|
|
82
|
+
rateLimitedThisTurn: false,
|
|
70
83
|
lastModel: undefined,
|
|
71
84
|
calls: 0,
|
|
72
85
|
pending: new Map(),
|
|
@@ -78,17 +91,25 @@ export function createJevGuardState(): JevGuardState {
|
|
|
78
91
|
export function resetJevGuard(state: JevGuardState): void {
|
|
79
92
|
state.halted = undefined;
|
|
80
93
|
state.transientFailures = 0;
|
|
94
|
+
state.rateLimitedThisTurn = false;
|
|
81
95
|
}
|
|
82
96
|
|
|
83
|
-
// Usage
|
|
84
|
-
|
|
97
|
+
// Usage and per-turn memory belong to the active session: a new session starts
|
|
98
|
+
// counting from zero. The breaker stays, because it tracks the key.
|
|
99
|
+
export function resetJevSession(state: JevGuardState): void {
|
|
85
100
|
state.calls = 0;
|
|
101
|
+
state.rateLimitedThisTurn = false;
|
|
102
|
+
state.pending.clear();
|
|
103
|
+
state.notedThisTurn.clear();
|
|
86
104
|
}
|
|
87
105
|
|
|
88
106
|
export function guardStatus(state: JevGuardState): JevStatus {
|
|
89
107
|
if (state.halted !== undefined) {
|
|
90
108
|
return state.halted;
|
|
91
109
|
}
|
|
110
|
+
if (state.rateLimitedThisTurn) {
|
|
111
|
+
return JEV_STATUS.rateLimited;
|
|
112
|
+
}
|
|
92
113
|
return state.transientFailures > 0
|
|
93
114
|
? JEV_STATUS.unavailable
|
|
94
115
|
: JEV_STATUS.ready;
|
|
@@ -97,6 +118,7 @@ export function guardStatus(state: JevGuardState): JevStatus {
|
|
|
97
118
|
export function shouldCallJev(state: JevGuardState): boolean {
|
|
98
119
|
return (
|
|
99
120
|
state.halted === undefined &&
|
|
121
|
+
!state.rateLimitedThisTurn &&
|
|
100
122
|
state.transientFailures < JEV_TRANSIENT_FAILURE_LIMIT
|
|
101
123
|
);
|
|
102
124
|
}
|
|
@@ -107,17 +129,24 @@ export function registerJevGuard(
|
|
|
107
129
|
dependencies: JevGuardDependencies,
|
|
108
130
|
): void {
|
|
109
131
|
pi.on('input', (event) => {
|
|
110
|
-
if (event
|
|
132
|
+
if (!startsUserTurn(event)) {
|
|
111
133
|
return;
|
|
112
134
|
}
|
|
113
135
|
state.notedThisTurn.clear();
|
|
136
|
+
state.rateLimitedThisTurn = false;
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// A call another extension blocks in tool_call never reaches tool_result,
|
|
140
|
+
// and every result of a turn lands before its turn_end.
|
|
141
|
+
pi.on('turn_end', () => {
|
|
142
|
+
state.pending.clear();
|
|
114
143
|
});
|
|
115
144
|
|
|
116
145
|
// The added lines are read before the mutation lands, because after a write
|
|
117
146
|
// the previous content is gone. The body is read after, from disk.
|
|
118
147
|
pi.on('tool_call', (event, ctx) => {
|
|
119
148
|
const mutation = extractMutation(event, ctx.cwd);
|
|
120
|
-
if (mutation === undefined) {
|
|
149
|
+
if (mutation === undefined || !isWorthAsking(mutation)) {
|
|
121
150
|
return undefined;
|
|
122
151
|
}
|
|
123
152
|
state.pending.set(event.toolCallId, mutation);
|
|
@@ -144,8 +173,17 @@ export function registerJevGuard(
|
|
|
144
173
|
return undefined;
|
|
145
174
|
}
|
|
146
175
|
|
|
147
|
-
const body =
|
|
148
|
-
if (body === undefined
|
|
176
|
+
const body = readFileIfExists(pending.absolutePath);
|
|
177
|
+
if (body === undefined) {
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
const fileState = buildFileState(
|
|
181
|
+
pending.relativePath,
|
|
182
|
+
body,
|
|
183
|
+
pending.kind,
|
|
184
|
+
pending.lines,
|
|
185
|
+
);
|
|
186
|
+
if (!stateFitsBudget(fileState) || ctx.signal?.aborted === true) {
|
|
149
187
|
return undefined;
|
|
150
188
|
}
|
|
151
189
|
|
|
@@ -153,7 +191,8 @@ export function registerJevGuard(
|
|
|
153
191
|
const attempt = await askJev(
|
|
154
192
|
dependencies.transport,
|
|
155
193
|
apiKey,
|
|
156
|
-
|
|
194
|
+
fileState,
|
|
195
|
+
ctx.signal,
|
|
157
196
|
);
|
|
158
197
|
if (attempt.failure !== undefined) {
|
|
159
198
|
registerFailure(state, attempt.failure);
|
|
@@ -169,18 +208,17 @@ export function registerJevGuard(
|
|
|
169
208
|
|
|
170
209
|
const findings = attempt.findings ?? [];
|
|
171
210
|
const fresh = findings.filter((finding) =>
|
|
172
|
-
isUnnoted(state, pending.
|
|
211
|
+
isUnnoted(state, pending.relativePath, finding),
|
|
173
212
|
);
|
|
174
213
|
if (fresh.length === 0) {
|
|
175
214
|
return undefined;
|
|
176
215
|
}
|
|
177
|
-
markNoted(state, pending.
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
...event.content,
|
|
181
|
-
{ type: 'text' as const, text: formatFindings(pending.path, fresh) },
|
|
182
|
-
],
|
|
216
|
+
markNoted(state, pending.relativePath, fresh);
|
|
217
|
+
const note: ReviewNote = {
|
|
218
|
+
text: formatFindings(pending.relativePath, fresh),
|
|
183
219
|
};
|
|
220
|
+
pi.events.emit(REVIEW_NOTE_EVENT, note);
|
|
221
|
+
return undefined;
|
|
184
222
|
});
|
|
185
223
|
}
|
|
186
224
|
|
|
@@ -202,7 +240,16 @@ function markNoted(
|
|
|
202
240
|
}
|
|
203
241
|
}
|
|
204
242
|
|
|
243
|
+
// A user abort says nothing about the service, and a rate limit clears on its
|
|
244
|
+
// own, so neither may count toward switching Jev off.
|
|
205
245
|
function registerFailure(state: JevGuardState, failure: JevFailure): void {
|
|
246
|
+
if (failure === JEV_FAILURE.cancelled) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (failure === JEV_FAILURE.rateLimited) {
|
|
250
|
+
state.rateLimitedThisTurn = true;
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
206
253
|
if (failure === JEV_FAILURE.quota) {
|
|
207
254
|
state.halted = JEV_STATUS.quota;
|
|
208
255
|
return;
|
|
@@ -222,24 +269,37 @@ function registerSuccess(state: JevGuardState): void {
|
|
|
222
269
|
state.transientFailures = 0;
|
|
223
270
|
}
|
|
224
271
|
|
|
272
|
+
// A deletion-only edit leaves nothing for the six questions to read, and only
|
|
273
|
+
// the project-relative path of a supported language may leave the machine.
|
|
274
|
+
function isWorthAsking(mutation: PendingMutation): boolean {
|
|
275
|
+
return mutation.lines.length > 0 && isReviewablePath(mutation.relativePath);
|
|
276
|
+
}
|
|
277
|
+
|
|
225
278
|
function extractMutation(
|
|
226
279
|
event: ToolCallEvent,
|
|
227
280
|
cwd: string,
|
|
228
281
|
): PendingMutation | undefined {
|
|
229
282
|
if (isToolCallEventType('write', event)) {
|
|
230
|
-
const
|
|
283
|
+
const target = resolveTarget(cwd, event.input.path);
|
|
284
|
+
if (target === undefined) {
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
231
287
|
return {
|
|
232
|
-
|
|
288
|
+
...target,
|
|
233
289
|
kind: MUTATION_KIND.write,
|
|
234
290
|
lines: addedLinesForWrite(
|
|
235
|
-
|
|
291
|
+
readFileIfExists(target.absolutePath),
|
|
236
292
|
event.input.content,
|
|
237
293
|
),
|
|
238
294
|
};
|
|
239
295
|
}
|
|
240
296
|
if (isToolCallEventType('edit', event)) {
|
|
297
|
+
const target = resolveTarget(cwd, event.input.path);
|
|
298
|
+
if (target === undefined) {
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
241
301
|
return {
|
|
242
|
-
|
|
302
|
+
...target,
|
|
243
303
|
kind: MUTATION_KIND.edit,
|
|
244
304
|
lines: addedLinesForEdit(event.input.edits),
|
|
245
305
|
};
|
|
@@ -247,10 +307,14 @@ function extractMutation(
|
|
|
247
307
|
return undefined;
|
|
248
308
|
}
|
|
249
309
|
|
|
250
|
-
function
|
|
310
|
+
function resolveTarget(cwd: string, path: string): MutationTarget | undefined {
|
|
251
311
|
const absolutePath = resolveFileToolPath(cwd, path);
|
|
252
312
|
if (absolutePath === undefined) {
|
|
253
313
|
return undefined;
|
|
254
314
|
}
|
|
255
|
-
|
|
315
|
+
const relativePath = projectRelativePath(cwd, absolutePath);
|
|
316
|
+
if (relativePath === undefined) {
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
return { absolutePath, relativePath };
|
|
256
320
|
}
|
package/extensions/jev/index.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
type JevStatus,
|
|
12
12
|
registerJevGuard,
|
|
13
13
|
resetJevGuard,
|
|
14
|
-
|
|
14
|
+
resetJevSession,
|
|
15
15
|
} from './guard.ts';
|
|
16
16
|
import {
|
|
17
17
|
clearStoredKey,
|
|
@@ -75,7 +75,7 @@ export default function registerJev(pi: ExtensionAPI): void {
|
|
|
75
75
|
});
|
|
76
76
|
|
|
77
77
|
pi.on('session_start', (_event, ctx) => {
|
|
78
|
-
|
|
78
|
+
resetJevSession(guardState);
|
|
79
79
|
publish(currentStatus(), ctx);
|
|
80
80
|
});
|
|
81
81
|
|
package/extensions/jev/model.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { languageForPath } from '../rules/catalog.ts';
|
|
1
|
+
import { LANGUAGE, languageForPath } from '../rules/catalog.ts';
|
|
2
2
|
import { type AddedLine, addedLines } from '../rules/model.ts';
|
|
3
3
|
|
|
4
4
|
export type { AddedLine } from '../rules/model.ts';
|
|
@@ -11,8 +11,9 @@ export const JEV_MODEL = 'jev-latest';
|
|
|
11
11
|
// noise on every edit.
|
|
12
12
|
export const VIOLATION_PROBABILITY = 0.8;
|
|
13
13
|
|
|
14
|
-
// The state must stay well under the two documented budgets (64k
|
|
15
|
-
// whole request, 32k for the state plus the longest question). A
|
|
14
|
+
// The serialized state must stay well under the two documented budgets (64k
|
|
15
|
+
// tokens for the whole request, 32k for the state plus the longest question). A
|
|
16
|
+
// new file travels twice, as the body and as its added lines. A state that does
|
|
16
17
|
// not fit is skipped, never truncated: half a file reads as a distorted picture
|
|
17
18
|
// and Jev answers it with the same confidence.
|
|
18
19
|
export const MAX_STATE_CHARACTERS = 60_000;
|
|
@@ -167,8 +168,14 @@ export function addedLinesForEdit(
|
|
|
167
168
|
return edits.flatMap((edit) => addedLines(edit.oldText, edit.newText));
|
|
168
169
|
}
|
|
169
170
|
|
|
170
|
-
|
|
171
|
-
|
|
171
|
+
// The six questions are about code structure; a README or a lockfile would
|
|
172
|
+
// spend a call on answers that mean nothing.
|
|
173
|
+
export function isReviewablePath(path: string): boolean {
|
|
174
|
+
return languageForPath(path) !== LANGUAGE.generic;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function stateFitsBudget(state: JevFileState): boolean {
|
|
178
|
+
return JSON.stringify(state).length <= MAX_STATE_CHARACTERS;
|
|
172
179
|
}
|
|
173
180
|
|
|
174
181
|
export function buildRequestBody(file: JevFileState): JevRequestBody {
|
package/extensions/jev/ui.ts
CHANGED
|
@@ -30,6 +30,7 @@ const STATUS_LABEL: Readonly<Record<JevStatus, string>> = {
|
|
|
30
30
|
[JEV_STATUS.noKey]: 'jev: no key (F10)',
|
|
31
31
|
[JEV_STATUS.quota]: 'jev: out of quota (F10)',
|
|
32
32
|
[JEV_STATUS.auth]: 'jev: key rejected (F10)',
|
|
33
|
+
[JEV_STATUS.rateLimited]: 'jev: rate limited',
|
|
33
34
|
[JEV_STATUS.unavailable]: 'jev: unreachable',
|
|
34
35
|
};
|
|
35
36
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { realpathSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
|
-
import { isAbsolute, join, resolve } from 'node:path';
|
|
3
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
|
|
6
6
|
const FILE_URL_PREFIX = 'file://';
|
|
@@ -29,6 +29,31 @@ export function resolveFileToolPath(
|
|
|
29
29
|
return realpathOr(absolutePath);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
// A file that does not exist yet keeps its unresolved path, so it is matched
|
|
33
|
+
// against the literal working directory as well as the canonical one.
|
|
34
|
+
export function projectRelativePath(
|
|
35
|
+
cwd: string,
|
|
36
|
+
absolutePath: string,
|
|
37
|
+
): string | undefined {
|
|
38
|
+
const literalRoot = resolve(cwd);
|
|
39
|
+
for (const root of new Set([realpathOr(literalRoot), literalRoot])) {
|
|
40
|
+
const relativePath = relative(root, absolutePath);
|
|
41
|
+
if (isInsideRoot(relativePath)) {
|
|
42
|
+
return relativePath;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isInsideRoot(relativePath: string): boolean {
|
|
49
|
+
return (
|
|
50
|
+
relativePath.length > 0 &&
|
|
51
|
+
relativePath !== '..' &&
|
|
52
|
+
!relativePath.startsWith(`..${sep}`) &&
|
|
53
|
+
!isAbsolute(relativePath)
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
32
57
|
function normalizeFileToolPath(path: string): string {
|
|
33
58
|
const normalizedSpaces = path.replace(UNICODE_SPACES, ' ');
|
|
34
59
|
const withoutAtPrefix = normalizedSpaces.startsWith('@')
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const REVIEW_NOTE_EVENT = 'sideroom:review-note';
|
|
2
|
+
|
|
3
|
+
export interface ReviewNote {
|
|
4
|
+
readonly text: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function isReviewNote(value: unknown): value is ReviewNote {
|
|
8
|
+
return (
|
|
9
|
+
typeof value === 'object' &&
|
|
10
|
+
value !== null &&
|
|
11
|
+
'text' in value &&
|
|
12
|
+
typeof value.text === 'string'
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentBeforeSettleEvent,
|
|
3
|
+
AgentBeforeSettleEventResult,
|
|
4
|
+
InputEvent,
|
|
5
|
+
InputSource,
|
|
6
|
+
} from '@earendil-works/pi-coding-agent';
|
|
7
|
+
|
|
8
|
+
const COMPLETED_OUTCOME = 'completed';
|
|
9
|
+
const USER_ROLE = 'user';
|
|
10
|
+
const CUSTOM_MESSAGE_ENTRY = 'custom_message';
|
|
11
|
+
const USER_INPUT_SOURCES: ReadonlySet<InputSource> = new Set([
|
|
12
|
+
'interactive',
|
|
13
|
+
'rpc',
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
// A message typed while the agent runs joins the current run as a steer or
|
|
17
|
+
// follow-up; only input that arrives idle opens a new turn.
|
|
18
|
+
export function startsUserTurn(event: InputEvent): boolean {
|
|
19
|
+
return (
|
|
20
|
+
USER_INPUT_SOURCES.has(event.source) &&
|
|
21
|
+
event.streamingBehavior === undefined
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// An aborted run is the user stopping the agent, an errored run has nothing
|
|
26
|
+
// worth reviewing, and a pending user message already decides what comes next.
|
|
27
|
+
export function acceptsSettleSteer(event: AgentBeforeSettleEvent): boolean {
|
|
28
|
+
if (event.outcome !== COMPLETED_OUTCOME) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
return !event.context.pendingMessages.some(
|
|
32
|
+
(message) => message.role === USER_ROLE,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Handlers chain on agent_before_settle, so keep the entries earlier handlers
|
|
37
|
+
// proposed; replacing them would drop another extension's steer.
|
|
38
|
+
export function withHiddenSteer(
|
|
39
|
+
event: AgentBeforeSettleEvent,
|
|
40
|
+
customType: string,
|
|
41
|
+
content: string,
|
|
42
|
+
): AgentBeforeSettleEventResult {
|
|
43
|
+
return {
|
|
44
|
+
entries: [
|
|
45
|
+
...event.entries,
|
|
46
|
+
{ type: CUSTOM_MESSAGE_ENTRY, customType, content, display: false },
|
|
47
|
+
],
|
|
48
|
+
continue: true,
|
|
49
|
+
};
|
|
50
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmrdeveloper/sideroom-pi",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.10.0",
|
|
4
4
|
"description": "Pi coding-agent package that asks clarifying questions, shows a live todo board, enforces coding guidelines, and will not call it done while your checks are red.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -89,8 +89,8 @@
|
|
|
89
89
|
"@changesets/cli": "^3.0.2",
|
|
90
90
|
"@commitlint/cli": "^21.2.2",
|
|
91
91
|
"@commitlint/config-conventional": "^21.2.2",
|
|
92
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
93
|
-
"@earendil-works/pi-tui": "0.
|
|
92
|
+
"@earendil-works/pi-coding-agent": "0.87.1",
|
|
93
|
+
"@earendil-works/pi-tui": "0.87.1",
|
|
94
94
|
"@types/node": "^22.20.1",
|
|
95
95
|
"typebox": "1.3.7",
|
|
96
96
|
"typescript": "^5.6.0"
|
|
@@ -77,9 +77,12 @@ dead-locked.
|
|
|
77
77
|
### What steering means
|
|
78
78
|
|
|
79
79
|
The extension sends a corrective steer after a degraded artifact block or a
|
|
80
|
-
prohibited assistant message.
|
|
81
|
-
|
|
82
|
-
|
|
80
|
+
prohibited assistant message. Both moments happen while the agent is working, so
|
|
81
|
+
the steer travels through `pi.sendMessage` with `{ triggerTurn: true, deliverAs:
|
|
82
|
+
'steer' }` and joins the current run. A steer sent after the run ended belongs
|
|
83
|
+
to the guidelines review and the walkthrough offer, which append a
|
|
84
|
+
`custom_message` entry on `agent_before_settle` instead.
|
|
85
|
+
At most three steers are sent per agent run.
|
|
83
86
|
|
|
84
87
|
## Boundaries
|
|
85
88
|
|