@soederpop/luca 0.1.3 → 0.2.1

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.
@@ -1,186 +1,80 @@
1
- import { z } from 'zod'
2
- import type { ContainerContext } from '@soederpop/luca'
3
- import { CommandOptionsSchema } from '@soederpop/luca/schemas'
4
-
5
- const TARGETS = [
6
- { name: 'linux-x64', bunTarget: 'bun-linux-x64', suffix: 'linux-x64' },
7
- { name: 'linux-arm64', bunTarget: 'bun-linux-arm64', suffix: 'linux-arm64' },
8
- { name: 'darwin-x64', bunTarget: 'bun-darwin-x64', suffix: 'darwin-x64' },
9
- { name: 'darwin-arm64', bunTarget: 'bun-darwin-arm64', suffix: 'darwin-arm64' },
10
- { name: 'windows-x64', bunTarget: 'bun-windows-x64', suffix: 'windows-x64', ext: '.exe' },
11
- ]
1
+ import { z } from "zod";
2
+ import type { ContainerContext } from "@soederpop/luca";
3
+ import { CommandOptionsSchema } from "@soederpop/luca/schemas";
12
4
 
13
5
  export const argsSchema = CommandOptionsSchema.extend({
14
- dryRun: z.boolean().optional().describe('Build binaries but skip tagging and uploading'),
15
- skipBuild: z.boolean().optional().describe('Skip pre-build steps (introspection, scaffolds, bootstrap)'),
16
- skipTests: z.boolean().optional().describe('Skip running tests before release'),
17
- draft: z.boolean().optional().describe('Create the GitHub release as a draft'),
18
- targets: z.string().optional().describe('Comma-separated list of targets to build (e.g. linux-x64,darwin-arm64). Defaults to all'),
19
- })
20
-
21
- async function release(options: z.infer<typeof argsSchema>, context: ContainerContext) {
22
- const container = context.container as any
23
- const proc = container.feature('proc')
24
- const fileSystem = container.feature('fs')
25
- const ui = container.feature('ui')
26
-
27
- const pkg = JSON.parse(await fileSystem.readFileAsync('package.json'))
28
- const version = pkg.version
29
- const tag = `v${version}`
30
- const distDir = 'dist/release'
31
-
32
- ui.banner(`Luca Release ${tag}`)
33
-
34
- // Filter targets if specified
35
- let selectedTargets = TARGETS
36
- if (options.targets) {
37
- const requested = options.targets.split(',').map((t: string) => t.trim())
38
- selectedTargets = TARGETS.filter(t => requested.includes(t.suffix) || requested.includes(t.name))
39
- if (selectedTargets.length === 0) {
40
- console.error(`No valid targets found. Available: ${TARGETS.map(t => t.suffix).join(', ')}`)
41
- return
42
- }
43
- }
44
-
45
- // 1. Run tests
46
- if (!options.skipTests) {
47
- console.log('\n→ Running tests...')
48
- const testResult = await proc.execAndCapture('bun test test/*.test.ts', { silent: false })
49
- if (testResult.exitCode !== 0) {
50
- console.error('Tests failed. Fix them before releasing.')
51
- return
52
- }
53
- }
54
-
55
- // 2. Pre-build steps
56
- if (!options.skipBuild) {
57
- console.log('\n→ Running pre-build steps...')
58
- const steps = [
59
- ['build:introspection', 'bun run build:introspection'],
60
- ['build:scaffolds', 'bun run build:scaffolds'],
61
- ['build:bootstrap', 'bun run build:bootstrap'],
62
- ]
63
- for (const [label, cmd] of steps) {
64
- console.log(` ${label}...`)
65
- const r = await proc.execAndCapture(cmd, { silent: true })
66
- if (r.exitCode !== 0) {
67
- console.error(`${label} failed:\n${r.stderr}`)
68
- return
69
- }
70
- }
71
- }
72
-
73
- // 3. Cross-compile for all targets
74
- fileSystem.ensureFolder(distDir)
75
-
76
- console.log(`\n→ Compiling for ${selectedTargets.length} targets...`)
77
- for (const target of selectedTargets) {
78
- const ext = target.ext || ''
79
- const outfile = `${distDir}/luca-${target.suffix}${ext}`
80
- const cmd = `bun build ./src/cli/cli.ts --compile --target=${target.bunTarget} --outfile ${outfile} --external node-llama-cpp`
81
-
82
- console.log(` ${target.name}...`)
83
- const result = await proc.execAndCapture(cmd, { silent: true })
84
- if (result.exitCode !== 0) {
85
- console.error(` Failed to compile for ${target.name}:\n${result.stderr}`)
86
- return
87
- }
88
-
89
- const sizeBytes = proc.exec(`stat -f%z ${container.paths.resolve(outfile)}`)
90
- const sizeMB = (parseInt(sizeBytes, 10) / 1024 / 1024).toFixed(1)
91
- console.log(` ✓ ${outfile} (${sizeMB} MB)`)
92
- }
93
-
94
- if (options.dryRun) {
95
- console.log(`\n→ Dry run complete. Binaries are in ${distDir}/`)
96
- console.log(' Skipping git tag and GitHub release.')
97
- return
98
- }
99
-
100
- // 4. Check if tag already exists
101
- const tagCheck = await proc.execAndCapture(`git tag -l "${tag}"`, { silent: true })
102
- if (tagCheck.stdout.trim() === tag) {
103
- console.error(`\nTag ${tag} already exists. Bump the version in package.json first.`)
104
- return
105
- }
106
-
107
- // 5. Check for clean working tree (allow untracked)
108
- const statusCheck = await proc.execAndCapture('git status --porcelain', { silent: true })
109
- const dirtyFiles = statusCheck.stdout.trim().split('\n').filter((l: string) => l && !l.startsWith('??'))
110
- if (dirtyFiles.length > 0) {
111
- console.error('\nWorking tree has uncommitted changes. Commit or stash them first.')
112
- console.error(dirtyFiles.join('\n'))
113
- return
114
- }
115
-
116
- // 6. Create git tag
117
- console.log(`\n→ Creating tag ${tag}...`)
118
- const tagResult = await proc.execAndCapture(`git tag -a "${tag}" -m "Release ${tag}"`, { silent: true })
119
- if (tagResult.exitCode !== 0) {
120
- console.error(`Failed to create tag:\n${tagResult.stderr}`)
121
- return
122
- }
123
-
124
- // 7. Push tag
125
- console.log(`→ Pushing tag ${tag}...`)
126
- const pushResult = await proc.execAndCapture(`git push origin "${tag}"`, { silent: true })
127
- if (pushResult.exitCode !== 0) {
128
- console.error(`Failed to push tag:\n${pushResult.stderr}`)
129
- return
130
- }
131
-
132
- // 8. Create GitHub release and upload binaries
133
- const assetPaths = selectedTargets
134
- .map(t => container.paths.resolve(`${distDir}/luca-${t.suffix}${t.ext || ''}`))
135
-
136
- const releaseTitle = `Luca ${tag}`
137
- const releaseNotes = await generateReleaseNotes(proc, tag)
138
-
139
- // Write notes to a temp file so gh doesn't need shell quoting
140
- const notesFile = container.paths.resolve(distDir, 'release-notes.md')
141
- await fileSystem.writeFileAsync(notesFile, releaseNotes)
142
-
143
- console.log(`\n→ Creating GitHub release ${tag}...`)
144
- const ghArgs = [
145
- 'release', 'create', tag,
146
- ...assetPaths,
147
- '--title', releaseTitle,
148
- '--notes-file', notesFile,
149
- ...(options.draft ? ['--draft'] : []),
150
- ]
151
- const ghResult = await proc.spawnAndCapture('gh', ghArgs)
152
-
153
- if (ghResult.exitCode !== 0) {
154
- console.error(`Failed to create GitHub release:\n${ghResult.stderr}`)
155
- console.log('The tag was pushed. You can manually create the release with:')
156
- console.log(` gh release create ${tag} ${assetPaths.join(' ')}`)
157
- return
158
- }
159
-
160
- console.log(`\n✓ Released ${tag} successfully!`)
161
- console.log(` https://github.com/soederpop/luca/releases/tag/${tag}`)
162
- }
163
-
164
- async function generateReleaseNotes(proc: any, tag: string): Promise<string> {
165
- // Get commits since last tag
166
- const lastTag = await proc.execAndCapture('git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo ""', { silent: true })
167
- const since = lastTag.stdout.trim()
168
-
169
- let logCmd: string
170
- if (since) {
171
- logCmd = `git log ${since}..HEAD --oneline --no-decorate`
172
- } else {
173
- logCmd = 'git log --oneline --no-decorate -20'
174
- }
175
-
176
- const log = await proc.execAndCapture(logCmd, { silent: true })
177
- const commits = log.stdout.trim()
178
-
179
- return `## What's Changed\n\n${commits ? commits.split('\n').map((c: string) => `- ${c}`).join('\n') : 'Initial release'}\n\n## Platforms\n\n- Linux x64\n- Linux ARM64\n- macOS x64 (Intel)\n- macOS ARM64 (Apple Silicon)\n- Windows x64`
6
+ skipTests: z
7
+ .boolean()
8
+ .optional()
9
+ .describe("Skip running tests before release"),
10
+ });
11
+
12
+ async function release(
13
+ options: z.infer<typeof argsSchema>,
14
+ context: ContainerContext,
15
+ ) {
16
+ const container = context.container as any;
17
+ const proc = container.feature("proc");
18
+ const fileSystem = container.feature("fs");
19
+ const ui = container.feature("ui");
20
+
21
+ const pkg = JSON.parse(await fileSystem.readFileAsync("package.json"));
22
+ const version = pkg.version;
23
+ const tag = `v${version}`;
24
+
25
+ ui.banner(`Luca Release ${tag}`);
26
+
27
+ // Check if tag already exists
28
+ const tagCheck = await proc.execAndCapture(`git tag -l "${tag}"`, {
29
+ silent: true,
30
+ });
31
+ if (tagCheck.stdout.trim() === tag) {
32
+ console.error(
33
+ `\nTag ${tag} already exists. Bump the version in package.json first.`,
34
+ );
35
+ return;
36
+ }
37
+
38
+ // Run tests
39
+ if (!options.skipTests) {
40
+ console.log("\n→ Running tests...");
41
+ const testResult = await proc.execAndCapture("bun test test/*.test.ts", {
42
+ silent: false,
43
+ });
44
+ if (testResult.exitCode !== 0) {
45
+ console.error("Tests failed. Fix them before releasing.");
46
+ return;
47
+ }
48
+ }
49
+
50
+ // Create and push git tag — triggers the GitHub Actions release workflow
51
+ console.log(`\n→ Creating tag ${tag}...`);
52
+ const tagResult = await proc.execAndCapture(
53
+ `git tag -a "${tag}" -m "Release ${tag}"`,
54
+ { silent: true },
55
+ );
56
+ if (tagResult.exitCode !== 0) {
57
+ console.error(`Failed to create tag:\n${tagResult.stderr}`);
58
+ return;
59
+ }
60
+
61
+ console.log(`→ Pushing tag ${tag}...`);
62
+ const pushResult = await proc.execAndCapture(`git push origin "${tag}"`, {
63
+ silent: true,
64
+ });
65
+ if (pushResult.exitCode !== 0) {
66
+ console.error(`Failed to push tag:\n${pushResult.stderr}`);
67
+ return;
68
+ }
69
+
70
+ console.log(
71
+ `\n✓ Tag ${tag} pushed. GitHub Actions will build, sign, and create the draft release.`,
72
+ );
73
+ console.log(` https://github.com/soederpop/luca/actions`);
180
74
  }
