@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.
- package/CHANGELOG.md +68 -2
- package/dist/types/cli/classify-install-target.d.ts +5 -1
- package/dist/types/cli/dry-balance-cli.d.ts +104 -0
- package/dist/types/commands/dry-balance.d.ts +31 -0
- package/dist/types/config/model-registry.d.ts +2 -0
- package/dist/types/config/models-config-schema.d.ts +3 -0
- package/dist/types/config/settings-schema.d.ts +13 -4
- package/dist/types/config/settings.d.ts +11 -0
- package/dist/types/discovery/helpers.d.ts +1 -0
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +2 -3
- package/dist/types/hindsight/bank.d.ts +17 -9
- package/dist/types/hindsight/mental-models.d.ts +1 -1
- package/dist/types/hindsight/state.d.ts +9 -3
- package/dist/types/mcp/manager.d.ts +1 -1
- package/dist/types/modes/components/assistant-message.d.ts +11 -0
- package/dist/types/modes/components/custom-editor.d.ts +3 -1
- package/dist/types/modes/components/error-banner.d.ts +11 -0
- package/dist/types/modes/components/tool-execution.d.ts +15 -0
- package/dist/types/modes/components/transcript-container.d.ts +4 -2
- package/dist/types/modes/components/user-message.d.ts +1 -1
- package/dist/types/modes/image-references.d.ts +17 -0
- package/dist/types/modes/interactive-mode.d.ts +7 -0
- package/dist/types/modes/types.d.ts +7 -0
- package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
- package/dist/types/session/agent-session.d.ts +9 -0
- package/dist/types/session/auth-storage.d.ts +2 -2
- package/dist/types/session/blob-store.d.ts +12 -11
- package/dist/types/session/session-manager.d.ts +5 -3
- package/dist/types/system-prompt.d.ts +2 -0
- package/dist/types/task/types.d.ts +2 -0
- package/dist/types/tiny/title-client.d.ts +16 -1
- package/dist/types/tool-discovery/mode.d.ts +8 -0
- package/dist/types/tools/archive-reader.d.ts +5 -1
- package/dist/types/tools/index.d.ts +16 -0
- package/dist/types/tools/path-utils.d.ts +11 -0
- package/dist/types/tui/hyperlink.d.ts +12 -0
- package/dist/types/web/search/render.d.ts +1 -2
- package/package.json +9 -9
- package/src/cli/classify-install-target.ts +31 -5
- package/src/cli/dry-balance-cli.ts +823 -0
- package/src/cli/plugin-cli.ts +45 -0
- package/src/cli/web-search-cli.ts +0 -1
- package/src/cli-commands.ts +1 -0
- package/src/commands/dry-balance.ts +43 -0
- package/src/config/model-registry.ts +60 -4
- package/src/config/models-config-schema.ts +2 -0
- package/src/config/settings-schema.ts +14 -4
- package/src/config/settings.ts +38 -0
- package/src/discovery/builtin-rules/ts-no-tiny-functions.md +1 -0
- package/src/discovery/github.ts +37 -1
- package/src/discovery/helpers.ts +3 -1
- package/src/eval/__tests__/agent-bridge.test.ts +72 -0
- package/src/eval/py/tool-bridge.ts +43 -5
- package/src/extensibility/custom-commands/bundled/ci-green/index.ts +31 -2
- package/src/extensibility/plugins/legacy-pi-compat.ts +245 -25
- package/src/hindsight/backend.ts +184 -35
- package/src/hindsight/bank.ts +32 -22
- package/src/hindsight/mental-models.ts +1 -1
- package/src/hindsight/state.ts +21 -7
- package/src/internal-urls/docs-index.generated.ts +6 -6
- package/src/internal-urls/omp-protocol.ts +8 -2
- package/src/main.ts +7 -1
- package/src/mcp/manager.ts +40 -21
- package/src/modes/components/assistant-message.ts +22 -0
- package/src/modes/components/custom-editor.ts +14 -2
- package/src/modes/components/error-banner.ts +33 -0
- package/src/modes/components/tool-execution.ts +44 -0
- package/src/modes/components/transcript-container.ts +102 -30
- package/src/modes/components/tree-selector.ts +29 -2
- package/src/modes/components/user-message.ts +9 -2
- package/src/modes/controllers/event-controller.ts +42 -3
- package/src/modes/controllers/input-controller.ts +41 -3
- package/src/modes/image-references.ts +111 -0
- package/src/modes/interactive-mode.ts +48 -13
- package/src/modes/setup-wizard/scenes/sign-in.ts +27 -7
- package/src/modes/types.ts +10 -1
- package/src/modes/utils/ui-helpers.ts +23 -2
- package/src/prompts/agents/explore.md +1 -0
- package/src/prompts/agents/librarian.md +1 -0
- package/src/prompts/ci-green-request.md +5 -3
- package/src/prompts/dry-balance-bench.md +8 -0
- package/src/prompts/system/project-prompt.md +1 -0
- package/src/sdk.ts +99 -18
- package/src/session/agent-session.ts +103 -19
- package/src/session/auth-storage.ts +4 -0
- package/src/session/blob-store.ts +96 -9
- package/src/session/session-manager.ts +19 -10
- package/src/system-prompt.ts +4 -0
- package/src/task/executor.ts +6 -2
- package/src/task/index.ts +8 -7
- package/src/task/types.ts +2 -0
- package/src/tiny/title-client.ts +7 -1
- package/src/tool-discovery/mode.ts +24 -0
- package/src/tools/archive-reader.ts +339 -31
- package/src/tools/bash.ts +3 -4
- package/src/tools/fetch.ts +29 -9
- package/src/tools/gh.ts +65 -11
- package/src/tools/index.ts +22 -8
- package/src/tools/job.ts +3 -3
- package/src/tools/memory-reflect.ts +2 -2
- package/src/tools/path-utils.ts +21 -0
- package/src/tools/read.ts +58 -12
- package/src/tools/search-tool-bm25.ts +4 -6
- package/src/tools/search.ts +78 -12
- package/src/tui/hyperlink.ts +42 -7
- package/src/utils/file-mentions.ts +7 -107
- package/src/utils/title-generator.ts +58 -37
- package/src/web/search/index.ts +2 -2
- 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
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
232
|
+
* Rewrite the extension-owned specifiers OMP must host-resolve — legacy
|
|
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
|
-
|
|
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
|
|
248
|
-
// `
|
|
249
|
-
//
|
|
250
|
-
const
|
|
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(
|
|
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 =
|
|
293
|
-
|
|
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
|
|
307
|
-
* `@sinclair/typebox
|
|
308
|
-
* cannot fall through (Bun requires a result object),
|
|
309
|
-
* exact-path alternation of the graph's realpaths — it
|
|
310
|
-
* other extensions, `node_modules` deps, or unrelated
|
|
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
|
|
341
|
-
*
|
|
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
|
package/src/hindsight/backend.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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[] {
|
package/src/hindsight/bank.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Bank ID derivation, project-tag scoping, and first-use
|
|
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
|
-
*
|
|
15
|
-
* banks we've already
|
|
16
|
-
* `createBank` call.
|
|
17
|
-
*
|
|
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
|
|
99
|
+
* Ensure a bank exists, and patch its reflect/retain mission on first use.
|
|
97
100
|
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
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
|
|
109
|
+
export async function ensureBankExists(
|
|
102
110
|
client: HindsightApi,
|
|
103
111
|
bankId: string,
|
|
104
112
|
config: HindsightConfig,
|
|
105
|
-
|
|
113
|
+
banksSet: Set<string>,
|
|
106
114
|
): Promise<void> {
|
|
115
|
+
if (banksSet.has(bankId)) return;
|
|
116
|
+
|
|
107
117
|
const mission = config.bankMission?.trim();
|
|
108
|
-
|
|
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:
|
|
122
|
+
reflectMission: mission || undefined,
|
|
123
|
+
retainMission: retainMission || undefined,
|
|
115
124
|
});
|
|
116
|
-
|
|
117
|
-
if (
|
|
118
|
-
const keys = [...
|
|
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
|
-
|
|
129
|
+
banksSet.delete(key);
|
|
121
130
|
}
|
|
122
131
|
}
|
|
123
132
|
if (config.debug) {
|
|
124
|
-
logger.debug("Hindsight:
|
|
133
|
+
logger.debug("Hindsight: ensured bank", { bankId, mission: Boolean(mission) });
|
|
125
134
|
}
|
|
126
135
|
} catch (err) {
|
|
127
|
-
//
|
|
128
|
-
// reject the call. Either way, retain/recall
|
|
129
|
-
|
|
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 `
|
|
115
|
+
* swallow-on-failure pattern used by `ensureBankExists`.
|
|
116
116
|
*
|
|
117
117
|
* Existing models are NEVER modified. See module docstring.
|
|
118
118
|
*/
|