@soederpop/luca 0.0.17 → 0.0.19
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/assistants/codingAssistant/CORE.md +25 -0
- package/assistants/codingAssistant/hooks.ts +3 -0
- package/assistants/codingAssistant/tools.ts +108 -0
- package/package.json +2 -2
- package/src/agi/features/assistant.ts +48 -0
- package/src/bootstrap/generated.ts +1 -1
- package/src/commands/chat.ts +42 -6
- package/src/introspection/generated.agi.ts +471 -471
- package/src/introspection/generated.node.ts +1 -1
- package/src/introspection/generated.web.ts +1 -1
- package/src/scaffolds/generated.ts +1 -1
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Coding Assistant
|
|
2
|
+
|
|
3
|
+
You are a coding assistant whose purpose is to read and understand codebases as efficiently as possible.
|
|
4
|
+
|
|
5
|
+
You are armed with the following tools:
|
|
6
|
+
|
|
7
|
+
- **rg** — ripgrep for fast content search across files. Pass any arguments you'd normally pass to `rg`.
|
|
8
|
+
- **ls** — list files and directories. Pass any arguments you'd normally pass to `ls`.
|
|
9
|
+
- **cat** — read file contents. Pass any arguments you'd normally pass to `cat`.
|
|
10
|
+
- **sed** — stream editor for filtering and transforming text. Pass any arguments you'd normally pass to `sed`.
|
|
11
|
+
- **awk** — pattern scanning and text processing. Pass any arguments you'd normally pass to `awk`.
|
|
12
|
+
- **pwd** — print the current working directory.
|
|
13
|
+
|
|
14
|
+
Each tool accepts a single string argument: everything that comes after the command name on the command line. For example, to search for "TODO" in TypeScript files, call `rg` with `"TODO" --type ts`.
|
|
15
|
+
|
|
16
|
+
## How to Work
|
|
17
|
+
|
|
18
|
+
1. Start by orienting yourself — use `pwd` to know where you are, then `ls` to see what's around.
|
|
19
|
+
2. Use `rg` liberally to find relevant code quickly. It's your most powerful tool.
|
|
20
|
+
3. Use `cat` to read files once you've located them.
|
|
21
|
+
4. Use `sed` and `awk` when you need to extract or transform specific parts of output.
|
|
22
|
+
5. Be efficient — don't read entire large files when `rg` can pinpoint what you need.
|
|
23
|
+
6. Synthesize what you find into clear, concise answers.
|
|
24
|
+
|
|
25
|
+
You are read-only. You do not modify files. Your job is to find, read, and explain code.
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type { Assistant, AGIContainer } from '@soederpop/luca/agi'
|
|
3
|
+
|
|
4
|
+
declare global {
|
|
5
|
+
var assistant: Assistant
|
|
6
|
+
var container: AGIContainer
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const proc = () => container.feature('proc')
|
|
10
|
+
const fs = () => container.feature('fs')
|
|
11
|
+
|
|
12
|
+
// Patterns that enable command chaining, substitution, or injection at the shell level.
|
|
13
|
+
const SHELL_INJECTION_PATTERNS = [
|
|
14
|
+
/;/, // command chaining
|
|
15
|
+
/&&/, // logical AND chaining
|
|
16
|
+
/\|\|/, // logical OR chaining
|
|
17
|
+
/\$\(/, // command substitution $(...)
|
|
18
|
+
/`/, // backtick command substitution
|
|
19
|
+
/\$\{/, // variable expansion ${...}
|
|
20
|
+
/\n/, // newline injection
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
// Additional patterns for tools that should not use piping or redirection.
|
|
24
|
+
const PIPE_AND_REDIRECT_PATTERNS = [
|
|
25
|
+
/\|/, // piping
|
|
26
|
+
/>\s*/, // output redirection
|
|
27
|
+
/<\(/, // process substitution
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Validates that args don't contain shell injection metacharacters.
|
|
32
|
+
* `strict` mode also blocks pipes and redirects (for tools like ls, cat).
|
|
33
|
+
* `permissive` mode allows | and > since they're valid in regex patterns (for rg, sed, awk).
|
|
34
|
+
*/
|
|
35
|
+
function sanitizeArgs(args: string, command: string, mode: 'strict' | 'permissive' = 'strict'): string {
|
|
36
|
+
const patterns = mode === 'strict'
|
|
37
|
+
? [...SHELL_INJECTION_PATTERNS, ...PIPE_AND_REDIRECT_PATTERNS]
|
|
38
|
+
: SHELL_INJECTION_PATTERNS
|
|
39
|
+
|
|
40
|
+
for (const pattern of patterns) {
|
|
41
|
+
if (pattern.test(args)) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`Refused to execute ${command}: args contain a disallowed shell metacharacter (matched ${pattern}). ` +
|
|
44
|
+
`Only pass flags, patterns, and file paths — no command chaining or substitution.`
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return args
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const schemas = {
|
|
53
|
+
rg: z.object({
|
|
54
|
+
args: z.string().describe('Arguments to pass to ripgrep, e.g. "TODO" --type ts -n'),
|
|
55
|
+
}).describe('Search file contents using ripgrep (rg). Fast, recursive, respects .gitignore.'),
|
|
56
|
+
|
|
57
|
+
ls: z.object({
|
|
58
|
+
args: z.string().default('.').describe('Arguments to pass to ls, e.g. -la src/'),
|
|
59
|
+
}).describe('List files and directories.'),
|
|
60
|
+
|
|
61
|
+
cat: z.object({
|
|
62
|
+
args: z.string().describe('Arguments to pass to cat, e.g. src/index.ts'),
|
|
63
|
+
}).describe('Read file contents.'),
|
|
64
|
+
|
|
65
|
+
sed: z.object({
|
|
66
|
+
args: z.string().describe('Arguments to pass to sed, e.g. -n "10,20p" src/index.ts'),
|
|
67
|
+
}).describe('Stream editor for filtering and transforming text.'),
|
|
68
|
+
|
|
69
|
+
awk: z.object({
|
|
70
|
+
args: z.string().describe('Arguments to pass to awk, e.g. \'{print $1}\' file.txt'),
|
|
71
|
+
}).describe('Pattern scanning and text processing.'),
|
|
72
|
+
|
|
73
|
+
writeFile: z.object({
|
|
74
|
+
path: z.string().describe('File path relative to the project root, e.g. src/utils/helper.ts'),
|
|
75
|
+
content: z.string().describe('The full content to write to the file'),
|
|
76
|
+
}).describe('Write content to a file. Creates the file if it does not exist, overwrites if it does.'),
|
|
77
|
+
|
|
78
|
+
pwd: z.object({}).describe('Print the current working directory.'),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function rg({ args }: z.infer<typeof schemas.rg>): string {
|
|
82
|
+
return proc().exec(`rg ${sanitizeArgs(args, 'rg', 'permissive')}`)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function ls({ args }: z.infer<typeof schemas.ls>): string {
|
|
86
|
+
return proc().exec(`ls ${sanitizeArgs(args, 'ls')}`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function cat({ args }: z.infer<typeof schemas.cat>): string {
|
|
90
|
+
return proc().exec(`cat ${sanitizeArgs(args, 'cat')}`)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function sed({ args }: z.infer<typeof schemas.sed>): string {
|
|
94
|
+
return proc().exec(`sed ${sanitizeArgs(args, 'sed', 'permissive')}`)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function awk({ args }: z.infer<typeof schemas.awk>): string {
|
|
98
|
+
return proc().exec(`awk ${sanitizeArgs(args, 'awk', 'permissive')}`)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function writeFile({ path, content }: z.infer<typeof schemas.writeFile>): Promise<string> {
|
|
102
|
+
await fs().writeFileAsync(path, content)
|
|
103
|
+
return `Wrote ${content.length} bytes to ${path}`
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function pwd(): string {
|
|
107
|
+
return proc().exec('pwd')
|
|
108
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soederpop/luca",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.19",
|
|
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>",
|
|
@@ -102,7 +102,7 @@
|
|
|
102
102
|
"chokidar": "^3.5.3",
|
|
103
103
|
"cli-markdown": "^3.5.0",
|
|
104
104
|
"compromise": "^14.14.5",
|
|
105
|
-
"contentbase": "^0.1.
|
|
105
|
+
"contentbase": "^0.1.7",
|
|
106
106
|
"cors": "^2.8.5",
|
|
107
107
|
"detect-port": "^1.5.1",
|
|
108
108
|
"dotenv": "^17.2.4",
|
|
@@ -69,6 +69,9 @@ export const AssistantOptionsSchema = FeatureOptionsSchema.extend({
|
|
|
69
69
|
|
|
70
70
|
/** History persistence mode: lifecycle (ephemeral), daily (auto-resume per day), persistent (single long-running thread), session (unique per run, resumable) */
|
|
71
71
|
historyMode: z.enum(['lifecycle', 'daily', 'persistent', 'session']).optional().describe('Conversation history persistence mode'),
|
|
72
|
+
|
|
73
|
+
/** When true, prepend a timestamp to each user message so the assistant can track the passage of time across sessions */
|
|
74
|
+
injectTimestamps: z.boolean().default(false).describe('Prepend timestamps to user messages so the assistant can perceive time passing between sessions'),
|
|
72
75
|
})
|
|
73
76
|
|
|
74
77
|
export type AssistantState = z.infer<typeof AssistantStateSchema>
|
|
@@ -402,6 +405,15 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
|
|
|
402
405
|
prompt = prompt + '\n\n' + this.options.appendPrompt
|
|
403
406
|
}
|
|
404
407
|
|
|
408
|
+
if (this.options.injectTimestamps) {
|
|
409
|
+
prompt = prompt + '\n\n' + [
|
|
410
|
+
'## Timestamps',
|
|
411
|
+
'Each user message is prefixed with a timestamp in [YYYY-MM-DD HH:MM] format.',
|
|
412
|
+
'Use these to understand the passage of time between interactions.',
|
|
413
|
+
'The user may return hours or days later within the same conversation — acknowledge the time gap naturally when relevant, and use timestamps to contextualize when topics were previously discussed.',
|
|
414
|
+
].join('\n')
|
|
415
|
+
}
|
|
416
|
+
|
|
405
417
|
return prompt.trim()
|
|
406
418
|
}
|
|
407
419
|
|
|
@@ -544,6 +556,25 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
|
|
|
544
556
|
}
|
|
545
557
|
}
|
|
546
558
|
|
|
559
|
+
/**
|
|
560
|
+
* Prepend a [YYYY-MM-DD HH:MM] timestamp to user message content.
|
|
561
|
+
*/
|
|
562
|
+
private prependTimestamp(content: string | ContentPart[]): string | ContentPart[] {
|
|
563
|
+
const now = new Date()
|
|
564
|
+
const pad = (n: number) => String(n).padStart(2, '0')
|
|
565
|
+
const stamp = `[${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}]`
|
|
566
|
+
|
|
567
|
+
if (typeof content === 'string') {
|
|
568
|
+
return `${stamp} ${content}`
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (content.length > 0 && content[0].type === 'text') {
|
|
572
|
+
return [{ type: 'text' as const, text: `${stamp} ${content[0].text}` }, ...content.slice(1)]
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
return [{ type: 'text' as const, text: stamp }, ...content]
|
|
576
|
+
}
|
|
577
|
+
|
|
547
578
|
// -- History mode helpers --
|
|
548
579
|
|
|
549
580
|
/** The assistant name derived from the folder basename. */
|
|
@@ -663,9 +694,13 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
|
|
|
663
694
|
* name matches an event gets wired up so it fires automatically when
|
|
664
695
|
* that event is emitted. Must be called before any events are emitted.
|
|
665
696
|
*/
|
|
697
|
+
/** Hook names that are called directly during lifecycle, not bound as event listeners. */
|
|
698
|
+
private static lifecycleHooks = new Set(['formatSystemPrompt'])
|
|
699
|
+
|
|
666
700
|
private bindHooksToEvents() {
|
|
667
701
|
const assistant = this
|
|
668
702
|
for (const [eventName, hookFn] of Object.entries(this._hooks)) {
|
|
703
|
+
if (Assistant.lifecycleHooks.has(eventName)) continue
|
|
669
704
|
this.on(eventName as any, (...args: any[]) => {
|
|
670
705
|
this.emit('hookFired', eventName)
|
|
671
706
|
hookFn(assistant, ...args)
|
|
@@ -690,6 +725,15 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
|
|
|
690
725
|
this._pendingPlugins = []
|
|
691
726
|
}
|
|
692
727
|
|
|
728
|
+
// Allow hooks.ts to export a formatSystemPrompt(assistant, prompt) => string
|
|
729
|
+
// that transforms the system prompt before the conversation is created.
|
|
730
|
+
if (this._hooks.formatSystemPrompt) {
|
|
731
|
+
const result = await this._hooks.formatSystemPrompt(this, this._systemPrompt)
|
|
732
|
+
if (typeof result === 'string') {
|
|
733
|
+
this._systemPrompt = result
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
693
737
|
// Wire up event forwarding from conversation to assistant.
|
|
694
738
|
// Hooks fire automatically because they're bound as event listeners.
|
|
695
739
|
this.conversation.on('turnStart', (info: any) => this.emit('turnStart', info))
|
|
@@ -745,6 +789,10 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
|
|
|
745
789
|
const count = (this.state.get('conversationCount') || 0) + 1
|
|
746
790
|
this.state.set('conversationCount', count)
|
|
747
791
|
|
|
792
|
+
if (this.options.injectTimestamps) {
|
|
793
|
+
question = this.prependTimestamp(question)
|
|
794
|
+
}
|
|
795
|
+
|
|
748
796
|
const result = await this.conversation.ask(question, options)
|
|
749
797
|
|
|
750
798
|
// Auto-save for non-lifecycle modes
|
package/src/commands/chat.ts
CHANGED
|
@@ -18,6 +18,8 @@ export const argsSchema = CommandOptionsSchema.extend({
|
|
|
18
18
|
historyMode: z.enum(['lifecycle', 'daily', 'persistent', 'session']).optional().describe('Override history persistence mode'),
|
|
19
19
|
offRecord: z.boolean().optional().describe('Alias for --history-mode lifecycle (ephemeral, no persistence)'),
|
|
20
20
|
clear: z.boolean().optional().describe('Clear the conversation history for the resolved history mode and exit'),
|
|
21
|
+
prependPrompt: z.string().optional().describe('Text or path to a markdown file to prepend to the system prompt'),
|
|
22
|
+
appendPrompt: z.string().optional().describe('Text or path to a markdown file to append to the system prompt'),
|
|
21
23
|
})
|
|
22
24
|
|
|
23
25
|
export default async function chat(options: z.infer<typeof argsSchema>, context: ContainerContext) {
|
|
@@ -67,10 +69,26 @@ export default async function chat(options: z.infer<typeof argsSchema>, context:
|
|
|
67
69
|
? 'lifecycle'
|
|
68
70
|
: (options.historyMode || 'daily')
|
|
69
71
|
|
|
70
|
-
const createOptions: Record<string, any> = { historyMode }
|
|
72
|
+
const createOptions: Record<string, any> = { historyMode, injectTimestamps: true }
|
|
71
73
|
if (options.model) createOptions.model = options.model
|
|
72
74
|
if (options.local) createOptions.local = options.local
|
|
73
75
|
|
|
76
|
+
// Resolve --prepend-prompt / --append-prompt: if it's an existing file, read it; if it ends in .md but doesn't exist, error
|
|
77
|
+
const fs = container.feature('fs')
|
|
78
|
+
for (const flag of ['prependPrompt', 'appendPrompt'] as const) {
|
|
79
|
+
const raw = options[flag]
|
|
80
|
+
if (!raw) continue
|
|
81
|
+
const resolved = container.paths.resolve(raw)
|
|
82
|
+
if (fs.exists(resolved)) {
|
|
83
|
+
createOptions[flag] = fs.readFile(resolved)
|
|
84
|
+
} else if (raw.endsWith('.md')) {
|
|
85
|
+
console.error(ui.colors.red(`File not found: ${resolved}`))
|
|
86
|
+
process.exit(1)
|
|
87
|
+
} else {
|
|
88
|
+
createOptions[flag] = raw
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
74
92
|
const assistant = manager.create(name, createOptions)
|
|
75
93
|
|
|
76
94
|
// --clear: wipe history for the current mode and exit
|
|
@@ -110,17 +128,34 @@ export default async function chat(options: z.infer<typeof argsSchema>, context:
|
|
|
110
128
|
assistant.resumeThread(options.resume)
|
|
111
129
|
}
|
|
112
130
|
|
|
113
|
-
|
|
131
|
+
const ink = container.feature('ink', { enable: true })
|
|
132
|
+
await ink.loadModules()
|
|
133
|
+
const React = ink.React
|
|
134
|
+
const { Text } = ink.components
|
|
135
|
+
|
|
136
|
+
// Use the raw ink render (sync) since modules are already loaded
|
|
137
|
+
const inkModule = await import('ink')
|
|
138
|
+
|
|
139
|
+
let responseBuffer = ''
|
|
140
|
+
let inkInstance: any = null
|
|
141
|
+
|
|
142
|
+
function mdElement(content: string) {
|
|
143
|
+
const rendered = content ? String(ui.markdown(content)).trimEnd() : ''
|
|
144
|
+
return React.createElement(Text, null, rendered)
|
|
145
|
+
}
|
|
114
146
|
|
|
115
147
|
assistant.on('chunk', (text: string) => {
|
|
116
|
-
|
|
148
|
+
responseBuffer += text
|
|
149
|
+
if (!inkInstance) {
|
|
117
150
|
process.stdout.write('\n')
|
|
118
|
-
|
|
151
|
+
inkInstance = inkModule.render(mdElement(responseBuffer), { patchConsole: false })
|
|
152
|
+
} else {
|
|
153
|
+
inkInstance.rerender(mdElement(responseBuffer))
|
|
119
154
|
}
|
|
120
|
-
process.stdout.write(text)
|
|
121
155
|
})
|
|
122
156
|
|
|
123
157
|
assistant.on('toolCall', (toolName: string, args: any) => {
|
|
158
|
+
if (inkInstance) { inkInstance.unmount(); inkInstance = null }
|
|
124
159
|
const argsStr = JSON.stringify(args).slice(0, 120)
|
|
125
160
|
process.stdout.write(ui.colors.dim(`\n ⟳ ${toolName}`) + ui.colors.dim(`(${argsStr})\n`))
|
|
126
161
|
})
|
|
@@ -136,8 +171,9 @@ export default async function chat(options: z.infer<typeof argsSchema>, context:
|
|
|
136
171
|
})
|
|
137
172
|
|
|
138
173
|
assistant.on('response', () => {
|
|
174
|
+
if (inkInstance) { inkInstance.unmount(); inkInstance = null }
|
|
175
|
+
responseBuffer = ''
|
|
139
176
|
process.stdout.write('\n')
|
|
140
|
-
isFirstChunk = true
|
|
141
177
|
})
|
|
142
178
|
|
|
143
179
|
// Start the assistant (loads history if applicable)
|