@rmrdeveloper/sideroom-pi 8.2.0 → 8.4.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 +76 -2
- package/extensions/explain/guard.ts +51 -0
- package/extensions/explain/index.ts +11 -0
- package/extensions/explain/model.ts +28 -0
- package/extensions/persona/catalog.ts +110 -0
- package/extensions/persona/checks.ts +116 -0
- package/extensions/persona/execute.ts +17 -0
- package/extensions/persona/guard.ts +170 -0
- package/extensions/persona/index.ts +77 -0
- package/extensions/persona/model.ts +105 -0
- package/extensions/persona/prompt.ts +32 -0
- package/extensions/todo/index.ts +8 -0
- package/extensions/todo/ui.ts +233 -24
- package/package.json +3 -2
- package/skills/sideroom-persona/SKILL.md +79 -0
package/README.md
CHANGED
|
@@ -54,7 +54,9 @@ Sideroom answers each one with a small, opinionated surface:
|
|
|
54
54
|
| Style drift | A pre-edit gate + the on-demand `sideroom-guidelines` skill |
|
|
55
55
|
| Vague plans | `sideroom-grill` — an interview that settles the words before the work |
|
|
56
56
|
| Unapplied guidelines | `sideroom_rules` — mechanical checks that block or flag the lines you add |
|
|
57
|
+
| Unclear answers | `sideroom_persona` — one voice: direct, plain, and free of jargon it invented |
|
|
57
58
|
| Premature completion | `sideroom_done` — steers back to the project's check command before finishing |
|
|
59
|
+
| Work left unexplained | `explain` — offers a walkthrough and how to test it once the work settles |
|
|
58
60
|
|
|
59
61
|
## What it feels like
|
|
60
62
|
|
|
@@ -70,8 +72,10 @@ wall of tool calls, it:
|
|
|
70
72
|
- **Works in the open.** A compact board sits above the editor:
|
|
71
73
|
`pending → in_progress → completed`. Exactly one step is active at a
|
|
72
74
|
time; the agent completes the current step and starts the next one in the
|
|
73
|
-
same move, so skipped steps become structurally hard. The
|
|
74
|
-
|
|
75
|
+
same move, so skipped steps become structurally hard. The widget shows at
|
|
76
|
+
most five rows, keeps the active step visible, and `F9` opens the full
|
|
77
|
+
read-only board. The board survives reload, tree navigation, and
|
|
78
|
+
compaction.
|
|
75
79
|
- **Shows its traces.** Below the board, the last edited files appear as
|
|
76
80
|
clickable `file://` links. `F8` opens the full session history; `R` clears
|
|
77
81
|
it. Only successful Pi `write`/`edit` calls are tracked — no guesses from
|
|
@@ -114,6 +118,10 @@ chat. It never creates a repository task file or a `/todos` command.
|
|
|
114
118
|
- `update` patches `{ id, status?, content? }` items and works everywhere,
|
|
115
119
|
including headless modes.
|
|
116
120
|
- Invariant: while anything is pending, exactly one item is `in_progress`.
|
|
121
|
+
- Widget: at most five rows — open items before resolved ones, the
|
|
122
|
+
`in_progress` row always visible — plus a `…+N more · F9: view all` hint.
|
|
123
|
+
`F9` opens the full board as a read-only overlay, so the widget stays small
|
|
124
|
+
without hiding work from you; the agent still gets every item in its prompt.
|
|
117
125
|
|
|
118
126
|
### Edited files — proof, not promises
|
|
119
127
|
|
|
@@ -157,6 +165,18 @@ appended as notes to the tool result. A per-rule circuit breaker degrades a
|
|
|
157
165
|
repeatedly firing block to a note so the agent never dead-locks. No files
|
|
158
166
|
written, no config read: the catalog ships with the package.
|
|
159
167
|
|
|
168
|
+
### Persona — one voice, not a costume
|
|
169
|
+
|
|
170
|
+
`sideroom_persona` is the agent's voice, and there is exactly one of it. A
|
|
171
|
+
short reminder rides the system prompt on every turn, so it survives
|
|
172
|
+
compaction; the full guide lives in the on-demand `sideroom-persona` skill.
|
|
173
|
+
The voice is direct and dry, free of filler, and plain enough to leave no
|
|
174
|
+
doubt about what is being discussed — while calling things by the names the
|
|
175
|
+
user already uses. Emojis and decorative symbols in the lines a `write`/`edit`
|
|
176
|
+
adds block the mutation; flattering openers, hedging, automatic apologies, and
|
|
177
|
+
AI meta-commentary are answered with a capped steer. No profiles to switch and
|
|
178
|
+
no profile selection to store.
|
|
179
|
+
|
|
160
180
|
### Done — done means green
|
|
161
181
|
|
|
162
182
|
`sideroom_done` detects the project's check command — `package.json` scripts
|
|
@@ -166,6 +186,16 @@ target — and watches for it to pass. If files changed without a green run, the
|
|
|
166
186
|
agent is steered once per turn, up to a cap, to run it before finishing. When
|
|
167
187
|
nothing is detectable, the gate does nothing.
|
|
168
188
|
|
|
189
|
+
### Explain — the walkthrough offer
|
|
190
|
+
|
|
191
|
+
When an implementation settles, `explain` steers the agent to offer a
|
|
192
|
+
walkthrough through `sideroom_ask`: explain the changes and how to test them,
|
|
193
|
+
only how to test them, or nothing. It fires on `agent_settled`, the point where
|
|
194
|
+
no retry, compaction, or queued message is left, so the offer never lands on
|
|
195
|
+
work that is about to be redone. Once per prompt, only after a real
|
|
196
|
+
`write`/`edit`, and only in the TUI, because `sideroom_ask` cannot run anywhere
|
|
197
|
+
else. No tool, no widget, no persisted state.
|
|
198
|
+
|
|
169
199
|
## Quick path
|
|
170
200
|
|
|
171
201
|
1. Install globally from npm. Pi records this in your user settings, so do
|
|
@@ -213,6 +243,13 @@ Items have stable string ids, short content, and one of `pending`,
|
|
|
213
243
|
start the next one in the same `update` call. Snapshots live in the active
|
|
214
244
|
session branch and are rebuilt after navigation and compaction.
|
|
215
245
|
|
|
246
|
+
- Widget: max five rows. Open items win the cap over resolved ones, the
|
|
247
|
+
`in_progress` row is always shown, and a `…+N more · F9: view all` line
|
|
248
|
+
appears when rows are hidden.
|
|
249
|
+
- Extended view: `F9` toggles a read-only overlay. `↑`/`↓` and `PgUp`/`PgDn`
|
|
250
|
+
scroll; `Esc` or `F9` closes. It never mutates the board.
|
|
251
|
+
- The cap is display-only: every item still reaches the system prompt.
|
|
252
|
+
|
|
216
253
|
A forked session inherits the board visible at its fork point. Later board
|
|
217
254
|
updates are reconstructed from each session's active branch, so the fork and
|
|
218
255
|
the original session evolve independently.
|
|
@@ -247,6 +284,21 @@ the diff against the loaded guide and run the relevant project checks.
|
|
|
247
284
|
- A rule that blocks three times in a run degrades to a warning until five
|
|
248
285
|
clean checks or a new interactive prompt reset it.
|
|
249
286
|
|
|
287
|
+
### Persona contract
|
|
288
|
+
|
|
289
|
+
- One built-in voice. No profiles, no switching, and no persisted profile
|
|
290
|
+
state; runtime guard counters stay in memory. The footer status is derived
|
|
291
|
+
from the catalog.
|
|
292
|
+
- Injection: a short reminder appended in `before_agent_start`, chained after
|
|
293
|
+
the other extensions and idempotent by heading, so it returns after
|
|
294
|
+
compaction.
|
|
295
|
+
- Blocking: decorative symbols in the lines a `write`/`edit` adds. Three fires
|
|
296
|
+
degrade the prohibition to a steer; five clean mutations reset the counters.
|
|
297
|
+
- Steering: one corrective message per violating assistant message, at most
|
|
298
|
+
three per run.
|
|
299
|
+
- `sideroom_persona` takes no arguments and returns every rule and every
|
|
300
|
+
prohibition with its enforcement mode.
|
|
301
|
+
|
|
250
302
|
### Done contract
|
|
251
303
|
|
|
252
304
|
- Detected command: `package.json` scripts in priority order with the
|
|
@@ -256,6 +308,17 @@ the diff against the loaded guide and run the relevant project checks.
|
|
|
256
308
|
successful `write`/`edit` clears green again.
|
|
257
309
|
- Steering: once per turn, at most twice per run, then it stops.
|
|
258
310
|
|
|
311
|
+
### Explain contract
|
|
312
|
+
|
|
313
|
+
- Trigger: `agent_settled` with at least one successful `write`/`edit` since the
|
|
314
|
+
last user prompt, in TUI mode.
|
|
315
|
+
- The steer names the intent; the agent writes the question in the user's
|
|
316
|
+
language with three options and one recommendation. `sideroom_ask` still adds
|
|
317
|
+
*Out of scope* and the custom answer.
|
|
318
|
+
- At most one offer per user prompt. The flag is set before the steer, so the
|
|
319
|
+
turn the offer triggers cannot re-trigger it.
|
|
320
|
+
- No green-check requirement: red checks still produce the offer.
|
|
321
|
+
|
|
259
322
|
### Grill contract
|
|
260
323
|
|
|
261
324
|
Load the skill at the start of a change with a fuzzy plan.
|
|
@@ -280,6 +343,17 @@ Maintainer reference lives in [`docs/`](docs/README.md): the
|
|
|
280
343
|
[architecture](docs/architecture.md) and one page per
|
|
281
344
|
[extension](docs/extensions/ask.md).
|
|
282
345
|
|
|
346
|
+
## Contributing
|
|
347
|
+
|
|
348
|
+
Contributions are welcome: bug reports, feature requests, language guides, and
|
|
349
|
+
pull requests. Start with [`CONTRIBUTING.md`](CONTRIBUTING.md) for setup, the
|
|
350
|
+
commands, the design rules, and the branch and release flow — pull requests
|
|
351
|
+
target `develop`, and `main` only carries releases.
|
|
352
|
+
|
|
353
|
+
- [Report a bug](https://github.com/RMRdeveloper/sideroom-pi/issues/new?template=bug_report.yml) or [request a feature](https://github.com/RMRdeveloper/sideroom-pi/issues/new?template=feature_request.yml).
|
|
354
|
+
- Report vulnerabilities privately through [`SECURITY.md`](SECURITY.md).
|
|
355
|
+
- Participation is covered by the [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md).
|
|
356
|
+
|
|
283
357
|
## License
|
|
284
358
|
|
|
285
359
|
MIT.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ExtensionAPI,
|
|
3
|
+
isEditToolResult,
|
|
4
|
+
isWriteToolResult,
|
|
5
|
+
} from '@earendil-works/pi-coding-agent';
|
|
6
|
+
import {
|
|
7
|
+
EXPLAIN_OFFER,
|
|
8
|
+
EXPLAIN_OFFER_TYPE,
|
|
9
|
+
type ExplainState,
|
|
10
|
+
resetExplainRun,
|
|
11
|
+
shouldOfferExplanation,
|
|
12
|
+
} from './model.ts';
|
|
13
|
+
|
|
14
|
+
export function registerExplainGuard(
|
|
15
|
+
pi: ExtensionAPI,
|
|
16
|
+
state: ExplainState,
|
|
17
|
+
): void {
|
|
18
|
+
pi.on('input', (event) => {
|
|
19
|
+
if (event.source !== 'interactive' && event.source !== 'rpc') {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
resetExplainRun(state);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
pi.on('tool_result', (event) => {
|
|
26
|
+
if (event.isError) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (!isEditToolResult(event) && !isWriteToolResult(event)) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
state.mutatedSincePrompt = true;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// agent_settled is the only point with no retry, compaction, or queued
|
|
36
|
+
// continuation left, so the walkthrough is never offered over reworked work.
|
|
37
|
+
pi.on('agent_settled', (_event, ctx) => {
|
|
38
|
+
if (!shouldOfferExplanation(state, ctx.mode)) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
state.offeredThisRun = true;
|
|
42
|
+
pi.sendMessage(
|
|
43
|
+
{
|
|
44
|
+
customType: EXPLAIN_OFFER_TYPE,
|
|
45
|
+
content: EXPLAIN_OFFER,
|
|
46
|
+
display: false,
|
|
47
|
+
},
|
|
48
|
+
{ triggerTurn: true, deliverAs: 'steer' },
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import { registerExplainGuard } from './guard.ts';
|
|
3
|
+
import { createExplainState, resetExplainRun } from './model.ts';
|
|
4
|
+
|
|
5
|
+
// Pi loads extensions/*/index.ts through export default.
|
|
6
|
+
export default function registerExplain(pi: ExtensionAPI): void {
|
|
7
|
+
const state = createExplainState();
|
|
8
|
+
registerExplainGuard(pi, state);
|
|
9
|
+
|
|
10
|
+
pi.on('session_start', () => resetExplainRun(state));
|
|
11
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export const EXPLAIN_OFFER_TYPE = 'sideroom-explain-offer';
|
|
2
|
+
|
|
3
|
+
const TUI_MODE = 'tui';
|
|
4
|
+
|
|
5
|
+
export const EXPLAIN_OFFER =
|
|
6
|
+
"Use sideroom_ask for one question in the user's language with three options: explain changes and test steps, test steps only, or no explanation. Recommend one.";
|
|
7
|
+
|
|
8
|
+
export interface ExplainState {
|
|
9
|
+
mutatedSincePrompt: boolean;
|
|
10
|
+
offeredThisRun: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createExplainState(): ExplainState {
|
|
14
|
+
return { mutatedSincePrompt: false, offeredThisRun: false };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function resetExplainRun(state: ExplainState): void {
|
|
18
|
+
state.mutatedSincePrompt = false;
|
|
19
|
+
state.offeredThisRun = false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// sideroom_ask rejects every mode but the TUI, so the offer is gated the same way.
|
|
23
|
+
export function shouldOfferExplanation(
|
|
24
|
+
state: ExplainState,
|
|
25
|
+
mode: string,
|
|
26
|
+
): boolean {
|
|
27
|
+
return mode === TUI_MODE && state.mutatedSincePrompt && !state.offeredThisRun;
|
|
28
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { dirname, join, resolve } from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
|
|
4
|
+
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
|
5
|
+
|
|
6
|
+
export const PERSONA_TOOL_NAME = 'sideroom_persona';
|
|
7
|
+
export const PERSONA_STATUS_KEY = 'sideroom-persona';
|
|
8
|
+
export const PERSONA_STEER_TYPE = 'sideroom-persona-steer';
|
|
9
|
+
export const PERSONA_SKILL_PATH = join(
|
|
10
|
+
PACKAGE_ROOT,
|
|
11
|
+
'skills/sideroom-persona/SKILL.md',
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
export const TEXT_SCOPE = {
|
|
15
|
+
prose: 'prose',
|
|
16
|
+
artifact: 'artifact',
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
export type TextScope = (typeof TEXT_SCOPE)[keyof typeof TEXT_SCOPE];
|
|
20
|
+
|
|
21
|
+
export type VoiceRuleId =
|
|
22
|
+
| 'direct'
|
|
23
|
+
| 'no-filler'
|
|
24
|
+
| 'plain-language'
|
|
25
|
+
| 'no-invented-terms'
|
|
26
|
+
| 'short-prose'
|
|
27
|
+
| 'user-language';
|
|
28
|
+
|
|
29
|
+
export interface VoiceRule {
|
|
30
|
+
readonly id: VoiceRuleId;
|
|
31
|
+
readonly instruction: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const VOICE_RULES: readonly VoiceRule[] = [
|
|
35
|
+
{
|
|
36
|
+
id: 'direct',
|
|
37
|
+
instruction:
|
|
38
|
+
'Be direct and dry. Use short sentences. No preamble or closing summary.',
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: 'no-filler',
|
|
42
|
+
instruction: 'No filler.',
|
|
43
|
+
},
|
|
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
|
+
{
|
|
50
|
+
id: 'no-invented-terms',
|
|
51
|
+
instruction:
|
|
52
|
+
'Name things as they are. Add no intermediate terms, abbreviations, or codenames unless the user asks.',
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: 'short-prose',
|
|
56
|
+
instruction:
|
|
57
|
+
'Default to short prose. Use lists or tables only for comparisons or more than three items.',
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: 'user-language',
|
|
61
|
+
instruction: "Answer in the user's language and variant.",
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
export type ProhibitionId =
|
|
66
|
+
| 'decorative-symbols'
|
|
67
|
+
| 'flattery-and-filler'
|
|
68
|
+
| 'hedging-and-apology'
|
|
69
|
+
| 'ai-meta-commentary';
|
|
70
|
+
|
|
71
|
+
export interface Prohibition {
|
|
72
|
+
readonly id: ProhibitionId;
|
|
73
|
+
readonly rule: string;
|
|
74
|
+
readonly scopes: readonly TextScope[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const PROHIBITIONS: readonly Prohibition[] = [
|
|
78
|
+
{
|
|
79
|
+
id: 'decorative-symbols',
|
|
80
|
+
rule: 'No emojis or decorative symbols in prose or artifacts.',
|
|
81
|
+
scopes: [TEXT_SCOPE.prose, TEXT_SCOPE.artifact],
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: 'flattery-and-filler',
|
|
85
|
+
rule: 'No flattering openers such as "Great question!", restating what was just said, or announcing the answer.',
|
|
86
|
+
scopes: [TEXT_SCOPE.prose],
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: 'hedging-and-apology',
|
|
90
|
+
rule: 'No hedging such as "maybe we could consider" or automatic apologies.',
|
|
91
|
+
scopes: [TEXT_SCOPE.prose],
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: 'ai-meta-commentary',
|
|
95
|
+
rule: 'No AI meta-commentary such as "as a language model" or "I do not have access to".',
|
|
96
|
+
scopes: [TEXT_SCOPE.prose],
|
|
97
|
+
},
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
const PROHIBITION_BY_ID: ReadonlyMap<ProhibitionId, Prohibition> = new Map(
|
|
101
|
+
PROHIBITIONS.map((prohibition) => [prohibition.id, prohibition]),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
export function prohibitionById(id: ProhibitionId): Prohibition {
|
|
105
|
+
const prohibition = PROHIBITION_BY_ID.get(id);
|
|
106
|
+
if (prohibition === undefined) {
|
|
107
|
+
throw new Error(`Unknown prohibition id: ${id}`);
|
|
108
|
+
}
|
|
109
|
+
return prohibition;
|
|
110
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Prohibition,
|
|
3
|
+
type ProhibitionId,
|
|
4
|
+
prohibitionById,
|
|
5
|
+
TEXT_SCOPE,
|
|
6
|
+
type TextScope,
|
|
7
|
+
} from './catalog.ts';
|
|
8
|
+
|
|
9
|
+
interface ProhibitionDetector {
|
|
10
|
+
readonly prohibitionId: ProhibitionId;
|
|
11
|
+
readonly pattern: RegExp;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Each detector is a heuristic for one prohibition. The rule text in the
|
|
15
|
+
// catalog stays authoritative; a detector never fires on its own judgement.
|
|
16
|
+
const DETECTORS: readonly ProhibitionDetector[] = [
|
|
17
|
+
{
|
|
18
|
+
prohibitionId: 'decorative-symbols',
|
|
19
|
+
pattern: /\p{Extended_Pictographic}|\uFE0F|[\u2713\u2714\u2717\u2718]/u,
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
prohibitionId: 'flattery-and-filler',
|
|
23
|
+
pattern:
|
|
24
|
+
/^[ \t>*+-]*(?:[¡!]?\s*(?:great|excellent|awesome|fantastic|amazing|perfect|good)\s+(?:question|idea|point|catch|observation|call)\b[!.]?|certainly\b|of course\b|absolutely\b|sure thing\b|[¡!]?\s*(?:excelente|gran|buena|perfecta)\s+(?:pregunta|idea|observaci[oó]n|punto)\b[!.]?|por supuesto\b|claro que s[ií]\b)/imu,
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
prohibitionId: 'hedging-and-apology',
|
|
28
|
+
pattern:
|
|
29
|
+
/\b(?:sorry (?:for|about)|i apologize|my apologies|perd[oó]n por|disculp[áa]|lamento (?:el|la|los|las|haber|no haber))\b|\b(?:maybe we could|might be worth|i think we could|we could consider|perhaps we could|quiz[áa]s podr[íi]amos|tal vez podr[íi]amos|podr[íi]amos considerar|creo que podr[íi]amos|you might want to consider)\b/iu,
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
prohibitionId: 'ai-meta-commentary',
|
|
33
|
+
pattern:
|
|
34
|
+
/\b(?:as an ai\b|as a language model\b|i['’]?m an ai\b|i am an ai\b|como (?:un )?modelo de lenguaje\b|soy una? (?:ia|inteligencia artificial|asistente de ia)\b|no tengo acceso a (?:internet|tiempo real)\b|i (?:do not|don['’]?t) have access to (?:the )?internet\b)/iu,
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
export interface ProhibitionViolation {
|
|
39
|
+
readonly prohibition: Prohibition;
|
|
40
|
+
readonly excerpt: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function findViolations(
|
|
44
|
+
text: string,
|
|
45
|
+
scope: TextScope,
|
|
46
|
+
): readonly ProhibitionViolation[] {
|
|
47
|
+
if (text.length === 0) {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const violations: ProhibitionViolation[] = [];
|
|
52
|
+
for (const detector of DETECTORS) {
|
|
53
|
+
const prohibition = prohibitionById(detector.prohibitionId);
|
|
54
|
+
if (!prohibition.scopes.includes(scope)) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const match = detector.pattern.exec(text);
|
|
58
|
+
if (match === null) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
violations.push({ prohibition, excerpt: excerptOf(match[0]) });
|
|
62
|
+
}
|
|
63
|
+
return violations;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function artifactViolations(
|
|
67
|
+
addedLines: readonly string[],
|
|
68
|
+
): readonly ProhibitionViolation[] {
|
|
69
|
+
return findViolations(addedLines.join('\n'), TEXT_SCOPE.artifact);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function addedLinesMissingFrom(
|
|
73
|
+
previous: string | undefined,
|
|
74
|
+
next: string,
|
|
75
|
+
): readonly string[] {
|
|
76
|
+
const remaining = countLines(previous);
|
|
77
|
+
const added: string[] = [];
|
|
78
|
+
|
|
79
|
+
for (const line of next.split('\n')) {
|
|
80
|
+
const key = line.trim();
|
|
81
|
+
if (key.length === 0) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const available = remaining.get(key) ?? 0;
|
|
85
|
+
if (available > 0) {
|
|
86
|
+
remaining.set(key, available - 1);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
added.push(line);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return added;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function countLines(content: string | undefined): Map<string, number> {
|
|
96
|
+
const counts = new Map<string, number>();
|
|
97
|
+
if (content === undefined) {
|
|
98
|
+
return counts;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const line of content.split('\n')) {
|
|
102
|
+
const key = line.trim();
|
|
103
|
+
if (key.length === 0) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
107
|
+
}
|
|
108
|
+
return counts;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Detector matches are short by construction; drop the bullet or indentation
|
|
112
|
+
// they carry so the reason stays on one line.
|
|
113
|
+
function excerptOf(match: string): string {
|
|
114
|
+
const singleLine = match.replaceAll(/\s+/gu, ' ').trim();
|
|
115
|
+
return singleLine.replace(/^[>*+-]+\s*/u, '');
|
|
116
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import {
|
|
2
|
+
formatPersonaDetail,
|
|
3
|
+
type PersonaToolDetails,
|
|
4
|
+
personaToolDetails,
|
|
5
|
+
} from './model.ts';
|
|
6
|
+
|
|
7
|
+
export interface PersonaToolResult {
|
|
8
|
+
readonly content: { readonly type: 'text'; readonly text: string }[];
|
|
9
|
+
readonly details: PersonaToolDetails;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function executePersona(): PersonaToolResult {
|
|
13
|
+
return {
|
|
14
|
+
content: [{ type: 'text', text: formatPersonaDetail() }],
|
|
15
|
+
details: personaToolDetails(),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
type ExtensionAPI,
|
|
5
|
+
isToolCallEventType,
|
|
6
|
+
type ToolCallEvent,
|
|
7
|
+
} from '@earendil-works/pi-coding-agent';
|
|
8
|
+
import {
|
|
9
|
+
PERSONA_STEER_TYPE,
|
|
10
|
+
type ProhibitionId,
|
|
11
|
+
TEXT_SCOPE,
|
|
12
|
+
} from './catalog.ts';
|
|
13
|
+
import {
|
|
14
|
+
addedLinesMissingFrom,
|
|
15
|
+
artifactViolations,
|
|
16
|
+
findViolations,
|
|
17
|
+
} from './checks.ts';
|
|
18
|
+
import {
|
|
19
|
+
assistantMessageText,
|
|
20
|
+
formatArtifactSteer,
|
|
21
|
+
formatBlockReason,
|
|
22
|
+
formatSteer,
|
|
23
|
+
} from './model.ts';
|
|
24
|
+
|
|
25
|
+
// A rule that keeps firing degrades to a steer instead of trapping the agent.
|
|
26
|
+
const BLOCK_DEGRADE_AFTER = 3;
|
|
27
|
+
const CLEAN_RESET_AFTER = 5;
|
|
28
|
+
|
|
29
|
+
// One agent run can be corrected at most this many times.
|
|
30
|
+
const STEER_LIMIT_PER_RUN = 3;
|
|
31
|
+
|
|
32
|
+
interface ExtractedMutation {
|
|
33
|
+
readonly path: string;
|
|
34
|
+
readonly addedLines: readonly string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PersonaGuardState {
|
|
38
|
+
readonly fires: Map<ProhibitionId, number>;
|
|
39
|
+
cleanMutations: number;
|
|
40
|
+
steersThisRun: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createPersonaGuardState(): PersonaGuardState {
|
|
44
|
+
return { fires: new Map(), cleanMutations: 0, steersThisRun: 0 };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function resetPersonaRun(state: PersonaGuardState): void {
|
|
48
|
+
state.steersThisRun = 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function registerPersonaGuard(
|
|
52
|
+
pi: ExtensionAPI,
|
|
53
|
+
state: PersonaGuardState,
|
|
54
|
+
): void {
|
|
55
|
+
pi.on('tool_call', (event, ctx) => {
|
|
56
|
+
const mutation = extractMutation(event, ctx.cwd);
|
|
57
|
+
if (mutation === undefined) {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
return applyArtifactOutcome(pi, state, mutation);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
pi.on('message_end', (event) => {
|
|
64
|
+
if (event.message.role !== 'assistant') {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
const violations = findViolations(
|
|
68
|
+
assistantMessageText(event.message.content),
|
|
69
|
+
TEXT_SCOPE.prose,
|
|
70
|
+
);
|
|
71
|
+
if (violations.length === 0) {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
sendPersonaSteer(pi, state, formatSteer(violations));
|
|
76
|
+
return undefined;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function applyArtifactOutcome(
|
|
81
|
+
pi: ExtensionAPI,
|
|
82
|
+
state: PersonaGuardState,
|
|
83
|
+
mutation: ExtractedMutation,
|
|
84
|
+
): { block: true; reason: string } | undefined {
|
|
85
|
+
const violations = artifactViolations(mutation.addedLines);
|
|
86
|
+
if (violations.length === 0) {
|
|
87
|
+
registerCleanMutation(state);
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const blocking = violations.filter(
|
|
92
|
+
(violation) => !isDegraded(state, violation.prohibition.id),
|
|
93
|
+
);
|
|
94
|
+
if (blocking.length > 0) {
|
|
95
|
+
for (const violation of blocking) {
|
|
96
|
+
incrementFires(state, violation.prohibition.id);
|
|
97
|
+
}
|
|
98
|
+
return { block: true, reason: formatBlockReason(mutation.path, blocking) };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
sendPersonaSteer(pi, state, formatArtifactSteer(mutation.path, violations));
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function extractMutation(
|
|
106
|
+
event: ToolCallEvent,
|
|
107
|
+
cwd: string,
|
|
108
|
+
): ExtractedMutation | undefined {
|
|
109
|
+
if (isToolCallEventType('write', event)) {
|
|
110
|
+
const previous = readFileIfExists(resolve(cwd, event.input.path));
|
|
111
|
+
return {
|
|
112
|
+
path: event.input.path,
|
|
113
|
+
addedLines: addedLinesMissingFrom(previous, event.input.content),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
if (isToolCallEventType('edit', event)) {
|
|
117
|
+
const addedLines = event.input.edits.flatMap((edit) =>
|
|
118
|
+
addedLinesMissingFrom(edit.oldText, edit.newText),
|
|
119
|
+
);
|
|
120
|
+
return { path: event.input.path, addedLines };
|
|
121
|
+
}
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function readFileIfExists(path: string): string | undefined {
|
|
126
|
+
try {
|
|
127
|
+
return readFileSync(path, 'utf8');
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (isMissingFile(error)) {
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function isMissingFile(error: unknown): boolean {
|
|
137
|
+
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function isDegraded(state: PersonaGuardState, id: ProhibitionId): boolean {
|
|
141
|
+
return (state.fires.get(id) ?? 0) >= BLOCK_DEGRADE_AFTER;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function incrementFires(state: PersonaGuardState, id: ProhibitionId): void {
|
|
145
|
+
state.fires.set(id, (state.fires.get(id) ?? 0) + 1);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function sendPersonaSteer(
|
|
149
|
+
pi: Pick<ExtensionAPI, 'sendMessage'>,
|
|
150
|
+
state: PersonaGuardState,
|
|
151
|
+
content: string,
|
|
152
|
+
): void {
|
|
153
|
+
if (state.steersThisRun >= STEER_LIMIT_PER_RUN) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
state.steersThisRun += 1;
|
|
157
|
+
pi.sendMessage(
|
|
158
|
+
{ customType: PERSONA_STEER_TYPE, content, display: false },
|
|
159
|
+
{ deliverAs: 'steer' },
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function registerCleanMutation(state: PersonaGuardState): void {
|
|
164
|
+
state.cleanMutations += 1;
|
|
165
|
+
if (state.cleanMutations < CLEAN_RESET_AFTER) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
state.cleanMutations = 0;
|
|
169
|
+
state.fires.clear();
|
|
170
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
} from '@earendil-works/pi-coding-agent';
|
|
5
|
+
import { Text } from '@earendil-works/pi-tui';
|
|
6
|
+
import { Type } from 'typebox';
|
|
7
|
+
import { PERSONA_STATUS_KEY, PERSONA_TOOL_NAME } from './catalog.ts';
|
|
8
|
+
import { executePersona, type PersonaToolResult } from './execute.ts';
|
|
9
|
+
import {
|
|
10
|
+
createPersonaGuardState,
|
|
11
|
+
registerPersonaGuard,
|
|
12
|
+
resetPersonaRun,
|
|
13
|
+
} from './guard.ts';
|
|
14
|
+
import { PERSONA_STATUS_LABEL } from './model.ts';
|
|
15
|
+
import { appendPersonaReminder } from './prompt.ts';
|
|
16
|
+
|
|
17
|
+
export const PERSONA_DESCRIPTION =
|
|
18
|
+
"Read Sideroom's voice rules and hard prohibitions for user-facing answers.";
|
|
19
|
+
|
|
20
|
+
export const PERSONA_PROMPT_SNIPPET = 'Read the active Sideroom persona.';
|
|
21
|
+
|
|
22
|
+
// Pi loads extensions/*/index.ts through export default.
|
|
23
|
+
export default function registerPersona(pi: ExtensionAPI): void {
|
|
24
|
+
const state = createPersonaGuardState();
|
|
25
|
+
registerPersonaGuard(pi, state);
|
|
26
|
+
|
|
27
|
+
pi.on('session_start', (_event, ctx) => publishStatus(ctx));
|
|
28
|
+
|
|
29
|
+
pi.on('before_agent_start', (event, ctx) => {
|
|
30
|
+
resetPersonaRun(state);
|
|
31
|
+
publishStatus(ctx);
|
|
32
|
+
const systemPrompt = appendPersonaReminder(event.systemPrompt);
|
|
33
|
+
if (systemPrompt === event.systemPrompt) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
return { systemPrompt };
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
pi.registerTool({
|
|
40
|
+
name: PERSONA_TOOL_NAME,
|
|
41
|
+
label: 'Sideroom Persona',
|
|
42
|
+
description: PERSONA_DESCRIPTION,
|
|
43
|
+
promptSnippet: PERSONA_PROMPT_SNIPPET,
|
|
44
|
+
parameters: Type.Object({}),
|
|
45
|
+
executionMode: 'sequential',
|
|
46
|
+
execute: async () => executePersona(),
|
|
47
|
+
renderCall(_args, theme) {
|
|
48
|
+
return new Text(
|
|
49
|
+
theme.fg('toolTitle', theme.bold(PERSONA_TOOL_NAME)),
|
|
50
|
+
0,
|
|
51
|
+
0,
|
|
52
|
+
);
|
|
53
|
+
},
|
|
54
|
+
renderResult(result, _options, theme) {
|
|
55
|
+
const details = result.details as
|
|
56
|
+
| PersonaToolResult['details']
|
|
57
|
+
| undefined;
|
|
58
|
+
const voices = details?.voiceRules.length ?? 0;
|
|
59
|
+
const prohibitions = details?.prohibitions.length ?? 0;
|
|
60
|
+
return new Text(
|
|
61
|
+
theme.fg(
|
|
62
|
+
'success',
|
|
63
|
+
`${String(voices)} voice rules · ${String(prohibitions)} prohibitions`,
|
|
64
|
+
),
|
|
65
|
+
0,
|
|
66
|
+
0,
|
|
67
|
+
);
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
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
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PERSONA_SKILL_PATH,
|
|
3
|
+
PERSONA_TOOL_NAME,
|
|
4
|
+
PROHIBITIONS,
|
|
5
|
+
type Prohibition,
|
|
6
|
+
TEXT_SCOPE,
|
|
7
|
+
VOICE_RULES,
|
|
8
|
+
} from './catalog.ts';
|
|
9
|
+
import type { ProhibitionViolation } from './checks.ts';
|
|
10
|
+
|
|
11
|
+
// Pi narrows these blocks before handing the message over; the shape is
|
|
12
|
+
// structural so no runtime decoding is needed here.
|
|
13
|
+
export interface TextBlock {
|
|
14
|
+
readonly type: string;
|
|
15
|
+
readonly text?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PersonaToolDetails {
|
|
19
|
+
readonly voiceRules: readonly string[];
|
|
20
|
+
readonly prohibitions: readonly string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const PERSONA_STATUS_LABEL = `persona: direct, ${String(PROHIBITIONS.length)} prohibitions`;
|
|
24
|
+
|
|
25
|
+
export function personaToolDetails(): PersonaToolDetails {
|
|
26
|
+
return {
|
|
27
|
+
voiceRules: VOICE_RULES.map((rule) => rule.id),
|
|
28
|
+
prohibitions: PROHIBITIONS.map((prohibition) => prohibition.id),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function formatPersonaDetail(): string {
|
|
33
|
+
return [
|
|
34
|
+
'Sideroom persona: one always-on voice; no profiles or switching.',
|
|
35
|
+
'',
|
|
36
|
+
'Voice rules',
|
|
37
|
+
...VOICE_RULES.map((rule) => `- ${rule.instruction}`),
|
|
38
|
+
'',
|
|
39
|
+
'Hard prohibitions',
|
|
40
|
+
...PROHIBITIONS.map(
|
|
41
|
+
(prohibition) => `- ${prohibition.rule} (${enforcementOf(prohibition)})`,
|
|
42
|
+
),
|
|
43
|
+
'',
|
|
44
|
+
`Complete guide: ${PERSONA_SKILL_PATH}`,
|
|
45
|
+
].join('\n');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function formatBlockReason(
|
|
49
|
+
path: string,
|
|
50
|
+
violations: readonly ProhibitionViolation[],
|
|
51
|
+
): string {
|
|
52
|
+
return [
|
|
53
|
+
`Blocked ${path}: the Sideroom persona forbids this content.`,
|
|
54
|
+
...violations.map(
|
|
55
|
+
(violation) =>
|
|
56
|
+
`- ${violation.prohibition.rule} Found: "${violation.excerpt}"`,
|
|
57
|
+
),
|
|
58
|
+
'Remove the flagged content and retry the mutation.',
|
|
59
|
+
].join('\n');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function formatArtifactSteer(
|
|
63
|
+
path: string,
|
|
64
|
+
violations: readonly ProhibitionViolation[],
|
|
65
|
+
): string {
|
|
66
|
+
return [
|
|
67
|
+
`Sideroom persona stopped blocking ${path} after repeated attempts, but this content is still forbidden.`,
|
|
68
|
+
...violations.map(
|
|
69
|
+
(violation) =>
|
|
70
|
+
`- ${violation.prohibition.rule} Found: "${violation.excerpt}"`,
|
|
71
|
+
),
|
|
72
|
+
'Remove it in the next edit.',
|
|
73
|
+
].join('\n');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function formatSteer(
|
|
77
|
+
violations: readonly ProhibitionViolation[],
|
|
78
|
+
): string {
|
|
79
|
+
return [
|
|
80
|
+
`${PERSONA_TOOL_NAME}: part of your last message is forbidden.`,
|
|
81
|
+
...violations.map(
|
|
82
|
+
(violation) =>
|
|
83
|
+
`- ${violation.prohibition.rule} Found: "${violation.excerpt}"`,
|
|
84
|
+
),
|
|
85
|
+
'Rewrite it plainly: name the subject and goal before details, without ambiguity or repetition.',
|
|
86
|
+
].join('\n');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function assistantMessageText(content: readonly TextBlock[]): string {
|
|
90
|
+
const parts: string[] = [];
|
|
91
|
+
for (const block of content) {
|
|
92
|
+
if (block.type !== 'text') {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
parts.push(block.text ?? '');
|
|
96
|
+
}
|
|
97
|
+
return parts.join('\n');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function enforcementOf(prohibition: Prohibition): string {
|
|
101
|
+
if (prohibition.scopes.includes(TEXT_SCOPE.artifact)) {
|
|
102
|
+
return 'blocked on write and edit';
|
|
103
|
+
}
|
|
104
|
+
return 'corrected with a steer';
|
|
105
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PERSONA_SKILL_PATH,
|
|
3
|
+
PERSONA_TOOL_NAME,
|
|
4
|
+
PROHIBITIONS,
|
|
5
|
+
VOICE_RULES,
|
|
6
|
+
} from './catalog.ts';
|
|
7
|
+
|
|
8
|
+
export const PERSONA_REMINDER_HEADING =
|
|
9
|
+
'Sideroom persona (always-on reminder):';
|
|
10
|
+
|
|
11
|
+
const VOICE_SUMMARY = VOICE_RULES.map((rule) => rule.instruction).join(' ');
|
|
12
|
+
const PROHIBITION_SUMMARY = PROHIBITIONS.map(
|
|
13
|
+
(prohibition) => prohibition.rule,
|
|
14
|
+
).join(' ');
|
|
15
|
+
const ENFORCEMENT_SUMMARY =
|
|
16
|
+
'A write/edit adding a decorative symbol is blocked; other violations trigger a steer.';
|
|
17
|
+
|
|
18
|
+
export const PERSONA_REMINDER = `${PERSONA_REMINDER_HEADING}
|
|
19
|
+
Voice: ${VOICE_SUMMARY}
|
|
20
|
+
Never: ${PROHIBITION_SUMMARY}
|
|
21
|
+
${ENFORCEMENT_SUMMARY}
|
|
22
|
+
Full guide: call ${PERSONA_TOOL_NAME} or read ${PERSONA_SKILL_PATH}.`;
|
|
23
|
+
|
|
24
|
+
export function appendPersonaReminder(systemPrompt: string): string {
|
|
25
|
+
if (systemPrompt.includes(PERSONA_REMINDER_HEADING)) {
|
|
26
|
+
return systemPrompt;
|
|
27
|
+
}
|
|
28
|
+
if (systemPrompt.length === 0) {
|
|
29
|
+
return PERSONA_REMINDER;
|
|
30
|
+
}
|
|
31
|
+
return `${systemPrompt}\n\n${PERSONA_REMINDER}`;
|
|
32
|
+
}
|
package/extensions/todo/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
TODO_WIDGET_REFRESH_EVENT,
|
|
19
19
|
type TodoStore,
|
|
20
20
|
} from './session.ts';
|
|
21
|
+
import { createTodoBoardOverlayController } from './ui.ts';
|
|
21
22
|
|
|
22
23
|
export const TODO_DESCRIPTION =
|
|
23
24
|
'Maintain a live, user-visible work board. propose replaces the full board; update patches existing items by id.';
|
|
@@ -32,9 +33,12 @@ export const TODO_PROMPT_GUIDELINES = [
|
|
|
32
33
|
'propose is interactive-TUI-only; update also works in print, JSON, and RPC modes.',
|
|
33
34
|
];
|
|
34
35
|
|
|
36
|
+
const BOARD_SHORTCUT = 'f9';
|
|
37
|
+
|
|
35
38
|
// Pi loads extensions/*/index.ts through export default.
|
|
36
39
|
export default function registerTodo(pi: ExtensionAPI): void {
|
|
37
40
|
const store = createTodoStore();
|
|
41
|
+
const overlay = createTodoBoardOverlayController(() => store.items);
|
|
38
42
|
const restore: BoardLifecycle = (ctx) => {
|
|
39
43
|
restoreBoard(store, ctx);
|
|
40
44
|
};
|
|
@@ -50,6 +54,10 @@ export default function registerTodo(pi: ExtensionAPI): void {
|
|
|
50
54
|
registerGuardEvents(pi, store, restore);
|
|
51
55
|
registerBoardContext(pi, store, restoreAndRefresh);
|
|
52
56
|
registerTodoTool(pi, store, notifyWidgetRefresh);
|
|
57
|
+
pi.registerShortcut(BOARD_SHORTCUT, {
|
|
58
|
+
description: 'Toggle the work board',
|
|
59
|
+
handler: (ctx) => overlay.toggle(ctx),
|
|
60
|
+
});
|
|
53
61
|
}
|
|
54
62
|
|
|
55
63
|
function registerTodoTool(
|
package/extensions/todo/ui.ts
CHANGED
|
@@ -1,5 +1,48 @@
|
|
|
1
|
-
import type { Theme } from '@earendil-works/pi-coding-agent';
|
|
2
|
-
import {
|
|
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 {
|
|
10
|
+
formatBoardBlock,
|
|
11
|
+
TODO_STATUS,
|
|
12
|
+
type TodoItem,
|
|
13
|
+
type TodoStatus,
|
|
14
|
+
} from './model.ts';
|
|
15
|
+
|
|
16
|
+
const COMPACT_ROW_LIMIT = 5;
|
|
17
|
+
const EXTENDED_ROW_LIMIT = 12;
|
|
18
|
+
|
|
19
|
+
const SELECTION_RANK = {
|
|
20
|
+
inProgress: 0,
|
|
21
|
+
pending: 1,
|
|
22
|
+
resolved: 2,
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
export interface VisibleBoardRows {
|
|
26
|
+
readonly indexes: readonly number[];
|
|
27
|
+
readonly hiddenCount: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function selectVisibleBoardRows(
|
|
31
|
+
items: readonly TodoItem[],
|
|
32
|
+
limit: number,
|
|
33
|
+
): VisibleBoardRows {
|
|
34
|
+
if (items.length <= limit) {
|
|
35
|
+
return { indexes: items.map((_item, index) => index), hiddenCount: 0 };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const indexes = items
|
|
39
|
+
.map((item, index) => ({ index, rank: selectionRank(item) }))
|
|
40
|
+
.sort((left, right) => left.rank - right.rank || left.index - right.index)
|
|
41
|
+
.slice(0, limit)
|
|
42
|
+
.map((row) => row.index)
|
|
43
|
+
.sort((left, right) => left - right);
|
|
44
|
+
return { indexes, hiddenCount: items.length - indexes.length };
|
|
45
|
+
}
|
|
3
46
|
|
|
4
47
|
export function renderTodoWidget(
|
|
5
48
|
items: readonly TodoItem[],
|
|
@@ -9,33 +52,199 @@ export function renderTodoWidget(
|
|
|
9
52
|
return undefined;
|
|
10
53
|
}
|
|
11
54
|
|
|
55
|
+
const rows = boardRowLines(items);
|
|
56
|
+
const visible = selectVisibleBoardRows(items, COMPACT_ROW_LIMIT);
|
|
57
|
+
const renderedRows = visible.indexes.flatMap((index) => {
|
|
58
|
+
const item = items[index];
|
|
59
|
+
const row = rows[index];
|
|
60
|
+
if (item === undefined || row === undefined) {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
return [colorizeBoardRow(row, item.status, theme)];
|
|
64
|
+
});
|
|
65
|
+
const lines = [
|
|
66
|
+
theme.fg('accent', theme.bold(boardHeader(items))),
|
|
67
|
+
...renderedRows,
|
|
68
|
+
];
|
|
69
|
+
if (visible.hiddenCount > 0) {
|
|
70
|
+
lines.push(theme.fg('dim', hiddenRowsHint(visible.hiddenCount)));
|
|
71
|
+
}
|
|
72
|
+
return lines;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface TodoBoardOverlayController {
|
|
76
|
+
toggle(ctx: ExtensionContext): void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function createTodoBoardOverlayController(
|
|
80
|
+
items: () => readonly TodoItem[],
|
|
81
|
+
): TodoBoardOverlayController {
|
|
82
|
+
let close: (() => void) | undefined;
|
|
83
|
+
let opening = false;
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
toggle(ctx) {
|
|
87
|
+
if (close !== undefined) {
|
|
88
|
+
close();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (opening || ctx.mode !== 'tui') {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
opening = true;
|
|
96
|
+
void ctx.ui
|
|
97
|
+
.custom<void>(
|
|
98
|
+
(tui, theme, _keybindings, done) => {
|
|
99
|
+
close = done;
|
|
100
|
+
return new TodoBoardOverlay(tui, items, theme, done);
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
overlay: true,
|
|
104
|
+
overlayOptions: {
|
|
105
|
+
anchor: 'center',
|
|
106
|
+
width: '70%',
|
|
107
|
+
minWidth: 40,
|
|
108
|
+
maxHeight: '80%',
|
|
109
|
+
visible: (terminalWidth) => terminalWidth >= 60,
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
)
|
|
113
|
+
.finally(() => {
|
|
114
|
+
close = undefined;
|
|
115
|
+
opening = false;
|
|
116
|
+
});
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export class TodoBoardOverlay implements Component {
|
|
122
|
+
private offset = 0;
|
|
123
|
+
private readonly tui: TUI;
|
|
124
|
+
private readonly items: () => readonly TodoItem[];
|
|
125
|
+
private readonly theme: Theme;
|
|
126
|
+
private readonly close: () => void;
|
|
127
|
+
|
|
128
|
+
constructor(
|
|
129
|
+
tui: TUI,
|
|
130
|
+
items: () => readonly TodoItem[],
|
|
131
|
+
theme: Theme,
|
|
132
|
+
close: () => void,
|
|
133
|
+
) {
|
|
134
|
+
this.tui = tui;
|
|
135
|
+
this.items = items;
|
|
136
|
+
this.theme = theme;
|
|
137
|
+
this.close = close;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
handleInput(data: string): void {
|
|
141
|
+
const maxOffset = Math.max(0, this.items().length - EXTENDED_ROW_LIMIT);
|
|
142
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.f9)) {
|
|
143
|
+
this.close();
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (matchesKey(data, Key.up)) {
|
|
147
|
+
this.offset = Math.max(0, this.offset - 1);
|
|
148
|
+
this.tui.requestRender();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (matchesKey(data, Key.down)) {
|
|
152
|
+
this.offset = Math.min(maxOffset, this.offset + 1);
|
|
153
|
+
this.tui.requestRender();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (matchesKey(data, Key.pageUp)) {
|
|
157
|
+
this.offset = Math.max(0, this.offset - EXTENDED_ROW_LIMIT);
|
|
158
|
+
this.tui.requestRender();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (matchesKey(data, Key.pageDown)) {
|
|
162
|
+
this.offset = Math.min(maxOffset, this.offset + EXTENDED_ROW_LIMIT);
|
|
163
|
+
this.tui.requestRender();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
render(width: number): string[] {
|
|
168
|
+
const innerWidth = Math.max(1, width - 2);
|
|
169
|
+
const items = this.items();
|
|
170
|
+
const rows = boardRowLines(items);
|
|
171
|
+
const visibleRows = items
|
|
172
|
+
.map((item, index) => ({ item, row: rows[index] }))
|
|
173
|
+
.slice(this.offset, this.offset + EXTENDED_ROW_LIMIT);
|
|
174
|
+
const border = (text: string) => this.theme.fg('border', text);
|
|
175
|
+
const line = (text: string) => truncateToWidth(text, innerWidth, '…', true);
|
|
176
|
+
const title = line(` Work board (${String(items.length)}) `);
|
|
177
|
+
const lines = [
|
|
178
|
+
border(`╭${'─'.repeat(innerWidth)}╮`),
|
|
179
|
+
`${border('│')}${line(this.theme.fg('accent', this.theme.bold(title)))}${border('│')}`,
|
|
180
|
+
`${border('├')}${border('─'.repeat(innerWidth))}${border('┤')}`,
|
|
181
|
+
];
|
|
182
|
+
|
|
183
|
+
for (const entry of visibleRows) {
|
|
184
|
+
const row =
|
|
185
|
+
entry.row === undefined
|
|
186
|
+
? ''
|
|
187
|
+
: colorizeBoardRow(entry.row, entry.item.status, this.theme);
|
|
188
|
+
lines.push(`${border('│')}${line(row)}${border('│')}`);
|
|
189
|
+
}
|
|
190
|
+
if (items.length === 0) {
|
|
191
|
+
lines.push(
|
|
192
|
+
`${border('│')}${line(this.theme.fg('muted', ' No items on the board.'))}${border('│')}`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const scrollHint = items.length > EXTENDED_ROW_LIMIT ? ' ↑↓ scroll ·' : '';
|
|
197
|
+
lines.push(`${border('├')}${border('─'.repeat(innerWidth))}${border('┤')}`);
|
|
198
|
+
lines.push(
|
|
199
|
+
`${border('│')}${line(this.theme.fg('dim', `${scrollHint} F9 or Esc close`))}${border('│')}`,
|
|
200
|
+
);
|
|
201
|
+
lines.push(border(`╰${'─'.repeat(innerWidth)}╯`));
|
|
202
|
+
return lines;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
invalidate(): void {}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function selectionRank(item: TodoItem): number {
|
|
209
|
+
if (item.status === TODO_STATUS.inProgress) {
|
|
210
|
+
return SELECTION_RANK.inProgress;
|
|
211
|
+
}
|
|
212
|
+
if (item.status === TODO_STATUS.pending) {
|
|
213
|
+
return SELECTION_RANK.pending;
|
|
214
|
+
}
|
|
215
|
+
return SELECTION_RANK.resolved;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function boardHeader(items: readonly TodoItem[]): string {
|
|
12
219
|
const active = items.filter(
|
|
13
220
|
(item) => item.status === TODO_STATUS.inProgress,
|
|
14
221
|
).length;
|
|
15
222
|
const queued = items.filter(
|
|
16
223
|
(item) => item.status === TODO_STATUS.pending,
|
|
17
224
|
).length;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
225
|
+
return `Sideroom board (${String(active)} active, ${String(queued)} queued)`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function boardRowLines(items: readonly TodoItem[]): string[] {
|
|
229
|
+
return formatBoardBlock(items)
|
|
230
|
+
.split('\n')
|
|
231
|
+
.slice(1, 1 + items.length);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function colorizeBoardRow(
|
|
235
|
+
row: string,
|
|
236
|
+
status: TodoStatus,
|
|
237
|
+
theme: Theme,
|
|
238
|
+
): string {
|
|
239
|
+
if (status === TODO_STATUS.inProgress) {
|
|
240
|
+
return theme.fg('success', row);
|
|
241
|
+
}
|
|
242
|
+
if (status === TODO_STATUS.pending) {
|
|
243
|
+
return theme.fg('text', row);
|
|
244
|
+
}
|
|
245
|
+
return theme.fg('muted', row);
|
|
246
|
+
}
|
|
39
247
|
|
|
40
|
-
|
|
248
|
+
function hiddenRowsHint(hiddenCount: number): string {
|
|
249
|
+
return `…+${String(hiddenCount)} more · F9: view all`;
|
|
41
250
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmrdeveloper/sideroom-pi",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.4.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": [
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"version-packages": "changeset version",
|
|
17
17
|
"commitlint": "commitlint",
|
|
18
18
|
"types": "tsc --noEmit",
|
|
19
|
-
"test": "node --test 'extensions/**/*.test.ts'",
|
|
19
|
+
"test": "node --test 'extensions/**/*.test.ts' 'scripts/**/*.test.mjs'",
|
|
20
20
|
"lint": "biome lint extensions scripts commitlint.config.mjs",
|
|
21
21
|
"lint:fix": "biome lint --write extensions scripts commitlint.config.mjs",
|
|
22
22
|
"format": "biome format --write extensions scripts package.json biome.json commitlint.config.mjs .changeset/config.json",
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"linting",
|
|
52
52
|
"testing",
|
|
53
53
|
"done",
|
|
54
|
+
"persona",
|
|
54
55
|
"skills",
|
|
55
56
|
"typescript",
|
|
56
57
|
"tui",
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sideroom-persona
|
|
3
|
+
description: "Apply Sideroom's single built-in persona: the voice rules and hard prohibitions every user-facing answer must follow. Load it when an explanation, summary, or report needs the full guide. Do not use for code-only mutations that need no prose."
|
|
4
|
+
license: MIT
|
|
5
|
+
metadata:
|
|
6
|
+
author: RMRdeveloper
|
|
7
|
+
version: "1.0"
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Sideroom persona
|
|
11
|
+
|
|
12
|
+
One built-in voice, always active. There is no other profile and nothing to
|
|
13
|
+
switch. The extension injects a short reminder on every agent run; this file is
|
|
14
|
+
the complete guide.
|
|
15
|
+
|
|
16
|
+
## Activation Contract
|
|
17
|
+
|
|
18
|
+
Read this whole file with `read`, without `offset` or `limit`, when an answer
|
|
19
|
+
needs the full persona instead of the injected reminder.
|
|
20
|
+
|
|
21
|
+
## Voice rules
|
|
22
|
+
|
|
23
|
+
| # | Rule | Do | Don't |
|
|
24
|
+
| --- | --- | --- | --- |
|
|
25
|
+
| 1 | 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.` |
|
|
26
|
+
| 2 | No filler | Start with the answer. | `Before we dive in…`, `As I mentioned earlier…`, `To summarize what I just said…` |
|
|
27
|
+
| 3 | Plain, never in doubt | `Persona controls how the agent talks. Its goal is to keep every answer direct and clear.` | `The voice layer hooks the persona surface and normalizes register.` |
|
|
28
|
+
| 4 | No invented terms | `the file the mutation adds` | `the artifact delta` (when the user never asked for that name) |
|
|
29
|
+
| 5 | Short prose | Prose for one or two points. | A three-bullet list for a single sentence. |
|
|
30
|
+
| 6 | User's language | Answer in the language and variant the user wrote in. | A Spanish question answered in English. |
|
|
31
|
+
|
|
32
|
+
### Plain means no doubt, not more words
|
|
33
|
+
|
|
34
|
+
Rule 3 outranks brevity for clarity, and rule 2 outranks rule 3 for length.
|
|
35
|
+
Concretely: name the subject and the goal **before** the detail, define an
|
|
36
|
+
unfamiliar term in one line, then stop. Being explicit is not a licence to
|
|
37
|
+
repeat yourself, restate the question, or explain the same point twice.
|
|
38
|
+
|
|
39
|
+
- Do: `The persona reminder is injected on every turn, so it survives
|
|
40
|
+
compaction. That is why switching profiles would be pointless here.`
|
|
41
|
+
- Don't: `What I'm saying is that the reminder gets injected, and since it's
|
|
42
|
+
injected every turn, it survives compaction — which means it comes back after
|
|
43
|
+
compaction, so profile switching has no effect.`
|
|
44
|
+
|
|
45
|
+
### Names the user did not ask for
|
|
46
|
+
|
|
47
|
+
Rule 4 bans coining vocabulary mid-conversation. Do not rename a concept the
|
|
48
|
+
user already named, do not invent abbreviations (`the PRF layer`), and do not
|
|
49
|
+
use an internal field name as if it were the domain word. Use the user's word
|
|
50
|
+
until they ask for another.
|
|
51
|
+
|
|
52
|
+
## Hard prohibitions
|
|
53
|
+
|
|
54
|
+
| Prohibition | Covers | Enforcement |
|
|
55
|
+
| --- | --- | --- |
|
|
56
|
+
| `decorative-symbols` | Emojis and decorative symbols. | Blocked on `write`/`edit`; also steered in prose. |
|
|
57
|
+
| `flattery-and-filler` | Flattering openers (`Great question!`), restating the prompt, announcing the answer. | Steered in prose. |
|
|
58
|
+
| `hedging-and-apology` | `maybe we could consider`, `sorry for the confusion`, `perdón por la confusión`. | Steered in prose. |
|
|
59
|
+
| `ai-meta-commentary` | `as a language model`, `I do not have access to`, `como modelo de lenguaje`. | Steered in prose. |
|
|
60
|
+
|
|
61
|
+
### What blocking means
|
|
62
|
+
|
|
63
|
+
A `write` or `edit` whose **added lines** contain a decorative symbol is
|
|
64
|
+
rejected with a reason listing the offending line. Existing content is not
|
|
65
|
+
re-checked, so editing a file that already contains an emoji elsewhere is not
|
|
66
|
+
blocked. A prohibition that fires three times degrades to a steer so the agent
|
|
67
|
+
is never dead-locked.
|
|
68
|
+
|
|
69
|
+
### What steering means
|
|
70
|
+
|
|
71
|
+
The extension sends a corrective steer after a degraded artifact block or a
|
|
72
|
+
prohibited assistant message. At most three steers are sent per agent run.
|
|
73
|
+
|
|
74
|
+
## Boundaries
|
|
75
|
+
|
|
76
|
+
- No persisted profile state, no profiles, no switching: the persona ships with the package.
|
|
77
|
+
- Nothing is written to the target repository.
|
|
78
|
+
- The persona governs wording. It never relaxes the coding guidelines, the
|
|
79
|
+
added-line rules, or the check-before-finish gate.
|