saeeol 1.2.1 → 1.2.3
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/bin/saeeol.cjs +187 -0
- package/npm/bin/saeeol +0 -0
- package/package.json +12 -12
- package/src/cli/cmd/tui/component/dialog/dialog-agent.tsx +32 -0
- package/src/cli/cmd/tui/component/dialog/dialog-command.tsx +190 -0
- package/src/cli/cmd/tui/component/dialog/dialog-console-org.tsx +103 -0
- package/src/cli/cmd/tui/component/dialog/dialog-go-upsell.tsx +159 -0
- package/src/cli/cmd/tui/component/dialog/dialog-mcp.tsx +86 -0
- package/src/cli/cmd/tui/component/dialog/dialog-model.tsx +238 -0
- package/src/cli/cmd/tui/component/dialog/dialog-provider.tsx +343 -0
- package/src/cli/cmd/tui/component/dialog/dialog-session-delete-failed.tsx +103 -0
- package/src/cli/cmd/tui/component/dialog/dialog-session-list.tsx +301 -0
- package/src/cli/cmd/tui/component/dialog/dialog-session-rename.tsx +35 -0
- package/src/cli/cmd/tui/component/dialog/dialog-skill.tsx +37 -0
- package/src/cli/cmd/tui/component/dialog/dialog-stash.tsx +87 -0
- package/src/cli/cmd/tui/component/dialog/dialog-status.tsx +190 -0
- package/src/cli/cmd/tui/component/dialog/dialog-tag.tsx +44 -0
- package/src/cli/cmd/tui/component/dialog/dialog-theme-list.tsx +50 -0
- package/src/cli/cmd/tui/component/dialog/dialog-variant.tsx +39 -0
- package/src/cli/cmd/tui/component/dialog/dialog-workspace-create.tsx +200 -0
- package/src/cli/cmd/tui/component/dialog/dialog-workspace-unavailable.tsx +81 -0
- package/src/cli/cmd/tui/component/dialog-agent.tsx +1 -32
- package/src/cli/cmd/tui/component/dialog-command.tsx +1 -190
- package/src/cli/cmd/tui/component/dialog-console-org.tsx +1 -103
- package/src/cli/cmd/tui/component/dialog-go-upsell.tsx +1 -159
- package/src/cli/cmd/tui/component/dialog-mcp.tsx +1 -86
- package/src/cli/cmd/tui/component/dialog-model.tsx +1 -238
- package/src/cli/cmd/tui/component/dialog-provider.tsx +1 -343
- package/src/cli/cmd/tui/component/dialog-session-delete-failed.tsx +1 -103
- package/src/cli/cmd/tui/component/dialog-session-list.tsx +1 -301
- package/src/cli/cmd/tui/component/dialog-session-rename.tsx +1 -35
- package/src/cli/cmd/tui/component/dialog-skill.tsx +1 -37
- package/src/cli/cmd/tui/component/dialog-stash.tsx +1 -87
- package/src/cli/cmd/tui/component/dialog-status.tsx +1 -190
- package/src/cli/cmd/tui/component/dialog-tag.tsx +1 -44
- package/src/cli/cmd/tui/component/dialog-theme-list.tsx +1 -50
- package/src/cli/cmd/tui/component/dialog-variant.tsx +1 -39
- package/src/cli/cmd/tui/component/dialog-workspace-create.tsx +1 -200
- package/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx +1 -81
- package/src/cli/cmd/tui/context/app/args.tsx +15 -0
- package/src/cli/cmd/tui/context/app/directory.ts +15 -0
- package/src/cli/cmd/tui/context/app/editor-zed.ts +281 -0
- package/src/cli/cmd/tui/context/app/editor.ts +425 -0
- package/src/cli/cmd/tui/context/app/helper.tsx +25 -0
- package/src/cli/cmd/tui/context/app/project.tsx +109 -0
- package/src/cli/cmd/tui/context/app/route.tsx +67 -0
- package/src/cli/cmd/tui/context/app/sdk.tsx +142 -0
- package/src/cli/cmd/tui/context/app/sync.tsx +713 -0
- package/src/cli/cmd/tui/context/app/theme.tsx +307 -0
- package/src/cli/cmd/tui/context/app/tui-config.tsx +9 -0
- package/src/cli/cmd/tui/context/args.tsx +1 -15
- package/src/cli/cmd/tui/context/directory.ts +1 -15
- package/src/cli/cmd/tui/context/editor-zed.ts +1 -281
- package/src/cli/cmd/tui/context/editor.ts +1 -425
- package/src/cli/cmd/tui/context/event.ts +1 -45
- package/src/cli/cmd/tui/context/exit.tsx +1 -67
- package/src/cli/cmd/tui/context/helper.tsx +1 -25
- package/src/cli/cmd/tui/context/keybind.tsx +1 -105
- package/src/cli/cmd/tui/context/kv.tsx +1 -76
- package/src/cli/cmd/tui/context/local.tsx +1 -478
- package/src/cli/cmd/tui/context/plugin-keybinds.ts +1 -41
- package/src/cli/cmd/tui/context/project.tsx +1 -109
- package/src/cli/cmd/tui/context/prompt.tsx +1 -18
- package/src/cli/cmd/tui/context/route.tsx +1 -67
- package/src/cli/cmd/tui/context/runtime/event.ts +45 -0
- package/src/cli/cmd/tui/context/runtime/exit.tsx +67 -0
- package/src/cli/cmd/tui/context/runtime/keybind.tsx +105 -0
- package/src/cli/cmd/tui/context/runtime/kv.tsx +76 -0
- package/src/cli/cmd/tui/context/runtime/local.tsx +478 -0
- package/src/cli/cmd/tui/context/runtime/plugin-keybinds.ts +41 -0
- package/src/cli/cmd/tui/context/sdk.tsx +1 -142
- package/src/cli/cmd/tui/context/session/prompt.tsx +18 -0
- package/src/cli/cmd/tui/context/sync.tsx +1 -713
- package/src/cli/cmd/tui/context/theme.tsx +1 -307
- package/src/cli/cmd/tui/context/tui-config.tsx +1 -9
- package/src/tool/apply_patch.ts +1 -334
- package/src/tool/bash.ts +1 -656
- package/src/tool/core/external-directory.ts +55 -0
- package/src/tool/core/invalid.ts +21 -0
- package/src/tool/core/recall.ts +164 -0
- package/src/tool/core/recall.txt +12 -0
- package/src/tool/core/schema.ts +16 -0
- package/src/tool/core/tool.ts +162 -0
- package/src/tool/core/truncate.ts +160 -0
- package/src/tool/core/truncation-dir.ts +4 -0
- package/src/tool/diagnostics.ts +1 -20
- package/src/tool/edit-replacers.ts +1 -288
- package/src/tool/edit-utils.ts +1 -86
- package/src/tool/edit.ts +1 -262
- package/src/tool/external-directory.ts +1 -55
- package/src/tool/file/apply_patch.ts +334 -0
- package/src/tool/file/apply_patch.txt +33 -0
- package/src/tool/file/bash.ts +656 -0
- package/src/tool/file/bash.txt +119 -0
- package/src/tool/file/edit-replacers.ts +288 -0
- package/src/tool/file/edit-utils.ts +86 -0
- package/src/tool/file/edit.ts +262 -0
- package/src/tool/file/edit.txt +10 -0
- package/src/tool/file/read.ts +389 -0
- package/src/tool/file/read.txt +14 -0
- package/src/tool/file/write.ts +114 -0
- package/src/tool/file/write.txt +8 -0
- package/src/tool/glob.ts +1 -115
- package/src/tool/grep.ts +1 -151
- package/src/tool/integration/diagnostics.ts +20 -0
- package/src/tool/integration/lsp.ts +113 -0
- package/src/tool/integration/lsp.txt +24 -0
- package/src/tool/integration/mcp-exa.ts +73 -0
- package/src/tool/integration/package.ts +168 -0
- package/src/tool/integration/registry.ts +375 -0
- package/src/tool/invalid.ts +1 -21
- package/src/tool/lsp.ts +1 -113
- package/src/tool/mcp-exa.ts +1 -73
- package/src/tool/package.ts +1 -168
- package/src/tool/plan.ts +1 -30
- package/src/tool/question.ts +1 -52
- package/src/tool/read.ts +1 -389
- package/src/tool/recall.ts +1 -164
- package/src/tool/registry.ts +1 -375
- package/src/tool/schema.ts +1 -16
- package/src/tool/search/glob.ts +115 -0
- package/src/tool/search/glob.txt +6 -0
- package/src/tool/search/grep.ts +151 -0
- package/src/tool/search/grep.txt +8 -0
- package/src/tool/search/warpgrep.ts +107 -0
- package/src/tool/search/warpgrep.txt +10 -0
- package/src/tool/search/webfetch.ts +202 -0
- package/src/tool/search/webfetch.txt +13 -0
- package/src/tool/search/websearch.ts +71 -0
- package/src/tool/search/websearch.txt +14 -0
- package/src/tool/skill.ts +1 -91
- package/src/tool/task.ts +1 -197
- package/src/tool/todo.ts +1 -62
- package/src/tool/tool.ts +1 -162
- package/src/tool/truncate.ts +1 -160
- package/src/tool/truncation-dir.ts +1 -4
- package/src/tool/warpgrep.ts +1 -107
- package/src/tool/webfetch.ts +1 -202
- package/src/tool/websearch.ts +1 -71
- package/src/tool/workflow/plan-enter.txt +14 -0
- package/src/tool/workflow/plan-exit.txt +13 -0
- package/src/tool/workflow/plan.ts +30 -0
- package/src/tool/workflow/question.ts +52 -0
- package/src/tool/workflow/question.txt +11 -0
- package/src/tool/workflow/skill.ts +91 -0
- package/src/tool/workflow/skill.txt +5 -0
- package/src/tool/workflow/task.ts +197 -0
- package/src/tool/workflow/task.txt +57 -0
- package/src/tool/workflow/todo.ts +62 -0
- package/src/tool/workflow/todowrite.txt +167 -0
- package/src/tool/write.ts +1 -114
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { Schema } from "effect"
|
|
2
|
+
import * as path from "path"
|
|
3
|
+
import { Effect } from "effect"
|
|
4
|
+
import * as Tool from "../core/tool"
|
|
5
|
+
import { LSP } from "@/lsp/lsp"
|
|
6
|
+
import { createTwoFilesPatch } from "diff"
|
|
7
|
+
import DESCRIPTION from "./write.txt"
|
|
8
|
+
import { Bus } from "../../bus"
|
|
9
|
+
import { File } from "../../file"
|
|
10
|
+
import { FileWatcher } from "../../file/watcher"
|
|
11
|
+
import { Format } from "../../format"
|
|
12
|
+
import { AppFileSystem } from "@saeeol/core/filesystem"
|
|
13
|
+
import { InstanceState } from "@/effect/instance-state"
|
|
14
|
+
import { trimDiff, buildFileDiff } from "./edit"
|
|
15
|
+
import { assertExternalDirectoryEffect } from "../core/external-directory"
|
|
16
|
+
import { filterDiagnostics } from "../integration/diagnostics"
|
|
17
|
+
import { ConfigValidation } from "../../overlay/config-validation"
|
|
18
|
+
import * as EncodedIO from "../../overlay/tool/encoded-io"
|
|
19
|
+
import * as Bom from "@/util/bom"
|
|
20
|
+
|
|
21
|
+
const MAX_PROJECT_DIAGNOSTICS_FILES = 5
|
|
22
|
+
|
|
23
|
+
export const Parameters = Schema.Struct({
|
|
24
|
+
content: Schema.String.annotate({ description: "The content to write to the file" }),
|
|
25
|
+
filePath: Schema.String.annotate({
|
|
26
|
+
description: "The absolute path to the file to write (must be absolute, not relative)",
|
|
27
|
+
}),
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
export const WriteTool = Tool.define(
|
|
31
|
+
"write",
|
|
32
|
+
Effect.gen(function* () {
|
|
33
|
+
const lsp = yield* LSP.Service
|
|
34
|
+
const fs = yield* AppFileSystem.Service
|
|
35
|
+
const bus = yield* Bus.Service
|
|
36
|
+
const format = yield* Format.Service
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
description: DESCRIPTION,
|
|
40
|
+
parameters: Parameters,
|
|
41
|
+
execute: (params: { content: string; filePath: string }, ctx: Tool.Context) =>
|
|
42
|
+
Effect.gen(function* () {
|
|
43
|
+
const instance = yield* InstanceState.context
|
|
44
|
+
const filepath = path.isAbsolute(params.filePath)
|
|
45
|
+
? params.filePath
|
|
46
|
+
: path.join(instance.directory, params.filePath)
|
|
47
|
+
yield* assertExternalDirectoryEffect(ctx, filepath)
|
|
48
|
+
|
|
49
|
+
const exists = yield* fs.existsSafe(filepath)
|
|
50
|
+
// derive the BOM flag from the detected encoding label instead of the decoded text.
|
|
51
|
+
const pre = exists ? yield* EncodedIO.read(filepath) : { text: "", encoding: "utf-8" }
|
|
52
|
+
const source = { bom: pre.encoding === "utf-8-bom", text: pre.text, encoding: pre.encoding }
|
|
53
|
+
const next = Bom.split(params.content)
|
|
54
|
+
const desiredBom = source.bom || next.bom
|
|
55
|
+
const contentOld = source.text
|
|
56
|
+
const contentNew = next.text
|
|
57
|
+
|
|
58
|
+
const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, contentNew))
|
|
59
|
+
const filediff = buildFileDiff(filepath, contentOld, contentNew)
|
|
60
|
+
yield* ctx.ask({
|
|
61
|
+
permission: "edit",
|
|
62
|
+
patterns: [path.relative(instance.worktree, filepath)],
|
|
63
|
+
always: ["*"],
|
|
64
|
+
metadata: {
|
|
65
|
+
filepath,
|
|
66
|
+
diff,
|
|
67
|
+
filediff,
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
yield* EncodedIO.write(filepath, Bom.join(contentNew, desiredBom), source.encoding)
|
|
72
|
+
if (yield* format.file(filepath)) {
|
|
73
|
+
yield* Bom.syncFile(fs, filepath, desiredBom)
|
|
74
|
+
}
|
|
75
|
+
yield* bus.publish(File.Event.Edited, { file: filepath })
|
|
76
|
+
yield* bus.publish(FileWatcher.Event.Updated, {
|
|
77
|
+
file: filepath,
|
|
78
|
+
event: exists ? "change" : "add",
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
let output = "Wrote file successfully."
|
|
82
|
+
yield* lsp.touchFile(filepath, "document")
|
|
83
|
+
const diagnostics = yield* lsp.diagnostics()
|
|
84
|
+
const normalizedFilepath = AppFileSystem.normalizePath(filepath)
|
|
85
|
+
let projectDiagnosticsCount = 0
|
|
86
|
+
for (const [file, issues] of Object.entries(diagnostics)) {
|
|
87
|
+
const current = file === normalizedFilepath
|
|
88
|
+
if (!current && projectDiagnosticsCount >= MAX_PROJECT_DIAGNOSTICS_FILES) continue
|
|
89
|
+
const block = LSP.Diagnostic.report(current ? filepath : file, issues)
|
|
90
|
+
if (!block) continue
|
|
91
|
+
if (current) {
|
|
92
|
+
output += `\n\nLSP errors detected in this file, please fix:\n${block}`
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
projectDiagnosticsCount++
|
|
96
|
+
output += `\n\nLSP errors detected in other files:\n${block}`
|
|
97
|
+
}
|
|
98
|
+
output += yield* Effect.promise(() => ConfigValidation.check(filepath))
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
title: path.relative(instance.worktree, filepath),
|
|
102
|
+
metadata: {
|
|
103
|
+
diagnostics: filterDiagnostics(diagnostics, [normalizedFilepath]),
|
|
104
|
+
filepath,
|
|
105
|
+
exists: exists,
|
|
106
|
+
diff,
|
|
107
|
+
filediff,
|
|
108
|
+
},
|
|
109
|
+
output,
|
|
110
|
+
}
|
|
111
|
+
}).pipe(Effect.orDie),
|
|
112
|
+
}
|
|
113
|
+
}),
|
|
114
|
+
)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Writes a file to the local filesystem.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
- This tool will overwrite the existing file if there is one at the provided path.
|
|
5
|
+
- If this is an existing file, you MUST use the Read tool first to read the file's contents. This tool will fail if you did not read the file first.
|
|
6
|
+
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
|
|
7
|
+
- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.
|
|
8
|
+
- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked.
|
package/src/tool/glob.ts
CHANGED
|
@@ -1,115 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { Effect, Option, Schema } from "effect"
|
|
3
|
-
import * as Stream from "effect/Stream"
|
|
4
|
-
import { InstanceState } from "@/effect/instance-state"
|
|
5
|
-
import { AppFileSystem } from "@saeeol/core/filesystem"
|
|
6
|
-
import { Ripgrep } from "../file/ripgrep"
|
|
7
|
-
import { assertExternalDirectoryEffect } from "./external-directory"
|
|
8
|
-
import DESCRIPTION from "./glob.txt"
|
|
9
|
-
import * as Tool from "./tool"
|
|
10
|
-
function normalize(p: string) {
|
|
11
|
-
return p.replaceAll("\\", "/")
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function split(pattern: string) {
|
|
15
|
-
const normalized = normalize(pattern)
|
|
16
|
-
if (!path.isAbsolute(normalized)) return
|
|
17
|
-
const index = normalized.search(/[*?{[]/)
|
|
18
|
-
if (index === -1) return { dir: normalized, pattern: "*" }
|
|
19
|
-
const slice = normalized.slice(0, index)
|
|
20
|
-
const cut = slice.lastIndexOf("/")
|
|
21
|
-
const dir = cut > 0 ? slice.slice(0, cut) : "/"
|
|
22
|
-
const next = normalized.slice(cut + 1)
|
|
23
|
-
return { dir, pattern: next || "*" }
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export const Parameters = Schema.Struct({
|
|
27
|
-
pattern: Schema.String.annotate({ description: "The glob pattern to match files against" }),
|
|
28
|
-
path: Schema.optional(Schema.String).annotate({
|
|
29
|
-
description: `The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`,
|
|
30
|
-
}),
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
export const GlobTool = Tool.define(
|
|
34
|
-
"glob",
|
|
35
|
-
Effect.gen(function* () {
|
|
36
|
-
const rg = yield* Ripgrep.Service
|
|
37
|
-
const fs = yield* AppFileSystem.Service
|
|
38
|
-
|
|
39
|
-
return {
|
|
40
|
-
description: DESCRIPTION,
|
|
41
|
-
parameters: Parameters,
|
|
42
|
-
execute: (params: { pattern: string; path?: string }, ctx: Tool.Context) =>
|
|
43
|
-
Effect.gen(function* () {
|
|
44
|
-
const ins = yield* InstanceState.context
|
|
45
|
-
const absolute = split(params.pattern)
|
|
46
|
-
yield* ctx.ask({
|
|
47
|
-
permission: "glob",
|
|
48
|
-
patterns: [params.pattern],
|
|
49
|
-
always: ["*"],
|
|
50
|
-
metadata: {
|
|
51
|
-
pattern: params.pattern,
|
|
52
|
-
path: params.path,
|
|
53
|
-
},
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
const base = absolute?.dir ?? params.path ?? ins.directory
|
|
57
|
-
const search = path.isAbsolute(base) ? base : path.resolve(ins.directory, base)
|
|
58
|
-
const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
59
|
-
if (info?.type === "File") {
|
|
60
|
-
throw new Error(`glob path must be a directory: ${search}`)
|
|
61
|
-
}
|
|
62
|
-
yield* assertExternalDirectoryEffect(ctx, search, { kind: "directory" })
|
|
63
|
-
|
|
64
|
-
const limit = 100
|
|
65
|
-
let truncated = false
|
|
66
|
-
const files = yield* rg
|
|
67
|
-
.files({ cwd: search, glob: [absolute?.pattern ?? params.pattern], signal: ctx.abort })
|
|
68
|
-
.pipe(
|
|
69
|
-
Stream.mapEffect((file) =>
|
|
70
|
-
Effect.gen(function* () {
|
|
71
|
-
const full = path.resolve(search, file)
|
|
72
|
-
const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
73
|
-
const mtime =
|
|
74
|
-
info?.mtime.pipe(
|
|
75
|
-
Option.map((date) => date.getTime()),
|
|
76
|
-
Option.getOrElse(() => 0),
|
|
77
|
-
) ?? 0
|
|
78
|
-
return { path: full, mtime }
|
|
79
|
-
}),
|
|
80
|
-
),
|
|
81
|
-
Stream.take(limit + 1),
|
|
82
|
-
Stream.runCollect,
|
|
83
|
-
Effect.map((chunk) => [...chunk]),
|
|
84
|
-
)
|
|
85
|
-
|
|
86
|
-
if (files.length > limit) {
|
|
87
|
-
truncated = true
|
|
88
|
-
files.length = limit
|
|
89
|
-
}
|
|
90
|
-
files.sort((a, b) => b.mtime - a.mtime)
|
|
91
|
-
|
|
92
|
-
const output = []
|
|
93
|
-
if (files.length === 0) output.push("No files found")
|
|
94
|
-
if (files.length > 0) {
|
|
95
|
-
output.push(...files.map((file) => file.path))
|
|
96
|
-
if (truncated) {
|
|
97
|
-
output.push("")
|
|
98
|
-
output.push(
|
|
99
|
-
`(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`,
|
|
100
|
-
)
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
return {
|
|
105
|
-
title: path.relative(ins.worktree, search),
|
|
106
|
-
metadata: {
|
|
107
|
-
count: files.length,
|
|
108
|
-
truncated,
|
|
109
|
-
},
|
|
110
|
-
output: output.join("\n"),
|
|
111
|
-
}
|
|
112
|
-
}).pipe(Effect.orDie),
|
|
113
|
-
}
|
|
114
|
-
}),
|
|
115
|
-
)
|
|
1
|
+
export * from "./search/glob"
|
package/src/tool/grep.ts
CHANGED
|
@@ -1,151 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { Schema } from "effect"
|
|
3
|
-
import { Effect, Option } from "effect"
|
|
4
|
-
import { InstanceState } from "@/effect/instance-state"
|
|
5
|
-
import { AppFileSystem } from "@saeeol/core/filesystem"
|
|
6
|
-
import { Ripgrep } from "../file/ripgrep"
|
|
7
|
-
import { assertExternalDirectoryEffect } from "./external-directory"
|
|
8
|
-
import DESCRIPTION from "./grep.txt"
|
|
9
|
-
import * as Tool from "./tool"
|
|
10
|
-
|
|
11
|
-
const MAX_LINE_LENGTH = 2000
|
|
12
|
-
|
|
13
|
-
export const Parameters = Schema.Struct({
|
|
14
|
-
pattern: Schema.String.annotate({ description: "The regex pattern to search for in file contents" }),
|
|
15
|
-
path: Schema.optional(Schema.String).annotate({
|
|
16
|
-
description: "The directory to search in. Defaults to the current working directory.",
|
|
17
|
-
}),
|
|
18
|
-
include: Schema.optional(Schema.String).annotate({
|
|
19
|
-
description: 'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")',
|
|
20
|
-
}),
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
export const GrepTool = Tool.define(
|
|
24
|
-
"grep",
|
|
25
|
-
Effect.gen(function* () {
|
|
26
|
-
const fs = yield* AppFileSystem.Service
|
|
27
|
-
const rg = yield* Ripgrep.Service
|
|
28
|
-
|
|
29
|
-
return {
|
|
30
|
-
description: DESCRIPTION,
|
|
31
|
-
parameters: Parameters,
|
|
32
|
-
execute: (params: { pattern: string; path?: string; include?: string }, ctx: Tool.Context) =>
|
|
33
|
-
Effect.gen(function* () {
|
|
34
|
-
const empty = {
|
|
35
|
-
title: params.pattern,
|
|
36
|
-
metadata: { matches: 0, truncated: false },
|
|
37
|
-
output: "No files found",
|
|
38
|
-
}
|
|
39
|
-
if (!params.pattern) {
|
|
40
|
-
throw new Error("pattern is required")
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
yield* ctx.ask({
|
|
44
|
-
permission: "grep",
|
|
45
|
-
patterns: [params.pattern],
|
|
46
|
-
always: ["*"],
|
|
47
|
-
metadata: {
|
|
48
|
-
pattern: params.pattern,
|
|
49
|
-
path: params.path,
|
|
50
|
-
include: params.include,
|
|
51
|
-
},
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
const ins = yield* InstanceState.context
|
|
55
|
-
const search = AppFileSystem.resolve(
|
|
56
|
-
path.isAbsolute(params.path ?? ins.directory)
|
|
57
|
-
? (params.path ?? ins.directory)
|
|
58
|
-
: path.join(ins.directory, params.path ?? "."),
|
|
59
|
-
)
|
|
60
|
-
const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
61
|
-
const cwd = info?.type === "Directory" ? search : path.dirname(search)
|
|
62
|
-
const file = info?.type === "Directory" ? undefined : [path.relative(cwd, search)]
|
|
63
|
-
yield* assertExternalDirectoryEffect(ctx, search, {
|
|
64
|
-
kind: info?.type === "Directory" ? "directory" : "file",
|
|
65
|
-
})
|
|
66
|
-
|
|
67
|
-
const result = yield* rg.search({
|
|
68
|
-
cwd,
|
|
69
|
-
pattern: params.pattern,
|
|
70
|
-
glob: params.include ? [params.include] : undefined,
|
|
71
|
-
file,
|
|
72
|
-
signal: ctx.abort,
|
|
73
|
-
})
|
|
74
|
-
if (result.items.length === 0) return empty
|
|
75
|
-
|
|
76
|
-
const rows = result.items.map((item) => ({
|
|
77
|
-
path: AppFileSystem.resolve(
|
|
78
|
-
path.isAbsolute(item.path.text) ? item.path.text : path.join(cwd, item.path.text),
|
|
79
|
-
),
|
|
80
|
-
line: item.line_number,
|
|
81
|
-
text: item.lines.text,
|
|
82
|
-
}))
|
|
83
|
-
const times = new Map(
|
|
84
|
-
(yield* Effect.forEach(
|
|
85
|
-
[...new Set(rows.map((row) => row.path))],
|
|
86
|
-
Effect.fnUntraced(function* (file) {
|
|
87
|
-
const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
88
|
-
if (!info || info.type === "Directory") return undefined
|
|
89
|
-
return [
|
|
90
|
-
file,
|
|
91
|
-
info.mtime.pipe(
|
|
92
|
-
Option.map((time) => time.getTime()),
|
|
93
|
-
Option.getOrElse(() => 0),
|
|
94
|
-
) ?? 0,
|
|
95
|
-
] as const
|
|
96
|
-
}),
|
|
97
|
-
{ concurrency: 16 },
|
|
98
|
-
)).filter((entry): entry is readonly [string, number] => Boolean(entry)),
|
|
99
|
-
)
|
|
100
|
-
const matches = rows.flatMap((row) => {
|
|
101
|
-
const mtime = times.get(row.path)
|
|
102
|
-
if (mtime === undefined) return []
|
|
103
|
-
return [{ ...row, mtime }]
|
|
104
|
-
})
|
|
105
|
-
|
|
106
|
-
matches.sort((a, b) => b.mtime - a.mtime)
|
|
107
|
-
|
|
108
|
-
const limit = 100
|
|
109
|
-
const truncated = matches.length > limit
|
|
110
|
-
const final = truncated ? matches.slice(0, limit) : matches
|
|
111
|
-
if (final.length === 0) return empty
|
|
112
|
-
|
|
113
|
-
const total = matches.length
|
|
114
|
-
const output = [`Found ${total} matches${truncated ? ` (showing first ${limit})` : ""}`]
|
|
115
|
-
|
|
116
|
-
let current = ""
|
|
117
|
-
for (const match of final) {
|
|
118
|
-
if (current !== match.path) {
|
|
119
|
-
if (current !== "") output.push("")
|
|
120
|
-
current = match.path
|
|
121
|
-
output.push(`${match.path}:`)
|
|
122
|
-
}
|
|
123
|
-
const text =
|
|
124
|
-
match.text.length > MAX_LINE_LENGTH ? match.text.substring(0, MAX_LINE_LENGTH) + "..." : match.text
|
|
125
|
-
output.push(` Line ${match.line}: ${text}`)
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
if (truncated) {
|
|
129
|
-
output.push("")
|
|
130
|
-
output.push(
|
|
131
|
-
`(Results truncated: showing ${limit} of ${total} matches (${total - limit} hidden). Consider using a more specific path or pattern.)`,
|
|
132
|
-
)
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
if (result.partial) {
|
|
136
|
-
output.push("")
|
|
137
|
-
output.push("(Some paths were inaccessible and skipped)")
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
return {
|
|
141
|
-
title: params.pattern,
|
|
142
|
-
metadata: {
|
|
143
|
-
matches: total,
|
|
144
|
-
truncated,
|
|
145
|
-
},
|
|
146
|
-
output: output.join("\n"),
|
|
147
|
-
}
|
|
148
|
-
}).pipe(Effect.orDie),
|
|
149
|
-
}
|
|
150
|
-
}),
|
|
151
|
-
)
|
|
1
|
+
export * from "./search/grep"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { LSPClient } from "@/lsp/client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Filter diagnostics to only include entries for the specified files.
|
|
5
|
+
* Tools like edit, write, and apply_patch receive diagnostics for ALL project files
|
|
6
|
+
* from the LSP, but only the edited files' diagnostics are relevant for storage
|
|
7
|
+
* and display. Storing all files' diagnostics bloats session payloads significantly
|
|
8
|
+
* (100KB+ per tool call in large projects).
|
|
9
|
+
*/
|
|
10
|
+
export function filterDiagnostics(
|
|
11
|
+
diagnostics: Record<string, LSPClient.Diagnostic[]>,
|
|
12
|
+
files: string[],
|
|
13
|
+
): Record<string, LSPClient.Diagnostic[]> {
|
|
14
|
+
const result: Record<string, LSPClient.Diagnostic[]> = {}
|
|
15
|
+
for (const file of files) {
|
|
16
|
+
const items = diagnostics[file]
|
|
17
|
+
if (items) result[file] = items
|
|
18
|
+
}
|
|
19
|
+
return result
|
|
20
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect"
|
|
2
|
+
import * as Tool from "../core/tool"
|
|
3
|
+
import path from "path"
|
|
4
|
+
import { LSP } from "@/lsp/lsp"
|
|
5
|
+
import DESCRIPTION from "./lsp.txt"
|
|
6
|
+
import { InstanceState } from "@/effect/instance-state"
|
|
7
|
+
import { pathToFileURL } from "url"
|
|
8
|
+
import { assertExternalDirectoryEffect } from "../core/external-directory"
|
|
9
|
+
import { AppFileSystem } from "@saeeol/core/filesystem"
|
|
10
|
+
|
|
11
|
+
const operations = [
|
|
12
|
+
"goToDefinition",
|
|
13
|
+
"findReferences",
|
|
14
|
+
"hover",
|
|
15
|
+
"documentSymbol",
|
|
16
|
+
"workspaceSymbol",
|
|
17
|
+
"goToImplementation",
|
|
18
|
+
"prepareCallHierarchy",
|
|
19
|
+
"incomingCalls",
|
|
20
|
+
"outgoingCalls",
|
|
21
|
+
] as const
|
|
22
|
+
|
|
23
|
+
export const Parameters = Schema.Struct({
|
|
24
|
+
operation: Schema.Literals(operations).annotate({ description: "The LSP operation to perform" }),
|
|
25
|
+
filePath: Schema.String.annotate({ description: "The absolute or relative path to the file" }),
|
|
26
|
+
line: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).annotate({
|
|
27
|
+
description: "The line number (1-based, as shown in editors)",
|
|
28
|
+
}),
|
|
29
|
+
character: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).annotate({
|
|
30
|
+
description: "The character offset (1-based, as shown in editors)",
|
|
31
|
+
}),
|
|
32
|
+
query: Schema.optional(Schema.String).annotate({
|
|
33
|
+
description: "Search query for workspaceSymbol. Empty string requests all symbols.",
|
|
34
|
+
}),
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
export const LspTool = Tool.define(
|
|
38
|
+
"lsp",
|
|
39
|
+
Effect.gen(function* () {
|
|
40
|
+
const lsp = yield* LSP.Service
|
|
41
|
+
const fs = yield* AppFileSystem.Service
|
|
42
|
+
return {
|
|
43
|
+
description: DESCRIPTION,
|
|
44
|
+
parameters: Parameters,
|
|
45
|
+
execute: (args: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
|
|
46
|
+
Effect.gen(function* () {
|
|
47
|
+
const instance = yield* InstanceState.context
|
|
48
|
+
const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(instance.directory, args.filePath)
|
|
49
|
+
yield* assertExternalDirectoryEffect(ctx, file)
|
|
50
|
+
const meta =
|
|
51
|
+
args.operation === "workspaceSymbol"
|
|
52
|
+
? { operation: args.operation }
|
|
53
|
+
: args.operation === "documentSymbol"
|
|
54
|
+
? { operation: args.operation, filePath: file }
|
|
55
|
+
: { operation: args.operation, filePath: file, line: args.line, character: args.character }
|
|
56
|
+
yield* ctx.ask({
|
|
57
|
+
permission: "lsp",
|
|
58
|
+
patterns: ["*"],
|
|
59
|
+
always: ["*"],
|
|
60
|
+
metadata: meta,
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const uri = pathToFileURL(file).href
|
|
64
|
+
const position = { file, line: args.line - 1, character: args.character - 1 }
|
|
65
|
+
const relPath = path.relative(instance.worktree, file)
|
|
66
|
+
const detail =
|
|
67
|
+
args.operation === "workspaceSymbol"
|
|
68
|
+
? ""
|
|
69
|
+
: args.operation === "documentSymbol"
|
|
70
|
+
? relPath
|
|
71
|
+
: `${relPath}:${args.line}:${args.character}`
|
|
72
|
+
const title = detail ? `${args.operation} ${detail}` : args.operation
|
|
73
|
+
|
|
74
|
+
const exists = yield* fs.existsSafe(file)
|
|
75
|
+
if (!exists) throw new Error(`File not found: ${file}`)
|
|
76
|
+
|
|
77
|
+
const available = yield* lsp.hasClients(file)
|
|
78
|
+
if (!available) throw new Error("No LSP server available for this file type.")
|
|
79
|
+
|
|
80
|
+
yield* lsp.touchFile(file, "document")
|
|
81
|
+
|
|
82
|
+
const result: unknown[] = yield* (() => {
|
|
83
|
+
switch (args.operation) {
|
|
84
|
+
case "goToDefinition":
|
|
85
|
+
return lsp.definition(position)
|
|
86
|
+
case "findReferences":
|
|
87
|
+
return lsp.references(position)
|
|
88
|
+
case "hover":
|
|
89
|
+
return lsp.hover(position)
|
|
90
|
+
case "documentSymbol":
|
|
91
|
+
return lsp.documentSymbol(uri)
|
|
92
|
+
case "workspaceSymbol":
|
|
93
|
+
return lsp.workspaceSymbol(args.query ?? "")
|
|
94
|
+
case "goToImplementation":
|
|
95
|
+
return lsp.implementation(position)
|
|
96
|
+
case "prepareCallHierarchy":
|
|
97
|
+
return lsp.prepareCallHierarchy(position)
|
|
98
|
+
case "incomingCalls":
|
|
99
|
+
return lsp.incomingCalls(position)
|
|
100
|
+
case "outgoingCalls":
|
|
101
|
+
return lsp.outgoingCalls(position)
|
|
102
|
+
}
|
|
103
|
+
})()
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
title,
|
|
107
|
+
metadata: { result },
|
|
108
|
+
output: result.length === 0 ? `No results found for ${args.operation}` : JSON.stringify(result, null, 2),
|
|
109
|
+
}
|
|
110
|
+
}).pipe(Effect.orDie),
|
|
111
|
+
}
|
|
112
|
+
}),
|
|
113
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
Interact with Language Server Protocol (LSP) servers to get code intelligence features.
|
|
2
|
+
|
|
3
|
+
Supported operations:
|
|
4
|
+
- goToDefinition: Find where a symbol is defined
|
|
5
|
+
- findReferences: Find all references to a symbol
|
|
6
|
+
- hover: Get hover information (documentation, type info) for a symbol
|
|
7
|
+
- documentSymbol: Get all symbols (functions, classes, variables) in a document
|
|
8
|
+
- workspaceSymbol: List project-wide symbols matching a query string
|
|
9
|
+
- goToImplementation: Find implementations of an interface or abstract method
|
|
10
|
+
- prepareCallHierarchy: Get call hierarchy item at a position (functions/methods)
|
|
11
|
+
- incomingCalls: Find all functions/methods that call the function at a position
|
|
12
|
+
- outgoingCalls: Find all functions/methods called by the function at a position
|
|
13
|
+
|
|
14
|
+
All operations require:
|
|
15
|
+
- filePath: The file to operate on
|
|
16
|
+
- line: The line number (1-based, as shown in editors)
|
|
17
|
+
- character: The character offset (1-based, as shown in editors)
|
|
18
|
+
|
|
19
|
+
workspaceSymbol also accepts:
|
|
20
|
+
- query: A query string to filter symbols by. Empty string requests all symbols.
|
|
21
|
+
|
|
22
|
+
For workspaceSymbol, filePath is not sent in the LSP workspace/symbol request. It is used by saeeol to select and start the matching LSP server.
|
|
23
|
+
|
|
24
|
+
Note: LSP servers must be configured for the file type. If no server is available, an error will be returned.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Duration, Effect, Schema } from "effect"
|
|
2
|
+
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
|
3
|
+
|
|
4
|
+
const URL = process.env.EXA_API_KEY
|
|
5
|
+
? `https://mcp.exa.ai/mcp?exaApiKey=${encodeURIComponent(process.env.EXA_API_KEY)}`
|
|
6
|
+
: "https://mcp.exa.ai/mcp"
|
|
7
|
+
|
|
8
|
+
const McpResult = Schema.Struct({
|
|
9
|
+
result: Schema.Struct({
|
|
10
|
+
content: Schema.Array(
|
|
11
|
+
Schema.Struct({
|
|
12
|
+
type: Schema.String,
|
|
13
|
+
text: Schema.String,
|
|
14
|
+
}),
|
|
15
|
+
),
|
|
16
|
+
}),
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(McpResult))
|
|
20
|
+
|
|
21
|
+
const parseSse = Effect.fn("McpExa.parseSse")(function* (body: string) {
|
|
22
|
+
for (const line of body.split("\n")) {
|
|
23
|
+
if (!line.startsWith("data: ")) continue
|
|
24
|
+
const data = yield* decode(line.substring(6))
|
|
25
|
+
if (data.result.content[0]?.text) return data.result.content[0].text
|
|
26
|
+
}
|
|
27
|
+
return undefined
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
export const SearchArgs = Schema.Struct({
|
|
31
|
+
query: Schema.String,
|
|
32
|
+
type: Schema.String,
|
|
33
|
+
numResults: Schema.Number,
|
|
34
|
+
livecrawl: Schema.String,
|
|
35
|
+
contextMaxCharacters: Schema.optional(Schema.Number),
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const McpRequest = <F extends Schema.Struct.Fields>(args: Schema.Struct<F>) =>
|
|
39
|
+
Schema.Struct({
|
|
40
|
+
jsonrpc: Schema.Literal("2.0"),
|
|
41
|
+
id: Schema.Literal(1),
|
|
42
|
+
method: Schema.Literal("tools/call"),
|
|
43
|
+
params: Schema.Struct({
|
|
44
|
+
name: Schema.String,
|
|
45
|
+
arguments: args,
|
|
46
|
+
}),
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
export const call = <F extends Schema.Struct.Fields>(
|
|
50
|
+
http: HttpClient.HttpClient,
|
|
51
|
+
tool: string,
|
|
52
|
+
args: Schema.Struct<F>,
|
|
53
|
+
value: Schema.Struct.Type<F>,
|
|
54
|
+
timeout: Duration.Input,
|
|
55
|
+
) =>
|
|
56
|
+
Effect.gen(function* () {
|
|
57
|
+
const request = yield* HttpClientRequest.post(URL).pipe(
|
|
58
|
+
HttpClientRequest.accept("application/json, text/event-stream"),
|
|
59
|
+
HttpClientRequest.schemaBodyJson(McpRequest(args))({
|
|
60
|
+
jsonrpc: "2.0" as const,
|
|
61
|
+
id: 1 as const,
|
|
62
|
+
method: "tools/call" as const,
|
|
63
|
+
params: { name: tool, arguments: value },
|
|
64
|
+
}),
|
|
65
|
+
)
|
|
66
|
+
const response = yield* HttpClient.filterStatusOk(http)
|
|
67
|
+
.execute(request)
|
|
68
|
+
.pipe(
|
|
69
|
+
Effect.timeoutOrElse({ duration: timeout, orElse: () => Effect.die(new Error(`${tool} request timed out`)) }),
|
|
70
|
+
)
|
|
71
|
+
const body = yield* response.text
|
|
72
|
+
return yield* parseSse(body)
|
|
73
|
+
})
|