hob-bot 1.0.3 → 1.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/README.md +75 -5
- package/README.zh-CN.md +75 -5
- package/dist/web/assets/DesktopView-rVxacveF.js +5 -0
- package/dist/web/assets/index-B-8ISOr1.js +267 -0
- package/dist/web/assets/index-Cu1Pf8yX.css +1 -0
- package/dist/web/assets/{math-zZ4JA8eM.js → math-weKGDUmL.js} +2 -2
- package/dist/web/index.html +2 -2
- package/dist/web/mock/desktop.jpg +0 -0
- package/dist/web/mock/photo.jpg +0 -0
- package/dist/web/mock/photo2.jpg +0 -0
- package/dist/web/mock/sunset.html +90 -0
- package/dist/web/mock//347/201/253/346/230/237/346/227/245/350/220/275/347/244/272/346/204/217/345/233/276.md +34 -0
- package/package.json +10 -2
- package/server/src/actions.ts +24 -57
- package/server/src/assistant-skills.ts +201 -0
- package/server/src/boot.ts +1 -0
- package/server/src/claude-status.ts +47 -0
- package/server/src/config.ts +3 -1
- package/server/src/delegations.ts +40 -12
- package/server/src/desktop-control.ts +152 -0
- package/server/src/desktop-events.ts +63 -0
- package/server/src/desktop-public.ts +43 -0
- package/server/src/desktop-tools.ts +161 -0
- package/server/src/desktop.ts +76 -0
- package/server/src/embedding.ts +52 -0
- package/server/src/files.ts +63 -43
- package/server/src/http.ts +334 -15
- package/server/src/jobs.ts +330 -59
- package/server/src/knowledge-index.ts +123 -0
- package/server/src/knowledge-tools.ts +94 -0
- package/server/src/knowledge.ts +451 -0
- package/server/src/lore-entry.ts +10 -0
- package/server/src/main.ts +6 -0
- package/server/src/mcp.ts +745 -0
- package/server/src/memory.ts +11 -4
- package/server/src/now.ts +33 -0
- package/server/src/overview.ts +1 -1
- package/server/src/pages.ts +1 -1
- package/server/src/pi.ts +92 -16
- package/server/src/prompt.ts +46 -154
- package/server/src/providers.ts +3 -2
- package/server/src/push.ts +42 -7
- package/server/src/router-activity.ts +4 -0
- package/server/src/router-tools.ts +132 -38
- package/server/src/router.ts +159 -19
- package/server/src/routines.ts +14 -4
- package/server/src/runtime.ts +15 -0
- package/server/src/settings.ts +43 -1
- package/server/src/store.ts +4 -3
- package/server/src/thumbs.ts +49 -0
- package/server/src/transcript.ts +57 -4
- package/server/src/worker-tools.ts +14 -12
- package/shared/knowledge.ts +92 -0
- package/shared/mcp.ts +91 -0
- package/shared/media.ts +6 -0
- package/shared/plain-text.ts +31 -0
- package/shared/skills.ts +18 -0
- package/shared/sprites.ts +198 -0
- package/shared/types.ts +105 -20
- package/vendor/claude-bridge/HOB-PATCH.md +10 -0
- package/vendor/claude-bridge/LICENSE +21 -0
- package/vendor/claude-bridge/package.json +62 -0
- package/vendor/claude-bridge/src/agents-md.ts +14 -0
- package/vendor/claude-bridge/src/askclaude-schema.ts +92 -0
- package/vendor/claude-bridge/src/askclaude-ui.ts +90 -0
- package/vendor/claude-bridge/src/attachments.ts +135 -0
- package/vendor/claude-bridge/src/config.ts +105 -0
- package/vendor/claude-bridge/src/convert.ts +222 -0
- package/vendor/claude-bridge/src/extract-tool-results.ts +47 -0
- package/vendor/claude-bridge/src/index.ts +2503 -0
- package/vendor/claude-bridge/src/log-paths.ts +19 -0
- package/vendor/claude-bridge/src/mcp-server.ts +169 -0
- package/vendor/claude-bridge/src/models.ts +164 -0
- package/vendor/claude-bridge/src/prompt-capture.ts +420 -0
- package/vendor/claude-bridge/src/prompt-stream.ts +102 -0
- package/vendor/claude-bridge/src/query-state.ts +118 -0
- package/vendor/claude-bridge/src/rate-limit.ts +48 -0
- package/vendor/claude-bridge/src/session-cwd.ts +38 -0
- package/vendor/claude-bridge/src/session-verify.ts +38 -0
- package/vendor/claude-bridge/src/skills.ts +20 -0
- package/vendor/claude-bridge/src/transcript.ts +86 -0
- package/vendor/claude-bridge/src/usage.ts +53 -0
- package/vendor/lore-pi-extension/package.json +1 -1
- package/vendor/lore-pi-extension/tools.ts +8 -5
- package/dist/web/assets/index-C72gAwgN.css +0 -1
- package/dist/web/assets/index-Dyw5G8Pg.js +0 -230
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import type { Skill } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { formatProjectContext } from "./agents-md.js";
|
|
3
|
+
import { debugLogPath } from "./log-paths.js";
|
|
4
|
+
import { renderSkillsBlock, type SkillReadTool } from "./skills.js";
|
|
5
|
+
|
|
6
|
+
// What pi assembled for one agent, kept so the bridge can append only the
|
|
7
|
+
// portable parts after Claude Code's own preset.
|
|
8
|
+
|
|
9
|
+
export type PromptCaptureInput = {
|
|
10
|
+
custom?: string;
|
|
11
|
+
append?: string;
|
|
12
|
+
contextFiles: { path: string; content: string }[];
|
|
13
|
+
skills: Skill[];
|
|
14
|
+
/** Custom prompt sections from `systemPromptOptions.sections`, raw content keyed by
|
|
15
|
+
* section name. pi renders each one as `<name>\ncontent\n</name>` after the built-in
|
|
16
|
+
* sections; the projection does the same. */
|
|
17
|
+
sections?: Record<string, string>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type InheritedPrompt = {
|
|
21
|
+
start: number;
|
|
22
|
+
end: number;
|
|
23
|
+
parent: PromptCapture;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type PromptCapture = PromptCaptureInput & {
|
|
27
|
+
assembledPrompt: string;
|
|
28
|
+
/** Which bridge boundary last recorded this key (before_agent_start | agent_start | turn_start). */
|
|
29
|
+
source?: string;
|
|
30
|
+
/** Exact previously assembled prompts embedded in `custom`. */
|
|
31
|
+
inherited: InheritedPrompt[];
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Captures keyed by the fully assembled prompt pi sends to a provider.
|
|
36
|
+
*
|
|
37
|
+
* A sub-agent's systemPromptOverride embeds its parent's assembled prompt
|
|
38
|
+
* verbatim. Pi currently exposes that override as an ordinary custom prompt,
|
|
39
|
+
* without provenance. Linking exact prior keys recovers the inheritance graph
|
|
40
|
+
* without recognizing pi prose or sub-agent markers. If pi later exposes an
|
|
41
|
+
* inherited-system-prompt field, it should replace this inference.
|
|
42
|
+
*/
|
|
43
|
+
export type PromptCaptureDiagnostic = {
|
|
44
|
+
/** The prompt that matched nothing: the full system prompt is too big to log
|
|
45
|
+
* inline, so a fingerprint plus the closest match's first divergent offset
|
|
46
|
+
* are enough to recognize the pump.
|
|
47
|
+
*
|
|
48
|
+
* Closest is by shared prefix — the case that matters here is pi itself
|
|
49
|
+
* rebuilding the prompt outside `before_agent_start` (a changed tool list or
|
|
50
|
+
* fresh resource discovery), which edits near the boundary, and a prefix key
|
|
51
|
+
* gets us to within a handful of characters of where. */
|
|
52
|
+
systemPrompt: string;
|
|
53
|
+
matches: { key: string; firstDivergent: number; source?: string }[];
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export class PromptCaptures {
|
|
57
|
+
private readonly captures = new Map<string, PromptCapture>();
|
|
58
|
+
/** Invoked with everything that would otherwise be lost when resolution throws,
|
|
59
|
+
* so the bridge can write it to its debug log. Kept off the throw path itself:
|
|
60
|
+
* the resolver is hot and the caller may own a faster sink than string-building.
|
|
61
|
+
*
|
|
62
|
+
* Set by the bridge on the shared instance; tests that want the diagnostic can
|
|
63
|
+
* pass one per instance. */
|
|
64
|
+
private readonly onDiagnose: (diagnostic: PromptCaptureDiagnostic) => void;
|
|
65
|
+
|
|
66
|
+
/** Pi rebuilds prompts when tools change, so retain only recent lookup keys.
|
|
67
|
+
* Inheritance edges hold direct references and survive key eviction.
|
|
68
|
+
*
|
|
69
|
+
* Set well above any plausible working set because the costs are lopsided: a
|
|
70
|
+
* capture is tens of KB, while evicting one that is still live fails the turn.
|
|
71
|
+
* A parent that fans out to more distinct sub-agent prompts than this before its
|
|
72
|
+
* own next turn would be evicted despite being in use. The bound exists only to
|
|
73
|
+
* cap an extension that rebuilds the prompt every turn, which would otherwise
|
|
74
|
+
* grow keys without limit. */
|
|
75
|
+
constructor(private readonly limit = 256, onDiagnose?: (diagnostic: PromptCaptureDiagnostic) => void) {
|
|
76
|
+
this.onDiagnose = onDiagnose ?? (() => {});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
record(systemPrompt: string, input: PromptCaptureInput, source?: string): void {
|
|
80
|
+
const existing = this.captures.get(systemPrompt);
|
|
81
|
+
const customChanged = existing?.custom !== input.custom;
|
|
82
|
+
const capture = existing ?? {
|
|
83
|
+
...input,
|
|
84
|
+
assembledPrompt: systemPrompt,
|
|
85
|
+
contextFiles: [],
|
|
86
|
+
skills: [],
|
|
87
|
+
inherited: [],
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
capture.custom = input.custom;
|
|
91
|
+
capture.append = input.append;
|
|
92
|
+
capture.contextFiles = input.contextFiles.map((file) => ({ ...file }));
|
|
93
|
+
capture.skills = [...input.skills];
|
|
94
|
+
// Copied, not referenced: the caller's systemPromptOptions is a live object that
|
|
95
|
+
// later before_agent_start handlers mutate.
|
|
96
|
+
capture.sections = { ...input.sections };
|
|
97
|
+
capture.source = source;
|
|
98
|
+
if (!existing || customChanged) {
|
|
99
|
+
capture.inherited = this.findInheritedPrompts(systemPrompt, input.custom);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Mutate an existing node in place so descendants retain a live reference,
|
|
103
|
+
// then re-insert its key so Map order tracks recency.
|
|
104
|
+
this.touch(systemPrompt, capture);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Exact lookup only. Callers serving a query want `resolveOrDerive`. */
|
|
108
|
+
resolve(systemPrompt?: string): PromptCapture | undefined {
|
|
109
|
+
if (!systemPrompt) return undefined;
|
|
110
|
+
const capture = this.captures.get(systemPrompt);
|
|
111
|
+
if (capture) this.touch(systemPrompt, capture);
|
|
112
|
+
return capture;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Recency is by use, not just by record. A parent agent records its prompt once
|
|
116
|
+
* and then only ever resolves it, so counting writes alone ages it out behind the
|
|
117
|
+
* sub-agent prompts churning past it — observed in a real 135-message session,
|
|
118
|
+
* where the parent's own prompt was evicted and its next turn resolved to
|
|
119
|
+
* nothing. */
|
|
120
|
+
private touch(systemPrompt: string, capture: PromptCapture): void {
|
|
121
|
+
this.captures.delete(systemPrompt);
|
|
122
|
+
this.captures.set(systemPrompt, capture);
|
|
123
|
+
// Trims here, not only in record(): reviving an evicted node re-adds a key that
|
|
124
|
+
// was not in the map, so without this a run of revivals grows it without bound.
|
|
125
|
+
for (const key of this.captures.keys()) {
|
|
126
|
+
if (this.captures.size <= this.limit) break;
|
|
127
|
+
this.captures.delete(key);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The capture to project for one query, for both the provider and AskClaude.
|
|
133
|
+
*
|
|
134
|
+
* An exact key is the normal case. A prompt that only *embeds* known prompts —
|
|
135
|
+
* anything that wrapped what Pi assembled after we recorded it — resolves to a
|
|
136
|
+
* transient descendant over the whole prompt, so projection swaps each embedded
|
|
137
|
+
* capture for its portable parts and carries everything around them through
|
|
138
|
+
* unchanged. That surrounding text belongs to whatever did the wrapping, and
|
|
139
|
+
* dropping it would be exactly the silent instruction loss this exists to
|
|
140
|
+
* prevent. The descendant is not retained — its key is not ours to own.
|
|
141
|
+
*
|
|
142
|
+
* Throws when a prompt can be accounted for by neither route. Returning an empty
|
|
143
|
+
* capture instead would hand Claude Code a turn with none of the user's context
|
|
144
|
+
* files, skills, custom prompt or append text, and say so only in a debug line —
|
|
145
|
+
* silently discarding policy the user wrote down. A failed turn is recoverable;
|
|
146
|
+
* a turn that quietly ignored its instructions is not.
|
|
147
|
+
*/
|
|
148
|
+
resolveOrDerive(systemPrompt?: string): PromptCapture | undefined {
|
|
149
|
+
if (!systemPrompt) return undefined;
|
|
150
|
+
const exact = this.captures.get(systemPrompt);
|
|
151
|
+
if (exact) {
|
|
152
|
+
this.touch(systemPrompt, exact);
|
|
153
|
+
return exact;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// A capture outlives its lookup key: eviction drops the key while inheritance
|
|
157
|
+
// edges keep the node alive. findInheritedPrompts deliberately skips a node whose
|
|
158
|
+
// key *is* the prompt, so without this an evicted exact match would derive
|
|
159
|
+
// nothing and throw. Touching it puts the key back.
|
|
160
|
+
const revived = this.reachableCaptures().find((node) => node.assembledPrompt === systemPrompt);
|
|
161
|
+
if (revived) {
|
|
162
|
+
this.touch(systemPrompt, revived);
|
|
163
|
+
return revived;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Inheritance must be tried before any tolerance/adoption route. A sub-agent
|
|
167
|
+
// child that embeds its parent's prompt verbatim contains every portable part
|
|
168
|
+
// of the parent's capture, so an "adopt the capture whose portable parts all
|
|
169
|
+
// appear here" heuristic (as drafted in upstream PR #76's findPortableMatch)
|
|
170
|
+
// placed above this route would match first, re-key the PARENT's capture under
|
|
171
|
+
// the child's prompt, and silently drop the child's wrapper text — exactly the
|
|
172
|
+
// instruction loss the throw exists to prevent. If such a route is ever added,
|
|
173
|
+
// it belongs below this block.
|
|
174
|
+
const embedded = this.findInheritedPrompts(systemPrompt, systemPrompt);
|
|
175
|
+
if (embedded.length === 0) {
|
|
176
|
+
const matches = this.closestKnown(systemPrompt);
|
|
177
|
+
this.onDiagnose({ systemPrompt, matches });
|
|
178
|
+
// The query is a recorded key minus its tail. That shape is the signature of
|
|
179
|
+
// pi#5581: an extension-triggered idle turn (sendMessage with triggerTurn) skips
|
|
180
|
+
// before_agent_start, so the prompt renders without that turn's extension
|
|
181
|
+
// additions. The capture cannot serve this turn either way, so name the bug.
|
|
182
|
+
const truncated = matches.find(
|
|
183
|
+
(m) => m.firstDivergent === systemPrompt.length && m.key.length > systemPrompt.length,
|
|
184
|
+
);
|
|
185
|
+
if (truncated) {
|
|
186
|
+
throw new Error(
|
|
187
|
+
`prompt-capture: this ${systemPrompt.length}-char prompt is the ${truncated.key.length}-char capture recorded at `
|
|
188
|
+
+ `${truncated.source ?? "an unknown boundary"} with its final ${truncated.key.length - systemPrompt.length} chars missing. `
|
|
189
|
+
+ `That shape matches pi#5581: an extension-triggered idle turn (sendMessage with triggerTurn) skips before_agent_start, `
|
|
190
|
+
+ `so the prompt lacks that turn's extension additions. The turn fails; the next user-typed turn resolves normally. `
|
|
191
|
+
+ `Workaround: when idle, send a user message instead of triggerTurn.`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
throw new Error(
|
|
195
|
+
`prompt-capture: no capture for this ${systemPrompt.length}-char system prompt, and it embeds none of the ${this.captures.size} known. `
|
|
196
|
+
+ `Closest known match diverges at offset ${matches[0]?.firstDivergent ?? "?"} `
|
|
197
|
+
+ `(${matches.length ? matches[0].key.length : 0}-char key${matches[0]?.source ? `, last recorded at ${matches[0].source}` : ""}). `
|
|
198
|
+
+ `Claude Code would receive none of this turn's context files, skills or custom instructions. `
|
|
199
|
+
+ `When the divergence sits at a section's opening tag, the capture and this prompt disagree about that section — `
|
|
200
|
+
+ `pi's mcp_servers since pi 0.99.2, or one an extension added. `
|
|
201
|
+
+ `Otherwise an extension loaded after claude-bridge rewrote the system prompt from before_agent_start — `
|
|
202
|
+
+ `one that wraps it is fine, one that rebuilds or strips it leaves nothing to match — `
|
|
203
|
+
+ `or pi rebuilt the prompt outside before_agent_start (a late-registered tool or fresh resource discovery).`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// `custom` is the prompt itself and the edges keep their original offsets, so
|
|
208
|
+
// projectCustom substitutes the embedded captures in place and preserves every
|
|
209
|
+
// byte between and around them.
|
|
210
|
+
return { assembledPrompt: systemPrompt, custom: systemPrompt, contextFiles: [], skills: [], inherited: embedded };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
get size(): number {
|
|
214
|
+
return this.captures.size;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Longest shared-prefix matches, best first, for the throw diagnostic. */
|
|
218
|
+
private closestKnown(systemPrompt: string): { key: string; firstDivergent: number; source?: string }[] {
|
|
219
|
+
let shared = 0;
|
|
220
|
+
const matches: { key: string; firstDivergent: number; source?: string }[] = [];
|
|
221
|
+
for (const [key, capture] of this.captures.entries()) {
|
|
222
|
+
const limit = Math.min(key.length, systemPrompt.length);
|
|
223
|
+
let i = 0;
|
|
224
|
+
while (i < limit && key.charCodeAt(i) === systemPrompt.charCodeAt(i)) i++;
|
|
225
|
+
if (i >= shared) {
|
|
226
|
+
if (i > shared) {
|
|
227
|
+
shared = i;
|
|
228
|
+
matches.length = 0;
|
|
229
|
+
}
|
|
230
|
+
matches.push({ key, firstDivergent: i, source: capture.source });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return matches;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private findInheritedPrompts(systemPrompt: string, custom?: string): InheritedPrompt[] {
|
|
237
|
+
if (!custom) return [];
|
|
238
|
+
|
|
239
|
+
const candidates: Array<InheritedPrompt & { length: number }> = [];
|
|
240
|
+
for (const parent of this.reachableCaptures()) {
|
|
241
|
+
const key = parent.assembledPrompt;
|
|
242
|
+
if (key === systemPrompt || key.length === 0) continue;
|
|
243
|
+
for (let start = custom.indexOf(key); start !== -1; start = custom.indexOf(key, start + key.length)) {
|
|
244
|
+
candidates.push({ start, end: start + key.length, length: key.length, parent });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// A grandchild contains both its parent's key and the grandparent key
|
|
249
|
+
// nested inside it. Keep the longest exact non-overlapping matches.
|
|
250
|
+
candidates.sort((a, b) => b.length - a.length || a.start - b.start);
|
|
251
|
+
const selected: InheritedPrompt[] = [];
|
|
252
|
+
for (const candidate of candidates) {
|
|
253
|
+
if (selected.some((edge) => candidate.start < edge.end && candidate.end > edge.start)) continue;
|
|
254
|
+
selected.push({ start: candidate.start, end: candidate.end, parent: candidate.parent });
|
|
255
|
+
}
|
|
256
|
+
return selected.sort((a, b) => a.start - b.start);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private reachableCaptures(): PromptCapture[] {
|
|
260
|
+
const result: PromptCapture[] = [];
|
|
261
|
+
const seen = new Set<PromptCapture>();
|
|
262
|
+
const visit = (capture: PromptCapture): void => {
|
|
263
|
+
if (seen.has(capture)) return;
|
|
264
|
+
seen.add(capture);
|
|
265
|
+
result.push(capture);
|
|
266
|
+
for (const edge of capture.inherited) visit(edge.parent);
|
|
267
|
+
};
|
|
268
|
+
for (const capture of this.captures.values()) visit(capture);
|
|
269
|
+
return result;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Pi's own preamble, the first section of every prompt pi renders for a session
|
|
274
|
+
* without a custom prompt. Machine-generated, so operator text never carries it;
|
|
275
|
+
* forwarding it makes Claude Code's subscription path read the request as a
|
|
276
|
+
* third-party app. */
|
|
277
|
+
export const PI_PREAMBLE = "You are an expert coding assistant operating inside pi";
|
|
278
|
+
|
|
279
|
+
/** Both doc paths from pi's documentation-routing line. Anthropic's subscription gate
|
|
280
|
+
* rejects a system prompt carrying both, while either alone passes (issues #883, #88). */
|
|
281
|
+
const ANTHROPIC_THIRD_PARTY_TRIGGERS = ["docs/custom-provider.md", "docs/packages.md"];
|
|
282
|
+
|
|
283
|
+
/** One piece of the append, named so a refusal can say where it found the text. */
|
|
284
|
+
type PromptPart = { label: string; text: string };
|
|
285
|
+
|
|
286
|
+
const SHARED_CAPTURES_KEY = Symbol.for("claude-bridge:promptCaptures");
|
|
287
|
+
|
|
288
|
+
/** Isolated agents re-evaluate this module; a process-wide instance lets the pinned
|
|
289
|
+
* stream resolve their captures (issue #64). The first instance's onDiagnose wins —
|
|
290
|
+
* later callers reuse the instance as-is. Never cleared at session_shutdown: identical
|
|
291
|
+
* keys carry identical portable parts, so cross-session reuse is safe. */
|
|
292
|
+
export function sharedPromptCaptures(onDiagnose?: (diagnostic: PromptCaptureDiagnostic) => void): PromptCaptures {
|
|
293
|
+
const globals = globalThis as Record<symbol, PromptCaptures | undefined>;
|
|
294
|
+
return (globals[SHARED_CAPTURES_KEY] ??= new PromptCaptures(256, onDiagnose));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function projectPromptCapture(
|
|
298
|
+
capture: PromptCapture,
|
|
299
|
+
options: { skillReadTool: SkillReadTool },
|
|
300
|
+
): string | undefined {
|
|
301
|
+
return projectCapture(capture, options, new Set());
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Skills visible through inherited prompts, ancestor first and once per file. */
|
|
305
|
+
export function collectPromptSkills(capture: PromptCapture): Skill[] {
|
|
306
|
+
const result: Skill[] = [];
|
|
307
|
+
const seenPaths = new Set<string>();
|
|
308
|
+
const visited = new Set<PromptCapture>();
|
|
309
|
+
const visiting = new Set<PromptCapture>();
|
|
310
|
+
|
|
311
|
+
const visit = (node: PromptCapture): void => {
|
|
312
|
+
if (visited.has(node)) return;
|
|
313
|
+
if (visiting.has(node)) throw new Error("Cyclic prompt inheritance");
|
|
314
|
+
visiting.add(node);
|
|
315
|
+
for (const edge of node.inherited) visit(edge.parent);
|
|
316
|
+
for (const skill of node.skills) {
|
|
317
|
+
if (skill.disableModelInvocation || seenPaths.has(skill.filePath)) continue;
|
|
318
|
+
seenPaths.add(skill.filePath);
|
|
319
|
+
result.push(skill);
|
|
320
|
+
}
|
|
321
|
+
visiting.delete(node);
|
|
322
|
+
visited.add(node);
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
visit(capture);
|
|
326
|
+
return result;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function projectCapture(
|
|
330
|
+
capture: PromptCapture,
|
|
331
|
+
options: { skillReadTool: SkillReadTool },
|
|
332
|
+
visiting: Set<PromptCapture>,
|
|
333
|
+
): string | undefined {
|
|
334
|
+
if (visiting.has(capture)) throw new Error("Cyclic prompt inheritance");
|
|
335
|
+
visiting.add(capture);
|
|
336
|
+
try {
|
|
337
|
+
const inheritedSkillPaths = new Set(
|
|
338
|
+
capture.inherited.flatMap((edge) => collectPromptSkills(edge.parent).map((skill) => skill.filePath)),
|
|
339
|
+
);
|
|
340
|
+
const ownSkillPaths = new Set<string>();
|
|
341
|
+
const ownSkills = capture.skills.filter((skill) => {
|
|
342
|
+
if (skill.disableModelInvocation || inheritedSkillPaths.has(skill.filePath) || ownSkillPaths.has(skill.filePath)) {
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
ownSkillPaths.add(skill.filePath);
|
|
346
|
+
return true;
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
const custom = projectCustom(capture, options, visiting);
|
|
350
|
+
const parts: PromptPart[] = [];
|
|
351
|
+
const context = formatProjectContext(capture.contextFiles);
|
|
352
|
+
if (context) parts.push({ label: "the project context block", text: context });
|
|
353
|
+
const skills = renderSkillsBlock(ownSkills, options.skillReadTool);
|
|
354
|
+
if (skills) parts.push({ label: "the skills block", text: skills });
|
|
355
|
+
if (custom) parts.push({ label: "the custom prompt", text: custom });
|
|
356
|
+
if (capture.append) parts.push({ label: "the appended instructions", text: capture.append });
|
|
357
|
+
// pi's builder renders custom sections last, after `cwd` — the one built-in section
|
|
358
|
+
// with nothing portable to forward — so they follow the append here.
|
|
359
|
+
for (const [name, content] of Object.entries(capture.sections ?? {})) {
|
|
360
|
+
if (!content) continue;
|
|
361
|
+
parts.push({ label: `the ${name} section`, text: `<${name}>\n${content}\n</${name}>` });
|
|
362
|
+
}
|
|
363
|
+
assertSendablePrompt(parts, capture);
|
|
364
|
+
return parts.length > 0 ? parts.map((part) => part.text).join("\n\n") : undefined;
|
|
365
|
+
} finally {
|
|
366
|
+
visiting.delete(capture);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function assertSendablePrompt(parts: readonly PromptPart[], capture: PromptCapture): void {
|
|
371
|
+
const findings: string[] = [];
|
|
372
|
+
for (const { label, text } of parts) {
|
|
373
|
+
const offset = preambleAtLineStart(text);
|
|
374
|
+
if (offset !== -1) {
|
|
375
|
+
findings.push(`pi's preamble ("${PI_PREAMBLE}") in ${label}, at offset ${offset} of ${text.length} chars`);
|
|
376
|
+
}
|
|
377
|
+
// The pair has to co-occur in one part; the two phrases split across parts are not detected.
|
|
378
|
+
if (ANTHROPIC_THIRD_PARTY_TRIGGERS.every((trigger) => text.includes(trigger))) {
|
|
379
|
+
findings.push(`${ANTHROPIC_THIRD_PARTY_TRIGGERS.join(" and ")} in ${label}`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
if (findings.length === 0) return;
|
|
383
|
+
|
|
384
|
+
throw new Error([
|
|
385
|
+
"prompt-capture: refusing to send this prompt. Claude Code's Anthropic path reads a request",
|
|
386
|
+
" carrying pi's harness, or the phrase pair its subscription gate rejects, as a third-party",
|
|
387
|
+
" app: it fails with 400 or is billed as extra usage.",
|
|
388
|
+
...findings.map((finding) => ` Found: ${finding}.`),
|
|
389
|
+
` Capture: ${capture.source ?? "unknown"}, ${capture.inherited.length} inherited capture(s) substituted.`,
|
|
390
|
+
" If this came from an inherited pi prompt, see README \"Compatibility with other extensions\".",
|
|
391
|
+
" If it is your own text, reword or remove it. CLAUDE_BRIDGE_DEBUG=1 writes the full prompt to",
|
|
392
|
+
` ${debugLogPath()}.`,
|
|
393
|
+
].join("\n"));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Offset of pi's preamble at the start of a line, or -1. Mid-line mentions are someone
|
|
397
|
+
* describing pi's prompt, not pi's prompt. */
|
|
398
|
+
function preambleAtLineStart(text: string): number {
|
|
399
|
+
for (let offset = text.indexOf(PI_PREAMBLE); offset !== -1; offset = text.indexOf(PI_PREAMBLE, offset + PI_PREAMBLE.length)) {
|
|
400
|
+
if (offset === 0 || text[offset - 1] === "\n") return offset;
|
|
401
|
+
}
|
|
402
|
+
return -1;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function projectCustom(
|
|
406
|
+
capture: PromptCapture,
|
|
407
|
+
options: { skillReadTool: SkillReadTool },
|
|
408
|
+
visiting: Set<PromptCapture>,
|
|
409
|
+
): string | undefined {
|
|
410
|
+
if (!capture.custom || capture.inherited.length === 0) return capture.custom;
|
|
411
|
+
|
|
412
|
+
let result = "";
|
|
413
|
+
let cursor = 0;
|
|
414
|
+
for (const edge of capture.inherited) {
|
|
415
|
+
result += capture.custom.slice(cursor, edge.start);
|
|
416
|
+
result += projectCapture(edge.parent, options, visiting) ?? "";
|
|
417
|
+
cursor = edge.end;
|
|
418
|
+
}
|
|
419
|
+
return result + capture.custom.slice(cursor);
|
|
420
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Long-lived streaming-input prompt for query().
|
|
2
|
+
//
|
|
3
|
+
// The SDK accepts `prompt: AsyncIterable<SDKUserMessage>` and pumps it to the
|
|
4
|
+
// CLI's stdin. Keeping that iterable parked for the life of the query lets us
|
|
5
|
+
// write a steer to stdin *while* a tool is running, which is what makes CC
|
|
6
|
+
// drain it at the next tool boundary (`priority: "next"`) instead of treating
|
|
7
|
+
// it as a follow-up turn.
|
|
8
|
+
//
|
|
9
|
+
// The ack is the load-bearing part. `push()` resolves on the line *after*
|
|
10
|
+
// `yield`, and the SDK's pump is `for await (m of stream) { await
|
|
11
|
+
// transport.write(m) }` — so resuming past the yield proves the write to stdin
|
|
12
|
+
// completed. Callers await that before releasing the MCP tool result, which
|
|
13
|
+
// travels back over the same stdin FIFO; winning that race is what guarantees
|
|
14
|
+
// CC sees the steer before the tool result.
|
|
15
|
+
//
|
|
16
|
+
// The drain and the FIFO ordering are CC CLI internals, not SDK contract, so
|
|
17
|
+
// this can break under a CC upgrade without any type error.
|
|
18
|
+
|
|
19
|
+
import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
|
20
|
+
|
|
21
|
+
export interface PromptStream {
|
|
22
|
+
stream: AsyncGenerator<SDKUserMessage>;
|
|
23
|
+
/** Enqueue a message; resolves once the SDK has written it to stdin.
|
|
24
|
+
* Rejects (never hangs) if the stream is already ended or failed. */
|
|
25
|
+
push: (msg: SDKUserMessage) => Promise<void>;
|
|
26
|
+
/** Close the input: the generator returns, the SDK closes the CLI's stdin. */
|
|
27
|
+
end: () => void;
|
|
28
|
+
/** Abandon the input, rejecting every queued and in-flight ack. */
|
|
29
|
+
fail: (error: Error) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function makePromptStream(): PromptStream {
|
|
33
|
+
type Item = { msg: SDKUserMessage; resolve: () => void; reject: (e: Error) => void };
|
|
34
|
+
const queue: Item[] = [];
|
|
35
|
+
// The item currently parked at `yield`. Tracked separately so fail() can
|
|
36
|
+
// settle it — a dying CLI may abandon the pump without ever resuming us,
|
|
37
|
+
// and an unsettled ack would wedge tool-result delivery forever.
|
|
38
|
+
let inflight: Item | null = null;
|
|
39
|
+
let wake: (() => void) | null = null;
|
|
40
|
+
let done = false;
|
|
41
|
+
let failure: Error | null = null;
|
|
42
|
+
|
|
43
|
+
const kick = () => { wake?.(); wake = null; };
|
|
44
|
+
|
|
45
|
+
async function* gen(): AsyncGenerator<SDKUserMessage> {
|
|
46
|
+
try {
|
|
47
|
+
while (true) {
|
|
48
|
+
while (queue.length === 0 && !done && !failure) {
|
|
49
|
+
await new Promise<void>((resolve) => { wake = resolve; });
|
|
50
|
+
}
|
|
51
|
+
if (failure) throw failure;
|
|
52
|
+
const item = queue.shift();
|
|
53
|
+
if (!item) return; // ended and drained
|
|
54
|
+
inflight = item;
|
|
55
|
+
try {
|
|
56
|
+
yield item.msg;
|
|
57
|
+
item.resolve();
|
|
58
|
+
} finally {
|
|
59
|
+
// Reached either normally (no-op, already resolved) or when the
|
|
60
|
+
// pump abandons iteration — a `for await` break/throw calls
|
|
61
|
+
// gen.return(), which resumes the yield as a return.
|
|
62
|
+
item.reject(new Error("prompt stream closed"));
|
|
63
|
+
inflight = null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
} finally {
|
|
67
|
+
// No consumer left to drain the queue, so nothing would ever settle a
|
|
68
|
+
// later push. Closing here keeps the reject-never-hang contract a
|
|
69
|
+
// property of this module rather than of every call site.
|
|
70
|
+
done = true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
stream: gen(),
|
|
76
|
+
push: (msg) => failure || done
|
|
77
|
+
? Promise.reject(failure ?? new Error("prompt stream closed"))
|
|
78
|
+
: new Promise<void>((resolve, reject) => { queue.push({ msg, resolve, reject }); kick(); }),
|
|
79
|
+
end: () => { done = true; kick(); },
|
|
80
|
+
fail: (error) => {
|
|
81
|
+
// First failure wins: the query's `finally` fails the stream a second
|
|
82
|
+
// time with a generic "query ended", which would otherwise mask the
|
|
83
|
+
// real cause its `catch` recorded.
|
|
84
|
+
if (failure) return;
|
|
85
|
+
failure = error;
|
|
86
|
+
queue.splice(0).forEach((item) => item.reject(error));
|
|
87
|
+
inflight?.reject(error);
|
|
88
|
+
kick();
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** `uuid` is deliberately omitted: we need no dedup, and supplying one makes
|
|
94
|
+
* CC's stdin loop do a session lookup on the message. */
|
|
95
|
+
export function userMessage(content: SDKUserMessage["message"]["content"], priority?: SDKUserMessage["priority"]): SDKUserMessage {
|
|
96
|
+
return {
|
|
97
|
+
type: "user",
|
|
98
|
+
message: { role: "user", content } as SDKUserMessage["message"],
|
|
99
|
+
parent_tool_use_id: null,
|
|
100
|
+
...(priority ? { priority } : {}),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Query state: QueryContext class.
|
|
2
|
+
//
|
|
3
|
+
// All per-query and per-turn mutable state lives here. Reentrant queries
|
|
4
|
+
// (subagents) each get their own QueryContext instance, managed by index.ts.
|
|
5
|
+
// Adding a new field = one property on the class.
|
|
6
|
+
//
|
|
7
|
+
// Extracted from index.ts so tests can import without activating the extension.
|
|
8
|
+
|
|
9
|
+
import type { AssistantMessage, AssistantMessageEventStream, Model, SimpleStreamOptions } from "@earendil-works/pi-ai";
|
|
10
|
+
import type { McpResult } from "./extract-tool-results.js";
|
|
11
|
+
import type { PromptStream } from "./prompt-stream.js";
|
|
12
|
+
import type { RateLimitInfo } from "./rate-limit.js";
|
|
13
|
+
|
|
14
|
+
export interface PendingToolCall {
|
|
15
|
+
toolName: string;
|
|
16
|
+
resolve: (result: McpResult) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class QueryContext {
|
|
20
|
+
// Query-scoped (fully isolated per query)
|
|
21
|
+
activeQuery: unknown | null = null;
|
|
22
|
+
currentPiStream: AssistantMessageEventStream | null = null;
|
|
23
|
+
/** onResponse from the provider call that owns currentPiStream. */
|
|
24
|
+
currentOnResponse: SimpleStreamOptions["onResponse"] | undefined;
|
|
25
|
+
/** Latest rate_limit_event not yet reported. CC sends it after message_stop, when a
|
|
26
|
+
* tool call has already ended the stream, so it waits for the next call's stream. */
|
|
27
|
+
heldRateLimit: RateLimitInfo | undefined;
|
|
28
|
+
latestCursor = 0;
|
|
29
|
+
pendingToolCalls = new Map<string, PendingToolCall>();
|
|
30
|
+
pendingResults = new Map<string, McpResult>();
|
|
31
|
+
/** tool_use ids emitted this turn. Sole purpose is routing a delivered result
|
|
32
|
+
* to the owning query when several queries are in flight — pairing a result
|
|
33
|
+
* to its call is done by id from Claude's tools/call _meta, not from here. */
|
|
34
|
+
turnToolCallIds: string[] = [];
|
|
35
|
+
/** Streaming-input handle for the active query — how steers reach CC mid-turn. */
|
|
36
|
+
promptStream: PromptStream | null = null;
|
|
37
|
+
/** Last rate-limit rejection seen on this query. Claude Code sends it just before the
|
|
38
|
+
* failure it caused, which is the only thing tying the two together. */
|
|
39
|
+
rateLimitRejection: { rateLimitType?: string; resetsAt?: number } | null = null;
|
|
40
|
+
/** Highest 5% utilization bucket we notified for, so repeat rate_limit_event spam is suppressed. */
|
|
41
|
+
lastRateLimitWarnStep: number | null = null;
|
|
42
|
+
lastRateLimitWarnThreshold: number | undefined;
|
|
43
|
+
/** pi session this query serves, from SimpleStreamOptions.sessionId at fresh-query
|
|
44
|
+
* setup. A bridge process serves several pi sessions at once (subagents run their
|
|
45
|
+
* own AgentSessions), and history rewrites must only discard the rewriting
|
|
46
|
+
* session's parked queries — this is the match key. Null when the host did not
|
|
47
|
+
* supply an id.
|
|
48
|
+
*/
|
|
49
|
+
piSessionId: string | null = null;
|
|
50
|
+
/** pi rewrote the history this query was built from (session_compact,
|
|
51
|
+
* session_tree in its own pi session). Set by markRebuildForSession, consumed
|
|
52
|
+
* by the tool-result delivery that discards the query. Not session-wide state:
|
|
53
|
+
* it dies with the context it belongs to, so it cannot leak into later turns
|
|
54
|
+
* the way a module flag does.
|
|
55
|
+
*/
|
|
56
|
+
historyStale = false;
|
|
57
|
+
/** A steer never reached CC. A first query has no session mirror yet, so
|
|
58
|
+
* completion must carry this into the mirror it creates. */
|
|
59
|
+
missedSteer = false;
|
|
60
|
+
|
|
61
|
+
// Per-turn (reset together)
|
|
62
|
+
turnOutput: AssistantMessage | null = null;
|
|
63
|
+
turnStarted = false;
|
|
64
|
+
turnSawStreamEvent = false;
|
|
65
|
+
turnSawToolCall = false;
|
|
66
|
+
/** API message id from the last message_start, and whether its message_stop has
|
|
67
|
+
* arrived. An `assistant` message under a different id while the stream is still
|
|
68
|
+
* open is Claude Code's non-streaming fallback for a stalled stream. */
|
|
69
|
+
turnStreamMessageId: string | undefined;
|
|
70
|
+
turnStreamOpen = false;
|
|
71
|
+
/** turnBlocks length at that message_start: where an abandoned attempt's blocks begin. */
|
|
72
|
+
turnStreamBlockStart = 0;
|
|
73
|
+
|
|
74
|
+
get turnBlocks(): Array<any> {
|
|
75
|
+
if (!this.turnOutput) throw new Error("turnBlocks accessed before resetTurnState");
|
|
76
|
+
return this.turnOutput.content;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Answer every parked MCP handler with `reason` and forget the turn's queued
|
|
80
|
+
* results. Called when the query it belongs to is going away (abort, error,
|
|
81
|
+
* normal end). Handlers must be *resolved*, not rejected: an error reply is
|
|
82
|
+
* still a reply, and a handler left awaiting a subprocess that is gone keeps
|
|
83
|
+
* CC's tools/call open forever, which wedges pi's turn behind it. */
|
|
84
|
+
releasePendingToolCalls(reason: string): void {
|
|
85
|
+
for (const pending of this.pendingToolCalls.values()) pending.resolve({ content: [{ type: "text", text: reason }] });
|
|
86
|
+
this.pendingToolCalls.clear();
|
|
87
|
+
this.pendingResults.clear();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
resetTurnState(model: Model<any>): void {
|
|
91
|
+
this.turnOutput = {
|
|
92
|
+
role: "assistant", content: [],
|
|
93
|
+
api: model.api, provider: model.provider, model: model.id,
|
|
94
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0,
|
|
95
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
|
96
|
+
stopReason: "stop", timestamp: Date.now(),
|
|
97
|
+
};
|
|
98
|
+
this.turnStarted = false;
|
|
99
|
+
this.turnSawStreamEvent = false;
|
|
100
|
+
this.turnSawToolCall = false;
|
|
101
|
+
this.turnStreamMessageId = undefined;
|
|
102
|
+
this.turnStreamOpen = false;
|
|
103
|
+
this.turnStreamBlockStart = 0;
|
|
104
|
+
// turnToolCallIds is NOT reset — it persists across tool-result delivery
|
|
105
|
+
// callbacks within the same assistant message so results can be routed to
|
|
106
|
+
// this query while its handlers are still pending.
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let _ctx = new QueryContext();
|
|
111
|
+
|
|
112
|
+
export function ctx(): QueryContext { return _ctx; }
|
|
113
|
+
|
|
114
|
+
// Test-only: replace the module-level context so test files start clean.
|
|
115
|
+
// Not called from production.
|
|
116
|
+
export function resetCtx(): void {
|
|
117
|
+
_ctx = new QueryContext();
|
|
118
|
+
}
|