@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.
@@ -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
+ }
@@ -6,23 +6,28 @@ import {
6
6
  isWriteToolResult,
7
7
  type ToolCallEvent,
8
8
  } from '@earendil-works/pi-coding-agent';
9
- import { resolveFileToolPath } from '../shared/file-path.ts';
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 PendingMutation {
46
- readonly path: string;
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 belongs to the active session: a new session starts counting from zero.
84
- export function resetJevUsage(state: JevGuardState): void {
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.source !== 'interactive' && event.source !== 'rpc') {
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 = readMutationBody(ctx.cwd, pending.path);
148
- if (body === undefined || !stateFitsBudget(body)) {
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
- buildFileState(pending.path, body, pending.kind, pending.lines),
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.path, finding),
214
+ isUnnoted(state, pending.relativePath, finding),
173
215
  );
174
216
  if (fresh.length === 0) {
175
217
  return undefined;
176
218
  }
177
- markNoted(state, pending.path, fresh);
178
- return {
179
- content: [
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 absolutePath = resolveFileToolPath(cwd, event.input.path);
286
+ const target = resolveTarget(cwd, event.input.path);
287
+ if (target === undefined) {
288
+ return undefined;
289
+ }
231
290
  return {
232
- path: event.input.path,
291
+ ...target,
233
292
  kind: MUTATION_KIND.write,
234
293
  lines: addedLinesForWrite(
235
- absolutePath === undefined ? undefined : readFileIfExists(absolutePath),
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
- path: event.input.path,
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 readMutationBody(cwd: string, path: string): string | undefined {
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
- return readFileIfExists(absolutePath);
318
+ const relativePath = projectRelativePath(cwd, absolutePath);
319
+ if (relativePath === undefined) {
320
+ return undefined;
321
+ }
322
+ return { absolutePath, relativePath };
256
323
  }
@@ -11,7 +11,7 @@ import {
11
11
  type JevStatus,
12
12
  registerJevGuard,
13
13
  resetJevGuard,
14
- resetJevUsage,
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
- resetJevUsage(guardState);
78
+ resetJevSession(guardState);
79
79
  publish(currentStatus(), ctx);
80
80
  });
81
81