pi-ui-extend 1.0.6 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,17 +1,6 @@
1
- import type { Api, Model } from "@earendil-works/pi-ai";
1
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
2
2
  import type { SessionTitleConfig } from "./config.js";
3
- type TitleModelRegistry = {
4
- find(provider: string, modelId: string): Model<Api> | undefined;
5
- getApiKeyAndHeaders(model: Model<Api>): Promise<{
6
- ok: true;
7
- apiKey?: string;
8
- headers?: Record<string, string>;
9
- env?: Record<string, string>;
10
- } | {
11
- ok: false;
12
- error: string;
13
- }>;
14
- };
3
+ type TitleModelRegistry = Pick<ModelRegistry, "complete" | "find">;
15
4
  /** Extension-side title generation through Pi's public ModelRegistry facade. */
16
5
  export declare function generateSessionTitle(input: string, modelRegistry: TitleModelRegistry, config: SessionTitleConfig, modelRef: string, signal: AbortSignal, onWarning?: (message: string) => void): Promise<string | undefined>;
17
6
  export {};
@@ -1,4 +1,3 @@
1
- import { complete } from "@earendil-works/pi-ai/compat";
2
1
  import { buildTitlePrompt, parseTitleModelRef, sanitizeSessionTitle, TITLE_SYSTEM_PROMPT, titleResponseText, } from "./title-generation.js";
3
2
  /** Extension-side title generation through Pi's public ModelRegistry facade. */
