@oh-my-pi/pi-coding-agent 16.3.3 → 16.3.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 (47) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/cli.js +3405 -3382
  3. package/dist/types/hindsight/content.d.ts +4 -0
  4. package/dist/types/internal-urls/artifact-protocol.d.ts +8 -0
  5. package/dist/types/internal-urls/types.d.ts +10 -0
  6. package/dist/types/lsp/config.d.ts +4 -0
  7. package/dist/types/lsp/edits.d.ts +2 -0
  8. package/dist/types/mcp/oauth-discovery.d.ts +30 -0
  9. package/dist/types/modes/components/login-dialog.d.ts +10 -2
  10. package/dist/types/modes/components/transcript-container.d.ts +0 -1
  11. package/dist/types/modes/controllers/mcp-command-controller.d.ts +20 -3
  12. package/dist/types/modes/rpc/rpc-client.d.ts +7 -3
  13. package/dist/types/modes/rpc/rpc-types.d.ts +6 -0
  14. package/dist/types/subprocess/worker-runtime.d.ts +11 -0
  15. package/dist/types/tools/bash-skill-urls.d.ts +2 -2
  16. package/dist/types/utils/git.d.ts +16 -0
  17. package/package.json +12 -12
  18. package/src/cli/auth-broker-cli.ts +13 -2
  19. package/src/cli/tiny-models-cli.ts +12 -5
  20. package/src/cli/usage-cli.ts +34 -5
  21. package/src/hindsight/content.ts +31 -0
  22. package/src/internal-urls/artifact-protocol.ts +97 -53
  23. package/src/internal-urls/types.ts +10 -0
  24. package/src/lsp/config.ts +15 -0
  25. package/src/lsp/edits.ts +28 -7
  26. package/src/lsp/index.ts +46 -4
  27. package/src/mcp/oauth-discovery.ts +88 -18
  28. package/src/mnemopi/state.ts +26 -2
  29. package/src/modes/components/login-dialog.ts +16 -2
  30. package/src/modes/components/mcp-add-wizard.ts +9 -1
  31. package/src/modes/components/transcript-container.ts +0 -26
  32. package/src/modes/controllers/mcp-command-controller.ts +106 -29
  33. package/src/modes/controllers/selector-controller.ts +9 -1
  34. package/src/modes/rpc/rpc-client.ts +8 -4
  35. package/src/modes/rpc/rpc-mode.ts +1 -0
  36. package/src/modes/rpc/rpc-types.ts +13 -1
  37. package/src/modes/setup-wizard/scenes/sign-in.ts +18 -0
  38. package/src/prompts/tools/read.md +1 -1
  39. package/src/subprocess/worker-runtime.ts +219 -2
  40. package/src/task/worktree.ts +28 -6
  41. package/src/tiny/worker.ts +14 -4
  42. package/src/tools/bash-skill-urls.ts +3 -3
  43. package/src/tools/grep.ts +19 -2
  44. package/src/tools/path-utils.ts +4 -0
  45. package/src/tools/read.ts +198 -1
  46. package/src/utils/git.ts +20 -0
  47. package/src/utils/open.ts +51 -6
@@ -64,6 +64,10 @@ export interface RetentionTranscript {
64
64
  transcript: string | null;
65
65
  messageCount: number;
66
66
  }
67
+ /** Remove retention framing lines from a stored coding-agent episode transcript. */
68
+ export declare function stripRetentionProtocolMarkers(content: string): string;
67
69
  export declare function prepareRetentionTranscript(messages: HindsightMessage[], retainFullWindow?: boolean): RetentionTranscript;
70
+ /** Format all retention messages without protocol markers for embedding, FTS, and recall display. */
71
+ export declare function prepareEmbeddableRetentionTranscript(messages: HindsightMessage[]): RetentionTranscript;
68
72
  /** Format only user-authored messages for memory fact/entity extraction. */
69
73
  export declare function prepareUserRetentionTranscript(messages: HindsightMessage[]): RetentionTranscript;
@@ -1,4 +1,12 @@
1
1
  import type { InternalResource, InternalUrl, ProtocolHandler, ResolveContext, UrlCompletion } from "./types";
