@rmrdeveloper/sideroom-pi 5.0.1 → 6.0.1
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 +101 -81
- package/{src/assets → assets}/artifacts/GUIDELINES_TEMPLATE.md +40 -11
- package/extensions/ask/execute.ts +56 -0
- package/extensions/ask/index.ts +77 -0
- package/extensions/ask/model.ts +196 -0
- package/extensions/ask/ui.ts +445 -0
- package/extensions/modified-files/index.ts +45 -0
- package/extensions/modified-files/model.ts +74 -0
- package/extensions/modified-files/session.ts +200 -0
- package/extensions/modified-files/ui.ts +207 -0
- package/extensions/todo/execute.ts +69 -0
- package/extensions/todo/guards.ts +107 -0
- package/extensions/todo/index.ts +91 -0
- package/extensions/todo/model.ts +327 -0
- package/extensions/todo/session.ts +166 -0
- package/extensions/todo/ui.ts +41 -0
- package/package.json +26 -27
- package/dist/pi-extension.js +0 -1175
- package/skills/sideroom-critic/SKILL.md +0 -32
- package/skills/sideroom-grilling/SKILL.md +0 -81
- package/skills/sideroom-spec/SKILL.md +0 -38
- package/skills/sideroom-transcribe-audio/SKILL.md +0 -38
- package/skills/sideroom-transcribe-audio/scripts/transcribe.py +0 -40
- package/src/assets/agents/sideroom-code-reviewer.md +0 -30
- package/src/assets/agents/sideroom-fixer.md +0 -37
- package/src/assets/agents/sideroom-implementer.md +0 -39
- package/src/assets/agents/sideroom-planner.md +0 -31
- package/src/assets/agents/sideroom-verifier.md +0 -32
- package/src/assets/artifacts/guidelines/java.md +0 -211
- package/src/assets/artifacts/guidelines/javascript.md +0 -235
- package/src/assets/artifacts/guidelines/php-laravel.md +0 -210
- package/src/assets/artifacts/guidelines/python.md +0 -227
- package/src/assets/artifacts/guidelines/typescript.md +0 -231
package/README.md
CHANGED
|
@@ -5,26 +5,30 @@
|
|
|
5
5
|
[](https://www.npmjs.com/package/@rmrdeveloper/sideroom-pi)
|
|
6
6
|
|
|
7
7
|
Sideroom Pi is a global [Pi package](https://pi.dev/docs/latest/packages). It
|
|
8
|
-
registers
|
|
9
|
-
|
|
8
|
+
registers two parent-agent tools: `sideroom_ask`, a TUI questionnaire for
|
|
9
|
+
clarifying decisions, and `sideroom_todo`, a live work board shown above the
|
|
10
|
+
editor. It also shows a session-scoped list of files successfully edited by Pi.
|
|
11
|
+
It writes no `.pi` configuration, task graph, or other Sideroom files into the
|
|
12
|
+
repository you are working in.
|
|
10
13
|
|
|
11
|
-
|
|
12
|
-
in the repository being changed.
|
|
14
|
+
## Quick path
|
|
13
15
|
|
|
14
|
-
|
|
16
|
+
1. Install globally from npm. Pi writes this installation to your user
|
|
17
|
+
settings, so do not use `-l`.
|
|
15
18
|
|
|
16
|
-
|
|
17
|
-
|
|
19
|
+
```bash
|
|
20
|
+
pi install npm:@rmrdeveloper/sideroom-pi
|
|
21
|
+
```
|
|
18
22
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
23
|
+
2. Start Pi in the repository you want to work in.
|
|
24
|
+
3. Ask the parent agent a question that needs a decision or a visible work
|
|
25
|
+
board. It can call `sideroom_ask` or `sideroom_todo` in the TUI.
|
|
22
26
|
|
|
23
|
-
For a reproducible environment,
|
|
24
|
-
|
|
27
|
+
For a reproducible environment, pin a release. A pinned version does not move
|
|
28
|
+
when you run Pi's package update command.
|
|
25
29
|
|
|
26
30
|
```bash
|
|
27
|
-
pi install npm:@rmrdeveloper/sideroom-pi@
|
|
31
|
+
pi install npm:@rmrdeveloper/sideroom-pi@7.0.0
|
|
28
32
|
```
|
|
29
33
|
|
|
30
34
|
To update an unpinned installation:
|
|
@@ -33,74 +37,80 @@ To update an unpinned installation:
|
|
|
33
37
|
pi update --extension npm:@rmrdeveloper/sideroom-pi
|
|
34
38
|
```
|
|
35
39
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
40
|
+
## Details
|
|
41
|
+
|
|
42
|
+
| Topic | Decision |
|
|
43
|
+
| --- | --- |
|
|
44
|
+
| Surface | Two LLM-callable tools: `sideroom_ask` and `sideroom_todo`. No `/sideroom` command. |
|
|
45
|
+
| Questionnaire | One `sideroom_ask` call is one 1–N question batch; every question has `recommendationIndex`. |
|
|
46
|
+
| Always-on rows | The questionnaire UI adds Out of scope and a custom answer. Do not send those options. |
|
|
47
|
+
| Work board | `sideroom_todo propose` replaces a visible board; `update` patches items by id. |
|
|
48
|
+
| Headless | `sideroom_todo update` works in print, JSON, and RPC. `propose` and `sideroom_ask` return `Error: UI not available` there. |
|
|
49
|
+
| Edited files | Successful `write` and `edit` results appear below an active work board; `F8` opens the full list. |
|
|
50
|
+
| Session state | The work board and edited-file history live in the Pi session branch, not in the target repository. |
|
|
51
|
+
| Seed artifact | `assets/artifacts/GUIDELINES_TEMPLATE.md` ships unwired. |
|
|
52
|
+
|
|
53
|
+
## Tool
|
|
54
|
+
|
|
55
|
+
`sideroom_ask` follows Pi's questionnaire UI: a simple list for one question,
|
|
56
|
+
tabs plus Submit for several. English copy. Each question needs:
|
|
57
|
+
|
|
58
|
+
- `id`
|
|
59
|
+
- `prompt`
|
|
60
|
+
- at least two `{ value, label, description? }` options
|
|
61
|
+
- `recommendationIndex` pointing at the recommended option
|
|
62
|
+
- optional `label` for the tab bar (defaults to `Q1`, `Q2`, …)
|
|
63
|
+
|
|
64
|
+
The recommended option is marked in the list. Escape cancels the batch.
|
|
65
|
+
|
|
66
|
+
## Work board
|
|
67
|
+
|
|
68
|
+
`sideroom_todo` keeps an ordered, display-only board above the editor. The
|
|
69
|
+
agent maintains it and the user steers through chat. It never creates a
|
|
70
|
+
repository task file or a `/todos` command.
|
|
71
|
+
|
|
72
|
+
- `propose` replaces the full board and is available only in the interactive
|
|
73
|
+
TUI.
|
|
74
|
+
- `update` applies one or more `{ id, status?, content? }` patches and works in
|
|
75
|
+
every Pi mode.
|
|
76
|
+
- Items have stable string ids, short content, and one of `pending`,
|
|
77
|
+
`in_progress`, `completed`, or `cancelled`.
|
|
78
|
+
- While items are pending, exactly one item must be `in_progress`. Complete the
|
|
79
|
+
current item and start the next one in the same update.
|
|
80
|
+
- Snapshots are stored in the active session branch. The board is rebuilt after
|
|
81
|
+
session navigation and compaction.
|
|
82
|
+
|
|
83
|
+
## Edited files
|
|
84
|
+
|
|
85
|
+
The package automatically records successful Pi `write` and `edit` calls for
|
|
86
|
+
the active session. It does not infer filesystem changes from shell commands,
|
|
87
|
+
Git, people, or subagents. The compact list is shown below the work board when
|
|
88
|
+
one is active and contains at most five recent paths.
|
|
89
|
+
|
|
90
|
+
- `F8` opens or closes the extended, scrollable list; press `R` there
|
|
91
|
+
to clear the current session's history.
|
|
92
|
+
- Every path is an OSC 8 `file://` link. Your terminal determines whether that
|
|
93
|
+
is a click, Ctrl+click, or another modifier-assisted action.
|
|
94
|
+
- Paths outside the active project are labelled `external`.
|
|
95
|
+
- A new session starts with a new list; the current session's history survives
|
|
96
|
+
reload, tree navigation, and compaction.
|
|
87
97
|
|
|
88
98
|
## Development
|
|
89
99
|
|
|
90
100
|
```bash
|
|
91
101
|
npm install
|
|
92
102
|
npm run check
|
|
93
|
-
npm run build
|
|
94
103
|
```
|
|
95
104
|
|
|
96
|
-
Biome is the sole formatter and linter.
|
|
97
|
-
provides `@earendil-works/pi-coding-agent
|
|
105
|
+
Biome is the sole formatter and linter. Pi loads TypeScript from `extensions/`
|
|
106
|
+
directly; do not bundle. Pi provides `@earendil-works/pi-coding-agent`,
|
|
107
|
+
`@earendil-works/pi-tui`, and `typebox` as peer dependencies.
|
|
98
108
|
|
|
99
109
|
## Releases and commits
|
|
100
110
|
|
|
101
111
|
Commit messages must follow the Conventional Commits format. The existing
|
|
102
112
|
Husky `commit-msg` hook validates each local commit with Commitlint, for
|
|
103
|
-
example `feat:
|
|
113
|
+
example `feat: mark the recommended sideroom_ask option`.
|
|
104
114
|
|
|
105
115
|
Every user-visible package change needs a Changeset. Create one with:
|
|
106
116
|
|
|
@@ -108,21 +118,31 @@ Every user-visible package change needs a Changeset. Create one with:
|
|
|
108
118
|
npm run changeset
|
|
109
119
|
```
|
|
110
120
|
|
|
111
|
-
Review pending release work with `npm run changeset:status`.
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
121
|
+
Review pending release work with `npm run changeset:status`. When Changesets
|
|
122
|
+
are merged to `main`, the `Release` workflow creates or updates a reviewable
|
|
123
|
+
Changesets version PR. That PR contains the generated `package.json`,
|
|
124
|
+
`package-lock.json`, and `CHANGELOG.md` updates. Merge the version PR only after
|
|
125
|
+
review; its merge runs `npm ci` and `npm run check`, then uses
|
|
126
|
+
npm 11.5.1 and npm Trusted Publishing (GitHub OIDC) to publish. Changesets then
|
|
127
|
+
pushes the release Git tag and creates the GitHub Release automatically.
|
|
128
|
+
|
|
129
|
+
Never run `npm publish`, create release tags, or create GitHub Releases locally.
|
|
130
|
+
Publishing is allowed only through the merged Changesets version PR and the
|
|
131
|
+
`Release` workflow. The npm package's Trusted Publisher must be configured for
|
|
132
|
+
this GitHub repository, the workflow filename `release.yml` (not its path),
|
|
133
|
+
and the `npm` environment; configure the matching GitHub `npm` environment
|
|
134
|
+
before the first release.
|
|
116
135
|
|
|
117
136
|
## Layout
|
|
118
137
|
|
|
119
138
|
```text
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
assets/ role prompts and coding guidelines
|
|
127
|
-
skills/ package-provided Pi skills
|
|
139
|
+
extensions/
|
|
140
|
+
ask/ sideroom_ask (index.ts, execute.ts, model.ts, ui.ts)
|
|
141
|
+
todo/ sideroom_todo (index.ts, execute.ts, session.ts,
|
|
142
|
+
guards.ts, model.ts, ui.ts)
|
|
143
|
+
modified-files/ session-backed edited-file tracker and view
|
|
144
|
+
assets/artifacts/ unwired GUIDELINES_TEMPLATE.md seed
|
|
128
145
|
```
|
|
146
|
+
|
|
147
|
+
Add another tool as `extensions/<name>/index.ts`. Pi discovers
|
|
148
|
+
`extensions/*/index.ts`; helpers in that folder are not loaded as extensions.
|
|
@@ -8,6 +8,7 @@ Each row is expanded, with examples, in the matching section below.
|
|
|
8
8
|
|
|
9
9
|
| Do | Don't |
|
|
10
10
|
| -------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
|
11
|
+
| Use braces around every `if` body | Write an unbraced, single-statement conditional body |
|
|
11
12
|
| Early return on bad input | Pyramid `if/else` nesting |
|
|
12
13
|
| Explicit error, fail now | Multiple fallbacks that hide the real failure |
|
|
13
14
|
| One responsibility per unit | Validate + transform + persist + notify in one place |
|
|
@@ -55,13 +56,26 @@ function getDiscount(user) {
|
|
|
55
56
|
|
|
56
57
|
// Good — guard clauses, happy path at the end, shallow indent
|
|
57
58
|
function getDiscount(user) {
|
|
58
|
-
if (!user)
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
if (!user) {
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
if (!user.isActive) {
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
if (!user.hasSubscription) {
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
61
68
|
return 0.2;
|
|
62
69
|
}
|
|
63
70
|
```
|
|
64
71
|
|
|
72
|
+
### Braced conditionals
|
|
73
|
+
|
|
74
|
+
Every `if` and `else` body must use braces in languages that support them,
|
|
75
|
+
including a one-line guard clause. Never rely on indentation alone to bind a
|
|
76
|
+
statement to a conditional. In Python, which has no braces, use a complete
|
|
77
|
+
indented suite and never a one-line conditional body.
|
|
78
|
+
|
|
65
79
|
### Fail fast
|
|
66
80
|
|
|
67
81
|
Invalid input, impossible state, or a broken dependency should fail immediately with a clear error. Prefer that over chains of fallbacks, silent defaults, or "keep going somehow."
|
|
@@ -78,7 +92,9 @@ function getShippingCost(order) {
|
|
|
78
92
|
// Good — fails immediately with a clear error
|
|
79
93
|
function getShippingCost(order) {
|
|
80
94
|
const zone = ZONES[order.zoneId];
|
|
81
|
-
if (!zone)
|
|
95
|
+
if (!zone) {
|
|
96
|
+
throw new Error(`Unknown zoneId: ${order.zoneId}`);
|
|
97
|
+
}
|
|
82
98
|
return zone.baseCost;
|
|
83
99
|
}
|
|
84
100
|
```
|
|
@@ -90,7 +106,9 @@ A function, class, or module has one reason to change. If it does two jobs, spli
|
|
|
90
106
|
```js
|
|
91
107
|
// Bad — validates, transforms, persists, and notifies all in one place
|
|
92
108
|
async function saveUser(data) {
|
|
93
|
-
if (!data.email)
|
|
109
|
+
if (!data.email) {
|
|
110
|
+
throw new Error('email required');
|
|
111
|
+
}
|
|
94
112
|
const normalized = { ...data, email: data.email.trim().toLowerCase() };
|
|
95
113
|
await db.users.insert(normalized);
|
|
96
114
|
await mailer.send(normalized.email, 'welcome');
|
|
@@ -98,7 +116,9 @@ async function saveUser(data) {
|
|
|
98
116
|
|
|
99
117
|
// Good — each unit has a single reason to change
|
|
100
118
|
function validateUser(data) {
|
|
101
|
-
if (!data.email)
|
|
119
|
+
if (!data.email) {
|
|
120
|
+
throw new Error('email required');
|
|
121
|
+
}
|
|
102
122
|
}
|
|
103
123
|
function normalizeUser(data) {
|
|
104
124
|
return { ...data, email: data.email.trim().toLowerCase() };
|
|
@@ -284,14 +304,22 @@ Pick one convention and apply it consistently: e.g. `undefined` for "not yet set
|
|
|
284
304
|
```js
|
|
285
305
|
// Bad — null is overloaded to mean three different things
|
|
286
306
|
function getDiscount(user) {
|
|
287
|
-
if (!user)
|
|
288
|
-
|
|
289
|
-
|
|
307
|
+
if (!user) {
|
|
308
|
+
return null; // no user
|
|
309
|
+
}
|
|
310
|
+
if (!user.plan) {
|
|
311
|
+
return null; // no plan
|
|
312
|
+
}
|
|
313
|
+
if (user.plan.discount === 0) {
|
|
314
|
+
return null; // legitimately zero discount
|
|
315
|
+
}
|
|
290
316
|
}
|
|
291
317
|
|
|
292
318
|
// Good — each case is explicit, zero is a real value
|
|
293
319
|
function getDiscount(user) {
|
|
294
|
-
if (!user || !user.plan)
|
|
320
|
+
if (!user || !user.plan) {
|
|
321
|
+
return 0;
|
|
322
|
+
}
|
|
295
323
|
return user.plan.discount;
|
|
296
324
|
}
|
|
297
325
|
```
|
|
@@ -439,7 +467,8 @@ class UpdateProfileRequest extends FormRequest {
|
|
|
439
467
|
}
|
|
440
468
|
class ProfileService {
|
|
441
469
|
public function update(array $data) {
|
|
442
|
-
|
|
470
|
+
// Redundant re-validation.
|
|
471
|
+
if (empty($data['email'])) {
|
|
443
472
|
throw new \InvalidArgumentException('email required');
|
|
444
473
|
}
|
|
445
474
|
$this->user->update($data);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import {
|
|
3
|
+
type AskParams,
|
|
4
|
+
type AskQuestion,
|
|
5
|
+
type AskResult,
|
|
6
|
+
formatAnswerLines,
|
|
7
|
+
parseAskParams,
|
|
8
|
+
UI_UNAVAILABLE,
|
|
9
|
+
} from './model.ts';
|
|
10
|
+
import { runAskUi } from './ui.ts';
|
|
11
|
+
|
|
12
|
+
interface AskToolResult {
|
|
13
|
+
readonly content: [{ readonly type: 'text'; readonly text: string }];
|
|
14
|
+
readonly details: AskResult;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function executeAsk(
|
|
18
|
+
params: AskParams,
|
|
19
|
+
ctx: Pick<ExtensionContext, 'mode' | 'ui'>,
|
|
20
|
+
): Promise<AskToolResult> {
|
|
21
|
+
const parsed = parseAskParams(params);
|
|
22
|
+
if (!parsed.ok) {
|
|
23
|
+
return cancelledResult(parsed.message);
|
|
24
|
+
}
|
|
25
|
+
if (ctx.mode !== 'tui') {
|
|
26
|
+
return cancelledResult(UI_UNAVAILABLE, parsed.questions);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const result = await runAskUi(ctx.ui, parsed.questions);
|
|
30
|
+
if (result.cancelled) {
|
|
31
|
+
return {
|
|
32
|
+
content: [{ type: 'text', text: 'User cancelled the questionnaire' }],
|
|
33
|
+
details: result,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
content: [
|
|
39
|
+
{
|
|
40
|
+
type: 'text',
|
|
41
|
+
text: formatAnswerLines(parsed.questions, result.answers).join('\n'),
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
details: result,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function cancelledResult(
|
|
49
|
+
message: string,
|
|
50
|
+
questions: readonly AskQuestion[] = [],
|
|
51
|
+
): AskToolResult {
|
|
52
|
+
return {
|
|
53
|
+
content: [{ type: 'text', text: message }],
|
|
54
|
+
details: { questions, answers: [], cancelled: true },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import { Text } from '@earendil-works/pi-tui';
|
|
3
|
+
import { executeAsk } from './execute.ts';
|
|
4
|
+
import { AskParamsSchema, type AskResult, TOOL_NAME } from './model.ts';
|
|
5
|
+
|
|
6
|
+
export const ASK_DESCRIPTION =
|
|
7
|
+
'Ask the user one or more questions. Use for clarifying requirements, getting preferences, or confirming decisions. Each question must include a recommended option. For a single question, shows a simple option list. For multiple questions, shows a tab-based interface.';
|
|
8
|
+
|
|
9
|
+
export const ASK_PROMPT_SNIPPET =
|
|
10
|
+
'Ask the user one or more questions with a recommended option.';
|
|
11
|
+
|
|
12
|
+
export const ASK_PROMPT_GUIDELINES = [
|
|
13
|
+
'Use sideroom_ask to clarify requirements, preferences, or decisions with the user.',
|
|
14
|
+
'Each sideroom_ask question needs an id, a prompt, at least two options, and a recommendationIndex pointing at the recommended option.',
|
|
15
|
+
'sideroom_ask always adds Out of scope and a custom answer; do not include those options yourself.',
|
|
16
|
+
'One sideroom_ask call is one batch; call it again if another round of questions is needed.',
|
|
17
|
+
'sideroom_ask only works in the interactive TUI; it returns an error in print, JSON, or RPC modes.',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
// Pi loads extensions/*/index.ts through export default.
|
|
21
|
+
export default function registerAsk(pi: ExtensionAPI): void {
|
|
22
|
+
pi.registerTool({
|
|
23
|
+
name: TOOL_NAME,
|
|
24
|
+
label: 'Sideroom Ask',
|
|
25
|
+
description: ASK_DESCRIPTION,
|
|
26
|
+
promptSnippet: ASK_PROMPT_SNIPPET,
|
|
27
|
+
promptGuidelines: ASK_PROMPT_GUIDELINES,
|
|
28
|
+
parameters: AskParamsSchema,
|
|
29
|
+
executionMode: 'sequential',
|
|
30
|
+
execute: async (_toolCallId, params, _signal, _onUpdate, ctx) =>
|
|
31
|
+
executeAsk(params, ctx),
|
|
32
|
+
renderCall(args, theme) {
|
|
33
|
+
const questions = args.questions;
|
|
34
|
+
const count = questions.length;
|
|
35
|
+
const labels = questions
|
|
36
|
+
.map(
|
|
37
|
+
(question, index) =>
|
|
38
|
+
question.label || question.id || `Q${String(index + 1)}`,
|
|
39
|
+
)
|
|
40
|
+
.join(', ');
|
|
41
|
+
let text = theme.fg('toolTitle', theme.bold(`${TOOL_NAME} `));
|
|
42
|
+
text += theme.fg(
|
|
43
|
+
'muted',
|
|
44
|
+
`${String(count)} question${count !== 1 ? 's' : ''}`,
|
|
45
|
+
);
|
|
46
|
+
if (labels.length > 0) {
|
|
47
|
+
text += theme.fg('dim', ` (${labels})`);
|
|
48
|
+
}
|
|
49
|
+
return new Text(text, 0, 0);
|
|
50
|
+
},
|
|
51
|
+
renderResult(result, _options, theme) {
|
|
52
|
+
const details = result.details as AskResult | undefined;
|
|
53
|
+
if (details === undefined) {
|
|
54
|
+
const text = result.content[0];
|
|
55
|
+
return new Text(text?.type === 'text' ? text.text : '', 0, 0);
|
|
56
|
+
}
|
|
57
|
+
if (details.cancelled) {
|
|
58
|
+
return new Text(theme.fg('warning', 'Cancelled'), 0, 0);
|
|
59
|
+
}
|
|
60
|
+
const lines = details.answers.map((answer) => {
|
|
61
|
+
const mark = theme.fg('success', '✓ ');
|
|
62
|
+
const id = theme.fg('accent', answer.id);
|
|
63
|
+
if (answer.outOfScope) {
|
|
64
|
+
return `${mark}${id}: ${theme.fg('muted', '(out of scope)')}`;
|
|
65
|
+
}
|
|
66
|
+
if (answer.wasCustom) {
|
|
67
|
+
return `${mark}${id}: ${theme.fg('muted', '(wrote) ')}${answer.label}`;
|
|
68
|
+
}
|
|
69
|
+
if (answer.index !== undefined) {
|
|
70
|
+
return `${mark}${id}: ${String(answer.index)}. ${answer.label}`;
|
|
71
|
+
}
|
|
72
|
+
return `${mark}${id}: ${answer.label}`;
|
|
73
|
+
});
|
|
74
|
+
return new Text(lines.join('\n'), 0, 0);
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { type Static, Type } from 'typebox';
|
|
2
|
+
import { Check, Errors } from 'typebox/value';
|
|
3
|
+
|
|
4
|
+
export const TOOL_NAME = 'sideroom_ask';
|
|
5
|
+
export const OUT_OF_SCOPE_VALUE = 'Out of scope';
|
|
6
|
+
export const OUT_OF_SCOPE_LABEL = 'Out of scope';
|
|
7
|
+
export const CUSTOM_VALUE = '__other__';
|
|
8
|
+
export const CUSTOM_LABEL = 'Write a custom answer';
|
|
9
|
+
export const UI_UNAVAILABLE =
|
|
10
|
+
'Error: UI not available (running in non-interactive mode)';
|
|
11
|
+
|
|
12
|
+
const QuestionOptionSchema = Type.Object({
|
|
13
|
+
value: Type.String({ description: 'The value returned when selected' }),
|
|
14
|
+
label: Type.String({ description: 'Display label for the option' }),
|
|
15
|
+
description: Type.Optional(
|
|
16
|
+
Type.String({ description: 'Optional description shown below the label' }),
|
|
17
|
+
),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const QuestionSchema = Type.Object({
|
|
21
|
+
id: Type.String({ description: 'Unique identifier for this question' }),
|
|
22
|
+
label: Type.Optional(
|
|
23
|
+
Type.String({
|
|
24
|
+
description:
|
|
25
|
+
"Short contextual label for the tab bar, e.g. 'Scope' (defaults to Q1, Q2)",
|
|
26
|
+
}),
|
|
27
|
+
),
|
|
28
|
+
prompt: Type.String({ description: 'The full question text to display' }),
|
|
29
|
+
options: Type.Array(QuestionOptionSchema, {
|
|
30
|
+
minItems: 2,
|
|
31
|
+
description: 'At least two mutually exclusive options',
|
|
32
|
+
}),
|
|
33
|
+
recommendationIndex: Type.Integer({
|
|
34
|
+
minimum: 0,
|
|
35
|
+
description: 'Index into options pointing at the recommended option',
|
|
36
|
+
}),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export const AskParamsSchema = Type.Object({
|
|
40
|
+
questions: Type.Array(QuestionSchema, {
|
|
41
|
+
minItems: 1,
|
|
42
|
+
description: 'Questions to ask the user in this batch',
|
|
43
|
+
}),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
export type AskParams = Static<typeof AskParamsSchema>;
|
|
47
|
+
export type QuestionOption = AskParams['questions'][number]['options'][number];
|
|
48
|
+
|
|
49
|
+
export interface AskQuestion {
|
|
50
|
+
readonly id: string;
|
|
51
|
+
readonly label: string;
|
|
52
|
+
readonly prompt: string;
|
|
53
|
+
readonly options: readonly QuestionOption[];
|
|
54
|
+
readonly recommendationIndex: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface AskAnswer {
|
|
58
|
+
readonly id: string;
|
|
59
|
+
readonly value: string;
|
|
60
|
+
readonly label: string;
|
|
61
|
+
readonly wasCustom: boolean;
|
|
62
|
+
readonly outOfScope: boolean;
|
|
63
|
+
readonly index?: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface AskResult {
|
|
67
|
+
readonly questions: readonly AskQuestion[];
|
|
68
|
+
readonly answers: readonly AskAnswer[];
|
|
69
|
+
readonly cancelled: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type RenderOption = QuestionOption & {
|
|
73
|
+
readonly isOther?: boolean;
|
|
74
|
+
readonly isOutOfScope?: boolean;
|
|
75
|
+
readonly isRecommended?: boolean;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export type ParseAskResult =
|
|
79
|
+
| { readonly ok: true; readonly questions: readonly AskQuestion[] }
|
|
80
|
+
| { readonly ok: false; readonly message: string };
|
|
81
|
+
|
|
82
|
+
export function parseAskParams(value: unknown): ParseAskResult {
|
|
83
|
+
if (!Check(AskParamsSchema, value)) {
|
|
84
|
+
return { ok: false, message: schemaErrorMessage(value) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const seen = new Set<string>();
|
|
88
|
+
for (const question of value.questions) {
|
|
89
|
+
if (seen.has(question.id)) {
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
message: `Error: Duplicate question id: ${question.id}`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
seen.add(question.id);
|
|
96
|
+
if (question.recommendationIndex >= question.options.length) {
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
message: `Error: recommendationIndex ${String(question.recommendationIndex)} is out of range for question '${question.id}'`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { ok: true, questions: normalizeQuestions(value.questions) };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function normalizeQuestions(
|
|
108
|
+
questions: AskParams['questions'],
|
|
109
|
+
): AskQuestion[] {
|
|
110
|
+
return questions.map((question, index) => {
|
|
111
|
+
const label = question.label;
|
|
112
|
+
return {
|
|
113
|
+
id: question.id,
|
|
114
|
+
label:
|
|
115
|
+
label !== undefined && label.trim().length > 0
|
|
116
|
+
? label
|
|
117
|
+
: `Q${String(index + 1)}`,
|
|
118
|
+
prompt: question.prompt,
|
|
119
|
+
options: question.options,
|
|
120
|
+
recommendationIndex: question.recommendationIndex,
|
|
121
|
+
};
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function renderOptions(question: AskQuestion): RenderOption[] {
|
|
126
|
+
const options: RenderOption[] = question.options.map((option, index) => ({
|
|
127
|
+
...option,
|
|
128
|
+
isRecommended: index === question.recommendationIndex,
|
|
129
|
+
}));
|
|
130
|
+
options.push({
|
|
131
|
+
value: OUT_OF_SCOPE_VALUE,
|
|
132
|
+
label: OUT_OF_SCOPE_LABEL,
|
|
133
|
+
isOutOfScope: true,
|
|
134
|
+
});
|
|
135
|
+
options.push({
|
|
136
|
+
value: CUSTOM_VALUE,
|
|
137
|
+
label: CUSTOM_LABEL,
|
|
138
|
+
isOther: true,
|
|
139
|
+
});
|
|
140
|
+
return options;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function formatAnswerLines(
|
|
144
|
+
questions: readonly AskQuestion[],
|
|
145
|
+
answers: readonly AskAnswer[],
|
|
146
|
+
): string[] {
|
|
147
|
+
return answers.map((answer) => {
|
|
148
|
+
const question = questions.find((entry) => entry.id === answer.id);
|
|
149
|
+
const questionLabel = question?.label ?? answer.id;
|
|
150
|
+
if (answer.outOfScope) {
|
|
151
|
+
return `${questionLabel}: Out of scope`;
|
|
152
|
+
}
|
|
153
|
+
if (answer.wasCustom) {
|
|
154
|
+
return `${questionLabel}: user wrote: ${answer.label}`;
|
|
155
|
+
}
|
|
156
|
+
if (answer.index !== undefined) {
|
|
157
|
+
return `${questionLabel}: user selected: ${String(answer.index)}. ${answer.label}`;
|
|
158
|
+
}
|
|
159
|
+
return `${questionLabel}: user selected: ${answer.label}`;
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function schemaErrorMessage(value: unknown): string {
|
|
164
|
+
const [error] = Errors(AskParamsSchema, value);
|
|
165
|
+
if (error === undefined) {
|
|
166
|
+
return 'Error: Invalid sideroom_ask parameters';
|
|
167
|
+
}
|
|
168
|
+
if (error.keyword === 'minItems' && error.instancePath === '/questions') {
|
|
169
|
+
return 'Error: No questions provided';
|
|
170
|
+
}
|
|
171
|
+
const optionsMatch = /\/questions\/(\d+)\/options$/.exec(error.instancePath);
|
|
172
|
+
if (error.keyword === 'minItems' && optionsMatch !== null) {
|
|
173
|
+
const questionIndex = Number(optionsMatch[1]);
|
|
174
|
+
const questions = asQuestions(value);
|
|
175
|
+
const question = questions[questionIndex];
|
|
176
|
+
const id =
|
|
177
|
+
question !== undefined && typeof question.id === 'string'
|
|
178
|
+
? question.id
|
|
179
|
+
: String(questionIndex);
|
|
180
|
+
return `Error: Question '${id}' must include at least two options`;
|
|
181
|
+
}
|
|
182
|
+
return `Error: Invalid sideroom_ask parameters`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function asQuestions(value: unknown): readonly Record<string, unknown>[] {
|
|
186
|
+
if (typeof value !== 'object' || value === null) {
|
|
187
|
+
return [];
|
|
188
|
+
}
|
|
189
|
+
if (!('questions' in value) || !Array.isArray(value.questions)) {
|
|
190
|
+
return [];
|
|
191
|
+
}
|
|
192
|
+
return value.questions.filter(
|
|
193
|
+
(question): question is Record<string, unknown> =>
|
|
194
|
+
typeof question === 'object' && question !== null,
|
|
195
|
+
);
|
|
196
|
+
}
|