ethagent 2.3.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 (110) hide show
  1. package/README.md +18 -4
  2. package/package.json +2 -1
  3. package/src/app/FirstRun.tsx +157 -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 +12 -1
  8. package/src/chat/ChatScreen.tsx +17 -5
  9. package/src/chat/ConversationStack.tsx +25 -19
  10. package/src/chat/MessageList.tsx +194 -53
  11. package/src/chat/chatSessionState.ts +4 -1
  12. package/src/chat/chatTurnOrchestrator.ts +65 -2
  13. package/src/chat/input/ChatInput.tsx +28 -2
  14. package/src/chat/input/imageRefs.ts +30 -0
  15. package/src/chat/input/textCursor.ts +13 -3
  16. package/src/chat/transcript/TranscriptView.tsx +7 -5
  17. package/src/chat/transcript/transcriptViewport.ts +88 -17
  18. package/src/chat/views/PermissionPrompt.tsx +26 -26
  19. package/src/chat/views/PermissionsView.tsx +18 -12
  20. package/src/chat/views/ResumeView.tsx +16 -7
  21. package/src/chat/views/RewindView.tsx +3 -1
  22. package/src/cli/ResetConfirmView.tsx +24 -9
  23. package/src/identity/continuity/editor.ts +27 -2
  24. package/src/identity/continuity/envelope.ts +125 -0
  25. package/src/identity/continuity/publicSkills.ts +37 -1
  26. package/src/identity/continuity/skills/frontmatter.ts +183 -0
  27. package/src/identity/continuity/skills/loadSkills.ts +609 -0
  28. package/src/identity/continuity/skills/publicSkillsSync.ts +32 -0
  29. package/src/identity/continuity/skills/scaffold.ts +52 -0
  30. package/src/identity/continuity/skills/types.ts +30 -0
  31. package/src/identity/continuity/storage/defaults.ts +28 -47
  32. package/src/identity/continuity/storage/files.ts +1 -0
  33. package/src/identity/continuity/storage/paths.ts +1 -0
  34. package/src/identity/continuity/storage/scaffold.ts +25 -23
  35. package/src/identity/continuity/storage/status.ts +34 -5
  36. package/src/identity/continuity/storage/types.ts +3 -2
  37. package/src/identity/continuity/storage.ts +3 -0
  38. package/src/identity/hub/OperationalRoutes.tsx +105 -3
  39. package/src/identity/hub/Routes.tsx +5 -3
  40. package/src/identity/hub/continuity/ContinuityDashboardScreen.tsx +5 -51
  41. package/src/identity/hub/continuity/RecoveryConfirmScreen.tsx +1 -1
  42. package/src/identity/hub/continuity/SavePromptScreen.tsx +1 -0
  43. package/src/identity/hub/continuity/effects.ts +36 -5
  44. package/src/identity/hub/continuity/skills/DeleteSkillConfirmScreen.tsx +112 -0
  45. package/src/identity/hub/continuity/skills/DeleteSkillScreen.tsx +123 -0
  46. package/src/identity/hub/continuity/skills/NewSkillScreen.tsx +57 -0
  47. package/src/identity/hub/continuity/skills/NewSkillVisibilityScreen.tsx +52 -0
  48. package/src/identity/hub/continuity/skills/SkillVisibilityScreen.tsx +171 -0
  49. package/src/identity/hub/continuity/skills/SkillsTreeScreen.tsx +213 -0
  50. package/src/identity/hub/continuity/snapshot.ts +3 -0
  51. package/src/identity/hub/continuity/state.ts +3 -2
  52. package/src/identity/hub/continuity/vault.ts +42 -10
  53. package/src/identity/hub/custody/CustodyEditFlow.tsx +3 -3
  54. package/src/identity/hub/identityHubReducer.ts +21 -0
  55. package/src/identity/hub/profile/effects.ts +16 -3
  56. package/src/identity/hub/restore/RestoreFlow.tsx +43 -6
  57. package/src/identity/hub/restore/apply.ts +12 -1
  58. package/src/identity/hub/restore/recovery.ts +11 -1
  59. package/src/identity/hub/restore/resolve.ts +1 -1
  60. package/src/identity/hub/restore/useRestoreEffects.ts +4 -6
  61. package/src/identity/hub/shared/components/DetailsScreen.tsx +4 -1
  62. package/src/identity/hub/shared/components/IdentitySummary.tsx +97 -53
  63. package/src/identity/hub/shared/components/MenuScreen.tsx +18 -15
  64. package/src/identity/hub/shared/components/UnlinkedIdentityScreen.tsx +1 -1
  65. package/src/identity/hub/shared/components/menuFlagsFromReconciliation.ts +8 -12
  66. package/src/identity/hub/shared/effects/sync.ts +16 -3
  67. package/src/identity/hub/shared/model/copy.ts +2 -4
  68. package/src/identity/hub/transfer/effects.ts +15 -2
  69. package/src/identity/hub/useIdentityHubContinuity.ts +145 -23
  70. package/src/identity/hub/useIdentityHubController.ts +5 -1
  71. package/src/identity/hub/useIdentityHubSideEffects.ts +2 -4
  72. package/src/mcp/manager.ts +1 -1
  73. package/src/models/ModelPicker.tsx +211 -74
  74. package/src/models/huggingface.ts +180 -2
  75. package/src/models/llamacpp.ts +261 -17
  76. package/src/models/llamacppPreflight.ts +16 -12
  77. package/src/models/modelPickerOptions.ts +57 -38
  78. package/src/providers/anthropic.ts +36 -5
  79. package/src/providers/contracts.ts +10 -1
  80. package/src/providers/gemini.ts +29 -3
  81. package/src/providers/openai-chat.ts +131 -11
  82. package/src/providers/openai-responses-format.ts +29 -8
  83. package/src/providers/openai-responses.ts +41 -11
  84. package/src/providers/registry.ts +1 -0
  85. package/src/runtime/toolExecution.ts +4 -3
  86. package/src/runtime/turn.ts +61 -30
  87. package/src/storage/config.ts +1 -0
  88. package/src/storage/sessions.ts +14 -2
  89. package/src/tools/changeDirectoryTool.ts +1 -1
  90. package/src/tools/contracts.ts +10 -0
  91. package/src/tools/deleteFileTool.ts +1 -1
  92. package/src/tools/editTool.ts +1 -1
  93. package/src/tools/listDirectoryTool.ts +1 -1
  94. package/src/tools/listSkillFilesTool.ts +77 -0
  95. package/src/tools/listSkillsTool.ts +68 -0
  96. package/src/tools/mcpResourceTools.ts +2 -2
  97. package/src/tools/privateContinuityReadTool.ts +1 -1
  98. package/src/tools/readSkillTool.ts +107 -0
  99. package/src/tools/readTool.ts +1 -1
  100. package/src/tools/registry.ts +6 -0
  101. package/src/tools/writeFileTool.ts +22 -2
  102. package/src/ui/Spinner.tsx +15 -3
  103. package/src/ui/theme.ts +2 -0
  104. package/src/utils/images.ts +140 -0
  105. package/src/utils/messages.ts +2 -0
  106. package/src/identity/continuity/localBackup.ts +0 -249
  107. package/src/identity/continuity/zipWriter.ts +0 -95
  108. package/src/identity/hub/continuity/index.ts +0 -7
  109. package/src/identity/hub/ens/index.ts +0 -11
  110. package/src/identity/hub/restore/index.ts +0 -22
