hotmilk 0.1.8 → 0.1.9
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/AGENTS.md +8 -8
- package/README.md +29 -27
- package/hotmilk.json +1 -1
- package/package.json +13 -13
- package/src/bootstrap/context-stack.ts +167 -0
- package/src/bootstrap/defaults.ts +10 -0
- package/src/bootstrap/extensions.ts +18 -8
- package/src/bootstrap/rtk-coexist.ts +6 -0
- package/src/bootstrap/session.ts +16 -22
- package/src/config/extension-groups.ts +5 -10
- package/src/index.ts +5 -0
- package/src/ui/footer.ts +53 -4
- package/src/ui/session-logo.ts +74 -0
package/AGENTS.md
CHANGED
|
@@ -6,14 +6,14 @@ User-facing setup: [README.md](README.md).
|
|
|
6
6
|
|
|
7
7
|
## Commands (this repo)
|
|
8
8
|
|
|
9
|
-
| Command | Purpose
|
|
10
|
-
| --------------- |
|
|
11
|
-
| `bun install` | Install deps (`bun.lock`)
|
|
12
|
-
| `bun test` | Vitest via vite-plus
|
|
13
|
-
| `bun run lint` | `vp lint`
|
|
14
|
-
| `bun run check` | lint + format + test
|
|
15
|
-
|
|
16
|
-
Requires **Node.js 22+**. Targets **Pi 0.
|
|
9
|
+
| Command | Purpose |
|
|
10
|
+
| --------------- | ------------------------- |
|
|
11
|
+
| `bun install` | Install deps (`bun.lock`) |
|
|
12
|
+
| `bun test` | Vitest via vite-plus |
|
|
13
|
+
| `bun run lint` | `vp lint` |
|
|
14
|
+
| `bun run check` | lint + format + test |
|
|
15
|
+
|
|
16
|
+
Requires **Node.js 22+**. Targets **Pi 0.78** (`@earendil-works/pi-coding-agent` peers).
|
|
17
17
|
|
|
18
18
|
## Pi essentials
|
|
19
19
|
|
package/README.md
CHANGED
|
@@ -50,11 +50,11 @@ pi install -l npm:hotmilk
|
|
|
50
50
|
2. On first session, hotmilk creates `~/.pi/agent/hotmilk.json` if missing (defaults match the bundled template).
|
|
51
51
|
3. After config changes, run `/reload`.
|
|
52
52
|
|
|
53
|
-
### Pi 0.
|
|
53
|
+
### Pi 0.78 and npm peers
|
|
54
54
|
|
|
55
|
-
hotmilk targets **Pi 0.
|
|
55
|
+
hotmilk targets **Pi 0.78** (`@earendil-works/pi-coding-agent` and peers). Several bundled dependencies still declare **0.74.x** peer ranges (`pi-simplify`, `pi-btw`, `pi-docparser`, `@blackbelt-technology/pi-flows`, and others). npm may report `ERESOLVE` until those packages publish 0.78-compatible peers.
|
|
56
56
|
|
|
57
|
-
This repo ships **`.npmrc`** with `legacy-peer-deps=true` so `npm install` and `npm ci` succeed. Copy from `.npmrc.example` if you clone without `.npmrc`. Treat upstream extensions as **best-effort on 0.
|
|
57
|
+
This repo ships **`.npmrc`** with `legacy-peer-deps=true` so `npm install` and `npm ci` succeed. Copy from `.npmrc.example` if you clone without `.npmrc`. Treat upstream extensions as **best-effort on 0.78** until their maintainers widen peer ranges.
|
|
58
58
|
|
|
59
59
|
Heavy optional stacks (`agent-dashboard`, `pi-flows`) stay **off by default**; enable in `/mode` only when you need them and accept extra peer / startup cost.
|
|
60
60
|
|
|
@@ -98,7 +98,7 @@ Or ask Pi to use the planning-with-files skill. The extension maintains `task_pl
|
|
|
98
98
|
"cursor-provider": true,
|
|
99
99
|
"btw": true,
|
|
100
100
|
"simplify": true,
|
|
101
|
-
"rtk-optimizer":
|
|
101
|
+
"rtk-optimizer": false,
|
|
102
102
|
"mcp-adapter": false,
|
|
103
103
|
"planning-with-files": false,
|
|
104
104
|
"caveman": false,
|
|
@@ -121,20 +121,22 @@ Or ask Pi to use the planning-with-files skill. The extension maintains `task_pl
|
|
|
121
121
|
}
|
|
122
122
|
```
|
|
123
123
|
|
|
124
|
-
| Key / area | Behavior
|
|
125
|
-
| --------------------------------- |
|
|
126
|
-
| `extensions.*` | Set to `false` to skip registering that bundled extension
|
|
127
|
-
| `extensions.subagents` | Default `false`. When `true`, imports pi-subagents (~10s). Use with `gentle-ai` for delegation
|
|
128
|
-
| `extensions.
|
|
129
|
-
|
|
|
130
|
-
| `
|
|
131
|
-
|
|
|
132
|
-
| `
|
|
133
|
-
| `
|
|
134
|
-
| `
|
|
135
|
-
| `
|
|
136
|
-
|
|
137
|
-
|
|
124
|
+
| Key / area | Behavior |
|
|
125
|
+
| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
126
|
+
| `extensions.*` | Set to `false` to skip registering that bundled extension |
|
|
127
|
+
| `extensions.subagents` | Default `false`. When `true`, imports pi-subagents (~10s). Use with `gentle-ai` for delegation |
|
|
128
|
+
| `extensions.context-mode` | Default `true`. Prefer `ctx_*` for large outputs (see project context-window rules) |
|
|
129
|
+
| `extensions.rtk-optimizer` | Default `false`. Bash/read/grep output compaction; enable with `context-mode` for leftover shell output. Install [`rtk` CLI](https://github.com/rtk-ai/rtk) for command rewrite (`/rtk verify`) |
|
|
130
|
+
| `extensions.goal` … `mcp-adapter` | Integration / perf extensions (formerly always loaded via `pi.extensions`; now toggled like other bundled deps) |
|
|
131
|
+
| Enabled extensions | Loaded **in parallel** on session start (faster than sequential import when many toggles are on) |
|
|
132
|
+
| `graph.warnOnStale` | Notify when `graphify-out/needs_update` exists |
|
|
133
|
+
| `graph.autoSuggestUpdate` | Append `graphify update .` to that notification |
|
|
134
|
+
| `defaults.persona` | Seeds `.pi/gentle-ai/persona.json` when missing (`gentleman` \| `neutral`) |
|
|
135
|
+
| `defaults.language` | Appends a project language hint to the system prompt each turn |
|
|
136
|
+
| `mcp.seedOnStart` | Copy `mcp.json` template into `~/.pi/agent/mcp.json` when missing (empty template; for pi-mcp-adapter) |
|
|
137
|
+
| `extensions.mcp-adapter` | Default `false`. Enable only when you want MCP servers from `~/.pi/agent/mcp.json` (do not duplicate context-mode) |
|
|
138
|
+
|
|
139
|
+
**MCP (default):** `context-mode` extension registers `ctx_*` via its built-in bridge (same module as [upstream `.pi/extensions/context-mode`](https://github.com/mksglu/context-mode/tree/main/.pi/extensions/context-mode), loaded from `build/adapters/pi/extension.js`). Hotmilk removes any `context-mode` server from `~/.pi/agent/mcp.json` when the extension is on. Enable `mcp-adapter` only for **other** MCP servers—not a second context-mode entry.
|
|
138
140
|
|
|
139
141
|
### Optional extensions (off by default)
|
|
140
142
|
|
|
@@ -144,9 +146,9 @@ Enable in `/mode` or set the key to `true` in `hotmilk.json`, then `/reload`.
|
|
|
144
146
|
| ----------------- | ------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
|
|
145
147
|
| `caveman` | [pi-caveman](https://www.npmjs.com/package/pi-caveman) | Terse English (`/caveman`). Conflicts with `defaults.language: ja` |
|
|
146
148
|
| `red-green` | [pi-red-green](https://www.npmjs.com/package/pi-red-green) | TDD via `/tdd`, `/tdd-status`. Config: `~/.pi/red-green/config.json` |
|
|
147
|
-
| `agent-dashboard` | [@blackbelt-technology/pi-agent-dashboard](https://www.npmjs.com/package/@blackbelt-technology/pi-agent-dashboard) | Warm-starts via `pi-dashboard start` (~30s). Peers **0.74**; test on 0.
|
|
149
|
+
| `agent-dashboard` | [@blackbelt-technology/pi-agent-dashboard](https://www.npmjs.com/package/@blackbelt-technology/pi-agent-dashboard) | Warm-starts via `pi-dashboard start` (~30s). Peers **0.74**; test on 0.78 before relying on it. Node.js ≥ 22.18 |
|
|
148
150
|
| `web-access` | [pi-web-access](https://www.npmjs.com/package/pi-web-access) | `web_search`, fetch, GitHub clone, PDF/video. Optional keys: `~/.pi/web-search.json` |
|
|
149
|
-
| `pi-flows` | [@blackbelt-technology/pi-flows](https://www.npmjs.com/package/@blackbelt-technology/pi-flows) | YAML DAG workflows (`/flows`). Peers **0.74** + `@sinclair/typebox`; off by default on 0.
|
|
151
|
+
| `pi-flows` | [@blackbelt-technology/pi-flows](https://www.npmjs.com/package/@blackbelt-technology/pi-flows) | YAML DAG workflows (`/flows`). Peers **0.74** + `@sinclair/typebox`; off by default on 0.78 stacks |
|
|
150
152
|
|
|
151
153
|
**Agent dashboard troubleshooting**
|
|
152
154
|
|
|
@@ -170,7 +172,7 @@ Older **tabako** users: replace `npm:tabako` with `npm:hotmilk`; let hotmilk see
|
|
|
170
172
|
|
|
171
173
|
## Development
|
|
172
174
|
|
|
173
|
-
Requires **Node.js 22+** (or **Bun 1.3+**), **Bun** for installs in this repo, and Pi **0.
|
|
175
|
+
Requires **Node.js 22+** (or **Bun 1.3+**), **Bun** for installs in this repo, and Pi **0.78** peers in the environment.
|
|
174
176
|
|
|
175
177
|
```bash
|
|
176
178
|
bun install # commit bun.lock; peers resolved by Bun
|
|
@@ -186,7 +188,7 @@ bun run check # lint + format + test
|
|
|
186
188
|
On push to `main`, GitHub Actions runs **lint + test**, then a **`publish` job** (`needs: test`) when `package.json` version is **newer than npm**. No separate workflow or tag push is required to start publish.
|
|
187
189
|
|
|
188
190
|
```text
|
|
189
|
-
push main → test → publish (
|
|
191
|
+
push main → test → publish (npm publish --provenance) → git tag v<version>
|
|
190
192
|
```
|
|
191
193
|
|
|
192
194
|
Bump `version` in `package.json` before pushing to `main`.
|
|
@@ -197,20 +199,20 @@ Bump `version` in `package.json` before pushing to `main`.
|
|
|
197
199
|
2. Scope: publish to **`hotmilk`** (or classic `publish` on the account)
|
|
198
200
|
3. Repository → **Settings → Secrets → Actions** → name **`NPM_TOKEN`**
|
|
199
201
|
|
|
200
|
-
CI
|
|
202
|
+
CI uses [npm’s CI/CD workflow](https://docs.npmjs.com/using-private-packages-in-a-ci-cd-workflow): `actions/setup-node` with `registry-url`, then `npm publish --provenance --access public` with **`NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}`**. The secret is named `NPM_TOKEN`; `setup-node` reads **`NODE_AUTH_TOKEN`** for auth. Dependencies are **not** bundled into the tarball (`bundleDependencies` removed — npm rejected the 162 MB hard-linked bundle with `E415`).
|
|
201
203
|
|
|
202
|
-
Trusted Publisher on npm can stay configured or be removed; CI
|
|
204
|
+
Trusted Publisher on npm can stay configured or be removed; CI uses the token path above.
|
|
203
205
|
|
|
204
206
|
GitHub Release is optional — npm publish does not require it.
|
|
205
207
|
|
|
206
|
-
Local publish:
|
|
208
|
+
Local publish: `npm login` once, then `npm publish --access public`. Or add the token to **`~/.npmrc`** (not the repo `.npmrc`):
|
|
207
209
|
|
|
208
210
|
```bash
|
|
209
211
|
echo "//registry.npmjs.org/:_authToken=YOUR_NPM_TOKEN" >> ~/.npmrc
|
|
210
|
-
|
|
212
|
+
npm publish --access public
|
|
211
213
|
```
|
|
212
214
|
|
|
213
|
-
`bun publish` does not read the `NPM_TOKEN` environment variable by itself
|
|
215
|
+
`bun publish` also works locally if `~/.npmrc` has a token; it does not read the `NPM_TOKEN` environment variable by itself.
|
|
214
216
|
|
|
215
217
|
### Layout
|
|
216
218
|
|
package/hotmilk.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hotmilk",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "hotmilk — Pi package bundling gentle-pi, context-mode, graphify, subagents, and bundled extension toggles via hotmilk.json",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"hotmilk",
|
|
@@ -60,21 +60,24 @@
|
|
|
60
60
|
"pi-web-access": "^0.10.7"
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
|
-
"@earendil-works/pi-agent-core": "^0.
|
|
64
|
-
"@earendil-works/pi-ai": "^0.
|
|
65
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
66
|
-
"@earendil-works/pi-tui": "^0.
|
|
67
|
-
"@types/node": "^22.
|
|
63
|
+
"@earendil-works/pi-agent-core": "^0.78.0",
|
|
64
|
+
"@earendil-works/pi-ai": "^0.78.0",
|
|
65
|
+
"@earendil-works/pi-coding-agent": "^0.78.0",
|
|
66
|
+
"@earendil-works/pi-tui": "^0.78.0",
|
|
67
|
+
"@types/node": "^22.19.19",
|
|
68
68
|
"typebox": "^1.1.39",
|
|
69
69
|
"vite-plus": "^0.1.23"
|
|
70
70
|
},
|
|
71
71
|
"peerDependencies": {
|
|
72
|
-
"@earendil-works/pi-agent-core": "^0.
|
|
73
|
-
"@earendil-works/pi-ai": "^0.
|
|
74
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
75
|
-
"@earendil-works/pi-tui": "^0.
|
|
72
|
+
"@earendil-works/pi-agent-core": "^0.78.0",
|
|
73
|
+
"@earendil-works/pi-ai": "^0.78.0",
|
|
74
|
+
"@earendil-works/pi-coding-agent": "^0.78.0",
|
|
75
|
+
"@earendil-works/pi-tui": "^0.78.0",
|
|
76
76
|
"typebox": "^1.1.39"
|
|
77
77
|
},
|
|
78
|
+
"engines": {
|
|
79
|
+
"node": "22"
|
|
80
|
+
},
|
|
78
81
|
"pi": {
|
|
79
82
|
"extensions": [
|
|
80
83
|
"./src/index.ts"
|
|
@@ -100,8 +103,5 @@
|
|
|
100
103
|
"themes": [
|
|
101
104
|
"./themes"
|
|
102
105
|
]
|
|
103
|
-
},
|
|
104
|
-
"engines": {
|
|
105
|
-
"node": "22"
|
|
106
106
|
}
|
|
107
107
|
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { pruneContextModeMcpServerFromAgentConfig } from "../config/mcp.ts";
|
|
5
|
+
import type { HotmilkRuntime } from "../config/runtime.ts";
|
|
6
|
+
import type { BundledExtensionId } from "../config/hotmilk.ts";
|
|
7
|
+
|
|
8
|
+
const RTK_EXTENSION_DIR = "pi-rtk-optimizer";
|
|
9
|
+
const RTK_CONFIG_FILENAME = "config.json";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Register in this order so context-mode hooks run before rtk-optimizer compacts tool output.
|
|
13
|
+
* Loader paths match upstream npm `pi.extensions` (not `.pi/extensions/` shims in the git repo).
|
|
14
|
+
*/
|
|
15
|
+
export const CONTEXT_STACK_EXTENSION_IDS = [
|
|
16
|
+
"context-mode",
|
|
17
|
+
"rtk-optimizer",
|
|
18
|
+
] as const satisfies readonly BundledExtensionId[];
|
|
19
|
+
|
|
20
|
+
export function getRtkOptimizerConfigPath(): string {
|
|
21
|
+
return `${getAgentDir()}/extensions/${RTK_EXTENSION_DIR}/${RTK_CONFIG_FILENAME}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function expectedRtkMode(contextModeEnabled: boolean): "suggest" | "rewrite" {
|
|
25
|
+
return contextModeEnabled ? "suggest" : "rewrite";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Defaults when hotmilk seeds pi-rtk-optimizer config for the first time. */
|
|
29
|
+
export function buildHotmilkRtkConfig(contextModeEnabled: boolean): Record<string, unknown> {
|
|
30
|
+
return {
|
|
31
|
+
enabled: true,
|
|
32
|
+
mode: expectedRtkMode(contextModeEnabled),
|
|
33
|
+
guardWhenRtkMissing: true,
|
|
34
|
+
showRewriteNotifications: false,
|
|
35
|
+
outputCompaction: {
|
|
36
|
+
enabled: true,
|
|
37
|
+
stripAnsi: true,
|
|
38
|
+
readCompaction: { enabled: false },
|
|
39
|
+
truncate: { enabled: true, maxChars: 12_000 },
|
|
40
|
+
sourceCodeFilteringEnabled: false,
|
|
41
|
+
preserveExactSkillReads: true,
|
|
42
|
+
sourceCodeFiltering: "none",
|
|
43
|
+
smartTruncate: { enabled: false, maxLines: 220 },
|
|
44
|
+
aggregateTestOutput: true,
|
|
45
|
+
filterBuildOutput: true,
|
|
46
|
+
compactGitOutput: true,
|
|
47
|
+
aggregateLinterOutput: true,
|
|
48
|
+
groupSearchOutput: true,
|
|
49
|
+
trackSavings: true,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type RtkConfigRecord = Record<string, unknown> & {
|
|
55
|
+
mode?: string;
|
|
56
|
+
outputCompaction?: {
|
|
57
|
+
readCompaction?: { enabled?: boolean };
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
function writeRtkConfig(configPath: string, config: unknown): void {
|
|
62
|
+
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function seedRtkConfigIfMissing(
|
|
66
|
+
contextModeEnabled: boolean,
|
|
67
|
+
configPath = getRtkOptimizerConfigPath(),
|
|
68
|
+
): { seeded: boolean; path: string } {
|
|
69
|
+
if (existsSync(configPath)) {
|
|
70
|
+
return { seeded: false, path: configPath };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
74
|
+
writeRtkConfig(configPath, buildHotmilkRtkConfig(contextModeEnabled));
|
|
75
|
+
return { seeded: true, path: configPath };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Hotmilk-managed fields only — does not touch Pi auto-compaction (settings.json). */
|
|
79
|
+
function alignRtkConfigWithContextMode(
|
|
80
|
+
config: RtkConfigRecord,
|
|
81
|
+
contextModeEnabled: boolean,
|
|
82
|
+
): boolean {
|
|
83
|
+
let changed = false;
|
|
84
|
+
const mode = expectedRtkMode(contextModeEnabled);
|
|
85
|
+
if (config.mode !== mode) {
|
|
86
|
+
config.mode = mode;
|
|
87
|
+
changed = true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (contextModeEnabled && config.outputCompaction?.readCompaction?.enabled !== false) {
|
|
91
|
+
config.outputCompaction ??= {};
|
|
92
|
+
config.outputCompaction.readCompaction = { enabled: false };
|
|
93
|
+
changed = true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return changed;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function syncRtkConfigForContextStack(
|
|
100
|
+
contextModeEnabled: boolean,
|
|
101
|
+
rtkEnabled: boolean,
|
|
102
|
+
configPath = getRtkOptimizerConfigPath(),
|
|
103
|
+
): { updated: boolean; seeded: boolean; path: string } {
|
|
104
|
+
if (!rtkEnabled) {
|
|
105
|
+
return { updated: false, seeded: false, path: configPath };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!existsSync(configPath)) {
|
|
109
|
+
seedRtkConfigIfMissing(contextModeEnabled, configPath);
|
|
110
|
+
return { updated: true, seeded: true, path: configPath };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const config = JSON.parse(readFileSync(configPath, "utf8")) as RtkConfigRecord;
|
|
114
|
+
const changed = alignRtkConfigWithContextMode(config, contextModeEnabled);
|
|
115
|
+
if (changed) {
|
|
116
|
+
writeRtkConfig(configPath, config);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { updated: changed, seeded: false, path: configPath };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Run before bundled extensions register (rtk seed/sync when that toggle is on). */
|
|
123
|
+
export function prepareContextStack(extensionToggles: Record<BundledExtensionId, boolean>): void {
|
|
124
|
+
if (extensionToggles["rtk-optimizer"]) {
|
|
125
|
+
syncRtkConfigForContextStack(extensionToggles["context-mode"], true);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const MCP_PRUNED_MESSAGE = (path: string): string =>
|
|
130
|
+
`Removed duplicate context-mode entry from ${path}. ctx_* tools use the extension bridge only.`;
|
|
131
|
+
|
|
132
|
+
const MCP_ADAPTER_DUPLICATE_WARNING =
|
|
133
|
+
"context-mode runs on the extension bridge (ctx_*). Do not add a context-mode server to mcp.json — use mcp-adapter only for other MCP servers.";
|
|
134
|
+
|
|
135
|
+
const RTK_SYNC_MESSAGE =
|
|
136
|
+
"Adjusted pi-rtk-optimizer for context-mode coexistence (mode/readCompaction). Pi auto-compaction unchanged.";
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Option A: context-mode extension owns ctx_* via built-in MCP bridge.
|
|
140
|
+
* Prune legacy `context-mode` MCP server entries whenever the extension is enabled.
|
|
141
|
+
*/
|
|
142
|
+
export function applyContextStackOnSessionStart(
|
|
143
|
+
runtime: HotmilkRuntime,
|
|
144
|
+
notify: (message: string, level: "info" | "warning") => void,
|
|
145
|
+
): void {
|
|
146
|
+
const { extensionToggles } = runtime;
|
|
147
|
+
|
|
148
|
+
if (extensionToggles["rtk-optimizer"]) {
|
|
149
|
+
const sync = syncRtkConfigForContextStack(extensionToggles["context-mode"], true);
|
|
150
|
+
if (sync.updated && !sync.seeded) {
|
|
151
|
+
notify(RTK_SYNC_MESSAGE, "info");
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!extensionToggles["context-mode"]) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const mcpPrune = pruneContextModeMcpServerFromAgentConfig();
|
|
160
|
+
if (mcpPrune.pruned) {
|
|
161
|
+
notify(MCP_PRUNED_MESSAGE(mcpPrune.path), "info");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (extensionToggles["mcp-adapter"]) {
|
|
165
|
+
notify(MCP_ADAPTER_DUPLICATE_WARNING, "warning");
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -3,6 +3,16 @@ import { dirname, join } from "node:path";
|
|
|
3
3
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import type { ResolvedDefaults } from "../config/hotmilk.ts";
|
|
5
5
|
|
|
6
|
+
export const CAVEMAN_JA_CONFLICT_MESSAGE =
|
|
7
|
+
"caveman is on while defaults.language is ja — caveman adds English terse rules that fight the Japanese language hint. Turn off caveman (/mode), clear defaults.language, or use /caveman off.";
|
|
8
|
+
|
|
9
|
+
export function shouldWarnCavemanJaConflict(
|
|
10
|
+
cavemanEnabled: boolean,
|
|
11
|
+
language: string | undefined,
|
|
12
|
+
): boolean {
|
|
13
|
+
return cavemanEnabled && language?.trim().toLowerCase() === "ja";
|
|
14
|
+
}
|
|
15
|
+
|
|
6
16
|
export function seedPersonaFromDefaults(cwd: string, defaults: ResolvedDefaults): void {
|
|
7
17
|
const path = join(cwd, ".pi", "gentle-ai", "persona.json");
|
|
8
18
|
if (existsSync(path)) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { CONTEXT_STACK_EXTENSION_IDS } from "./context-stack.ts";
|
|
2
3
|
import { BUNDLED_EXTENSION_IDS, type BundledExtensionId } from "../config/hotmilk.ts";
|
|
3
4
|
|
|
4
5
|
type ExtensionFactory = (pi: ExtensionAPI) => void | Promise<void>;
|
|
@@ -8,12 +9,13 @@ type ExtensionModule = { default: unknown };
|
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Load bundled extensions on demand so disabled toggles do not pay import cost.
|
|
11
|
-
*
|
|
12
|
+
* Context stack registers sequentially; other enabled extensions load in parallel.
|
|
12
13
|
*/
|
|
13
14
|
const BUNDLED_EXTENSION_LOADERS: Record<BundledExtensionId, () => Promise<ExtensionModule>> = {
|
|
14
15
|
"skill-registry": () => import("../../node_modules/gentle-pi/extensions/skill-registry.ts"),
|
|
15
16
|
"sdd-init": () => import("../../node_modules/gentle-pi/extensions/sdd-init.ts"),
|
|
16
17
|
"gentle-ai": () => import("../../node_modules/gentle-pi/extensions/gentle-ai.ts"),
|
|
18
|
+
// Same entry as upstream .pi/extensions/context-mode → build/adapters/pi/extension.js
|
|
17
19
|
"context-mode": () => import("../../node_modules/context-mode/build/adapters/pi/extension.js"),
|
|
18
20
|
"ask-user": () => import("pi-ask-user"),
|
|
19
21
|
graphify: () => import("../../node_modules/graphify-pi/extensions/graphify.ts"),
|
|
@@ -36,15 +38,23 @@ const BUNDLED_EXTENSION_LOADERS: Record<BundledExtensionId, () => Promise<Extens
|
|
|
36
38
|
"pi-flows": () => import("../../node_modules/@blackbelt-technology/pi-flows/extensions/index.ts"),
|
|
37
39
|
};
|
|
38
40
|
|
|
41
|
+
async function registerOne(pi: ExtensionAPI, id: BundledExtensionId): Promise<void> {
|
|
42
|
+
const mod = await BUNDLED_EXTENSION_LOADERS[id]();
|
|
43
|
+
await (mod.default as ExtensionFactory)(pi);
|
|
44
|
+
}
|
|
45
|
+
|
|
39
46
|
export async function registerBundledExtensions(
|
|
40
47
|
pi: ExtensionAPI,
|
|
41
48
|
enabled: Record<BundledExtensionId, boolean>,
|
|
42
49
|
): Promise<void> {
|
|
43
|
-
const enabledIds = BUNDLED_EXTENSION_IDS.filter((id) => enabled[id]);
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
await (
|
|
48
|
-
|
|
49
|
-
|
|
50
|
+
const enabledIds = new Set(BUNDLED_EXTENSION_IDS.filter((id) => enabled[id]));
|
|
51
|
+
|
|
52
|
+
for (const id of CONTEXT_STACK_EXTENSION_IDS) {
|
|
53
|
+
if (enabledIds.has(id)) {
|
|
54
|
+
await registerOne(pi, id);
|
|
55
|
+
enabledIds.delete(id);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
await Promise.all([...enabledIds].map((id) => registerOne(pi, id)));
|
|
50
60
|
}
|
package/src/bootstrap/session.ts
CHANGED
|
@@ -1,58 +1,52 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
seedAgentMcpJsonIfMissing,
|
|
5
|
-
} from "../config/mcp.ts";
|
|
2
|
+
import { applyContextStackOnSessionStart } from "./context-stack.ts";
|
|
3
|
+
import { seedAgentMcpJsonIfMissing } from "../config/mcp.ts";
|
|
6
4
|
import { AGENT_HOTMILK_CONFIG_LABEL, seedHotmilkConfigIfMissing } from "../config/hotmilk.ts";
|
|
7
5
|
import type { HotmilkRuntime } from "../config/runtime.ts";
|
|
8
6
|
import { setupHotmilkFooter } from "../ui/footer.ts";
|
|
9
|
-
import {
|
|
10
|
-
|
|
7
|
+
import {
|
|
8
|
+
CAVEMAN_JA_CONFLICT_MESSAGE,
|
|
9
|
+
seedPersonaFromDefaults,
|
|
10
|
+
shouldWarnCavemanJaConflict,
|
|
11
|
+
} from "./defaults.ts";
|
|
11
12
|
|
|
12
13
|
const HOTMILK_SEEDED_MESSAGE = `Created ${AGENT_HOTMILK_CONFIG_LABEL} (toggle bundled extensions with /mode, then /reload).`;
|
|
13
14
|
const HOTMILK_PARSE_ERROR_MESSAGE = (path: string, error: string): string =>
|
|
14
15
|
`Failed to parse ${path}: ${error}. Using default extension toggles.`;
|
|
15
16
|
const MCP_SEEDED_MESSAGE = (path: string): string =>
|
|
16
17
|
`Created ${path} from hotmilk MCP template (add servers for pi-mcp-adapter; context-mode uses the extension bridge).`;
|
|
17
|
-
const MCP_PRUNED_CONTEXT_MODE_MESSAGE = (path: string): string =>
|
|
18
|
-
`Removed duplicate context-mode entry from ${path}. Use context-mode extension tools (ctx_*), not MCP adapter, unless mcp-adapter is enabled.`;
|
|
19
18
|
|
|
20
19
|
export function registerSessionHandlers(pi: ExtensionAPI, runtime: HotmilkRuntime): void {
|
|
21
20
|
const termProgram = process.env.TERM_PROGRAM ?? "none";
|
|
22
21
|
|
|
23
22
|
pi.on("session_start", (_event, ctx) => {
|
|
23
|
+
const uiNotify = (message: string, level: "info" | "warning") => ctx.ui.notify(message, level);
|
|
24
|
+
|
|
24
25
|
setupHotmilkFooter(ctx, termProgram);
|
|
25
26
|
|
|
26
|
-
ctx.ui.notify(`\`\`\`text\n${HOTMILK_LOGO}\n\`\`\``, "info");
|
|
27
27
|
const hotmilkSeed = seedHotmilkConfigIfMissing();
|
|
28
28
|
if (hotmilkSeed.seeded) {
|
|
29
|
-
|
|
29
|
+
uiNotify(HOTMILK_SEEDED_MESSAGE, "info");
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
if (runtime.configError) {
|
|
33
|
-
|
|
34
|
-
HOTMILK_PARSE_ERROR_MESSAGE(runtime.configPath, runtime.configError),
|
|
35
|
-
"warning",
|
|
36
|
-
);
|
|
33
|
+
uiNotify(HOTMILK_PARSE_ERROR_MESSAGE(runtime.configPath, runtime.configError), "warning");
|
|
37
34
|
}
|
|
38
35
|
|
|
39
36
|
if (runtime.extensionToggles["gentle-ai"]) {
|
|
40
37
|
seedPersonaFromDefaults(ctx.cwd, runtime.defaults);
|
|
41
38
|
}
|
|
42
39
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (
|
|
46
|
-
|
|
47
|
-
if (mcpPrune.pruned) {
|
|
48
|
-
ctx.ui.notify(MCP_PRUNED_CONTEXT_MODE_MESSAGE(mcpPrune.path), "info");
|
|
49
|
-
}
|
|
40
|
+
applyContextStackOnSessionStart(runtime, uiNotify);
|
|
41
|
+
|
|
42
|
+
if (shouldWarnCavemanJaConflict(runtime.extensionToggles.caveman, runtime.defaults.language)) {
|
|
43
|
+
uiNotify(CAVEMAN_JA_CONFLICT_MESSAGE, "warning");
|
|
50
44
|
}
|
|
51
45
|
|
|
52
46
|
if (runtime.mcp.seedOnStart) {
|
|
53
47
|
const mcpSeed = seedAgentMcpJsonIfMissing();
|
|
54
48
|
if (mcpSeed.seeded) {
|
|
55
|
-
|
|
49
|
+
uiNotify(MCP_SEEDED_MESSAGE(mcpSeed.path), "info");
|
|
56
50
|
}
|
|
57
51
|
}
|
|
58
52
|
});
|
|
@@ -10,21 +10,16 @@ export const BUNDLED_EXTENSION_GROUPS: BundledExtensionGroup[] = [
|
|
|
10
10
|
{ label: "Harness", ids: ["skill-registry", "sdd-init", "gentle-ai"] },
|
|
11
11
|
{
|
|
12
12
|
label: "Agent tools",
|
|
13
|
-
ids: [
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
"agent-dashboard",
|
|
19
|
-
"web-access",
|
|
20
|
-
"pi-flows",
|
|
21
|
-
],
|
|
13
|
+
ids: ["ask-user", "graphify", "subagents", "agent-dashboard", "web-access", "pi-flows"],
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
label: "Context & performance",
|
|
17
|
+
ids: ["context-mode", "rtk-optimizer", "simplify"],
|
|
22
18
|
},
|
|
23
19
|
{
|
|
24
20
|
label: "Integrations",
|
|
25
21
|
ids: ["goal", "docparser", "obsidian", "cursor-provider", "btw", "mcp-adapter"],
|
|
26
22
|
},
|
|
27
|
-
{ label: "Performance", ids: ["simplify", "rtk-optimizer"] },
|
|
28
23
|
{ label: "Workflow", ids: ["planning-with-files", "red-green"] },
|
|
29
24
|
{ label: "Output", ids: ["caveman"] },
|
|
30
25
|
];
|
package/src/index.ts
CHANGED
|
@@ -6,9 +6,11 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
6
6
|
import { createHotmilkRuntime } from "./config/runtime.ts";
|
|
7
7
|
import { registerDefaultsHandlers } from "./bootstrap/defaults.ts";
|
|
8
8
|
import { registerBundledExtensions } from "./bootstrap/extensions.ts";
|
|
9
|
+
import { prepareContextStack } from "./bootstrap/context-stack.ts";
|
|
9
10
|
import { registerGraphHandlers } from "./bootstrap/graph.ts";
|
|
10
11
|
import { registerSessionHandlers } from "./bootstrap/session.ts";
|
|
11
12
|
import { registerInputCommands, routeInputCommand } from "./controller/input.ts";
|
|
13
|
+
import { registerHotmilkSessionLogo } from "./ui/session-logo.ts";
|
|
12
14
|
|
|
13
15
|
export default async function registerHotmilk(pi: ExtensionAPI): Promise<void> {
|
|
14
16
|
const runtime = createHotmilkRuntime();
|
|
@@ -18,10 +20,13 @@ export default async function registerHotmilk(pi: ExtensionAPI): Promise<void> {
|
|
|
18
20
|
scheduleDashboardWarmStart();
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
prepareContextStack(runtime.extensionToggles);
|
|
24
|
+
|
|
21
25
|
await registerBundledExtensions(pi, runtime.extensionToggles);
|
|
22
26
|
registerGraphHandlers(pi, runtime.graph);
|
|
23
27
|
registerDefaultsHandlers(pi, runtime.defaults);
|
|
24
28
|
registerSessionHandlers(pi, runtime);
|
|
25
29
|
registerInputCommands(pi);
|
|
30
|
+
registerHotmilkSessionLogo(pi);
|
|
26
31
|
pi.on("input", (event, ctx) => routeInputCommand(event.text, pi, ctx));
|
|
27
32
|
}
|
package/src/ui/footer.ts
CHANGED
|
@@ -4,10 +4,51 @@ import {
|
|
|
4
4
|
type ReadonlyFooterDataProvider,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
7
|
-
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
7
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
8
8
|
import type { ExtensionContext } from "../controller/context.ts";
|
|
9
9
|
import { formatFooterTime } from "./footer-time.ts";
|
|
10
10
|
|
|
11
|
+
function sanitizeStatusText(text: string): string {
|
|
12
|
+
return text
|
|
13
|
+
.replace(/[\r\n\t]/g, " ")
|
|
14
|
+
.replace(/ +/g, " ")
|
|
15
|
+
.trim();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function extensionStatusLines(
|
|
19
|
+
footerData: ReadonlyFooterDataProvider,
|
|
20
|
+
width: number,
|
|
21
|
+
dim: (text: string) => string,
|
|
22
|
+
ellipsis: string,
|
|
23
|
+
): string[] {
|
|
24
|
+
const extensionStatuses = footerData.getExtensionStatuses();
|
|
25
|
+
if (extensionStatuses.size === 0) {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return [...extensionStatuses.entries()]
|
|
30
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
31
|
+
.map(([, text]) => sanitizeStatusText(text))
|
|
32
|
+
.filter((text) => text.length > 0)
|
|
33
|
+
.map((text) => truncateToWidth(dim(text), width, ellipsis));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function appendMetaToLastLine(lines: string[], meta: string, width: number): string[] {
|
|
37
|
+
if (lines.length === 0) {
|
|
38
|
+
return [meta];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const lastIndex = lines.length - 1;
|
|
42
|
+
const lastLine = lines[lastIndex] ?? "";
|
|
43
|
+
const gap = 2;
|
|
44
|
+
if (visibleWidth(lastLine) + gap + visibleWidth(meta) <= width) {
|
|
45
|
+
const padding = " ".repeat(width - visibleWidth(lastLine) - visibleWidth(meta));
|
|
46
|
+
return [...lines.slice(0, lastIndex), lastLine + padding + meta];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return [...lines, meta];
|
|
50
|
+
}
|
|
51
|
+
|
|
11
52
|
const FOOTER_TIME_REFRESH_MS = 30_000;
|
|
12
53
|
|
|
13
54
|
function latestThinkingLevel(sessionManager: ExtensionContext["sessionManager"]): ThinkingLevel {
|
|
@@ -55,9 +96,17 @@ export function setupHotmilkFooter(ctx: ExtensionContext, termProgram: string):
|
|
|
55
96
|
base.invalidate();
|
|
56
97
|
},
|
|
57
98
|
render(width: number): string[] {
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
|
|
99
|
+
const baseLines = base.render(width);
|
|
100
|
+
const coreLines = baseLines.slice(0, 2);
|
|
101
|
+
const dim = (text: string) => theme.fg("dim", text);
|
|
102
|
+
const ellipsis = theme.fg("dim", "...");
|
|
103
|
+
const statusLines = extensionStatusLines(footerData, width, dim, ellipsis);
|
|
104
|
+
const meta = truncateToWidth(
|
|
105
|
+
dim(`${formatFooterTime(new Date())} ${termProgram}`),
|
|
106
|
+
width,
|
|
107
|
+
ellipsis,
|
|
108
|
+
);
|
|
109
|
+
return appendMetaToLastLine([...coreLines, ...statusLines], meta, width);
|
|
61
110
|
},
|
|
62
111
|
};
|
|
63
112
|
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { ExtensionContext } from "../controller/context.ts";
|
|
4
|
+
import { HOTMILK_LOGO } from "./logo.ts";
|
|
5
|
+
|
|
6
|
+
const LOGO_LINES = HOTMILK_LOGO.split("\n");
|
|
7
|
+
/** Same lead time as gentle-pi `startup-banner.ts`. */
|
|
8
|
+
const LOGO_SHOW_DELAY_MS = 50;
|
|
9
|
+
|
|
10
|
+
let showTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
11
|
+
let activeTui: TUI | undefined;
|
|
12
|
+
|
|
13
|
+
/** Center fixed-width ASCII lines within the terminal width. */
|
|
14
|
+
export function centerAsciiLines(lines: readonly string[], width: number): string[] {
|
|
15
|
+
const contentWidth = Math.max(...lines.map((line) => line.length), 1);
|
|
16
|
+
const pad = Math.max(0, Math.floor((width - contentWidth) / 2));
|
|
17
|
+
const prefix = " ".repeat(pad);
|
|
18
|
+
return lines.map((line) => prefix + line.padEnd(contentWidth));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Skip only `/reload`; show on fresh launch and session resume (gentle-pi shows every session_start). */
|
|
22
|
+
export function shouldShowSessionLogo(reason: string): boolean {
|
|
23
|
+
return reason !== "reload";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function clearLogoTimers(): void {
|
|
27
|
+
if (showTimeout) {
|
|
28
|
+
clearTimeout(showTimeout);
|
|
29
|
+
showTimeout = undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function dismissHotmilkLogo(ctx: ExtensionContext): void {
|
|
34
|
+
if (!ctx.hasUI) return;
|
|
35
|
+
ctx.ui.setHeader(undefined);
|
|
36
|
+
activeTui?.requestRender();
|
|
37
|
+
activeTui = undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function showHotmilkSessionLogo(ctx: ExtensionContext): void {
|
|
41
|
+
if (!ctx.hasUI) return;
|
|
42
|
+
|
|
43
|
+
clearLogoTimers();
|
|
44
|
+
showTimeout = setTimeout(() => {
|
|
45
|
+
showTimeout = undefined;
|
|
46
|
+
if (!ctx.hasUI) return;
|
|
47
|
+
|
|
48
|
+
ctx.ui.setHeader((tui, theme) => {
|
|
49
|
+
activeTui = tui;
|
|
50
|
+
return {
|
|
51
|
+
render(width: number): string[] {
|
|
52
|
+
return centerAsciiLines(LOGO_LINES, width).map((line) => theme.fg("accent", line));
|
|
53
|
+
},
|
|
54
|
+
invalidate() {
|
|
55
|
+
clearLogoTimers();
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
activeTui?.requestRender();
|
|
60
|
+
}, LOGO_SHOW_DELAY_MS);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Register persistent header logo on session start; clear on shutdown only. */
|
|
64
|
+
export function registerHotmilkSessionLogo(pi: ExtensionAPI): void {
|
|
65
|
+
pi.on("session_start", (event, ctx) => {
|
|
66
|
+
if (!shouldShowSessionLogo(event.reason)) return;
|
|
67
|
+
showHotmilkSessionLogo(ctx);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
71
|
+
clearLogoTimers();
|
|
72
|
+
dismissHotmilkLogo(ctx);
|
|
73
|
+
});
|
|
74
|
+
}
|