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
@@ -5,7 +5,8 @@ import { providerErrorFromResponse } from './errors.js'
5
5
  import { fetchWithRetryStreamEvents } from './retry.js'
6
6
  import { iterSseEvents } from './sse.js'
7
7
  import { buildResponsesBody } from './openai-responses-format.js'
8
- import type { OpenAIToolDefinition } from './openai-chat.js'
8
+ import { supportsOpenAIImages, type OpenAIToolDefinition } from './openai-chat.js'
9
+ import { hasImageBlocks, ImageLoadError } from '../utils/images.js'
9
10
 
10
11
  const READ_TIMEOUT_MS = 45_000
11
12
 
@@ -64,15 +65,29 @@ export class OpenAIResponsesProvider implements Provider {
64
65
  return
65
66
  }
66
67
 
68
+ if (hasImageBlocks(messages) && !supportsOpenAIImages(this.model)) {
69
+ yield { type: 'error', message: `image input is not enabled for ${this.model}` }
70
+ return
71
+ }
72
+
67
73
  let attempt = 0
68
74
  while (true) {
69
75
  attempt += 1
70
- const body = JSON.stringify(buildResponsesBody({
71
- model: this.model,
72
- messages,
73
- tools: this.tools,
74
- maxOutputTokens: options.maxTokens,
75
- }))
76
+ let body: string
77
+ try {
78
+ body = JSON.stringify(await buildResponsesBody({
79
+ model: this.model,
80
+ messages,
81
+ tools: this.tools,
82
+ maxOutputTokens: options.maxTokens,
83
+ }))
84
+ } catch (err: unknown) {
85
+ if (err instanceof ImageLoadError) {
86
+ yield { type: 'error', message: err.message }
87
+ return
88
+ }
89
+ throw err
90
+ }
76
91
 
77
92
  let response: Response
78
93
  try {
@@ -261,15 +276,30 @@ function parseToolArguments(input: string): Record<string, unknown> {
261
276
  const trimmed = input.trim()
262
277
  if (!trimmed) return {}
263
278
  try {
264
- const parsed = JSON.parse(trimmed) as unknown
265
- return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
266
- ? (parsed as Record<string, unknown>)
267
- : {}
279
+ return coerceToToolArguments(JSON.parse(trimmed))
268
280
  } catch {
269
281
  return {}
270
282
  }
271
283
  }
272
284
 
285
+ function coerceToToolArguments(value: unknown): Record<string, unknown> {
286
+ if (typeof value === 'string') {
287
+ const trimmed = value.trim()
288
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
289
+ try {
290
+ return coerceToToolArguments(JSON.parse(trimmed))
291
+ } catch {
292
+ return {}
293
+ }
294
+ }
295
+ return {}
296
+ }
297
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
298
+ return value as Record<string, unknown>
299
+ }
300
+ return {}
301
+ }
302
+
273
303
  function networkErrorMessage(baseUrl: string, err: unknown, fallback = 'network error'): string {
274
304
  const message = (err as Error).message || fallback
275
305
  return `openai request failed at ${baseUrl}: ${message}`
@@ -34,6 +34,7 @@ export function createProvider(config: EthagentConfig, options: { mode?: Session
34
34
  baseUrl: localProviderBaseUrlFor('llamacpp', config.baseUrl),
35
35
  apiKey: 'llamacpp',
36
36
  tools: openAITools(mode, toolContext),
37
+ hasVisionProjector: Boolean(config.localMmprojPath),
37
38
  })
38
39
  case 'openai':
39
40
  return createOpenAIProvider(config, openAITools(mode, toolContext))
@@ -50,8 +50,8 @@ export async function executeToolWithPermissions(
50
50
  return {
51
51
  result: {
52
52
  ok: false,
53
- summary: `unknown tool ${options.name}`,
54
- content: `tool '${options.name}' is not registered`,
53
+ summary: `Unknown tool ${options.name}`,
54
+ content: `Tool '${options.name}' is not registered`,
55
55
  },
56
56
  }
57
57
  }
@@ -98,6 +98,7 @@ export async function executeToolWithPermissions(
98
98
  options.permissionMode === 'plan' &&
99
99
  request.kind !== 'read' &&
100
100
  request.kind !== 'private-continuity-read' &&
101
+ request.kind !== 'private-skill-read' &&
101
102
  !(request.kind === 'mcp' && request.readOnly)
102
103
  ) {
103
104
  return {
@@ -111,7 +112,7 @@ export async function executeToolWithPermissions(
111
112
 
112
113
  const matchedRule = matchPermissionRule(options.getPermissionRules(), request)
113
114
  const decision: PermissionDecision =
114
- modePolicy(options.permissionMode).autoAllowToolKind(request.kind)
115
+ modePolicy(options.permissionMode).autoAllowToolKind(tool.kind)
115
116
  ? 'allow-once'
116
117
  : matchedRule
117
118
  ? 'allow-once'
@@ -10,6 +10,7 @@ import {
10
10
  type ProviderTurnEvent =
11
11
  | { type: 'text'; delta: string }
12
12
  | { type: 'thinking'; delta: string }
13
+ | { type: 'thinking_end' }
13
14
  | ProviderRetryStreamEvent
14
15
  | { type: 'tool_use_start'; id: string; name: string }
15
16
  | { type: 'tool_use_delta'; id: string; delta: string }
@@ -46,6 +47,7 @@ function normalize(event: StreamEvent): ProviderTurnEvent {
46
47
  switch (event.type) {
47
48
  case 'text': return { type: 'text', delta: event.delta }
48
49
  case 'thinking': return { type: 'thinking', delta: event.delta }
50
+ case 'thinking_end': return { type: 'thinking_end' }
49
51
  case 'retry': return event
50
52
  case 'tool_use_start': return event
51
53
  case 'tool_use_delta': return event
@@ -67,6 +69,7 @@ export type ContinuationNudgeReason =
67
69
  | 'tool_budget'
68
70
  | 'private_continuity_tool'
69
71
  | 'private_continuity_tool_repair'
72
+ | 'write_file_repair'
70
73
  | 'reasoning_only'
71
74
 
72
75
  const CONTINUATION_NUDGE_TEXT =
@@ -93,6 +96,9 @@ const PRIVATE_CONTINUITY_NUDGE_TEXT =
93
96
  const PRIVATE_CONTINUITY_REPAIR_NUDGE_TEXT =
94
97
  'The previous propose_private_continuity_edit call had invalid or missing input. Retry the same native tool now with complete arguments. Do not answer in prose and do not search for markdown files. For memory/preferences use {"file":"MEMORY.md","appendToSection":"Durable User Preferences","appendText":"- User preference or memory note."}. For persona use {"file":"SOUL.md","appendToSection":"Persona","appendText":"- Persona or standing behavior note."}.'
95
98
 
99
+ const WRITE_FILE_REPAIR_NUDGE_TEXT =
100
+ 'The previous write_file call was rejected because the arguments were missing or malformed. Retry the same native tool now with a JSON object (not a JSON string) shaped exactly like {"path":"relative/path.ext","content":"...complete file contents..."}. Both fields are required and must be non-empty. Do not answer in prose.'
101
+
96
102
  const REASONING_ONLY_NUDGE_TEXT =
97
103
  'You produced private reasoning but no user-visible answer. Answer the user now in visible text. Do not continue only with reasoning.'
98
104
 
@@ -100,6 +106,7 @@ export type TurnEvent =
100
106
  | { type: 'iteration_start'; index: number }
101
107
  | { type: 'text'; delta: string }
102
108
  | { type: 'thinking'; delta: string }
109
+ | { type: 'thinking_end' }
103
110
  | ProviderRetryStreamEvent
104
111
  | { type: 'tool_use_start'; id: string; name: string }
105
112
  | { type: 'tool_use_delta'; id: string; delta: string }
@@ -203,6 +210,8 @@ export async function* runRuntimeTurn(
203
210
  } else if (ev.type === 'thinking') {
204
211
  thinkingSeen = true
205
212
  yield { type: 'thinking', delta: ev.delta }
213
+ } else if (ev.type === 'thinking_end') {
214
+ yield { type: 'thinking_end' }
206
215
  } else if (ev.type === 'tool_use_start') {
207
216
  yield { type: 'tool_use_start', id: ev.id, name: ev.name }
208
217
  } else if (ev.type === 'tool_use_delta') {
@@ -279,7 +288,7 @@ export async function* runRuntimeTurn(
279
288
  }
280
289
  yield {
281
290
  type: 'error',
282
- message: 'model printed tool names instead of making a tool call',
291
+ message: 'Model printed tool names instead of making a tool call',
283
292
  discardAssistant: true,
284
293
  }
285
294
  yield doneEvent(false, stopReason)
@@ -302,7 +311,7 @@ export async function* runRuntimeTurn(
302
311
  }
303
312
  yield {
304
313
  type: 'error',
305
- message: 'model asked the user to run a tool instead of making a tool call',
314
+ message: 'Model asked the user to run a tool instead of making a tool call',
306
315
  discardAssistant: true,
307
316
  }
308
317
  yield doneEvent(false, stopReason)
@@ -333,7 +342,7 @@ export async function* runRuntimeTurn(
333
342
  }
334
343
  yield {
335
344
  type: 'error',
336
- message: 'model claimed workspace state without matching tool evidence',
345
+ message: 'Model claimed workspace state without matching tool evidence',
337
346
  discardAssistant: true,
338
347
  }
339
348
  yield doneEvent(false, stopReason)
@@ -342,26 +351,18 @@ export async function* runRuntimeTurn(
342
351
  }
343
352
 
344
353
  if (pendingToolUses.length === 0) {
345
- if (!assistantText && thinkingSeen) {
346
- if (continuationNudges < maxContinuationNudges) {
347
- continuationNudges += 1
348
- yield {
349
- type: 'continuation_nudge',
350
- attempt: continuationNudges,
351
- reason: 'reasoning_only',
352
- }
353
- workingMessages = [
354
- ...await rebuildMessages(),
355
- { role: 'user', content: REASONING_ONLY_NUDGE_TEXT },
356
- ]
357
- continue
358
- }
354
+ if (!assistantText && thinkingSeen && continuationNudges < maxContinuationNudges) {
355
+ continuationNudges += 1
359
356
  yield {
360
- type: 'error',
361
- message: 'model produced reasoning but no visible answer',
357
+ type: 'continuation_nudge',
358
+ attempt: continuationNudges,
359
+ reason: 'reasoning_only',
362
360
  }
363
- yield doneEvent(false, stopReason)
364
- return
361
+ workingMessages = [
362
+ ...await rebuildMessages(),
363
+ { role: 'user', content: REASONING_ONLY_NUDGE_TEXT },
364
+ ]
365
+ continue
365
366
  }
366
367
 
367
368
  const nudge = nextNudge(provider, assistantText)
@@ -387,7 +388,7 @@ export async function* runRuntimeTurn(
387
388
  if (assistantText && nudge?.reason === 'tool_capability') {
388
389
  yield {
389
390
  type: 'error',
390
- message: 'model refused available tools after corrective nudges',
391
+ message: 'Model refused available tools after corrective nudges',
391
392
  }
392
393
  yield doneEvent(false, stopReason)
393
394
  return
@@ -456,17 +457,17 @@ export async function* runRuntimeTurn(
456
457
  yield {
457
458
  type: 'continuation_nudge',
458
459
  attempt: continuationNudges,
459
- reason: 'private_continuity_tool_repair',
460
+ reason: repairNudge.reason,
460
461
  }
461
462
  workingMessages = [
462
463
  ...await rebuildMessages(),
463
- { role: 'user', content: repairNudge },
464
+ { role: 'user', content: repairNudge.text },
464
465
  ]
465
466
  continue
466
467
  }
467
468
  yield {
468
469
  type: 'error',
469
- message: 'model called propose_private_continuity_edit with invalid input after corrective nudges',
470
+ message: repairNudge.failureMessage,
470
471
  discardAssistant: true,
471
472
  }
472
473
  yield doneEvent(false, stopReason)
@@ -485,26 +486,56 @@ function doneEvent(finishedNormally: boolean, stopReason?: TurnStopReason): Extr
485
486
  return { type: 'done', finishedNormally }
486
487
  }
487
488
 
489
+ type RepairNudge = {
490
+ text: string
491
+ reason: ContinuationNudgeReason
492
+ failureMessage: string
493
+ }
494
+
488
495
  function nextToolResultRepairNudge(
489
496
  provider: Pick<Provider, 'id' | 'supportsTools'>,
490
497
  completedTools: ExecutedToolUse[],
491
- ): string | null {
498
+ ): RepairNudge | null {
492
499
  if (!provider.supportsTools) return null
493
500
  const failedPrivateEdit = completedTools.some(completed =>
494
501
  completed.name === 'propose_private_continuity_edit'
495
502
  && !completed.result.ok
496
503
  && completed.result.summary === 'propose_private_continuity_edit rejected input',
497
504
  )
498
- if (failedPrivateEdit) return PRIVATE_CONTINUITY_REPAIR_NUDGE_TEXT
505
+ if (failedPrivateEdit) {
506
+ return {
507
+ text: PRIVATE_CONTINUITY_REPAIR_NUDGE_TEXT,
508
+ reason: 'private_continuity_tool_repair',
509
+ failureMessage: 'Model called propose_private_continuity_edit with invalid input after corrective nudges',
510
+ }
511
+ }
512
+
513
+ const failedWriteFile = completedTools.some(completed =>
514
+ completed.name === 'write_file'
515
+ && !completed.result.ok
516
+ && completed.result.summary === 'write_file rejected input',
517
+ )
518
+ if (failedWriteFile) {
519
+ return {
520
+ text: WRITE_FILE_REPAIR_NUDGE_TEXT,
521
+ reason: 'write_file_repair',
522
+ failureMessage: 'Model called write_file with invalid input after corrective nudges',
523
+ }
524
+ }
499
525
 
500
526
  const failedWorkspacePrivateRead = completedTools.some(completed =>
501
527
  completed.name === 'read_file'
502
528
  && !completed.result.ok
503
529
  && /read_private_continuity_file/.test(completed.result.content),
504
530
  )
505
- return failedWorkspacePrivateRead
506
- ? 'The previous read_file call targeted private identity continuity markdown. Retry now with read_private_continuity_file and complete input such as {"file":"MEMORY.md"} or {"file":"SOUL.md"}. Do not search workspace folders.'
507
- : null
531
+ if (failedWorkspacePrivateRead) {
532
+ return {
533
+ text: 'The previous read_file call targeted private identity continuity markdown. Retry now with read_private_continuity_file and complete input such as {"file":"MEMORY.md"} or {"file":"SOUL.md"}. Do not search workspace folders.',
534
+ reason: 'private_continuity_tool_repair',
535
+ failureMessage: 'Model kept reading private continuity files via read_file after corrective nudges',
536
+ }
537
+ }
538
+ return null
508
539
  }
509
540
 
510
541
  export function parseLocalModelTextToolUse(
@@ -80,6 +80,7 @@ const ConfigSchema = z.object({
80
80
  provider: z.enum(PROVIDERS),
81
81
  model: z.string().min(1),
82
82
  baseUrl: z.string().url().optional(),
83
+ localMmprojPath: z.string().min(1).optional(),
83
84
  firstRunAt: z.string(),
84
85
  identity: IdentitySchema.optional(),
85
86
  erc8004: z.object({
@@ -11,9 +11,10 @@ import {
11
11
  isUserCorrectionOfToolState,
12
12
  looksLikeToolStateClaim,
13
13
  } from '../runtime/toolClaimGuards.js'
14
+ import { userTextToContentBlocks } from '../utils/images.js'
14
15
 
15
16
  export type SessionMessage =
16
- | { version?: 2; role: 'user'; content: string; createdAt: string; turnId?: string; synthetic?: boolean }
17
+ | { version?: 2; role: 'user'; content: string; providerContent?: Message['content']; createdAt: string; turnId?: string; synthetic?: boolean }
17
18
  | { version?: 2; role: 'assistant'; content: string; createdAt: string; model?: string; usage?: { in?: number; out?: number }; turnId?: string; synthetic?: boolean }
18
19
  | { version?: 2; role: 'system'; content: string; createdAt: string; turnId?: string; synthetic?: boolean }
19
20
  | { version: 2; role: 'tool_use'; toolUseId: string; name: string; input: Record<string, unknown>; createdAt: string; turnId?: string }
@@ -244,6 +245,17 @@ export type ProviderMessageProjectionOptions = {
244
245
  export const TOOL_CORRECTION_CONTEXT_MESSAGE =
245
246
  'The latest user message corrects a prior assistant claim about tool or filesystem state. Treat user correction and tool_result messages as authoritative. Ignore any recent assistant claim about files, directories, cwd, or tool execution unless it is backed by a tool_result, and retry with the appropriate tool.'
246
247
 
248
+ function resolveUserContent(
249
+ message: Extract<SessionMessage, { role: 'system' | 'user' | 'assistant' }>,
250
+ ): Message['content'] {
251
+ if (message.role !== 'user') return message.content
252
+ if (message.providerContent) return message.providerContent
253
+ if (message.content.includes('[image:')) {
254
+ return userTextToContentBlocks(message.content)
255
+ }
256
+ return message.content
257
+ }
258
+
247
259
  export function sessionMessagesToProviderMessages(
248
260
  messages: SessionMessage[],
249
261
  options: ProviderMessageProjectionOptions = {},
@@ -255,7 +267,7 @@ export function sessionMessagesToProviderMessages(
255
267
  for (const [index, message] of messages.entries()) {
256
268
  if (message.role === 'system' || message.role === 'user' || message.role === 'assistant') {
257
269
  if (message.role === 'assistant' && invalidatedAssistantMessages.has(index)) continue
258
- out.push({ role: message.role, content: message.content })
270
+ out.push({ role: message.role, content: resolveUserContent(message) })
259
271
  continue
260
272
  }
261
273
  if (message.role === 'tool_use') {
@@ -32,7 +32,7 @@ export const changeDirectoryTool: Tool<typeof schema> = {
32
32
  path: fullPath,
33
33
  relativePath: path.relative(context.workspaceRoot, fullPath) || path.basename(fullPath),
34
34
  directoryPath: path.dirname(fullPath),
35
- title: 'allow directory change?',
35
+ title: 'Allow directory change?',
36
36
  subtitle: fullPath,
37
37
  }
38
38
  },
@@ -48,6 +48,16 @@ export type PermissionRequest =
48
48
  file: 'SOUL.md' | 'MEMORY.md'
49
49
  range: string
50
50
  }
51
+ | {
52
+ kind: 'private-skill-read'
53
+ path: string
54
+ relativePath: string
55
+ directoryPath: string
56
+ title: string
57
+ subtitle: string
58
+ skillName: string
59
+ mode: 'list' | 'read'
60
+ }
51
61
  | {
52
62
  kind: 'private-continuity-edit'
53
63
  path: string
@@ -32,7 +32,7 @@ export const deleteFileTool: Tool<typeof schema> = {
32
32
  path: prepared.fullPath,
33
33
  relativePath: prepared.relativePath,
34
34
  directoryPath: path.dirname(prepared.fullPath),
35
- title: 'allow file delete?',
35
+ title: 'Allow file delete?',
36
36
  subtitle: prepared.fullPath,
37
37
  before: preview(prepared.before),
38
38
  after: '(deleted)',
@@ -41,7 +41,7 @@ export const editTool: Tool<typeof schema> = {
41
41
  path: fullPath,
42
42
  relativePath,
43
43
  directoryPath: path.dirname(fullPath),
44
- title: 'allow file edit?',
44
+ title: 'Allow file edit?',
45
45
  subtitle: fullPath,
46
46
  before: applied.previewBefore,
47
47
  after: applied.previewAfter,
@@ -31,7 +31,7 @@ export const listDirectoryTool: Tool<typeof schema> = {
31
31
  path: fullPath,
32
32
  relativePath,
33
33
  directoryPath: fullPath,
34
- title: 'allow directory listing?',
34
+ title: 'Allow directory listing?',
35
35
  subtitle: fullPath,
36
36
  }
37
37
  },
@@ -0,0 +1,77 @@
1
+ import { z } from 'zod'
2
+ import path from 'node:path'
3
+ import {
4
+ continuityVaultRef,
5
+ } from '../identity/continuity/storage.js'
6
+ import { listSkillFiles } from '../identity/continuity/skills/loadSkills.js'
7
+ import type { Tool } from './contracts.js'
8
+
9
+ const schema = z.object({
10
+ name: z.string().min(1),
11
+ })
12
+
13
+ export const listSkillFilesTool: Tool<typeof schema> = {
14
+ name: 'list_private_skill_files',
15
+ kind: 'private-continuity-read',
16
+ readOnly: true,
17
+ description: [
18
+ 'List every file inside a private skill folder for the active identity.',
19
+ 'Returns each file as `- <relativePath> (<bytes>) — <absolutePath>`, including SKILL.md and any supporting files (references, examples, scripts).',
20
+ 'Use this after list_private_skills shows a skill with `(+N supporting files)` to discover what is available, then call read_private_skill with `file` to load text content.',
21
+ 'Pass the absolute path directly to run_bash to execute supporting scripts (e.g. `python "<absolutePath>" <args>`).',
22
+ ].join(' '),
23
+ inputSchema: schema,
24
+ inputSchemaJson: {
25
+ type: 'object',
26
+ properties: {
27
+ name: { type: 'string', description: 'Skill folder name from the private skills index.' },
28
+ },
29
+ required: ['name'],
30
+ additionalProperties: false,
31
+ },
32
+ parse(input) {
33
+ return schema.parse(input)
34
+ },
35
+ async buildPermissionRequest(input, context) {
36
+ const identity = context.config?.identity
37
+ if (!identity) throw new Error('No active identity; create or load an identity before listing private skill files')
38
+ const ref = continuityVaultRef(identity)
39
+ const folder = input.name.replace(/^.*:/, '')
40
+ const skillDir = path.join(ref.skillsDir, folder)
41
+ return {
42
+ kind: 'private-skill-read',
43
+ path: skillDir,
44
+ relativePath: `identity-vault/skills/${folder}`,
45
+ directoryPath: skillDir,
46
+ title: 'Allow private skill folder list?',
47
+ subtitle: `List files in ${folder}/`,
48
+ skillName: input.name,
49
+ mode: 'list',
50
+ }
51
+ },
52
+ async execute(input, context) {
53
+ const identity = context.config?.identity
54
+ if (!identity) {
55
+ return { ok: false, summary: 'no active identity', content: 'No active identity; cannot list private skill files.' }
56
+ }
57
+ try {
58
+ const folder = input.name.replace(/^.*:/, '')
59
+ const files = await listSkillFiles(identity, folder)
60
+ if (files.length === 0) {
61
+ return { ok: true, summary: `no files in ${folder}`, content: `Skill folder ${folder}/ is empty or does not exist.` }
62
+ }
63
+ const lines = files.map(f => `- ${f.relativePath} (${f.sizeBytes} bytes) — ${f.absolutePath}`)
64
+ return {
65
+ ok: true,
66
+ summary: `listed ${files.length} file${files.length === 1 ? '' : 's'} in ${folder}`,
67
+ content: lines.join('\n'),
68
+ }
69
+ } catch (err: unknown) {
70
+ return {
71
+ ok: false,
72
+ summary: 'list failed',
73
+ content: (err as Error).message,
74
+ }
75
+ }
76
+ },
77
+ }
@@ -0,0 +1,68 @@
1
+ import { z } from 'zod'
2
+ import {
3
+ continuityVaultRef,
4
+ } from '../identity/continuity/storage.js'
5
+ import { listSkillsTree } from '../identity/continuity/skills/loadSkills.js'
6
+ import type { Tool } from './contracts.js'
7
+
8
+ const schema = z.object({})
9
+
10
+ export const listSkillsTool: Tool<typeof schema> = {
11
+ name: 'list_private_skills',
12
+ kind: 'private-continuity-read',
13
+ readOnly: true,
14
+ description: [
15
+ 'List private skills available in the owner-authored skills tree for the active identity.',
16
+ 'Returns each skill folder name with its one-line description; bodies are loaded separately via read_private_skill.',
17
+ 'When a skill has supporting files beyond SKILL.md, the entry is annotated with (+N supporting files) so you know to call list_private_skill_files.',
18
+ 'Use this when the user mentions a skill not in the injected skill index, or to discover what skills exist.',
19
+ ].join(' '),
20
+ inputSchema: schema,
21
+ inputSchemaJson: {
22
+ type: 'object',
23
+ properties: {},
24
+ additionalProperties: false,
25
+ },
26
+ parse(input) {
27
+ return schema.parse(input ?? {})
28
+ },
29
+ async buildPermissionRequest(_input, context) {
30
+ const identity = context.config?.identity
31
+ if (!identity) throw new Error('No active identity; create or load an identity before listing private skills')
32
+ const ref = continuityVaultRef(identity)
33
+ return {
34
+ kind: 'private-skill-read',
35
+ path: ref.skillsDir,
36
+ relativePath: 'identity-vault/skills',
37
+ directoryPath: ref.skillsDir,
38
+ title: 'Allow private skills index read?',
39
+ subtitle: 'List skill names and descriptions from the private skills tree',
40
+ skillName: '*',
41
+ mode: 'list',
42
+ }
43
+ },
44
+ async execute(_input, context) {
45
+ const identity = context.config?.identity
46
+ if (!identity) {
47
+ return { ok: false, summary: 'no active identity', content: 'No active identity; cannot list private skills.' }
48
+ }
49
+ const { skills, supportingCounts } = await listSkillsTree(identity)
50
+ if (skills.length === 0) {
51
+ return { ok: true, summary: 'no private skills', content: 'The private skills tree is empty.' }
52
+ }
53
+ const lines = skills.map(entry => {
54
+ const display = entry.displayName ?? entry.name
55
+ const desc = entry.description ? ` — ${entry.description}` : ''
56
+ const when = entry.whenToUse ? ` (when: ${entry.whenToUse})` : ''
57
+ const vis = entry.visibility !== 'private' ? ` [visibility: ${entry.visibility}]` : ''
58
+ const supporting = supportingCounts[entry.name] ?? 0
59
+ const trailer = supporting > 0 ? ` (+${supporting} supporting file${supporting === 1 ? '' : 's'})` : ''
60
+ return `- ${display}${desc}${when}${vis}${trailer}`
61
+ })
62
+ return {
63
+ ok: true,
64
+ summary: `listed ${skills.length} private skill${skills.length === 1 ? '' : 's'}`,
65
+ content: lines.join('\n'),
66
+ }
67
+ },
68
+ }
@@ -30,7 +30,7 @@ export const listMcpResourcesTool: Tool<typeof ListMcpResourcesInput> = {
30
30
  const serverName = input.server ?? '*'
31
31
  return {
32
32
  kind: 'mcp',
33
- title: 'allow MCP resource listing?',
33
+ title: 'Allow MCP resource listing?',
34
34
  subtitle: input.server ? `list resources from ${input.server}` : 'list resources from all connected MCP servers',
35
35
  serverName,
36
36
  normalizedServerName: normalizeNameForMcp(serverName),
@@ -72,7 +72,7 @@ export const readMcpResourceTool: Tool<typeof ReadMcpResourceInput> = {
72
72
  async buildPermissionRequest(input) {
73
73
  return {
74
74
  kind: 'mcp',
75
- title: 'allow MCP resource read?',
75
+ title: 'Allow MCP resource read?',
76
76
  subtitle: `${input.server} / ${input.uri}`,
77
77
  serverName: input.server,
78
78
  normalizedServerName: normalizeNameForMcp(input.server),
@@ -43,7 +43,7 @@ export const privateContinuityReadTool: Tool<typeof schema> = {
43
43
  path: prepared.fullPath,
44
44
  relativePath: prepared.relativePath,
45
45
  directoryPath: prepared.directoryPath,
46
- title: 'allow private continuity read?',
46
+ title: 'Allow private continuity read?',
47
47
  subtitle: input.startLine || input.endLine
48
48
  ? `${prepared.fullPath} · lines ${input.startLine ?? 1}-${input.endLine ?? 'end'}`
49
49
  : prepared.fullPath,