2
+ /** Filesystem location for a session artifact, resolved without materializing its content. */
3
+ export interface ResolvedArtifactFile {
4
+ id: string;
5
+ path: string;
6
+ size: number;
7
+ }
8
+ /** Resolve an `artifact://` URL to its backing file without reading artifact bytes. */
9
+ export declare function resolveArtifactFile(url: InternalUrl, context?: ResolveContext): Promise<ResolvedArtifactFile>;
2
10
  export declare class ArtifactProtocolHandler implements ProtocolHandler {
3
11
  readonly scheme = "artifact";
4
12
  readonly immutable = true;
@@ -102,6 +102,16 @@ export interface ResolveContext {
102
102
  * reject directory resources, so they never need the listing.
103
103
  */
104
104
  skipDirectoryListing?: boolean;
105
+ /**
106
+ * When set, handlers that would otherwise materialize expensive content
107
+ * (e.g. reading a multi-MiB artifact into memory just to expose its
108
+ * `sourcePath`) may return the resource shape without content. Callers
109
+ * that only need `sourcePath` — search/grep, bash URL expansion — pass
110
+ * this so a large `artifact://` still resolves to its backing file
111
+ * without OOM risk. Handlers that cannot separate path from content
112
+ * ignore the flag.
113
+ */
114
+ pathOnly?: boolean;
105
115
  }
106
116
  /**
107
117
  * Caller context for write operations dispatched to host-owned URI handlers.
@@ -8,6 +8,10 @@ export interface LspConfig {
8
8
  * Check if any root marker file exists in the directory
9
9
  */
10
10
  export declare function hasRootMarkers(cwd: string, markers: string[]): boolean;
11
+ /**
12
+ * Check whether any ancestor directory of a file is an LSP project root.
13
+ */
14
+ export declare function hasRootMarkerAncestor(filePath: string, markers: string[]): boolean;
11
15
  /**
12
16
  * Resolve a command to an executable path.
13
17
  * Checks project-local bin directories first, then falls back to $PATH.
@@ -11,6 +11,8 @@ export declare function rangesOverlap(a: Range, b: Range): boolean;
11
11
  * Equal start positions tiebreak by original array index descending so that,
12
12
  * applied bottom-up, inserts at the same position land in array order
13
13
  * (LSP spec: the order of edits in the array defines the order in the result).
14
+ * Byte-identical non-empty range edits are idempotent, so duplicate server
15
+ * output is collapsed before overlap validation.
14
16
  */
15
17
  export declare function sortAndValidateTextEdits(edits: TextEdit[]): TextEdit[];
16
18
  /**
@@ -12,9 +12,26 @@ export interface AuthDetectionResult {
12
12
  oauth?: OAuthEndpoints;
13
13
  authServerUrl?: string;
14
14
  resourceMetadataUrl?: string;
15
+ /**
16
+ * OAuth scopes advertised by the challenge (RFC 6750 `scope=` on
17
+ * `WWW-Authenticate`) or by protected-resource metadata. Passed through
18
+ * `discoverOAuthEndpoints` as `protectedScopes` so the eventual
19
+ * authorization request carries them even when the auth-server metadata
20
+ * document itself omits `scopes_supported`.
21
+ */
22
+ scopes?: string;
15
23
  message?: string;
16
24
  }
17
25
  export declare function extractMcpAuthServerUrl(error: Error, serverUrl?: string): string | undefined;
26
+ /**
27
+ * Pull the `scope`/`scopes` parameter out of a `WWW-Authenticate` challenge
28
+ * embedded in the error message. RFC 6750 lets servers advertise the missing
29
+ * scopes when they reject a bearer token with `insufficient_scope`, and RFC
30
+ * 8414-adjacent MCP gateways sometimes list the required scopes there rather
31
+ * than in `scopes_supported`. Returns the raw space-separated value, or
32
+ * `undefined` when the challenge does not carry one.
33
+ */
34
+ export declare function extractOAuthChallengeScopes(error: Error): string | undefined;
18
35
  /**
19
36
  * Extract OAuth endpoints from error response.
20
37
  * Looks for WWW-Authenticate header format or JSON error bodies.
@@ -25,6 +42,18 @@ export declare function extractOAuthEndpoints(error: Error): OAuthEndpoints | nu
25
42
  * Returns structured info about what auth is needed.
26
43
  */
27
44
  export declare function analyzeAuthError(error: Error, serverUrl?: string): AuthDetectionResult;
