@aexhq/agentloop-pi 0.0.0 → 1.2.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/index.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { Agentloop } from "@aexhq/brain";
2
+
3
+ export interface PiOptions {
4
+ /** Model context window in tokens the compaction budget is measured against. Default 200000. */
5
+ readonly contextWindow?: number;
6
+ /** Compact when the estimated context exceeds contextWindow - reserveTokens. pi default 16384. */
7
+ readonly reserveTokens?: number;
8
+ /** Approximate tokens of recent conversation kept verbatim through a compaction. pi default 20000. */
9
+ readonly keepRecentTokens?: number;
10
+ /** Disable automatic compaction entirely. Default true (enabled). */
11
+ readonly compaction?: boolean;
12
+ }
13
+
14
+ export declare const pi: (options?: PiOptions) => Agentloop;
package/package.json CHANGED
@@ -1,19 +1,41 @@
1
1
  {
2
2
  "name": "@aexhq/agentloop-pi",
3
- "version": "0.0.0",
4
- "description": "Reserved package name; no stable release is available.",
5
- "license": "Apache-2.0",
3
+ "version": "1.2.0",
4
+ "description": "Pi-style parallel-Tool Brain extension",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/aexhq/extensions.git",
9
+ "directory": "packages/loop-pi"
10
+ },
6
11
  "type": "module",
7
- "exports": "./index.js",
12
+ "sideEffects": false,
13
+ "engines": {
14
+ "node": ">=22.0.0"
15
+ },
16
+ "publishConfig": {
17
+ "access": "public",
18
+ "provenance": true,
19
+ "tag": "next"
20
+ },
21
+ "exports": {
22
+ ".": { "types": "./index.d.ts", "import": "./dist/index.mjs" },
23
+ "./package.json": "./package.json"
24
+ },
8
25
  "files": [
9
- "index.js"
26
+ "dist",
27
+ "src",
28
+ "index.d.ts",
29
+ "README.md"
10
30
  ],
11
- "repository": {
12
- "type": "git",
13
- "url": "git+https://github.com/aexhq/extensions.git"
31
+ "scripts": {
32
+ "build": "brain build src/index.mjs --out dist",
33
+ "test": "npm run build && node --test",
34
+ "prepack": "npm run build"
35
+ },
36
+ "dependencies": {
37
+ "@aexhq/brain": "0.11.0",
38
+ "zod": "4.4.3"
14
39
  },
15
- "aexHoldPlaceholder": {
16
- "stagedVersion": "1.1.0",
17
- "source": "720d4ff2ae98f7df85fcd54a53a36ccbd6f03230"
18
- }
40
+ "devDependencies": {}
19
41
  }
