@rmrdeveloper/sideroom-pi 8.8.0 → 8.9.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 +59 -9
- package/extensions/explain/guard.ts +8 -4
- package/extensions/explain/model.ts +57 -8
- package/extensions/guidelines/index.ts +12 -0
- package/extensions/guidelines/prompt.ts +1 -2
- package/extensions/guidelines/review.ts +70 -0
- package/extensions/jev/client.ts +106 -0
- package/extensions/jev/guard.ts +256 -0
- package/extensions/jev/index.ts +88 -0
- package/extensions/jev/key.ts +95 -0
- package/extensions/jev/model.ts +259 -0
- package/extensions/jev/ui.ts +281 -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/read-file.ts +18 -0
- package/extensions/todo/guards.ts +2 -0
- package/package.json +1 -1
- package/skills/sideroom-persona/SKILL.md +15 -8
|
@@ -0,0 +1,281 @@
|
|
|
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.unavailable]: 'jev: unreachable',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export interface JevKeyActions {
|
|
37
|
+
readonly store: (apiKey: string) => void;
|
|
38
|
+
readonly clear: () => void;
|
|
39
|
+
readonly retry: () => void;
|
|
40
|
+
readonly origin: () => JevKeySource['origin'] | undefined;
|
|
41
|
+
readonly usage: () => number;
|
|
42
|
+
readonly refresh: (ctx: ExtensionContext) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface JevKeyController {
|
|
46
|
+
open(ctx: ExtensionContext): void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function statusLabel(
|
|
50
|
+
status: JevStatus,
|
|
51
|
+
model?: string,
|
|
52
|
+
calls = 0,
|
|
53
|
+
): string {
|
|
54
|
+
const base =
|
|
55
|
+
status === JEV_STATUS.ready && model !== undefined
|
|
56
|
+
? `jev: ready (${model})`
|
|
57
|
+
: STATUS_LABEL[status];
|
|
58
|
+
if (calls === 0) {
|
|
59
|
+
return base;
|
|
60
|
+
}
|
|
61
|
+
return `${base} · ${String(calls)} call${calls === 1 ? '' : 's'}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function createJevKeyController(
|
|
65
|
+
actions: JevKeyActions,
|
|
66
|
+
): JevKeyController {
|
|
67
|
+
let opening = false;
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
open(ctx) {
|
|
71
|
+
if (opening) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (ctx.mode !== 'tui') {
|
|
75
|
+
ctx.ui.notify(
|
|
76
|
+
`Set ${JEV_KEY_ENV_VAR} in the environment: capturing a key needs the terminal.`,
|
|
77
|
+
'warning',
|
|
78
|
+
);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
opening = true;
|
|
82
|
+
void ctx.ui
|
|
83
|
+
.custom<void>(
|
|
84
|
+
(tui, theme, _keybindings, done) =>
|
|
85
|
+
new JevKeyOverlay(tui, theme, done, actions, ctx),
|
|
86
|
+
{
|
|
87
|
+
overlay: true,
|
|
88
|
+
overlayOptions: {
|
|
89
|
+
anchor: 'center',
|
|
90
|
+
width: '70%',
|
|
91
|
+
minWidth: 44,
|
|
92
|
+
maxHeight: '60%',
|
|
93
|
+
visible: (terminalWidth) => terminalWidth >= 60,
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
)
|
|
97
|
+
.finally(() => {
|
|
98
|
+
opening = false;
|
|
99
|
+
});
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The packaged Input component has no masking, and a key must never render, so
|
|
105
|
+
// this draws its own single masked field.
|
|
106
|
+
export class JevKeyOverlay implements Component {
|
|
107
|
+
private entry = '';
|
|
108
|
+
private readonly tui: TUI;
|
|
109
|
+
private readonly theme: Theme;
|
|
110
|
+
private readonly close: () => void;
|
|
111
|
+
private readonly actions: JevKeyActions;
|
|
112
|
+
private readonly ctx: ExtensionContext;
|
|
113
|
+
|
|
114
|
+
constructor(
|
|
115
|
+
tui: TUI,
|
|
116
|
+
theme: Theme,
|
|
117
|
+
close: () => void,
|
|
118
|
+
actions: JevKeyActions,
|
|
119
|
+
ctx: ExtensionContext,
|
|
120
|
+
) {
|
|
121
|
+
this.tui = tui;
|
|
122
|
+
this.theme = theme;
|
|
123
|
+
this.close = close;
|
|
124
|
+
this.actions = actions;
|
|
125
|
+
this.ctx = ctx;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
handleInput(keyData: string): void {
|
|
129
|
+
const pasted = readPastedKey(keyData);
|
|
130
|
+
if (pasted !== undefined) {
|
|
131
|
+
this.append(pasted);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (matchesKey(keyData, Key.escape)) {
|
|
135
|
+
this.close();
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (matchesKey(keyData, Key.enter)) {
|
|
139
|
+
this.save();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (matchesKey(keyData, Key.backspace)) {
|
|
143
|
+
this.entry = this.entry.slice(0, -1);
|
|
144
|
+
this.tui.requestRender();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (matchesKey(keyData, CLEAR_KEY)) {
|
|
148
|
+
this.clearStored();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (isTypedCharacter(keyData)) {
|
|
152
|
+
this.append(keyData);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private append(text: string): void {
|
|
157
|
+
if (text.length === 0) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
this.entry = `${this.entry}${text}`.slice(0, MAX_KEY_LENGTH);
|
|
161
|
+
this.tui.requestRender();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
render(width: number): string[] {
|
|
165
|
+
const innerWidth = Math.max(1, width - 2);
|
|
166
|
+
const border = (text: string) => this.theme.fg('border', text);
|
|
167
|
+
const line = (text: string) => truncateToWidth(text, innerWidth, '…', true);
|
|
168
|
+
const row = (text: string) => `${border('│')}${line(text)}${border('│')}`;
|
|
169
|
+
|
|
170
|
+
const lines = [
|
|
171
|
+
border(`╭${'─'.repeat(innerWidth)}╮`),
|
|
172
|
+
row(this.theme.fg('accent', this.theme.bold(' Jev API key '))),
|
|
173
|
+
border(`├${'─'.repeat(innerWidth)}┤`),
|
|
174
|
+
row(` ${MASK_CHARACTER.repeat(this.entry.length)}`),
|
|
175
|
+
row(this.theme.fg('dim', ` source: ${this.originLabel()}`)),
|
|
176
|
+
row(
|
|
177
|
+
this.theme.fg(
|
|
178
|
+
'dim',
|
|
179
|
+
` calls this session: ${String(this.actions.usage())}`,
|
|
180
|
+
),
|
|
181
|
+
),
|
|
182
|
+
border(`├${'─'.repeat(innerWidth)}┤`),
|
|
183
|
+
row(
|
|
184
|
+
this.theme.fg(
|
|
185
|
+
'dim',
|
|
186
|
+
' The key is stored in sideroom.json, never in the session.',
|
|
187
|
+
),
|
|
188
|
+
),
|
|
189
|
+
row(
|
|
190
|
+
this.theme.fg(
|
|
191
|
+
'dim',
|
|
192
|
+
` ${JEV_KEY_ENV_VAR} wins over the file when both are set.`,
|
|
193
|
+
),
|
|
194
|
+
),
|
|
195
|
+
border(`├${'─'.repeat(innerWidth)}┤`),
|
|
196
|
+
row(this.theme.fg('dim', ' Enter save · Ctrl+U clear · Esc close')),
|
|
197
|
+
border(`╰${'─'.repeat(innerWidth)}╯`),
|
|
198
|
+
];
|
|
199
|
+
return lines;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
invalidate(): void {}
|
|
203
|
+
|
|
204
|
+
private save(): void {
|
|
205
|
+
const apiKey = this.entry.trim();
|
|
206
|
+
if (apiKey.length === 0) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
this.actions.store(apiKey);
|
|
210
|
+
this.finish('Jev API key stored.');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private clearStored(): void {
|
|
214
|
+
this.entry = '';
|
|
215
|
+
this.actions.clear();
|
|
216
|
+
this.finish('Jev API key cleared.');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private finish(message: string): void {
|
|
220
|
+
this.actions.retry();
|
|
221
|
+
this.actions.refresh(this.ctx);
|
|
222
|
+
this.ctx.ui.notify(message, 'info');
|
|
223
|
+
this.close();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private originLabel(): string {
|
|
227
|
+
const origin = this.actions.origin();
|
|
228
|
+
if (origin === 'environment') {
|
|
229
|
+
return `from ${JEV_KEY_ENV_VAR}`;
|
|
230
|
+
}
|
|
231
|
+
if (origin === 'config') {
|
|
232
|
+
return 'stored in sideroom.json';
|
|
233
|
+
}
|
|
234
|
+
return 'not configured';
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Pi re-wraps paste content as `ESC[200~ ... ESC[201~`, so the payload is read
|
|
239
|
+
// between the markers. A paste split across chunks still contributes whatever
|
|
240
|
+
// arrived after the opening marker.
|
|
241
|
+
export function readPastedKey(keyData: string): string | undefined {
|
|
242
|
+
const start = keyData.indexOf(PASTE_START);
|
|
243
|
+
if (start === -1) {
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
const from = start + PASTE_START.length;
|
|
247
|
+
const end = keyData.indexOf(PASTE_END, from);
|
|
248
|
+
const body = end === -1 ? keyData.slice(from) : keyData.slice(from, end);
|
|
249
|
+
return printableOnly(body);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// C0 control bytes and DEL are the only boundaries that matter here, and they
|
|
253
|
+
// live in one place so the two input paths cannot drift apart.
|
|
254
|
+
function isControlCode(code: number): boolean {
|
|
255
|
+
return code < 0x20 || code === 0x7f;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Paste drops the space as well: no key holds one, and a pasted line carries a
|
|
259
|
+
// trailing newline that would otherwise sit right before the closing marker.
|
|
260
|
+
function printableOnly(text: string): string {
|
|
261
|
+
let printable = '';
|
|
262
|
+
for (const character of text) {
|
|
263
|
+
const code = character.codePointAt(0) ?? 0;
|
|
264
|
+
if (!isControlCode(code) && character !== ' ') {
|
|
265
|
+
printable += character;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return printable;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function isTypedCharacter(keyData: string): boolean {
|
|
272
|
+
if (keyData.length === 0) {
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
for (const character of keyData) {
|
|
276
|
+
if (isControlCode(character.codePointAt(0) ?? 0)) {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
@@ -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
|
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
registerPersonaGuard,
|
|
12
12
|
resetPersonaRun,
|
|
13
13
|
} from './guard.ts';
|
|
14
|
-
import {
|
|
14
|
+
import { personaStatusLabel } from './model.ts';
|
|
15
15
|
import { appendPersonaReminder } from './prompt.ts';
|
|
16
16
|
|
|
17
17
|
export const PERSONA_DESCRIPTION =
|
|
@@ -22,7 +22,16 @@ export const PERSONA_PROMPT_SNIPPET = 'Read the active Sideroom persona.';
|
|
|
22
22
|
// Pi loads extensions/*/index.ts through export default.
|
|
23
23
|
export default function registerPersona(pi: ExtensionAPI): void {
|
|
24
24
|
const state = createPersonaGuardState();
|
|
25
|
-
|
|
25
|
+
const publishStatus = (ctx: ExtensionContext): void => {
|
|
26
|
+
if (!ctx.hasUI) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
ctx.ui.setStatus(
|
|
30
|
+
PERSONA_STATUS_KEY,
|
|
31
|
+
personaStatusLabel(state.blocksThisRun, state.steersThisRun),
|
|
32
|
+
);
|
|
33
|
+
};
|
|
34
|
+
registerPersonaGuard(pi, state, publishStatus);
|
|
26
35
|
|
|
27
36
|
pi.on('session_start', (_event, ctx) => publishStatus(ctx));
|
|
28
37
|
|
|
@@ -68,10 +77,3 @@ export default function registerPersona(pi: ExtensionAPI): void {
|
|
|
68
77
|
},
|
|
69
78
|
});
|
|
70
79
|
}
|
|
71
|
-
|
|
72
|
-
function publishStatus(ctx: ExtensionContext): void {
|
|
73
|
-
if (!ctx.hasUI) {
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
ctx.ui.setStatus(PERSONA_STATUS_KEY, PERSONA_STATUS_LABEL);
|
|
77
|
-
}
|
|
@@ -20,7 +20,23 @@ export interface PersonaToolDetails {
|
|
|
20
20
|
readonly prohibitions: readonly string[];
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
export
|
|
23
|
+
export function personaStatusLabel(blocks: number, steers: number): string {
|
|
24
|
+
const counts = [
|
|
25
|
+
countLabel(blocks, 'block'),
|
|
26
|
+
countLabel(steers, 'steer'),
|
|
27
|
+
].filter((count) => count.length > 0);
|
|
28
|
+
if (counts.length === 0) {
|
|
29
|
+
return 'persona: direct';
|
|
30
|
+
}
|
|
31
|
+
return `persona: direct · ${counts.join(' · ')}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function countLabel(count: number, noun: string): string {
|
|
35
|
+
if (count === 0) {
|
|
36
|
+
return '';
|
|
37
|
+
}
|
|
38
|
+
return `${String(count)} ${noun}${count === 1 ? '' : 's'}`;
|
|
39
|
+
}
|
|
24
40
|
|
|
25
41
|
export function personaToolDetails(): PersonaToolDetails {
|
|
26
42
|
return {
|
|
@@ -82,7 +98,7 @@ export function formatSteer(
|
|
|
82
98
|
(violation) =>
|
|
83
99
|
`- ${violation.prohibition.rule} Found: "${violation.excerpt}"`,
|
|
84
100
|
),
|
|
85
|
-
'Rewrite
|
|
101
|
+
'Rewrite in plain words: subject and goal first, everyday terms, no concept jargon the user did not use.',
|
|
86
102
|
].join('\n');
|
|
87
103
|
}
|
|
88
104
|
|
|
@@ -15,10 +15,19 @@ 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
|
+
export const PERSONA_EXAMPLES = [
|
|
21
|
+
"Do: Enable the flag to skip cache (enableCache). Don't: The registry resolves the provider through the auth resolver pipeline.",
|
|
22
|
+
"Do: This runs when the session closes (session_save). Don't: The lifecycle hook tears down session-scoped resources via the cleanup path.",
|
|
23
|
+
] as const;
|
|
24
|
+
|
|
18
25
|
export const PERSONA_REMINDER = `${PERSONA_REMINDER_HEADING}
|
|
19
26
|
Voice: ${VOICE_SUMMARY}
|
|
20
27
|
Never: ${PROHIBITION_SUMMARY}
|
|
21
28
|
${ENFORCEMENT_SUMMARY}
|
|
29
|
+
Examples:
|
|
30
|
+
${PERSONA_EXAMPLES.join('\n')}
|
|
22
31
|
Full guide: call ${PERSONA_TOOL_NAME} or read ${PERSONA_SKILL_PATH}.`;
|
|
23
32
|
|
|
24
33
|
export function appendPersonaReminder(systemPrompt: string): string {
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
2
1
|
import { resolve } from 'node:path';
|
|
3
2
|
import {
|
|
4
3
|
type ExtensionAPI,
|
|
@@ -7,6 +6,7 @@ import {
|
|
|
7
6
|
isWriteToolResult,
|
|
8
7
|
type ToolCallEvent,
|
|
9
8
|
} from '@earendil-works/pi-coding-agent';
|
|
9
|
+
import { readFileIfExists } from '../shared/read-file.ts';
|
|
10
10
|
import { languageForPath, type RuleId, SEVERITY } from './catalog.ts';
|
|
11
11
|
import { evaluateAddedLines } from './checks.ts';
|
|
12
12
|
import {
|
|
@@ -164,21 +164,6 @@ function extractEdit(
|
|
|
164
164
|
return { path, language: languageForPath(path), lines };
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
-
function readFileIfExists(path: string): string | undefined {
|
|
168
|
-
try {
|
|
169
|
-
return readFileSync(path, 'utf8');
|
|
170
|
-
} catch (error) {
|
|
171
|
-
if (isMissingFile(error)) {
|
|
172
|
-
return undefined;
|
|
173
|
-
}
|
|
174
|
-
throw error;
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function isMissingFile(error: unknown): boolean {
|
|
179
|
-
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
|
|
180
|
-
}
|
|
181
|
-
|
|
182
167
|
function isDegraded(state: RulesState, ruleId: RuleId): boolean {
|
|
183
168
|
return (state.fires.get(ruleId) ?? 0) >= BLOCK_DEGRADE_AFTER;
|
|
184
169
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
// A missing file is an expected answer to "what was there before", not a
|
|
4
|
+
// failure. Anything else about the read is a real problem and surfaces.
|
|
5
|
+
export function readFileIfExists(path: string): string | undefined {
|
|
6
|
+
try {
|
|
7
|
+
return readFileSync(path, 'utf8');
|
|
8
|
+
} catch (error) {
|
|
9
|
+
if (isMissingFile(error)) {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
throw error;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isMissingFile(error: unknown): boolean {
|
|
17
|
+
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
|
|
18
|
+
}
|
|
@@ -91,6 +91,8 @@ function resetTurnState(runState: TodoRunState): void {
|
|
|
91
91
|
runState.turnMutated = false;
|
|
92
92
|
runState.turnCalledTodo = false;
|
|
93
93
|
runState.updateWatchdogThisTurn = false;
|
|
94
|
+
// A steer only silences the turn that sent it; later turns may steer again.
|
|
95
|
+
runState.steerFromUs = false;
|
|
94
96
|
}
|
|
95
97
|
|
|
96
98
|
function sendSteer(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmrdeveloper/sideroom-pi",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.9.0",
|
|
4
4
|
"description": "Pi coding-agent package that asks clarifying questions, shows a live todo board, enforces coding guidelines, and will not call it done while your checks are red.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -22,20 +22,25 @@ needs the full persona instead of the injected reminder.
|
|
|
22
22
|
|
|
23
23
|
| # | Rule | Do | Don't |
|
|
24
24
|
| --- | --- | --- | --- |
|
|
25
|
-
| 1 |
|
|
26
|
-
| 2 |
|
|
27
|
-
| 3 |
|
|
25
|
+
| 1 | Plain, never in doubt | `Enable the flag to skip cache (enableCache).` | `The registry resolves the provider through the auth resolver pipeline.` |
|
|
26
|
+
| 2 | Direct and dry | `The guard blocks the write. Fix the emoji and retry.` | `I'd be happy to help with that! Let's take a look together.` |
|
|
27
|
+
| 3 | No filler | Start with the answer. | `Before we dive in…`, `As I mentioned earlier…`, `To summarize what I just said…` |
|
|
28
28
|
| 4 | No invented terms | `the file the mutation adds` | `the artifact delta` (when the user never asked for that name) |
|
|
29
29
|
| 5 | Short prose | Prose for one or two points. | A three-bullet list for a single sentence. |
|
|
30
30
|
| 6 | User's language | Answer in the language and variant the user wrote in. | A Spanish question answered in English. |
|
|
31
31
|
|
|
32
|
-
### Plain means
|
|
32
|
+
### Plain means everyday words, not more words
|
|
33
33
|
|
|
34
|
-
Rule
|
|
35
|
-
Concretely: name the subject and the goal **before** the detail,
|
|
36
|
-
|
|
34
|
+
Rule 1 outranks brevity for clarity, and rule 3 outranks rule 1 for length.
|
|
35
|
+
Concretely: name the subject and the goal **before** the detail, use everyday
|
|
36
|
+
words instead of concept jargon (`hook`, `pipeline`, `registry`, `resolver`,
|
|
37
|
+
`guardrail`, `invariant`) unless the user already used them, define any
|
|
38
|
+
necessary term in one line, then stop. Being explicit is not a licence to
|
|
37
39
|
repeat yourself, restate the question, or explain the same point twice.
|
|
38
40
|
|
|
41
|
+
- Do: `This runs when the session closes. In code: \`session_save\`.`
|
|
42
|
+
- Don't: `The lifecycle hook tears down session-scoped resources via the
|
|
43
|
+
cleanup path.`
|
|
39
44
|
- Do: `The persona reminder is injected on every turn, so it survives
|
|
40
45
|
compaction. That is why switching profiles would be pointless here.`
|
|
41
46
|
- Don't: `What I'm saying is that the reminder gets injected, and since it's
|
|
@@ -72,7 +77,9 @@ dead-locked.
|
|
|
72
77
|
### What steering means
|
|
73
78
|
|
|
74
79
|
The extension sends a corrective steer after a degraded artifact block or a
|
|
75
|
-
prohibited assistant message.
|
|
80
|
+
prohibited assistant message. Steers use `triggerTurn: true` so they reach the
|
|
81
|
+
model even when the run is already idle. At most three steers are sent per
|
|
82
|
+
agent run.
|
|
76
83
|
|
|
77
84
|
## Boundaries
|
|
78
85
|
|