@oh-my-pi/pi-coding-agent 5.5.0 → 5.6.7
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 +98 -0
- package/docs/python-repl.md +77 -0
- package/examples/hooks/snake.ts +7 -7
- package/package.json +5 -5
- package/src/bun-imports.d.ts +6 -0
- package/src/cli/args.ts +7 -0
- package/src/cli/setup-cli.ts +231 -0
- package/src/cli.ts +2 -0
- package/src/core/agent-session.ts +118 -15
- package/src/core/bash-executor.ts +3 -84
- package/src/core/compaction/compaction.ts +10 -5
- package/src/core/extensions/index.ts +2 -0
- package/src/core/extensions/loader.ts +13 -1
- package/src/core/extensions/runner.ts +50 -2
- package/src/core/extensions/types.ts +67 -2
- package/src/core/keybindings.ts +51 -1
- package/src/core/prompt-templates.ts +15 -0
- package/src/core/python-executor-display.test.ts +42 -0
- package/src/core/python-executor-lifecycle.test.ts +99 -0
- package/src/core/python-executor-mapping.test.ts +41 -0
- package/src/core/python-executor-per-call.test.ts +49 -0
- package/src/core/python-executor-session.test.ts +103 -0
- package/src/core/python-executor-streaming.test.ts +77 -0
- package/src/core/python-executor-timeout.test.ts +35 -0
- package/src/core/python-executor.lifecycle.test.ts +139 -0
- package/src/core/python-executor.result.test.ts +49 -0
- package/src/core/python-executor.test.ts +180 -0
- package/src/core/python-executor.ts +313 -0
- package/src/core/python-gateway-coordinator.ts +832 -0
- package/src/core/python-kernel-display.test.ts +54 -0
- package/src/core/python-kernel-env.test.ts +138 -0
- package/src/core/python-kernel-session.test.ts +87 -0
- package/src/core/python-kernel-ws.test.ts +104 -0
- package/src/core/python-kernel.lifecycle.test.ts +249 -0
- package/src/core/python-kernel.test.ts +549 -0
- package/src/core/python-kernel.ts +1178 -0
- package/src/core/python-prelude.py +889 -0
- package/src/core/python-prelude.test.ts +140 -0
- package/src/core/python-prelude.ts +3 -0
- package/src/core/sdk.ts +24 -6
- package/src/core/session-manager.ts +174 -82
- package/src/core/settings-manager-python.test.ts +23 -0
- package/src/core/settings-manager.ts +202 -0
- package/src/core/streaming-output.test.ts +26 -0
- package/src/core/streaming-output.ts +100 -0
- package/src/core/system-prompt.python.test.ts +17 -0
- package/src/core/system-prompt.ts +3 -1
- package/src/core/timings.ts +1 -1
- package/src/core/tools/bash.ts +13 -2
- package/src/core/tools/edit-diff.ts +9 -1
- package/src/core/tools/index.test.ts +50 -23
- package/src/core/tools/index.ts +83 -1
- package/src/core/tools/python-execution.test.ts +68 -0
- package/src/core/tools/python-fallback.test.ts +72 -0
- package/src/core/tools/python-renderer.test.ts +36 -0
- package/src/core/tools/python-tool-mode.test.ts +43 -0
- package/src/core/tools/python.test.ts +121 -0
- package/src/core/tools/python.ts +760 -0
- package/src/core/tools/renderers.ts +2 -0
- package/src/core/tools/schema-validation.test.ts +1 -0
- package/src/core/tools/task/executor.ts +146 -3
- package/src/core/tools/task/worker-protocol.ts +32 -2
- package/src/core/tools/task/worker.ts +182 -15
- package/src/index.ts +6 -0
- package/src/main.ts +136 -40
- package/src/modes/interactive/components/custom-editor.ts +16 -31
- package/src/modes/interactive/components/extensions/extension-dashboard.ts +5 -16
- package/src/modes/interactive/components/extensions/extension-list.ts +5 -13
- package/src/modes/interactive/components/history-search.ts +5 -8
- package/src/modes/interactive/components/hook-editor.ts +3 -4
- package/src/modes/interactive/components/hook-input.ts +3 -3
- package/src/modes/interactive/components/hook-selector.ts +5 -15
- package/src/modes/interactive/components/index.ts +1 -0
- package/src/modes/interactive/components/keybinding-hints.ts +66 -0
- package/src/modes/interactive/components/model-selector.ts +53 -66
- package/src/modes/interactive/components/oauth-selector.ts +5 -5
- package/src/modes/interactive/components/session-selector.ts +29 -23
- package/src/modes/interactive/components/settings-defs.ts +404 -196
- package/src/modes/interactive/components/settings-selector.ts +14 -10
- package/src/modes/interactive/components/status-line-segment-editor.ts +7 -7
- package/src/modes/interactive/components/tool-execution.ts +8 -0
- package/src/modes/interactive/components/tree-selector.ts +29 -23
- package/src/modes/interactive/components/user-message-selector.ts +6 -17
- package/src/modes/interactive/controllers/command-controller.ts +86 -37
- package/src/modes/interactive/controllers/event-controller.ts +8 -0
- package/src/modes/interactive/controllers/extension-ui-controller.ts +51 -0
- package/src/modes/interactive/controllers/input-controller.ts +42 -6
- package/src/modes/interactive/interactive-mode.ts +56 -30
- package/src/modes/interactive/theme/theme-schema.json +2 -2
- package/src/modes/interactive/types.ts +6 -1
- package/src/modes/interactive/utils/ui-helpers.ts +2 -1
- package/src/modes/print-mode.ts +23 -0
- package/src/modes/rpc/rpc-mode.ts +21 -0
- package/src/prompts/agents/reviewer.md +1 -1
- package/src/prompts/system/system-prompt.md +32 -1
- package/src/prompts/tools/python.md +91 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { resetPreludeDocsCache, warmPythonEnvironment } from "./python-executor";
|
|
5
|
+
import { createPythonTool, getPythonToolDescription } from "./tools/python";
|
|
6
|
+
|
|
7
|
+
const resolvePythonPath = (): string | null => {
|
|
8
|
+
const venvPath = process.env.VIRTUAL_ENV;
|
|
9
|
+
const candidates = [venvPath, join(process.cwd(), ".venv"), join(process.cwd(), "venv")].filter(Boolean) as string[];
|
|
10
|
+
for (const candidate of candidates) {
|
|
11
|
+
const binDir = process.platform === "win32" ? "Scripts" : "bin";
|
|
12
|
+
const exeName = process.platform === "win32" ? "python.exe" : "python";
|
|
13
|
+
const pythonCandidate = join(candidate, binDir, exeName);
|
|
14
|
+
if (existsSync(pythonCandidate)) {
|
|
15
|
+
return pythonCandidate;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return Bun.which("python") ?? Bun.which("python3");
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const pythonPath = resolvePythonPath();
|
|
22
|
+
const hasKernelDeps = (() => {
|
|
23
|
+
if (!pythonPath) return false;
|
|
24
|
+
const result = Bun.spawnSync(
|
|
25
|
+
[
|
|
26
|
+
pythonPath,
|
|
27
|
+
"-c",
|
|
28
|
+
"import importlib.util,sys;sys.exit(0 if importlib.util.find_spec('kernel_gateway') and importlib.util.find_spec('ipykernel') else 1)",
|
|
29
|
+
],
|
|
30
|
+
{ stdin: "ignore", stdout: "pipe", stderr: "pipe" },
|
|
31
|
+
);
|
|
32
|
+
return result.exitCode === 0;
|
|
33
|
+
})();
|
|
34
|
+
|
|
35
|
+
const shouldRun = Boolean(pythonPath) && hasKernelDeps;
|
|
36
|
+
|
|
37
|
+
describe.skipIf(!shouldRun)("PYTHON_PRELUDE integration", () => {
|
|
38
|
+
it("exposes prelude helpers via python tool", async () => {
|
|
39
|
+
const helpers = [
|
|
40
|
+
"pwd",
|
|
41
|
+
"cd",
|
|
42
|
+
"env",
|
|
43
|
+
"read",
|
|
44
|
+
"write",
|
|
45
|
+
"append",
|
|
46
|
+
"mkdir",
|
|
47
|
+
"rm",
|
|
48
|
+
"mv",
|
|
49
|
+
"cp",
|
|
50
|
+
"ls",
|
|
51
|
+
"cat",
|
|
52
|
+
"touch",
|
|
53
|
+
"find",
|
|
54
|
+
"grep",
|
|
55
|
+
"rgrep",
|
|
56
|
+
"head",
|
|
57
|
+
"tail",
|
|
58
|
+
"replace",
|
|
59
|
+
"sed",
|
|
60
|
+
"rsed",
|
|
61
|
+
"wc",
|
|
62
|
+
"sort_lines",
|
|
63
|
+
"uniq",
|
|
64
|
+
"cols",
|
|
65
|
+
"tree",
|
|
66
|
+
"stat",
|
|
67
|
+
"diff",
|
|
68
|
+
"glob_files",
|
|
69
|
+
"batch",
|
|
70
|
+
"lines",
|
|
71
|
+
"delete_lines",
|
|
72
|
+
"delete_matching",
|
|
73
|
+
"insert_at",
|
|
74
|
+
"git_status",
|
|
75
|
+
"git_diff",
|
|
76
|
+
"git_log",
|
|
77
|
+
"git_show",
|
|
78
|
+
"git_file_at",
|
|
79
|
+
"git_branch",
|
|
80
|
+
"git_has_changes",
|
|
81
|
+
"run",
|
|
82
|
+
"sh",
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
const session = {
|
|
86
|
+
cwd: process.cwd(),
|
|
87
|
+
hasUI: false,
|
|
88
|
+
getSessionFile: () => null,
|
|
89
|
+
getSessionSpawns: () => null,
|
|
90
|
+
settings: {
|
|
91
|
+
getImageAutoResize: () => true,
|
|
92
|
+
getLspFormatOnWrite: () => false,
|
|
93
|
+
getLspDiagnosticsOnWrite: () => false,
|
|
94
|
+
getLspDiagnosticsOnEdit: () => false,
|
|
95
|
+
getEditFuzzyMatch: () => true,
|
|
96
|
+
getGitToolEnabled: () => true,
|
|
97
|
+
getBashInterceptorEnabled: () => true,
|
|
98
|
+
getBashInterceptorSimpleLsEnabled: () => true,
|
|
99
|
+
getBashInterceptorRules: () => [],
|
|
100
|
+
getPythonToolMode: () => "ipy-only" as const,
|
|
101
|
+
getPythonKernelMode: () => "per-call" as const,
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const tool = createPythonTool(session);
|
|
106
|
+
const code = `
|
|
107
|
+
helpers = ${JSON.stringify(helpers)}
|
|
108
|
+
missing = [name for name in helpers if name not in globals() or not callable(globals()[name])]
|
|
109
|
+
docs = __omp_prelude_docs__()
|
|
110
|
+
doc_names = [d.get("name") for d in docs]
|
|
111
|
+
doc_categories = [d.get("category") for d in docs]
|
|
112
|
+
print("HELPERS_OK=" + ("1" if not missing else "0"))
|
|
113
|
+
print("DOCS_OK=" + ("1" if "pwd" in doc_names and "Navigation" in doc_categories else "0"))
|
|
114
|
+
if missing:
|
|
115
|
+
print("MISSING=" + ",".join(missing))
|
|
116
|
+
`;
|
|
117
|
+
|
|
118
|
+
const result = await tool.execute("tool-call-1", { code });
|
|
119
|
+
const output = result.content.find((item) => item.type === "text")?.text ?? "";
|
|
120
|
+
expect(output).toContain("HELPERS_OK=1");
|
|
121
|
+
expect(output).toContain("DOCS_OK=1");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("exposes prelude docs via warmup", async () => {
|
|
125
|
+
resetPreludeDocsCache();
|
|
126
|
+
const result = await warmPythonEnvironment(process.cwd(), undefined, false);
|
|
127
|
+
expect(result.ok).toBe(true);
|
|
128
|
+
const names = result.docs.map((doc) => doc.name);
|
|
129
|
+
expect(names).toContain("pwd");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("renders prelude docs in python tool description", async () => {
|
|
133
|
+
resetPreludeDocsCache();
|
|
134
|
+
const result = await warmPythonEnvironment(process.cwd(), undefined, false);
|
|
135
|
+
expect(result.ok).toBe(true);
|
|
136
|
+
const description = getPythonToolDescription();
|
|
137
|
+
expect(description).toContain("pwd");
|
|
138
|
+
expect(description).not.toContain("Documentation unavailable");
|
|
139
|
+
});
|
|
140
|
+
});
|
package/src/core/sdk.ts
CHANGED
|
@@ -66,6 +66,7 @@ import { convertToLlm } from "./messages";
|
|
|
66
66
|
import { ModelRegistry } from "./model-registry";
|
|
67
67
|
import { formatModelString, parseModelString } from "./model-resolver";
|
|
68
68
|
import { loadPromptTemplates as loadPromptTemplatesInternal, type PromptTemplate } from "./prompt-templates";
|
|
69
|
+
import { disposeAllKernelSessions } from "./python-executor";
|
|
69
70
|
import { SessionManager } from "./session-manager";
|
|
70
71
|
import { type Settings, SettingsManager, type SkillsSettings } from "./settings-manager";
|
|
71
72
|
import { loadSkills as loadSkillsInternal, type Skill, type SkillWarning } from "./skills";
|
|
@@ -88,6 +89,7 @@ import {
|
|
|
88
89
|
createGitTool,
|
|
89
90
|
createGrepTool,
|
|
90
91
|
createLsTool,
|
|
92
|
+
createPythonTool,
|
|
91
93
|
createReadTool,
|
|
92
94
|
createSshTool,
|
|
93
95
|
createTools,
|
|
@@ -217,6 +219,7 @@ export {
|
|
|
217
219
|
// Individual tool factories (for custom usage)
|
|
218
220
|
createReadTool,
|
|
219
221
|
createBashTool,
|
|
222
|
+
createPythonTool,
|
|
220
223
|
createSshTool,
|
|
221
224
|
createEditTool,
|
|
222
225
|
createWriteTool,
|
|
@@ -441,6 +444,16 @@ function registerSshCleanup(): void {
|
|
|
441
444
|
registerAsyncCleanup(() => cleanupSshResources());
|
|
442
445
|
}
|
|
443
446
|
|
|
447
|
+
let pythonCleanupRegistered = false;
|
|
448
|
+
|
|
449
|
+
function registerPythonCleanup(): void {
|
|
450
|
+
if (pythonCleanupRegistered) return;
|
|
451
|
+
pythonCleanupRegistered = true;
|
|
452
|
+
registerAsyncCleanup(async () => {
|
|
453
|
+
await disposeAllKernelSessions();
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
444
457
|
function customToolToDefinition(tool: CustomTool): ToolDefinition {
|
|
445
458
|
const definition: ToolDefinition & { [TOOL_DEFINITION_MARKER]: true } = {
|
|
446
459
|
name: tool.name,
|
|
@@ -541,6 +554,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
541
554
|
const eventBus = options.eventBus ?? createEventBus();
|
|
542
555
|
|
|
543
556
|
registerSshCleanup();
|
|
557
|
+
registerPythonCleanup();
|
|
544
558
|
|
|
545
559
|
// Use provided or create AuthStorage and ModelRegistry
|
|
546
560
|
const authStorage = options.authStorage ?? (await discoverAuthStorage(agentDir));
|
|
@@ -726,7 +740,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
726
740
|
|
|
727
741
|
// Log MCP errors
|
|
728
742
|
for (const { path, error } of mcpResult.errors) {
|
|
729
|
-
|
|
743
|
+
logger.error("MCP tool load failed", { path, error });
|
|
730
744
|
}
|
|
731
745
|
|
|
732
746
|
if (mcpResult.tools.length > 0) {
|
|
@@ -784,7 +798,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
784
798
|
);
|
|
785
799
|
time("discoverAndLoadExtensions");
|
|
786
800
|
for (const { path, error } of extensionsResult.errors) {
|
|
787
|
-
|
|
801
|
+
logger.error("Failed to load extension", { path, error });
|
|
788
802
|
}
|
|
789
803
|
}
|
|
790
804
|
|
|
@@ -804,10 +818,14 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
804
818
|
}
|
|
805
819
|
|
|
806
820
|
// Discover custom commands (TypeScript slash commands)
|
|
807
|
-
const customCommandsResult =
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
821
|
+
const customCommandsResult: CustomCommandsLoadResult = options.disableExtensionDiscovery
|
|
822
|
+
? { commands: [], errors: [] }
|
|
823
|
+
: await loadCustomCommandsInternal({ cwd, agentDir });
|
|
824
|
+
if (!options.disableExtensionDiscovery) {
|
|
825
|
+
time("discoverCustomCommands");
|
|
826
|
+
for (const { path, error } of customCommandsResult.errors) {
|
|
827
|
+
logger.error("Failed to load custom command", { path, error });
|
|
828
|
+
}
|
|
811
829
|
}
|
|
812
830
|
|
|
813
831
|
let extensionRunner: ExtensionRunner | undefined;
|
|
@@ -456,8 +456,8 @@ export function loadEntriesFromFile(filePath: string, storage: SessionStorage =
|
|
|
456
456
|
|
|
457
457
|
// Validate session header
|
|
458
458
|
if (entries.length === 0) return entries;
|
|
459
|
-
const header = entries[0];
|
|
460
|
-
if (header.type !== "session" || typeof
|
|
459
|
+
const header = entries[0] as SessionHeader;
|
|
460
|
+
if (header.type !== "session" || typeof header.id !== "string") {
|
|
461
461
|
return [];
|
|
462
462
|
}
|
|
463
463
|
|
|
@@ -468,6 +468,14 @@ export function loadEntriesFromFile(filePath: string, storage: SessionStorage =
|
|
|
468
468
|
* Lightweight metadata for a session file, used in session picker UI.
|
|
469
469
|
* Uses lazy getters to defer string formatting until actually displayed.
|
|
470
470
|
*/
|
|
471
|
+
function sanitizeSessionName(value: string | undefined): string | undefined {
|
|
472
|
+
if (!value) return undefined;
|
|
473
|
+
const firstLine = value.split(/\r?\n/)[0] ?? "";
|
|
474
|
+
const stripped = firstLine.replace(/[\x00-\x1F\x7F]/g, "");
|
|
475
|
+
const trimmed = stripped.trim();
|
|
476
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
477
|
+
}
|
|
478
|
+
|
|
471
479
|
class RecentSessionInfo {
|
|
472
480
|
readonly path: string;
|
|
473
481
|
readonly mtime: number;
|
|
@@ -476,13 +484,16 @@ class RecentSessionInfo {
|
|
|
476
484
|
#name: string | undefined;
|
|
477
485
|
#timeAgo: string | undefined;
|
|
478
486
|
|
|
479
|
-
constructor(path: string, mtime: number, header: Record<string, unknown
|
|
487
|
+
constructor(path: string, mtime: number, header: Record<string, unknown>, firstPrompt?: string) {
|
|
480
488
|
this.path = path;
|
|
481
489
|
this.mtime = mtime;
|
|
482
490
|
|
|
483
|
-
// Extract title from session header, falling back to
|
|
491
|
+
// Extract title from session header, falling back to first user prompt, then id
|
|
484
492
|
const trystr = (v: unknown) => (typeof v === "string" ? v : undefined);
|
|
485
|
-
this.#fullName =
|
|
493
|
+
this.#fullName =
|
|
494
|
+
sanitizeSessionName(trystr(header.title)) ??
|
|
495
|
+
sanitizeSessionName(firstPrompt) ??
|
|
496
|
+
sanitizeSessionName(trystr(header.id));
|
|
486
497
|
}
|
|
487
498
|
|
|
488
499
|
/** Full session name from header, or filename without extension as fallback */
|
|
@@ -508,26 +519,58 @@ class RecentSessionInfo {
|
|
|
508
519
|
}
|
|
509
520
|
}
|
|
510
521
|
|
|
522
|
+
/**
|
|
523
|
+
* Extracts the text content from a user message entry.
|
|
524
|
+
* Returns undefined if the entry is not a user message or has no text.
|
|
525
|
+
*/
|
|
526
|
+
function extractFirstUserPrompt(lines: string[]): string | undefined {
|
|
527
|
+
for (let i = 1; i < lines.length; i++) {
|
|
528
|
+
const line = lines[i];
|
|
529
|
+
if (!line?.trim()) continue;
|
|
530
|
+
try {
|
|
531
|
+
const entry = JSON.parse(line) as Record<string, unknown>;
|
|
532
|
+
if (entry.type !== "message") continue;
|
|
533
|
+
const message = entry.message as Record<string, unknown> | undefined;
|
|
534
|
+
if (message?.role !== "user") continue;
|
|
535
|
+
const content = message.content;
|
|
536
|
+
if (typeof content === "string") return content;
|
|
537
|
+
if (Array.isArray(content)) {
|
|
538
|
+
for (const block of content) {
|
|
539
|
+
if (typeof block === "object" && block !== null && "text" in block) {
|
|
540
|
+
const text = (block as { text: unknown }).text;
|
|
541
|
+
if (typeof text === "string") return text;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
} catch {
|
|
546
|
+
// Invalid JSON, skip to next line
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return undefined;
|
|
550
|
+
}
|
|
551
|
+
|
|
511
552
|
/**
|
|
512
553
|
* Reads all session files from the directory and returns them sorted by mtime (newest first).
|
|
513
|
-
* Uses low-level file I/O to efficiently read only the first
|
|
514
|
-
* to extract the JSON header without loading entire session logs into memory.
|
|
554
|
+
* Uses low-level file I/O to efficiently read only the first 4KB of each file
|
|
555
|
+
* to extract the JSON header and first user message without loading entire session logs into memory.
|
|
515
556
|
*/
|
|
516
557
|
function getSortedSessions(sessionDir: string, storage: SessionStorage): RecentSessionInfo[] {
|
|
517
558
|
try {
|
|
518
|
-
const buf = Buffer.alloc(
|
|
559
|
+
const buf = Buffer.alloc(4096);
|
|
519
560
|
const files: string[] = storage.listFilesSync(sessionDir, "*.jsonl");
|
|
520
561
|
return files
|
|
521
562
|
.map((path: string) => {
|
|
522
563
|
try {
|
|
523
564
|
const length = storage.readTextPrefixSync(path, buf);
|
|
524
565
|
const content = buf.toString("utf-8", 0, length);
|
|
525
|
-
const
|
|
566
|
+
const lines = content.split("\n");
|
|
567
|
+
const firstLine = lines[0];
|
|
526
568
|
if (!firstLine || !firstLine.trim()) return null;
|
|
527
569
|
const header = JSON.parse(firstLine) as Record<string, unknown>;
|
|
528
570
|
if (header.type !== "session" || typeof header.id !== "string") return null;
|
|
529
571
|
const mtime = storage.statSync(path).mtimeMs;
|
|
530
|
-
|
|
572
|
+
const firstPrompt = header.title ? undefined : extractFirstUserPrompt(lines);
|
|
573
|
+
return new RecentSessionInfo(path, mtime, header, firstPrompt);
|
|
531
574
|
} catch {
|
|
532
575
|
return null;
|
|
533
576
|
}
|
|
@@ -834,6 +877,85 @@ function getTaskToolUsage(details: unknown): Usage | undefined {
|
|
|
834
877
|
return usage as Usage;
|
|
835
878
|
}
|
|
836
879
|
|
|
880
|
+
function extractTextFromContent(content: Message["content"]): string {
|
|
881
|
+
if (typeof content === "string") return content;
|
|
882
|
+
return content
|
|
883
|
+
.filter((block): block is TextContent => block.type === "text")
|
|
884
|
+
.map((block) => block.text)
|
|
885
|
+
.join(" ");
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function collectSessionsFromFiles(files: string[], storage: SessionStorage): SessionInfo[] {
|
|
889
|
+
const sessions: SessionInfo[] = [];
|
|
890
|
+
|
|
891
|
+
for (const file of files) {
|
|
892
|
+
try {
|
|
893
|
+
const content = storage.readTextSync(file);
|
|
894
|
+
const lines = content.trim().split("\n");
|
|
895
|
+
if (lines.length === 0) continue;
|
|
896
|
+
|
|
897
|
+
// Check first line for valid session header
|
|
898
|
+
type SessionHeaderShape = { type: string; id: string; cwd?: string; title?: string; timestamp: string };
|
|
899
|
+
let header: SessionHeaderShape | null = null;
|
|
900
|
+
try {
|
|
901
|
+
const first = JSON.parse(lines[0]) as SessionHeaderShape;
|
|
902
|
+
if (first.type === "session" && first.id) {
|
|
903
|
+
header = first;
|
|
904
|
+
}
|
|
905
|
+
} catch {
|
|
906
|
+
// Not valid JSON
|
|
907
|
+
}
|
|
908
|
+
if (!header) continue;
|
|
909
|
+
|
|
910
|
+
const stats = storage.statSync(file);
|
|
911
|
+
let messageCount = 0;
|
|
912
|
+
let firstMessage = "";
|
|
913
|
+
const allMessages: string[] = [];
|
|
914
|
+
|
|
915
|
+
for (let i = 1; i < lines.length; i++) {
|
|
916
|
+
try {
|
|
917
|
+
const entry = JSON.parse(lines[i]) as { type?: string; message?: Message };
|
|
918
|
+
|
|
919
|
+
if (entry.type === "message" && entry.message) {
|
|
920
|
+
messageCount++;
|
|
921
|
+
|
|
922
|
+
if (entry.message.role === "user" || entry.message.role === "assistant") {
|
|
923
|
+
const textContent = extractTextFromContent(entry.message.content);
|
|
924
|
+
|
|
925
|
+
if (textContent) {
|
|
926
|
+
allMessages.push(textContent);
|
|
927
|
+
|
|
928
|
+
if (!firstMessage && entry.message.role === "user") {
|
|
929
|
+
firstMessage = textContent;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
} catch {
|
|
935
|
+
// Skip malformed lines
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
sessions.push({
|
|
940
|
+
path: file,
|
|
941
|
+
id: header.id,
|
|
942
|
+
cwd: typeof header.cwd === "string" ? header.cwd : "",
|
|
943
|
+
title: header.title,
|
|
944
|
+
created: new Date(header.timestamp),
|
|
945
|
+
modified: stats.mtime,
|
|
946
|
+
messageCount,
|
|
947
|
+
firstMessage: firstMessage || "(no messages)",
|
|
948
|
+
allMessagesText: allMessages.join(" "),
|
|
949
|
+
});
|
|
950
|
+
} catch {
|
|
951
|
+
// Skip files that can't be read
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
|
956
|
+
return sessions;
|
|
957
|
+
}
|
|
958
|
+
|
|
837
959
|
export class SessionManager {
|
|
838
960
|
private sessionId: string = "";
|
|
839
961
|
private sessionTitle: string | undefined;
|
|
@@ -1643,6 +1765,32 @@ export class SessionManager {
|
|
|
1643
1765
|
return manager;
|
|
1644
1766
|
}
|
|
1645
1767
|
|
|
1768
|
+
/**
|
|
1769
|
+
* Fork a session into the current project directory.
|
|
1770
|
+
* Copies history from another session file while creating a new session file in the current sessionDir.
|
|
1771
|
+
*/
|
|
1772
|
+
static async forkFrom(
|
|
1773
|
+
sourcePath: string,
|
|
1774
|
+
cwd: string,
|
|
1775
|
+
sessionDir?: string,
|
|
1776
|
+
storage: SessionStorage = new FileSessionStorage(),
|
|
1777
|
+
): Promise<SessionManager> {
|
|
1778
|
+
const dir = sessionDir ?? getDefaultSessionDir(cwd, storage);
|
|
1779
|
+
const manager = new SessionManager(cwd, dir, true, storage);
|
|
1780
|
+
const forkEntries = structuredClone(loadEntriesFromFile(sourcePath, storage)) as FileEntry[];
|
|
1781
|
+
migrateToCurrentVersion(forkEntries);
|
|
1782
|
+
const sourceHeader = forkEntries.find((e) => e.type === "session") as SessionHeader | undefined;
|
|
1783
|
+
const historyEntries = forkEntries.filter((entry) => entry.type !== "session") as SessionEntry[];
|
|
1784
|
+
manager._newSessionSync({ parentSession: sourceHeader?.id });
|
|
1785
|
+
const newHeader = manager.fileEntries[0] as SessionHeader;
|
|
1786
|
+
newHeader.title = sourceHeader?.title;
|
|
1787
|
+
manager.fileEntries = [newHeader, ...historyEntries];
|
|
1788
|
+
manager.sessionTitle = newHeader.title;
|
|
1789
|
+
manager._buildIndex();
|
|
1790
|
+
await manager._rewriteFile();
|
|
1791
|
+
return manager;
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1646
1794
|
/**
|
|
1647
1795
|
* Open a specific session file.
|
|
1648
1796
|
* @param path Path to session file
|
|
@@ -1699,82 +1847,26 @@ export class SessionManager {
|
|
|
1699
1847
|
*/
|
|
1700
1848
|
static list(cwd: string, sessionDir?: string, storage: SessionStorage = new FileSessionStorage()): SessionInfo[] {
|
|
1701
1849
|
const dir = sessionDir ?? getDefaultSessionDir(cwd, storage);
|
|
1702
|
-
const sessions: SessionInfo[] = [];
|
|
1703
|
-
|
|
1704
1850
|
try {
|
|
1705
1851
|
const files = storage.listFilesSync(dir, "*.jsonl");
|
|
1706
|
-
|
|
1707
|
-
for (const file of files) {
|
|
1708
|
-
try {
|
|
1709
|
-
const content = storage.readTextSync(file);
|
|
1710
|
-
const lines = content.trim().split("\n");
|
|
1711
|
-
if (lines.length === 0) continue;
|
|
1712
|
-
|
|
1713
|
-
// Check first line for valid session header
|
|
1714
|
-
let header: { type: string; id: string; cwd?: string; title?: string; timestamp: string } | null = null;
|
|
1715
|
-
try {
|
|
1716
|
-
const first = JSON.parse(lines[0]);
|
|
1717
|
-
if (first.type === "session" && first.id) {
|
|
1718
|
-
header = first;
|
|
1719
|
-
}
|
|
1720
|
-
} catch {
|
|
1721
|
-
// Not valid JSON
|
|
1722
|
-
}
|
|
1723
|
-
if (!header) continue;
|
|
1724
|
-
|
|
1725
|
-
const stats = storage.statSync(file);
|
|
1726
|
-
let messageCount = 0;
|
|
1727
|
-
let firstMessage = "";
|
|
1728
|
-
const allMessages: string[] = [];
|
|
1729
|
-
|
|
1730
|
-
for (let i = 1; i < lines.length; i++) {
|
|
1731
|
-
try {
|
|
1732
|
-
const entry = JSON.parse(lines[i]);
|
|
1733
|
-
|
|
1734
|
-
if (entry.type === "message") {
|
|
1735
|
-
messageCount++;
|
|
1736
|
-
|
|
1737
|
-
if (entry.message.role === "user" || entry.message.role === "assistant") {
|
|
1738
|
-
const textContent = entry.message.content
|
|
1739
|
-
.filter((c: any) => c.type === "text")
|
|
1740
|
-
.map((c: any) => c.text)
|
|
1741
|
-
.join(" ");
|
|
1742
|
-
|
|
1743
|
-
if (textContent) {
|
|
1744
|
-
allMessages.push(textContent);
|
|
1745
|
-
|
|
1746
|
-
if (!firstMessage && entry.message.role === "user") {
|
|
1747
|
-
firstMessage = textContent;
|
|
1748
|
-
}
|
|
1749
|
-
}
|
|
1750
|
-
}
|
|
1751
|
-
}
|
|
1752
|
-
} catch {
|
|
1753
|
-
// Skip malformed lines
|
|
1754
|
-
}
|
|
1755
|
-
}
|
|
1756
|
-
|
|
1757
|
-
sessions.push({
|
|
1758
|
-
path: file,
|
|
1759
|
-
id: header.id,
|
|
1760
|
-
cwd: typeof header.cwd === "string" ? header.cwd : "",
|
|
1761
|
-
title: header.title,
|
|
1762
|
-
created: new Date(header.timestamp),
|
|
1763
|
-
modified: stats.mtime,
|
|
1764
|
-
messageCount,
|
|
1765
|
-
firstMessage: firstMessage || "(no messages)",
|
|
1766
|
-
allMessagesText: allMessages.join(" "),
|
|
1767
|
-
});
|
|
1768
|
-
} catch {
|
|
1769
|
-
// Skip files that can't be read
|
|
1770
|
-
}
|
|
1771
|
-
}
|
|
1772
|
-
|
|
1773
|
-
sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
|
1852
|
+
return collectSessionsFromFiles(files, storage);
|
|
1774
1853
|
} catch {
|
|
1775
|
-
|
|
1854
|
+
return [];
|
|
1776
1855
|
}
|
|
1856
|
+
}
|
|
1777
1857
|
|
|
1778
|
-
|
|
1858
|
+
/**
|
|
1859
|
+
* List all sessions across all project directories.
|
|
1860
|
+
*/
|
|
1861
|
+
static listAll(storage: SessionStorage = new FileSessionStorage()): SessionInfo[] {
|
|
1862
|
+
const sessionsRoot = join(getDefaultAgentDir(), "sessions");
|
|
1863
|
+
try {
|
|
1864
|
+
const files = Array.from(new Bun.Glob("**/*.jsonl").scanSync(sessionsRoot)).map((name) =>
|
|
1865
|
+
join(sessionsRoot, name),
|
|
1866
|
+
);
|
|
1867
|
+
return collectSessionsFromFiles(files, storage);
|
|
1868
|
+
} catch {
|
|
1869
|
+
return [];
|
|
1870
|
+
}
|
|
1779
1871
|
}
|
|
1780
1872
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { SettingsManager } from "./settings-manager";
|
|
3
|
+
|
|
4
|
+
describe("SettingsManager python settings", () => {
|
|
5
|
+
it("defaults to both and session", () => {
|
|
6
|
+
const settings = SettingsManager.inMemory();
|
|
7
|
+
|
|
8
|
+
expect(settings.getPythonToolMode()).toBe("both");
|
|
9
|
+
expect(settings.getPythonKernelMode()).toBe("session");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("persists python tool and kernel modes", async () => {
|
|
13
|
+
const settings = SettingsManager.inMemory();
|
|
14
|
+
|
|
15
|
+
await settings.setPythonToolMode("bash-only");
|
|
16
|
+
await settings.setPythonKernelMode("per-call");
|
|
17
|
+
|
|
18
|
+
expect(settings.getPythonToolMode()).toBe("bash-only");
|
|
19
|
+
expect(settings.getPythonKernelMode()).toBe("per-call");
|
|
20
|
+
expect(settings.serialize().python?.toolMode).toBe("bash-only");
|
|
21
|
+
expect(settings.serialize().python?.kernelMode).toBe("per-call");
|
|
22
|
+
});
|
|
23
|
+
});
|