45
+ /**
46
+ * Fetch the RFC 9728 protected-resource metadata document at
47
+ * {@link resourceMetadataUrl} and return any scopes it advertises. Used by
48
+ * `/mcp add` / `/mcp reauth` on the JSON-error-body path, where the caller
49
+ * already holds usable OAuth endpoints but the required scopes live only in
50
+ * the advertised protected-resource metadata — a case `discoverOAuthEndpoints`
51
+ * normally handles but that path is skipped when the body carried endpoints.
52
+ * Returns `undefined` on any error or when no scopes are advertised.
53
+ */
54
+ export declare function fetchResourceMetadataScopes(resourceMetadataUrl: string, opts?: {
55
+ fetch?: FetchImpl;
56
+ }): Promise<string | undefined>;
28
57
  /**
29
58
  * Try to discover OAuth endpoints by querying the server's well-known endpoints.
30
59
  * This is a fallback when error responses don't include OAuth metadata.
@@ -32,4 +61,5 @@ export declare function analyzeAuthError(error: Error, serverUrl?: string): Auth
32
61
  export declare function discoverOAuthEndpoints(serverUrl: string, authServerUrl?: string, resourceMetadataUrl?: string, opts?: {
33
62
  fetch?: FetchImpl;
34
63
  protectedResource?: string;
64
+ protectedScopes?: string;
35
65
  }): Promise<OAuthEndpoints | null>;
@@ -8,9 +8,17 @@ export declare class LoginDialogComponent extends Container {
8
8
  constructor(tui: TUI, providerId: string, onComplete: (success: boolean, message?: string) => void);
9
9
  get signal(): AbortSignal;
10
10
  /**
11
- * Called by onAuth callback - show URL and optional instructions
11
+ * Called by the OAuth `onAuth` callback. Renders the full authorization URL
12
+ * as the primary copy target — that works from any machine, including
13
+ * SSH/WSL/headless sessions where the OMP-hosted `launchUrl` would resolve
14
+ * against the user's local browser and fail. When `launchUrl` is present it
15
+ * is offered as an additional local shortcut so narrow local terminals still
16
+ * have a truncation-safe copy target (viewport clipping on a long authorize
17
+ * URL silently drops trailing OAuth query parameters — e.g.
18
+ * `code_challenge_method=S256`). The OSC 8 hyperlink carries the full URL
19
+ * for terminals that support click-through.
12
20
  */
13
- showAuth(url: string, instructions?: string): void;
21
+ showAuth(url: string, instructions?: string, launchUrl?: string): void;
14
22
  /**
15
23
  * Show input for manual code/URL entry (for callback server providers)
16
24
  */
@@ -31,7 +31,6 @@ export declare class TranscriptContainer extends Container implements NativeScro
31
31
  getNativeScrollbackLiveRegionStart(): number | undefined;
32
32
  getNativeScrollbackCommitSafeEnd(): number | undefined;
33
33
  getNativeScrollbackSnapshotSafeEnd(): number | undefined;
34
- getNativeScrollbackOfferSafeEnd(): number | undefined;
35
34
  /**
36
35
  * Whether `component` sits below a still-mutating block — i.e. inside the
37
36
  * live region, where its rows cannot have been committed to native
@@ -1,11 +1,28 @@
1
1
  import { type Component } from "@oh-my-pi/pi-tui";
2
2
  import type { InteractiveModeContext } from "../types";
3
- /** Renders the MCP OAuth fallback URL without hard-wrapping the copy target. */
3
+ /**
4
+ * Renders the MCP OAuth fallback URL. Always shows the full authorization URL
5
+ * as the primary `Copy URL:` target — that works from any machine, including
6
+ * SSH/WSL/headless sessions where the OMP-hosted `/launch` loopback URL would
7
+ * resolve against the user's local browser and fail.
8
+ *
9
+ * The render is `width`-aware: on any viewport narrower than the composed row
10
+ * ({@link TUI#prepareLine} truncates anything wider with `Ellipsis.Omit`, no
11
+ * marker), the URL is hard-wrapped into width-fitted rows so the primary copy
12
+ * target can never silently lose trailing OAuth parameters — the failure mode
13
+ * that motivated #4418 in the first place. Browsers strip whitespace when a
14
+ * multi-row selection is pasted into the address bar, so the reassembled URL
15
+ * is byte-identical to what we rendered.
16
+ *
17
+ * When the flow's callback server hosts a short `launchUrl`, it is offered
18
+ * as an additional local shortcut for wide-terminal local users. The OSC 8
19
+ * hyperlink continues to carry the full URL for terminals that support it.
20
+ */
4
21
  export declare class MCPAuthorizationLinkPrompt implements Component {
5
22
  #private;
6
- constructor(url: string);
23
+ constructor(url: string, launchUrl?: string);
7
24
  invalidate(): void;
8
- render(_width: number): readonly string[];
25
+ render(width: number): readonly string[];
9
26
  }
10
27
  /**
11
28
  * Thrown by {@link MCPCommandController}'s OAuth handler when the user (or a
@@ -271,11 +271,15 @@ export declare class RpcClient {
271
271
  * The server will emit an `open_url` extension_ui_request for the auth URL.
272
272
  * Resolves when login completes or rejects on failure.
273
273
  *
274
- * @param onOpenUrl Called when the server emits the auth URL. The host must open
275
- * it in a browser for the callback-server OAuth flow to complete.
274
+ * @param onOpenUrl Called when the server emits the auth URL. The host must
275
+ * open `url` in a browser for the callback-server OAuth flow to complete.
276
+ * When the flow's callback server hosts a `/launch` redirect, `launchUrl`
277
+ * is a short loopback URL that 302s to `url` — hosts SHOULD surface it as
278
+ * the truncation-safe copy target so terminal viewport clipping cannot
279
+ * corrupt trailing OAuth query parameters (e.g. `code_challenge_method=S256`).
276
280
  */
