@oh-my-pi/pi-coding-agent 15.13.2 → 16.0.0
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/CHANGELOG.md +62 -0
- package/dist/cli.js +587 -499
- package/dist/types/advisor/__tests__/advisor.test.d.ts +1 -0
- package/dist/types/advisor/advise-tool.d.ts +58 -0
- package/dist/types/advisor/index.d.ts +3 -0
- package/dist/types/advisor/runtime.d.ts +52 -0
- package/dist/types/advisor/watchdog.d.ts +5 -0
- package/dist/types/config/model-roles.d.ts +1 -1
- package/dist/types/config/settings-schema.d.ts +75 -5
- package/dist/types/eval/js/context-manager.d.ts +15 -0
- package/dist/types/modes/components/advisor-message.d.ts +9 -0
- package/dist/types/modes/components/assistant-message.d.ts +1 -0
- package/dist/types/modes/controllers/command-controller.d.ts +3 -1
- package/dist/types/modes/interactive-mode.d.ts +4 -1
- package/dist/types/modes/types.d.ts +9 -1
- package/dist/types/sdk.d.ts +3 -3
- package/dist/types/session/agent-session.d.ts +71 -2
- package/dist/types/session/session-history-format.d.ts +4 -0
- package/dist/types/session/unexpected-stop-classifier.d.ts +13 -0
- package/dist/types/session/yield-queue.d.ts +2 -0
- package/dist/types/stt/asr-client.d.ts +1 -1
- package/dist/types/tiny/title-client.d.ts +1 -1
- package/dist/types/tools/job.d.ts +1 -0
- package/dist/types/tools/path-utils.d.ts +1 -0
- package/dist/types/tools/report-tool-issue.d.ts +0 -1
- package/dist/types/tts/tts-client.d.ts +1 -1
- package/dist/types/utils/thinking-display.d.ts +1 -17
- package/package.json +13 -13
- package/src/advisor/__tests__/advisor.test.ts +586 -0
- package/src/advisor/advise-tool.ts +87 -0
- package/src/advisor/index.ts +3 -0
- package/src/advisor/runtime.ts +248 -0
- package/src/advisor/watchdog.ts +83 -0
- package/src/cli.ts +25 -12
- package/src/config/model-registry.ts +6 -2
- package/src/config/model-roles.ts +13 -1
- package/src/config/settings-schema.ts +67 -5
- package/src/eval/__tests__/agent-bridge.test.ts +106 -46
- package/src/eval/__tests__/js-context-manager.test.ts +12 -2
- package/src/eval/js/context-manager.ts +40 -3
- package/src/eval/js/worker-entry.ts +7 -0
- package/src/export/html/template.js +18 -22
- package/src/internal-urls/docs-index.generated.ts +8 -5
- package/src/main.ts +19 -5
- package/src/modes/acp/acp-agent.ts +2 -2
- package/src/modes/acp/acp-event-mapper.ts +2 -2
- package/src/modes/components/advisor-message.ts +99 -0
- package/src/modes/components/agent-hub.ts +38 -7
- package/src/modes/components/assistant-message.ts +110 -15
- package/src/modes/components/snapcompact-shape-preview-doc.md +2 -2
- package/src/modes/components/snapcompact-shape-preview.ts +2 -2
- package/src/modes/components/status-line/segments.ts +20 -7
- package/src/modes/components/tree-selector.ts +3 -2
- package/src/modes/controllers/command-controller.ts +69 -2
- package/src/modes/controllers/event-controller.ts +3 -3
- package/src/modes/controllers/input-controller.ts +7 -1
- package/src/modes/controllers/streaming-reveal.ts +4 -4
- package/src/modes/interactive-mode.ts +14 -2
- package/src/modes/types.ts +9 -1
- package/src/modes/utils/ui-helpers.ts +12 -3
- package/src/prompts/advisor/advise-tool.md +1 -0
- package/src/prompts/advisor/system.md +31 -0
- package/src/prompts/agents/oracle.md +0 -1
- package/src/prompts/agents/reviewer.md +0 -1
- package/src/prompts/system/unexpected-stop-classifier.md +17 -0
- package/src/prompts/system/unexpected-stop-retry.md +4 -0
- package/src/sdk.ts +52 -13
- package/src/session/agent-session.ts +722 -21
- package/src/session/session-dump-format.ts +15 -142
- package/src/session/session-history-format.ts +30 -11
- package/src/session/unexpected-stop-classifier.ts +129 -0
- package/src/session/yield-queue.ts +5 -1
- package/src/slash-commands/builtin-registry.ts +102 -4
- package/src/stt/asr-client.ts +1 -1
- package/src/system-prompt.ts +1 -1
- package/src/tiny/title-client.ts +1 -1
- package/src/tools/browser/tab-supervisor.ts +1 -1
- package/src/tools/browser/tab-worker-entry.ts +12 -4
- package/src/tools/job.ts +1 -0
- package/src/tools/path-utils.ts +33 -2
- package/src/tools/report-tool-issue.ts +2 -7
- package/src/tts/tts-client.ts +1 -1
- package/src/utils/thinking-display.ts +8 -34
- package/src/web/scrapers/docs-rs.ts +2 -3
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
|
2
|
+
import { z } from "zod/v4";
|
|
3
|
+
import adviseDescription from "../prompts/advisor/advise-tool.md" with { type: "text" };
|
|
4
|
+
|
|
5
|
+
const adviseSchema = z.object({
|
|
6
|
+
note: z
|
|
7
|
+
.string()
|
|
8
|
+
.describe("One concrete piece of advice for the agent you are watching. Terse, specific, actionable."),
|
|
9
|
+
severity: z
|
|
10
|
+
.enum(["nit", "concern", "blocker"])
|
|
11
|
+
.optional()
|
|
12
|
+
.describe("How strongly to weigh this. Omit for a plain nit."),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export type AdviseParams = z.infer<typeof adviseSchema>;
|
|
16
|
+
|
|
17
|
+
export type AdvisorSeverity = "nit" | "concern" | "blocker";
|
|
18
|
+
|
|
19
|
+
export interface AdviseDetails {
|
|
20
|
+
note: string;
|
|
21
|
+
severity?: AdvisorSeverity;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** One queued advice note. */
|
|
25
|
+
export interface AdvisorNote {
|
|
26
|
+
note: string;
|
|
27
|
+
severity?: AdvisorSeverity;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Details payload on the batched `advisor` custom message rendered in the transcript. */
|
|
31
|
+
export interface AdvisorMessageDetails {
|
|
32
|
+
notes: AdvisorNote[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Prose framing prepended to every batched advisor message. Kept here so the
|
|
37
|
+
* non-interrupting YieldQueue dispatcher and the interrupting steer path build
|
|
38
|
+
* byte-identical content.
|
|
39
|
+
*/
|
|
40
|
+
const ADVISOR_BATCH_PREFIX = "Advisor (a senior reviewer watching your work — weigh it, don't blindly obey):";
|
|
41
|
+
|
|
42
|
+
/** Render one advisor card body from a batch of notes (prefix + one bullet per note). */
|
|
43
|
+
export function formatAdvisorBatchContent(notes: readonly AdvisorNote[]): string {
|
|
44
|
+
return `${ADVISOR_BATCH_PREFIX}\n${notes.map(n => `- ${n.severity ? `[${n.severity}] ` : ""}${n.note}`).join("\n")}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether advice at this severity should interrupt the running agent (delivered
|
|
49
|
+
* via the steering channel, aborting in-flight tools) rather than ride the
|
|
50
|
+
* non-interrupting aside queue that lands at the next step boundary. `concern`
|
|
51
|
+
* and `blocker` interrupt; a plain `nit` queues.
|
|
52
|
+
*/
|
|
53
|
+
export function isInterruptingSeverity(severity: AdvisorSeverity | undefined): boolean {
|
|
54
|
+
return severity === "concern" || severity === "blocker";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Side-effect-free investigation tools handed to the advisor agent so it can
|
|
59
|
+
* inspect the workspace before weighing in. Names match the primary session's
|
|
60
|
+
* tool instances, which the advisor reuses.
|
|
61
|
+
*/
|
|
62
|
+
export const ADVISOR_READONLY_TOOL_NAMES: ReadonlySet<string> = new Set(["read", "search", "find"]);
|
|
63
|
+
|
|
64
|
+
export class AdviseTool implements AgentTool<typeof adviseSchema, AdviseDetails> {
|
|
65
|
+
readonly name = "advise";
|
|
66
|
+
readonly label = "Advise";
|
|
67
|
+
readonly description = adviseDescription;
|
|
68
|
+
readonly parameters = adviseSchema;
|
|
69
|
+
readonly intent = "omit" as const;
|
|
70
|
+
|
|
71
|
+
constructor(private readonly onAdvice: (note: string, severity?: AdviseDetails["severity"]) => void) {}
|
|
72
|
+
|
|
73
|
+
async execute(
|
|
74
|
+
_toolCallId: string,
|
|
75
|
+
args: AdviseParams,
|
|
76
|
+
_signal?: AbortSignal,
|
|
77
|
+
_onUpdate?: AgentToolUpdateCallback<AdviseDetails>,
|
|
78
|
+
_context?: AgentToolContext,
|
|
79
|
+
): Promise<AgentToolResult<AdviseDetails>> {
|
|
80
|
+
this.onAdvice(args.note, args.severity);
|
|
81
|
+
return {
|
|
82
|
+
content: [{ type: "text", text: "Recorded." }],
|
|
83
|
+
details: { note: args.note, severity: args.severity },
|
|
84
|
+
useless: true,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
|
2
|
+
import { estimateTokens } from "@oh-my-pi/pi-agent-core/compaction";
|
|
3
|
+
import { logger } from "@oh-my-pi/pi-utils";
|
|
4
|
+
import { formatSessionHistoryMarkdown } from "../session/session-history-format";
|
|
5
|
+
|
|
6
|
+
/** Minimal slice of `Agent` the runtime drives — satisfied by pi-agent-core `Agent`. */
|
|
7
|
+
export interface AdvisorAgent {
|
|
8
|
+
prompt(input: string): Promise<void>;
|
|
9
|
+
abort(reason?: unknown): void;
|
|
10
|
+
reset(): void;
|
|
11
|
+
readonly state: { messages: AgentMessage[] };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface AdvisorRuntimeHost {
|
|
15
|
+
/** Live primary transcript (use `agent.state.messages`). */
|
|
16
|
+
snapshotMessages(): AgentMessage[];
|
|
17
|
+
/** Surface one advice note to the primary (enqueues into the session YieldQueue). */
|
|
18
|
+
enqueueAdvice(note: string, severity?: "nit" | "concern" | "blocker"): void;
|
|
19
|
+
/**
|
|
20
|
+
* Pre-prompt context maintenance for the advisor's own append-only context.
|
|
21
|
+
* Promotes the advisor model to a larger sibling when its context nears the
|
|
22
|
+
* window (mirroring the primary's promote-first policy) and resolves `true`
|
|
23
|
+
* when the advisor should re-prime — reset and replay the current
|
|
24
|
+
* primary-bounded transcript — because promotion did not free enough room.
|
|
25
|
+
* Optional: hosts that omit it get no maintenance (context only shrinks when
|
|
26
|
+
* the primary's next compaction triggers {@link AdvisorRuntime.reset}).
|
|
27
|
+
*/
|
|
28
|
+
maintainContext?(incomingTokens: number): Promise<boolean>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface PendingDelta {
|
|
32
|
+
text: string;
|
|
33
|
+
turns: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface CatchupWaiter {
|
|
37
|
+
threshold: number;
|
|
38
|
+
resolve: () => void;
|
|
39
|
+
finish: () => void;
|
|
40
|
+
timer?: NodeJS.Timeout;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class AdvisorRuntime {
|
|
44
|
+
#lastCount = 0;
|
|
45
|
+
#pending: PendingDelta[] = [];
|
|
46
|
+
#busy = false;
|
|
47
|
+
#backlog = 0;
|
|
48
|
+
#consecutiveFailures = 0;
|
|
49
|
+
#latestMessages?: AgentMessage[];
|
|
50
|
+
#waiters: CatchupWaiter[] = [];
|
|
51
|
+
disposed = false;
|
|
52
|
+
|
|
53
|
+
constructor(
|
|
54
|
+
private readonly agent: AdvisorAgent,
|
|
55
|
+
private readonly host: AdvisorRuntimeHost,
|
|
56
|
+
private readonly retryDelayMs = 1000,
|
|
57
|
+
) {}
|
|
58
|
+
|
|
59
|
+
get backlog(): number {
|
|
60
|
+
return this.#backlog;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
onTurnEnd(messages?: AgentMessage[]): void {
|
|
64
|
+
if (this.disposed) return;
|
|
65
|
+
const all = messages ?? this.host.snapshotMessages();
|
|
66
|
+
this.#latestMessages = all;
|
|
67
|
+
const render = this.#renderDelta(all);
|
|
68
|
+
if (render) {
|
|
69
|
+
this.#pending.push({ text: render, turns: 1 });
|
|
70
|
+
this.#backlog++;
|
|
71
|
+
this.#notifyWaiters();
|
|
72
|
+
void this.#drain();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
waitForCatchup(maxMs: number, threshold: number, signal?: AbortSignal): Promise<void> {
|
|
77
|
+
if (this.disposed || signal?.aborted || this.#backlog < threshold) return Promise.resolve();
|
|
78
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
79
|
+
let waiter!: CatchupWaiter;
|
|
80
|
+
const finish = (): void => {
|
|
81
|
+
const idx = this.#waiters.indexOf(waiter);
|
|
82
|
+
if (idx >= 0) this.#waiters.splice(idx, 1);
|
|
83
|
+
clearTimeout(waiter.timer);
|
|
84
|
+
signal?.removeEventListener("abort", finish);
|
|
85
|
+
resolve();
|
|
86
|
+
};
|
|
87
|
+
waiter = { threshold, resolve, finish, timer: setTimeout(finish, maxMs) };
|
|
88
|
+
this.#waiters.push(waiter);
|
|
89
|
+
signal?.addEventListener("abort", finish, { once: true });
|
|
90
|
+
if (signal?.aborted) {
|
|
91
|
+
finish();
|
|
92
|
+
}
|
|
93
|
+
return promise;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
dispose(): void {
|
|
97
|
+
this.disposed = true;
|
|
98
|
+
this.#pending = [];
|
|
99
|
+
this.#backlog = 0;
|
|
100
|
+
this.#consecutiveFailures = 0;
|
|
101
|
+
this.#wakeAllWaiters();
|
|
102
|
+
try {
|
|
103
|
+
this.agent.abort("advisor disposed");
|
|
104
|
+
} catch {}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
#resetAdvisorContext(clearBacklog: boolean, wakeWaiters: boolean): void {
|
|
108
|
+
this.#lastCount = 0;
|
|
109
|
+
this.#pending = [];
|
|
110
|
+
this.#consecutiveFailures = 0;
|
|
111
|
+
if (clearBacklog) {
|
|
112
|
+
this.#backlog = 0;
|
|
113
|
+
}
|
|
114
|
+
if (wakeWaiters) {
|
|
115
|
+
this.#wakeAllWaiters();
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
this.agent.reset();
|
|
119
|
+
} catch {}
|
|
120
|
+
try {
|
|
121
|
+
this.agent.abort("advisor reset");
|
|
122
|
+
} catch {}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Re-prime the advisor after a history rewrite (compaction, session
|
|
127
|
+
* switch/resume, branch). Clears the advisor's own (non-persisted) context
|
|
128
|
+
* and rewinds the cursor to 0 so the NEXT turn replays the full current —
|
|
129
|
+
* post-compaction — transcript, giving the advisor fresh context instead of
|
|
130
|
+
* leaving it blind to everything before the rewrite.
|
|
131
|
+
*/
|
|
132
|
+
reset(): void {
|
|
133
|
+
this.#resetAdvisorContext(true, true);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Seed the cursor to the current transcript length when the advisor is enabled
|
|
138
|
+
* mid-session. Prevents the next turn from replaying the entire history to the
|
|
139
|
+
* advisor (which would be expensive and likely stale).
|
|
140
|
+
*/
|
|
141
|
+
seedTo(count: number): void {
|
|
142
|
+
this.#lastCount = count;
|
|
143
|
+
this.#pending = [];
|
|
144
|
+
this.#backlog = 0;
|
|
145
|
+
this.#consecutiveFailures = 0;
|
|
146
|
+
this.#wakeAllWaiters();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
#renderDelta(messages?: AgentMessage[]): string | null {
|
|
150
|
+
const all = messages ?? this.#latestMessages ?? this.host.snapshotMessages();
|
|
151
|
+
if (all.length < this.#lastCount) {
|
|
152
|
+
this.#lastCount = all.length;
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
const delta = all
|
|
156
|
+
.slice(this.#lastCount)
|
|
157
|
+
.filter(m => !(m.role === "custom" && (m as { customType?: string }).customType === "advisor"));
|
|
158
|
+
this.#lastCount = all.length;
|
|
159
|
+
if (delta.length === 0) return null;
|
|
160
|
+
const md = formatSessionHistoryMarkdown(delta, { includeThinking: true, includeToolIntent: true });
|
|
161
|
+
return md.trim() ? md : null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
#notifyWaiters(): void {
|
|
165
|
+
for (let i = this.#waiters.length - 1; i >= 0; i--) {
|
|
166
|
+
const w = this.#waiters[i];
|
|
167
|
+
if (this.#backlog < w.threshold) {
|
|
168
|
+
w.finish();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
#wakeAllWaiters(): void {
|
|
174
|
+
for (const w of [...this.#waiters]) {
|
|
175
|
+
w.finish();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async #drain(): Promise<void> {
|
|
180
|
+
if (this.#busy) return;
|
|
181
|
+
this.#busy = true;
|
|
182
|
+
try {
|
|
183
|
+
while (!this.disposed && this.#pending.length) {
|
|
184
|
+
const popped = this.#pending.splice(0);
|
|
185
|
+
const candidateBatch = popped.map(b => b.text).join("\n\n---\n\n");
|
|
186
|
+
const turnsCovered = popped.reduce((sum, b) => sum + b.turns, 0);
|
|
187
|
+
const incomingTokens = estimateTokens({
|
|
188
|
+
role: "user",
|
|
189
|
+
content: candidateBatch,
|
|
190
|
+
timestamp: Date.now(),
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
let shouldReprime = false;
|
|
194
|
+
if (this.host.maintainContext) {
|
|
195
|
+
try {
|
|
196
|
+
shouldReprime = await this.host.maintainContext(incomingTokens);
|
|
197
|
+
} catch (err) {
|
|
198
|
+
logger.debug("advisor context maintenance failed", { err: String(err) });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
let batch: string | null;
|
|
203
|
+
let finalTurns: number;
|
|
204
|
+
if (shouldReprime) {
|
|
205
|
+
// Promotion could not fit the advisor's context — re-prime.
|
|
206
|
+
const newTurns = this.#pending.reduce((sum, b) => sum + b.turns, 0);
|
|
207
|
+
this.#resetAdvisorContext(false, false);
|
|
208
|
+
batch = this.#renderDelta(this.#latestMessages);
|
|
209
|
+
finalTurns = turnsCovered + newTurns;
|
|
210
|
+
} else {
|
|
211
|
+
batch = candidateBatch;
|
|
212
|
+
finalTurns = turnsCovered;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (this.disposed || batch === null) {
|
|
216
|
+
this.#backlog = Math.max(0, this.#backlog - finalTurns);
|
|
217
|
+
this.#notifyWaiters();
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let success = false;
|
|
222
|
+
try {
|
|
223
|
+
await this.agent.prompt(batch);
|
|
224
|
+
success = true;
|
|
225
|
+
this.#consecutiveFailures = 0;
|
|
226
|
+
} catch (err) {
|
|
227
|
+
logger.debug("advisor turn failed", { err: String(err) });
|
|
228
|
+
this.#consecutiveFailures++;
|
|
229
|
+
if (this.#consecutiveFailures >= 3) {
|
|
230
|
+
logger.warn("advisor failed consecutively 3 times; dropping backlog to prevent stall");
|
|
231
|
+
this.#consecutiveFailures = 0;
|
|
232
|
+
success = true;
|
|
233
|
+
} else {
|
|
234
|
+
this.#pending.unshift({ text: batch, turns: finalTurns });
|
|
235
|
+
await Bun.sleep(this.retryDelayMs);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (success) {
|
|
240
|
+
this.#backlog = Math.max(0, this.#backlog - finalTurns);
|
|
241
|
+
this.#notifyWaiters();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
} finally {
|
|
245
|
+
this.#busy = false;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import * as os from "node:os";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { getAgentDir, isEnoent, logger } from "@oh-my-pi/pi-utils";
|
|
4
|
+
import { expandAtImports } from "../discovery/at-imports";
|
|
5
|
+
import { repo } from "../utils/git";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Discover and load WATCHDOG.md files walking up from cwd, project .omp folder, and user agent dir.
|
|
9
|
+
* Returns formatted watchdog file blocks ready to be appended to the advisor system prompt.
|
|
10
|
+
*/
|
|
11
|
+
export async function discoverWatchdogFiles(cwd: string, agentDir?: string): Promise<string[]> {
|
|
12
|
+
const home = os.homedir();
|
|
13
|
+
const resolvedAgentDir = agentDir ?? getAgentDir();
|
|
14
|
+
const userPath = resolvedAgentDir ? path.resolve(resolvedAgentDir, "WATCHDOG.md") : null;
|
|
15
|
+
let repoRoot: string | null = null;
|
|
16
|
+
try {
|
|
17
|
+
repoRoot = await repo.root(cwd);
|
|
18
|
+
} catch (err) {
|
|
19
|
+
logger.debug("Failed to resolve git root for watchdog discovery", { err: String(err) });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const candidates = new Set<string>();
|
|
23
|
+
|
|
24
|
+
// 1. User level: ~/.omp/WATCHDOG.md (or active profile agent dir)
|
|
25
|
+
if (resolvedAgentDir) {
|
|
26
|
+
candidates.add(path.resolve(resolvedAgentDir, "WATCHDOG.md"));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 2. Project levels (both standalone and native config .omp/): walk up from cwd to repoRoot / home
|
|
30
|
+
let current = cwd;
|
|
31
|
+
while (true) {
|
|
32
|
+
candidates.add(path.resolve(current, ".omp", "WATCHDOG.md"));
|
|
33
|
+
candidates.add(path.resolve(current, "WATCHDOG.md"));
|
|
34
|
+
|
|
35
|
+
if (current === (repoRoot ?? home)) break;
|
|
36
|
+
const parent = path.dirname(current);
|
|
37
|
+
if (parent === current) break;
|
|
38
|
+
current = parent;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const items: Array<{ path: string; content: string; level: "user" | "project"; depth: number }> = [];
|
|
42
|
+
|
|
43
|
+
for (const candidate of candidates) {
|
|
44
|
+
try {
|
|
45
|
+
const content = await Bun.file(candidate).text();
|
|
46
|
+
const expanded = await expandAtImports(content, candidate);
|
|
47
|
+
const parent = path.dirname(candidate);
|
|
48
|
+
const baseName = parent.split(path.sep).pop() ?? "";
|
|
49
|
+
|
|
50
|
+
const isUser = userPath !== null && candidate === userPath;
|
|
51
|
+
const ownerDir = baseName === ".omp" ? path.dirname(parent) : parent;
|
|
52
|
+
const ownerBaseName = ownerDir.split(path.sep).pop() ?? "";
|
|
53
|
+
|
|
54
|
+
if (isUser || !ownerBaseName.startsWith(".") || baseName === ".omp") {
|
|
55
|
+
const relative = path.relative(cwd, ownerDir);
|
|
56
|
+
const depth = relative === "" ? 0 : relative.split(path.sep).filter(Boolean).length;
|
|
57
|
+
items.push({
|
|
58
|
+
path: candidate,
|
|
59
|
+
content: expanded,
|
|
60
|
+
level: isUser ? "user" : "project",
|
|
61
|
+
depth,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (!isEnoent(err)) {
|
|
66
|
+
logger.warn("Failed to read WATCHDOG.md candidate", { path: candidate, error: String(err) });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Sort files so that user level comes first, then project level sorted by depth (descending).
|
|
72
|
+
// This means user-level rules are first, then project-level rules from ancestor directories down to the leaf directory (depth 0 is last/most prominent).
|
|
73
|
+
items.sort((a, b) => {
|
|
74
|
+
if (a.level !== b.level) {
|
|
75
|
+
return a.level === "user" ? -1 : 1;
|
|
76
|
+
}
|
|
77
|
+
return b.depth - a.depth;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
return items.map(item => {
|
|
81
|
+
return `Especially pay attention to:\n<attention>\n${item.content}\n</attention>`;
|
|
82
|
+
});
|
|
83
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -14,6 +14,7 @@ try {
|
|
|
14
14
|
* CLI entry point — registers all commands explicitly and delegates to the
|
|
15
15
|
* lightweight CLI runner from pi-utils.
|
|
16
16
|
*/
|
|
17
|
+
import { parentPort } from "node:worker_threads";
|
|
17
18
|
import type { CliConfig } from "@oh-my-pi/pi-utils/cli";
|
|
18
19
|
import {
|
|
19
20
|
APP_NAME,
|
|
@@ -23,7 +24,7 @@ import {
|
|
|
23
24
|
setProfile,
|
|
24
25
|
VERSION,
|
|
25
26
|
} from "@oh-my-pi/pi-utils/dirs";
|
|
26
|
-
import { declareWorkerHostEntry } from "@oh-my-pi/pi-utils/worker-host";
|
|
27
|
+
import { declareWorkerHostEntry, installWorkerInbox } from "@oh-my-pi/pi-utils/worker-host";
|
|
27
28
|
import { installProfileAlias, resolveProfileAliasCommandFromProcess } from "./cli/profile-alias";
|
|
28
29
|
import { extractProfileFlags } from "./cli/profile-bootstrap";
|
|
29
30
|
|
|
@@ -67,6 +68,7 @@ async function runSmokeTest(): Promise<void> {
|
|
|
67
68
|
const { smokeTestTinyTitleWorker } = await import("./tiny/title-client");
|
|
68
69
|
const { smokeTestSttWorker } = await import("./stt/asr-client");
|
|
69
70
|
const { smokeTestTtsWorker } = await import("./tts/tts-client");
|
|
71
|
+
const { smokeTestJsEvalWorker } = await import("./eval/js/context-manager");
|
|
70
72
|
await smokeTestSyncWorker();
|
|
71
73
|
|
|
72
74
|
const statsServer = await startServer(0);
|
|
@@ -83,18 +85,23 @@ async function runSmokeTest(): Promise<void> {
|
|
|
83
85
|
|
|
84
86
|
await smokeTestTinyTitleWorker();
|
|
85
87
|
await smokeTestSttWorker();
|
|
88
|
+
await smokeTestJsEvalWorker();
|
|
86
89
|
await smokeTestTtsWorker();
|
|
87
90
|
process.stdout.write("smoke-test: ok\n");
|
|
88
91
|
}
|
|
89
92
|
|
|
90
|
-
const
|
|
91
|
-
const STATS_SYNC_WORKER_ARG = "
|
|
92
|
-
const TAB_WORKER_ARG = "
|
|
93
|
-
const JS_EVAL_WORKER_ARG = "
|
|
94
|
-
const STT_WORKER_ARG = "
|
|
95
|
-
const TTS_WORKER_ARG = "
|
|
93
|
+
const TINY_WORKER_ARG = "__omp_worker_tiny_inference";
|
|
94
|
+
const STATS_SYNC_WORKER_ARG = "__omp_worker_stats_sync";
|
|
95
|
+
const TAB_WORKER_ARG = "__omp_worker_tab";
|
|
96
|
+
const JS_EVAL_WORKER_ARG = "__omp_worker_js_eval";
|
|
97
|
+
const STT_WORKER_ARG = "__omp_worker_stt";
|
|
98
|
+
const TTS_WORKER_ARG = "__omp_worker_tts";
|
|
96
99
|
|
|
97
100
|
async function runWorkerEntrypoint(arg: string | undefined): Promise<boolean> {
|
|
101
|
+
if (arg === TINY_WORKER_ARG) {
|
|
102
|
+
await runTinyWorker();
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
98
105
|
if (arg === STATS_SYNC_WORKER_ARG) {
|
|
99
106
|
// The sync worker handles messages via `self.onmessage`, assigned during
|
|
100
107
|
// this *async* dynamic import. Bun flushes the worker's initial message
|
|
@@ -117,11 +124,20 @@ async function runWorkerEntrypoint(arg: string | undefined): Promise<boolean> {
|
|
|
117
124
|
}
|
|
118
125
|
return true;
|
|
119
126
|
}
|
|
127
|
+
// Bun flushes messages the parent posted before spawn once this entry's
|
|
128
|
+
// top-level evaluation completes, delivering them only to listeners present
|
|
129
|
+
// at that moment. These worker modules are imported dynamically below, so
|
|
130
|
+
// their own `parentPort.on("message")` lands after the flush and the parent's
|
|
131
|
+
// synchronous `init` is dropped. Install a buffering inbox synchronously here
|
|
132
|
+
// (still inside the entry's sync prefix) so the handshake survives; the worker
|
|
133
|
+
// module binds the real handler once loaded.
|
|
120
134
|
if (arg === TAB_WORKER_ARG) {
|
|
135
|
+
if (parentPort) installWorkerInbox(parentPort);
|
|
121
136
|
await import("./tools/browser/tab-worker-entry");
|
|
122
137
|
return true;
|
|
123
138
|
}
|
|
124
139
|
if (arg === JS_EVAL_WORKER_ARG) {
|
|
140
|
+
if (parentPort) installWorkerInbox(parentPort);
|
|
125
141
|
await import("./eval/js/worker-entry");
|
|
126
142
|
return true;
|
|
127
143
|
}
|
|
@@ -251,11 +267,8 @@ export async function runCli(argv: string[]): Promise<void> {
|
|
|
251
267
|
// synchronous prefix of `runWorkerEntrypoint`, and Bun flushes the
|
|
252
268
|
// worker's parked initial messages as soon as the entry module's
|
|
253
269
|
// top-level evaluation finishes.
|
|
254
|
-
if (
|
|
255
|
-
await
|
|
256
|
-
return;
|
|
257
|
-
}
|
|
258
|
-
if (await runWorkerEntrypoint(resolvedArgv[0])) {
|
|
270
|
+
if (resolvedArgv[0]?.startsWith("__omp_worker_")) {
|
|
271
|
+
await runWorkerEntrypoint(resolvedArgv[0]);
|
|
259
272
|
return;
|
|
260
273
|
}
|
|
261
274
|
|
|
@@ -59,7 +59,7 @@ import {
|
|
|
59
59
|
resolveCanonicalVariant,
|
|
60
60
|
resolveModelReference,
|
|
61
61
|
} from "@oh-my-pi/pi-catalog/identity";
|
|
62
|
-
import { isRecord, logger } from "@oh-my-pi/pi-utils";
|
|
62
|
+
import { isBunTestRuntime, isRecord, logger } from "@oh-my-pi/pi-utils";
|
|
63
63
|
import { parseModelString, resolveProviderModelReference } from "../config/model-resolver";
|
|
64
64
|
import type { AuthStorage, OAuthCredential } from "../session/auth-storage";
|
|
65
65
|
import { type ApiKeyResolverModel, type ApiKeyResolverOptions, createApiKeyResolver } from "./api-key-resolver";
|
|
@@ -690,7 +690,11 @@ export class ModelRegistry {
|
|
|
690
690
|
modelsPath?: string,
|
|
691
691
|
options?: { fetch?: FetchImpl },
|
|
692
692
|
) {
|
|
693
|
-
this.#fetch =
|
|
693
|
+
this.#fetch =
|
|
694
|
+
options?.fetch ??
|
|
695
|
+
(isBunTestRuntime()
|
|
696
|
+
? () => Promise.reject(new Error("network disabled in model-registry runtime test"))
|
|
697
|
+
: fetch);
|
|
694
698
|
this.#modelsConfigFile = ModelsConfigFile.relocate(modelsPath);
|
|
695
699
|
this.#cacheDbPath = modelsPath ? path.join(path.dirname(modelsPath), "models.db") : undefined;
|
|
696
700
|
// Set up fallback resolver for custom provider API keys
|
|
@@ -5,7 +5,17 @@
|
|
|
5
5
|
import { isValidThemeColor, type ThemeColor } from "../modes/theme/theme";
|
|
6
6
|
import type { Settings } from "./settings";
|
|
7
7
|
|
|
8
|
-
export type ModelRole =
|
|
8
|
+
export type ModelRole =
|
|
9
|
+
| "default"
|
|
10
|
+
| "smol"
|
|
11
|
+
| "slow"
|
|
12
|
+
| "vision"
|
|
13
|
+
| "plan"
|
|
14
|
+
| "designer"
|
|
15
|
+
| "commit"
|
|
16
|
+
| "title"
|
|
17
|
+
| "task"
|
|
18
|
+
| "advisor";
|
|
9
19
|
|
|
10
20
|
export interface ModelRoleInfo {
|
|
11
21
|
tag?: string;
|
|
@@ -25,6 +35,7 @@ export const MODEL_ROLES: Record<ModelRole, ModelRoleInfo> = {
|
|
|
25
35
|
commit: { tag: "COMMIT", name: "Commit", color: "dim" },
|
|
26
36
|
title: { tag: "TITLE", name: "Title", color: "dim", hidden: true },
|
|
27
37
|
task: { tag: "TASK", name: "Subtask", color: "muted" },
|
|
38
|
+
advisor: { tag: "ADVISOR", name: "Advisor", color: "accent" },
|
|
28
39
|
};
|
|
29
40
|
|
|
30
41
|
export const MODEL_ROLE_IDS: ModelRole[] = [
|
|
@@ -37,6 +48,7 @@ export const MODEL_ROLE_IDS: ModelRole[] = [
|
|
|
37
48
|
"commit",
|
|
38
49
|
"title",
|
|
39
50
|
"task",
|
|
51
|
+
"advisor",
|
|
40
52
|
];
|
|
41
53
|
|
|
42
54
|
export type RoleInfo = ModelRoleInfo;
|
|
@@ -106,7 +106,7 @@ export const TAB_METADATA: Record<SettingTab, { label: string; icon: `tab.${stri
|
|
|
106
106
|
*/
|
|
107
107
|
export const TAB_GROUPS: Record<SettingTab, readonly string[]> = {
|
|
108
108
|
appearance: ["Theme", "Status Line", "Display", "Images"],
|
|
109
|
-
model: ["Thinking", "Sampling", "Prompt", "Retry & Fallback"],
|
|
109
|
+
model: ["Thinking", "Sampling", "Prompt", "Retry & Fallback", "Advisor"],
|
|
110
110
|
interaction: [
|
|
111
111
|
"Input",
|
|
112
112
|
"Approvals",
|
|
@@ -116,6 +116,7 @@ export const TAB_GROUPS: Record<SettingTab, readonly string[]> = {
|
|
|
116
116
|
"Magic Keywords",
|
|
117
117
|
"Startup & Updates",
|
|
118
118
|
"Power (macOS)",
|
|
119
|
+
"Agent",
|
|
119
120
|
],
|
|
120
121
|
context: ["General", "Compaction", "Rules (TTSR)", "Experimental"],
|
|
121
122
|
memory: ["General", "Auto-Learn", "Mnemopi", "Hindsight"],
|
|
@@ -380,6 +381,39 @@ export const SETTINGS_SCHEMA = {
|
|
|
380
381
|
description: "Keep the display from idle-sleeping while a session is open (caffeinate -d)",
|
|
381
382
|
},
|
|
382
383
|
},
|
|
384
|
+
"advisor.enabled": {
|
|
385
|
+
type: "boolean",
|
|
386
|
+
default: false,
|
|
387
|
+
ui: {
|
|
388
|
+
tab: "model",
|
|
389
|
+
group: "Advisor",
|
|
390
|
+
label: "Enable Advisor",
|
|
391
|
+
description:
|
|
392
|
+
"Pair a second model (assigned to the 'advisor' role) that passively reviews each turn and injects notes.",
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
"advisor.subagents": {
|
|
396
|
+
type: "boolean",
|
|
397
|
+
default: false,
|
|
398
|
+
ui: {
|
|
399
|
+
tab: "model",
|
|
400
|
+
group: "Advisor",
|
|
401
|
+
label: "Advisor for Subagents",
|
|
402
|
+
description: "Also enable the advisor on spawned task/eval subagents.",
|
|
403
|
+
},
|
|
404
|
+
},
|
|
405
|
+
"advisor.syncBacklog": {
|
|
406
|
+
type: "enum",
|
|
407
|
+
values: ["off", "1", "3", "5"] as const,
|
|
408
|
+
default: "off",
|
|
409
|
+
ui: {
|
|
410
|
+
tab: "model",
|
|
411
|
+
group: "Advisor",
|
|
412
|
+
label: "Advisor Sync Backlog",
|
|
413
|
+
description:
|
|
414
|
+
"Pause the main agent for up to 30 seconds if the advisor falls behind by this many turns. Off disables catch-up delays.",
|
|
415
|
+
},
|
|
416
|
+
},
|
|
383
417
|
shellPath: { type: "string", default: undefined },
|
|
384
418
|
|
|
385
419
|
extensions: { type: "array", default: EMPTY_STRING_ARRAY },
|
|
@@ -1733,14 +1767,16 @@ export const SETTINGS_SCHEMA = {
|
|
|
1733
1767
|
"harmony",
|
|
1734
1768
|
"pi",
|
|
1735
1769
|
"qwen3",
|
|
1770
|
+
"gemini",
|
|
1771
|
+
"gemma",
|
|
1736
1772
|
] as const,
|
|
1737
1773
|
default: "auto",
|
|
1738
1774
|
ui: {
|
|
1739
1775
|
tab: "context",
|
|
1740
1776
|
group: "Experimental",
|
|
1741
|
-
label: "Tool
|
|
1777
|
+
label: "Tool Calling Mode",
|
|
1742
1778
|
description:
|
|
1743
|
-
"Controls how tools are exposed to the model. Auto uses native tool calls unless the selected model is marked as not supporting
|
|
1779
|
+
"Controls how tools are exposed to the model. Auto uses provider-native tool calls unless the selected model is marked as not supporting them, then falls back to the GLM owned dialect. Native forces provider-native tools; the other values force the named owned dialect. Applies on session start.",
|
|
1744
1780
|
options: [
|
|
1745
1781
|
{
|
|
1746
1782
|
value: "auto",
|
|
@@ -1755,8 +1791,10 @@ export const SETTINGS_SCHEMA = {
|
|
|
1755
1791
|
{ value: "anthropic", label: "Anthropic", description: "Use Anthropic-style in-band tool calls." },
|
|
1756
1792
|
{ value: "deepseek", label: "DeepSeek", description: "Use DeepSeek-style in-band tool calls." },
|
|
1757
1793
|
{ value: "harmony", label: "Harmony", description: "Use Harmony-style in-band tool calls." },
|
|
1758
|
-
{ value: "pi", label: "Pi", description: "Use Pi
|
|
1759
|
-
{ value: "qwen3", label: "Qwen3", description: "Use Qwen3
|
|
1794
|
+
{ value: "pi", label: "Pi", description: "Use the Pi owned dialect." },
|
|
1795
|
+
{ value: "qwen3", label: "Qwen3", description: "Use the Qwen3 owned dialect." },
|
|
1796
|
+
{ value: "gemini", label: "Gemini", description: "Use the Gemini owned dialect." },
|
|
1797
|
+
{ value: "gemma", label: "Gemma", description: "Use the Gemma owned dialect." },
|
|
1760
1798
|
],
|
|
1761
1799
|
},
|
|
1762
1800
|
},
|
|
@@ -3993,6 +4031,30 @@ export const SETTINGS_SCHEMA = {
|
|
|
3993
4031
|
options: AUTO_THINKING_MODEL_OPTIONS,
|
|
3994
4032
|
},
|
|
3995
4033
|
},
|
|
4034
|
+
"features.unexpectedStopDetection": {
|
|
4035
|
+
type: "boolean",
|
|
4036
|
+
default: false,
|
|
4037
|
+
ui: {
|
|
4038
|
+
tab: "interaction",
|
|
4039
|
+
group: "Agent",
|
|
4040
|
+
label: "Detect unexpected stops",
|
|
4041
|
+
description:
|
|
4042
|
+
"Use a small model to detect when the assistant says it will continue but stops without tool calls; automatically prompt it to continue.",
|
|
4043
|
+
},
|
|
4044
|
+
},
|
|
4045
|
+
"providers.unexpectedStopModel": {
|
|
4046
|
+
type: "enum",
|
|
4047
|
+
values: TINY_MEMORY_MODEL_VALUES,
|
|
4048
|
+
default: ONLINE_MEMORY_MODEL_KEY,
|
|
4049
|
+
ui: {
|
|
4050
|
+
tab: "providers",
|
|
4051
|
+
group: "Tiny Model",
|
|
4052
|
+
label: "Unexpected Stop Model",
|
|
4053
|
+
description: "Classifier for unexpected-stop detection: online smol by default, or a local on-device model.",
|
|
4054
|
+
condition: "unexpectedStopDetection",
|
|
4055
|
+
options: TINY_MEMORY_MODEL_OPTIONS,
|
|
4056
|
+
},
|
|
4057
|
+
},
|
|
3996
4058
|
|
|
3997
4059
|
"providers.kimiApiFormat": {
|
|
3998
4060
|
type: "enum",
|