@oh-my-pi/pi-coding-agent 15.9.1 → 15.9.5

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.
Files changed (109) hide show
  1. package/CHANGELOG.md +68 -2
  2. package/dist/types/cli/classify-install-target.d.ts +5 -1
  3. package/dist/types/cli/dry-balance-cli.d.ts +104 -0
  4. package/dist/types/commands/dry-balance.d.ts +31 -0
  5. package/dist/types/config/model-registry.d.ts +2 -0
  6. package/dist/types/config/models-config-schema.d.ts +3 -0
  7. package/dist/types/config/settings-schema.d.ts +13 -4
  8. package/dist/types/config/settings.d.ts +11 -0
  9. package/dist/types/discovery/helpers.d.ts +1 -0
  10. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +2 -3
  11. package/dist/types/hindsight/bank.d.ts +17 -9
  12. package/dist/types/hindsight/mental-models.d.ts +1 -1
  13. package/dist/types/hindsight/state.d.ts +9 -3
  14. package/dist/types/mcp/manager.d.ts +1 -1
  15. package/dist/types/modes/components/assistant-message.d.ts +11 -0
  16. package/dist/types/modes/components/custom-editor.d.ts +3 -1
  17. package/dist/types/modes/components/error-banner.d.ts +11 -0
  18. package/dist/types/modes/components/tool-execution.d.ts +15 -0
  19. package/dist/types/modes/components/transcript-container.d.ts +4 -2
  20. package/dist/types/modes/components/user-message.d.ts +1 -1
  21. package/dist/types/modes/image-references.d.ts +17 -0
  22. package/dist/types/modes/interactive-mode.d.ts +7 -0
  23. package/dist/types/modes/types.d.ts +7 -0
  24. package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
  25. package/dist/types/session/agent-session.d.ts +9 -0
  26. package/dist/types/session/auth-storage.d.ts +2 -2
  27. package/dist/types/session/blob-store.d.ts +12 -11
  28. package/dist/types/session/session-manager.d.ts +5 -3
  29. package/dist/types/system-prompt.d.ts +2 -0
  30. package/dist/types/task/types.d.ts +2 -0
  31. package/dist/types/tiny/title-client.d.ts +16 -1
  32. package/dist/types/tool-discovery/mode.d.ts +8 -0
  33. package/dist/types/tools/archive-reader.d.ts +5 -1
  34. package/dist/types/tools/index.d.ts +16 -0
  35. package/dist/types/tools/path-utils.d.ts +11 -0
  36. package/dist/types/tui/hyperlink.d.ts +12 -0
  37. package/dist/types/web/search/render.d.ts +1 -2
  38. package/package.json +9 -9
  39. package/src/cli/classify-install-target.ts +31 -5
  40. package/src/cli/dry-balance-cli.ts +823 -0
  41. package/src/cli/plugin-cli.ts +45 -0
  42. package/src/cli/web-search-cli.ts +0 -1
  43. package/src/cli-commands.ts +1 -0
  44. package/src/commands/dry-balance.ts +43 -0
  45. package/src/config/model-registry.ts +60 -4
  46. package/src/config/models-config-schema.ts +2 -0
  47. package/src/config/settings-schema.ts +14 -4
  48. package/src/config/settings.ts +38 -0
  49. package/src/discovery/builtin-rules/ts-no-tiny-functions.md +1 -0
  50. package/src/discovery/github.ts +37 -1
  51. package/src/discovery/helpers.ts +3 -1
  52. package/src/eval/__tests__/agent-bridge.test.ts +72 -0
  53. package/src/eval/py/tool-bridge.ts +43 -5
  54. package/src/extensibility/custom-commands/bundled/ci-green/index.ts +31 -2
  55. package/src/extensibility/plugins/legacy-pi-compat.ts +245 -25
  56. package/src/hindsight/backend.ts +184 -35
  57. package/src/hindsight/bank.ts +32 -22
  58. package/src/hindsight/mental-models.ts +1 -1
  59. package/src/hindsight/state.ts +21 -7
  60. package/src/internal-urls/docs-index.generated.ts +6 -6
  61. package/src/internal-urls/omp-protocol.ts +8 -2
  62. package/src/main.ts +7 -1
  63. package/src/mcp/manager.ts +40 -21
  64. package/src/modes/components/assistant-message.ts +22 -0
  65. package/src/modes/components/custom-editor.ts +14 -2
  66. package/src/modes/components/error-banner.ts +33 -0
  67. package/src/modes/components/tool-execution.ts +44 -0
  68. package/src/modes/components/transcript-container.ts +102 -30
  69. package/src/modes/components/tree-selector.ts +29 -2
  70. package/src/modes/components/user-message.ts +9 -2
  71. package/src/modes/controllers/event-controller.ts +42 -3
  72. package/src/modes/controllers/input-controller.ts +41 -3
  73. package/src/modes/image-references.ts +111 -0
  74. package/src/modes/interactive-mode.ts +48 -13
  75. package/src/modes/setup-wizard/scenes/sign-in.ts +27 -7
  76. package/src/modes/types.ts +10 -1
  77. package/src/modes/utils/ui-helpers.ts +23 -2
  78. package/src/prompts/agents/explore.md +1 -0
  79. package/src/prompts/agents/librarian.md +1 -0
  80. package/src/prompts/ci-green-request.md +5 -3
  81. package/src/prompts/dry-balance-bench.md +8 -0
  82. package/src/prompts/system/project-prompt.md +1 -0
  83. package/src/sdk.ts +99 -18
  84. package/src/session/agent-session.ts +103 -19
  85. package/src/session/auth-storage.ts +4 -0
  86. package/src/session/blob-store.ts +96 -9
  87. package/src/session/session-manager.ts +19 -10
  88. package/src/system-prompt.ts +4 -0
  89. package/src/task/executor.ts +6 -2
  90. package/src/task/index.ts +8 -7
  91. package/src/task/types.ts +2 -0
  92. package/src/tiny/title-client.ts +7 -1
  93. package/src/tool-discovery/mode.ts +24 -0
  94. package/src/tools/archive-reader.ts +339 -31
  95. package/src/tools/bash.ts +3 -4
  96. package/src/tools/fetch.ts +29 -9
  97. package/src/tools/gh.ts +65 -11
  98. package/src/tools/index.ts +22 -8
  99. package/src/tools/job.ts +3 -3
  100. package/src/tools/memory-reflect.ts +2 -2
  101. package/src/tools/path-utils.ts +21 -0
  102. package/src/tools/read.ts +58 -12
  103. package/src/tools/search-tool-bm25.ts +4 -6
  104. package/src/tools/search.ts +78 -12
  105. package/src/tui/hyperlink.ts +42 -7
  106. package/src/utils/file-mentions.ts +7 -107
  107. package/src/utils/title-generator.ts +58 -37
  108. package/src/web/search/index.ts +2 -2
  109. package/src/web/search/render.ts +20 -52
