@henryqw/pi-herdr-btw 1.0.1
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 +78 -0
- package/extensions/btw.ts +829 -0
- package/internal/config.ts +253 -0
- package/internal/context-store.ts +287 -0
- package/internal/core.ts +393 -0
- package/internal/merge.ts +317 -0
- package/internal/router.ts +42 -0
- package/package.json +52 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import type { BtwPayload } from "./core.ts";
|
|
3
|
+
|
|
4
|
+
export const MERGE_PROTOCOL_VERSION = 2 as const;
|
|
5
|
+
export const MERGE_REQUEST_FILE = "merge-request.json";
|
|
6
|
+
export const MERGE_CUSTOM_TYPE = "pi-herdr-btw.merge";
|
|
7
|
+
export const MAX_SUMMARY_BYTES = 64 * 1024;
|
|
8
|
+
export const MAX_PROMPT_BYTES = 16 * 1024;
|
|
9
|
+
/** Transcript budget stays well under MAX_SUMMARY_BYTES for JSON overhead. */
|
|
10
|
+
export const MERGE_TRANSCRIPT_BUDGET_BYTES = 48 * 1024;
|
|
11
|
+
export const TRANSCRIPT_TRUNCATION_NOTE =
|
|
12
|
+
"[earlier side-thread turns omitted to fit the merge budget]";
|
|
13
|
+
|
|
14
|
+
export type MergeRequest = {
|
|
15
|
+
protocolVersion: typeof MERGE_PROTOCOL_VERSION;
|
|
16
|
+
requestId: string;
|
|
17
|
+
launchId: string;
|
|
18
|
+
parentSessionId: string;
|
|
19
|
+
capability: string;
|
|
20
|
+
createdAt: string;
|
|
21
|
+
/** Trimmed, 1..64 KiB packaged side-thread transcript. */
|
|
22
|
+
summary: string;
|
|
23
|
+
/** Trimmed, 1..16 KiB user prompt the parent auto-submits after the merge. */
|
|
24
|
+
prompt: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function isMergeRequest(value: unknown): value is MergeRequest {
|
|
28
|
+
if (!value || typeof value !== "object") return false;
|
|
29
|
+
const request = value as Partial<MergeRequest>;
|
|
30
|
+
return (
|
|
31
|
+
request.protocolVersion === MERGE_PROTOCOL_VERSION &&
|
|
32
|
+
typeof request.requestId === "string" &&
|
|
33
|
+
request.requestId.length > 0 &&
|
|
34
|
+
typeof request.launchId === "string" &&
|
|
35
|
+
request.launchId.length > 0 &&
|
|
36
|
+
typeof request.parentSessionId === "string" &&
|
|
37
|
+
request.parentSessionId.length > 0 &&
|
|
38
|
+
typeof request.capability === "string" &&
|
|
39
|
+
request.capability.length >= 32 &&
|
|
40
|
+
typeof request.createdAt === "string" &&
|
|
41
|
+
typeof request.summary === "string" &&
|
|
42
|
+
isSummaryWithinBounds(request.summary) &&
|
|
43
|
+
typeof request.prompt === "string" &&
|
|
44
|
+
isPromptWithinBounds(request.prompt)
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function isSummaryWithinBounds(summary: string): boolean {
|
|
49
|
+
const trimmed = summary.trim();
|
|
50
|
+
return trimmed.length > 0 && Buffer.byteLength(trimmed, "utf8") <= MAX_SUMMARY_BYTES;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function isPromptWithinBounds(prompt: string): boolean {
|
|
54
|
+
const trimmed = prompt.trim();
|
|
55
|
+
return trimmed.length > 0 && Buffer.byteLength(trimmed, "utf8") <= MAX_PROMPT_BYTES;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A merge request is trusted only when it echoes the exact launch identity,
|
|
60
|
+
* capability token, and parent session binding of its own launch payload.
|
|
61
|
+
*/
|
|
62
|
+
export function validateRequestAgainstPayload(
|
|
63
|
+
request: MergeRequest,
|
|
64
|
+
payload: BtwPayload,
|
|
65
|
+
): string | undefined {
|
|
66
|
+
if (request.launchId !== payload.launchId) return "launch ID mismatch";
|
|
67
|
+
if (request.capability !== payload.capability) return "capability mismatch";
|
|
68
|
+
if (request.parentSessionId !== payload.parentSessionId) return "parent session mismatch";
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function buildMergeMessageContent(summary: string): string {
|
|
73
|
+
return `Merged from /btw (side-thread transcript)\n\n<btw-merge>\n${summary.trim()}\n</btw-merge>`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type EntryLike = {
|
|
77
|
+
type: string;
|
|
78
|
+
customType?: string;
|
|
79
|
+
details?: unknown;
|
|
80
|
+
message?: { role?: unknown; content?: unknown };
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** Deduplicate against an already-persisted merge custom message by requestId. */
|
|
84
|
+
export function hasMergedRequestId(entries: EntryLike[], requestId: string): boolean {
|
|
85
|
+
return entries.some(
|
|
86
|
+
(entry) =>
|
|
87
|
+
entry.type === "custom_message" &&
|
|
88
|
+
entry.customType === MERGE_CUSTOM_TYPE &&
|
|
89
|
+
!!entry.details &&
|
|
90
|
+
typeof entry.details === "object" &&
|
|
91
|
+
(entry.details as { requestId?: unknown }).requestId === requestId,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Detect durable evidence that this merge's user prompt was persisted. */
|
|
96
|
+
export function hasSubmittedPromptRequestId(
|
|
97
|
+
entries: EntryLike[],
|
|
98
|
+
requestId: string,
|
|
99
|
+
prompt: string,
|
|
100
|
+
): boolean {
|
|
101
|
+
const mergeIndex = entries.findIndex(
|
|
102
|
+
(entry) =>
|
|
103
|
+
entry.type === "custom_message" &&
|
|
104
|
+
entry.customType === MERGE_CUSTOM_TYPE &&
|
|
105
|
+
!!entry.details &&
|
|
106
|
+
typeof entry.details === "object" &&
|
|
107
|
+
(entry.details as { requestId?: unknown; prompt?: unknown }).requestId === requestId &&
|
|
108
|
+
(entry.details as { requestId?: unknown; prompt?: unknown }).prompt === prompt,
|
|
109
|
+
);
|
|
110
|
+
return mergeIndex >= 0 && entries.slice(mergeIndex + 1).some(
|
|
111
|
+
(entry) => entry.type === "message" && entry.message?.role === "user" && textOfTurn(entry.message.content) === prompt.trim(),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function textOfTurn(content: unknown): string {
|
|
116
|
+
if (typeof content === "string") return content.trim();
|
|
117
|
+
if (!Array.isArray(content)) return "";
|
|
118
|
+
return content
|
|
119
|
+
.filter(
|
|
120
|
+
(block): block is { type: "text"; text: string } =>
|
|
121
|
+
!!block && typeof block === "object" && block.type === "text" && typeof block.text === "string",
|
|
122
|
+
)
|
|
123
|
+
.map((block) => block.text)
|
|
124
|
+
.join("\n")
|
|
125
|
+
.trim();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Keep at most maxBytes of UTF-8 from the tail of the text. */
|
|
129
|
+
function tailBytes(text: string, maxBytes: number): string {
|
|
130
|
+
let sliced = text.slice(-maxBytes);
|
|
131
|
+
while (Buffer.byteLength(sliced, "utf8") > maxBytes) {
|
|
132
|
+
const excess = Buffer.byteLength(sliced, "utf8") - maxBytes;
|
|
133
|
+
sliced = sliced.slice(Math.max(1, Math.ceil(excess / 4)));
|
|
134
|
+
}
|
|
135
|
+
return sliced;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Package the child's own conversation (user/assistant text turns only, no
|
|
140
|
+
* tool payloads) as the merge transcript. When over budget, whole turns are
|
|
141
|
+
* dropped from the head so the most recent findings survive.
|
|
142
|
+
*/
|
|
143
|
+
export function buildMergeTranscript(
|
|
144
|
+
messages: AgentMessage[],
|
|
145
|
+
budgetBytes = MERGE_TRANSCRIPT_BUDGET_BYTES,
|
|
146
|
+
): string | undefined {
|
|
147
|
+
const turns: string[] = [];
|
|
148
|
+
for (const message of messages) {
|
|
149
|
+
const { role, content } = message as { role?: string; content?: unknown };
|
|
150
|
+
if (role !== "user" && role !== "assistant") continue;
|
|
151
|
+
const text = textOfTurn(content);
|
|
152
|
+
if (text) turns.push(`${role === "user" ? "User" : "Assistant"}:\n${text}`);
|
|
153
|
+
}
|
|
154
|
+
if (turns.length === 0) return undefined;
|
|
155
|
+
|
|
156
|
+
const kept: string[] = [];
|
|
157
|
+
let used = 0;
|
|
158
|
+
for (let index = turns.length - 1; index >= 0; index -= 1) {
|
|
159
|
+
const turn = turns[index] as string;
|
|
160
|
+
const bytes = Buffer.byteLength(turn, "utf8") + 2;
|
|
161
|
+
if (used + bytes > budgetBytes) {
|
|
162
|
+
if (kept.length === 0) {
|
|
163
|
+
// A single oversized turn keeps its tail (the latest content).
|
|
164
|
+
kept.unshift(`${TRANSCRIPT_TRUNCATION_NOTE}\n${tailBytes(turn, budgetBytes)}`);
|
|
165
|
+
} else {
|
|
166
|
+
kept.unshift(TRANSCRIPT_TRUNCATION_NOTE);
|
|
167
|
+
}
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
kept.unshift(turn);
|
|
171
|
+
used += bytes;
|
|
172
|
+
}
|
|
173
|
+
return kept.join("\n\n");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export type MergeStorePort = {
|
|
177
|
+
listLaunchPayloadPaths(): Promise<string[]>;
|
|
178
|
+
read(payloadPath: string): Promise<BtwPayload>;
|
|
179
|
+
readMergeRequest(payloadPath: string): Promise<unknown>;
|
|
180
|
+
remove(payloadPath: string): Promise<void>;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
export type ParentSessionPort = {
|
|
184
|
+
getSessionId(): string;
|
|
185
|
+
isIdle(): boolean;
|
|
186
|
+
getBranch(): EntryLike[];
|
|
187
|
+
/** Check model and authentication before consuming a merge request. */
|
|
188
|
+
canSubmitPrompt(): Promise<boolean>;
|
|
189
|
+
sendMergeMessage(content: string, details: { requestId: string; launchId: string; prompt: string }): void;
|
|
190
|
+
/** Submit the merge prompt as a user message that triggers a model turn. */
|
|
191
|
+
submitPrompt(prompt: string): void;
|
|
192
|
+
notify(message: string, type: "info" | "warning" | "error"): void;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
export type ScanResult = {
|
|
196
|
+
delivered: number;
|
|
197
|
+
deferred: number;
|
|
198
|
+
rejected: number;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Parent-side merge coordinator. Scans the private launch store for pending
|
|
203
|
+
* merge requests bound to the current parent session and consumes each after
|
|
204
|
+
* delivery or rejection.
|
|
205
|
+
*/
|
|
206
|
+
export class MergeCoordinator {
|
|
207
|
+
private readonly store: MergeStorePort;
|
|
208
|
+
private readonly session: ParentSessionPort;
|
|
209
|
+
private scanning = false;
|
|
210
|
+
|
|
211
|
+
constructor(store: MergeStorePort, session: ParentSessionPort) {
|
|
212
|
+
this.store = store;
|
|
213
|
+
this.session = session;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async scan(): Promise<ScanResult> {
|
|
217
|
+
if (this.scanning) return { delivered: 0, deferred: 0, rejected: 0 };
|
|
218
|
+
this.scanning = true;
|
|
219
|
+
try {
|
|
220
|
+
return await this.scanOnce();
|
|
221
|
+
} finally {
|
|
222
|
+
this.scanning = false;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private async scanOnce(): Promise<ScanResult> {
|
|
227
|
+
const result: ScanResult = { delivered: 0, deferred: 0, rejected: 0 };
|
|
228
|
+
let payloadPaths: string[];
|
|
229
|
+
try {
|
|
230
|
+
payloadPaths = await this.store.listLaunchPayloadPaths();
|
|
231
|
+
} catch {
|
|
232
|
+
return result;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
for (const payloadPath of payloadPaths) {
|
|
236
|
+
if (result.delivered > 0) break;
|
|
237
|
+
try {
|
|
238
|
+
await this.processLaunch(payloadPath, result);
|
|
239
|
+
} catch {
|
|
240
|
+
// Unsafe or unreadable launch directories are skipped, never trusted.
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return result;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private async processLaunch(payloadPath: string, result: ScanResult): Promise<void> {
|
|
247
|
+
const rawRequest = await this.store.readMergeRequest(payloadPath);
|
|
248
|
+
if (rawRequest === undefined) return;
|
|
249
|
+
|
|
250
|
+
const payload = await this.store.read(payloadPath);
|
|
251
|
+
// Only the session a launch is bound to may consume its merge requests.
|
|
252
|
+
if (payload.parentSessionId !== this.session.getSessionId()) return;
|
|
253
|
+
|
|
254
|
+
if (!isMergeRequest(rawRequest)) {
|
|
255
|
+
await this.reject(payloadPath, "malformed merge request");
|
|
256
|
+
result.rejected += 1;
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const validationError = validateRequestAgainstPayload(rawRequest, payload);
|
|
260
|
+
if (validationError) {
|
|
261
|
+
await this.reject(payloadPath, validationError);
|
|
262
|
+
result.rejected += 1;
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const entries = this.session.getBranch();
|
|
267
|
+
if (hasSubmittedPromptRequestId(entries, rawRequest.requestId, rawRequest.prompt)) {
|
|
268
|
+
await this.store.remove(payloadPath);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// pi.sendUserMessage() is fire-and-forget. Keep the request pending when
|
|
273
|
+
// current model/auth state would reject its prompt.
|
|
274
|
+
try {
|
|
275
|
+
if (!(await this.session.canSubmitPrompt())) {
|
|
276
|
+
result.deferred += 1;
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
} catch {
|
|
280
|
+
result.deferred += 1;
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (!this.session.isIdle()) {
|
|
285
|
+
// Never steer or queue a model turn mid-stream; retry on agent_settled.
|
|
286
|
+
result.deferred += 1;
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Refresh branch evidence after authentication; branch can change while the
|
|
291
|
+
// asynchronous model check is pending.
|
|
292
|
+
const currentEntries = this.session.getBranch();
|
|
293
|
+
if (hasSubmittedPromptRequestId(currentEntries, rawRequest.requestId, rawRequest.prompt)) {
|
|
294
|
+
await this.store.remove(payloadPath);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Re-check the session binding immediately before appending.
|
|
299
|
+
if (payload.parentSessionId !== this.session.getSessionId()) return;
|
|
300
|
+
if (!hasMergedRequestId(currentEntries, rawRequest.requestId)) {
|
|
301
|
+
this.session.sendMergeMessage(buildMergeMessageContent(rawRequest.summary), {
|
|
302
|
+
requestId: rawRequest.requestId,
|
|
303
|
+
launchId: rawRequest.launchId,
|
|
304
|
+
prompt: rawRequest.prompt,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
this.session.submitPrompt(rawRequest.prompt);
|
|
308
|
+
result.delivered += 1;
|
|
309
|
+
await this.store.remove(payloadPath);
|
|
310
|
+
this.session.notify("Merged a /btw side thread into this session; continuing with its prompt.", "info");
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private async reject(payloadPath: string, reason: string): Promise<void> {
|
|
314
|
+
await this.store.remove(payloadPath);
|
|
315
|
+
this.session.notify(`Rejected a /btw merge request: ${reason}`, "warning");
|
|
316
|
+
}
|
|
317
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export type BtwRoute =
|
|
2
|
+
| { kind: "open" }
|
|
3
|
+
| { kind: "ask"; question: string }
|
|
4
|
+
| { kind: "config"; args: string }
|
|
5
|
+
| { kind: "merge"; text: string }
|
|
6
|
+
| { kind: "help" };
|
|
7
|
+
|
|
8
|
+
export const HELP_TEXT = `/btw usage:
|
|
9
|
+
/btw open an empty side pane
|
|
10
|
+
/btw <question...> open a side pane with a draft question
|
|
11
|
+
/btw ask <question...> explicit form for questions starting with a reserved word
|
|
12
|
+
/btw config [...] show or change defaults (auto-submit, tools, split, reset)
|
|
13
|
+
/btw merge <prompt...> fold this side thread into the parent and continue with the prompt
|
|
14
|
+
/btw help show this grammar`;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Exact first-word routing. Only the reserved words `ask`, `config`, `merge`,
|
|
18
|
+
* and `help` are subcommands; any other first word keeps the whole input as a
|
|
19
|
+
* question. `/btw ask ...` is the escape hatch for questions that begin with a
|
|
20
|
+
* reserved word.
|
|
21
|
+
*/
|
|
22
|
+
export function parseBtwCommand(input: string): BtwRoute {
|
|
23
|
+
const trimmed = input.trim();
|
|
24
|
+
if (!trimmed) return { kind: "open" };
|
|
25
|
+
|
|
26
|
+
const spaceIndex = trimmed.search(/\s/);
|
|
27
|
+
const first = spaceIndex === -1 ? trimmed : trimmed.slice(0, spaceIndex);
|
|
28
|
+
const rest = spaceIndex === -1 ? "" : trimmed.slice(spaceIndex).trim();
|
|
29
|
+
|
|
30
|
+
switch (first) {
|
|
31
|
+
case "ask":
|
|
32
|
+
return rest ? { kind: "ask", question: rest } : { kind: "open" };
|
|
33
|
+
case "config":
|
|
34
|
+
return { kind: "config", args: rest };
|
|
35
|
+
case "merge":
|
|
36
|
+
return { kind: "merge", text: rest };
|
|
37
|
+
case "help":
|
|
38
|
+
return { kind: "help" };
|
|
39
|
+
default:
|
|
40
|
+
return { kind: "ask", question: trimmed };
|
|
41
|
+
}
|
|
42
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@henryqw/pi-herdr-btw",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Open and merge tool-enabled Pi side threads in Herdr panes.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi",
|
|
8
|
+
"herdr",
|
|
9
|
+
"side-thread",
|
|
10
|
+
"btw"
|
|
11
|
+
],
|
|
12
|
+
"type": "module",
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=22.19.0"
|
|
15
|
+
},
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"files": [
|
|
18
|
+
"extensions",
|
|
19
|
+
"internal",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "node --test test/*.test.ts",
|
|
25
|
+
"typecheck": "tsc --noEmit --allowImportingTsExtensions --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck extensions/btw.ts internal/*.ts test/*.test.ts",
|
|
26
|
+
"pack:check": "npm pack --dry-run"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@earendil-works/pi-agent-core": "^0.84.2",
|
|
30
|
+
"@earendil-works/pi-coding-agent": "^0.84.2"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/HenryQW/pi-packages.git",
|
|
35
|
+
"directory": "packages/pi-herdr-btw"
|
|
36
|
+
},
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/HenryQW/pi-packages/issues"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"pi": {
|
|
44
|
+
"extensions": [
|
|
45
|
+
"./extensions/btw.ts"
|
|
46
|
+
]
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@henryqw/pi-herdr": "^0.1.1",
|
|
50
|
+
"@henryqw/pi-task-models": "^0.3.0"
|
|
51
|
+
}
|
|
52
|
+
}
|