@rmrdeveloper/sideroom-pi 8.9.0 → 8.11.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 +66 -45
- package/extensions/ask/index.ts +3 -2
- 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 +43 -8
- package/extensions/jev/context.ts +327 -0
- package/extensions/jev/guard.ts +92 -25
- package/extensions/jev/index.ts +2 -2
- package/extensions/jev/model.ts +97 -16
- package/extensions/jev/ui.ts +1 -0
- package/extensions/persona/catalog.ts +2 -2
- package/extensions/persona/prompt.ts +2 -2
- 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-architecture/SKILL.md +12 -5
- package/skills/sideroom-grill/SKILL.md +10 -2
- package/skills/sideroom-persona/SKILL.md +34 -24
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { extname, join, posix, relative, sep } from 'node:path';
|
|
3
|
+
import ignore from 'ignore';
|
|
4
|
+
import { LANGUAGE, languageForPath } from '../rules/catalog.ts';
|
|
5
|
+
import {
|
|
6
|
+
buildFileState,
|
|
7
|
+
type JevFileState,
|
|
8
|
+
type JevRelatedFile,
|
|
9
|
+
stateFitsBudget,
|
|
10
|
+
} from './model.ts';
|
|
11
|
+
|
|
12
|
+
const MAX_RELATED_FILES = 4;
|
|
13
|
+
const MAX_SCAN_FILES = 1_024;
|
|
14
|
+
const MAX_SCAN_DEPTH = 10;
|
|
15
|
+
const MAX_CANDIDATE_BYTES = 32_000;
|
|
16
|
+
const IGNORE_FILES = ['.gitignore', '.ignore', '.fdignore'] as const;
|
|
17
|
+
const SENSITIVE_NAME =
|
|
18
|
+
/secret|credential|password|token|private.?key|api.?key|auth.?key|(?:^|[._-])(?:env|private|key)(?:[._-]|$)/iu;
|
|
19
|
+
const SKIP_DIRECTORIES = new Set(['node_modules', 'dist', 'build', 'coverage']);
|
|
20
|
+
|
|
21
|
+
interface Candidate {
|
|
22
|
+
readonly path: string;
|
|
23
|
+
readonly body: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ScanResult {
|
|
27
|
+
readonly candidates: readonly Candidate[];
|
|
28
|
+
readonly partial: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Keep the edited file complete. Other files are included whole or not at all.
|
|
32
|
+
export function buildContextualState(
|
|
33
|
+
cwd: string,
|
|
34
|
+
path: string,
|
|
35
|
+
body: string,
|
|
36
|
+
kind: string,
|
|
37
|
+
lines: Parameters<typeof buildFileState>[3],
|
|
38
|
+
): JevFileState | undefined {
|
|
39
|
+
const base = buildFileState(path, body, kind, lines);
|
|
40
|
+
if (!stateFitsBudget(base)) {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const scan = scanSourceFiles(cwd, path);
|
|
45
|
+
const imported = references(path, body);
|
|
46
|
+
const related: JevRelatedFile[] = [];
|
|
47
|
+
let partial = scan.partial;
|
|
48
|
+
const matches = scan.candidates
|
|
49
|
+
.flatMap((candidate) => {
|
|
50
|
+
const importedByTarget = matchesReference(imported, candidate.path);
|
|
51
|
+
const consumesTarget = matchesReference(
|
|
52
|
+
references(candidate.path, candidate.body),
|
|
53
|
+
path,
|
|
54
|
+
);
|
|
55
|
+
if (!importedByTarget && !consumesTarget) {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
return [
|
|
59
|
+
{
|
|
60
|
+
...candidate,
|
|
61
|
+
relation: importedByTarget ? 'imported' : 'consumer',
|
|
62
|
+
} as const,
|
|
63
|
+
];
|
|
64
|
+
})
|
|
65
|
+
.sort((left, right) => {
|
|
66
|
+
if (left.relation !== right.relation) {
|
|
67
|
+
return left.relation === 'imported' ? -1 : 1;
|
|
68
|
+
}
|
|
69
|
+
return compareText(left.path, right.path);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
for (const candidate of matches) {
|
|
73
|
+
if (related.length >= MAX_RELATED_FILES) {
|
|
74
|
+
partial = true;
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
const next = [...related, candidate];
|
|
78
|
+
if (!stateFitsBudget(buildFileState(path, body, kind, lines, next))) {
|
|
79
|
+
partial = true;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
related.push(candidate);
|
|
83
|
+
}
|
|
84
|
+
if (
|
|
85
|
+
[...imported].some(
|
|
86
|
+
(name) =>
|
|
87
|
+
!related.some((file) => matchesReference(new Set([name]), file.path)),
|
|
88
|
+
)
|
|
89
|
+
) {
|
|
90
|
+
partial = true;
|
|
91
|
+
}
|
|
92
|
+
while (
|
|
93
|
+
related.length > 0 &&
|
|
94
|
+
!stateFitsBudget(buildFileState(path, body, kind, lines, related, partial))
|
|
95
|
+
) {
|
|
96
|
+
related.pop();
|
|
97
|
+
partial = true;
|
|
98
|
+
}
|
|
99
|
+
const state = buildFileState(path, body, kind, lines, related, partial);
|
|
100
|
+
return stateFitsBudget(state) ? state : undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function scanSourceFiles(cwd: string, editedPath: string): ScanResult {
|
|
104
|
+
const root = realpathSync(cwd);
|
|
105
|
+
const candidates: Candidate[] = [];
|
|
106
|
+
let visited = 0;
|
|
107
|
+
let partial = false;
|
|
108
|
+
|
|
109
|
+
function visit(
|
|
110
|
+
directory: string,
|
|
111
|
+
depth: number,
|
|
112
|
+
patterns: readonly string[],
|
|
113
|
+
): void {
|
|
114
|
+
if (depth > MAX_SCAN_DEPTH || visited >= MAX_SCAN_FILES) {
|
|
115
|
+
partial = true;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const localPatterns = [...patterns, ...readIgnorePatterns(directory, root)];
|
|
119
|
+
const matcher = ignore().add(localPatterns);
|
|
120
|
+
const entries = readdirSync(directory, { withFileTypes: true }).sort(
|
|
121
|
+
(a, b) => compareText(a.name, b.name),
|
|
122
|
+
);
|
|
123
|
+
for (const entry of entries) {
|
|
124
|
+
if (visited >= MAX_SCAN_FILES) {
|
|
125
|
+
partial = true;
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const absolute = join(directory, entry.name);
|
|
129
|
+
const path = relative(root, absolute).split(sep).join('/');
|
|
130
|
+
if (entry.name.startsWith('.') || SENSITIVE_NAME.test(entry.name)) {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (entry.isDirectory()) {
|
|
134
|
+
if (!SKIP_DIRECTORIES.has(entry.name) && !matcher.ignores(`${path}/`)) {
|
|
135
|
+
visit(absolute, depth + 1, localPatterns);
|
|
136
|
+
}
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (
|
|
140
|
+
!entry.isFile() ||
|
|
141
|
+
matcher.ignores(path) ||
|
|
142
|
+
path === editedPath ||
|
|
143
|
+
languageForPath(path) === LANGUAGE.generic
|
|
144
|
+
) {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
visited += 1;
|
|
148
|
+
if (statSync(absolute).size > MAX_CANDIDATE_BYTES) {
|
|
149
|
+
partial = true;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
candidates.push({ path, body: readFileSync(absolute, 'utf8') });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
visit(root, 0, []);
|
|
157
|
+
return { candidates, partial };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function readIgnorePatterns(directory: string, root: string): string[] {
|
|
161
|
+
const prefix = relative(root, directory).split(sep).join('/');
|
|
162
|
+
const rules: string[] = [];
|
|
163
|
+
for (const name of IGNORE_FILES) {
|
|
164
|
+
const file = join(directory, name);
|
|
165
|
+
if (!exists(file)) {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
for (const line of readFileSync(file, 'utf8').split(/\r?\n/u)) {
|
|
169
|
+
const trimmed = line.trim();
|
|
170
|
+
if (!trimmed || (trimmed.startsWith('#') && !trimmed.startsWith('\\#'))) {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const negative = line.startsWith('!');
|
|
174
|
+
const pattern = (negative ? line.slice(1) : line).replace(/^\//u, '');
|
|
175
|
+
rules.push(
|
|
176
|
+
`${negative ? '!' : ''}${prefix ? `${prefix}/` : ''}${pattern}`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return rules;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function exists(path: string): boolean {
|
|
184
|
+
try {
|
|
185
|
+
statSync(path);
|
|
186
|
+
return true;
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function modulePath(path: string): string {
|
|
196
|
+
const extension = extname(path);
|
|
197
|
+
const withoutExtension = extension ? path.slice(0, -extension.length) : path;
|
|
198
|
+
return withoutExtension.endsWith('/index')
|
|
199
|
+
? withoutExtension.slice(0, -'/index'.length)
|
|
200
|
+
: withoutExtension;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function references(path: string, body: string): Set<string> {
|
|
204
|
+
const names = new Set<string>();
|
|
205
|
+
const language = languageForPath(path);
|
|
206
|
+
const code = body.replace(/\/\*[\s\S]*?\*\/|^[ \t]*\/\/[^\n]*$/gmu, '');
|
|
207
|
+
if (language === LANGUAGE.typescript || language === LANGUAGE.javascript) {
|
|
208
|
+
const imports = [
|
|
209
|
+
/^[ \t]*(?:import|export)\s+(?:\{[^}]*\}|[^\n;]+)\s*from\s*['"](\.[^'"]+)['"]/gmu,
|
|
210
|
+
/^[ \t]*import\s*['"](\.[^'"]+)['"]/gmu,
|
|
211
|
+
/^[ \t]*(?:(?:const|let|var)\s+[\w{} ,]+\s*=\s*)?require\s*\(\s*['"](\.[^'"]+)['"]/gmu,
|
|
212
|
+
];
|
|
213
|
+
for (const pattern of imports) {
|
|
214
|
+
for (const match of code.matchAll(pattern)) {
|
|
215
|
+
const specifier = match[1];
|
|
216
|
+
if (specifier) {
|
|
217
|
+
names.add(
|
|
218
|
+
modulePath(
|
|
219
|
+
posix.normalize(posix.join(posix.dirname(path), specifier)),
|
|
220
|
+
),
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (language === LANGUAGE.python) {
|
|
227
|
+
for (const match of code.matchAll(
|
|
228
|
+
/^[ \t]*from\s+(\.+)([\w.]*)\s+import\b/gmu,
|
|
229
|
+
)) {
|
|
230
|
+
const dots = match[1]?.length ?? 0;
|
|
231
|
+
const specifier = match[2]?.replaceAll('.', '/') ?? '';
|
|
232
|
+
const parent = posix.resolve(
|
|
233
|
+
'/',
|
|
234
|
+
posix.dirname(path),
|
|
235
|
+
...Array(dots - 1).fill('..'),
|
|
236
|
+
);
|
|
237
|
+
names.add(modulePath(posix.join(parent.slice(1), specifier)));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (language === LANGUAGE.rust) {
|
|
241
|
+
let sourceRoot = posix.dirname(path);
|
|
242
|
+
if (path.startsWith('src/')) {
|
|
243
|
+
sourceRoot = 'src';
|
|
244
|
+
} else if (path.includes('/src/')) {
|
|
245
|
+
sourceRoot = path.slice(0, path.indexOf('/src/') + '/src'.length);
|
|
246
|
+
}
|
|
247
|
+
for (const match of code.matchAll(
|
|
248
|
+
/^[ \t]*(?:pub\s+)?(?:use|mod)\s+(crate::)?([\w:]+)\s*[;{]/gmu,
|
|
249
|
+
)) {
|
|
250
|
+
const specifier = match[2]?.replaceAll('::', '/');
|
|
251
|
+
if (specifier) {
|
|
252
|
+
const from = match[1] ? sourceRoot : posix.dirname(path);
|
|
253
|
+
names.add(modulePath(posix.join(from, specifier)));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (language === LANGUAGE.php) {
|
|
258
|
+
for (const match of code.matchAll(
|
|
259
|
+
/^[ \t]*(?:require|include)(?:_once)?\s*(?:\(?\s*__DIR__\s*\.\s*)?['"](\.[^'"]+|\/[^'"]+)['"]/gmu,
|
|
260
|
+
)) {
|
|
261
|
+
const specifier = match[1];
|
|
262
|
+
if (specifier) {
|
|
263
|
+
names.add(
|
|
264
|
+
modulePath(
|
|
265
|
+
posix.normalize(posix.join(posix.dirname(path), specifier)),
|
|
266
|
+
),
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (language === LANGUAGE.java) {
|
|
272
|
+
for (const match of code.matchAll(
|
|
273
|
+
/^[ \t]*import\s+(?:static\s+)?([\w.]+)\s*;/gmu,
|
|
274
|
+
)) {
|
|
275
|
+
const specifier = match[1]?.replaceAll('.', '/');
|
|
276
|
+
if (specifier) {
|
|
277
|
+
names.add(specifier);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (language === LANGUAGE.go) {
|
|
282
|
+
for (const declaration of code.matchAll(
|
|
283
|
+
/^[ \t]*import\s*(?:\(([^)]*)\)|"([^"\n]+)")/gmu,
|
|
284
|
+
)) {
|
|
285
|
+
const quoted = declaration[1] ?? `"${declaration[2] ?? ''}"`;
|
|
286
|
+
for (const match of quoted.matchAll(/"([^"\n]+)"/gu)) {
|
|
287
|
+
if (match[1]) {
|
|
288
|
+
names.add(match[1]);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return names;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function compareText(left: string, right: string): number {
|
|
297
|
+
if (left < right) {
|
|
298
|
+
return -1;
|
|
299
|
+
}
|
|
300
|
+
if (left > right) {
|
|
301
|
+
return 1;
|
|
302
|
+
}
|
|
303
|
+
return 0;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function matchesReference(names: ReadonlySet<string>, path: string): boolean {
|
|
307
|
+
const target = modulePath(path);
|
|
308
|
+
if (names.has(target)) {
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
for (const name of names) {
|
|
312
|
+
if (path.endsWith('.java') && target.endsWith(`/${name}`)) {
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
if (path.endsWith('.go')) {
|
|
316
|
+
const packageParts = name.split('/');
|
|
317
|
+
const packageSuffix = packageParts.slice(-2).join('/');
|
|
318
|
+
if (
|
|
319
|
+
packageParts.length >= 2 &&
|
|
320
|
+
posix.dirname(path).endsWith(`/${packageSuffix}`)
|
|
321
|
+
) {
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return false;
|
|
327
|
+
}
|
package/extensions/jev/guard.ts
CHANGED
|
@@ -6,23 +6,28 @@ 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,
|
|
14
19
|
type JevFailure,
|
|
15
20
|
type JevTransport,
|
|
16
21
|
} from './client.ts';
|
|
22
|
+
import { buildContextualState } from './context.ts';
|
|
17
23
|
import {
|
|
18
24
|
type AddedLine,
|
|
19
25
|
addedLinesForEdit,
|
|
20
26
|
addedLinesForWrite,
|
|
21
|
-
buildFileState,
|
|
22
27
|
findingKey,
|
|
23
28
|
formatFindings,
|
|
29
|
+
isReviewablePath,
|
|
24
30
|
type JevFinding,
|
|
25
|
-
stateFitsBudget,
|
|
26
31
|
} from './model.ts';
|
|
27
32
|
|
|
28
33
|
export const JEV_TRANSIENT_FAILURE_LIMIT = 3;
|
|
@@ -37,13 +42,18 @@ export const JEV_STATUS = {
|
|
|
37
42
|
noKey: 'noKey',
|
|
38
43
|
quota: 'quota',
|
|
39
44
|
auth: 'auth',
|
|
45
|
+
rateLimited: 'rateLimited',
|
|
40
46
|
unavailable: 'unavailable',
|
|
41
47
|
} as const;
|
|
42
48
|
|
|
43
49
|
export type JevStatus = (typeof JEV_STATUS)[keyof typeof JEV_STATUS];
|
|
44
50
|
|
|
45
|
-
interface
|
|
46
|
-
readonly
|
|
51
|
+
interface MutationTarget {
|
|
52
|
+
readonly absolutePath: string;
|
|
53
|
+
readonly relativePath: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface PendingMutation extends MutationTarget {
|
|
47
57
|
readonly kind: string;
|
|
48
58
|
readonly lines: readonly AddedLine[];
|
|
49
59
|
}
|
|
@@ -51,6 +61,7 @@ interface PendingMutation {
|
|
|
51
61
|
export interface JevGuardState {
|
|
52
62
|
halted: JevStatus | undefined;
|
|
53
63
|
transientFailures: number;
|
|
64
|
+
rateLimitedThisTurn: boolean;
|
|
54
65
|
lastModel: string | undefined;
|
|
55
66
|
calls: number;
|
|
56
67
|
readonly pending: Map<string, PendingMutation>;
|
|
@@ -67,6 +78,7 @@ export function createJevGuardState(): JevGuardState {
|
|
|
67
78
|
return {
|
|
68
79
|
halted: undefined,
|
|
69
80
|
transientFailures: 0,
|
|
81
|
+
rateLimitedThisTurn: false,
|
|
70
82
|
lastModel: undefined,
|
|
71
83
|
calls: 0,
|
|
72
84
|
pending: new Map(),
|
|
@@ -78,17 +90,25 @@ export function createJevGuardState(): JevGuardState {
|
|
|
78
90
|
export function resetJevGuard(state: JevGuardState): void {
|
|
79
91
|
state.halted = undefined;
|
|
80
92
|
state.transientFailures = 0;
|
|
93
|
+
state.rateLimitedThisTurn = false;
|
|
81
94
|
}
|
|
82
95
|
|
|
83
|
-
// Usage
|
|
84
|
-
|
|
96
|
+
// Usage and per-turn memory belong to the active session: a new session starts
|
|
97
|
+
// counting from zero. The breaker stays, because it tracks the key.
|
|
98
|
+
export function resetJevSession(state: JevGuardState): void {
|
|
85
99
|
state.calls = 0;
|
|
100
|
+
state.rateLimitedThisTurn = false;
|
|
101
|
+
state.pending.clear();
|
|
102
|
+
state.notedThisTurn.clear();
|
|
86
103
|
}
|
|
87
104
|
|
|
88
105
|
export function guardStatus(state: JevGuardState): JevStatus {
|
|
89
106
|
if (state.halted !== undefined) {
|
|
90
107
|
return state.halted;
|
|
91
108
|
}
|
|
109
|
+
if (state.rateLimitedThisTurn) {
|
|
110
|
+
return JEV_STATUS.rateLimited;
|
|
111
|
+
}
|
|
92
112
|
return state.transientFailures > 0
|
|
93
113
|
? JEV_STATUS.unavailable
|
|
94
114
|
: JEV_STATUS.ready;
|
|
@@ -97,6 +117,7 @@ export function guardStatus(state: JevGuardState): JevStatus {
|
|
|
97
117
|
export function shouldCallJev(state: JevGuardState): boolean {
|
|
98
118
|
return (
|
|
99
119
|
state.halted === undefined &&
|
|
120
|
+
!state.rateLimitedThisTurn &&
|
|
100
121
|
state.transientFailures < JEV_TRANSIENT_FAILURE_LIMIT
|
|
101
122
|
);
|
|
102
123
|
}
|
|
@@ -107,17 +128,24 @@ export function registerJevGuard(
|
|
|
107
128
|
dependencies: JevGuardDependencies,
|
|
108
129
|
): void {
|
|
109
130
|
pi.on('input', (event) => {
|
|
110
|
-
if (event
|
|
131
|
+
if (!startsUserTurn(event)) {
|
|
111
132
|
return;
|
|
112
133
|
}
|
|
113
134
|
state.notedThisTurn.clear();
|
|
135
|
+
state.rateLimitedThisTurn = false;
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// A call another extension blocks in tool_call never reaches tool_result,
|
|
139
|
+
// and every result of a turn lands before its turn_end.
|
|
140
|
+
pi.on('turn_end', () => {
|
|
141
|
+
state.pending.clear();
|
|
114
142
|
});
|
|
115
143
|
|
|
116
144
|
// The added lines are read before the mutation lands, because after a write
|
|
117
145
|
// the previous content is gone. The body is read after, from disk.
|
|
118
146
|
pi.on('tool_call', (event, ctx) => {
|
|
119
147
|
const mutation = extractMutation(event, ctx.cwd);
|
|
120
|
-
if (mutation === undefined) {
|
|
148
|
+
if (mutation === undefined || !isWorthAsking(mutation)) {
|
|
121
149
|
return undefined;
|
|
122
150
|
}
|
|
123
151
|
state.pending.set(event.toolCallId, mutation);
|
|
@@ -144,8 +172,21 @@ export function registerJevGuard(
|
|
|
144
172
|
return undefined;
|
|
145
173
|
}
|
|
146
174
|
|
|
147
|
-
const body =
|
|
148
|
-
if (body === undefined
|
|
175
|
+
const body = readFileIfExists(pending.absolutePath);
|
|
176
|
+
if (body === undefined) {
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
if (ctx.signal?.aborted === true) {
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
const fileState = buildContextualState(
|
|
183
|
+
ctx.cwd,
|
|
184
|
+
pending.relativePath,
|
|
185
|
+
body,
|
|
186
|
+
pending.kind,
|
|
187
|
+
pending.lines,
|
|
188
|
+
);
|
|
189
|
+
if (fileState === undefined) {
|
|
149
190
|
return undefined;
|
|
150
191
|
}
|
|
151
192
|
|
|
@@ -153,7 +194,8 @@ export function registerJevGuard(
|
|
|
153
194
|
const attempt = await askJev(
|
|
154
195
|
dependencies.transport,
|
|
155
196
|
apiKey,
|
|
156
|
-
|
|
197
|
+
fileState,
|
|
198
|
+
ctx.signal,
|
|
157
199
|
);
|
|
158
200
|
if (attempt.failure !== undefined) {
|
|
159
201
|
registerFailure(state, attempt.failure);
|
|
@@ -169,18 +211,17 @@ export function registerJevGuard(
|
|
|
169
211
|
|
|
170
212
|
const findings = attempt.findings ?? [];
|
|
171
213
|
const fresh = findings.filter((finding) =>
|
|
172
|
-
isUnnoted(state, pending.
|
|
214
|
+
isUnnoted(state, pending.relativePath, finding),
|
|
173
215
|
);
|
|
174
216
|
if (fresh.length === 0) {
|
|
175
217
|
return undefined;
|
|
176
218
|
}
|
|
177
|
-
markNoted(state, pending.
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
...event.content,
|
|
181
|
-
{ type: 'text' as const, text: formatFindings(pending.path, fresh) },
|
|
182
|
-
],
|
|
219
|
+
markNoted(state, pending.relativePath, fresh);
|
|
220
|
+
const note: ReviewNote = {
|
|
221
|
+
text: formatFindings(pending.relativePath, fresh),
|
|
183
222
|
};
|
|
223
|
+
pi.events.emit(REVIEW_NOTE_EVENT, note);
|
|
224
|
+
return undefined;
|
|
184
225
|
});
|
|
185
226
|
}
|
|
186
227
|
|
|
@@ -202,7 +243,16 @@ function markNoted(
|
|
|
202
243
|
}
|
|
203
244
|
}
|
|
204
245
|
|
|
246
|
+
// A user abort says nothing about the service, and a rate limit clears on its
|
|
247
|
+
// own, so neither may count toward switching Jev off.
|
|
205
248
|
function registerFailure(state: JevGuardState, failure: JevFailure): void {
|
|
249
|
+
if (failure === JEV_FAILURE.cancelled) {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (failure === JEV_FAILURE.rateLimited) {
|
|
253
|
+
state.rateLimitedThisTurn = true;
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
206
256
|
if (failure === JEV_FAILURE.quota) {
|
|
207
257
|
state.halted = JEV_STATUS.quota;
|
|
208
258
|
return;
|
|
@@ -222,24 +272,37 @@ function registerSuccess(state: JevGuardState): void {
|
|
|
222
272
|
state.transientFailures = 0;
|
|
223
273
|
}
|
|
224
274
|
|
|
275
|
+
// A deletion-only edit leaves nothing for the questions to read, and only
|
|
276
|
+
// the project-relative path of a supported language may leave the machine.
|
|
277
|
+
function isWorthAsking(mutation: PendingMutation): boolean {
|
|
278
|
+
return mutation.lines.length > 0 && isReviewablePath(mutation.relativePath);
|
|
279
|
+
}
|
|
280
|
+
|
|
225
281
|
function extractMutation(
|
|
226
282
|
event: ToolCallEvent,
|
|
227
283
|
cwd: string,
|
|
228
284
|
): PendingMutation | undefined {
|
|
229
285
|
if (isToolCallEventType('write', event)) {
|
|
230
|
-
const
|
|
286
|
+
const target = resolveTarget(cwd, event.input.path);
|
|
287
|
+
if (target === undefined) {
|
|
288
|
+
return undefined;
|
|
289
|
+
}
|
|
231
290
|
return {
|
|
232
|
-
|
|
291
|
+
...target,
|
|
233
292
|
kind: MUTATION_KIND.write,
|
|
234
293
|
lines: addedLinesForWrite(
|
|
235
|
-
|
|
294
|
+
readFileIfExists(target.absolutePath),
|
|
236
295
|
event.input.content,
|
|
237
296
|
),
|
|
238
297
|
};
|
|
239
298
|
}
|
|
240
299
|
if (isToolCallEventType('edit', event)) {
|
|
300
|
+
const target = resolveTarget(cwd, event.input.path);
|
|
301
|
+
if (target === undefined) {
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
241
304
|
return {
|
|
242
|
-
|
|
305
|
+
...target,
|
|
243
306
|
kind: MUTATION_KIND.edit,
|
|
244
307
|
lines: addedLinesForEdit(event.input.edits),
|
|
245
308
|
};
|
|
@@ -247,10 +310,14 @@ function extractMutation(
|
|
|
247
310
|
return undefined;
|
|
248
311
|
}
|
|
249
312
|
|
|
250
|
-
function
|
|
313
|
+
function resolveTarget(cwd: string, path: string): MutationTarget | undefined {
|
|
251
314
|
const absolutePath = resolveFileToolPath(cwd, path);
|
|
252
315
|
if (absolutePath === undefined) {
|
|
253
316
|
return undefined;
|
|
254
317
|
}
|
|
255
|
-
|
|
318
|
+
const relativePath = projectRelativePath(cwd, absolutePath);
|
|
319
|
+
if (relativePath === undefined) {
|
|
320
|
+
return undefined;
|
|
321
|
+
}
|
|
322
|
+
return { absolutePath, relativePath };
|
|
256
323
|
}
|
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
|
|