@huseynsnmz/pi-suite 0.2.2 → 0.2.4

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +13 -4
  3. package/package.json +12 -2
  4. package/packages/board/README.md +1 -1
  5. package/packages/board/package.json +2 -1
  6. package/packages/commands/README.md +1 -1
  7. package/packages/commands/src/index.ts +22 -0
  8. package/packages/frame/README.md +8 -2
  9. package/packages/frame/docs/reference.md +1 -1
  10. package/packages/frame/src/index.ts +8 -3
  11. package/packages/frame/src/kit/settings/files.ts +8 -8
  12. package/packages/frame/src/kit/settings/store.ts +3 -1
  13. package/packages/frame/src/ui/editor.ts +64 -7
  14. package/packages/frame/src/ui/history-search.ts +21 -0
  15. package/packages/frame/src/ui/pi-entries.ts +2 -0
  16. package/packages/frame/src/ui/thinking.ts +34 -0
  17. package/packages/memory/README.md +20 -1
  18. package/packages/memory/src/index.ts +60 -1
  19. package/packages/memory/src/mastermind.ts +110 -0
  20. package/packages/memory/src/playbook.ts +92 -0
  21. package/packages/modes/README.md +11 -3
  22. package/packages/modes/skills/review-permissions/SKILL.md +11 -2
  23. package/packages/modes/src/index.ts +29 -11
  24. package/packages/modes/src/judge-gate.ts +78 -7
  25. package/packages/modes/src/modes.ts +4 -1
  26. package/packages/modes/src/review/apply.mjs +1 -1
  27. package/packages/modes/src/review/collect.mjs +45 -2
  28. package/packages/modes/src/workdir.ts +105 -0
  29. package/packages/router/README.md +30 -9
  30. package/packages/router/src/config.ts +30 -28
  31. package/packages/router/src/index.ts +70 -13
  32. package/packages/router/src/route.ts +16 -10
  33. package/packages/subagent/LICENSE +21 -0
  34. package/packages/subagent/README.md +17 -0
  35. package/packages/subagent/package.json +22 -0
  36. package/packages/subagent/src/index.ts +76 -0
  37. package/packages/subscription/LICENSE +21 -0
  38. package/packages/subscription/README.md +60 -0
  39. package/packages/subscription/package.json +23 -0
  40. package/packages/subscription/src/anchors.ts +75 -0
  41. package/packages/subscription/src/billing.ts +39 -0
  42. package/packages/subscription/src/catalog.ts +310 -0
  43. package/packages/subscription/src/cc-sessions.ts +122 -0
  44. package/packages/subscription/src/config.ts +34 -0
  45. package/packages/subscription/src/convert.ts +161 -0
  46. package/packages/subscription/src/index.ts +69 -0
  47. package/packages/subscription/src/mcp.ts +78 -0
  48. package/packages/subscription/src/models.ts +65 -0
  49. package/packages/subscription/src/prompt.ts +57 -0
  50. package/packages/subscription/src/provider.ts +617 -0
  51. package/packages/subscription/src/session.ts +294 -0
  52. package/packages/subscription/src/util.ts +73 -0
  53. package/packages/web/README.md +5 -5
  54. package/packages/web/src/config.ts +13 -23
  55. package/packages/web/src/index.ts +11 -7
  56. package/packages/web/src/reader.ts +15 -4
  57. package/packages/web/src/validate.ts +2 -2
  58. package/src/index.ts +93 -11
  59. package/src/settings-ui.ts +63 -0
  60. package/src/settings.ts +117 -15
package/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.4]
4
+
5
+ ### Features
6
+
7
+ - `/mastermind`: a strong model, chosen as the new `mastermind` role in `/router`, reads past conversations and writes a short playbook that other models get on every run. You approve every rule in an editor. `/mastermind compact` shortens it, and `/mastermind show` shows it.
8
+ - New part, `subagent`: the `agent` tool hands a task to a fresh agent and gives back its answer. `explore` can only read, and also works in `plan` and `read-only`. `general` can do everything, and its permission asks come to your session.
9
+ - A `cd` at the start of a bash command carries over to later commands while it stays in the project.
10
+ - `/review-permissions` reports what the permission judge decided, and why it deferred.
11
+
12
+ ### Changes
13
+
14
+ - `auto` no longer sends `edit` and `write` on ordinary project files to the judge. `.git`, `.pi`, `.claude`, `.husky`, `.github`, `.vscode`, `.envrc` and paths outside the project still go to it.
15
+ - The first time you start Pi in a project, pi-modes allows `cd` inside that project in your bash rules, with a backup, so you aren't asked about each one. A `cd` elsewhere still asks.
16
+
17
+ ### Development
18
+
19
+ - `npm test` takes seconds. Board's tests that start real processes moved to `npm run test:all`, and `npm run test:e2e` runs every suite at once.
20
+
21
+ ## [0.2.3]
22
+
23
+ ### Features
24
+
25
+ - Router: each role can have its own reasoning level, chosen in `/router` from what that role's model offers.
26
+ - Up and Down with one line typed go through past prompts that start with it. With nothing typed they also reach this folder's other sessions.
27
+ - With `hideThinkingBlock` on, reasoning shows while it's written and collapses when the message is done. `Ctrl+T` opens it.
28
+ - New part, off until you run `/suite on subscription`: Claude models on your Claude login, based on pi-claude-subscription. It asks before anything more is billed as extra usage. See its README for the limits and what Anthropic's terms say.
29
+
30
+ - One settings file, `~/.pi/agent/suite.json`, with a section for each part. `/suite` now opens each part's settings as well as turning it on or off. Old files move in the first time Pi starts, and stay as `.bak`.
31
+
32
+ ### Fixes
33
+
34
+ - `router/auto` is always listed in `/model`, and says to run `/router` until `plan` and `build` are set.
35
+ - `/clear` keeps the current model and reasoning level.
36
+
3
37
  ## [0.2.2]
