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.
- package/README.md +15 -12
- package/docs/EFFICIENCY_PLAN.md +20 -0
- package/docs/INTEGRATIONS.md +22 -6
- package/docs/SUBAGENT-TOOLS.md +1 -1
- package/docs/WORKFLOWS.md +2 -2
- package/extensions/index.ts +83 -16
- package/package.json +1 -1
- package/src/advisor.ts +21 -9
- package/src/ask-tool.ts +2 -7
- package/src/compact-tools.ts +148 -8
- package/src/context-budget.ts +226 -0
- package/src/context-diet.ts +158 -17
- package/src/{democracy.ts → council.ts} +19 -22
- package/src/delegate.ts +80 -50
- package/src/discussion-hub.ts +9 -6
- package/src/discussion.ts +31 -22
- package/src/footer.ts +5 -1
- package/src/goal-loop.ts +3 -0
- package/src/info-panels.ts +6 -0
- package/src/model-roles.ts +26 -2
- package/src/plan.ts +4 -2
- package/src/profile-ui.ts +21 -6
- package/src/profiles.ts +7 -3
- package/src/quota.ts +71 -49
- package/src/roles-ui.ts +4 -2
- package/src/settings-ui.ts +27 -8
- package/src/settings.ts +21 -1
- package/src/shells.ts +77 -15
- package/src/subagent-tools.ts +1 -1
- package/src/suggest.ts +4 -11
- package/src/task-tool.ts +3 -9
- package/src/tool-activation.ts +14 -0
- package/src/usage-view.ts +8 -4
- package/src/usage.ts +4 -2
package/src/context-diet.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
22
|
-
*
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
27
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
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
|
|
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:
|
|
37
|
-
export function
|
|
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
|
|
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:
|
|
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
|
|
89
|
-
maxSubagents: number, signal?: AbortSignal, timeoutMs = BALLOT_TIMEOUT_MS): Promise<
|
|
90
|
-
|
|
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:
|
|
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
|
|
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: "
|
|
170
|
-
description: "
|
|
171
|
-
promptSnippet: "
|
|
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
|
-
"
|
|
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
|
|
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
|
|
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: "
|
|
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: "
|
|
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,
|