ethagent 3.0.2 → 3.1.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 +6 -1
- package/package.json +3 -1
- package/src/app/FirstRun.tsx +1 -24
- package/src/app/firstRunConfig.ts +26 -0
- package/src/auth/openaiOAuth/landingPage.ts +2 -11
- package/src/chat/ChatScreen.tsx +15 -116
- package/src/chat/MessageList.tsx +18 -260
- package/src/chat/chatEnvironment.ts +16 -0
- package/src/chat/chatTurnContext.ts +50 -0
- package/src/chat/chatTurnOrchestrator.ts +5 -112
- package/src/chat/chatTurnRows.ts +64 -0
- package/src/chat/commands.ts +3 -178
- package/src/chat/continuityEditReview.ts +42 -0
- package/src/chat/input/ChatInput.tsx +10 -144
- package/src/chat/input/chatInputHelpers.ts +62 -0
- package/src/chat/input/inputRendering.tsx +93 -0
- package/src/chat/messageMarkdown.ts +220 -0
- package/src/chat/messageRows.ts +43 -0
- package/src/chat/planImplementation.ts +62 -0
- package/src/chat/slashCommandHandlers.ts +165 -0
- package/src/chat/slashCommandViews.ts +120 -0
- package/src/identity/continuity/challenges.ts +123 -0
- package/src/identity/continuity/envelope.ts +49 -1484
- package/src/identity/continuity/envelopeCreate.ts +322 -0
- package/src/identity/continuity/envelopeCrypto.ts +182 -0
- package/src/identity/continuity/envelopeParse.ts +441 -0
- package/src/identity/continuity/envelopeTypes.ts +204 -0
- package/src/identity/continuity/envelopeVersion.ts +1 -0
- package/src/identity/continuity/payloadNormalization.ts +183 -0
- package/src/identity/continuity/skills/loadSkills.ts +12 -69
- package/src/identity/continuity/skills/skillPaths.ts +76 -0
- package/src/identity/continuity/skillsNormalization.ts +119 -0
- package/src/identity/continuity/snapshotToken.ts +28 -0
- package/src/identity/hub/continuity/completion.ts +67 -0
- package/src/identity/hub/continuity/effects.ts +5 -62
- package/src/identity/hub/profile/effects.ts +6 -170
- package/src/identity/hub/profile/operatorSave.ts +202 -0
- package/src/identity/wallet/browserWallet/html.ts +1 -57
- package/src/identity/wallet/browserWallet/walletPageSource.ts +85 -0
- package/src/identity/wallet/page/controller.ts +1 -1
- package/src/identity/wallet/page/errorView.ts +122 -0
- package/src/identity/wallet/page/view.ts +3 -114
- package/src/mcp/manager.ts +8 -66
- package/src/mcp/managerHelpers.ts +70 -0
- package/src/models/ModelPicker.tsx +69 -889
- package/src/models/huggingface.ts +20 -137
- package/src/models/huggingfaceStorage.ts +136 -0
- package/src/models/llamacpp.ts +37 -303
- package/src/models/llamacppCommands.ts +44 -0
- package/src/models/llamacppConfig.ts +34 -0
- package/src/models/llamacppDiscovery.ts +176 -0
- package/src/models/llamacppOutput.ts +65 -0
- package/src/models/modelPickerCatalogFlow.ts +56 -0
- package/src/models/modelPickerCredentials.ts +166 -0
- package/src/models/modelPickerData.ts +41 -0
- package/src/models/modelPickerDisplay.tsx +132 -0
- package/src/models/modelPickerHfFlow.ts +192 -0
- package/src/models/modelPickerLocalRunnerFlow.ts +115 -0
- package/src/models/modelPickerTypes.ts +69 -0
- package/src/models/modelPickerUninstallFlow.ts +48 -0
- package/src/models/modelPickerViewHelpers.ts +174 -0
- package/src/providers/openai-chat.ts +5 -124
- package/src/providers/openaiChatWire.ts +124 -0
- package/src/runtime/providerTurn.ts +38 -0
- package/src/runtime/textToolParser.ts +161 -0
- package/src/runtime/toolIntent.ts +1 -1
- package/src/runtime/turn.ts +43 -499
- package/src/runtime/turnNudges.ts +223 -0
- package/src/runtime/turnTypes.ts +86 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { toChecksumAddress } from '../crypto/eth.js'
|
|
2
|
+
import { normalizeContinuitySkills } from './skillsNormalization.js'
|
|
3
|
+
import type {
|
|
4
|
+
ContinuityAgentSnapshot,
|
|
5
|
+
ContinuityFiles,
|
|
6
|
+
ContinuitySnapshotPayload,
|
|
7
|
+
TransferContinuitySnapshotSlot,
|
|
8
|
+
WalletContinuityRestoreAccessKey,
|
|
9
|
+
WalletContinuitySnapshotSlot,
|
|
10
|
+
} from './envelope.js'
|
|
11
|
+
|
|
12
|
+
type ContinuityTranscriptSummary = {
|
|
13
|
+
sessionId?: string
|
|
14
|
+
createdAt?: string
|
|
15
|
+
summary: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function continuityPayloadFromArgs(args: {
|
|
19
|
+
ownerAddress: string
|
|
20
|
+
createdAt: string
|
|
21
|
+
payload: Omit<ContinuitySnapshotPayload, 'version' | 'ownerAddress' | 'createdAt'> & { createdAt?: string }
|
|
22
|
+
}): ContinuitySnapshotPayload {
|
|
23
|
+
const skills = normalizeContinuitySkills(args.payload.skills)
|
|
24
|
+
return {
|
|
25
|
+
version: 1,
|
|
26
|
+
ownerAddress: args.ownerAddress,
|
|
27
|
+
createdAt: args.createdAt,
|
|
28
|
+
...(args.payload.sequence !== undefined ? { sequence: args.payload.sequence } : {}),
|
|
29
|
+
agent: normalizeAgentSnapshot(args.payload.agent),
|
|
30
|
+
files: normalizeContinuityFiles(args.payload.files),
|
|
31
|
+
...(skills ? { skills } : {}),
|
|
32
|
+
transcript: normalizeTranscript(args.payload.transcript),
|
|
33
|
+
state: normalizeState(args.payload.state),
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function normalizeContinuityPayload(input: unknown): ContinuitySnapshotPayload {
|
|
38
|
+
if (!input || typeof input !== 'object') throw new Error('Continuity snapshot payload is invalid')
|
|
39
|
+
const obj = input as Partial<ContinuitySnapshotPayload>
|
|
40
|
+
if (obj.version !== 1) throw new Error('Continuity snapshot payload version is invalid')
|
|
41
|
+
if (typeof obj.ownerAddress !== 'string') throw new Error('Continuity snapshot owner is invalid')
|
|
42
|
+
if (typeof obj.createdAt !== 'string') throw new Error('Continuity snapshot timestamp is invalid')
|
|
43
|
+
const skills = normalizeContinuitySkills(obj.skills)
|
|
44
|
+
return {
|
|
45
|
+
version: 1,
|
|
46
|
+
ownerAddress: toChecksumAddress(obj.ownerAddress),
|
|
47
|
+
createdAt: obj.createdAt,
|
|
48
|
+
...(typeof obj.sequence === 'number' && Number.isSafeInteger(obj.sequence) ? { sequence: obj.sequence } : {}),
|
|
49
|
+
agent: normalizeAgentSnapshot(obj.agent),
|
|
50
|
+
files: normalizeContinuityFiles(obj.files),
|
|
51
|
+
...(skills ? { skills } : {}),
|
|
52
|
+
transcript: normalizeTranscript(obj.transcript),
|
|
53
|
+
state: normalizeState(obj.state),
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function normalizeAgentSnapshot(input: unknown): ContinuityAgentSnapshot {
|
|
58
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) return {}
|
|
59
|
+
const obj = input as Record<string, unknown>
|
|
60
|
+
return {
|
|
61
|
+
...(typeof obj.chainId === 'number' && Number.isSafeInteger(obj.chainId) && obj.chainId > 0 ? { chainId: obj.chainId } : {}),
|
|
62
|
+
...(typeof obj.identityRegistryAddress === 'string' ? { identityRegistryAddress: obj.identityRegistryAddress } : {}),
|
|
63
|
+
...(typeof obj.agentId === 'string' ? { agentId: obj.agentId } : {}),
|
|
64
|
+
...(typeof obj.agentUri === 'string' ? { agentUri: obj.agentUri } : {}),
|
|
65
|
+
...(typeof obj.metadataCid === 'string' ? { metadataCid: obj.metadataCid } : {}),
|
|
66
|
+
...(typeof obj.name === 'string' ? { name: obj.name } : {}),
|
|
67
|
+
...(typeof obj.description === 'string' ? { description: obj.description } : {}),
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function normalizeContinuityFiles(input: unknown): ContinuityFiles {
|
|
72
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
73
|
+
throw new Error('Continuity snapshot files are invalid')
|
|
74
|
+
}
|
|
75
|
+
const obj = input as Partial<ContinuityFiles>
|
|
76
|
+
if (typeof obj['SOUL.md'] !== 'string') throw new Error('SOUL.md is missing from continuity snapshot')
|
|
77
|
+
if (typeof obj['MEMORY.md'] !== 'string') throw new Error('MEMORY.md is missing from continuity snapshot')
|
|
78
|
+
return {
|
|
79
|
+
'SOUL.md': obj['SOUL.md'],
|
|
80
|
+
'MEMORY.md': obj['MEMORY.md'],
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function normalizeTranscript(input: unknown): ContinuityTranscriptSummary[] {
|
|
85
|
+
if (!Array.isArray(input)) return []
|
|
86
|
+
return input.flatMap(item => {
|
|
87
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) return []
|
|
88
|
+
const obj = item as Partial<ContinuityTranscriptSummary>
|
|
89
|
+
if (typeof obj.summary !== 'string' || !obj.summary.trim()) return []
|
|
90
|
+
return [{
|
|
91
|
+
...(typeof obj.sessionId === 'string' ? { sessionId: obj.sessionId } : {}),
|
|
92
|
+
...(typeof obj.createdAt === 'string' ? { createdAt: obj.createdAt } : {}),
|
|
93
|
+
summary: obj.summary,
|
|
94
|
+
}]
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function normalizeState(input: unknown): Record<string, unknown> {
|
|
99
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) return {}
|
|
100
|
+
return input as Record<string, unknown>
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function normalizeTransferSlot(input: unknown, expectedAddress: string): TransferContinuitySnapshotSlot {
|
|
104
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
105
|
+
throw new Error('Continuity transfer slot is invalid')
|
|
106
|
+
}
|
|
107
|
+
const obj = input as Partial<TransferContinuitySnapshotSlot>
|
|
108
|
+
if (typeof obj.address !== 'string') throw new Error('Continuity transfer slot address is invalid')
|
|
109
|
+
const address = toChecksumAddress(obj.address)
|
|
110
|
+
if (address.toLowerCase() !== expectedAddress.toLowerCase()) {
|
|
111
|
+
throw new Error('Continuity transfer slot address mismatch')
|
|
112
|
+
}
|
|
113
|
+
if (typeof obj.challenge !== 'string') throw new Error('Continuity transfer slot challenge is invalid')
|
|
114
|
+
if (typeof obj.salt !== 'string') throw new Error('Continuity transfer slot salt is invalid')
|
|
115
|
+
if (typeof obj.nonce !== 'string') throw new Error('Continuity transfer slot nonce is invalid')
|
|
116
|
+
if (typeof obj.encryptedKey !== 'string') throw new Error('Continuity transfer slot key is invalid')
|
|
117
|
+
if (typeof obj.tag !== 'string') throw new Error('Continuity transfer slot tag is invalid')
|
|
118
|
+
return {
|
|
119
|
+
address,
|
|
120
|
+
challenge: obj.challenge,
|
|
121
|
+
salt: obj.salt,
|
|
122
|
+
nonce: obj.nonce,
|
|
123
|
+
encryptedKey: obj.encryptedKey,
|
|
124
|
+
tag: obj.tag,
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function normalizeWalletRestoreAccessKeys(input: unknown): WalletContinuityRestoreAccessKey[] {
|
|
129
|
+
if (!Array.isArray(input)) return []
|
|
130
|
+
const out: WalletContinuityRestoreAccessKey[] = []
|
|
131
|
+
const seen = new Set<string>()
|
|
132
|
+
for (const item of input) {
|
|
133
|
+
const key = normalizeWalletRestoreAccessKey(item)
|
|
134
|
+
const dedupe = key.address.toLowerCase()
|
|
135
|
+
if (seen.has(dedupe)) continue
|
|
136
|
+
seen.add(dedupe)
|
|
137
|
+
out.push(key)
|
|
138
|
+
}
|
|
139
|
+
return out.sort((a, b) => a.address.toLowerCase().localeCompare(b.address.toLowerCase()))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function normalizeWalletRestoreAccessKey(input: unknown): WalletContinuityRestoreAccessKey {
|
|
143
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
144
|
+
throw new Error('Wallet restore access key is invalid')
|
|
145
|
+
}
|
|
146
|
+
const obj = input as Partial<WalletContinuityRestoreAccessKey>
|
|
147
|
+
if (typeof obj.address !== 'string') throw new Error('Wallet restore access address is invalid')
|
|
148
|
+
if (typeof obj.challenge !== 'string') throw new Error('Wallet restore access challenge is invalid')
|
|
149
|
+
if (typeof obj.salt !== 'string') throw new Error('Wallet restore access salt is invalid')
|
|
150
|
+
if (typeof obj.kemPublicKey !== 'string') throw new Error('Wallet restore access public key is invalid')
|
|
151
|
+
return {
|
|
152
|
+
address: toChecksumAddress(obj.address),
|
|
153
|
+
challenge: obj.challenge,
|
|
154
|
+
salt: obj.salt,
|
|
155
|
+
kemPublicKey: obj.kemPublicKey,
|
|
156
|
+
...(typeof obj.createdAt === 'string' ? { createdAt: obj.createdAt } : {}),
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function normalizeWalletSlot(input: unknown): WalletContinuitySnapshotSlot {
|
|
161
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
162
|
+
throw new Error('Continuity wallet slot is invalid')
|
|
163
|
+
}
|
|
164
|
+
const obj = input as Partial<WalletContinuitySnapshotSlot>
|
|
165
|
+
if (typeof obj.address !== 'string') throw new Error('Continuity wallet slot address is invalid')
|
|
166
|
+
if (typeof obj.challenge !== 'string') throw new Error('Continuity wallet slot challenge is invalid')
|
|
167
|
+
if (typeof obj.salt !== 'string') throw new Error('Continuity wallet slot salt is invalid')
|
|
168
|
+
if (typeof obj.kemPublicKey !== 'string') throw new Error('Continuity wallet slot public key is invalid')
|
|
169
|
+
if (typeof obj.kemCiphertext !== 'string') throw new Error('Continuity wallet slot ciphertext is invalid')
|
|
170
|
+
if (typeof obj.nonce !== 'string') throw new Error('Continuity wallet slot nonce is invalid')
|
|
171
|
+
if (typeof obj.encryptedKey !== 'string') throw new Error('Continuity wallet slot key is invalid')
|
|
172
|
+
if (typeof obj.tag !== 'string') throw new Error('Continuity wallet slot tag is invalid')
|
|
173
|
+
return {
|
|
174
|
+
address: toChecksumAddress(obj.address),
|
|
175
|
+
challenge: obj.challenge,
|
|
176
|
+
salt: obj.salt,
|
|
177
|
+
kemPublicKey: obj.kemPublicKey,
|
|
178
|
+
kemCiphertext: obj.kemCiphertext,
|
|
179
|
+
nonce: obj.nonce,
|
|
180
|
+
encryptedKey: obj.encryptedKey,
|
|
181
|
+
tag: obj.tag,
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -6,6 +6,16 @@ import { ensureContinuityVault } from '../storage/files.js'
|
|
|
6
6
|
import { continuityVaultRef } from '../storage/paths.js'
|
|
7
7
|
import { parseSkillFile } from './frontmatter.js'
|
|
8
8
|
import { defaultSkillScaffold } from './scaffold.js'
|
|
9
|
+
import {
|
|
10
|
+
isReservedWindowsSegment,
|
|
11
|
+
isValidFilenameSegment,
|
|
12
|
+
isValidSegment,
|
|
13
|
+
isValidSkillEntryKey,
|
|
14
|
+
isValidSkillFilePath,
|
|
15
|
+
isWithin,
|
|
16
|
+
MAX_FOLDER_DEPTH,
|
|
17
|
+
SKILL_FILE_NAME,
|
|
18
|
+
} from './skillPaths.js'
|
|
9
19
|
import type {
|
|
10
20
|
ContinuitySkillsTree,
|
|
11
21
|
Skill,
|
|
@@ -13,18 +23,9 @@ import type {
|
|
|
13
23
|
SkillVisibility,
|
|
14
24
|
} from './types.js'
|
|
15
25
|
|
|
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
26
|
const MAX_SKILL_ENTRIES = 200
|
|
25
27
|
const MAX_SKILL_FILE_BYTES = 256 * 1024
|
|
26
28
|
const MAX_TREE_FILES = 500
|
|
27
|
-
const MAX_FOLDER_DEPTH = 4
|
|
28
29
|
|
|
29
30
|
type IdentityKey = Pick<EthagentIdentity, 'chainId' | 'identityRegistryAddress' | 'agentId' | 'address'>
|
|
30
31
|
|
|
@@ -135,13 +136,6 @@ async function walkSkillFileStats(root: string): Promise<SkillFileStat[]> {
|
|
|
135
136
|
return out
|
|
136
137
|
}
|
|
137
138
|
|
|
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
139
|
export async function readSkill(identity: EthagentIdentity, name: string): Promise<Skill> {
|
|
146
140
|
const entries = await listSkills(identity)
|
|
147
141
|
const lookup = name.replace(/^.*:/, '').replace(/:SKILL$/i, '')
|
|
@@ -444,7 +438,7 @@ async function walkFolderFiles(
|
|
|
444
438
|
for (const ent of dirents) {
|
|
445
439
|
if (ent.isSymbolicLink()) continue
|
|
446
440
|
if (ent.name.startsWith('.')) continue
|
|
447
|
-
if (
|
|
441
|
+
if (isReservedWindowsSegment(ent.name)) continue
|
|
448
442
|
if (ent.isDirectory()) {
|
|
449
443
|
if (!isValidSegment(ent.name)) continue
|
|
450
444
|
const nextPrefix = relativePrefix ? `${relativePrefix}/${ent.name}` : ent.name
|
|
@@ -465,14 +459,6 @@ async function walkFolderFiles(
|
|
|
465
459
|
}
|
|
466
460
|
}
|
|
467
461
|
|
|
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
462
|
async function statOrNull(file: string): Promise<import('node:fs').Stats | null> {
|
|
477
463
|
try {
|
|
478
464
|
return await fs.stat(file)
|
|
@@ -563,47 +549,4 @@ async function loadSkillBody(entry: SkillIndexEntry): Promise<Skill> {
|
|
|
563
549
|
return { ...entry, body: parsed.body }
|
|
564
550
|
}
|
|
565
551
|
|
|
566
|
-
export
|
|
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
|
-
}
|
|
552
|
+
export { isValidSkillEntryKey, isValidSkillFilePath } from './skillPaths.js'
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
|
|
3
|
+
export const SKILL_FILE_NAME = 'SKILL.md'
|
|
4
|
+
export const MAX_FOLDER_DEPTH = 4
|
|
5
|
+
|
|
6
|
+
const SEGMENT_RE = /^[A-Za-z0-9._-]+$/
|
|
7
|
+
const FILE_EXT_RE = /\.[A-Za-z0-9]+$/
|
|
8
|
+
const RESERVED_WINDOWS_SEGMENTS = new Set([
|
|
9
|
+
'con', 'prn', 'aux', 'nul',
|
|
10
|
+
'com1', 'com2', 'com3', 'com4', 'com5', 'com6', 'com7', 'com8', 'com9',
|
|
11
|
+
'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9',
|
|
12
|
+
])
|
|
13
|
+
|
|
14
|
+
export function isReservedWindowsSegment(name: string): boolean {
|
|
15
|
+
return RESERVED_WINDOWS_SEGMENTS.has(name.toLowerCase())
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isValidSegment(name: string): boolean {
|
|
19
|
+
if (!name) return false
|
|
20
|
+
if (name.startsWith('.')) return false
|
|
21
|
+
if (isReservedWindowsSegment(name)) return false
|
|
22
|
+
return SEGMENT_RE.test(name)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isValidFilenameSegment(name: string): boolean {
|
|
26
|
+
if (!name) return false
|
|
27
|
+
if (name.startsWith('.')) return false
|
|
28
|
+
if (isReservedWindowsSegment(name)) return false
|
|
29
|
+
if (!SEGMENT_RE.test(name)) return false
|
|
30
|
+
return FILE_EXT_RE.test(name)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isValidSkillEntryKey(rel: string): boolean {
|
|
34
|
+
if (!rel || rel.length > 256) return false
|
|
35
|
+
if (rel.includes('\0')) return false
|
|
36
|
+
if (rel.startsWith('/') || rel.startsWith('\\')) return false
|
|
37
|
+
if (/^[a-zA-Z]:/.test(rel)) return false
|
|
38
|
+
const segments = rel.split('/')
|
|
39
|
+
if (segments.length !== 2) return false
|
|
40
|
+
const [name, filename] = segments
|
|
41
|
+
if (!name || !filename) return false
|
|
42
|
+
if (filename !== SKILL_FILE_NAME) return false
|
|
43
|
+
if (!isValidSegment(name)) return false
|
|
44
|
+
return true
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isValidSkillFilePath(rel: string): boolean {
|
|
48
|
+
if (!rel || rel.length > 256) return false
|
|
49
|
+
if (rel.includes('\0')) return false
|
|
50
|
+
if (rel.startsWith('/') || rel.startsWith('\\')) return false
|
|
51
|
+
if (/^[a-zA-Z]:/.test(rel)) return false
|
|
52
|
+
const segments = rel.split('/')
|
|
53
|
+
if (segments.length < 2) return false
|
|
54
|
+
if (segments.length > MAX_FOLDER_DEPTH + 2) return false
|
|
55
|
+
const [first, ...rest] = segments
|
|
56
|
+
if (!first || !isValidSegment(first)) return false
|
|
57
|
+
for (let i = 0; i < rest.length; i++) {
|
|
58
|
+
const seg = rest[i]
|
|
59
|
+
if (!seg) return false
|
|
60
|
+
if (i === rest.length - 1) {
|
|
61
|
+
if (seg === SKILL_FILE_NAME) continue
|
|
62
|
+
if (!isValidFilenameSegment(seg)) return false
|
|
63
|
+
} else {
|
|
64
|
+
if (!isValidSegment(seg)) return false
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return true
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function isWithin(root: string, target: string): boolean {
|
|
71
|
+
const rootResolved = path.resolve(root)
|
|
72
|
+
const targetResolved = path.resolve(target)
|
|
73
|
+
if (targetResolved === rootResolved) return true
|
|
74
|
+
const prefix = rootResolved.endsWith(path.sep) ? rootResolved : rootResolved + path.sep
|
|
75
|
+
return targetResolved.startsWith(prefix)
|
|
76
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { ContinuitySkillsTree } from './envelope.js'
|
|
2
|
+
|
|
3
|
+
const PRIVATE_SKILL_FILE_RE = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+$/
|
|
4
|
+
const PRIVATE_SKILL_LAST_SEG_FILE_RE = /^[A-Za-z0-9._-]+\.[A-Za-z0-9]+$/
|
|
5
|
+
const LEGACY_NESTED_SKILL_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+\/.+$/
|
|
6
|
+
const LEGACY_FLAT_NAME_MD_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+\.md$/i
|
|
7
|
+
const MAX_PRIVATE_SKILL_ENTRIES = 500
|
|
8
|
+
const MAX_PRIVATE_SKILL_BODY_BYTES = 256 * 1024
|
|
9
|
+
const MAX_PRIVATE_SKILL_PATH_LEN = 256
|
|
10
|
+
|
|
11
|
+
export function normalizeContinuitySkills(input: unknown): ContinuitySkillsTree | undefined {
|
|
12
|
+
if (input === undefined || input === null) return undefined
|
|
13
|
+
if (typeof input !== 'object' || Array.isArray(input)) return undefined
|
|
14
|
+
const obj = input as Record<string, unknown>
|
|
15
|
+
const out: ContinuitySkillsTree = {}
|
|
16
|
+
let count = 0
|
|
17
|
+
const tryInsert = (key: string, rawValue: unknown): void => {
|
|
18
|
+
if (count >= MAX_PRIVATE_SKILL_ENTRIES) return
|
|
19
|
+
if (typeof rawValue !== 'string') return
|
|
20
|
+
if (key.length === 0 || key.length > MAX_PRIVATE_SKILL_PATH_LEN) return
|
|
21
|
+
if (key.includes('\0')) return
|
|
22
|
+
if (key.includes('..')) return
|
|
23
|
+
if (key.startsWith('/')) return
|
|
24
|
+
if (/^[A-Za-z]:/.test(key)) return
|
|
25
|
+
if (!isAcceptableSkillKey(key)) return
|
|
26
|
+
if (Buffer.byteLength(rawValue, 'utf8') > MAX_PRIVATE_SKILL_BODY_BYTES) return
|
|
27
|
+
if (out[key] !== undefined) return
|
|
28
|
+
out[key] = rawValue
|
|
29
|
+
count++
|
|
30
|
+
}
|
|
31
|
+
const legacyRoots = new Set<string>()
|
|
32
|
+
const realSkillFolders = new Set<string>()
|
|
33
|
+
for (const rawKey of Object.keys(obj)) {
|
|
34
|
+
const key = rawKey.replace(/\\/g, '/')
|
|
35
|
+
const segments = key.split('/')
|
|
36
|
+
if (segments.length === 3 && segments[2] === 'SKILL.md' && segments[0] && segments[1]) {
|
|
37
|
+
legacyRoots.add(`${segments[0]}/${segments[1]}`)
|
|
38
|
+
}
|
|
39
|
+
if (segments.length === 2 && segments[1] === 'SKILL.md' && segments[0]) {
|
|
40
|
+
realSkillFolders.add(segments[0])
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
for (const [rawKey, rawValue] of Object.entries(obj)) {
|
|
44
|
+
const key = rawKey.replace(/\\/g, '/')
|
|
45
|
+
if (!isCanonicalFlatKey(key)) continue
|
|
46
|
+
if (isUnderLegacyRoot(key, legacyRoots)) continue
|
|
47
|
+
if (!keyHasRealSkillFolder(key, realSkillFolders)) continue
|
|
48
|
+
tryInsert(key, rawValue)
|
|
49
|
+
}
|
|
50
|
+
for (const [rawKey, rawValue] of Object.entries(obj)) {
|
|
51
|
+
const key = rawKey.replace(/\\/g, '/')
|
|
52
|
+
if (
|
|
53
|
+
isCanonicalFlatKey(key)
|
|
54
|
+
&& !isUnderLegacyRoot(key, legacyRoots)
|
|
55
|
+
&& keyHasRealSkillFolder(key, realSkillFolders)
|
|
56
|
+
) continue
|
|
57
|
+
const upgraded = upgradeLegacySkillKey(key, legacyRoots)
|
|
58
|
+
if (!upgraded) continue
|
|
59
|
+
tryInsert(upgraded, rawValue)
|
|
60
|
+
}
|
|
61
|
+
return count > 0 ? out : undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isUnderLegacyRoot(key: string, legacyRoots: Set<string>): boolean {
|
|
65
|
+
for (const root of legacyRoots) {
|
|
66
|
+
if (key === `${root}/SKILL.md`) return true
|
|
67
|
+
if (key.startsWith(`${root}/`)) return true
|
|
68
|
+
}
|
|
69
|
+
return false
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function keyHasRealSkillFolder(key: string, realSkillFolders: Set<string>): boolean {
|
|
73
|
+
const first = key.split('/')[0]
|
|
74
|
+
if (!first) return false
|
|
75
|
+
return realSkillFolders.has(first)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isCanonicalFlatKey(key: string): boolean {
|
|
79
|
+
if (!PRIVATE_SKILL_FILE_RE.test(key)) return false
|
|
80
|
+
const segments = key.split('/')
|
|
81
|
+
if (segments.length < 2) return false
|
|
82
|
+
const last = segments[segments.length - 1]!
|
|
83
|
+
if (last === 'SKILL.md') return segments.length === 2
|
|
84
|
+
return PRIVATE_SKILL_LAST_SEG_FILE_RE.test(last)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isAcceptableSkillKey(key: string): boolean {
|
|
88
|
+
return isCanonicalFlatKey(key)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function upgradeLegacySkillKey(key: string, legacyRoots: Set<string>): string | null {
|
|
92
|
+
for (const root of legacyRoots) {
|
|
93
|
+
if (key === `${root}/SKILL.md` || key.startsWith(`${root}/`)) {
|
|
94
|
+
const [first, second] = root.split('/')
|
|
95
|
+
if (!first || !second) continue
|
|
96
|
+
const rest = key.slice(root.length + 1)
|
|
97
|
+
const flattened = `${first}-${second}/${rest}`
|
|
98
|
+
return isCanonicalFlatKey(flattened) ? flattened : null
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (LEGACY_FLAT_NAME_MD_RE.test(key)) {
|
|
102
|
+
const [category, file] = key.split('/')
|
|
103
|
+
if (!category || !file) return null
|
|
104
|
+
const slug = file.replace(/\.md$/i, '')
|
|
105
|
+
if (!slug) return null
|
|
106
|
+
const flattened = `${category}-${slug}/SKILL.md`
|
|
107
|
+
return isCanonicalFlatKey(flattened) ? flattened : null
|
|
108
|
+
}
|
|
109
|
+
if (LEGACY_NESTED_SKILL_RE.test(key)) {
|
|
110
|
+
const segments = key.split('/')
|
|
111
|
+
if (segments.length < 3) return null
|
|
112
|
+
const [first, second, ...rest] = segments
|
|
113
|
+
if (!first || !second || rest.length === 0) return null
|
|
114
|
+
const flattened = `${first}-${second}/${rest.join('/')}`
|
|
115
|
+
return isCanonicalFlatKey(flattened) ? flattened : null
|
|
116
|
+
}
|
|
117
|
+
if (isCanonicalFlatKey(key)) return key
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { toChecksumAddress } from '../crypto/eth.js'
|
|
2
|
+
|
|
3
|
+
export type ContinuitySnapshotToken = {
|
|
4
|
+
chainId: number
|
|
5
|
+
identityRegistryAddress: string
|
|
6
|
+
agentId: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function normalizeContinuitySnapshotToken(input: unknown): ContinuitySnapshotToken {
|
|
10
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
11
|
+
throw new Error('Continuity snapshot token is invalid')
|
|
12
|
+
}
|
|
13
|
+
const obj = input as Partial<ContinuitySnapshotToken>
|
|
14
|
+
if (typeof obj.chainId !== 'number' || !Number.isSafeInteger(obj.chainId) || obj.chainId <= 0) {
|
|
15
|
+
throw new Error('Continuity snapshot token chain is invalid')
|
|
16
|
+
}
|
|
17
|
+
if (typeof obj.identityRegistryAddress !== 'string') {
|
|
18
|
+
throw new Error('Continuity snapshot token registry is invalid')
|
|
19
|
+
}
|
|
20
|
+
if (typeof obj.agentId !== 'string' || !/^\d+$/.test(obj.agentId)) {
|
|
21
|
+
throw new Error('Continuity snapshot token id is invalid')
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
chainId: obj.chainId,
|
|
25
|
+
identityRegistryAddress: toChecksumAddress(obj.identityRegistryAddress),
|
|
26
|
+
agentId: obj.agentId,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { EthagentIdentity } from '../../../storage/config.js'
|
|
2
|
+
import type { WalletPurpose } from '../../wallet/browserWallet.js'
|
|
3
|
+
import type { ProfileUpdates } from '../identityHubReducer.js'
|
|
4
|
+
import { snapshotSaveWalletRole } from './snapshot.js'
|
|
5
|
+
|
|
6
|
+
export function rebackupWalletPurpose(
|
|
7
|
+
identity: EthagentIdentity,
|
|
8
|
+
profileUpdates: ProfileUpdates | undefined,
|
|
9
|
+
): WalletPurpose {
|
|
10
|
+
const role = snapshotSaveWalletRole(identity, profileUpdates)
|
|
11
|
+
const snapshotPurpose = role === 'operator'
|
|
12
|
+
? 'update-snapshot-operator' as const
|
|
13
|
+
: role === 'owner'
|
|
14
|
+
? 'update-snapshot-owner' as const
|
|
15
|
+
: 'update-snapshot-connected' as const
|
|
16
|
+
const profilePurpose = role === 'operator'
|
|
17
|
+
? 'update-profile-operator' as const
|
|
18
|
+
: role === 'owner'
|
|
19
|
+
? 'update-profile-owner' as const
|
|
20
|
+
: 'update-profile-connected' as const
|
|
21
|
+
if (!profileUpdates) return snapshotPurpose
|
|
22
|
+
const baseState = (identity.state ?? {}) as Record<string, unknown>
|
|
23
|
+
const currentEns = typeof baseState.ensName === 'string' ? baseState.ensName.trim() : ''
|
|
24
|
+
const ensTouched = typeof profileUpdates.ensName === 'string'
|
|
25
|
+
const profileFieldsTouched = profileUpdates.name !== undefined
|
|
26
|
+
|| profileUpdates.description !== undefined
|
|
27
|
+
|| profileUpdates.imagePath !== undefined
|
|
28
|
+
const operatorFieldsTouched = profileUpdates.ownerAddress !== undefined
|
|
29
|
+
|| profileUpdates.approvedOperatorWallets !== undefined
|
|
30
|
+
|| profileUpdates.activeOperatorAddress !== undefined
|
|
31
|
+
|| profileUpdates.restoreAccessEpoch !== undefined
|
|
32
|
+
if (operatorFieldsTouched && !ensTouched && !profileFieldsTouched) return 'update-operators'
|
|
33
|
+
if (ensTouched && !profileFieldsTouched) {
|
|
34
|
+
const next = (profileUpdates.ensName ?? '').trim()
|
|
35
|
+
if (!next && currentEns) return 'clear-ens'
|
|
36
|
+
return 'update-ens'
|
|
37
|
+
}
|
|
38
|
+
if (profileFieldsTouched) return profilePurpose
|
|
39
|
+
return snapshotPurpose
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function rebackupCompletionMessage(
|
|
43
|
+
profileUpdates: ProfileUpdates | undefined,
|
|
44
|
+
identity: EthagentIdentity,
|
|
45
|
+
ensOk?: boolean,
|
|
46
|
+
): string {
|
|
47
|
+
if (!profileUpdates) return 'Backup Saved'
|
|
48
|
+
const baseState = (identity.state ?? {}) as Record<string, unknown>
|
|
49
|
+
const currentEns = typeof baseState.ensName === 'string' ? baseState.ensName.trim() : ''
|
|
50
|
+
const ensTouched = typeof profileUpdates.ensName === 'string'
|
|
51
|
+
const profileFieldsTouched = profileUpdates.name !== undefined
|
|
52
|
+
|| profileUpdates.description !== undefined
|
|
53
|
+
|| profileUpdates.imagePath !== undefined
|
|
54
|
+
const operatorFieldsTouched = profileUpdates.ownerAddress !== undefined
|
|
55
|
+
|| profileUpdates.approvedOperatorWallets !== undefined
|
|
56
|
+
|| profileUpdates.activeOperatorAddress !== undefined
|
|
57
|
+
|| profileUpdates.restoreAccessEpoch !== undefined
|
|
58
|
+
if (operatorFieldsTouched && !ensTouched && !profileFieldsTouched) return 'Operator Wallets Updated'
|
|
59
|
+
if (ensTouched && !profileFieldsTouched) {
|
|
60
|
+
const next = (profileUpdates.ensName ?? '').trim()
|
|
61
|
+
if (!next && currentEns) return 'ENS Unlinked'
|
|
62
|
+
if (next) return ensOk === false ? 'ENS Issue' : 'ENS Linked'
|
|
63
|
+
return 'ENS Updated'
|
|
64
|
+
}
|
|
65
|
+
if (profileFieldsTouched) return 'Profile Updated'
|
|
66
|
+
return 'Backup Saved'
|
|
67
|
+
}
|
|
@@ -77,6 +77,10 @@ import {
|
|
|
77
77
|
syncVaultOperatorsAfterOwnerSave,
|
|
78
78
|
} from '../shared/effects/sync.js'
|
|
79
79
|
import { runOperatorWalletRebackup } from './vault.js'
|
|
80
|
+
import {
|
|
81
|
+
rebackupCompletionMessage,
|
|
82
|
+
rebackupWalletPurpose,
|
|
83
|
+
} from './completion.js'
|
|
80
84
|
|
|
81
85
|
type BackupMetadata = NonNullable<EthagentIdentity['backup']>
|
|
82
86
|
type PublicSkillsMetadata = NonNullable<EthagentIdentity['publicSkills']>
|
|
@@ -418,65 +422,4 @@ export async function runRebackupStorageSubmit(
|
|
|
418
422
|
callbacks.onStep({ kind: 'rebackup-signing', identity: step.identity, registry: step.registry, pinataJwt, profileUpdates: step.profileUpdates, returnTo: step.returnTo, walletPurpose: step.walletPurpose, vaultAddress: step.vaultAddress })
|
|
419
423
|
}
|
|
420
424
|
|
|
421
|
-
|
|
422
|
-
identity: EthagentIdentity,
|
|
423
|
-
profileUpdates: ProfileUpdates | undefined,
|
|
424
|
-
): WalletPurpose {
|
|
425
|
-
const role = snapshotSaveWalletRole(identity, profileUpdates)
|
|
426
|
-
const snapshotPurpose = role === 'operator'
|
|
427
|
-
? 'update-snapshot-operator' as const
|
|
428
|
-
: role === 'owner'
|
|
429
|
-
? 'update-snapshot-owner' as const
|
|
430
|
-
: 'update-snapshot-connected' as const
|
|
431
|
-
const profilePurpose = role === 'operator'
|
|
432
|
-
? 'update-profile-operator' as const
|
|
433
|
-
: role === 'owner'
|
|
434
|
-
? 'update-profile-owner' as const
|
|
435
|
-
: 'update-profile-connected' as const
|
|
436
|
-
if (!profileUpdates) return snapshotPurpose
|
|
437
|
-
const baseState = (identity.state ?? {}) as Record<string, unknown>
|
|
438
|
-
const currentEns = typeof baseState.ensName === 'string' ? baseState.ensName.trim() : ''
|
|
439
|
-
const ensTouched = typeof profileUpdates.ensName === 'string'
|
|
440
|
-
const profileFieldsTouched = profileUpdates.name !== undefined
|
|
441
|
-
|| profileUpdates.description !== undefined
|
|
442
|
-
|| profileUpdates.imagePath !== undefined
|
|
443
|
-
const operatorFieldsTouched = profileUpdates.ownerAddress !== undefined
|
|
444
|
-
|| profileUpdates.approvedOperatorWallets !== undefined
|
|
445
|
-
|| profileUpdates.activeOperatorAddress !== undefined
|
|
446
|
-
|| profileUpdates.restoreAccessEpoch !== undefined
|
|
447
|
-
if (operatorFieldsTouched && !ensTouched && !profileFieldsTouched) return 'update-operators'
|
|
448
|
-
if (ensTouched && !profileFieldsTouched) {
|
|
449
|
-
const next = (profileUpdates.ensName ?? '').trim()
|
|
450
|
-
if (!next && currentEns) return 'clear-ens'
|
|
451
|
-
return 'update-ens'
|
|
452
|
-
}
|
|
453
|
-
if (profileFieldsTouched) return profilePurpose
|
|
454
|
-
return snapshotPurpose
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
export function rebackupCompletionMessage(
|
|
458
|
-
profileUpdates: ProfileUpdates | undefined,
|
|
459
|
-
identity: EthagentIdentity,
|
|
460
|
-
ensOk?: boolean,
|
|
461
|
-
): string {
|
|
462
|
-
if (!profileUpdates) return 'Backup Saved'
|
|
463
|
-
const baseState = (identity.state ?? {}) as Record<string, unknown>
|
|
464
|
-
const currentEns = typeof baseState.ensName === 'string' ? baseState.ensName.trim() : ''
|
|
465
|
-
const ensTouched = typeof profileUpdates.ensName === 'string'
|
|
466
|
-
const profileFieldsTouched = profileUpdates.name !== undefined
|
|
467
|
-
|| profileUpdates.description !== undefined
|
|
468
|
-
|| profileUpdates.imagePath !== undefined
|
|
469
|
-
const operatorFieldsTouched = profileUpdates.ownerAddress !== undefined
|
|
470
|
-
|| profileUpdates.approvedOperatorWallets !== undefined
|
|
471
|
-
|| profileUpdates.activeOperatorAddress !== undefined
|
|
472
|
-
|| profileUpdates.restoreAccessEpoch !== undefined
|
|
473
|
-
if (operatorFieldsTouched && !ensTouched && !profileFieldsTouched) return 'Operator Wallets Updated'
|
|
474
|
-
if (ensTouched && !profileFieldsTouched) {
|
|
475
|
-
const next = (profileUpdates.ensName ?? '').trim()
|
|
476
|
-
if (!next && currentEns) return 'ENS Unlinked'
|
|
477
|
-
if (next) return ensOk === false ? 'ENS Issue' : 'ENS Linked'
|
|
478
|
-
return 'ENS Updated'
|
|
479
|
-
}
|
|
480
|
-
if (profileFieldsTouched) return 'Profile Updated'
|
|
481
|
-
return 'Backup Saved'
|
|
482
|
-
}
|
|
425
|
+
export { rebackupCompletionMessage } from './completion.js'
|