@@ -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))
@@ -0,0 +1,183 @@
1
+ import type { SkillFrontmatter, SkillVisibility } from './types.js'
2
+
3
+ const SUPPORTED_KEYS = new Set([
4
+ 'name',
5
+ 'description',
6
+ 'when_to_use',
7
+ 'when-to-use',
8
+ 'whenToUse',
9
+ 'version',
10
+ 'argument-hint',
11
+ 'argument_hint',
12
+ 'argumentHint',
13
+ 'tags',
14
+ 'visibility',
15
+ ])
16
+
17
+ const VISIBILITY_VALUES: SkillVisibility[] = ['private', 'public', 'discoverable']
18
+
19
+ export type ParsedSkillFile = {
20
+ frontmatter: SkillFrontmatter
21
+ body: string
22
+ }
23
+
24
+ export function parseSkillFile(content: string): ParsedSkillFile {
25
+ const normalized = content.replace(/^/, '').replace(/\r\n?/g, '\n')
26
+ if (!normalized.startsWith('---\n') && normalized !== '---' && !normalized.startsWith('---\r')) {
27
+ return { frontmatter: {}, body: normalized.trim() }
28
+ }
29
+ const afterOpen = normalized.slice(4)
30
+ const closeIdx = afterOpen.search(/^---\s*$/m)
31
+ if (closeIdx < 0) {
32
+ return { frontmatter: {}, body: normalized.trim() }
33
+ }
34
+ const fmText = afterOpen.slice(0, closeIdx)
35
+ const bodyText = afterOpen.slice(closeIdx).replace(/^---\s*\n?/, '').replace(/^\n+/, '')
36
+ return {
37
+ frontmatter: parseFrontmatterBlock(fmText),
38
+ body: bodyText.trim(),
39
+ }
40
+ }
41
+
42
+ function parseFrontmatterBlock(text: string): SkillFrontmatter {
43
+ const out: SkillFrontmatter = {}
44
+ const lines = text.split('\n')
45
+ for (let i = 0; i < lines.length; i++) {
46
+ const line = lines[i]
47
+ if (line === undefined) continue
48
+ const trimmed = line.trim()
49
+ if (!trimmed || trimmed.startsWith('#')) continue
50
+ const match = /^([A-Za-z_][A-Za-z0-9_\-]*)\s*:\s*(.*)$/.exec(line)
51
+ if (!match) continue
52
+ const rawKey = match[1] ?? ''
53
+ if (!SUPPORTED_KEYS.has(rawKey)) continue
54
+ let rawValue = match[2] ?? ''
55
+ if (rawValue === '' || rawValue === '|' || rawValue === '>') {
56
+ const collected: string[] = []
57
+ while (i + 1 < lines.length) {
58
+ const next = lines[i + 1]
59
+ if (next === undefined) break
60
+ if (next.startsWith(' ') || next.startsWith('\t')) {
61
+ collected.push(next.replace(/^\s+/, ''))
62
+ i++
63
+ } else if (next === '' || next.trim() === '') {
64
+ break
65
+ } else {
66
+ break
67
+ }
68
+ }
69
+ rawValue = collected.join(' ').trim()
70
+ }
71
+ const key = normalizeKey(rawKey)
72
+ if (!key) continue
73
+ assignKey(out, key, rawValue)
74
+ }
75
+ return out
76
+ }
77
+
78
+ function normalizeKey(key: string): keyof SkillFrontmatter | null {
79
+ switch (key) {
80
+ case 'name': return 'name'
81
+ case 'description': return 'description'
82
+ case 'when_to_use':
83
+ case 'when-to-use':
84
+ case 'whenToUse':
85
+ return 'whenToUse'
86
+ case 'version': return 'version'
87
+ case 'argument-hint':
88
+ case 'argument_hint':
89
+ case 'argumentHint':
90
+ return 'argumentHint'
91
+ case 'tags': return 'tags'
92
+ case 'visibility': return 'visibility'
93
+ default: return null
94
+ }
95
+ }
96
+
97
+ function assignKey(out: SkillFrontmatter, key: keyof SkillFrontmatter, rawValue: string): void {
98
+ const stripped = stripInlineComment(rawValue).trim()
99
+ if (key === 'tags') {
100
+ out.tags = parseStringList(stripped)
101
+ return
102
+ }
103
+ if (key === 'visibility') {
104
+ const literal = parseScalar(stripped).toLowerCase()
105
+ if ((VISIBILITY_VALUES as string[]).includes(literal)) {
106
+ out.visibility = literal as SkillVisibility
107
+ }
108
+ return
109
+ }
110
+ const value = parseScalar(stripped)
111
+ if (!value) return
112
+ out[key] = value
113
+ }
114
+
115
+ function parseScalar(value: string): string {
116
+ if (value === '') return ''
117
+ if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
118
+ return unescapeDoubleQuoted(value.slice(1, -1))
119
+ }
120
+ if (value.startsWith("'") && value.endsWith("'") && value.length >= 2) {
121
+ return value.slice(1, -1).replace(/''/g, "'")
122
+ }
123
+ return value
124
+ }
125
+
126
+ function parseStringList(value: string): string[] {
127
+ if (!value) return []
128
+ if (value.startsWith('[') && value.endsWith(']')) {
129
+ const inner = value.slice(1, -1)
130
+ return splitListItems(inner)
131
+ .map(parseScalar)
132
+ .filter(item => item.length > 0)
133
+ }
134
+ return splitListItems(value)
135
+ .map(parseScalar)
136
+ .filter(item => item.length > 0)
137
+ }
138
+
139
+ function splitListItems(value: string): string[] {
140
+ const items: string[] = []
141
+ let buffer = ''
142
+ let quote: string | null = null
143
+ for (let i = 0; i < value.length; i++) {
144
+ const ch = value[i]
145
+ if (quote) {
146
+ buffer += ch
147
+ if (ch === quote && value[i - 1] !== '\\') quote = null
148
+ continue
149
+ }
150
+ if (ch === '"' || ch === "'") {
151
+ quote = ch
152
+ buffer += ch
153
+ continue
154
+ }
155
+ if (ch === ',') {
156
+ items.push(buffer.trim())
157
+ buffer = ''
158
+ continue
159
+ }
160
+ buffer += ch
161
+ }
162
+ if (buffer.trim()) items.push(buffer.trim())
163
+ return items
164
+ }
165
+
166
+ function unescapeDoubleQuoted(value: string): string {
167
+ return value.replace(/\\(["\\/bfnrt])/g, (_, c) => {
168
+ switch (c) {
169
+ case 'n': return '\n'
170
+ case 'r': return '\r'
171
+ case 't': return '\t'
172
+ case 'b': return '\b'
173
+ case 'f': return '\f'
174
+ default: return c
175
+ }
176
+ })
177
+ }
178
+
179
+ function stripInlineComment(value: string): string {
180
+ if (value.startsWith('"') || value.startsWith("'") || value.startsWith('[')) return value
181
+ const hash = value.indexOf(' #')
182
+ return hash === -1 ? value : value.slice(0, hash)
183
+ }