277
281
  login(providerId: string, options?: {
278
- onOpenUrl?: (url: string, instructions?: string) => void;
282
+ onOpenUrl?: (url: string, instructions?: string, launchUrl?: string) => void;
279
283
  }): Promise<{
280
284
  providerId: string;
281
285
  }>;
@@ -599,6 +599,12 @@ export type RpcExtensionUIRequest = {
599
599
  id: string;
600
600
  method: "open_url";
601
601
  url: string;
602
+ /**
603
+ * Short loopback URL that 302-redirects to {@link url}. When present,
604
+ * hosts SHOULD surface it as the copy target so terminal viewport
605
+ * truncation cannot corrupt OAuth query parameters on the full URL.
606
+ */
607
+ launchUrl?: string;
602
608
  instructions?: string;
603
609
  };
604
610
  export interface RpcHostToolDefinition {
@@ -69,6 +69,11 @@ export declare function replayCachedReady<K, M>(cache: Map<K, Promise<M>>, model
69
69
  * runtime's `node_modules` directory.
70
70
  */
71
71
  export declare function installSharpStubResolver(runtimeDir: string): Promise<string>;
72
+ /**
73
+ * Repairs the compiled Transformers side runtime when CUDA was requested and
74
+ * Bun skipped `onnxruntime-node`'s NuGet sidecar install.
75
+ */
76
+ export declare function ensureOnnxRuntimeCudaProviders(runtimeDir: string, device?: string | undefined): Promise<void>;
72
77
  /**
73
78
  * Lazily resolve (and memoize) the transformers version spec. In the `catalog:`
74
79
  * case {@link resolveTransformersVersionSpec} `require`s the installed
@@ -88,6 +93,12 @@ interface ConfigurableTransformers {
88
93
  ERROR: unknown;
89
94
  };
90
95
  }
96
+ export interface TransformersRuntimeMetadata {
97
+ __ompRuntimeNodeModules?: string;
98
+ __ompTransformersEntry?: string;
99
+ __ompCudaRepairError?: string;
100
+ }
101
+ export declare function formatOnnxRuntimeCudaDiagnostics(metadata: TransformersRuntimeMetadata, requestedDevice: string, error: unknown): Promise<string | null>;
91
102
  /**
92
103
  * Memoize an async runtime load so it runs at most once per process, clearing
93
104
  * the cache on failure so a later call can retry. Each worker holds one
@@ -1,9 +1,9 @@
1
1
  import type { Skill } from "../extensibility/skills";
2
2
  import { type LocalProtocolOptions } from "../internal-urls";
3
- import type { InternalResource } from "../internal-urls/types";
3
+ import type { InternalResource, ResolveContext } from "../internal-urls/types";
4
4
  interface InternalUrlResolver {
5
5
  canHandle(input: string): boolean;
6
- resolve(input: string): Promise<InternalResource>;
6
+ resolve(input: string, context?: ResolveContext): Promise<InternalResource>;
7
7
  }
8
8
  export interface InternalUrlExpansionOptions {
9
9
  skills: readonly Skill[];
@@ -324,6 +324,22 @@ export declare const patch: {
324
324
  };
325
325
  export declare const cherryPick: ((cwd: string, revision: string, signal?: AbortSignal) => Promise<void>) & {
326
326
  abort(cwd: string, signal?: AbortSignal): Promise<void>;
327
+ /**
328
+ * Skip the current commit of an in-progress cherry-pick sequence and
329
+ * continue with the rest of the range. Use after {@link isEmptyError}
330
+ * reports the current attempt collapsed to a no-op — the alternative,
331
+ * `--abort`, throws away every remaining commit in the range.
332
+ */
333
+ skip(cwd: string, signal?: AbortSignal): Promise<void>;
334
+ /**
335
+ * True when a cherry-pick failure was caused by the current commit
336
+ * being empty against HEAD — either redundant with an already-applied
337
+ * change, or auto-resolved to HEAD by a 3-way merge. Callers should
338
+ * `--skip` in this case to advance the sequencer rather than aborting
339
+ * the whole range: an empty commit is not a merge conflict, and any
340
+ * later commits in the range still deserve to land.
341
+ */
342
+ isEmptyError(err: unknown): boolean;
327
343
  };
328
344
  export declare const stash: {
329
345
  /** Stash working tree + index changes. Returns true when git created a new stash entry. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "16.3.3",
4
+ "version": "16.3.5",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -56,17 +56,17 @@
56
56
  "@agentclientprotocol/sdk": "0.25.0",
57
57
  "@babel/parser": "^7.29.7",
58
58
  "@mozilla/readability": "^0.6.0",
59
- "@oh-my-pi/hashline": "16.3.3",
60
- "@oh-my-pi/omp-stats": "16.3.3",
61
- "@oh-my-pi/pi-agent-core": "16.3.3",
62
- "@oh-my-pi/pi-ai": "16.3.3",
63
- "@oh-my-pi/pi-catalog": "16.3.3",
64
- "@oh-my-pi/pi-mnemopi": "16.3.3",
65
- "@oh-my-pi/pi-natives": "16.3.3",
66
- "@oh-my-pi/pi-tui": "16.3.3",
67
- "@oh-my-pi/pi-utils": "16.3.3",
68
- "@oh-my-pi/pi-wire": "16.3.3",
69
- "@oh-my-pi/snapcompact": "16.3.3",
59
+ "@oh-my-pi/hashline": "16.3.5",
60
+ "@oh-my-pi/omp-stats": "16.3.5",
61
+ "@oh-my-pi/pi-agent-core": "16.3.5",
62
+ "@oh-my-pi/pi-ai": "16.3.5",
63
+ "@oh-my-pi/pi-catalog": "16.3.5",
64
+ "@oh-my-pi/pi-mnemopi": "16.3.5",
65
+ "@oh-my-pi/pi-natives": "16.3.5",
66
+ "@oh-my-pi/pi-tui": "16.3.5",
67
+ "@oh-my-pi/pi-utils": "16.3.5",
68
+ "@oh-my-pi/pi-wire": "16.3.5",
69
+ "@oh-my-pi/snapcompact": "16.3.5",
70
70
  "@opentelemetry/api": "^1.9.1",
71
71
  "@opentelemetry/context-async-hooks": "^2.7.1",
72
72
  "@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
@@ -222,8 +222,19 @@ async function runLocalLogin(provider: OAuthProvider): Promise<void> {
222
222
  // for non-paste-code providers, so this is defense-in-depth on the same gate.
223
223
  const usesManualInput = PASTE_CODE_LOGIN_PROVIDERS.has(provider);
224
224
  await storage.login(provider, {
225
- onAuth({ url, instructions }) {
226
- process.stdout.write(`\nOpen this URL in your browser:\n${url}\n`);
225
+ onAuth({ url, launchUrl, instructions }) {
226
+ process.stdout.write("\nOpen this URL in your browser:\n");
227
+ // Full URL first so the CLI works from any machine, including SSH
228
+ // sessions where a `launchUrl` (loopback `/launch` on the OMP
229
+ // host) would resolve against the caller's browser and fail.
230
+ // Headless capture is unaffected: it reads the first URL line.
231
+ process.stdout.write(`${url}\n`);
232
+ if (launchUrl && launchUrl !== url) {
233
+ // Local shortcut for the machine running OMP. Terminals or
234
+ // screen-scrapers narrower than the full URL still get an
235
+ // unbroken copy target here.
236
+ process.stdout.write(`Local shortcut (this machine only): ${launchUrl}\n`);
237
+ }
227
238
  if (instructions) process.stdout.write(`${instructions}\n`);
228
239
  process.stdout.write("\n");
229
240
  },
@@ -35,12 +35,19 @@ function writeLine(text = ""): void {
35
35
  process.stdout.write(`${text}\n`);
36
36
  }
37
37
 
38
+ const ACTIONABLE_DOWNLOAD_ERROR_LINE = /PI_TINY_|CUDA|cuDNN|cudnn|libcudnn|tiny-title-runtime|onnxruntime-node/i;
39
+
38
40
  function downloadErrorSummary(error: string | undefined): string | undefined {
39
- return error
40
- ?.split(/\r?\n/)
41
- .map(line => line.trim())
42
- .find(line => line.length > 0)
43
- ?.replace(/^Error:\s*/, "");
41
+ const lines =
42
+ error
43
+ ?.split(/\r?\n/)
44
+ .map(line => line.trim().replace(/^Error:\s*/, ""))
45
+ .filter(line => line.length > 0) ?? [];
46
+ const first = lines[0];
47
+ if (!first) return undefined;
48
+ const details = lines.slice(1).filter(line => ACTIONABLE_DOWNLOAD_ERROR_LINE.test(line));
49
+ if (details.length === 0) return first;
50
+ return [first, ...details].join("\n");
44
51
  }