@@ -41,10 +41,15 @@ const PI_SUBPATH_REMAPS: ReadonlyMap<string, string> = new Map<string, string>([
41
41
 
42
42
  const LEGACY_PI_SPECIFIER_FILTER = new RegExp(`^@(?:${PI_SCOPE_ALTERNATION})/(?:${PI_PACKAGE_ALTERNATION})(?:/.*)?$`);
43
43
  const LEGACY_PI_IMPORT_SPECIFIER_REGEX = new RegExp(
44
- `((?:from\\s+|import\\s*\\(\\s*)["'])(@(?:${PI_SCOPE_ALTERNATION})/(?:${PI_PACKAGE_ALTERNATION})(?:/[^"'()\\s]+)?)(["'])`,
44
+ `((?:from\\s+|import\\s+|import\\s*\\(\\s*)["'])(@(?:${PI_SCOPE_ALTERNATION})/(?:${PI_PACKAGE_ALTERNATION})(?:/[^"'()\\s]+)?)(["'])`,
45
45
  "g",
46
46
  );
47
47
  const resolvedSpecifierFallbacks = new Map<string, string>();
48
+ const SOURCE_MODULE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"] as const;
49
+ const SUPPORTED_PACKAGE_IMPORT_CONDITIONS = new Set(["bun", "node", "import", "default"]);
50
+ const packageRootCache = new Map<string, string | null>();
51
+ const packageImportsCache = new Map<string, Record<string, unknown> | null>();
52
+ const PACKAGE_IMPORT_EXCLUDED = Symbol("packageImportExcluded");
48
53
 
49
54
  // Extensions that imported `@sinclair/typebox` directly used to resolve against a
50
55
  // real `@sinclair/typebox` install. The runtime dep was replaced with the Zod-backed
@@ -221,33 +226,245 @@ function rewriteLegacyPiImports(source: string): string {
221
226
  // Match the bare `@sinclair/typebox` import specifier (static + dynamic).
222
227
  // Subpath imports like `@sinclair/typebox/compiler` are intentionally excluded —
223
228
  // they expose TypeBox-only APIs the Zod-backed shim does not provide.
224
- const TYPEBOX_IMPORT_SPECIFIER_REGEX = /((?:from\s+|import\s*\(\s*)["'])(@sinclair\/typebox)(["'])/g;
229
+ const TYPEBOX_IMPORT_SPECIFIER_REGEX = /((?:from\s+|import\s+|import\s*\(\s*)["'])(@sinclair\/typebox)(["'])/g;
225
230
 
226
231
  /**
227
- * Rewrite the legacy specifiers a Pi extension may import `@(scope)/pi-*` and
228
- * the bare `@sinclair/typebox` root to absolute `file://` URLs pointing at the
229
- * bundled package or compat shim. Every other specifier (relative siblings, the
230
- * extension's own bare dependencies) is left untouched so Bun resolves it
232
+ * Rewrite the extension-owned specifiers OMP must host-resolvelegacy
233
+ * `@(scope)/pi-*`, bare `@sinclair/typebox`, and package `imports` aliases like
234
+ * `#src/*` to absolute `file://` URLs. Every other specifier (relative
235
+ * siblings and third-party dependencies) is left untouched so Bun resolves it
231
236
  * natively from the extension's real on-disk location.
232
237
  */
233
- function rewriteLegacyExtensionSource(source: string): string {
238
+ async function rewriteLegacyExtensionSource(source: string, importerPath: string): Promise<string> {
234
239
  const withPi = rewriteLegacyPiImports(source);
235
- return withPi.replace(
240
+ const withTypeBox = withPi.replace(
236
241
  TYPEBOX_IMPORT_SPECIFIER_REGEX,
237
242
  (_match, prefix: string, _specifier: string, suffix: string) => {
238
243
  return `${prefix}${toImportSpecifier(TYPEBOX_SHIM_PATH)}${suffix}`;
239
244
  },
240
245
  );
246
+ return rewriteExtensionPackageImports(withTypeBox, importerPath);
247
+ }
248
+
249
+ function isRecord(value: unknown): value is Record<string, unknown> {
250
+ return typeof value === "object" && value !== null && !Array.isArray(value);
251
+ }
252
+
253
+ async function pathExists(p: string): Promise<boolean> {
254
+ try {
255
+ await fs.stat(p);
256
+ return true;
257
+ } catch {
258
+ return false;
259
+ }
260
+ }
261
+
262
+ function hasSourceModuleExtension(p: string): boolean {
263
+ const ext = path.extname(p).toLowerCase();
264
+ return (SOURCE_MODULE_EXTENSIONS as readonly string[]).includes(ext);
265
+ }
266
+
267
+ async function resolveSourceModuleFile(basePath: string): Promise<string | null> {
268
+ try {
269
+ const stats = await fs.stat(basePath);
270
+ if (stats.isFile()) {
271
+ // Non-source files (JSON, WASM, text assets, etc.) bypass the on-load
272
+ // rewrite hook so Bun's native loaders handle them; our hook would
273
+ // otherwise pass them through `getLoader()` which falls back to `js`.
274
+ return hasSourceModuleExtension(basePath) ? realpathOrSelf(basePath) : null;
275
+ }
276
+ if (stats.isDirectory()) {
277
+ for (const extension of SOURCE_MODULE_EXTENSIONS) {
278
+ const resolved = await resolveSourceModuleFile(path.join(basePath, `index${extension}`));
279
+ if (resolved) return resolved;
280
+ }
281
+ }
282
+ } catch {
283
+ // Fall through to extension candidates below.
284
+ }
285
+
286
+ if (path.extname(basePath)) {
287
+ return null;
288
+ }
289
+
290
+ for (const extension of SOURCE_MODULE_EXTENSIONS) {
291
+ const resolved = await resolveSourceModuleFile(`${basePath}${extension}`);
292
+ if (resolved) return resolved;
293
+ }
294
+ return null;
295
+ }
296
+
297
+ async function findPackageRoot(importerPath: string): Promise<string | null> {
298
+ let dir = path.dirname(importerPath);
299
+ while (true) {
300
+ const cached = packageRootCache.get(dir);
301
+ if (cached !== undefined) {
302
+ return cached;
303
+ }
304
+
305
+ if (await pathExists(path.join(dir, "package.json"))) {
306
+ packageRootCache.set(path.dirname(importerPath), dir);
307
+ return dir;
308
+ }
309
+
310
+ const parent = path.dirname(dir);
311
+ if (parent === dir) {
312
+ packageRootCache.set(path.dirname(importerPath), null);
313
+ return null;
314
+ }
315
+ dir = parent;
316
+ }
317
+ }
318
+
319
+ async function readPackageImports(packageRoot: string): Promise<Record<string, unknown> | null> {
320
+ const cached = packageImportsCache.get(packageRoot);
321
+ if (cached !== undefined) {
322
+ return cached;
323
+ }
324
+
325
+ let imports: Record<string, unknown> | null = null;
326
+ try {
327
+ const pkg = await Bun.file(path.join(packageRoot, "package.json")).json();
328
+ if (isRecord(pkg) && isRecord(pkg.imports)) {
329
+ imports = pkg.imports;
330
+ }
331
+ } catch {
332
+ imports = null;
333
+ }
334
+ packageImportsCache.set(packageRoot, imports);
335
+ return imports;
336
+ }
337
+
338
+ type PackageImportTargetSelection = string | typeof PACKAGE_IMPORT_EXCLUDED | null;
339
+ type ResolvedPackageImportTargetSelection = string | typeof PACKAGE_IMPORT_EXCLUDED;
340
+
341
+ function selectPackageImportTarget(entry: unknown): PackageImportTargetSelection {
342
+ if (entry === null) {
343
+ return PACKAGE_IMPORT_EXCLUDED;
344
+ }
345
+ if (typeof entry === "string") {
346
+ return entry;
347
+ }
348
+ if (Array.isArray(entry)) {
349
+ for (const item of entry) {
350
+ const target = selectPackageImportTarget(item);
351
+ if (target !== null) return target;
352
+ }
353
+ return null;
354
+ }
355
+ if (!isRecord(entry)) {
356
+ return null;
357
+ }
358
+ for (const [condition, value] of Object.entries(entry)) {
359
+ if (!SUPPORTED_PACKAGE_IMPORT_CONDITIONS.has(condition)) {
360
+ continue;
361
+ }
362
+ const target = selectPackageImportTarget(value);
363
+ if (target !== null) return target;
364
+ }
365
+ return null;
366
+ }
367
+
368
+ async function resolvePackageImportTarget(
369
+ packageRoot: string,
370
+ target: string,
371
+ wildcard: string | null,
372
+ ): Promise<string | null> {
373
+ if (!target.startsWith("./")) {
374
+ return null;
375
+ }
376
+ const substituted = wildcard === null ? target : target.replaceAll("*", wildcard);
377
+ return resolveSourceModuleFile(path.resolve(packageRoot, substituted));
378
+ }
379
+
380
+ async function resolvePackageImportSpecifier(specifier: string, importerPath: string): Promise<string | null> {
381
+ if (!specifier.startsWith("#")) {
382
+ return null;
383
+ }
384
+
385
+ const packageRoot = await findPackageRoot(importerPath);
386
+ if (!packageRoot) {
387
+ return null;
388
+ }
389
+
390
+ const imports = await readPackageImports(packageRoot);
391
+ if (!imports) {
392
+ return null;
393
+ }
394
+
395
+ const exactTarget = selectPackageImportTarget(imports[specifier]);
396
+ if (exactTarget === PACKAGE_IMPORT_EXCLUDED) {
397
+ return null;
398
+ }
399
+ if (exactTarget !== null) {
400
+ return resolvePackageImportTarget(packageRoot, exactTarget, null);
401
+ }
402
+
403
+ let bestMatch: { keyLength: number; target: ResolvedPackageImportTargetSelection; wildcard: string } | null = null;
404
+ for (const [key, entry] of Object.entries(imports)) {
405
+ const starIndex = key.indexOf("*");
406
+ if (starIndex === -1) continue;
407
+
408
+ const prefix = key.slice(0, starIndex);
409
+ const suffix = key.slice(starIndex + 1);
410
+ if (!specifier.startsWith(prefix) || !specifier.endsWith(suffix)) {
411
+ continue;
412
+ }
413
+
414
+ const target = selectPackageImportTarget(entry);
415
+ if (target === null) {
416
+ continue;
417
+ }
418
+
419
+ if (!bestMatch || key.length > bestMatch.keyLength) {
420
+ bestMatch = {
421
+ keyLength: key.length,
422
+ target,
423
+ wildcard: specifier.slice(prefix.length, specifier.length - suffix.length),
424
+ };
425
+ }
426
+ }
427
+
428
+ if (!bestMatch || bestMatch.target === PACKAGE_IMPORT_EXCLUDED) {
429
+ return null;
430
+ }
431
+ return resolvePackageImportTarget(packageRoot, bestMatch.target, bestMatch.wildcard);
432
+ }
433
+
434
+ const PACKAGE_IMPORT_SPECIFIER_REGEX = /((?:from\s+|import\s+|import\s*\(\s*)["'])(#[^"'()\s]+)(["'])/g;
435
+
436
+ async function rewriteExtensionPackageImports(source: string, importerPath: string): Promise<string> {
437
+ let rewritten = "";
438
+ let lastIndex = 0;
439
+ for (const match of source.matchAll(PACKAGE_IMPORT_SPECIFIER_REGEX)) {
440
+ const matchIndex = match.index;
441
+ if (matchIndex === undefined) continue;
442
+
443
+ const [fullMatch, prefix, specifier, suffix] = match;
444
+ if (!prefix || !specifier || !suffix) continue;
445
+
446
+ const resolved = await resolvePackageImportSpecifier(specifier, importerPath);
447
+ if (!resolved) continue;
448
+
449
+ rewritten += source.slice(lastIndex, matchIndex);
450
+ rewritten += `${prefix}${toImportSpecifier(resolved)}${suffix}`;
451
+ lastIndex = matchIndex + fullMatch.length;
452
+ }
453
+
454
+ if (lastIndex === 0) {
455
+ return source;
456
+ }
457
+ return `${rewritten}${source.slice(lastIndex)}`;
241
458
  }
242
459
 
243
460
  function escapeRegExp(value: string): string {
244
461
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
245
462
  }
246
463
 
247
- // Match relative import specifiers (static `from "./…"` and dynamic
248
- // `import("./…")`). Used to walk an extension's own module graph; bare and
249
- // absolute specifiers are deliberately excluded.
250
- const RELATIVE_IMPORT_SPECIFIER_REGEX = /(?:from\s+|import\s*\(\s*)["'](\.\.?\/[^"']+)["']/g;
464
+ // Match source modules in an extension graph (relative imports and package
465
+ // `imports` aliases such as `#src/*`). Bare third-party dependencies remain
466
+ // native Bun resolutions.
467
+ const EXTENSION_GRAPH_SPECIFIER_REGEX = /(?:from\s+|import\s+|import\s*\(\s*)["']((?:\.\.?\/|#)[^"']+)["']/g;
251
468
 
252
469
  // Extension entry realpaths that already have a load-time rewrite hook
253
470
  // installed. Each `Bun.plugin()` registration is process-global and permanent,
@@ -287,10 +504,14 @@ async function collectExtensionModules(entryRealPath: string): Promise<Set<strin
287
504
  }
288
505
  modules.add(file);
289
506
  const dir = path.dirname(file);
290
- for (const match of source.matchAll(RELATIVE_IMPORT_SPECIFIER_REGEX)) {
507
+ for (const match of source.matchAll(EXTENSION_GRAPH_SPECIFIER_REGEX)) {
508
+ const specifier = match[1];
509
+ if (!specifier) continue;
291
510
  try {
292
- const resolved = await realpathOrSelf(Bun.resolveSync(match[1], dir));
293
- if (!modules.has(resolved)) {
511
+ const resolved = specifier.startsWith("#")
512
+ ? await resolvePackageImportSpecifier(specifier, file)
513
+ : await realpathOrSelf(Bun.resolveSync(specifier, dir));
514
+ if (resolved && !modules.has(resolved)) {
294
515
  queue.push(resolved);
295
516
  }
296
517
  } catch {
@@ -303,11 +524,12 @@ async function collectExtensionModules(entryRealPath: string): Promise<Set<strin
303
524
 
304
525
  /**
305
526
  * Install a `Bun.plugin()` `onLoad` hook scoped to exactly the modules in an
306
- * extension's relative-import graph, so their legacy `@(scope)/pi-*` and bare
307
- * `@sinclair/typebox` imports are rewritten at load time. A runtime `onLoad`
308
- * cannot fall through (Bun requires a result object), so the filter is an
309
- * exact-path alternation of the graph's realpaths — it never matches the host,
310
- * other extensions, `node_modules` deps, or unrelated project source.
527
+ * extension's source graph, so their legacy `@(scope)/pi-*`, bare
528
+ * `@sinclair/typebox`, and local package-import aliases are rewritten at load
529
+ * time. A runtime `onLoad` cannot fall through (Bun requires a result object),
530
+ * so the filter is an exact-path alternation of the graph's realpaths — it
531
+ * never matches the host, other extensions, `node_modules` deps, or unrelated
532
+ * project source.
311
533
  */
312
534
  async function ensureExtensionGraphHook(entryRealPath: string): Promise<void> {
313
535
  if (hookedExtensionEntries.has(entryRealPath)) {
@@ -322,9 +544,8 @@ async function ensureExtensionGraphHook(entryRealPath: string): Promise<void> {
322
544
  name: `omp:legacy-pi-ext:${Bun.hash(entryRealPath).toString(36)}`,
323
545
  setup(build) {
324
546
  build.onLoad({ filter, namespace: "file" }, async args => {
325
- // Re-read on every load so a `?mtime` reload picks up edited source.
326
547
  const raw = await Bun.file(args.path).text();
327
- return { contents: rewriteLegacyExtensionSource(raw), loader: getLoader(args.path) };
548
+ return { contents: await rewriteLegacyExtensionSource(raw, args.path), loader: getLoader(args.path) };
328
549
  });
329
550
  },
330
551
  });
@@ -337,9 +558,8 @@ async function ensureExtensionGraphHook(entryRealPath: string): Promise<void> {
337
558
  * and `__dirname`-relative `readFileSync` asset loads (HTML/CSS bundled next to
338
559
  * the entry) resolve exactly as they do under the original Pi runtime — no
339
560
  * temp-directory mirroring and no asset copying. An `onLoad` hook scoped to the
340
- * entry's relative-import graph rewrites only the legacy `@(scope)/pi-*` and
341
- * `@sinclair/typebox` imports in the extension's own source; everything else
342
- * resolves natively.
561
+ * entry's source graph rewrites only host-resolved compatibility imports in the
562
+ * extension's own source; everything else resolves natively.
343
563
  */
344
564
  export async function loadLegacyPiModule(resolvedPath: string): Promise<unknown> {
345
565
  // Bun reports the realpath of a loaded module to `onLoad` and exposes it as
@@ -9,10 +9,10 @@
9
9
 
10
10
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
11
11
  import { logger } from "@oh-my-pi/pi-utils";
12
- import type { Settings } from "../config/settings";
12
+ import { onHindsightScopeChanged, type Settings } from "../config/settings";
13
13
  import type { MemoryBackend, MemoryBackendStartOptions } from "../memory-backend/types";
14
14
  import type { AgentSession } from "../session/agent-session";
15
- import { computeBankScope } from "./bank";
15
+ import { type BankScope, computeBankScope } from "./bank";
16
16
  import { createHindsightClient } from "./client";
17
17
  import { isHindsightConfigured, loadHindsightConfig } from "./config";
18
18
  import type { HindsightMessage } from "./content";
@@ -60,12 +60,16 @@ export const hindsightBackend: MemoryBackend = {
60
60
  recallTagsMatch: parent.recallTagsMatch,
61
61
  config: parent.config,
62
62
  session,
63
- missionsSet: parent.missionsSet,
63
+ banksSet: parent.banksSet,
64
64
  lastRetainedTurn: 0,
65
65
  hasRecalledForFirstTurn: true,
66
66
  aliasOf: parent,
67
67
  }),
68
68
  );
69
+ // Aliases don't run auto-recall/auto-retain, so any pending retain
70
+ // queue belongs to the previous alias and is safe to drop after a
71
+ // best-effort flush (`flushRetainQueue` is no-op when empty).
72
+ await previous?.flushRetainQueue();
69
73
  previous?.dispose();
70
74
  return;
71
75
  }
@@ -76,38 +80,7 @@ export const hindsightBackend: MemoryBackend = {
76
80
  return;
77
81
  }
78
82
 
79
- const client = createHindsightClient(config);
80
- const scope = computeBankScope(config, session.sessionManager.getCwd());
81
-
82
- const state = new HindsightSessionState({
83
- sessionId,
84
- client,
85
- bankId: scope.bankId,
86
- retainTags: scope.retainTags,
87
- recallTags: scope.recallTags,
88
- recallTagsMatch: scope.recallTagsMatch,
89
- config,
90
- session,
91
- missionsSet: new Set(),
92
- lastRetainedTurn: 0,
93
- hasRecalledForFirstTurn: false,
94
- });
95
-
96
- // Cleanup any stale state for this session (defensive — prevents leaks
97
- // when a session is reused without going through dispose).
98
- const previous = session.setHindsightSessionState(state);
99
- previous?.dispose();
100
- state.attachSessionListeners();
101
-
102
- // Kick off mental-model bootstrap. Resolves asynchronously; the first
103
- // turn races and is covered in `beforeAgentStartPrompt` via
104
- // `mentalModelsLoadPromise`. Subsequent turns see the populated cache
105
- // because `runMentalModelLoad` calls `refreshBaseSystemPrompt`.
106
- if (config.mentalModelsEnabled) {
107
- state.mentalModelsLoadPromise = state.runMentalModelLoad(scope).catch(err => {
108
- logger.debug("Hindsight: mental-model bootstrap failed", { bankId: state.bankId, error: String(err) });
109
- });
110
- }
83
+ await installPrimaryState(session, settings, new Set());
111
84
  },
112
85
 
113
86
  async buildDeveloperInstructions(_agentDir, settings, session): Promise<string | undefined> {
@@ -173,6 +146,182 @@ export const hindsightBackend: MemoryBackend = {
173
146
  return await state.recallForCompaction(flat);
174
147
  },
175
148
  };
149
+ interface PrimaryRebuildTask {
150
+ pending: boolean;
151
+ }
152
+
153
+ const primaryRebuildTasks = new WeakMap<AgentSession, PrimaryRebuildTask>();
154
+
155
+ /**
156
+ * Coalesce and serialize live scope rebuilds for one session. Cwd reloads fire
157
+ * all settings hooks synchronously; running every callback immediately would
158
+ * let multiple rebuilds capture the same old state and leak the fresh states
159
+ * installed by earlier continuations.
160
+ */
161
+ function schedulePrimaryStateRebuild(session: AgentSession): void {
162
+ const task = primaryRebuildTasks.get(session);
163
+ if (task) {
164
+ task.pending = true;
165
+ return;
166
+ }
167
+
168
+ const nextTask: PrimaryRebuildTask = { pending: true };
169
+ primaryRebuildTasks.set(session, nextTask);
170
+ void Promise.resolve()
171
+ .then(async () => {
172
+ while (nextTask.pending) {
173
+ nextTask.pending = false;
174
+ try {
175
+ await rebuildPrimaryStateOnScopeChange(session);
176
+ } catch (err) {
177
+ logger.warn("Hindsight: scope rebuild failed", { error: String(err) });
178
+ }
179
+ }
180
+ })
181
+ .finally(() => {
182
+ if (primaryRebuildTasks.get(session) === nextTask) {
183
+ primaryRebuildTasks.delete(session);
184
+ }
185
+ });
186
+ }
187
+
188
+ /**
189
+ * Build (or rebuild) the primary `HindsightSessionState` for `session` from
190
+ * the current settings and install it. Disposes any previous primary state
191
+ * after flushing its retain queue so in-flight tool-initiated retains land in
192
+ * the bank that was selected when they were enqueued, not in the new bank.
193
+ *
194
+ * The created state takes ownership of the `onHindsightScopeChanged`
195
+ * subscription so subsequent `hindsight.bankId` / `bankIdPrefix` / `scoping`
196
+ * edits trigger another rebuild from the same wiring.
197
+ */
198
+ async function installPrimaryState(
199
+ session: AgentSession,
200
+ settings: Settings,
201
+ banksSet: Set<string>,
202
+ ): Promise<HindsightSessionState | undefined> {
203
+ const sessionId = session.sessionId;
204
+ if (!sessionId) return undefined;
205
+
206
+ const config = loadHindsightConfig(settings);
207
+ if (!isHindsightConfigured(config)) return undefined;
208
+
209
+ const client = createHindsightClient(config);
210
+ const scope = computeBankScope(config, session.sessionManager.getCwd());
211
+
212
+ // Cleanup any stale state for this session (defensive — prevents leaks
213
+ // when a session is reused without going through dispose). Flush the
214
+ // previous state's retain queue BEFORE clearing it, otherwise
215
+ // `HindsightRetainQueue.#doFlush` sees `session.getHindsightSessionState()
216
+ // !== state` and drops the batch. Re-read after the await so a concurrent
217
+ // owner cannot leave the actual current state undisposed.
218
+ let previous = session.getHindsightSessionState();
219
+ if (previous) {
220
+ await previous.flushRetainQueue();
221
+ }
222
+ const latest = session.getHindsightSessionState();
223
+ if (latest && latest !== previous) {
224
+ previous?.dispose();
225
+ previous = latest;
226
+ await previous.flushRetainQueue();
227
+ }
228
+
229
+ const state = new HindsightSessionState({
230
+ sessionId,
231
+ client,
232
+ bankId: scope.bankId,
233
+ retainTags: scope.retainTags,
234
+ recallTags: scope.recallTags,
235
+ recallTagsMatch: scope.recallTagsMatch,
236
+ config,
237
+ session,
238
+ banksSet,
239
+ lastRetainedTurn: 0,
240
+ hasRecalledForFirstTurn: false,
241
+ });
242
+
243
+ // Subscribe BEFORE installing: if the operator manages to flip another
244
+ // setting between install and subscribe, we'd miss the edge.
245
+ state.unsubscribeScope = onHindsightScopeChanged(() => {
246
+ schedulePrimaryStateRebuild(session);
247
+ });
248
+
249
+ const displaced = session.setHindsightSessionState(state);
250
+ if (displaced && displaced !== previous) {
251
+ await displaced.flushRetainQueue();
252
+ displaced.dispose();
253
+ }
254
+ previous?.dispose();
255
+ state.attachSessionListeners();
256
+
257
+ // Kick off mental-model bootstrap. Resolves asynchronously; the first
258
+ // turn races and is covered in `beforeAgentStartPrompt` via
259
+ // `mentalModelsLoadPromise`. Subsequent turns see the populated cache
260
+ // because `runMentalModelLoad` calls `refreshBaseSystemPrompt`.
261
+ if (config.mentalModelsEnabled) {
262
+ state.mentalModelsLoadPromise = state.runMentalModelLoad(scope).catch(err => {
263
+ logger.debug("Hindsight: mental-model bootstrap failed", { bankId: state.bankId, error: String(err) });
264
+ });
265
+ }
266
+
267
+ return state;
268
+ }
269
+
270
+ /**
271
+ * `onHindsightScopeChanged` handler: re-evaluate the bank scope from current
272
+ * settings and rebuild the primary state when it has actually drifted. No-op
273
+ * when the scope is unchanged or the session is no longer hosting a primary
274
+ * state (e.g. it was wiped to `undefined`, or this is a subagent alias).
275
+ */
276
+ async function rebuildPrimaryStateOnScopeChange(session: AgentSession): Promise<void> {
277
+ const current = session.getHindsightSessionState();
278
+ if (!current || current.aliasOf) return;
279
+
280
+ const settings = session.settings;
281
+ const config = loadHindsightConfig(settings);
282
+ if (!isHindsightConfigured(config)) {
283
+ // Hindsight effectively unwired mid-session. Flush before clearing so
284
+ // queued retains don't get dropped by `HindsightRetainQueue.#doFlush`.
285
+ await current.flushRetainQueue();
286
+ const previous = session.setHindsightSessionState(undefined);
287
+ previous?.dispose();
288
+ return;
289
+ }
290
+
291
+ const next = computeBankScope(config, session.sessionManager.getCwd());
292
+ if (bankScopesEqual(next, current)) return;
293
+
294
+ // Preserve the banksSet so we don't re-PUT banks we've already confirmed.
295
+ await installPrimaryState(session, settings, current.banksSet);
296
+ }
297
+
298
+ /** Tag-array equality: order matters because we never reorder on the way in. */
299
+ function stringArraysEqual(a: string[] | undefined, b: string[] | undefined): boolean {
300
+ if (a === b) return true;
301
+ if (!a || !b) return false;
302
+ if (a.length !== b.length) return false;
303
+ for (let i = 0; i < a.length; i++) {
304
+ if (a[i] !== b[i]) return false;
305
+ }
306
+ return true;
307
+ }
308
+
309
+ /**
310
+ * Structural compare of a freshly resolved `BankScope` against a live state's
311
+ * bank routing. Used by the scope-change handler to skip rebuilds that don't
312
+ * actually move the bank or its tag filters.
313
+ */
314
+ function bankScopesEqual(
315
+ scope: BankScope,
316
+ state: Pick<HindsightSessionState, "bankId" | "retainTags" | "recallTags" | "recallTagsMatch">,
317
+ ): boolean {
318
+ return (
319
+ scope.bankId === state.bankId &&
320
+ stringArraysEqual(scope.retainTags, state.retainTags) &&
321
+ stringArraysEqual(scope.recallTags, state.recallTags) &&
322
+ scope.recallTagsMatch === state.recallTagsMatch
323
+ );
324
+ }
176
325
 
177
326
  /** Reduce arbitrary AgentMessages into the Hindsight flat-text shape. */
178
327
  function flattenMessagesForRecall(messages: AgentMessage[]): HindsightMessage[] {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Bank ID derivation, project-tag scoping, and first-use mission setup.
2
+ * Bank ID derivation, project-tag scoping, and first-use bank setup.
3
3
  *
4
4
  * Three scoping modes (`HindsightConfig.scoping`):
5
5
  * - `global` — single shared bank, no per-project filter.
@@ -11,10 +11,13 @@
11
11
  * The base bank id is `bankIdPrefix-bankId` (default `omp`). Per-project mode
12
12
  * appends `-<project>`; tagged mode leaves the bank untouched and uses tags.
13
13
  *
14
- * Mission setup is idempotent at module level — a missionsSet keeps track of
15
- * banks we've already POSTed to so each session boundary doesn't fire a fresh
16
- * `createBank` call. Failures are swallowed: missions are an optimisation, not
17
- * a precondition for retain/recall.
14
+ * Bank existence is idempotent at module level — a banksSet keeps track of
15
+ * banks we've already PUT so each session boundary doesn't fire a fresh
16
+ * `createBank` call. The PUT is idempotent server-side, so re-firing on a hot
17
+ * path would only burn round-trips. Failures are swallowed: missing the
18
+ * mission patch is an optimisation, but the bank ITSELF must exist before
19
+ * mental-model bootstrap or the first retain, otherwise the very first POST
20
+ * lands against a missing bank.
18
21
  */
19
22
 
20
23
  import * as path from "node:path";
@@ -93,39 +96,46 @@ export function deriveBankId(config: HindsightConfig, directory: string): string
93
96
  }
94
97
 
95
98
  /**
96
- * Ensure a bank's reflect/retain mission is set, exactly once per process.
99
+ * Ensure a bank exists, and patch its reflect/retain mission on first use.
97
100
  *
98
- * Tracked via the supplied set; on overflow we drop the oldest half so the set
99
- * cannot grow unboundedly across long-lived processes.
101
+ * Idempotent: skips the PUT when the bank id is already in the supplied set.
102
+ * The mission body is optional — when `bankMission` is blank we still PUT to
103
+ * make sure the bank itself is created, so mental-model bootstrap and the
104
+ * first retain don't land against a non-existent bank.
105
+ *
106
+ * The set is capped; on overflow we drop the oldest half so it cannot grow
107
+ * unboundedly across long-lived processes.
100
108
  */
101
- export async function ensureBankMission(
109
+ export async function ensureBankExists(
102
110
  client: HindsightApi,
103
111
  bankId: string,
104
112
  config: HindsightConfig,
105
- missionsSet: Set<string>,
113
+ banksSet: Set<string>,
106
114
  ): Promise<void> {
115
+ if (banksSet.has(bankId)) return;
116
+
107
117
  const mission = config.bankMission?.trim();
108
- if (!mission) return;
109
- if (missionsSet.has(bankId)) return;
118
+ const retainMission = config.retainMission?.trim();
110
119
 
111
120
  try {
112
121
  await client.createBank(bankId, {
113
- reflectMission: mission,
114
- retainMission: config.retainMission?.trim() || undefined,
122
+ reflectMission: mission || undefined,
123
+ retainMission: retainMission || undefined,
115
124
  });
116
- missionsSet.add(bankId);
117
- if (missionsSet.size > MISSION_SET_CAP) {
118
- const keys = [...missionsSet].sort();
125
+ banksSet.add(bankId);
126
+ if (banksSet.size > MISSION_SET_CAP) {
127
+ const keys = [...banksSet].sort();
119
128
  for (const key of keys.slice(0, keys.length >> 1)) {
120
- missionsSet.delete(key);
129
+ banksSet.delete(key);
121
130
  }
122
131
  }
123
132
  if (config.debug) {
124
- logger.debug("Hindsight: set mission for bank", { bankId });
133
+ logger.debug("Hindsight: ensured bank", { bankId, mission: Boolean(mission) });
125
134
  }
126
135
  } catch (err) {
127
- // Mission set is best-effort; the bank may not exist yet, or the API may
128
- // reject the call. Either way, retain/recall still work, so swallow.
129
- logger.debug("Hindsight: ensureBankMission failed", { bankId, error: String(err) });
136
+ // Bank creation is best-effort; the server may already have it, or the
137
+ // API may reject the call. Either way, downstream retain/recall calls
138
+ // will surface a clearer error if the bank really is missing.
139
+ logger.debug("Hindsight: ensureBankExists failed", { bankId, error: String(err) });
130
140
  }
131
141
  }
@@ -112,7 +112,7 @@ function dedupe<T>(items: T[]): T[] {
112
112
  * Idempotently create any seed mental models that don't already exist on the
113
113
  * bank. Best-effort: a list/create failure does not throw — mental models are
114
114
  * an optimization, not a precondition for retain/recall, and we mirror the
115
- * swallow-on-failure pattern used by `ensureBankMission`.
115
+ * swallow-on-failure pattern used by `ensureBankExists`.
116
116
  *
117
117
  * Existing models are NEVER modified. See module docstring.
118
118
  */