@rmrdeveloper/sideroom-pi 9.0.0 → 9.1.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 +23 -28
- package/assets/visual-context/browser/background.js +2 -2
- package/assets/visual-context/browser/popup.js +1 -2
- package/extensions/ask/index.ts +0 -1
- package/extensions/ask/model.ts +1 -1
- package/extensions/ask/selection.ts +0 -4
- package/extensions/done/guard.ts +20 -0
- package/extensions/done/index.ts +0 -1
- package/extensions/done/model.ts +1 -3
- package/extensions/explain/guard.ts +11 -21
- package/extensions/explain/index.ts +0 -1
- package/extensions/explain/model.ts +0 -5
- package/extensions/guidelines/execute.ts +16 -2
- package/extensions/guidelines/guard.ts +1 -2
- package/extensions/guidelines/index.ts +7 -7
- package/extensions/guidelines/prompt.ts +0 -2
- package/extensions/guidelines/review.ts +9 -10
- package/extensions/jev/client.ts +0 -7
- package/extensions/jev/context.ts +0 -2
- package/extensions/jev/guard.ts +78 -44
- package/extensions/jev/index.ts +0 -3
- package/extensions/jev/key.ts +0 -3
- package/extensions/jev/model.ts +0 -8
- package/extensions/jev/queue.ts +125 -0
- package/extensions/jev/ui.ts +0 -14
- package/extensions/modified-files/index.ts +0 -1
- package/extensions/monorepo-skills/index.ts +0 -1
- package/extensions/persona/checks.ts +0 -4
- package/extensions/persona/guard.ts +45 -6
- package/extensions/persona/index.ts +4 -2
- package/extensions/persona/model.ts +0 -2
- package/extensions/persona/prompt.ts +0 -2
- package/extensions/rules/checks.ts +0 -5
- package/extensions/rules/index.ts +0 -1
- package/extensions/rules/lexer.ts +1 -1
- package/extensions/shared/check-status.ts +35 -0
- package/extensions/shared/file-path.ts +0 -3
- package/extensions/shared/read-file.ts +0 -2
- package/extensions/shared/settle.ts +78 -13
- package/extensions/todo/guards.ts +40 -84
- package/extensions/todo/index.ts +1 -2
- package/extensions/todo/model.ts +6 -48
- package/extensions/todo/session.ts +1 -4
- package/extensions/visual-context/broker.ts +10 -10
- package/extensions/visual-context/configure.ts +4 -4
- package/extensions/visual-context/files.ts +0 -3
- package/extensions/visual-context/index.ts +8 -9
- package/extensions/visual-context/literal.ts +0 -1
- package/extensions/visual-context/markup.ts +3 -5
- package/extensions/visual-context/model.ts +2 -2
- package/extensions/visual-context/pairing.ts +1 -2
- package/extensions/visual-context/parse-astro.ts +9 -12
- package/extensions/visual-context/parse-jsx.ts +2 -2
- package/extensions/visual-context/parse-vue.ts +1 -1
- package/extensions/visual-context/setup.ts +8 -8
- package/extensions/visual-context/source.ts +0 -3
- package/package.json +15 -9
- package/skills/sideroom-guidelines/SKILL.md +1 -1
- package/skills/sideroom-persona/SKILL.md +8 -6
- package/skills/sideroom-user-stories/SKILL.md +78 -0
- package/skills/sideroom-user-stories/references/review.md +84 -0
- package/skills/sideroom-user-stories/references/templates.md +131 -0
package/extensions/jev/guard.ts
CHANGED
|
@@ -13,7 +13,8 @@ import {
|
|
|
13
13
|
} from '../shared/file-path.ts';
|
|
14
14
|
import { readFileIfExists } from '../shared/read-file.ts';
|
|
15
15
|
import { REVIEW_NOTE_EVENT, type ReviewNote } from '../shared/review-note.ts';
|
|
16
|
-
import { startsUserTurn } from '../shared/settle.ts';
|
|
16
|
+
import { registerSettleWait, startsUserTurn } from '../shared/settle.ts';
|
|
17
|
+
import { JevReviewQueue } from './queue.ts';
|
|
17
18
|
import {
|
|
18
19
|
askJevRequest,
|
|
19
20
|
JEV_FAILURE,
|
|
@@ -78,6 +79,7 @@ export interface JevGuardState {
|
|
|
78
79
|
rateLimitedThisTurn: boolean;
|
|
79
80
|
lastModel: string | undefined;
|
|
80
81
|
calls: number;
|
|
82
|
+
readonly queue: JevReviewQueue;
|
|
81
83
|
readonly evidence: WorkflowEvidence;
|
|
82
84
|
readonly pending: Map<string, PendingMutation>;
|
|
83
85
|
readonly notedThisTurn: Set<string>;
|
|
@@ -96,23 +98,23 @@ export function createJevGuardState(): JevGuardState {
|
|
|
96
98
|
rateLimitedThisTurn: false,
|
|
97
99
|
lastModel: undefined,
|
|
98
100
|
calls: 0,
|
|
101
|
+
queue: new JevReviewQueue(),
|
|
99
102
|
evidence: createWorkflowEvidence(),
|
|
100
103
|
pending: new Map(),
|
|
101
104
|
notedThisTurn: new Set(),
|
|
102
105
|
};
|
|
103
106
|
}
|
|
104
107
|
|
|
105
|
-
// The F10 screen calls this after the user stores a key or asks to retry.
|
|
106
108
|
export function resetJevGuard(state: JevGuardState): void {
|
|
109
|
+
state.queue.cancel();
|
|
107
110
|
resetWorkflowEvidence(state.evidence);
|
|
108
111
|
state.halted = undefined;
|
|
109
112
|
state.transientFailures = 0;
|
|
110
113
|
state.rateLimitedThisTurn = false;
|
|
111
114
|
}
|
|
112
115
|
|
|
113
|
-
// Usage and per-turn memory belong to the active session: a new session starts
|
|
114
|
-
// counting from zero. The breaker stays, because it tracks the key.
|
|
115
116
|
export function resetJevSession(state: JevGuardState): void {
|
|
117
|
+
state.queue.cancel();
|
|
116
118
|
resetWorkflowEvidence(state.evidence);
|
|
117
119
|
state.calls = 0;
|
|
118
120
|
state.rateLimitedThisTurn = false;
|
|
@@ -145,23 +147,23 @@ export function registerJevGuard(
|
|
|
145
147
|
state: JevGuardState,
|
|
146
148
|
dependencies: JevGuardDependencies,
|
|
147
149
|
): void {
|
|
150
|
+
registerSettleWait(pi, () => state.queue.drain());
|
|
151
|
+
pi.on('session_shutdown', () => state.queue.cancel());
|
|
152
|
+
|
|
148
153
|
pi.on('input', (event) => {
|
|
149
154
|
if (!startsUserTurn(event)) {
|
|
150
155
|
return;
|
|
151
156
|
}
|
|
157
|
+
state.queue.cancel();
|
|
152
158
|
resetWorkflowEvidence(state.evidence);
|
|
153
159
|
state.notedThisTurn.clear();
|
|
154
160
|
state.rateLimitedThisTurn = false;
|
|
155
161
|
});
|
|
156
162
|
|
|
157
|
-
// A call another extension blocks in tool_call never reaches tool_result,
|
|
158
|
-
// and every result of a turn lands before its turn_end.
|
|
159
163
|
pi.on('turn_end', () => {
|
|
160
164
|
state.pending.clear();
|
|
161
165
|
});
|
|
162
166
|
|
|
163
|
-
// The added lines are read before the mutation lands, because after a write
|
|
164
|
-
// the previous content is gone. The body is read after, from disk.
|
|
165
167
|
pi.on('tool_call', (event, ctx) => {
|
|
166
168
|
const mutation = extractMutation(event, ctx.cwd);
|
|
167
169
|
if (mutation === undefined || !isReviewablePath(mutation.relativePath)) {
|
|
@@ -171,9 +173,13 @@ export function registerJevGuard(
|
|
|
171
173
|
return undefined;
|
|
172
174
|
});
|
|
173
175
|
|
|
174
|
-
pi.on('tool_result',
|
|
176
|
+
pi.on('tool_result', (event, ctx) => {
|
|
175
177
|
const pending = state.pending.get(event.toolCallId);
|
|
176
178
|
state.pending.delete(event.toolCallId);
|
|
179
|
+
if (!event.isError && pending !== undefined) {
|
|
180
|
+
state.queue.invalidate(pending.relativePath);
|
|
181
|
+
state.queue.invalidate('sideroom_todo');
|
|
182
|
+
}
|
|
177
183
|
const apiKey = dependencies.apiKey();
|
|
178
184
|
if (
|
|
179
185
|
apiKey === undefined ||
|
|
@@ -196,6 +202,7 @@ export function registerJevGuard(
|
|
|
196
202
|
const check = detectCheckCommand(ctx.cwd);
|
|
197
203
|
if (check !== undefined && commandMatches(event.input.command, check)) {
|
|
198
204
|
state.evidence.checks = 'failed';
|
|
205
|
+
state.queue.invalidate('sideroom_todo');
|
|
199
206
|
}
|
|
200
207
|
}
|
|
201
208
|
return undefined;
|
|
@@ -204,11 +211,13 @@ export function registerJevGuard(
|
|
|
204
211
|
const check = detectCheckCommand(ctx.cwd);
|
|
205
212
|
if (check !== undefined && commandMatches(event.input.command, check)) {
|
|
206
213
|
state.evidence.checks = 'passed';
|
|
214
|
+
state.queue.invalidate('sideroom_todo');
|
|
207
215
|
}
|
|
208
216
|
return undefined;
|
|
209
217
|
}
|
|
210
218
|
let requestBody: JevRequestBody | undefined;
|
|
211
219
|
let subject: string;
|
|
220
|
+
let sourcePaths: readonly string[] = [];
|
|
212
221
|
if (event.toolName === 'sideroom_ask') {
|
|
213
222
|
if (
|
|
214
223
|
typeof event.details !== 'object' ||
|
|
@@ -231,6 +240,7 @@ export function registerJevGuard(
|
|
|
231
240
|
changedIds: completionIds(event.input),
|
|
232
241
|
});
|
|
233
242
|
subject = 'sideroom_todo';
|
|
243
|
+
sourcePaths = [...state.evidence.files.keys()];
|
|
234
244
|
} else {
|
|
235
245
|
if (
|
|
236
246
|
pending === undefined ||
|
|
@@ -240,7 +250,7 @@ export function registerJevGuard(
|
|
|
240
250
|
}
|
|
241
251
|
subject = pending.relativePath;
|
|
242
252
|
state.evidence.checks = 'stale';
|
|
243
|
-
|
|
253
|
+
const previous = state.evidence.files.get(subject);
|
|
244
254
|
state.evidence.files.delete(subject);
|
|
245
255
|
if (!isWorthAsking(pending)) {
|
|
246
256
|
state.evidence.partial = true;
|
|
@@ -256,7 +266,7 @@ export function registerJevGuard(
|
|
|
256
266
|
subject,
|
|
257
267
|
body,
|
|
258
268
|
pending.kind,
|
|
259
|
-
pending.lines,
|
|
269
|
+
mergeReviewLines({ previous: previous?.change.addedLines ?? [], added: pending.lines, body }),
|
|
260
270
|
);
|
|
261
271
|
if (fileState === undefined) {
|
|
262
272
|
state.evidence.partial = true;
|
|
@@ -264,46 +274,74 @@ export function registerJevGuard(
|
|
|
264
274
|
}
|
|
265
275
|
recordFileEvidence(state.evidence, fileState);
|
|
266
276
|
requestBody = buildRequestBody(fileState);
|
|
277
|
+
sourcePaths = [subject, ...fileState.context.files.map((file) => file.path)];
|
|
267
278
|
}
|
|
268
279
|
if (requestBody === undefined) {
|
|
269
280
|
return undefined;
|
|
270
281
|
}
|
|
271
282
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
requestBody,
|
|
283
|
+
const body = requestBody;
|
|
284
|
+
return state.queue.enqueue({
|
|
285
|
+
key: subject,
|
|
286
|
+
dependencies: sourcePaths,
|
|
277
287
|
signal: ctx.signal,
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
288
|
+
run: async (signal) => {
|
|
289
|
+
if (!shouldCallJev(state) || dependencies.apiKey() !== apiKey || signal.aborted) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
state.calls += 1;
|
|
293
|
+
const attempt = await askJevRequest({
|
|
294
|
+
transport: dependencies.transport,
|
|
295
|
+
apiKey,
|
|
296
|
+
requestBody: body,
|
|
297
|
+
signal,
|
|
298
|
+
});
|
|
299
|
+
if (signal.aborted || dependencies.apiKey() !== apiKey) {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (attempt.failure !== undefined) {
|
|
303
|
+
registerFailure(state, attempt.failure);
|
|
304
|
+
dependencies.report(guardStatus(state), ctx);
|
|
305
|
+
return undefined;
|
|
306
|
+
}
|
|
284
307
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
308
|
+
registerSuccess(state);
|
|
309
|
+
if (attempt.model !== undefined) {
|
|
310
|
+
state.lastModel = attempt.model;
|
|
311
|
+
}
|
|
312
|
+
dependencies.report(JEV_STATUS.ready, ctx);
|
|
290
313
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
314
|
+
const findings = attempt.findings ?? [];
|
|
315
|
+
const fresh = findings.filter((finding) =>
|
|
316
|
+
isUnnoted(state, subject, finding),
|
|
317
|
+
);
|
|
318
|
+
if (fresh.length === 0) {
|
|
319
|
+
return undefined;
|
|
320
|
+
}
|
|
321
|
+
markNoted(state, subject, fresh);
|
|
322
|
+
const note: ReviewNote = {
|
|
323
|
+
text: formatFindings(subject, fresh),
|
|
324
|
+
};
|
|
325
|
+
pi.events.emit(REVIEW_NOTE_EVENT, note);
|
|
326
|
+
return undefined;
|
|
327
|
+
},
|
|
328
|
+
});
|
|
304
329
|
});
|
|
305
330
|
}
|
|
306
331
|
|
|
332
|
+
interface ReviewLinesInput {
|
|
333
|
+
readonly previous: readonly string[];
|
|
334
|
+
readonly added: readonly AddedLine[];
|
|
335
|
+
readonly body: string;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function mergeReviewLines({ previous, added, body }: ReviewLinesInput): readonly AddedLine[] {
|
|
339
|
+
const changed = new Set([...previous, ...added.map((line) => line.text)].map((text) => text.trim()));
|
|
340
|
+
return body.split('\n').flatMap((text, index) =>
|
|
341
|
+
changed.has(text.trim()) ? [{ line: index + 1, text }] : [],
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
307
345
|
function isUnnoted(
|
|
308
346
|
state: JevGuardState,
|
|
309
347
|
path: string,
|
|
@@ -322,8 +360,6 @@ function markNoted(
|
|
|
322
360
|
}
|
|
323
361
|
}
|
|
324
362
|
|
|
325
|
-
// A user abort says nothing about the service, and a rate limit clears on its
|
|
326
|
-
// own, so neither may count toward switching Jev off.
|
|
327
363
|
function registerFailure(state: JevGuardState, failure: JevFailure): void {
|
|
328
364
|
if (failure === JEV_FAILURE.cancelled) {
|
|
329
365
|
return;
|
|
@@ -351,8 +387,6 @@ function registerSuccess(state: JevGuardState): void {
|
|
|
351
387
|
state.transientFailures = 0;
|
|
352
388
|
}
|
|
353
389
|
|
|
354
|
-
// A deletion-only edit leaves nothing for the questions to read, and only
|
|
355
|
-
// the project-relative path of a supported language may leave the machine.
|
|
356
390
|
function isWorthAsking(mutation: PendingMutation): boolean {
|
|
357
391
|
return mutation.lines.length > 0 && isReviewablePath(mutation.relativePath);
|
|
358
392
|
}
|
package/extensions/jev/index.ts
CHANGED
|
@@ -26,13 +26,10 @@ import {
|
|
|
26
26
|
statusLabel,
|
|
27
27
|
} from './ui.ts';
|
|
28
28
|
|
|
29
|
-
// Pi loads extensions/*/index.ts through export default.
|
|
30
29
|
export default function registerJev(pi: ExtensionAPI): void {
|
|
31
30
|
const configPath = configPathFor(getAgentDir());
|
|
32
31
|
const guardState = createJevGuardState();
|
|
33
32
|
|
|
34
|
-
// The key is read on each use instead of cached at load time, so a key stored
|
|
35
|
-
// during a running session takes effect without a restart.
|
|
36
33
|
const readKey = () => resolveApiKey(process.env, configPath);
|
|
37
34
|
|
|
38
35
|
const currentStatus = (): JevStatus => {
|
package/extensions/jev/key.ts
CHANGED
|
@@ -17,9 +17,6 @@ export function configPathFor(agentDirectory: string): string {
|
|
|
17
17
|
return join(agentDirectory, SIDEROOM_CONFIG_FILE);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
// The environment wins so containers and CI work with no file at all. A missing
|
|
21
|
-
// or malformed config means "no key"; anything else about the file is a real
|
|
22
|
-
// problem and surfaces instead of hiding.
|
|
23
20
|
export function resolveApiKey(
|
|
24
21
|
environment: Readonly<Record<string, string | undefined>>,
|
|
25
22
|
configPath: string,
|
package/extensions/jev/model.ts
CHANGED
|
@@ -6,14 +6,8 @@ export type { AddedLine } from '../rules/model.ts';
|
|
|
6
6
|
export const JEV_ENDPOINT = 'https://api.typesafe.ai/v1/systemone';
|
|
7
7
|
export const JEV_MODEL = 'jev-latest';
|
|
8
8
|
|
|
9
|
-
// A noul answer carries a probability and no confidence, so this cutoff is the
|
|
10
|
-
// only brake. Below it Jev is saying it has nothing to go on, which would be
|
|
11
|
-
// noise on every edit.
|
|
12
9
|
export const VIOLATION_PROBABILITY = 0.8;
|
|
13
10
|
|
|
14
|
-
// TypeSafe documents 32k tokens for state plus the longest question and 64k
|
|
15
|
-
// for the full request. JSON UTF-8 bytes are a deliberately conservative
|
|
16
|
-
// upper bound; no third-party tokenizer can promise the model's exact count.
|
|
17
11
|
export const MAX_STATE_BYTES = 32_000;
|
|
18
12
|
export const MAX_REQUEST_BYTES = 64_000;
|
|
19
13
|
|
|
@@ -30,7 +24,6 @@ export interface JevRule {
|
|
|
30
24
|
readonly question: JevNoulQuestion;
|
|
31
25
|
}
|
|
32
26
|
|
|
33
|
-
// Questions must only use evidence in the file and the selected direct neighbours.
|
|
34
27
|
export const JEV_RULES: readonly JevRule[] = [
|
|
35
28
|
{
|
|
36
29
|
id: 'guard-clauses',
|
|
@@ -290,7 +283,6 @@ export function addedLinesForEdit(
|
|
|
290
283
|
return edits.flatMap((edit) => addedLines(edit.oldText, edit.newText));
|
|
291
284
|
}
|
|
292
285
|
|
|
293
|
-
// Questions are about code structure; a README or a lockfile has no useful state.
|
|
294
286
|
export function isReviewablePath(path: string): boolean {
|
|
295
287
|
return languageForPath(path) !== LANGUAGE.generic;
|
|
296
288
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
const MAX_QUEUED_REVIEWS = 16;
|
|
2
|
+
|
|
3
|
+
interface ReviewJob {
|
|
4
|
+
readonly key: string;
|
|
5
|
+
readonly dependencies?: readonly string[];
|
|
6
|
+
readonly signal?: AbortSignal;
|
|
7
|
+
readonly run: (signal: AbortSignal) => Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface QueuedReview extends ReviewJob {
|
|
11
|
+
readonly controller: AbortController;
|
|
12
|
+
readonly signal: AbortSignal;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class JevReviewQueue {
|
|
16
|
+
private readonly queued = new Map<string, QueuedReview>();
|
|
17
|
+
private active: QueuedReview | undefined;
|
|
18
|
+
private running: Promise<void> | undefined;
|
|
19
|
+
private failures: unknown[] = [];
|
|
20
|
+
private readonly waiting = new Set<QueuedReview>();
|
|
21
|
+
|
|
22
|
+
enqueue(job: ReviewJob): Promise<void> | undefined {
|
|
23
|
+
this.invalidate(job.key);
|
|
24
|
+
const controller = new AbortController();
|
|
25
|
+
const signal = job.signal === undefined
|
|
26
|
+
? controller.signal
|
|
27
|
+
: AbortSignal.any([controller.signal, job.signal]);
|
|
28
|
+
const review = { ...job, controller, signal };
|
|
29
|
+
if (this.queued.size >= MAX_QUEUED_REVIEWS) {
|
|
30
|
+
this.waiting.add(review);
|
|
31
|
+
return this.enqueueWhenReady(review);
|
|
32
|
+
}
|
|
33
|
+
this.queued.set(job.key, review);
|
|
34
|
+
this.pump();
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private async enqueueWhenReady(job: QueuedReview): Promise<void> {
|
|
39
|
+
try {
|
|
40
|
+
while (this.queued.size >= MAX_QUEUED_REVIEWS && this.running !== undefined && !job.signal.aborted) {
|
|
41
|
+
await this.running;
|
|
42
|
+
}
|
|
43
|
+
if (!job.signal.aborted) {
|
|
44
|
+
this.queued.set(job.key, job);
|
|
45
|
+
this.pump();
|
|
46
|
+
}
|
|
47
|
+
} finally {
|
|
48
|
+
this.waiting.delete(job);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
invalidate(path: string): void {
|
|
53
|
+
for (const [key, job] of this.queued) {
|
|
54
|
+
if (key === path || job.dependencies?.includes(path)) {
|
|
55
|
+
job.controller.abort();
|
|
56
|
+
this.queued.delete(key);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
for (const job of this.waiting) {
|
|
60
|
+
if (job.key === path || job.dependencies?.includes(path)) {
|
|
61
|
+
job.controller.abort();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (this.active?.key === path || this.active?.dependencies?.includes(path)) {
|
|
65
|
+
this.active.controller.abort();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
cancel(): void {
|
|
70
|
+
this.active?.controller.abort();
|
|
71
|
+
for (const job of this.queued.values()) {
|
|
72
|
+
job.controller.abort();
|
|
73
|
+
}
|
|
74
|
+
this.queued.clear();
|
|
75
|
+
for (const job of this.waiting) {
|
|
76
|
+
job.controller.abort();
|
|
77
|
+
}
|
|
78
|
+
this.waiting.clear();
|
|
79
|
+
this.failures = [];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async drain(): Promise<void> {
|
|
83
|
+
while (this.running !== undefined || this.waiting.size > 0) {
|
|
84
|
+
await this.running;
|
|
85
|
+
}
|
|
86
|
+
if (this.failures.length > 0) {
|
|
87
|
+
const failures = this.failures;
|
|
88
|
+
this.failures = [];
|
|
89
|
+
throw new AggregateError(failures, 'Jev background review failed');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private pump(): void {
|
|
94
|
+
if (this.running !== undefined) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const next = this.queued.entries().next().value;
|
|
98
|
+
if (next === undefined) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const [key, job] = next;
|
|
102
|
+
this.queued.delete(key);
|
|
103
|
+
if (job.signal.aborted) {
|
|
104
|
+
this.pump();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
this.active = job;
|
|
108
|
+
this.running = Promise.resolve()
|
|
109
|
+
.then(() => {
|
|
110
|
+
if (!job.signal.aborted) {
|
|
111
|
+
return job.run(job.signal);
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
.catch((error: unknown) => {
|
|
115
|
+
if (!job.signal.aborted) {
|
|
116
|
+
this.failures.push(error);
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
.finally(() => {
|
|
120
|
+
this.active = undefined;
|
|
121
|
+
this.running = undefined;
|
|
122
|
+
this.pump();
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
package/extensions/jev/ui.ts
CHANGED
|
@@ -15,14 +15,9 @@ export const JEV_SHORTCUT = 'f10';
|
|
|
15
15
|
const MASK_CHARACTER = '•';
|
|
16
16
|
const CLEAR_KEY = Key.ctrl('u');
|
|
17
17
|
|
|
18
|
-
// Pi wraps every paste in bracketed-paste markers before it reaches a component,
|
|
19
|
-
// so the leading escape byte made the whole chunk look like a control sequence
|
|
20
|
-
// and nothing landed in the field.
|
|
21
18
|
const PASTE_START = '\x1b[200~';
|
|
22
19
|
const PASTE_END = '\x1b[201~';
|
|
23
20
|
|
|
24
|
-
// A pasted line usually carries a trailing newline, and no key holds
|
|
25
|
-
// whitespace, so everything unprintable is dropped.
|
|
26
21
|
export const MAX_KEY_LENGTH = 512;
|
|
27
22
|
|
|
28
23
|
const STATUS_LABEL: Readonly<Record<JevStatus, string>> = {
|
|
@@ -102,8 +97,6 @@ export function createJevKeyController(
|
|
|
102
97
|
};
|
|
103
98
|
}
|
|
104
99
|
|
|
105
|
-
// The packaged Input component has no masking, and a key must never render, so
|
|
106
|
-
// this draws its own single masked field.
|
|
107
100
|
export class JevKeyOverlay implements Component {
|
|
108
101
|
private entry = '';
|
|
109
102
|
private readonly tui: TUI;
|
|
@@ -236,9 +229,6 @@ export class JevKeyOverlay implements Component {
|
|
|
236
229
|
}
|
|
237
230
|
}
|
|
238
231
|
|
|
239
|
-
// Pi re-wraps paste content as `ESC[200~ ... ESC[201~`, so the payload is read
|
|
240
|
-
// between the markers. A paste split across chunks still contributes whatever
|
|
241
|
-
// arrived after the opening marker.
|
|
242
232
|
export function readPastedKey(keyData: string): string | undefined {
|
|
243
233
|
const start = keyData.indexOf(PASTE_START);
|
|
244
234
|
if (start === -1) {
|
|
@@ -250,14 +240,10 @@ export function readPastedKey(keyData: string): string | undefined {
|
|
|
250
240
|
return printableOnly(body);
|
|
251
241
|
}
|
|
252
242
|
|
|
253
|
-
// C0 control bytes and DEL are the only boundaries that matter here, and they
|
|
254
|
-
// live in one place so the two input paths cannot drift apart.
|
|
255
243
|
function isControlCode(code: number): boolean {
|
|
256
244
|
return code < 0x20 || code === 0x7f;
|
|
257
245
|
}
|
|
258
246
|
|
|
259
|
-
// Paste drops the space as well: no key holds one, and a pasted line carries a
|
|
260
|
-
// trailing newline that would otherwise sit right before the closing marker.
|
|
261
247
|
function printableOnly(text: string): string {
|
|
262
248
|
let printable = '';
|
|
263
249
|
for (const character of text) {
|
|
@@ -13,7 +13,6 @@ import { createModifiedFilesOverlayController } from './ui.ts';
|
|
|
13
13
|
|
|
14
14
|
const SHORTCUT = 'f8';
|
|
15
15
|
|
|
16
|
-
// Pi loads extensions/*/index.ts through export default.
|
|
17
16
|
export default function registerModifiedFiles(pi: ExtensionAPI): void {
|
|
18
17
|
const store = createModifiedFilesStore();
|
|
19
18
|
const overlay = createModifiedFilesOverlayController(
|
|
@@ -3,7 +3,6 @@ import { areSkillsDisabled } from './flags.ts';
|
|
|
3
3
|
import { appendMonorepoSkillsNote } from './prompt.ts';
|
|
4
4
|
import { findMonorepoSkillPaths } from './scan.ts';
|
|
5
5
|
|
|
6
|
-
// Pi loads extensions/*/index.ts through export default.
|
|
7
6
|
export default function registerMonorepoSkills(pi: ExtensionAPI): void {
|
|
8
7
|
let hasMonorepoSkills = false;
|
|
9
8
|
|
|
@@ -11,8 +11,6 @@ interface ProhibitionDetector {
|
|
|
11
11
|
readonly pattern: RegExp;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
// Each detector is a heuristic for one prohibition. The rule text in the
|
|
15
|
-
// catalog stays authoritative; a detector never fires on its own judgement.
|
|
16
14
|
const DETECTORS: readonly ProhibitionDetector[] = [
|
|
17
15
|
{
|
|
18
16
|
prohibitionId: 'decorative-symbols',
|
|
@@ -108,8 +106,6 @@ function countLines(content: string | undefined): Map<string, number> {
|
|
|
108
106
|
return counts;
|
|
109
107
|
}
|
|
110
108
|
|
|
111
|
-
// Detector matches are short by construction; drop the bullet or indentation
|
|
112
|
-
// they carry so the reason stays on one line.
|
|
113
109
|
function excerptOf(match: string): string {
|
|
114
110
|
const singleLine = match.replaceAll(/\s+/gu, ' ').trim();
|
|
115
111
|
return singleLine.replace(/^[>*+-]+\s*/u, '');
|
|
@@ -6,6 +6,12 @@ import {
|
|
|
6
6
|
} from '@earendil-works/pi-coding-agent';
|
|
7
7
|
import { resolveFileToolPath } from '../shared/file-path.ts';
|
|
8
8
|
import { readFileIfExists } from '../shared/read-file.ts';
|
|
9
|
+
import {
|
|
10
|
+
registerSettleGuard,
|
|
11
|
+
registerSettleSupplement,
|
|
12
|
+
SETTLE_PRIORITY,
|
|
13
|
+
withHiddenSteer,
|
|
14
|
+
} from '../shared/settle.ts';
|
|
9
15
|
import {
|
|
10
16
|
PERSONA_STEER_TYPE,
|
|
11
17
|
type ProhibitionId,
|
|
@@ -23,11 +29,9 @@ import {
|
|
|
23
29
|
formatSteer,
|
|
24
30
|
} from './model.ts';
|
|
25
31
|
|
|
26
|
-
// A rule that keeps firing degrades to a steer instead of trapping the agent.
|
|
27
32
|
const BLOCK_DEGRADE_AFTER = 3;
|
|
28
33
|
const CLEAN_RESET_AFTER = 5;
|
|
29
34
|
|
|
30
|
-
// One agent run can be corrected at most this many times.
|
|
31
35
|
const STEER_LIMIT_PER_RUN = 3;
|
|
32
36
|
|
|
33
37
|
interface ExtractedMutation {
|
|
@@ -40,6 +44,7 @@ export interface PersonaGuardState {
|
|
|
40
44
|
cleanMutations: number;
|
|
41
45
|
steersThisRun: number;
|
|
42
46
|
blocksThisRun: number;
|
|
47
|
+
pendingProse: string | undefined;
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
export function createPersonaGuardState(): PersonaGuardState {
|
|
@@ -48,12 +53,14 @@ export function createPersonaGuardState(): PersonaGuardState {
|
|
|
48
53
|
cleanMutations: 0,
|
|
49
54
|
steersThisRun: 0,
|
|
50
55
|
blocksThisRun: 0,
|
|
56
|
+
pendingProse: undefined,
|
|
51
57
|
};
|
|
52
58
|
}
|
|
53
59
|
|
|
54
60
|
export function resetPersonaRun(state: PersonaGuardState): void {
|
|
55
61
|
state.steersThisRun = 0;
|
|
56
62
|
state.blocksThisRun = 0;
|
|
63
|
+
state.pendingProse = undefined;
|
|
57
64
|
}
|
|
58
65
|
|
|
59
66
|
export function registerPersonaGuard(
|
|
@@ -73,10 +80,14 @@ export function registerPersonaGuard(
|
|
|
73
80
|
return blockDecision;
|
|
74
81
|
});
|
|
75
82
|
|
|
76
|
-
pi.on('message_end', (event
|
|
83
|
+
pi.on('message_end', (event) => {
|
|
77
84
|
if (event.message.role !== 'assistant') {
|
|
78
85
|
return undefined;
|
|
79
86
|
}
|
|
87
|
+
state.pendingProse = undefined;
|
|
88
|
+
if (event.message.stopReason !== 'stop') {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
80
91
|
const violations = findViolations(
|
|
81
92
|
assistantMessageText(event.message.content),
|
|
82
93
|
TEXT_SCOPE.prose,
|
|
@@ -85,10 +96,38 @@ export function registerPersonaGuard(
|
|
|
85
96
|
return undefined;
|
|
86
97
|
}
|
|
87
98
|
|
|
88
|
-
|
|
89
|
-
report(ctx);
|
|
99
|
+
state.pendingProse = formatSteer(violations);
|
|
90
100
|
return undefined;
|
|
91
101
|
});
|
|
102
|
+
|
|
103
|
+
const pending = () =>
|
|
104
|
+
state.pendingProse !== undefined &&
|
|
105
|
+
state.steersThisRun < STEER_LIMIT_PER_RUN;
|
|
106
|
+
const prepare = (ctx: ExtensionContext) => {
|
|
107
|
+
if (
|
|
108
|
+
state.pendingProse === undefined ||
|
|
109
|
+
state.steersThisRun >= STEER_LIMIT_PER_RUN
|
|
110
|
+
) {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
const content = state.pendingProse;
|
|
114
|
+
state.pendingProse = undefined;
|
|
115
|
+
state.steersThisRun += 1;
|
|
116
|
+
report(ctx);
|
|
117
|
+
return { customType: PERSONA_STEER_TYPE, content };
|
|
118
|
+
};
|
|
119
|
+
registerSettleSupplement(pi, prepare);
|
|
120
|
+
registerSettleGuard(pi, {
|
|
121
|
+
priority: SETTLE_PRIORITY.persona,
|
|
122
|
+
pending,
|
|
123
|
+
handle: (event, ctx) => {
|
|
124
|
+
const message = prepare(ctx);
|
|
125
|
+
if (message === undefined) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
return withHiddenSteer({ event, ...message });
|
|
129
|
+
},
|
|
130
|
+
});
|
|
92
131
|
}
|
|
93
132
|
|
|
94
133
|
function applyArtifactOutcome(
|
|
@@ -160,7 +199,7 @@ function sendPersonaSteer(
|
|
|
160
199
|
state.steersThisRun += 1;
|
|
161
200
|
pi.sendMessage(
|
|
162
201
|
{ customType: PERSONA_STEER_TYPE, content, display: false },
|
|
163
|
-
{ triggerTurn:
|
|
202
|
+
{ triggerTurn: false, deliverAs: 'steer' },
|
|
164
203
|
);
|
|
165
204
|
}
|
|
166
205
|
|
|
@@ -20,7 +20,6 @@ export const PERSONA_DESCRIPTION =
|
|
|
20
20
|
export const PERSONA_PROMPT_SNIPPET =
|
|
21
21
|
'Load the complete writing guide before substantial user-facing prose.';
|
|
22
22
|
|
|
23
|
-
// Pi loads extensions/*/index.ts through export default.
|
|
24
23
|
export default function registerPersona(pi: ExtensionAPI): void {
|
|
25
24
|
const state = createPersonaGuardState();
|
|
26
25
|
const publishStatus = (ctx: ExtensionContext): void => {
|
|
@@ -34,7 +33,10 @@ export default function registerPersona(pi: ExtensionAPI): void {
|
|
|
34
33
|
};
|
|
35
34
|
registerPersonaGuard(pi, state, publishStatus);
|
|
36
35
|
|
|
37
|
-
pi.on('session_start', (_event, ctx) =>
|
|
36
|
+
pi.on('session_start', (_event, ctx) => {
|
|
37
|
+
resetPersonaRun(state);
|
|
38
|
+
publishStatus(ctx);
|
|
39
|
+
});
|
|
38
40
|
|
|
39
41
|
pi.on('before_agent_start', (event, ctx) => {
|
|
40
42
|
resetPersonaRun(state);
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { PERSONA_TOOL_NAME, PROHIBITIONS, VOICE_RULES } from './catalog.ts';
|
|
2
2
|
import type { ProhibitionViolation } from './checks.ts';
|
|
3
3
|
|
|
4
|
-
// Pi narrows these blocks before handing the message over; the shape is
|
|
5
|
-
// structural so no runtime decoding is needed here.
|
|
6
4
|
export interface TextBlock {
|
|
7
5
|
readonly type: string;
|
|
8
6
|
readonly text?: string;
|
|
@@ -15,8 +15,6 @@ const PROHIBITION_SUMMARY = PROHIBITIONS.map(
|
|
|
15
15
|
const ENFORCEMENT_SUMMARY =
|
|
16
16
|
'A write/edit adding a decorative symbol is blocked; other violations trigger a steer.';
|
|
17
17
|
|
|
18
|
-
// Static Do/Don't pairs. Literals only: no per-turn data, so the reminder
|
|
19
|
-
// stays byte-identical across turns and the provider prompt cache holds.
|
|
20
18
|
export const PERSONA_EXAMPLES = [
|
|
21
19
|
"Do: Your work list stays with this conversation, not in project files. Don't: Session-scoped board state persists outside the repository.",
|
|
22
20
|
"Do: To get a fresh answer, turn off the cache (stored results). Don't: Disable the cache invalidation pipeline.",
|