@oh-my-pi/pi-coding-agent 16.1.15 → 16.1.17
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 +78 -0
- package/dist/cli.js +3531 -3812
- package/dist/types/auto-thinking/classifier.d.ts +4 -2
- package/dist/types/cli/args.d.ts +2 -5
- package/dist/types/cli/flag-tables.d.ts +2 -2
- package/dist/types/cli/session-picker.d.ts +4 -2
- package/dist/types/commands/launch.d.ts +1 -1
- package/dist/types/config/model-discovery.d.ts +1 -0
- package/dist/types/config/model-registry.d.ts +4 -0
- package/dist/types/config/settings-schema.d.ts +12 -1
- package/dist/types/eval/agent-bridge.d.ts +19 -0
- package/dist/types/eval/js/shared/helpers.d.ts +1 -13
- package/dist/types/eval/js/shared/types.d.ts +1 -1
- package/dist/types/eval/js/worker-protocol.d.ts +1 -1
- package/dist/types/eval/py/executor.d.ts +1 -1
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +22 -7
- package/dist/types/extensibility/plugins/loader.d.ts +16 -9
- package/dist/types/extensibility/tool-event-input.d.ts +2 -0
- package/dist/types/hindsight/content.d.ts +2 -10
- package/dist/types/internal-urls/local-protocol.d.ts +18 -1
- package/dist/types/main.d.ts +2 -0
- package/dist/types/modes/components/chat-transcript-builder.d.ts +2 -0
- package/dist/types/modes/components/custom-editor.d.ts +3 -0
- package/dist/types/modes/components/plugin-settings.d.ts +5 -0
- package/dist/types/modes/components/session-selector.d.ts +25 -0
- package/dist/types/modes/components/status-line/types.d.ts +1 -0
- package/dist/types/modes/controllers/command-controller.d.ts +2 -0
- package/dist/types/modes/controllers/input-controller.d.ts +14 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +11 -0
- package/dist/types/session/agent-session.d.ts +6 -6
- package/dist/types/session/provider-image-budget.d.ts +3 -0
- package/dist/types/session/session-context.d.ts +6 -5
- package/dist/types/task/isolation-runner.d.ts +128 -0
- package/dist/types/task/parallel.d.ts +4 -0
- package/dist/types/task/worktree.d.ts +14 -1
- package/dist/types/thinking.d.ts +23 -1
- package/dist/types/tiny/title-client.d.ts +2 -2
- package/dist/types/tools/eval-render.d.ts +3 -0
- package/dist/types/tools/eval.d.ts +11 -17
- package/dist/types/tools/todo.d.ts +26 -28
- package/dist/types/tui/output-block.d.ts +8 -0
- package/dist/types/utils/image-resize.d.ts +2 -0
- package/dist/types/utils/tools-manager.d.ts +2 -0
- package/dist/types/web/search/providers/exa.d.ts +2 -0
- package/package.json +12 -12
- package/scripts/build-binary.ts +18 -4
- package/src/auto-thinking/classifier.ts +7 -2
- package/src/cli/args.ts +4 -5
- package/src/cli/flag-tables.ts +3 -3
- package/src/cli/gallery-fixtures/interaction.ts +6 -9
- package/src/cli/gallery-fixtures/shell.ts +15 -23
- package/src/cli/profile-alias.ts +38 -7
- package/src/cli/session-picker.ts +17 -3
- package/src/cli/usage-cli.ts +5 -1
- package/src/commands/launch.ts +3 -3
- package/src/config/model-discovery.ts +59 -8
- package/src/config/model-registry.ts +74 -3
- package/src/config/settings-schema.ts +13 -1
- package/src/discovery/omp-extension-roots.ts +1 -3
- package/src/edit/renderer.ts +34 -12
- package/src/eval/__tests__/agent-bridge.test.ts +462 -3
- package/src/eval/__tests__/helpers-local-roots.test.ts +2 -5
- package/src/eval/__tests__/julia-prelude.test.ts +1 -30
- package/src/eval/__tests__/prelude-agent.test.ts +42 -8
- package/src/eval/agent-bridge.ts +301 -71
- package/src/eval/jl/prelude.jl +32 -227
- package/src/eval/jl/runner.jl +38 -12
- package/src/eval/js/shared/helpers.ts +1 -114
- package/src/eval/js/shared/prelude.txt +13 -27
- package/src/eval/js/shared/runtime.ts +0 -6
- package/src/eval/js/shared/types.ts +1 -1
- package/src/eval/js/worker-protocol.ts +1 -1
- package/src/eval/py/__tests__/prelude.test.ts +13 -0
- package/src/eval/py/executor.ts +1 -1
- package/src/eval/py/prelude.py +47 -105
- package/src/eval/py/runner.py +0 -6
- package/src/eval/rb/prelude.rb +21 -189
- package/src/eval/rb/runner.rb +116 -9
- package/src/export/html/tool-views.generated.js +29 -29
- package/src/extensibility/extensions/wrapper.ts +3 -2
- package/src/extensibility/hooks/tool-wrapper.ts +4 -3
- package/src/extensibility/plugins/legacy-pi-compat.ts +40 -8
- package/src/extensibility/plugins/loader.ts +71 -23
- package/src/extensibility/plugins/marketplace/manager.ts +134 -0
- package/src/extensibility/tool-event-input.ts +57 -0
- package/src/hindsight/content.ts +21 -12
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/internal-urls/local-protocol.ts +100 -53
- package/src/main.ts +15 -4
- package/src/mcp/tool-bridge.ts +27 -2
- package/src/mnemopi/state.ts +5 -2
- package/src/modes/acp/acp-event-mapper.ts +7 -2
- package/src/modes/components/agent-transcript-viewer.ts +193 -41
- package/src/modes/components/chat-transcript-builder.ts +6 -0
- package/src/modes/components/custom-editor.test.ts +18 -1
- package/src/modes/components/custom-editor.ts +77 -45
- package/src/modes/components/hook-editor.ts +15 -2
- package/src/modes/components/plugin-settings.ts +7 -1
- package/src/modes/components/session-selector.ts +143 -29
- package/src/modes/components/settings-selector.ts +2 -2
- package/src/modes/components/status-line/component.ts +52 -8
- package/src/modes/components/status-line/segments.ts +5 -1
- package/src/modes/components/status-line/types.ts +1 -0
- package/src/modes/components/welcome.ts +12 -14
- package/src/modes/controllers/command-controller.ts +21 -5
- package/src/modes/controllers/input-controller.ts +115 -3
- package/src/modes/controllers/selector-controller.ts +19 -1
- package/src/modes/interactive-mode.ts +3 -3
- package/src/modes/rpc/rpc-mode.ts +6 -0
- package/src/modes/utils/copy-targets.ts +7 -2
- package/src/modes/utils/ui-helpers.ts +3 -3
- package/src/prompts/system/system-prompt.md +3 -3
- package/src/prompts/system/workflow-notice.md +3 -3
- package/src/prompts/tools/bash.md +16 -0
- package/src/prompts/tools/eval.md +19 -19
- package/src/prompts/tools/todo.md +1 -1
- package/src/sdk.ts +8 -10
- package/src/session/agent-session.ts +422 -97
- package/src/session/provider-image-budget.ts +86 -0
- package/src/session/session-context.ts +14 -7
- package/src/session/session-storage.ts +24 -2
- package/src/session/snapcompact-inline.ts +19 -3
- package/src/slash-commands/builtin-registry.ts +0 -22
- package/src/slash-commands/helpers/usage-report.ts +9 -1
- package/src/task/index.ts +61 -207
- package/src/task/isolation-runner.ts +354 -0
- package/src/task/parallel.ts +6 -1
- package/src/task/worktree.ts +46 -9
- package/src/thinking.ts +29 -2
- package/src/tiny/title-client.ts +75 -21
- package/src/tools/ask.ts +44 -38
- package/src/tools/bash.ts +9 -2
- package/src/tools/browser/tab-worker.ts +1 -1
- package/src/tools/eval-render.ts +34 -27
- package/src/tools/eval.ts +100 -103
- package/src/tools/index.ts +8 -1
- package/src/tools/read.ts +136 -60
- package/src/tools/todo.ts +60 -64
- package/src/tui/code-cell.ts +1 -1
- package/src/tui/output-block.ts +11 -0
- package/src/utils/image-resize.ts +30 -0
- package/src/utils/tools-manager.ts +67 -10
- package/src/web/search/providers/exa.ts +85 -1
|
@@ -157,7 +157,7 @@ export function resolveLocalUrlToPath(
|
|
|
157
157
|
}
|
|
158
158
|
|
|
159
159
|
/**
|
|
160
|
-
* On-disk roots the eval helpers (`read`/`write
|
|
160
|
+
* On-disk roots the eval helpers (`read`/`write`) substitute for
|
|
161
161
|
* internal-URL schemes so e.g. `write("local://x.md")` lands where a later
|
|
162
162
|
* `read local://x.md` resolves — instead of a literal `local:/` directory under
|
|
163
163
|
* the cwd (a stdlib `pathlib.Path`/`path.resolve` collapses `local://` to
|
|
@@ -169,6 +169,96 @@ export function buildEvalUrlRoots(options: LocalProtocolOptions): Record<string,
|
|
|
169
169
|
return { local: resolveLocalRoot(options) };
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
const LOCAL_WRITE_NOTE = "Use write path local://<file> to persist large intermediate artifacts across turns.";
|
|
173
|
+
|
|
174
|
+
type ResolvedLocalTarget =
|
|
175
|
+
| { kind: "listing"; root: string }
|
|
176
|
+
| { kind: "directory"; path: string }
|
|
177
|
+
| { kind: "file"; path: string; size: number };
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Resolve a local:// URL to its on-disk target with realpath + containment
|
|
181
|
+
* checks on the root, parent, and target so symlinks cannot escape the session
|
|
182
|
+
* local root. Does NOT read or decode file contents — callers decide how to
|
|
183
|
+
* consume the resolved path. Shared by {@link LocalProtocolHandler.resolve} and
|
|
184
|
+
* {@link resolveLocalUrlToFile}.
|
|
185
|
+
*/
|
|
186
|
+
async function resolveLocalTarget(url: InternalUrl, opts: LocalProtocolOptions): Promise<ResolvedLocalTarget> {
|
|
187
|
+
const localRoot = path.resolve(resolveLocalRoot(opts));
|
|
188
|
+
await fs.mkdir(localRoot, { recursive: true });
|
|
189
|
+
|
|
190
|
+
let resolvedRoot: string;
|
|
191
|
+
try {
|
|
192
|
+
resolvedRoot = await fs.realpath(localRoot);
|
|
193
|
+
} catch (error) {
|
|
194
|
+
if (isEnoent(error)) {
|
|
195
|
+
throw new Error("Unable to initialize local:// root");
|
|
196
|
+
}
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const relativePath = extractRelativePath(url);
|
|
201
|
+
const targetPath = relativePath ? path.resolve(resolvedRoot, relativePath) : resolvedRoot;
|
|
202
|
+
ensureWithinRoot(targetPath, resolvedRoot);
|
|
203
|
+
|
|
204
|
+
if (targetPath === resolvedRoot) {
|
|
205
|
+
return { kind: "listing", root: resolvedRoot };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const parentDir = path.dirname(targetPath);
|
|
209
|
+
try {
|
|
210
|
+
const realParent = await fs.realpath(parentDir);
|
|
211
|
+
ensureWithinRoot(realParent, resolvedRoot);
|
|
212
|
+
} catch (error) {
|
|
213
|
+
if (!isEnoent(error)) throw error;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
let realTargetPath: string;
|
|
217
|
+
try {
|
|
218
|
+
realTargetPath = await fs.realpath(targetPath);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (isEnoent(error)) {
|
|
221
|
+
throw new Error(`Local file not found: ${url.href}`);
|
|
222
|
+
}
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
ensureWithinRoot(realTargetPath, resolvedRoot);
|
|
227
|
+
|
|
228
|
+
const stat = await fs.stat(realTargetPath);
|
|
229
|
+
if (stat.isDirectory()) {
|
|
230
|
+
return { kind: "directory", path: realTargetPath };
|
|
231
|
+
}
|
|
232
|
+
if (!stat.isFile()) {
|
|
233
|
+
throw new Error(`local:// URL must resolve to a file or directory: ${url.href}`);
|
|
234
|
+
}
|
|
235
|
+
return { kind: "file", path: realTargetPath, size: stat.size };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Resolve a local:// URL to a regular on-disk file, applying the same
|
|
240
|
+
* realpath + containment guarantees as {@link LocalProtocolHandler.resolve}
|
|
241
|
+
* but WITHOUT reading or UTF-8-decoding its contents. Returns null when there
|
|
242
|
+
* is no active session or when the URL targets the root listing or a directory;
|
|
243
|
+
* throws the handler's not-found and "escapes local root" errors for missing
|
|
244
|
+
* files and symlink escapes.
|
|
245
|
+
*
|
|
246
|
+
* Options are resolved via {@link LocalProtocolHandler.resolveOptions} so the
|
|
247
|
+
* caller-options → override → registry order matches router resolution exactly.
|
|
248
|
+
* The read tool uses this to detect and emit image files from their real path
|
|
249
|
+
* before the text-only resource contract would decode the binary into mojibake.
|
|
250
|
+
*/
|
|
251
|
+
export async function resolveLocalUrlToFile(
|
|
252
|
+
input: string | InternalUrl,
|
|
253
|
+
context?: ResolveContext,
|
|
254
|
+
): Promise<{ path: string; size: number } | null> {
|
|
255
|
+
const opts = LocalProtocolHandler.resolveOptions(context);
|
|
256
|
+
if (!opts) return null;
|
|
257
|
+
const url = typeof input === "string" ? parseLocalUrl(input) : input;
|
|
258
|
+
const resolved = await resolveLocalTarget(url, opts);
|
|
259
|
+
return resolved.kind === "file" ? { path: resolved.path, size: resolved.size } : null;
|
|
260
|
+
}
|
|
261
|
+
|
|
172
262
|
/**
|
|
173
263
|
* Protocol handler for local:// URLs.
|
|
174
264
|
*
|
|
@@ -238,65 +328,22 @@ export class LocalProtocolHandler implements ProtocolHandler {
|
|
|
238
328
|
throw new Error("No session - local:// unavailable");
|
|
239
329
|
}
|
|
240
330
|
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
let resolvedRoot: string;
|
|
245
|
-
try {
|
|
246
|
-
resolvedRoot = await fs.realpath(localRoot);
|
|
247
|
-
} catch (error) {
|
|
248
|
-
if (isEnoent(error)) {
|
|
249
|
-
throw new Error("Unable to initialize local:// root");
|
|
250
|
-
}
|
|
251
|
-
throw error;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
const relativePath = extractRelativePath(url);
|
|
255
|
-
const targetPath = relativePath ? path.resolve(resolvedRoot, relativePath) : resolvedRoot;
|
|
256
|
-
ensureWithinRoot(targetPath, resolvedRoot);
|
|
257
|
-
|
|
258
|
-
if (targetPath === resolvedRoot) {
|
|
259
|
-
return buildListing(url, resolvedRoot);
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const parentDir = path.dirname(targetPath);
|
|
263
|
-
try {
|
|
264
|
-
const realParent = await fs.realpath(parentDir);
|
|
265
|
-
ensureWithinRoot(realParent, resolvedRoot);
|
|
266
|
-
} catch (error) {
|
|
267
|
-
if (!isEnoent(error)) throw error;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
let realTargetPath: string;
|
|
271
|
-
try {
|
|
272
|
-
realTargetPath = await fs.realpath(targetPath);
|
|
273
|
-
} catch (error) {
|
|
274
|
-
if (isEnoent(error)) {
|
|
275
|
-
throw new Error(`Local file not found: ${url.href}`);
|
|
276
|
-
}
|
|
277
|
-
throw error;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
ensureWithinRoot(realTargetPath, resolvedRoot);
|
|
281
|
-
|
|
282
|
-
const stat = await fs.stat(realTargetPath);
|
|
283
|
-
if (stat.isDirectory()) {
|
|
284
|
-
return buildDirectoryResource(url.href, realTargetPath, [
|
|
285
|
-
"Use write path local://<file> to persist large intermediate artifacts across turns.",
|
|
286
|
-
]);
|
|
331
|
+
const resolved = await resolveLocalTarget(url, opts);
|
|
332
|
+
if (resolved.kind === "listing") {
|
|
333
|
+
return buildListing(url, resolved.root);
|
|
287
334
|
}
|
|
288
|
-
if (
|
|
289
|
-
|
|
335
|
+
if (resolved.kind === "directory") {
|
|
336
|
+
return buildDirectoryResource(url.href, resolved.path, [LOCAL_WRITE_NOTE]);
|
|
290
337
|
}
|
|
291
338
|
|
|
292
|
-
const content = await Bun.file(
|
|
339
|
+
const content = await Bun.file(resolved.path).text();
|
|
293
340
|
return {
|
|
294
341
|
url: url.href,
|
|
295
342
|
content,
|
|
296
|
-
contentType: getContentType(
|
|
343
|
+
contentType: getContentType(resolved.path),
|
|
297
344
|
size: Buffer.byteLength(content, "utf-8"),
|
|
298
|
-
sourcePath:
|
|
299
|
-
notes: [
|
|
345
|
+
sourcePath: resolved.path,
|
|
346
|
+
notes: [LOCAL_WRITE_NOTE],
|
|
300
347
|
};
|
|
301
348
|
}
|
|
302
349
|
|
package/src/main.ts
CHANGED
|
@@ -945,6 +945,7 @@ async function buildSessionOptions(
|
|
|
945
945
|
interface RunRootCommandDependencies {
|
|
946
946
|
createAgentSession?: typeof createAgentSession;
|
|
947
947
|
discoverAuthStorage?: typeof discoverAuthStorage;
|
|
948
|
+
selectSession?: typeof selectSession;
|
|
948
949
|
runAcpMode?: RunAcpMode;
|
|
949
950
|
settings?: Settings;
|
|
950
951
|
forceSetupWizard?: boolean;
|
|
@@ -1131,7 +1132,8 @@ export async function runRootCommand(
|
|
|
1131
1132
|
// (see issue #1668).
|
|
1132
1133
|
if (typeof parsedArgs.resume === "string" && !sessionManager) {
|
|
1133
1134
|
writeStartupNotice(parsedArgs, `${chalk.dim("Resume cancelled: session is in another project.")}\n`);
|
|
1134
|
-
|
|
1135
|
+
stopStartupWatchdog();
|
|
1136
|
+
process.exit(0);
|
|
1135
1137
|
}
|
|
1136
1138
|
|
|
1137
1139
|
// Handle --resume (no value): show session picker
|
|
@@ -1147,17 +1149,26 @@ export async function runRootCommand(
|
|
|
1147
1149
|
preloadedAllSessions = await logger.time("SessionManager.listAll", SessionManager.listAll);
|
|
1148
1150
|
if (preloadedAllSessions.length === 0) {
|
|
1149
1151
|
writeStartupNotice(parsedArgs, `${chalk.dim("No sessions found")}\n`);
|
|
1150
|
-
|
|
1152
|
+
stopStartupWatchdog();
|
|
1153
|
+
process.exit(0);
|
|
1151
1154
|
}
|
|
1152
1155
|
}
|
|
1153
1156
|
pauseStartupWatchdog();
|
|
1154
|
-
const selected = await logger.time("selectSession", selectSession, folderSessions, {
|
|
1157
|
+
const selected = await logger.time("selectSession", deps.selectSession ?? selectSession, folderSessions, {
|
|
1155
1158
|
allSessions: preloadedAllSessions,
|
|
1156
1159
|
});
|
|
1157
1160
|
resumeStartupWatchdog();
|
|
1158
1161
|
if (!selected) {
|
|
1159
1162
|
writeStartupNotice(parsedArgs, `${chalk.dim("No session selected")}\n`);
|
|
1160
|
-
|
|
1163
|
+
// Quit instead of returning: startup already armed long-lived handles
|
|
1164
|
+
// (theme watcher + SIGWINCH/macOS appearance listeners via initTheme,
|
|
1165
|
+
// settings save timer, model registry) that keep the event loop alive,
|
|
1166
|
+
// so a bare return hangs the process after the picker leaves the alt
|
|
1167
|
+
// screen. No session was built here, so there is nothing to flush. The
|
|
1168
|
+
// in-session `/resume` picker (selector-controller.ts) takes a different
|
|
1169
|
+
// onCancel that just closes the overlay — only this startup path exits.
|
|
1170
|
+
stopStartupWatchdog();
|
|
1171
|
+
process.exit(0);
|
|
1161
1172
|
}
|
|
1162
1173
|
// Resuming a session from another project: switch the process into that
|
|
1163
1174
|
// project's directory and refresh cwd-derived caches before the session is
|
package/src/mcp/tool-bridge.ts
CHANGED
|
@@ -58,6 +58,31 @@ function normalizeToolArgs(value: unknown): MCPToolArgs {
|
|
|
58
58
|
return value as MCPToolArgs;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
function isUnusedOptionalPlaceholder(value: unknown): boolean {
|
|
62
|
+
return (
|
|
63
|
+
value === undefined ||
|
|
64
|
+
value === "" ||
|
|
65
|
+
(typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length === 0)
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function omitUnusedOptionalArgs(args: MCPToolArgs, inputSchema: MCPToolDefinition["inputSchema"]): MCPToolArgs {
|
|
70
|
+
const properties = inputSchema.properties;
|
|
71
|
+
if (!properties) return args;
|
|
72
|
+
|
|
73
|
+
let cleaned: MCPToolArgs | undefined;
|
|
74
|
+
const required = new Set(inputSchema.required ?? []);
|
|
75
|
+
for (const [key, value] of Object.entries(args)) {
|
|
76
|
+
if (required.has(key) || !Object.hasOwn(properties, key) || !isUnusedOptionalPlaceholder(value)) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
cleaned ??= { ...args };
|
|
80
|
+
delete cleaned[key];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return cleaned ?? args;
|
|
84
|
+
}
|
|
85
|
+
|
|
61
86
|
/** Details included in MCP tool results for rendering */
|
|
62
87
|
export interface MCPToolDetails {
|
|
63
88
|
/** Server name */
|
|
@@ -261,7 +286,7 @@ export class MCPTool implements CustomTool<TSchema, MCPToolDetails> {
|
|
|
261
286
|
signal?: AbortSignal,
|
|
262
287
|
): Promise<CustomToolResult<MCPToolDetails>> {
|
|
263
288
|
throwIfAborted(signal);
|
|
264
|
-
const args = normalizeToolArgs(params);
|
|
289
|
+
const args = omitUnusedOptionalArgs(normalizeToolArgs(params), this.tool.inputSchema);
|
|
265
290
|
const provider = this.connection._source?.provider;
|
|
266
291
|
const providerName = this.connection._source?.providerName;
|
|
267
292
|
|
|
@@ -360,7 +385,7 @@ export class DeferredMCPTool implements CustomTool<TSchema, MCPToolDetails> {
|
|
|
360
385
|
signal?: AbortSignal,
|
|
361
386
|
): Promise<CustomToolResult<MCPToolDetails>> {
|
|
362
387
|
throwIfAborted(signal);
|
|
363
|
-
const args = normalizeToolArgs(params);
|
|
388
|
+
const args = omitUnusedOptionalArgs(normalizeToolArgs(params), this.tool.inputSchema);
|
|
364
389
|
const provider = this.#fallbackProvider;
|
|
365
390
|
const providerName = this.#fallbackProviderName;
|
|
366
391
|
|
package/src/mnemopi/state.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
composeRecallQuery,
|
|
10
10
|
formatCurrentTime,
|
|
11
11
|
prepareRetentionTranscript,
|
|
12
|
+
prepareUserRetentionTranscript,
|
|
12
13
|
truncateRecallQuery,
|
|
13
14
|
} from "../hindsight/content";
|
|
14
15
|
import { extractMessages } from "../hindsight/transcript";
|
|
@@ -352,6 +353,7 @@ export class MnemopiSessionState {
|
|
|
352
353
|
async retainMessages(messages: Array<{ role: string; content: string }>, sourceId: string): Promise<void> {
|
|
353
354
|
const { transcript, messageCount } = prepareRetentionTranscript(messages, true);
|
|
354
355
|
if (!transcript) return;
|
|
356
|
+
const { transcript: extractText } = prepareUserRetentionTranscript(messages);
|
|
355
357
|
this.rememberInScope(transcript, {
|
|
356
358
|
source: "coding-agent-transcript",
|
|
357
359
|
importance: 0.65,
|
|
@@ -362,8 +364,9 @@ export class MnemopiSessionState {
|
|
|
362
364
|
cwd: this.session.sessionManager.getCwd(),
|
|
363
365
|
},
|
|
364
366
|
scope: "bank",
|
|
365
|
-
extract:
|
|
366
|
-
extractEntities:
|
|
367
|
+
extract: extractText !== null,
|
|
368
|
+
extractEntities: extractText !== null,
|
|
369
|
+
extractText,
|
|
367
370
|
veracity: "unknown",
|
|
368
371
|
memoryType: "episode",
|
|
369
372
|
});
|
|
@@ -468,8 +468,13 @@ function buildEvalStartText(args: unknown): string | undefined {
|
|
|
468
468
|
if (typeof args !== "object" || args === null || Array.isArray(args)) {
|
|
469
469
|
return undefined;
|
|
470
470
|
}
|
|
471
|
-
const
|
|
472
|
-
|
|
471
|
+
const container = args as EvalCellContainer & EvalCellLike;
|
|
472
|
+
const cells = Array.isArray(container.cells)
|
|
473
|
+
? container.cells
|
|
474
|
+
: typeof container.code === "string"
|
|
475
|
+
? [container]
|
|
476
|
+
: [];
|
|
477
|
+
if (cells.length === 0) {
|
|
473
478
|
return undefined;
|
|
474
479
|
}
|
|
475
480
|
const lines: string[] = [];
|
|
@@ -7,16 +7,11 @@
|
|
|
7
7
|
* compositing into the live transcript's scrollback. It renders a parked
|
|
8
8
|
* subagent / advisor / collab-guest transcript that has no live in-view session.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* Local agents re-read the whole session file whenever its size or mtime changes
|
|
17
|
-
* (covering SessionManager's in-place rewrites, not just appends). Collab guests
|
|
18
|
-
* keep the incremental byte cursor the host's capped `readTranscript` requires
|
|
19
|
-
* and rebuild components from the accumulated entries.
|
|
10
|
+
* Local transcripts tail append-only growth: unchanged file identity plus stable
|
|
11
|
+
* sentinels means only newly appended JSONL is parsed and rendered. Rewrites,
|
|
12
|
+
* truncation, rotation, or sentinel drift fall back to a full rebuild so changed
|
|
13
|
+
* historical entries cannot leave stale components behind. Collab guests use the
|
|
14
|
+
* same append path over the host's byte-capped transcript reads.
|
|
20
15
|
*/
|
|
21
16
|
import * as fs from "node:fs";
|
|
22
17
|
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
|
|
@@ -64,6 +59,56 @@ export interface AgentTranscriptViewerDeps {
|
|
|
64
59
|
/** How often to re-stat a file-backed transcript for growth (advisor/live tail). */
|
|
65
60
|
const POLL_MS = 250;
|
|
66
61
|
|
|
62
|
+
const SENTINEL_BYTES = 4096;
|
|
63
|
+
|
|
64
|
+
interface LocalTranscriptSentinel {
|
|
65
|
+
offset: number;
|
|
66
|
+
bytes: Buffer;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface LocalTranscriptState {
|
|
70
|
+
path: string;
|
|
71
|
+
dev: number;
|
|
72
|
+
ino: number;
|
|
73
|
+
size: number;
|
|
74
|
+
mtimeMs: number;
|
|
75
|
+
offset: number;
|
|
76
|
+
pending: string;
|
|
77
|
+
sentinels: LocalTranscriptSentinel[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function readFileRangeSync(file: string, offset: number, length: number): Buffer {
|
|
81
|
+
if (length <= 0) return Buffer.alloc(0);
|
|
82
|
+
const fd = fs.openSync(file, "r");
|
|
83
|
+
try {
|
|
84
|
+
const buffer = Buffer.alloc(length);
|
|
85
|
+
const bytesRead = fs.readSync(fd, buffer, 0, length, offset);
|
|
86
|
+
return bytesRead === length ? buffer : buffer.subarray(0, bytesRead);
|
|
87
|
+
} finally {
|
|
88
|
+
fs.closeSync(fd);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function sentinelOffsets(size: number): number[] {
|
|
93
|
+
if (size <= 0) return [];
|
|
94
|
+
const length = Math.min(SENTINEL_BYTES, size);
|
|
95
|
+
return [...new Set([0, Math.max(0, Math.floor((size - length) / 2)), Math.max(0, size - length)])];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function sentinelsFromBuffer(buffer: Buffer): LocalTranscriptSentinel[] {
|
|
99
|
+
const size = buffer.byteLength;
|
|
100
|
+
const length = Math.min(SENTINEL_BYTES, size);
|
|
101
|
+
return sentinelOffsets(size).map(offset => ({
|
|
102
|
+
offset,
|
|
103
|
+
bytes: Buffer.from(buffer.subarray(offset, offset + length)),
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function sentinelsFromFile(file: string, size: number): LocalTranscriptSentinel[] {
|
|
108
|
+
const length = Math.min(SENTINEL_BYTES, size);
|
|
109
|
+
return sentinelOffsets(size).map(offset => ({ offset, bytes: readFileRangeSync(file, offset, length) }));
|
|
110
|
+
}
|
|
111
|
+
|
|
67
112
|
function statusBadge(status: AgentStatus): string {
|
|
68
113
|
switch (status) {
|
|
69
114
|
case "running":
|
|
@@ -85,10 +130,9 @@ export class AgentTranscriptViewer implements Component {
|
|
|
85
130
|
#notice: string | undefined;
|
|
86
131
|
#expanded = false;
|
|
87
132
|
|
|
88
|
-
|
|
89
|
-
#
|
|
133
|
+
#localState: LocalTranscriptState | undefined;
|
|
134
|
+
#localUnavailable = "";
|
|
90
135
|
// Remote transcript state (incremental; the host caps each read).
|
|
91
|
-
#remoteEntries: SessionMessageEntry[] = [];
|
|
92
136
|
#remoteBytes = 0;
|
|
93
137
|
#remoteFetchInFlight = false;
|
|
94
138
|
#remoteToken = 0;
|
|
@@ -145,7 +189,7 @@ export class AgentTranscriptViewer implements Component {
|
|
|
145
189
|
// Transcript loading
|
|
146
190
|
// ========================================================================
|
|
147
191
|
|
|
148
|
-
/**
|
|
192
|
+
/** Refresh the transcript from a local file or remote host. */
|
|
149
193
|
#refresh(): void {
|
|
150
194
|
if (this.#disposed) return;
|
|
151
195
|
if (this.deps.remote) {
|
|
@@ -154,39 +198,132 @@ export class AgentTranscriptViewer implements Component {
|
|
|
154
198
|
}
|
|
155
199
|
const sessionFile = this.deps.registry.get(this.deps.agentId)?.sessionFile;
|
|
156
200
|
if (!sessionFile) {
|
|
157
|
-
|
|
158
|
-
this.#lastSignature = "none";
|
|
159
|
-
this.#rebuild([]);
|
|
160
|
-
}
|
|
201
|
+
this.#clearLocal("none");
|
|
161
202
|
return;
|
|
162
203
|
}
|
|
163
|
-
let
|
|
204
|
+
let stat: fs.Stats;
|
|
164
205
|
try {
|
|
165
|
-
|
|
166
|
-
// Include the path: a different file with the same size/mtime must not alias.
|
|
167
|
-
signature = `${sessionFile}:${stat.size}:${stat.mtimeMs}`;
|
|
206
|
+
stat = fs.statSync(sessionFile);
|
|
168
207
|
} catch {
|
|
169
|
-
|
|
170
|
-
// clear stale content once instead of freezing on it forever.
|
|
171
|
-
if (this.#lastSignature !== "missing") {
|
|
172
|
-
this.#lastSignature = "missing";
|
|
173
|
-
this.#model = undefined;
|
|
174
|
-
this.#rebuild([]);
|
|
175
|
-
}
|
|
208
|
+
this.#clearLocal("missing");
|
|
176
209
|
return;
|
|
177
210
|
}
|
|
178
|
-
|
|
179
|
-
|
|
211
|
+
const state = this.#localState;
|
|
212
|
+
if (state && this.#canAppendLocal(sessionFile, stat, state)) {
|
|
213
|
+
if (stat.size === state.size && stat.mtimeMs === state.mtimeMs) return;
|
|
214
|
+
if (stat.size > state.size) {
|
|
215
|
+
this.#appendLocal(sessionFile, stat, state);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
this.#loadLocalFull(sessionFile, stat);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
#clearLocal(reason: string): void {
|
|
223
|
+
if (!this.#localState && this.#localUnavailable === reason) return;
|
|
224
|
+
this.#localState = undefined;
|
|
225
|
+
this.#localUnavailable = reason;
|
|
226
|
+
this.#model = undefined;
|
|
227
|
+
this.#rebuild([]);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
#canAppendLocal(sessionFile: string, stat: fs.Stats, state: LocalTranscriptState): boolean {
|
|
231
|
+
if (state.path !== sessionFile || state.dev !== stat.dev || state.ino !== stat.ino || stat.size < state.size)
|
|
232
|
+
return false;
|
|
233
|
+
for (const sentinel of state.sentinels) {
|
|
234
|
+
let current: Buffer;
|
|
235
|
+
try {
|
|
236
|
+
current = readFileRangeSync(sessionFile, sentinel.offset, sentinel.bytes.byteLength);
|
|
237
|
+
} catch (err) {
|
|
238
|
+
// The file can be unlinked/rotated between statSync and this read.
|
|
239
|
+
// Treat as not-appendable so #refresh falls back to a guarded full load.
|
|
240
|
+
logger.debug("transcript viewer: sentinel read failed", { err: String(err) });
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
if (!current.equals(sentinel.bytes)) return false;
|
|
244
|
+
}
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
#loadLocalFull(sessionFile: string, stat: fs.Stats): void {
|
|
249
|
+
let data: Buffer;
|
|
180
250
|
try {
|
|
181
|
-
|
|
251
|
+
data = fs.readFileSync(sessionFile);
|
|
182
252
|
} catch (err) {
|
|
183
|
-
// Leave #
|
|
253
|
+
// Leave #localState unchanged so a transient read error retries next poll.
|
|
184
254
|
logger.debug("transcript viewer: read failed", { err: String(err) });
|
|
185
255
|
return;
|
|
186
256
|
}
|
|
187
|
-
this
|
|
257
|
+
// The file may have grown between the earlier `statSync` and this read.
|
|
258
|
+
// Anchor the tail cursor to what we actually consumed so the next poll's
|
|
259
|
+
// `#appendLocal` never re-renders bytes already in the rebuilt transcript;
|
|
260
|
+
// re-stat for mtime/identity so the post-read clock matches what's on disk.
|
|
261
|
+
let post: fs.Stats;
|
|
262
|
+
try {
|
|
263
|
+
post = fs.statSync(sessionFile);
|
|
264
|
+
} catch {
|
|
265
|
+
post = stat;
|
|
266
|
+
}
|
|
267
|
+
// A reader that opens the file mid-append sees a trailing partial line
|
|
268
|
+
// (no terminating newline). Carry those bytes as `pending` so the next
|
|
269
|
+
// poll's `#appendLocal` joins them with the completion bytes instead of
|
|
270
|
+
// parsing a headless line fragment and dropping the entry.
|
|
271
|
+
const text = data.toString("utf-8");
|
|
272
|
+
const lastNewline = text.lastIndexOf("\n");
|
|
273
|
+
const complete = lastNewline >= 0 ? text.slice(0, lastNewline + 1) : "";
|
|
274
|
+
const pending = lastNewline >= 0 ? text.slice(lastNewline + 1) : text;
|
|
275
|
+
this.#localUnavailable = "";
|
|
276
|
+
this.#localState = {
|
|
277
|
+
path: sessionFile,
|
|
278
|
+
dev: post.dev,
|
|
279
|
+
ino: post.ino,
|
|
280
|
+
size: data.byteLength,
|
|
281
|
+
mtimeMs: post.mtimeMs,
|
|
282
|
+
offset: data.byteLength,
|
|
283
|
+
pending,
|
|
284
|
+
sentinels: sentinelsFromBuffer(data),
|
|
285
|
+
};
|
|
188
286
|
this.#model = undefined;
|
|
189
|
-
this.#rebuild(this.#extractMessages(parseSessionEntries(
|
|
287
|
+
this.#rebuild(this.#extractMessages(parseSessionEntries(complete)));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
#appendLocal(sessionFile: string, stat: fs.Stats, state: LocalTranscriptState): void {
|
|
291
|
+
let chunk: string;
|
|
292
|
+
try {
|
|
293
|
+
chunk = readFileRangeSync(sessionFile, state.offset, stat.size - state.offset).toString("utf-8");
|
|
294
|
+
} catch (err) {
|
|
295
|
+
logger.debug("transcript viewer: tail read failed", { err: String(err) });
|
|
296
|
+
this.#loadLocalFull(sessionFile, stat);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const combined = state.pending + chunk;
|
|
300
|
+
const lastNewline = combined.lastIndexOf("\n");
|
|
301
|
+
const complete = lastNewline >= 0 ? combined.slice(0, lastNewline + 1) : "";
|
|
302
|
+
const previousModel = this.#model;
|
|
303
|
+
const parsed = complete ? this.#extractMessages(parseSessionEntries(complete)) : [];
|
|
304
|
+
let sentinels: LocalTranscriptSentinel[];
|
|
305
|
+
try {
|
|
306
|
+
sentinels = sentinelsFromFile(sessionFile, stat.size);
|
|
307
|
+
} catch (err) {
|
|
308
|
+
// File unlinked/rotated mid-poll: fall back to a guarded full reload
|
|
309
|
+
// instead of letting the open escape the poll timer.
|
|
310
|
+
logger.debug("transcript viewer: sentinel recompute failed", { err: String(err) });
|
|
311
|
+
this.#loadLocalFull(sessionFile, stat);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
this.#localState = {
|
|
315
|
+
...state,
|
|
316
|
+
size: stat.size,
|
|
317
|
+
mtimeMs: stat.mtimeMs,
|
|
318
|
+
offset: stat.size,
|
|
319
|
+
pending: lastNewline >= 0 ? combined.slice(lastNewline + 1) : combined,
|
|
320
|
+
sentinels,
|
|
321
|
+
};
|
|
322
|
+
if (parsed.length > 0) {
|
|
323
|
+
this.#append(parsed);
|
|
324
|
+
} else if (this.#model !== previousModel) {
|
|
325
|
+
this.deps.requestRender();
|
|
326
|
+
}
|
|
190
327
|
}
|
|
191
328
|
|
|
192
329
|
#fetchRemote(): void {
|
|
@@ -209,9 +346,13 @@ export class AgentTranscriptViewer implements Component {
|
|
|
209
346
|
return;
|
|
210
347
|
}
|
|
211
348
|
if (result.newSize < fromByte) {
|
|
212
|
-
// Host transcript rotated/truncated —
|
|
349
|
+
// Host transcript rotated/truncated — drop the stale rendered rows
|
|
350
|
+
// before restarting; otherwise the post-rotation fetch would stack
|
|
351
|
+
// new content under the pre-rotation history.
|
|
213
352
|
this.#remoteBytes = 0;
|
|
214
|
-
this.#
|
|
353
|
+
this.#hasRemoteData = false;
|
|
354
|
+
this.#model = undefined;
|
|
355
|
+
this.#rebuild([]);
|
|
215
356
|
this.#fetchRemote();
|
|
216
357
|
return;
|
|
217
358
|
}
|
|
@@ -222,10 +363,14 @@ export class AgentTranscriptViewer implements Component {
|
|
|
222
363
|
if (lastNewline >= 0) {
|
|
223
364
|
const completeChunk = result.text.slice(0, lastNewline + 1);
|
|
224
365
|
this.#remoteBytes = fromByte + Buffer.byteLength(completeChunk, "utf-8");
|
|
366
|
+
const previousModel = this.#model;
|
|
225
367
|
const parsed = this.#extractMessages(parseSessionEntries(completeChunk));
|
|
226
368
|
if (parsed.length > 0) {
|
|
227
|
-
this.#
|
|
228
|
-
|
|
369
|
+
this.#append(parsed);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (this.#model !== previousModel) {
|
|
373
|
+
this.deps.requestRender();
|
|
229
374
|
return;
|
|
230
375
|
}
|
|
231
376
|
}
|
|
@@ -257,6 +402,11 @@ export class AgentTranscriptViewer implements Component {
|
|
|
257
402
|
this.deps.requestRender();
|
|
258
403
|
}
|
|
259
404
|
|
|
405
|
+
#append(entries: SessionMessageEntry[]): void {
|
|
406
|
+
this.#builder.append(entries);
|
|
407
|
+
this.deps.requestRender();
|
|
408
|
+
}
|
|
409
|
+
|
|
260
410
|
// ========================================================================
|
|
261
411
|
// Input
|
|
262
412
|
// ========================================================================
|
|
@@ -455,8 +605,10 @@ export class AgentTranscriptViewer implements Component {
|
|
|
455
605
|
}
|
|
456
606
|
|
|
457
607
|
#placeholder(): string {
|
|
458
|
-
if (this.deps.remote
|
|
459
|
-
|
|
608
|
+
if (this.deps.remote) {
|
|
609
|
+
if (this.#remoteUnavailable) return "Transcript lives on the host — not available.";
|
|
610
|
+
return this.#hasRemoteData ? "No messages yet." : "Loading transcript from host…";
|
|
611
|
+
}
|
|
460
612
|
if (!this.deps.registry.get(this.deps.agentId)?.sessionFile) return "No session file available yet.";
|
|
461
613
|
return "No messages yet.";
|
|
462
614
|
}
|
|
@@ -103,6 +103,12 @@ export class ChatTranscriptBuilder {
|
|
|
103
103
|
if (this.#readArgs.size === 0 && this.#pendingTools.size === 0) this.#flushPendingUsage();
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/** Append newly persisted entries without rebuilding already rendered rows. */
|
|
107
|
+
append(entries: SessionMessageEntry[]): void {
|
|
108
|
+
for (const entry of entries) this.#appendChatMessage(entry.message);
|
|
109
|
+
if (this.#readArgs.size === 0 && this.#pendingTools.size === 0) this.#flushPendingUsage();
|
|
110
|
+
}
|
|
111
|
+
|
|
106
112
|
/** Toggle tool-output expansion across every expandable component. */
|
|
107
113
|
setExpanded(expanded: boolean): void {
|
|
108
114
|
this.#expanded = expanded;
|