pi-ui-extend 1.0.4 → 1.0.6

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.
@@ -3,7 +3,7 @@ import * as path from "node:path";
3
3
  import type { Api, AssistantMessage, ImageContent, Model, TextContent } from "@earendil-works/pi-ai";
4
4
  import { Type } from "typebox";
5
5
 
6
- import { loadPiToolsSuiteConfig } from "../config.js";
6
+ import { loadPiToolsSuiteConfig, DEFAULT_CODING_DISCIPLINE_STRICTNESS, type CodingDisciplineStrictness } from "../config.js";
7
7
  import { ignoreStaleExtensionContextError } from "../context-usage.js";
8
8
  import { completeWithModelRegistry, type ModelCompletionRegistry } from "../model-completion.js";
9
9
 
@@ -43,6 +43,9 @@ const DEFAULT_LOOKUP_TIMEOUT_MS = 120_000;
43
43
  const MAX_IMAGE_BYTES = 16 * 1024 * 1024;
44
44
  const SILENCE_REMINDER_MIN_VIOLATION_GAP = 3;
45
45
  const SILENCE_REMINDER_MIN_MESSAGE_GAP = 12;
46
+ // When the visible history shrinks by more than this many messages (compaction or
47
+ // truncation), the chatter baseline is reset so it isn't measured against a stale peak.
48
+ const SILENCE_REMINDER_COMPACTION_MARGIN = 8;
46
49
  const LOOKUP_TOOL_NAME = "lookup";
47
50
 