181
75
 
182
76
  export default {
183
- description: 'Build cross-platform binaries and publish a GitHub release tagged by version',
184
- argsSchema,
185
- handler: release,
186
- }
77
+ description: "Run tests and trigger a GitHub Actions release via git tag",
78
+ argsSchema,
79
+ handler: release,
80
+ };
@@ -0,0 +1,142 @@
1
+ ---
2
+ title: Assistant Factory Pattern — Eliminate Wrapper Duplication
3
+ status: idea
4
+ tags: [agi, assistant, refactor, lucaCoder, autonomousAssistant]
5
+ ---
6
+
7
+ # Assistant Factory Pattern
8
+
9
+ ## Problem
10
+
11
+ `LucaCoder` and `AutonomousAssistant` both duplicate the same pattern: create an inner `Assistant`, stack tool bundles, wire a permission interceptor, forward events. The permission system, approval flow, and event forwarding are copy-pasted between them. `LucaCoder` is just `AutonomousAssistant` + coding opinions (bash tool, skill loading, project instructions).
12
+
13
+ Both features **wrap** an assistant rather than **composing** one. This means every method on `Assistant` (ask, tools, messages, conversation) has to be proxied through the wrapper.
14
+
15
+ ## Proposal
16
+
17
+ ### 1. Extract the permission layer into a `use()`-able plugin
18
+
19
+ The permission system (allow/ask/deny per tool, pending approvals, approval history) is just a `beforeToolCall` interceptor. It doesn't need to be a feature — it's a plugin function.
20
+
21
+ ```typescript
22
+ import type { Assistant } from './assistant'
23
+
24
+ interface PermissionConfig {
25
+ permissions?: Record<string, 'allow' | 'ask' | 'deny'>
26
+ defaultPermission?: 'allow' | 'ask' | 'deny'
27
+ }
28
+
29
+ function withPermissions(config: PermissionConfig = {}) {
30
+ return (assistant: Assistant) => {
31
+ const perms = config.permissions || {}
32
+ const defaultPerm = config.defaultPermission || 'ask'
33
+ const pendingResolvers = new Map<string, (d: 'approve' | 'deny') => void>()
34
+
35
+ assistant.intercept('beforeToolCall', async (ctx, next) => {
36
+ const policy = perms[ctx.name] || defaultPerm
37
+
38
+ if (policy === 'deny') {
39
+ ctx.skip = true
40
+ ctx.result = JSON.stringify({ blocked: true, tool: ctx.name, reason: 'Permission denied.' })
41
+ assistant.emit('toolBlocked', ctx.name, 'deny policy')
42
+ return
43
+ }
44
+
45
+ if (policy === 'allow') {
46
+ await next()
47
+ return
48
+ }
49
+
50
+ // 'ask' — emit event and block until resolved
51
+ const id = assistant.container.utils.uuid()
52
+ const decision = await new Promise<'approve' | 'deny'>((resolve) => {
53
+ pendingResolvers.set(id, resolve)
54
+ assistant.emit('permissionRequest', { id, toolName: ctx.name, args: ctx.args })
55
+ })
56
+
57
+ pendingResolvers.delete(id)
58
+
59
+ if (decision === 'approve') {
60
+ await next()
61
+ } else {
62
+ ctx.skip = true
63
+ ctx.result = JSON.stringify({ blocked: true, tool: ctx.name, reason: 'User denied.' })
64
+ }
65
+ })
66
+
67
+ // Expose approve/deny on the assistant instance
68
+ ;(assistant as any).approve = (id: string) => pendingResolvers.get(id)?.('approve')
69
+ ;(assistant as any).deny = (id: string) => pendingResolvers.get(id)?.('deny')
70
+ }
71
+ }
72
+ ```
73
+
74
+ ### 2. LucaCoder becomes an assistant factory, not a wrapper
75
+
76
+ Instead of owning an inner assistant and proxying everything, `LucaCoder` creates and returns a configured `Assistant`:
77
+
78
+ ```typescript
79
+ export class LucaCoder extends Feature {
80
+ createAssistant(overrides?: Partial<AssistantOptions>): Assistant {
81
+ const assistant = this.container.feature('assistant', {
82
+ systemPrompt: this.buildSystemPrompt(),
83
+ ...overrides,
84
+ })
85
+
86
+ // This is the runtime equivalent of hooks.ts started()
87
+ assistant.use(this.container.feature('codingTools'))
88
+
89
+ const fileTools = this.container.feature('fileTools')
90
+ assistant.use(fileTools.toTools({ only: ['editFile', 'writeFile', 'deleteFile'] }))
91
+ fileTools.setupToolsConsumer(assistant)
92
+
93
+ assistant.use(this.container.feature('processManager'))
94
+ assistant.use(this.container.feature('skillsLibrary'))
95
+
96
+ // Permission layer as a plugin
97
+ if (this.options.permissions || this.options.defaultPermission) {
98
+ assistant.use(withPermissions({
99
+ permissions: this.options.permissions,
100
+ defaultPermission: this.options.defaultPermission,
101
+ }))
102
+ }
103
+
104
+ return assistant
105
+ }
106
+ }
107
+ ```
108
+
109
+ ### 3. Usage
110
+
111
+ ```typescript
112
+ // Runtime — no disk files, works in compiled binary
113
+ const coder = container.feature('lucaCoder')
114
+ const assistant = await coder.createAssistant()
115
+ await assistant.ask('refactor the auth module')
116
+
117
+ // With permission gating
118
+ const coder = container.feature('lucaCoder', {
119
+ permissions: { editFile: 'ask', writeFile: 'ask', deleteFile: 'ask' },
120
+ defaultPermission: 'allow',
121
+ })
122
+ const assistant = await coder.createAssistant()
123
+ assistant.on('permissionRequest', ({ id }) => assistant.approve(id))
124
+ ```
125
+
126
+ ### 4. AutonomousAssistant collapses
127
+
128
+ `AutonomousAssistant` becomes either:
129
+ - Deleted entirely (replaced by `withPermissions` plugin on any assistant)
130
+ - Or a thin factory like LucaCoder but with no coding opinions — just `createAssistant()` + permissions
131
+
132
+ ## What This Achieves
133
+
134
+ - **No duplication** — permission logic lives in one place (the plugin)
135
+ - **No proxying** — you get back a real `Assistant`, not a wrapper that forwards `.ask()`, `.tools`, `.messages`, `.conversation`
136
+ - **Binary-compatible** — the factory pattern doesn't need disk files, so it works in the compiled `luca` binary
137
+ - **Composable** — `assistants/codingAssistant/hooks.ts` and `LucaCoder.createAssistant()` produce the exact same result through the same mechanism (`assistant.use()`)
138
+ - **The folder-based assistant becomes the reference implementation** of what the factory does programmatically
139
+
140
+ ## Relationship to assistants/codingAssistant
141
+
142
+ The disk-based `assistants/codingAssistant/` (CORE.md + hooks.ts + tools.ts) is the "editable" version. `LucaCoder.createAssistant()` is the "compiled" version. Same tools, same prompt, same behavior — one loads from files, the other is baked in code.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soederpop/luca",
3
- "version": "0.1.3",
3
+ "version": "0.2.1",
4
4
  "website": "https://luca.soederpop.com",
