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
@@ -72,16 +72,16 @@ export const PermissionPrompt: React.FC<PermissionPromptProps> = ({ request, onD
72
72
  export function permissionOptionsForRequest(request: PermissionRequest): Array<{ value: PermissionDecision; label: string; hint?: string; disabled?: boolean }> {
73
73
  if (request.kind === 'bash') {
74
74
  return [
75
- { value: 'allow-once', label: 'Allow Once', hint: 'Approve only this command execution' },
75
+ { value: 'allow-once', label: 'Allow once', hint: 'Approve only this command execution' },
76
76
  {
77
77
  value: 'allow-command-project',
78
- label: 'Allow Exact Command',
78
+ label: 'Allow exact command',
79
79
  hint: 'Remember this exact command for this project',
80
80
  disabled: !request.canPersistExact,
81
81
  },
82
82
  {
83
83
  value: 'allow-command-prefix-project',
84
- label: request.commandPrefix ? `Allow ${request.commandPrefix} Commands` : 'Allow Command Family',
84
+ label: request.commandPrefix ? `Allow ${request.commandPrefix} commands` : 'Allow command family',
85
85
  hint: 'Remember this base command in this working directory for this project',
86
86
  disabled: !request.canPersistPrefix,
87
87
  },
@@ -91,63 +91,63 @@ export function permissionOptionsForRequest(request: PermissionRequest): Array<{
91
91
 
92
92
  if (request.kind === 'mcp') {
93
93
  const risk = request.destructive
94
- ? 'server marks this tool as destructive'
94
+ ? 'Server marks this tool as destructive'
95
95
  : request.openWorld
96
- ? 'server marks this tool as open-world'
96
+ ? 'Server marks this tool as open-world'
97
97
  : request.readOnly
98
- ? 'server marks this tool as read-only'
99
- : 'server did not mark this tool read-only'
98
+ ? 'Server marks this tool as read-only'
99
+ : 'Server did not mark this tool read-only'
100
100
  return [
101
- { value: 'allow-once', label: 'allow once', hint: risk },
102
- { value: 'allow-mcp-tool-project', label: 'always allow this MCP tool', hint: request.toolKey },
101
+ { value: 'allow-once', label: 'Allow once', hint: risk },
102
+ { value: 'allow-mcp-tool-project', label: 'Always allow this MCP tool', hint: request.toolKey },
103
103
  {
104
104
  value: 'allow-mcp-server-project',
105
- label: `always allow ${request.serverName}`,
106
- hint: 'remember all tools from this MCP server for this project',
105
+ label: `Always allow ${request.serverName}`,
106
+ hint: 'Remember all tools from this MCP server for this project',
107
107
  disabled: !request.canPersistServer,
108
108
  },
109
- { value: 'deny', label: 'deny', hint: 'return a denial back to the model' },
109
+ { value: 'deny', label: 'Deny', hint: 'Return a denial back to the model' },
110
110
  ]
111
111
  }
112
112
 
113
113
  if (request.kind === 'delete') {
114
114
  return [
115
- { value: 'allow-once', label: 'delete this file', hint: 'approve this deletion only' },
116
- { value: 'deny', label: 'deny', hint: 'keep the file unchanged' },
115
+ { value: 'allow-once', label: 'Delete this file', hint: 'Approve this deletion only' },
116
+ { value: 'deny', label: 'Deny', hint: 'Keep the file unchanged' },
117
117
  ]
118
118
  }
119
119
 
120
120
  if (request.kind === 'private-continuity-read') {
121
121
  return [
122
- { value: 'allow-once', label: 'allow once', hint: `read ${request.file}` },
123
- { value: 'deny', label: 'deny', hint: 'keep private continuity hidden' },
122
+ { value: 'allow-once', label: 'Allow once', hint: `Read ${request.file}` },
123
+ { value: 'deny', label: 'Deny', hint: 'Keep private continuity hidden' },
124
124
  ]
125
125
  }
126
126
 
127
127
  if (request.kind === 'private-continuity-edit') {
128
128
  return [
129
- { value: 'allow-once', label: 'Approve Once', hint: `Apply this edit to ${request.file}` },
129
+ { value: 'allow-once', label: 'Approve once', hint: `Apply this edit to ${request.file}` },
130
130
  { value: 'deny', label: 'Deny', hint: 'Keep private continuity unchanged' },
131
131
  ]
132
132
  }
133
133
 
134
134
  return [
135
- { value: 'allow-once', label: 'allow once', hint: 'approve only this action' },
136
- { value: 'allow-path-project', label: 'always allow this file', hint: request.relativePath },
137
- { value: 'allow-directory-project', label: 'always allow this folder', hint: request.directoryPath },
135
+ { value: 'allow-once', label: 'Allow once', hint: 'Approve only this action' },
136
+ { value: 'allow-path-project', label: 'Always allow this file', hint: request.relativePath },
137
+ { value: 'allow-directory-project', label: 'Always allow this folder', hint: request.directoryPath },
138
138
  {
139
139
  value: 'allow-kind-project',
140
140
  label:
141
141
  request.kind === 'edit'
142
- ? 'always allow edits'
142
+ ? 'Always allow edits'
143
143
  : request.kind === 'write'
144
- ? 'always allow writes'
144
+ ? 'Always allow writes'
145
145
  : request.kind === 'cd'
146
- ? 'always allow directory changes'
147
- : 'always allow reads',
148
- hint: 'remember this tool kind for this project',
146
+ ? 'Always allow directory changes'
147
+ : 'Always allow reads',
148
+ hint: 'Remember this tool kind for this project',
149
149
  },
150
- { value: 'deny', label: 'deny', hint: 'return a denial back to the model' },
150
+ { value: 'deny', label: 'Deny', hint: 'Return a denial back to the model' },
151
151
  ]
152
152
  }
153
153
 
@@ -118,18 +118,24 @@ export const PermissionsView: React.FC<PermissionsViewProps> = ({
118
118
  }
119
119
 
120
120
  function buildOptions(rules: SessionPermissionRule[]): Array<SelectOption<SessionPermissionRule | typeof CLEAR_ALL_VALUE>> {
121
- return [
122
- ...rules.map(rule => ({
123
- value: rule,
124
- label: describeRule(rule),
125
- hint: describeRuleScope(rule),
126
- })),
127
- {
128
- value: CLEAR_ALL_VALUE,
129
- label: 'Remove all saved rules',
130
- hint: 'Clear all remembered permissions for this project',
131
- },
132
- ]
121
+ const out: Array<SelectOption<SessionPermissionRule | typeof CLEAR_ALL_VALUE>> = []
122
+ if (rules.length > 0) {
123
+ out.push({ value: CLEAR_ALL_VALUE, role: 'section', label: 'Saved Rules' })
124
+ for (const rule of rules) {
125
+ out.push({
126
+ value: rule,
127
+ label: describeRule(rule),
128
+ hint: describeRuleScope(rule),
129
+ })
130
+ }
131
+ }
132
+ out.push({ value: CLEAR_ALL_VALUE, role: 'section', label: 'Manage' })
133
+ out.push({
134
+ value: CLEAR_ALL_VALUE,
135
+ label: 'Remove all saved rules',
136
+ hint: 'Clear all remembered permissions for this project',
137
+ })
138
+ return out
133
139
  }
134
140
 
135
141
  function describeRule(rule: SessionPermissionRule): string {
@@ -142,11 +142,13 @@ export const RewindView: React.FC<RewindViewProps> = ({
142
142
  const canRestoreCode = selectedRow.entries.length > 0
143
143
 
144
144
  const actionOptions: Array<SelectOption<ConfirmOption>> = [
145
+ { value: 'both', role: 'section', label: 'Restore' },
145
146
  { value: 'both', prefix: '1.', label: 'Restore code and conversation', disabled: !canRestoreCode },
146
147
  { value: 'conversation', prefix: '2.', label: 'Restore conversation' },
147
148
  { value: 'code', prefix: '3.', label: 'Restore code', disabled: !canRestoreCode },
148
149
  { value: 'summarize', prefix: '4.', label: 'Summarize from here' },
149
- { value: 'nevermind', prefix: '5.', label: 'Never mind' },
150
+ { value: 'nevermind', role: 'section', label: 'Navigation' },
151
+ { value: 'nevermind', prefix: '5.', label: 'Never mind', role: 'utility' },
150
152
  ]
151
153
  const defaultValue: ConfirmOption = canRestoreCode ? 'both' : 'conversation'
152
154
  const initialIndex = actionOptions.findIndex(option => option.value === defaultValue && !option.disabled)
@@ -5,6 +5,8 @@ import { Select } from '../ui/Select.js'
5
5
  import { theme } from '../ui/theme.js'
6
6
  import type { FactoryResetPlan } from '../storage/factoryReset.js'
7
7
 
8
+ type SectionTone = 'destructive' | 'safe' | 'untouched'
9
+
8
10
  export const ResetConfirmView: React.FC<{
9
11
  plan: FactoryResetPlan
10
12
  onDone: (confirmed: boolean) => void
@@ -16,17 +18,22 @@ export const ResetConfirmView: React.FC<{
16
18
  }
17
19
 
18
20
  return (
19
- <Surface title="Reset Local Data?" subtitle="Deletes this machine's ethagent data. Models and onchain records stay." footer="enter select · esc cancel">
21
+ <Surface
22
+ title="Reset Local Data?"
23
+ subtitle="Deletes this machine's ethagent data. Models and onchain records stay."
24
+ footer="enter select · esc cancel"
25
+ tone="error"
26
+ >
20
27
  <Box flexDirection="column">
21
- <Section title="Deletes" lines={[
28
+ <Section tone="destructive" title="Deletes" lines={[
22
29
  'Identity files, sessions, history, credentials',
23
30
  localDataLine(plan.deletePaths.length),
24
31
  ]} />
25
- <Section title="Keeps" lines={[
32
+ <Section tone="safe" title="Keeps" lines={[
26
33
  'Local GGUF models and llama.cpp runners',
27
34
  ...(plan.preservedPaths.length > 0 ? [`${plan.preservedPaths.length} local model path${plan.preservedPaths.length === 1 ? '' : 's'}`] : ['No local model assets found']),
28
35
  ]} />
29
- <Section title="Not Touched" lines={[
36
+ <Section tone="untouched" title="Not Touched" lines={[
30
37
  'ERC-8004 tokens and onchain records',
31
38
  'IPFS snapshots and public metadata',
32
39
  ]} />
@@ -34,9 +41,11 @@ export const ResetConfirmView: React.FC<{
34
41
  <Box marginTop={1}>
35
42
  <Select<'confirm' | 'cancel'>
36
43
  options={[
37
- { value: 'confirm', label: 'Reset Local Data', hint: 'Delete local ethagent data now' },
38
- { value: 'cancel', label: 'Cancel', hint: 'Leave local data unchanged' },
44
+ { value: 'confirm', label: 'Yes, reset local data', hint: 'Delete local ethagent data on this machine', bold: true },
45
+ { value: 'cancel', label: 'No, cancel', hint: 'Leave local data unchanged', role: 'utility' },
39
46
  ]}
47
+ hintLayout="inline"
48
+ initialIndex={1}
40
49
  onSubmit={choice => finish(choice === 'confirm')}
41
50
  onCancel={() => finish(false)}
42
51
  />
@@ -45,15 +54,21 @@ export const ResetConfirmView: React.FC<{
45
54
  )
46
55
  }
47
56
 
48
- const Section: React.FC<{ title: string; lines: string[] }> = ({ title, lines }) => (
57
+ const Section: React.FC<{ tone: SectionTone; title: string; lines: string[] }> = ({ tone, title, lines }) => (
49
58
  <Box flexDirection="column" marginBottom={1}>
50
- <Text color={theme.accentPeriwinkle}>{title}</Text>
59
+ <Text color={sectionTitleColor(tone)}>{title}</Text>
51
60
  {lines.map(line => (
52
- <Text key={line} color={theme.textSubtle}>- {line}</Text>
61
+ <Text key={line} color={theme.textSubtle} {line}</Text>
53
62
  ))}
54
63
  </Box>
55
64
  )
56
65
 
66
+ function sectionTitleColor(tone: SectionTone): string {
67
+ if (tone === 'destructive') return theme.accentError
68
+ if (tone === 'safe') return theme.accentPeriwinkle
69
+ return theme.dim
70
+ }
71
+
57
72
  function localDataLine(count: number): string {
58
73
  if (count === 0) return 'No local ethagent data found'
59
74
  return `${count} local path${count === 1 ? '' : 's'} under ~/.ethagent`
@@ -12,9 +12,20 @@ type EditorCommand = {
12
12
  shell?: boolean
13
13
  }
14
14
 
15
- export function openFileInEditor(file: string): Promise<EditorOpenResult> {
15
+ export async function openFileInEditor(file: string): Promise<EditorOpenResult> {
16
+ if (isVscodeEnvironment()) {
17
+ const vscode = vscodeEditorCommand(file)
18
+ const result = await openEditorCommand(vscode)
19
+ if (result.ok) return result
20
+ }
16
21
  const command = defaultEditorCommand(file)
17
- if (!command) return Promise.resolve({ ok: false, error: 'no default open command for this platform' })
22
+ if (!command) return { ok: false, error: 'no default open command for this platform' }
23
+ return openEditorCommand(command)
24
+ }
25
+
26
+ export async function openInFileManager(target: string): Promise<EditorOpenResult> {
27
+ const command = defaultEditorCommand(target)
28
+ if (!command) return { ok: false, error: 'no default open command for this platform' }
18
29
  return openEditorCommand(command)
19
30
  }
20
31
 
@@ -47,3 +58,17 @@ export function defaultEditorCommand(file: string, platform: NodeJS.Platform = p
47
58
  if (platform === 'darwin') return { cmd: 'open', args: [file], method: 'open', waited: false }
48
59
  return { cmd: 'xdg-open', args: [file], method: 'xdg-open', waited: false }
49
60
  }
61
+
62
+ export function isVscodeEnvironment(env: NodeJS.ProcessEnv = process.env): boolean {
63
+ if (env.TERM_PROGRAM === 'vscode') return true
64
+ if (env.VSCODE_PID) return true
65
+ if (env.VSCODE_GIT_IPC_HANDLE) return true
66
+ return false
67
+ }
68
+
69
+ export function vscodeEditorCommand(file: string, platform: NodeJS.Platform = process.platform): EditorCommand {
70
+ if (platform === 'win32') {
71
+ return { cmd: 'cmd', args: ['/c', 'code', '--reuse-window', file], method: 'vscode', waited: false }
72
+ }
73
+ return { cmd: 'code', args: ['--reuse-window', file], method: 'vscode', waited: false }
74
+ }
@@ -10,6 +10,16 @@ export type ContinuityFiles = {
10
10
  'MEMORY.md': string
11
11
  }
12
12
 
13
+ export type ContinuitySkillsTree = Record<string, string>
14
+
15
+ const PRIVATE_SKILL_FILE_RE = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+$/
16
+ const PRIVATE_SKILL_LAST_SEG_FILE_RE = /^[A-Za-z0-9._-]+\.[A-Za-z0-9]+$/
17
+ const LEGACY_NESTED_SKILL_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+\/.+$/
18
+ const LEGACY_FLAT_NAME_MD_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+\.md$/i
19
+ const MAX_PRIVATE_SKILL_ENTRIES = 500
20
+ const MAX_PRIVATE_SKILL_BODY_BYTES = 256 * 1024
21
+ const MAX_PRIVATE_SKILL_PATH_LEN = 256
22
+
13
23
  type ContinuityTranscriptSummary = {
14
24
  sessionId?: string
15
25
  createdAt?: string
@@ -33,6 +43,7 @@ type ContinuitySnapshotPayload = {
33
43
  sequence?: number
34
44
  agent: ContinuityAgentSnapshot
35
45
  files: ContinuityFiles
46
+ skills?: ContinuitySkillsTree
36
47
  transcript: ContinuityTranscriptSummary[]
37
48
  state: Record<string, unknown>
38
49
  }
@@ -997,6 +1008,7 @@ function continuityPayloadFromArgs(args: {
997
1008
  createdAt: string
998
1009
  payload: Omit<ContinuitySnapshotPayload, 'version' | 'ownerAddress' | 'createdAt'> & { createdAt?: string }
999
1010
  }): ContinuitySnapshotPayload {
1011
+ const skills = normalizeContinuitySkills(args.payload.skills)
1000
1012
  return {
1001
1013
  version: 1,
1002
1014
  ownerAddress: args.ownerAddress,
@@ -1004,6 +1016,7 @@ function continuityPayloadFromArgs(args: {
1004
1016
  ...(args.payload.sequence !== undefined ? { sequence: args.payload.sequence } : {}),
1005
1017
  agent: normalizeAgentSnapshot(args.payload.agent),
1006
1018
  files: normalizeContinuityFiles(args.payload.files),
1019
+ ...(skills ? { skills } : {}),
1007
1020
  transcript: normalizeTranscript(args.payload.transcript),
1008
1021
  state: normalizeState(args.payload.state),
1009
1022
  }
@@ -1015,6 +1028,7 @@ function normalizeContinuityPayload(input: unknown): ContinuitySnapshotPayload {
1015
1028
  if (obj.version !== 1) throw new Error('Continuity snapshot payload version is invalid')
1016
1029
  if (typeof obj.ownerAddress !== 'string') throw new Error('Continuity snapshot owner is invalid')
1017
1030
  if (typeof obj.createdAt !== 'string') throw new Error('Continuity snapshot timestamp is invalid')
1031
+ const skills = normalizeContinuitySkills(obj.skills)
1018
1032
  return {
1019
1033
  version: 1,
1020
1034
  ownerAddress: toChecksumAddress(obj.ownerAddress),
@@ -1022,11 +1036,122 @@ function normalizeContinuityPayload(input: unknown): ContinuitySnapshotPayload {
1022
1036
  ...(typeof obj.sequence === 'number' && Number.isSafeInteger(obj.sequence) ? { sequence: obj.sequence } : {}),
1023
1037
  agent: normalizeAgentSnapshot(obj.agent),
1024
1038
  files: normalizeContinuityFiles(obj.files),
1039
+ ...(skills ? { skills } : {}),
1025
1040
  transcript: normalizeTranscript(obj.transcript),
1026
1041
  state: normalizeState(obj.state),
1027
1042
  }
1028
1043
  }
1029
1044
 
1045
+ export function normalizeContinuitySkills(input: unknown): ContinuitySkillsTree | undefined {
1046
+ if (input === undefined || input === null) return undefined
1047
+ if (typeof input !== 'object' || Array.isArray(input)) return undefined
1048
+ const obj = input as Record<string, unknown>
1049
+ const out: ContinuitySkillsTree = {}
1050
+ let count = 0
1051
+ const tryInsert = (key: string, rawValue: unknown): void => {
1052
+ if (count >= MAX_PRIVATE_SKILL_ENTRIES) return
1053
+ if (typeof rawValue !== 'string') return
1054
+ if (key.length === 0 || key.length > MAX_PRIVATE_SKILL_PATH_LEN) return
1055
+ if (key.includes('\0')) return
1056
+ if (key.includes('..')) return
1057
+ if (key.startsWith('/')) return
1058
+ if (/^[A-Za-z]:/.test(key)) return
1059
+ if (!isAcceptableSkillKey(key)) return
1060
+ if (Buffer.byteLength(rawValue, 'utf8') > MAX_PRIVATE_SKILL_BODY_BYTES) return
1061
+ if (out[key] !== undefined) return
1062
+ out[key] = rawValue
1063
+ count++
1064
+ }
1065
+ const legacyRoots = new Set<string>()
1066
+ const realSkillFolders = new Set<string>()
1067
+ for (const rawKey of Object.keys(obj)) {
1068
+ const key = rawKey.replace(/\\/g, '/')
1069
+ const segments = key.split('/')
1070
+ if (segments.length === 3 && segments[2] === 'SKILL.md' && segments[0] && segments[1]) {
1071
+ legacyRoots.add(`${segments[0]}/${segments[1]}`)
1072
+ }
1073
+ if (segments.length === 2 && segments[1] === 'SKILL.md' && segments[0]) {
1074
+ realSkillFolders.add(segments[0])
1075
+ }
1076
+ }
1077
+ for (const [rawKey, rawValue] of Object.entries(obj)) {
1078
+ const key = rawKey.replace(/\\/g, '/')
1079
+ if (!isCanonicalFlatKey(key)) continue
1080
+ if (isUnderLegacyRoot(key, legacyRoots)) continue
1081
+ if (!keyHasRealSkillFolder(key, realSkillFolders)) continue
1082
+ tryInsert(key, rawValue)
1083
+ }
1084
+ for (const [rawKey, rawValue] of Object.entries(obj)) {
1085
+ const key = rawKey.replace(/\\/g, '/')
1086
+ if (
1087
+ isCanonicalFlatKey(key)
1088
+ && !isUnderLegacyRoot(key, legacyRoots)
1089
+ && keyHasRealSkillFolder(key, realSkillFolders)
1090
+ ) continue
1091
+ const upgraded = upgradeLegacySkillKey(key, legacyRoots)
1092
+ if (!upgraded) continue
1093
+ tryInsert(upgraded, rawValue)
1094
+ }
1095
+ return count > 0 ? out : undefined
1096
+ }
1097
+
1098
+ function isUnderLegacyRoot(key: string, legacyRoots: Set<string>): boolean {
1099
+ for (const root of legacyRoots) {
1100
+ if (key === `${root}/SKILL.md`) return true
1101
+ if (key.startsWith(`${root}/`)) return true
1102
+ }
1103
+ return false
1104
+ }
1105
+
1106
+ function keyHasRealSkillFolder(key: string, realSkillFolders: Set<string>): boolean {
1107
+ const first = key.split('/')[0]
1108
+ if (!first) return false
1109
+ return realSkillFolders.has(first)
1110
+ }
1111
+
1112
+ function isCanonicalFlatKey(key: string): boolean {
1113
+ if (!PRIVATE_SKILL_FILE_RE.test(key)) return false
1114
+ const segments = key.split('/')
1115
+ if (segments.length < 2) return false
1116
+ const last = segments[segments.length - 1]!
1117
+ if (last === 'SKILL.md') return segments.length === 2
1118
+ return PRIVATE_SKILL_LAST_SEG_FILE_RE.test(last)
1119
+ }
1120
+
1121
+ function isAcceptableSkillKey(key: string): boolean {
1122
+ return isCanonicalFlatKey(key)
1123
+ }
1124
+
1125
+ function upgradeLegacySkillKey(key: string, legacyRoots: Set<string>): string | null {
1126
+ for (const root of legacyRoots) {
1127
+ if (key === `${root}/SKILL.md` || key.startsWith(`${root}/`)) {
1128
+ const [first, second] = root.split('/')
1129
+ if (!first || !second) continue
1130
+ const rest = key.slice(root.length + 1)
1131
+ const flattened = `${first}-${second}/${rest}`
1132
+ return isCanonicalFlatKey(flattened) ? flattened : null
1133
+ }
1134
+ }
1135
+ if (LEGACY_FLAT_NAME_MD_RE.test(key)) {
1136
+ const [category, file] = key.split('/')
1137
+ if (!category || !file) return null
1138
+ const slug = file.replace(/\.md$/i, '')
1139
+ if (!slug) return null
1140
+ const flattened = `${category}-${slug}/SKILL.md`
1141
+ return isCanonicalFlatKey(flattened) ? flattened : null
1142
+ }
1143
+ if (LEGACY_NESTED_SKILL_RE.test(key)) {
1144
+ const segments = key.split('/')
1145
+ if (segments.length < 3) return null
1146
+ const [first, second, ...rest] = segments
1147
+ if (!first || !second || rest.length === 0) return null
1148
+ const flattened = `${first}-${second}/${rest.join('/')}`
1149
+ return isCanonicalFlatKey(flattened) ? flattened : null
1150
+ }
1151
+ if (isCanonicalFlatKey(key)) return key
1152
+ return null
1153
+ }
1154
+
1030
1155
  function normalizeAgentSnapshot(input: unknown): ContinuityAgentSnapshot {
1031
1156
  if (!input || typeof input !== 'object' || Array.isArray(input)) return {}
1032
1157
  const obj = input as Record<string, unknown>
@@ -1,4 +1,5 @@
1
1
  import type { EthagentIdentity } from '../../storage/config.js'
2
+ import type { SkillIndexEntry } from './skills/types.js'
2
3
 
3
4
  type PublicSkill = {
4
5
  id: string
@@ -45,7 +46,7 @@ export function defaultPublicSkillsProfile(identity: EthagentIdentity): PublicSk
45
46
  : identity.agentId ? `ethagent #${identity.agentId}` : 'ethagent'
46
47
  const description = typeof state.description === 'string' && state.description.trim()
47
48
  ? state.description.trim()
48
- : 'A wallet-owned AI coding agent.'
49
+ : 'privacy-first AI agent with a portable Ethereum identity'
49
50
  const imageUrl = typeof state.imageUrl === 'string' && state.imageUrl.trim()
50
51
  ? state.imageUrl.trim()
51
52
  : undefined
@@ -80,6 +81,41 @@ export function defaultPublicSkillsProfile(identity: EthagentIdentity): PublicSk
80
81
  }
81
82
  }
82
83
 
84
+ export function appendPublicSkillEntries(
85
+ profile: PublicSkillsProfile,
86
+ entries: readonly SkillIndexEntry[],
87
+ ): PublicSkillsProfile {
88
+ if (entries.length === 0) return profile
89
+ const baselineIds = new Set(profile.skills.map(skill => skill.id))
90
+ const appended: PublicSkill[] = []
91
+ const usedIds = new Set(baselineIds)
92
+ for (const entry of entries) {
93
+ if (entry.visibility !== 'public' && entry.visibility !== 'discoverable') continue
94
+ const id = uniqueSkillId(entry.name, usedIds)
95
+ usedIds.add(id)
96
+ appended.push({
97
+ id,
98
+ name: entry.displayName ?? entry.name,
99
+ description: entry.description || entry.name,
100
+ inputModes: ['text/markdown'],
101
+ outputModes: ['text/markdown'],
102
+ })
103
+ }
104
+ if (appended.length === 0) return profile
105
+ return {
106
+ ...profile,
107
+ skills: [...profile.skills, ...appended],
108
+ }
109
+ }
110
+
111
+ function uniqueSkillId(base: string, used: Set<string>): string {
112
+ const slug = base.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'skill'
113
+ if (!used.has(slug)) return slug
114
+ let i = 2
115
+ while (used.has(`${slug}-${i}`)) i++
116
+ return `${slug}-${i}`
117
+ }
118
+
83
119
  export function renderPublicSkillsJson(profile: PublicSkillsProfile): string {
84
120
  const inputModes = unique(profile.skills.flatMap(skill => skill.inputModes))
85
121
  const outputModes = unique(profile.skills.flatMap(skill => skill.outputModes))