@soederpop/luca 0.0.6 → 0.0.8

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 (208) hide show
  1. package/CLAUDE.md +10 -1
  2. package/RUNME.md +56 -0
  3. package/bun.lock +1 -1
  4. package/commands/build-bootstrap.ts +78 -0
  5. package/commands/build-scaffolds.ts +24 -2
  6. package/commands/try-all-challenges.ts +543 -0
  7. package/commands/try-challenge.ts +100 -0
  8. package/docs/README.md +52 -80
  9. package/docs/TABLE-OF-CONTENTS.md +82 -51
  10. package/docs/apis/clients/elevenlabs.md +232 -8
  11. package/docs/apis/clients/graph.md +59 -8
  12. package/docs/apis/clients/openai.md +362 -2
  13. package/docs/apis/clients/rest.md +122 -2
  14. package/docs/apis/clients/websocket.md +71 -17
  15. package/docs/apis/features/agi/assistant.md +9 -3
  16. package/docs/apis/features/agi/assistants-manager.md +2 -2
  17. package/docs/apis/features/agi/claude-code.md +153 -14
  18. package/docs/apis/features/agi/conversation-history.md +15 -3
  19. package/docs/apis/features/agi/conversation.md +133 -20
  20. package/docs/apis/features/agi/openai-codex.md +90 -12
  21. package/docs/apis/features/agi/skills-library.md +23 -5
  22. package/docs/apis/features/node/container-link.md +59 -0
  23. package/docs/apis/features/node/content-db.md +1 -1
  24. package/docs/apis/features/node/disk-cache.md +1 -1
  25. package/docs/apis/features/node/dns.md +1 -0
  26. package/docs/apis/features/node/docker.md +2 -1
  27. package/docs/apis/features/node/esbuild.md +4 -3
  28. package/docs/apis/features/node/file-manager.md +13 -4
  29. package/docs/apis/features/node/fs.md +726 -171
  30. package/docs/apis/features/node/git.md +1 -0
  31. package/docs/apis/features/node/google-auth.md +23 -4
  32. package/docs/apis/features/node/google-calendar.md +14 -2
  33. package/docs/apis/features/node/google-docs.md +15 -2
  34. package/docs/apis/features/node/google-drive.md +21 -3
  35. package/docs/apis/features/node/google-sheets.md +14 -2
  36. package/docs/apis/features/node/grep.md +2 -0
  37. package/docs/apis/features/node/helpers.md +29 -0
  38. package/docs/apis/features/node/ink.md +2 -2
  39. package/docs/apis/features/node/networking.md +39 -4
  40. package/docs/apis/features/node/os.md +28 -0
  41. package/docs/apis/features/node/postgres.md +26 -4
  42. package/docs/apis/features/node/proc.md +37 -28
  43. package/docs/apis/features/node/process-manager.md +33 -5
  44. package/docs/apis/features/node/repl.md +1 -1
  45. package/docs/apis/features/node/runpod.md +1 -0
  46. package/docs/apis/features/node/secure-shell.md +7 -0
  47. package/docs/apis/features/node/semantic-search.md +12 -5
  48. package/docs/apis/features/node/sqlite.md +26 -4
  49. package/docs/apis/features/node/telegram.md +30 -5
  50. package/docs/apis/features/node/tts.md +17 -2
  51. package/docs/apis/features/node/ui.md +1 -1
  52. package/docs/apis/features/node/vault.md +4 -9
  53. package/docs/apis/features/node/vm.md +3 -12
  54. package/docs/apis/features/node/window-manager.md +128 -20
  55. package/docs/apis/features/web/asset-loader.md +13 -1
  56. package/docs/apis/features/web/container-link.md +59 -0
  57. package/docs/apis/features/web/esbuild.md +4 -3
  58. package/docs/apis/features/web/helpers.md +29 -0
  59. package/docs/apis/features/web/network.md +16 -2
  60. package/docs/apis/features/web/speech.md +16 -2
  61. package/docs/apis/features/web/vault.md +4 -9
  62. package/docs/apis/features/web/vm.md +3 -12
  63. package/docs/apis/features/web/voice.md +18 -1
  64. package/docs/apis/servers/express.md +18 -2
  65. package/docs/apis/servers/mcp.md +29 -4
  66. package/docs/apis/servers/websocket.md +34 -6
  67. package/docs/bootstrap/CLAUDE.md +100 -0
  68. package/docs/bootstrap/SKILL.md +222 -0
  69. package/docs/bootstrap/templates/about-command.ts +41 -0
  70. package/docs/bootstrap/templates/docs-models.ts +22 -0
  71. package/docs/bootstrap/templates/docs-readme.md +43 -0
  72. package/docs/bootstrap/templates/example-feature.ts +53 -0
  73. package/docs/bootstrap/templates/health-endpoint.ts +15 -0
  74. package/docs/bootstrap/templates/luca-cli.ts +25 -0
  75. package/docs/bootstrap/templates/runme.md +54 -0
  76. package/docs/challenges/caching-proxy.md +16 -0
  77. package/docs/challenges/content-db-round-trip.md +14 -0
  78. package/docs/challenges/custom-command.md +9 -0
  79. package/docs/challenges/file-watcher-pipeline.md +11 -0
  80. package/docs/challenges/grep-audit-report.md +15 -0
  81. package/docs/challenges/multi-feature-dashboard.md +14 -0
  82. package/docs/challenges/process-orchestrator.md +17 -0
  83. package/docs/challenges/rest-api-server-with-client.md +12 -0
  84. package/docs/challenges/script-runner-with-vm.md +11 -0
  85. package/docs/challenges/simple-rest-api.md +15 -0
  86. package/docs/challenges/websocket-serve-and-client.md +11 -0
  87. package/docs/challenges/yaml-config-system.md +14 -0
  88. package/docs/command-system-overhaul.md +94 -0
  89. package/docs/examples/assistant/CORE.md +18 -0
  90. package/docs/examples/assistant/hooks.ts +3 -0
  91. package/docs/examples/assistant/tools.ts +10 -0
  92. package/docs/examples/window-manager-layouts.md +180 -0
  93. package/docs/in-memory-fs.md +4 -0
  94. package/docs/models.ts +13 -10
  95. package/docs/philosophy.md +4 -3
  96. package/docs/reports/console-hmr-design.md +170 -0
  97. package/docs/reports/helper-semantic-search.md +72 -0
  98. package/docs/scaffolds/client.md +29 -20
  99. package/docs/scaffolds/command.md +64 -50
  100. package/docs/scaffolds/endpoint.md +31 -36
  101. package/docs/scaffolds/feature.md +28 -18
  102. package/docs/scaffolds/selector.md +91 -0
  103. package/docs/scaffolds/server.md +18 -9
  104. package/docs/selectors.md +115 -0
  105. package/docs/sessions/custom-command/attempt-log-2.md +195 -0
  106. package/docs/sessions/file-watcher-pipeline/attempt-log-1.md +728 -0
  107. package/docs/sessions/file-watcher-pipeline/attempt-log-2.md +555 -0
  108. package/docs/sessions/grep-audit-report/attempt-log-1.md +289 -0
  109. package/docs/sessions/multi-feature-dashboard/attempt-log-2.md +679 -0
  110. package/docs/sessions/rest-api-server-with-client/attempt-log-1.md +1 -0
  111. package/docs/sessions/rest-api-server-with-client/attempt-log-3.md +920 -0
  112. package/docs/sessions/simple-rest-api/attempt-log-1.md +593 -0
  113. package/docs/sessions/websocket-serve-and-client/attempt-log-2.md +995 -0
  114. package/docs/tutorials/00-bootstrap.md +148 -0
  115. package/docs/tutorials/07-endpoints.md +7 -7
  116. package/docs/tutorials/08-commands.md +153 -72
  117. package/luca.cli.ts +3 -0
  118. package/package.json +6 -5
  119. package/public/index.html +1430 -0
  120. package/scripts/examples/using-ollama.ts +2 -1
  121. package/scripts/update-introspection-data.ts +2 -2
  122. package/src/agi/endpoints/experts.ts +1 -1
  123. package/src/agi/features/assistant.ts +7 -0
  124. package/src/agi/features/assistants-manager.ts +5 -5
  125. package/src/agi/features/claude-code.ts +263 -3
  126. package/src/agi/features/conversation-history.ts +7 -1
  127. package/src/agi/features/conversation.ts +26 -3
  128. package/src/agi/features/openai-codex.ts +26 -2
  129. package/src/agi/features/openapi.ts +6 -1
  130. package/src/agi/features/skills-library.ts +9 -1
  131. package/src/bootstrap/generated.ts +595 -0
  132. package/src/cli/cli.ts +64 -21
  133. package/src/client.ts +23 -357
  134. package/src/clients/civitai/index.ts +1 -1
  135. package/src/clients/client-template.ts +1 -1
  136. package/src/clients/comfyui/index.ts +13 -2
  137. package/src/clients/elevenlabs/index.ts +2 -1
  138. package/src/clients/graph.ts +87 -0
  139. package/src/clients/openai/index.ts +10 -1
  140. package/src/clients/rest.ts +207 -0
  141. package/src/clients/websocket.ts +176 -0
  142. package/src/command.ts +281 -34
  143. package/src/commands/bootstrap.ts +185 -0
  144. package/src/commands/chat.ts +5 -4
  145. package/src/commands/describe.ts +341 -4
  146. package/src/commands/help.ts +35 -9
  147. package/src/commands/index.ts +3 -0
  148. package/src/commands/introspect.ts +92 -2
  149. package/src/commands/prompt.ts +5 -6
  150. package/src/commands/run.ts +75 -10
  151. package/src/commands/save-api-docs.ts +49 -0
  152. package/src/commands/scaffold.ts +169 -23
  153. package/src/commands/select.ts +94 -0
  154. package/src/commands/serve.ts +10 -1
  155. package/src/container.ts +15 -0
  156. package/src/endpoint.ts +19 -0
  157. package/src/graft.ts +181 -0
  158. package/src/introspection/generated.agi.ts +12458 -8968
  159. package/src/introspection/generated.node.ts +10573 -7145
  160. package/src/introspection/generated.web.ts +1 -1
  161. package/src/introspection/index.ts +26 -0
  162. package/src/node/container.ts +6 -7
  163. package/src/node/features/content-db.ts +49 -2
  164. package/src/node/features/disk-cache.ts +16 -9
  165. package/src/node/features/dns.ts +16 -3
  166. package/src/node/features/docker.ts +16 -4
  167. package/src/node/features/esbuild.ts +22 -2
  168. package/src/node/features/file-manager.ts +184 -29
  169. package/src/node/features/fs.ts +704 -248
  170. package/src/node/features/git.ts +21 -8
  171. package/src/node/features/grep.ts +23 -3
  172. package/src/node/features/helpers.ts +372 -43
  173. package/src/node/features/networking.ts +39 -4
  174. package/src/node/features/opener.ts +28 -15
  175. package/src/node/features/os.ts +76 -0
  176. package/src/node/features/port-exposer.ts +11 -1
  177. package/src/node/features/postgres.ts +17 -1
  178. package/src/node/features/proc.ts +4 -1
  179. package/src/node/features/python.ts +63 -14
  180. package/src/node/features/repl.ts +11 -7
  181. package/src/node/features/runpod.ts +16 -3
  182. package/src/node/features/secure-shell.ts +27 -2
  183. package/src/node/features/semantic-search.ts +12 -1
  184. package/src/node/features/ui.ts +5 -69
  185. package/src/node/features/vm.ts +17 -0
  186. package/src/node/features/window-manager.ts +68 -20
  187. package/src/node.ts +5 -0
  188. package/src/scaffolds/generated.ts +492 -290
  189. package/src/scaffolds/template.ts +9 -0
  190. package/src/schemas/base.ts +46 -5
  191. package/src/selector.ts +282 -0
  192. package/src/server.ts +11 -0
  193. package/src/servers/express.ts +27 -12
  194. package/src/servers/socket.ts +45 -11
  195. package/src/web/clients/socket.ts +4 -1
  196. package/src/web/container.ts +2 -1
  197. package/src/web/features/network.ts +7 -1
  198. package/src/web/features/voice-recognition.ts +16 -1
  199. package/test/clients-servers.test.ts +2 -1
  200. package/test/command.test.ts +267 -0
  201. package/test/vm-context.test.ts +146 -0
  202. package/test-integration/assistants-manager.test.ts +10 -20
  203. package/docs/apis/features/node/launcher-app-command-listener.md +0 -145
  204. package/docs/examples/launcher-app-command-listener.md +0 -120
  205. package/docs/tasks/web-container-helper-discovery.md +0 -71
  206. package/docs/todos.md +0 -1
  207. package/scripts/test-command-listener.ts +0 -123
  208. package/src/node/features/launcher-app-command-listener.ts +0 -389
