@nghyane/arcane 0.1.16 → 0.1.18
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 +21 -0
- package/package.json +7 -15
- package/src/cli/setup-cli.ts +2 -62
- package/src/commands/setup.ts +1 -1
- package/src/config/keybindings.ts +1 -4
- package/src/config/settings-schema.ts +23 -98
- package/src/config/settings.ts +0 -1
- package/src/exa/mcp-client.ts +57 -2
- package/src/extensibility/custom-tools/types.ts +2 -2
- package/src/extensibility/custom-tools/wrapper.ts +1 -1
- package/src/extensibility/extensions/wrapper.ts +1 -1
- package/src/extensibility/hooks/tool-wrapper.ts +1 -1
- package/src/internal-urls/docs-index.generated.ts +1 -2
- package/src/internal-urls/index.ts +2 -4
- package/src/internal-urls/router.ts +2 -2
- package/src/internal-urls/types.ts +2 -2
- package/src/mcp/oauth-flow.ts +1 -1
- package/src/modes/components/custom-editor.ts +6 -2
- package/src/modes/controllers/command-controller.ts +4 -46
- package/src/modes/controllers/input-controller.ts +123 -6
- package/src/modes/interactive-mode.ts +1 -84
- package/src/modes/types.ts +0 -1
- package/src/patch/edit-tool.ts +2 -11
- package/src/patch/hashline.ts +42 -0
- package/src/prompts/agents/explore.md +4 -2
- package/src/prompts/agents/librarian.md +4 -6
- package/src/prompts/agents/reviewer.md +1 -1
- package/src/prompts/agents/task.md +5 -1
- package/src/prompts/system/system-prompt.md +29 -18
- package/src/prompts/thread-extract.md +16 -0
- package/src/prompts/tools/render-mermaid.md +9 -0
- package/src/sdk.ts +12 -37
- package/src/session/agent-session.ts +5 -10
- package/src/session/retry-utils.ts +1 -1
- package/src/session/session-index.ts +329 -0
- package/src/session/session-manager.ts +0 -30
- package/src/session/streaming-edit.ts +1 -36
- package/src/slash-commands/builtin-registry.ts +0 -16
- package/src/task/index.ts +1 -1
- package/src/tools/ask.ts +9 -6
- package/src/tools/bash-skill-urls.ts +3 -3
- package/src/tools/bash.ts +2 -1
- package/src/tools/create-tools.ts +28 -33
- package/src/tools/fetch.ts +1 -1
- package/src/tools/find-thread.ts +120 -0
- package/src/tools/grep.ts +2 -1
- package/src/tools/index.ts +5 -0
- package/src/tools/python.ts +53 -1
- package/src/tools/read-thread.ts +409 -0
- package/src/tools/read.ts +4 -3
- package/src/tools/render-mermaid.ts +68 -0
- package/src/tools/save-memory.ts +182 -0
- package/src/tools/write.ts +1 -1
- package/src/web/search/index.ts +4 -1
- package/src/web/search/provider.ts +3 -0
- package/src/web/search/providers/anthropic.ts +1 -0
- package/src/web/search/providers/gemini.ts +122 -37
- package/src/web/search/providers/kagi.ts +163 -0
- package/src/web/search/types.ts +1 -0
- package/src/internal-urls/memory-protocol.ts +0 -133
- package/src/memories/index.ts +0 -1099
- package/src/memories/storage.ts +0 -563
- package/src/patch/normative.ts +0 -72
- package/src/prompts/memories/consolidation.md +0 -30
- package/src/prompts/memories/read_path.md +0 -11
- package/src/prompts/memories/stage_one_input.md +0 -6
- package/src/prompts/memories/stage_one_system.md +0 -21
- package/src/stt/downloader.ts +0 -68
- package/src/stt/index.ts +0 -3
- package/src/stt/recorder.ts +0 -351
- package/src/stt/setup.ts +0 -50
- package/src/stt/stt-controller.ts +0 -160
- package/src/stt/transcribe.py +0 -70
- package/src/stt/transcriber.ts +0 -91
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@nghyane/arcane-agent";
|
|
2
|
+
import type { Component } from "@nghyane/arcane-tui";
|
|
3
|
+
import { Text } from "@nghyane/arcane-tui";
|
|
4
|
+
import { logger } from "@nghyane/arcane-utils";
|
|
5
|
+
import { type Static, Type } from "@sinclair/typebox";
|
|
6
|
+
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
7
|
+
import { SessionIndex, type SessionSearchResult } from "../session/session-index";
|
|
8
|
+
import type { Theme } from "../theme/theme";
|
|
9
|
+
import { renderStatusLine, renderTreeList } from "../tui";
|
|
10
|
+
import { PREVIEW_LIMITS } from "../ui/render-utils";
|
|
11
|
+
import type { ToolSession } from ".";
|
|
12
|
+
|
|
13
|
+
const findThreadSchema = Type.Object({
|
|
14
|
+
query: Type.String({
|
|
15
|
+
description:
|
|
16
|
+
"Keywords to search past sessions. Supports bare words, quoted phrases, after:7d, before:2026-01-01 date filters.",
|
|
17
|
+
}),
|
|
18
|
+
limit: Type.Optional(Type.Number({ description: "Max results (default: 10)" })),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
type FindThreadParams = Static<typeof findThreadSchema>;
|
|
22
|
+
|
|
23
|
+
export interface FindThreadToolDetails {
|
|
24
|
+
results: SessionSearchResult[];
|
|
25
|
+
query: string;
|
|
26
|
+
indexed: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface FindThreadRenderArgs {
|
|
30
|
+
query?: string;
|
|
31
|
+
limit?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class FindThreadTool implements AgentTool<typeof findThreadSchema, FindThreadToolDetails, Theme> {
|
|
35
|
+
readonly name = "find_thread";
|
|
36
|
+
readonly label = "Find Thread";
|
|
37
|
+
description = [
|
|
38
|
+
"Find past conversation threads by keyword search. Returns thread IDs, titles, dates, and matching snippets.",
|
|
39
|
+
"Use read_thread to get full content from a specific thread.",
|
|
40
|
+
"",
|
|
41
|
+
'Query syntax: bare keywords, "quoted phrases", after:7d, before:2026-01-01 date filters.',
|
|
42
|
+
"",
|
|
43
|
+
"When to use:",
|
|
44
|
+
"- User references past work or previous sessions",
|
|
45
|
+
"- Need context from earlier conversations",
|
|
46
|
+
"- Task may overlap with prior work",
|
|
47
|
+
"",
|
|
48
|
+
"When NOT to use: git history/blame, current session context, generic questions.",
|
|
49
|
+
].join("\n");
|
|
50
|
+
readonly parameters = findThreadSchema;
|
|
51
|
+
readonly concurrency = "shared" as const;
|
|
52
|
+
|
|
53
|
+
constructor(readonly _session: ToolSession) {}
|
|
54
|
+
|
|
55
|
+
async execute(
|
|
56
|
+
_toolCallId: string,
|
|
57
|
+
params: FindThreadParams,
|
|
58
|
+
_signal?: AbortSignal,
|
|
59
|
+
_onUpdate?: AgentToolUpdateCallback<FindThreadToolDetails>,
|
|
60
|
+
_context?: AgentToolContext,
|
|
61
|
+
): Promise<AgentToolResult<FindThreadToolDetails>> {
|
|
62
|
+
const index = SessionIndex.open();
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
await index.indexAllSessions();
|
|
66
|
+
} catch (error) {
|
|
67
|
+
logger.warn("FindThread: indexing failed", { error: String(error) });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const limit = Math.min(Math.max(1, params.limit ?? 10), 50);
|
|
71
|
+
const results = index.search(params.query, limit);
|
|
72
|
+
|
|
73
|
+
const text =
|
|
74
|
+
results.length > 0 ? JSON.stringify(results, null, 2) : `No threads found matching "${params.query}".`;
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
content: [{ type: "text", text }],
|
|
78
|
+
details: { results, query: params.query, indexed: true },
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
renderCall(args: FindThreadRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
|
|
83
|
+
const meta = args.query ? [`"${args.query}"`] : [];
|
|
84
|
+
const text = renderStatusLine({ icon: "pending", title: "Find Thread", meta }, uiTheme);
|
|
85
|
+
return new Text(text, 0, 0);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
renderResult(
|
|
89
|
+
result: { content: Array<{ type: string; text?: string }>; details?: FindThreadToolDetails },
|
|
90
|
+
options: RenderResultOptions,
|
|
91
|
+
uiTheme: Theme,
|
|
92
|
+
_args?: FindThreadRenderArgs,
|
|
93
|
+
): Component {
|
|
94
|
+
const results = result.details?.results ?? [];
|
|
95
|
+
const header = renderStatusLine(
|
|
96
|
+
{ icon: "success", title: "Find Thread", meta: [`${results.length} results`] },
|
|
97
|
+
uiTheme,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
if (results.length === 0) {
|
|
101
|
+
const fallback = result.content?.find(c => c.type === "text")?.text ?? "No results";
|
|
102
|
+
return new Text(`${header}\n${uiTheme.fg("dim", fallback)}`, 0, 0);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const { expanded } = options;
|
|
106
|
+
const treeLines = renderTreeList(
|
|
107
|
+
{
|
|
108
|
+
items: results,
|
|
109
|
+
expanded,
|
|
110
|
+
maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
|
|
111
|
+
itemType: "thread",
|
|
112
|
+
renderItem: r =>
|
|
113
|
+
`${uiTheme.fg("accent", r.title)} ${uiTheme.fg("dim", r.date)} ${uiTheme.fg("dim", `(${r.messageCount} msgs)`)}`,
|
|
114
|
+
},
|
|
115
|
+
uiTheme,
|
|
116
|
+
);
|
|
117
|
+
const text = [header, ...treeLines].join("\n");
|
|
118
|
+
return new Text(text, 0, 0);
|
|
119
|
+
}
|
|
120
|
+
}
|
package/src/tools/grep.ts
CHANGED
|
@@ -54,7 +54,8 @@ type GrepParams = Static<typeof grepSchema>;
|
|
|
54
54
|
export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails, Theme> {
|
|
55
55
|
readonly name = "grep";
|
|
56
56
|
readonly label = "Grep";
|
|
57
|
-
description =
|
|
57
|
+
description =
|
|
58
|
+
"Search file contents with regex. Use for exact text matches (variable names, function calls, strings). Use explore for semantic/conceptual searches.";
|
|
58
59
|
readonly parameters = grepSchema;
|
|
59
60
|
|
|
60
61
|
constructor(private readonly session: ToolSession) {}
|
package/src/tools/index.ts
CHANGED
|
@@ -26,6 +26,7 @@ export {
|
|
|
26
26
|
warmupLspServers,
|
|
27
27
|
} from "../lsp";
|
|
28
28
|
export { EditTool, type EditToolDetails } from "../patch";
|
|
29
|
+
export { SessionIndex, type SessionIndexEntry, type SessionSearchResult } from "../session/session-index";
|
|
29
30
|
export {
|
|
30
31
|
DEFAULT_MAX_BYTES,
|
|
31
32
|
DEFAULT_MAX_LINES,
|
|
@@ -70,6 +71,7 @@ export {
|
|
|
70
71
|
type FindToolInput,
|
|
71
72
|
type FindToolOptions,
|
|
72
73
|
} from "./find";
|
|
74
|
+
export { FindThreadTool, type FindThreadToolDetails } from "./find-thread";
|
|
73
75
|
export { setPreferredImageProvider } from "./gemini-image";
|
|
74
76
|
export { GitHubTool, type GitHubToolDetails } from "./github";
|
|
75
77
|
export { GrepTool, type GrepToolDetails, type GrepToolInput } from "./grep";
|
|
@@ -82,7 +84,10 @@ export {
|
|
|
82
84
|
type PythonToolOptions,
|
|
83
85
|
} from "./python";
|
|
84
86
|
export { ReadTool, type ReadToolDetails, type ReadToolInput } from "./read";
|
|
87
|
+
export { ReadThreadTool, type ReadThreadToolDetails } from "./read-thread";
|
|
88
|
+
export { RenderMermaidTool, type RenderMermaidToolDetails } from "./render-mermaid";
|
|
85
89
|
export { reviewerConfig } from "./reviewer-tool";
|
|
90
|
+
export { SaveMemoryTool, type SaveMemoryToolDetails } from "./save-memory";
|
|
86
91
|
export { loadSshTool, type SSHToolDetails, SshTool } from "./ssh";
|
|
87
92
|
export { type SubagentConfig, SubagentTool } from "./subagent-tool";
|
|
88
93
|
export {
|
package/src/tools/python.ts
CHANGED
|
@@ -4,11 +4,13 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb
|
|
|
4
4
|
import type { ImageContent } from "@nghyane/arcane-ai";
|
|
5
5
|
import type { Component } from "@nghyane/arcane-tui";
|
|
6
6
|
import { Text } from "@nghyane/arcane-tui";
|
|
7
|
+
import { $env, logger } from "@nghyane/arcane-utils";
|
|
7
8
|
import { getProjectDir } from "@nghyane/arcane-utils/dirs";
|
|
8
9
|
import { type Static, Type } from "@sinclair/typebox";
|
|
9
10
|
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
10
|
-
import { executePython, type PythonExecutorOptions } from "../ipy/executor";
|
|
11
|
+
import { executePython, getPreludeDocs, type PythonExecutorOptions, warmPythonEnvironment } from "../ipy/executor";
|
|
11
12
|
import type { PythonStatusEvent } from "../ipy/kernel";
|
|
13
|
+
import { checkPythonKernelAvailability } from "../ipy/kernel";
|
|
12
14
|
import { DEFAULT_MAX_BYTES, OutputSink, type OutputSummary } from "../session/streaming-output";
|
|
13
15
|
import type { Theme } from "../theme/theme";
|
|
14
16
|
import { renderCodeCell, renderStatusLine } from "../tui";
|
|
@@ -76,6 +78,8 @@ export class PythonTool implements AgentTool<typeof pythonSchema, any, Theme> {
|
|
|
76
78
|
readonly concurrency = "exclusive";
|
|
77
79
|
|
|
78
80
|
readonly #proxyExecutor?: PythonProxyExecutor;
|
|
81
|
+
#initialized = false;
|
|
82
|
+
#initPromise: Promise<void> | undefined;
|
|
79
83
|
|
|
80
84
|
constructor(
|
|
81
85
|
private readonly session: ToolSession | null,
|
|
@@ -84,6 +88,52 @@ export class PythonTool implements AgentTool<typeof pythonSchema, any, Theme> {
|
|
|
84
88
|
this.#proxyExecutor = options?.proxyExecutor;
|
|
85
89
|
}
|
|
86
90
|
|
|
91
|
+
async #ensureInitialized(): Promise<void> {
|
|
92
|
+
if (this.#initialized) return;
|
|
93
|
+
if (this.#initPromise) return this.#initPromise;
|
|
94
|
+
this.#initPromise = this.#doInit();
|
|
95
|
+
return this.#initPromise;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async #doInit(): Promise<void> {
|
|
99
|
+
try {
|
|
100
|
+
const isTestEnv = Bun.env.BUN_ENV === "test" || Bun.env.NODE_ENV === "test";
|
|
101
|
+
if (isTestEnv || $env.ARCANE_PYTHON_SKIP_CHECK === "1") {
|
|
102
|
+
this.#initialized = true;
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (!this.session) {
|
|
106
|
+
this.#initialized = true;
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const availability = await checkPythonKernelAvailability(this.session.cwd);
|
|
110
|
+
if (!availability.ok) {
|
|
111
|
+
throw new ToolError(`Python kernel unavailable: ${availability.reason}`);
|
|
112
|
+
}
|
|
113
|
+
if (getPreludeDocs().length === 0) {
|
|
114
|
+
const sessionFile = this.session.getSessionFile?.() ?? undefined;
|
|
115
|
+
const warmSessionId = sessionFile
|
|
116
|
+
? `session:${sessionFile}:cwd:${this.session.cwd}`
|
|
117
|
+
: `cwd:${this.session.cwd}`;
|
|
118
|
+
try {
|
|
119
|
+
await warmPythonEnvironment(
|
|
120
|
+
this.session.cwd,
|
|
121
|
+
warmSessionId,
|
|
122
|
+
this.session.settings.get("python.sharedGateway"),
|
|
123
|
+
);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
logger.warn("Failed to warm Python environment", {
|
|
126
|
+
error: err instanceof Error ? err.message : String(err),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
this.#initialized = true;
|
|
131
|
+
} catch (err) {
|
|
132
|
+
this.#initPromise = undefined;
|
|
133
|
+
throw err;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
87
137
|
async execute(
|
|
88
138
|
_toolCallId: string,
|
|
89
139
|
params: Static<typeof pythonSchema>,
|
|
@@ -99,6 +149,8 @@ export class PythonTool implements AgentTool<typeof pythonSchema, any, Theme> {
|
|
|
99
149
|
throw new ToolError("Python tool requires a session when not using proxy executor");
|
|
100
150
|
}
|
|
101
151
|
|
|
152
|
+
await this.#ensureInitialized();
|
|
153
|
+
|
|
102
154
|
const { cells, timeout: rawTimeout = 30, cwd, reset } = params;
|
|
103
155
|
// Clamp to reasonable range: 1s - 600s (10 min)
|
|
104
156
|
const timeoutSec = Math.max(1, Math.min(600, rawTimeout));
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@nghyane/arcane-agent";
|
|
4
|
+
import type { Api, Model } from "@nghyane/arcane-ai";
|
|
5
|
+
import { completeSimple } from "@nghyane/arcane-ai";
|
|
6
|
+
import type { Component } from "@nghyane/arcane-tui";
|
|
7
|
+
import { Text } from "@nghyane/arcane-tui";
|
|
8
|
+
import { logger, parseJsonlLenient } from "@nghyane/arcane-utils";
|
|
9
|
+
import { getSessionsDir } from "@nghyane/arcane-utils/dirs";
|
|
10
|
+
import { type Static, Type } from "@sinclair/typebox";
|
|
11
|
+
import { parseModelString } from "../config/model-resolver";
|
|
12
|
+
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
13
|
+
import extractPrompt from "../prompts/thread-extract.md" with { type: "text" };
|
|
14
|
+
import type { Theme } from "../theme/theme";
|
|
15
|
+
import { renderStatusLine } from "../tui";
|
|
16
|
+
import { PREVIEW_LIMITS, truncateToWidth } from "../ui/render-utils";
|
|
17
|
+
import type { ToolSession } from ".";
|
|
18
|
+
|
|
19
|
+
const readThreadSchema = Type.Object({
|
|
20
|
+
threadId: Type.String({ description: "Session/thread ID to read" }),
|
|
21
|
+
goal: Type.String({ description: "What information to extract from the thread. Be specific." }),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
type ReadThreadParams = Static<typeof readThreadSchema>;
|
|
25
|
+
|
|
26
|
+
export interface ReadThreadToolDetails {
|
|
27
|
+
threadId: string;
|
|
28
|
+
goal: string;
|
|
29
|
+
title?: string;
|
|
30
|
+
originalLength: number;
|
|
31
|
+
extractedLength: number;
|
|
32
|
+
compressionRatio: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface ReadThreadRenderArgs {
|
|
36
|
+
threadId?: string;
|
|
37
|
+
goal?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface MessageContent {
|
|
41
|
+
type?: string;
|
|
42
|
+
text?: string;
|
|
43
|
+
name?: string;
|
|
44
|
+
input?: Record<string, unknown>;
|
|
45
|
+
arguments?: Record<string, unknown>;
|
|
46
|
+
toolCallId?: string;
|
|
47
|
+
toolName?: string;
|
|
48
|
+
isError?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface RawSessionEntry {
|
|
52
|
+
type?: string;
|
|
53
|
+
id?: string;
|
|
54
|
+
title?: string;
|
|
55
|
+
message?: {
|
|
56
|
+
role?: string;
|
|
57
|
+
content?: string | MessageContent[];
|
|
58
|
+
toolName?: string;
|
|
59
|
+
isError?: boolean;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function findSessionFile(threadId: string): Promise<{ file: string; title?: string } | null> {
|
|
64
|
+
const sessionsDir = getSessionsDir();
|
|
65
|
+
let subdirs: string[];
|
|
66
|
+
try {
|
|
67
|
+
subdirs = fs.readdirSync(sessionsDir);
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const subdir of subdirs) {
|
|
73
|
+
const dirPath = path.join(sessionsDir, subdir);
|
|
74
|
+
let stat: fs.Stats;
|
|
75
|
+
try {
|
|
76
|
+
stat = fs.statSync(dirPath);
|
|
77
|
+
} catch {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (!stat.isDirectory()) continue;
|
|
81
|
+
|
|
82
|
+
let files: string[];
|
|
83
|
+
try {
|
|
84
|
+
files = fs.readdirSync(dirPath);
|
|
85
|
+
} catch {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const file of files) {
|
|
90
|
+
if (!file.endsWith(".jsonl")) continue;
|
|
91
|
+
const filePath = path.join(dirPath, file);
|
|
92
|
+
try {
|
|
93
|
+
const fd = fs.openSync(filePath, "r");
|
|
94
|
+
const buf = Buffer.alloc(4096);
|
|
95
|
+
const bytesRead = fs.readSync(fd, buf, 0, 4096, 0);
|
|
96
|
+
fs.closeSync(fd);
|
|
97
|
+
const firstLine = buf.subarray(0, bytesRead).toString("utf-8").split("\n")[0];
|
|
98
|
+
if (!firstLine) continue;
|
|
99
|
+
const header = JSON.parse(firstLine) as RawSessionEntry;
|
|
100
|
+
if (header.type === "session" && header.id === threadId) {
|
|
101
|
+
return { file: filePath, title: header.title };
|
|
102
|
+
}
|
|
103
|
+
} catch {}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function renderSessionMarkdown(entries: RawSessionEntry[]): { markdown: string; turnCount: number } {
|
|
110
|
+
const parts: string[] = [];
|
|
111
|
+
let turnCount = 0;
|
|
112
|
+
|
|
113
|
+
for (const entry of entries) {
|
|
114
|
+
if (entry.type === "session") continue;
|
|
115
|
+
if (entry.type !== "message") continue;
|
|
116
|
+
|
|
117
|
+
const msg = entry.message;
|
|
118
|
+
if (!msg?.role) continue;
|
|
119
|
+
const role = msg.role;
|
|
120
|
+
if (!["user", "assistant", "toolResult"].includes(role)) continue;
|
|
121
|
+
|
|
122
|
+
if (role === "user") {
|
|
123
|
+
turnCount++;
|
|
124
|
+
const text = typeof msg.content === "string" ? msg.content : "";
|
|
125
|
+
parts.push(`## User\n\n${text}\n`);
|
|
126
|
+
} else if (role === "assistant") {
|
|
127
|
+
turnCount++;
|
|
128
|
+
if (typeof msg.content === "string") {
|
|
129
|
+
parts.push(`## Assistant\n\n${msg.content}\n`);
|
|
130
|
+
} else if (Array.isArray(msg.content)) {
|
|
131
|
+
const blocks: string[] = [];
|
|
132
|
+
for (const block of msg.content) {
|
|
133
|
+
if (block.type === "text" && block.text) {
|
|
134
|
+
blocks.push(block.text);
|
|
135
|
+
} else if (block.type === "toolCall" || block.type === "tool_use") {
|
|
136
|
+
const name = block.name ?? "unknown";
|
|
137
|
+
const input = block.arguments ?? block.input;
|
|
138
|
+
let argSummary = "";
|
|
139
|
+
if (input && typeof input === "object") {
|
|
140
|
+
const argParts: string[] = [];
|
|
141
|
+
for (const [k, v] of Object.entries(input)) {
|
|
142
|
+
const val = typeof v === "string" ? v : JSON.stringify(v);
|
|
143
|
+
argParts.push(`${k}: ${val.length > 200 ? `${val.slice(0, 200)}...` : val}`);
|
|
144
|
+
}
|
|
145
|
+
argSummary = argParts.join("\n");
|
|
146
|
+
}
|
|
147
|
+
blocks.push(`**Tool: ${name}**\n${argSummary}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (blocks.length > 0) {
|
|
151
|
+
parts.push(`## Assistant\n\n${blocks.join("\n\n")}\n`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
} else if (role === "toolResult") {
|
|
155
|
+
const toolName = msg.toolName ?? "unknown";
|
|
156
|
+
const isError = msg.isError === true;
|
|
157
|
+
if (typeof msg.content === "string") {
|
|
158
|
+
const text = msg.content;
|
|
159
|
+
if (isError) {
|
|
160
|
+
parts.push(`**Error (${toolName}):**\n${text}\n`);
|
|
161
|
+
} else if (text.length > 500) {
|
|
162
|
+
parts.push(
|
|
163
|
+
`**Result (${toolName}):**\n${text.slice(0, 300)}... [truncated, ${text.length} chars total]\n`,
|
|
164
|
+
);
|
|
165
|
+
} else {
|
|
166
|
+
parts.push(`**Result (${toolName}):**\n${text}\n`);
|
|
167
|
+
}
|
|
168
|
+
} else if (Array.isArray(msg.content)) {
|
|
169
|
+
for (const block of msg.content) {
|
|
170
|
+
const text = block.text ?? "";
|
|
171
|
+
if (isError) {
|
|
172
|
+
parts.push(`**Error (${toolName}):**\n${text}\n`);
|
|
173
|
+
} else if (text.length > 500) {
|
|
174
|
+
parts.push(
|
|
175
|
+
`**Result (${toolName}):**\n${text.slice(0, 300)}... [truncated, ${text.length} chars total]\n`,
|
|
176
|
+
);
|
|
177
|
+
} else {
|
|
178
|
+
parts.push(`**Result (${toolName}):**\n${text}\n`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return { markdown: parts.join("\n"), turnCount };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function truncateTurns(markdown: string, turnCount: number): string {
|
|
189
|
+
if (turnCount <= 40) return markdown;
|
|
190
|
+
|
|
191
|
+
const lines = markdown.split("\n");
|
|
192
|
+
const turnStarts: number[] = [];
|
|
193
|
+
for (let i = 0; i < lines.length; i++) {
|
|
194
|
+
if (lines[i].startsWith("## User") || lines[i].startsWith("## Assistant")) {
|
|
195
|
+
turnStarts.push(i);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (turnStarts.length <= 40) return markdown;
|
|
200
|
+
|
|
201
|
+
const keepFirst = 20;
|
|
202
|
+
const keepLast = 20;
|
|
203
|
+
const firstEnd = turnStarts[keepFirst];
|
|
204
|
+
const lastStart = turnStarts[turnStarts.length - keepLast];
|
|
205
|
+
const omitted = turnStarts.length - keepFirst - keepLast;
|
|
206
|
+
|
|
207
|
+
const head = lines.slice(0, firstEnd).join("\n");
|
|
208
|
+
const tail = lines.slice(lastStart).join("\n");
|
|
209
|
+
return `${head}\n\n---\n[... ${omitted} turns omitted ...]\n---\n\n${tail}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export class ReadThreadTool implements AgentTool<typeof readThreadSchema, ReadThreadToolDetails, Theme> {
|
|
213
|
+
readonly name = "read_thread";
|
|
214
|
+
readonly label = "Read Thread";
|
|
215
|
+
description = [
|
|
216
|
+
"Read and extract relevant content from a past conversation thread by its ID.",
|
|
217
|
+
"Uses AI to extract only information relevant to your goal, keeping context concise.",
|
|
218
|
+
"Use find_thread first to discover thread IDs.",
|
|
219
|
+
"",
|
|
220
|
+
'Goal tips: be specific ("what auth approach was chosen" not "tell me about auth").',
|
|
221
|
+
"",
|
|
222
|
+
"Examples:",
|
|
223
|
+
'- read_thread(id, "Extract the implementation plan and design decisions")',
|
|
224
|
+
'- read_thread(id, "Extract the bug fix, root cause, and relevant code changes")',
|
|
225
|
+
].join("\n");
|
|
226
|
+
readonly parameters = readThreadSchema;
|
|
227
|
+
readonly concurrency = "shared" as const;
|
|
228
|
+
|
|
229
|
+
constructor(private readonly session: ToolSession) {}
|
|
230
|
+
|
|
231
|
+
async execute(
|
|
232
|
+
_toolCallId: string,
|
|
233
|
+
params: ReadThreadParams,
|
|
234
|
+
_signal?: AbortSignal,
|
|
235
|
+
_onUpdate?: AgentToolUpdateCallback<ReadThreadToolDetails>,
|
|
236
|
+
_context?: AgentToolContext,
|
|
237
|
+
): Promise<AgentToolResult<ReadThreadToolDetails>> {
|
|
238
|
+
const { threadId, goal } = params;
|
|
239
|
+
|
|
240
|
+
// Find session file
|
|
241
|
+
const found = await findSessionFile(threadId);
|
|
242
|
+
if (!found) {
|
|
243
|
+
return {
|
|
244
|
+
content: [{ type: "text", text: `Thread "${threadId}" not found.` }],
|
|
245
|
+
details: { threadId, goal, originalLength: 0, extractedLength: 0, compressionRatio: 1 },
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const { file: sessionFile, title } = found;
|
|
250
|
+
|
|
251
|
+
// Load and parse JSONL
|
|
252
|
+
const content = await Bun.file(sessionFile).text();
|
|
253
|
+
const entries = parseJsonlLenient<RawSessionEntry>(content);
|
|
254
|
+
|
|
255
|
+
// Render to markdown
|
|
256
|
+
const { markdown: rawMarkdown, turnCount } = renderSessionMarkdown(entries);
|
|
257
|
+
const markdown = truncateTurns(rawMarkdown, turnCount);
|
|
258
|
+
|
|
259
|
+
if (markdown.length === 0) {
|
|
260
|
+
return {
|
|
261
|
+
content: [{ type: "text", text: `Thread "${threadId}" is empty.` }],
|
|
262
|
+
details: { threadId, goal, title, originalLength: 0, extractedLength: 0, compressionRatio: 1 },
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Resolve extraction model
|
|
267
|
+
const registry = this.session.subagentContext?.modelRegistry;
|
|
268
|
+
if (!registry) {
|
|
269
|
+
return {
|
|
270
|
+
content: [{ type: "text", text: `No model registry available. Cannot extract content.` }],
|
|
271
|
+
details: {
|
|
272
|
+
threadId,
|
|
273
|
+
goal,
|
|
274
|
+
title,
|
|
275
|
+
originalLength: markdown.length,
|
|
276
|
+
extractedLength: 0,
|
|
277
|
+
compressionRatio: 0,
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const fastModelId = this.session.settings.getModelRole("fast") ?? this.session.settings.getModelRole("default");
|
|
283
|
+
const availableModels = registry.getAvailable();
|
|
284
|
+
let model: Model<Api> | undefined;
|
|
285
|
+
|
|
286
|
+
if (fastModelId) {
|
|
287
|
+
const parsed = parseModelString(fastModelId);
|
|
288
|
+
if (parsed) {
|
|
289
|
+
model = availableModels.find(m => m.provider === parsed.provider && m.id === parsed.id);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (!model) {
|
|
293
|
+
model = availableModels[0];
|
|
294
|
+
}
|
|
295
|
+
if (!model) {
|
|
296
|
+
return {
|
|
297
|
+
content: [{ type: "text", text: "No model available for extraction." }],
|
|
298
|
+
details: {
|
|
299
|
+
threadId,
|
|
300
|
+
goal,
|
|
301
|
+
title,
|
|
302
|
+
originalLength: markdown.length,
|
|
303
|
+
extractedLength: 0,
|
|
304
|
+
compressionRatio: 0,
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const sessionId = this.session.getSessionId?.() ?? undefined;
|
|
310
|
+
const apiKey = await registry.getApiKey(model, sessionId);
|
|
311
|
+
if (!apiKey) {
|
|
312
|
+
return {
|
|
313
|
+
content: [{ type: "text", text: "No API key available for extraction model." }],
|
|
314
|
+
details: {
|
|
315
|
+
threadId,
|
|
316
|
+
goal,
|
|
317
|
+
title,
|
|
318
|
+
originalLength: markdown.length,
|
|
319
|
+
extractedLength: 0,
|
|
320
|
+
compressionRatio: 0,
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Call LLM for extraction
|
|
326
|
+
let relevantContent: string;
|
|
327
|
+
try {
|
|
328
|
+
const response = await completeSimple(
|
|
329
|
+
model,
|
|
330
|
+
{
|
|
331
|
+
systemPrompt: extractPrompt,
|
|
332
|
+
messages: [
|
|
333
|
+
{
|
|
334
|
+
role: "user",
|
|
335
|
+
content: `Here is the thread content:\n\n<thread>\n${markdown}\n</thread>\n\nGoal: ${goal}`,
|
|
336
|
+
timestamp: Date.now(),
|
|
337
|
+
},
|
|
338
|
+
],
|
|
339
|
+
},
|
|
340
|
+
{ apiKey, maxTokens: 8192 },
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
let text = "";
|
|
344
|
+
for (const block of response.content) {
|
|
345
|
+
if (block.type === "text") {
|
|
346
|
+
text += block.text;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
relevantContent = text.trim();
|
|
350
|
+
|
|
351
|
+
if (!relevantContent) {
|
|
352
|
+
relevantContent = "No relevant content extracted.";
|
|
353
|
+
}
|
|
354
|
+
} catch (err) {
|
|
355
|
+
logger.error("read_thread: extraction failed", { error: err instanceof Error ? err.message : String(err) });
|
|
356
|
+
return {
|
|
357
|
+
content: [{ type: "text", text: `Extraction failed: ${err instanceof Error ? err.message : String(err)}` }],
|
|
358
|
+
details: {
|
|
359
|
+
threadId,
|
|
360
|
+
goal,
|
|
361
|
+
title,
|
|
362
|
+
originalLength: markdown.length,
|
|
363
|
+
extractedLength: 0,
|
|
364
|
+
compressionRatio: 0,
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const originalLength = markdown.length;
|
|
370
|
+
const extractedLength = relevantContent.length;
|
|
371
|
+
const compressionRatio = originalLength > 0 ? extractedLength / originalLength : 1;
|
|
372
|
+
logger.debug("read_thread compression", { originalLength, extractedLength, compressionRatio });
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
content: [{ type: "text", text: relevantContent }],
|
|
376
|
+
details: { threadId, goal, title, originalLength, extractedLength, compressionRatio },
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
renderCall(args: ReadThreadRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
|
|
381
|
+
const meta = args.threadId ? [args.threadId] : [];
|
|
382
|
+
const text = renderStatusLine({ icon: "pending", title: "Read Thread", meta }, uiTheme);
|
|
383
|
+
return new Text(text, 0, 0);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
renderResult(
|
|
387
|
+
result: { content: Array<{ type: string; text?: string }>; details?: ReadThreadToolDetails },
|
|
388
|
+
options: RenderResultOptions,
|
|
389
|
+
uiTheme: Theme,
|
|
390
|
+
_args?: ReadThreadRenderArgs,
|
|
391
|
+
): Component {
|
|
392
|
+
const details = result.details;
|
|
393
|
+
const titlePart = details?.title ? ` — ${details.title}` : "";
|
|
394
|
+
const compressionPart = details ? ` (${Math.round(details.compressionRatio * 100)}% of original)` : "";
|
|
395
|
+
const header = renderStatusLine(
|
|
396
|
+
{ icon: "success", title: "Read Thread", meta: [`${titlePart}${compressionPart}`] },
|
|
397
|
+
uiTheme,
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
const contentText = result.content?.find(c => c.type === "text")?.text ?? "No content";
|
|
401
|
+
const { expanded } = options;
|
|
402
|
+
const maxLines = expanded ? PREVIEW_LIMITS.EXPANDED_LINES : PREVIEW_LIMITS.COLLAPSED_LINES;
|
|
403
|
+
const lines = contentText.split("\n").slice(0, maxLines);
|
|
404
|
+
const truncated = lines.map(line => truncateToWidth(line, 120)).join("\n");
|
|
405
|
+
const preview = uiTheme.fg("dim", truncated);
|
|
406
|
+
|
|
407
|
+
return new Text(`${header}\n${preview}`, 0, 0);
|
|
408
|
+
}
|
|
409
|
+
}
|
package/src/tools/read.ts
CHANGED
|
@@ -533,7 +533,8 @@ type ReadParams = ReadToolInput;
|
|
|
533
533
|
export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails, Theme> {
|
|
534
534
|
readonly name = "read";
|
|
535
535
|
readonly label = "Read";
|
|
536
|
-
description =
|
|
536
|
+
description =
|
|
537
|
+
"Read file contents, list directories, or view images. When possible, call in parallel for all files you need. Avoid tiny repeated slices — read a larger range instead.";
|
|
537
538
|
readonly parameters = readSchema;
|
|
538
539
|
readonly nonAbortable = true;
|
|
539
540
|
|
|
@@ -554,7 +555,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails, T
|
|
|
554
555
|
|
|
555
556
|
const displayMode = resolveFileDisplayMode(this.session);
|
|
556
557
|
|
|
557
|
-
// Handle internal URLs (agent://, artifact://, plan://,
|
|
558
|
+
// Handle internal URLs (agent://, artifact://, plan://, skill://, rule://)
|
|
558
559
|
const internalRouter = this.session.internalRouter;
|
|
559
560
|
if (internalRouter?.canHandle(readPath)) {
|
|
560
561
|
return this.#handleInternalUrl(readPath, offset, limit);
|
|
@@ -832,7 +833,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails, T
|
|
|
832
833
|
}
|
|
833
834
|
|
|
834
835
|
/**
|
|
835
|
-
* Handle internal URLs (agent://, artifact://, plan://,
|
|
836
|
+
* Handle internal URLs (agent://, artifact://, plan://, skill://, rule://).
|
|
836
837
|
* Supports pagination via offset/limit but rejects them when query extraction is used.
|
|
837
838
|
*/
|
|
838
839
|
async #handleInternalUrl(url: string, offset?: number, limit?: number): Promise<AgentToolResult<ReadToolDetails>> {
|