@soederpop/luca 0.0.23 → 0.0.25

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 (58) hide show
  1. package/AGENTS.md +1 -1
  2. package/CLAUDE.md +6 -1
  3. package/assistants/codingAssistant/hooks.ts +0 -1
  4. package/assistants/lucaExpert/CORE.md +37 -0
  5. package/assistants/lucaExpert/hooks.ts +9 -0
  6. package/assistants/lucaExpert/tools.ts +177 -0
  7. package/commands/build-bootstrap.ts +41 -1
  8. package/docs/TABLE-OF-CONTENTS.md +0 -1
  9. package/docs/apis/clients/rest.md +5 -5
  10. package/docs/apis/features/agi/assistant.md +1 -1
  11. package/docs/apis/features/agi/conversation-history.md +6 -7
  12. package/docs/apis/features/agi/conversation.md +1 -1
  13. package/docs/apis/features/agi/semantic-search.md +1 -1
  14. package/docs/bootstrap/CLAUDE.md +1 -1
  15. package/docs/bootstrap/SKILL.md +7 -3
  16. package/docs/bootstrap/templates/luca-cli.ts +5 -0
  17. package/docs/mcp/readme.md +1 -1
  18. package/docs/tutorials/00-bootstrap.md +18 -0
  19. package/package.json +2 -2
  20. package/scripts/stamp-build.sh +12 -0
  21. package/scripts/test-docs-reader.ts +10 -0
  22. package/src/agi/container.server.ts +8 -5
  23. package/src/agi/features/assistant.ts +208 -55
  24. package/src/agi/features/assistants-manager.ts +138 -66
  25. package/src/agi/features/conversation.ts +46 -14
  26. package/src/agi/features/docs-reader.ts +142 -0
  27. package/src/agi/features/openapi.ts +1 -1
  28. package/src/agi/features/skills-library.ts +257 -313
  29. package/src/bootstrap/generated.ts +8163 -6
  30. package/src/cli/build-info.ts +4 -0
  31. package/src/cli/cli.ts +2 -1
  32. package/src/commands/bootstrap.ts +16 -1
  33. package/src/commands/eval.ts +6 -1
  34. package/src/commands/sandbox-mcp.ts +17 -7
  35. package/src/helper.ts +56 -2
  36. package/src/introspection/generated.agi.ts +2409 -1608
  37. package/src/introspection/generated.node.ts +902 -594
  38. package/src/introspection/generated.web.ts +1 -1
  39. package/src/node/container.ts +1 -1
  40. package/src/node/features/content-db.ts +251 -13
  41. package/src/node/features/git.ts +90 -0
  42. package/src/node/features/grep.ts +1 -1
  43. package/src/node/features/proc.ts +1 -0
  44. package/src/node/features/tts.ts +1 -1
  45. package/src/node/features/vm.ts +48 -0
  46. package/src/scaffolds/generated.ts +2 -2
  47. package/assistants/architect/CORE.md +0 -3
  48. package/assistants/architect/hooks.ts +0 -3
  49. package/assistants/architect/tools.ts +0 -10
  50. package/docs/apis/features/agi/skills-library.md +0 -234
  51. package/docs/reports/assistant-bugs.md +0 -38
  52. package/docs/reports/attach-pattern-usage.md +0 -18
  53. package/docs/reports/code-audit-results.md +0 -391
  54. package/docs/reports/console-hmr-design.md +0 -170
  55. package/docs/reports/helper-semantic-search.md +0 -72
  56. package/docs/reports/introspection-audit-tasks.md +0 -378
  57. package/docs/reports/luca-mcp-improvements.md +0 -128
  58. package/test-integration/skills-library.test.ts +0 -157
@@ -1,7 +1,7 @@
1
1
  import { setBuildTimeData, setContainerBuildTimeData } from './index.js';
2
2
 
3
3
  // Auto-generated introspection registry data
