@oh-my-pi/pi-coding-agent 16.2.2 → 16.2.4
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 +60 -0
- package/dist/cli.js +3682 -3615
- package/dist/types/advisor/__tests__/config.test.d.ts +1 -0
- package/dist/types/advisor/advise-tool.d.ts +8 -4
- package/dist/types/advisor/config.d.ts +88 -0
- package/dist/types/advisor/index.d.ts +1 -0
- package/dist/types/advisor/transcript-recorder.d.ts +13 -2
- package/dist/types/advisor/watchdog.d.ts +20 -0
- package/dist/types/cli/update-cli.d.ts +15 -0
- package/dist/types/collab/guest.d.ts +29 -0
- package/dist/types/collab/replication-shrink.d.ts +39 -0
- package/dist/types/config/provider-globals.d.ts +7 -0
- package/dist/types/config/settings-schema.d.ts +81 -0
- package/dist/types/debug/log-viewer.d.ts +1 -0
- package/dist/types/debug/raw-sse.d.ts +1 -0
- package/dist/types/edit/hashline/diff.d.ts +0 -11
- package/dist/types/extensibility/tool-event-input.d.ts +7 -0
- package/dist/types/extensibility/utils.d.ts +12 -0
- package/dist/types/mcp/transports/index.d.ts +1 -0
- package/dist/types/mcp/transports/sse.d.ts +20 -0
- package/dist/types/memories/index.d.ts +20 -1
- package/dist/types/modes/components/advisor-config.d.ts +59 -0
- package/dist/types/modes/components/index.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +9 -1
- package/dist/types/modes/components/settings-selector.d.ts +1 -0
- package/dist/types/modes/components/status-line/component.d.ts +35 -1
- package/dist/types/modes/components/status-line/types.d.ts +13 -1
- package/dist/types/modes/controllers/command-controller.d.ts +3 -3
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +11 -4
- package/dist/types/modes/skill-command.d.ts +32 -0
- package/dist/types/modes/types.d.ts +9 -3
- package/dist/types/session/agent-session.d.ts +58 -10
- package/dist/types/session/indexed-session-storage.d.ts +7 -1
- package/dist/types/session/messages.d.ts +26 -0
- package/dist/types/session/messages.test.d.ts +1 -0
- package/dist/types/session/session-entries.d.ts +31 -3
- package/dist/types/session/session-history-format.d.ts +6 -0
- package/dist/types/session/session-loader.d.ts +9 -1
- package/dist/types/session/session-manager.d.ts +8 -7
- package/dist/types/session/session-storage.d.ts +11 -0
- package/dist/types/session/session-title-slot.d.ts +19 -0
- package/dist/types/ssh/connection-manager.d.ts +47 -0
- package/dist/types/ssh/utils.d.ts +16 -0
- package/dist/types/task/executor.d.ts +3 -16
- package/dist/types/task/render.d.ts +0 -5
- package/dist/types/task/renderer.d.ts +13 -0
- package/dist/types/task/types.d.ts +16 -0
- package/dist/types/task/yield-assembly.d.ts +28 -0
- package/dist/types/tiny/text.d.ts +8 -0
- package/dist/types/tools/render-utils.d.ts +2 -0
- package/dist/types/tools/review.d.ts +6 -4
- package/dist/types/tools/ssh.d.ts +1 -1
- package/dist/types/tools/todo.d.ts +6 -0
- package/dist/types/tools/yield.d.ts +8 -3
- package/dist/types/utils/thinking-display.d.ts +4 -0
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +242 -10
- package/src/advisor/__tests__/config.test.ts +173 -0
- package/src/advisor/advise-tool.ts +11 -6
- package/src/advisor/config.ts +256 -0
- package/src/advisor/index.ts +1 -0
- package/src/advisor/runtime.ts +12 -2
- package/src/advisor/transcript-recorder.ts +25 -2
- package/src/advisor/watchdog.ts +57 -31
- package/src/autolearn/controller.ts +13 -22
- package/src/autoresearch/index.ts +7 -2
- package/src/cli/gc-cli.ts +17 -10
- package/src/cli/update-cli.ts +254 -0
- package/src/collab/guest.ts +43 -7
- package/src/collab/host.ts +13 -8
- package/src/collab/replication-shrink.ts +111 -0
- package/src/config/model-registry.ts +80 -18
- package/src/config/provider-globals.ts +25 -0
- package/src/config/settings-schema.ts +77 -0
- package/src/debug/index.ts +32 -7
- package/src/debug/log-viewer.ts +111 -53
- package/src/debug/raw-sse.ts +68 -48
- package/src/discovery/codex.ts +13 -5
- package/src/edit/hashline/diff.ts +57 -4
- package/src/eval/__tests__/julia-prelude.test.ts +2 -2
- package/src/eval/js/shared/local-module-loader.ts +23 -1
- package/src/export/html/template.js +13 -7
- package/src/extensibility/extensions/loader.ts +5 -3
- package/src/extensibility/extensions/wrapper.ts +9 -3
- package/src/extensibility/hooks/loader.ts +3 -3
- package/src/extensibility/hooks/tool-wrapper.ts +13 -4
- package/src/extensibility/plugins/manager.ts +2 -1
- package/src/extensibility/tool-event-input.ts +23 -0
- package/src/extensibility/utils.ts +74 -0
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/mcp/client.ts +3 -1
- package/src/mcp/manager.ts +12 -5
- package/src/mcp/transports/index.ts +1 -0
- package/src/mcp/transports/sse.ts +377 -0
- package/src/memories/index.ts +130 -15
- package/src/memory-backend/local-backend.ts +5 -3
- package/src/modes/components/advisor-config.ts +555 -0
- package/src/modes/components/advisor-message.ts +9 -2
- package/src/modes/components/agent-hub.ts +9 -4
- package/src/modes/components/index.ts +2 -0
- package/src/modes/components/model-selector.ts +79 -48
- package/src/modes/components/settings-selector.ts +1 -0
- package/src/modes/components/status-line/component.ts +150 -5
- package/src/modes/components/status-line/segments.ts +46 -21
- package/src/modes/components/status-line/types.ts +13 -1
- package/src/modes/components/tool-execution.ts +47 -6
- package/src/modes/controllers/command-controller.ts +27 -36
- package/src/modes/controllers/event-controller.ts +113 -1
- package/src/modes/controllers/extension-ui-controller.ts +1 -1
- package/src/modes/controllers/input-controller.ts +61 -61
- package/src/modes/controllers/selector-controller.ts +100 -9
- package/src/modes/interactive-mode.ts +74 -25
- package/src/modes/skill-command.ts +116 -0
- package/src/modes/types.ts +9 -3
- package/src/modes/utils/ui-helpers.ts +41 -23
- package/src/prompts/agents/reviewer.md +11 -10
- package/src/prompts/goals/goal-todo-context.md +12 -0
- package/src/prompts/review-custom-request.md +1 -2
- package/src/prompts/review-request.md +1 -2
- package/src/prompts/system/interrupted-thinking.md +7 -0
- package/src/prompts/system/recap-user.md +9 -0
- package/src/prompts/system/subagent-system-prompt.md +8 -5
- package/src/prompts/system/subagent-yield-reminder.md +6 -5
- package/src/prompts/system/system-prompt.md +0 -1
- package/src/prompts/tools/irc.md +2 -2
- package/src/prompts/tools/read.md +2 -2
- package/src/sdk.ts +28 -24
- package/src/session/agent-session.ts +899 -429
- package/src/session/indexed-session-storage.ts +86 -13
- package/src/session/messages.test.ts +125 -0
- package/src/session/messages.ts +172 -9
- package/src/session/redis-session-storage.ts +49 -2
- package/src/session/session-entries.ts +39 -2
- package/src/session/session-history-format.ts +29 -2
- package/src/session/session-listing.ts +54 -24
- package/src/session/session-loader.ts +66 -3
- package/src/session/session-manager.ts +115 -22
- package/src/session/session-persistence.ts +95 -1
- package/src/session/session-storage.ts +36 -0
- package/src/session/session-title-slot.ts +141 -0
- package/src/session/sql-session-storage.ts +71 -11
- package/src/slash-commands/builtin-registry.ts +23 -24
- package/src/ssh/__tests__/connection-manager-args.test.ts +123 -1
- package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +55 -18
- package/src/ssh/connection-manager.ts +139 -12
- package/src/ssh/file-transfer.ts +23 -18
- package/src/ssh/ssh-executor.ts +2 -13
- package/src/ssh/utils.ts +19 -0
- package/src/task/executor.ts +21 -23
- package/src/task/render.ts +162 -20
- package/src/task/renderer.ts +14 -0
- package/src/task/types.ts +17 -0
- package/src/task/yield-assembly.ts +207 -0
- package/src/tiny/text.ts +23 -0
- package/src/tools/ask.ts +55 -4
- package/src/tools/render-utils.ts +2 -0
- package/src/tools/renderers.ts +8 -2
- package/src/tools/review.ts +17 -7
- package/src/tools/ssh.ts +8 -4
- package/src/tools/todo.ts +17 -1
- package/src/tools/yield.ts +140 -31
- package/src/utils/thinking-display.ts +15 -0
- package/src/prompts/system/autolearn-nudge.md +0 -5
|
@@ -1,25 +1,33 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "bun:test";
|
|
2
2
|
import type { AgentMessage, AgentTelemetryConfig } from "@oh-my-pi/pi-agent-core";
|
|
3
|
+
import type { TUI } from "@oh-my-pi/pi-tui";
|
|
3
4
|
import { type } from "arktype";
|
|
5
|
+
import type { ModelRegistry } from "../../config/model-registry";
|
|
6
|
+
import type { Settings } from "../../config/settings";
|
|
7
|
+
import { type AdvisorConfigDeps, AdvisorConfigOverlayComponent } from "../../modes/components/advisor-config";
|
|
4
8
|
import { createAdvisorMessageCard } from "../../modes/components/advisor-message";
|
|
5
|
-
import { getThemeByName } from "../../modes/theme/theme";
|
|
9
|
+
import { getThemeByName, setThemeInstance } from "../../modes/theme/theme";
|
|
6
10
|
import advisorSystemPrompt from "../../prompts/advisor/system.md" with { type: "text" };
|
|
7
11
|
import { SecretObfuscator } from "../../secrets/obfuscator";
|
|
8
12
|
import { formatSessionHistoryMarkdown } from "../../session/session-history-format";
|
|
9
13
|
import { YieldQueue } from "../../session/yield-queue";
|
|
14
|
+
import { BUILTIN_TOOL_NAMES } from "../../tools/builtin-names";
|
|
10
15
|
import {
|
|
11
|
-
|
|
16
|
+
ADVISOR_DEFAULT_TOOL_NAMES,
|
|
12
17
|
AdviseTool,
|
|
13
18
|
type AdvisorAgent,
|
|
14
19
|
type AdvisorNote,
|
|
15
20
|
AdvisorRuntime,
|
|
16
21
|
type AdvisorRuntimeHost,
|
|
22
|
+
advisorTranscriptFilename,
|
|
17
23
|
deriveAdvisorTelemetry,
|
|
18
24
|
formatAdvisorBatchContent,
|
|
19
25
|
formatAdvisorContextPrompt,
|
|
20
26
|
isAdvisorInterruptImmuneTurnActive,
|
|
27
|
+
isAdvisorTranscriptName,
|
|
21
28
|
isInterruptingSeverity,
|
|
22
29
|
resolveAdvisorDeliveryChannel,
|
|
30
|
+
type WatchdogConfigDoc,
|
|
23
31
|
} from "..";
|
|
24
32
|
|
|
25
33
|
describe("advisor", () => {
|
|
@@ -158,6 +166,57 @@ describe("advisor", () => {
|
|
|
158
166
|
});
|
|
159
167
|
});
|
|
160
168
|
|
|
169
|
+
describe("formatSessionHistoryMarkdown expandEditDiffs", () => {
|
|
170
|
+
const diff = "--- a/foo.ts\n+++ b/foo.ts\n@@ -1,2 +1,2 @@\n-const x = 1;\n+const x = 2;";
|
|
171
|
+
const editCall = {
|
|
172
|
+
role: "assistant",
|
|
173
|
+
content: [{ type: "toolCall", id: "c1", name: "edit", arguments: { path: "foo.ts" } }],
|
|
174
|
+
timestamp: 1,
|
|
175
|
+
} as unknown as AgentMessage;
|
|
176
|
+
const editResult = {
|
|
177
|
+
role: "toolResult",
|
|
178
|
+
toolCallId: "c1",
|
|
179
|
+
toolName: "edit",
|
|
180
|
+
content: "ok",
|
|
181
|
+
details: { diff },
|
|
182
|
+
timestamp: 2,
|
|
183
|
+
} as unknown as AgentMessage;
|
|
184
|
+
|
|
185
|
+
it("appends the full diff in a fenced block when expandEditDiffs is set", () => {
|
|
186
|
+
const md = formatSessionHistoryMarkdown([editCall, editResult], {
|
|
187
|
+
expandEditDiffs: true,
|
|
188
|
+
watchedRoles: true,
|
|
189
|
+
});
|
|
190
|
+
expect(md).toContain("```diff");
|
|
191
|
+
expect(md).toContain("-const x = 1;");
|
|
192
|
+
expect(md).toContain("+const x = 2;");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("omits the diff body without the flag", () => {
|
|
196
|
+
const md = formatSessionHistoryMarkdown([editCall, editResult], { watchedRoles: true });
|
|
197
|
+
expect(md).not.toContain("```diff");
|
|
198
|
+
expect(md).not.toContain("+const x = 2;");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("widens the fence past backtick runs in the diff body", () => {
|
|
202
|
+
const fenced = "--- a/readme.md\n+++ b/readme.md\n@@ -1 +1 @@\n-```\n+```ts\n+code\n+```";
|
|
203
|
+
const result = {
|
|
204
|
+
role: "toolResult",
|
|
205
|
+
toolCallId: "c1",
|
|
206
|
+
toolName: "edit",
|
|
207
|
+
content: "ok",
|
|
208
|
+
details: { diff: fenced },
|
|
209
|
+
timestamp: 2,
|
|
210
|
+
} as unknown as AgentMessage;
|
|
211
|
+
const md = formatSessionHistoryMarkdown([editCall, result], {
|
|
212
|
+
expandEditDiffs: true,
|
|
213
|
+
watchedRoles: true,
|
|
214
|
+
});
|
|
215
|
+
// The body contains a ``` run, so the wrapping fence widens to 4 backticks.
|
|
216
|
+
expect(md).toContain("````diff");
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
161
220
|
describe("advisor yield-queue dispatcher", () => {
|
|
162
221
|
it("batches advice notes into one custom message", async () => {
|
|
163
222
|
const injected: AgentMessage[] = [];
|
|
@@ -373,6 +432,18 @@ describe("advisor", () => {
|
|
|
373
432
|
// Exactly one severity attribute (only the blocker note carries one).
|
|
374
433
|
expect(content.split('severity="').length - 1).toBe(1);
|
|
375
434
|
});
|
|
435
|
+
|
|
436
|
+
it("emits an advisor attribute only for named advisors, escaping the name", () => {
|
|
437
|
+
const content = formatAdvisorBatchContent([
|
|
438
|
+
{ note: "named note", advisor: 'Arch "X"' },
|
|
439
|
+
{ note: "default note" },
|
|
440
|
+
]);
|
|
441
|
+
// Named advisor: attribute present, double quote escaped for attribute context.
|
|
442
|
+
expect(content).toContain('advisor="Arch "X""');
|
|
443
|
+
// A note with no source (the legacy/default advisor) carries no advisor attribute.
|
|
444
|
+
expect(content.split('advisor="').length - 1).toBe(1);
|
|
445
|
+
expect(content).toContain("default note");
|
|
446
|
+
});
|
|
376
447
|
});
|
|
377
448
|
|
|
378
449
|
describe("deriveAdvisorTelemetry", () => {
|
|
@@ -662,6 +733,46 @@ describe("advisor", () => {
|
|
|
662
733
|
expect(promptInputs[0]).not.toContain(secret);
|
|
663
734
|
});
|
|
664
735
|
|
|
736
|
+
it("surfaces edit diff details but redacts secrets inside the diff", async () => {
|
|
737
|
+
const secret = "DIFF_SECRET_TOKEN_123";
|
|
738
|
+
const obfuscator = new SecretObfuscator([{ type: "plain", content: secret }]);
|
|
739
|
+
const placeholder = obfuscator.obfuscate(secret);
|
|
740
|
+
const promptInputs: string[] = [];
|
|
741
|
+
const agent = makeAgent(promptInputs);
|
|
742
|
+
const diff = `--- a/config.ts\n+++ b/config.ts\n@@ -1 +1 @@\n-const token = "old";\n+const token = "${secret}";`;
|
|
743
|
+
const messages: AgentMessage[] = [
|
|
744
|
+
{
|
|
745
|
+
role: "assistant",
|
|
746
|
+
content: [{ type: "toolCall", id: "c1", name: "edit", arguments: { path: "config.ts" } }],
|
|
747
|
+
timestamp: 1,
|
|
748
|
+
} as unknown as AgentMessage,
|
|
749
|
+
{
|
|
750
|
+
role: "toolResult",
|
|
751
|
+
toolCallId: "c1",
|
|
752
|
+
toolName: "edit",
|
|
753
|
+
content: "ok",
|
|
754
|
+
details: { diff },
|
|
755
|
+
timestamp: 2,
|
|
756
|
+
} as unknown as AgentMessage,
|
|
757
|
+
];
|
|
758
|
+
const host: AdvisorRuntimeHost = {
|
|
759
|
+
snapshotMessages: () => messages,
|
|
760
|
+
enqueueAdvice: () => {},
|
|
761
|
+
obfuscator,
|
|
762
|
+
};
|
|
763
|
+
const runtime = new AdvisorRuntime(agent, host);
|
|
764
|
+
|
|
765
|
+
runtime.onTurnEnd();
|
|
766
|
+
await Promise.resolve();
|
|
767
|
+
|
|
768
|
+
expect(promptInputs).toHaveLength(1);
|
|
769
|
+
// The diff is surfaced to the advisor (expandEditDiffs) ...
|
|
770
|
+
expect(promptInputs[0]).toContain("+const token =");
|
|
771
|
+
// ... but a secret living inside details.diff is obfuscated (details now walked).
|
|
772
|
+
expect(promptInputs[0]).toContain(placeholder);
|
|
773
|
+
expect(promptInputs[0]).not.toContain(secret);
|
|
774
|
+
});
|
|
775
|
+
|
|
665
776
|
it("expands plan-mode context once, then collapses an unchanged re-injection", async () => {
|
|
666
777
|
const promptInputs: string[] = [];
|
|
667
778
|
const agent = makeAgent(promptInputs);
|
|
@@ -1259,14 +1370,18 @@ describe("advisor", () => {
|
|
|
1259
1370
|
});
|
|
1260
1371
|
});
|
|
1261
1372
|
|
|
1262
|
-
describe("
|
|
1263
|
-
it("
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1373
|
+
describe("advisor default tools", () => {
|
|
1374
|
+
it("defaults to read/grep/glob, a subset of the full grantable tool pool", () => {
|
|
1375
|
+
expect([...ADVISOR_DEFAULT_TOOL_NAMES]).toEqual(["read", "grep", "glob"]);
|
|
1376
|
+
// The advisor is a full agent now: every built tool is grantable (no hard
|
|
1377
|
+
// read-only restriction), including mutating ones like edit/bash/write.
|
|
1378
|
+
const builtin = new Set<string>(BUILTIN_TOOL_NAMES);
|
|
1379
|
+
for (const name of ["read", "grep", "glob", "edit", "bash", "write"]) {
|
|
1380
|
+
expect(builtin.has(name)).toBe(true);
|
|
1381
|
+
}
|
|
1382
|
+
for (const name of ADVISOR_DEFAULT_TOOL_NAMES) {
|
|
1383
|
+
expect(builtin.has(name)).toBe(true);
|
|
1384
|
+
}
|
|
1270
1385
|
});
|
|
1271
1386
|
});
|
|
1272
1387
|
|
|
@@ -1289,6 +1404,26 @@ describe("advisor", () => {
|
|
|
1289
1404
|
expect(text).toContain("watch the empty case");
|
|
1290
1405
|
});
|
|
1291
1406
|
|
|
1407
|
+
it("prefixes the note with a named-advisor label, but not for the default advisor", async () => {
|
|
1408
|
+
const uiTheme = await getThemeByName("dark");
|
|
1409
|
+
if (!uiTheme) throw new Error("theme unavailable");
|
|
1410
|
+
const card = createAdvisorMessageCard(
|
|
1411
|
+
{
|
|
1412
|
+
notes: [
|
|
1413
|
+
{ note: "module boundary leak", severity: "concern", advisor: "Architecture" },
|
|
1414
|
+
{ note: "default-advisor note", advisor: "default" },
|
|
1415
|
+
],
|
|
1416
|
+
},
|
|
1417
|
+
() => true,
|
|
1418
|
+
uiTheme,
|
|
1419
|
+
);
|
|
1420
|
+
const text = strip(card.render(80));
|
|
1421
|
+
expect(text).toContain("[Architecture]");
|
|
1422
|
+
expect(text).toContain("module boundary leak");
|
|
1423
|
+
// The implicit "default" advisor stays unlabeled.
|
|
1424
|
+
expect(text).not.toContain("[default]");
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1292
1427
|
it("collapses to the first notes with an overflow hint", async () => {
|
|
1293
1428
|
const uiTheme = await getThemeByName("dark");
|
|
1294
1429
|
if (!uiTheme) throw new Error("theme unavailable");
|
|
@@ -1427,4 +1562,101 @@ describe("advisor", () => {
|
|
|
1427
1562
|
}
|
|
1428
1563
|
});
|
|
1429
1564
|
});
|
|
1565
|
+
describe("advisor transcript filenames", () => {
|
|
1566
|
+
it("derives default and named transcript filenames", () => {
|
|
1567
|
+
expect(advisorTranscriptFilename("")).toBe("__advisor.jsonl");
|
|
1568
|
+
expect(advisorTranscriptFilename("arch")).toBe("__advisor.arch.jsonl");
|
|
1569
|
+
});
|
|
1570
|
+
|
|
1571
|
+
it("recognizes default and named advisor transcripts, and nothing else", () => {
|
|
1572
|
+
expect(isAdvisorTranscriptName("__advisor.jsonl")).toBe(true);
|
|
1573
|
+
expect(isAdvisorTranscriptName("__advisor.arch.jsonl")).toBe(true);
|
|
1574
|
+
expect(isAdvisorTranscriptName("__advisor-2.jsonl")).toBe(false);
|
|
1575
|
+
expect(isAdvisorTranscriptName("Foo.jsonl")).toBe(false);
|
|
1576
|
+
expect(isAdvisorTranscriptName("__advisor.arch.bak")).toBe(false);
|
|
1577
|
+
});
|
|
1578
|
+
});
|
|
1579
|
+
|
|
1580
|
+
describe("AdvisorConfigOverlayComponent", () => {
|
|
1581
|
+
const deps = {
|
|
1582
|
+
modelRegistry: { getCanonicalModelSelections: () => [] } as unknown as ModelRegistry,
|
|
1583
|
+
settings: {} as unknown as Settings,
|
|
1584
|
+
scopedModels: [],
|
|
1585
|
+
availableToolNames: ["read", "grep", "glob", "lsp", "web_search"],
|
|
1586
|
+
};
|
|
1587
|
+
const callbacks = {
|
|
1588
|
+
loadDoc: async () => ({ advisors: [] }),
|
|
1589
|
+
save: async () => {},
|
|
1590
|
+
close: () => {},
|
|
1591
|
+
requestRender: () => {},
|
|
1592
|
+
notify: () => {},
|
|
1593
|
+
};
|
|
1594
|
+
const strip = (lines: readonly string[]): string => lines.join("\n").replace(/\x1b\[[0-9;]*m/g, "");
|
|
1595
|
+
const make = (doc: WatchdogConfigDoc, extra?: Partial<AdvisorConfigDeps>): AdvisorConfigOverlayComponent =>
|
|
1596
|
+
new AdvisorConfigOverlayComponent({} as unknown as TUI, { ...deps, ...extra }, "project", doc, callbacks);
|
|
1597
|
+
const fullHeight = Math.max(14, process.stdout.rows || 40);
|
|
1598
|
+
|
|
1599
|
+
it("paints a full-screen split frame: roster sidebar + selected-advisor preview", async () => {
|
|
1600
|
+
const uiTheme = await getThemeByName("dark");
|
|
1601
|
+
if (!uiTheme) throw new Error("theme unavailable");
|
|
1602
|
+
setThemeInstance(uiTheme);
|
|
1603
|
+
const overlay = make({
|
|
1604
|
+
instructions: "shared baseline",
|
|
1605
|
+
advisors: [
|
|
1606
|
+
{ name: "Architecture", model: "x-ai/grok-code-fast:high" },
|
|
1607
|
+
{ name: "Security", tools: ["read", "web_search"] },
|
|
1608
|
+
],
|
|
1609
|
+
});
|
|
1610
|
+
const frame = overlay.render(200);
|
|
1611
|
+
// Fills the screen top-to-bottom (the fix for the bottom-anchored frame
|
|
1612
|
+
// whose offset broke mouse hit-testing and wasted the upper space).
|
|
1613
|
+
expect(frame.length).toBe(fullHeight);
|
|
1614
|
+
const text = strip(frame);
|
|
1615
|
+
expect(text).toContain("Advisor configuration");
|
|
1616
|
+
expect(text).toContain("project");
|
|
1617
|
+
expect(text).toContain("Architecture");
|
|
1618
|
+
expect(text).toContain("Security");
|
|
1619
|
+
expect(text).toContain("+ Add advisor");
|
|
1620
|
+
expect(text).toContain("Save & apply");
|
|
1621
|
+
// Right preview reflects the highlighted (first) advisor.
|
|
1622
|
+
expect(text).toContain("x-ai/grok-code-fast:high");
|
|
1623
|
+
expect(text).toContain("read, grep, glob (default)");
|
|
1624
|
+
});
|
|
1625
|
+
|
|
1626
|
+
it("moves the preview with keyboard selection and preserves an explicit tool set", async () => {
|
|
1627
|
+
const uiTheme = await getThemeByName("dark");
|
|
1628
|
+
if (!uiTheme) throw new Error("theme unavailable");
|
|
1629
|
+
setThemeInstance(uiTheme);
|
|
1630
|
+
const overlay = make({
|
|
1631
|
+
advisors: [{ name: "Architecture" }, { name: "Security", tools: ["read", "web_search"] }],
|
|
1632
|
+
});
|
|
1633
|
+
overlay.render(200);
|
|
1634
|
+
overlay.handleInput("\x1b[B"); // arrow down → highlight Security
|
|
1635
|
+
expect(strip(overlay.render(200))).toContain("read, web_search");
|
|
1636
|
+
});
|
|
1637
|
+
|
|
1638
|
+
it("opens an advisor's detail editor on a left click in the sidebar", async () => {
|
|
1639
|
+
const uiTheme = await getThemeByName("dark");
|
|
1640
|
+
if (!uiTheme) throw new Error("theme unavailable");
|
|
1641
|
+
setThemeInstance(uiTheme);
|
|
1642
|
+
const overlay = make({ advisors: [{ name: "Architecture" }, { name: "Security" }] });
|
|
1643
|
+
// Render once so the frame geometry is recorded; the first advisor sits on
|
|
1644
|
+
// the first body row (0-based screen row 1 → SGR 1-based row 2).
|
|
1645
|
+
overlay.render(120);
|
|
1646
|
+
overlay.handleInput("\x1b[<0;4;2M"); // left-button press, col 4, row 2
|
|
1647
|
+
const text = strip(overlay.render(120));
|
|
1648
|
+
expect(text).toContain("Editing");
|
|
1649
|
+
expect(text).toContain("Architecture");
|
|
1650
|
+
});
|
|
1651
|
+
|
|
1652
|
+
it("seeds a visible default advisor (labeled with the role model) when the config is empty", async () => {
|
|
1653
|
+
const uiTheme = await getThemeByName("dark");
|
|
1654
|
+
if (!uiTheme) throw new Error("theme unavailable");
|
|
1655
|
+
setThemeInstance(uiTheme);
|
|
1656
|
+
const overlay = make({ advisors: [] }, { defaultModelLabel: "anthropic/claude-opus" });
|
|
1657
|
+
const text = strip(overlay.render(200));
|
|
1658
|
+
expect(text).toContain("default");
|
|
1659
|
+
expect(text).toContain("anthropic/claude-opus");
|
|
1660
|
+
});
|
|
1661
|
+
});
|
|
1430
1662
|
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import * as fsp from "node:fs/promises";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
advisorConfigFilePath,
|
|
7
|
+
discoverAdvisorConfigs,
|
|
8
|
+
loadWatchdogConfigFile,
|
|
9
|
+
resolveAdvisorConfigEditPath,
|
|
10
|
+
saveWatchdogConfigFile,
|
|
11
|
+
serializeWatchdogConfig,
|
|
12
|
+
slugifyAdvisorName,
|
|
13
|
+
type WatchdogConfigDoc,
|
|
14
|
+
} from "../config";
|
|
15
|
+
|
|
16
|
+
describe("discoverAdvisorConfigs", () => {
|
|
17
|
+
let tmp: string;
|
|
18
|
+
let agentDir: string;
|
|
19
|
+
|
|
20
|
+
beforeEach(async () => {
|
|
21
|
+
tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-advisor-config-"));
|
|
22
|
+
// Empty agent dir so the user-level search path can't pick up a real ~/.omp/WATCHDOG.yml.
|
|
23
|
+
agentDir = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-advisor-agentdir-"));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(async () => {
|
|
27
|
+
await fsp.rm(tmp, { recursive: true, force: true });
|
|
28
|
+
await fsp.rm(agentDir, { recursive: true, force: true });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("parses advisors, the model thinking suffix, tool filtering, and shared instructions", async () => {
|
|
32
|
+
const yaml = [
|
|
33
|
+
"instructions: Shared baseline for all advisors.",
|
|
34
|
+
"advisors:",
|
|
35
|
+
" - name: Architecture",
|
|
36
|
+
" model: x-ai/grok-code-fast:high",
|
|
37
|
+
" instructions: Watch module boundaries.",
|
|
38
|
+
" - name: Security Reviewer",
|
|
39
|
+
" tools: [read, definitely-not-a-tool]",
|
|
40
|
+
].join("\n");
|
|
41
|
+
await Bun.write(path.join(tmp, "WATCHDOG.yml"), yaml);
|
|
42
|
+
|
|
43
|
+
const { advisors, sharedInstructions } = await discoverAdvisorConfigs(tmp, agentDir);
|
|
44
|
+
expect(advisors).toHaveLength(2);
|
|
45
|
+
const [arch, sec] = advisors;
|
|
46
|
+
expect(arch.name).toBe("Architecture");
|
|
47
|
+
// The model selector (incl. the `:high` thinking suffix) is stored verbatim;
|
|
48
|
+
// resolution happens later in the session, not here.
|
|
49
|
+
expect(arch.model).toBe("x-ai/grok-code-fast:high");
|
|
50
|
+
expect(arch.instructions).toBe("Watch module boundaries.");
|
|
51
|
+
expect(sec.name).toBe("Security Reviewer");
|
|
52
|
+
expect(sec.model).toBeUndefined();
|
|
53
|
+
// The unknown/non-read-only tool is dropped; only `read` survives.
|
|
54
|
+
expect(sec.tools).toEqual(["read"]);
|
|
55
|
+
expect(sharedInstructions).toBe("Shared baseline for all advisors.");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("ignores a malformed YAML file without throwing", async () => {
|
|
59
|
+
await Bun.write(path.join(tmp, "WATCHDOG.yml"), "advisors: [unclosed bracket");
|
|
60
|
+
const result = await discoverAdvisorConfigs(tmp, agentDir);
|
|
61
|
+
expect(result.advisors).toEqual([]);
|
|
62
|
+
expect(result.sharedInstructions).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("skips a file whose shape fails the schema (advisors must be a list)", async () => {
|
|
66
|
+
await Bun.write(path.join(tmp, "WATCHDOG.yml"), "advisors: not-an-array");
|
|
67
|
+
const result = await discoverAdvisorConfigs(tmp, agentDir);
|
|
68
|
+
expect(result.advisors).toEqual([]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("returns an empty roster when no config file exists", async () => {
|
|
72
|
+
const result = await discoverAdvisorConfigs(tmp, agentDir);
|
|
73
|
+
expect(result.advisors).toEqual([]);
|
|
74
|
+
expect(result.sharedInstructions).toBeUndefined();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("slugifyAdvisorName", () => {
|
|
79
|
+
it("lowercases and collapses non-alphanumeric runs to single hyphens", () => {
|
|
80
|
+
expect(slugifyAdvisorName("Security Reviewer")).toBe("security-reviewer");
|
|
81
|
+
expect(slugifyAdvisorName(" Arch/Boundaries! ")).toBe("arch-boundaries");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("falls back to 'advisor' when nothing alphanumeric survives", () => {
|
|
85
|
+
expect(slugifyAdvisorName("!!!")).toBe("advisor");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("WATCHDOG.yml file round-trip", () => {
|
|
90
|
+
let tmp: string;
|
|
91
|
+
beforeEach(async () => {
|
|
92
|
+
tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-advisor-file-"));
|
|
93
|
+
});
|
|
94
|
+
afterEach(async () => {
|
|
95
|
+
await fsp.rm(tmp, { recursive: true, force: true });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const doc: WatchdogConfigDoc = {
|
|
99
|
+
instructions: 'Shared baseline.\nSecond line with: a colon and "quotes".',
|
|
100
|
+
advisors: [
|
|
101
|
+
{ name: "Architecture", model: "x-ai/grok-code-fast:high", instructions: "Watch module boundaries." },
|
|
102
|
+
{ name: "Security", tools: ["read", "grep"] },
|
|
103
|
+
],
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
it("saves and reloads a doc byte-equivalently (incl. multiline and special chars)", async () => {
|
|
107
|
+
const file = path.join(tmp, "WATCHDOG.yml");
|
|
108
|
+
await saveWatchdogConfigFile(file, doc);
|
|
109
|
+
const loaded = await loadWatchdogConfigFile(file);
|
|
110
|
+
expect(loaded).toEqual(doc);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("serializes block-style YAML that the discovery path also parses", async () => {
|
|
114
|
+
const file = path.join(tmp, "WATCHDOG.yml");
|
|
115
|
+
await saveWatchdogConfigFile(file, doc);
|
|
116
|
+
const text = await Bun.file(file).text();
|
|
117
|
+
// Block style (not the flow `{...}` form), so it stays hand-editable.
|
|
118
|
+
expect(text).toContain("advisors:");
|
|
119
|
+
expect(text).not.toMatch(/^\{/);
|
|
120
|
+
const { advisors, sharedInstructions } = await discoverAdvisorConfigs(tmp, tmp);
|
|
121
|
+
expect(advisors.map(a => a.name)).toEqual(["Architecture", "Security"]);
|
|
122
|
+
expect(sharedInstructions).toContain("Shared baseline.");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("removes the file when the doc is empty so legacy discovery resumes", async () => {
|
|
126
|
+
const file = path.join(tmp, "WATCHDOG.yml");
|
|
127
|
+
await saveWatchdogConfigFile(file, doc);
|
|
128
|
+
await saveWatchdogConfigFile(file, { advisors: [] });
|
|
129
|
+
expect(await Bun.file(file).exists()).toBe(false);
|
|
130
|
+
// Loading a missing file yields an empty doc, never throws.
|
|
131
|
+
expect(await loadWatchdogConfigFile(file)).toEqual({ advisors: [] });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("returns an empty serialization for an empty doc", () => {
|
|
135
|
+
expect(serializeWatchdogConfig({ advisors: [] })).toBe("");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("resolves project and user scope paths", () => {
|
|
139
|
+
expect(advisorConfigFilePath("project", { projectDir: "/repo", agentDir: "/home/.omp" })).toBe(
|
|
140
|
+
path.join("/repo", "WATCHDOG.yml"),
|
|
141
|
+
);
|
|
142
|
+
expect(advisorConfigFilePath("user", { projectDir: "/repo", agentDir: "/home/.omp" })).toBe(
|
|
143
|
+
path.join("/home/.omp", "WATCHDOG.yml"),
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("resolveAdvisorConfigEditPath", () => {
|
|
149
|
+
let tmp: string;
|
|
150
|
+
beforeEach(async () => {
|
|
151
|
+
tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-advisor-resolve-"));
|
|
152
|
+
});
|
|
153
|
+
afterEach(async () => {
|
|
154
|
+
await fsp.rm(tmp, { recursive: true, force: true });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const dirs = (d: string) => ({ projectDir: d, agentDir: d });
|
|
158
|
+
|
|
159
|
+
it("defaults to .yml when neither file exists", async () => {
|
|
160
|
+
expect(await resolveAdvisorConfigEditPath("project", dirs(tmp))).toBe(path.join(tmp, "WATCHDOG.yml"));
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("edits an existing .yaml in place when only it exists", async () => {
|
|
164
|
+
await Bun.write(path.join(tmp, "WATCHDOG.yaml"), "advisors: []\n");
|
|
165
|
+
expect(await resolveAdvisorConfigEditPath("project", dirs(tmp))).toBe(path.join(tmp, "WATCHDOG.yaml"));
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("prefers the canonical .yml when both exist", async () => {
|
|
169
|
+
await Bun.write(path.join(tmp, "WATCHDOG.yml"), "advisors: []\n");
|
|
170
|
+
await Bun.write(path.join(tmp, "WATCHDOG.yaml"), "advisors: []\n");
|
|
171
|
+
expect(await resolveAdvisorConfigEditPath("project", dirs(tmp))).toBe(path.join(tmp, "WATCHDOG.yml"));
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -6,7 +6,7 @@ import type {
|
|
|
6
6
|
AgentToolResult,
|
|
7
7
|
AgentToolUpdateCallback,
|
|
8
8
|
} from "@oh-my-pi/pi-agent-core";
|
|
9
|
-
import { escapeXmlText } from "@oh-my-pi/pi-utils";
|
|
9
|
+
import { escapeXmlAttribute, escapeXmlText } from "@oh-my-pi/pi-utils";
|
|
10
10
|
import { type } from "arktype";
|
|
11
11
|
import adviseDescription from "../prompts/advisor/advise-tool.md" with { type: "text" };
|
|
12
12
|
|
|
@@ -24,12 +24,16 @@ export type AdvisorSeverity = "nit" | "concern" | "blocker";
|
|
|
24
24
|
export interface AdviseDetails {
|
|
25
25
|
note: string;
|
|
26
26
|
severity?: AdvisorSeverity;
|
|
27
|
+
/** Which configured advisor produced this note (omitted for the default advisor). */
|
|
28
|
+
advisor?: string;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
/** One queued advice note. */
|
|
30
32
|
export interface AdvisorNote {
|
|
31
33
|
note: string;
|
|
32
34
|
severity?: AdvisorSeverity;
|
|
35
|
+
/** Which configured advisor produced this note (omitted for the default advisor). */
|
|
36
|
+
advisor?: string;
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
/** Details payload on the batched `advisor` custom message rendered in the transcript. */
|
|
@@ -55,7 +59,8 @@ export function formatAdvisorBatchContent(notes: readonly AdvisorNote[]): string
|
|
|
55
59
|
return notes
|
|
56
60
|
.map(n => {
|
|
57
61
|
const severity = n.severity ? ` severity="${n.severity}"` : "";
|
|
58
|
-
|
|
62
|
+
const who = n.advisor ? ` advisor="${escapeXmlAttribute(n.advisor)}"` : "";
|
|
63
|
+
return `<advisory${who}${severity} guidance="${ADVISOR_GUIDANCE}">\n${escapeXmlText(n.note)}\n</advisory>`;
|
|
59
64
|
})
|
|
60
65
|
.join("\n");
|
|
61
66
|
}
|
|
@@ -133,11 +138,11 @@ export function deriveAdvisorTelemetry(
|
|
|
133
138
|
}
|
|
134
139
|
|
|
135
140
|
/**
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
141
|
+
* The tools an advisor receives by default when its config omits `tools` — the
|
|
142
|
+
* read-only investigative set. The full available pool is every built tool the
|
|
143
|
+
* session has (the advisor is a full agent); a config's `tools` selects from it.
|
|
139
144
|
*/
|
|
140
|
-
export const
|
|
145
|
+
export const ADVISOR_DEFAULT_TOOL_NAMES: ReadonlySet<string> = new Set(["read", "grep", "glob"]);
|
|
141
146
|
|
|
142
147
|
function advisorNoteDedupeKey(note: string): string {
|
|
143
148
|
return note.trim().replace(/\s+/g, " ");
|