@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
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { isEnoent, logger } from "@oh-my-pi/pi-utils";
|
|
4
|
+
import { type } from "arktype";
|
|
5
|
+
import { YAML } from "bun";
|
|
6
|
+
import { expandAtImports } from "../discovery/at-imports";
|
|
7
|
+
import { BUILTIN_TOOL_NAMES, normalizeToolNames } from "../tools/builtin-names";
|
|
8
|
+
import { collectConfigCandidates } from "./watchdog";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* One advisor declared in a `WATCHDOG.yml` file. `model` is a model selector
|
|
12
|
+
* with an optional `:level` thinking suffix (e.g. `x-ai/grok-code-fast:high`),
|
|
13
|
+
* resolved exactly like any other model override; `tools` is a subset of the
|
|
14
|
+
* read-only investigative tool names (default: all). `instructions` is the
|
|
15
|
+
* advisor's specialization, appended to the shared baseline.
|
|
16
|
+
*/
|
|
17
|
+
export interface AdvisorConfig {
|
|
18
|
+
name: string;
|
|
19
|
+
model?: string;
|
|
20
|
+
tools?: string[];
|
|
21
|
+
instructions?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The result of walking the `WATCHDOG.yml`/`WATCHDOG.yaml` search path: the
|
|
26
|
+
* deduped advisor roster plus the concatenated top-level `instructions` baseline
|
|
27
|
+
* that is prepended (alongside `WATCHDOG.md`) to every advisor.
|
|
28
|
+
*/
|
|
29
|
+
export interface DiscoveredAdvisors {
|
|
30
|
+
advisors: AdvisorConfig[];
|
|
31
|
+
sharedInstructions: string | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const advisorEntrySchema = type({
|
|
35
|
+
name: "string",
|
|
36
|
+
"model?": "string",
|
|
37
|
+
"tools?": "string[]",
|
|
38
|
+
"instructions?": "string",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const watchdogYamlSchema = type({
|
|
42
|
+
"instructions?": "string",
|
|
43
|
+
"advisors?": advisorEntrySchema.array(),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Normalize an advisor name into a filesystem-/id-safe slug used for its
|
|
48
|
+
* transcript filename and session id: lowercase, non-alphanumerics collapsed to
|
|
49
|
+
* `-`, leading/trailing `-` trimmed. Falls back to `"advisor"` when nothing
|
|
50
|
+
* survives; callers dedupe collisions.
|
|
51
|
+
*/
|
|
52
|
+
export function slugifyAdvisorName(name: string): string {
|
|
53
|
+
const slug = name
|
|
54
|
+
.toLowerCase()
|
|
55
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
56
|
+
.replace(/^-+|-+$/g, "");
|
|
57
|
+
return slug || "advisor";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Built tool names, for validating an advisor's `tools` list. */
|
|
61
|
+
const KNOWN_TOOL_NAMES = new Set<string>(BUILTIN_TOOL_NAMES);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Keep only valid tool names from an advisor's `tools` list, dropping unknowns
|
|
65
|
+
* with a warning. The advisor is a full agent, so any built tool may be granted;
|
|
66
|
+
* the runtime further filters to what's actually available this session. An empty
|
|
67
|
+
* result (or no list) means "use the default subset" (read/grep/glob).
|
|
68
|
+
*/
|
|
69
|
+
function filterAdvisorTools(tools: string[] | undefined, sourcePath: string): string[] | undefined {
|
|
70
|
+
if (!tools || tools.length === 0) return undefined;
|
|
71
|
+
// Normalize legacy aliases (search→grep, find→glob) and dedupe before validating.
|
|
72
|
+
const filtered = normalizeToolNames(tools).filter(name => {
|
|
73
|
+
if (KNOWN_TOOL_NAMES.has(name)) return true;
|
|
74
|
+
logger.warn("Advisor config: dropping unknown tool", { path: sourcePath, tool: name });
|
|
75
|
+
return false;
|
|
76
|
+
});
|
|
77
|
+
return filtered.length > 0 ? filtered : undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Discover advisor configs from `WATCHDOG.yml`/`WATCHDOG.yaml` files on the same
|
|
82
|
+
* user + project search path as `WATCHDOG.md`. Advisors are keyed by slug; a
|
|
83
|
+
* more-specific file (project leaf > project ancestor > user) replaces an earlier
|
|
84
|
+
* entry with the same slug. Top-level `instructions` across all files concatenate
|
|
85
|
+
* into the shared baseline. A malformed file is logged and skipped — never
|
|
86
|
+
* thrown — so a bad project config can't kill the session.
|
|
87
|
+
*/
|
|
88
|
+
export async function discoverAdvisorConfigs(cwd: string, agentDir?: string): Promise<DiscoveredAdvisors> {
|
|
89
|
+
const items = await collectConfigCandidates(cwd, agentDir, ["WATCHDOG.yml", "WATCHDOG.yaml"]);
|
|
90
|
+
const advisors = new Map<string, AdvisorConfig>();
|
|
91
|
+
const sharedParts: string[] = [];
|
|
92
|
+
|
|
93
|
+
for (const item of items) {
|
|
94
|
+
let parsed: unknown;
|
|
95
|
+
try {
|
|
96
|
+
parsed = YAML.parse(item.content);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
logger.warn("Advisor config: failed to parse YAML", { path: item.path, error: String(err) });
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
102
|
+
logger.warn("Advisor config: expected a YAML mapping", { path: item.path });
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const result = watchdogYamlSchema(parsed);
|
|
106
|
+
if (result instanceof type.errors) {
|
|
107
|
+
logger.warn("Advisor config: invalid schema", { path: item.path, error: result.summary });
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (result.instructions?.trim()) {
|
|
112
|
+
const expanded = (await expandAtImports(result.instructions, item.path)).trim();
|
|
113
|
+
if (expanded) sharedParts.push(expanded);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for (const entry of result.advisors ?? []) {
|
|
117
|
+
const slug = slugifyAdvisorName(entry.name);
|
|
118
|
+
const instructions = entry.instructions?.trim()
|
|
119
|
+
? (await expandAtImports(entry.instructions, item.path)).trim() || undefined
|
|
120
|
+
: undefined;
|
|
121
|
+
advisors.set(slug, {
|
|
122
|
+
name: entry.name,
|
|
123
|
+
model: entry.model?.trim() || undefined,
|
|
124
|
+
tools: filterAdvisorTools(entry.tools, item.path),
|
|
125
|
+
instructions,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
advisors: [...advisors.values()],
|
|
132
|
+
sharedInstructions: sharedParts.length > 0 ? sharedParts.join("\n\n") : undefined,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Which level a `WATCHDOG.yml` lives at: the project root or the user agent dir. */
|
|
137
|
+
export type AdvisorConfigScope = "project" | "user";
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The editable contents of a single `WATCHDOG.yml` file: the shared top-level
|
|
141
|
+
* `instructions` plus the advisor roster. Unlike {@link DiscoveredAdvisors}, this
|
|
142
|
+
* is one file's raw view (no cross-level merge, no `@import` expansion) so the
|
|
143
|
+
* config editor round-trips exactly what the user wrote.
|
|
144
|
+
*/
|
|
145
|
+
export interface WatchdogConfigDoc {
|
|
146
|
+
instructions?: string;
|
|
147
|
+
advisors: AdvisorConfig[];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Resolve the `WATCHDOG.yml` path for a scope: `project` → `<projectDir>/WATCHDOG.yml`
|
|
152
|
+
* (discovered by the project-level walk), `user` → `<agentDir>/WATCHDOG.yml` (the
|
|
153
|
+
* user-level candidate).
|
|
154
|
+
*/
|
|
155
|
+
export function advisorConfigFilePath(
|
|
156
|
+
scope: AdvisorConfigScope,
|
|
157
|
+
dirs: { projectDir: string; agentDir: string },
|
|
158
|
+
): string {
|
|
159
|
+
return path.join(scope === "user" ? dirs.agentDir : dirs.projectDir, "WATCHDOG.yml");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Resolve which `WATCHDOG.{yml,yaml}` to edit for a scope: prefer the canonical
|
|
164
|
+
* `.yml`, but when only a `.yaml` exists for that scope, edit it in place so an
|
|
165
|
+
* existing `.yaml` user isn't shown a blank editor and left with two files at the
|
|
166
|
+
* same precedence. Falls back to `.yml` when neither exists.
|
|
167
|
+
*/
|
|
168
|
+
export async function resolveAdvisorConfigEditPath(
|
|
169
|
+
scope: AdvisorConfigScope,
|
|
170
|
+
dirs: { projectDir: string; agentDir: string },
|
|
171
|
+
): Promise<string> {
|
|
172
|
+
const dir = scope === "user" ? dirs.agentDir : dirs.projectDir;
|
|
173
|
+
const yml = path.join(dir, "WATCHDOG.yml");
|
|
174
|
+
const yaml = path.join(dir, "WATCHDOG.yaml");
|
|
175
|
+
if (!(await Bun.file(yml).exists()) && (await Bun.file(yaml).exists())) return yaml;
|
|
176
|
+
return yml;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Load one `WATCHDOG.yml` file for editing — raw, un-merged, un-expanded. Missing,
|
|
181
|
+
* unparseable, or schema-invalid files yield an empty doc (never throws) so the
|
|
182
|
+
* editor opens cleanly on a fresh or broken file.
|
|
183
|
+
*/
|
|
184
|
+
export async function loadWatchdogConfigFile(filePath: string): Promise<WatchdogConfigDoc> {
|
|
185
|
+
let text: string;
|
|
186
|
+
try {
|
|
187
|
+
text = await Bun.file(filePath).text();
|
|
188
|
+
} catch (err) {
|
|
189
|
+
if (!isEnoent(err))
|
|
190
|
+
logger.warn("Advisor config: failed to read for edit", { path: filePath, error: String(err) });
|
|
191
|
+
return { advisors: [] };
|
|
192
|
+
}
|
|
193
|
+
let parsed: unknown;
|
|
194
|
+
try {
|
|
195
|
+
parsed = YAML.parse(text);
|
|
196
|
+
} catch (err) {
|
|
197
|
+
logger.warn("Advisor config: failed to parse for edit", { path: filePath, error: String(err) });
|
|
198
|
+
return { advisors: [] };
|
|
199
|
+
}
|
|
200
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { advisors: [] };
|
|
201
|
+
const result = watchdogYamlSchema(parsed);
|
|
202
|
+
if (result instanceof type.errors) {
|
|
203
|
+
logger.warn("Advisor config: invalid schema for edit", { path: filePath, error: result.summary });
|
|
204
|
+
return { advisors: [] };
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
instructions: result.instructions?.trim() ? result.instructions : undefined,
|
|
208
|
+
advisors: (result.advisors ?? []).map(a => ({
|
|
209
|
+
name: a.name,
|
|
210
|
+
model: a.model?.trim() || undefined,
|
|
211
|
+
tools: a.tools?.length ? [...a.tools] : undefined,
|
|
212
|
+
instructions: a.instructions?.trim() ? a.instructions : undefined,
|
|
213
|
+
})),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Serialize an editable doc back to block-style `WATCHDOG.yml` text via Bun's
|
|
219
|
+
* `YAML.stringify` (the same API the repo uses for other hand-editable config),
|
|
220
|
+
* omitting empty fields. Round-trips through {@link loadWatchdogConfigFile}.
|
|
221
|
+
* Returns `""` for an empty doc.
|
|
222
|
+
*/
|
|
223
|
+
export function serializeWatchdogConfig(doc: WatchdogConfigDoc): string {
|
|
224
|
+
const out: { instructions?: string; advisors?: AdvisorConfig[] } = {};
|
|
225
|
+
if (doc.instructions?.trim()) out.instructions = doc.instructions;
|
|
226
|
+
if (doc.advisors.length > 0) {
|
|
227
|
+
out.advisors = doc.advisors.map(a => {
|
|
228
|
+
const entry: AdvisorConfig = { name: a.name };
|
|
229
|
+
if (a.model?.trim()) entry.model = a.model;
|
|
230
|
+
if (a.tools?.length) entry.tools = [...a.tools];
|
|
231
|
+
if (a.instructions?.trim()) entry.instructions = a.instructions;
|
|
232
|
+
return entry;
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
if (out.instructions === undefined && out.advisors === undefined) return "";
|
|
236
|
+
const text = YAML.stringify(out, null, 2);
|
|
237
|
+
return text.endsWith("\n") ? text : `${text}\n`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Write an editable doc to `WATCHDOG.yml`. An empty doc removes the file so
|
|
242
|
+
* discovery falls back to the legacy single-advisor path rather than leaving an
|
|
243
|
+
* empty config behind.
|
|
244
|
+
*/
|
|
245
|
+
export async function saveWatchdogConfigFile(filePath: string, doc: WatchdogConfigDoc): Promise<void> {
|
|
246
|
+
const content = serializeWatchdogConfig(doc);
|
|
247
|
+
if (!content.trim()) {
|
|
248
|
+
try {
|
|
249
|
+
await fs.rm(filePath, { force: true });
|
|
250
|
+
} catch (err) {
|
|
251
|
+
if (!isEnoent(err)) throw err;
|
|
252
|
+
}
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
await Bun.write(filePath, content);
|
|
256
|
+
}
|
package/src/advisor/index.ts
CHANGED
package/src/advisor/runtime.ts
CHANGED
|
@@ -209,6 +209,7 @@ export class AdvisorRuntime {
|
|
|
209
209
|
includeToolIntent: true,
|
|
210
210
|
watchedRoles: true,
|
|
211
211
|
expandPrimaryContext: true,
|
|
212
|
+
expandEditDiffs: true,
|
|
212
213
|
});
|
|
213
214
|
if (!md.trim()) return null;
|
|
214
215
|
return `### Session update\n\n${md}`;
|
|
@@ -434,11 +435,20 @@ function obfuscateDetails(
|
|
|
434
435
|
function obfuscateAdvisorMessage(obfuscator: SecretObfuscator, message: AgentMessage): AgentMessage {
|
|
435
436
|
switch (message.role) {
|
|
436
437
|
case "user":
|
|
437
|
-
case "developer":
|
|
438
|
-
case "toolResult": {
|
|
438
|
+
case "developer": {
|
|
439
439
|
const content = obfuscateTextualContent(obfuscator, message.content as TextualContent);
|
|
440
440
|
return content === message.content ? message : ({ ...(message as object), content } as AgentMessage);
|
|
441
441
|
}
|
|
442
|
+
case "toolResult": {
|
|
443
|
+
const msg = message as AgentMessage & {
|
|
444
|
+
content: TextualContent;
|
|
445
|
+
details?: Record<string, unknown>;
|
|
446
|
+
};
|
|
447
|
+
const content = obfuscateTextualContent(obfuscator, msg.content);
|
|
448
|
+
const details = obfuscateDetails(obfuscator, msg.details);
|
|
449
|
+
if (content === msg.content && details === msg.details) return message;
|
|
450
|
+
return { ...(message as object), content, details } as AgentMessage;
|
|
451
|
+
}
|
|
442
452
|
case "assistant":
|
|
443
453
|
return obfuscateAssistantMessage(obfuscator, message as AssistantMessage) as AgentMessage;
|
|
444
454
|
case "custom":
|
|
@@ -14,6 +14,23 @@ export const ADVISOR_TRANSCRIPT_FILENAME = `${ADVISOR_TRANSCRIPT_STEM}.jsonl`;
|
|
|
14
14
|
|
|
15
15
|
const JSONL_SUFFIX = ".jsonl";
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Transcript filename for an advisor: `__advisor.jsonl` for the legacy/default
|
|
19
|
+
* advisor (empty slug), `__advisor.<slug>.jsonl` for a named advisor. The `.`
|
|
20
|
+
* separator keeps named files out of the output manager's `-<n>` bump namespace.
|
|
21
|
+
*/
|
|
22
|
+
export function advisorTranscriptFilename(slug: string): string {
|
|
23
|
+
return slug ? `${ADVISOR_TRANSCRIPT_STEM}.${slug}${JSONL_SUFFIX}` : ADVISOR_TRANSCRIPT_FILENAME;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Whether a filename is any advisor transcript (`__advisor.jsonl` or `__advisor.<slug>.jsonl`). */
|
|
27
|
+
export function isAdvisorTranscriptName(name: string): boolean {
|
|
28
|
+
return (
|
|
29
|
+
name === ADVISOR_TRANSCRIPT_FILENAME ||
|
|
30
|
+
(name.startsWith(`${ADVISOR_TRANSCRIPT_STEM}.`) && name.endsWith(JSONL_SUFFIX))
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
17
34
|
/**
|
|
18
35
|
* Append-only persister for an advisor agent's transcript.
|
|
19
36
|
*
|
|
@@ -38,19 +55,25 @@ const JSONL_SUFFIX = ".jsonl";
|
|
|
38
55
|
export class AdvisorTranscriptRecorder {
|
|
39
56
|
#manager: SessionManager | undefined;
|
|
40
57
|
#file: string | undefined;
|
|
58
|
+
#filename: string;
|
|
41
59
|
/** Serializes the async open/close against synchronous appends so records land in order. */
|
|
42
60
|
#queue: Promise<void>;
|
|
43
61
|
|
|
44
62
|
/**
|
|
63
|
+
* @param filename Transcript filename within the session dir. Defaults to
|
|
64
|
+
* `__advisor.jsonl`; named advisors pass `__advisor.<slug>.jsonl` via
|
|
65
|
+
* {@link advisorTranscriptFilename}.
|
|
45
66
|
* @param after Optional barrier the queue starts behind — used on the advisor
|
|
46
67
|
* on→off→on toggle so a fresh recorder's first `open` waits for the prior
|
|
47
|
-
* recorder's `close` and the two never hold the same
|
|
68
|
+
* recorder's `close` and the two never hold the same file at once.
|
|
48
69
|
*/
|
|
49
70
|
constructor(
|
|
50
71
|
private readonly resolveSessionFile: () => string | undefined,
|
|
51
72
|
private readonly resolveCwd: () => string,
|
|
73
|
+
filename: string = ADVISOR_TRANSCRIPT_FILENAME,
|
|
52
74
|
after?: Promise<unknown>,
|
|
53
75
|
) {
|
|
76
|
+
this.#filename = filename;
|
|
54
77
|
this.#queue = after
|
|
55
78
|
? after.then(
|
|
56
79
|
() => {},
|
|
@@ -83,7 +106,7 @@ export class AdvisorTranscriptRecorder {
|
|
|
83
106
|
}
|
|
84
107
|
const sessionFile = this.resolveSessionFile();
|
|
85
108
|
if (!sessionFile?.endsWith(JSONL_SUFFIX)) return;
|
|
86
|
-
const file = path.join(sessionFile.slice(0, -JSONL_SUFFIX.length),
|
|
109
|
+
const file = path.join(sessionFile.slice(0, -JSONL_SUFFIX.length), this.#filename);
|
|
87
110
|
const cwd = this.resolveCwd();
|
|
88
111
|
this.#enqueue(async () => {
|
|
89
112
|
if (file !== this.#file) {
|
package/src/advisor/watchdog.ts
CHANGED
|
@@ -31,79 +31,105 @@ export function formatAdvisorContextPrompt(
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
34
|
+
* A readable config candidate discovered on the watchdog/advisor search path,
|
|
35
|
+
* with raw (un-expanded) content and its position metadata.
|
|
36
36
|
*/
|
|
37
|
-
export
|
|
37
|
+
export interface ConfigCandidate {
|
|
38
|
+
path: string;
|
|
39
|
+
content: string;
|
|
40
|
+
level: "user" | "project";
|
|
41
|
+
depth: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Walk the watchdog/advisor config search path — the user agent dir plus every
|
|
46
|
+
* directory from `cwd` up to the repo root (or home), probing both `<F>` and
|
|
47
|
+
* `.omp/<F>` for each given filename — and return the readable candidates with
|
|
48
|
+
* their raw content, sorted user-first then project ancestor→leaf (depth
|
|
49
|
+
* descending, so the leaf directory is most specific/last). Shared by
|
|
50
|
+
* {@link discoverWatchdogFiles} and `discoverAdvisorConfigs`. Content is returned
|
|
51
|
+
* verbatim (no `@import` expansion); callers expand what they need.
|
|
52
|
+
*/
|
|
53
|
+
export async function collectConfigCandidates(
|
|
54
|
+
cwd: string,
|
|
55
|
+
agentDir: string | undefined,
|
|
56
|
+
filenames: string[],
|
|
57
|
+
): Promise<ConfigCandidate[]> {
|
|
38
58
|
const home = os.homedir();
|
|
39
59
|
const resolvedAgentDir = agentDir ?? getAgentDir();
|
|
40
|
-
const
|
|
60
|
+
const userPaths = new Set<string>();
|
|
41
61
|
let repoRoot: string | null = null;
|
|
42
62
|
try {
|
|
43
63
|
repoRoot = await repo.root(cwd);
|
|
44
64
|
} catch (err) {
|
|
45
|
-
logger.debug("Failed to resolve git root for
|
|
65
|
+
logger.debug("Failed to resolve git root for config discovery", { err: String(err) });
|
|
46
66
|
}
|
|
47
67
|
|
|
48
68
|
const candidates = new Set<string>();
|
|
49
69
|
|
|
50
|
-
// 1. User level: ~/.omp
|
|
70
|
+
// 1. User level: ~/.omp/<F> (or active profile agent dir)
|
|
51
71
|
if (resolvedAgentDir) {
|
|
52
|
-
|
|
72
|
+
for (const filename of filenames) {
|
|
73
|
+
const userPath = path.resolve(resolvedAgentDir, filename);
|
|
74
|
+
candidates.add(userPath);
|
|
75
|
+
userPaths.add(userPath);
|
|
76
|
+
}
|
|
53
77
|
}
|
|
54
78
|
|
|
55
79
|
// 2. Project levels (both standalone and native config .omp/): walk up from cwd to repoRoot / home
|
|
56
80
|
let current = cwd;
|
|
57
81
|
while (true) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
82
|
+
for (const filename of filenames) {
|
|
83
|
+
candidates.add(path.resolve(current, ".omp", filename));
|
|
84
|
+
candidates.add(path.resolve(current, filename));
|
|
85
|
+
}
|
|
61
86
|
if (current === (repoRoot ?? home)) break;
|
|
62
87
|
const parent = path.dirname(current);
|
|
63
88
|
if (parent === current) break;
|
|
64
89
|
current = parent;
|
|
65
90
|
}
|
|
66
91
|
|
|
67
|
-
const items:
|
|
68
|
-
|
|
92
|
+
const items: ConfigCandidate[] = [];
|
|
69
93
|
for (const candidate of candidates) {
|
|
70
94
|
try {
|
|
71
95
|
const content = await Bun.file(candidate).text();
|
|
72
|
-
const expanded = await expandAtImports(content, candidate);
|
|
73
96
|
const parent = path.dirname(candidate);
|
|
74
97
|
const baseName = parent.split(path.sep).pop() ?? "";
|
|
75
|
-
|
|
76
|
-
const isUser = userPath !== null && candidate === userPath;
|
|
98
|
+
const isUser = userPaths.has(candidate);
|
|
77
99
|
const ownerDir = baseName === ".omp" ? path.dirname(parent) : parent;
|
|
78
100
|
const ownerBaseName = ownerDir.split(path.sep).pop() ?? "";
|
|
79
|
-
|
|
80
101
|
if (isUser || !ownerBaseName.startsWith(".") || baseName === ".omp") {
|
|
81
102
|
const relative = path.relative(cwd, ownerDir);
|
|
82
103
|
const depth = relative === "" ? 0 : relative.split(path.sep).filter(Boolean).length;
|
|
83
|
-
items.push({
|
|
84
|
-
path: candidate,
|
|
85
|
-
content: expanded,
|
|
86
|
-
level: isUser ? "user" : "project",
|
|
87
|
-
depth,
|
|
88
|
-
});
|
|
104
|
+
items.push({ path: candidate, content, level: isUser ? "user" : "project", depth });
|
|
89
105
|
}
|
|
90
106
|
} catch (err) {
|
|
91
107
|
if (!isEnoent(err)) {
|
|
92
|
-
logger.warn("Failed to read
|
|
108
|
+
logger.warn("Failed to read config candidate", { path: candidate, error: String(err) });
|
|
93
109
|
}
|
|
94
110
|
}
|
|
95
111
|
}
|
|
96
112
|
|
|
97
|
-
//
|
|
98
|
-
//
|
|
113
|
+
// User level first, then project levels sorted by depth descending — ancestor
|
|
114
|
+
// directories first, the leaf (depth 0) last/most prominent.
|
|
99
115
|
items.sort((a, b) => {
|
|
100
|
-
if (a.level !== b.level)
|
|
101
|
-
return a.level === "user" ? -1 : 1;
|
|
102
|
-
}
|
|
116
|
+
if (a.level !== b.level) return a.level === "user" ? -1 : 1;
|
|
103
117
|
return b.depth - a.depth;
|
|
104
118
|
});
|
|
105
119
|
|
|
106
|
-
return items
|
|
107
|
-
|
|
108
|
-
|
|
120
|
+
return items;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Discover and load WATCHDOG.md files walking up from cwd, project .omp folder, and user agent dir.
|
|
125
|
+
* Returns formatted watchdog file blocks ready to be appended to the advisor system prompt.
|
|
126
|
+
*/
|
|
127
|
+
export async function discoverWatchdogFiles(cwd: string, agentDir?: string): Promise<string[]> {
|
|
128
|
+
const items = await collectConfigCandidates(cwd, agentDir, ["WATCHDOG.md"]);
|
|
129
|
+
const blocks: string[] = [];
|
|
130
|
+
for (const item of items) {
|
|
131
|
+
const expanded = await expandAtImports(item.content, item.path);
|
|
132
|
+
blocks.push(`Especially pay attention to:\n<attention>\n${expanded}\n</attention>`);
|
|
133
|
+
}
|
|
134
|
+
return blocks;
|
|
109
135
|
}
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* Auto-learn session controller (experimental).
|
|
3
3
|
*
|
|
4
4
|
* Subscribes to the session event stream and, after a substantive turn,
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* optionally auto-runs a synthetic capture turn. Passive mode is intentionally
|
|
6
|
+
* prompt-cache neutral: the standing system guidance remains available, but no
|
|
7
|
+
* hidden mid-session reminder is inserted into the conversation.
|
|
8
8
|
*
|
|
9
9
|
* Installed once per top-level session (taskDepth 0). The subscription lives
|
|
10
10
|
* for the session's lifetime — `newSession` resets the session in place
|
|
@@ -14,11 +14,9 @@ import { logger } from "@oh-my-pi/pi-utils";
|
|
|
14
14
|
import type { Settings } from "../config/settings";
|
|
15
15
|
import autolearnGuidance from "../prompts/system/autolearn-guidance.md" with { type: "text" };
|
|
16
16
|
import autolearnGuidanceLearn from "../prompts/system/autolearn-guidance-learn.md" with { type: "text" };
|
|
17
|
-
import autolearnNudge from "../prompts/system/autolearn-nudge.md" with { type: "text" };
|
|
18
17
|
import autolearnNudgeAutoContinue from "../prompts/system/autolearn-nudge-autocontinue.md" with { type: "text" };
|
|
19
18
|
import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
|
|
20
19
|
|
|
21
|
-
const AUTOLEARN_NUDGE_PASSIVE = autolearnNudge.trim();
|
|
22
20
|
const AUTOLEARN_NUDGE_AUTOCONTINUE = autolearnNudgeAutoContinue.trim();
|
|
23
21
|
const DEFAULT_MIN_TOOL_CALLS = 5;
|
|
24
22
|
|
|
@@ -110,28 +108,21 @@ export class AutoLearnController {
|
|
|
110
108
|
// auto-continue would compete with it.
|
|
111
109
|
if (startedInGoalMode || this.#session.getGoalModeState()?.enabled) return;
|
|
112
110
|
|
|
113
|
-
// Auto-run a capture turn only when explicitly enabled
|
|
114
|
-
// hidden
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
// user-role payload, so the prompt must be terminal — it tells the
|
|
119
|
-
// agent to capture and STOP, and must not be read as the user's
|
|
120
|
-
// reply to any pending question. Without that contract, the agent
|
|
121
|
-
// conflates the synthetic prompt with user approval and resumes
|
|
122
|
-
// prior work (e.g. commits/pushes an unanswered "want me to push?"
|
|
123
|
-
// question — #3504).
|
|
124
|
-
// - Passive mode appends the nudge to the user's real next message,
|
|
125
|
-
// so the agent must answer the user normally and treat the capture
|
|
126
|
-
// as additive — not as the whole turn's job.
|
|
111
|
+
// Auto-run a capture turn only when explicitly enabled. Passive mode used to
|
|
112
|
+
// queue a hidden custom message for the next real turn, but that mutates the
|
|
113
|
+
// persisted conversation prefix after providers have cached it. The standing
|
|
114
|
+
// auto-learn system guidance is stable; keep passive mode to that guidance
|
|
115
|
+
// so Anthropic prompt-cache prefixes survive long sessions.
|
|
127
116
|
const autoContinue = this.#settings.get("autolearn.autoContinue") === true;
|
|
128
|
-
|
|
117
|
+
if (!autoContinue) return;
|
|
118
|
+
|
|
119
|
+
const content = AUTOLEARN_NUDGE_AUTOCONTINUE;
|
|
129
120
|
// Arm suppression synchronously: the synthetic capture turn's agent_end
|
|
130
121
|
// fires inside sendCustomMessage (before it resolves), so the flag must be
|
|
131
122
|
// set before then. Disarm when no turn actually started — a deferred/queued
|
|
132
123
|
// dispatch or a failed send produces no agent_end, and a latched flag would
|
|
133
124
|
// otherwise swallow the next real stop.
|
|
134
|
-
|
|
125
|
+
this.#suppressNext = true;
|
|
135
126
|
|
|
136
127
|
this.#session
|
|
137
128
|
.sendCustomMessage(
|
|
@@ -141,7 +132,7 @@ export class AutoLearnController {
|
|
|
141
132
|
display: false,
|
|
142
133
|
attribution: "user",
|
|
143
134
|
},
|
|
144
|
-
{ deliverAs: "nextTurn", triggerTurn:
|
|
135
|
+
{ deliverAs: "nextTurn", triggerTurn: true },
|
|
145
136
|
)
|
|
146
137
|
.then(started => {
|
|
147
138
|
if (!started) this.#suppressNext = false;
|
|
@@ -320,6 +320,11 @@ export const createAutoresearchExtension: ExtensionFactory = api => {
|
|
|
320
320
|
runtime.lastRunDuration = pendingRun?.durationSeconds ?? runtime.lastRunDuration;
|
|
321
321
|
runtime.lastRunAsi = pendingRun?.parsedAsi ?? runtime.lastRunAsi;
|
|
322
322
|
const state = runtime.state;
|
|
323
|
+
// `event.systemPrompt` is typed `string[]`, but upstream code paths can leave
|
|
324
|
+
// it unset (issue #3665). Coerce defensively so the autoresearch block still
|
|
325
|
+
// renders — the model just loses the upstream prefix for this turn, which is
|
|
326
|
+
// strictly better than crashing the handler.
|
|
327
|
+
const basePrompt = Array.isArray(event.systemPrompt) ? event.systemPrompt.join("\n\n") : "";
|
|
323
328
|
const currentSegmentResults = currentResults(state.results, state.currentSegment);
|
|
324
329
|
const baselineMetric = findBaselineMetric(state.results, state.currentSegment);
|
|
325
330
|
const baselineRunNumber = findBaselineRunNumber(state.results, state.currentSegment);
|
|
@@ -358,7 +363,7 @@ export const createAutoresearchExtension: ExtensionFactory = api => {
|
|
|
358
363
|
return {
|
|
359
364
|
systemPrompt: [
|
|
360
365
|
prompt.render(setupPromptTemplate, {
|
|
361
|
-
base_system_prompt:
|
|
366
|
+
base_system_prompt: basePrompt,
|
|
362
367
|
has_goal: goal.trim().length > 0,
|
|
363
368
|
goal,
|
|
364
369
|
working_dir: ctx.cwd,
|
|
@@ -373,7 +378,7 @@ export const createAutoresearchExtension: ExtensionFactory = api => {
|
|
|
373
378
|
return {
|
|
374
379
|
systemPrompt: [
|
|
375
380
|
prompt.render(promptTemplate, {
|
|
376
|
-
base_system_prompt:
|
|
381
|
+
base_system_prompt: basePrompt,
|
|
377
382
|
has_goal: goal.trim().length > 0,
|
|
378
383
|
goal,
|
|
379
384
|
working_dir: ctx.cwd,
|
package/src/cli/gc-cli.ts
CHANGED
|
@@ -434,17 +434,24 @@ function sessionIdFromSessionPath(sessionPath: string): string | undefined {
|
|
|
434
434
|
}
|
|
435
435
|
|
|
436
436
|
function sessionIdFromSessionText(text: string): string | undefined {
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
437
|
+
let sawTitleSlot = false;
|
|
438
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
439
|
+
const line = rawLine.trim();
|
|
440
|
+
if (!line) continue;
|
|
441
|
+
try {
|
|
442
|
+
const record = JSON.parse(line) as { type?: unknown; id?: unknown };
|
|
443
|
+
if (!sawTitleSlot && record.type === "title") {
|
|
444
|
+
sawTitleSlot = true;
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
return record.type === "session" && typeof record.id === "string" && record.id.length > 0
|
|
448
|
+
? record.id
|
|
449
|
+
: undefined;
|
|
450
|
+
} catch {
|
|
451
|
+
return undefined;
|
|
452
|
+
}
|
|
447
453
|
}
|
|
454
|
+
return undefined;
|
|
448
455
|
}
|
|
449
456
|
|
|
450
457
|
async function archivedSessionIdFromFile(file: string): Promise<string | undefined> {
|