ethagent 0.2.0 → 1.0.0

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 (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -32
  3. package/bin/ethagent.js +11 -2
  4. package/package.json +30 -8
  5. package/src/app/FirstRun.tsx +412 -0
  6. package/src/app/hooks/useCancelRequest.ts +22 -0
  7. package/src/app/hooks/useDoublePress.ts +46 -0
  8. package/src/app/hooks/useExitOnCtrlC.ts +36 -0
  9. package/src/app/input/AppInputProvider.tsx +116 -0
  10. package/src/app/input/appInputParser.ts +279 -0
  11. package/src/app/keybindings/KeybindingProvider.tsx +134 -0
  12. package/src/app/keybindings/resolver.ts +42 -0
  13. package/src/app/keybindings/types.ts +26 -0
  14. package/src/chat/ChatBottomPane.tsx +280 -0
  15. package/src/chat/ChatInput.tsx +722 -0
  16. package/src/chat/ChatScreen.tsx +1575 -0
  17. package/src/chat/ContextLimitView.tsx +95 -0
  18. package/src/chat/ContinuityEditReviewView.tsx +48 -0
  19. package/src/chat/ConversationStack.tsx +47 -0
  20. package/src/chat/CopyPicker.tsx +52 -0
  21. package/src/chat/MessageList.tsx +609 -0
  22. package/src/chat/PermissionPrompt.tsx +153 -0
  23. package/src/chat/PermissionsView.tsx +159 -0
  24. package/src/chat/PlanApprovalView.tsx +91 -0
  25. package/src/chat/ResumeView.tsx +267 -0
  26. package/src/chat/RewindView.tsx +386 -0
  27. package/src/chat/SessionStatus.tsx +51 -0
  28. package/src/chat/TranscriptView.tsx +202 -0
  29. package/src/chat/chatInputState.ts +247 -0
  30. package/src/chat/chatPaste.ts +49 -0
  31. package/src/chat/chatScreenUtils.ts +187 -0
  32. package/src/chat/chatSessionState.ts +142 -0
  33. package/src/chat/chatTurnOrchestrator.ts +701 -0
  34. package/src/chat/commands.ts +673 -0
  35. package/src/chat/textCursor.ts +202 -0
  36. package/src/chat/toolResultDisplay.ts +8 -0
  37. package/src/chat/transcriptViewport.ts +247 -0
  38. package/src/cli/ResetConfirmView.tsx +61 -0
  39. package/src/cli/main.tsx +177 -0
  40. package/src/cli/preview.tsx +19 -0
  41. package/src/cli/reset.ts +106 -0
  42. package/src/identity/continuity/editor.ts +149 -0
  43. package/src/identity/continuity/envelope.ts +345 -0
  44. package/src/identity/continuity/history.ts +153 -0
  45. package/src/identity/continuity/privateEdit.ts +334 -0
  46. package/src/identity/continuity/publicSkills.ts +173 -0
  47. package/src/identity/continuity/snapshots.ts +183 -0
  48. package/src/identity/continuity/storage.ts +507 -0
  49. package/src/identity/crypto/backupEnvelope.ts +486 -0
  50. package/src/identity/crypto/eth.ts +137 -0
  51. package/src/identity/hub/IdentityHub.tsx +868 -0
  52. package/src/identity/hub/identityHubEffects.ts +1146 -0
  53. package/src/identity/hub/identityHubModel.ts +291 -0
  54. package/src/identity/hub/identityHubReducer.ts +212 -0
  55. package/src/identity/hub/screens/BusyScreen.tsx +26 -0
  56. package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +144 -0
  57. package/src/identity/hub/screens/CreateFlow.tsx +206 -0
  58. package/src/identity/hub/screens/DetailsScreen.tsx +64 -0
  59. package/src/identity/hub/screens/EditProfileFlow.tsx +145 -0
  60. package/src/identity/hub/screens/ErrorScreen.tsx +35 -0
  61. package/src/identity/hub/screens/IdentitySummary.tsx +70 -0
  62. package/src/identity/hub/screens/MenuScreen.tsx +117 -0
  63. package/src/identity/hub/screens/NetworkScreen.tsx +41 -0
  64. package/src/identity/hub/screens/RebackupStorageScreen.tsx +50 -0
  65. package/src/identity/hub/screens/RecoveryConfirmScreen.tsx +85 -0
  66. package/src/identity/hub/screens/RestoreFlow.tsx +206 -0
  67. package/src/identity/hub/screens/StorageCredentialScreen.tsx +128 -0
  68. package/src/identity/hub/screens/WalletApprovalScreen.tsx +43 -0
  69. package/src/identity/profile/imagePicker.ts +180 -0
  70. package/src/identity/registry/erc8004.ts +1106 -0
  71. package/src/identity/registry/registryConfig.ts +69 -0
  72. package/src/identity/storage/ipfs.ts +212 -0
  73. package/src/identity/storage/pinataJwt.ts +53 -0
  74. package/src/identity/wallet/browserWallet.ts +393 -0
  75. package/src/identity/wallet/wallet-page/wallet.html +1082 -0
  76. package/src/mcp/approvals.ts +113 -0
  77. package/src/mcp/config.ts +235 -0
  78. package/src/mcp/manager.ts +541 -0
  79. package/src/mcp/names.ts +19 -0
  80. package/src/mcp/output.ts +96 -0
  81. package/src/models/ModelPicker.tsx +1446 -0
  82. package/src/models/catalog.ts +296 -0
  83. package/src/models/huggingface.ts +651 -0
  84. package/src/models/llamacpp.ts +810 -0
  85. package/src/models/llamacppPreflight.ts +150 -0
  86. package/src/models/modelDisplay.ts +105 -0
  87. package/src/models/modelPickerOptions.ts +421 -0
  88. package/src/models/modelRecommendation.ts +140 -0
  89. package/src/models/runtimeDetection.ts +81 -0
  90. package/src/models/uncensoredCatalog.ts +86 -0
  91. package/src/providers/anthropic.ts +259 -0
  92. package/src/providers/contracts.ts +62 -0
  93. package/src/providers/errors.ts +62 -0
  94. package/src/providers/gemini.ts +152 -0
  95. package/src/providers/openai-chat.ts +472 -0
  96. package/src/providers/registry.ts +42 -0
  97. package/src/providers/retry.ts +58 -0
  98. package/src/providers/sse.ts +93 -0
  99. package/src/runtime/compaction.ts +389 -0
  100. package/src/runtime/cwd.ts +43 -0
  101. package/src/runtime/sessionMode.ts +55 -0
  102. package/src/runtime/systemPrompt.ts +209 -0
  103. package/src/runtime/toolClaimGuards.ts +143 -0
  104. package/src/runtime/toolExecution.ts +304 -0
  105. package/src/runtime/toolIntent.ts +163 -0
  106. package/src/runtime/turn.ts +858 -0
  107. package/src/storage/atomicWrite.ts +68 -0
  108. package/src/storage/config.ts +189 -0
  109. package/src/storage/factoryReset.ts +130 -0
  110. package/src/storage/history.ts +58 -0
  111. package/src/storage/identity.ts +99 -0
  112. package/src/storage/permissions.ts +76 -0
  113. package/src/storage/rewind.ts +246 -0
  114. package/src/storage/secrets.ts +181 -0
  115. package/src/storage/sessionExport.ts +49 -0
  116. package/src/storage/sessions.ts +482 -0
  117. package/src/tools/bashSafety.ts +174 -0
  118. package/src/tools/bashTool.ts +140 -0
  119. package/src/tools/changeDirectoryTool.ts +213 -0
  120. package/src/tools/contracts.ts +179 -0
  121. package/src/tools/deleteFileTool.ts +111 -0
  122. package/src/tools/editTool.ts +160 -0
  123. package/src/tools/editUtils.ts +170 -0
  124. package/src/tools/listDirectoryTool.ts +55 -0
  125. package/src/tools/mcpResourceTools.ts +95 -0
  126. package/src/tools/permissionRules.ts +85 -0
  127. package/src/tools/privateContinuityEditTool.ts +178 -0
  128. package/src/tools/privateContinuityReadTool.ts +107 -0
  129. package/src/tools/readTool.ts +85 -0
  130. package/src/tools/registry.ts +67 -0
  131. package/src/tools/writeFileTool.ts +142 -0
  132. package/src/ui/BrandSplash.tsx +193 -0
  133. package/src/ui/ProgressBar.tsx +34 -0
  134. package/src/ui/Select.tsx +143 -0
  135. package/src/ui/Spinner.tsx +269 -0
  136. package/src/ui/Surface.tsx +47 -0
  137. package/src/ui/TextInput.tsx +97 -0
  138. package/src/ui/theme.ts +59 -0
  139. package/src/utils/clipboard.ts +216 -0
  140. package/src/utils/markdownSegments.ts +51 -0
  141. package/src/utils/messages.ts +35 -0
  142. package/src/utils/withRetry.ts +280 -0
  143. package/src/cli.tsx +0 -147
@@ -0,0 +1,178 @@
1
+ import { z } from 'zod'
2
+ import {
3
+ preparePrivateContinuityEdit,
4
+ writePreparedPrivateContinuityEdit,
5
+ } from '../identity/continuity/privateEdit.js'
6
+ import { recordPrivateContinuityHistorySnapshot } from '../identity/continuity/history.js'
7
+ import { readContinuityFiles, readPublicSkillsFile } from '../identity/continuity/storage.js'
8
+ import type { Tool } from './contracts.js'
9
+
10
+ const schema = z.object({
11
+ file: z.preprocess(normalizePrivateContinuityFile, z.enum(['SOUL.md', 'MEMORY.md'])),
12
+ oldText: z.string().optional(),
13
+ newText: z.string().optional(),
14
+ appendToSection: z.string().optional(),
15
+ appendText: z.string().optional(),
16
+ replaceAll: z.boolean().optional(),
17
+ replaceWholeFile: z.boolean().optional(),
18
+ }).superRefine((input, ctx) => {
19
+ const hasOldText = input.oldText !== undefined
20
+ const hasNewText = input.newText !== undefined
21
+ const hasAppendToSection = input.appendToSection !== undefined
22
+ const hasAppendText = input.appendText !== undefined
23
+ const targeted = hasOldText || hasNewText
24
+ const append = hasAppendToSection || hasAppendText
25
+ if (input.replaceWholeFile) {
26
+ ctx.addIssue({
27
+ code: z.ZodIssueCode.custom,
28
+ message: 'private continuity files must be edited in place; whole-file replacement is disabled',
29
+ path: ['replaceWholeFile'],
30
+ })
31
+ }
32
+ if (!targeted && !append) {
33
+ ctx.addIssue({
34
+ code: z.ZodIssueCode.custom,
35
+ message: 'file alone is not enough; provide either oldText+newText for a targeted edit or appendToSection+appendText for an in-place append',
36
+ })
37
+ }
38
+ if (targeted && append) {
39
+ ctx.addIssue({
40
+ code: z.ZodIssueCode.custom,
41
+ message: 'provide only one edit mode: oldText+newText or appendToSection+appendText',
42
+ })
43
+ }
44
+ if (targeted && !input.oldText?.trim()) {
45
+ ctx.addIssue({
46
+ code: z.ZodIssueCode.custom,
47
+ message: 'oldText is required for targeted private continuity edits',
48
+ path: ['oldText'],
49
+ })
50
+ }
51
+ if (targeted && !hasNewText) {
52
+ ctx.addIssue({
53
+ code: z.ZodIssueCode.custom,
54
+ message: 'newText is required for targeted private continuity edits',
55
+ path: ['newText'],
56
+ })
57
+ }
58
+ if (append && !input.appendToSection?.trim()) {
59
+ ctx.addIssue({
60
+ code: z.ZodIssueCode.custom,
61
+ message: 'appendToSection is required for private continuity appends',
62
+ path: ['appendToSection'],
63
+ })
64
+ }
65
+ if (append && !input.appendText?.trim()) {
66
+ ctx.addIssue({
67
+ code: z.ZodIssueCode.custom,
68
+ message: 'appendText is required for private continuity appends',
69
+ path: ['appendText'],
70
+ })
71
+ }
72
+ })
73
+
74
+ export const privateContinuityEditTool: Tool<typeof schema> = {
75
+ name: 'propose_private_continuity_edit',
76
+ kind: 'private-continuity-edit',
77
+ description: [
78
+ 'Propose an explicit user-approved in-place edit to an existing private identity continuity scaffold.',
79
+ 'Only the identity-vault SOUL.md and MEMORY.md are valid targets; do not create workspace files with these names.',
80
+ 'Do not call read_file, list_directory, or run_bash to locate these files; pass file as SOUL.md or MEMORY.md and this tool resolves the vault path.',
81
+ 'For new memories or preferences call exactly: {"file":"MEMORY.md","appendToSection":"Durable User Preferences","appendText":"- User preference or memory note."}.',
82
+ 'For persona or standing behavior call exactly: {"file":"SOUL.md","appendToSection":"Persona","appendText":"- Persona or standing behavior note."}.',
83
+ 'Prefer appendToSection+appendText to build on an existing scaffold section; use oldText+newText only for targeted replacement after exact text is known.',
84
+ 'Whole-file replacement is disabled for private continuity.',
85
+ 'Approved private continuity edits are not managed by /rewind; the previous version is saved to private identity history before writing.',
86
+ ].join(' '),
87
+ inputSchema: schema,
88
+ inputSchemaJson: {
89
+ type: 'object',
90
+ properties: {
91
+ file: { type: 'string', enum: ['SOUL.md', 'MEMORY.md'], description: 'Private continuity file to edit. Use only the file name; do not pass a workspace path.' },
92
+ oldText: { type: 'string', description: 'Exact existing scaffold text to replace. Required for targeted replacement edits.' },
93
+ newText: { type: 'string', description: 'Replacement text for oldText. Do not use this as whole-file content.' },
94
+ appendToSection: { type: 'string', description: 'Existing markdown section heading to append under, for example "Durable User Preferences" or "Persona". Prefer this for new notes.' },
95
+ appendText: { type: 'string', description: 'New non-empty markdown bullet to append under appendToSection. This builds on the existing scaffold instead of overwriting it.' },
96
+ replaceAll: { type: 'boolean', description: 'Replace every exact oldText match. Prefer false unless certain.' },
97
+ },
98
+ required: ['file'],
99
+ additionalProperties: false,
100
+ },
101
+ parse(input) {
102
+ return schema.parse(normalizePrivateContinuityInput(input))
103
+ },
104
+ async buildPermissionRequest(input, context) {
105
+ const prepared = await preparePrivateContinuityEdit(input, context.config)
106
+ return {
107
+ kind: 'private-continuity-edit',
108
+ path: prepared.fullPath,
109
+ relativePath: prepared.relativePath,
110
+ directoryPath: prepared.directoryPath,
111
+ title: 'approve private continuity edit?',
112
+ subtitle: prepared.fullPath,
113
+ file: prepared.file,
114
+ before: prepared.previewBefore,
115
+ after: prepared.previewAfter,
116
+ diff: prepared.diff,
117
+ changeSummary: prepared.changeSummary,
118
+ }
119
+ },
120
+ async execute(input, context) {
121
+ const prepared = await preparePrivateContinuityEdit(input, context.config)
122
+ const [previousFiles, previousPublicSkills] = await Promise.all([
123
+ readContinuityFiles(prepared.identity),
124
+ readPublicSkillsFile(prepared.identity),
125
+ ])
126
+ await recordPrivateContinuityHistorySnapshot({
127
+ identity: prepared.identity,
128
+ file: prepared.file,
129
+ filePath: prepared.fullPath,
130
+ existedBefore: prepared.existedBefore,
131
+ previousContent: prepared.previousContent,
132
+ previousFiles,
133
+ previousPublicSkills,
134
+ changeSummary: prepared.changeSummary,
135
+ createdAt: new Date().toISOString(),
136
+ sessionId: context.checkpoint?.sessionId,
137
+ turnId: context.checkpoint?.turnId,
138
+ promptSnippet: context.checkpoint?.promptSnippet,
139
+ checkpointLabel: context.checkpoint?.checkpointLabel,
140
+ })
141
+ await writePreparedPrivateContinuityEdit(prepared)
142
+ return {
143
+ ok: true,
144
+ summary: prepared.changeSummary,
145
+ content: formatPrivateContinuityEditResult(prepared.file, prepared.fullPath),
146
+ }
147
+ },
148
+ }
149
+
150
+ function formatPrivateContinuityEditResult(file: 'SOUL.md' | 'MEMORY.md', fullPath: string): string {
151
+ return [
152
+ '## Saved private continuity',
153
+ '',
154
+ `- File: \`identity-vault/${file}\``,
155
+ `- Review file: \`${fullPath}\``,
156
+ '- Open: Identity Hub > Memory and Persona',
157
+ '- Publish: Identity Hub > Recovery > Publish Snapshot Now',
158
+ '- History: previous version saved to private identity history; `/rewind` does not restore identity continuity',
159
+ ].join('\n')
160
+ }
161
+
162
+ function normalizePrivateContinuityInput(input: Record<string, unknown>): Record<string, unknown> {
163
+ const normalized = { ...input }
164
+ if (normalized.appendToSection === undefined) {
165
+ normalized.appendToSection = normalized.section ?? normalized.heading
166
+ }
167
+ if (normalized.appendText === undefined) {
168
+ normalized.appendText = normalized.note ?? normalized.text ?? normalized.content
169
+ }
170
+ return normalized
171
+ }
172
+
173
+ function normalizePrivateContinuityFile(value: unknown): unknown {
174
+ if (typeof value !== 'string') return value
175
+ if (/^soul\.md$/i.test(value.trim())) return 'SOUL.md'
176
+ if (/^memory\.md$/i.test(value.trim())) return 'MEMORY.md'
177
+ return value
178
+ }
@@ -0,0 +1,107 @@
1
+ import path from 'node:path'
2
+ import { z } from 'zod'
3
+ import {
4
+ continuityVaultRef,
5
+ ensureContinuityFiles,
6
+ type PrivateContinuityFile,
7
+ } from '../identity/continuity/storage.js'
8
+ import type { Tool } from './contracts.js'
9
+
10
+ const schema = z.object({
11
+ file: z.preprocess(normalizePrivateContinuityFile, z.enum(['SOUL.md', 'MEMORY.md'])),
12
+ startLine: z.number().int().positive().optional(),
13
+ endLine: z.number().int().positive().optional(),
14
+ })
15
+
16
+ export const privateContinuityReadTool: Tool<typeof schema> = {
17
+ name: 'read_private_continuity_file',
18
+ kind: 'private-continuity-read',
19
+ description: [
20
+ 'Read an explicitly user-approved private identity continuity file from the identity vault.',
21
+ 'Only SOUL.md and MEMORY.md are valid targets; do not use workspace read_file for these files.',
22
+ 'Use this before surgical removals or targeted replacements that need exact oldText.',
23
+ 'Pass file as SOUL.md or MEMORY.md; this tool resolves the vault path.',
24
+ 'Use startLine and endLine to limit the returned range when possible.',
25
+ ].join(' '),
26
+ inputSchema: schema,
27
+ inputSchemaJson: {
28
+ type: 'object',
29
+ properties: {
30
+ file: { type: 'string', enum: ['SOUL.md', 'MEMORY.md'], description: 'Private continuity file to read. Use only the file name; do not pass a workspace path.' },
31
+ startLine: { type: 'number', description: 'Optional 1-based starting line.' },
32
+ endLine: { type: 'number', description: 'Optional 1-based ending line.' },
33
+ },
34
+ required: ['file'],
35
+ additionalProperties: false,
36
+ },
37
+ parse(input) {
38
+ return schema.parse(normalizePrivateContinuityReadInput(input))
39
+ },
40
+ async buildPermissionRequest(input, context) {
41
+ const prepared = preparePrivateContinuityRead(input, context.config)
42
+ return {
43
+ kind: 'private-continuity-read',
44
+ path: prepared.fullPath,
45
+ relativePath: prepared.relativePath,
46
+ directoryPath: prepared.directoryPath,
47
+ title: 'allow private continuity read?',
48
+ subtitle: input.startLine || input.endLine
49
+ ? `${prepared.fullPath} · lines ${input.startLine ?? 1}-${input.endLine ?? 'end'}`
50
+ : prepared.fullPath,
51
+ file: input.file,
52
+ range: input.startLine || input.endLine
53
+ ? `lines ${input.startLine ?? 1}-${input.endLine ?? 'end'}`
54
+ : 'entire file',
55
+ }
56
+ },
57
+ async execute(input, context) {
58
+ const prepared = preparePrivateContinuityRead(input, context.config)
59
+ const files = await ensureContinuityFiles(prepared.identity)
60
+ return {
61
+ ok: true,
62
+ summary: `read private ${input.file}`,
63
+ content: numberedLineSlice(files[input.file], input.startLine, input.endLine),
64
+ }
65
+ },
66
+ }
67
+
68
+ function preparePrivateContinuityRead(
69
+ input: z.infer<typeof schema>,
70
+ config: Parameters<Tool['buildPermissionRequest']>[1]['config'],
71
+ ) {
72
+ const identity = config?.identity
73
+ if (!identity) {
74
+ throw new Error('no active identity; create or load an identity before reading private continuity files')
75
+ }
76
+ const ref = continuityVaultRef(identity)
77
+ const fullPath = input.file === 'SOUL.md' ? ref.soulPath : ref.memoryPath
78
+ return {
79
+ identity,
80
+ fullPath,
81
+ relativePath: `identity-vault/${input.file}`,
82
+ directoryPath: path.dirname(fullPath),
83
+ }
84
+ }
85
+
86
+ function numberedLineSlice(content: string, startLine?: number, endLine?: number): string {
87
+ const lines = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n')
88
+ const start = Math.max(1, startLine ?? 1)
89
+ const end = Math.max(start, endLine ?? lines.length)
90
+ return lines.slice(start - 1, end).map((line, index) => `${start + index}: ${line}`).join('\n')
91
+ }
92
+
93
+ function normalizePrivateContinuityReadInput(input: Record<string, unknown>): Record<string, unknown> {
94
+ const normalized = { ...input }
95
+ if (normalized.file === undefined) {
96
+ normalized.file = normalized.path ?? normalized.name
97
+ }
98
+ return normalized
99
+ }
100
+
101
+ function normalizePrivateContinuityFile(value: unknown): unknown {
102
+ if (typeof value !== 'string') return value
103
+ const basename = path.basename(value.replaceAll('\\', '/')).trim()
104
+ if (/^soul\.md$/i.test(basename)) return 'SOUL.md'
105
+ if (/^memory\.md$/i.test(basename)) return 'MEMORY.md'
106
+ return value
107
+ }
@@ -0,0 +1,85 @@
1
+ import fs from 'node:fs/promises'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { z } from 'zod'
5
+ import type { EthagentConfig } from '../storage/config.js'
6
+ import type { Tool } from './contracts.js'
7
+
8
+ const schema = z.object({
9
+ path: z.string().min(1),
10
+ startLine: z.number().int().positive().optional(),
11
+ endLine: z.number().int().positive().optional(),
12
+ })
13
+
14
+ export const readTool: Tool<typeof schema> = {
15
+ name: 'read_file',
16
+ kind: 'read',
17
+ description: 'Read a text file from the current workspace. Use startLine and endLine to limit the range when the file is large.',
18
+ inputSchema: schema,
19
+ inputSchemaJson: {
20
+ type: 'object',
21
+ properties: {
22
+ path: { type: 'string', description: 'Path to the file to read.' },
23
+ startLine: { type: 'number', description: 'Optional 1-based starting line.' },
24
+ endLine: { type: 'number', description: 'Optional 1-based ending line.' },
25
+ },
26
+ required: ['path'],
27
+ },
28
+ parse(input) {
29
+ return schema.parse(input)
30
+ },
31
+ async buildPermissionRequest(input, context) {
32
+ assertNotPrivateContinuityWorkspacePath(input.path, context.config)
33
+ const fullPath = resolveWorkspacePath(context.workspaceRoot, input.path)
34
+ const relativePath = path.relative(context.workspaceRoot, fullPath) || path.basename(fullPath)
35
+ return {
36
+ kind: 'read',
37
+ path: fullPath,
38
+ relativePath,
39
+ directoryPath: path.dirname(fullPath),
40
+ title: 'allow file read?',
41
+ subtitle: input.startLine || input.endLine
42
+ ? `${fullPath} · lines ${input.startLine ?? 1}-${input.endLine ?? 'end'}`
43
+ : fullPath,
44
+ }
45
+ },
46
+ async execute(input, context) {
47
+ assertNotPrivateContinuityWorkspacePath(input.path, context.config)
48
+ const fullPath = resolveWorkspacePath(context.workspaceRoot, input.path)
49
+ const raw = await fs.readFile(fullPath, 'utf8')
50
+ const lines = raw.replace(/\r\n/g, '\n').split('\n')
51
+ const start = Math.max(1, input.startLine ?? 1)
52
+ const end = Math.max(start, input.endLine ?? lines.length)
53
+ const slice = lines.slice(start - 1, end)
54
+ const numbered = slice.map((line, i) => `${start + i}: ${line}`).join('\n')
55
+ return {
56
+ ok: true,
57
+ summary: `read ${path.relative(context.workspaceRoot, fullPath) || path.basename(fullPath)}`,
58
+ content: numbered,
59
+ }
60
+ },
61
+ }
62
+
63
+ function assertNotPrivateContinuityWorkspacePath(
64
+ requestedPath: string,
65
+ config: EthagentConfig | undefined,
66
+ ): void {
67
+ if (!config?.identity) return
68
+ const basename = path.basename(requestedPath.replaceAll('\\', '/')).toUpperCase()
69
+ if (basename !== 'SOUL.MD' && basename !== 'MEMORY.MD') return
70
+ throw new Error(
71
+ `read_file must not read ${basename} from the workspace; use read_private_continuity_file with file "${basename === 'SOUL.MD' ? 'SOUL.md' : 'MEMORY.md'}"`,
72
+ )
73
+ }
74
+
75
+ export function resolveWorkspacePath(workspaceRoot: string, requestedPath: string): string {
76
+ const expandedPath = requestedPath.startsWith('~')
77
+ ? path.join(os.homedir(), requestedPath.slice(1))
78
+ : requestedPath
79
+ const fullPath = path.resolve(workspaceRoot, expandedPath)
80
+ const rel = path.relative(workspaceRoot, fullPath)
81
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
82
+ throw new Error(`path escapes workspace: ${requestedPath}`)
83
+ }
84
+ return fullPath
85
+ }
@@ -0,0 +1,67 @@
1
+ import type { AnthropicToolDefinition } from '../providers/anthropic.js'
2
+ import type { OpenAIToolDefinition } from '../providers/openai-chat.js'
3
+ import type { Tool } from './contracts.js'
4
+ import { modePolicy, type SessionMode } from '../runtime/sessionMode.js'
5
+ import { bashTool } from './bashTool.js'
6
+ import { changeDirectoryTool } from './changeDirectoryTool.js'
7
+ import { deleteFileTool } from './deleteFileTool.js'
8
+ import { editTool } from './editTool.js'
9
+ import { listDirectoryTool } from './listDirectoryTool.js'
10
+ import { privateContinuityEditTool } from './privateContinuityEditTool.js'
11
+ import { privateContinuityReadTool } from './privateContinuityReadTool.js'
12
+ import { readTool } from './readTool.js'
13
+ import { listMcpResourcesTool, readMcpResourceTool } from './mcpResourceTools.js'
14
+ import { writeFileTool } from './writeFileTool.js'
15
+
16
+ export const BUILTIN_TOOLS: Tool[] = [
17
+ changeDirectoryTool,
18
+ listDirectoryTool,
19
+ readTool,
20
+ privateContinuityReadTool,
21
+ listMcpResourcesTool,
22
+ readMcpResourceTool,
23
+ writeFileTool,
24
+ editTool,
25
+ privateContinuityEditTool,
26
+ deleteFileTool,
27
+ bashTool,
28
+ ]
29
+
30
+ export type ToolAvailabilityContext = {
31
+ hasIdentity?: boolean
32
+ dynamicTools?: Tool[]
33
+ }
34
+
35
+ export function getTool(name: string, context: ToolAvailabilityContext = {}): Tool | undefined {
36
+ return [...(context.dynamicTools ?? []), ...BUILTIN_TOOLS].find(tool => tool.name === name)
37
+ }
38
+
39
+ export function toolsForMode(mode: SessionMode = 'chat', context: ToolAvailabilityContext = {}): Tool[] {
40
+ const policy = modePolicy(mode)
41
+ const allTools = [...BUILTIN_TOOLS, ...(context.dynamicTools ?? [])]
42
+ return allTools.filter(tool => {
43
+ if (mode === 'plan' && tool.kind === 'mcp' && tool.readOnly !== true) return false
44
+ if (!policy.exposesToolKind(tool.kind)) return false
45
+ if ((tool.kind === 'private-continuity-read' || tool.kind === 'private-continuity-edit') && !context.hasIdentity) return false
46
+ return true
47
+ })
48
+ }
49
+
50
+ export function anthropicTools(mode: SessionMode = 'chat', context: ToolAvailabilityContext = {}): AnthropicToolDefinition[] {
51
+ return toolsForMode(mode, context).map(tool => ({
52
+ name: tool.name,
53
+ description: tool.description,
54
+ input_schema: tool.inputSchemaJson,
55
+ }))
56
+ }
57
+
58
+ export function openAITools(mode: SessionMode = 'chat', context: ToolAvailabilityContext = {}): OpenAIToolDefinition[] {
59
+ return toolsForMode(mode, context).map(tool => ({
60
+ type: 'function',
61
+ function: {
62
+ name: tool.name,
63
+ description: tool.description,
64
+ parameters: tool.inputSchemaJson,
65
+ },
66
+ }))
67
+ }
@@ -0,0 +1,142 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { z } from 'zod'
4
+ import { recordRewindSnapshot } from '../storage/rewind.js'
5
+ import type { EthagentConfig } from '../storage/config.js'
6
+ import type { Tool } from './contracts.js'
7
+ import { resolveWorkspacePath } from './readTool.js'
8
+
9
+ const schema = z.object({
10
+ path: z.string().min(1),
11
+ content: z.string(),
12
+ overwrite: z.boolean().optional(),
13
+ })
14
+
15
+ export const writeFileTool: Tool<typeof schema> = {
16
+ name: 'write_file',
17
+ kind: 'write',
18
+ description: 'Create a new text file, or replace an entire existing file only when overwrite is true. Prefer edit_file for targeted changes to existing files.',
19
+ inputSchema: schema,
20
+ inputSchemaJson: {
21
+ type: 'object',
22
+ properties: {
23
+ path: { type: 'string', description: 'Workspace-relative file path to write.' },
24
+ content: { type: 'string', description: 'Complete file contents to write.' },
25
+ overwrite: { type: 'boolean', description: 'Set true only when intentionally replacing an existing file.' },
26
+ },
27
+ required: ['path', 'content'],
28
+ },
29
+ parse(input) {
30
+ return schema.parse(input)
31
+ },
32
+ async buildPermissionRequest(input, context) {
33
+ const prepared = await prepareWrite(input, context)
34
+ return {
35
+ kind: 'write',
36
+ path: prepared.fullPath,
37
+ relativePath: prepared.relativePath,
38
+ directoryPath: path.dirname(prepared.fullPath),
39
+ title: prepared.existedBefore ? 'allow file rewrite?' : 'allow file creation?',
40
+ subtitle: prepared.fullPath,
41
+ before: previewText(prepared.before),
42
+ after: previewText(input.content),
43
+ changeSummary: prepared.existedBefore ? `replace entire ${prepared.relativePath}` : `create ${prepared.relativePath}`,
44
+ }
45
+ },
46
+ async execute(input, context) {
47
+ const prepared = await prepareWrite(input, context)
48
+ const rewindWarning = await tryRecordRewindSnapshot({
49
+ workspaceRoot: context.workspaceRoot,
50
+ filePath: prepared.fullPath,
51
+ relativePath: prepared.relativePath,
52
+ existedBefore: prepared.existedBefore,
53
+ previousContent: prepared.before,
54
+ changeSummary: prepared.existedBefore ? `replace entire ${prepared.relativePath}` : `create ${prepared.relativePath}`,
55
+ createdAt: new Date().toISOString(),
56
+ sessionId: context.checkpoint?.sessionId,
57
+ turnId: context.checkpoint?.turnId,
58
+ messageRole: context.checkpoint?.messageRole,
59
+ promptSnippet: context.checkpoint?.promptSnippet,
60
+ checkpointLabel: context.checkpoint?.checkpointLabel,
61
+ })
62
+ await fs.mkdir(path.dirname(prepared.fullPath), { recursive: true })
63
+ await fs.writeFile(prepared.fullPath, input.content, 'utf8')
64
+ return {
65
+ ok: true,
66
+ summary: prepared.existedBefore ? `replace entire ${prepared.relativePath}` : `create ${prepared.relativePath}`,
67
+ content: rewindWarning
68
+ ? `updated ${prepared.fullPath}\nwarning: ${rewindWarning}`
69
+ : `updated ${prepared.fullPath}`,
70
+ }
71
+ },
72
+ }
73
+
74
+ async function prepareWrite(
75
+ input: z.infer<typeof schema>,
76
+ context: { workspaceRoot: string; config?: EthagentConfig },
77
+ ) {
78
+ assertSafeWritePath(input.path)
79
+ assertNotPrivateContinuityWorkspacePath(input.path, context.config, 'write_file')
80
+ if (input.content.length === 0) {
81
+ throw new Error('write_file content is empty; provide non-empty file contents')
82
+ }
83
+
84
+ const fullPath = resolveWorkspacePath(context.workspaceRoot, input.path)
85
+ const relativePath = path.relative(context.workspaceRoot, fullPath) || path.basename(fullPath)
86
+ const { before, existedBefore } = await readExistingFile(fullPath)
87
+
88
+ return { fullPath, relativePath, before, existedBefore }
89
+ }
90
+
91
+ async function readExistingFile(fullPath: string): Promise<{ before: string; existedBefore: boolean }> {
92
+ try {
93
+ const stats = await fs.stat(fullPath)
94
+ if (stats.isDirectory()) throw new Error('write_file path points to a directory; provide a file path')
95
+ return { before: await fs.readFile(fullPath, 'utf8'), existedBefore: true }
96
+ } catch (error: unknown) {
97
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { before: '', existedBefore: false }
98
+ throw error
99
+ }
100
+ }
101
+
102
+ async function tryRecordRewindSnapshot(
103
+ snapshot: Parameters<typeof recordRewindSnapshot>[0],
104
+ ): Promise<string | undefined> {
105
+ try {
106
+ await recordRewindSnapshot(snapshot)
107
+ return undefined
108
+ } catch (error: unknown) {
109
+ return `rewind checkpoint was not recorded (${(error as Error).message || 'unknown error'})`
110
+ }
111
+ }
112
+
113
+ function assertSafeWritePath(requestedPath: string): void {
114
+ const trimmed = requestedPath.trim()
115
+ if (trimmed !== requestedPath || trimmed.length === 0) {
116
+ throw new Error('write_file path must be a clean workspace-relative file path')
117
+ }
118
+ if (/[|;&<>`]/.test(trimmed)) {
119
+ throw new Error('write_file path must not contain shell operators')
120
+ }
121
+ if (/^(?:rm|del|erase|rmdir|remove-item|mkdir|type|cat|echo|copy|move|mv|cp)\b/i.test(trimmed)) {
122
+ throw new Error('write_file path looks like a shell command; pass only the file path')
123
+ }
124
+ }
125
+
126
+ function assertNotPrivateContinuityWorkspacePath(
127
+ requestedPath: string,
128
+ config: EthagentConfig | undefined,
129
+ toolName: string,
130
+ ): void {
131
+ if (!config?.identity) return
132
+ const basename = path.basename(requestedPath.replaceAll('\\', '/')).toUpperCase()
133
+ if (basename !== 'SOUL.MD' && basename !== 'MEMORY.MD') return
134
+ throw new Error(
135
+ `${toolName} must not create or overwrite ${basename}; use propose_private_continuity_edit to patch the existing identity-vault scaffold`,
136
+ )
137
+ }
138
+
139
+ function previewText(text: string, max = 700): string {
140
+ if (text.length <= max) return text
141
+ return `${text.slice(0, max - 3)}...`
142
+ }