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.
Files changed (98) hide show
  1. package/README.md +7 -4
  2. package/package.json +2 -1
  3. package/src/app/FirstRun.tsx +155 -15
  4. package/src/app/FirstRunTimeline.tsx +4 -0
  5. package/src/app/input/AppInputProvider.tsx +19 -0
  6. package/src/app/input/appInputParser.ts +19 -4
  7. package/src/chat/ChatBottomPane.tsx +3 -1
  8. package/src/chat/ChatScreen.tsx +7 -1
  9. package/src/chat/ConversationStack.tsx +25 -19
  10. package/src/chat/MessageList.tsx +194 -53
  11. package/src/chat/chatSessionState.ts +1 -1
  12. package/src/chat/chatTurnOrchestrator.ts +59 -0
  13. package/src/chat/input/ChatInput.tsx +3 -0
  14. package/src/chat/input/textCursor.ts +13 -3
  15. package/src/chat/transcript/TranscriptView.tsx +7 -5
  16. package/src/chat/transcript/transcriptViewport.ts +88 -17
  17. package/src/chat/views/PermissionPrompt.tsx +26 -26
  18. package/src/chat/views/PermissionsView.tsx +18 -12
  19. package/src/chat/views/RewindView.tsx +3 -1
  20. package/src/cli/ResetConfirmView.tsx +24 -9
  21. package/src/identity/continuity/editor.ts +27 -2
  22. package/src/identity/continuity/envelope.ts +125 -0
  23. package/src/identity/continuity/publicSkills.ts +37 -1
  24. package/src/identity/continuity/skills/frontmatter.ts +183 -0
  25. package/src/identity/continuity/skills/loadSkills.ts +609 -0
  26. package/src/identity/continuity/skills/publicSkillsSync.ts +32 -0
  27. package/src/identity/continuity/skills/scaffold.ts +52 -0
  28. package/src/identity/continuity/skills/types.ts +30 -0
  29. package/src/identity/continuity/storage/defaults.ts +28 -47
  30. package/src/identity/continuity/storage/files.ts +1 -0
  31. package/src/identity/continuity/storage/paths.ts +1 -0
  32. package/src/identity/continuity/storage/scaffold.ts +25 -23
  33. package/src/identity/continuity/storage/status.ts +34 -5
  34. package/src/identity/continuity/storage/types.ts +3 -2
  35. package/src/identity/continuity/storage.ts +3 -0
  36. package/src/identity/hub/OperationalRoutes.tsx +105 -3
  37. package/src/identity/hub/Routes.tsx +5 -3
  38. package/src/identity/hub/continuity/ContinuityDashboardScreen.tsx +5 -51
  39. package/src/identity/hub/continuity/RecoveryConfirmScreen.tsx +1 -1
  40. package/src/identity/hub/continuity/SavePromptScreen.tsx +1 -0
  41. package/src/identity/hub/continuity/effects.ts +36 -5
  42. package/src/identity/hub/continuity/skills/DeleteSkillConfirmScreen.tsx +112 -0
  43. package/src/identity/hub/continuity/skills/DeleteSkillScreen.tsx +123 -0
  44. package/src/identity/hub/continuity/skills/NewSkillScreen.tsx +57 -0
  45. package/src/identity/hub/continuity/skills/NewSkillVisibilityScreen.tsx +52 -0
  46. package/src/identity/hub/continuity/skills/SkillVisibilityScreen.tsx +171 -0
  47. package/src/identity/hub/continuity/skills/SkillsTreeScreen.tsx +213 -0
  48. package/src/identity/hub/continuity/snapshot.ts +3 -0
  49. package/src/identity/hub/continuity/state.ts +3 -2
  50. package/src/identity/hub/continuity/vault.ts +42 -10
  51. package/src/identity/hub/custody/CustodyEditFlow.tsx +3 -3
  52. package/src/identity/hub/identityHubReducer.ts +21 -0
  53. package/src/identity/hub/profile/effects.ts +16 -3
  54. package/src/identity/hub/restore/RestoreFlow.tsx +43 -6
  55. package/src/identity/hub/restore/apply.ts +12 -1
  56. package/src/identity/hub/restore/recovery.ts +11 -1
  57. package/src/identity/hub/restore/resolve.ts +1 -1
  58. package/src/identity/hub/restore/useRestoreEffects.ts +4 -6
  59. package/src/identity/hub/shared/components/DetailsScreen.tsx +4 -1
  60. package/src/identity/hub/shared/components/IdentitySummary.tsx +97 -53
  61. package/src/identity/hub/shared/components/MenuScreen.tsx +18 -15
  62. package/src/identity/hub/shared/components/UnlinkedIdentityScreen.tsx +1 -1
  63. package/src/identity/hub/shared/components/menuFlagsFromReconciliation.ts +8 -12
  64. package/src/identity/hub/shared/effects/sync.ts +16 -3
  65. package/src/identity/hub/shared/model/copy.ts +2 -4
  66. package/src/identity/hub/transfer/effects.ts +15 -2
  67. package/src/identity/hub/useIdentityHubContinuity.ts +145 -23
  68. package/src/identity/hub/useIdentityHubController.ts +5 -1
  69. package/src/identity/hub/useIdentityHubSideEffects.ts +2 -4
  70. package/src/mcp/manager.ts +1 -1
  71. package/src/models/ModelPicker.tsx +89 -84
  72. package/src/models/llamacpp.ts +160 -11
  73. package/src/models/llamacppPreflight.ts +1 -16
  74. package/src/models/modelPickerOptions.ts +43 -37
  75. package/src/providers/contracts.ts +1 -0
  76. package/src/providers/openai-chat.ts +50 -9
  77. package/src/providers/openai-responses.ts +19 -4
  78. package/src/runtime/toolExecution.ts +4 -3
  79. package/src/runtime/turn.ts +61 -30
  80. package/src/tools/changeDirectoryTool.ts +1 -1
  81. package/src/tools/contracts.ts +10 -0
  82. package/src/tools/deleteFileTool.ts +1 -1
  83. package/src/tools/editTool.ts +1 -1
  84. package/src/tools/listDirectoryTool.ts +1 -1
  85. package/src/tools/listSkillFilesTool.ts +77 -0
  86. package/src/tools/listSkillsTool.ts +68 -0
  87. package/src/tools/mcpResourceTools.ts +2 -2
  88. package/src/tools/privateContinuityReadTool.ts +1 -1
  89. package/src/tools/readSkillTool.ts +107 -0
  90. package/src/tools/readTool.ts +1 -1
  91. package/src/tools/registry.ts +6 -0
  92. package/src/tools/writeFileTool.ts +22 -2
  93. package/src/ui/Spinner.tsx +1 -1
  94. package/src/identity/continuity/localBackup.ts +0 -249
  95. package/src/identity/continuity/zipWriter.ts +0 -95
  96. package/src/identity/hub/continuity/index.ts +0 -7
  97. package/src/identity/hub/ens/index.ts +0 -11
  98. 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
+ }