pi-hermes-memory 0.7.22 → 0.8.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.
@@ -1,15 +1,19 @@
1
- import { existsSync } from "node:fs";
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import * as fs from "node:fs/promises";
3
+ import * as os from "node:os";
2
4
  import { dirname, join, resolve } from "node:path";
3
5
  import { fileURLToPath } from "node:url";
4
6
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
7
  import type { MemoryConfig, ThinkingLevel } from "../types.js";
8
+ import { AGENT_ROOT } from "../paths.js";
6
9
 
7
- type ChildLlmConfig = Pick<MemoryConfig, "llmModelOverride" | "llmThinkingOverride">;
10
+ type ChildLlmConfig = Pick<MemoryConfig, "llmModelOverride" | "llmThinkingOverride" | "childExtensionPaths">;
8
11
 
9
12
  interface PiExecResult {
10
13
  code: number;
11
14
  stdout?: string;
12
15
  stderr?: string;
16
+ killed?: boolean;
13
17
  }
14
18
 
15
19
  interface ExecChildPromptOptions {
@@ -18,6 +22,20 @@ interface ExecChildPromptOptions {
18
22
  retryWithoutOverrides?: boolean;
19
23
  }
20
24
 
25
+ interface ExecChildPromptDependencies {
26
+ removeTemporaryDirectory: (dir: string) => Promise<void>;
27
+ }
28
+
29
+ const DEFAULT_EXEC_CHILD_PROMPT_DEPENDENCIES: ExecChildPromptDependencies = {
30
+ removeTemporaryDirectory: async (dir) => {
31
+ await fs.rm(dir, { recursive: true, force: true });
32
+ },
33
+ };
34
+
35
+ const WATCHDOG_EXIT_GRACE_MS = 5000;
36
+ const CHILD_PROCESS_WATCHDOG_PATH = fileURLToPath(
37
+ new URL("./child-process-watchdog.mjs", import.meta.url),
38
+ );
21
39
  export interface ChildPiInvocation {
22
40
  command: string;
23
41
  args: string[];
@@ -80,34 +98,158 @@ export function inheritedExtensionArgs(argv: string[] = process.argv.slice(2)):
80
98
  return args;
81
99
  }
82
100
 
83
- function appendOwnExtensionArgs(args: string[]): void {
101
+ // Provider-auth-adapter packages (e.g. Anthropic/xAI/Codex OAuth) inject
102
+ // subscription billing headers via pi.registerProvider(). --no-extensions
103
+ // strips these from child `pi -p` subprocesses, which silently rebills
104
+ // subscription usage as pay-as-you-go "extra usage" instead (see issue #94).
105
+ //
106
+ // pi has no runtime API to enumerate loaded extensions or map a registered
107
+ // provider back to its extension file, so we can't ask pi "what adapter is
108
+ // active" directly. Instead we mirror pi's OWN static package-discovery
109
+ // convention (package.json -> "pi": { "extensions": [...] }, the same field
110
+ // pi-hermes-memory's own package.json declares) and match sibling package
111
+ // names against a naming convention, so a future xai-oauth-adapter or
112
+ // pi-codex-oauth-adapter is picked up automatically without a code change
113
+ // here — no code execution, just JSON reads of sibling package.json files.
114
+ const AUTH_ADAPTER_NAME_PATTERNS: readonly RegExp[] = [
115
+ /(^|[-/])oauth-adapter$/,
116
+ /(^|[-/])auth-adapter$/,
117
+ ];
118
+
119
+ function isAuthAdapterPackageName(name: string): boolean {
120
+ return AUTH_ADAPTER_NAME_PATTERNS.some((pattern) => pattern.test(name));
121
+ }
122
+
123
+ // Read a sibling package's "pi": { "extensions": [...] } manifest field —
124
+ // the same field pi's own loader reads — and resolve declared paths
125
+ // relative to that package's directory. Mirrors loader.js#resolveExtensionEntries.
126
+ function readPackageExtensionEntries(packageDir: string): string[] {
127
+ const packageJsonPath = join(packageDir, "package.json");
128
+ if (!existsSync(packageJsonPath)) return [];
129
+
130
+ let manifest: unknown;
131
+ try {
132
+ manifest = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
133
+ } catch {
134
+ return [];
135
+ }
136
+
137
+ const declaredExtensions = (manifest as { pi?: { extensions?: unknown } } | null)?.pi?.extensions;
138
+ if (!Array.isArray(declaredExtensions)) return [];
139
+
140
+ const entries: string[] = [];
141
+ for (const relativePath of declaredExtensions) {
142
+ if (typeof relativePath !== "string") continue;
143
+ const resolved = resolve(packageDir, relativePath);
144
+ if (existsSync(resolved)) entries.push(resolved);
145
+ }
146
+ return entries;
147
+ }
148
+
149
+ function scanRootForAuthAdapters(root: string): string[] {
150
+ let entries: string[];
151
+ try {
152
+ entries = readdirSync(root);
153
+ } catch {
154
+ return [];
155
+ }
156
+
157
+ const detected: string[] = [];
158
+ for (const entry of entries) {
159
+ if (entry.startsWith("@")) {
160
+ // Scoped org, e.g. @xai/pi-oauth-adapter — one extra level, no deeper.
161
+ const scopeDir = join(root, entry);
162
+ let scopedPackages: string[];
163
+ try {
164
+ scopedPackages = readdirSync(scopeDir);
165
+ } catch {
166
+ continue;
167
+ }
168
+ for (const scopedName of scopedPackages) {
169
+ if (!isAuthAdapterPackageName(scopedName)) continue;
170
+ detected.push(...readPackageExtensionEntries(join(scopeDir, scopedName)));
171
+ }
172
+ continue;
173
+ }
174
+
175
+ if (!isAuthAdapterPackageName(entry)) continue;
176
+ detected.push(...readPackageExtensionEntries(join(root, entry)));
177
+ }
178
+ return detected;
179
+ }
180
+
181
+ // `roots` is overridable so tests can point this at fixture directories
182
+ // instead of the real sibling-packages trees. Two roots are scanned by
183
+ // default: packages installed alongside pi-hermes-memory's own package
184
+ // (the common npm-managed-extensions layout), and AGENT_ROOT's npm
185
+ // directory (covers pi-hermes-memory being loaded from elsewhere, e.g. a
186
+ // local dev checkout via -e, while the adapter is still npm-managed).
187
+ export function detectAuthAdapterExtensionPaths(roots?: string[]): string[] {
188
+ const searchRoots = roots ?? [
189
+ OWN_EXTENSION_PATH ? resolve(dirname(dirname(OWN_EXTENSION_PATH)), "..") : "",
190
+ join(AGENT_ROOT, "npm", "node_modules"),
191
+ ].filter((root) => root.length > 0);
192
+
193
+ const seenRoots: string[] = [];
194
+ const detected: string[] = [];
195
+ for (const root of searchRoots) {
196
+ if (seenRoots.includes(root)) continue;
197
+ seenRoots.push(root);
198
+ detected.push(...scanRootForAuthAdapters(root));
199
+ }
200
+ return detected;
201
+ }
202
+
203
+ function childExtensionPaths(config: ChildLlmConfig): string[] {
204
+ const candidates = [
205
+ OWN_EXTENSION_PATH,
206
+ ...(config.childExtensionPaths ?? []),
207
+ ...detectAuthAdapterExtensionPaths(),
208
+ ];
209
+ const seen = new Set<string>();
210
+ const paths: string[] = [];
211
+ for (const candidate of candidates) {
212
+ const trimmed = candidate?.trim();
213
+ if (!trimmed) continue;
214
+ const normalized = resolve(trimmed);
215
+ if (seen.has(normalized) || !existsSync(normalized)) continue;
216
+ seen.add(normalized);
217
+ paths.push(normalized);
218
+ }
219
+ return paths;
220
+ }
221
+
222
+ function appendOwnExtensionArgs(args: string[], config: ChildLlmConfig): void {
84
223
  // Skip all packages from settings.json (--no-extensions) — the subprocess
85
- // only needs pi-hermes-memory to access the memory tool. Loading every
86
- // plugin (context-mode, pi-lens, pi-web-access, pi-review, …) wastes
87
- // prompt tokens and startup CPU for simple one-shot memory tasks.
88
- if (OWN_EXTENSION_PATH) {
89
- args.push("--no-extensions", "-e", OWN_EXTENSION_PATH);
224
+ // loads only Hermes and explicitly required provider adapters.
225
+ args.push("--no-extensions");
226
+ for (const extensionPath of childExtensionPaths(config)) {
227
+ args.push("-e", extensionPath);
90
228
  }
91
229
  }
92
230
 
93
- export function buildChildPiPromptArgs(prompt: string, config: ChildLlmConfig, _argv?: string[]): string[] {
231
+ export function buildChildPiPromptArgs(
232
+ prompt: string,
233
+ config: ChildLlmConfig,
234
+ _argv: string[] = process.argv.slice(2),
235
+ ): string[] {
94
236
  const args = ["-p", "--no-session"];
95
237
  const model = normalizedModelOverride(config);
96
238
  const thinking = effectiveThinkingOverride(config);
97
239
 
98
240
  if (model) args.push("--model", model);
99
241
  if (thinking) args.push("--thinking", thinking);
100
- appendOwnExtensionArgs(args);
242
+ appendOwnExtensionArgs(args, config);
101
243
  args.push(prompt);
102
244
 
103
245
  return args;
104
246
  }
105
247
 
106
- function basePromptArgs(prompt: string): string[] {
248
+ function basePromptArgs(prompt: string, config: ChildLlmConfig): string[] {
107
249
  // Always use --no-extensions + own path so the retry also avoids loading
108
250
  // all settings.json packages — matching the primary code path.
109
251
  const args = ["-p", "--no-session"];
110
- appendOwnExtensionArgs(args);
252
+ appendOwnExtensionArgs(args, config);
111
253
  args.push(prompt);
112
254
  return args;
113
255
  }
@@ -142,6 +284,40 @@ function resolvedPiCliPath(options: ResolveChildPiInvocationOptions): string | u
142
284
  return resolvedInstalledPiCliPath();
143
285
  }
144
286
 
287
+ function resolvedWindowsPiInvocation(
288
+ args: string[],
289
+ execPath: string,
290
+ ): ChildPiInvocation | undefined {
291
+ const pathEntries = (process.env.PATH ?? process.env.Path ?? "")
292
+ .split(";")
293
+ .map((entry) => entry.trim().replace(/^"|"$/g, ""))
294
+ .filter(Boolean);
295
+
296
+ for (const directory of pathEntries) {
297
+ for (const executableName of ["pi.exe", "pi.com"]) {
298
+ const executablePath = join(directory, executableName);
299
+ if (existsSync(executablePath)) {
300
+ return { command: executablePath, args };
301
+ }
302
+ }
303
+
304
+ if (!existsSync(join(directory, "pi.cmd")) && !existsSync(join(directory, "pi.bat"))) {
305
+ continue;
306
+ }
307
+
308
+ for (const cliPath of [
309
+ join(directory, "node_modules", "@earendil-works", "pi-coding-agent", "dist", "cli.js"),
310
+ join(directory, "node_modules", "@earendil-works", "pi-coding-agent", "cli.js"),
311
+ ]) {
312
+ if (existsSync(cliPath)) {
313
+ return { command: execPath, args: [cliPath, ...args] };
314
+ }
315
+ }
316
+ }
317
+
318
+ return undefined;
319
+ }
320
+
145
321
  export function resolveChildPiInvocation(
146
322
  args: string[],
147
323
  options: ResolveChildPiInvocationOptions = {},
@@ -152,13 +328,33 @@ export function resolveChildPiInvocation(
152
328
  }
153
329
 
154
330
  const piCliPath = resolvedPiCliPath(options);
155
- if (!piCliPath) {
156
- return { command: "pi", args };
331
+ if (piCliPath) {
332
+ return {
333
+ command: options.execPath ?? process.execPath,
334
+ args: [piCliPath, ...args],
335
+ };
157
336
  }
158
337
 
338
+ const fallback = resolvedWindowsPiInvocation(args, options.execPath ?? process.execPath);
339
+ if (fallback) return fallback;
340
+
341
+ throw new Error("Unable to resolve a directly executable Pi CLI on Windows");
342
+ }
343
+
344
+ export function resolveWatchedChildPiInvocation(
345
+ invocation: ChildPiInvocation,
346
+ timeoutMs: number,
347
+ cancellationPath = "-",
348
+ ): ChildPiInvocation {
159
349
  return {
160
- command: options.execPath ?? process.execPath,
161
- args: [piCliPath, ...args],
350
+ command: process.execPath,
351
+ args: [
352
+ CHILD_PROCESS_WATCHDOG_PATH,
353
+ String(timeoutMs),
354
+ cancellationPath,
355
+ invocation.command,
356
+ ...invocation.args,
357
+ ],
162
358
  };
163
359
  }
164
360
 
@@ -175,38 +371,75 @@ function shouldRetryWithoutOverridesForError(error: unknown): boolean {
175
371
  return shouldRetryWithoutOverridesFromText(String(error));
176
372
  }
177
373
 
374
+ async function writePromptToTemporaryFile(prompt: string): Promise<{ dir: string; filePath: string }> {
375
+ const dir = await fs.mkdtemp(join(os.tmpdir(), "pi-hermes-prompt-"));
376
+ const filePath = join(dir, "prompt.md");
377
+ try {
378
+ await fs.writeFile(filePath, prompt, { encoding: "utf-8", mode: 0o600 });
379
+ return { dir, filePath };
380
+ } catch (error) {
381
+ try { await fs.rm(dir, { recursive: true, force: true }); } catch {}
382
+ throw error;
383
+ }
384
+ }
385
+
178
386
  export async function execChildPrompt(
179
387
  pi: Pick<ExtensionAPI, "exec">,
180
388
  prompt: string,
181
389
  config: ChildLlmConfig,
182
390
  options: ExecChildPromptOptions,
391
+ dependencies: ExecChildPromptDependencies = DEFAULT_EXEC_CHILD_PROMPT_DEPENDENCIES,
183
392
  ): Promise<PiExecResult> {
184
393
  const execOptions = {
185
- signal: options.signal,
186
- timeout: options.timeoutMs,
394
+ timeout: options.timeoutMs + WATCHDOG_EXIT_GRACE_MS,
395
+ };
396
+ const temporaryPrompt = await writePromptToTemporaryFile(prompt);
397
+ const promptReference = `@${temporaryPrompt.filePath}`;
398
+ const cancellationPath = join(temporaryPrompt.dir, "cancel");
399
+ const requestCancellation = () => {
400
+ void fs.writeFile(cancellationPath, "", { mode: 0o600 }).catch(() => {});
187
401
  };
402
+ options.signal?.addEventListener("abort", requestCancellation, { once: true });
403
+ if (options.signal?.aborted) requestCancellation();
188
404
 
189
405
  try {
190
- const invocation = resolveChildPiInvocation(buildChildPiPromptArgs(prompt, config));
191
- const result = await pi.exec(invocation.command, invocation.args, execOptions) as PiExecResult;
192
- if (
193
- result.code === 0 ||
194
- !options.retryWithoutOverrides ||
195
- !hasChildLlmOverrides(config) ||
196
- !shouldRetryWithoutOverrides(result)
197
- ) {
198
- return result;
406
+ try {
407
+ const invocation = resolveWatchedChildPiInvocation(
408
+ resolveChildPiInvocation(buildChildPiPromptArgs(promptReference, config)),
409
+ options.timeoutMs,
410
+ cancellationPath,
411
+ );
412
+ const result = await pi.exec(invocation.command, invocation.args, execOptions) as PiExecResult;
413
+ if (
414
+ result.code === 0 ||
415
+ !options.retryWithoutOverrides ||
416
+ !hasChildLlmOverrides(config) ||
417
+ !shouldRetryWithoutOverrides(result)
418
+ ) {
419
+ return result;
420
+ }
421
+ } catch (error) {
422
+ if (
423
+ !options.retryWithoutOverrides ||
424
+ !hasChildLlmOverrides(config) ||
425
+ !shouldRetryWithoutOverridesForError(error)
426
+ ) {
427
+ throw error;
428
+ }
199
429
  }
200
- } catch (error) {
201
- if (
202
- !options.retryWithoutOverrides ||
203
- !hasChildLlmOverrides(config) ||
204
- !shouldRetryWithoutOverridesForError(error)
205
- ) {
206
- throw error;
430
+
431
+ const retryInvocation = resolveWatchedChildPiInvocation(
432
+ resolveChildPiInvocation(basePromptArgs(promptReference, config)),
433
+ options.timeoutMs,
434
+ cancellationPath,
435
+ );
436
+ return await pi.exec(retryInvocation.command, retryInvocation.args, execOptions) as PiExecResult;
437
+ } finally {
438
+ options.signal?.removeEventListener("abort", requestCancellation);
439
+ try {
440
+ await dependencies.removeTemporaryDirectory(temporaryPrompt.dir);
441
+ } catch {
442
+ try { await fs.unlink(temporaryPrompt.filePath); } catch {}
207
443
  }
208
444
  }
209
-
210
- const retryInvocation = resolveChildPiInvocation(basePromptArgs(prompt));
211
- return pi.exec(retryInvocation.command, retryInvocation.args, execOptions) as Promise<PiExecResult>;
212
445
  }