5
5
  "description": "lightweight universal conversational architecture AKA Le Ultimate Component Architecture AKA Last Universal Common Ancestor, part AI part Human",
6
6
  "author": "jon soeder aka the people's champ <jon@soederpop.com>",
@@ -109,7 +109,8 @@
109
109
  "build:python-bridge": "bun run src/cli/cli.ts build-python-bridge",
110
110
  "test": "bun test test/*.test.ts",
111
111
  "update-all-docs": "bun run test && bun run build:introspection && bun run src/cli/cli.ts generate-api-docs && bun run build:scaffolds && bun run build:bootstrap && bun run build:python-bridge",
112
- "precommit": "bun run typecheck && bun run update-all-docs && git add docs/apis/ src/introspection/generated.*.ts src/scaffolds/generated.ts src/bootstrap/generated.ts src/python/generated.ts && bun compile",
112
+ "setup": "git update-index --skip-worktree src/introspection/generated.node.ts src/introspection/generated.web.ts src/introspection/generated.agi.ts src/scaffolds/generated.ts src/bootstrap/generated.ts src/python/generated.ts",
113
+ "precommit": "bun run typecheck && bun run update-all-docs && git add docs/apis/ && bun compile",
113
114
  "test:integration": "bun test ./test-integration/"
114
115
  },
