@rmrdeveloper/sideroom-pi 8.8.0 → 8.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -14
- package/extensions/explain/guard.ts +22 -16
- package/extensions/explain/model.ts +58 -8
- package/extensions/guidelines/index.ts +12 -0
- package/extensions/guidelines/prompt.ts +1 -2
- package/extensions/guidelines/review.ts +115 -0
- package/extensions/jev/client.ts +137 -0
- package/extensions/jev/guard.ts +320 -0
- package/extensions/jev/index.ts +88 -0
- package/extensions/jev/key.ts +95 -0
- package/extensions/jev/model.ts +266 -0
- package/extensions/jev/ui.ts +282 -0
- package/extensions/persona/catalog.ts +8 -8
- package/extensions/persona/guard.ts +20 -16
- package/extensions/persona/index.ts +11 -9
- package/extensions/persona/model.ts +18 -2
- package/extensions/persona/prompt.ts +9 -0
- package/extensions/rules/guard.ts +1 -16
- package/extensions/shared/file-path.ts +26 -1
- package/extensions/shared/read-file.ts +18 -0
- package/extensions/shared/review-note.ts +14 -0
- package/extensions/shared/settle.ts +50 -0
- package/extensions/todo/guards.ts +2 -0
- package/package.json +3 -3
- package/skills/sideroom-persona/SKILL.md +18 -8
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { LANGUAGE, languageForPath } from '../rules/catalog.ts';
|
|
2
|
+
import { type AddedLine, addedLines } from '../rules/model.ts';
|
|
3
|
+
|
|
4
|
+
export type { AddedLine } from '../rules/model.ts';
|
|
5
|
+
|
|
6
|
+
export const JEV_ENDPOINT = 'https://api.typesafe.ai/v1/systemone';
|
|
7
|
+
export const JEV_MODEL = 'jev-latest';
|
|
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
|
+
export const VIOLATION_PROBABILITY = 0.8;
|
|
13
|
+
|
|
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
|
|
17
|
+
// not fit is skipped, never truncated: half a file reads as a distorted picture
|
|
18
|
+
// and Jev answers it with the same confidence.
|
|
19
|
+
export const MAX_STATE_CHARACTERS = 60_000;
|
|
20
|
+
|
|
21
|
+
const NOUL_TYPE = 'noul';
|
|
22
|
+
|
|
23
|
+
export interface JevNoulQuestion {
|
|
24
|
+
readonly type: typeof NOUL_TYPE;
|
|
25
|
+
readonly instructions: string;
|
|
26
|
+
readonly criteria: { readonly true: string; readonly false: string };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface JevRule {
|
|
30
|
+
readonly id: string;
|
|
31
|
+
readonly question: JevNoulQuestion;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The six guide rules that a single file can answer and that no mechanical
|
|
35
|
+
// check covers. Rules 11, 12, 15, 16, 17 and 18 need the module's neighbours.
|
|
36
|
+
export const JEV_RULES: readonly JevRule[] = [
|
|
37
|
+
{
|
|
38
|
+
id: 'guard-clauses',
|
|
39
|
+
question: {
|
|
40
|
+
type: NOUL_TYPE,
|
|
41
|
+
instructions:
|
|
42
|
+
'In `change.addedLines`, is the control flow nested so the main path is buried, instead of validating and exiting early?',
|
|
43
|
+
criteria: {
|
|
44
|
+
true: 'Conditionals nest two or more levels deep, or the main path sits inside a condition that could have returned early',
|
|
45
|
+
false:
|
|
46
|
+
'Each condition is handled with an early return or a flat sequence',
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: 'fail-fast',
|
|
52
|
+
question: {
|
|
53
|
+
type: NOUL_TYPE,
|
|
54
|
+
instructions:
|
|
55
|
+
'In `change.addedLines`, is an invalid or impossible state papered over with a silent fallback instead of failing immediately?',
|
|
56
|
+
criteria: {
|
|
57
|
+
true: 'A default value or a silent coercion hides a broken dependency or a state that should never happen',
|
|
58
|
+
false:
|
|
59
|
+
'An impossible or invalid case throws with a clear message, or no such case appears',
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: 'command-query',
|
|
65
|
+
question: {
|
|
66
|
+
type: NOUL_TYPE,
|
|
67
|
+
instructions:
|
|
68
|
+
'In `change.addedLines`, does a function both change state and return a value, or does something that reads like a query have a side effect?',
|
|
69
|
+
criteria: {
|
|
70
|
+
true: 'A function that returns data also mutates state, or a getter mutates',
|
|
71
|
+
false: 'Functions either change state or return data, never both',
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: 'null-handling',
|
|
77
|
+
question: {
|
|
78
|
+
type: NOUL_TYPE,
|
|
79
|
+
instructions:
|
|
80
|
+
'In `change.addedLines`, is the same empty value reused to mean several different states, or are `null` and `undefined` mixed for one meaning?',
|
|
81
|
+
criteria: {
|
|
82
|
+
true: 'One empty value carries more than one business meaning, or null and undefined stand for the same thing in different places',
|
|
83
|
+
false:
|
|
84
|
+
'One convention is used and each distinct state has its own value',
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: 'immutability',
|
|
90
|
+
question: {
|
|
91
|
+
type: NOUL_TYPE,
|
|
92
|
+
instructions:
|
|
93
|
+
'In `change.addedLines`, is an object or array received as a parameter modified in place?',
|
|
94
|
+
criteria: {
|
|
95
|
+
true: 'A parameter is pushed, assigned or deleted into, or a transform writes to state outside itself',
|
|
96
|
+
false: 'New values are returned instead of changing the inputs',
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
id: 'validate-once',
|
|
102
|
+
question: {
|
|
103
|
+
type: NOUL_TYPE,
|
|
104
|
+
instructions:
|
|
105
|
+
'In `change.addedLines`, is the same required, type or range check repeated after a boundary already owns that input?',
|
|
106
|
+
criteria: {
|
|
107
|
+
true: 'A rule already validated at the boundary is checked again deeper in the code',
|
|
108
|
+
false: 'Validation happens once, at the boundary that owns the input',
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
export interface JevFileState {
|
|
115
|
+
readonly file: {
|
|
116
|
+
readonly path: string;
|
|
117
|
+
readonly language: string;
|
|
118
|
+
readonly body: string;
|
|
119
|
+
};
|
|
120
|
+
readonly change: {
|
|
121
|
+
readonly kind: string;
|
|
122
|
+
readonly addedLines: readonly string[];
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface JevRequestBody {
|
|
127
|
+
readonly model: string;
|
|
128
|
+
readonly state: JevFileState;
|
|
129
|
+
readonly questions: Readonly<Record<string, JevNoulQuestion>>;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface JevNoulAnswer {
|
|
133
|
+
readonly noul: number;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface JevResponseEnvelope {
|
|
137
|
+
readonly model: string | undefined;
|
|
138
|
+
readonly answers: Readonly<Record<string, JevNoulAnswer>> | undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface JevFinding {
|
|
142
|
+
readonly ruleId: string;
|
|
143
|
+
readonly probability: number;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function buildFileState(
|
|
147
|
+
path: string,
|
|
148
|
+
body: string,
|
|
149
|
+
kind: string,
|
|
150
|
+
lines: readonly AddedLine[],
|
|
151
|
+
): JevFileState {
|
|
152
|
+
return {
|
|
153
|
+
file: { path, language: languageForPath(path), body },
|
|
154
|
+
change: { kind, addedLines: lines.map((line) => line.text) },
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function addedLinesForWrite(
|
|
159
|
+
previous: string | undefined,
|
|
160
|
+
next: string,
|
|
161
|
+
): AddedLine[] {
|
|
162
|
+
return addedLines(previous, next);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function addedLinesForEdit(
|
|
166
|
+
edits: readonly { readonly oldText: string; readonly newText: string }[],
|
|
167
|
+
): AddedLine[] {
|
|
168
|
+
return edits.flatMap((edit) => addedLines(edit.oldText, edit.newText));
|
|
169
|
+
}
|
|
170
|
+
|
|
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;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function buildRequestBody(file: JevFileState): JevRequestBody {
|
|
182
|
+
const questions: Record<string, JevNoulQuestion> = {};
|
|
183
|
+
for (const rule of JEV_RULES) {
|
|
184
|
+
questions[rule.id] = rule.question;
|
|
185
|
+
}
|
|
186
|
+
return { model: JEV_MODEL, state: file, questions };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function parseEnvelope(text: string): JevResponseEnvelope | undefined {
|
|
190
|
+
const payload = parseJsonObject(text);
|
|
191
|
+
if (payload === undefined) {
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
const model = typeof payload.model === 'string' ? payload.model : undefined;
|
|
195
|
+
return { model, answers: readAnswers(payload.answers) };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function readFindings(
|
|
199
|
+
envelope: JevResponseEnvelope,
|
|
200
|
+
): readonly JevFinding[] {
|
|
201
|
+
const answers = envelope.answers;
|
|
202
|
+
if (answers === undefined) {
|
|
203
|
+
return [];
|
|
204
|
+
}
|
|
205
|
+
const findings: JevFinding[] = [];
|
|
206
|
+
for (const rule of JEV_RULES) {
|
|
207
|
+
const answer = answers[rule.id];
|
|
208
|
+
if (answer === undefined || answer.noul < VIOLATION_PROBABILITY) {
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
findings.push({ ruleId: rule.id, probability: answer.noul });
|
|
212
|
+
}
|
|
213
|
+
return findings;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function findingKey(path: string, ruleId: string): string {
|
|
217
|
+
return `${path}#${ruleId}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function formatFindings(
|
|
221
|
+
path: string,
|
|
222
|
+
findings: readonly JevFinding[],
|
|
223
|
+
): string {
|
|
224
|
+
return [
|
|
225
|
+
`Sideroom Jev review flagged ${path}:`,
|
|
226
|
+
...findings.map(
|
|
227
|
+
(finding) =>
|
|
228
|
+
`- [${finding.ruleId}] Jev scored ${finding.probability.toFixed(2)} probability that the added lines break this rule. Verify against the loaded guide before changing anything.`,
|
|
229
|
+
),
|
|
230
|
+
].join('\n');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function parseJsonObject(text: string): Record<string, unknown> | undefined {
|
|
234
|
+
let parsed: unknown;
|
|
235
|
+
try {
|
|
236
|
+
parsed = JSON.parse(text);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
if (error instanceof SyntaxError) {
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
243
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
return parsed as Record<string, unknown>;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function readAnswers(
|
|
250
|
+
value: unknown,
|
|
251
|
+
): Record<string, JevNoulAnswer> | undefined {
|
|
252
|
+
if (typeof value !== 'object' || value === null) {
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
const answers: Record<string, JevNoulAnswer> = {};
|
|
256
|
+
for (const [id, entry] of Object.entries(value)) {
|
|
257
|
+
if (typeof entry !== 'object' || entry === null) {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
const { noul } = entry as { noul?: unknown };
|
|
261
|
+
if (typeof noul === 'number') {
|
|
262
|
+
answers[id] = { noul };
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return answers;
|
|
266
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import type { ExtensionContext, Theme } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import {
|
|
3
|
+
type Component,
|
|
4
|
+
Key,
|
|
5
|
+
matchesKey,
|
|
6
|
+
type TUI,
|
|
7
|
+
truncateToWidth,
|
|
8
|
+
} from '@earendil-works/pi-tui';
|
|
9
|
+
import { JEV_STATUS, type JevStatus } from './guard.ts';
|
|
10
|
+
import { JEV_KEY_ENV_VAR, type JevKeySource } from './key.ts';
|
|
11
|
+
|
|
12
|
+
export const JEV_STATUS_KEY = 'sideroom-jev';
|
|
13
|
+
export const JEV_SHORTCUT = 'f10';
|
|
14
|
+
|
|
15
|
+
const MASK_CHARACTER = '•';
|
|
16
|
+
const CLEAR_KEY = Key.ctrl('u');
|
|
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
|
+
const PASTE_START = '\x1b[200~';
|
|
22
|
+
const PASTE_END = '\x1b[201~';
|
|
23
|
+
|
|
24
|
+
// A pasted line usually carries a trailing newline, and no key holds
|
|
25
|
+
// whitespace, so everything unprintable is dropped.
|
|
26
|
+
export const MAX_KEY_LENGTH = 512;
|
|
27
|
+
|
|
28
|
+
const STATUS_LABEL: Readonly<Record<JevStatus, string>> = {
|
|
29
|
+
[JEV_STATUS.ready]: 'jev: ready',
|
|
30
|
+
[JEV_STATUS.noKey]: 'jev: no key (F10)',
|
|
31
|
+
[JEV_STATUS.quota]: 'jev: out of quota (F10)',
|
|
32
|
+
[JEV_STATUS.auth]: 'jev: key rejected (F10)',
|
|
33
|
+
[JEV_STATUS.rateLimited]: 'jev: rate limited',
|
|
34
|
+
[JEV_STATUS.unavailable]: 'jev: unreachable',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export interface JevKeyActions {
|
|
38
|
+
readonly store: (apiKey: string) => void;
|
|
39
|
+
readonly clear: () => void;
|
|
40
|
+
readonly retry: () => void;
|
|
41
|
+
readonly origin: () => JevKeySource['origin'] | undefined;
|
|
42
|
+
readonly usage: () => number;
|
|
43
|
+
readonly refresh: (ctx: ExtensionContext) => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface JevKeyController {
|
|
47
|
+
open(ctx: ExtensionContext): void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function statusLabel(
|
|
51
|
+
status: JevStatus,
|
|
52
|
+
model?: string,
|
|
53
|
+
calls = 0,
|
|
54
|
+
): string {
|
|
55
|
+
const base =
|
|
56
|
+
status === JEV_STATUS.ready && model !== undefined
|
|
57
|
+
? `jev: ready (${model})`
|
|
58
|
+
: STATUS_LABEL[status];
|
|
59
|
+
if (calls === 0) {
|
|
60
|
+
return base;
|
|
61
|
+
}
|
|
62
|
+
return `${base} · ${String(calls)} call${calls === 1 ? '' : 's'}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createJevKeyController(
|
|
66
|
+
actions: JevKeyActions,
|
|
67
|
+
): JevKeyController {
|
|
68
|
+
let opening = false;
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
open(ctx) {
|
|
72
|
+
if (opening) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (ctx.mode !== 'tui') {
|
|
76
|
+
ctx.ui.notify(
|
|
77
|
+
`Set ${JEV_KEY_ENV_VAR} in the environment: capturing a key needs the terminal.`,
|
|
78
|
+
'warning',
|
|
79
|
+
);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
opening = true;
|
|
83
|
+
void ctx.ui
|
|
84
|
+
.custom<void>(
|
|
85
|
+
(tui, theme, _keybindings, done) =>
|
|
86
|
+
new JevKeyOverlay(tui, theme, done, actions, ctx),
|
|
87
|
+
{
|
|
88
|
+
overlay: true,
|
|
89
|
+
overlayOptions: {
|
|
90
|
+
anchor: 'center',
|
|
91
|
+
width: '70%',
|
|
92
|
+
minWidth: 44,
|
|
93
|
+
maxHeight: '60%',
|
|
94
|
+
visible: (terminalWidth) => terminalWidth >= 60,
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
)
|
|
98
|
+
.finally(() => {
|
|
99
|
+
opening = false;
|
|
100
|
+
});
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// The packaged Input component has no masking, and a key must never render, so
|
|
106
|
+
// this draws its own single masked field.
|
|
107
|
+
export class JevKeyOverlay implements Component {
|
|
108
|
+
private entry = '';
|
|
109
|
+
private readonly tui: TUI;
|
|
110
|
+
private readonly theme: Theme;
|
|
111
|
+
private readonly close: () => void;
|
|
112
|
+
private readonly actions: JevKeyActions;
|
|
113
|
+
private readonly ctx: ExtensionContext;
|
|
114
|
+
|
|
115
|
+
constructor(
|
|
116
|
+
tui: TUI,
|
|
117
|
+
theme: Theme,
|
|
118
|
+
close: () => void,
|
|
119
|
+
actions: JevKeyActions,
|
|
120
|
+
ctx: ExtensionContext,
|
|
121
|
+
) {
|
|
122
|
+
this.tui = tui;
|
|
123
|
+
this.theme = theme;
|
|
124
|
+
this.close = close;
|
|
125
|
+
this.actions = actions;
|
|
126
|
+
this.ctx = ctx;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
handleInput(keyData: string): void {
|
|
130
|
+
const pasted = readPastedKey(keyData);
|
|
131
|
+
if (pasted !== undefined) {
|
|
132
|
+
this.append(pasted);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (matchesKey(keyData, Key.escape)) {
|
|
136
|
+
this.close();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (matchesKey(keyData, Key.enter)) {
|
|
140
|
+
this.save();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (matchesKey(keyData, Key.backspace)) {
|
|
144
|
+
this.entry = this.entry.slice(0, -1);
|
|
145
|
+
this.tui.requestRender();
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (matchesKey(keyData, CLEAR_KEY)) {
|
|
149
|
+
this.clearStored();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (isTypedCharacter(keyData)) {
|
|
153
|
+
this.append(keyData);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private append(text: string): void {
|
|
158
|
+
if (text.length === 0) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
this.entry = `${this.entry}${text}`.slice(0, MAX_KEY_LENGTH);
|
|
162
|
+
this.tui.requestRender();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
render(width: number): string[] {
|
|
166
|
+
const innerWidth = Math.max(1, width - 2);
|
|
167
|
+
const border = (text: string) => this.theme.fg('border', text);
|
|
168
|
+
const line = (text: string) => truncateToWidth(text, innerWidth, '…', true);
|
|
169
|
+
const row = (text: string) => `${border('│')}${line(text)}${border('│')}`;
|
|
170
|
+
|
|
171
|
+
const lines = [
|
|
172
|
+
border(`╭${'─'.repeat(innerWidth)}╮`),
|
|
173
|
+
row(this.theme.fg('accent', this.theme.bold(' Jev API key '))),
|
|
174
|
+
border(`├${'─'.repeat(innerWidth)}┤`),
|
|
175
|
+
row(` ${MASK_CHARACTER.repeat(this.entry.length)}`),
|
|
176
|
+
row(this.theme.fg('dim', ` source: ${this.originLabel()}`)),
|
|
177
|
+
row(
|
|
178
|
+
this.theme.fg(
|
|
179
|
+
'dim',
|
|
180
|
+
` calls this session: ${String(this.actions.usage())}`,
|
|
181
|
+
),
|
|
182
|
+
),
|
|
183
|
+
border(`├${'─'.repeat(innerWidth)}┤`),
|
|
184
|
+
row(
|
|
185
|
+
this.theme.fg(
|
|
186
|
+
'dim',
|
|
187
|
+
' The key is stored in sideroom.json, never in the session.',
|
|
188
|
+
),
|
|
189
|
+
),
|
|
190
|
+
row(
|
|
191
|
+
this.theme.fg(
|
|
192
|
+
'dim',
|
|
193
|
+
` ${JEV_KEY_ENV_VAR} wins over the file when both are set.`,
|
|
194
|
+
),
|
|
195
|
+
),
|
|
196
|
+
border(`├${'─'.repeat(innerWidth)}┤`),
|
|
197
|
+
row(this.theme.fg('dim', ' Enter save · Ctrl+U clear · Esc close')),
|
|
198
|
+
border(`╰${'─'.repeat(innerWidth)}╯`),
|
|
199
|
+
];
|
|
200
|
+
return lines;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
invalidate(): void {}
|
|
204
|
+
|
|
205
|
+
private save(): void {
|
|
206
|
+
const apiKey = this.entry.trim();
|
|
207
|
+
if (apiKey.length === 0) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
this.actions.store(apiKey);
|
|
211
|
+
this.finish('Jev API key stored.');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private clearStored(): void {
|
|
215
|
+
this.entry = '';
|
|
216
|
+
this.actions.clear();
|
|
217
|
+
this.finish('Jev API key cleared.');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private finish(message: string): void {
|
|
221
|
+
this.actions.retry();
|
|
222
|
+
this.actions.refresh(this.ctx);
|
|
223
|
+
this.ctx.ui.notify(message, 'info');
|
|
224
|
+
this.close();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private originLabel(): string {
|
|
228
|
+
const origin = this.actions.origin();
|
|
229
|
+
if (origin === 'environment') {
|
|
230
|
+
return `from ${JEV_KEY_ENV_VAR}`;
|
|
231
|
+
}
|
|
232
|
+
if (origin === 'config') {
|
|
233
|
+
return 'stored in sideroom.json';
|
|
234
|
+
}
|
|
235
|
+
return 'not configured';
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
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
|
+
export function readPastedKey(keyData: string): string | undefined {
|
|
243
|
+
const start = keyData.indexOf(PASTE_START);
|
|
244
|
+
if (start === -1) {
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
const from = start + PASTE_START.length;
|
|
248
|
+
const end = keyData.indexOf(PASTE_END, from);
|
|
249
|
+
const body = end === -1 ? keyData.slice(from) : keyData.slice(from, end);
|
|
250
|
+
return printableOnly(body);
|
|
251
|
+
}
|
|
252
|
+
|
|
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
|
+
function isControlCode(code: number): boolean {
|
|
256
|
+
return code < 0x20 || code === 0x7f;
|
|
257
|
+
}
|
|
258
|
+
|
|
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
|
+
function printableOnly(text: string): string {
|
|
262
|
+
let printable = '';
|
|
263
|
+
for (const character of text) {
|
|
264
|
+
const code = character.codePointAt(0) ?? 0;
|
|
265
|
+
if (!isControlCode(code) && character !== ' ') {
|
|
266
|
+
printable += character;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return printable;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function isTypedCharacter(keyData: string): boolean {
|
|
273
|
+
if (keyData.length === 0) {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
for (const character of keyData) {
|
|
277
|
+
if (isControlCode(character.codePointAt(0) ?? 0)) {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
@@ -32,29 +32,29 @@ export interface VoiceRule {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
export const VOICE_RULES: readonly VoiceRule[] = [
|
|
35
|
+
{
|
|
36
|
+
id: 'plain-language',
|
|
37
|
+
instruction:
|
|
38
|
+
'Explain in plain words: name the subject and goal before the detail, prefer everyday terms over concept jargon (hook, pipeline, registry, resolver, guardrail, invariant) unless the user used them, define any necessary term in one line, and be explicit without repetition. Never write tangled chains such as "the registry resolves the provider through the auth resolver".',
|
|
39
|
+
},
|
|
35
40
|
{
|
|
36
41
|
id: 'direct',
|
|
37
42
|
instruction:
|
|
38
|
-
'Be direct and dry.
|
|
43
|
+
'Be direct and dry. Short sentences. No preamble or closing summary.',
|
|
39
44
|
},
|
|
40
45
|
{
|
|
41
46
|
id: 'no-filler',
|
|
42
47
|
instruction: 'No filler.',
|
|
43
48
|
},
|
|
44
|
-
{
|
|
45
|
-
id: 'plain-language',
|
|
46
|
-
instruction:
|
|
47
|
-
'Explain plainly and leave no doubt. Name the subject and goal before details, define unfamiliar terms in one line, and be explicit without repetition.',
|
|
48
|
-
},
|
|
49
49
|
{
|
|
50
50
|
id: 'no-invented-terms',
|
|
51
51
|
instruction:
|
|
52
|
-
'Name things as they are.
|
|
52
|
+
'Name things as they are. Coin no intermediate term, abbreviation, codename, or technical concept word the user did not ask for.',
|
|
53
53
|
},
|
|
54
54
|
{
|
|
55
55
|
id: 'short-prose',
|
|
56
56
|
instruction:
|
|
57
|
-
'Default to short prose.
|
|
57
|
+
'Default to short prose. Lists or tables only to compare options or list more than three items.',
|
|
58
58
|
},
|
|
59
59
|
{
|
|
60
60
|
id: 'user-language',
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
2
1
|
import {
|
|
3
2
|
type ExtensionAPI,
|
|
3
|
+
type ExtensionContext,
|
|
4
4
|
isToolCallEventType,
|
|
5
5
|
type ToolCallEvent,
|
|
6
6
|
} from '@earendil-works/pi-coding-agent';
|
|
7
7
|
import { resolveFileToolPath } from '../shared/file-path.ts';
|
|
8
|
+
import { readFileIfExists } from '../shared/read-file.ts';
|
|
8
9
|
import {
|
|
9
10
|
PERSONA_STEER_TYPE,
|
|
10
11
|
type ProhibitionId,
|
|
@@ -38,29 +39,41 @@ export interface PersonaGuardState {
|
|
|
38
39
|
readonly fires: Map<ProhibitionId, number>;
|
|
39
40
|
cleanMutations: number;
|
|
40
41
|
steersThisRun: number;
|
|
42
|
+
blocksThisRun: number;
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
export function createPersonaGuardState(): PersonaGuardState {
|
|
44
|
-
return {
|
|
46
|
+
return {
|
|
47
|
+
fires: new Map(),
|
|
48
|
+
cleanMutations: 0,
|
|
49
|
+
steersThisRun: 0,
|
|
50
|
+
blocksThisRun: 0,
|
|
51
|
+
};
|
|
45
52
|
}
|
|
46
53
|
|
|
47
54
|
export function resetPersonaRun(state: PersonaGuardState): void {
|
|
48
55
|
state.steersThisRun = 0;
|
|
56
|
+
state.blocksThisRun = 0;
|
|
49
57
|
}
|
|
50
58
|
|
|
51
59
|
export function registerPersonaGuard(
|
|
52
60
|
pi: ExtensionAPI,
|
|
53
61
|
state: PersonaGuardState,
|
|
62
|
+
report: (ctx: ExtensionContext) => void,
|
|
54
63
|
): void {
|
|
55
64
|
pi.on('tool_call', (event, ctx) => {
|
|
56
65
|
const mutation = extractMutation(event, ctx.cwd);
|
|
57
66
|
if (mutation === undefined) {
|
|
58
67
|
return undefined;
|
|
59
68
|
}
|
|
60
|
-
|
|
69
|
+
const blockDecision = applyArtifactOutcome(pi, state, mutation);
|
|
70
|
+
if (blockDecision !== undefined) {
|
|
71
|
+
report(ctx);
|
|
72
|
+
}
|
|
73
|
+
return blockDecision;
|
|
61
74
|
});
|
|
62
75
|
|
|
63
|
-
pi.on('message_end', (event) => {
|
|
76
|
+
pi.on('message_end', (event, ctx) => {
|
|
64
77
|
if (event.message.role !== 'assistant') {
|
|
65
78
|
return undefined;
|
|
66
79
|
}
|
|
@@ -73,6 +86,7 @@ export function registerPersonaGuard(
|
|
|
73
86
|
}
|
|
74
87
|
|
|
75
88
|
sendPersonaSteer(pi, state, formatSteer(violations));
|
|
89
|
+
report(ctx);
|
|
76
90
|
return undefined;
|
|
77
91
|
});
|
|
78
92
|
}
|
|
@@ -92,6 +106,7 @@ function applyArtifactOutcome(
|
|
|
92
106
|
(violation) => !isDegraded(state, violation.prohibition.id),
|
|
93
107
|
);
|
|
94
108
|
if (blocking.length > 0) {
|
|
109
|
+
state.blocksThisRun += 1;
|
|
95
110
|
for (const violation of blocking) {
|
|
96
111
|
incrementFires(state, violation.prohibition.id);
|
|
97
112
|
}
|
|
@@ -126,17 +141,6 @@ function extractMutation(
|
|
|
126
141
|
return undefined;
|
|
127
142
|
}
|
|
128
143
|
|
|
129
|
-
function readFileIfExists(path: string): string | undefined {
|
|
130
|
-
try {
|
|
131
|
-
return readFileSync(path, 'utf8');
|
|
132
|
-
} catch (error) {
|
|
133
|
-
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
134
|
-
return undefined;
|
|
135
|
-
}
|
|
136
|
-
throw error;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
144
|
function isDegraded(state: PersonaGuardState, id: ProhibitionId): boolean {
|
|
141
145
|
return (state.fires.get(id) ?? 0) >= BLOCK_DEGRADE_AFTER;
|
|
142
146
|
}
|
|
@@ -156,7 +160,7 @@ function sendPersonaSteer(
|
|
|
156
160
|
state.steersThisRun += 1;
|
|
157
161
|
pi.sendMessage(
|
|
158
162
|
{ customType: PERSONA_STEER_TYPE, content, display: false },
|
|
159
|
-
{ deliverAs: 'steer' },
|
|
163
|
+
{ triggerTurn: true, deliverAs: 'steer' },
|
|
160
164
|
);
|
|
161
165
|
}
|
|
162
166
|
|