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
package/src/tool/read.ts
CHANGED
|
@@ -1,389 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { Effect, Option, Schema, Scope } from "effect"
|
|
3
|
-
import { NonNegativeInt } from "@/util/schema"
|
|
4
|
-
import * as path from "path"
|
|
5
|
-
import type { Readable } from "stream"
|
|
6
|
-
import { createInterface } from "readline"
|
|
7
|
-
import * as Tool from "./tool"
|
|
8
|
-
import { AppFileSystem } from "@saeeol/core/filesystem"
|
|
9
|
-
import { LSP } from "@/lsp/lsp"
|
|
10
|
-
import DESCRIPTION from "./read.txt"
|
|
11
|
-
import { InstanceState } from "@/effect/instance-state"
|
|
12
|
-
import { assertExternalDirectoryEffect } from "./external-directory"
|
|
13
|
-
import { Instruction } from "../session/instruction"
|
|
14
|
-
import { isPdfAttachment, sniffAttachmentMime } from "@/util/media"
|
|
15
|
-
import * as Encoding from "../overlay/encoding"
|
|
16
|
-
import * as TextStream from "../overlay/text-stream"
|
|
17
|
-
|
|
18
|
-
const DEFAULT_READ_LIMIT = 2000
|
|
19
|
-
const MAX_LINE_LENGTH = 2000
|
|
20
|
-
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
|
|
21
|
-
const MAX_BYTES = 50 * 1024
|
|
22
|
-
const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`
|
|
23
|
-
const SAMPLE_BYTES = 4096
|
|
24
|
-
const DIRECTORY_CONCURRENCY = 8
|
|
25
|
-
const SUPPORTED_IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"])
|
|
26
|
-
|
|
27
|
-
// `offset` and `limit` were originally `z.coerce.number()` — the runtime
|
|
28
|
-
// coercion was useful when the tool was called from a shell but serves no
|
|
29
|
-
// purpose in the LLM tool-call path (the model emits typed JSON). The JSON
|
|
30
|
-
// Schema output is identical (`type: "number"`), so the LLM view is
|
|
31
|
-
// unchanged; purely CLI-facing uses must now send numbers rather than strings.
|
|
32
|
-
export const Parameters = Schema.Struct({
|
|
33
|
-
filePath: Schema.String.annotate({ description: "The absolute path to the file or directory to read" }),
|
|
34
|
-
offset: Schema.optional(NonNegativeInt).annotate({
|
|
35
|
-
description: "The line number to start reading from (1-indexed)",
|
|
36
|
-
}),
|
|
37
|
-
limit: Schema.optional(NonNegativeInt).annotate({
|
|
38
|
-
description: "The maximum number of lines to read (defaults to 2000)",
|
|
39
|
-
}),
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
export const ReadTool = Tool.define(
|
|
43
|
-
"read",
|
|
44
|
-
Effect.gen(function* () {
|
|
45
|
-
const fs = yield* AppFileSystem.Service
|
|
46
|
-
const instruction = yield* Instruction.Service
|
|
47
|
-
const lsp = yield* LSP.Service
|
|
48
|
-
const scope = yield* Scope.Scope
|
|
49
|
-
|
|
50
|
-
const miss = Effect.fn("ReadTool.miss")(function* (filepath: string) {
|
|
51
|
-
const dir = path.dirname(filepath)
|
|
52
|
-
const base = path.basename(filepath)
|
|
53
|
-
const items = yield* fs.readDirectory(dir).pipe(
|
|
54
|
-
Effect.map((items) =>
|
|
55
|
-
items
|
|
56
|
-
.filter(
|
|
57
|
-
(item) =>
|
|
58
|
-
item.toLowerCase().includes(base.toLowerCase()) || base.toLowerCase().includes(item.toLowerCase()),
|
|
59
|
-
)
|
|
60
|
-
.map((item) => path.join(dir, item))
|
|
61
|
-
.slice(0, 3),
|
|
62
|
-
),
|
|
63
|
-
Effect.catch(() => Effect.succeed([] as string[])),
|
|
64
|
-
)
|
|
65
|
-
|
|
66
|
-
if (items.length > 0) {
|
|
67
|
-
return yield* Effect.fail(
|
|
68
|
-
new Error(`File not found: ${filepath}\n\nDid you mean one of these?\n${items.join("\n")}`),
|
|
69
|
-
)
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
return yield* Effect.fail(new Error(`File not found: ${filepath}`))
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
const list = Effect.fn("ReadTool.list")(function* (filepath: string) {
|
|
76
|
-
const items = yield* fs.readDirectoryEntries(filepath)
|
|
77
|
-
return yield* Effect.forEach(
|
|
78
|
-
items,
|
|
79
|
-
Effect.fnUntraced(function* (item) {
|
|
80
|
-
if (item.type === "directory") return item.name + "/"
|
|
81
|
-
if (item.type !== "symlink") return item.name
|
|
82
|
-
|
|
83
|
-
const target = yield* fs.stat(path.join(filepath, item.name)).pipe(Effect.catch(() => Effect.void))
|
|
84
|
-
if (target?.type === "Directory") return item.name + "/"
|
|
85
|
-
return item.name
|
|
86
|
-
}),
|
|
87
|
-
{ concurrency: "unbounded" },
|
|
88
|
-
).pipe(Effect.map((items: string[]) => items.sort((a, b) => a.localeCompare(b))))
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
const warm = Effect.fn("ReadTool.warm")(function* (filepath: string) {
|
|
92
|
-
yield* lsp.touchFile(filepath).pipe(Effect.ignore, Effect.forkIn(scope))
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
const readSample = Effect.fn("ReadTool.readSample")(function* (
|
|
96
|
-
filepath: string,
|
|
97
|
-
fileSize: number,
|
|
98
|
-
sampleSize: number,
|
|
99
|
-
) {
|
|
100
|
-
if (fileSize === 0) return new Uint8Array()
|
|
101
|
-
|
|
102
|
-
return yield* Effect.scoped(
|
|
103
|
-
Effect.gen(function* () {
|
|
104
|
-
const file = yield* fs.open(filepath, { flag: "r" })
|
|
105
|
-
return Option.getOrElse(yield* file.readAlloc(Math.min(sampleSize, fileSize)), () => new Uint8Array())
|
|
106
|
-
}),
|
|
107
|
-
)
|
|
108
|
-
})
|
|
109
|
-
|
|
110
|
-
const isBinaryFile = (filepath: string, bytes: Uint8Array) => {
|
|
111
|
-
const ext = path.extname(filepath).toLowerCase()
|
|
112
|
-
switch (ext) {
|
|
113
|
-
case ".zip":
|
|
114
|
-
case ".tar":
|
|
115
|
-
case ".gz":
|
|
116
|
-
case ".exe":
|
|
117
|
-
case ".dll":
|
|
118
|
-
case ".so":
|
|
119
|
-
case ".class":
|
|
120
|
-
case ".jar":
|
|
121
|
-
case ".war":
|
|
122
|
-
case ".7z":
|
|
123
|
-
case ".doc":
|
|
124
|
-
case ".docx":
|
|
125
|
-
case ".xls":
|
|
126
|
-
case ".xlsx":
|
|
127
|
-
case ".ppt":
|
|
128
|
-
case ".pptx":
|
|
129
|
-
case ".odt":
|
|
130
|
-
case ".ods":
|
|
131
|
-
case ".odp":
|
|
132
|
-
case ".bin":
|
|
133
|
-
case ".dat":
|
|
134
|
-
case ".obj":
|
|
135
|
-
case ".o":
|
|
136
|
-
case ".a":
|
|
137
|
-
case ".lib":
|
|
138
|
-
case ".wasm":
|
|
139
|
-
case ".pyc":
|
|
140
|
-
case ".pyo":
|
|
141
|
-
return true
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
if (bytes.length === 0) return false
|
|
145
|
-
const buf = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
146
|
-
if (Encoding.hasUtf16Bom(buf, bytes.length) || Encoding.hasUtf32Bom(buf, bytes.length)) return false
|
|
147
|
-
|
|
148
|
-
let nonPrintableCount = 0
|
|
149
|
-
for (let i = 0; i < bytes.length; i++) {
|
|
150
|
-
if (bytes[i] === 0) return true
|
|
151
|
-
if (bytes[i] < 9 || (bytes[i] > 13 && bytes[i] < 32)) {
|
|
152
|
-
nonPrintableCount++
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
return nonPrintableCount / bytes.length > 0.3
|
|
157
|
-
}
|
|
158
|
-
type DirectoryFile = {
|
|
159
|
-
filepath: string
|
|
160
|
-
content: string
|
|
161
|
-
}
|
|
162
|
-
const readDirectoryFiles = Effect.fn("ReadTool.readDirectoryFiles")(function* (
|
|
163
|
-
filepath: string,
|
|
164
|
-
items: string[],
|
|
165
|
-
directory: string,
|
|
166
|
-
) {
|
|
167
|
-
const entries = yield* fs.readDirectoryEntries(filepath).pipe(Effect.catch(() => Effect.succeed([])))
|
|
168
|
-
const types = new Map(entries.map((entry) => [entry.name, entry.type]))
|
|
169
|
-
const files = yield* Effect.forEach(
|
|
170
|
-
items.filter((item) => !item.endsWith("/") && types.get(item) === "file"),
|
|
171
|
-
Effect.fnUntraced(function* (item) {
|
|
172
|
-
const child = path.join(filepath, item)
|
|
173
|
-
const info = yield* Effect.promise(() => lstat(child)).pipe(Effect.catch(() => Effect.void))
|
|
174
|
-
if (!info?.isFile()) return
|
|
175
|
-
const sample = yield* readSample(child, Number(info.size), SAMPLE_BYTES).pipe(
|
|
176
|
-
Effect.catch(() => Effect.succeed(new Uint8Array())),
|
|
177
|
-
)
|
|
178
|
-
if (isBinaryFile(child, sample)) return
|
|
179
|
-
const file = yield* Effect.promise(() => lines(child, { limit: DEFAULT_READ_LIMIT, offset: 1 })).pipe(
|
|
180
|
-
Effect.catch(() => Effect.void),
|
|
181
|
-
)
|
|
182
|
-
if (!file) return
|
|
183
|
-
const rel = path.relative(directory, child).replaceAll("\\", "/")
|
|
184
|
-
const note = file.cut || file.more ? "\n\n(File truncated)" : ""
|
|
185
|
-
return {
|
|
186
|
-
filepath: child,
|
|
187
|
-
content: `<file_content path="${rel}">\n${file.raw.join("\n")}${note}\n</file_content>`,
|
|
188
|
-
}
|
|
189
|
-
}),
|
|
190
|
-
{ concurrency: DIRECTORY_CONCURRENCY },
|
|
191
|
-
)
|
|
192
|
-
return files.filter((item): item is DirectoryFile => item !== undefined)
|
|
193
|
-
})
|
|
194
|
-
|
|
195
|
-
const run = Effect.fn("ReadTool.execute")(function* (
|
|
196
|
-
params: Schema.Schema.Type<typeof Parameters>,
|
|
197
|
-
ctx: Tool.Context,
|
|
198
|
-
) {
|
|
199
|
-
const instance = yield* InstanceState.context
|
|
200
|
-
let filepath = params.filePath
|
|
201
|
-
if (!path.isAbsolute(filepath)) {
|
|
202
|
-
filepath = path.resolve(instance.directory, filepath)
|
|
203
|
-
}
|
|
204
|
-
if (process.platform === "win32") {
|
|
205
|
-
filepath = AppFileSystem.normalizePath(filepath)
|
|
206
|
-
}
|
|
207
|
-
const title = path.relative(instance.worktree, filepath)
|
|
208
|
-
|
|
209
|
-
const stat = yield* fs.stat(filepath).pipe(
|
|
210
|
-
Effect.catchIf(
|
|
211
|
-
(err) => "reason" in err && err.reason._tag === "NotFound",
|
|
212
|
-
() => Effect.succeed(undefined),
|
|
213
|
-
),
|
|
214
|
-
)
|
|
215
|
-
|
|
216
|
-
yield* assertExternalDirectoryEffect(ctx, filepath, {
|
|
217
|
-
bypass: Boolean(ctx.extra?.["bypassCwdCheck"]),
|
|
218
|
-
kind: stat?.type === "Directory" ? "directory" : "file",
|
|
219
|
-
})
|
|
220
|
-
|
|
221
|
-
yield* ctx.ask({
|
|
222
|
-
permission: "read",
|
|
223
|
-
patterns: [filepath],
|
|
224
|
-
always: ["*"],
|
|
225
|
-
metadata: {},
|
|
226
|
-
})
|
|
227
|
-
|
|
228
|
-
if (!stat) return yield* miss(filepath)
|
|
229
|
-
|
|
230
|
-
if (stat.type === "Directory") {
|
|
231
|
-
const items = yield* list(filepath)
|
|
232
|
-
const limit = params.limit ?? DEFAULT_READ_LIMIT
|
|
233
|
-
const offset = params.offset || 1
|
|
234
|
-
const start = offset - 1
|
|
235
|
-
const sliced = items.slice(start, start + limit)
|
|
236
|
-
const truncated = start + sliced.length < items.length
|
|
237
|
-
const expand = Boolean(ctx.extra?.["includeDirectoryFiles"])
|
|
238
|
-
const loaded = expand ? yield* readDirectoryFiles(filepath, sliced, instance.directory) : []
|
|
239
|
-
const content = loaded.map((item) => item.content).join("\n\n")
|
|
240
|
-
|
|
241
|
-
return {
|
|
242
|
-
title,
|
|
243
|
-
output: [
|
|
244
|
-
`<path>${filepath}</path>`,
|
|
245
|
-
`<type>directory</type>`,
|
|
246
|
-
`<entries>`,
|
|
247
|
-
sliced.join("\n"),
|
|
248
|
-
truncated
|
|
249
|
-
? `\n(Showing ${sliced.length} of ${items.length} entries. Use 'offset' parameter to read beyond entry ${offset + sliced.length})`
|
|
250
|
-
: `\n(${items.length} entries)`,
|
|
251
|
-
`</entries>`,
|
|
252
|
-
...(content ? [`\n${content}`] : []),
|
|
253
|
-
].join("\n"),
|
|
254
|
-
metadata: {
|
|
255
|
-
preview: sliced.slice(0, 20).join("\n"),
|
|
256
|
-
truncated,
|
|
257
|
-
loaded: loaded.map((item) => item.filepath),
|
|
258
|
-
},
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const loaded = yield* instruction.resolve(ctx.messages, filepath, ctx.messageID)
|
|
263
|
-
const sample = yield* readSample(filepath, Number(stat.size), SAMPLE_BYTES)
|
|
264
|
-
|
|
265
|
-
const mime = sniffAttachmentMime(sample, AppFileSystem.mimeType(filepath))
|
|
266
|
-
const isImage = SUPPORTED_IMAGE_MIMES.has(mime)
|
|
267
|
-
|
|
268
|
-
if (isImage || isPdfAttachment(mime)) {
|
|
269
|
-
const bytes = yield* fs.readFile(filepath)
|
|
270
|
-
const msg = isPdfAttachment(mime) ? "PDF read successfully" : "Image read successfully"
|
|
271
|
-
return {
|
|
272
|
-
title,
|
|
273
|
-
output: msg,
|
|
274
|
-
metadata: {
|
|
275
|
-
preview: msg,
|
|
276
|
-
truncated: false,
|
|
277
|
-
loaded: loaded.map((item) => item.filepath),
|
|
278
|
-
},
|
|
279
|
-
attachments: [
|
|
280
|
-
{
|
|
281
|
-
type: "file" as const,
|
|
282
|
-
mime,
|
|
283
|
-
url: `data:${mime};base64,${Buffer.from(bytes).toString("base64")}`,
|
|
284
|
-
},
|
|
285
|
-
],
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
if (isBinaryFile(filepath, sample)) {
|
|
290
|
-
return yield* Effect.fail(new Error(`Cannot read binary file: ${filepath}`))
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
const file = yield* Effect.promise(() =>
|
|
294
|
-
lines(filepath, { limit: params.limit ?? DEFAULT_READ_LIMIT, offset: params.offset || 1 }),
|
|
295
|
-
)
|
|
296
|
-
if (file.count < file.offset && !(file.count === 0 && file.offset === 1)) {
|
|
297
|
-
return yield* Effect.fail(
|
|
298
|
-
new Error(`Offset ${file.offset} is out of range for this file (${file.count} lines)`),
|
|
299
|
-
)
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
let output = [`<path>${filepath}</path>`, `<type>file</type>`, "<content>\n"].join("\n")
|
|
303
|
-
output += file.raw.map((line, i) => `${i + file.offset}: ${line}`).join("\n")
|
|
304
|
-
|
|
305
|
-
const last = file.offset + file.raw.length - 1
|
|
306
|
-
const next = last + 1
|
|
307
|
-
const truncated = file.more || file.cut
|
|
308
|
-
if (file.cut) {
|
|
309
|
-
output += `\n\n(Output capped at ${MAX_BYTES_LABEL}. Showing lines ${file.offset}-${last}. Use offset=${next} to continue.)`
|
|
310
|
-
} else if (file.more) {
|
|
311
|
-
output += `\n\n(Showing lines ${file.offset}-${last} of ${file.count}. Use offset=${next} to continue.)`
|
|
312
|
-
} else {
|
|
313
|
-
output += `\n\n(End of file - total ${file.count} lines)`
|
|
314
|
-
}
|
|
315
|
-
output += "\n</content>"
|
|
316
|
-
|
|
317
|
-
yield* warm(filepath)
|
|
318
|
-
|
|
319
|
-
if (loaded.length > 0) {
|
|
320
|
-
output += `\n\n<system-reminder>\n${loaded.map((item) => item.content).join("\n\n")}\n</system-reminder>`
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
return {
|
|
324
|
-
title,
|
|
325
|
-
output,
|
|
326
|
-
metadata: {
|
|
327
|
-
preview: file.raw.slice(0, 20).join("\n"),
|
|
328
|
-
truncated,
|
|
329
|
-
loaded: loaded.map((item) => item.filepath),
|
|
330
|
-
},
|
|
331
|
-
}
|
|
332
|
-
})
|
|
333
|
-
|
|
334
|
-
return {
|
|
335
|
-
description: DESCRIPTION,
|
|
336
|
-
parameters: Parameters,
|
|
337
|
-
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
|
|
338
|
-
run(params, ctx).pipe(Effect.orDie),
|
|
339
|
-
}
|
|
340
|
-
}),
|
|
341
|
-
)
|
|
342
|
-
// routed through TextStream.withFallback so non-UTF-8 files are decoded via
|
|
343
|
-
// iconv. The body otherwise matches upstream.
|
|
344
|
-
export async function lines(filepath: string, opts: { limit: number; offset: number }) {
|
|
345
|
-
return TextStream.withFallback(filepath, (stream) => readLines(stream, opts))
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
async function readLines(stream: Readable, opts: { limit: number; offset: number }) {
|
|
349
|
-
const rl = createInterface({
|
|
350
|
-
input: stream,
|
|
351
|
-
// Note: we use the crlfDelay option to recognize all instances of CR LF
|
|
352
|
-
// ('\r\n') in file as a single line break.
|
|
353
|
-
crlfDelay: Infinity,
|
|
354
|
-
})
|
|
355
|
-
|
|
356
|
-
const start = opts.offset - 1
|
|
357
|
-
const raw: string[] = []
|
|
358
|
-
let bytes = 0
|
|
359
|
-
let count = 0
|
|
360
|
-
let cut = false
|
|
361
|
-
let more = false
|
|
362
|
-
try {
|
|
363
|
-
for await (const text of rl) {
|
|
364
|
-
count += 1
|
|
365
|
-
if (count <= start) continue
|
|
366
|
-
|
|
367
|
-
if (raw.length >= opts.limit) {
|
|
368
|
-
more = true
|
|
369
|
-
continue
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
const line = text.length > MAX_LINE_LENGTH ? text.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : text
|
|
373
|
-
const size = Buffer.byteLength(line, "utf-8") + (raw.length > 0 ? 1 : 0)
|
|
374
|
-
if (bytes + size > MAX_BYTES) {
|
|
375
|
-
cut = true
|
|
376
|
-
more = true
|
|
377
|
-
break
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
raw.push(line)
|
|
381
|
-
bytes += size
|
|
382
|
-
}
|
|
383
|
-
} finally {
|
|
384
|
-
rl.close()
|
|
385
|
-
stream.destroy()
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
return { raw, count, cut, more, offset: opts.offset }
|
|
389
|
-
}
|
|
1
|
+
export * from "./file/read"
|
package/src/tool/recall.ts
CHANGED
|
@@ -1,164 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import * as Tool from "./tool"
|
|
3
|
-
import { Instance } from "../project/instance"
|
|
4
|
-
import { Locale } from "../util/locale"
|
|
5
|
-
import { Filesystem } from "../util/filesystem"
|
|
6
|
-
import { WorktreeFamily } from "../overlay/worktree-family"
|
|
7
|
-
import DESCRIPTION from "./recall.txt"
|
|
8
|
-
|
|
9
|
-
const Parameters = Schema.Struct({
|
|
10
|
-
mode: Schema.Literals(["search", "read"]).annotate({
|
|
11
|
-
description: "'search' to find sessions by title, 'read' to get a session transcript",
|
|
12
|
-
}),
|
|
13
|
-
query: Schema.optional(Schema.String).annotate({
|
|
14
|
-
description: "Search query to match against session titles (required for search mode)",
|
|
15
|
-
}),
|
|
16
|
-
sessionID: Schema.optional(Schema.String).annotate({
|
|
17
|
-
description: "Session ID to read the transcript of (required for read mode)",
|
|
18
|
-
}),
|
|
19
|
-
limit: Schema.optional(Schema.Number).annotate({
|
|
20
|
-
description: "Maximum number of search results to return (default: 20, max: 50)",
|
|
21
|
-
}),
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
export const RecallTool = Tool.define(
|
|
25
|
-
"saeeol_local_recall",
|
|
26
|
-
Effect.gen(function* () {
|
|
27
|
-
return {
|
|
28
|
-
description: DESCRIPTION,
|
|
29
|
-
parameters: Parameters,
|
|
30
|
-
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
|
|
31
|
-
Effect.gen(function* () {
|
|
32
|
-
if (params.mode === "search") {
|
|
33
|
-
return yield* Effect.promise(() => search(params, ctx))
|
|
34
|
-
}
|
|
35
|
-
return yield* Effect.promise(() => read(params, ctx))
|
|
36
|
-
}).pipe(Effect.orDie),
|
|
37
|
-
}
|
|
38
|
-
}),
|
|
39
|
-
)
|
|
40
|
-
|
|
41
|
-
async function search(params: { query?: string; limit?: number }, ctx: Tool.Context) {
|
|
42
|
-
if (!params.query) {
|
|
43
|
-
throw new Error("The 'query' parameter is required when mode is 'search'")
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
await ctx.ask({
|
|
47
|
-
permission: "recall",
|
|
48
|
-
patterns: ["search"],
|
|
49
|
-
always: ["search"],
|
|
50
|
-
metadata: {
|
|
51
|
-
mode: "search",
|
|
52
|
-
query: params.query,
|
|
53
|
-
},
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
const limit = Math.min(params.limit ?? 20, 50)
|
|
57
|
-
const dirs = await WorktreeFamily.list()
|
|
58
|
-
const { Session } = await import("../session/session")
|
|
59
|
-
|
|
60
|
-
const results: Array<{
|
|
61
|
-
id: string
|
|
62
|
-
title: string
|
|
63
|
-
directory: string
|
|
64
|
-
updated: string
|
|
65
|
-
}> = []
|
|
66
|
-
|
|
67
|
-
for (const session of Session.listGlobal({
|
|
68
|
-
projectID: Instance.project.id,
|
|
69
|
-
directories: dirs,
|
|
70
|
-
search: params.query,
|
|
71
|
-
roots: true,
|
|
72
|
-
limit,
|
|
73
|
-
})) {
|
|
74
|
-
results.push({
|
|
75
|
-
id: session.id,
|
|
76
|
-
title: session.title,
|
|
77
|
-
directory: session.directory,
|
|
78
|
-
updated: Locale.todayTimeOrDateTime(session.time.updated),
|
|
79
|
-
})
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
if (results.length === 0) {
|
|
83
|
-
return {
|
|
84
|
-
title: `Search: "${params.query}" (no results)`,
|
|
85
|
-
output: `No sessions found matching "${params.query}".`,
|
|
86
|
-
metadata: {},
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const lines = results.map((r) => `- **${r.title}**\n ID: ${r.id} | Updated: ${r.updated} | Dir: ${r.directory}`)
|
|
91
|
-
|
|
92
|
-
return {
|
|
93
|
-
title: `Search: "${params.query}" (${results.length} results)`,
|
|
94
|
-
output: lines.join("\n"),
|
|
95
|
-
metadata: {},
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
async function read(params: { sessionID?: string }, ctx: Tool.Context) {
|
|
100
|
-
if (!params.sessionID) {
|
|
101
|
-
throw new Error("The 'sessionID' parameter is required when mode is 'read'")
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const { Session } = await import("../session/session")
|
|
105
|
-
const { SessionID } = await import("../session/schema")
|
|
106
|
-
const session = await Session.get(SessionID.make(params.sessionID)).catch(() => {
|
|
107
|
-
throw new Error(`Session "${params.sessionID}" not found. Use search mode first to find valid session IDs.`)
|
|
108
|
-
})
|
|
109
|
-
const dirs = await WorktreeFamily.list()
|
|
110
|
-
const dir = Filesystem.resolve(session.directory)
|
|
111
|
-
if (!dirs.some((root) => Filesystem.contains(root, dir))) {
|
|
112
|
-
throw new Error(
|
|
113
|
-
`Session "${params.sessionID}" belongs to a different workspace and cannot be read from this directory.`,
|
|
114
|
-
)
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const cross = session.projectID !== Instance.project.id
|
|
118
|
-
if (cross) {
|
|
119
|
-
await ctx.ask({
|
|
120
|
-
permission: "recall",
|
|
121
|
-
patterns: [session.directory],
|
|
122
|
-
always: [session.directory],
|
|
123
|
-
metadata: {
|
|
124
|
-
sessionID: session.id,
|
|
125
|
-
title: session.title,
|
|
126
|
-
directory: session.directory,
|
|
127
|
-
},
|
|
128
|
-
})
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
const msgs = await Session.messages({ sessionID: session.id })
|
|
132
|
-
const lines: string[] = [
|
|
133
|
-
`# Session: ${session.title}`,
|
|
134
|
-
`Directory: ${session.directory}`,
|
|
135
|
-
`Created: ${Locale.todayTimeOrDateTime(session.time.created)}`,
|
|
136
|
-
"",
|
|
137
|
-
]
|
|
138
|
-
|
|
139
|
-
for (const msg of msgs) {
|
|
140
|
-
if (msg.info.role === "user") {
|
|
141
|
-
lines.push("## User")
|
|
142
|
-
for (const part of msg.parts) {
|
|
143
|
-
if (part.type === "text") lines.push(part.text)
|
|
144
|
-
}
|
|
145
|
-
lines.push("")
|
|
146
|
-
}
|
|
147
|
-
if (msg.info.role === "assistant") {
|
|
148
|
-
lines.push("## Assistant")
|
|
149
|
-
for (const part of msg.parts) {
|
|
150
|
-
if (part.type === "text") lines.push(part.text)
|
|
151
|
-
if (part.type === "tool" && part.state.status === "completed") {
|
|
152
|
-
lines.push(`[Tool: ${part.tool}] ${part.state.title}`)
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
lines.push("")
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
return {
|
|
160
|
-
title: `Read: ${session.title}`,
|
|
161
|
-
output: lines.join("\n"),
|
|
162
|
-
metadata: {},
|
|
163
|
-
}
|
|
164
|
-
}
|
|
1
|
+
export * from "./core/recall"
|