115
116
  "devDependencies": {
@@ -3,6 +3,7 @@ import { type NodeFeatures, NodeContainer } from '../node/container'
3
3
  import '@/introspection/generated.agi.js'
4
4
  import { OpenAIClient } from '../clients/openai'
5
5
  import { ElevenLabsClient } from '../clients/elevenlabs'
6
+ import { VoiceBoxClient } from '../clients/voicebox'
6
7
  import { ClaudeCode } from './features/claude-code'
7
8
  import { OpenAICodex } from './features/openai-codex'
8
9
  import { Conversation } from './features/conversation'
@@ -16,6 +17,8 @@ import { SemanticSearch } from '@soederpop/luca/node/features/semantic-search'
16
17
  import { ContentDb } from '@soederpop/luca/node/features/content-db'
17
18
  import { FileTools } from './features/file-tools'
18
19
  import { LucaCoder } from './features/luca-coder'
20
+ import { Memory } from './features/agent-memory'
21
+ import { CodingTools } from './features/coding-tools'
19
22
 
20
23
  import type { ConversationTool } from './features/conversation'
21
24
  import type { ZodType } from 'zod'
@@ -32,6 +35,8 @@ export {
32
35
  BrowserUse,
33
36
  FileTools,
34
37
  LucaCoder,
38
+ Memory,
39
+ CodingTools,
35
40
  SemanticSearch,
36
41
  ContentDb,
37
42
  NodeContainer,
@@ -58,6 +63,8 @@ export interface AGIFeatures extends NodeFeatures {
58
63
  browserUse: typeof BrowserUse
59
64
  fileTools: typeof FileTools
60
65
  lucaCoder: typeof LucaCoder
66
+ memory: typeof Memory
67
+ codingTools: typeof CodingTools
61
68
  }
62
69
 
63
70
  export interface ConversationFactoryOptions {
@@ -122,6 +129,7 @@ export class AGIContainer<
122
129
  const container = new AGIContainer()
123
130
  .use(OpenAIClient)
124
131
  .use(ElevenLabsClient)
132
+ .use(VoiceBoxClient)
125
133
  .use(ClaudeCode)
126
134
  .use(OpenAICodex)
127
135
  .use(Conversation)
@@ -133,6 +141,8 @@ const container = new AGIContainer()
133
141
  .use(BrowserUse)
134
142
  .use(FileTools)
135
143
  .use(LucaCoder)
144
+ .use(Memory)
145
+ .use(CodingTools)
136
146
  .use(SemanticSearch)
137
147
 
138
148
  container.docs = container.feature('contentDb', {