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.
Files changed (151) hide show
  1. package/bin/saeeol.cjs +187 -0
  2. package/npm/bin/saeeol +0 -0
  3. package/package.json +12 -12
  4. package/src/cli/cmd/tui/component/dialog/dialog-agent.tsx +32 -0
  5. package/src/cli/cmd/tui/component/dialog/dialog-command.tsx +190 -0
  6. package/src/cli/cmd/tui/component/dialog/dialog-console-org.tsx +103 -0
  7. package/src/cli/cmd/tui/component/dialog/dialog-go-upsell.tsx +159 -0
  8. package/src/cli/cmd/tui/component/dialog/dialog-mcp.tsx +86 -0
  9. package/src/cli/cmd/tui/component/dialog/dialog-model.tsx +238 -0
  10. package/src/cli/cmd/tui/component/dialog/dialog-provider.tsx +343 -0
  11. package/src/cli/cmd/tui/component/dialog/dialog-session-delete-failed.tsx +103 -0
  12. package/src/cli/cmd/tui/component/dialog/dialog-session-list.tsx +301 -0
  13. package/src/cli/cmd/tui/component/dialog/dialog-session-rename.tsx +35 -0
  14. package/src/cli/cmd/tui/component/dialog/dialog-skill.tsx +37 -0
  15. package/src/cli/cmd/tui/component/dialog/dialog-stash.tsx +87 -0
  16. package/src/cli/cmd/tui/component/dialog/dialog-status.tsx +190 -0
  17. package/src/cli/cmd/tui/component/dialog/dialog-tag.tsx +44 -0
  18. package/src/cli/cmd/tui/component/dialog/dialog-theme-list.tsx +50 -0
  19. package/src/cli/cmd/tui/component/dialog/dialog-variant.tsx +39 -0
  20. package/src/cli/cmd/tui/component/dialog/dialog-workspace-create.tsx +200 -0
  21. package/src/cli/cmd/tui/component/dialog/dialog-workspace-unavailable.tsx +81 -0
  22. package/src/cli/cmd/tui/component/dialog-agent.tsx +1 -32
  23. package/src/cli/cmd/tui/component/dialog-command.tsx +1 -190
  24. package/src/cli/cmd/tui/component/dialog-console-org.tsx +1 -103
  25. package/src/cli/cmd/tui/component/dialog-go-upsell.tsx +1 -159
  26. package/src/cli/cmd/tui/component/dialog-mcp.tsx +1 -86
  27. package/src/cli/cmd/tui/component/dialog-model.tsx +1 -238
  28. package/src/cli/cmd/tui/component/dialog-provider.tsx +1 -343
  29. package/src/cli/cmd/tui/component/dialog-session-delete-failed.tsx +1 -103
  30. package/src/cli/cmd/tui/component/dialog-session-list.tsx +1 -301
  31. package/src/cli/cmd/tui/component/dialog-session-rename.tsx +1 -35
  32. package/src/cli/cmd/tui/component/dialog-skill.tsx +1 -37
  33. package/src/cli/cmd/tui/component/dialog-stash.tsx +1 -87
  34. package/src/cli/cmd/tui/component/dialog-status.tsx +1 -190
  35. package/src/cli/cmd/tui/component/dialog-tag.tsx +1 -44
  36. package/src/cli/cmd/tui/component/dialog-theme-list.tsx +1 -50
  37. package/src/cli/cmd/tui/component/dialog-variant.tsx +1 -39
  38. package/src/cli/cmd/tui/component/dialog-workspace-create.tsx +1 -200
  39. package/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx +1 -81
  40. package/src/cli/cmd/tui/context/app/args.tsx +15 -0
  41. package/src/cli/cmd/tui/context/app/directory.ts +15 -0
  42. package/src/cli/cmd/tui/context/app/editor-zed.ts +281 -0
  43. package/src/cli/cmd/tui/context/app/editor.ts +425 -0
  44. package/src/cli/cmd/tui/context/app/helper.tsx +25 -0
  45. package/src/cli/cmd/tui/context/app/project.tsx +109 -0
  46. package/src/cli/cmd/tui/context/app/route.tsx +67 -0
  47. package/src/cli/cmd/tui/context/app/sdk.tsx +142 -0
  48. package/src/cli/cmd/tui/context/app/sync.tsx +713 -0
  49. package/src/cli/cmd/tui/context/app/theme.tsx +307 -0
  50. package/src/cli/cmd/tui/context/app/tui-config.tsx +9 -0
  51. package/src/cli/cmd/tui/context/args.tsx +1 -15
  52. package/src/cli/cmd/tui/context/directory.ts +1 -15
  53. package/src/cli/cmd/tui/context/editor-zed.ts +1 -281
  54. package/src/cli/cmd/tui/context/editor.ts +1 -425
  55. package/src/cli/cmd/tui/context/event.ts +1 -45
  56. package/src/cli/cmd/tui/context/exit.tsx +1 -67
  57. package/src/cli/cmd/tui/context/helper.tsx +1 -25
  58. package/src/cli/cmd/tui/context/keybind.tsx +1 -105
  59. package/src/cli/cmd/tui/context/kv.tsx +1 -76
  60. package/src/cli/cmd/tui/context/local.tsx +1 -478
  61. package/src/cli/cmd/tui/context/plugin-keybinds.ts +1 -41
  62. package/src/cli/cmd/tui/context/project.tsx +1 -109
  63. package/src/cli/cmd/tui/context/prompt.tsx +1 -18
  64. package/src/cli/cmd/tui/context/route.tsx +1 -67
  65. package/src/cli/cmd/tui/context/runtime/event.ts +45 -0
  66. package/src/cli/cmd/tui/context/runtime/exit.tsx +67 -0
  67. package/src/cli/cmd/tui/context/runtime/keybind.tsx +105 -0
  68. package/src/cli/cmd/tui/context/runtime/kv.tsx +76 -0
  69. package/src/cli/cmd/tui/context/runtime/local.tsx +478 -0
  70. package/src/cli/cmd/tui/context/runtime/plugin-keybinds.ts +41 -0
  71. package/src/cli/cmd/tui/context/sdk.tsx +1 -142
  72. package/src/cli/cmd/tui/context/session/prompt.tsx +18 -0
  73. package/src/cli/cmd/tui/context/sync.tsx +1 -713
  74. package/src/cli/cmd/tui/context/theme.tsx +1 -307
  75. package/src/cli/cmd/tui/context/tui-config.tsx +1 -9
  76. package/src/tool/apply_patch.ts +1 -334
  77. package/src/tool/bash.ts +1 -656
  78. package/src/tool/core/external-directory.ts +55 -0
  79. package/src/tool/core/invalid.ts +21 -0
  80. package/src/tool/core/recall.ts +164 -0
  81. package/src/tool/core/recall.txt +12 -0
  82. package/src/tool/core/schema.ts +16 -0
  83. package/src/tool/core/tool.ts +162 -0
  84. package/src/tool/core/truncate.ts +160 -0
  85. package/src/tool/core/truncation-dir.ts +4 -0
  86. package/src/tool/diagnostics.ts +1 -20
  87. package/src/tool/edit-replacers.ts +1 -288
  88. package/src/tool/edit-utils.ts +1 -86
  89. package/src/tool/edit.ts +1 -262
  90. package/src/tool/external-directory.ts +1 -55
  91. package/src/tool/file/apply_patch.ts +334 -0
  92. package/src/tool/file/apply_patch.txt +33 -0
  93. package/src/tool/file/bash.ts +656 -0
  94. package/src/tool/file/bash.txt +119 -0
  95. package/src/tool/file/edit-replacers.ts +288 -0
  96. package/src/tool/file/edit-utils.ts +86 -0
  97. package/src/tool/file/edit.ts +262 -0
  98. package/src/tool/file/edit.txt +10 -0
  99. package/src/tool/file/read.ts +389 -0
  100. package/src/tool/file/read.txt +14 -0
  101. package/src/tool/file/write.ts +114 -0
  102. package/src/tool/file/write.txt +8 -0
  103. package/src/tool/glob.ts +1 -115
  104. package/src/tool/grep.ts +1 -151
  105. package/src/tool/integration/diagnostics.ts +20 -0
  106. package/src/tool/integration/lsp.ts +113 -0
  107. package/src/tool/integration/lsp.txt +24 -0
  108. package/src/tool/integration/mcp-exa.ts +73 -0
  109. package/src/tool/integration/package.ts +168 -0
  110. package/src/tool/integration/registry.ts +375 -0
  111. package/src/tool/invalid.ts +1 -21
  112. package/src/tool/lsp.ts +1 -113
  113. package/src/tool/mcp-exa.ts +1 -73
  114. package/src/tool/package.ts +1 -168
  115. package/src/tool/plan.ts +1 -30
  116. package/src/tool/question.ts +1 -52
  117. package/src/tool/read.ts +1 -389
  118. package/src/tool/recall.ts +1 -164
  119. package/src/tool/registry.ts +1 -375
  120. package/src/tool/schema.ts +1 -16
  121. package/src/tool/search/glob.ts +115 -0
  122. package/src/tool/search/glob.txt +6 -0
  123. package/src/tool/search/grep.ts +151 -0
  124. package/src/tool/search/grep.txt +8 -0
  125. package/src/tool/search/warpgrep.ts +107 -0
  126. package/src/tool/search/warpgrep.txt +10 -0
  127. package/src/tool/search/webfetch.ts +202 -0
  128. package/src/tool/search/webfetch.txt +13 -0
  129. package/src/tool/search/websearch.ts +71 -0
  130. package/src/tool/search/websearch.txt +14 -0
  131. package/src/tool/skill.ts +1 -91
  132. package/src/tool/task.ts +1 -197
  133. package/src/tool/todo.ts +1 -62
  134. package/src/tool/tool.ts +1 -162
  135. package/src/tool/truncate.ts +1 -160
  136. package/src/tool/truncation-dir.ts +1 -4
  137. package/src/tool/warpgrep.ts +1 -107
  138. package/src/tool/webfetch.ts +1 -202
  139. package/src/tool/websearch.ts +1 -71
  140. package/src/tool/workflow/plan-enter.txt +14 -0
  141. package/src/tool/workflow/plan-exit.txt +13 -0
  142. package/src/tool/workflow/plan.ts +30 -0
  143. package/src/tool/workflow/question.ts +52 -0
  144. package/src/tool/workflow/question.txt +11 -0
  145. package/src/tool/workflow/skill.ts +91 -0
  146. package/src/tool/workflow/skill.txt +5 -0
  147. package/src/tool/workflow/task.ts +197 -0
  148. package/src/tool/workflow/task.txt +57 -0
  149. package/src/tool/workflow/todo.ts +62 -0
  150. package/src/tool/workflow/todowrite.txt +167 -0
  151. package/src/tool/write.ts +1 -114