48
51
  const LOOKUP_TOOL_PARAMS = Type.Object(
@@ -60,31 +63,46 @@ const LOOKUP_TOOL_PARAMS = Type.Object(
60
63
  { additionalProperties: false },
61
64
  );
62
65
 
63
- const QUALITY_DISCIPLINE_LINES = [
64
- "TOOL-ONLY CODING AGENT CONTRACT.",
65
- "",
66
- "Follow this output-channel contract literally for coding tasks.",
67
- "WORKING STATE: from the first assistant turn until complete, final, or blocked, emit exactly one tool call with empty text.",
68
- "No prose, markdown, acknowledgments, plans, progress, summaries, explanations, or commentary before/after tool calls.",
69
- "Keep reasoning, planning, hypotheses, and retry decisions internal; if a safe next local action exists, call the tool silently.",
70
- "",
71
- "After every tool result, choose exactly one transition:",
72
- "- WORKING: exactly one next tool call with empty text;",
73
- "- FINAL: one final answer after completion or practical verification;",
74
- "- BLOCKED: one concise question only when no safe/useful tool action can continue.",
75
- "No transition permits commentary between tool calls.",
76
- "",
77
- "PRIORITY: this overrides default assistant friendliness and conversational behavior.",
78
- "",
79
- "Coding discipline (express only through tool choices, not prose):",
80
- "- inspect before editing; do not invent APIs, files, commands, or behavior;",
81
- "- make the smallest change that fully fixes the issue; follow nearby conventions;",
82
- "- for bugs, prefer a failing repro first, then the minimal fix, then verify;",
83
- "- high-risk changes (security, data/schema, public APIs, concurrency, irreversible) need a short spec first;",
84
- "- handle edge cases, errors, cancellation, and async behavior; do not block UI/event loops;",
85
- "- avoid duplicate state, duplicate prompts, and repeated side effects;",
86
- "- write code, identifiers, comments, and commit messages in English.",
87
- ];
66
+ type DisciplinePromptOptions = { lookupEnabled?: boolean; strictness?: CodingDisciplineStrictness };
67
+
68
+ /**
69
+ * Builds the tool-only contract lines. The working-state rule is strictness-aware:
70
+ * - "strict" — one tool call per turn, no assistant text at all (Opus-like);
71
+ * - "lenient" independent (especially read-only) calls may be batched, and brief
72
+ * substantive reasoning text is acceptable when thinking is off.
73
+ */
74
+ function buildDisciplineLines(strictness: CodingDisciplineStrictness): readonly string[] {
75
+ const isStrict = strictness === "strict";
76
+ return [
77
+ "TOOL-ONLY CODING AGENT CONTRACT.",
78
+ "",
79
+ "Follow this output-channel contract literally for coding tasks.",
80
+ isStrict
81
+ ? "WORKING STATE: from the first assistant turn until complete, final, or blocked, emit exactly one tool call with empty text."
82
+ : "WORKING STATE: from the first assistant turn until complete, final, or blocked, emit tool calls with empty text.",
83
+ isStrict
84
+ ? "No prose, markdown, acknowledgments, plans, progress, summaries, explanations, or commentary before/after tool calls."
85
+ : "Batch independent calls in one turn (especially read-only inspection: read/grep/glob/bash); make dependent calls sequentially. Avoid filler prose, acknowledgments, restatements, and narration of obvious next steps.",
86
+ "Keep reasoning, planning, hypotheses, and retry decisions internal when thinking is on; when thinking is off, brief substantive reasoning text is acceptable.",
87
+ "",
88
+ "After every tool result, choose exactly one transition:",
89
+ isStrict ? "- WORKING: exactly one next tool call with empty text;" : "- WORKING: one or more independent tool calls with empty text;",
90
+ "- FINAL: one final answer after completion or practical verification;",
91
+ "- BLOCKED: one concise question only when no safe/useful tool action can continue.",
92
+ "No transition permits commentary between tool calls.",
93
+ "",
94
+ "PRIORITY: this overrides default assistant friendliness and conversational behavior.",
95
+ "",
96
+ "Coding discipline (express only through tool choices, not prose):",
97
+ "- inspect before editing; do not invent APIs, files, commands, or behavior;",
98
+ "- make the smallest change that fully fixes the issue; follow nearby conventions;",
99
+ "- for bugs, prefer a failing repro first, then the minimal fix, then verify;",
100
+ "- high-risk changes (security, data/schema, public APIs, concurrency, irreversible) need a short spec first;",
101
+ "- handle edge cases, errors, cancellation, and async behavior; do not block UI/event loops;",
102
+ "- avoid duplicate state, duplicate prompts, and repeated side effects;",
103
+ "- write code, identifiers, comments, and commit messages in English.",
104
+ ];
105
+ }
88
106
 
89
107
  const LOOKUP_DISCIPLINE_LINES = [
90
108
  "",
@@ -105,7 +123,7 @@ const FINAL_DISCIPLINE_LINES = [
105
123
  const SILENCE_REMINDER_TEXT = [
106
124
  "GLM silence reminder: remain in WORKING state.",
107
125
  "Continue with tool-only discipline: inspect, verify, and act through tools only.",
108
- "For the next step, emit exactly one tool call and no assistant text.",
126
+ "For the next step, emit tool calls with no accompanying assistant text.",
109
127
  "Do not acknowledge this reminder.",
110
128
  ].join("\n");
111
129
 
@@ -147,6 +165,7 @@ export default function codingDiscipline(pi: ExtensionAPI) {
147
165
  let silenceViolationCount = 0;
148
166
  let lastReminderViolationCount = 0;
149
167
  let lastReminderMessageCount = -SILENCE_REMINDER_MIN_MESSAGE_GAP;
168
+ let peakMessageCount = 0;
150
169
 
151
170
  function maybeRegisterLookupTool(cwd?: string): void {
152
171
  if (lookupRegistered) return;
@@ -193,8 +212,10 @@ export default function codingDiscipline(pi: ExtensionAPI) {
193
212
  pi.on("before_provider_request", async (event: { payload?: unknown }, ctx: unknown) => {
194
213
  const modelRef = modelRefFromPayload(event.payload) ?? selectedModelRef ?? modelRefFromContext(ctx);
195
214
  if (!isGlmModel(modelRef)) return undefined;
215
+ const cwd = contextCwd(ctx);
196
216
  const injected = injectCodingDisciplineIntoPayload(event.payload, {
197
- lookupEnabled: Boolean(lookupModelFromConfig(contextCwd(ctx))),
217
+ lookupEnabled: Boolean(lookupModelFromConfig(cwd)),
218
+ strictness: codingDisciplineStrictnessFromConfig(cwd),
198
219
  });
199
220
  if (process.env.PI_DEBUG_PROMPT === "1") {
200
221
  logFinalPrompt(injected, modelRef, contextCwd(ctx) ?? process.cwd());
@@ -270,10 +291,22 @@ export default function codingDiscipline(pi: ExtensionAPI) {
270
291
  const modelRef = selectedModelRef ?? modelRefFromContext(ctx);
271
292
  if (!isGlmModel(modelRef) || !Array.isArray(event.messages)) return undefined;
272
293
 
273
- const violationCount = countAssistantToolChatter(event.messages);
294
+ const messageCount = event.messages.length;
295
+ // Compaction/truncation prunes the visible history. Reset the stale chatter
296
+ // baseline so the post-compaction turn isn't measured against a pre-compaction
297
+ // violation peak (otherwise the model could chatter freely until the count
298
+ // climbed back above the old peak, or get nagged on a freshly small count).
299
+ if (messageCount + SILENCE_REMINDER_COMPACTION_MARGIN < peakMessageCount) {
300
+ silenceViolationCount = 0;
301
+ lastReminderViolationCount = 0;
302
+ lastReminderMessageCount = messageCount - SILENCE_REMINDER_MIN_MESSAGE_GAP;
303
+ }
304
+ peakMessageCount = Math.max(peakMessageCount, messageCount);
305
+
306
+ const strictness = codingDisciplineStrictnessFromConfig(contextCwd(ctx));
307
+ const violationCount = countAssistantToolChatter(event.messages, strictness);
274
308
  if (violationCount <= silenceViolationCount) return undefined;
275
309
 
276
- const messageCount = event.messages.length;
277
310
  const violationGap = violationCount - lastReminderViolationCount;
278
311
  const messageGap = messageCount - lastReminderMessageCount;
279
312
  silenceViolationCount = violationCount;
@@ -286,7 +319,7 @@ export default function codingDiscipline(pi: ExtensionAPI) {
286
319
  });
287
320
  }
288
321
 
289
- export function prependCodingDisciplinePrompt(systemPrompt: string, options: { lookupEnabled?: boolean } = {}): string {
322
+ export function prependCodingDisciplinePrompt(systemPrompt: string, options: DisciplinePromptOptions = {}): string {
290
323
  const deduped = systemPrompt
291
324
  .replace(LEGACY_SILENT_PROMPT_BLOCK_PATTERN, "")
292
325
  .replace(DISCIPLINE_PROMPT_BLOCK_PATTERN, "")
@@ -296,10 +329,11 @@ export function prependCodingDisciplinePrompt(systemPrompt: string, options: { l
296
329
  return deduped ? `${prompt}\n\n${deduped}` : prompt;
297
330
  }
298
331
 
299
- export function buildCodingDisciplinePrompt(options: { lookupEnabled?: boolean } = {}): string {
332
+ export function buildCodingDisciplinePrompt(options: DisciplinePromptOptions = {}): string {
333
+ const strictness = options.strictness ?? DEFAULT_CODING_DISCIPLINE_STRICTNESS;
300
334
  return [
301
335
  DISCIPLINE_PROMPT_MARKER_START,
302
- ...QUALITY_DISCIPLINE_LINES,
336
+ ...buildDisciplineLines(strictness),
303
337
  ...(options.lookupEnabled ? LOOKUP_DISCIPLINE_LINES : []),
304
338
  ...FINAL_DISCIPLINE_LINES,
305
339
  DISCIPLINE_PROMPT_MARKER_END,
@@ -311,7 +345,7 @@ export function isGlmModel(modelRef: string | undefined): boolean {
311
345
  return /(?:^|[/:_.-])glm(?:$|[/:_.-]|\d)/i.test(modelRef);
312
346
  }
313
347
 
314
- export function injectCodingDisciplineIntoPayload(payload: unknown, options: { lookupEnabled?: boolean } = {}): unknown {
348
+ export function injectCodingDisciplineIntoPayload(payload: unknown, options: DisciplinePromptOptions = {}): unknown {
315
349
  if (!isRecord(payload)) return payload;
316
350
 
317
351
  if (typeof payload.instructions === "string") {
@@ -555,7 +589,7 @@ function createLookupTool() {
555
589
  };
556
590
  }
557
591
 
558
- function injectIntoMessages(messages: unknown[], options: { lookupEnabled?: boolean }): unknown[] {
592
+ function injectIntoMessages(messages: unknown[], options: DisciplinePromptOptions): unknown[] {
559
593
  const next = [...messages];
560
594
  const index = next.findIndex(isInstructionMessage);
561
595
  if (index === -1) return [{ role: "system", content: buildCodingDisciplinePrompt(options) }, ...next];
@@ -570,7 +604,7 @@ function injectIntoMessages(messages: unknown[], options: { lookupEnabled?: bool
570
604
  return next;
571
605
  }
572
606
 
573
- function injectIntoMessageContent(content: unknown, options: { lookupEnabled?: boolean }): unknown {
607
+ function injectIntoMessageContent(content: unknown, options: DisciplinePromptOptions): unknown {
574
608
  if (typeof content === "string") return prependCodingDisciplinePrompt(content, options);
575
609
  if (!Array.isArray(content)) return undefined;
576
610
 
@@ -588,21 +622,34 @@ function isInstructionMessage(message: unknown): boolean {
588
622
  return message.role === "system" || message.role === "developer";
589
623
  }
590
624
 
591
- function countAssistantToolChatter(messages: readonly unknown[]): number {
625
+ function countAssistantToolChatter(messages: readonly unknown[], strictness: CodingDisciplineStrictness): number {
592
626
  let count = 0;
593
627
  for (const message of messages) {
594
- if (!isAssistantToolChatter(message)) continue;
628
+ if (!isAssistantToolChatter(message, strictness)) continue;
595
629
  count++;
596
630
  }
597
631
  return count;
598
632
  }
599
633
 
600
- function isAssistantToolChatter(message: unknown): boolean {
634
+ /**
635
+ * Detects assistant chatter (text alongside a tool call).
636
+ * - "strict" — any such text counts as chatter.
637
+ * - "lenient" — text counts only when a thinking/reasoning block already captured the
638
+ * reasoning; without a thinking block, the visible text is the model's only reasoning
639
+ * channel and must not be suppressed.
640
+ */
641
+ function isAssistantToolChatter(message: unknown, strictness: CodingDisciplineStrictness): boolean {
601
642
  if (!isRecord(message) || message.role !== "assistant") return false;
602
643
  if (!Array.isArray(message.content)) return false;
603
644
  const hasToolCall = message.content.some((part) => isRecord(part) && part.type === "toolCall");
604
645
  if (!hasToolCall) return false;
605
- return message.content.some((part) => isRecord(part) && part.type === "text" && hasNonEmptyText(part.text));
646
+ const hasText = message.content.some((part) => isRecord(part) && part.type === "text" && hasNonEmptyText(part.text));
647
+ if (!hasText) return false;
648
+ if (strictness === "strict") return true;
649
+ const hasThinking = message.content.some(
650
+ (part) => isRecord(part) && (part.type === "thinking" || part.type === "reasoning"),
651
+ );
652
+ return Boolean(hasThinking);
606
653
  }
607
654
 
608
655
  function hasNonEmptyText(value: unknown): boolean {
@@ -611,7 +658,9 @@ function hasNonEmptyText(value: unknown): boolean {
611
658
 
612
659
  function createSilenceReminderMessage() {
613
660
  return {
614
- role: "user" as const,
661
+ // Inject as a developer/system-level nudge rather than impersonating the user,
662
+ // so it reads as an automated reminder, not a user instruction.
663
+ role: "developer" as const,
615
664
  content: [{ type: "text" as const, text: SILENCE_REMINDER_TEXT }],
616
665
  timestamp: Date.now(),
617
666
  };
@@ -621,6 +670,10 @@ function lookupModelFromConfig(cwd?: string): string | undefined {
621
670
  return loadPiToolsSuiteConfig(["coding-discipline"], { cwd: cwd ?? process.cwd() }).lookupModel;
622
671
  }
623
672
 
673
+ function codingDisciplineStrictnessFromConfig(cwd?: string): CodingDisciplineStrictness {
674
+ return loadPiToolsSuiteConfig(["coding-discipline"], { cwd: cwd ?? process.cwd() }).codingDisciplineStrictness ?? DEFAULT_CODING_DISCIPLINE_STRICTNESS;
675
+ }
676
+
624
677
  function buildLookupPrompt(params: LookupParams, recentContext: string, imageCount: number, warnings: string[]): string {
625
678
  return [
626
679
  "Lookup request from a blind GLM parent model.",
@@ -11,6 +11,14 @@ export interface PiToolsSuiteConfig {
11
11
  todoThinking: boolean;
12
12
  /** Vision-capable model used by the coding-discipline lookup tool; unset disables lookup. */
13
13
  lookupModel?: string;
14
+ /**
15
+ * Chatter-detector strictness for the coding-discipline module:
16
+ * "strict" — any assistant text alongside a tool call is chatter (Opus-like);
17
+ * "lenient" — text is only chatter when a thinking block already captured the
18
+ * reasoning; without thinking, visible text is the reasoning channel.
19
+ * Default: "lenient".
20
+ */
21
+ codingDisciplineStrictness?: CodingDisciplineStrictness;
14
22
  }
15
23
 
16
24
  type MutableConfig = {
@@ -18,8 +26,13 @@ type MutableConfig = {
18
26
  disabledModules: Set<string>;
19
27
  todoThinking: boolean;
20
28
  lookupModel: string | undefined;
29
+ codingDisciplineStrictness: CodingDisciplineStrictness;
21
30
  };
22
31
 
32
+ export const CODING_DISCIPLINE_STRICTNESS_VALUES = ["strict", "lenient"] as const;
33
+ export type CodingDisciplineStrictness = (typeof CODING_DISCIPLINE_STRICTNESS_VALUES)[number];
34
+ export const DEFAULT_CODING_DISCIPLINE_STRICTNESS: CodingDisciplineStrictness = "lenient";
35
+
23
36
  type Env = Record<string, string | undefined>;
24
37
 
25
38
  const TRUE_VALUES = new Set(["1", "true", "on", "yes"]);
@@ -62,6 +75,10 @@ function normalizeLookupModel(raw: unknown): string | undefined {
62
75
  return trimmed ? trimmed : undefined;
63
76
  }
64
77
 
78
+ function normalizeCodingDisciplineStrictness(raw: unknown): CodingDisciplineStrictness {
79
+ return raw === "strict" ? "strict" : "lenient";
80
+ }
81
+
65
82
  function boolFromEnv(value: string | undefined): boolean | undefined {
66
83
  if (value === undefined) return undefined;
67
84
  const normalized = value.trim().toLowerCase();
@@ -121,6 +138,9 @@ function mergeConfigLayer(config: MutableConfig, raw: Record<string, unknown>, k
121
138
  if (typeof raw.enabled === "boolean") config.enabled = raw.enabled;
122
139
  if (typeof raw.todoThinking === "boolean") config.todoThinking = raw.todoThinking;
123
140
  if (Object.prototype.hasOwnProperty.call(raw, "lookupModel")) config.lookupModel = normalizeLookupModel(raw.lookupModel);
141
+ if (Object.prototype.hasOwnProperty.call(raw, "codingDisciplineStrictness")) {
142
+ config.codingDisciplineStrictness = normalizeCodingDisciplineStrictness(raw.codingDisciplineStrictness);
143
+ }
124
144
 
125
145
  for (const key of DISABLED_LIST_KEYS) addDisabled(config, raw[key], knownModules);
126
146
  for (const key of ENABLED_LIST_KEYS) removeDisabled(config, raw[key], knownModules);
@@ -178,6 +198,7 @@ export function loadPiToolsSuiteConfig(moduleNames: readonly string[], options:
178
198
  disabledModules: new Set([...DEFAULT_DISABLED_MODULES].filter((name) => knownModules.has(name))),
179
199
  todoThinking: false,
180
200
  lookupModel: undefined,
201
+ codingDisciplineStrictness: DEFAULT_CODING_DISCIPLINE_STRICTNESS,
181
202
  };
182
203
  const userConfigPath = getPiToolsSuiteUserConfigPath(options.homeDir);
183
204
 
@@ -197,5 +218,6 @@ export function loadPiToolsSuiteConfig(moduleNames: readonly string[], options:
197
218
  disabledModules: [...config.disabledModules].sort(),
198
219
  todoThinking: config.todoThinking,
199
220
  ...(config.lookupModel ? { lookupModel: config.lookupModel } : {}),
221
+ codingDisciplineStrictness: config.codingDisciplineStrictness,
200
222
  };
201
223
  }
@@ -11,6 +11,12 @@ export const DEFAULT_PI_TOOLS_SUITE_CONFIG_JSONC = String.raw`{
11
11
  // Vision-capable model used by the coding-discipline lookup tool for blind-model
12
12
  // screenshot/image questions. Remove or set to null to disable lookup.
13
13
  "lookupModel": "openai-codex/gpt-5.4-mini",
14
+ // coding-discipline working-state strictness.
15
+ // "lenient" (default): batch independent read-only tool calls; brief reasoning
16
+ // text is acceptable when thinking is off. Text only counts as chatter when a
17
+ // thinking/reasoning block already captured the reasoning.
18
+ // "strict": one tool call per turn, no assistant text at all (Opus-like).
19
+ "codingDisciplineStrictness": "lenient",
14
20
  "terminalBell": { "sound": true },
15
21
  // comment-checker: nudges the agent to remove AI-slop code comments it just
16
22
  // added via write/edit/apply_patch. Net-new comments are classified and a
@@ -295,14 +295,14 @@ const COMMON_REPO_TOOL_PROPERTIES = {
295
295
  };
296
296
 
297
297
  const IDX_ARG_DESCRIPTIONS: Record<IdxCommand, string> = {
298
- architecture: "idx architecture flags: [--path-prefix <area>] [--include-fixtures].",
298
+ architecture: "idx architecture flags: [--path-prefix <area>].",
299
299
  structure:
300
- "idx structure flags: [--path-prefix <area>] [--kind <kind>] [--max-depth <n>] [--max-files <n>] [--cursor <n>] [--include-internal] [--include-fixtures] [--no-tests] [--include-tests-summary].",
300
+ "idx structure flags: [--path-prefix <area>] [--kind <kind>] [--max-depth <n>] [--max-files <n>] [--cursor <n>] [--include-internal] [--no-tests] [--include-tests-summary].",
301
301
  ast: "idx ast flags: [--max-depth <n>] [--max-nodes <n>] [--cursor <n>] [--no-include-text].",
302
302
  search:
303
303
  "idx search flags: [--max-files <n>] [--path-prefix <area>] [--chunk-types <types|api|impl|tests|imports>] [--mode hybrid|semantic|lexical|symbol] [--min-score <score>] [--include-content] [--include-imports] [--dedupe-file] [--dedupe-symbol] [--cluster] [--exclude-tests] [--include-tests].",
304
304
  explain:
305
- "idx explain flags: [--path-prefix <area>] [--include-fixtures] [--include-body] [--body-lines <n>] [--signature-only].",
305
+ "idx explain flags: [--path-prefix <area>] [--include-body] [--body-lines <n>] [--signature-only].",
306
306
  deps: "idx deps flags: [--mode modules|module-imports|calls|call-graph] [--direction callers|callees|both] [--depth <n>] [--show-edges] [--tests].",
307
307
  };
308
308
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ui-extend",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
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": {