chudbot 0.0.0 → 0.0.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.
- package/LICENSE +21 -0
- package/README.md +225 -0
- package/bin/chudbot-cli.js +133 -0
- package/lib/chudbot.js +105 -0
- package/lib/initializer.js +119 -0
- package/lib/parser.js +117 -0
- package/lib/provider.js +69 -0
- package/lib/resolver.js +103 -0
- package/lib/runner.js +51 -0
- package/lib/watcher.js +157 -0
- package/package.json +48 -8
- package/types.d.ts +241 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Basedwon
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the “Software”), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="chud.png" width="300" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">Chudbot</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
Chudbot is a stupid-simple “chat in a file” bot.
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
You write messages in a `chat.md` file, save it, and Chudbot appends the assistant reply right into the same file.
|
|
12
|
+
|
|
13
|
+
## What you need
|
|
14
|
+
|
|
15
|
+
- A computer (Windows or Mac)
|
|
16
|
+
- Node.js (free)
|
|
17
|
+
- An OpenRouter account + API key
|
|
18
|
+
|
|
19
|
+
## Step 1: Install Node.js
|
|
20
|
+
|
|
21
|
+
### Windows
|
|
22
|
+
|
|
23
|
+
1. Go to nodejs.org
|
|
24
|
+
2. Download the LTS installer
|
|
25
|
+
3. Run it (Next, Next, Finish)
|
|
26
|
+
4. Open **PowerShell** (Start menu → type “PowerShell”)
|
|
27
|
+
|
|
28
|
+
### Mac
|
|
29
|
+
|
|
30
|
+
1. Go to nodejs.org
|
|
31
|
+
2. Download the LTS installer
|
|
32
|
+
3. Run it
|
|
33
|
+
4. Open **Terminal**
|
|
34
|
+
|
|
35
|
+
To confirm Node is installed, run:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
node -v
|
|
39
|
+
npm -v
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Step 2: Install Chudbot
|
|
43
|
+
|
|
44
|
+
Install it globally:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
npm install -g chudbot
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Confirm it works:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
chudbot --help
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Step 3: Create an OpenRouter account and API key
|
|
57
|
+
|
|
58
|
+
1. Create an account:
|
|
59
|
+
|
|
60
|
+
[https://openrouter.ai](https://openrouter.ai/)
|
|
61
|
+
|
|
62
|
+
1. Create an API key:
|
|
63
|
+
|
|
64
|
+
https://openrouter.ai/settings/keys
|
|
65
|
+
|
|
66
|
+
You’ll paste that key into Chudbot’s `.env` in a minute.
|
|
67
|
+
|
|
68
|
+
## Step 4: Create a folder and open a terminal in it
|
|
69
|
+
|
|
70
|
+
Create a new folder anywhere you want, for example:
|
|
71
|
+
|
|
72
|
+
- `Desktop/chudbot-test`
|
|
73
|
+
|
|
74
|
+
Now open a terminal and make sure you’re “in” that folder.
|
|
75
|
+
|
|
76
|
+
If you already have a terminal open, you can always use `cd` to switch into the folder.
|
|
77
|
+
|
|
78
|
+
### Windows
|
|
79
|
+
|
|
80
|
+
Option A (easiest): open PowerShell directly in the folder
|
|
81
|
+
|
|
82
|
+
1. Open File Explorer and open your `chudbot-test` folder
|
|
83
|
+
2. Click the address bar (where the folder path is)
|
|
84
|
+
3. Type `powershell` and press Enter
|
|
85
|
+
|
|
86
|
+
Option B: open PowerShell first, then cd
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
cd $HOME
|
|
90
|
+
cd Desktop
|
|
91
|
+
cd chudbot-test
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Mac
|
|
95
|
+
|
|
96
|
+
Option A: open Terminal, then cd
|
|
97
|
+
|
|
98
|
+
1. Open Terminal
|
|
99
|
+
2. Type `cd` (with a space)
|
|
100
|
+
3. Drag the folder into the Terminal window
|
|
101
|
+
4. Press Enter
|
|
102
|
+
|
|
103
|
+
Option B (if enabled): open Terminal at the folder
|
|
104
|
+
|
|
105
|
+
- In Finder, right-click the folder → Services → New Terminal at Folder
|
|
106
|
+
|
|
107
|
+
## Step 5: Initialize the folder
|
|
108
|
+
|
|
109
|
+
This creates a starter `chat.md` in your folder.
|
|
110
|
+
|
|
111
|
+
It also creates `~/.chudbot/.env` if it doesn’t exist yet.
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
chudbot init
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Step 6: Paste your API key into Chudbot’s .env
|
|
118
|
+
|
|
119
|
+
Chudbot reads its API key from your user folder:
|
|
120
|
+
|
|
121
|
+
- Mac: `~/.chudbot/.env`
|
|
122
|
+
- Windows: `%USERPROFILE%\\.chudbot\\.env`
|
|
123
|
+
|
|
124
|
+
Open that file and set:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
OPENROUTER_API_KEY=YOUR_KEY_HERE
|
|
128
|
+
OPENROUTER_MODEL=openrouter/free
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`openrouter/free` routes to a currently-free option on OpenRouter. If it ever errors later, pick a different model.
|
|
132
|
+
|
|
133
|
+
## Step 7: Use it
|
|
134
|
+
|
|
135
|
+
Open `chat.md`, type under the last `# %% user` block, save the file, then run:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
chudbot run
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
It only runs if the chat ends with a non-empty `# %% user` message.
|
|
142
|
+
|
|
143
|
+
On success it appends `# %% assistant` with the reply, then appends a fresh `# %% user` header so you can type the next message.
|
|
144
|
+
|
|
145
|
+
## Watch mode (auto-reply on save)
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
chudbot watch
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Stop with **Ctrl+C**.
|
|
152
|
+
|
|
153
|
+
## How the chat format works
|
|
154
|
+
|
|
155
|
+
A chat file is just blocks. Each block starts with a header line:
|
|
156
|
+
|
|
157
|
+
- `# %% system`
|
|
158
|
+
- `# %% user`
|
|
159
|
+
- `# %% assistant`
|
|
160
|
+
|
|
161
|
+
Everything under that header (until the next header) is the message content.
|
|
162
|
+
|
|
163
|
+
Important rule: only type into the LAST `# %% user` block.
|
|
164
|
+
|
|
165
|
+
## Optional: create memory.md
|
|
166
|
+
|
|
167
|
+
If you create a `memory.md`, it gets injected as extra system instructions.
|
|
168
|
+
|
|
169
|
+
Example `memory.md`:
|
|
170
|
+
|
|
171
|
+
```markdown
|
|
172
|
+
- My name is Craig
|
|
173
|
+
- Keep answers short and practical
|
|
174
|
+
- I like humor
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Advanced: front matter
|
|
178
|
+
|
|
179
|
+
At the very top of `chat.md`, you can add front matter to set the model and memory file for that chat:
|
|
180
|
+
|
|
181
|
+
```markdown
|
|
182
|
+
---
|
|
183
|
+
model: openrouter/free
|
|
184
|
+
memory: memory.md
|
|
185
|
+
---
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
If you want the local memory file to override root memory instead of merging, add:
|
|
189
|
+
|
|
190
|
+
```markdown
|
|
191
|
+
---
|
|
192
|
+
memory.override: true
|
|
193
|
+
---
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Where files are loaded from
|
|
197
|
+
|
|
198
|
+
Chat:
|
|
199
|
+
|
|
200
|
+
- Uses `chat.md` in your current folder by default (or `f/--chat`)
|
|
201
|
+
|
|
202
|
+
Env:
|
|
203
|
+
|
|
204
|
+
- Mac: `~/.chudbot/.env`
|
|
205
|
+
- Windows: `%USERPROFILE%\\.chudbot\\.env`
|
|
206
|
+
|
|
207
|
+
Memory:
|
|
208
|
+
|
|
209
|
+
- Root memory: `~/.chudbot/memory.md` (Windows: `%USERPROFILE%\\.chudbot\\memory.md`)
|
|
210
|
+
- Local memory: `memory.md` next to your `chat.md`
|
|
211
|
+
- Default behavior is merge (root + blank line + local)
|
|
212
|
+
- If `memory.override: true` is set in chat front matter, local memory overrides root
|
|
213
|
+
|
|
214
|
+
## Troubleshooting
|
|
215
|
+
|
|
216
|
+
- “Missing OPENROUTER_API_KEY”
|
|
217
|
+
- Put your key into `~/.chudbot/.env` (Windows: `%USERPROFILE%\\.chudbot\\.env`)
|
|
218
|
+
- “It didn’t reply”
|
|
219
|
+
- Make sure the chat ends with a `# %% user` block that has real text
|
|
220
|
+
- “401 / unauthorized”
|
|
221
|
+
- Your API key is missing or wrong
|
|
222
|
+
- “Model not found”
|
|
223
|
+
- Try a different `OPENROUTER_MODEL` or set `model:` in chat front matter
|
|
224
|
+
- “It replied twice / weird loop”
|
|
225
|
+
- Only edit the last `# %% user` block, and let the bot append assistant blocks
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const path = require('path')
|
|
4
|
+
const { Command } = require('commander')
|
|
5
|
+
|
|
6
|
+
const pkg = require('../package.json')
|
|
7
|
+
const { Chudbot } = require('../lib/chudbot')
|
|
8
|
+
|
|
9
|
+
function int(v, def) {
|
|
10
|
+
const n = parseInt(v, 10)
|
|
11
|
+
return Number.isFinite(n) ? n : def
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function pick(opts, keys) {
|
|
15
|
+
const out = {}
|
|
16
|
+
for (const k of keys) {
|
|
17
|
+
if (opts[k] !== undefined)
|
|
18
|
+
out[k] = opts[k]
|
|
19
|
+
}
|
|
20
|
+
return out
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function makeBot(opts) {
|
|
24
|
+
return new Chudbot(pick(opts, ['userRoot', 'chudRoot', 'envPath', 'debounceMs']))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function main(argv) {
|
|
28
|
+
const program = new Command()
|
|
29
|
+
program
|
|
30
|
+
.name('chudbot')
|
|
31
|
+
.description('A tiny local markdown chatbot (chat.md in, replies appended)')
|
|
32
|
+
.version(pkg.version)
|
|
33
|
+
.option('--user-root <dir>', 'override OS home dir')
|
|
34
|
+
.option('--chud-root <dir>', 'override chudbot root (default: ~/.chudbot)')
|
|
35
|
+
.option('--env <path>', 'override env path (default: ~/.chudbot/.env)')
|
|
36
|
+
|
|
37
|
+
program
|
|
38
|
+
.command('init')
|
|
39
|
+
.description('Create a starter chat file in a folder')
|
|
40
|
+
.option('-C, --cwd <dir>', 'target folder (default: current folder)')
|
|
41
|
+
.option('-f, --chat <file>', 'chat filename (default: chat.md)')
|
|
42
|
+
.option('--force', 'overwrite chat file if it exists', false)
|
|
43
|
+
.option('--system <text>', 'system prompt for the starter chat')
|
|
44
|
+
.action((opts) => {
|
|
45
|
+
const root = program.opts()
|
|
46
|
+
const bot = makeBot(root)
|
|
47
|
+
const res = bot.init({
|
|
48
|
+
cwd: opts.cwd || process.cwd(),
|
|
49
|
+
chat: opts.chat,
|
|
50
|
+
force: !!opts.force,
|
|
51
|
+
system: opts.system,
|
|
52
|
+
})
|
|
53
|
+
console.log('created:', res.chatPath)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
program
|
|
57
|
+
.command('run')
|
|
58
|
+
.description('Run once and append the assistant reply (if last user block is non-empty)')
|
|
59
|
+
.option('-C, --cwd <dir>', 'folder to run in (default: current folder)')
|
|
60
|
+
.option('-f, --chat <file>', 'chat file (default: chat.md)')
|
|
61
|
+
.option('-m, --memory <file>', 'memory file (default: memory.md)')
|
|
62
|
+
.option('--model <id>', 'model id (default: openrouter/free)')
|
|
63
|
+
.action(async (opts) => {
|
|
64
|
+
const root = program.opts()
|
|
65
|
+
const bot = makeBot(root)
|
|
66
|
+
const res = await bot.run({
|
|
67
|
+
cwd: opts.cwd || process.cwd(),
|
|
68
|
+
chat: opts.chat,
|
|
69
|
+
memory: opts.memory,
|
|
70
|
+
model: opts.model,
|
|
71
|
+
})
|
|
72
|
+
if (!res.ok) {
|
|
73
|
+
console.error(res.error && res.error.message ? res.error.message : res.error)
|
|
74
|
+
process.exitCode = 1
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
if (!res.didRun) {
|
|
78
|
+
console.log('no-op: chat did not end with a non-empty # %% user block')
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
console.log('appended reply to:', res.chatPath)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
program
|
|
85
|
+
.command('watch')
|
|
86
|
+
.description('Watch chat file and auto-append replies on save (Ctrl+C to stop)')
|
|
87
|
+
.option('-C, --cwd <dir>', 'folder to run in (default: current folder)')
|
|
88
|
+
.option('-f, --chat <file>', 'chat file (default: chat.md)')
|
|
89
|
+
.option('-m, --memory <file>', 'memory file (default: memory.md)')
|
|
90
|
+
.option('--model <id>', 'model id (default: openrouter/free)')
|
|
91
|
+
.option('--debounce-ms <n>', 'debounce delay in ms (default: 200)')
|
|
92
|
+
.action((opts) => {
|
|
93
|
+
const root = program.opts()
|
|
94
|
+
const bot = makeBot({ ...root, debounceMs: int(opts.debounceMs, 200) })
|
|
95
|
+
|
|
96
|
+
const res = bot.watch({
|
|
97
|
+
cwd: opts.cwd || process.cwd(),
|
|
98
|
+
chat: opts.chat,
|
|
99
|
+
memory: opts.memory,
|
|
100
|
+
model: opts.model,
|
|
101
|
+
debounceMs: int(opts.debounceMs, 200),
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
const chatPath = res && res.chatPath
|
|
105
|
+
? res.chatPath
|
|
106
|
+
: path.join(opts.cwd || process.cwd(), opts.chat || 'chat.md')
|
|
107
|
+
|
|
108
|
+
console.log('watching:', chatPath)
|
|
109
|
+
console.log('tip: edit the last # %% user block, save, and wait for the reply')
|
|
110
|
+
console.log('tip: press Ctrl+C to stop')
|
|
111
|
+
|
|
112
|
+
const onStop = async () => {
|
|
113
|
+
try {
|
|
114
|
+
await bot.stop()
|
|
115
|
+
} catch (_) {}
|
|
116
|
+
process.exit(0)
|
|
117
|
+
}
|
|
118
|
+
process.on('SIGINT', onStop)
|
|
119
|
+
process.on('SIGTERM', onStop)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
program.addHelpText(
|
|
123
|
+
'afterAll',
|
|
124
|
+
'\nMore help: read the README in the project folder.\n'
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
await program.parseAsync(argv)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
main(process.argv).catch((err) => {
|
|
131
|
+
console.error(err && err.message ? err.message : err)
|
|
132
|
+
process.exitCode = 1
|
|
133
|
+
})
|
package/lib/chudbot.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
const os = require('os')
|
|
2
|
+
const fs = require('fs')
|
|
3
|
+
const path = require('path')
|
|
4
|
+
|
|
5
|
+
const { Runner } = require('./runner')
|
|
6
|
+
const { Watcher } = require('./watcher')
|
|
7
|
+
const { ChatParser } = require('./parser')
|
|
8
|
+
const { Provider } = require('./provider')
|
|
9
|
+
const { ContextResolver } = require('./resolver')
|
|
10
|
+
const { EnvLoader, Initializer } = require('./initializer')
|
|
11
|
+
|
|
12
|
+
const ANSI_DIM = '\x1b[2m'
|
|
13
|
+
const ANSI_RESET = '\x1b[0m'
|
|
14
|
+
|
|
15
|
+
class Spinner {
|
|
16
|
+
constructor() {
|
|
17
|
+
this.spinner = null
|
|
18
|
+
this.spinnerFrame = 0
|
|
19
|
+
this.spinnerFrames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏']
|
|
20
|
+
this.spinnerActive = false
|
|
21
|
+
}
|
|
22
|
+
startSpinner(label = 'thinking') {
|
|
23
|
+
if (this.spinnerActive) return
|
|
24
|
+
this.spinnerActive = true
|
|
25
|
+
process.stdout.write(ANSI_DIM)
|
|
26
|
+
this.spinner = setInterval(() => {
|
|
27
|
+
const index = this.spinnerFrame++ % this.spinnerFrames.length
|
|
28
|
+
const frame = this.spinnerFrames[index]
|
|
29
|
+
process.stdout.write('\r' + frame + ' ' + label)
|
|
30
|
+
}, 80)
|
|
31
|
+
}
|
|
32
|
+
stopSpinner() {
|
|
33
|
+
if (!this.spinnerActive) return
|
|
34
|
+
clearInterval(this.spinner)
|
|
35
|
+
this.spinner = null
|
|
36
|
+
this.spinnerActive = false
|
|
37
|
+
process.stdout.write('\r\x1b[K')
|
|
38
|
+
process.stdout.write(ANSI_RESET)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
class Chudbot {
|
|
42
|
+
constructor(opts = {}) {
|
|
43
|
+
this.userRoot = opts.userRoot || os.homedir()
|
|
44
|
+
this.chudRoot = opts.chudRoot || path.join(this.userRoot, '.chudbot')
|
|
45
|
+
this.envPath = opts.envPath || path.join(this.chudRoot, '.env')
|
|
46
|
+
|
|
47
|
+
this.parser = new ChatParser(opts)
|
|
48
|
+
this.resolver = new ContextResolver({
|
|
49
|
+
...opts,
|
|
50
|
+
userRoot: this.userRoot,
|
|
51
|
+
chudRoot: this.chudRoot,
|
|
52
|
+
envPath: this.envPath,
|
|
53
|
+
})
|
|
54
|
+
this.env = new EnvLoader({
|
|
55
|
+
...opts,
|
|
56
|
+
userRoot: this.userRoot,
|
|
57
|
+
chudRoot: this.chudRoot,
|
|
58
|
+
envPath: this.envPath,
|
|
59
|
+
})
|
|
60
|
+
this.initializer = new Initializer({
|
|
61
|
+
...opts,
|
|
62
|
+
userRoot: this.userRoot,
|
|
63
|
+
chudRoot: this.chudRoot,
|
|
64
|
+
envPath: this.envPath,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
this.initializer.initRoot({ makeEnv: true })
|
|
68
|
+
this.env.load({ envPath: this.envPath, required: false, override: false })
|
|
69
|
+
|
|
70
|
+
this.provider = new Provider(opts)
|
|
71
|
+
this.runner = new Runner({
|
|
72
|
+
parser: this.parser,
|
|
73
|
+
resolver: this.resolver,
|
|
74
|
+
provider: this.provider,
|
|
75
|
+
})
|
|
76
|
+
const spinner = new Spinner()
|
|
77
|
+
this.watcher = new Watcher({
|
|
78
|
+
runner: this.runner,
|
|
79
|
+
debounceMs: opts.debounceMs,
|
|
80
|
+
onEvent: (evt) => {
|
|
81
|
+
if (evt.type === 'signal')
|
|
82
|
+
spinner.startSpinner('thinking')
|
|
83
|
+
if (evt.type === 'run_end')
|
|
84
|
+
spinner.stopSpinner()
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
init(opts = {}) {
|
|
89
|
+
// opts.cwd, opts.chat, opts.force, opts.system
|
|
90
|
+
return this.initializer.initChat(opts)
|
|
91
|
+
}
|
|
92
|
+
async run(opts = {}) {
|
|
93
|
+
// opts.cwd, opts.chat, opts.memory, opts.model
|
|
94
|
+
return await this.runner.runOnce(opts)
|
|
95
|
+
}
|
|
96
|
+
watch(opts = {}) {
|
|
97
|
+
// opts.cwd, opts.chat, opts.memory, opts.model, opts.debounceMs
|
|
98
|
+
return this.watcher.start(opts)
|
|
99
|
+
}
|
|
100
|
+
async stop() {
|
|
101
|
+
return await this.watcher.stop()
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = { Chudbot }
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const path = require('path')
|
|
3
|
+
const os = require('os')
|
|
4
|
+
const dotenv = require('dotenv')
|
|
5
|
+
|
|
6
|
+
class EnvLoader {
|
|
7
|
+
constructor(opts = {}) {
|
|
8
|
+
this.userRoot = opts.userRoot || os.homedir()
|
|
9
|
+
this.chudRoot = opts.chudRoot || path.join(this.userRoot, '.chudbot')
|
|
10
|
+
this.envPath = opts.envPath || path.join(this.chudRoot, '.env')
|
|
11
|
+
}
|
|
12
|
+
load(opts = {}) {
|
|
13
|
+
// Loads env vars from envPath into process.env.
|
|
14
|
+
// opts.envPath: string (override)
|
|
15
|
+
// opts.override: boolean (default false)
|
|
16
|
+
// opts.required: boolean (default false)
|
|
17
|
+
const envPath = opts.envPath || this.envPath
|
|
18
|
+
const override = opts.override ?? false
|
|
19
|
+
const required = opts.required ?? false
|
|
20
|
+
if (!fs.existsSync(envPath)) {
|
|
21
|
+
if (required)
|
|
22
|
+
throw new Error('Missing env file: ' + envPath)
|
|
23
|
+
return { ok: true, envPath, loaded: false }
|
|
24
|
+
}
|
|
25
|
+
const res = dotenv.config({ path: envPath, override, quiet: true })
|
|
26
|
+
if (res && res.error)
|
|
27
|
+
throw res.error
|
|
28
|
+
return { ok: true, envPath, loaded: true }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
class Initializer {
|
|
33
|
+
constructor(opts = {}) {
|
|
34
|
+
this.userRoot = opts.userRoot || os.homedir()
|
|
35
|
+
this.chudRoot = opts.chudRoot || path.join(this.userRoot, '.chudbot')
|
|
36
|
+
this.envPath = opts.envPath || path.join(this.chudRoot, '.env')
|
|
37
|
+
this.defaultChatName = opts.defaultChatName || 'chat.md'
|
|
38
|
+
}
|
|
39
|
+
initRoot(opts = {}) {
|
|
40
|
+
// Create chudRoot and (optionally) starter .env.
|
|
41
|
+
// opts.makeEnv: boolean (default true)
|
|
42
|
+
const makeEnv = opts.makeEnv ?? true
|
|
43
|
+
fs.mkdirSync(this.chudRoot, { recursive: true })
|
|
44
|
+
const b = makeEnv
|
|
45
|
+
? this.writeIfMissing(this.envPath, this.defaultEnv(), { force: false })
|
|
46
|
+
: { didWrite: false }
|
|
47
|
+
return {
|
|
48
|
+
ok: true,
|
|
49
|
+
chudRoot: this.chudRoot,
|
|
50
|
+
envPath: this.envPath,
|
|
51
|
+
didWriteEnv: b.didWrite,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
initChat(opts = {}) {
|
|
55
|
+
// Create starter chat in opts.cwd.
|
|
56
|
+
// opts.cwd: string (default process.cwd())
|
|
57
|
+
// opts.chat: string (default chat.md)
|
|
58
|
+
// opts.force: boolean
|
|
59
|
+
// opts.system: string
|
|
60
|
+
const cwd = opts.cwd || process.cwd()
|
|
61
|
+
const chat = opts.chat || this.defaultChatName
|
|
62
|
+
const force = opts.force ?? false
|
|
63
|
+
const chatPath = path.isAbsolute(chat) ? chat : path.join(cwd, chat)
|
|
64
|
+
const a = this.writeIfMissing(
|
|
65
|
+
chatPath,
|
|
66
|
+
this.defaultChat({ system: opts.system }),
|
|
67
|
+
{ force }
|
|
68
|
+
)
|
|
69
|
+
return { ok: true, chatPath, didWriteChat: a.didWrite }
|
|
70
|
+
}
|
|
71
|
+
init(opts = {}) {
|
|
72
|
+
// Convenience: initRoot + initChat.
|
|
73
|
+
const root = this.initRoot({ makeEnv: opts.makeEnv })
|
|
74
|
+
const chat = this.initChat(opts)
|
|
75
|
+
return {
|
|
76
|
+
ok: true,
|
|
77
|
+
chudRoot: root.chudRoot,
|
|
78
|
+
envPath: root.envPath,
|
|
79
|
+
chatPath: chat.chatPath,
|
|
80
|
+
didWriteEnv: root.didWriteEnv,
|
|
81
|
+
didWriteChat: chat.didWriteChat,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
defaultChat(opts = {}) {
|
|
85
|
+
// System + empty user header.
|
|
86
|
+
const system = String(opts.system || 'You are a helpful assistant.').trim()
|
|
87
|
+
return [
|
|
88
|
+
'# %% system',
|
|
89
|
+
system,
|
|
90
|
+
'',
|
|
91
|
+
'# %% user',
|
|
92
|
+
'',
|
|
93
|
+
].join('\n')
|
|
94
|
+
}
|
|
95
|
+
defaultEnv() {
|
|
96
|
+
// Minimal template.
|
|
97
|
+
return [
|
|
98
|
+
'# Chudbot env',
|
|
99
|
+
'# Put your key here:',
|
|
100
|
+
'OPENROUTER_API_KEY=REPLACE_ME',
|
|
101
|
+
'OPENROUTER_MODEL=openrouter/free',
|
|
102
|
+
'',
|
|
103
|
+
].join('\n')
|
|
104
|
+
}
|
|
105
|
+
writeIfMissing(filePath, content, opts = {}) {
|
|
106
|
+
// Write only if missing unless force.
|
|
107
|
+
const force = opts.force ?? false
|
|
108
|
+
const p = String(filePath || '')
|
|
109
|
+
if (!p)
|
|
110
|
+
throw new Error('Missing filePath')
|
|
111
|
+
if (fs.existsSync(p) && !force)
|
|
112
|
+
return { didWrite: false }
|
|
113
|
+
fs.mkdirSync(path.dirname(p), { recursive: true })
|
|
114
|
+
fs.writeFileSync(p, String(content || ''), 'utf8')
|
|
115
|
+
return { didWrite: true }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
module.exports = { EnvLoader, Initializer }
|
package/lib/parser.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
class ChatParser {
|
|
2
|
+
constructor(opts = {}) {
|
|
3
|
+
this.eol = opts.eol || '\n'
|
|
4
|
+
this.headerRe = opts.headerRe || /^#\s*%%\s*(\w+)\s*$/
|
|
5
|
+
this.allowedRoles = opts.allowedRoles || {
|
|
6
|
+
system: true,
|
|
7
|
+
user: true,
|
|
8
|
+
assistant: true,
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
parse(raw, opts = {}) {
|
|
12
|
+
raw = String(raw || '')
|
|
13
|
+
const fm = this.parseFrontMatter(raw)
|
|
14
|
+
if (fm && fm.body != null)
|
|
15
|
+
raw = fm.body
|
|
16
|
+
const blocks = this.parseBlocks(raw, opts)
|
|
17
|
+
const messages = blocks.map((b) => ({ role: b.role, content: b.content }))
|
|
18
|
+
return {
|
|
19
|
+
frontMatter: fm ? fm.data : {},
|
|
20
|
+
blocks,
|
|
21
|
+
messages,
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
parseFile(filePath, opts = {}) {
|
|
25
|
+
const raw = require('fs').readFileSync(filePath, 'utf8')
|
|
26
|
+
return this.parse(raw, { ...opts, filePath })
|
|
27
|
+
}
|
|
28
|
+
parseFrontMatter(raw) {
|
|
29
|
+
// Minimal YAML-ish front matter:
|
|
30
|
+
// ---
|
|
31
|
+
// model: openrouter/free
|
|
32
|
+
// memory: memory.md
|
|
33
|
+
// ---
|
|
34
|
+
// Only supports top-level key: value pairs.
|
|
35
|
+
const s = String(raw || '')
|
|
36
|
+
if (!s.startsWith('---\n') && !s.startsWith('---\r\n'))
|
|
37
|
+
return null
|
|
38
|
+
const endIdx = s.indexOf('\n---', 4)
|
|
39
|
+
if (endIdx === -1)
|
|
40
|
+
return null
|
|
41
|
+
const head = s.slice(4, endIdx + 1)
|
|
42
|
+
const body = s.slice(endIdx + 5)
|
|
43
|
+
const data = {}
|
|
44
|
+
for (const line of head.split(/\r?\n/)) {
|
|
45
|
+
const t = String(line || '').trim()
|
|
46
|
+
if (!t || t.startsWith('#'))
|
|
47
|
+
continue
|
|
48
|
+
const ii = t.indexOf(':')
|
|
49
|
+
if (ii === -1)
|
|
50
|
+
continue
|
|
51
|
+
const k = t.slice(0, ii).trim()
|
|
52
|
+
let v = t.slice(ii + 1).trim()
|
|
53
|
+
if ((v.startsWith('"') && v.endsWith('"')) ||
|
|
54
|
+
(v.startsWith("'") && v.endsWith("'")))
|
|
55
|
+
v = v.slice(1, -1)
|
|
56
|
+
if (k)
|
|
57
|
+
data[k] = v
|
|
58
|
+
}
|
|
59
|
+
return { data, body }
|
|
60
|
+
}
|
|
61
|
+
parseBlocks(raw, opts = {}) {
|
|
62
|
+
const lines = String(raw || '').split(/\r?\n/)
|
|
63
|
+
const blocks = []
|
|
64
|
+
let cur = null
|
|
65
|
+
let buf = []
|
|
66
|
+
const push = () => {
|
|
67
|
+
if (!cur)
|
|
68
|
+
return
|
|
69
|
+
const content = buf.join(this.eol).replace(/\s+$/,'')
|
|
70
|
+
blocks.push({ role: cur, content })
|
|
71
|
+
buf = []
|
|
72
|
+
}
|
|
73
|
+
for (const line of lines) {
|
|
74
|
+
const m = String(line || '').match(this.headerRe)
|
|
75
|
+
if (m) {
|
|
76
|
+
push()
|
|
77
|
+
const role = String(m[1] || '').toLowerCase()
|
|
78
|
+
if (!this.allowedRoles[role]) {
|
|
79
|
+
if (opts.allowUnknown)
|
|
80
|
+
cur = role
|
|
81
|
+
else
|
|
82
|
+
throw new Error('Unknown role: ' + role)
|
|
83
|
+
} else {
|
|
84
|
+
cur = role
|
|
85
|
+
}
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
if (cur)
|
|
89
|
+
buf.push(line)
|
|
90
|
+
}
|
|
91
|
+
push()
|
|
92
|
+
return blocks
|
|
93
|
+
}
|
|
94
|
+
isRunnable(parsed, opts = {}) {
|
|
95
|
+
const minLen = Number(opts.minLen || 1)
|
|
96
|
+
const last = this.getLastBlock(parsed)
|
|
97
|
+
if (!last || last.role !== 'user')
|
|
98
|
+
return false
|
|
99
|
+
return String(last.content || '').trim().length >= minLen
|
|
100
|
+
}
|
|
101
|
+
getLastBlock(parsed) {
|
|
102
|
+
const blocks = parsed && parsed.blocks
|
|
103
|
+
if (!Array.isArray(blocks) || !blocks.length)
|
|
104
|
+
return null
|
|
105
|
+
return blocks[blocks.length - 1]
|
|
106
|
+
}
|
|
107
|
+
formatAppendAssistant(text, opts = {}) {
|
|
108
|
+
const trim = opts.trim ?? true
|
|
109
|
+
const body = trim ? String(text || '').trim() : String(text || '')
|
|
110
|
+
return this.eol + this.eol + '# %% assistant' + this.eol + body
|
|
111
|
+
}
|
|
112
|
+
formatAppendUser() {
|
|
113
|
+
return this.eol + this.eol + '# %% user' + this.eol
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { ChatParser }
|
package/lib/provider.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
const axios = require('axios')
|
|
2
|
+
const path = require('path')
|
|
3
|
+
const os = require('os')
|
|
4
|
+
const dotenv = require('dotenv')
|
|
5
|
+
|
|
6
|
+
class Provider {
|
|
7
|
+
constructor(opts = {}) {
|
|
8
|
+
this.baseUrl = opts.baseUrl || 'https://openrouter.ai/api/v1'
|
|
9
|
+
this.apiKey = opts.apiKey || process.env.OPENROUTER_API_KEY || ''
|
|
10
|
+
this.model = opts.model || process.env.OPENROUTER_MODEL || 'openrouter/free'
|
|
11
|
+
this.timeoutMs = opts.timeoutMs || 60000
|
|
12
|
+
this.headers = opts.headers || {}
|
|
13
|
+
if (!this.apiKey)
|
|
14
|
+
throw new Error('Missing OPENROUTER_API_KEY')
|
|
15
|
+
}
|
|
16
|
+
async send(messages, opts = {}) {
|
|
17
|
+
// POST /chat/completions, return full JSON response.
|
|
18
|
+
// opts.model, opts.temperature, opts.maxTokens, opts.headers
|
|
19
|
+
const url = this.baseUrl.replace(/\/+$/, '') + '/chat/completions'
|
|
20
|
+
const model = opts.model || this.model
|
|
21
|
+
const payload = {
|
|
22
|
+
model,
|
|
23
|
+
messages,
|
|
24
|
+
}
|
|
25
|
+
if (opts.temperature != null)
|
|
26
|
+
payload.temperature = opts.temperature
|
|
27
|
+
if (opts.maxTokens != null)
|
|
28
|
+
payload.max_tokens = opts.maxTokens
|
|
29
|
+
const headers = {
|
|
30
|
+
Authorization: 'Bearer ' + this.apiKey,
|
|
31
|
+
'Content-Type': 'application/json',
|
|
32
|
+
...this.headers,
|
|
33
|
+
...(opts.headers || {}),
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const res = await axios.post(url, payload, {
|
|
37
|
+
headers,
|
|
38
|
+
timeout: this.timeoutMs,
|
|
39
|
+
})
|
|
40
|
+
return res.data
|
|
41
|
+
} catch (err) {
|
|
42
|
+
const r = err && err.response
|
|
43
|
+
if (r && r.status)
|
|
44
|
+
throw new Error('OpenRouter ' + r.status + ': ' + this._errMsg(r.data))
|
|
45
|
+
throw err
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async complete(messages, opts = {}) {
|
|
49
|
+
// send() then return assistant content string.
|
|
50
|
+
const json = await this.send(messages, opts)
|
|
51
|
+
return this.extractContent(json)
|
|
52
|
+
}
|
|
53
|
+
extractContent(json) {
|
|
54
|
+
const c = json && json.choices && json.choices[0]
|
|
55
|
+
const m = c && c.message
|
|
56
|
+
const t = m && m.content
|
|
57
|
+
const s = String(t || '')
|
|
58
|
+
if (!s.trim())
|
|
59
|
+
throw new Error('Missing assistant content')
|
|
60
|
+
return s
|
|
61
|
+
}
|
|
62
|
+
_errMsg(data) {
|
|
63
|
+
const e = data && (data.error || data)
|
|
64
|
+
const m = e && (e.message || e.error || e)
|
|
65
|
+
return String(m || 'request failed')
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { Provider }
|
package/lib/resolver.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const path = require('path')
|
|
3
|
+
const os = require('os')
|
|
4
|
+
|
|
5
|
+
class ContextResolver {
|
|
6
|
+
constructor(opts = {}) {
|
|
7
|
+
this.userRoot = opts.userRoot || os.homedir()
|
|
8
|
+
this.chudRoot = opts.chudRoot || path.join(this.userRoot, '.chudbot')
|
|
9
|
+
this.envPath = opts.envPath || path.join(this.chudRoot, '.env')
|
|
10
|
+
this.defaultChatName = opts.defaultChatName || 'chat.md'
|
|
11
|
+
this.defaultMemoryName = opts.defaultMemoryName || 'memory.md'
|
|
12
|
+
}
|
|
13
|
+
resolvePaths(opts = {}) {
|
|
14
|
+
// Finds chat in cwd first, then chudRoot.
|
|
15
|
+
const cwd = opts.cwd || process.cwd()
|
|
16
|
+
const chatName = opts.chat || this.defaultChatName
|
|
17
|
+
const chatPath = this._pickPath(chatName, [cwd, this.chudRoot])
|
|
18
|
+
const chatDir = path.dirname(chatPath)
|
|
19
|
+
const fm = opts.frontMatter || null
|
|
20
|
+
const memName = (fm && fm.memory) || opts.memory || this.defaultMemoryName
|
|
21
|
+
const rootMem = path.join(this.chudRoot, this.defaultMemoryName)
|
|
22
|
+
const localMem = this._pickPath(memName, [chatDir], { allowMissing: true })
|
|
23
|
+
return {
|
|
24
|
+
cwd,
|
|
25
|
+
userRoot: this.userRoot,
|
|
26
|
+
chudRoot: this.chudRoot,
|
|
27
|
+
envPath: this.envPath,
|
|
28
|
+
chatPath,
|
|
29
|
+
rootMemoryPath: rootMem,
|
|
30
|
+
localMemoryPath: localMem,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
readText(filePath, opts = {}) {
|
|
34
|
+
// opts.required: throw if missing
|
|
35
|
+
const p = String(filePath || '')
|
|
36
|
+
if (!p)
|
|
37
|
+
return ''
|
|
38
|
+
if (!fs.existsSync(p)) {
|
|
39
|
+
if (opts.required)
|
|
40
|
+
throw new Error('Missing file: ' + p)
|
|
41
|
+
return ''
|
|
42
|
+
}
|
|
43
|
+
return fs.readFileSync(p, 'utf8')
|
|
44
|
+
}
|
|
45
|
+
loadChat(opts = {}) {
|
|
46
|
+
const paths = this.resolvePaths(opts)
|
|
47
|
+
const raw = this.readText(paths.chatPath, { required: true })
|
|
48
|
+
return { chatPath: paths.chatPath, raw, paths }
|
|
49
|
+
}
|
|
50
|
+
loadMemory(opts = {}) {
|
|
51
|
+
// Merges root + local memory unless memory.override is truthy.
|
|
52
|
+
const paths = this.resolvePaths(opts)
|
|
53
|
+
const fm = opts.frontMatter || null
|
|
54
|
+
const over = this._truthy(fm && fm['memory.override'])
|
|
55
|
+
const root = this.readText(paths.rootMemoryPath)
|
|
56
|
+
const local = this.readText(paths.localMemoryPath)
|
|
57
|
+
let raw = ''
|
|
58
|
+
if (over)
|
|
59
|
+
raw = local
|
|
60
|
+
else
|
|
61
|
+
raw = this._joinNonEmpty(root, local)
|
|
62
|
+
return { raw, paths, over }
|
|
63
|
+
}
|
|
64
|
+
buildMessages(parsed, memoryRaw, opts = {}) {
|
|
65
|
+
// Prepends memory as a system message if present.
|
|
66
|
+
const msgs = (parsed && parsed.messages) || []
|
|
67
|
+
const mem = String(memoryRaw || '').trim()
|
|
68
|
+
if (!mem)
|
|
69
|
+
return msgs
|
|
70
|
+
const role = opts.memoryRole || 'system'
|
|
71
|
+
return [{ role, content: mem }, ...msgs]
|
|
72
|
+
}
|
|
73
|
+
_pickPath(name, roots, opts = {}) {
|
|
74
|
+
const s = String(name || '').trim()
|
|
75
|
+
if (!s)
|
|
76
|
+
return ''
|
|
77
|
+
if (path.isAbsolute(s))
|
|
78
|
+
return s
|
|
79
|
+
for (const root of roots || []) {
|
|
80
|
+
const p = path.join(root, s)
|
|
81
|
+
if (fs.existsSync(p))
|
|
82
|
+
return p
|
|
83
|
+
}
|
|
84
|
+
if (opts.allowMissing)
|
|
85
|
+
return path.join(roots && roots[0] ? roots[0] : '', s)
|
|
86
|
+
return path.join(roots && roots[0] ? roots[0] : '', s)
|
|
87
|
+
}
|
|
88
|
+
_truthy(v) {
|
|
89
|
+
const s = String(v || '').trim().toLowerCase()
|
|
90
|
+
return s === '1' || s === 'true' || s === 'yes' || s === 'y'
|
|
91
|
+
}
|
|
92
|
+
_joinNonEmpty(a, b) {
|
|
93
|
+
const x = String(a || '').trim()
|
|
94
|
+
const y = String(b || '').trim()
|
|
95
|
+
if (x && y)
|
|
96
|
+
return x + '\n\n' + y
|
|
97
|
+
return x || y || ''
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = {
|
|
102
|
+
ContextResolver,
|
|
103
|
+
}
|
package/lib/runner.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
|
|
3
|
+
class Runner {
|
|
4
|
+
constructor(opts = {}) {
|
|
5
|
+
this.parser = opts.parser
|
|
6
|
+
this.resolver = opts.resolver
|
|
7
|
+
this.provider = opts.provider
|
|
8
|
+
if (!this.parser) throw new Error('Runner requires parser')
|
|
9
|
+
if (!this.resolver) throw new Error('Runner requires resolver')
|
|
10
|
+
if (!this.provider) throw new Error('Runner requires provider')
|
|
11
|
+
}
|
|
12
|
+
async runOnce(opts = {}) {
|
|
13
|
+
// opts.cwd: string
|
|
14
|
+
// opts.chat: string
|
|
15
|
+
// opts.memory: string
|
|
16
|
+
// opts.model: string
|
|
17
|
+
try {
|
|
18
|
+
const chat = this.resolver.loadChat(opts)
|
|
19
|
+
const parsed = this.parser.parse(chat.raw, opts)
|
|
20
|
+
if (!this.shouldRun(parsed, opts))
|
|
21
|
+
return { ok: true, didRun: false, chatPath: chat.chatPath }
|
|
22
|
+
const mem = this.resolver.loadMemory({
|
|
23
|
+
...opts,
|
|
24
|
+
cwd: chat.paths.cwd,
|
|
25
|
+
frontMatter: parsed.frontMatter,
|
|
26
|
+
})
|
|
27
|
+
const messages = this.resolver.buildMessages(parsed, mem.raw, opts)
|
|
28
|
+
const model = opts.model || (parsed.frontMatter && parsed.frontMatter.model)
|
|
29
|
+
const assistantText = await this.provider.complete(messages, { model })
|
|
30
|
+
const rawNext = this.buildAppend(chat.raw, assistantText, opts)
|
|
31
|
+
this.writeAll(chat.chatPath, rawNext, opts)
|
|
32
|
+
return { ok: true, didRun: true, chatPath: chat.chatPath }
|
|
33
|
+
} catch (err) {
|
|
34
|
+
return { ok: false, didRun: false, error: err }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
shouldRun(parsed, opts = {}) {
|
|
38
|
+
return this.parser.isRunnable(parsed, opts)
|
|
39
|
+
}
|
|
40
|
+
buildAppend(raw, assistantText, opts = {}) {
|
|
41
|
+
const base = String(raw || '').replace(/\s+$/, '')
|
|
42
|
+
const a = this.parser.formatAppendAssistant(assistantText, opts)
|
|
43
|
+
const u = this.parser.formatAppendUser(opts)
|
|
44
|
+
return base + a + u
|
|
45
|
+
}
|
|
46
|
+
writeAll(filePath, rawNext, opts = {}) {
|
|
47
|
+
fs.writeFileSync(filePath, String(rawNext || ''), 'utf8')
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { Runner }
|
package/lib/watcher.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
const chokidar = require('chokidar')
|
|
2
|
+
|
|
3
|
+
class Watcher {
|
|
4
|
+
constructor(opts = {}) {
|
|
5
|
+
this.runner = opts.runner
|
|
6
|
+
this.watcher = null
|
|
7
|
+
this.busy = false
|
|
8
|
+
this.pending = false
|
|
9
|
+
this.timer = null
|
|
10
|
+
this.debounceMs = opts.debounceMs || 200
|
|
11
|
+
this.onEvent = typeof opts.onEvent === 'function' ? opts.onEvent : null
|
|
12
|
+
this.quiet = opts.quiet ?? false
|
|
13
|
+
if (!this.runner)
|
|
14
|
+
throw new Error('Watcher requires runner')
|
|
15
|
+
}
|
|
16
|
+
start(opts = {}) {
|
|
17
|
+
this.debounceMs = opts.debounceMs || this.debounceMs
|
|
18
|
+
if (typeof opts.onEvent === 'function')
|
|
19
|
+
this.onEvent = opts.onEvent
|
|
20
|
+
if (this.watcher)
|
|
21
|
+
return { ok: true, watching: true }
|
|
22
|
+
|
|
23
|
+
const chatPath = this._chatPath(opts)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
this.watcher = chokidar.watch(chatPath, {
|
|
27
|
+
ignoreInitial: true,
|
|
28
|
+
awaitWriteFinish: {
|
|
29
|
+
stabilityThreshold: 150,
|
|
30
|
+
pollInterval: 25
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
this.watcher.on('all', (evt, p) => {
|
|
35
|
+
if (evt === 'add' || evt === 'change')
|
|
36
|
+
this.onChange(p, opts)
|
|
37
|
+
})
|
|
38
|
+
// this.watcher.on('change', p => this.onChange(p, opts))
|
|
39
|
+
this._emit('watch_start', { chatPath }, opts)
|
|
40
|
+
|
|
41
|
+
return { ok: true, watching: true, chatPath }
|
|
42
|
+
}
|
|
43
|
+
async stop(opts = {}) {
|
|
44
|
+
if (this.timer) {
|
|
45
|
+
clearTimeout(this.timer)
|
|
46
|
+
this.timer = null
|
|
47
|
+
}
|
|
48
|
+
if (this.watcher) {
|
|
49
|
+
const w = this.watcher
|
|
50
|
+
this.watcher = null
|
|
51
|
+
try { await w.close() } catch {}
|
|
52
|
+
}
|
|
53
|
+
this.busy = false
|
|
54
|
+
this.pending = false
|
|
55
|
+
this._emit('watch_stop', {}, opts)
|
|
56
|
+
return { ok: true, watching: false }
|
|
57
|
+
}
|
|
58
|
+
_chatPath(opts = {}) {
|
|
59
|
+
if (opts.chatPath)
|
|
60
|
+
return String(opts.chatPath)
|
|
61
|
+
if (opts.chat)
|
|
62
|
+
return String(opts.chat)
|
|
63
|
+
|
|
64
|
+
const r = this.runner.resolver
|
|
65
|
+
if (!r || typeof r.resolvePaths !== 'function')
|
|
66
|
+
throw new Error('Watcher requires chatPath/chat or runner.resolver.resolvePaths()')
|
|
67
|
+
|
|
68
|
+
const out = r.resolvePaths(opts) || {}
|
|
69
|
+
const chatPath = out.chatPath || null
|
|
70
|
+
if (!chatPath)
|
|
71
|
+
throw new Error('Missing chat path')
|
|
72
|
+
|
|
73
|
+
return chatPath
|
|
74
|
+
}
|
|
75
|
+
_emit(type, payload = {}, opts = {}) {
|
|
76
|
+
const fn = (opts && typeof opts.onEvent === 'function')
|
|
77
|
+
? opts.onEvent
|
|
78
|
+
: this.onEvent
|
|
79
|
+
|
|
80
|
+
if (typeof fn === 'function') {
|
|
81
|
+
try {
|
|
82
|
+
fn({ type, ...payload })
|
|
83
|
+
} catch (e) {
|
|
84
|
+
const message = e && e.message ? e.message : String(e)
|
|
85
|
+
process.stderr.write('watch onEvent error: ' + message + '\n')
|
|
86
|
+
}
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (this.quiet)
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
if (type === 'watch_start')
|
|
94
|
+
return process.stdout.write('watching: ' + (payload.chatPath || '') + '\n')
|
|
95
|
+
|
|
96
|
+
if (type === 'signal')
|
|
97
|
+
return process.stdout.write('change detected\n')
|
|
98
|
+
|
|
99
|
+
if (type === 'pending')
|
|
100
|
+
return process.stdout.write('queued\n')
|
|
101
|
+
|
|
102
|
+
if (type === 'run_start')
|
|
103
|
+
return process.stdout.write('running...\n')
|
|
104
|
+
|
|
105
|
+
if (type === 'run_end') {
|
|
106
|
+
if (payload && payload.ok)
|
|
107
|
+
return process.stdout.write('done\n')
|
|
108
|
+
const msg = payload && payload.error ? String(payload.error) : 'error'
|
|
109
|
+
return process.stdout.write('error: ' + msg + '\n')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (type === 'watch_stop')
|
|
113
|
+
return process.stdout.write('stopped\n')
|
|
114
|
+
}
|
|
115
|
+
onChange(filePath, opts = {}) {
|
|
116
|
+
const first = !this.timer
|
|
117
|
+
if (this.timer)
|
|
118
|
+
clearTimeout(this.timer)
|
|
119
|
+
this.timer = setTimeout(() => {
|
|
120
|
+
this.timer = null
|
|
121
|
+
this._kick(opts)
|
|
122
|
+
}, this.debounceMs)
|
|
123
|
+
if (first)
|
|
124
|
+
this._emit('signal', { filePath }, opts)
|
|
125
|
+
}
|
|
126
|
+
async _kick(opts = {}) {
|
|
127
|
+
if (this.busy) {
|
|
128
|
+
this.pending = true
|
|
129
|
+
this._emit('pending', {}, opts)
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
this.busy = true
|
|
134
|
+
const start = Date.now()
|
|
135
|
+
this._emit('run_start', {}, opts)
|
|
136
|
+
|
|
137
|
+
let ok = true
|
|
138
|
+
let errMsg = null
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
await this.runner.runOnce(opts)
|
|
142
|
+
} catch (err) {
|
|
143
|
+
ok = false
|
|
144
|
+
errMsg = err && err.message ? err.message : String(err)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
this._emit('run_end', { ok, ms: Date.now() - start, error: errMsg }, opts)
|
|
148
|
+
this.busy = false
|
|
149
|
+
|
|
150
|
+
if (this.pending) {
|
|
151
|
+
this.pending = false
|
|
152
|
+
await this._kick(opts)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
module.exports = { Watcher }
|
package/package.json
CHANGED
|
@@ -1,12 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chudbot",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "",
|
|
5
|
-
"main": "
|
|
6
|
-
"
|
|
7
|
-
"
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Chudbot is a stupid-simple “chat in a file” bot.",
|
|
5
|
+
"main": "lib/chudbot.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"chudbot": "bin/chudbot-cli.js"
|
|
8
8
|
},
|
|
9
|
-
"
|
|
10
|
-
"
|
|
11
|
-
|
|
9
|
+
"types": "types.d.ts",
|
|
10
|
+
"files": [
|
|
11
|
+
"bin/",
|
|
12
|
+
"lib/",
|
|
13
|
+
"types.d.ts",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"directories": {
|
|
18
|
+
"lib": "lib"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"axios": "^1.13.6",
|
|
23
|
+
"chokidar": "^3.6.0",
|
|
24
|
+
"commander": "^14.0.3",
|
|
25
|
+
"dotenv": "^17.3.1"
|
|
26
|
+
},
|
|
27
|
+
"author": "Basedwon",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"keywords": [
|
|
30
|
+
"ai",
|
|
31
|
+
"llm",
|
|
32
|
+
"chatgpt",
|
|
33
|
+
"gpt",
|
|
34
|
+
"claude",
|
|
35
|
+
"openrouter",
|
|
36
|
+
"prompt",
|
|
37
|
+
"prompting",
|
|
38
|
+
"chatbot",
|
|
39
|
+
"markdown",
|
|
40
|
+
"markdown-chat",
|
|
41
|
+
"chat-in-a-file",
|
|
42
|
+
"file-based",
|
|
43
|
+
"local-first",
|
|
44
|
+
"cli",
|
|
45
|
+
"command-line",
|
|
46
|
+
"terminal",
|
|
47
|
+
"developer-tools"
|
|
48
|
+
],
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=16"
|
|
51
|
+
}
|
|
12
52
|
}
|
package/types.d.ts
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
declare module 'chudbot' {
|
|
2
|
+
export type Role = 'system' | 'user' | 'assistant' | string
|
|
3
|
+
|
|
4
|
+
export interface ChatBlock {
|
|
5
|
+
role: Role
|
|
6
|
+
content: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ChatMessage {
|
|
10
|
+
role: Role
|
|
11
|
+
content: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ChatParsed {
|
|
15
|
+
frontMatter?: Record<string, string>
|
|
16
|
+
blocks: ChatBlock[]
|
|
17
|
+
messages: ChatMessage[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ChatParserOptions {
|
|
21
|
+
eol?: string
|
|
22
|
+
headerRe?: RegExp
|
|
23
|
+
allowedRoles?: Record<string, boolean>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class ChatParser {
|
|
27
|
+
constructor(opts?: ChatParserOptions)
|
|
28
|
+
parse(raw: string, opts?: { allowUnknown?: boolean }): ChatParsed
|
|
29
|
+
parseFile(filePath: string, opts?: { allowUnknown?: boolean }): ChatParsed
|
|
30
|
+
isRunnable(parsed: ChatParsed, opts?: { minLen?: number }): boolean
|
|
31
|
+
getLastBlock(parsed: ChatParsed): ChatBlock | null
|
|
32
|
+
formatAppendAssistant(text: string, opts?: { trim?: boolean }): string
|
|
33
|
+
formatAppendUser(opts?: {}): string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ResolvePathsResult {
|
|
37
|
+
cwd: string
|
|
38
|
+
userRoot: string
|
|
39
|
+
chudRoot: string
|
|
40
|
+
envPath: string
|
|
41
|
+
chatPath: string
|
|
42
|
+
rootMemoryPath: string
|
|
43
|
+
localMemoryPath: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ContextResolverOptions {
|
|
47
|
+
userRoot?: string
|
|
48
|
+
chudRoot?: string
|
|
49
|
+
envPath?: string
|
|
50
|
+
defaultChatName?: string
|
|
51
|
+
defaultMemoryName?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class ContextResolver {
|
|
55
|
+
constructor(opts?: ContextResolverOptions)
|
|
56
|
+
resolvePaths(opts?: {
|
|
57
|
+
cwd?: string
|
|
58
|
+
chat?: string
|
|
59
|
+
memory?: string
|
|
60
|
+
frontMatter?: Record<string, string>
|
|
61
|
+
}): ResolvePathsResult
|
|
62
|
+
readText(filePath: string, opts?: { required?: boolean }): string
|
|
63
|
+
loadChat(opts?: { cwd?: string; chat?: string }): {
|
|
64
|
+
chatPath: string
|
|
65
|
+
raw: string
|
|
66
|
+
paths: ResolvePathsResult
|
|
67
|
+
}
|
|
68
|
+
loadMemory(opts?: {
|
|
69
|
+
cwd?: string
|
|
70
|
+
memory?: string
|
|
71
|
+
frontMatter?: Record<string, string>
|
|
72
|
+
}): {
|
|
73
|
+
raw: string
|
|
74
|
+
paths: ResolvePathsResult
|
|
75
|
+
over: boolean
|
|
76
|
+
}
|
|
77
|
+
buildMessages(
|
|
78
|
+
parsed: ChatParsed,
|
|
79
|
+
memoryRaw: string,
|
|
80
|
+
opts?: { memoryRole?: Role }
|
|
81
|
+
): ChatMessage[]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface EnvLoaderOptions {
|
|
85
|
+
userRoot?: string
|
|
86
|
+
chudRoot?: string
|
|
87
|
+
envPath?: string
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export class EnvLoader {
|
|
91
|
+
constructor(opts?: EnvLoaderOptions)
|
|
92
|
+
load(opts?: {
|
|
93
|
+
envPath?: string
|
|
94
|
+
override?: boolean
|
|
95
|
+
required?: boolean
|
|
96
|
+
}): { ok: boolean; envPath: string; loaded: boolean }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface InitializerOptions {
|
|
100
|
+
userRoot?: string
|
|
101
|
+
chudRoot?: string
|
|
102
|
+
envPath?: string
|
|
103
|
+
defaultChatName?: string
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export class Initializer {
|
|
107
|
+
constructor(opts?: InitializerOptions)
|
|
108
|
+
initRoot(opts?: { makeEnv?: boolean }): {
|
|
109
|
+
ok: boolean
|
|
110
|
+
chudRoot: string
|
|
111
|
+
envPath: string
|
|
112
|
+
didWriteEnv: boolean
|
|
113
|
+
}
|
|
114
|
+
initChat(opts?: {
|
|
115
|
+
cwd?: string
|
|
116
|
+
chat?: string
|
|
117
|
+
force?: boolean
|
|
118
|
+
system?: string
|
|
119
|
+
}): { ok: boolean; chatPath: string; didWriteChat: boolean }
|
|
120
|
+
init(opts?: {
|
|
121
|
+
cwd?: string
|
|
122
|
+
chat?: string
|
|
123
|
+
force?: boolean
|
|
124
|
+
system?: string
|
|
125
|
+
makeEnv?: boolean
|
|
126
|
+
}): {
|
|
127
|
+
ok: boolean
|
|
128
|
+
chudRoot: string
|
|
129
|
+
envPath: string
|
|
130
|
+
chatPath: string
|
|
131
|
+
didWriteEnv: boolean
|
|
132
|
+
didWriteChat: boolean
|
|
133
|
+
}
|
|
134
|
+
defaultChat(opts?: { system?: string }): string
|
|
135
|
+
defaultEnv(opts?: {}): string
|
|
136
|
+
writeIfMissing(
|
|
137
|
+
filePath: string,
|
|
138
|
+
content: string,
|
|
139
|
+
opts?: { force?: boolean }
|
|
140
|
+
): { didWrite: boolean }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface OpenRouterProviderOptions {
|
|
144
|
+
baseUrl?: string
|
|
145
|
+
apiKey?: string
|
|
146
|
+
model?: string
|
|
147
|
+
timeoutMs?: number
|
|
148
|
+
headers?: Record<string, string>
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export class OpenRouterProvider {
|
|
152
|
+
constructor(opts?: OpenRouterProviderOptions)
|
|
153
|
+
send(
|
|
154
|
+
messages: ChatMessage[],
|
|
155
|
+
opts?: {
|
|
156
|
+
model?: string
|
|
157
|
+
temperature?: number
|
|
158
|
+
maxTokens?: number
|
|
159
|
+
headers?: Record<string, string>
|
|
160
|
+
}
|
|
161
|
+
): Promise<any>
|
|
162
|
+
complete(
|
|
163
|
+
messages: ChatMessage[],
|
|
164
|
+
opts?: {
|
|
165
|
+
model?: string
|
|
166
|
+
temperature?: number
|
|
167
|
+
maxTokens?: number
|
|
168
|
+
headers?: Record<string, string>
|
|
169
|
+
}
|
|
170
|
+
): Promise<string>
|
|
171
|
+
extractContent(json: any): string
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface RunnerOptions {
|
|
175
|
+
parser: ChatParser
|
|
176
|
+
resolver: ContextResolver
|
|
177
|
+
provider: OpenRouterProvider
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export class Runner {
|
|
181
|
+
constructor(opts: RunnerOptions)
|
|
182
|
+
runOnce(opts?: {
|
|
183
|
+
cwd?: string
|
|
184
|
+
chat?: string
|
|
185
|
+
memory?: string
|
|
186
|
+
model?: string
|
|
187
|
+
}): Promise<{ ok: boolean; didRun: boolean; chatPath?: string; error?: any }>
|
|
188
|
+
shouldRun(parsed: ChatParsed, opts?: { minLen?: number }): boolean
|
|
189
|
+
buildAppend(raw: string, assistantText: string, opts?: {}): string
|
|
190
|
+
writeAll(filePath: string, rawNext: string, opts?: {}): void
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export interface WatcherOptions {
|
|
194
|
+
runner: Runner
|
|
195
|
+
debounceMs?: number
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export class Watcher {
|
|
199
|
+
constructor(opts: WatcherOptions)
|
|
200
|
+
start(opts?: {
|
|
201
|
+
cwd?: string
|
|
202
|
+
chat?: string
|
|
203
|
+
memory?: string
|
|
204
|
+
model?: string
|
|
205
|
+
debounceMs?: number
|
|
206
|
+
}): { ok: boolean; watching: boolean; chatPath?: string }
|
|
207
|
+
stop(): Promise<{ ok: boolean; watching: boolean }>
|
|
208
|
+
onChange(filePath: string, opts?: any): void
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export interface ChudbotOptions {
|
|
212
|
+
userRoot?: string
|
|
213
|
+
chudRoot?: string
|
|
214
|
+
envPath?: string
|
|
215
|
+
debounceMs?: number
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export class Chudbot {
|
|
219
|
+
constructor(opts?: ChudbotOptions)
|
|
220
|
+
init(opts?: {
|
|
221
|
+
cwd?: string
|
|
222
|
+
chat?: string
|
|
223
|
+
force?: boolean
|
|
224
|
+
system?: string
|
|
225
|
+
}): { ok: boolean; chatPath: string; didWriteChat: boolean }
|
|
226
|
+
run(opts?: {
|
|
227
|
+
cwd?: string
|
|
228
|
+
chat?: string
|
|
229
|
+
memory?: string
|
|
230
|
+
model?: string
|
|
231
|
+
}): Promise<{ ok: boolean; didRun: boolean; chatPath?: string; error?: any }>
|
|
232
|
+
watch(opts?: {
|
|
233
|
+
cwd?: string
|
|
234
|
+
chat?: string
|
|
235
|
+
memory?: string
|
|
236
|
+
model?: string
|
|
237
|
+
debounceMs?: number
|
|
238
|
+
}): { ok: boolean; watching: boolean; chatPath?: string }
|
|
239
|
+
stop(): Promise<{ ok: boolean; watching: boolean }>
|
|
240
|
+
}
|
|
241
|
+
}
|