@jaybeeuu/agent-cortex 2.2.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +48 -0
- package/README.md +1 -1
- package/bin/installers/pi.mjs +7 -4
- package/extensions/agent-guardrails/package.json +0 -3
- package/extensions/agent-modes/index.ts +4 -3
- package/extensions/agent-modes/package.json +1 -5
- package/extensions/auto-name.ts +16 -49
- package/extensions/lib/tiny-model.ts +15 -151
- package/extensions/notify/index.ts +9 -59
- package/extensions/output-style/README.md +1 -1
- package/extensions/output-style/index.ts +2 -2
- package/extensions/output-style/output-style.ts +2 -2
- package/extensions/output-style/package.json +0 -3
- package/extensions/session-start/package.json +0 -3
- package/extensions/skill-stats/package.json +0 -3
- package/extensions/subagent/README.md +2 -2
- package/extensions/subagent/agents.ts +17 -1
- package/extensions/subagent/components.test.ts +111 -0
- package/extensions/subagent/components.ts +182 -0
- package/extensions/subagent/frontmatter.test.ts +74 -0
- package/extensions/subagent/frontmatter.ts +105 -0
- package/extensions/subagent/index.ts +14 -12
- package/extensions/subagent/package.json +3 -4
- package/package.json +1 -1
- package/skills/workflow/git-workflow/SKILL.md +29 -27
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,53 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.4.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 0b72348: The extensions now load outside this checkout. `auto-name`, `notify`, `subagent` and `agent-modes`
|
|
8
|
+
value-imported `@earendil-works/pi-*`, which resolved only inside the repository, where pnpm had
|
|
9
|
+
installed each extension's own copy of pi's packages. The staged pi package carried no such
|
|
10
|
+
`node_modules`, so after `agent-cortex install pi` those four extensions failed to load for every
|
|
11
|
+
project outside this checkout — `subagent` included, which is the dispatcher ralph uses.
|
|
12
|
+
|
|
13
|
+
The runtime coupling is gone: frontmatter parsing, pi's user agent directory, the key helper and the
|
|
14
|
+
transcript components are now local, and the extensions declare no peer dependency on pi's packages. Two
|
|
15
|
+
callers lose their model-backed behaviour, since an extension has no completion API of its own:
|
|
16
|
+
`auto-name` names a session from its first prompt truncated (the fallback it already had) and
|
|
17
|
+
`notify` summarises with that same truncation. Subagent output renders as plain text instead of
|
|
18
|
+
markdown.
|
|
19
|
+
|
|
20
|
+
One bare runtime import remains: `typebox`. pi documents it as a host-provided module for
|
|
21
|
+
extensions and aliases it in its loader, so it resolves in the staged package. Everything imported
|
|
22
|
+
from `@earendil-works/*` is `import type`, which is erased. `test/pi-extensions-load.test.mjs`
|
|
23
|
+
stages the package and loads every extension from a temp directory with pi absent. A static scan in
|
|
24
|
+
the same file then rejects any value import from `@earendil-works/*` — including an unused one, which
|
|
25
|
+
tsx elides before the loader sees it.
|
|
26
|
+
|
|
27
|
+
## 2.3.0
|
|
28
|
+
|
|
29
|
+
### Minor Changes
|
|
30
|
+
|
|
31
|
+
- 3dd1e20: Sync a behind branch by rebasing it onto its base branch, not by merging the base in. The
|
|
32
|
+
`git-workflow` skill previously told agents to merge the base in, which dragged every intervening
|
|
33
|
+
commit into the review diff and left a merge commit behind; rule 6 now says fetch, rebase onto
|
|
34
|
+
`origin/main` (or `epic/<id>` for a feature branch inside an epic), and force-push with
|
|
35
|
+
`--force-with-lease`. Rewritten commits are re-signed automatically by `commit.gpgsign`, so no
|
|
36
|
+
`--gpg-sign` and no `rebase.gpgSign` is needed. The rule is scoped to your own unmerged branch —
|
|
37
|
+
never `main`, never a branch someone else is working on — with one stated exception: a shared
|
|
38
|
+
integration branch that other branches are based on is synced by merging the base in, because
|
|
39
|
+
rewriting it would rewrite history under every branch based on it. Landing is unchanged:
|
|
40
|
+
squash-merge still governs how a branch reaches `main`, so the two decisions can no longer be read
|
|
41
|
+
as one rule. `AGENTS.md` splits its collapsed "Landing method" bullet into those two decisions
|
|
42
|
+
accordingly.
|
|
43
|
+
|
|
44
|
+
### Patch Changes
|
|
45
|
+
|
|
46
|
+
- 9944bca: Retire `docs/ideas/` now that ideas are `kind:idea` beads. The four remaining idea files that had
|
|
47
|
+
never been migrated were recorded as beads, and the directory was deleted, so ideas have a single
|
|
48
|
+
home. Live references to the deleted paths now cite the originating bead instead; `agent-cortex
|
|
49
|
+
migrate` step v2 still reads `docs/ideas/` as a migration source, which is unchanged.
|
|
50
|
+
|
|
3
51
|
## 2.2.0
|
|
4
52
|
|
|
5
53
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -201,7 +201,7 @@ The staged package contains:
|
|
|
201
201
|
| `package.json` | pi manifest — `keywords: ["pi-package"]`, `pi.extensions` + `pi.skills`; the version tracks the package |
|
|
202
202
|
| `agents/` | composed flat agents (ralph, plan, ralph-plan, strategy) with `{{TOOL:...}}` / `{{PATH:...}}` substituted — the shipped `agent-modes` extension resolves them from the package root |
|
|
203
203
|
| `skills/` | token-substituted skill copies (real files, never symlinks) |
|
|
204
|
-
| `extensions/` | copy of the repo `extensions/` tree |
|
|
204
|
+
| `extensions/` | copy of the repo `extensions/` tree — imports nothing from pi's runtime except the host-aliased `typebox`, so pi loads it from any directory |
|
|
205
205
|
| `token-map.json` | the token map `agent-modes` reads from the package root |
|
|
206
206
|
| `node_modules/` | the required third-party pi packages and their transitive closure |
|
|
207
207
|
|
package/bin/installers/pi.mjs
CHANGED
|
@@ -405,10 +405,13 @@ async function installSkills({ root, skillsDir, dryRun, pluginRoot, tokenMap, wa
|
|
|
405
405
|
/**
|
|
406
406
|
* Copy the repo's extensions/ tree into the staged package verbatim.
|
|
407
407
|
*
|
|
408
|
-
* The extensions import
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
408
|
+
* The extensions import nothing from pi's runtime — only `import type`, which is
|
|
409
|
+
* erased, and the one module pi documents as host-provided (`typebox`) — so no
|
|
410
|
+
* dependency tree of their own is needed. A value import of `@earendil-works/*`
|
|
411
|
+
* would only resolve where pnpm installed it and would break the staged package
|
|
412
|
+
* everywhere else, which test/pi-extensions-load.test.mjs guards. `node_modules`
|
|
413
|
+
* (pnpm workspace links) and symlinks are never copied: the staged package owns
|
|
414
|
+
* its dependencies and must contain real files only.
|
|
412
415
|
* @param {{ root: string, output: string, dryRun: boolean }} options
|
|
413
416
|
* @returns {Promise<{ files: number, dir: string }>}
|
|
414
417
|
*/
|
|
@@ -23,7 +23,6 @@
|
|
|
23
23
|
import { join, dirname } from "node:path";
|
|
24
24
|
import { fileURLToPath } from "node:url";
|
|
25
25
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
26
|
-
import { Key } from "@earendil-works/pi-tui";
|
|
27
26
|
import { discoverAgents, DEFAULT_TOOLS, type AgentDef } from "./discover.ts";
|
|
28
27
|
import type { ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
29
28
|
|
|
@@ -196,8 +195,10 @@ export default function agentModesExtension(pi: ExtensionAPI): void {
|
|
|
196
195
|
},
|
|
197
196
|
});
|
|
198
197
|
|
|
199
|
-
// Ctrl+Shift+A to cycle agents (A = Agent)
|
|
200
|
-
pi
|
|
198
|
+
// Ctrl+Shift+A to cycle agents (A = Agent). Spelled out rather than built
|
|
199
|
+
// with pi-tui's Key helper: a value import of pi-tui stops the extension
|
|
200
|
+
// loading outside the agent-cortex checkout (test/pi-extensions-load.test.mjs).
|
|
201
|
+
pi.registerShortcut("ctrl+shift+a", {
|
|
201
202
|
description: "Next agent mode",
|
|
202
203
|
handler: async (ctx) => {
|
|
203
204
|
const next = (activeAgentIndex + 1) % (agents.length + 1);
|
|
@@ -9,12 +9,8 @@
|
|
|
9
9
|
},
|
|
10
10
|
"devDependencies": {
|
|
11
11
|
"@earendil-works/pi-coding-agent": "^0.78.1",
|
|
12
|
-
"@earendil-works/pi-tui": "^0.78.1",
|
|
13
12
|
"@types/node": "^22.0.0",
|
|
14
13
|
"tsx": "^4.0.0",
|
|
15
14
|
"typescript": "^5.0.0"
|
|
16
|
-
},
|
|
17
|
-
"peerDependencies": {
|
|
18
|
-
"@earendil-works/pi-coding-agent": "^0.78.1"
|
|
19
15
|
}
|
|
20
|
-
}
|
|
16
|
+
}
|
package/extensions/auto-name.ts
CHANGED
|
@@ -1,30 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Auto-name sessions
|
|
2
|
+
* Auto-name sessions from the first user prompt.
|
|
3
3
|
*
|
|
4
|
-
* On the first
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* On the first prompt of a new session, names the session with a trimmed
|
|
5
|
+
* version of that prompt, then sets it via pi.setSessionName(). Existing names
|
|
6
|
+
* (set via --name or /name) are never overwritten.
|
|
7
|
+
*
|
|
8
|
+
* The name used to come from a cheap side-model, which needed pi-ai's
|
|
9
|
+
* `complete()` — a value import that stopped this extension loading outside the
|
|
10
|
+
* agent-cortex checkout (see test/pi-extensions-load.test.mjs). The truncation
|
|
11
|
+
* that already served as its fallback is now the only path; `tinyModel.enabled`
|
|
12
|
+
* still turns naming off entirely.
|
|
8
13
|
*
|
|
9
14
|
* Config (in settings.json under `tinyModel`):
|
|
10
15
|
* ```json
|
|
11
16
|
* {
|
|
12
17
|
* "tinyModel": {
|
|
13
|
-
* "
|
|
18
|
+
* "enabled": true,
|
|
14
19
|
* "maxNameLength": 50
|
|
15
20
|
* }
|
|
16
21
|
* }
|
|
17
22
|
* ```
|
|
18
|
-
*
|
|
19
|
-
* Existing names (set via --name or /name) are never overwritten.
|
|
20
23
|
*/
|
|
21
24
|
|
|
22
25
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
23
|
-
import {
|
|
24
|
-
loadTinyModelConfig,
|
|
25
|
-
parseModelString,
|
|
26
|
-
generateNameFromPrompt,
|
|
27
|
-
} from "./lib/tiny-model.js";
|
|
26
|
+
import { loadTinyModelConfig } from "./lib/tiny-model.js";
|
|
28
27
|
import type { TinyModelConfig } from "./lib/tiny-model.js";
|
|
29
28
|
|
|
30
29
|
export default function (pi: ExtensionAPI) {
|
|
@@ -41,7 +40,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
41
40
|
}
|
|
42
41
|
});
|
|
43
42
|
|
|
44
|
-
pi.on("input", async (event,
|
|
43
|
+
pi.on("input", async (event, _ctx) => {
|
|
45
44
|
if (!config.enabled || hasNamed) return;
|
|
46
45
|
if (event.source === "rpc") return;
|
|
47
46
|
if (pi.getSessionName()) {
|
|
@@ -51,46 +50,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
51
50
|
if (!event.text?.trim()) return;
|
|
52
51
|
|
|
53
52
|
hasNamed = true;
|
|
54
|
-
|
|
55
|
-
// Fire the LLM call asynchronously — don't block the agent
|
|
56
|
-
(async () => {
|
|
57
|
-
try {
|
|
58
|
-
const { provider, id } = parseModelString(config.model);
|
|
59
|
-
const model = ctx.modelRegistry.find(provider, id);
|
|
60
|
-
if (!model) {
|
|
61
|
-
// Model not found; fall back to truncation
|
|
62
|
-
fallbackName(event.text, config.maxNameLength, pi);
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const { apiKey, headers } =
|
|
67
|
-
await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
68
|
-
|
|
69
|
-
const name = await generateNameFromPrompt(
|
|
70
|
-
model,
|
|
71
|
-
apiKey,
|
|
72
|
-
headers,
|
|
73
|
-
event.text,
|
|
74
|
-
config.maxNameLength,
|
|
75
|
-
);
|
|
76
|
-
|
|
77
|
-
if (name) {
|
|
78
|
-
pi.setSessionName(name);
|
|
79
|
-
} else {
|
|
80
|
-
fallbackName(event.text, config.maxNameLength, pi);
|
|
81
|
-
}
|
|
82
|
-
} catch (err) {
|
|
83
|
-
console.warn("[auto-name] LLM call failed, falling back:", err);
|
|
84
|
-
fallbackName(event.text, config.maxNameLength, pi);
|
|
85
|
-
}
|
|
86
|
-
})();
|
|
53
|
+
nameFromPrompt(event.text, config.maxNameLength, pi);
|
|
87
54
|
});
|
|
88
55
|
}
|
|
89
56
|
|
|
90
57
|
/**
|
|
91
|
-
*
|
|
58
|
+
* Name the session with the first line of the prompt, truncated to `maxLength`.
|
|
92
59
|
*/
|
|
93
|
-
function
|
|
60
|
+
function nameFromPrompt(
|
|
94
61
|
text: string,
|
|
95
62
|
maxLength: number,
|
|
96
63
|
pi: ExtensionAPI,
|
|
@@ -1,43 +1,45 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The `tinyModel` config block shared by the extensions that used to call a
|
|
3
|
+
* cheap side-model: session naming (auto-name) and response summaries (notify).
|
|
3
4
|
*
|
|
4
|
-
* Reads
|
|
5
|
-
* and provides functions for session naming and response summarization using
|
|
6
|
-
* the configured model — typically the tiniest free model available.
|
|
7
|
-
*
|
|
8
|
-
* Config (in ~/.pi/agent/settings.json or .pi/settings.json):
|
|
5
|
+
* Reads the key from settings.json (global + project merged):
|
|
9
6
|
* ```json
|
|
10
7
|
* {
|
|
11
8
|
* "tinyModel": {
|
|
12
|
-
* "
|
|
9
|
+
* "enabled": true,
|
|
13
10
|
* "maxNameLength": 50,
|
|
14
11
|
* "maxSummaryLength": 100
|
|
15
12
|
* }
|
|
16
13
|
* }
|
|
17
14
|
* ```
|
|
15
|
+
*
|
|
16
|
+
* The model call itself is gone. It went through pi-ai's `complete()`, a value
|
|
17
|
+
* import of pi's runtime, and that import stopped the extension loading outside
|
|
18
|
+
* the agent-cortex checkout (see test/pi-extensions-load.test.mjs). The
|
|
19
|
+
* ExtensionAPI promises no completion call. Newer hosts (pi 1.4.0) expose
|
|
20
|
+
* `ModelRegistry.complete`, but the installed host (0.73.1) does not. Calling it
|
|
21
|
+
* would need an untyped cast to a method that exists on one version only.
|
|
22
|
+
* Extensions also make no internal LLM calls (AGENTS.md), so this feature stays
|
|
23
|
+
* off. Both callers fall back to the truncation they already used when the model
|
|
24
|
+
* failed. The `model` key is no longer read and is ignored if present.
|
|
18
25
|
*/
|
|
19
26
|
|
|
20
|
-
import { complete } from "@earendil-works/pi-ai";
|
|
21
27
|
import { homedir } from "node:os";
|
|
22
28
|
import { join } from "node:path";
|
|
23
29
|
import { readFileSync, existsSync } from "node:fs";
|
|
24
|
-
import type { Model, Api } from "@earendil-works/pi-ai";
|
|
25
30
|
|
|
26
31
|
/* ── Types ── */
|
|
27
32
|
|
|
28
33
|
export interface TinyModelConfig {
|
|
29
|
-
/** Provider/id string, e.g. "opencode/mimo-v2.5-free" */
|
|
30
|
-
model: string;
|
|
31
34
|
/** Global on/off toggle */
|
|
32
35
|
enabled: boolean;
|
|
33
|
-
/** Max characters for
|
|
36
|
+
/** Max characters for generated session names */
|
|
34
37
|
maxNameLength: number;
|
|
35
38
|
/** Max characters for response summaries in notifications */
|
|
36
39
|
maxSummaryLength: number;
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
const DEFAULT_CONFIG: TinyModelConfig = {
|
|
40
|
-
model: "opencode/mimo-v2.5-free",
|
|
41
43
|
enabled: true,
|
|
42
44
|
maxNameLength: 40,
|
|
43
45
|
maxSummaryLength: 100,
|
|
@@ -72,141 +74,3 @@ export function loadTinyModelConfig(cwd?: string): TinyModelConfig {
|
|
|
72
74
|
|
|
73
75
|
return { ...DEFAULT_CONFIG, ...merged } as TinyModelConfig;
|
|
74
76
|
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Parse a "provider/id" string into its parts.
|
|
78
|
-
*/
|
|
79
|
-
export function parseModelString(modelStr: string): {
|
|
80
|
-
provider: string;
|
|
81
|
-
id: string;
|
|
82
|
-
} {
|
|
83
|
-
const slash = modelStr.indexOf("/");
|
|
84
|
-
if (slash === -1) return { provider: "opencode", id: modelStr };
|
|
85
|
-
return {
|
|
86
|
-
provider: modelStr.slice(0, slash),
|
|
87
|
-
id: modelStr.slice(slash + 1),
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/* ── LLM helpers ── */
|
|
92
|
-
|
|
93
|
-
type ModelAny = Model<Api>;
|
|
94
|
-
type CompleteOptions = Record<string, any>;
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Generate a concise session title from the user's first prompt.
|
|
98
|
-
*
|
|
99
|
-
* Calls the configured tiny model with a system prompt tuned for naming.
|
|
100
|
-
* Returns null on failure (caller should fall back to truncation).
|
|
101
|
-
*/
|
|
102
|
-
export async function generateNameFromPrompt(
|
|
103
|
-
model: ModelAny,
|
|
104
|
-
apiKey: string | undefined,
|
|
105
|
-
headers: Record<string, string> | undefined,
|
|
106
|
-
prompt: string,
|
|
107
|
-
maxLength: number,
|
|
108
|
-
): Promise<string | null> {
|
|
109
|
-
const systemPrompt = [
|
|
110
|
-
"You are a session naming assistant. Generate a concise title for a coding session.",
|
|
111
|
-
"",
|
|
112
|
-
"Rules:",
|
|
113
|
-
"- Output ONLY the title, no quotes, no prefix, no explanation",
|
|
114
|
-
`- Maximum ${maxLength} characters`,
|
|
115
|
-
"- Summarize intent, do not copy verbatim",
|
|
116
|
-
"- Keep it short (3\u20135 words), capture the general theme",
|
|
117
|
-
"- If the message mentions files or modules, keep the key names brief",
|
|
118
|
-
].join("\n");
|
|
119
|
-
|
|
120
|
-
const truncated = prompt.length > 2000 ? prompt.slice(0, 2000) + "..." : prompt;
|
|
121
|
-
|
|
122
|
-
try {
|
|
123
|
-
const options: CompleteOptions = { maxTokens: 64 };
|
|
124
|
-
if (apiKey) options.apiKey = apiKey;
|
|
125
|
-
if (headers) options.headers = headers;
|
|
126
|
-
|
|
127
|
-
const result = await complete(model, {
|
|
128
|
-
systemPrompt,
|
|
129
|
-
messages: [{ role: "user", content: truncated, timestamp: Date.now() }],
|
|
130
|
-
}, options);
|
|
131
|
-
|
|
132
|
-
const text = result.content
|
|
133
|
-
?.filter((b: any): b is { type: "text"; text: string } => b.type === "text")
|
|
134
|
-
?.map((b) => b.text)
|
|
135
|
-
?.join("")
|
|
136
|
-
?.trim() ?? "";
|
|
137
|
-
|
|
138
|
-
return cleanTitle(text, maxLength);
|
|
139
|
-
} catch {
|
|
140
|
-
return null;
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Summarize an assistant response into a one-line summary.
|
|
146
|
-
*
|
|
147
|
-
* Calls the configured tiny model. Returns null on failure.
|
|
148
|
-
*/
|
|
149
|
-
export async function summarizeResponseText(
|
|
150
|
-
model: ModelAny,
|
|
151
|
-
apiKey: string | undefined,
|
|
152
|
-
headers: Record<string, string> | undefined,
|
|
153
|
-
responseText: string,
|
|
154
|
-
maxLength: number,
|
|
155
|
-
): Promise<string | null> {
|
|
156
|
-
const systemPrompt = [
|
|
157
|
-
"Summarize what the AI assistant did in this coding session response.",
|
|
158
|
-
"Be concise — one short sentence or phrase.",
|
|
159
|
-
`Maximum ${maxLength} characters.`,
|
|
160
|
-
"Focus on what changed or was accomplished.",
|
|
161
|
-
"Output ONLY the summary, no quotes, no prefix, no explanation.",
|
|
162
|
-
].join("\n");
|
|
163
|
-
|
|
164
|
-
const truncated =
|
|
165
|
-
responseText.length > 2000 ? responseText.slice(0, 2000) + "..." : responseText;
|
|
166
|
-
|
|
167
|
-
try {
|
|
168
|
-
const options: CompleteOptions = { maxTokens: 64 };
|
|
169
|
-
if (apiKey) options.apiKey = apiKey;
|
|
170
|
-
if (headers) options.headers = headers;
|
|
171
|
-
|
|
172
|
-
const result = await complete(model, {
|
|
173
|
-
systemPrompt,
|
|
174
|
-
messages: [{ role: "user", content: truncated, timestamp: Date.now() }],
|
|
175
|
-
}, options);
|
|
176
|
-
|
|
177
|
-
const text = result.content
|
|
178
|
-
?.filter((b: any): b is { type: "text"; text: string } => b.type === "text")
|
|
179
|
-
?.map((b) => b.text)
|
|
180
|
-
?.join("")
|
|
181
|
-
?.trim() ?? "";
|
|
182
|
-
|
|
183
|
-
return text || null;
|
|
184
|
-
} catch {
|
|
185
|
-
return null;
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/* ── Internal helpers ── */
|
|
190
|
-
|
|
191
|
-
function cleanTitle(raw: string, maxLength: number): string | null {
|
|
192
|
-
let name = raw.trim();
|
|
193
|
-
if (!name) return null;
|
|
194
|
-
|
|
195
|
-
// Strip surrounding quotes if present
|
|
196
|
-
if (
|
|
197
|
-
(name.startsWith('"') && name.endsWith('"')) ||
|
|
198
|
-
(name.startsWith("'") && name.endsWith("'"))
|
|
199
|
-
) {
|
|
200
|
-
name = name.slice(1, -1);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// Collapse newlines
|
|
204
|
-
name = name.replace(/\n/g, " ").trim();
|
|
205
|
-
|
|
206
|
-
// Truncate
|
|
207
|
-
if (name.length > maxLength) {
|
|
208
|
-
name = name.slice(0, maxLength - 3) + "...";
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
return name || null;
|
|
212
|
-
}
|
|
@@ -5,13 +5,16 @@
|
|
|
5
5
|
* multiline notification showing:
|
|
6
6
|
* Line 1: working directory path
|
|
7
7
|
* Line 2: session name (if set)
|
|
8
|
-
* Line 3:
|
|
8
|
+
* Line 3: truncated summary of what the agent did
|
|
9
9
|
*
|
|
10
10
|
* The notification title is the tmux session:window.pane identifier (when
|
|
11
11
|
* available) or "pi".
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
13
|
+
* The summary used to come from a cheap side-model, which needed pi-ai's
|
|
14
|
+
* `complete()` — a value import that stopped this extension loading outside the
|
|
15
|
+
* agent-cortex checkout (see test/pi-extensions-load.test.mjs). The truncation
|
|
16
|
+
* that already served as its fallback is now the only path; `maxSummaryLength`
|
|
17
|
+
* still sets its length.
|
|
15
18
|
*/
|
|
16
19
|
|
|
17
20
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
@@ -19,11 +22,7 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
|
19
22
|
import { execSync } from "node:child_process";
|
|
20
23
|
import { homedir } from "node:os";
|
|
21
24
|
|
|
22
|
-
import {
|
|
23
|
-
loadTinyModelConfig,
|
|
24
|
-
parseModelString,
|
|
25
|
-
summarizeResponseText,
|
|
26
|
-
} from "../lib/tiny-model.js";
|
|
25
|
+
import { loadTinyModelConfig } from "../lib/tiny-model.js";
|
|
27
26
|
import type { TinyModelConfig } from "../lib/tiny-model.js";
|
|
28
27
|
|
|
29
28
|
/* ── Context helpers ── */
|
|
@@ -215,20 +214,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
215
214
|
const sessionName = pi.getSessionName();
|
|
216
215
|
if (sessionName) sections.push(sessionName);
|
|
217
216
|
|
|
218
|
-
// Section 2:
|
|
217
|
+
// Section 2: truncated summary of the assistant's response
|
|
219
218
|
const responseText = extractAssistantText(event.messages);
|
|
220
219
|
if (responseText) {
|
|
221
|
-
|
|
222
|
-
const summary = await summarizeResponseWithFallback(
|
|
223
|
-
ctx,
|
|
224
|
-
responseText,
|
|
225
|
-
config,
|
|
226
|
-
);
|
|
227
|
-
sections.push(summary);
|
|
228
|
-
} catch {
|
|
229
|
-
// If summary fails (e.g. config not loaded), just show raw text
|
|
230
|
-
sections.push(responseText.slice(0, config?.maxSummaryLength ?? 100).replace(/\s+/g, ' ').trim());
|
|
231
|
-
}
|
|
220
|
+
sections.push(truncateToOneLine(responseText, config?.maxSummaryLength ?? 100));
|
|
232
221
|
}
|
|
233
222
|
|
|
234
223
|
// Desktop notification
|
|
@@ -239,45 +228,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
239
228
|
});
|
|
240
229
|
}
|
|
241
230
|
|
|
242
|
-
/**
|
|
243
|
-
* Summarize response text using the configured tiny model, falling back
|
|
244
|
-
* to simple truncation if the LLM call fails or is disabled.
|
|
245
|
-
*/
|
|
246
|
-
async function summarizeResponseWithFallback(
|
|
247
|
-
ctx: any,
|
|
248
|
-
responseText: string,
|
|
249
|
-
config: TinyModelConfig,
|
|
250
|
-
): Promise<string> {
|
|
251
|
-
if (!config.enabled) {
|
|
252
|
-
return truncateToOneLine(responseText, config.maxSummaryLength);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
try {
|
|
256
|
-
const { provider, id } = parseModelString(config.model);
|
|
257
|
-
const model = ctx.modelRegistry.find(provider, id);
|
|
258
|
-
if (!model) {
|
|
259
|
-
return truncateToOneLine(responseText, config.maxSummaryLength);
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const { apiKey, headers } =
|
|
263
|
-
await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
264
|
-
|
|
265
|
-
const summary = await summarizeResponseText(
|
|
266
|
-
model,
|
|
267
|
-
apiKey,
|
|
268
|
-
headers,
|
|
269
|
-
responseText,
|
|
270
|
-
config.maxSummaryLength,
|
|
271
|
-
);
|
|
272
|
-
|
|
273
|
-
if (summary) return summary;
|
|
274
|
-
} catch (err) {
|
|
275
|
-
console.warn("[notify] LLM summary failed, falling back:", err);
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
return truncateToOneLine(responseText, config.maxSummaryLength);
|
|
279
|
-
}
|
|
280
|
-
|
|
281
231
|
/**
|
|
282
232
|
* Truncate text to roughly `maxLen` characters on a single line,
|
|
283
233
|
* breaking at a word boundary.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Appends the always-on reply shape to the PI system prompt once per user prompt.
|
|
4
4
|
|
|
5
|
-
This is the second slice of `
|
|
5
|
+
This is the second slice of the "Improve PI System Prompt" idea (bead `agnt-ctx-s5hn`): the first
|
|
6
6
|
(`agent-guardrails`) stops wasted work; this one stops the answer getting buried in
|
|
7
7
|
it. The extension injects a stable block via the `before_agent_start` hook — which
|
|
8
8
|
fires once per user prompt, before the agent loop — so the rules apply to every
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Appends the always-on reply shape to the PI system prompt on every turn: lead
|
|
5
5
|
* with the action, write in Simplified Technical English (ASD-STE100), restate the
|
|
6
|
-
* context the reader cannot remember (the second slice of
|
|
7
|
-
*
|
|
6
|
+
* context the reader cannot remember (the second slice of the "Improve PI
|
|
7
|
+
* System Prompt" idea, bead `agnt-ctx-s5hn`).
|
|
8
8
|
*
|
|
9
9
|
* The block is stable text appended after any chained system-prompt changes, so it
|
|
10
10
|
* keeps the prompt-cache prefix intact while remaining authoritative for the model.
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Output style for the PI system prompt.
|
|
3
3
|
*
|
|
4
|
-
* The second slice of
|
|
5
|
-
* the task discipline. Three concerns, in order — lead with the action, write in
|
|
4
|
+
* The second slice of the "Improve PI System Prompt" idea (bead `agnt-ctx-s5hn`): the reply
|
|
5
|
+
* shape, not the task discipline. Three concerns, in order — lead with the action, write in
|
|
6
6
|
* Simplified Technical English, restate the context the reader cannot remember.
|
|
7
7
|
*
|
|
8
8
|
* The prose lives in output-style.md so one file feeds both harnesses: this
|
|
@@ -7,7 +7,7 @@ Delegate tasks to specialized subagents with isolated context windows.
|
|
|
7
7
|
- **Isolated context**: Each subagent runs in a separate `pi` process
|
|
8
8
|
- **Streaming output**: See tool calls and progress as they happen
|
|
9
9
|
- **Parallel streaming**: All parallel tasks stream updates simultaneously
|
|
10
|
-
- **
|
|
10
|
+
- **Plain-text output**: Final output wrapped to the terminal width (expanded view)
|
|
11
11
|
- **Usage tracking**: Shows turns, tokens, cost, and context usage per agent
|
|
12
12
|
- **Abort support**: Ctrl+C propagates to kill subagent processes
|
|
13
13
|
|
|
@@ -125,7 +125,7 @@ pending `wait_for_agents` returns immediately with whatever is known so far.
|
|
|
125
125
|
**Expanded view** (Ctrl+O):
|
|
126
126
|
- Full task text
|
|
127
127
|
- All tool calls with formatted arguments
|
|
128
|
-
- Final output rendered as
|
|
128
|
+
- Final output rendered as width-wrapped plain text
|
|
129
129
|
- Per-task usage (for chain/parallel)
|
|
130
130
|
|
|
131
131
|
**Parallel mode streaming**:
|
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import * as fs from "node:fs";
|
|
6
|
+
import * as os from "node:os";
|
|
6
7
|
import * as path from "node:path";
|
|
7
8
|
import { fileURLToPath } from "node:url";
|
|
8
|
-
import {
|
|
9
|
+
import { parseFrontmatter } from "./frontmatter.ts";
|
|
9
10
|
|
|
10
11
|
export type AgentScope = "user" | "project" | "both";
|
|
11
12
|
|
|
@@ -145,6 +146,21 @@ function isRenderedAgentsTree(agentsDir: string): boolean {
|
|
|
145
146
|
return !entries.some((entry) => entry.isDirectory() && fs.existsSync(path.join(agentsDir, entry.name, "agent.md")));
|
|
146
147
|
}
|
|
147
148
|
|
|
149
|
+
/**
|
|
150
|
+
* pi's user config directory, which holds pi's own `agents/` directory.
|
|
151
|
+
*
|
|
152
|
+
* Read here rather than through `getAgentDir()` from
|
|
153
|
+
* `@earendil-works/pi-coding-agent`: a value import of pi's runtime makes this
|
|
154
|
+
* extension fail to load outside the agent-cortex checkout
|
|
155
|
+
* (test/pi-extensions-load.test.mjs). Same contract — `PI_CODING_AGENT_DIR`
|
|
156
|
+
* wins when set, otherwise `~/.pi/agent`.
|
|
157
|
+
*/
|
|
158
|
+
function getAgentDir(): string {
|
|
159
|
+
const override = process.env.PI_CODING_AGENT_DIR;
|
|
160
|
+
if (override) return path.resolve(override.replace(/^~(?=$|[\\/])/, os.homedir()));
|
|
161
|
+
return path.join(os.homedir(), ".pi", "agent");
|
|
162
|
+
}
|
|
163
|
+
|
|
148
164
|
/**
|
|
149
165
|
* Directories the "user" scope discovers agents from, in precedence order.
|
|
150
166
|
*
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The local transcript components pi's renderers draw for the subagent tool.
|
|
3
|
+
*
|
|
4
|
+
* pi-tui's own components are unreachable from an installed extension, so these
|
|
5
|
+
* implement pi's `Component` contract directly. The contract that matters is the
|
|
6
|
+
* width: pi-tui reports a rendered line wider than the terminal as an error, so
|
|
7
|
+
* every line these return must fit the width they are given, with SGR colour
|
|
8
|
+
* sequences still intact.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it } from "node:test";
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
|
|
14
|
+
import { Container, Spacer, Text } from "./components.ts";
|
|
15
|
+
|
|
16
|
+
const RESET = "\x1b[0m";
|
|
17
|
+
const GREEN = "\x1b[32m";
|
|
18
|
+
|
|
19
|
+
/** Visible column count, ignoring SGR sequences. */
|
|
20
|
+
function visibleWidth(line: string): number {
|
|
21
|
+
return line.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Visible terminal-cell count, ignoring SGR sequences and counting a wide or
|
|
26
|
+
* astral character as two cells. `String.length` counts a wide character as one
|
|
27
|
+
* unit, so a width assertion built on it misses the overflow this guards.
|
|
28
|
+
*/
|
|
29
|
+
function cellWidth(line: string): number {
|
|
30
|
+
return [...line.replace(/\x1b\[[0-9;]*m/g, "")].reduce((cells, character) => {
|
|
31
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
32
|
+
const wide =
|
|
33
|
+
codePoint > 0xffff ||
|
|
34
|
+
(codePoint >= 0x1100 &&
|
|
35
|
+
(codePoint <= 0x115f ||
|
|
36
|
+
(codePoint >= 0x2e80 && codePoint <= 0xa4cf) ||
|
|
37
|
+
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
|
|
38
|
+
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
|
|
39
|
+
(codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
|
|
40
|
+
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
|
|
41
|
+
(codePoint >= 0xffe0 && codePoint <= 0xffe6)));
|
|
42
|
+
return cells + (wide ? 2 : 1);
|
|
43
|
+
}, 0);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("Text", () => {
|
|
47
|
+
it("wraps a long line to the width it is given", () => {
|
|
48
|
+
const lines = new Text("alpha beta gamma delta").render(11);
|
|
49
|
+
|
|
50
|
+
assert.deepEqual(lines, ["alpha beta", "gamma delta"]);
|
|
51
|
+
assert.ok(lines.every((line) => visibleWidth(line) <= 11));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("hard-wraps a word longer than the width instead of overflowing", () => {
|
|
55
|
+
const lines = new Text("abcdefghij").render(4);
|
|
56
|
+
|
|
57
|
+
assert.deepEqual(lines, ["abcd", "efgh", "ij"]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("keeps a colour sequence intact and carries it onto the wrapped line", () => {
|
|
61
|
+
const lines = new Text(`${GREEN}alpha beta gamma${RESET}`).render(11);
|
|
62
|
+
|
|
63
|
+
assert.equal(lines.length, 2);
|
|
64
|
+
for (const line of lines) assert.ok(visibleWidth(line) <= 11, `width of ${JSON.stringify(line)}`);
|
|
65
|
+
assert.ok(lines[0].endsWith(RESET), "a line cut inside a colour span closes it");
|
|
66
|
+
assert.ok(lines[1].startsWith(GREEN), "the continuation reopens the colour");
|
|
67
|
+
assert.equal(lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "")).join(" "), "alpha beta gamma");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("pads and separates lines with the padding it is given", () => {
|
|
71
|
+
const lines = new Text("body", 1, 1).render(10);
|
|
72
|
+
|
|
73
|
+
assert.deepEqual(lines, [" ", " body ", " "]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("keeps an empty line and renders blank text as one empty line", () => {
|
|
77
|
+
assert.deepEqual(new Text("a\n\nb").render(10), ["a", "", "b"]);
|
|
78
|
+
assert.deepEqual(new Text().render(10), [""]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("counts a wide character as two cells so it cannot overflow the width", () => {
|
|
82
|
+
// Each of these characters occupies two terminal cells but one string unit.
|
|
83
|
+
assert.deepEqual(new Text("日本語").render(4), ["日本", "語"]);
|
|
84
|
+
|
|
85
|
+
const lines = new Text("日本語のテキストは全角文字です。これは折り返しのテストです。").render(20);
|
|
86
|
+
assert.ok(lines.length > 1, "wide text must wrap");
|
|
87
|
+
for (const line of lines) assert.ok(cellWidth(line) <= 20, `width of ${JSON.stringify(line)}`);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("counts an astral character as two cells", () => {
|
|
91
|
+
assert.deepEqual(new Text("🚀🚀🚀").render(4), ["🚀🚀", "🚀"]);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("Spacer", () => {
|
|
96
|
+
it("renders the blank lines it was asked for", () => {
|
|
97
|
+
assert.deepEqual(new Spacer(2).render(10), ["", ""]);
|
|
98
|
+
assert.deepEqual(new Spacer().render(10), [""]);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("Container", () => {
|
|
103
|
+
it("draws its children in order, each at the given width", () => {
|
|
104
|
+
const container = new Container();
|
|
105
|
+
container.addChild(new Text("alpha beta"));
|
|
106
|
+
container.addChild(new Spacer(1));
|
|
107
|
+
container.addChild(new Text("gamma"));
|
|
108
|
+
|
|
109
|
+
assert.deepEqual(container.render(5), ["alpha", "beta", "", "gamma"]);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcript components for the subagent tool's renderers.
|
|
3
|
+
*
|
|
4
|
+
* pi-tui's `Text`, `Container` and `Spacer` are not reachable from an installed
|
|
5
|
+
* extension: a value import of `@earendil-works/pi-tui` makes the extension fail
|
|
6
|
+
* to load outside the agent-cortex checkout (test/pi-extensions-load.test.mjs).
|
|
7
|
+
* These are the local equivalents, implementing the `Component` contract pi's
|
|
8
|
+
* transcript renderer calls: `render(width)` returns lines that fit `width`, and
|
|
9
|
+
* `invalidate()` drops cached layout.
|
|
10
|
+
*
|
|
11
|
+
* Two deliberate limits, because reimplementing pi-tui is not this change:
|
|
12
|
+
* markdown is drawn as plain text, and only SGR colour sequences (`ESC[…m`) are
|
|
13
|
+
* measured and carried across a wrapped line. Other escapes — OSC-8 hyperlinks,
|
|
14
|
+
* images — count as visible characters, the same way a naive strip does.
|
|
15
|
+
* Width is measured in terminal cells: a wide (East Asian) character, and any
|
|
16
|
+
* astral character such as an emoji, counts as two.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Lines to draw at a given width — what pi's transcript renderer calls. */
|
|
20
|
+
export interface Component {
|
|
21
|
+
render(width: number): string[];
|
|
22
|
+
invalidate(): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const SGR = /\x1b\[[0-9;]*m/g;
|
|
26
|
+
const SGR_AT = /\x1b\[[0-9;]*m/y;
|
|
27
|
+
const RESET = "\x1b[0m";
|
|
28
|
+
|
|
29
|
+
/** A block of text, word-wrapped to the width it is drawn at. */
|
|
30
|
+
export class Text implements Component {
|
|
31
|
+
readonly #text: string;
|
|
32
|
+
readonly #paddingX: number;
|
|
33
|
+
readonly #paddingY: number;
|
|
34
|
+
|
|
35
|
+
constructor(text = "", paddingX = 0, paddingY = 0) {
|
|
36
|
+
this.#text = text;
|
|
37
|
+
this.#paddingX = paddingX;
|
|
38
|
+
this.#paddingY = paddingY;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
render(width: number): string[] {
|
|
42
|
+
const available = Math.max(1, width - this.#paddingX * 2);
|
|
43
|
+
const indent = " ".repeat(this.#paddingX);
|
|
44
|
+
const blank = indent.repeat(2);
|
|
45
|
+
const body = this.#text.split("\n").flatMap((line) => wrapLine(line, available));
|
|
46
|
+
|
|
47
|
+
return [
|
|
48
|
+
...emptyLines(this.#paddingY, blank),
|
|
49
|
+
...body.map((line) => `${indent}${line}${indent}`),
|
|
50
|
+
...emptyLines(this.#paddingY, blank),
|
|
51
|
+
];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
invalidate(): void {}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Blank lines. */
|
|
58
|
+
export class Spacer implements Component {
|
|
59
|
+
readonly #lines: number;
|
|
60
|
+
|
|
61
|
+
constructor(lines = 1) {
|
|
62
|
+
this.#lines = lines;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
render(_width: number): string[] {
|
|
66
|
+
return emptyLines(this.#lines, "");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
invalidate(): void {}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A vertical stack of components. */
|
|
73
|
+
export class Container implements Component {
|
|
74
|
+
readonly #children: Component[] = [];
|
|
75
|
+
|
|
76
|
+
addChild(child: Component): void {
|
|
77
|
+
this.#children.push(child);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
render(width: number): string[] {
|
|
81
|
+
return this.#children.flatMap((child) => child.render(width));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
invalidate(): void {
|
|
85
|
+
for (const child of this.#children) child.invalidate();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** `count` blank lines, each filled with `filler` (padding keeps the width). */
|
|
90
|
+
function emptyLines(count: number, filler: string): string[] {
|
|
91
|
+
return Array.from({ length: Math.max(0, count) }, () => filler);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Visible cell count of a line: everything except SGR sequences, wide characters as two. */
|
|
95
|
+
function visibleWidth(line: string): number {
|
|
96
|
+
let cells = 0;
|
|
97
|
+
for (const character of line.replace(SGR, "")) cells += cellWidth(character.codePointAt(0) ?? 0);
|
|
98
|
+
return cells;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Terminal cells one character occupies. Wide East Asian characters and astral
|
|
103
|
+
* characters (emoji included) occupy two; every other character occupies one.
|
|
104
|
+
* Counting in `String.length` units would let a wide character overflow: it is
|
|
105
|
+
* one unit but two cells.
|
|
106
|
+
*/
|
|
107
|
+
function cellWidth(codePoint: number): number {
|
|
108
|
+
if (codePoint > 0xffff) return 2;
|
|
109
|
+
return codePoint >= 0x1100 &&
|
|
110
|
+
(codePoint <= 0x115f ||
|
|
111
|
+
(codePoint >= 0x2e80 && codePoint <= 0xa4cf) ||
|
|
112
|
+
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
|
|
113
|
+
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
|
|
114
|
+
(codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
|
|
115
|
+
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
|
|
116
|
+
(codePoint >= 0xffe0 && codePoint <= 0xffe6))
|
|
117
|
+
? 2
|
|
118
|
+
: 1;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Wrap one line to `width` visible columns without cutting an SGR sequence. A
|
|
123
|
+
* cut inside a coloured span closes the line and reopens the same colour on the
|
|
124
|
+
* continuation, so broken escapes never reach the terminal.
|
|
125
|
+
*/
|
|
126
|
+
function wrapLine(line: string, width: number): string[] {
|
|
127
|
+
const lines: string[] = [];
|
|
128
|
+
let rest = line;
|
|
129
|
+
|
|
130
|
+
while (visibleWidth(rest) > width) {
|
|
131
|
+
const cut = breakIndex(rest, width);
|
|
132
|
+
if (cut <= 0) break;
|
|
133
|
+
|
|
134
|
+
const head = rest.slice(0, cut);
|
|
135
|
+
const colour = lastColour(head);
|
|
136
|
+
lines.push(colour === "" ? head : `${head}${RESET}`);
|
|
137
|
+
// A break taken at a space consumes it, so the continuation starts with its word.
|
|
138
|
+
rest = `${colour}${rest.slice(rest[cut] === " " ? cut + 1 : cut)}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
lines.push(rest);
|
|
142
|
+
return lines;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Index at which to cut `text` so its visible width stays within `width`:
|
|
147
|
+
* the last space at or before the limit, or the limit itself when the line has
|
|
148
|
+
* no usable space.
|
|
149
|
+
*/
|
|
150
|
+
function breakIndex(text: string, width: number): number {
|
|
151
|
+
let visible = 0;
|
|
152
|
+
let lastSpace = -1;
|
|
153
|
+
let index = 0;
|
|
154
|
+
|
|
155
|
+
while (index < text.length) {
|
|
156
|
+
SGR_AT.lastIndex = index;
|
|
157
|
+
const escape = SGR_AT.exec(text);
|
|
158
|
+
if (escape !== null) {
|
|
159
|
+
index += escape[0].length;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const codePoint = text.codePointAt(index) ?? 0;
|
|
164
|
+
const character = String.fromCodePoint(codePoint);
|
|
165
|
+
if (character === " ") lastSpace = index;
|
|
166
|
+
if (visible + cellWidth(codePoint) > width) return lastSpace > 0 ? lastSpace : index;
|
|
167
|
+
|
|
168
|
+
visible += cellWidth(codePoint);
|
|
169
|
+
index += character.length;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return lastSpace > 0 ? lastSpace : index;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The colour sequence left open at the end of `text`, or "" when none is. */
|
|
176
|
+
function lastColour(text: string): string {
|
|
177
|
+
let colour = "";
|
|
178
|
+
for (const [sequence] of text.matchAll(SGR)) {
|
|
179
|
+
colour = sequence === RESET || sequence === "\x1b[m" ? "" : sequence;
|
|
180
|
+
}
|
|
181
|
+
return colour;
|
|
182
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The local frontmatter reader for agent files.
|
|
3
|
+
*
|
|
4
|
+
* pi's `parseFrontmatter` is unreachable from an installed extension, so this
|
|
5
|
+
* parser reads the subset agent files use. Agents are user-authored files at a
|
|
6
|
+
* system edge: a field this parser mishandles silently changes an agent's name,
|
|
7
|
+
* description or tool list, and an exception here would break discovery of every
|
|
8
|
+
* other agent, so both the parsing and the tolerance are covered.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it } from "node:test";
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
|
|
14
|
+
import { parseFrontmatter } from "./frontmatter.ts";
|
|
15
|
+
|
|
16
|
+
describe("parseFrontmatter", () => {
|
|
17
|
+
it("splits fields from the body and trims the body", () => {
|
|
18
|
+
const { frontmatter, body } = parseFrontmatter(`---\nname: ralph\ndescription: d\n---\n\n# ralph\n`);
|
|
19
|
+
|
|
20
|
+
assert.deepEqual(frontmatter, { name: "ralph", description: "d" });
|
|
21
|
+
assert.equal(body, "# ralph");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("reads a quoted scalar, unescaping it", () => {
|
|
25
|
+
const { frontmatter } = parseFrontmatter(`---\nname: "a: b\\"c\\n"\n---\n`);
|
|
26
|
+
|
|
27
|
+
assert.equal(frontmatter.name, 'a: b"c\n');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("reads a flow sequence of quoted items", () => {
|
|
31
|
+
const { frontmatter } = parseFrontmatter(`---\ntools: ["bash", "read"]\n---\n`);
|
|
32
|
+
|
|
33
|
+
assert.deepEqual(frontmatter.tools, ["bash", "read"]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("reads a block sequence", () => {
|
|
37
|
+
const { frontmatter } = parseFrontmatter(`---\ntools:\n - bash\n - read\n---\n`);
|
|
38
|
+
|
|
39
|
+
assert.deepEqual(frontmatter.tools, ["bash", "read"]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("keeps a comma-separated tool list as one scalar", () => {
|
|
43
|
+
const { frontmatter } = parseFrontmatter(`---\ntools: "bash, read"\n---\n`);
|
|
44
|
+
|
|
45
|
+
assert.equal(frontmatter.tools, "bash, read");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("separates fields from a following block sequence", () => {
|
|
49
|
+
const { frontmatter } = parseFrontmatter(`---\nname: ralph\ntools:\n - bash\nmodel: fast\n---\n`);
|
|
50
|
+
|
|
51
|
+
assert.deepEqual(frontmatter, { name: "ralph", tools: ["bash"], model: "fast" });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("normalises CRLF line endings", () => {
|
|
55
|
+
const { frontmatter, body } = parseFrontmatter(`---\r\nname: ralph\r\n---\r\n\r\nbody\r\n`);
|
|
56
|
+
|
|
57
|
+
assert.deepEqual(frontmatter, { name: "ralph" });
|
|
58
|
+
assert.equal(body, "body");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("returns the whole document as body when there is no frontmatter", () => {
|
|
62
|
+
const { frontmatter, body } = parseFrontmatter("# just markdown\n");
|
|
63
|
+
|
|
64
|
+
assert.deepEqual(frontmatter, {});
|
|
65
|
+
assert.equal(body, "# just markdown\n");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("treats an unclosed block as body rather than throwing", () => {
|
|
69
|
+
const { frontmatter, body } = parseFrontmatter(`---\nname: ralph\n`);
|
|
70
|
+
|
|
71
|
+
assert.deepEqual(frontmatter, {});
|
|
72
|
+
assert.equal(body, "---\nname: ralph\n");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read the YAML frontmatter block of a markdown document.
|
|
3
|
+
*
|
|
4
|
+
* pi's own `parseFrontmatter` is not reachable from an installed extension: a
|
|
5
|
+
* value import of `@earendil-works/pi-coding-agent` makes the extension fail to
|
|
6
|
+
* load outside the agent-cortex checkout (test/pi-extensions-load.test.mjs).
|
|
7
|
+
* This module is the subset agent files use — top-level `key: value` pairs,
|
|
8
|
+
* plain and quoted scalars, flow sequences (`[a, b]`) and block sequences
|
|
9
|
+
* (`- a`).
|
|
10
|
+
*
|
|
11
|
+
* Deliberately outside the subset: nested maps and block scalars (`|`, `>`) keep
|
|
12
|
+
* their literal text as the value. No construct throws — an unreadable block
|
|
13
|
+
* yields empty frontmatter — so one malformed agent file cannot break discovery
|
|
14
|
+
* of the others.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** A parsed document: its frontmatter fields and the markdown body. */
|
|
18
|
+
export interface ParsedFrontmatter<T extends Record<string, unknown>> {
|
|
19
|
+
frontmatter: T;
|
|
20
|
+
body: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const FENCE = "---";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Split a markdown document into frontmatter fields and body, matching pi's
|
|
27
|
+
* contract: the document must open with `---` and close the block with a line
|
|
28
|
+
* that starts with `---`; the body is trimmed.
|
|
29
|
+
*/
|
|
30
|
+
export function parseFrontmatter<T extends Record<string, unknown>>(content: string): ParsedFrontmatter<T> {
|
|
31
|
+
const normalized = content.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
|
|
32
|
+
const end = normalized.startsWith(FENCE) ? normalized.indexOf(`\n${FENCE}`, FENCE.length) : -1;
|
|
33
|
+
if (end === -1) return { frontmatter: {} as T, body: normalized };
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
frontmatter: parseBlock(normalized.slice(FENCE.length + 1, end)) as T,
|
|
37
|
+
body: normalized.slice(end + FENCE.length + 1).trim(),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Parse the `key: value` lines of a frontmatter block. */
|
|
42
|
+
function parseBlock(block: string): Record<string, unknown> {
|
|
43
|
+
const fields: Record<string, unknown> = {};
|
|
44
|
+
const lines = block.split("\n");
|
|
45
|
+
|
|
46
|
+
for (let index = 0; index < lines.length; index++) {
|
|
47
|
+
const match = /^([\w.-]+):(?:[ \t]+(.*))?$/.exec(lines[index]);
|
|
48
|
+
if (match === null) continue;
|
|
49
|
+
|
|
50
|
+
const [, key, inlineValue] = match;
|
|
51
|
+
if (inlineValue !== undefined && inlineValue.trim() !== "") {
|
|
52
|
+
fields[key] = parseValue(inlineValue.trim());
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const { items, consumed } = readBlockSequence(lines, index + 1);
|
|
57
|
+
index += consumed;
|
|
58
|
+
fields[key] = items.length > 0 ? items : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return fields;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Read the indented `- item` lines that follow a `key:` line with no inline
|
|
66
|
+
* value. Returns the items and how many lines were consumed.
|
|
67
|
+
*/
|
|
68
|
+
function readBlockSequence(lines: string[], start: number): { items: string[]; consumed: number } {
|
|
69
|
+
const items: string[] = [];
|
|
70
|
+
let consumed = 0;
|
|
71
|
+
|
|
72
|
+
for (let index = start; index < lines.length; index++) {
|
|
73
|
+
const match = /^[ \t]+-[ \t]+(.*)$/.exec(lines[index]);
|
|
74
|
+
if (match === null) break;
|
|
75
|
+
|
|
76
|
+
const value = parseValue(match[1].trim());
|
|
77
|
+
items.push(typeof value === "string" ? value : String(value));
|
|
78
|
+
consumed++;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { items, consumed };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Parse one scalar or flow sequence. */
|
|
85
|
+
function parseValue(raw: string): unknown {
|
|
86
|
+
if (raw.startsWith("[") && raw.endsWith("]")) {
|
|
87
|
+
return raw
|
|
88
|
+
.slice(1, -1)
|
|
89
|
+
.split(",")
|
|
90
|
+
.map((item) => item.trim())
|
|
91
|
+
.filter(Boolean)
|
|
92
|
+
.map(parseValue);
|
|
93
|
+
}
|
|
94
|
+
if (raw.startsWith('"')) return unquote(raw, '"', (text) => text.replace(/\\(["\\/nrt])/g, (_, escape: string) => {
|
|
95
|
+
return escape === "n" ? "\n" : escape === "r" ? "\r" : escape === "t" ? "\t" : escape;
|
|
96
|
+
}));
|
|
97
|
+
if (raw.startsWith("'")) return unquote(raw, "'", (text) => text.replace(/''/g, "'"));
|
|
98
|
+
return raw;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Strip a matching quote pair and unescape the content; an unterminated quote keeps the raw text. */
|
|
102
|
+
function unquote(raw: string, quote: string, unescape: (text: string) => string): string {
|
|
103
|
+
if (raw.length < 2 || !raw.endsWith(quote)) return raw;
|
|
104
|
+
return unescape(raw.slice(1, -1));
|
|
105
|
+
}
|
|
@@ -21,8 +21,13 @@ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
|
21
21
|
import type { Message } from "@earendil-works/pi-ai";
|
|
22
22
|
import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.ts";
|
|
23
23
|
import { waitForAgents } from "./wait.ts";
|
|
24
|
-
import {
|
|
25
|
-
import { Container,
|
|
24
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
25
|
+
import { Container, Spacer, Text } from "./components.ts";
|
|
26
|
+
// `typebox` is the one bare runtime import an extension may make: pi documents
|
|
27
|
+
// it as host-provided and its loader aliases it (pi's docs/packages.md), so it
|
|
28
|
+
// resolves in the staged package too. Every `@earendil-works/*` import here is
|
|
29
|
+
// `import type`, which is erased; a value import of pi's runtime breaks the
|
|
30
|
+
// package outside this checkout (test/pi-extensions-load.test.mjs).
|
|
26
31
|
import { Type } from "typebox";
|
|
27
32
|
|
|
28
33
|
|
|
@@ -236,9 +241,8 @@ async function writePromptToTempFile(agentName: string, prompt: string): Promise
|
|
|
236
241
|
const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-subagent-"));
|
|
237
242
|
const safeName = agentName.replace(/[^\w.-]+/g, "_");
|
|
238
243
|
const filePath = path.join(tmpDir, `prompt-${safeName}.md`);
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
});
|
|
244
|
+
// No mutation queue: the directory is fresh per call, so nothing else writes this file.
|
|
245
|
+
await fs.promises.writeFile(filePath, prompt, { encoding: "utf-8", mode: 0o600 });
|
|
242
246
|
return { dir: tmpDir, filePath };
|
|
243
247
|
}
|
|
244
248
|
|
|
@@ -746,8 +750,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
746
750
|
return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
|
|
747
751
|
}
|
|
748
752
|
|
|
749
|
-
const mdTheme = getMarkdownTheme();
|
|
750
|
-
|
|
751
753
|
const renderDisplayItems = (items: DisplayItem[], limit?: number) => {
|
|
752
754
|
const toShow = limit ? items.slice(-limit) : items;
|
|
753
755
|
const skipped = limit && items.length > limit ? items.length - limit : 0;
|
|
@@ -798,7 +800,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
798
800
|
}
|
|
799
801
|
if (finalOutput) {
|
|
800
802
|
container.addChild(new Spacer(1));
|
|
801
|
-
container.addChild(new
|
|
803
|
+
container.addChild(new Text(finalOutput.trim(), 0, 0));
|
|
802
804
|
}
|
|
803
805
|
}
|
|
804
806
|
const usageStr = formatUsageStats(r.usage, r.model);
|
|
@@ -880,10 +882,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
880
882
|
}
|
|
881
883
|
}
|
|
882
884
|
|
|
883
|
-
// Show final output as
|
|
885
|
+
// Show final output as plain text
|
|
884
886
|
if (finalOutput) {
|
|
885
887
|
container.addChild(new Spacer(1));
|
|
886
|
-
container.addChild(new
|
|
888
|
+
container.addChild(new Text(finalOutput.trim(), 0, 0));
|
|
887
889
|
}
|
|
888
890
|
|
|
889
891
|
const stepUsage = formatUsageStats(r.usage, r.model);
|
|
@@ -965,10 +967,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
965
967
|
}
|
|
966
968
|
}
|
|
967
969
|
|
|
968
|
-
// Show final output as
|
|
970
|
+
// Show final output as plain text
|
|
969
971
|
if (finalOutput) {
|
|
970
972
|
container.addChild(new Spacer(1));
|
|
971
|
-
container.addChild(new
|
|
973
|
+
container.addChild(new Text(finalOutput.trim(), 0, 0));
|
|
972
974
|
}
|
|
973
975
|
|
|
974
976
|
const taskUsage = formatUsageStats(r.usage, r.model);
|
|
@@ -4,20 +4,19 @@
|
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"test": "node --test --import tsx/esm wait.test.ts agents.test.ts",
|
|
7
|
+
"test": "node --test --import tsx/esm wait.test.ts agents.test.ts frontmatter.test.ts components.test.ts",
|
|
8
8
|
"typecheck": "tsc --noEmit"
|
|
9
9
|
},
|
|
10
10
|
"devDependencies": {
|
|
11
11
|
"@earendil-works/pi-agent-core": "^0.78.1",
|
|
12
12
|
"@earendil-works/pi-ai": "^0.78.1",
|
|
13
13
|
"@earendil-works/pi-coding-agent": "^0.78.1",
|
|
14
|
-
"@earendil-works/pi-tui": "^0.78.1",
|
|
15
14
|
"@types/node": "^22.0.0",
|
|
16
15
|
"tsx": "^4.0.0",
|
|
17
16
|
"typebox": "^1.1.38",
|
|
18
17
|
"typescript": "^5.0.0"
|
|
19
18
|
},
|
|
20
19
|
"peerDependencies": {
|
|
21
|
-
"
|
|
20
|
+
"typebox": "*"
|
|
22
21
|
}
|
|
23
|
-
}
|
|
22
|
+
}
|
package/package.json
CHANGED
|
@@ -90,39 +90,41 @@ git pull
|
|
|
90
90
|
|
|
91
91
|
This avoids avoidable merge conflicts.
|
|
92
92
|
|
|
93
|
-
### 6. Bring a behind branch up to date —
|
|
93
|
+
### 6. Bring a behind branch up to date — rebase, don't merge
|
|
94
94
|
|
|
95
|
-
When the base branch moves and the PR reports `BEHIND` or `CONFLICTING`,
|
|
96
|
-
|
|
95
|
+
When the base branch moves and the PR reports `BEHIND` or `CONFLICTING`, rebase
|
|
96
|
+
your branch onto its base branch before anything else — never merge the base in:
|
|
97
97
|
|
|
98
98
|
```bash
|
|
99
99
|
git fetch origin
|
|
100
|
-
git
|
|
100
|
+
git rebase origin/main # or epic/<id> for a feature branch inside an epic
|
|
101
|
+
git push --force-with-lease
|
|
101
102
|
```
|
|
102
103
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
104
|
+
A merge from the base drags every intervening commit into the review diff and,
|
|
105
|
+
under a squash-merge landing method, contributes nothing that reaches the base
|
|
106
|
+
branch. Landing is still a squash merge; rebasing only reshapes *your* branch
|
|
107
|
+
before it lands, so these are separate decisions, not rivals. Keeping the branch
|
|
108
|
+
linear is what makes the rebase safe: `git rebase` drops merge commits and the
|
|
109
|
+
patches they resolved, so never rebase a branch that already merged the base.
|
|
110
|
+
|
|
111
|
+
One exception: an **integration branch** that other branches are based on
|
|
112
|
+
(`main`, or an `epic/<id>` branch) is not yours to rewrite. Sync it by merging
|
|
113
|
+
the base in — under squash-merge that merge never reaches the landing diff, and
|
|
114
|
+
the branches based on it stay linear by rebasing onto it.
|
|
115
|
+
|
|
116
|
+
Rewritten commits are re-signed automatically when `commit.gpgsign` is set. Pass
|
|
117
|
+
no `--gpg-sign`, and do not set `rebase.gpgSign` — both are redundant.
|
|
118
|
+
|
|
119
|
+
Scope: rebase only your **own unmerged feature branch**. Never rebase `main` or an
|
|
120
|
+
`epic/<id>` branch others are based on, and never rebase a branch another person
|
|
121
|
+
or agent is working on — it discards their context and invalidates review.
|
|
122
|
+
Force-push with `--force-with-lease`, never `--force`, which overwrites work you
|
|
123
|
+
have not seen. Rewriting a reviewed branch discards the review context and
|
|
124
|
+
invalidates approvals, so re-request review after a sync.
|
|
125
|
+
|
|
126
|
+
After any update, re-run the checks: a conflict resolved by hand is not verified
|
|
127
|
+
by the base branch's green CI.
|
|
126
128
|
|
|
127
129
|
### 7. Worktree lifecycle
|
|
128
130
|
|