45
52
 
46
53
  export function resolveModels(model: string | undefined): TinyLocalModelKey[] {
@@ -370,6 +370,29 @@ function formatLimitLine(limit: UsageLimit, labelWidth: number, nowMs: number):
370
370
  return lines;
371
371
  }
372
372
 
373
+ interface ProviderLimitTemplate {
374
+ id: string;
375
+ title: string;
376
+ }
377
+
378
+ function collectProviderLimitTemplates(reports: UsageReport[]): ProviderLimitTemplate[] {
379
+ const seen = new Set<string>();
380
+ const templates: ProviderLimitTemplate[] = [];
381
+ for (const report of reports) {
382
+ for (const limit of report.limits) {
383
+ if (seen.has(limit.id)) continue;
384
+ seen.add(limit.id);
385
+ templates.push({ id: limit.id, title: limitTitle(limit) });
386
+ }
387
+ }
388
+ return templates;
389
+ }
390
+
391
+ function formatMissingLimitLine(template: ProviderLimitTemplate, labelWidth: number): string {
392
+ const padded = template.title.padEnd(labelWidth);
393
+ return ` ${chalk.dim("○")} ${padded} ${chalk.dim("·".repeat(BAR_WIDTH))} ${chalk.dim("not reported")}`;
394
+ }
395
+
373
396
  /** Per-window capacity stat: how much account quota is burned and left. */
