@soederpop/luca 0.0.16 → 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.
@@ -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,3 @@
1
+ export function started() {
2
+ console.log('Assistant started!')
3
+ }
@@ -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.16",
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.5",
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
@@ -1,5 +1,5 @@
1
1
  // Auto-generated bootstrap content
2
- // Generated at: 2026-03-20T16:22:30.732Z
2
+ // Generated at: 2026-03-20T21:13:00.583Z
3
3
  // Source: docs/bootstrap/*.md, docs/bootstrap/templates/*
4
4
  //
5
5
  // Do not edit manually. Run: luca build-bootstrap
@@ -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
- let isFirstChunk = true
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
- if (isFirstChunk) {
148
+ responseBuffer += text
149
+ if (!inkInstance) {
117
150
  process.stdout.write('\n')
118
- isFirstChunk = false
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)
@@ -146,7 +182,7 @@ export default async function chat(options: z.infer<typeof argsSchema>, context:
146
182
  const messageCount = assistant.messages?.length || 0
147
183
  const isResuming = historyMode !== 'lifecycle' && messageCount > 1
148
184
 
149
- const rl = readline.createInterface({
185
+ let rl = readline.createInterface({
150
186
  input: process.stdin,
151
187
  output: process.stdout,
152
188
  })
@@ -174,6 +210,46 @@ export default async function chat(options: z.infer<typeof argsSchema>, context:
174
210
  if (!question) continue
175
211
  if (question === '.exit') break
176
212
 
213
+ if (question === '/console') {
214
+ // Pause chat readline so the REPL can own stdin
215
+ rl.close()
216
+
217
+ // Build feature context like `luca console` does
218
+ const featureContext: Record<string, any> = {}
219
+ for (const fname of container.features.available) {
220
+ try { featureContext[fname] = container.feature(fname) } catch {}
221
+ }
222
+
223
+ const replPrompt = ui.colors.magenta('console') + ui.colors.dim(' > ')
224
+ const repl = container.feature('repl', { prompt: replPrompt })
225
+
226
+ console.log()
227
+ console.log(ui.colors.dim(' Dropping into console. The assistant is available as `assistant`.'))
228
+ console.log(ui.colors.dim(' Type .exit to return to chat.'))
229
+ console.log()
230
+
231
+ await repl.start({
232
+ context: {
233
+ ...featureContext,
234
+ assistant,
235
+ console,
236
+ setTimeout, setInterval, clearTimeout, clearInterval,
237
+ fetch,
238
+ },
239
+ })
240
+
241
+ // Wait for the REPL to close
242
+ await new Promise<void>((resolve) => {
243
+ repl._rl!.on('close', resolve)
244
+ })
245
+
246
+ // Resume chat readline
247
+ console.log()
248
+ console.log(ui.colors.dim(` Back in chat with ${ui.colors.cyan(name)}.`))
249
+ rl = readline.createInterface({ input: process.stdin, output: process.stdout })
250
+ continue
251
+ }
252
+
177
253
  await assistant.ask(question)
178
254
  }
179
255