4
- // Generated at: 2026-03-21T15:48:33.058Z
4
+ // Generated at: 2026-03-22T06:53:17.219Z
5
5
 
6
6
  setBuildTimeData('features.containerLink', {
7
7
  "id": "features.containerLink",
@@ -358,7 +358,7 @@ export class NodeContainer<
358
358
  return parse(path).dir
359
359
  },
360
360
  join(...paths: string[]) {
361
- return join(cwd, ...paths);
361
+ return resolve(cwd, ...paths)
362
362
  },
363
363
  resolve(...paths: string[]) {
364
364
  return resolve(cwd, ...paths);
@@ -3,8 +3,8 @@ import * as contentbaseExports from 'contentbase'
3
3
  import { parse, Collection, extractSections, type ModelDefinition } from 'contentbase'
4
4
  import { z } from 'zod'
5
5
  import { FeatureStateSchema, FeatureOptionsSchema, FeatureEventsSchema } from '../../schemas/base.js'
6
- import { join, dirname } from 'node:path'
7
- import { existsSync, readdirSync } from 'node:fs'
6
+ import { realpathSync } from 'node:fs'
7
+ import type { GrepOptions } from './grep.js'
8
8
 
9
9
  export const ContentDbStateSchema = FeatureStateSchema.extend({
10
10
  loaded: z.boolean().default(false).describe('Whether the content collection has been loaded and parsed'),
@@ -45,6 +45,71 @@ export class ContentDb extends Feature<ContentDbState, ContentDbOptions> {
45
45
  static override eventsSchema = ContentDbEventsSchema
46
46
  static { Feature.register(this, 'contentDb') }
47
47
 
48
+ /** Tools that any assistant can use to progressively explore this collection. */
49
+ static tools: Record<string, { schema: z.ZodType; handler?: Function }> = {
50
+ getCollectionOverview: {
51
+ schema: z.object({}).describe(
52
+ 'Get a high-level overview of the document collection: models, document counts, directory tree, and search index status. Call this first to understand what is available.'
53
+ ),
54
+ },
55
+ listDocuments: {
56
+ schema: z.object({
57
+ model: z.string().optional().describe('Filter to documents belonging to this model name'),
58
+ glob: z.string().optional().describe('Glob pattern to filter document path IDs (e.g. "guides/*", "apis/**")'),
59
+ }).describe(
60
+ 'List available document IDs in the collection, optionally filtered by model or glob pattern.'
61
+ ),
62
+ },
63
+ readDocument: {
64
+ schema: z.object({
65
+ id: z.string().describe('The document path ID to read (e.g. "guides/intro")'),
66
+ include: z.array(z.string()).optional().describe('Only return these section headings'),
67
+ exclude: z.array(z.string()).optional().describe('Remove these section headings from the output'),
68
+ meta: z.boolean().optional().describe('Include YAML frontmatter in the output'),
69
+ }).describe(
70
+ 'Read a single document by its path ID. Use include/exclude to read only specific sections and avoid loading unnecessary content.'
71
+ ),
72
+ },
73
+ readMultipleDocuments: {
74
+ schema: z.object({
75
+ ids: z.array(z.string()).describe('Array of document path IDs to read'),
76
+ include: z.array(z.string()).optional().describe('Only return these section headings from each document'),
77
+ exclude: z.array(z.string()).optional().describe('Remove these section headings from each document'),
78
+ meta: z.boolean().optional().describe('Include YAML frontmatter in the output'),
79
+ }).describe(
80
+ 'Read multiple documents at once, concatenated with dividers. Use include/exclude to focus on relevant sections.'
81
+ ),
82
+ },
83
+ queryDocuments: {
84
+ schema: z.object({
85
+ model: z.string().describe('The model name to query (e.g. "Plan", "Task")'),
86
+ where: z.string().optional().describe('Filter conditions as JSON string, e.g. \'{ "meta.status": "approved" }\' or \'{ "meta.priority": { "$gt": 3 } }\''),
87
+ sort: z.string().optional().describe('Sort specification as JSON string, e.g. \'{ "meta.priority": "desc" }\''),
88
+ limit: z.number().optional().describe('Maximum number of results to return'),
89
+ offset: z.number().optional().describe('Number of results to skip'),
90
+ select: z.array(z.string()).optional().describe('Fields to include in output (e.g. ["id", "title", "meta.status"])'),
91
+ }).describe(
92
+ 'Query documents by model with MongoDB-style filtering, sorting, and pagination. Returns serialized model instances.'
93
+ ),
94
+ },
95
+ searchContent: {
96
+ schema: z.object({
97
+ pattern: z.string().describe('Regex pattern to search for across all documents'),
98
+ caseSensitive: z.boolean().optional().describe('Whether the search is case-sensitive (default: false)'),
99
+ }).describe(
100
+ 'Text/regex search (grep) across all documents in the collection. Returns matching lines with file context.'
101
+ ),
102
+ },
103
+ semanticSearch: {
104
+ schema: z.object({
105
+ query: z.string().describe('Natural language search query'),
106
+ limit: z.number().optional().describe('Maximum number of results (default: 10)'),
107
+ }).describe(
108
+ 'Semantic search across documents using keyword + vector similarity. Falls back to text search if no search index exists.'
109
+ ),
110
+ },
111
+ }
112
+
48
113
  override get initialState(): ContentDbState {
49
114
  return {
50
115
  ...super.initialState,
@@ -54,7 +119,7 @@ export class ContentDb extends Feature<ContentDbState, ContentDbOptions> {
54
119
 
55
120
  /** Whether the content database has been loaded. */
56
121
  get isLoaded() {
57
- return this.state.get('loaded')
122
+ return this.state.get('started')
58
123
  }
59
124
 
60
125
  _collection?: Collection
@@ -82,7 +147,7 @@ export class ContentDb extends Feature<ContentDbState, ContentDbOptions> {
82
147
  /** Check if contentbase is resolvable via native import from the project root */
83
148
  private _canNativeImportContentbase(): boolean {
84
149
  const cwd = this.container.cwd
85
- return existsSync(join(cwd, 'node_modules', 'contentbase'))
150
+ return this.container.fs.exists(this.container.paths.resolve(cwd, 'node_modules', 'contentbase'))
86
151
  }
87
152
 
88
153
  /** Seed the VM with virtual modules so models.ts can import from 'contentbase', 'zod', etc. */
@@ -122,6 +187,61 @@ export class ContentDb extends Feature<ContentDbState, ContentDbOptions> {
122
187
  return this.collection.modelDefinitions.map((d) => d.name)
123
188
  }
124
189
 
190
+ /**
191
+ * Returns the available document ids in the collection
192
+ */
193
+ get available() : string[] {
194
+ return this.collection.available
195
+ }
196
+
197
+ /**
198
+ * Render a tree view of the collection directory structure.
199
+ * Built with container.fs so it works without the `tree` binary.
200
+ */
201
+ renderTree(options?: { depth?: number; dirsOnly?: boolean }): string {
202
+ const maxDepth = options?.depth ?? Infinity
203
+ const dirsOnly = options?.dirsOnly ?? false
204
+ const fs = this.container.fs
205
+ const paths = this.container.paths
206
+ const root = this.collectionPath
207
+ const lines: string[] = [paths.basename(root)]
208
+
209
+ const walk = (dir: string, prefix: string, currentDepth: number) => {
210
+ if (currentDepth >= maxDepth) return
211
+ const entries: string[] = fs.readdirSync(dir).sort()
212
+ const filtered = entries.filter((e: string) => {
213
+ if (e.startsWith('.')) return false
214
+ if (dirsOnly) return fs.isDirectory(paths.resolve(dir, e))
215
+ return true
216
+ })
217
+
218
+ filtered.forEach((entry: string, i: number) => {
219
+ const isLast = i === filtered.length - 1
220
+ const connector = isLast ? '└── ' : '├── '
221
+ const fullPath = paths.resolve(dir, entry)
222
+ lines.push(`${prefix}${connector}${entry}`)
223
+ if (fs.isDirectory(fullPath)) {
224
+ walk(fullPath, prefix + (isLast ? ' ' : '│ '), currentDepth + 1)
225
+ }
226
+ })
227
+ }
228
+
229
+ walk(root, '', 0)
230
+ return lines.join('\n')
231
+ }
232
+
233
+ async grep(options: string | GrepOptions) {
234
+ if (typeof options === 'string') {
235
+ options = { pattern: options }
236
+ }
237
+ return this.container.feature('grep').search({
238
+ path: this.collectionPath,
239
+ include: ['**/*.md'],
240
+ exclude: ['.env', 'secrets'],
241
+ ...options,
242
+ })
243
+ }
244
+
125
245
  /**
126
246
  * Query documents belonging to a specific model definition.
127
247
  *
@@ -170,7 +290,7 @@ export class ContentDb extends Feature<ContentDbState, ContentDbOptions> {
170
290
  }
171
291
 
172
292
  await this.collection.load()
173
- this.state.set('loaded', true)
293
+ this.state.set('started', true)
174
294
 
175
295
  return this
176
296
  }
@@ -315,7 +435,7 @@ export class ContentDb extends Feature<ContentDbState, ContentDbOptions> {
315
435
 
316
436
  /**
317
437
  * Lazily initialize the semanticSearch feature, attaching it to the container if needed.
318
- * The dbPath defaults to `.contentbase/search.sqlite` relative to the collection root.
438
+ * The dbPath defaults to `~/.luca/contentbase/{hash}/search.sqlite` where hash is derived from the resolved collection path.
319
439
  */
320
440
  private async _getSemanticSearch(options?: { dbPath?: string; embeddingProvider?: string; embeddingModel?: string }) {
321
441
  if (this._semanticSearch?.state?.get('dbReady')) return this._semanticSearch
@@ -326,9 +446,10 @@ export class ContentDb extends Feature<ContentDbState, ContentDbOptions> {
326
446
  SemanticSearch.attach(this.container as any)
327
447
  }
328
448
 
329
- // Put .contentbase at project root (dirname of docs/), not inside the docs folder
330
- const projectRoot = dirname(this.collectionPath)
331
- const dbPath = options?.dbPath ?? join(projectRoot, '.contentbase/search.sqlite')
449
+ // Store search index in ~/.luca/contentbase/{hash}/ keyed by the real (symlink-resolved) collection path
450
+ const realPath = realpathSync(this.collectionPath)
451
+ const pathHash = this.container.utils.hashObject(realPath).slice(0, 12)
452
+ const dbPath = options?.dbPath ?? this.container.paths.resolve(this.container.os.homedir, '.luca', 'contentbase', pathHash, 'search.sqlite')
332
453
  this._semanticSearch = (this.container as any).feature('semanticSearch', {
333
454
  dbPath,
334
455
  ...(options?.embeddingProvider ? { embeddingProvider: options.embeddingProvider } : {}),
@@ -343,10 +464,14 @@ export class ContentDb extends Feature<ContentDbState, ContentDbOptions> {
343
464
  * Check if a search index exists for this collection.
344
465
  */
345
466
  private _hasSearchIndex(): boolean {
346
- const dbDir = join(dirname(this.collectionPath), '.contentbase')
347
- if (!existsSync(dbDir)) return false
467
+ const realPath = realpathSync(this.collectionPath)
468
+ const pathHash = this.container.utils.hashObject(realPath).slice(0, 12)
469
+ const dbDir = this.container.paths.resolve(this.container.os.homedir, '.luca', 'contentbase', pathHash)
470
+
471
+ if (!this.container.fs.exists(dbDir)) return false
472
+
348
473
  try {
349
- const files = readdirSync(dbDir) as string[]
474
+ const files = this.container.fs.readdirSync(dbDir)
350
475
  return files.some((f: string) => f.startsWith('search.') && f.endsWith('.sqlite'))
351
476
  } catch {
352
477
  return false
@@ -525,6 +650,119 @@ export class ContentDb extends Feature<ContentDbState, ContentDbOptions> {
525
650
  }
526
651
  return Object.fromEntries(queryChains)
527
652
  }
653
+ // ── Tool Methods ─────────────────────────────────────────────────
654
+ // These methods are auto-bound as tool handlers by toTools() because
655
+ // their names match the keys in static tools above.
656
+
657
+ /** Returns a high-level overview of the collection. */
658
+ async getCollectionOverview() {
659
+ if (!this.isLoaded) await this.load()
660
+
661
+ const modelCounts: Record<string, number> = {}
662
+ for (const def of this.collection.modelDefinitions) {
663
+ const count = await this.collection.query(def).count()
664
+ modelCounts[def.name] = count
665
+ }
666
+
667
+ return {
668
+ rootPath: this.collectionPath,
669
+ totalDocuments: this.available.length,
670
+ models: modelCounts,
671
+ tree: this.renderTree({ depth: 2 }),
672
+ hasSearchIndex: this._hasSearchIndex(),
673
+ }
674
+ }
675
+
676
+ /** List document IDs, optionally filtered by model or glob. */
677
+ async listDocuments(args: { model?: string; glob?: string }) {
678
+ if (!this.isLoaded) await this.load()
679
+
680
+ let ids = this.available
681
+
682
+ if (args.model) {
683
+ const def = this.models[args.model]
684
+ if (!def) return { error: `Unknown model "${args.model}". Available: ${this.modelNames.join(', ')}` }
685
+ const instances = await this.collection.query(def).fetchAll()
686
+ ids = instances.map((inst: any) => inst.id)
687
+ }
688
+
689
+ if (args.glob) {
690
+ const matched = this.collection.matchPaths(args.glob)
691
+ ids = ids.filter((id: string) => matched.includes(id))
692
+ }
693
+
694
+ return ids
695
+ }
696
+
697
+ /** Read a single document with optional section filtering. */
698
+ async readDocument(args: { id: string; include?: string[]; exclude?: string[]; meta?: boolean }) {
699
+ return this.read(args.id, args)
700
+ }
701
+
702
+ /** Read multiple documents with optional section filtering. */
703
+ async readMultipleDocuments(args: { ids: string[]; include?: string[]; exclude?: string[]; meta?: boolean }) {
704
+ return this.readMultiple(args.ids, args)
705
+ }
706
+
707
+ /** Query documents by model with filters, sort, limit. */
708
+ async queryDocuments(args: { model: string; where?: string; sort?: string; limit?: number; offset?: number; select?: string[] }) {
709
+ if (!this.isLoaded) await this.load()
710
+
711
+ const def = this.models[args.model]
712
+ if (!def) return { error: `Unknown model "${args.model}". Available: ${this.modelNames.join(', ')}` }
713
+
714
+ let q = this.collection.query(def)
715
+
716
+ if (args.where) {
717
+ const where: Record<string, any> = typeof args.where === 'string' ? JSON.parse(args.where) : args.where
718
+ for (const [path, value] of Object.entries(where)) {
719
+ q = q.where(path, value)
720
+ }
721
+ }
722
+ if (args.sort) {
723
+ const sort: Record<string, string> = typeof args.sort === 'string' ? JSON.parse(args.sort) : args.sort
724
+ for (const [path, dir] of Object.entries(sort)) {
725
+ q = q.sort(path, dir as 'asc' | 'desc')
726
+ }
727
+ }
728
+ if (args.limit) q = q.limit(args.limit)
729
+ if (args.offset) q = q.offset(args.offset)
730
+
731
+ const results = await q.fetchAll()
732
+
733
+ return results.map((inst: any) => {
734
+ const json = inst.toJSON()
735
+ if (args.select?.length) {
736
+ const picked: Record<string, any> = {}
737
+ for (const field of args.select) {
738
+ picked[field] = this.container.utils.lodash.get(json, field)
739
+ }
740
+ return picked
741
+ }
742
+ return json
743
+ })
744
+ }
745
+
746
+ /** Grep/text search across the collection. */
747
+ async searchContent(args: { pattern: string; caseSensitive?: boolean }) {
748
+ return this.grep({
749
+ pattern: args.pattern,
750
+ caseSensitive: args.caseSensitive ?? false,
751
+ } as GrepOptions)
752
+ }
753
+
754
+ /** Hybrid semantic search with graceful fallback to grep. */
755
+ async semanticSearch(args: { query: string; limit?: number }) {
756
+ try {
757
+ return await this.hybridSearch(args.query, { limit: args.limit ?? 10 })
758
+ } catch {
759
+ const grepResults = await this.grep({ pattern: args.query })
760
+ return {
761
+ results: grepResults,
762
+ note: 'No search index available — fell back to text search. Run `cbase embed` to enable semantic search.',
763
+ }
764
+ }
765
+ }
528
766
  }
529
767
 
530
- export default ContentDb
768
+ export default ContentDb
@@ -425,6 +425,96 @@ export class Git extends Feature {
425
425
  return output
426
426
  }
427
427
 
428
+ /**
429
+ * Extracts a folder (or entire repo) from a remote GitHub repository without cloning.
430
+ *
431
+ * Downloads the repo as a tarball and extracts only the specified subfolder,
432
+ * similar to how degit works. No .git history is included — just the files.
433
+ *
434
+ * Supports shorthand (`user/repo/path`), branch refs (`user/repo/path#branch`),
435
+ * and full GitHub URLs (`https://github.com/user/repo/tree/branch/path`).
436
+ *
437
+ * @param {object} options
438
+ * @param {string} options.source - Repository source in degit-style shorthand
439
+ * @param {string} options.destination - Local path to extract files into
440
+ * @param {string} [options.branch] - Branch, tag, or commit ref (overrides ref in source string)
441
+ * @returns {Promise<{ files: string[], source: { user: string, repo: string, ref: string, subdir: string } }>}
442
+ *
443
+ * @example
444
+ * ```typescript
445
+ * // Extract a subfolder
446
+ * await git.extractFolder({ source: 'soederpop/luca/src/assistants', destination: './my-assistants' })
447
+ *
448
+ * // Specific branch
449
+ * await git.extractFolder({ source: 'sveltejs/template', destination: './my-app', branch: 'main' })
450
+ *
451
+ * // Full GitHub URL
452
+ * await git.extractFolder({ source: 'https://github.com/user/repo/tree/main/examples', destination: './examples' })
453
+ * ```
454
+ */
455
+ async extractFolder({ source, destination, branch }: { source: string, destination: string, branch?: string }) {
456
+ const parsed = this._parseRemoteSource(source)
457
+ if (branch) parsed.ref = branch
458
+
459
+ const tarballUrl = `https://github.com/${parsed.user}/${parsed.repo}/archive/${parsed.ref}.tar.gz`
460
+ const stamp = Date.now()
461
+ const fs = this.container.feature('fs')
462
+ const proc = this.container.feature('proc')
463
+ const tmpBase = this.container.paths.resolve(this.container.feature('os').tmpdir, 'luca-degit')
464
+ fs.ensureFolder(tmpBase)
465
+ const tarPath = this.container.paths.resolve(tmpBase, `.degit-${stamp}.tar.gz`)
466
+ const tmpExtract = this.container.paths.resolve(tmpBase, `.degit-extract-${stamp}`)
467
+ const dest = destination.startsWith('/') ? destination : this.container.paths.resolve(destination)
468
+
469
+ const dl = this.container.feature('downloader')
470
+
471
+ await dl.download(tarballUrl, tarPath)
472
+
473
+ // Extract everything, strip the root archive directory (e.g. repo-commitsha/)
474
+ fs.ensureFolder(tmpExtract)
475
+ const extractResult = await proc.execAndCapture(`tar xzf ${tarPath} -C ${tmpExtract} --strip-components=1`)
476
+ if (extractResult.exitCode !== 0) {
477
+ await fs.rmdir(tmpExtract)
478
+ await fs.rm(tarPath)
479
+ throw new Error(`Failed to extract tarball: ${extractResult.stderr}`)
480
+ }
481
+
482
+ // Copy the subfolder (or everything) to destination
483
+ const sourceDir = parsed.subdir ? `${tmpExtract}/${parsed.subdir}` : tmpExtract
484
+ if (fs.existsSync(dest)) await fs.rmdir(dest)
485
+ fs.ensureFolder(dest)
486
+ fs.copy(sourceDir, dest, { overwrite: true })
487
+
488
+ // Cleanup temp files
489
+ await fs.rm(tarPath)
490
+ await fs.rmdir(tmpExtract)
491
+
492
+ const files = fs.readdirSync(dest)
493
+ return { files, source: parsed }
494
+ }
495
+
496
+ /** Parses degit-style source strings into components. */
497
+ private _parseRemoteSource(input: string) {
498
+ let ref = 'HEAD'
499
+ let str = input.replace(/^https?:\/\//, '')
500
+
501
+ // Handle github.com/user/repo/tree/branch/path URLs
502
+ const treeMatch = str.match(/^github\.com\/([^/]+)\/([^/]+)\/tree\/([^/]+)(?:\/(.+))?$/)
503
+ if (treeMatch) {
504
+ return { user: treeMatch[1], repo: treeMatch[2], ref: treeMatch[3], subdir: treeMatch[4] || '' }
505
+ }
506
+
507
+ if (str.startsWith('github.com/')) str = str.replace('github.com/', '')
508
+ if (str.includes('#')) {
509
+ const parts = str.split('#')
510
+ str = parts[0]
511
+ ref = parts[1]
512
+ }
513
+
514
+ const parts = str.split('/')
515
+ return { user: parts[0], repo: parts[1], ref, subdir: parts.slice(2).join('/') }
516
+ }
517
+
428
518
  /**
429
519
  * Gets the commit history for a set of files or glob patterns.
430
520
  *
@@ -13,7 +13,7 @@ export type GrepMatch = {
13
13
  content: string
14
14
  }
15
15
 
16
- type GrepOptions = {
16
+ export type GrepOptions = {
17
17
  /** Pattern to search for (string or regex) */
18
18
  pattern: string
19
19
  /** Directory or file to search in (defaults to container cwd) */
@@ -273,6 +273,7 @@ export class ChildProcess extends Feature {
273
273
  exec(command: string, options?: any): string {
274
274
  return execSync(command, {
275
275
  cwd: this.container.cwd,
276
+ stdio: ['pipe', 'pipe', 'pipe'],
276
277
  ...options,
277
278
  })
278
279
  .toString()
@@ -70,7 +70,7 @@ export class TTS extends Feature<TTSState, TTSOptions> {
70
70
 
71
71
  /** Directory where generated audio files are saved. */
72
72
  get outputDir(): string {
73
- return this.options.outputDir || this.container.paths.join(this.container.feature('os').homedir, '.luca', 'tts-cache')
73
+ return this.options.outputDir || this.container.paths.resolve(this.container.feature('os').homedir, '.luca', 'tts-cache')
74
74
  }
75
75
 
76
76
  /** The 20 preset voice names available in Chatterbox Turbo. */
@@ -247,6 +247,54 @@ export class VM<
247
247
  return (await script.runInContext(context)) as T
248
248
  }
249
249
 
250
+ /**
251
+ * Execute code and capture all console output as structured JSON.
252
+ *
253
+ * Returns both the execution result and an array of every `console.*` call
254
+ * made during execution, each entry recording the method name and arguments.
255
+ *
256
+ * @param code - The JavaScript code to execute
257
+ * @param ctx - Context variables to make available to the executing code
258
+ * @returns The result, an array of captured console calls, and the context
259
+ *
260
+ * @example
261
+ * ```typescript
262
+ * const { result, console: calls } = await vm.runCaptured('console.log("hi"); console.warn("oh"); 42')
263
+ * // result === 42
264
+ * // calls === [{ method: 'log', args: ['hi'] }, { method: 'warn', args: ['oh'] }]
265
+ * ```
266
+ */
267
+ async runCaptured<T extends any>(code: string, ctx: any = {}): Promise<{
268
+ result: T
269
+ console: Array<{ method: string, args: any[] }>
270
+ context: vm.Context
271
+ }> {
272
+ const calls: Array<{ method: string, args: any[] }> = []
273
+ const captureConsole: Record<string, (...args: any[]) => void> = {}
274
+
275
+ for (const method of ['log', 'info', 'warn', 'error', 'debug', 'trace', 'dir', 'table'] as const) {
276
+ captureConsole[method] = (...args: any[]) => {
277
+ calls.push({ method, args })
278
+ }
279
+ }
280
+
281
+ const isExisting = this.isContext(ctx)
282
+ const context = isExisting ? ctx : this.createContext({ ...ctx, console: captureConsole })
283
+
284
+ // For existing contexts, swap console in for the run and restore after
285
+ const prevConsole = isExisting ? ctx.console : undefined
286
+ if (isExisting) ctx.console = captureConsole
287
+
288
+ const wrapped = this.wrapTopLevelAwait(code)
289
+ const script = this.createScript(wrapped)
290
+ try {
291
+ const result = (await script.runInContext(context)) as T
292
+ return { result, console: calls, context }
293
+ } finally {
294
+ if (isExisting) ctx.console = prevConsole
295
+ }
296
+ }
297
+
250
298
  /**
251
299
  * Execute JavaScript code synchronously in a controlled environment.
252
300
  *
@@ -1,5 +1,5 @@
1
1
  // Auto-generated scaffold and MCP readme content
2
- // Generated at: 2026-03-21T05:25:03.287Z
2
+ // Generated at: 2026-03-22T06:53:18.105Z
3
3
  // Source: docs/scaffolds/*.md, docs/examples/assistant/, and docs/mcp/readme.md
4
4
  //
5
5
  // Do not edit manually. Run: luca build-scaffolds
@@ -1669,7 +1669,7 @@ import { Server, servers, ServerStateSchema } from '@soederpop/luca'
1669
1669
  import { commands, CommandOptionsSchema } from '@soederpop/luca'
1670
1670
  \`\`\`
1671
1671
 
1672
- Never import from \`fs\`, \`path\`, \`crypto\`, or other Node builtins. Never import third-party packages in consumer code. The only exception is inside helper implementations themselves — a feature that wraps a library may import it.
1672
+ Never import from \`fs\`, \`path\`, \`crypto\`, or other Node builtins. Never import third-party packages in consumer code. If a container feature wraps the functionality, use it.
1673
1673
 
1674
1674
  ## Zod v4
1675
1675
 
@@ -1,3 +0,0 @@
1
- # Luca Framework Assistant
2
-
3
- Your job is to help people use the Luca framework
@@ -1,3 +0,0 @@
1
- export function started() {
2
- console.log('Assistant started!')
3
- }
@@ -1,10 +0,0 @@
1
- import { z } from 'zod'
2
-
3
- export const schemas = {
4
- README: z.object({}).describe('CALL THIS README FUNCTION AS EARLY AS POSSIBLE')
5
- }
6
-
7
- export function README(options: z.infer<typeof schemas.README>) {
8
- return 'YO YO'
9
- }
10
-