@@ -2,7 +2,8 @@ import container from '@soederpop/luca/agi'
2
2
 
3
3
  const conversation = container.feature('conversation', {
4
4
  local: true,
5
- model: "qwen2.5:7b"
5
+ model: "qwen/qwen3-coder-30b",
6
+ api: "responses"
6
7
  })
7
8
 
8
9
  const response = await conversation.ask('What model am I using?')
@@ -14,7 +14,7 @@ import { NodeContainer } from '../src/node/container.js';
14
14
  const targets = [
15
15
  {
16
16
  name: 'node',
17
- src: ['src/node/features', 'src/servers', 'src/container.ts', 'src/node/container.ts'],
17
+ src: ['src/node/features', 'src/clients', 'src/servers', 'src/container.ts', 'src/node/container.ts'],
18
18
  outputPath: 'src/introspection/generated.node.ts',
19
19
  },
20
20
  {
@@ -24,7 +24,7 @@ const targets = [
24
24
  },
25
25
  {
26
26
  name: 'agi',
27
- src: ['src/node/features', 'src/servers', 'src/agi/features', 'src/container.ts', 'src/node/container.ts', 'src/agi/container.server.ts'],
27
+ src: ['src/node/features', 'src/clients', 'src/servers', 'src/agi/features', 'src/container.ts', 'src/node/container.ts', 'src/agi/container.server.ts'],
28
28
  outputPath: 'src/introspection/generated.agi.ts',
29
29
  },
30
30
  ];
@@ -19,7 +19,7 @@ export async function get(_parameters: any, ctx: EndpointContext) {
19
19
 
20
20
  for (const relativePath of promptFiles) {
21
21
  const name = relativePath.split('/')[1]
22
- const prompt = (await fs.readFileAsync(container.paths.resolve(relativePath))).toString()
22
+ const prompt = await fs.readFileAsync(container.paths.resolve(relativePath))
23
23
  const lines = prompt.split('\n').filter((l: string) => l.trim())
24
24
  const title = lines[0]?.replace(/^#+\s*/, '') || name
25
25
  const description = lines[1] || ''
@@ -65,6 +65,8 @@ export const AssistantOptionsSchema = FeatureOptionsSchema.extend({
65
65
 
66
66
  maxTokens: z.number().optional().describe('Maximum number of output tokens per completion'),
67
67
 
68
+ local: z.boolean().default(false).describe('Whether to use our local models for this'),
69
+
68
70
  /** History persistence mode: lifecycle (ephemeral), daily (auto-resume per day), persistent (single long-running thread), session (unique per run, resumable) */
69
71
  historyMode: z.enum(['lifecycle', 'daily', 'persistent', 'session']).optional().describe('Conversation history persistence mode'),
70
72
  })
@@ -110,6 +112,10 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
110
112
  return super.container as AGIContainer
111
113
  }
112
114
 
115
+ get name() {
116
+ return this.resolvedFolder.split('/').pop()
117
+ }
118
+
113
119
  /** The absolute resolved path to the assistant folder. */
114
120
  get resolvedFolder(): string {
115
121
  return this.container.paths.resolve(this.options.folder)
@@ -203,6 +209,7 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
203
209
  if (!this._conversation) {
204
210
  this._conversation = this.container.feature('conversation', {
205
211
  model: this.options.model || 'gpt-5.2',
212
+ local: !!this.options.local,
206
213
  tools: this._tools || this.loadTools(),
207
214
  ...(this.options.maxTokens ? { maxTokens: this.options.maxTokens } : {}),
208
215
  history: [
@@ -96,9 +96,9 @@ export class AssistantsManager extends Feature<AssistantsManagerState, Assistant
96
96
  private _entries: Map<string, AssistantEntry> = new Map()
97
97
  private _instances: Map<string, Assistant> = new Map()
98
98
 
99
- override afterInitialize() {
99
+ override async afterInitialize() {
100
100
  if (this.options.autoDiscover) {
101
- this.discover()
101
+ await this.discover()
102
102
  }
103
103
  }
104
104
 
@@ -107,13 +107,13 @@ export class AssistantsManager extends Feature<AssistantsManagerState, Assistant
107
107
  * using the fileManager. Each directory containing a CORE.md is
108
108
  * treated as an assistant definition.
109
109
  *
110
- * @returns {this} This instance, for chaining
110
+ * @returns {Promise<this>} This instance, for chaining
111
111
  */
112
- discover(): this {
112
+ async discover(): Promise<this> {
113
113
  const { fs, paths } = this.container
114
114
  const fileManager = this.container.feature('fileManager') as any
115
115
 
116
- fileManager.start()
116
+ await fileManager.start()
117
117
 
118
118
  this._entries.clear()
119
119
 
@@ -1,6 +1,6 @@
1
1
  // @ts-nocheck
2
2
  import { z } from 'zod'
3
- import { FeatureStateSchema, FeatureOptionsSchema } from '../../schemas/base.js'
3
+ import { FeatureStateSchema, FeatureOptionsSchema, FeatureEventsSchema } from '../../schemas/base.js'
4
4
  import { type AvailableFeatures } from '@soederpop/luca/feature'
5
5
  import { Feature } from '@soederpop/luca/feature'
6
6
 
@@ -136,6 +136,8 @@ export const FileLogLevelSchema = z.enum(['verbose', 'normal', 'minimal']).descr
136
136
  export type FileLogLevel = z.infer<typeof FileLogLevelSchema>
137
137
 
138
138
  export const ClaudeCodeOptionsSchema = FeatureOptionsSchema.extend({
139
+ /** Claude CLI session ID to resume by default. When set, subsequent run()/start() calls will resume this session unless overridden. */
140
+ session: z.string().optional().describe('Claude CLI session ID to resume by default'),
139
141
  /** Path to the claude CLI binary. Defaults to 'claude'. */
140
142
  claudePath: z.string().optional().describe('Path to the claude CLI binary'),
141
143
  /** Default model to use for sessions. */
@@ -182,6 +184,21 @@ export const ClaudeCodeOptionsSchema = FeatureOptionsSchema.extend({
182
184
  skillsFolders: z.array(z.string()).optional().describe('Directories containing Claude Code skills to load into sessions'),
183
185
  })
184
186
 
187
+ export const ClaudeCodeEventsSchema = FeatureEventsSchema.extend({
188
+ 'session:start': z.tuple([z.object({ sessionId: z.string(), prompt: z.string() })]).describe('Fired when a new Claude Code session is spawned'),
189
+ 'session:init': z.tuple([z.object({ sessionId: z.string(), init: z.any() })]).describe('Fired when the CLI emits its init system event'),
190
+ 'session:event': z.tuple([z.object({ sessionId: z.string(), event: z.any() })]).describe('Fired for every parsed JSON event from the CLI stream'),
191
+ 'session:stream': z.tuple([z.object({ sessionId: z.string(), streamEvent: z.any() })]).describe('Fired for stream_event type events from the CLI'),
192
+ 'session:delta': z.tuple([z.object({ sessionId: z.string(), text: z.string(), role: z.string() })]).describe('Fired for each text delta from an assistant message'),
193
+ 'session:message': z.tuple([z.object({ sessionId: z.string(), message: z.any() })]).describe('Fired when a complete assistant message is received'),
194
+ 'session:result': z.tuple([z.object({ sessionId: z.string(), result: z.string() })]).describe('Fired when a session completes with a final result'),
195
+ 'session:error': z.tuple([z.object({ sessionId: z.string(), error: z.any(), exitCode: z.number().optional() })]).describe('Fired when a session encounters an error'),
196
+ 'session:abort': z.tuple([z.object({ sessionId: z.string() })]).describe('Fired when a session is aborted by the user'),
197
+ 'session:warning': z.tuple([z.object({ sessionId: z.string(), message: z.string() })]).describe('Fired when the log reader encounters a warning'),
198
+ 'session:log-error': z.tuple([z.object({ sessionId: z.string(), error: z.any() })]).describe('Fired when the log reader encounters an error'),
199
+ 'session:parse-error': z.tuple([z.object({ sessionId: z.string(), line: z.string() })]).describe('Fired when a JSON line from the CLI cannot be parsed'),
200
+ }).describe('ClaudeCode events')
201
+
185
202
  export type ClaudeCodeState = z.infer<typeof ClaudeCodeStateSchema>
186
203
  export type ClaudeCodeOptions = z.infer<typeof ClaudeCodeOptionsSchema>
187
204
 
@@ -277,6 +294,7 @@ export interface RunOptions {
277
294
  export class ClaudeCode extends Feature<ClaudeCodeState, ClaudeCodeOptions> {
278
295
  static override stateSchema = ClaudeCodeStateSchema
279
296
  static override optionsSchema = ClaudeCodeOptionsSchema
297
+ static override eventsSchema = ClaudeCodeEventsSchema
280
298
  static override shortcut = 'features.claudeCode' as const
281
299
  static override envVars = ['TMPDIR']
282
300
 
@@ -296,8 +314,17 @@ export class ClaudeCode extends Feature<ClaudeCodeState, ClaudeCodeOptions> {
296
314
  *
297
315
  * @returns {string} The path to the claude binary
298
316
  */
317
+ private _resolvedClaudePath: string | null = null
318
+
299
319
  get claudePath(): string {
300
- return this.options.claudePath || 'claude'
320
+ if (this.options.claudePath) return this.options.claudePath
321
+ if (this._resolvedClaudePath) return this._resolvedClaudePath
322
+ try {
323
+ this._resolvedClaudePath = this.container.feature('proc').resolveRealPath('claude')
324
+ } catch {
325
+ this._resolvedClaudePath = 'claude'
326
+ }
327
+ return this._resolvedClaudePath
301
328
  }
302
329
 
303
330
  /**
@@ -511,7 +538,8 @@ export class ClaudeCode extends Feature<ClaudeCodeState, ClaudeCodeOptions> {
511
538
 
512
539
  if (configPaths.length) args.push('--mcp-config', ...configPaths)
513
540
 
514
- if (options.resumeSessionId) args.push('--resume', options.resumeSessionId)
541
+ const resumeSessionId = options.resumeSessionId ?? (options.sessionId ? undefined : this.options.session)
542
+ if (resumeSessionId) args.push('--resume', resumeSessionId)
515
543
  if (options.continue) args.push('--continue')
516
544
  if (options.dangerouslySkipPermissions) args.push('--dangerously-skip-permissions')
517
545
 
@@ -1082,6 +1110,238 @@ export class ClaudeCode extends Feature<ClaudeCodeState, ClaudeCodeOptions> {
1082
1110
  }
1083
1111
  }
1084
1112
 
1113
+ /**
1114
+ * The Claude CLI session ID of the most recently initialized session,
1115
+ * or the session set via the `session` option. Useful for resuming later.
1116
+ *
1117
+ * @returns {string | undefined} The Claude CLI session ID
1118
+ *
1119
+ * @example
1120
+ * ```typescript
1121
+ * const cc = container.feature('claudeCode')
1122
+ * await cc.run('Do something')
1123
+ * console.log(cc.sessionId) // the Claude CLI session ID
1124
+ * ```
1125
+ */
1126
+ get sessionId(): string | undefined {
1127
+ // Check if a default session was set via options
1128
+ if (this.options.session) return this.options.session
1129
+
1130
+ // Find the most recently created session that has a Claude CLI sessionId
1131
+ const sessions = Object.values(this.state.current.sessions) as ClaudeSession[]
1132
+ if (sessions.length === 0) return undefined
1133
+
1134
+ // Return the last session's Claude CLI session ID
1135
+ const last = sessions[sessions.length - 1]
1136
+ return last?.sessionId
1137
+ }
1138
+
1139
+ /**
1140
+ * Export session history as a readable markdown document.
1141
+ * Reads from a raw JSONL file (Claude CLI session log or this feature's NDJSON log)
1142
+ * so it works independently of in-memory state.
1143
+ *
1144
+ * Can also accept a local session ID to export from in-memory state as a fallback.
1145
+ *
1146
+ * @param {string} [source] - Path to a JSONL file, a local session ID, or omit for the most recent session
1147
+ * @returns {Promise<string>} Markdown-formatted session history
1148
+ *
1149
+ * @example
1150
+ * ```typescript
1151
+ * // From a JSONL file (works without any prior state)
1152
+ * const md = await cc.sessionHistoryToMarkdown('/path/to/session.jsonl')
1153
+ *
1154
+ * // From the most recent in-memory session
1155
+ * const md = await cc.sessionHistoryToMarkdown()
1156
+ *
1157
+ * // From a specific local session ID
1158
+ * const md = await cc.sessionHistoryToMarkdown(localSessionId)
1159
+ * ```
1160
+ */
1161
+ async sessionHistoryToMarkdown(source?: string): Promise<string> {
1162
+ // If source looks like a file path, read JSONL from disk
1163
+ if (source && (source.includes('/') || source.endsWith('.jsonl'))) {
1164
+ return this.jsonlToMarkdown(source)
1165
+ }
1166
+
1167
+ // Otherwise try to resolve from in-memory state
1168
+ const sessionId = source || this.findLastSessionId()
1169
+ if (!sessionId) throw new Error('No session found. Pass a JSONL file path or run a session first.')
1170
+
1171
+ const session = this.state.current.sessions[sessionId] as ClaudeSession | undefined
1172
+ if (!session) throw new Error(`Session ${sessionId} not found in state. Pass a JSONL file path instead.`)
1173
+
1174
+ return this.sessionToMarkdown(session)
1175
+ }
1176
+
1177
+ /**
1178
+ * Find the local ID of the most recent session.
1179
+ */
1180
+ private findLastSessionId(): string | undefined {
1181
+ const ids = Object.keys(this.state.current.sessions)
1182
+ return ids.length > 0 ? ids[ids.length - 1] : undefined
1183
+ }
1184
+
1185
+ /**
1186
+ * Parse a JSONL file and convert its events to markdown.
1187
+ */
1188
+ private async jsonlToMarkdown(filePath: string): Promise<string> {
1189
+ const fs = this.container.feature('fs')
1190
+ const content = await fs.readFileAsync(filePath, 'utf-8')
1191
+ const lines = content.split('\n').filter((l: string) => l.trim())
1192
+
1193
+ const events: ClaudeEvent[] = []
1194
+ for (const line of lines) {
1195
+ try {
1196
+ const parsed = JSON.parse(line)
1197
+ // Support both raw Claude events and our wrapper format (which has a .data field)
1198
+ events.push(parsed.data ?? parsed)
1199
+ } catch {
1200
+ // skip malformed lines
1201
+ }
1202
+ }
1203
+
1204
+ return this.eventsToMarkdown(events, filePath)
1205
+ }
1206
+
1207
+ /**
1208
+ * Convert a ClaudeSession (from state) to markdown.
1209
+ */
1210
+ private sessionToMarkdown(session: ClaudeSession): string {
1211
+ const lines: string[] = []
1212
+
1213
+ lines.push(`# Session: ${session.id}`)
1214
+ if (session.sessionId) lines.push(`**Claude Session ID:** \`${session.sessionId}\``)
1215
+ lines.push(`**Status:** ${session.status}`)
1216
+ if (session.costUsd) lines.push(`**Cost:** $${session.costUsd.toFixed(4)}`)
1217
+ if (session.turns) lines.push(`**Turns:** ${session.turns}`)
1218
+ lines.push('')
1219
+
1220
+ lines.push(`## Prompt`)
1221
+ lines.push('')
1222
+ lines.push(session.prompt)
1223
+ lines.push('')
1224
+
1225
+ if (session.messages.length > 0) {
1226
+ lines.push(`## Conversation`)
1227
+ lines.push('')
1228
+
1229
+ for (const msg of session.messages) {
1230
+ this.renderAssistantMessage(lines, msg)
1231
+ }
1232
+ }
1233
+
1234
+ if (session.result) {
1235
+ lines.push(`## Result`)
1236
+ lines.push('')
1237
+ lines.push(session.result)
1238
+ lines.push('')
1239
+ }
1240
+
1241
+ if (session.error) {
1242
+ lines.push(`## Error`)
1243
+ lines.push('')
1244
+ lines.push(`\`\`\`\n${session.error}\n\`\`\``)
1245
+ lines.push('')
1246
+ }
1247
+
1248
+ return lines.join('\n')
1249
+ }
1250
+
1251
+ /**
1252
+ * Convert raw Claude events to markdown.
1253
+ */
1254
+ private eventsToMarkdown(events: ClaudeEvent[], source: string): string {
1255
+ const lines: string[] = []
1256
+ let sessionId: string | undefined
1257
+ let model: string | undefined
1258
+ let prompt: string | undefined
1259
+ let costUsd: number | undefined
1260
+ let turns: number | undefined
1261
+ let durationMs: number | undefined
1262
+
1263
+ // Extract metadata from init and result events
1264
+ for (const event of events) {
1265
+ if (event.type === 'system' && (event as any).subtype === 'init') {
1266
+ const init = event as ClaudeInitEvent
1267
+ sessionId = init.session_id
1268
+ model = init.model
1269
+ }
1270
+ if (event.type === 'result') {
1271
+ const result = event as ClaudeResultEvent
1272
+ costUsd = result.total_cost_usd
1273
+ turns = result.num_turns
1274
+ durationMs = result.duration_ms
1275
+ }
1276
+ }
1277
+
1278
+ lines.push(`# Session History`)
1279
+ lines.push(`**Source:** \`${source}\``)
1280
+ if (sessionId) lines.push(`**Session ID:** \`${sessionId}\``)
1281
+ if (model) lines.push(`**Model:** ${model}`)
1282
+ if (costUsd != null) lines.push(`**Cost:** $${costUsd.toFixed(4)}`)
1283
+ if (turns != null) lines.push(`**Turns:** ${turns}`)
1284
+ if (durationMs != null) lines.push(`**Duration:** ${(durationMs / 1000).toFixed(1)}s`)
1285
+ lines.push('')
1286
+
1287
+ lines.push(`## Conversation`)
1288
+ lines.push('')
1289
+
1290
+ for (const event of events) {
1291
+ if (event.type === 'assistant') {
1292
+ this.renderAssistantMessage(lines, event as ClaudeAssistantMessage)
1293
+ } else if (event.type === 'tool_result') {
1294
+ const tr = event as ClaudeToolResult
1295
+ lines.push(`<details>`)
1296
+ lines.push(`<summary>Tool Result (${tr.tool_use_id})</summary>`)
1297
+ lines.push('')
1298
+ lines.push('```')
1299
+ lines.push(tr.content.length > 2000 ? tr.content.slice(0, 2000) + '\n... (truncated)' : tr.content)
1300
+ lines.push('```')
1301
+ lines.push(`</details>`)
1302
+ lines.push('')
1303
+ } else if (event.type === 'result') {
1304
+ const result = event as ClaudeResultEvent
1305
+ lines.push(`## Result`)
1306
+ lines.push('')
1307
+ if (result.is_error) {
1308
+ lines.push(`**Error:**`)
1309
+ lines.push(`\`\`\`\n${result.result}\n\`\`\``)
1310
+ } else {
1311
+ lines.push(result.result)
1312
+ }
1313
+ lines.push('')
1314
+ }
1315
+ }
1316
+
1317
+ return lines.join('\n')
1318
+ }
1319
+
1320
+ /**
1321
+ * Render a single assistant message to markdown lines.
1322
+ */
1323
+ private renderAssistantMessage(lines: string[], msg: ClaudeAssistantMessage): void {
1324
+ lines.push(`### Assistant`)
1325
+ if (msg.message?.usage) {
1326
+ const u = msg.message.usage
1327
+ lines.push(`*${u.input_tokens} in / ${u.output_tokens} out tokens*`)
1328
+ }
1329
+ lines.push('')
1330
+
1331
+ for (const block of msg.message?.content || []) {
1332
+ if (block.type === 'text') {
1333
+ lines.push(block.text)
1334
+ lines.push('')
1335
+ } else if (block.type === 'tool_use') {
1336
+ lines.push(`**Tool Use:** \`${block.name}\``)
1337
+ lines.push('```json')
1338
+ lines.push(JSON.stringify(block.input, null, 2))
1339
+ lines.push('```')
1340
+ lines.push('')
1341
+ }
1342
+ }
1343
+ }
1344
+
1085
1345
  /**
1086
1346
  * Clean up any temp MCP config files created during sessions.
1087
1347
  */
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod'
2
- import { FeatureStateSchema, FeatureOptionsSchema } from '../../schemas/base.js'
2
+ import { FeatureStateSchema, FeatureOptionsSchema, FeatureEventsSchema } from '../../schemas/base.js'
3
3
  import { type AvailableFeatures } from '@soederpop/luca/feature'
4
4
  import { Feature } from '@soederpop/luca/feature'
5
5
  import { NodeContainer, type DiskCache, type NodeFeatures } from '@soederpop/luca/node/container'
@@ -48,6 +48,11 @@ export const ConversationHistoryStateSchema = FeatureStateSchema.extend({
48
48
  lastSaved: z.string().optional().describe('ISO timestamp of the last save operation'),
49
49
  })
50
50
 
51
+ export const ConversationHistoryEventsSchema = FeatureEventsSchema.extend({
52
+ saved: z.tuple([z.string().describe('The conversation ID that was saved')]).describe('Fired after a conversation record is persisted'),
53
+ deleted: z.tuple([z.string().describe('The conversation ID that was deleted')]).describe('Fired after a conversation record is deleted'),
54
+ }).describe('ConversationHistory events')
55
+
51
56
  export type ConversationHistoryOptions = z.infer<typeof ConversationHistoryOptionsSchema>
52
57
  export type ConversationHistoryState = z.infer<typeof ConversationHistoryStateSchema>
53
58
 
@@ -76,6 +81,7 @@ export type ConversationHistoryState = z.infer<typeof ConversationHistoryStateSc
76
81
  export class ConversationHistory extends Feature<ConversationHistoryState, ConversationHistoryOptions> {
77
82
  static override stateSchema = ConversationHistoryStateSchema
78
83
  static override optionsSchema = ConversationHistoryOptionsSchema
84
+ static override eventsSchema = ConversationHistoryEventsSchema
79
85
  static override shortcut = 'features.conversationHistory' as const
80
86
 
81
87
  static { Feature.register(this, 'conversationHistory') }
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod'
2
- import { FeatureStateSchema, FeatureOptionsSchema } from '../../schemas/base.js'
2
+ import { FeatureStateSchema, FeatureOptionsSchema, FeatureEventsSchema } from '../../schemas/base.js'
3
3
  import { type AvailableFeatures } from '@soederpop/luca/feature'
4
4
  import { Feature } from '@soederpop/luca/feature'
5
5
  import type { OpenAIClient } from '../../clients/openai';
@@ -96,6 +96,28 @@ export const ConversationStateSchema = FeatureStateSchema.extend({
96
96
  contextWindow: z.number().describe('The context window size for the current model'),
97
97
  })
98
98
 
99
+ export const ConversationEventsSchema = FeatureEventsSchema.extend({
100
+ userMessage: z.tuple([z.any().describe('The user message content (string or ContentPart[])')]).describe('Fired when a user message is added to the conversation'),
101
+ turnStart: z.tuple([z.object({ turn: z.number(), isFollowUp: z.boolean() })]).describe('Fired at the start of each completion turn'),
102
+ turnEnd: z.tuple([z.object({ turn: z.number(), hasToolCalls: z.boolean() })]).describe('Fired at the end of each completion turn'),
103
+ toolCallsStart: z.tuple([z.any().describe('Array of tool call objects from the model')]).describe('Fired when the model begins a batch of tool calls'),
104
+ toolCall: z.tuple([z.string().describe('Tool name'), z.any().describe('Parsed arguments object')]).describe('Fired before invoking a single tool handler'),
105
+ toolResult: z.tuple([z.string().describe('Tool name'), z.string().describe('Serialized result')]).describe('Fired after a tool handler returns successfully'),
106
+ toolError: z.tuple([z.string().describe('Tool name'), z.any().describe('Error object or message')]).describe('Fired when a tool handler throws or the tool is unknown'),
107
+ toolCallsEnd: z.tuple([]).describe('Fired after all tool calls in a turn have been executed'),
108
+ chunk: z.tuple([z.string().describe('Text delta from the stream')]).describe('Fired for each streaming text delta'),
109
+ preview: z.tuple([z.string().describe('Accumulated text so far')]).describe('Fired after each chunk with the full accumulated text'),
110
+ response: z.tuple([z.string().describe('Final accumulated response text')]).describe('Fired when the final text response is produced'),
111
+ responseCompleted: z.tuple([z.any().describe('The completed OpenAI Response object')]).describe('Fired when the Responses API stream completes'),
112
+ rawEvent: z.tuple([z.any().describe('Raw stream event from the API')]).describe('Fired for every raw event from the Responses API stream'),
113
+ mcpEvent: z.tuple([z.any().describe('MCP-related stream event')]).describe('Fired for MCP-related events from the Responses API'),
114
+ summarizeStart: z.tuple([]).describe('Fired before generating a conversation summary'),
115
+ summarizeEnd: z.tuple([z.string().describe('The generated summary text')]).describe('Fired after the summary is generated'),
116
+ compactStart: z.tuple([z.object({ messageCount: z.number(), keepRecent: z.number() })]).describe('Fired before compacting the conversation history'),
117
+ compactEnd: z.tuple([z.object({ summary: z.string(), removedCount: z.number(), estimatedTokens: z.number(), compactionCount: z.number() })]).describe('Fired after compaction completes'),
118
+ autoCompactTriggered: z.tuple([z.object({ estimated: z.number(), limit: z.number(), contextWindow: z.number() })]).describe('Fired when auto-compact kicks in because tokens exceeded the threshold'),
119
+ }).describe('Conversation events')
120
+
99
121
  export type ConversationOptions = z.infer<typeof ConversationOptionsSchema>
100
122
  export type ConversationState = z.infer<typeof ConversationStateSchema>
101
123
 
@@ -122,6 +144,7 @@ export type AskOptions = {
122
144
  export class Conversation extends Feature<ConversationState, ConversationOptions> {
123
145
  static override stateSchema = ConversationStateSchema
124
146
  static override optionsSchema = ConversationOptionsSchema
147
+ static override eventsSchema = ConversationEventsSchema
125
148
  static override shortcut = 'features.conversation' as const
126
149
 
127
150
  static { Feature.register(this, 'conversation') }
@@ -438,11 +461,11 @@ export class Conversation extends Feature<ConversationState, ConversationOptions
438
461
  let baseURL = this.options.clientOptions?.baseURL ? this.options.clientOptions.baseURL : undefined
439
462
 
440
463
  if (this.options.local) {
441
- baseURL = "http://localhost:11434/v1"
464
+ baseURL = "http://localhost:1234/v1"
442
465
  }
443
466
 
444
467
  return (this.container as any).client('openai', {
445
- defaultModel: this.options.model || (this.options.local ? "qwen2.5:7b" : "gpt-4o"),
468
+ defaultModel: this.options.model || (this.options.local ? this.options.model || "qwen/qwen3-coder-30b" : "gpt-5"),
446
469
  ...this.options.clientOptions,
447
470
  ...(baseURL ? { baseURL } : {}),
448
471
  }) as OpenAIClient
@@ -1,6 +1,6 @@
1
1
  // @ts-nocheck
2
2
  import { z } from 'zod'
3
- import { FeatureStateSchema, FeatureOptionsSchema } from '../../schemas/base.js'
3
+ import { FeatureStateSchema, FeatureOptionsSchema, FeatureEventsSchema } from '../../schemas/base.js'
4
4
  import { type AvailableFeatures } from '@soederpop/luca/feature'
5
5
  import { Feature } from '@soederpop/luca/feature'
6
6
 
@@ -96,6 +96,20 @@ export const OpenAICodexOptionsSchema = FeatureOptionsSchema.extend({
96
96
  fullStdout: z.boolean().optional().describe('Do not truncate stdout/stderr from command outputs'),
97
97
  })
98
98
 
99
+ export const OpenAICodexEventsSchema = FeatureEventsSchema.extend({
100
+ 'session:start': z.tuple([z.object({ sessionId: z.string(), prompt: z.string() })]).describe('Fired when a new Codex session is spawned'),
101
+ 'session:event': z.tuple([z.object({ sessionId: z.string(), event: z.any() })]).describe('Fired for every parsed JSON event from the Codex CLI stream'),
102
+ 'session:delta': z.tuple([z.object({ sessionId: z.string(), text: z.string(), role: z.string() })]).describe('Fired for each text delta from an agent message'),
103
+ 'session:message': z.tuple([z.object({ sessionId: z.string(), message: z.any() })]).describe('Fired when a complete agent message is received'),
104
+ 'session:exec': z.tuple([z.object({ sessionId: z.string(), exec: z.any() })]).describe('Fired when a command execution item completes'),
105
+ 'session:exec-start': z.tuple([z.object({ sessionId: z.string(), command: z.string() })]).describe('Fired when a command execution item starts'),
106
+ 'session:reasoning': z.tuple([z.object({ sessionId: z.string(), text: z.string() })]).describe('Fired when a reasoning item is received'),
107
+ 'session:result': z.tuple([z.object({ sessionId: z.string(), result: z.string() })]).describe('Fired when a session completes with a final result'),
108
+ 'session:error': z.tuple([z.object({ sessionId: z.string(), error: z.any(), exitCode: z.number().optional() })]).describe('Fired when a session encounters an error'),
109
+ 'session:abort': z.tuple([z.object({ sessionId: z.string() })]).describe('Fired when a session is aborted by the user'),
110
+ 'session:parse-error': z.tuple([z.object({ sessionId: z.string(), line: z.string() })]).describe('Fired when a JSON line from the CLI cannot be parsed'),
111
+ }).describe('OpenAICodex events')
112
+
99
113
  export type OpenAICodexState = z.infer<typeof OpenAICodexStateSchema>
100
114
  export type OpenAICodexOptions = z.infer<typeof OpenAICodexOptionsSchema>
101
115
 
@@ -146,6 +160,7 @@ export interface CodexRunOptions {
146
160
  export class OpenAICodex extends Feature<OpenAICodexState, OpenAICodexOptions> {
147
161
  static override stateSchema = OpenAICodexStateSchema
148
162
  static override optionsSchema = OpenAICodexOptionsSchema
163
+ static override eventsSchema = OpenAICodexEventsSchema
149
164
  static override shortcut = 'features.openaiCodex' as const
150
165
 
151
166
  static { Feature.register(this, 'openaiCodex') }
@@ -159,9 +174,18 @@ export class OpenAICodex extends Feature<OpenAICodexState, OpenAICodexOptions> {
159
174
  }
160
175
  }
161
176
 
177
+ private _resolvedCodexPath: string | null = null
178
+
162
179
  /** @returns The path to the codex CLI binary, falling back to 'codex' on the PATH. */
163
180
  get codexPath(): string {
164
- return this.options.codexPath || 'codex'
181
+ if (this.options.codexPath) return this.options.codexPath
182
+ if (this._resolvedCodexPath) return this._resolvedCodexPath
183
+ try {
184
+ this._resolvedCodexPath = this.container.feature('proc').resolveRealPath('codex')
185
+ } catch {
186
+ this._resolvedCodexPath = 'codex'
187
+ }
188
+ return this._resolvedCodexPath
165
189
  }
166
190
 
167
191
  /**
@@ -1,5 +1,5 @@
1
1
  import { Feature } from '../../feature.js'
2
- import { FeatureStateSchema, FeatureOptionsSchema } from '../../schemas/base.js'
2
+ import { FeatureStateSchema, FeatureOptionsSchema, FeatureEventsSchema } from '../../schemas/base.js'
3
3
  import { z } from 'zod'
4
4
  import { camelCase } from 'lodash-es'
5
5
 
@@ -20,6 +20,10 @@ export const OpenAPIOptionsSchema = FeatureOptionsSchema.extend({
20
20
  url: z.string().optional().describe('URL to the OpenAPI/Swagger spec or the API server base URL')
21
21
  })
22
22
 
23
+ export const OpenAPIEventsSchema = FeatureEventsSchema.extend({
24
+ loaded: z.tuple([z.any().describe('The parsed OpenAPI spec object')]).describe('Fired after the spec is fetched and parsed'),
25
+ }).describe('OpenAPI events')
26
+
23
27
  export type OpenAPIOptions = z.infer<typeof OpenAPIOptionsSchema>
24
28
  export type OpenAPIState = z.infer<typeof OpenAPIStateSchema>
25
29
 
@@ -102,6 +106,7 @@ export class OpenAPI extends Feature<OpenAPIState, OpenAPIOptions> {
102
106
  static override description = 'Load and inspect OpenAPI specs, convert endpoints to OpenAI tool/function definitions'
103
107
  static override stateSchema = OpenAPIStateSchema
104
108
  static override optionsSchema = OpenAPIOptionsSchema
109
+ static override eventsSchema = OpenAPIEventsSchema
105
110
 
106
111
  static { Feature.register(this, 'openapi') }
107
112
 
@@ -4,7 +4,7 @@ import os from 'os'
4
4
  import fs from 'fs/promises'
5
5
  import yaml from 'js-yaml'
6
6
  import { kebabCase } from 'lodash-es'
7
- import { FeatureStateSchema, FeatureOptionsSchema } from '../../schemas/base.js'
7
+ import { FeatureStateSchema, FeatureOptionsSchema, FeatureEventsSchema } from '../../schemas/base.js'
8
8
  import { type AvailableFeatures, Feature } from '@soederpop/luca/feature'
9
9
  import { Collection, defineModel } from 'contentbase'
10
10
  import type { ConversationTool } from './conversation'
@@ -61,6 +61,13 @@ export const SkillsLibraryOptionsSchema = FeatureOptionsSchema.extend({
61
61
  userSkillsPath: z.string().optional().describe('Path to user-level global skills directory'),
62
62
  })
63
63
 
64
+ export const SkillsLibraryEventsSchema = FeatureEventsSchema.extend({
65
+ loaded: z.tuple([]).describe('Fired after both project and user skill collections are loaded'),
66
+ skillCreated: z.tuple([z.any().describe('The created SkillEntry object')]).describe('Fired after a new skill is written to disk'),
67
+ skillUpdated: z.tuple([z.any().describe('The updated SkillEntry object')]).describe('Fired after an existing skill is updated'),
68
+ skillRemoved: z.tuple([z.string().describe('The name of the removed skill')]).describe('Fired after a skill is deleted'),
69
+ }).describe('SkillsLibrary events')
70
+
64
71
  export type SkillsLibraryState = z.infer<typeof SkillsLibraryStateSchema>
65
72
  export type SkillsLibraryOptions = z.infer<typeof SkillsLibraryOptionsSchema>
66
73
 
@@ -91,6 +98,7 @@ export type SkillsLibraryOptions = z.infer<typeof SkillsLibraryOptionsSchema>
91
98
  export class SkillsLibrary extends Feature<SkillsLibraryState, SkillsLibraryOptions> {
92
99
  static override stateSchema = SkillsLibraryStateSchema
93
100
  static override optionsSchema = SkillsLibraryOptionsSchema
101
+ static override eventsSchema = SkillsLibraryEventsSchema
94
102
  static override shortcut = 'features.skillsLibrary' as const
95
103
 
96
104
  static { Feature.register(this, 'skillsLibrary') }