pi-jar 0.1.9 → 0.2.1

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/src/settings.ts CHANGED
@@ -23,6 +23,8 @@ export interface JarVisualSettings {
23
23
  advisor: boolean;
24
24
  /** Consult the advisor automatically when the agent repeats a tool call or keeps failing. */
25
25
  advisorGates: boolean;
26
+ /** Opt-in provider-context pruning of reasoning from completed earlier user turns. */
27
+ contextDiet: boolean;
26
28
  /** Glyph set for footer, composer and views: unicode, Nerd Font icons, or plain ascii. */
27
29
  icons: IconSet;
28
30
  footer: FooterSettings;
@@ -37,7 +39,7 @@ export function isMaxSubagents(value: unknown): value is MaxSubagents {
37
39
 
38
40
  export const SETTINGS_FILE = "pi-jar-settings.json";
39
41
  export function defaultVisualSettings(footer: FooterSettings = DEFAULT_FOOTER_SETTINGS): JarVisualSettings {
40
- return { version: 1, accent: "follow", animations: true, ui: true, composer: true, mascot: true, suggestions: true, goalRounds: 8, maxSubagents: 4, advisor: true, advisorGates: true, icons: "unicode", footer: { ...footer } };
42
+ return { version: 1, accent: "follow", animations: true, ui: true, composer: true, mascot: true, suggestions: true, goalRounds: 8, maxSubagents: 4, advisor: true, advisorGates: true, contextDiet: false, icons: "unicode", footer: { ...footer } };
41
43
  }
42
44
 
43
45
  /** Legacy footer choices are imported only while the new file is absent. Never modify the old file. */
@@ -70,6 +72,7 @@ export function loadVisualSettings(directory: string): JarVisualSettings {
70
72
  maxSubagents: isMaxSubagents(value.maxSubagents) ? value.maxSubagents : fallback.maxSubagents,
71
73
  advisor: typeof value.advisor === "boolean" ? value.advisor : fallback.advisor,
72
74
  advisorGates: typeof value.advisorGates === "boolean" ? value.advisorGates : fallback.advisorGates,
75
+ contextDiet: typeof value.contextDiet === "boolean" ? value.contextDiet : fallback.contextDiet,
73
76
  icons: ICON_SETS.some((set) => set === value.icons) ? value.icons as IconSet : fallback.icons,
74
77
  footer: Object.fromEntries(FOOTER_FIELDS.map((field) => [field,
75
78
  typeof footer[field] === "boolean" ? footer[field] : fallback.footer[field]])) as FooterSettings
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI, CustomMessageEntryDraft } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionContext, CustomMessageEntryDraft } from "@earendil-works/pi-coding-agent";
2
2
  import { Text } from "@earendil-works/pi-tui";
3
3
  import { cleanText } from "./status.ts";
4
4
  import { describe, SHELL_MESSAGE, type ShellEvent, type ShellManager } from "./shells.ts";
@@ -34,14 +34,40 @@ export function registerShellNotifications(pi: ExtensionAPI, manager: () => Shel
34
34
  let interrupted = false;
35
35
  let deliveryFailures = 0;
36
36
  let timer: ReturnType<typeof setTimeout> | undefined;
37
+ let feedbackTimer: ReturnType<typeof setTimeout> | undefined;
38
+ let context: ExtensionContext | undefined;
39
+ const shown = new Set<string>();
40
+ const eventKey = (event: ShellEvent) => `${event.kind}:${event.job.id}`;
37
41
  const warned = new Set<string>();
38
- const cancelTimer = () => { clearTimeout(timer); timer = undefined; };
42
+ const cancelTimer = () => { clearTimeout(timer); timer = undefined; clearTimeout(feedbackTimer); feedbackTimer = undefined; };
39
43
  const take = () => {
40
44
  cancelTimer();
41
45
  const events = manager()?.takeNotifications() ?? [];
46
+ shown.clear();
42
47
  return events.length ? shellNotification(events) : undefined;
43
48
  };
44
49
  const notify = () => {
50
+ // UI feedback must not wait for the remote model to finish reasoning. Keep model transport
51
+ // boundary-safe and acknowledgeable: this preview does not consume or enqueue a follow-up.
52
+ if (active && !interrupted && context?.hasUI && !feedbackTimer && manager()?.pendingNotifications()) {
53
+ const owner = manager(), ctx = context;
54
+ feedbackTimer = setTimeout(() => {
55
+ feedbackTimer = undefined;
56
+ if (!active || interrupted || owner !== manager() || ctx !== context) return;
57
+ const snapshot = owner?.notificationSnapshot() ?? [];
58
+ const known = new Set(snapshot.map(eventKey));
59
+ for (const key of shown) if (!known.has(key)) shown.delete(key);
60
+ const events = snapshot.filter(event => !shown.has(eventKey(event)));
61
+ if (!events.length) return;
62
+ const summary = shellNotification(events).details as ShellNotificationDetails;
63
+ const text = `${summary.summary}: ${events.slice(0, 3).map(event => describe(event.job)).join("; ")}. Model delivery waits for a safe turn boundary.`;
64
+ try {
65
+ ctx.ui.notify(cleanText(text, 420), events.some(event => event.job.exitCode !== 0 && event.kind === "exit") ? "warning" : "info");
66
+ for (const event of events) shown.add(eventKey(event));
67
+ } catch (error) { console.error("pi-jar: could not show shell completion", error); }
68
+ }, 300);
69
+ feedbackTimer.unref?.();
70
+ }
45
71
  if (active || interrupted || timer || !manager()?.pendingNotifications()) return;
46
72
  const owner = manager();
47
73
  timer = setTimeout(() => {
@@ -59,16 +85,17 @@ export function registerShellNotifications(pi: ExtensionAPI, manager: () => Shel
59
85
  }, 300);
60
86
  timer.unref?.();
61
87
  };
62
- const reset = () => { cancelTimer(); active = false; interrupted = false; deliveryFailures = 0; warned.clear(); };
88
+ const reset = () => { cancelTimer(); active = false; interrupted = false; context = undefined; deliveryFailures = 0; warned.clear(); shown.clear(); };
63
89
 
64
90
  pi.registerMessageRenderer?.<ShellNotificationDetails>(SHELL_MESSAGE, (message, { expanded }, theme) => {
65
91
  const text = typeof message.content === "string" ? message.content : "Background shells";
66
92
  const summary = message.details?.summary ?? text.split("\n", 1)[0] ?? "Background shells";
67
93
  return new Text(expanded ? text : theme.fg("accent", cleanText(summary, 160)) + theme.fg("dim", " · expand for status"), 0, 0);
68
94
  });
69
- pi.on?.("session_start", reset);
95
+ pi.on?.("session_start", (_event, ctx) => { reset(); context = ctx; });
70
96
  pi.on?.("session_shutdown", reset);
71
- pi.on?.("before_agent_start", () => {
97
+ pi.on?.("before_agent_start", (_event, ctx) => {
98
+ context = ctx;
72
99
  active = true;
73
100
  interrupted = false;
74
101
  const entry = take();
@@ -76,7 +103,7 @@ export function registerShellNotifications(pi: ExtensionAPI, manager: () => Shel
76
103
  const { type: _type, ...message } = entry;
77
104
  return { message };
78
105
  });
79
- pi.on?.("agent_start", () => { active = true; cancelTimer(); });
106
+ pi.on?.("agent_start", (_event, ctx) => { context = ctx; active = true; cancelTimer(); });
80
107
  pi.on?.("turn_end", event => {
81
108
  if (event.outcome !== "completed" || event.context?.canContinue === false) return;
82
109
  const entry = take();
package/src/shells.ts CHANGED
@@ -125,6 +125,8 @@ export class ShellManager {
125
125
  for (const event of events) if (this.jobs.has(event.job.id) && !this.pending.has(event.job.id)) this.pending.set(event.job.id, event);
126
126
  }
127
127
  pendingNotifications(): number { return this.pending.size; }
128
+ /** Non-consuming status snapshots for immediate UI feedback; never copy or expose log lines. */
129
+ notificationSnapshot(): ShellEvent[] { return [...this.pending.values()].map(event => ({ ...event, job: { ...event.job, lines: [] } })); }
128
130
  verificationPending(): Array<Omit<ShellJob, "lines">> {
129
131
  return [...this.jobs.values()].filter(entry => !entry.finished && entry.job.purpose !== "service").map(({ job }) => {
130
132
  const { lines: _lines, ...summary } = job;
@@ -438,6 +440,7 @@ export function registerShells(pi: ExtensionAPI, shells: () => ShellManager | un
438
440
  promptGuidelines: [
439
441
  "Use jar_shell start for servers, watchers and slow commands; set watch to a regex for the line you are waiting for (e.g. \"listening|ready|error\").",
440
442
  "Do not poll: work while commands run, then use wait with explicit ids for relevant checks before the final summary. A bounded wait returns pending jobs honestly; never claim success without their exit codes. Do not wait for services/watchers. output, peek, list and wait acknowledge observed events, preventing stale replay.",
443
+ "Completion events cannot interrupt an in-flight model response. If no useful work remains, call wait for the relevant finite jobs immediately instead of thinking or waiting for an automatic wake-up.",
441
444
  "Notifications contain coalesced status, not log dumps. Inspect output only for relevant diagnostics. Mark never-ending jobs purpose: service and kill shells you no longer need."
442
445
  ],
443
446
  parameters: Parameters,
@@ -453,7 +456,7 @@ export function registerShells(pi: ExtensionAPI, shells: () => ShellManager | un
453
456
  case "start": {
454
457
  const job = manager.start({ command: params.command ?? "", cwd: ctx.cwd, ...(params.name ? { name: params.name } : {}), ...(params.watch ? { watch: params.watch } : {}),
455
458
  ...(params.notify !== undefined ? { notify: params.notify } : {}), ...(params.purpose ? { purpose: params.purpose } : {}) });
456
- return reply(`Started ${describe(job)} (pid ${job.pid ?? "?"}). ${job.notify ? "You will be woken when it " + (job.watch ? "matches or " : "") + "exits." : "Notifications are off; check it with output."}`);
459
+ return reply(`Started ${describe(job)} (pid ${job.pid ?? "?"}). ${job.notify ? "Completion reaches the model at the next safe turn boundary (or wakes it when idle). If no other useful work remains, use wait now." : "Notifications are off; check it with output."}`);
457
460
  }
458
461
  case "output": {
459
462
  if (!params.id) return reply("output needs an id.");
package/src/side-model.ts CHANGED
@@ -30,7 +30,7 @@ export interface SideAnswer { text: string; model: string }
30
30
  * Ask the model assigned to `role` (or the current model) a one-shot question outside the
31
31
  * conversation. Tries configured fallbacks in order on failure; cancellation never retries.
32
32
  */
33
- export async function askRole(ctx: ExtensionContext, roles: ModelRoleManager, usage: SideUsage | undefined, role: string,
33
+ export async function askRole(ctx: ExtensionContext, roles: ModelRoleManager, usage: Pick<SideUsage, "add"> | undefined, role: string,
34
34
  systemPrompt: string, prompt: string, signal?: AbortSignal): Promise<SideAnswer> {
35
35
  const fallbacks = roles.fallbackSpecs?.(role) ?? [];
36
36
  const failures: string[] = [];
@@ -52,6 +52,8 @@ export async function askRole(ctx: ExtensionContext, roles: ModelRoleManager, us
52
52
  const result = await ctx.modelRegistry.streamSimple(model,
53
53
  { systemPrompt, messages: [{ role: "user", content: prompt, timestamp: Date.now() }] },
54
54
  { ...(reasoning ? { reasoning } : {}), ...(signal ? { signal } : {}) }).result();
55
+ // Callers with detached work can supply a session-generation-safe usage sink.
56
+ // Returned attempts (including cancelled attempts) still account for actual usage.
55
57
  const cost = result.usage?.cost?.total;
56
58
  usage?.add({ role, model: name, input: result.usage?.input ?? 0, output: result.usage?.output ?? 0, cacheRead: result.usage?.cacheRead ?? 0,
57
59
  cacheWrite: result.usage?.cacheWrite ?? 0, cost: Number.isFinite(cost) ? cost! : 0, at: Date.now() });
package/src/welcome.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { sliceByColumn, stripTerminalSequences, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
1
+ import { sliceByColumn, stripTerminalSequences, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
2
  import { FLAME_RAMP, FLAME_WIDTH, flameFrame, rgb, supportsTruecolor } from "./flame.ts";
3
3
  import { cleanText } from "./status.ts";
4
4
  import { iconSet, withIcon } from "./icons.ts";
@@ -10,6 +10,8 @@ export interface WelcomeInfo {
10
10
  model?: string;
11
11
  effort?: string;
12
12
  activeRole?: string;
13
+ /** Active configuration profile, shown even before a session is named. */
14
+ profile?: string;
13
15
  project?: string;
14
16
  context?: string;
15
17
  cost?: string;
@@ -189,6 +191,8 @@ function card(width: number, fg: Paint, info: WelcomeInfo): string[] {
189
191
  cell(center(fg("dim", "— welcome to ") + fg("accent", "pi-jar") + fg("dim", " —"), inner)),
190
192
  cell(""),
191
193
  divider,
194
+ ...wrapTextWithAnsi(cleanText(info.profile ?? "Default", 32) || "Default", Math.max(1, inner - labelWidth))
195
+ .map((line, index) => cell(index ? fg("dim", " ".repeat(labelWidth)) + fg("accent", line) : label("PROFILE", line, "accent"))),
192
196
  cell(label("PROJECT", withIcon("folder", info.project || "unavailable") + " · " + git, info.dirty ? "warning" : "muted")),
193
197
  cell(label("SESSION", [info.context ?? "ctx ?", quota, info.cost ?? ""].filter(Boolean).join(" · "))),
194
198
  divider,
@@ -262,6 +266,7 @@ function renderWelcome(width: number, frame: number, fg: Paint, info: WelcomeInf
262
266
  ...flame.slice(4).map((line) => fit(sliceByColumn(line, start, Math.min(width, FLAME_WIDTH)))),
263
267
  ...PI_COMPACT.map((line) => fit(fg("accent", line))),
264
268
  fit(fg("accent", "pi-jar")),
269
+ fit(fg("accent", "Profile: " + (cleanText(info.profile ?? "Default", 32) || "Default"))),
265
270
  ...wrap(cleanText(info.message ?? HOPEFUL_WELCOME_MESSAGES[0], 100), width).map((line) => fit("\x1b[1m" + fg("warning", line) + "\x1b[22m")),
266
271
  fit(fg(info.settingsClickable === false ? "dim" : "accent", info.settingsClickable === false ? "ctrl+alt+s settings" : "[ ⚙ Settings ]")),
267
272
  ""