4
3
  export async function generateSessionTitle(input, modelRegistry, config, modelRef, signal, onWarning) {
@@ -12,12 +11,7 @@ export async function generateSessionTitle(input, modelRegistry, config, modelRe
12
11
  onWarning?.(`Session-title model not found: ${modelRef}`);
13
12
  return undefined;
14
13
  }
15
- const auth = await modelRegistry.getApiKeyAndHeaders(model);
16
- if (auth.ok === false) {
17
- onWarning?.(auth.error);
18
- return undefined;
19
- }
20
- const response = await complete(model, {
14
+ const response = await modelRegistry.complete(model, {
21
15
  systemPrompt: TITLE_SYSTEM_PROMPT,
22
16
  messages: [
23
17
  {
@@ -27,9 +21,6 @@ export async function generateSessionTitle(input, modelRegistry, config, modelRe
27
21
  },
28
22
  ],
29
23
  }, {
30
- ...(auth.apiKey === undefined ? {} : { apiKey: auth.apiKey }),
31
- ...(auth.headers === undefined ? {} : { headers: auth.headers }),
32
- ...(auth.env === undefined ? {} : { env: auth.env }),
33
24
  cacheRetention: "none",
34
25
  maxRetries: config.maxRetries,
35
26
  maxTokens: config.maxTokens,
@@ -43,9 +43,9 @@
43
43
  "vscode-languageserver-protocol": "^3.17.5"
44
44
  },
45
45
  "peerDependencies": {
46
- "@earendil-works/pi-ai": "0.83.0",
47
- "@earendil-works/pi-coding-agent": "0.83.0",
48
- "@earendil-works/pi-tui": "0.83.0",
46
+ "@earendil-works/pi-ai": "0.84.1",
47
+ "@earendil-works/pi-coding-agent": "0.84.1",
48
+ "@earendil-works/pi-tui": "0.84.1",
49
49
  "typebox": "*"
50
50
  },
51
51
  "devDependencies": {
@@ -221,7 +221,11 @@ export async function addAntigravityAccount(
221
221
  };
222
222
  }
223
223
 
224
- async function refreshAccountToken(account: OpencodeAntigravityAccount, oauthClient?: GoogleOAuthClientCredentials): Promise<RefreshedAntigravityAccount> {
224
+ async function refreshAccountToken(
225
+ account: OpencodeAntigravityAccount,
226
+ oauthClient?: GoogleOAuthClientCredentials,
227
+ signal?: AbortSignal,
228
+ ): Promise<RefreshedAntigravityAccount> {
225
229
  const refreshToken = getAccountRefreshToken(account);
226
230
  if (!refreshToken) throw new Error(`Missing refresh token for Antigravity account ${account.email ?? "<unknown>"}`);
227
231
  const clientCredentials = getGoogleOAuthClientCredentials(account, oauthClient);
@@ -230,6 +234,7 @@ async function refreshAccountToken(account: OpencodeAntigravityAccount, oauthCli
230
234
  const start = Date.now();
231
235
  const response = await fetch("https://oauth2.googleapis.com/token", {
232
236
  method: "POST",
237
+ signal,
233
238
  headers: {
234
239
  "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
235
240
  Accept: "*/*",
@@ -256,7 +261,7 @@ async function refreshAccountToken(account: OpencodeAntigravityAccount, oauthCli
256
261
  };
257
262
  }
258
263
 
259
- export async function refreshAntigravityToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
264
+ export async function refreshAntigravityToken(credentials: OAuthCredentials, signal: AbortSignal): Promise<OAuthCredentials> {
260
265
  const credentialDetails = credentials as OAuthCredentials & PiAuthCredential;
261
266
  const oauthClient = getGoogleOAuthClientCredentials(credentialDetails);
262
267
  const storedAccounts = getStoredAccounts(credentialDetails);
@@ -272,6 +277,7 @@ export async function refreshAntigravityToken(credentials: OAuthCredentials): Pr
272
277
  email: credentialDetails.email,
273
278
  },
274
279
  oauthClient,
280
+ signal,
275
281
  );
276
282
  return {
277
283
  ...refreshed.credentials,
@@ -68,17 +68,21 @@ async function sendAntigravityRequest(
68
68
  const headerStyle = getModelHeaderStyle(model);
69
69
  const endpoints = headerStyle === "gemini-cli" ? [ENDPOINT_PROD] : STREAM_ENDPOINTS;
70
70
  for (const endpoint of endpoints) {
71
+ const headers = new Headers({
72
+ Authorization: `Bearer ${apiKey}`,
73
+ "Content-Type": "application/json",
74
+ Accept: "text/event-stream",
75
+ ...getAntigravityHeaders(headerStyle),
76
+ ...requestHeaders,
77
+ });
78
+ for (const [name, value] of Object.entries(options?.headers ?? {})) {
79
+ if (value === null) headers.delete(name);
80
+ else headers.set(name, value);
81
+ }
71
82
  response = await fetch(`${endpoint}/v1internal:streamGenerateContent?alt=sse`, {
72
83
  method: "POST",
73
84
  signal: options?.signal,
74
- headers: {
75
- Authorization: `Bearer ${apiKey}`,
76
- "Content-Type": "application/json",
77
- Accept: "text/event-stream",
78
- ...getAntigravityHeaders(headerStyle),
79
- ...requestHeaders,
80
- ...(options?.headers ?? {}),
81
- },
85
+ headers,
82
86
  body: JSON.stringify(payload),
83
87
  });
84
88
  await options?.onResponse?.({ status: response.status, headers: Object.fromEntries(response.headers.entries()) }, model);
@@ -1,4 +1,4 @@
1
- import type { Api, Model } from "@earendil-works/pi-ai";
1
+ import type { Api, Model, ProviderHeaders } from "@earendil-works/pi-ai";
2
2
  import { completeWithModelRegistry, type ModelCompletionRegistry } from "../../model-completion.js";
3
3
  import type { AgentTask } from "./types.js";
4
4
  import {
@@ -14,7 +14,7 @@ export interface SubagentRoutingContext {
14
14
  modelRegistry?: ModelCompletionRegistry & {
15
15
  find(provider: string, modelId: string): Model<Api> | undefined;
16
16
  getApiKeyAndHeaders(model: Model<Api>): Promise<
17
- | { ok?: true; apiKey?: string; headers?: Record<string, string>; env?: Record<string, string> }
17
+ | { ok?: true; apiKey?: string; headers?: ProviderHeaders; baseUrl?: string; env?: Record<string, string> }
18
18
  | { ok: false; error: string }
19
19
  >;
20
20
  };
@@ -180,7 +180,7 @@ async function resolveRoutingModels(
180
180
  interface RoutingCandidate {
181
181
  model: Model<Api>;
182
182
  apiKey?: string;
183
- headers?: Record<string, string>;
183
+ headers?: ProviderHeaders;
184
184
  env?: Record<string, string>;
185
185
  }
186
186
 
@@ -189,7 +189,7 @@ type RoutingResponse = Awaited<ReturnType<typeof completeWithModelRegistry>>;
189
189
  async function resolveModelRef(ctx: SubagentRoutingContext, modelRef: string): Promise<{
190
190
  model: Model<Api>;
191
191
  apiKey?: string;
192
- headers?: Record<string, string>;
192
+ headers?: ProviderHeaders;
193
193
  env?: Record<string, string>;
194
194
  } | undefined> {
195
195
  const parsed = parseModelRef(modelRef);
@@ -1,4 +1,4 @@
1
- import type { Api, Model } from "@earendil-works/pi-ai";
1
+ import type { Api, Model, ProviderHeaders } from "@earendil-works/pi-ai";
2
2
  import { completeWithModelRegistry, type ModelCompletionRegistry } from "../../model-completion.js";
3
3
  import { currentModelRef, resolveSubagentRoutingConfig, type SubagentConfig } from "./config.js";
4
4
 
@@ -9,7 +9,7 @@ export interface UltraworkAutoContext {
9
9
  modelRegistry?: ModelCompletionRegistry & {
10
10
  find(provider: string, modelId: string): Model<Api> | undefined;
11
11
  getApiKeyAndHeaders(model: Model<Api>): Promise<
12
- | { ok?: true; apiKey?: string; headers?: Record<string, string>; env?: Record<string, string> }
12
+ | { ok?: true; apiKey?: string; headers?: ProviderHeaders; baseUrl?: string; env?: Record<string, string> }
13
13
  | { ok: false; error: string }
14
14
  >;
15
15
  };
@@ -131,7 +131,7 @@ function buildClassifierPrompt(userText: string): string {
131
131
  async function resolveClassifierModel(ctx: UltraworkAutoContext, modelRef: string): Promise<{
132
132
  model: Model<Api>;
133
133
  apiKey?: string;
134
- headers?: Record<string, string>;
134
+ headers?: ProviderHeaders;
135
135
  env?: Record<string, string>;
136
136
  } | undefined> {
137
137
  const configured = await resolveModelRef(ctx, modelRef);
@@ -143,7 +143,7 @@ async function resolveClassifierModel(ctx: UltraworkAutoContext, modelRef: strin
143
143
  async function resolveModelRef(ctx: UltraworkAutoContext, modelRef: string): Promise<{
144
144
  model: Model<Api>;
145
145
  apiKey?: string;
146
- headers?: Record<string, string>;
146
+ headers?: ProviderHeaders;
147
147
  env?: Record<string, string>;
148
148
  } | undefined> {
149
149
  const parsed = parseModelRef(modelRef);
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import type { Api, AssistantMessage, ImageContent, Model, TextContent } from "@earendil-works/pi-ai";
3
+ import type { Api, AssistantMessage, ImageContent, Model, ProviderHeaders, TextContent } from "@earendil-works/pi-ai";
4
4
  import { Type } from "typebox";
5
5
 
6
6
  import { loadPiToolsSuiteConfig, DEFAULT_CODING_DISCIPLINE_STRICTNESS, type CodingDisciplineStrictness } from "../config.js";
@@ -28,7 +28,7 @@ type ResolvedLookupModel = {
28
28
  modelRegistry: ModelCompletionRegistry;
29
29
  model: Model<Api>;
30
30
  apiKey?: string;
31
- headers?: Record<string, string>;
31
+ headers?: ProviderHeaders;
32
32
  env?: Record<string, string>;
33
33
  };
34
34
 
@@ -825,7 +825,8 @@ async function resolveLookupModel(ctx: unknown, modelRef: string): Promise<Resol
825
825
  const auth = await registry.getApiKeyAndHeaders(model) as {
826
826
  ok?: true;
827
827
  apiKey?: string;
828
- headers?: Record<string, string>;
828
+ headers?: ProviderHeaders;
829
+ baseUrl?: string;
829
830
  env?: Record<string, string>;
830
831
  } | { ok: false; error: string };
831
832
  if (auth.ok === false) return undefined;
@@ -13,7 +13,7 @@
13
13
  // automatic fallback to the programmatic digest on any failure/timeout.
14
14
  // ---------------------------------------------------------------------------
15
15
 
16
- import type { Model, Api } from "@earendil-works/pi-ai"
16
+ import type { Model, Api, ProviderHeaders } from "@earendil-works/pi-ai"
17
17
  import { completeWithModelRegistry, type ModelCompletionRegistry } from "../model-completion.js"
18
18
  import type { DcpState } from "./state.js"
19
19
  import type { DcpConfig } from "./config.js"
@@ -137,7 +137,7 @@ export interface ModelSummaryResult {
137
137
  type ModelSummaryRegistry = ModelCompletionRegistry & {
138
138
  find(provider: string, modelId: string): Model<Api> | undefined
139
139
  getApiKeyAndHeaders(model: Model<Api>): Promise<
140
- | { ok: true; apiKey?: string; headers?: Record<string, string>; env?: Record<string, string> }
140
+ | { ok: true; apiKey?: string; headers?: ProviderHeaders; baseUrl?: string; env?: Record<string, string> }
141
141
  | { ok: false; error: string }
142
142
  >
143
143
  }
@@ -809,6 +809,7 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
809
809
  await saveDcpState(ctx, state)
810
810
  }
811
811
 
812
+ const anchorsBeforeFinalization = state.nudgeAnchors.length
812
813
  if (state.manualMode) {
813
814
  state.nudgeAnchors = state.nudgeAnchors.filter((anchor) =>
814
815
  anchor.type === "context-strong" || anchor.type === "context-soft",
@@ -817,6 +818,9 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
817
818
  applyAnchoredNudges(prunedMessages, state, (anchor) =>
818
819
  appendConcreteNudgeGuidance(baseNudgeText(anchor.type), candidate, messageCandidates, state),
819
820
  )
821
+ if (state.nudgeAnchors.length !== anchorsBeforeFinalization) {
822
+ await saveDcpState(ctx, state)
823
+ }
820
824
 
821
825
  return finishContext("complete", prunedMessages, {
822
826
  candidate,
@@ -51,7 +51,6 @@ function isRealAnchorCandidate(msg: any): boolean {
51
51
  const role = msg?.role ?? "";
52
52
  if (role !== "user" && role !== "assistant") return false;
53
53
  const text = messageText(msg);
54
- if (text.includes("<dcp-system-reminder>")) return false;
55
54
  if (extractBlockId(text) !== undefined) return false;
56
55
  return true;
57
56
  }
@@ -94,6 +93,12 @@ function anchorMatchesMessage(anchor: DcpNudgeAnchor, msg: any, index: number):
94
93
  return msg?.timestamp === anchor.anchorTimestamp;
95
94
  }
96
95
 
96
+ function isNewerAnchor(candidate: DcpNudgeAnchor, current: DcpNudgeAnchor): boolean {
97
+ if (candidate.updatedAt !== current.updatedAt) return candidate.updatedAt > current.updatedAt;
98
+ if (candidate.createdAt !== current.createdAt) return candidate.createdAt > current.createdAt;
99
+ return candidate.id > current.id;
100
+ }
101
+
97
102
  function appendTextToMessage(msg: any, text: string): void {
98
103
  const suffix = `\n\n${text}`;
99
104
  if (typeof msg.content === "string") {
@@ -205,15 +210,27 @@ export function upsertNudgeAnchor(
205
210
  options: { contextPercent?: number } = {},
206
211
  ): { anchor: DcpNudgeAnchor | null; created: boolean; updated: boolean } {
207
212
  const target = findAnchorMessage(messages);
208
- if (!target) return { anchor: null, created: false, updated: false };
213
+ if (!target) {
214
+ // The caller will append one synthetic tail reminder. Drop persisted
215
+ // anchors first so applyAnchoredNudges cannot render a second reminder.
216
+ state.nudgeAnchors = [];
217
+ state.lastNudge = undefined;
218
+ return { anchor: null, created: false, updated: false };
219
+ }
209
220
 
210
221
  const key = `${target.stableId}|${target.timestamp}`;
211
- const existing = state.nudgeAnchors.find(
212
- (anchor) => `${anchor.anchorStableId ?? ""}|${anchor.anchorTimestamp}` === key,
213
- );
222
+ let existing: DcpNudgeAnchor | null = null;
223
+ for (const anchor of state.nudgeAnchors) {
224
+ const anchorKey = `${anchor.anchorStableId ?? ""}|${anchor.anchorTimestamp}`;
225
+ if (anchorKey !== key) continue;
226
+ if (!existing || isNewerAnchor(anchor, existing)) existing = anchor;
227
+ }
214
228
 
215
229
  const now = Date.now();
216
230
  if (existing) {
231
+ // Older sidecars may contain several anchors. Keep only the anchor for the
232
+ // current target so every subsequent context pass has singleton state.
233
+ state.nudgeAnchors = [existing];
217
234
  const shouldUpgrade = typePriority(type) > typePriority(existing.type);
218
235
  if (shouldUpgrade) existing.type = type;
219
236
  existing.updatedAt = now;
@@ -240,7 +257,9 @@ export function upsertNudgeAnchor(
240
257
  createdAt: now,
241
258
  updatedAt: now,
242
259
  };
243
- state.nudgeAnchors.push(anchor);
260
+ // A nudge follows the latest useful message. Replacing the previous anchor
261
+ // prevents one full reminder from accumulating per turn/assistant response.
262
+ state.nudgeAnchors = [anchor];
244
263
  state.lastNudge = {
245
264
  type,
246
265
  anchorId: anchor.id,
@@ -259,15 +278,19 @@ export function applyAnchoredNudges(
259
278
  ): void {
260
279
  if (state.nudgeAnchors.length === 0) return;
261
280
 
262
- const activeAnchors: DcpNudgeAnchor[] = [];
281
+ let selected: { anchor: DcpNudgeAnchor; index: number } | null = null;
263
282
  for (const anchor of state.nudgeAnchors) {
264
283
  const index = messages.findIndex((msg, messageIndex) => anchorMatchesMessage(anchor, msg, messageIndex));
265
284
  if (index === -1) continue;
266
- activeAnchors.push(anchor);
267
- appendTextToMessage(messages[index], render(anchor));
285
+ if (!selected || isNewerAnchor(anchor, selected.anchor)) {
286
+ selected = { anchor, index };
287
+ }
268
288
  }
269
289
 
270
- state.nudgeAnchors = activeAnchors;
290
+ // Defensive migration for persisted pre-singleton state: render only the
291
+ // newest valid anchor and discard every stale predecessor.
292
+ state.nudgeAnchors = selected ? [selected.anchor] : [];
293
+ if (selected) appendTextToMessage(messages[selected.index], render(selected.anchor));
271
294
  }
272
295
 
273
296
  export function clearDcpNudgeAnchors(state: DcpState): number {
@@ -1,37 +1,31 @@
1
1
  import type {
2
2
  Api,
3
3
  AssistantMessage,
4
- AssistantMessageEventStream,
5
4
  Context,
6
5
  Model,
6
+ ModelsApiStreamOptions,
7
7
  SimpleStreamOptions,
8
8
  } from "@earendil-works/pi-ai";
9
9
  import { completeSimple } from "@earendil-works/pi-ai/compat";
10
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
10
11
 
11
- type RegisteredProviderConfig = {
12
- streamSimple?: (
13
- model: Model<Api>,
14
- context: Context,
15
- options?: SimpleStreamOptions,
16
- ) => AssistantMessageEventStream;
17
- };
18
-
19
- export type ModelCompletionRegistry = {
20
- getRegisteredProviderConfig?(providerId: string): RegisteredProviderConfig | undefined;
21
- };
12
+ export type ModelCompletionRegistry = Partial<Pick<ModelRegistry, "complete" | "getRegisteredProviderConfig">>;
22
13
 
23
14
  /**
24
- * Complete through an extension provider's registered stream when available.
25
- * Pi 0.80.8+ no longer copies extension streams into pi-ai's global compat
26
- * registry, so falling back to compat is valid only for built-in APIs.
15
+ * Complete through Pi's model runtime when available so custom providers and
16
+ * resolved auth endpoints are preserved. The older stream/compat branches are
17
+ * retained for narrow test doubles that do not expose ModelRegistry.complete().
27
18
  */
28
19
  export async function completeWithModelRegistry(
29
20
  modelRegistry: ModelCompletionRegistry | undefined,
30
21
  model: Model<Api>,
31
22
  context: Context,
32
- options?: SimpleStreamOptions,
23
+ options?: ModelsApiStreamOptions<Api>,
33
24
  ): Promise<AssistantMessage> {
25
+ if (modelRegistry?.complete) return modelRegistry.complete(model, context, options);
26
+
34
27
  const providerConfig = modelRegistry?.getRegisteredProviderConfig?.(model.provider);
35
- if (providerConfig?.streamSimple) return providerConfig.streamSimple(model, context, options).result();
36
- return completeSimple(model, context, options);
28
+ const simpleOptions = options as SimpleStreamOptions | undefined;
29
+ if (providerConfig?.streamSimple) return providerConfig.streamSimple(model, context, simpleOptions).result();
30
+ return completeSimple(model, context, simpleOptions);
37
31
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ui-extend",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
4
4
  "description": "Pix: a workspace-first terminal UI for Pi with tabs, readable tool activity, voice input, and bundled agent tools.",
5
5
  "private": false,
6
6
  "repository": {
@@ -75,9 +75,9 @@
75
75
  "prepublishOnly": "npm run check && npm run build:pix && npm run generate-schemas"
76
76
  },
77
77
  "dependencies": {
78
- "@earendil-works/pi-ai": "0.83.0",
79
- "@earendil-works/pi-coding-agent": "0.83.0",
80
- "@earendil-works/pi-tui": "0.83.0",
78
+ "@earendil-works/pi-ai": "0.84.1",
79
+ "@earendil-works/pi-coding-agent": "0.84.1",
80
+ "@earendil-works/pi-tui": "0.84.1",
81
81
  "@mariozechner/clipboard": "^0.3.9",
82
82
  "jsonc-parser": "3.3.1",
83
83
  "typebox": "1.1.38",