4
38
 
5
39
  ### Fixes
package/README.md CHANGED
@@ -11,13 +11,21 @@ One package for [Pi](https://pi.dev): modes and permissions, a framed screen, a
11
11
  | `frame` | The start card, framed editor, tool boxes, footer and the agent's todo list | [README](packages/frame/README.md) |
12
12
  | `board` | A board for starting, watching and attaching to background sessions | [README](packages/board/README.md) |
13
13
  | `commands` | `/clear` and `/security-review` | [README](packages/commands/README.md) |
14
+ | `subagent` | The `agent` tool: hand a task to a fresh agent, and get its answer back | [README](packages/subagent/README.md) |
15
+ | `subscription` | Off until you turn it on. Claude models on your Claude login, asking before anything more is billed as extra usage | [README](packages/subscription/README.md) |
16
+
17
+ ## Settings
18
+
19
+ Run `/suite` to turn parts on or off and change their settings. They're all kept in `~/.pi/agent/suite.json`, with a section for each part, so you can also edit it by hand. Older files such as `router.json` and `web.json` move in the first time Pi starts, and stay beside it as `.bak`. If one can't be read it stays where it is, and you're told, so a broken file can't silently turn off a setting.
20
+
21
+ There's no setting per project, so a repository can't choose where your code goes. The only exception is the frame's look, in `.pi/extensions/pi-harness/settings.json`, once you trust the project. Pi's own files, and the settings of other packages, such as `pi-permission-system`, stay where they are.
14
22
 
15
23
  ## Install
16
24
 
17
25
  Requires Pi 1.5 or newer.
18
26
 
19
27
  ```bash
20
- pi install npm:@huseynsnmz/pi-suite@0.2.2
28
+ pi install npm:@huseynsnmz/pi-suite@0.2.4
21
29
  ```
22
30
 
23
31
  If you installed any of these separately, remove them first:
@@ -82,8 +90,9 @@ Each part has its own limits, listed in its README or `SECURITY.md`. None of thi
82
90
  ```bash
83
91
  npm install --ignore-scripts
84
92
  npm run check
85
- npm test
86
- npm run test:e2e
93
+ npm test # seconds: logic only
94
+ npm run test:all # adds board's tests that start real processes
95
+ npm run test:e2e # real Pi in a real terminal with a fake model, every suite at once
87
96
  ```
88
97
 
89
- Each part keeps its own tests under `packages/<part>/test`. `frame` needs [bun](https://bun.sh) for its unit tests. `npm run test:e2e` drives real Pi in a real terminal with a fake model.
98
+ Each part keeps its own tests under `packages/<part>/test`. `frame` needs [bun](https://bun.sh) for its unit tests. To run some end-to-end suites, name them: `python3 test/e2e/all.py router web`. Run `test:all` and `test:e2e` before a release.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huseynsnmz/pi-suite",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "One Pi package for all of it: modes and permissions, a framed screen, an agent board, memory, web tools with a separate page reader, and a model router. Turn off what you don't want with /suite.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,10 +28,18 @@
28
28
  "README.md",
29
29
  "CHANGELOG.md",
30
30
  "LICENSE",
31
+ "packages/subscription/src",
32
+ "packages/subscription/package.json",
33
+ "packages/subscription/README.md",
34
+ "packages/subscription/LICENSE",
31
35
  "packages/commands/src",
32
36
  "packages/commands/package.json",
33
37
  "packages/commands/README.md",
34
38
  "packages/commands/LICENSE",
39
+ "packages/subagent/src",
40
+ "packages/subagent/package.json",
41
+ "packages/subagent/README.md",
42
+ "packages/subagent/LICENSE",
35
43
  "packages/board/src",
36
44
  "packages/board/runner",
37
45
  "packages/board/docs",
@@ -88,9 +96,11 @@
88
96
  "scripts": {
89
97
  "check": "tsc --noEmit && npm run --workspaces --if-present check && npm run -w packages/board typecheck",
90
98
  "test": "node --test test/*.test.ts && npm run --workspaces --if-present test",
91
- "test:e2e": "python3 test/e2e/run.py"
99
+ "test:all": "npm test && npm run -w packages/board test:integration",
100
+ "test:e2e": "python3 test/e2e/all.py"
92
101
  },
93
102
  "dependencies": {
103
+ "@anthropic-ai/claude-agent-sdk": "0.3.308",
94
104
  "@xterm/headless": "6.0.0",
95
105
  "node-pty": "1.1.0",
96
106
  "@mozilla/readability": "0.6.0",
@@ -26,7 +26,7 @@ Requires Pi 1.5 or newer.
26
26
  1. Install it:
27
27
 
28
28
  ```bash
29
- pi install npm:@huseynsnmz/pi-suite@0.2.2
29
+ pi install npm:@huseynsnmz/pi-suite@0.2.4
30
30
  ```
31
31
 
32
32
  2. Restart Pi, and press `Left` twice on an empty prompt, or run `/agent-board`.
@@ -45,9 +45,10 @@
45
45
  "scripts": {
46
46
  "typecheck": "tsc --noEmit",
47
47
  "test": "node scripts/run-tests.mjs",
48
+ "test:integration": "node scripts/run-tests.mjs --integration",
48
49
  "test:perf": "node scripts/run-perf-gate.mjs",
49
50
  "pack:dry": "npm pack --dry-run",
50
- "verify": "npm run typecheck && npm run test:perf && npm test && npm run pack:dry"
51
+ "verify": "npm run typecheck && npm run test:perf && npm test && npm run test:integration && npm run pack:dry"
51
52
  },
52
53
  "peerDependencies": {
53
54
  "@earendil-works/pi-coding-agent": "*",
@@ -4,7 +4,7 @@ Two commands for Pi. Part of [pi-suite](../../README.md); turn them off with `/s
4
4
 
5
5
  ## /clear
6
6
 
7
- Clears the conversation and starts a new session, as `/new` does. If the agent is working, it stops first. The old session is kept, and `/resume` brings it back.
7
+ Clears the conversation and starts a new session, keeping the model and reasoning level you have selected. `/new` goes back to your saved default instead. If the agent is working, it stops first. The old session is kept, and `/resume` brings it back.
8
8
 
9
9
  ## /security-review
10
10
 
@@ -5,6 +5,14 @@ import { changesToReview, type GitRun, reviewPrompt } from "./security-review.ts
5
5
 
6
6
  const REVIEW_MESSAGE = "security-review";
7
7
 
8
+ /** The model /clear hands to the session it starts. Pi's own new session would take the saved default. */
9
+ const KEPT = Symbol.for("pi-suite:clear-keeps");
10
+ interface Kept {
11
+ provider: string;
12
+ id: string;
13
+ thinking: ReturnType<ExtensionAPI["getThinkingLevel"]>;
14
+ }
15
+
8
16
  export default function piCommands(pi: ExtensionAPI): void {
9
17
  pi.registerCommand("clear", {
10
18
  description: "Clear the conversation and start a new session",
@@ -13,10 +21,24 @@ export default function piCommands(pi: ExtensionAPI): void {
13
21
  ctx.abort();
14
22
  await ctx.waitForIdle();
15
23
  }
24
+ const store = globalThis as Record<symbol, Kept | undefined>;
25
+ if (ctx.model) store[KEPT] = { provider: ctx.model.provider, id: ctx.model.id, thinking: pi.getThinkingLevel() };
16
26
  await ctx.newSession();
27
+ delete store[KEPT]; // Still here only if it was cancelled.
17
28
  },
18
29
  });
19
30
 
31
+ // Runs in the new session, where this extension is a new instance, so what /clear kept can't live in it.
32
+ pi.on("session_start", async (event, ctx) => {
33
+ const store = globalThis as Record<symbol, Kept | undefined>;
34
+ const kept = store[KEPT];
35
+ delete store[KEPT];
36
+ if (event.reason !== "new" || kept === undefined) return;
37
+ const model = ctx.modelRegistry.find(kept.provider, kept.id);
38
+ if (model !== undefined) await pi.setModel(model);
39
+ pi.setThinkingLevel(kept.thinking);
40
+ });
41
+
20
42
  // The diff goes to the model, and only this one line goes on screen.
21
43
  pi.registerMessageRenderer<{ summary: string }>(REVIEW_MESSAGE, (message, _options, theme) =>
22
44
  new Text(theme.fg("muted", message.details?.summary ?? "Security review"), 1, 0),
@@ -19,7 +19,7 @@ Requires Pi 1.5 or newer.
19
19
  2. Install it:
20
20
 
21
21
  ```bash
22
- pi install npm:@huseynsnmz/pi-suite@0.2.2
22
+ pi install npm:@huseynsnmz/pi-suite@0.2.4
23
23
  ```
24
24
 
25
25
  3. Restart Pi. The start card says `π-frame`.
@@ -44,6 +44,8 @@ Some pieces, such as rooms, boards, peers and the remote page, appear only with
44
44
  |---|---|
45
45
  | `Ctrl+C` | Stops a run and says `Aborted`. With nothing running, it clears the editor and says to press it again within 2 seconds to exit |
46
46
  | `Ctrl+R` | Search what you've typed, here and in this folder's other sessions. `Ctrl+R` again goes further back, `Enter` sends, `Esc` cancels, any other key keeps the match to edit. It replaces Pi's Rename on this key; `/name` still renames |
47
+ | `Up` / `Down` | With nothing typed, browse past prompts, including this folder's other sessions. With one line typed, go through prompts that start with it, as in a shell |
48
+ | `Ctrl+T` | Open or collapse finished reasoning |
47
49
  | `Alt+O` | Scroll through the session's tool output, one call at a time |
48
50
  | `Ctrl+O` | Expand every call in the transcript |
49
51
  | `/todos` | Show the agent's todo list. `/todos clear` empties it and tells the agent |
@@ -78,7 +80,7 @@ More detail is in [How it works](docs/how-it-works.md) and the [reference](docs/
78
80
  - **`gh` and `glab` still run in their repositories,** and they call `git` for reads such as the remote list.
79
81
  - **`glab` needs a host with `gitlab` in its name.** It starts for that remote, and if a token is set in the environment (`GITLAB_TOKEN`), only for `gitlab.com` or `GITLAB_HOST`, because a repository picks its own remote.
80
82
  - **The `git status` protections cover the ways to run code that I know of.** New Git options could add others.
81
- - **The parts of `@adeildo/pi-kit` 6.0.0 that frame uses are copied into `src/kit`,** with its license, so upstream fixes have to be copied in by hand. Its settings stay in `~/.pi/agent/extensions/pi-harness/settings.json`.
83
+ - **The parts of `@adeildo/pi-kit` 6.0.0 that frame uses are copied into `src/kit`,** with its license, so upstream fixes have to be copied in by hand. Its settings stay in `~/.pi/agent/suite.json`.
82
84
  - **Another session's view isn't fully covered.** Opening one needs other pi-harness packages, and that view hasn't been tested.
83
85
 
84
86
  ## Development
@@ -103,3 +105,7 @@ MIT. See [LICENSE](LICENSE), which keeps the original copyright notice.
103
105
  - [pi-look and pi-harness](https://github.com/felipeadeildo/pi-harness) by [adeildo](https://adeildo.dev)
104
106
  - The framed editor comes from [pi-open-tui](https://github.com/OldSuns/pi-open-tui), by way of pi-look.
105
107
  - The segments with priorities come from [oh-my-pi](https://github.com/can1357/oh-my-pi), by way of pi-look.
108
+
109
+ ## Reasoning
110
+
111
+ Set `"hideThinkingBlock": true` in `~/.pi/agent/settings.json`, or press `Ctrl+T` until reasoning is collapsed. It then shows while the model is writing it and collapses to one line once the message is done. `Ctrl+T` opens it again, or click it to open just that one. `Ctrl+T` is Pi's own setting, so it applies to every message and stays set in later sessions.
@@ -27,7 +27,7 @@ Syntax tokens are borrowed on purpose: a theme spends its hues there, so they gi
27
27
 
28
28
  ## Settings
29
29
 
30
- They live in the file `@adeildo/pi-kit` keeps, `~/.pi/agent/extensions/pi-harness/settings.json`, under `frame.`.
30
+ They live under `frame` in `~/.pi/agent/suite.json`.
31
31
 
32
32
  | Key | Default | Does |
33
33
  | ------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------- |
@@ -1,4 +1,4 @@
1
- import { dirname, join } from "node:path";
1
+ import { join } from "node:path";
2
2
 
3
3
  import {
4
4
  ACCOUNT,
@@ -23,7 +23,7 @@ import type {
23
23
  ExtensionContext,
24
24
  ReadonlyFooterDataProvider,
25
25
  } from "@earendil-works/pi-coding-agent";
26
- import { keyText, parseSkillBlock } from "@earendil-works/pi-coding-agent";
26
+ import { getAgentDir, keyText, parseSkillBlock } from "@earendil-works/pi-coding-agent";
27
27
  import { Key, type KeyId, type TUI } from "@earendil-works/pi-tui";
28
28
 
29
29
  import { type GitState, readGit } from "./data/git.ts";
@@ -157,7 +157,7 @@ export const frameFeature = defineFeature({
157
157
 
158
158
  for (const entry of FRAME_SETTINGS) entry.listen(scope, repaint);
159
159
  frame.listen(scope, () => live.ctx && installEditor(live.ctx, screen, live, scope));
160
- const visitsFile = join(dirname(scope.settings.globalPath), "frame-visits.json");
160
+ const visitsFile = join(getAgentDir(), "extensions", "pi-harness", "frame-visits.json");
161
161
  let visited = false;
162
162
  let lastVisit: LastVisit | undefined;
163
163
 
@@ -527,6 +527,9 @@ function pressCtrlC(live: Live, clearEditor: () => void): void {
527
527
  live.repaint?.();
528
528
  }
529
529
 
530
+ /** Everything collapsed has finished, because live reasoning stays open. */
531
+ const THOUGHT_LABEL = "Thought · ctrl+t to expand";
532
+
530
533
  function installEditor(
531
534
  ctx: ExtensionContext,
532
535
  screen: Screen,
@@ -535,9 +538,11 @@ function installEditor(
535
538
  ): void {
536
539
  if (ctx.mode !== "tui") return;
537
540
  if (frame.get(scope) === "off") {
541
+ ctx.ui.setHiddenThinkingLabel(undefined);
538
542
  ctx.ui.setEditorComponent(undefined);
539
543
  return;
540
544
  }
545
+ ctx.ui.setHiddenThinkingLabel(THOUGHT_LABEL);
541
546
  ctx.ui.setEditorComponent((tui, theme, keybindings) => {
542
547
  live.tui = tui;
543
548
  applyCursor(tui, cursor);
@@ -1,5 +1,5 @@
1
1
  // Every app shares the settings files, so a write keeps every key it does not know.
2
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
 
5
5
  import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
@@ -14,7 +14,7 @@ export interface SettingsFile {
14
14
  }
15
15
 
16
16
  export function globalSettingsPath(): string {
17
- return join(getAgentDir(), "extensions", "pi-harness", "settings.json");
17
+ return join(getAgentDir(), "suite.json");
18
18
  }
19
19
 
20
20
  export function projectSettingsPath(cwd: string): string {
@@ -40,12 +40,12 @@ export function writeSettingsFile(
40
40
  options: { mode?: number } = {},
41
41
  ): void {
42
42
  mkdirSync(dirname(path), { recursive: true });
43
- const temporary = `${path}.${process.pid}.tmp`;
44
- writeFileSync(temporary, `${JSON.stringify(data, null, "\t")}\n`, {
45
- encoding: "utf8",
46
- mode: options.mode,
47
- });
48
- renameSync(temporary, path);
43
+ // A linked file stays linked, and a private one stays private. Other parts' settings share it.
44
+ const target = existsSync(path) ? realpathSync(path) : path;
45
+ const mode = options.mode ?? (existsSync(target) ? statSync(target).mode & 0o777 : undefined);
46
+ const temporary = `${target}.${process.pid}.tmp`;
47
+ writeFileSync(temporary, `${JSON.stringify(data, null, "\t")}\n`, { encoding: "utf8", mode });
48
+ renameSync(temporary, target);
49
49
  }
50
50
 
51
51
  export function lookup(data: SettingsData, id: string): unknown {
@@ -135,7 +135,9 @@ export class SettingsStore {
135
135
  }
136
136
 
137
137
  #edit(path: string, change: (data: SettingsData) => void): string | undefined {
138
- const { data } = readSettingsFile(path);
138
+ const { data, warnings } = readSettingsFile(path);
139
+ // Reading a broken file gives nothing, and writing that would wipe what else it holds.
140
+ if (warnings.length > 0) return warnings[0];
139
141
  change(data);
140
142
  try {
141
143
  writeSettingsFile(path, data);
@@ -15,7 +15,15 @@ import { BOXES, border } from "../render/frame.ts";
15
15
  import { paintLead, promptLead } from "../render/lead.ts";
16
16
  import { SETTINGS_KEY, SETTINGS_SHORTCUT_INPUT } from "../render/keys.ts";
17
17
  import type { CursorStyle } from "../settings.ts";
18
- import { findPrompt, type HistorySearch, shownText, startSearch } from "./history-search.ts";
18
+ import {
19
+ findPrompt,
20
+ type HistorySearch,
21
+ type PrefixSearch,
22
+ shownText,
23
+ startPrefix,
24
+ startSearch,
25
+ stepPrefix,
26
+ } from "./history-search.ts";
19
27
  import { type Screen, type SlotName, slotPieces } from "./screen.ts";
20
28
 
21
29
  type WorkingIndicator = Parameters<CustomEditor["setWorkingStatusIndicator"]>[0];
@@ -44,6 +52,8 @@ export class FrameEditor extends CustomEditor {
44
52
  #hiddenBelow = 0;
45
53
  #inset = 0;
46
54
  #search: HistorySearch | undefined;
55
+ #prefix: PrefixSearch | undefined;
56
+ #seeded = false;
47
57
 
48
58
  constructor(
49
59
  tui: TUI,
@@ -77,13 +87,9 @@ export class FrameEditor extends CustomEditor {
77
87
  this.#searchKey(this.#search, data);
78
88
  return;
79
89
  }
90
+ if (this.#prefixKey(data)) return;
80
91
  if (matchesKey(data, SEARCH_KEY) && !this.isShowingAutocomplete()) {
81
- // What Up browses, then what was typed in this folder's other sessions.
82
- const own: unknown = Reflect.get(this, "history");
83
- this.#search = startSearch(this.getText(), [
84
- ...(Array.isArray(own) ? (own as string[]) : []),
85
- ...this.#screen.pastPrompts(),
86
- ]);
92
+ this.#search = startSearch(this.getText(), this.#history());
87
93
  return;
88
94
  }
89
95
  if (this.#keybindings.matches(data, "app.clear") && !this.isShowingAutocomplete()) {
@@ -101,6 +107,51 @@ export class FrameEditor extends CustomEditor {
101
107
  if (skillQueryAt(this.getLines(), line, col) !== undefined) openAutocomplete(this);
102
108
  }
103
109
 
110
+ /** What Up browses, then what was typed in this folder's other sessions. Read once, on first use. */
111
+ #history(): readonly string[] {
112
+ const own: unknown = Reflect.get(this, "history");
113
+ if (!Array.isArray(own)) return this.#screen.pastPrompts();
114
+ const entries = own as string[];
115
+ if (!this.#seeded) {
116
+ this.#seeded = true;
117
+ for (const prompt of this.#screen.pastPrompts()) {
118
+ if (!entries.includes(prompt)) entries.push(prompt);
119
+ }
120
+ }
121
+ return entries;
122
+ }
123
+
124
+ /**
125
+ * Up with one line typed looks back through prompts that start with it, and Down comes forward
126
+ * again. Any other key keeps what's showing. An empty prompt is left to pi's own browsing.
127
+ */
128
+ #prefixKey(data: string): boolean {
129
+ const up = this.#keybindings.matches(data, "tui.editor.cursorUp");
130
+ const down = this.#keybindings.matches(data, "tui.editor.cursorDown");
131
+ if (!up && !down) {
132
+ this.#prefix = undefined;
133
+ return false;
134
+ }
135
+ if (this.isShowingAutocomplete()) return false;
136
+ if (this.#prefix === undefined) {
137
+ const typed = this.getText();
138
+ const entries = this.#history();
139
+ const browsing = Reflect.get(this, "historyIndex") !== -1;
140
+ if (down || typed === "" || typed.includes("\n") || browsing || !onFirstVisualLine(this)) return false;
141
+ const search = startPrefix(typed, entries);
142
+ // With nothing to go through, the arrows stay pi's.
143
+ if (search.matches.length === 0) return false;
144
+ this.#prefix = search;
145
+ }
146
+ const text = stepPrefix(this.#prefix, up ? 1 : -1);
147
+ if (text === undefined && down) {
148
+ this.#prefix = undefined;
149
+ return false;
150
+ }
151
+ if (text !== undefined) this.setText(text);
152
+ return true;
153
+ }
154
+
104
155
  /** Enter sends the match, Esc goes back, and any other key keeps the match to edit. */
105
156
  #searchKey(search: HistorySearch, data: string): void {
106
157
  if (matchesKey(data, SEARCH_KEY)) {
@@ -248,6 +299,12 @@ export class FrameEditor extends CustomEditor {
248
299
  }
249
300
  }
250
301
 
302
+ /** The method is private. If a pi release renames it, Up just moves the cursor as before. */
303
+ function onFirstVisualLine(editor: CustomEditor): boolean {
304
+ const check: unknown = Reflect.get(editor, "isOnFirstVisualLine");
305
+ return typeof check === "function" && check.call(editor) === true;
306
+ }
307
+
251
308
  /** The method is private; nothing opens if a pi release renames it. */
252
309
  function openAutocomplete(editor: CustomEditor): void {
253
310
  const open: unknown = Reflect.get(editor, "tryTriggerAutocomplete");
@@ -21,3 +21,24 @@ export function findPrompt(search: HistorySearch, from: number): number {
21
21
  export function shownText(search: HistorySearch): string {
22
22
  return search.at < 0 ? search.draft : (search.entries[search.at] as string);
23
23
  }
24
+
25
+ /** Up with text typed: older prompts that start with it, as in a shell. */
26
+ export interface PrefixSearch {
27
+ typed: string;
28
+ matches: readonly string[];
29
+ at: number;
30
+ }
31
+
32
+ export function startPrefix(typed: string, entries: readonly string[]): PrefixSearch {
33
+ const wanted = typed.toLowerCase();
34
+ const matches = [...new Set(entries)].filter((entry) => entry !== typed && entry.toLowerCase().startsWith(wanted));
35
+ return { typed, matches, at: -1 };
36
+ }
37
+
38
+ /** One step older (1) or newer (-1), ending back on what was typed. Undefined past either end. */
39
+ export function stepPrefix(search: PrefixSearch, step: 1 | -1): string | undefined {
40
+ const next = search.at + step;
41
+ if (next < -1 || next >= search.matches.length) return undefined;
42
+ search.at = next;
43
+ return next < 0 ? search.typed : search.matches[next];
44
+ }
@@ -1,5 +1,6 @@
1
1
  // Pi's own transcript entries in the harness frame instead of a background. Pi has no hook for
2
2
  // drawing them, so this wraps each component's render.
3
+ import { showThinkingWhileLive } from "./thinking.ts";
3
4
  import { CHANGES_CUT, CHANGES_X, entryFrame, type EntryFrame, GLYPH } from "../kit/index.ts";
4
5
  import {
5
6
  BashExecutionComponent,
@@ -63,6 +64,7 @@ export function framePiEntries(source: EntryPaint): void {
63
64
  paint = source;
64
65
  if (installed) return;
65
66
  installed = true;
67
+ showThinkingWhileLive(() => paint() !== undefined);
66
68
  wrap(UserMessageComponent, (self, look) =>
67
69
  look.you ? youFrame(look, fromOf(self, look), sentInOf(self, look)) : undefined,
68
70
  );
@@ -0,0 +1,34 @@
1
+ import { AssistantMessageComponent } from "@earendil-works/pi-coding-agent";
2
+
3
+ /** Private in pi. If a release renames them, reasoning just follows pi's setting again. */
4
+ interface Message {
5
+ updateContent(message: { content: readonly { type: string }[] }, isStreaming?: boolean): void;
6
+ isStreaming?: boolean;
7
+ thinkingVisibilityOverrides?: Map<number, boolean>;
8
+ }
9
+
10
+ const live = new WeakSet<object>();
11
+ let isActive: () => boolean = () => false;
12
+ let installed = false;
13
+
14
+ /**
15
+ * With pi's setting that collapses reasoning on, it still shows while it's being written, and
16
+ * collapses once the message is done. Ctrl+T or a click opens it again.
17
+ */
18
+ export function showThinkingWhileLive(active: () => boolean): void {
19
+ isActive = active;
20
+ if (installed) return;
21
+ installed = true;
22
+ const prototype = AssistantMessageComponent.prototype as unknown as Message;
23
+ const own = prototype.updateContent;
24
+ prototype.updateContent = function updateContent(this: Message, message, isStreaming = this.isStreaming) {
25
+ const overrides = this.thinkingVisibilityOverrides;
26
+ if (overrides !== undefined && isActive() && isStreaming === true) {
27
+ live.add(this);
28
+ message.content.forEach((_part, run) => overrides.set(run, false));
29
+ } else if (overrides !== undefined && live.delete(this)) {
30
+ overrides.clear();
31
+ }
32
+ own.call(this, message, isStreaming);
33
+ };
34
+ }
@@ -9,7 +9,7 @@ Long-term memory for [Pi](https://pi.dev). It reviews your past conversations, k
9
9
  Requires Pi 1.5 or newer, on Node 22.18 or newer.
10
10
 
11
11
  ```bash
12
- pi install npm:@huseynsnmz/pi-suite@0.2.2
12
+ pi install npm:@huseynsnmz/pi-suite@0.2.4
13
13
  ```
14
14
 
15
15
  ## Use
@@ -23,6 +23,25 @@ pi install npm:@huseynsnmz/pi-suite@0.2.2
23
23
 
24
24
  Or just ask: "remember that I deploy from main", "review our past conversations".
25
25
 
26
+ ## Teaching weaker models
27
+
28
+ | Command | What it does |
29
+ | --- | --- |
30
+ | `/mastermind` | A strong model reads past conversations, one batch at a time, and writes rules for your other models |
31
+ | `/mastermind compact` | Shortens the playbook, keeping what each rule means |
32
+ | `/mastermind show` | The rules in force in this folder |
33
+
34
+ Choose the model and its reasoning as `mastermind` in `/router`. It looks for what you corrected, mistakes that came back, and what the model should have read or checked first.
35
+
36
+ The result is a playbook, one global and one per folder, of up to 1,000 characters each. It goes in the system prompt of every run, up to about 500 tokens, except when the mastermind itself is the selected model. Each review rewrites it to fit, so it can't keep growing.
37
+
38
+ - **You approve every word:** the rules open in an editor, where you can delete or change them, and nothing is saved until you do. This matters because a session can contain text copied from files or web pages, and the playbook is followed as instructions. A secret is refused.
39
+ - **Your session stays small:** the strong model is called directly, so what it reads never enters your conversation, and there are no extra tools.
40
+ - **Separate from `/memory review`:** each keeps its own place in your sessions. Memories are facts, and the playbook is how to work.
41
+ - **Cost:** a batch sends up to about 15,000 tokens of input to the model you chose, so your past conversations go to that provider. It doesn't run on its own, and once 10 sessions are waiting, Pi mentions it after your first message.
42
+
43
+ Playbooks are in `~/.pi/agent/memory/playbook/`, with the last version of each kept as `.bak`.
44
+
26
45
  ## How it works
27
46
 
28
47
  - **Review:** the `review-memory` skill reads a batch of about 60,000 characters, proposes memories, and saves the ones you keep. It remembers how far it read in each session file, so a session you continue later gets read from where it stopped.
@@ -2,10 +2,15 @@
2
2
  import { join } from "node:path";
3
3
  import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import { Type } from "typebox";
5
+ import { compact, type Mastermind, review, REVIEWED_KEY } from "./mastermind.ts";
6
+ import { GLOBAL, playbookSection, readPlaybook } from "./playbook.ts";
5
7
  import { promptSection } from "./prompt.ts";
6
8
  import { countUnreviewed, nextBatch, sessionFiles, type ReviewBatch } from "./review.ts";
7
9
  import { CATEGORIES, MemoryStore, type Category, type Memory } from "./store.ts";
8
10
 
11
+ /** Enough new material for a review to be worth what it costs. */
12
+ const NUDGE_SESSIONS = 10;
13
+
9
14
  const text = (value: string, details: Record<string, unknown> = {}) => ({ content: [{ type: "text" as const, text: value }], details });
10
15
 
11
16
  function line(memory: Memory): string {
@@ -22,14 +27,37 @@ export default function piMemory(pi: ExtensionAPI): void {
22
27
  let lastBatch: ReviewBatch | null = null;
23
28
  // Worked out once a session: a system prompt that changes mid-session would throw away the provider's prompt cache.
24
29
  let section: string | null = null;
30
+ let playbook: string | null = null;
31
+ let nudged = false;
25
32
 
26
33
  const store = () => (storePromise ??= MemoryStore.open(dbPath));
27
34
 
28
35
  pi.on("session_start", () => {
29
36
  section = null;
37
+ playbook = null;
30
38
  lastBatch = null;
31
39
  });
32
40
 
41
+ /** The strong model the router has for this, whichever part loads first. */
42
+ const mastermind = (): Mastermind | undefined => {
43
+ const probe: { model?: string; thinking?: string } = {};
44
+ pi.events.emit("pi-router:mastermind-model", probe);
45
+ return probe.model === undefined ? undefined : { model: probe.model, thinking: probe.thinking };
46
+ };
47
+
48
+ // After the first reply rather than at start, as it walks every session file.
49
+ pi.on("agent_end", async (_event, ctx) => {
50
+ if (nudged || !ctx.hasUI || mastermind() === undefined) return;
51
+ nudged = true;
52
+ try {
53
+ const memories = await store();
54
+ const waiting = countUnreviewed(sessionFiles(roots), (path) => memories.reviewedBytes(REVIEWED_KEY + path), ctx.sessionManager.getSessionFile());
55
+ if (waiting >= NUDGE_SESSIONS) ctx.ui.notify(`${waiting} sessions haven't been reviewed. /mastermind can turn them into rules for your models.`, "info");
56
+ } catch {
57
+ // A reminder isn't worth an error.
58
+ }
59
+ });
60
+
33
61
  pi.on("session_shutdown", async () => {
34
62
  const open = storePromise;
35
63
  storePromise = null;
@@ -44,7 +72,10 @@ export default function piMemory(pi: ExtensionAPI): void {
44
72
  section = "";
45
73
  ctx.ui.notify(`pi-memory: ${err instanceof Error ? err.message : String(err)}`, "warning");
46
74
  }
47
- return section ? { systemPrompt: event.systemPrompt + section } : undefined;
75
+ playbook ??= playbookSection(readPlaybook(agentDir, GLOBAL), readPlaybook(agentDir, ctx.cwd));
76
+ // The model that writes the playbook doesn't need it.
77
+ const rules = ctx.model !== undefined && `${ctx.model.provider}/${ctx.model.id}` === mastermind()?.model ? "" : playbook;
78
+ return section || rules ? { systemPrompt: event.systemPrompt + section + rules } : undefined;
48
79
  });
49
80
 
50
81
  pi.registerTool({
@@ -141,6 +172,34 @@ export default function piMemory(pi: ExtensionAPI): void {
141
172
  ctx.ui.notify(`${body}\n\nSessions not reviewed yet: ${pending}. /memory review goes through them.`, "info");
142
173
  };
143
174
 
175
+ let reviewing = false;
176
+
177
+ pi.registerCommand("mastermind", {
178
+ description: "A strong model turns past conversations into rules for your other models. Or: compact | show",
179
+ handler: async (args, ctx) => {
180
+ if (!ctx.hasUI) return;
181
+ const action = args.trim();
182
+ if (action === "show") {
183
+ const shown = [GLOBAL, ctx.cwd].map((scope) => `${scope}:\n${readPlaybook(agentDir, scope) || "(empty)"}`).join("\n\n");
184
+ return ctx.ui.notify(shown, "info");
185
+ }
186
+ if (action !== "" && action !== "compact") return ctx.ui.notify("Usage: /mastermind [compact | show]", "warning");
187
+ const chosen = mastermind();
188
+ if (chosen === undefined) return ctx.ui.notify("Choose a strong model first: set mastermind in /router.", "warning");
189
+ if (reviewing) return ctx.ui.notify("/mastermind is already running.", "warning");
190
+ reviewing = true;
191
+ try {
192
+ if (action === "compact") await compact(ctx, agentDir, chosen, [GLOBAL, ctx.cwd]);
193
+ else await review(ctx, agentDir, chosen, await store(), sessionFiles(roots));
194
+ playbook = null;
195
+ } catch (err) {
196
+ ctx.ui.notify(`/mastermind: ${err instanceof Error ? err.message : String(err)}`, "error");
197
+ } finally {
198
+ reviewing = false;
199
+ }
200
+ },
201
+ });
202
+
144
203
  pi.registerCommand("memory", {
145
204
  description: "Show memories, or: /memory <words> | review | forget <id>",
146
205
  handler: async (args, ctx) => {