package/src/compat.mjs ADDED
@@ -0,0 +1,33 @@
1
+ // StarlingMonkey compatibility rewrites for pi's dependency tree, applied while bundling.
2
+ // COMPAT (recorded in the H0 report): the pinned guest engine rejects Unicode property
3
+ // escapes in regex literals at parse time. typebox (a pi dependency) emits `\p{ID_Start}` in
4
+ // its identifier guard and builds IDN validators from `\p{…}` classes; the guard rewrites to
5
+ // an ASCII-equivalent class and the unused IDN modules stub out.
6
+ import { readFile } from "node:fs/promises";
7
+
8
+ const UNICODE_ID_REGEX_PATTERN = /\/\^\[\\p\{ID_Start\}[^/]*\*\$\/u/g;
9
+ const ASCII_ID_REGEX = String.raw`/^[A-Za-z_$][A-Za-z0-9_$]*$/`;
10
+
11
+ /** esbuild plugin for `buildLoopBundle({ plugins: [compatRewrite] })`. */
12
+ export const compatRewrite = {
13
+ name: "starlingmonkey-compat",
14
+ setup(b) {
15
+ b.onLoad({ filter: /typebox[\\/].*\.mjs$/ }, async (args) => {
16
+ if (/[\\/]format[\\/]idn_email\.mjs$/.test(args.path)) {
17
+ return { contents: "export function IsIdnEmail(){return false;}", loader: "js" };
18
+ }
19
+ if (/[\\/]format[\\/]_idna\.mjs$/.test(args.path)) {
20
+ return {
21
+ contents:
22
+ "export function IsIdnLabel(){return false;}\nexport function IsLabel(){return false;}",
23
+ loader: "js",
24
+ };
25
+ }
26
+ let contents = await readFile(args.path, "utf8");
27
+ if (UNICODE_ID_REGEX_PATTERN.test(contents)) {
28
+ contents = contents.replace(UNICODE_ID_REGEX_PATTERN, ASCII_ID_REGEX);
29
+ }
30
+ return { contents, loader: "js" };
31
+ });
32
+ },
33
+ };
package/src/index.mjs ADDED
@@ -0,0 +1,219 @@
1
+ import { agentloop } from "@aexhq/brain";
2
+ import { z } from "zod";
3
+
4
+ // Semantic port of the pi coding agent's loop, pinned against
5
+ // earendil-works/pi tag v0.84.4 (@earendil-works/pi-agent-core@0.84.4,
6
+ // packages/agent/src/agent-loop.ts + packages/coding-agent/src/core/compaction/compaction.ts).
7
+ //
8
+ // The published loop is an async driver: one invocation awaits the LLM stream
9
+ // and every tool execution internally, so it cannot run inside Brain's
10
+ // deterministic synchronous sandbox. This port reproduces its per-turn
11
+ // contract instead:
12
+ // - tool calls are issued as one parallel batch, results return as one
13
+ // user message of tool_result blocks in assistant source order
14
+ // - a length-stop that carries tool calls fails the whole batch without
15
+ // executing it, and the loop re-asks the model
16
+ // - automatic compaction: when the estimated context exceeds
17
+ // contextWindow - reserveTokens, everything older than ~keepRecentTokens
18
+ // is summarized into a structured context checkpoint that replaces it
19
+ // (cut points never split a tool_result away from its tool_use)
20
+ // Host-app seams of pi (steering and follow-up queues, per-tool
21
+ // executionMode) have no Brain equivalent and are not ported.
22
+
23
+ const optionsSchema = z.object({
24
+ contextWindow: z.number().int().positive().default(200_000),
25
+ // pi defaults (compaction.ts): compact when context exceeds
26
+ // contextWindow - reserveTokens, keep ~keepRecentTokens of recent messages.
27
+ reserveTokens: z.number().int().positive().default(16_384),
28
+ keepRecentTokens: z.number().int().positive().default(20_000),
29
+ compaction: z.boolean().default(true),
30
+ }).default({});
31
+
32
+ const stateSchema = z.object({
33
+ messages: z.array(z.unknown()),
34
+ summary: z.union([z.string(), z.null()]),
35
+ compacting: z.boolean(),
36
+ keepFrom: z.number().int().nonnegative(),
37
+ pending: z.array(z.object({ callId: z.string(), name: z.string() })),
38
+ });
39
+
40
+ const SUMMARIZATION_PROMPT = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.
41
+
42
+ Use this EXACT format:
43
+
44
+ ## Goal
45
+
46
+ ## Constraints & Preferences
47
+
48
+ ## Progress
49
+
50
+ ### Done
51
+
52
+ ### In Progress
53
+
54
+ ### Blocked
55
+
56
+ ## Key Decisions
57
+
58
+ ## Next Steps
59
+
60
+ ## Critical Context
61
+
62
+ Keep each section concise. Preserve exact file paths, function names, and error messages.`;
63
+
64
+ const UPDATE_RULES = `Update the existing structured summary with new information.
65
+
66
+ RULES:
67
+ - PRESERVE all existing information from the previous summary
68
+ - ADD new progress, decisions, and context from the new messages
69
+ - Move items from In Progress to Done as they complete
70
+
71
+ `;
72
+
73
+ const TRUNCATED_CALL_MESSAGE =
74
+ "Tool call was not executed: the model response was cut off by the output token limit, so the arguments may be truncated. Re-issue the tool call.";
75
+
76
+ const blockText = (block) => {
77
+ if (block.type === "text") return block.text;
78
+ if (block.type === "tool_use") return `[tool_use ${block.name}] ${JSON.stringify(block.input)}`;
79
+ const output = typeof block.content === "string" ? block.content : JSON.stringify(block.content);
80
+ return `[tool_result${block.is_error ? " (error)" : ""}] ${output.length > 2000 ? `${output.slice(0, 2000)}…` : output}`;
81
+ };
82
+
83
+ const serializeConversation = (messages) =>
84
+ messages.map((message) => `${message.role}:\n${message.content.map(blockText).join("\n")}`).join("\n\n");
85
+
86
+ export const pi = agentloop({ options: optionsSchema }, (author) => {
87
+ const options = author.options;
88
+ const state = author.state(stateSchema, () => ({
89
+ messages: [],
90
+ summary: null,
91
+ compacting: false,
92
+ keepFrom: 0,
93
+ pending: [],
94
+ }));
95
+
96
+ const context = () =>
97
+ state.summary === null
98
+ ? state.messages
99
+ : [
100
+ {
101
+ role: "user",
102
+ content: [{ type: "text", text: `Context checkpoint from earlier in this conversation:\n\n${state.summary}` }],
103
+ },
104
+ ...state.messages,
105
+ ];
106
+
107
+ const shouldCompact = () =>
108
+ options.compaction && author.context.estimateTokens(context()) > options.contextWindow - options.reserveTokens;
109
+
110
+ // Earliest index the recent-token budget allows, then forward to the next
111
+ // boundary that does not split a tool_result away from its tool_use.
112
+ const cutPoint = () => {
113
+ let kept = 0;
114
+ let cut = 0;
115
+ for (let index = state.messages.length - 1; index >= 0; index -= 1) {
116
+ kept += author.context.estimateTokens([state.messages[index]]);
117
+ if (kept > options.keepRecentTokens) {
118
+ cut = index + 1;
119
+ break;
120
+ }
121
+ }
122
+ while (
123
+ cut < state.messages.length &&
124
+ state.messages[cut].role === "user" &&
125
+ state.messages[cut].content.some((block) => block.type === "tool_result")
126
+ ) {
127
+ cut += 1;
128
+ }
129
+ return cut;
130
+ };
131
+
132
+ const compact = (turn) => {
133
+ state.keepFrom = cutPoint();
134
+ if (state.keepFrom === 0) return turn.model({ messages: context() });
135
+ state.compacting = true;
136
+ const previous = state.summary === null ? "" : `Previous summary:\n\n${state.summary}\n\n`;
137
+ const prompt = state.summary === null ? SUMMARIZATION_PROMPT : `${UPDATE_RULES}${SUMMARIZATION_PROMPT}`;
138
+ return turn.model({
139
+ messages: [
140
+ {
141
+ role: "user",
142
+ content: [
143
+ {
144
+ type: "text",
145
+ text: `${previous}${serializeConversation(state.messages.slice(0, state.keepFrom))}\n\n${prompt}`,
146
+ },
147
+ ],
148
+ },
149
+ ],
150
+ });
151
+ };
152
+
153
+ const advance = (turn) => (shouldCompact() ? compact(turn) : turn.model({ messages: context() }));
154
+
155
+ author.on.message(({ input }, turn) => {
156
+ state.messages.push({ role: "user", content: [{ type: "text", text: input.message }] });
157
+ return advance(turn);
158
+ });
159
+
160
+ author.on.model((completed, turn) => {
161
+ const { message, stop_reason } = completed.response;
162
+ if (state.compacting) {
163
+ state.summary = message.content
164
+ .filter((block) => block.type === "text")
165
+ .map((block) => block.text)
166
+ .join("");
167
+ state.messages = state.messages.slice(state.keepFrom);
168
+ state.compacting = false;
169
+ state.keepFrom = 0;
170
+ return turn.model({ messages: context() });
171
+ }
172
+ const calls = message.content
173
+ .filter((block) => block.type === "tool_use")
174
+ .map((block) => ({ callId: block.id, name: block.name, input: block.input }));
175
+ state.messages.push(message);
176
+ if (calls.length > 0 && stop_reason === "max_tokens") {
177
+ // pi fails the whole batch without executing it when the response was
178
+ // cut off: the arguments cannot be trusted.
179
+ state.messages.push({
180
+ role: "user",
181
+ content: calls.map((call) => ({
182
+ type: "tool_result",
183
+ tool_use_id: call.callId,
184
+ content: TRUNCATED_CALL_MESSAGE,
185
+ is_error: true,
186
+ })),
187
+ });
188
+ return advance(turn);
189
+ }
190
+ if (calls.length > 0) {
191
+ state.pending = calls.map(({ callId, name }) => ({ callId, name }));
192
+ return turn.tools(calls);
193
+ }
194
+ const text = message.content
195
+ .filter((block) => block.type === "text")
196
+ .map((block) => block.text)
197
+ .join("");
198
+ return turn.reply(text);
199
+ });
200
+
201
+ author.on.tools((completed, turn) => {
202
+ const byCall = new Map(completed.results.map((result) => [result.call_id, result]));
203
+ state.messages.push({
204
+ role: "user",
205
+ // Assistant source order, the order pi appends result messages in.
206
+ content: state.pending.map(({ callId }) => {
207
+ const result = byCall.get(callId);
208
+ return {
209
+ type: "tool_result",
210
+ tool_use_id: callId,
211
+ content: result === undefined ? "Tool produced no result." : result.output,
212
+ is_error: result === undefined ? true : result.is_error,
213
+ };
214
+ }),
215
+ });
216
+ state.pending = [];
217
+ return advance(turn);
218
+ });
219
+ });
@@ -0,0 +1,14 @@
1
+ // Runs before the workload bundle's top-level code (ESM evaluation order).
2
+ if (typeof globalThis.TextEncoder === "undefined") {
3
+ globalThis.TextEncoder = class TextEncoder {
4
+ encode(s) {
5
+ const bytes = unescape(encodeURIComponent(String(s)));
6
+ const out = new Uint8Array(bytes.length);
7
+ for (let i = 0; i < bytes.length; i++) out[i] = bytes.charCodeAt(i);
8
+ return out;
9
+ }
10
+ };
11
+ }
12
+ if (typeof globalThis.structuredClone === "undefined") {
13
+ globalThis.structuredClone = (v) => (v === undefined ? v : JSON.parse(JSON.stringify(v)));
14
+ }
package/index.js DELETED
@@ -1 +0,0 @@
1
- throw new Error("@aexhq/agentloop-pi has no stable release; install @aexhq/agentloop-pi@next or an exact version");