@soederpop/luca 0.1.3 → 0.2.2
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/.github/workflows/release.yaml +169 -0
- package/CNAME +1 -0
- package/README.md +3 -0
- package/assistants/codingAssistant/ABOUT.md +3 -0
- package/assistants/codingAssistant/CORE.md +22 -17
- package/assistants/codingAssistant/hooks.ts +19 -2
- package/assistants/codingAssistant/tools.ts +1 -106
- package/assistants/inkbot/ABOUT.md +5 -0
- package/assistants/inkbot/CORE.md +2 -0
- package/bun.lock +20 -4
- package/commands/release.ts +75 -181
- package/docs/CNAME +1 -0
- package/docs/ideas/assistant-factory-pattern.md +142 -0
- package/index.html +1430 -0
- package/package.json +3 -2
- package/src/agi/container.server.ts +10 -0
- package/src/agi/features/agent-memory.ts +694 -0
- package/src/agi/features/assistant.ts +1 -1
- package/src/agi/features/assistants-manager.ts +25 -0
- package/src/agi/features/browser-use.ts +30 -0
- package/src/agi/features/coding-tools.ts +175 -0
- package/src/agi/features/file-tools.ts +33 -26
- package/src/agi/features/skills-library.ts +28 -11
- package/src/bootstrap/generated.ts +1 -1
- package/src/cli/build-info.ts +2 -2
- package/src/clients/voicebox/index.ts +300 -0
- package/src/introspection/generated.agi.ts +2909 -914
- package/src/introspection/generated.node.ts +1641 -822
- package/src/introspection/generated.web.ts +1 -1
- package/src/node/features/content-db.ts +54 -27
- package/src/node/features/process-manager.ts +50 -17
- package/src/python/generated.ts +1 -1
- package/src/scaffolds/generated.ts +1 -1
- package/test/assistant.test.ts +14 -5
- package/test-integration/memory.test.ts +204 -0
package/commands/release.ts
CHANGED
|
@@ -1,186 +1,80 @@
|
|
|
1
|
-
import { z } from
|
|
2
|
-
import type { ContainerContext } from
|
|
3
|
-
import { CommandOptionsSchema } from
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}
|
|
77
|
+
description: "Run tests and trigger a GitHub Actions release via git tag",
|
|
78
|
+
argsSchema,
|
|
79
|
+
handler: release,
|
|
80
|
+
};
|
package/docs/CNAME
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
luca-js.soederpop.com
|
|
@@ -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.
|