ethagent 0.2.1 → 1.0.1
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/LICENSE +21 -0
- package/README.md +114 -32
- package/bin/ethagent.js +11 -2
- package/package.json +25 -7
- package/src/app/FirstRun.tsx +412 -0
- package/src/app/hooks/useCancelRequest.ts +22 -0
- package/src/app/hooks/useDoublePress.ts +46 -0
- package/src/app/hooks/useExitOnCtrlC.ts +36 -0
- package/src/app/input/AppInputProvider.tsx +116 -0
- package/src/app/input/appInputParser.ts +279 -0
- package/src/app/keybindings/KeybindingProvider.tsx +134 -0
- package/src/app/keybindings/resolver.ts +42 -0
- package/src/app/keybindings/types.ts +26 -0
- package/src/chat/ChatBottomPane.tsx +280 -0
- package/src/chat/ChatInput.tsx +722 -0
- package/src/chat/ChatScreen.tsx +1575 -0
- package/src/chat/ContextLimitView.tsx +95 -0
- package/src/chat/ContinuityEditReviewView.tsx +48 -0
- package/src/chat/ConversationStack.tsx +47 -0
- package/src/chat/CopyPicker.tsx +52 -0
- package/src/chat/MessageList.tsx +609 -0
- package/src/chat/PermissionPrompt.tsx +153 -0
- package/src/chat/PermissionsView.tsx +159 -0
- package/src/chat/PlanApprovalView.tsx +91 -0
- package/src/chat/ResumeView.tsx +267 -0
- package/src/chat/RewindView.tsx +386 -0
- package/src/chat/SessionStatus.tsx +51 -0
- package/src/chat/TranscriptView.tsx +202 -0
- package/src/chat/chatInputState.ts +247 -0
- package/src/chat/chatPaste.ts +49 -0
- package/src/chat/chatScreenUtils.ts +187 -0
- package/src/chat/chatSessionState.ts +142 -0
- package/src/chat/chatTurnOrchestrator.ts +701 -0
- package/src/chat/commands.ts +673 -0
- package/src/chat/textCursor.ts +202 -0
- package/src/chat/toolResultDisplay.ts +8 -0
- package/src/chat/transcriptViewport.ts +247 -0
- package/src/cli/ResetConfirmView.tsx +61 -0
- package/src/cli/main.tsx +177 -0
- package/src/cli/preview.tsx +19 -0
- package/src/cli/reset.ts +106 -0
- package/src/identity/continuity/editor.ts +149 -0
- package/src/identity/continuity/envelope.ts +345 -0
- package/src/identity/continuity/history.ts +153 -0
- package/src/identity/continuity/privateEdit.ts +334 -0
- package/src/identity/continuity/publicSkills.ts +173 -0
- package/src/identity/continuity/snapshots.ts +183 -0
- package/src/identity/continuity/storage.ts +507 -0
- package/src/identity/crypto/backupEnvelope.ts +486 -0
- package/src/identity/crypto/eth.ts +137 -0
- package/src/identity/hub/IdentityHub.tsx +845 -0
- package/src/identity/hub/identityHubEffects.ts +1100 -0
- package/src/identity/hub/identityHubModel.ts +291 -0
- package/src/identity/hub/identityHubReducer.ts +209 -0
- package/src/identity/hub/screens/BusyScreen.tsx +26 -0
- package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +139 -0
- package/src/identity/hub/screens/CreateFlow.tsx +206 -0
- package/src/identity/hub/screens/DetailsScreen.tsx +64 -0
- package/src/identity/hub/screens/EditProfileFlow.tsx +145 -0
- package/src/identity/hub/screens/ErrorScreen.tsx +35 -0
- package/src/identity/hub/screens/IdentitySummary.tsx +70 -0
- package/src/identity/hub/screens/MenuScreen.tsx +117 -0
- package/src/identity/hub/screens/NetworkScreen.tsx +41 -0
- package/src/identity/hub/screens/RebackupStorageScreen.tsx +50 -0
- package/src/identity/hub/screens/RecoveryConfirmScreen.tsx +85 -0
- package/src/identity/hub/screens/RestoreFlow.tsx +206 -0
- package/src/identity/hub/screens/StorageCredentialScreen.tsx +128 -0
- package/src/identity/hub/screens/WalletApprovalScreen.tsx +43 -0
- package/src/identity/profile/imagePicker.ts +180 -0
- package/src/identity/registry/erc8004.ts +1106 -0
- package/src/identity/registry/registryConfig.ts +69 -0
- package/src/identity/storage/ipfs.ts +212 -0
- package/src/identity/storage/pinataJwt.ts +53 -0
- package/src/identity/wallet/browserWallet.ts +393 -0
- package/src/identity/wallet/wallet-page/wallet.html +1082 -0
- package/src/mcp/approvals.ts +113 -0
- package/src/mcp/config.ts +235 -0
- package/src/mcp/manager.ts +541 -0
- package/src/mcp/names.ts +19 -0
- package/src/mcp/output.ts +96 -0
- package/src/models/ModelPicker.tsx +1446 -0
- package/src/models/catalog.ts +296 -0
- package/src/models/huggingface.ts +651 -0
- package/src/models/llamacpp.ts +810 -0
- package/src/models/llamacppPreflight.ts +150 -0
- package/src/models/modelDisplay.ts +105 -0
- package/src/models/modelPickerOptions.ts +421 -0
- package/src/models/modelRecommendation.ts +140 -0
- package/src/models/runtimeDetection.ts +81 -0
- package/src/models/uncensoredCatalog.ts +86 -0
- package/src/providers/anthropic.ts +259 -0
- package/src/providers/contracts.ts +62 -0
- package/src/providers/errors.ts +62 -0
- package/src/providers/gemini.ts +152 -0
- package/src/providers/openai-chat.ts +472 -0
- package/src/providers/registry.ts +42 -0
- package/src/providers/retry.ts +58 -0
- package/src/providers/sse.ts +93 -0
- package/src/runtime/compaction.ts +389 -0
- package/src/runtime/cwd.ts +43 -0
- package/src/runtime/sessionMode.ts +55 -0
- package/src/runtime/systemPrompt.ts +209 -0
- package/src/runtime/toolClaimGuards.ts +143 -0
- package/src/runtime/toolExecution.ts +304 -0
- package/src/runtime/toolIntent.ts +163 -0
- package/src/runtime/turn.ts +858 -0
- package/src/storage/atomicWrite.ts +68 -0
- package/src/storage/config.ts +189 -0
- package/src/storage/factoryReset.ts +130 -0
- package/src/storage/history.ts +58 -0
- package/src/storage/identity.ts +99 -0
- package/src/storage/permissions.ts +76 -0
- package/src/storage/rewind.ts +246 -0
- package/src/storage/secrets.ts +181 -0
- package/src/storage/sessionExport.ts +49 -0
- package/src/storage/sessions.ts +482 -0
- package/src/tools/bashSafety.ts +174 -0
- package/src/tools/bashTool.ts +140 -0
- package/src/tools/changeDirectoryTool.ts +213 -0
- package/src/tools/contracts.ts +179 -0
- package/src/tools/deleteFileTool.ts +111 -0
- package/src/tools/editTool.ts +160 -0
- package/src/tools/editUtils.ts +170 -0
- package/src/tools/listDirectoryTool.ts +55 -0
- package/src/tools/mcpResourceTools.ts +95 -0
- package/src/tools/permissionRules.ts +85 -0
- package/src/tools/privateContinuityEditTool.ts +178 -0
- package/src/tools/privateContinuityReadTool.ts +107 -0
- package/src/tools/readTool.ts +85 -0
- package/src/tools/registry.ts +67 -0
- package/src/tools/writeFileTool.ts +142 -0
- package/src/ui/BrandSplash.tsx +193 -0
- package/src/ui/ProgressBar.tsx +34 -0
- package/src/ui/Select.tsx +143 -0
- package/src/ui/Spinner.tsx +269 -0
- package/src/ui/Surface.tsx +47 -0
- package/src/ui/TextInput.tsx +97 -0
- package/src/ui/theme.ts +59 -0
- package/src/utils/clipboard.ts +216 -0
- package/src/utils/markdownSegments.ts +51 -0
- package/src/utils/messages.ts +35 -0
- package/src/utils/withRetry.ts +280 -0
- 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
|
+
}
|