@@ -0,0 +1,55 @@
1
+ import path from "path"
2
+ import { Effect } from "effect"
3
+ import * as EffectLogger from "@saeeol/core/effect/logger"
4
+ import { InstanceState } from "@/effect/instance-state"
5
+ import type * as Tool from "./tool"
6
+ import { AppFileSystem } from "@saeeol/core/filesystem"
7
+
8
+ type Kind = "file" | "directory"
9
+
10
+ type Options = {
11
+ bypass?: boolean
12
+ kind?: Kind
13
+ }
14
+ function root(dir: string) {
15
+ return path.parse(dir).root === dir
16
+ }
17
+
18
+ function inside(dir: string, file: string) {
19
+ return !root(dir) && AppFileSystem.contains(dir, file)
20
+ }
21
+
22
+ export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirectory")(function* (
23
+ ctx: Tool.Context,
24
+ target?: string,
25
+ options?: Options,
26
+ ) {
27
+ if (!target) return
28
+
29
+ if (options?.bypass) return
30
+
31
+ const ins = yield* InstanceState.context
32
+ const full = process.platform === "win32" ? AppFileSystem.normalizePath(target) : target
33
+ if (inside(ins.directory, full) || inside(ins.worktree, full)) return
34
+
35
+ const kind = options?.kind ?? "file"
36
+ const dir = kind === "directory" ? full : path.dirname(full)
37
+ const glob =
38
+ process.platform === "win32"
39
+ ? AppFileSystem.normalizePathPattern(path.join(dir, "*"))
40
+ : path.join(dir, "*").replaceAll("\\", "/")
41
+
42
+ yield* ctx.ask({
43
+ permission: "external_directory",
44
+ patterns: [glob],
45
+ always: [glob],
46
+ metadata: {
47
+ filepath: full,
48
+ parentDir: dir,
49
+ },
50
+ })
51
+ })
52
+
53
+ export async function assertExternalDirectory(ctx: Tool.Context, target?: string, options?: Options) {
54
+ return Effect.runPromise(assertExternalDirectoryEffect(ctx, target, options).pipe(Effect.provide(EffectLogger.layer)))
55
+ }
@@ -0,0 +1,21 @@
1
+ import { Effect, Schema } from "effect"
2
+ import * as Tool from "./tool"
3
+
4
+ export const Parameters = Schema.Struct({
5
+ tool: Schema.String,
6
+ error: Schema.String,
7
+ })
8
+
9
+ export const InvalidTool = Tool.define(
10
+ "invalid",
11
+ Effect.succeed({
12
+ description: "Do not use",
13
+ parameters: Parameters,
14
+ execute: (params: { tool: string; error: string }) =>
15
+ Effect.succeed({
16
+ title: "Invalid Tool",
17
+ output: `The arguments provided to the tool are invalid: ${params.error}`,
18
+ metadata: {},
19
+ }),
20
+ }),
21
+ )
@@ -0,0 +1,164 @@
1
+ import { Effect, Schema } from "effect"
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
+ }
@@ -0,0 +1,12 @@
1
+ Search and read past conversations from the current project on this machine, including its git worktrees. Use this to recall previous work, find how something was implemented before, or retrieve context from another worktree in the same repo.
2
+
3
+ Two modes:
4
+ 1. **Search** - Find sessions by title keyword in the current project and its worktrees. Returns a list of matching sessions with their title, directory, and last updated time. Use this first to locate relevant conversations.
5
+ 2. **Read** - Retrieve the full transcript of a specific session by ID. Returns the conversation messages (user prompts and assistant responses) so you can understand what was discussed and done.
6
+
7
+ Usage notes:
8
+ - Search matches against session titles using case-insensitive substring matching
9
+ - Results are limited to the current project/worktree family
10
+ - Reading a session from a different project is rejected
11
+ - Use search mode first to find session IDs, then read mode to get the full conversation
12
+ - Session transcripts can be large; prefer searching first to narrow down which session to read
@@ -0,0 +1,16 @@
1
+ import { Schema } from "effect"
2
+
3
+ import { Identifier } from "@/id/id"
4
+ import { zod, ZodOverride } from "@/util/effect-zod"
5
+ import { withStatics } from "@/util/schema"
6
+
7
+ const toolIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("tool") }).pipe(Schema.brand("ToolID"))
8
+
9
+ export type ToolID = typeof toolIdSchema.Type
10
+
11
+ export const ToolID = toolIdSchema.pipe(
12
+ withStatics((schema: typeof toolIdSchema) => ({
13
+ ascending: (id?: string) => schema.make(Identifier.ascending("tool", id)),
14
+ zod: zod(schema),
15
+ })),
16
+ )
@@ -0,0 +1,162 @@
1
+ import { Effect, Schema } from "effect"
2
+ import type { MessageV2 } from "../../session/message-v2"
3
+ import type { Permission } from "../../permission"
4
+ import type { SessionID, MessageID } from "../../session/schema"
5
+ import * as Truncate from "./truncate"
6
+ import { Agent } from "@/agent/agent"
7
+
8
+ interface Metadata {
9
+ [key: string]: any
10
+ }
11
+
12
+ // TODO: remove this hack
13
+ export type DynamicDescription = (agent: Agent.Info) => Effect.Effect<string>
14
+
15
+ export type Context<M extends Metadata = Metadata> = {
16
+ sessionID: SessionID
17
+ messageID: MessageID
18
+ agent: string
19
+ abort: AbortSignal
20
+ callID?: string
21
+ extra?: { [key: string]: unknown }
22
+ messages: MessageV2.WithParts[]
23
+ metadata(input: { title?: string; metadata?: M }): Effect.Effect<void>
24
+ ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Effect.Effect<void>
25
+ }
26
+
27
+ export interface ExecuteResult<M extends Metadata = Metadata> {
28
+ title: string
29
+ metadata: M
30
+ output: string
31
+ attachments?: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[]
32
+ }
33
+
34
+ export interface Def<
35
+ Parameters extends Schema.Decoder<unknown> = Schema.Decoder<unknown>,
36
+ M extends Metadata = Metadata,
37
+ > {
38
+ id: string
39
+ description: string
40
+ parameters: Parameters
41
+ execute(args: Schema.Schema.Type<Parameters>, ctx: Context): Effect.Effect<ExecuteResult<M>>
42
+ formatValidationError?(error: unknown): string
43
+ }
44
+ export type DefWithoutID<
45
+ Parameters extends Schema.Decoder<unknown> = Schema.Decoder<unknown>,
46
+ M extends Metadata = Metadata,
47
+ > = Omit<Def<Parameters, M>, "id">
48
+
49
+ export interface Info<
50
+ Parameters extends Schema.Decoder<unknown> = Schema.Decoder<unknown>,
51
+ M extends Metadata = Metadata,
52
+ > {
53
+ id: string
54
+ init: () => Effect.Effect<DefWithoutID<Parameters, M>>
55
+ }
56
+
57
+ type Init<Parameters extends Schema.Decoder<unknown>, M extends Metadata> =
58
+ | DefWithoutID<Parameters, M>
59
+ | (() => Effect.Effect<DefWithoutID<Parameters, M>>)
60
+
61
+ export type InferParameters<T> =
62
+ T extends Info<infer P, any>
63
+ ? Schema.Schema.Type<P>
64
+ : T extends Effect.Effect<Info<infer P, any>, any, any>
65
+ ? Schema.Schema.Type<P>
66
+ : never
67
+ export type InferMetadata<T> =
68
+ T extends Info<any, infer M> ? M : T extends Effect.Effect<Info<any, infer M>, any, any> ? M : never
69
+
70
+ export type InferDef<T> =
71
+ T extends Info<infer P, infer M>
72
+ ? Def<P, M>
73
+ : T extends Effect.Effect<Info<infer P, infer M>, any, any>
74
+ ? Def<P, M>
75
+ : never
76
+
77
+ function wrap<Parameters extends Schema.Decoder<unknown>, Result extends Metadata>(
78
+ id: string,
79
+ init: Init<Parameters, Result>,
80
+ truncate: Truncate.Interface,
81
+ agents: Agent.Interface,
82
+ ) {
83
+ return () =>
84
+ Effect.gen(function* () {
85
+ const toolInfo = typeof init === "function" ? { ...(yield* init()) } : { ...init }
86
+ // Compile the parser closure once per tool init; `decodeUnknownEffect`
87
+ // allocates a new closure per call, so hoisting avoids re-closing it for
88
+ // every LLM tool invocation.
89
+ const decode = Schema.decodeUnknownEffect(toolInfo.parameters)
90
+ const execute = toolInfo.execute
91
+ toolInfo.execute = (args, ctx) => {
92
+ const attrs = {
93
+ "tool.name": id,
94
+ "session.id": ctx.sessionID,
95
+ "message.id": ctx.messageID,
96
+ ...(ctx.callID ? { "tool.call_id": ctx.callID } : {}),
97
+ }
98
+ return Effect.gen(function* () {
99
+ const decoded = yield* decode(args).pipe(
100
+ Effect.mapError((error) =>
101
+ toolInfo.formatValidationError
102
+ ? new Error(toolInfo.formatValidationError(error), { cause: error })
103
+ : new Error(
104
+ `The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,
105
+ { cause: error },
106
+ ),
107
+ ),
108
+ )
109
+ const result = yield* execute(decoded as Schema.Schema.Type<Parameters>, ctx)
110
+ if (result.metadata.truncated !== undefined) {
111
+ return result
112
+ }
113
+ const agent = yield* agents.get(ctx.agent)
114
+ const truncated = yield* truncate.output(result.output, {}, agent)
115
+ return {
116
+ ...result,
117
+ output: truncated.content,
118
+ metadata: {
119
+ ...result.metadata,
120
+ truncated: truncated.truncated,
121
+ ...(truncated.truncated && { outputPath: truncated.outputPath }),
122
+ },
123
+ }
124
+ }).pipe(Effect.orDie, Effect.withSpan("Tool.execute", { attributes: attrs }))
125
+ }
126
+ return toolInfo
127
+ })
128
+ }
129
+
130
+ export function define<
131
+ Parameters extends Schema.Decoder<unknown>,
132
+ Result extends Metadata,
133
+ R,
134
+ ID extends string = string,
135
+ >(
136
+ id: ID,
137
+ init: Effect.Effect<Init<Parameters, Result>, never, R>,
138
+ ): Effect.Effect<Info<Parameters, Result>, never, R | Truncate.Service | Agent.Service> & { id: ID } {
139
+ return Object.assign(
140
+ Effect.gen(function* () {
141
+ const resolved = yield* init
142
+ const truncate = yield* Truncate.Service
143
+ const agents = yield* Agent.Service
144
+ return { id, init: wrap(id, resolved, truncate, agents) }
145
+ }),
146
+ { id },
147
+ )
148
+ }
149
+
150
+ export function init<P extends Schema.Decoder<unknown>, M extends Metadata>(
151
+ info: Info<P, M>,
152
+ ): Effect.Effect<Def<P, M>> {
153
+ return Effect.gen(function* () {
154
+ const init = yield* info.init()
155
+ return {
156
+ ...init,
157
+ id: info.id,
158
+ }
159
+ })
160
+ }
161
+
162
+ export * as Tool from "./tool"
@@ -0,0 +1,160 @@
1
+ import { NodePath } from "@effect/platform-node"
2
+ import { Cause, Duration, Effect, Layer, Option, Schedule, Context } from "effect"
3
+ import path from "path"
4
+ import type { Agent } from "../../agent/agent"
5
+ import { AppFileSystem } from "@saeeol/core/filesystem"
6
+ import { evaluate } from "@/permission/evaluate"
7
+ import { Config } from "@/config/config"
8
+ import { Identifier } from "../../id/id"
9
+ import * as Log from "@saeeol/core/util/log"
10
+ import { ToolID } from "./schema"
11
+ import { TRUNCATION_DIR } from "./truncation-dir"
12
+
13
+ const log = Log.create({ service: "truncation" })
14
+ const RETENTION = Duration.days(7)
15
+
16
+ export const MAX_LINES = 2000
17
+ export const MAX_BYTES = 50 * 1024
18
+ export const DIR = TRUNCATION_DIR
19
+ export const GLOB = path.join(TRUNCATION_DIR, "*")
20
+
21
+ export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
22
+
23
+ export interface Options {
24
+ maxLines?: number
25
+ maxBytes?: number
26
+ direction?: "head" | "tail"
27
+ }
28
+
29
+ function hasTaskTool(agent?: Agent.Info) {
30
+ if (!agent?.permission) return false
31
+ return evaluate("task", "*", agent.permission).action !== "deny"
32
+ }
33
+
34
+ export interface Interface {
35
+ readonly cleanup: () => Effect.Effect<void>
36
+ readonly write: (text: string) => Effect.Effect<string>
37
+ /**
38
+ * Returns output unchanged when it fits within the limits, otherwise writes the full text
39
+ * to the truncation directory and returns a preview plus a hint to inspect the saved file.
40
+ */
41
+ readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect<Result>
42
+ /**
43
+ * Resolved truncation limits: values from `tool_output` in saeeol config, or MAX_LINES / MAX_BYTES if unset.
44
+ */
45
+ readonly limits: () => Effect.Effect<{ maxLines: number; maxBytes: number }>
46
+ }
47
+
48
+ export class Service extends Context.Service<Service, Interface>()("@saeeol/Truncate") {}
49
+
50
+ export const layer = Layer.effect(
51
+ Service,
52
+ Effect.gen(function* () {
53
+ const fs = yield* AppFileSystem.Service
54
+
55
+ const cleanup = Effect.fn("Truncate.cleanup")(function* () {
56
+ const cutoff = Identifier.timestamp(
57
+ Identifier.create("tool", "ascending", Date.now() - Duration.toMillis(RETENTION)),
58
+ )
59
+ const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe(
60
+ Effect.map((all) => all.filter((name) => name.startsWith("tool_"))),
61
+ Effect.catch(() => Effect.succeed([])),
62
+ )
63
+ for (const entry of entries) {
64
+ if (Identifier.timestamp(entry) >= cutoff) continue
65
+ yield* fs.remove(path.join(TRUNCATION_DIR, entry)).pipe(Effect.catch(() => Effect.void))
66
+ }
67
+ })
68
+
69
+ const write = Effect.fn("Truncate.write")(function* (text: string) {
70
+ const file = path.join(TRUNCATION_DIR, ToolID.ascending())
71
+ yield* fs.ensureDir(TRUNCATION_DIR).pipe(Effect.orDie)
72
+ yield* fs.writeFileString(file, text).pipe(Effect.orDie)
73
+ return file
74
+ })
75
+
76
+ const limits = Effect.fn("Truncate.limits")(function* () {
77
+ const configSvc = yield* Effect.serviceOption(Config.Service)
78
+ if (Option.isNone(configSvc)) return { maxLines: MAX_LINES, maxBytes: MAX_BYTES }
79
+ const cfg = yield* configSvc.value.get().pipe(Effect.catch(() => Effect.succeed(undefined)))
80
+ return {
81
+ maxLines: cfg?.tool_output?.max_lines ?? MAX_LINES,
82
+ maxBytes: cfg?.tool_output?.max_bytes ?? MAX_BYTES,
83
+ }
84
+ })
85
+
86
+ const output = Effect.fn("Truncate.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) {
87
+ const resolved = yield* limits()
88
+ const maxLines = options.maxLines ?? resolved.maxLines
89
+ const maxBytes = options.maxBytes ?? resolved.maxBytes
90
+ const direction = options.direction ?? "head"
91
+ const lines = text.split("\n")
92
+ const totalBytes = Buffer.byteLength(text, "utf-8")
93
+
94
+ if (lines.length <= maxLines && totalBytes <= maxBytes) {
95
+ return { content: text, truncated: false } as const
96
+ }
97
+
98
+ const out: string[] = []
99
+ let i = 0
100
+ let bytes = 0
101
+ let hitBytes = false
102
+
103
+ if (direction === "head") {
104
+ for (i = 0; i < lines.length && i < maxLines; i++) {
105
+ const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
106
+ if (bytes + size > maxBytes) {
107
+ hitBytes = true
108
+ break
109
+ }
110
+ out.push(lines[i])
111
+ bytes += size
112
+ }
113
+ } else {
114
+ for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
115
+ const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
116
+ if (bytes + size > maxBytes) {
117
+ hitBytes = true
118
+ break
119
+ }
120
+ out.unshift(lines[i])
121
+ bytes += size
122
+ }
123
+ }
124
+
125
+ const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
126
+ const unit = hitBytes ? "bytes" : "lines"
127
+ const preview = out.join("\n")
128
+ const file = yield* write(text)
129
+
130
+ const hint = hasTaskTool(agent)
131
+ ? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
132
+ : `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
133
+
134
+ return {
135
+ content:
136
+ direction === "head"
137
+ ? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}`
138
+ : `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`,
139
+ truncated: true,
140
+ outputPath: file,
141
+ } as const
142
+ })
143
+
144
+ yield* cleanup().pipe(
145
+ Effect.catchCause((cause) => {
146
+ log.error("truncation cleanup failed", { cause: Cause.pretty(cause) })
147
+ return Effect.void
148
+ }),
149
+ Effect.repeat(Schedule.spaced(Duration.hours(1))),
150
+ Effect.delay(Duration.minutes(1)),
151
+ Effect.forkScoped,
152
+ )
153
+
154
+ return Service.of({ cleanup, write, output, limits })
155
+ }),
156
+ )
157
+
158
+ export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer))
159
+
160
+ export * as Truncate from "./truncate"
@@ -0,0 +1,4 @@
1
+ import path from "path"
2
+ import { Global } from "@saeeol/core/global"
3
+
4
+ export const TRUNCATION_DIR = path.join(Global.Path.data, "tool-output")
@@ -1,20 +1 @@
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
- }
1
+ export * from "./integration/diagnostics"