374
397
  export interface ProviderWindowStat {
375
398
  /** Compact window label, e.g. "5h", "7d". */
@@ -474,9 +497,8 @@ export function formatUsageBreakdown(
474
497
  for (const note of providerNotes)
475
498
  lines.push(` ${chalk.dim(sanitizeText(note.replace(/[\r\n]+/g, " ").replace(/\t/g, " ")))}`);
476
499
 
477
- const labelWidth = providerReports
478
- .flatMap(report => report.limits)
479
- .reduce((max, limit) => Math.max(max, limitTitle(limit).length), 0);
500
+ const providerLimitTemplates = collectProviderLimitTemplates(providerReports);
501
+ const labelWidth = providerLimitTemplates.reduce((max, template) => Math.max(max, template.title.length), 0);
480
502
 
481
503
  providerReports.forEach((report, index) => {
482
504
  lines.push(` ${formatAccountHeader(report, index, nowMs, redaction)}`);
@@ -484,8 +506,15 @@ export function formatUsageBreakdown(
484
506
  lines.push(` ${chalk.dim("no limits reported")}`);
485
507
  return;
486
508
  }
487
- for (const limit of report.limits) {
488
- lines.push(...formatLimitLine(limit, labelWidth, nowMs));
509
+ const limitsById = new Map<string, UsageLimit>();
510
+ for (const limit of report.limits) limitsById.set(limit.id, limit);
511
+ for (const template of providerLimitTemplates) {
512
+ const limit = limitsById.get(template.id);
513
+ if (limit) {
514
+ lines.push(...formatLimitLine(limit, labelWidth, nowMs));
515
+ } else {
516
+ lines.push(formatMissingLimitLine(template, labelWidth));
517
+ }
489
518
  }
490
519
  });
491
520
 
@@ -25,6 +25,7 @@ const LEGACY_HINDSIGHT_MEMORIES_REGEX = /<hindsight_memories>[\s\S]*?<\/hindsigh
25
25
  const LEGACY_RELEVANT_MEMORIES_REGEX = /<relevant_memories>[\s\S]*?<\/relevant_memories>/g;
26
26
  const MENTAL_MODELS_REGEX = /<mental_models>[\s\S]*?<\/mental_models>/g;
27
27
 
28
+ const RETENTION_PROTOCOL_MARKER_REGEX = /^\[(?:role:\s*[-_a-zA-Z0-9]+|[-_a-zA-Z0-9]+:end)\]$/;
28
29
  /**
29
30
  * Strip `<memories>`, `<mental_models>`, and legacy memory blocks.
30
31
  *
@@ -205,6 +206,32 @@ function formatRetentionMessages(messages: HindsightMessage[]): RetentionTranscr
205
206
  return { transcript, messageCount: parts.length };
206
207
  }
207
208
 
209
+ function formatEmbeddableRetentionMessages(messages: HindsightMessage[]): RetentionTranscript {
210
+ const parts: string[] = [];
211
+ for (const msg of messages) {
212
+ const content = stripRetentionProtocolMarkers(stripMemoryTags(msg.content)).trim();
213
+ if (!hasSubstantiveContent(content)) continue;
214
+ parts.push(content);
215
+ }
216
+
217
+ if (parts.length === 0) return { transcript: null, messageCount: 0 };
218
+
219
+ const transcript = parts.join("\n\n");
220
+ if (transcript.trim().length < 10) return { transcript: null, messageCount: 0 };
221
+
222
+ return { transcript, messageCount: parts.length };
223
+ }
224
+
225
+ /** Remove retention framing lines from a stored coding-agent episode transcript. */
226
+ export function stripRetentionProtocolMarkers(content: string): string {
227
+ return content
228
+ .split(/\r?\n/)
229
+ .filter(line => !RETENTION_PROTOCOL_MARKER_REGEX.test(line.trim()))
230
+ .join("\n")
231
+ .replace(/\n{3,}/g, "\n\n")
232
+ .trim();
233
+ }
234
+
208
235
  export function prepareRetentionTranscript(
209
236
  messages: HindsightMessage[],
210
237
  retainFullWindow = false,
@@ -229,6 +256,10 @@ export function prepareRetentionTranscript(
229
256
  return formatRetentionMessages(targetMessages);
230
257
  }
231
258
 
259
+ /** Format all retention messages without protocol markers for embedding, FTS, and recall display. */
260
+ export function prepareEmbeddableRetentionTranscript(messages: HindsightMessage[]): RetentionTranscript {
261
+ return formatEmbeddableRetentionMessages(messages);
262
+ }
232
263
  /** Format only user-authored messages for memory fact/entity extraction. */
233
264
  export function prepareUserRetentionTranscript(messages: HindsightMessage[]): RetentionTranscript {
234
265
  return formatRetentionMessages(messages.filter(message => message.role === "user"));
@@ -15,75 +15,119 @@ import { isEnoent } from "@oh-my-pi/pi-utils";
15
15
  import { artifactsDirsFromRegistry } from "./registry-helpers";
16
16
  import type { InternalResource, InternalUrl, ProtocolHandler, ResolveContext, UrlCompletion } from "./types";
17
17
 
18
- export class ArtifactProtocolHandler implements ProtocolHandler {
19
- readonly scheme = "artifact";
20
- readonly immutable = true;
18
+ const MAX_INLINE_ARTIFACT_BYTES = 8 * 1024 * 1024;
21
19
 
22
- async resolve(url: InternalUrl, context?: ResolveContext): Promise<InternalResource> {
23
- const id = url.rawHost || url.hostname;
24
- if (!id) {
25
- throw new Error("artifact:// URL requires a numeric ID: artifact://0");
20
+ /** Filesystem location for a session artifact, resolved without materializing its content. */
21
+ export interface ResolvedArtifactFile {
22
+ id: string;
23
+ path: string;
24
+ size: number;
25
+ }
26
+
27
+ function parseArtifactId(url: InternalUrl): string {
28
+ const id = url.rawHost || url.hostname;
29
+ if (!id) {
30
+ throw new Error("artifact:// URL requires a numeric ID: artifact://0");
31
+ }
32
+ if (!/^\d+$/.test(id)) {
33
+ throw new Error(`artifact:// ID must be numeric, got: ${id}`);
34
+ }
35
+ return id;
36
+ }
37
+
38
+ /** Resolve an `artifact://` URL to its backing file without reading artifact bytes. */
39
+ export async function resolveArtifactFile(url: InternalUrl, context?: ResolveContext): Promise<ResolvedArtifactFile> {
40
+ const id = parseArtifactId(url);
41
+
42
+ // Artifact ids are per-session counters; in multi-session hosts the same
43
+ // id exists in several dirs. Pin resolution to the calling session's
44
+ // artifacts dir first so `artifact://3` means *this* session's #3.
45
+ const dirs = artifactsDirsFromRegistry();
46
+ const pinnedDir = context?.localProtocolOptions?.getArtifactsDir?.() ?? null;
47
+ if (pinnedDir) {
48
+ const pinnedIndex = dirs.indexOf(pinnedDir);
49
+ if (pinnedIndex >= 0) dirs.splice(pinnedIndex, 1);
50
+ dirs.unshift(pinnedDir);
51
+ }
52
+
53
+ if (dirs.length === 0) {
54
+ throw new Error("No session - artifacts unavailable");
55
+ }
56
+
57
+ let foundPath: string | undefined;
58
+ let anyDirExists = false;
59
+ const availableIds = new Set<string>();
60
+
61
+ for (const dir of dirs) {
62
+ let files: string[];
63
+ try {
64
+ files = await fs.readdir(dir);
65
+ anyDirExists = true;
66
+ } catch (err) {
67
+ if (isEnoent(err)) continue;
68
+ throw err;
26
69
  }
27
- if (!/^\d+$/.test(id)) {
28
- throw new Error(`artifact:// ID must be numeric, got: ${id}`);
70
+ const match = files.find(f => f.startsWith(`${id}.`));
71
+ if (match) {
72
+ foundPath = path.join(dir, match);
73
+ break;
29
74
  }
30
-
31
- // Artifact ids are per-session counters; in multi-session hosts the same
32
- // id exists in several dirs. Pin resolution to the calling session's
33
- // artifacts dir first so `artifact://3` means *this* session's #3.
34
- const dirs = artifactsDirsFromRegistry();
35
- const pinnedDir = context?.localProtocolOptions?.getArtifactsDir?.() ?? null;
36
- if (pinnedDir) {
37
- const pinnedIndex = dirs.indexOf(pinnedDir);
38
- if (pinnedIndex >= 0) dirs.splice(pinnedIndex, 1);
39
- dirs.unshift(pinnedDir);
75
+ for (const f of files) {
76
+ const m = f.match(/^(\d+)\./);
77
+ if (m) availableIds.add(m[1]);
40
78
  }
79
+ }
41
80
 
42
- if (dirs.length === 0) {
43
- throw new Error("No session - artifacts unavailable");
44
- }
81
+ if (!anyDirExists) {
82
+ throw new Error("No artifacts directory found");
83
+ }
45
84
 
46
- let foundPath: string | undefined;
47
- let anyDirExists = false;
48
- const availableIds = new Set<string>();
85
+ if (!foundPath) {
86
+ const sorted = [...availableIds].sort((a, b) => Number(a) - Number(b));
87
+ const availableStr = sorted.length > 0 ? sorted.join(", ") : "none";
88
+ throw new Error(`Artifact ${id} not found. Available: ${availableStr}`);
89
+ }
49
90
 
50
- for (const dir of dirs) {
51
- let files: string[];
52
- try {
53
- files = await fs.readdir(dir);
54
- anyDirExists = true;
55
- } catch (err) {
56
- if (isEnoent(err)) continue;
57
- throw err;
58
- }
59
- const match = files.find(f => f.startsWith(`${id}.`));
60
- if (match) {
61
- foundPath = path.join(dir, match);
62
- break;
63
- }
64
- for (const f of files) {
65
- const m = f.match(/^(\d+)\./);
66
- if (m) availableIds.add(m[1]);
67
- }
68
- }
91
+ const stat = await Bun.file(foundPath).stat();
92
+ if (stat.isDirectory()) {
93
+ throw new Error(`Artifact ${id} resolved to a directory, not a file`);
94
+ }
95
+ return { id, path: foundPath, size: stat.size };
96
+ }
97
+
98
+ export class ArtifactProtocolHandler implements ProtocolHandler {
99
+ readonly scheme = "artifact";
100
+ readonly immutable = true;
101
+
102
+ async resolve(url: InternalUrl, context?: ResolveContext): Promise<InternalResource> {
103
+ const artifact = await resolveArtifactFile(url, context);
69
104
 
70
- if (!anyDirExists) {
71
- throw new Error("No artifacts directory found");
105
+ // Path-only callers (search/grep, bash URL expansion) never touch the
106
+ // artifact bytes. Return the resource shape so those flows keep working
107
+ // on artifacts of any size — only content materialization is gated.
108
+ if (context?.pathOnly) {
109
+ return {
110
+ url: url.href,
111
+ content: "",
112
+ contentType: "text/plain",
113
+ size: artifact.size,
114
+ sourcePath: artifact.path,
115
+ };
72
116
  }
73
117
 
74
- if (!foundPath) {
75
- const sorted = [...availableIds].sort((a, b) => Number(a) - Number(b));
76
- const availableStr = sorted.length > 0 ? sorted.join(", ") : "none";
77
- throw new Error(`Artifact ${id} not found. Available: ${availableStr}`);
118
+ if (artifact.size > MAX_INLINE_ARTIFACT_BYTES) {
119
+ throw new Error(
120
+ `Artifact ${artifact.id} is ${artifact.size} bytes; full internal resolution is blocked. Use read selectors such as artifact://${artifact.id}:1-3000 or artifact://${artifact.id}:raw:1-3000, and use the artifact file path for search/copy workflows: ${artifact.path}`,
121
+ );
78
122
  }
79
123
 
80
- const content = await Bun.file(foundPath).text();
124
+ const content = await Bun.file(artifact.path).text();
81
125
  return {
82
126
  url: url.href,
83
127
  content,
84
128
  contentType: "text/plain",
85
- size: Buffer.byteLength(content, "utf-8"),
86
- sourcePath: foundPath,
129
+ size: artifact.size,
130
+ sourcePath: artifact.path,
87
131
  };
88
132
  }
89
133