ethagent 2.4.0 → 3.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.
- package/README.md +7 -4
- package/package.json +2 -1
- package/src/app/FirstRun.tsx +155 -15
- package/src/app/FirstRunTimeline.tsx +4 -0
- package/src/app/input/AppInputProvider.tsx +19 -0
- package/src/app/input/appInputParser.ts +19 -4
- package/src/chat/ChatBottomPane.tsx +3 -1
- package/src/chat/ChatScreen.tsx +7 -1
- package/src/chat/ConversationStack.tsx +25 -19
- package/src/chat/MessageList.tsx +194 -53
- package/src/chat/chatSessionState.ts +1 -1
- package/src/chat/chatTurnOrchestrator.ts +59 -0
- package/src/chat/input/ChatInput.tsx +3 -0
- package/src/chat/input/textCursor.ts +13 -3
- package/src/chat/transcript/TranscriptView.tsx +7 -5
- package/src/chat/transcript/transcriptViewport.ts +88 -17
- package/src/chat/views/PermissionPrompt.tsx +26 -26
- package/src/chat/views/PermissionsView.tsx +18 -12
- package/src/chat/views/RewindView.tsx +3 -1
- package/src/cli/ResetConfirmView.tsx +24 -9
- package/src/identity/continuity/editor.ts +27 -2
- package/src/identity/continuity/envelope.ts +125 -0
- package/src/identity/continuity/publicSkills.ts +37 -1
- package/src/identity/continuity/skills/frontmatter.ts +183 -0
- package/src/identity/continuity/skills/loadSkills.ts +609 -0
- package/src/identity/continuity/skills/publicSkillsSync.ts +32 -0
- package/src/identity/continuity/skills/scaffold.ts +52 -0
- package/src/identity/continuity/skills/types.ts +30 -0
- package/src/identity/continuity/storage/defaults.ts +28 -47
- package/src/identity/continuity/storage/files.ts +1 -0
- package/src/identity/continuity/storage/paths.ts +1 -0
- package/src/identity/continuity/storage/scaffold.ts +25 -23
- package/src/identity/continuity/storage/status.ts +34 -5
- package/src/identity/continuity/storage/types.ts +3 -2
- package/src/identity/continuity/storage.ts +3 -0
- package/src/identity/hub/OperationalRoutes.tsx +105 -3
- package/src/identity/hub/Routes.tsx +5 -3
- package/src/identity/hub/continuity/ContinuityDashboardScreen.tsx +5 -51
- package/src/identity/hub/continuity/RecoveryConfirmScreen.tsx +1 -1
- package/src/identity/hub/continuity/SavePromptScreen.tsx +1 -0
- package/src/identity/hub/continuity/effects.ts +36 -5
- package/src/identity/hub/continuity/skills/DeleteSkillConfirmScreen.tsx +112 -0
- package/src/identity/hub/continuity/skills/DeleteSkillScreen.tsx +123 -0
- package/src/identity/hub/continuity/skills/NewSkillScreen.tsx +57 -0
- package/src/identity/hub/continuity/skills/NewSkillVisibilityScreen.tsx +52 -0
- package/src/identity/hub/continuity/skills/SkillVisibilityScreen.tsx +171 -0
- package/src/identity/hub/continuity/skills/SkillsTreeScreen.tsx +213 -0
- package/src/identity/hub/continuity/snapshot.ts +3 -0
- package/src/identity/hub/continuity/state.ts +3 -2
- package/src/identity/hub/continuity/vault.ts +42 -10
- package/src/identity/hub/custody/CustodyEditFlow.tsx +3 -3
- package/src/identity/hub/identityHubReducer.ts +21 -0
- package/src/identity/hub/profile/effects.ts +16 -3
- package/src/identity/hub/restore/RestoreFlow.tsx +43 -6
- package/src/identity/hub/restore/apply.ts +12 -1
- package/src/identity/hub/restore/recovery.ts +11 -1
- package/src/identity/hub/restore/resolve.ts +1 -1
- package/src/identity/hub/restore/useRestoreEffects.ts +4 -6
- package/src/identity/hub/shared/components/DetailsScreen.tsx +4 -1
- package/src/identity/hub/shared/components/IdentitySummary.tsx +97 -53
- package/src/identity/hub/shared/components/MenuScreen.tsx +18 -15
- package/src/identity/hub/shared/components/UnlinkedIdentityScreen.tsx +1 -1
- package/src/identity/hub/shared/components/menuFlagsFromReconciliation.ts +8 -12
- package/src/identity/hub/shared/effects/sync.ts +16 -3
- package/src/identity/hub/shared/model/copy.ts +2 -4
- package/src/identity/hub/transfer/effects.ts +15 -2
- package/src/identity/hub/useIdentityHubContinuity.ts +145 -23
- package/src/identity/hub/useIdentityHubController.ts +5 -1
- package/src/identity/hub/useIdentityHubSideEffects.ts +2 -4
- package/src/mcp/manager.ts +1 -1
- package/src/models/ModelPicker.tsx +89 -84
- package/src/models/llamacpp.ts +160 -11
- package/src/models/llamacppPreflight.ts +1 -16
- package/src/models/modelPickerOptions.ts +43 -37
- package/src/providers/contracts.ts +1 -0
- package/src/providers/openai-chat.ts +50 -9
- package/src/providers/openai-responses.ts +19 -4
- package/src/runtime/toolExecution.ts +4 -3
- package/src/runtime/turn.ts +61 -30
- package/src/tools/changeDirectoryTool.ts +1 -1
- package/src/tools/contracts.ts +10 -0
- package/src/tools/deleteFileTool.ts +1 -1
- package/src/tools/editTool.ts +1 -1
- package/src/tools/listDirectoryTool.ts +1 -1
- package/src/tools/listSkillFilesTool.ts +77 -0
- package/src/tools/listSkillsTool.ts +68 -0
- package/src/tools/mcpResourceTools.ts +2 -2
- package/src/tools/privateContinuityReadTool.ts +1 -1
- package/src/tools/readSkillTool.ts +107 -0
- package/src/tools/readTool.ts +1 -1
- package/src/tools/registry.ts +6 -0
- package/src/tools/writeFileTool.ts +22 -2
- package/src/ui/Spinner.tsx +1 -1
- package/src/identity/continuity/localBackup.ts +0 -249
- package/src/identity/continuity/zipWriter.ts +0 -95
- package/src/identity/hub/continuity/index.ts +0 -7
- package/src/identity/hub/ens/index.ts +0 -11
- package/src/identity/hub/restore/index.ts +0 -22
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { atomicWriteText } from '../../../storage/atomicWrite.js'
|
|
4
|
+
import type { EthagentIdentity } from '../../../storage/config.js'
|
|
5
|
+
import { ensureContinuityVault } from '../storage/files.js'
|
|
6
|
+
import { continuityVaultRef } from '../storage/paths.js'
|
|
7
|
+
import { parseSkillFile } from './frontmatter.js'
|
|
8
|
+
import { defaultSkillScaffold } from './scaffold.js'
|
|
9
|
+
import type {
|
|
10
|
+
ContinuitySkillsTree,
|
|
11
|
+
Skill,
|
|
12
|
+
SkillIndexEntry,
|
|
13
|
+
SkillVisibility,
|
|
14
|
+
} from './types.js'
|
|
15
|
+
|
|
16
|
+
const SKILL_FILE_NAME = 'SKILL.md'
|
|
17
|
+
const SEGMENT_RE = /^[A-Za-z0-9._-]+$/
|
|
18
|
+
const FILE_EXT_RE = /\.[A-Za-z0-9]+$/
|
|
19
|
+
const RESERVED_WINDOWS_SEGMENTS = new Set([
|
|
20
|
+
'con', 'prn', 'aux', 'nul',
|
|
21
|
+
'com1', 'com2', 'com3', 'com4', 'com5', 'com6', 'com7', 'com8', 'com9',
|
|
22
|
+
'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9',
|
|
23
|
+
])
|
|
24
|
+
const MAX_SKILL_ENTRIES = 200
|
|
25
|
+
const MAX_SKILL_FILE_BYTES = 256 * 1024
|
|
26
|
+
const MAX_TREE_FILES = 500
|
|
27
|
+
const MAX_FOLDER_DEPTH = 4
|
|
28
|
+
|
|
29
|
+
type IdentityKey = Pick<EthagentIdentity, 'chainId' | 'identityRegistryAddress' | 'agentId' | 'address'>
|
|
30
|
+
|
|
31
|
+
type CacheEntry = {
|
|
32
|
+
fingerprint: string
|
|
33
|
+
entries: SkillIndexEntry[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const cache = new Map<string, CacheEntry>()
|
|
37
|
+
|
|
38
|
+
function vaultKey(identity: IdentityKey): string {
|
|
39
|
+
return continuityVaultRef(identity).dir
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function invalidateSkillsCache(identity: IdentityKey): void {
|
|
43
|
+
cache.delete(vaultKey(identity))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function listSkills(identity: EthagentIdentity): Promise<SkillIndexEntry[]> {
|
|
47
|
+
const ref = await ensureContinuityVault(identity)
|
|
48
|
+
await migrateLegacySkillFiles(ref.skillsDir)
|
|
49
|
+
const key = ref.dir
|
|
50
|
+
const stat = await statOrNull(ref.skillsDir)
|
|
51
|
+
if (!stat) return []
|
|
52
|
+
const fingerprint = await skillsTreeFingerprint(ref.skillsDir)
|
|
53
|
+
if (fingerprint === '') return []
|
|
54
|
+
const cached = cache.get(key)
|
|
55
|
+
if (cached && cached.fingerprint === fingerprint) return cached.entries
|
|
56
|
+
const entries = await collectSkillEntries(ref.skillsDir)
|
|
57
|
+
cache.set(key, { fingerprint, entries })
|
|
58
|
+
return entries
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type SkillsTreeView = {
|
|
62
|
+
skills: SkillIndexEntry[]
|
|
63
|
+
supportingCounts: Record<string, number>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function listSkillsTree(identity: EthagentIdentity): Promise<SkillsTreeView> {
|
|
67
|
+
const skills = await listSkills(identity)
|
|
68
|
+
const supportingCounts: Record<string, number> = {}
|
|
69
|
+
for (const skill of skills) {
|
|
70
|
+
const files = await listSkillFiles(identity, skill.name).catch(() => [])
|
|
71
|
+
const extras = files.filter(f => f.relativePath !== SKILL_FILE_NAME).length
|
|
72
|
+
if (extras > 0) supportingCounts[skill.name] = extras
|
|
73
|
+
}
|
|
74
|
+
return { skills, supportingCounts }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type SkillFileEntry = {
|
|
78
|
+
relativePath: string
|
|
79
|
+
absolutePath: string
|
|
80
|
+
sizeBytes: number
|
|
81
|
+
mtimeMs: number
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function listSkillFiles(
|
|
85
|
+
identity: EthagentIdentity,
|
|
86
|
+
skillName: string,
|
|
87
|
+
): Promise<SkillFileEntry[]> {
|
|
88
|
+
if (!isValidSegment(skillName)) throw new Error('skill name is invalid')
|
|
89
|
+
const ref = await ensureContinuityVault(identity)
|
|
90
|
+
const skillDir = path.join(ref.skillsDir, skillName)
|
|
91
|
+
if (!isWithin(ref.skillsDir, skillDir)) throw new Error('skill path escapes the vault')
|
|
92
|
+
const stat = await statOrNull(skillDir)
|
|
93
|
+
if (!stat || !stat.isDirectory()) return []
|
|
94
|
+
const out: SkillFileEntry[] = []
|
|
95
|
+
await walkFolderFiles(skillDir, '', 0, out)
|
|
96
|
+
out.sort((a, b) => a.relativePath.localeCompare(b.relativePath))
|
|
97
|
+
return out
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function skillsTreeFingerprint(root: string): Promise<string> {
|
|
101
|
+
const leaves = await walkSkillFileStats(root)
|
|
102
|
+
if (leaves.length === 0) return ''
|
|
103
|
+
leaves.sort((a, b) => a.rel.localeCompare(b.rel))
|
|
104
|
+
return leaves.map(l => `${l.rel}|${l.mtimeMs}|${l.size}`).join('\n')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
type SkillFileStat = { rel: string; mtimeMs: number; size: number }
|
|
108
|
+
|
|
109
|
+
async function walkSkillFileStats(root: string): Promise<SkillFileStat[]> {
|
|
110
|
+
const out: SkillFileStat[] = []
|
|
111
|
+
let skillDirents: import('node:fs').Dirent[]
|
|
112
|
+
try {
|
|
113
|
+
skillDirents = await fs.readdir(root, { withFileTypes: true })
|
|
114
|
+
} catch {
|
|
115
|
+
return out
|
|
116
|
+
}
|
|
117
|
+
for (const skillEnt of skillDirents) {
|
|
118
|
+
if (out.length >= MAX_TREE_FILES) break
|
|
119
|
+
if (!skillEnt.isDirectory() || skillEnt.isSymbolicLink()) continue
|
|
120
|
+
if (!isValidSegment(skillEnt.name)) continue
|
|
121
|
+
const skillDir = path.join(root, skillEnt.name)
|
|
122
|
+
const skillFile = path.join(skillDir, SKILL_FILE_NAME)
|
|
123
|
+
if (!(await pathExists(skillFile))) continue
|
|
124
|
+
const files: SkillFileEntry[] = []
|
|
125
|
+
await walkFolderFiles(skillDir, '', 0, files)
|
|
126
|
+
for (const file of files) {
|
|
127
|
+
if (out.length >= MAX_TREE_FILES) break
|
|
128
|
+
out.push({
|
|
129
|
+
rel: `${skillEnt.name}/${file.relativePath}`,
|
|
130
|
+
mtimeMs: file.mtimeMs,
|
|
131
|
+
size: file.sizeBytes,
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return out
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isValidSegment(name: string): boolean {
|
|
139
|
+
if (!name) return false
|
|
140
|
+
if (name.startsWith('.')) return false
|
|
141
|
+
if (RESERVED_WINDOWS_SEGMENTS.has(name.toLowerCase())) return false
|
|
142
|
+
return SEGMENT_RE.test(name)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function readSkill(identity: EthagentIdentity, name: string): Promise<Skill> {
|
|
146
|
+
const entries = await listSkills(identity)
|
|
147
|
+
const lookup = name.replace(/^.*:/, '').replace(/:SKILL$/i, '')
|
|
148
|
+
const match = entries.find(entry =>
|
|
149
|
+
entry.name === name
|
|
150
|
+
|| entry.name === lookup
|
|
151
|
+
|| entry.displayName === name
|
|
152
|
+
|| entry.displayName === lookup,
|
|
153
|
+
)
|
|
154
|
+
if (!match) throw new Error(`unknown private skill: ${name}`)
|
|
155
|
+
return loadSkillBody(match)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function readSkillByRelativePath(
|
|
159
|
+
identity: EthagentIdentity,
|
|
160
|
+
relativePath: string,
|
|
161
|
+
): Promise<Skill> {
|
|
162
|
+
const ref = await ensureContinuityVault(identity)
|
|
163
|
+
const normalized = relativePath.replace(/\\/g, '/')
|
|
164
|
+
if (!isValidSkillEntryKey(normalized)) throw new Error('skill path is not allowed')
|
|
165
|
+
const absolute = path.resolve(ref.skillsDir, normalized)
|
|
166
|
+
if (!isWithin(ref.skillsDir, absolute)) throw new Error('skill path escapes vault')
|
|
167
|
+
const stat = await statOrNull(absolute)
|
|
168
|
+
if (!stat || !stat.isFile()) throw new Error(`skill not found: ${relativePath}`)
|
|
169
|
+
const raw = await fs.readFile(absolute, 'utf8')
|
|
170
|
+
const parsed = parseSkillFile(raw)
|
|
171
|
+
const entry = buildIndexEntry({
|
|
172
|
+
relativePath: normalized,
|
|
173
|
+
absolutePath: absolute,
|
|
174
|
+
parsed,
|
|
175
|
+
})
|
|
176
|
+
return { ...entry, body: parsed.body }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function readSkillFile(
|
|
180
|
+
identity: EthagentIdentity,
|
|
181
|
+
skillName: string,
|
|
182
|
+
filePath: string,
|
|
183
|
+
): Promise<{ relativePath: string; absolutePath: string; content: string }> {
|
|
184
|
+
if (!isValidSegment(skillName)) throw new Error('skill name is invalid')
|
|
185
|
+
const ref = await ensureContinuityVault(identity)
|
|
186
|
+
const skillDir = path.join(ref.skillsDir, skillName)
|
|
187
|
+
const normalizedFile = filePath.replace(/\\/g, '/').replace(/^\/+/, '')
|
|
188
|
+
const rel = `${skillName}/${normalizedFile}`
|
|
189
|
+
if (!isValidSkillFilePath(rel)) throw new Error('skill file path is not allowed')
|
|
190
|
+
const absolute = path.resolve(skillDir, normalizedFile)
|
|
191
|
+
if (!isWithin(ref.skillsDir, absolute)) throw new Error('skill file path escapes the vault')
|
|
192
|
+
const stat = await statOrNull(absolute)
|
|
193
|
+
if (!stat || !stat.isFile()) throw new Error(`skill file not found: ${rel}`)
|
|
194
|
+
if (stat.size > MAX_SKILL_FILE_BYTES) throw new Error(`skill file too large: ${rel}`)
|
|
195
|
+
const content = await fs.readFile(absolute, 'utf8')
|
|
196
|
+
return { relativePath: rel, absolutePath: absolute, content }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function loadSkillsTree(identity: EthagentIdentity): Promise<ContinuitySkillsTree> {
|
|
200
|
+
const ref = await ensureContinuityVault(identity)
|
|
201
|
+
await migrateLegacySkillFiles(ref.skillsDir)
|
|
202
|
+
const tree: ContinuitySkillsTree = {}
|
|
203
|
+
let categoryDirents: import('node:fs').Dirent[]
|
|
204
|
+
try {
|
|
205
|
+
categoryDirents = await fs.readdir(ref.skillsDir, { withFileTypes: true })
|
|
206
|
+
} catch {
|
|
207
|
+
return tree
|
|
208
|
+
}
|
|
209
|
+
let totalFiles = 0
|
|
210
|
+
for (const skillEnt of categoryDirents) {
|
|
211
|
+
if (totalFiles >= MAX_TREE_FILES) break
|
|
212
|
+
if (!skillEnt.isDirectory() || skillEnt.isSymbolicLink()) continue
|
|
213
|
+
if (!isValidSegment(skillEnt.name)) continue
|
|
214
|
+
const skillDir = path.join(ref.skillsDir, skillEnt.name)
|
|
215
|
+
const entryFile = path.join(skillDir, SKILL_FILE_NAME)
|
|
216
|
+
if (!(await pathExists(entryFile))) continue
|
|
217
|
+
const files: SkillFileEntry[] = []
|
|
218
|
+
await walkFolderFiles(skillDir, '', 0, files)
|
|
219
|
+
for (const file of files) {
|
|
220
|
+
if (totalFiles >= MAX_TREE_FILES) break
|
|
221
|
+
const rel = `${skillEnt.name}/${file.relativePath}`
|
|
222
|
+
if (!isValidSkillFilePath(rel)) continue
|
|
223
|
+
if (file.sizeBytes > MAX_SKILL_FILE_BYTES) continue
|
|
224
|
+
const content = await fs.readFile(file.absolutePath, 'utf8').catch(() => null)
|
|
225
|
+
if (content === null) continue
|
|
226
|
+
tree[rel] = content
|
|
227
|
+
totalFiles++
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return tree
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export async function materializeSkillsTree(
|
|
234
|
+
identity: EthagentIdentity,
|
|
235
|
+
tree: ContinuitySkillsTree | undefined,
|
|
236
|
+
): Promise<void> {
|
|
237
|
+
if (!tree) return
|
|
238
|
+
const ref = await ensureContinuityVault(identity)
|
|
239
|
+
for (const [rawRel, content] of Object.entries(tree)) {
|
|
240
|
+
const rel = rawRel.replace(/\\/g, '/')
|
|
241
|
+
if (!isValidSkillFilePath(rel)) continue
|
|
242
|
+
if (typeof content !== 'string') continue
|
|
243
|
+
if (Buffer.byteLength(content, 'utf8') > MAX_SKILL_FILE_BYTES) continue
|
|
244
|
+
const absolute = path.resolve(ref.skillsDir, rel)
|
|
245
|
+
if (!isWithin(ref.skillsDir, absolute)) continue
|
|
246
|
+
await fs.mkdir(path.dirname(absolute), { recursive: true, mode: 0o700 })
|
|
247
|
+
await atomicWriteText(absolute, content, { mode: 0o600 })
|
|
248
|
+
}
|
|
249
|
+
invalidateSkillsCache(identity)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export type CreateSkillArgs = {
|
|
253
|
+
name: string
|
|
254
|
+
body?: string
|
|
255
|
+
visibility?: SkillVisibility
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export type CreateSkillResult = {
|
|
259
|
+
relativePath: string
|
|
260
|
+
absolutePath: string
|
|
261
|
+
displayName: string
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export async function createSkillFile(
|
|
265
|
+
identity: EthagentIdentity,
|
|
266
|
+
args: CreateSkillArgs,
|
|
267
|
+
): Promise<CreateSkillResult> {
|
|
268
|
+
if (!isValidSegment(args.name)) throw new Error('folder name must contain only letters, digits, dots, dashes, or underscores')
|
|
269
|
+
const ref = await ensureContinuityVault(identity)
|
|
270
|
+
await migrateLegacySkillFiles(ref.skillsDir)
|
|
271
|
+
const skillDir = path.join(ref.skillsDir, args.name)
|
|
272
|
+
const file = path.join(skillDir, SKILL_FILE_NAME)
|
|
273
|
+
const relativePath = `${args.name}/${SKILL_FILE_NAME}`
|
|
274
|
+
if (await pathExists(file)) {
|
|
275
|
+
throw new Error(`skill already exists at ${relativePath}`)
|
|
276
|
+
}
|
|
277
|
+
const skillDirExisted = await pathExists(skillDir)
|
|
278
|
+
await fs.mkdir(skillDir, { recursive: true, mode: 0o700 })
|
|
279
|
+
const body = args.body ?? defaultSkillScaffold({ name: args.name, ...(args.visibility ? { visibility: args.visibility } : {}) })
|
|
280
|
+
try {
|
|
281
|
+
await atomicWriteText(file, body, { mode: 0o600 })
|
|
282
|
+
} catch (err) {
|
|
283
|
+
if (!skillDirExisted) {
|
|
284
|
+
await fs.rm(skillDir, { recursive: true, force: true }).catch(() => null)
|
|
285
|
+
}
|
|
286
|
+
throw err
|
|
287
|
+
}
|
|
288
|
+
invalidateSkillsCache(identity)
|
|
289
|
+
return {
|
|
290
|
+
relativePath,
|
|
291
|
+
absolutePath: file,
|
|
292
|
+
displayName: args.name,
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export async function setSkillVisibility(
|
|
297
|
+
identity: EthagentIdentity,
|
|
298
|
+
relativePath: string,
|
|
299
|
+
visibility: SkillVisibility,
|
|
300
|
+
): Promise<void> {
|
|
301
|
+
const ref = await ensureContinuityVault(identity)
|
|
302
|
+
const normalized = relativePath.replace(/\\/g, '/')
|
|
303
|
+
if (!isValidSkillEntryKey(normalized)) throw new Error('skill path is not allowed')
|
|
304
|
+
const absolute = path.resolve(ref.skillsDir, normalized)
|
|
305
|
+
if (!isWithin(ref.skillsDir, absolute)) throw new Error('skill path escapes the vault')
|
|
306
|
+
const stat = await statOrNull(absolute)
|
|
307
|
+
if (!stat || !stat.isFile()) throw new Error(`skill not found: ${normalized}`)
|
|
308
|
+
const raw = await fs.readFile(absolute, 'utf8')
|
|
309
|
+
const next = rewriteVisibility(raw, visibility)
|
|
310
|
+
if (next === raw) return
|
|
311
|
+
await atomicWriteText(absolute, next, { mode: 0o600 })
|
|
312
|
+
invalidateSkillsCache(identity)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function rewriteVisibility(raw: string, visibility: SkillVisibility): string {
|
|
316
|
+
const normalized = raw.replace(/\r\n?/g, '\n').replace(/^/, '')
|
|
317
|
+
if (!normalized.startsWith('---\n')) {
|
|
318
|
+
return `---\nvisibility: ${visibility}\n---\n\n${normalized.replace(/^\n+/, '')}`
|
|
319
|
+
}
|
|
320
|
+
const afterOpen = normalized.slice(4)
|
|
321
|
+
const closeMatch = afterOpen.match(/^---\s*$/m)
|
|
322
|
+
if (!closeMatch || closeMatch.index === undefined) {
|
|
323
|
+
return `---\nvisibility: ${visibility}\n---\n\n${normalized.replace(/^---\n/, '')}`
|
|
324
|
+
}
|
|
325
|
+
const closeIdx = closeMatch.index
|
|
326
|
+
const fmText = afterOpen.slice(0, closeIdx)
|
|
327
|
+
const rest = afterOpen.slice(closeIdx)
|
|
328
|
+
const lines = fmText.split('\n')
|
|
329
|
+
let replaced = false
|
|
330
|
+
const updated = lines.map(line => {
|
|
331
|
+
if (replaced) return line
|
|
332
|
+
if (/^\s*visibility\s*:/.test(line)) {
|
|
333
|
+
replaced = true
|
|
334
|
+
return `visibility: ${visibility}`
|
|
335
|
+
}
|
|
336
|
+
return line
|
|
337
|
+
})
|
|
338
|
+
if (!replaced) {
|
|
339
|
+
while (updated.length > 0 && updated[updated.length - 1] === '') updated.pop()
|
|
340
|
+
updated.push(`visibility: ${visibility}`)
|
|
341
|
+
updated.push('')
|
|
342
|
+
}
|
|
343
|
+
return `---\n${updated.join('\n')}${rest}`
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export async function deleteSkillEntry(identity: EthagentIdentity, relativePath: string): Promise<void> {
|
|
347
|
+
const ref = await ensureContinuityVault(identity)
|
|
348
|
+
const normalized = relativePath.replace(/\\/g, '/')
|
|
349
|
+
if (!isValidSkillEntryKey(normalized)) throw new Error('skill path is not allowed')
|
|
350
|
+
const skillFile = path.resolve(ref.skillsDir, normalized)
|
|
351
|
+
if (!isWithin(ref.skillsDir, skillFile)) throw new Error('skill path escapes the vault')
|
|
352
|
+
const skillDir = path.dirname(skillFile)
|
|
353
|
+
if (!isWithin(ref.skillsDir, skillDir) || skillDir === ref.skillsDir) {
|
|
354
|
+
throw new Error('skill path escapes the vault')
|
|
355
|
+
}
|
|
356
|
+
await fs.rm(skillDir, { recursive: true, force: true })
|
|
357
|
+
invalidateSkillsCache(identity)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export async function migrateLegacySkillFiles(skillsRoot: string): Promise<void> {
|
|
361
|
+
let topDirents: import('node:fs').Dirent[]
|
|
362
|
+
try {
|
|
363
|
+
topDirents = await fs.readdir(skillsRoot, { withFileTypes: true })
|
|
364
|
+
} catch {
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
for (const topEnt of topDirents) {
|
|
368
|
+
if (!topEnt.isDirectory() || topEnt.isSymbolicLink()) continue
|
|
369
|
+
if (!isValidSegment(topEnt.name)) continue
|
|
370
|
+
const topDir = path.join(skillsRoot, topEnt.name)
|
|
371
|
+
let children: import('node:fs').Dirent[]
|
|
372
|
+
try {
|
|
373
|
+
children = await fs.readdir(topDir, { withFileTypes: true })
|
|
374
|
+
} catch {
|
|
375
|
+
continue
|
|
376
|
+
}
|
|
377
|
+
const skillFileHere = path.join(topDir, SKILL_FILE_NAME)
|
|
378
|
+
if (await pathExists(skillFileHere)) continue
|
|
379
|
+
for (const child of children) {
|
|
380
|
+
if (child.isSymbolicLink()) continue
|
|
381
|
+
if (child.isFile() && /^[A-Za-z0-9._-]+\.md$/i.test(child.name) && !/^SKILL\.md$/i.test(child.name)) {
|
|
382
|
+
const slug = child.name.replace(/\.md$/i, '')
|
|
383
|
+
if (!isValidSegment(slug)) continue
|
|
384
|
+
const target = await chooseFlatTarget(skillsRoot, `${topEnt.name}-${slug}`)
|
|
385
|
+
const targetDir = path.join(skillsRoot, target)
|
|
386
|
+
const targetFile = path.join(targetDir, SKILL_FILE_NAME)
|
|
387
|
+
try {
|
|
388
|
+
await fs.mkdir(targetDir, { recursive: true, mode: 0o700 })
|
|
389
|
+
await fs.rename(path.join(topDir, child.name), targetFile)
|
|
390
|
+
} catch {
|
|
391
|
+
continue
|
|
392
|
+
}
|
|
393
|
+
continue
|
|
394
|
+
}
|
|
395
|
+
if (child.isDirectory() && isValidSegment(child.name)) {
|
|
396
|
+
const oldSkillDir = path.join(topDir, child.name)
|
|
397
|
+
const nestedSkillFile = path.join(oldSkillDir, SKILL_FILE_NAME)
|
|
398
|
+
if (!(await pathExists(nestedSkillFile))) continue
|
|
399
|
+
const target = await chooseFlatTarget(skillsRoot, `${topEnt.name}-${child.name}`)
|
|
400
|
+
const targetDir = path.join(skillsRoot, target)
|
|
401
|
+
try {
|
|
402
|
+
await fs.rename(oldSkillDir, targetDir)
|
|
403
|
+
} catch {
|
|
404
|
+
continue
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
await removeIfEmpty(topDir)
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function chooseFlatTarget(skillsRoot: string, base: string): Promise<string> {
|
|
413
|
+
let candidate = base
|
|
414
|
+
let suffix = 2
|
|
415
|
+
while (await pathExists(path.join(skillsRoot, candidate))) {
|
|
416
|
+
candidate = `${base}-${suffix}`
|
|
417
|
+
suffix++
|
|
418
|
+
if (suffix > 99) throw new Error(`cannot find unused name for legacy skill: ${base}`)
|
|
419
|
+
}
|
|
420
|
+
return candidate
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function removeIfEmpty(dir: string): Promise<void> {
|
|
424
|
+
try {
|
|
425
|
+
const entries = await fs.readdir(dir)
|
|
426
|
+
if (entries.length === 0) await fs.rmdir(dir).catch(() => null)
|
|
427
|
+
} catch {
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function walkFolderFiles(
|
|
432
|
+
root: string,
|
|
433
|
+
relativePrefix: string,
|
|
434
|
+
depth: number,
|
|
435
|
+
out: SkillFileEntry[],
|
|
436
|
+
): Promise<void> {
|
|
437
|
+
if (depth > MAX_FOLDER_DEPTH) return
|
|
438
|
+
let dirents: import('node:fs').Dirent[]
|
|
439
|
+
try {
|
|
440
|
+
dirents = await fs.readdir(path.join(root, relativePrefix), { withFileTypes: true })
|
|
441
|
+
} catch {
|
|
442
|
+
return
|
|
443
|
+
}
|
|
444
|
+
for (const ent of dirents) {
|
|
445
|
+
if (ent.isSymbolicLink()) continue
|
|
446
|
+
if (ent.name.startsWith('.')) continue
|
|
447
|
+
if (RESERVED_WINDOWS_SEGMENTS.has(ent.name.toLowerCase())) continue
|
|
448
|
+
if (ent.isDirectory()) {
|
|
449
|
+
if (!isValidSegment(ent.name)) continue
|
|
450
|
+
const nextPrefix = relativePrefix ? `${relativePrefix}/${ent.name}` : ent.name
|
|
451
|
+
await walkFolderFiles(root, nextPrefix, depth + 1, out)
|
|
452
|
+
continue
|
|
453
|
+
}
|
|
454
|
+
if (!ent.isFile()) continue
|
|
455
|
+
if (!isValidFilenameSegment(ent.name)) continue
|
|
456
|
+
const absolutePath = path.join(root, relativePrefix, ent.name)
|
|
457
|
+
const stat = await fs.stat(absolutePath).catch(() => null)
|
|
458
|
+
if (!stat) continue
|
|
459
|
+
out.push({
|
|
460
|
+
relativePath: relativePrefix ? `${relativePrefix}/${ent.name}` : ent.name,
|
|
461
|
+
absolutePath,
|
|
462
|
+
sizeBytes: stat.size,
|
|
463
|
+
mtimeMs: stat.mtimeMs,
|
|
464
|
+
})
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function isValidFilenameSegment(name: string): boolean {
|
|
469
|
+
if (!name) return false
|
|
470
|
+
if (name.startsWith('.')) return false
|
|
471
|
+
if (RESERVED_WINDOWS_SEGMENTS.has(name.toLowerCase())) return false
|
|
472
|
+
if (!SEGMENT_RE.test(name)) return false
|
|
473
|
+
return FILE_EXT_RE.test(name)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async function statOrNull(file: string): Promise<import('node:fs').Stats | null> {
|
|
477
|
+
try {
|
|
478
|
+
return await fs.stat(file)
|
|
479
|
+
} catch {
|
|
480
|
+
return null
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async function pathExists(file: string): Promise<boolean> {
|
|
485
|
+
try {
|
|
486
|
+
await fs.access(file)
|
|
487
|
+
return true
|
|
488
|
+
} catch {
|
|
489
|
+
return false
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async function collectSkillEntries(root: string): Promise<SkillIndexEntry[]> {
|
|
494
|
+
const out: SkillIndexEntry[] = []
|
|
495
|
+
let topDirents: import('node:fs').Dirent[]
|
|
496
|
+
try {
|
|
497
|
+
topDirents = await fs.readdir(root, { withFileTypes: true })
|
|
498
|
+
} catch {
|
|
499
|
+
return out
|
|
500
|
+
}
|
|
501
|
+
for (const skillEnt of topDirents) {
|
|
502
|
+
if (out.length >= MAX_SKILL_ENTRIES) break
|
|
503
|
+
if (!skillEnt.isDirectory() || skillEnt.isSymbolicLink()) continue
|
|
504
|
+
if (!isValidSegment(skillEnt.name)) continue
|
|
505
|
+
const skillFile = path.join(root, skillEnt.name, SKILL_FILE_NAME)
|
|
506
|
+
try {
|
|
507
|
+
const stat = await fs.stat(skillFile)
|
|
508
|
+
if (!stat.isFile()) continue
|
|
509
|
+
if (stat.size > MAX_SKILL_FILE_BYTES) continue
|
|
510
|
+
const raw = await fs.readFile(skillFile, 'utf8')
|
|
511
|
+
const parsed = parseSkillFile(raw)
|
|
512
|
+
const relativePath = `${skillEnt.name}/${SKILL_FILE_NAME}`
|
|
513
|
+
out.push(buildIndexEntry({
|
|
514
|
+
relativePath,
|
|
515
|
+
absolutePath: skillFile,
|
|
516
|
+
parsed,
|
|
517
|
+
}))
|
|
518
|
+
} catch {
|
|
519
|
+
continue
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
out.sort((a, b) => a.name.localeCompare(b.name))
|
|
523
|
+
return out
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function buildIndexEntry(args: {
|
|
527
|
+
relativePath: string
|
|
528
|
+
absolutePath: string
|
|
529
|
+
parsed: { frontmatter: import('./types.js').SkillFrontmatter; body: string }
|
|
530
|
+
}): SkillIndexEntry {
|
|
531
|
+
const segments = args.relativePath.split('/')
|
|
532
|
+
const folder = segments[0] ?? ''
|
|
533
|
+
const derivedName = folder || segments.join('/')
|
|
534
|
+
const fm = args.parsed.frontmatter
|
|
535
|
+
const description = pickDescription(fm.description, args.parsed.body)
|
|
536
|
+
const visibility: SkillVisibility = fm.visibility ?? 'discoverable'
|
|
537
|
+
return {
|
|
538
|
+
name: derivedName,
|
|
539
|
+
...(fm.name ? { displayName: fm.name } : {}),
|
|
540
|
+
description,
|
|
541
|
+
...(fm.whenToUse ? { whenToUse: fm.whenToUse } : {}),
|
|
542
|
+
...(fm.version ? { version: fm.version } : {}),
|
|
543
|
+
...(fm.argumentHint ? { argumentHint: fm.argumentHint } : {}),
|
|
544
|
+
...(fm.tags && fm.tags.length > 0 ? { tags: fm.tags } : {}),
|
|
545
|
+
visibility,
|
|
546
|
+
relativePath: args.relativePath,
|
|
547
|
+
absolutePath: args.absolutePath,
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function pickDescription(fromFrontmatter: string | undefined, body: string): string {
|
|
552
|
+
if (fromFrontmatter && fromFrontmatter.trim()) return fromFrontmatter.trim()
|
|
553
|
+
for (const line of body.split('\n')) {
|
|
554
|
+
const trimmed = line.replace(/^#+\s*/, '').trim()
|
|
555
|
+
if (trimmed) return trimmed.slice(0, 280)
|
|
556
|
+
}
|
|
557
|
+
return ''
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
async function loadSkillBody(entry: SkillIndexEntry): Promise<Skill> {
|
|
561
|
+
const raw = await fs.readFile(entry.absolutePath, 'utf8')
|
|
562
|
+
const parsed = parseSkillFile(raw)
|
|
563
|
+
return { ...entry, body: parsed.body }
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export function isValidSkillEntryKey(rel: string): boolean {
|
|
567
|
+
if (!rel || rel.length > 256) return false
|
|
568
|
+
if (rel.includes('\0')) return false
|
|
569
|
+
if (rel.startsWith('/') || rel.startsWith('\\')) return false
|
|
570
|
+
if (/^[a-zA-Z]:/.test(rel)) return false
|
|
571
|
+
const segments = rel.split('/')
|
|
572
|
+
if (segments.length !== 2) return false
|
|
573
|
+
const [name, filename] = segments
|
|
574
|
+
if (!name || !filename) return false
|
|
575
|
+
if (filename !== SKILL_FILE_NAME) return false
|
|
576
|
+
if (!isValidSegment(name)) return false
|
|
577
|
+
return true
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
export function isValidSkillFilePath(rel: string): boolean {
|
|
581
|
+
if (!rel || rel.length > 256) return false
|
|
582
|
+
if (rel.includes('\0')) return false
|
|
583
|
+
if (rel.startsWith('/') || rel.startsWith('\\')) return false
|
|
584
|
+
if (/^[a-zA-Z]:/.test(rel)) return false
|
|
585
|
+
const segments = rel.split('/')
|
|
586
|
+
if (segments.length < 2) return false
|
|
587
|
+
if (segments.length > MAX_FOLDER_DEPTH + 2) return false
|
|
588
|
+
const [first, ...rest] = segments
|
|
589
|
+
if (!first || !isValidSegment(first)) return false
|
|
590
|
+
for (let i = 0; i < rest.length; i++) {
|
|
591
|
+
const seg = rest[i]
|
|
592
|
+
if (!seg) return false
|
|
593
|
+
if (i === rest.length - 1) {
|
|
594
|
+
if (seg === SKILL_FILE_NAME) continue
|
|
595
|
+
if (!isValidFilenameSegment(seg)) return false
|
|
596
|
+
} else {
|
|
597
|
+
if (!isValidSegment(seg)) return false
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
return true
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function isWithin(root: string, target: string): boolean {
|
|
604
|
+
const rootResolved = path.resolve(root)
|
|
605
|
+
const targetResolved = path.resolve(target)
|
|
606
|
+
if (targetResolved === rootResolved) return true
|
|
607
|
+
const prefix = rootResolved.endsWith(path.sep) ? rootResolved : rootResolved + path.sep
|
|
608
|
+
return targetResolved.startsWith(prefix)
|
|
609
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { atomicWriteText } from '../../../storage/atomicWrite.js'
|
|
2
|
+
import type { EthagentIdentity } from '../../../storage/config.js'
|
|
3
|
+
import {
|
|
4
|
+
appendPublicSkillEntries,
|
|
5
|
+
defaultPublicSkillsProfile,
|
|
6
|
+
renderPublicSkillsJson,
|
|
7
|
+
} from '../publicSkills.js'
|
|
8
|
+
import { ensureContinuityVault, ensureTrailingNewline, readOrDefault } from '../storage/files.js'
|
|
9
|
+
import { listSkills } from './loadSkills.js'
|
|
10
|
+
import type { SkillIndexEntry } from './types.js'
|
|
11
|
+
|
|
12
|
+
export async function derivePublicSkillEntries(identity: EthagentIdentity): Promise<SkillIndexEntry[]> {
|
|
13
|
+
const entries = await listSkills(identity)
|
|
14
|
+
return entries.filter(entry => entry.visibility === 'public' || entry.visibility === 'discoverable')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function renderPublicSkillsJsonForIdentity(identity: EthagentIdentity): Promise<string> {
|
|
18
|
+
const publicEntries = await derivePublicSkillEntries(identity)
|
|
19
|
+
const profile = appendPublicSkillEntries(defaultPublicSkillsProfile(identity), publicEntries)
|
|
20
|
+
return renderPublicSkillsJson(profile)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function syncPublicSkillsManifest(identity: EthagentIdentity): Promise<string> {
|
|
24
|
+
const ref = await ensureContinuityVault(identity)
|
|
25
|
+
const next = await renderPublicSkillsJsonForIdentity(identity)
|
|
26
|
+
const current = await readOrDefault(ref.publicSkillsPath, '')
|
|
27
|
+
if (current === ensureTrailingNewline(next) || current === next) {
|
|
28
|
+
return current
|
|
29
|
+
}
|
|
30
|
+
await atomicWriteText(ref.publicSkillsPath, ensureTrailingNewline(next), { mode: 0o644 })
|
|
31
|
+
return next
|
|
32
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { SkillVisibility } from './types.js'
|
|
2
|
+
|
|
3
|
+
export type SkillScaffoldArgs = {
|
|
4
|
+
name: string
|
|
5
|
+
visibility?: SkillVisibility
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function defaultSkillScaffold({ name, visibility = 'discoverable' }: SkillScaffoldArgs): string {
|
|
9
|
+
return [
|
|
10
|
+
'---',
|
|
11
|
+
`name: ${name}`,
|
|
12
|
+
'description:',
|
|
13
|
+
`visibility: ${visibility}`,
|
|
14
|
+
'---',
|
|
15
|
+
'',
|
|
16
|
+
'# Overview',
|
|
17
|
+
'',
|
|
18
|
+
'Describe in one or two sentences what this skill does and the problem it solves.',
|
|
19
|
+
'',
|
|
20
|
+
'# When to use',
|
|
21
|
+
'',
|
|
22
|
+
'State the trigger condition the agent should match against the user request.',
|
|
23
|
+
'Keep this in sync with the description field above.',
|
|
24
|
+
'',
|
|
25
|
+
'# Instructions',
|
|
26
|
+
'',
|
|
27
|
+
'1. First concrete step.',
|
|
28
|
+
'2. Second concrete step.',
|
|
29
|
+
'3. Output format requirements.',
|
|
30
|
+
'',
|
|
31
|
+
'# Examples',
|
|
32
|
+
'',
|
|
33
|
+
'<example>',
|
|
34
|
+
'Input: ...',
|
|
35
|
+
'Output: ...',
|
|
36
|
+
'</example>',
|
|
37
|
+
'',
|
|
38
|
+
'# Notes',
|
|
39
|
+
'',
|
|
40
|
+
'Edge cases, anti-triggers (when not to use this skill), and pointers to related skills.',
|
|
41
|
+
'Reference supporting files in this skill folder with relative markdown links.',
|
|
42
|
+
'',
|
|
43
|
+
].join('\n')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isDraftScaffold(entry: { description: string; name: string }): boolean {
|
|
47
|
+
const desc = entry.description?.trim() ?? ''
|
|
48
|
+
if (desc.length === 0) return true
|
|
49
|
+
if (/^<.*>$/.test(desc)) return true
|
|
50
|
+
if (desc === entry.name) return true
|
|
51
|
+
return false
|
|
52
|
+
}
|