pi-jar 0.2.2 → 0.2.3

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,11 +1,20 @@
1
+ import { homedir } from "node:os";
2
+ import { join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
1
4
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
5
 
3
6
  /** Character counts are diagnostics, NOT token estimates: signatures can be opaque ciphertext. */
4
- export interface ContextDietStats {
7
+ export interface ThinkingDietStats {
5
8
  removedThinkingParts: number;
6
9
  removedVisibleChars: number;
7
10
  removedSignatureChars: number;
8
11
  }
12
+ /** Superseded `read` results replaced by a stub, and the characters of file text they carried. */
13
+ export interface ReadDietStats {
14
+ stubbedReads: number;
15
+ stubbedReadChars: number;
16
+ }
17
+ export type ContextDietStats = ThinkingDietStats & ReadDietStats;
9
18
 
10
19
  export interface DietMessage {
11
20
  role: string;
@@ -13,24 +22,58 @@ export interface DietMessage {
13
22
  toolCallId?: string;
14
23
  }
15
24
 
16
- const emptyStats = (): ContextDietStats => ({ removedThinkingParts: 0, removedVisibleChars: 0, removedSignatureChars: 0 });
25
+ const emptyStats = (): ContextDietStats => ({ removedThinkingParts: 0, removedVisibleChars: 0, removedSignatureChars: 0, stubbedReads: 0, stubbedReadChars: 0 });
17
26
  const record = (value: unknown): Record<string, unknown> | undefined =>
18
27
  value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
19
28
 
20
29
  /**
21
- * Opt-in provider-context projection; never edits the session or mutates its messages.
22
- * Only prior user turns are eligible. The complete current reasoning/tool chain is retained.
23
- * Ambiguous, unresolved or boundary-crossing historical tool calls make the whole projection
24
- * a no-op: reducing context is less important than preserving provider sequencing constraints.
30
+ * `details` of a read-cache stub (src/compact-tools.ts). Such a result points at an earlier read
31
+ * instead of carrying file text, so it never supersedes that read.
32
+ */
33
+ export interface ReadStubDetails { unchangedSinceRead: number }
34
+ export const isReadStub = (details: unknown): details is ReadStubDetails => typeof record(details)?.unchangedSinceRead === "number";
35
+
36
+ const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
37
+ /**
38
+ * Pi's read/edit/write path resolution (odd spaces, `@path`, `~`, file URLs) without probing the
39
+ * filesystem. Undefined for a file URL Pi itself rejects; callers then just don't match the path.
25
40
  */
26
- export function trimCompletedThinking<T extends DietMessage>(messages: T[]): { messages: T[]; stats: ContextDietStats } {
27
- const stats = emptyStats();
41
+ export function resolveToolPath(path: string, cwd: string): string | undefined {
42
+ let value = path.replace(UNICODE_SPACES, " ");
43
+ if (value.startsWith("@")) value = value.slice(1);
44
+ if (value === "~" || value.startsWith("~/")) value = join(homedir(), value.slice(2));
45
+ else if (value.startsWith("file://")) {
46
+ try { value = fileURLToPath(value); } catch { return undefined; }
47
+ }
48
+ return resolve(cwd, value);
49
+ }
50
+
51
+ /**
52
+ * The line window a read returns; Pi treats a missing offset and offsets ≤ 1 alike. Undefined when the
53
+ * arguments are not plain numbers, so an unvalidated request never matches another.
54
+ */
55
+ export function readRangeKey(offset: unknown, limit: unknown): string | undefined {
56
+ const start = offset === undefined ? 1 : typeof offset === "number" ? Math.max(offset, 1) : undefined;
57
+ if (start === undefined || (limit !== undefined && typeof limit !== "number")) return undefined;
58
+ return `${start}:${limit ?? ""}`;
59
+ }
60
+
61
+ /** Completed prior user turns span [firstUser, boundary); every call there has its result there. */
62
+ interface History { firstUser: number; boundary: number; results: Map<string, number> }
63
+
64
+ /**
65
+ * The completed prior-user-turn span a projection may rewrite; the complete current reasoning/tool
66
+ * chain is never eligible. Ambiguous, unresolved or boundary-crossing historical tool calls make it
67
+ * undefined, so every projection is a no-op: reducing context is less important than preserving
68
+ * provider sequencing constraints.
69
+ */
70
+ function completedHistory(messages: readonly DietMessage[]): History | undefined {
28
71
  let boundary = -1;
29
72
  for (let index = messages.length - 1; index >= 0; index--) {
30
73
  if (messages[index]!.role === "user") { boundary = index; break; }
31
74
  }
32
75
  const firstUser = messages.findIndex(message => message.role === "user");
33
- if (boundary <= 0 || firstUser === boundary) return { messages, stats };
76
+ if (boundary <= 0 || firstUser === boundary) return undefined;
34
77
 
35
78
  const calls = new Map<string, number>();
36
79
  const results = new Map<string, number>();
@@ -38,7 +81,7 @@ export function trimCompletedThinking<T extends DietMessage>(messages: T[]): { m
38
81
  const message = messages[index]!;
39
82
  if (message.role === "toolResult") {
40
83
  const id = message.toolCallId;
41
- if (typeof id !== "string" || !id || results.has(id)) return { messages, stats };
84
+ if (typeof id !== "string" || !id || results.has(id)) return undefined;
42
85
  results.set(id, index);
43
86
  }
44
87
  if (message.role !== "assistant" || !Array.isArray(message.content)) continue;
@@ -46,19 +89,31 @@ export function trimCompletedThinking<T extends DietMessage>(messages: T[]): { m
46
89
  const part = record(value);
47
90
  if (part?.type !== "toolCall") continue;
48
91
  const id = part.id;
49
- if (typeof id !== "string" || !id || calls.has(id)) return { messages, stats };
92
+ if (typeof id !== "string" || !id || calls.has(id)) return undefined;
50
93
  calls.set(id, index);
51
94
  }
52
95
  }
53
96
  for (const [id, index] of calls) {
54
97
  if (index >= boundary) continue;
55
98
  const result = results.get(id);
56
- if (result === undefined || result <= index || result >= boundary) return { messages, stats };
99
+ if (result === undefined || result <= index || result >= boundary) return undefined;
57
100
  }
58
101
  // A detached historical result means the original reasoning sequence is not fully known.
59
102
  for (const [id, index] of results) {
60
- if (index < boundary && !calls.has(id)) return { messages, stats };
103
+ if (index < boundary && !calls.has(id)) return undefined;
61
104
  }
105
+ return { firstUser, boundary, results };
106
+ }
107
+
108
+ /**
109
+ * Opt-in provider-context projection; never edits the session or mutates its messages.
110
+ * Only prior user turns are eligible (see completedHistory).
111
+ */
112
+ export function trimCompletedThinking<T extends DietMessage>(messages: T[]): { messages: T[]; stats: ThinkingDietStats } {
113
+ const stats: ThinkingDietStats = { removedThinkingParts: 0, removedVisibleChars: 0, removedSignatureChars: 0 };
114
+ const history = completedHistory(messages);
115
+ if (!history) return { messages, stats };
116
+ const { firstUser, boundary } = history;
62
117
 
63
118
  let changed = false;
64
119
  const projected = messages.map((message, index) => {
@@ -79,15 +134,101 @@ export function trimCompletedThinking<T extends DietMessage>(messages: T[]): { m
79
134
  return { messages: changed ? projected : messages, stats };
80
135
  }
81
136
 
137
+ /** Total text of an all-text result; undefined when it holds images or unknown parts. */
138
+ function textLength(content: unknown): number | undefined {
139
+ if (!Array.isArray(content) || !content.length) return undefined;
140
+ let chars = 0;
141
+ for (const value of content) {
142
+ const part = record(value);
143
+ if (part?.type !== "text" || typeof part.text !== "string") return undefined;
144
+ chars += part.text.length;
145
+ }
146
+ return chars;
147
+ }
148
+
149
+ interface OpenRead { result: number; path: string; chars: number }
150
+
151
+ /**
152
+ * Opt-in projection, under the same eligibility as trimCompletedThinking: a `read` result that a later
153
+ * read of the same file and line window, or a successful edit/write of the file, superseded becomes a
154
+ * one-line stub; the toolResult keeps its id, name and position. Only operations inside the read's own
155
+ * completed user turn count. A result's projection is therefore fixed when its turn completes and
156
+ * changes together with the thinking trim, never later: re-projecting an older message after newer
157
+ * ones arrive would re-bill the whole prefix after it (src/workflow-context.ts). Operations issued in
158
+ * the read's own assistant message may have run in parallel with it, so they never supersede it.
159
+ * Read-cache stubs, errors, images and results answered outside their turn are left alone.
160
+ */
161
+ export function stubSupersededReads<T extends DietMessage>(messages: T[], cwd: string): { messages: T[]; stats: ReadDietStats } {
162
+ const stats: ReadDietStats = { stubbedReads: 0, stubbedReadChars: 0 };
163
+ const history = completedHistory(messages);
164
+ if (!history) return { messages, stats };
165
+ const { firstUser, boundary, results } = history;
166
+
167
+ const stubs = new Map<number, string>();
168
+ const supersede = (read: OpenRead, by: string) => {
169
+ const stub = `[superseded by a later ${by} of ${read.path}]`;
170
+ if (stubs.has(read.result) || stub.length >= read.chars) return;
171
+ stubs.set(read.result, stub);
172
+ stats.stubbedReads++;
173
+ stats.stubbedReadChars += read.chars;
174
+ };
175
+ for (let start = firstUser; start < boundary;) {
176
+ // The boundary is a user message, so every completed turn ends at one.
177
+ let end = start + 1;
178
+ while (messages[end]!.role !== "user") end++;
179
+ /** File → line window → newest read of this turn not yet superseded. */
180
+ const open = new Map<string, Map<string, OpenRead>>();
181
+ for (let index = start + 1; index < end; index++) {
182
+ const message = messages[index]!;
183
+ if (message.role !== "assistant" || !Array.isArray(message.content)) continue;
184
+ const added: Array<{ file: string; range: string; read: OpenRead }> = [];
185
+ for (const value of message.content) {
186
+ const part = record(value);
187
+ const args = record(part?.arguments);
188
+ const name = part?.name;
189
+ if (part?.type !== "toolCall" || (name !== "read" && name !== "edit" && name !== "write") || typeof args?.path !== "string") continue;
190
+ const result = results.get(part.id as string)!;
191
+ const outcome = record(messages[result]);
192
+ const file = resolveToolPath(args.path, cwd);
193
+ if (result >= end || !file || outcome?.isError === true) continue;
194
+ if (name !== "read") {
195
+ for (const read of open.get(file)?.values() ?? []) supersede(read, name);
196
+ open.delete(file);
197
+ continue;
198
+ }
199
+ const range = readRangeKey(args.offset, args.limit);
200
+ const chars = range === undefined || isReadStub(outcome?.details) ? undefined : textLength(outcome?.content);
201
+ if (range === undefined || chars === undefined) continue;
202
+ const prior = open.get(file)?.get(range);
203
+ if (prior) supersede(prior, "read");
204
+ added.push({ file, range, read: { result, path: args.path, chars } });
205
+ }
206
+ for (const { file, range, read } of added) {
207
+ let ranges = open.get(file);
208
+ if (!ranges) open.set(file, ranges = new Map());
209
+ ranges.set(range, read);
210
+ }
211
+ }
212
+ start = end;
213
+ }
214
+ if (!stubs.size) return { messages, stats };
215
+ const projected = messages.map((message, index) => {
216
+ const stub = stubs.get(index);
217
+ return stub === undefined ? message : { ...message, content: [{ type: "text", text: stub }] } as T;
218
+ });
219
+ return { messages: projected, stats };
220
+ }
221
+
82
222
  /** Register a runtime-toggleable diet; caller owns its persisted, opt-in setting. */
83
223
  export function registerContextDiet(pi: ExtensionAPI, enabled: () => boolean): { stats: () => ContextDietStats } {
84
224
  let latest = emptyStats();
85
- pi.on("context", event => {
225
+ pi.on("context", (event, ctx) => {
86
226
  latest = emptyStats();
87
227
  if (!enabled()) return;
88
- const result = trimCompletedThinking(event.messages);
89
- latest = result.stats;
90
- if (result.messages !== event.messages) return { messages: result.messages };
228
+ const thinking = trimCompletedThinking(event.messages);
229
+ const reads = stubSupersededReads(thinking.messages, ctx.cwd);
230
+ latest = { ...thinking.stats, ...reads.stats };
231
+ if (reads.messages !== event.messages) return { messages: reads.messages };
91
232
  });
92
233
  pi.on("session_start", () => { latest = emptyStats(); });
93
234
  return { stats: () => ({ ...latest }) };
@@ -5,7 +5,7 @@ import { askOne } from "./ask-tool.ts";
5
5
  import { MAX_RESUME_CHARS, type DelegateController, type SubagentReport } from "./delegate.ts";
6
6
 
7
7
  export interface DecisionOption { id: string; label: string; evidence: string }
8
- export interface DemocracyRequest {
8
+ export interface CouncilRequest {
9
9
  complexity: "super-complex";
10
10
  issue: string;
11
11
  justification: string;
@@ -16,7 +16,7 @@ export interface DemocracyRequest {
16
16
  voters?: number;
17
17
  }
18
18
  export interface Ballot { agent: string; option: string; rationale: string }
19
- export interface DemocracyResult {
19
+ export interface CouncilResult {
20
20
  status: "majority" | "needs-user";
21
21
  electorate: number;
22
22
  ballots: Ballot[];
@@ -33,15 +33,15 @@ export const BALLOT_TIMEOUT_MS = 10 * 60_000;
33
33
  const MAX_RATIONALE = 600;
34
34
  const MAX_FAILURE = 200;
35
35
 
36
- /** Runtime gates as well as a schema: democracy is not an ordinary task-selection shortcut. */
37
- export function validateDemocracy(request: DemocracyRequest): void {
36
+ /** Runtime gates as well as a schema: the council is not an ordinary task-selection shortcut. */
37
+ export function validateCouncil(request: CouncilRequest): void {
38
38
  if (request.complexity !== "super-complex" || !request.issue?.trim() || !request.justification?.trim()) {
39
39
  throw new Error("Voting requires a persistent super-complex issue and a concrete complexity justification.");
40
40
  }
41
41
  if (!Array.isArray(request.failedApproaches) || request.failedApproaches.length < 2 ||
42
42
  request.failedApproaches.some((item) => typeof item !== "string" || !item.trim()) ||
43
43
  new Set(request.failedApproaches.map((item) => item.trim())).size < 2) {
44
- throw new Error("Provide evidence of at least two distinct failed approaches; routine decisions must not use democracy.");
44
+ throw new Error("Provide evidence of at least two distinct failed approaches; routine decisions must not use the council.");
45
45
  }
46
46
  if (!Array.isArray(request.options) || request.options.length < 2 || request.options.length > 8 ||
47
47
  request.options.some((option) => !/^[a-zA-Z0-9_-]{1,40}$/.test(option.id) || !option.label?.trim() || !option.evidence?.trim()) ||
@@ -65,7 +65,7 @@ export function parseBallot(report: SubagentReport, nonce: string, options: read
65
65
  }
66
66
 
67
67
  export function tallyBallots(options: readonly DecisionOption[], electorate: number, ballots: Ballot[],
68
- failures: DemocracyResult["failures"] = []): DemocracyResult {
68
+ failures: CouncilResult["failures"] = []): CouncilResult {
69
69
  if (!Number.isInteger(electorate) || electorate < 2 || ballots.length > electorate ||
70
70
  new Set(ballots.map((ballot) => ballot.agent)).size !== ballots.length) throw new Error("Invalid electorate or duplicate ballot");
71
71
  const counts = Object.fromEntries(options.map((option) => [option.id, 0]));
@@ -85,9 +85,9 @@ export function tallyBallots(options: readonly DecisionOption[], electorate: num
85
85
  * Same retained pool and launch cap as jar_delegate; no forks, writable workers or extra process launcher.
86
86
  * Scouts spawned only to vote are retired after the tally so they never hold pool slots; nominated scouts stay.
87
87
  */
88
- export async function conductDemocracy(controller: DelegateController, request: DemocracyRequest,
89
- maxSubagents: number, signal?: AbortSignal, timeoutMs = BALLOT_TIMEOUT_MS): Promise<DemocracyResult> {
90
- validateDemocracy(request);
88
+ export async function conductCouncil(controller: DelegateController, request: CouncilRequest,
89
+ maxSubagents: number, signal?: AbortSignal, timeoutMs = BALLOT_TIMEOUT_MS): Promise<CouncilResult> {
90
+ validateCouncil(request);
91
91
  if (signal?.aborted) throw new Error("Vote aborted");
92
92
  const agents = request.agents ?? [];
93
93
  const voters = request.voters ?? Math.max(2, agents.length || Math.min(4, maxSubagents));
@@ -135,7 +135,7 @@ export async function conductDemocracy(controller: DelegateController, request:
135
135
  const results = await Promise.allSettled(jobs.map((job) => job()));
136
136
  if (signal?.aborted) throw new Error("Vote aborted; do not act on incomplete ballots");
137
137
  const ballots: Ballot[] = [];
138
- const failures: DemocracyResult["failures"] = [];
138
+ const failures: CouncilResult["failures"] = [];
139
139
  results.forEach((result, index) => {
140
140
  if (result.status === "fulfilled") ballots.push(result.value);
141
141
  else failures.push({ agent: agents[index] ?? `new-scout-${index + 1}`,
@@ -161,19 +161,16 @@ async function abortable<T>(promise: Promise<T>, signal?: AbortSignal): Promise<
161
161
  } finally { if (abort) signal.removeEventListener("abort", abort); }
162
162
  }
163
163
 
164
- export function registerDemocracy(pi: ExtensionAPI, controller: () => DelegateController | undefined,
164
+ export function registerCouncil(pi: ExtensionAPI, controller: () => DelegateController | undefined,
165
165
  maxSubagents: () => number): void {
166
166
  if (typeof pi.registerTool !== "function") return;
167
167
  let running = false;
168
168
  pi.registerTool({
169
- name: "jar_democracy", label: "exceptional decision vote",
170
- description: "Only for persistent super-complex issues after at least two distinct failed approaches: open evidence-backed options, spawn or resume relevant fresh read-only scouts, collect private ballots, and return a strict-majority recommendation. Ties or no majority require the user's choice; this tool never implements a decision.",
171
- promptSnippet: "Reserve jar_democracy for persistent super-complex issues with several viable options, never routine tasks.",
169
+ name: "jar_council", label: "exceptional decision vote",
170
+ description: "Exceptional decision vote. Explain why the issue is super-complex and cite at least two distinct failed approaches, then open 2–8 evidence-backed options. Prefer relevant idle fresh-context scouts (`agents`) over forks; fresh scouts avoid discussion bias. All voters share the retained pool cap. A strict majority of the invited electorate wins; ties, insufficient ballots and plurality go to the user — present leading options with evidence-based tradeoffs, never invented probabilities. Never implements a decision.",
171
+ promptSnippet: "jar_council is exceptional: only for a persistent super-complex issue with several viable options after two failed approaches, never routine choices.",
172
172
  promptGuidelines: [
173
- "Explain why the task is super-complex and cite at least two failed approaches before opening a vote.",
174
- "Prefer relevant idle fresh-context scouts over forks; use fresh scouts where prior discussion could bias the decision. All voters share the configured retained pool cap.",
175
- "A strict majority of the invited electorate wins. Ties, insufficient ballots, and plurality without majority go to the user. Present leading options with evidence-based likelihood/tradeoffs, not invented probabilities.",
176
- "If userChoice is absent or cancelled, use jar_ask to resolve needs-user; never treat the moderator's preferred option as a winner. Safety and approval requirements still apply to majority outcomes."
173
+ "If the result needs the user and userChoice is absent or cancelled, resolve it with jar_ask; a majority is a recommendation, not approval to skip safety checks."
177
174
  ],
178
175
  parameters: Type.Object({
179
176
  complexity: Type.Literal("super-complex"),
@@ -186,22 +183,22 @@ export function registerDemocracy(pi: ExtensionAPI, controller: () => DelegateCo
186
183
  voters: Type.Optional(Type.Integer({ minimum: 2, maximum: 16 }))
187
184
  }),
188
185
  async execute(_id, params, signal, _update, ctx) {
189
- if (running) return { content: [{ type: "text", text: "A democracy round is already running." }], isError: true, details: undefined };
186
+ if (running) return { content: [{ type: "text", text: "A council round is already running." }], isError: true, details: undefined };
190
187
  const fleet = controller();
191
188
  if (!fleet) return { content: [{ type: "text", text: "Delegation is unavailable in this session." }], isError: true, details: undefined };
192
189
  running = true;
193
190
  try {
194
- const result = await conductDemocracy(fleet, params, maxSubagents(), signal);
191
+ const result = await conductCouncil(fleet, params, maxSubagents(), signal);
195
192
  let userChoice: Awaited<ReturnType<typeof askOne>> | undefined;
196
193
  let selectionError: string | undefined;
197
194
  if (result.status === "needs-user" && ctx.hasUI && ctx.mode === "tui" && !signal?.aborted) {
198
195
  const choices = params.options.filter((option) => result.shortlist.includes(option.id));
199
- try { userChoice = await abortable(askOne(ctx, { id: "democracy", header: "Moderator decision",
196
+ try { userChoice = await abortable(askOne(ctx, { id: "council", header: "Moderator decision",
200
197
  question: "No strict majority. Leading options: " + choices.map((option) => `${option.id}: ${result.counts[option.id]} votes`).join("; ") +
201
198
  ". Pick based on the evidence/tradeoffs, or discuss before deciding.",
202
199
  options: choices.map((option) => ({ label: option.id + ": " + option.label, description: option.evidence.slice(0, 320) })), allowCustom: true }, 0, 1, signal), signal); }
203
200
  catch (error) { selectionError = String(error); }
204
- if (signal?.aborted) userChoice = { id: "democracy", cancelled: true };
201
+ if (signal?.aborted) userChoice = { id: "council", cancelled: true };
205
202
  }
206
203
  const details = { ...result, ...(userChoice ? { userChoice } : {}), ...(selectionError ? { selectionError } : {}) };
207
204
  return { content: [{ type: "text", text: JSON.stringify({ ...details,