@oisin-ee/pi-prewalk 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +45 -2
- package/package.json +37 -3
- package/src/adapter.ts +294 -0
- package/src/branch.ts +69 -0
- package/src/config.ts +68 -0
- package/src/generated/prewalk_core.d.ts +253 -0
- package/src/generated/prewalk_core.js +616 -0
- package/src/generated/prewalk_core_bg.wasm +0 -0
- package/src/generated/prewalk_core_bg.wasm.d.ts +11 -0
- package/src/index.ts +82 -0
- package/src/ports.ts +31 -0
- package/src/projection.ts +35 -0
- package/src/wasm.ts +19 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Momokaya
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,46 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @oisin-ee/pi-prewalk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Prewalk starts with the active planner model. After the planning nudge, a successful `todo` call opens the handoff gate.
|
|
4
|
+
The first subsequent `edit` or `write` result switches the session to the target model, even if that result failed.
|
|
5
|
+
Nested calls through codemode also count. Without a callable `todo` tool, the gate is open.
|
|
6
|
+
|
|
7
|
+
## Config
|
|
8
|
+
|
|
9
|
+
Put `prewalk.json` in the pi agent directory:
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
{
|
|
13
|
+
"enabled": true,
|
|
14
|
+
"target": { "model": "alias/luna", "thinking": "medium" }
|
|
15
|
+
}
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
A trusted project's `.pi/prewalk.json` replaces matching user keys. Prewalk reads both files again at each input.
|
|
19
|
+
The target needs a provider-qualified model ID and configured credentials. Alias models need the alias extension.
|
|
20
|
+
|
|
21
|
+
## Controls
|
|
22
|
+
|
|
23
|
+
Use `/prewalk` to arm from the active model. Use `/prewalk off` to cancel without changing the model or config.
|
|
24
|
+
Use `/prewalk restart` to select the saved default and arm again. Use `/prewalk status` to inspect branch state and config errors.
|
|
25
|
+
|
|
26
|
+
The startup flags are `--prewalk`, `--no-prewalk`, and `--prewalk-into provider/model`.
|
|
27
|
+
Do not combine `--no-prewalk` with either arming flag.
|
|
28
|
+
Flags affect only the startup session. New sessions use `enabled` from config. Resumed sessions do not arm automatically.
|
|
29
|
+
|
|
30
|
+
For a Shepherdr child, add `"pi_args": ["--prewalk-into", "alias/luna"]` to its profile.
|
|
31
|
+
To suppress configured prewalk for that child, use `"pi_args": ["--no-prewalk"]`.
|
|
32
|
+
|
|
33
|
+
## State and context
|
|
34
|
+
|
|
35
|
+
The active branch stores arm, handoff, and off entries. The extension does not keep a separate in-memory protocol state.
|
|
36
|
+
After a handoff, `/new` restores the planner unless a later model or thinking selection replaced the handoff target.
|
|
37
|
+
|
|
38
|
+
pi 1.3.0 permits persistent context edits only at actionable boundaries.
|
|
39
|
+
Cancellation writes an off entry immediately. A context handler removes canceled planning steering before the next model request.
|
|
40
|
+
The next turn or settlement boundary records the persistent removal.
|
|
41
|
+
|
|
42
|
+
## Source
|
|
43
|
+
|
|
44
|
+
The protocol and prompts adapt [oh-my-pi prewalk](https://github.com/can1357/oh-my-pi/tree/e7fe318/packages/coding-agent/src),
|
|
45
|
+
including `session/prewalk.ts` and `prompts/system/prewalk-{plan,continue,checklist}.md`.
|
|
46
|
+
The original project uses the MIT license. This package keeps the one-shot handoff semantics and adapts them to pi's branch entries.
|
package/package.json
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oisin-ee/pi-prewalk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A one-shot planner-to-executor handoff for pi, with branch-backed Rust rules.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-extension",
|
|
7
|
+
"pi-package",
|
|
8
|
+
"prewalk"
|
|
9
|
+
],
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/oisin-ee/pi-prewalk.git",
|
|
14
|
+
"directory": "packages/pi-prewalk"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src/",
|
|
18
|
+
"!src/**/*.test.ts",
|
|
19
|
+
"!src/fixture.ts"
|
|
20
|
+
],
|
|
21
|
+
"type": "module",
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@earendil-works/pi-ai": "1.3.0",
|
|
24
|
+
"@earendil-works/pi-coding-agent": "1.3.0",
|
|
25
|
+
"@earendil-works/pi-tui": "1.3.0",
|
|
26
|
+
"typebox": "1.3.27"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@earendil-works/pi-ai": "*",
|
|
30
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
31
|
+
"@earendil-works/pi-tui": "*",
|
|
32
|
+
"typebox": "*"
|
|
33
|
+
},
|
|
34
|
+
"pi": {
|
|
35
|
+
"extensions": [
|
|
36
|
+
"./src/index.ts"
|
|
37
|
+
],
|
|
38
|
+
"image": "https://raw.githubusercontent.com/oisin-ee/pi-prewalk/main/assets/prewalk.png"
|
|
39
|
+
}
|
|
6
40
|
}
|
package/src/adapter.ts
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import type { Action, ConfigReport, Event, Facts, Selection, State } from "./wasm.ts";
|
|
2
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
3
|
+
import type { BoundaryResult, SessionBoundaryDraft } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { Context, Files, Host } from "./ports.ts";
|
|
5
|
+
import { branchRecords, calls } from "./branch.ts";
|
|
6
|
+
import type { CallEvidence } from "./branch.ts";
|
|
7
|
+
import { decide } from "./wasm.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolves an exact qualified ID with configured credentials.
|
|
11
|
+
* @param {string} id Qualified ID.
|
|
12
|
+
* @param {Context} ctx Context.
|
|
13
|
+
* @returns {Model<Api> | undefined} Available model.
|
|
14
|
+
*/
|
|
15
|
+
const model = (id: string, ctx: Context): Model<Api> | undefined =>
|
|
16
|
+
ctx.modelRegistry
|
|
17
|
+
.getAvailable()
|
|
18
|
+
.find((candidate) => `${candidate.provider}/${candidate.id}` === id),
|
|
19
|
+
/**
|
|
20
|
+
* Records pi's effective thinking after selection.
|
|
21
|
+
* @param {State} state Proposed state.
|
|
22
|
+
* @param {Selection | undefined} selected Effective selection.
|
|
23
|
+
* @returns {State} Effective state.
|
|
24
|
+
*/
|
|
25
|
+
effectiveState = (state: State, selected: Selection | undefined): State => {
|
|
26
|
+
if (selected && state.kind === "handoff") {
|
|
27
|
+
return Object.assign({}, state, { target: selected });
|
|
28
|
+
}
|
|
29
|
+
return selected && state.kind === "arm"
|
|
30
|
+
? Object.assign({}, state, { planner: selected })
|
|
31
|
+
: state;
|
|
32
|
+
},
|
|
33
|
+
/**
|
|
34
|
+
* Maps one action to a pi draft.
|
|
35
|
+
* @param {Action} action Core action.
|
|
36
|
+
* @param {Selection | undefined} selected Effective selection.
|
|
37
|
+
* @returns {SessionBoundaryDraft[]} Zero or one draft.
|
|
38
|
+
*/
|
|
39
|
+
drafts = (action: Action, selected: Selection | undefined): SessionBoundaryDraft[] => {
|
|
40
|
+
switch (action.kind) {
|
|
41
|
+
case "store": {
|
|
42
|
+
return [
|
|
43
|
+
{
|
|
44
|
+
customType: `prewalk-${action.state.kind}`,
|
|
45
|
+
data: effectiveState(action.state, selected),
|
|
46
|
+
type: "custom",
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
case "prompt": {
|
|
51
|
+
return [
|
|
52
|
+
{
|
|
53
|
+
content: action.content,
|
|
54
|
+
customType: action.name,
|
|
55
|
+
display: false,
|
|
56
|
+
type: "custom_message",
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
case "scrub": {
|
|
61
|
+
return [{ replacement: null, targetId: action.id, type: "context_edit" }];
|
|
62
|
+
}
|
|
63
|
+
case "select":
|
|
64
|
+
case "notify": {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
default: {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
/**
|
|
73
|
+
* Persists non-boundary entries. Scrubs persist at the next boundary.
|
|
74
|
+
* @param {Host} pi Host authority.
|
|
75
|
+
* @param {readonly SessionBoundaryDraft[]} entries Drafts.
|
|
76
|
+
*/
|
|
77
|
+
persist = (pi: Host, entries: readonly SessionBoundaryDraft[]): void => {
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
if (entry.type === "custom") {
|
|
80
|
+
pi.appendEntry(entry.customType, entry.data);
|
|
81
|
+
} else if (entry.type === "custom_message") {
|
|
82
|
+
pi.sendMessage(entry);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** No in-memory arm, gate, handoff, or continuation state. */
|
|
88
|
+
class Adapter {
|
|
89
|
+
readonly #pi: Host;
|
|
90
|
+
readonly #files: Files;
|
|
91
|
+
readonly #notified = new Set<string>();
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param {Host} pi Session authority.
|
|
95
|
+
* @param {Files} files File readers.
|
|
96
|
+
*/
|
|
97
|
+
public constructor(pi: Host, files: Files) {
|
|
98
|
+
this.#pi = pi;
|
|
99
|
+
this.#files = files;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @param {Event} event Protocol event.
|
|
104
|
+
* @param {Context} ctx Context.
|
|
105
|
+
* @returns {Facts} Boundary input.
|
|
106
|
+
*/
|
|
107
|
+
#facts(event: Event, ctx: Context): Facts {
|
|
108
|
+
const report = this.#files.config(ctx),
|
|
109
|
+
into = event.kind === "start" && event.reason === "startup" ? event.flags.into : undefined,
|
|
110
|
+
target = into ?? report.config.target?.model,
|
|
111
|
+
todo = this.#pi
|
|
112
|
+
.getAllTools()
|
|
113
|
+
.find((tool) => tool.name === "todo" && tool.exposure !== "hidden"),
|
|
114
|
+
callable =
|
|
115
|
+
todo &&
|
|
116
|
+
(todo.exposure === "codemode" ||
|
|
117
|
+
todo.exposure === "deferred" ||
|
|
118
|
+
this.#pi.getActiveTools().includes("todo"));
|
|
119
|
+
return {
|
|
120
|
+
available: ctx.modelRegistry
|
|
121
|
+
.getAvailable()
|
|
122
|
+
.map((candidate) => `${candidate.provider}/${candidate.id}`),
|
|
123
|
+
branch: branchRecords(ctx.sessionManager.getBranch()),
|
|
124
|
+
current: ctx.model
|
|
125
|
+
? { model: `${ctx.model.provider}/${ctx.model.id}`, thinking: this.#pi.getThinkingLevel() }
|
|
126
|
+
: undefined,
|
|
127
|
+
default: this.#default(ctx),
|
|
128
|
+
event,
|
|
129
|
+
previous: [],
|
|
130
|
+
report,
|
|
131
|
+
target_available: target !== undefined && model(target, ctx) !== undefined,
|
|
132
|
+
todo: Boolean(callable),
|
|
133
|
+
todo_owner: todo?.sourceInfo.path ?? "none",
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* @param {Context} ctx Context.
|
|
139
|
+
* @returns {Selection | undefined} Usable default.
|
|
140
|
+
*/
|
|
141
|
+
#default(ctx: Context): Selection | undefined {
|
|
142
|
+
const settings = this.#pi.getSettings(),
|
|
143
|
+
id = `${settings.defaultProvider ?? ""}/${settings.defaultModel ?? ""}`;
|
|
144
|
+
return model(id, ctx)
|
|
145
|
+
? { model: id, thinking: settings.defaultThinkingLevel ?? "medium" }
|
|
146
|
+
: undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* @param {ConfigReport} report Config report.
|
|
151
|
+
* @param {Context} ctx Context.
|
|
152
|
+
*/
|
|
153
|
+
#errors(report: ConfigReport, ctx: Context): void {
|
|
154
|
+
for (const error of report.errors) {
|
|
155
|
+
if (!this.#notified.has(error)) {
|
|
156
|
+
this.#notified.add(error);
|
|
157
|
+
ctx.ui.notify(error, "warning");
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* @param {Event} event Event.
|
|
164
|
+
* @param {Context} ctx Context.
|
|
165
|
+
* @param {readonly SessionBoundaryDraft[] | undefined} prior Existing boundary drafts.
|
|
166
|
+
* @returns {Promise<BoundaryResult>} Boundary result.
|
|
167
|
+
*/
|
|
168
|
+
public async run(
|
|
169
|
+
event: Event,
|
|
170
|
+
ctx: Context,
|
|
171
|
+
prior?: readonly SessionBoundaryDraft[],
|
|
172
|
+
): Promise<BoundaryResult> {
|
|
173
|
+
const facts = this.#facts(event, ctx);
|
|
174
|
+
this.#errors(facts.report, ctx);
|
|
175
|
+
return this.#apply(facts, ctx, prior);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Excludes this turn's persisted results from the historical gate evidence.
|
|
180
|
+
* @param {TurnEndEvent} event Completed turn.
|
|
181
|
+
* @param {Context} ctx Context.
|
|
182
|
+
* @returns {Promise<BoundaryResult>} Boundary result.
|
|
183
|
+
*/
|
|
184
|
+
public async turn(
|
|
185
|
+
event: Readonly<{
|
|
186
|
+
toolResults: readonly CallEvidence[];
|
|
187
|
+
toolResultEntryIds: readonly string[];
|
|
188
|
+
entries: readonly SessionBoundaryDraft[];
|
|
189
|
+
}>,
|
|
190
|
+
ctx: Context,
|
|
191
|
+
): Promise<BoundaryResult> {
|
|
192
|
+
const facts = this.#facts(
|
|
193
|
+
{ calls: event.toolResults.flatMap((result) => calls(result)), kind: "turn" },
|
|
194
|
+
ctx,
|
|
195
|
+
);
|
|
196
|
+
facts.branch = branchRecords(
|
|
197
|
+
ctx.sessionManager
|
|
198
|
+
.getBranch()
|
|
199
|
+
.filter((entry) => !event.toolResultEntryIds.includes(entry.id)),
|
|
200
|
+
);
|
|
201
|
+
this.#errors(facts.report, ctx);
|
|
202
|
+
return this.#apply(facts, ctx, event.entries);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* @param {string | undefined} path Previous session file.
|
|
207
|
+
* @param {Context} ctx Fresh context.
|
|
208
|
+
* @returns {Promise<void>} Completion.
|
|
209
|
+
*/
|
|
210
|
+
public async newSession(path: string | undefined, ctx: Context): Promise<void> {
|
|
211
|
+
const facts = this.#facts(
|
|
212
|
+
{
|
|
213
|
+
flags: { into: undefined, no_prewalk: false, prewalk: false },
|
|
214
|
+
kind: "start",
|
|
215
|
+
reason: "new",
|
|
216
|
+
},
|
|
217
|
+
ctx,
|
|
218
|
+
);
|
|
219
|
+
this.#errors(facts.report, ctx);
|
|
220
|
+
try {
|
|
221
|
+
facts.previous = path === undefined ? [] : branchRecords(this.#files.previous(path));
|
|
222
|
+
} catch (error) {
|
|
223
|
+
ctx.ui.notify(
|
|
224
|
+
`Prewalk: cannot read previous session: ${error instanceof Error ? error.message : String(error)}`,
|
|
225
|
+
"warning",
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
await this.#apply(facts, ctx);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* @param {Selection} selection Selection.
|
|
233
|
+
* @param {Context} ctx Context.
|
|
234
|
+
* @returns {Promise<Selection | undefined>} Effective selection, or failure.
|
|
235
|
+
*/
|
|
236
|
+
async #select(selection: Selection, ctx: Context): Promise<Selection | null> {
|
|
237
|
+
const resolved = model(selection.model, ctx);
|
|
238
|
+
if (!resolved || !(await this.#pi.setModel(resolved))) {
|
|
239
|
+
ctx.ui.notify(
|
|
240
|
+
`Prewalk: cannot select ${selection.model}. Active model stays unchanged.`,
|
|
241
|
+
"warning",
|
|
242
|
+
);
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
this.#pi.setThinkingLevel(selection.thinking);
|
|
246
|
+
return { model: selection.model, thinking: this.#pi.getThinkingLevel() };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* @param {readonly Action[]} actions Decisions.
|
|
251
|
+
* @param {Context} ctx Context.
|
|
252
|
+
*/
|
|
253
|
+
static #notices(actions: readonly Action[], ctx: Context): void {
|
|
254
|
+
for (const action of actions) {
|
|
255
|
+
if (action.kind === "notify") {
|
|
256
|
+
ctx.ui.notify(action.message, "info");
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Maps a protocol result to pi. Core decisions contain at most one selection.
|
|
263
|
+
* @param {Facts} facts Input.
|
|
264
|
+
* @param {Context} ctx Context.
|
|
265
|
+
* @param {readonly SessionBoundaryDraft[] | undefined} prior Existing boundary drafts.
|
|
266
|
+
* @returns {Promise<BoundaryResult>} Boundary result.
|
|
267
|
+
*/
|
|
268
|
+
async #apply(
|
|
269
|
+
facts: Facts,
|
|
270
|
+
ctx: Context,
|
|
271
|
+
prior?: readonly SessionBoundaryDraft[],
|
|
272
|
+
): Promise<BoundaryResult> {
|
|
273
|
+
const decision = decide(facts),
|
|
274
|
+
selection = decision.actions.find((action) => action.kind === "select"),
|
|
275
|
+
selected = selection ? await this.#select(selection.selection, ctx) : undefined,
|
|
276
|
+
entries = [
|
|
277
|
+
...(prior ?? []),
|
|
278
|
+
...decision.actions.flatMap((action) => drafts(action, selected ?? undefined)),
|
|
279
|
+
];
|
|
280
|
+
if (selection && !selected) {
|
|
281
|
+
if (facts.event.kind === "start" && facts.event.reason === "new") {
|
|
282
|
+
return this.#apply(Object.assign({}, facts, { previous: [] }), ctx, prior);
|
|
283
|
+
}
|
|
284
|
+
return { entries: [...(prior ?? [])] };
|
|
285
|
+
}
|
|
286
|
+
Adapter.#notices(decision.actions, ctx);
|
|
287
|
+
if (prior === undefined) {
|
|
288
|
+
persist(this.#pi, entries);
|
|
289
|
+
}
|
|
290
|
+
return decision.again ? { continue: true, entries } : { entries };
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export { Adapter };
|
package/src/branch.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { Record as BranchRecord, Call } from "./wasm.ts";
|
|
2
|
+
import type { SessionEntry, SessionMessageEntry } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { parse_state } from "./wasm.ts";
|
|
4
|
+
|
|
5
|
+
/** Only the fields the protocol reads from tool results. */
|
|
6
|
+
interface CallEvidence {
|
|
7
|
+
readonly toolName: string;
|
|
8
|
+
readonly isError: boolean;
|
|
9
|
+
readonly nestedCalls?: {
|
|
10
|
+
readonly calls: readonly { readonly name: string; readonly status: string }[];
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Extracts call evidence, including calls without transcript entries.
|
|
16
|
+
* @param {ToolResultMessage} result A completed outer tool result.
|
|
17
|
+
* @returns {Call[]} Nested calls followed by the outer result.
|
|
18
|
+
*/
|
|
19
|
+
const calls = (result: CallEvidence): Call[] => [
|
|
20
|
+
...(result.nestedCalls?.calls.map((call) => ({
|
|
21
|
+
name: call.name,
|
|
22
|
+
success: call.status === "ok",
|
|
23
|
+
})) ?? []),
|
|
24
|
+
{ name: result.toolName, success: !result.isError },
|
|
25
|
+
],
|
|
26
|
+
/**
|
|
27
|
+
* Translates message evidence.
|
|
28
|
+
* @param {SessionMessageEntry} entry A transcript entry.
|
|
29
|
+
* @returns {BranchRecord[]} Relevant messages.
|
|
30
|
+
*/
|
|
31
|
+
messageRecords = (entry: SessionMessageEntry): BranchRecord[] => {
|
|
32
|
+
if (entry.message.role === "toolResult") {
|
|
33
|
+
return [{ calls: calls(entry.message), kind: "calls" }];
|
|
34
|
+
}
|
|
35
|
+
return entry.message.role === "user" ? [{ kind: "user" }] : [];
|
|
36
|
+
},
|
|
37
|
+
/**
|
|
38
|
+
* Translates a pi entry. Rust validates protocol payloads.
|
|
39
|
+
* @param {SessionEntry} entry One branch entry.
|
|
40
|
+
* @returns {BranchRecord[]} Protocol evidence.
|
|
41
|
+
*/
|
|
42
|
+
entryRecords = (entry: SessionEntry): BranchRecord[] => {
|
|
43
|
+
if (
|
|
44
|
+
entry.type === "custom" &&
|
|
45
|
+
["prewalk-arm", "prewalk-handoff", "prewalk-off"].includes(entry.customType)
|
|
46
|
+
) {
|
|
47
|
+
return [{ kind: "state", state: parse_state(entry.data) }];
|
|
48
|
+
}
|
|
49
|
+
if (entry.type === "custom_message" && entry.customType.startsWith("prewalk-")) {
|
|
50
|
+
return [{ id: entry.id, kind: "prompt", name: entry.customType }];
|
|
51
|
+
}
|
|
52
|
+
if (entry.type === "context_edit" && entry.replacement === null) {
|
|
53
|
+
return [{ id: entry.targetId, kind: "scrub" }];
|
|
54
|
+
}
|
|
55
|
+
if (entry.type === "model_change" || entry.type === "thinking_level_change") {
|
|
56
|
+
return [{ kind: "selection" }];
|
|
57
|
+
}
|
|
58
|
+
return entry.type === "message" ? messageRecords(entry) : [];
|
|
59
|
+
},
|
|
60
|
+
/**
|
|
61
|
+
* Translates the active branch in transcript order.
|
|
62
|
+
* @param {readonly SessionEntry[]} entries Current branch, oldest first.
|
|
63
|
+
* @returns {BranchRecord[]} Protocol evidence.
|
|
64
|
+
*/
|
|
65
|
+
branchRecords = (entries: readonly SessionEntry[]): BranchRecord[] =>
|
|
66
|
+
entries.flatMap((entry) => entryRecords(entry));
|
|
67
|
+
|
|
68
|
+
export { branchRecords, calls };
|
|
69
|
+
export type { CallEvidence };
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CURRENT_SESSION_VERSION,
|
|
3
|
+
SessionManager,
|
|
4
|
+
getAgentDir,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type { ConfigReport } from "./wasm.ts";
|
|
7
|
+
import type { Context } from "./ports.ts";
|
|
8
|
+
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { configure } from "./wasm.ts";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Reads optional config, preserving permission and I/O errors.
|
|
15
|
+
* @param {string} file Config file path.
|
|
16
|
+
* @returns {string | undefined} Source, or undefined for a missing file.
|
|
17
|
+
*/
|
|
18
|
+
const source = (file: string): string | undefined => {
|
|
19
|
+
try {
|
|
20
|
+
return readFileSync(file, "utf8");
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
/**
|
|
29
|
+
* Reads live config. Untrusted project files never reach Rust.
|
|
30
|
+
* @param {Context} ctx Current context.
|
|
31
|
+
* @returns {ConfigReport} Validated config and errors.
|
|
32
|
+
*/
|
|
33
|
+
readConfig = (ctx: Context): ConfigReport => {
|
|
34
|
+
try {
|
|
35
|
+
const userPath = path.join(getAgentDir(), "prewalk.json"),
|
|
36
|
+
projectPath = path.join(ctx.cwd, ".pi", "prewalk.json"),
|
|
37
|
+
user = source(userPath),
|
|
38
|
+
project = ctx.isProjectTrusted() ? source(projectPath) : undefined;
|
|
39
|
+
return configure(user, project);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
return {
|
|
42
|
+
config: { enabled: false, target: undefined },
|
|
43
|
+
errors: [`prewalk.json: ${error instanceof Error ? error.message : String(error)}`],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
/**
|
|
48
|
+
* Opens only current-version sessions, preventing migration writes.
|
|
49
|
+
* @param {string} file Outgoing session path supplied by pi.
|
|
50
|
+
* @returns {SessionEntry[]} Previous active branch.
|
|
51
|
+
*/
|
|
52
|
+
previousBranch = (file: string): SessionEntry[] => {
|
|
53
|
+
const [line] = readFileSync(file, "utf8").split("\n"),
|
|
54
|
+
first: unknown = JSON.parse(line ?? "null");
|
|
55
|
+
if (
|
|
56
|
+
typeof first !== "object" ||
|
|
57
|
+
first === null ||
|
|
58
|
+
!("version" in first) ||
|
|
59
|
+
first.version !== CURRENT_SESSION_VERSION
|
|
60
|
+
) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
"Previous session is not a current-version pi session. Refusing a migration during restore.",
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return SessionManager.open(file).getBranch();
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export { previousBranch, readConfig };
|