@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.
- package/AGENTS.md +1 -1
- package/CLAUDE.md +6 -1
- package/assistants/codingAssistant/hooks.ts +0 -1
- package/assistants/lucaExpert/CORE.md +37 -0
- package/assistants/lucaExpert/hooks.ts +9 -0
- package/assistants/lucaExpert/tools.ts +177 -0
- package/commands/build-bootstrap.ts +41 -1
- package/docs/TABLE-OF-CONTENTS.md +0 -1
- package/docs/apis/clients/rest.md +5 -5
- package/docs/apis/features/agi/assistant.md +1 -1
- package/docs/apis/features/agi/conversation-history.md +6 -7
- package/docs/apis/features/agi/conversation.md +1 -1
- package/docs/apis/features/agi/semantic-search.md +1 -1
- package/docs/bootstrap/CLAUDE.md +1 -1
- package/docs/bootstrap/SKILL.md +7 -3
- package/docs/bootstrap/templates/luca-cli.ts +5 -0
- package/docs/mcp/readme.md +1 -1
- package/docs/tutorials/00-bootstrap.md +18 -0
- package/package.json +2 -2
- package/scripts/stamp-build.sh +12 -0
- package/scripts/test-docs-reader.ts +10 -0
- package/src/agi/container.server.ts +8 -5
- package/src/agi/features/assistant.ts +208 -55
- package/src/agi/features/assistants-manager.ts +138 -66
- package/src/agi/features/conversation.ts +46 -14
- package/src/agi/features/docs-reader.ts +142 -0
- package/src/agi/features/openapi.ts +1 -1
- package/src/agi/features/skills-library.ts +257 -313
- package/src/bootstrap/generated.ts +8163 -6
- package/src/cli/build-info.ts +4 -0
- package/src/cli/cli.ts +2 -1
- package/src/commands/bootstrap.ts +16 -1
- package/src/commands/eval.ts +6 -1
- package/src/commands/sandbox-mcp.ts +17 -7
- package/src/helper.ts +56 -2
- package/src/introspection/generated.agi.ts +2409 -1608
- package/src/introspection/generated.node.ts +902 -594
- package/src/introspection/generated.web.ts +1 -1
- package/src/node/container.ts +1 -1
- package/src/node/features/content-db.ts +251 -13
- package/src/node/features/git.ts +90 -0
- package/src/node/features/grep.ts +1 -1
- package/src/node/features/proc.ts +1 -0
- package/src/node/features/tts.ts +1 -1
- package/src/node/features/vm.ts +48 -0
- package/src/scaffolds/generated.ts +2 -2
- package/assistants/architect/CORE.md +0 -3
- package/assistants/architect/hooks.ts +0 -3
- package/assistants/architect/tools.ts +0 -10
- package/docs/apis/features/agi/skills-library.md +0 -234
- package/docs/reports/assistant-bugs.md +0 -38
- package/docs/reports/attach-pattern-usage.md +0 -18
- package/docs/reports/code-audit-results.md +0 -391
- package/docs/reports/console-hmr-design.md +0 -170
- package/docs/reports/helper-semantic-search.md +0 -72
- package/docs/reports/introspection-audit-tasks.md +0 -378
- package/docs/reports/luca-mcp-improvements.md +0 -128
- 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-
|
|
4
|
+
// Generated at: 2026-03-22T06:53:17.219Z
|
|
5
5
|
|
|
6
6
|
setBuildTimeData('features.containerLink', {
|
|
7
7
|
"id": "features.containerLink",
|
package/src/node/container.ts
CHANGED
|
@@ -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 {
|
|
7
|
-
import {
|
|
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('
|
|
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
|
|
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('
|
|
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
|
|
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
|
-
//
|
|
330
|
-
const
|
|
331
|
-
const
|
|
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
|
|
347
|
-
|
|
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)
|
|
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
|
package/src/node/features/git.ts
CHANGED
|
@@ -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
|
*
|
package/src/node/features/tts.ts
CHANGED
|
@@ -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.
|
|
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. */
|
package/src/node/features/vm.ts
CHANGED
|
@@ -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-
|
|
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.
|
|
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
|
|