@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.
@@ -11,7 +11,7 @@ import {
11
11
  registerPersonaGuard,
12
12
  resetPersonaRun,
13
13
  } from './guard.ts';
14
- import { PERSONA_STATUS_LABEL } from './model.ts';
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
- registerPersonaGuard(pi, state);
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 const PERSONA_STATUS_LABEL = `persona: direct, ${String(PROHIBITIONS.length)} prohibitions`;
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 it plainly: name the subject and goal before details, without ambiguity or repetition.',
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
  }
@@ -1,6 +1,6 @@
1
1
  import { realpathSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
- import { isAbsolute, join, resolve } from 'node:path';
3
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
 
6
6
  const FILE_URL_PREFIX = 'file://';
@@ -29,6 +29,31 @@ export function resolveFileToolPath(
29
29
  return realpathOr(absolutePath);
30
30
  }
31
31
 
32
+ // A file that does not exist yet keeps its unresolved path, so it is matched
33
+ // against the literal working directory as well as the canonical one.
34
+ export function projectRelativePath(
35
+ cwd: string,
36
+ absolutePath: string,
37
+ ): string | undefined {
38
+ const literalRoot = resolve(cwd);
39
+ for (const root of new Set([realpathOr(literalRoot), literalRoot])) {
40
+ const relativePath = relative(root, absolutePath);
41
+ if (isInsideRoot(relativePath)) {
42
+ return relativePath;
43
+ }
44
+ }
45
+ return undefined;
46
+ }
47
+
48
+ function isInsideRoot(relativePath: string): boolean {
49
+ return (
50
+ relativePath.length > 0 &&
51
+ relativePath !== '..' &&
52
+ !relativePath.startsWith(`..${sep}`) &&
53
+ !isAbsolute(relativePath)
54
+ );
55
+ }
56
+
32
57
  function normalizeFileToolPath(path: string): string {
33
58
  const normalizedSpaces = path.replace(UNICODE_SPACES, ' ');
34
59
  const withoutAtPrefix = normalizedSpaces.startsWith('@')
@@ -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
+ }
@@ -0,0 +1,14 @@
1
+ export const REVIEW_NOTE_EVENT = 'sideroom:review-note';
2
+
3
+ export interface ReviewNote {
4
+ readonly text: string;
5
+ }
6
+
7
+ export function isReviewNote(value: unknown): value is ReviewNote {
8
+ return (
9
+ typeof value === 'object' &&
10
+ value !== null &&
11
+ 'text' in value &&
12
+ typeof value.text === 'string'
13
+ );
14
+ }
@@ -0,0 +1,50 @@
1
+ import type {
2
+ AgentBeforeSettleEvent,
3
+ AgentBeforeSettleEventResult,
4
+ InputEvent,
5
+ InputSource,
6
+ } from '@earendil-works/pi-coding-agent';
7
+
8
+ const COMPLETED_OUTCOME = 'completed';
9
+ const USER_ROLE = 'user';
10
+ const CUSTOM_MESSAGE_ENTRY = 'custom_message';
11
+ const USER_INPUT_SOURCES: ReadonlySet<InputSource> = new Set([
12
+ 'interactive',
13
+ 'rpc',
14
+ ]);
15
+
16
+ // A message typed while the agent runs joins the current run as a steer or
17
+ // follow-up; only input that arrives idle opens a new turn.
18
+ export function startsUserTurn(event: InputEvent): boolean {
19
+ return (
20
+ USER_INPUT_SOURCES.has(event.source) &&
21
+ event.streamingBehavior === undefined
22
+ );
23
+ }
24
+
25
+ // An aborted run is the user stopping the agent, an errored run has nothing
26
+ // worth reviewing, and a pending user message already decides what comes next.
27
+ export function acceptsSettleSteer(event: AgentBeforeSettleEvent): boolean {
28
+ if (event.outcome !== COMPLETED_OUTCOME) {
29
+ return false;
30
+ }
31
+ return !event.context.pendingMessages.some(
32
+ (message) => message.role === USER_ROLE,
33
+ );
34
+ }
35
+
36
+ // Handlers chain on agent_before_settle, so keep the entries earlier handlers
37
+ // proposed; replacing them would drop another extension's steer.
38
+ export function withHiddenSteer(
39
+ event: AgentBeforeSettleEvent,
40
+ customType: string,
41
+ content: string,
42
+ ): AgentBeforeSettleEventResult {
43
+ return {
44
+ entries: [
45
+ ...event.entries,
46
+ { type: CUSTOM_MESSAGE_ENTRY, customType, content, display: false },
47
+ ],
48
+ continue: true,
49
+ };
50
+ }
@@ -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.8.0",
3
+ "version": "8.10.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": [
@@ -89,8 +89,8 @@
89
89
  "@changesets/cli": "^3.0.2",
90
90
  "@commitlint/cli": "^21.2.2",
91
91
  "@commitlint/config-conventional": "^21.2.2",
92
- "@earendil-works/pi-coding-agent": "0.84.4",
93
- "@earendil-works/pi-tui": "0.84.4",
92
+ "@earendil-works/pi-coding-agent": "0.87.1",
93
+ "@earendil-works/pi-tui": "0.87.1",
94
94
  "@types/node": "^22.20.1",
95
95
  "typebox": "1.3.7",
96
96
  "typescript": "^5.6.0"
@@ -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 | 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.` |
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 no doubt, not more words
32
+ ### Plain means everyday words, not more words
33
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
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,12 @@ 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. At most three steers are sent per agent run.
80
+ prohibited assistant message. Both moments happen while the agent is working, so
81
+ the steer travels through `pi.sendMessage` with `{ triggerTurn: true, deliverAs:
82
+ 'steer' }` and joins the current run. A steer sent after the run ended belongs
83
+ to the guidelines review and the walkthrough offer, which append a
84
+ `custom_message` entry on `agent_before_settle` instead.
85
+ At most three steers are sent per agent run.
76
86
 
77
87
  ## Boundaries
78
88