chudbot 0.0.1 → 0.0.3

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/README.md CHANGED
@@ -8,6 +8,18 @@
8
8
  Chudbot is a stupid-simple “chat in a file” bot.
9
9
  </p>
10
10
 
11
+ <p align="center">
12
+ <a href="https://github.com/basedwon/chudbot/actions/workflows/ci.yml">
13
+ <img alt="ci" src="https://github.com/basedwon/chudbot/actions/workflows/ci.yml/badge.svg" />
14
+ </a>
15
+ <a href="https://www.npmjs.com/package/chudbot">
16
+ <img alt="npm" src="https://img.shields.io/npm/v/chudbot" />
17
+ </a>
18
+ <a href="https://www.npmjs.com/package/chudbot">
19
+ <img alt="downloads" src="https://img.shields.io/npm/dw/chudbot" />
20
+ </a>
21
+ </p>
22
+
11
23
  You write messages in a `chat.md` file, save it, and Chudbot appends the assistant reply right into the same file.
12
24
 
13
25
  ## What you need
@@ -51,8 +63,18 @@ Confirm it works:
51
63
 
52
64
  ```bash
53
65
  chudbot --help
66
+ # alias also works
67
+ chud --help
54
68
  ```
55
69
 
70
+ ### Optional: check for updates
71
+
72
+ ```bash
73
+ chud update
74
+ ```
75
+
76
+ If an update is available, Chudbot prints the exact install command. To install automatically, use `-y/--yes` or set `CHUDBOT_AUTO_UPDATE=1`.
77
+
56
78
  ## Step 3: Create an OpenRouter account and API key
57
79
 
58
80
  1. Create an account:
@@ -126,9 +148,17 @@ Open that file and set:
126
148
  ```bash
127
149
  OPENROUTER_API_KEY=YOUR_KEY_HERE
128
150
  OPENROUTER_MODEL=openrouter/free
151
+ OPENROUTER_DEFAULT_MODEL=openrouter/free
129
152
  ```
130
153
 
131
- `openrouter/free` routes to a currently-free option on OpenRouter. If it ever errors later, pick a different model.
154
+ `OPENROUTER_MODEL` is the primary env var for model selection.
155
+ If set, it takes precedence.
156
+
157
+ `OPENROUTER_DEFAULT_MODEL` is also supported as a fallback when
158
+ `OPENROUTER_MODEL` is not set.
159
+
160
+ `openrouter/free` routes to a currently-free option on OpenRouter.
161
+ If it ever errors later, pick a different model.
132
162
 
133
163
  ## Step 7: Use it
134
164
 
@@ -142,6 +172,56 @@ It only runs if the chat ends with a non-empty `# %% user` message.
142
172
 
143
173
  On success it appends `# %% assistant` with the reply, then appends a fresh `# %% user` header so you can type the next message.
144
174
 
175
+
176
+ ### Optional file context injection
177
+
178
+ You can inject one or more local files into model context without copy/paste:
179
+
180
+ ```bash
181
+ chudbot run -f src/a.js -f notes/todo.md
182
+ chudbot run -files src/a.js,notes/todo.md
183
+ chudbot run -f src/a.js -files notes/todo.md,README.md
184
+ ```
185
+
186
+ Paths are resolved relative to your current working directory. Files are
187
+ prepended in deterministic order: repeated `-f/--file` entries first (in the
188
+ order provided), then `-files` entries left-to-right.
189
+
190
+ You can also set files in `chat.md` front matter:
191
+
192
+ ```markdown
193
+ ---
194
+ files:
195
+ - src/a.js
196
+ - notes/todo.md
197
+ ---
198
+ ```
199
+
200
+ These entries are appended after CLI `-f/--file` and `-files` values.
201
+
202
+ ### Optional trim limits from CLI
203
+
204
+ You can cap how much chat history is sent to the model:
205
+
206
+ ```bash
207
+ chudbot run --max-messages 24
208
+ chudbot run --max-tokens 4000
209
+ chudbot run --max-messages 24 --max-tokens 4000
210
+ ```
211
+
212
+ You can also pass the same limits in watch mode:
213
+
214
+ ```bash
215
+ chudbot watch --max-messages 24 --max-tokens 4000
216
+ ```
217
+
218
+ And you can set global defaults once per invocation:
219
+
220
+ ```bash
221
+ chudbot --max-messages 24 --max-tokens 4000 run
222
+ chudbot --max-messages 24 --max-tokens 4000 watch
223
+ ```
224
+
145
225
  ## Watch mode (auto-reply on save)
146
226
 
147
227
  ```bash
@@ -193,11 +273,24 @@ memory.override: true
193
273
  ---
194
274
  ```
195
275
 
276
+ You can also trim long chats before model calls:
277
+
278
+ ```markdown
279
+ ---
280
+ max_messages: 24
281
+ max_tokens: 4000
282
+ ---
283
+ ```
284
+
285
+ Trimming is deterministic: system messages are kept, the first user message is
286
+ kept as seed continuity, and then the newest remaining messages are kept within
287
+ the message/token limits.
288
+
196
289
  ## Where files are loaded from
197
290
 
198
291
  Chat:
199
292
 
200
- - Uses `chat.md` in your current folder by default (or `f/--chat`)
293
+ - Uses `chat.md` in your current folder by default (or `--chat`)
201
294
 
202
295
  Env:
203
296
 
@@ -220,6 +313,8 @@ Memory:
220
313
  - “401 / unauthorized”
221
314
  - Your API key is missing or wrong
222
315
  - “Model not found”
223
- - Try a different `OPENROUTER_MODEL` or set `model:` in chat front matter
316
+ - Try a different `OPENROUTER_MODEL`
317
+ - Or set `OPENROUTER_DEFAULT_MODEL` if you want a fallback env key
318
+ - Or set `model:` in chat front matter
224
319
  - “It replied twice / weird loop”
225
320
  - Only edit the last `# %% user` block, and let the bot append assistant blocks
@@ -1,16 +1,32 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  const path = require('path')
4
+ const { execSync } = require('child_process')
4
5
  const { Command } = require('commander')
5
6
 
6
7
  const pkg = require('../package.json')
7
8
  const { Chudbot } = require('../lib/chudbot')
9
+ const UpdateCheck = require('../lib/update-check')
10
+
11
+ let didAutoCheck = false
8
12
 
9
13
  function int(v, def) {
10
14
  const n = parseInt(v, 10)
11
15
  return Number.isFinite(n) ? n : def
12
16
  }
13
17
 
18
+ function list(v) {
19
+ if (!v)
20
+ return []
21
+ return String(v).split(',').map((x) => x.trim()).filter(Boolean)
22
+ }
23
+
24
+ function files(opts = {}) {
25
+ const repeat = Array.isArray(opts.file) ? opts.file : []
26
+ const csv = list(opts.files)
27
+ return [...repeat, ...csv]
28
+ }
29
+
14
30
  function pick(opts, keys) {
15
31
  const out = {}
16
32
  for (const k of keys) {
@@ -20,11 +36,74 @@ function pick(opts, keys) {
20
36
  return out
21
37
  }
22
38
 
23
- function makeBot(opts) {
24
- return new Chudbot(pick(opts, ['userRoot', 'chudRoot', 'envPath', 'debounceMs']))
39
+ function makeBot(opts, deps = {}) {
40
+ const Bot = deps.Chudbot || Chudbot
41
+ const normalized = { ...opts }
42
+ if (normalized.env && !normalized.envPath)
43
+ normalized.envPath = normalized.env
44
+ return new Bot(pick(normalized, [
45
+ 'userRoot',
46
+ 'chudRoot',
47
+ 'envPath',
48
+ 'debounceMs',
49
+ 'maxMessages',
50
+ 'maxTokens',
51
+ ]))
52
+ }
53
+
54
+ function shouldAutoInstall(opts = {}) {
55
+ const yes = !!opts.yes
56
+ const env = String(process.env.CHUDBOT_AUTO_UPDATE || '').trim()
57
+ return yes || env === '1' || env.toLowerCase() === 'true'
58
+ }
59
+
60
+ function installUpdate(name) {
61
+ execSync(`npm install -g ${name}@latest`, { stdio: 'inherit' })
62
+ }
63
+
64
+ async function runUpdate(opts = {}, deps = {}) {
65
+ const Update = deps.UpdateCheck || UpdateCheck
66
+ const u = new Update({ pkg: pkg.name })
67
+ const local = u.localVersion()
68
+ const remote = await u.remoteVersion()
69
+ if (!u.hasUpdate(local, remote)) {
70
+ if (opts.quiet)
71
+ return
72
+ console.log(`up to date: ${pkg.name}@${local}`)
73
+ return
74
+ }
75
+
76
+ if (opts.quiet) {
77
+ const cmd = path.basename(process.argv[1] || 'chudbot')
78
+ console.log(`[update] ${pkg.name} ${local} -> ${remote}. Run: ${cmd} update`)
79
+ return
80
+ }
81
+
82
+ console.log(`update available: ${pkg.name} ${local} -> ${remote}`)
83
+ if (!shouldAutoInstall(opts)) {
84
+ console.log(`run: npm install -g ${pkg.name}@latest`)
85
+ console.log('or set CHUDBOT_AUTO_UPDATE=1')
86
+ console.log('or pass --yes to install now')
87
+ return
88
+ }
89
+
90
+ console.log(`installing ${pkg.name}@latest...`)
91
+ installUpdate(pkg.name)
92
+ console.log('update complete')
93
+ }
94
+
95
+ async function maybeAutoCheck(argv, deps = {}) {
96
+ if (didAutoCheck)
97
+ return
98
+ didAutoCheck = true
99
+ if (argv.includes('update') || argv.includes('--no-update-check'))
100
+ return
101
+ try {
102
+ await runUpdate({ quiet: true }, deps)
103
+ } catch (_) {}
25
104
  }
26
105
 
27
- async function main(argv) {
106
+ async function main(argv, deps = {}) {
28
107
  const program = new Command()
29
108
  program
30
109
  .name('chudbot')
@@ -33,6 +112,22 @@ async function main(argv) {
33
112
  .option('--user-root <dir>', 'override OS home dir')
34
113
  .option('--chud-root <dir>', 'override chudbot root (default: ~/.chudbot)')
35
114
  .option('--env <path>', 'override env path (default: ~/.chudbot/.env)')
115
+ .option('--max-messages <n>', 'default max messages window')
116
+ .option('--max-tokens <n>', 'default max token estimate window')
117
+ .option('--no-update-check', 'disable npm latest update check')
118
+
119
+ program
120
+ .command('update')
121
+ .description('Check npm for latest version and optionally install it')
122
+ .option('-y, --yes', 'install update automatically if available', false)
123
+ .action(async (opts) => {
124
+ try {
125
+ await runUpdate({ yes: !!opts.yes }, deps)
126
+ } catch (err) {
127
+ console.error(err && err.message ? err.message : err)
128
+ process.exitCode = 1
129
+ }
130
+ })
36
131
 
37
132
  program
38
133
  .command('init')
@@ -43,7 +138,7 @@ async function main(argv) {
43
138
  .option('--system <text>', 'system prompt for the starter chat')
44
139
  .action((opts) => {
45
140
  const root = program.opts()
46
- const bot = makeBot(root)
141
+ const bot = makeBot(root, deps)
47
142
  const res = bot.init({
48
143
  cwd: opts.cwd || process.cwd(),
49
144
  chat: opts.chat,
@@ -57,17 +152,24 @@ async function main(argv) {
57
152
  .command('run')
58
153
  .description('Run once and append the assistant reply (if last user block is non-empty)')
59
154
  .option('-C, --cwd <dir>', 'folder to run in (default: current folder)')
60
- .option('-f, --chat <file>', 'chat file (default: chat.md)')
155
+ .option('--chat <file>', 'chat file (default: chat.md)')
156
+ .option('-f, --file <path>', 'inject file into context (repeatable)', (v, a) => [...a, v], [])
157
+ .option('-files, --files <a,b,c>', 'inject comma-separated files into context')
61
158
  .option('-m, --memory <file>', 'memory file (default: memory.md)')
62
159
  .option('--model <id>', 'model id (default: openrouter/free)')
160
+ .option('--max-messages <n>', 'max messages window')
161
+ .option('--max-tokens <n>', 'max token estimate window')
63
162
  .action(async (opts) => {
64
163
  const root = program.opts()
65
- const bot = makeBot(root)
164
+ const bot = makeBot(root, deps)
66
165
  const res = await bot.run({
67
166
  cwd: opts.cwd || process.cwd(),
68
167
  chat: opts.chat,
69
168
  memory: opts.memory,
70
169
  model: opts.model,
170
+ files: files(opts),
171
+ maxMessages: int(opts.maxMessages, int(root.maxMessages, undefined)),
172
+ maxTokens: int(opts.maxTokens, int(root.maxTokens, undefined)),
71
173
  })
72
174
  if (!res.ok) {
73
175
  console.error(res.error && res.error.message ? res.error.message : res.error)
@@ -85,20 +187,27 @@ async function main(argv) {
85
187
  .command('watch')
86
188
  .description('Watch chat file and auto-append replies on save (Ctrl+C to stop)')
87
189
  .option('-C, --cwd <dir>', 'folder to run in (default: current folder)')
88
- .option('-f, --chat <file>', 'chat file (default: chat.md)')
190
+ .option('--chat <file>', 'chat file (default: chat.md)')
191
+ .option('-f, --file <path>', 'inject file into context (repeatable)', (v, a) => [...a, v], [])
192
+ .option('-files, --files <a,b,c>', 'inject comma-separated files into context')
89
193
  .option('-m, --memory <file>', 'memory file (default: memory.md)')
90
194
  .option('--model <id>', 'model id (default: openrouter/free)')
195
+ .option('--max-messages <n>', 'max messages window')
196
+ .option('--max-tokens <n>', 'max token estimate window')
91
197
  .option('--debounce-ms <n>', 'debounce delay in ms (default: 200)')
92
198
  .action((opts) => {
93
199
  const root = program.opts()
94
- const bot = makeBot({ ...root, debounceMs: int(opts.debounceMs, 200) })
200
+ const bot = makeBot({ ...root, debounceMs: int(opts.debounceMs, 200) }, deps)
95
201
 
96
202
  const res = bot.watch({
97
203
  cwd: opts.cwd || process.cwd(),
98
204
  chat: opts.chat,
99
205
  memory: opts.memory,
100
206
  model: opts.model,
207
+ files: files(opts),
101
208
  debounceMs: int(opts.debounceMs, 200),
209
+ maxMessages: int(opts.maxMessages, int(root.maxMessages, undefined)),
210
+ maxTokens: int(opts.maxTokens, int(root.maxTokens, undefined)),
102
211
  })
103
212
 
104
213
  const chatPath = res && res.chatPath
@@ -124,10 +233,25 @@ async function main(argv) {
124
233
  '\nMore help: read the README in the project folder.\n'
125
234
  )
126
235
 
236
+ await maybeAutoCheck(argv, deps)
127
237
  await program.parseAsync(argv)
128
238
  }
129
239
 
130
- main(process.argv).catch((err) => {
131
- console.error(err && err.message ? err.message : err)
132
- process.exitCode = 1
133
- })
240
+ if (require.main === module) {
241
+ main(process.argv).catch((err) => {
242
+ console.error(err && err.message ? err.message : err)
243
+ process.exitCode = 1
244
+ })
245
+ }
246
+
247
+ module.exports = {
248
+ main,
249
+ int,
250
+ list,
251
+ files,
252
+ pick,
253
+ makeBot,
254
+ runUpdate,
255
+ maybeAutoCheck,
256
+ shouldAutoInstall,
257
+ }
package/lib/chudbot.js CHANGED
@@ -1,5 +1,4 @@
1
1
  const os = require('os')
2
- const fs = require('fs')
3
2
  const path = require('path')
4
3
 
5
4
  const { Runner } = require('./runner')
@@ -38,6 +37,7 @@ class Spinner {
38
37
  process.stdout.write(ANSI_RESET)
39
38
  }
40
39
  }
40
+
41
41
  class Chudbot {
42
42
  constructor(opts = {}) {
43
43
  this.userRoot = opts.userRoot || os.homedir()
@@ -95,7 +95,7 @@ class Chudbot {
95
95
  }
96
96
  watch(opts = {}) {
97
97
  // opts.cwd, opts.chat, opts.memory, opts.model, opts.debounceMs
98
- return this.watcher.start(opts)
98
+ return this.watcher.start({ ...opts, suppressWatchStart: true })
99
99
  }
100
100
  async stop() {
101
101
  return await this.watcher.stop()
package/lib/parser.js CHANGED
@@ -6,6 +6,7 @@ class ChatParser {
6
6
  system: true,
7
7
  user: true,
8
8
  assistant: true,
9
+ agent: true,
9
10
  }
10
11
  }
11
12
  parse(raw, opts = {}) {
@@ -31,25 +32,71 @@ class ChatParser {
31
32
  // model: openrouter/free
32
33
  // memory: memory.md
33
34
  // ---
34
- // Only supports top-level key: value pairs.
35
+ // Supports top-level key: value pairs and simple list values:
36
+ // files:
37
+ // - a
38
+ // - b
35
39
  const s = String(raw || '')
36
- if (!s.startsWith('---\n') && !s.startsWith('---\r\n'))
40
+ const open = s.match(/^---(?:\r\n|\n|\r)/)
41
+ if (!open)
37
42
  return null
38
- const endIdx = s.indexOf('\n---', 4)
39
- if (endIdx === -1)
43
+ const openEnd = open[0].length
44
+ let i = openEnd
45
+ let headEnd = -1
46
+ let bodyStart = s.length
47
+ while (i <= s.length) {
48
+ let j = i
49
+ while (j < s.length && s[j] !== '\n' && s[j] !== '\r')
50
+ j += 1
51
+ const line = s.slice(i, j)
52
+ let next = j
53
+ if (s[j] === '\r' && s[j + 1] === '\n')
54
+ next += 2
55
+ else if (s[j] === '\r' || s[j] === '\n')
56
+ next += 1
57
+ if (line === '---') {
58
+ headEnd = i
59
+ bodyStart = next
60
+ break
61
+ }
62
+ if (j >= s.length)
63
+ break
64
+ i = next
65
+ }
66
+ if (headEnd === -1)
40
67
  return null
41
- const head = s.slice(4, endIdx + 1)
42
- const body = s.slice(endIdx + 5)
68
+ const head = s.slice(openEnd, headEnd)
69
+ const body = s.slice(bodyStart)
43
70
  const data = {}
71
+ let listKey = ''
44
72
  for (const line of head.split(/\r?\n/)) {
45
- const t = String(line || '').trim()
73
+ const rawLine = String(line || '')
74
+ const t = rawLine.trim()
46
75
  if (!t || t.startsWith('#'))
47
76
  continue
77
+ if (listKey) {
78
+ const m = rawLine.match(/^\s*-\s*(.*)$/)
79
+ if (m) {
80
+ let item = String(m[1] || '').trim()
81
+ if ((item.startsWith('"') && item.endsWith('"')) ||
82
+ (item.startsWith("'") && item.endsWith("'")))
83
+ item = item.slice(1, -1)
84
+ if (item)
85
+ data[listKey].push(item)
86
+ continue
87
+ }
88
+ listKey = ''
89
+ }
48
90
  const ii = t.indexOf(':')
49
91
  if (ii === -1)
50
92
  continue
51
93
  const k = t.slice(0, ii).trim()
52
94
  let v = t.slice(ii + 1).trim()
95
+ if (k === 'files' && !v) {
96
+ data.files = []
97
+ listKey = 'files'
98
+ continue
99
+ }
53
100
  if ((v.startsWith('"') && v.endsWith('"')) ||
54
101
  (v.startsWith("'") && v.endsWith("'")))
55
102
  v = v.slice(1, -1)
@@ -74,7 +121,9 @@ class ChatParser {
74
121
  const m = String(line || '').match(this.headerRe)
75
122
  if (m) {
76
123
  push()
77
- const role = String(m[1] || '').toLowerCase()
124
+ let role = String(m[1] || '').toLowerCase()
125
+ if (role === 'agent')
126
+ role = 'assistant'
78
127
  if (!this.allowedRoles[role]) {
79
128
  if (opts.allowUnknown)
80
129
  cur = role
package/lib/provider.js CHANGED
@@ -1,13 +1,11 @@
1
1
  const axios = require('axios')
2
- const path = require('path')
3
- const os = require('os')
4
- const dotenv = require('dotenv')
5
2
 
6
3
  class Provider {
7
4
  constructor(opts = {}) {
8
5
  this.baseUrl = opts.baseUrl || 'https://openrouter.ai/api/v1'
9
6
  this.apiKey = opts.apiKey || process.env.OPENROUTER_API_KEY || ''
10
- this.model = opts.model || process.env.OPENROUTER_MODEL || 'openrouter/free'
7
+ this.model = opts.model || process.env.OPENROUTER_MODEL ||
8
+ process.env.OPENROUTER_DEFAULT_MODEL || 'openrouter/free'
11
9
  this.timeoutMs = opts.timeoutMs || 60000
12
10
  this.headers = opts.headers || {}
13
11
  if (!this.apiKey)
package/lib/resolver.js CHANGED
@@ -9,6 +9,8 @@ class ContextResolver {
9
9
  this.envPath = opts.envPath || path.join(this.chudRoot, '.env')
10
10
  this.defaultChatName = opts.defaultChatName || 'chat.md'
11
11
  this.defaultMemoryName = opts.defaultMemoryName || 'memory.md'
12
+ this.defaultMaxMessages = this._num(opts.maxMessages)
13
+ this.defaultMaxTokens = this._num(opts.maxTokens)
12
14
  }
13
15
  resolvePaths(opts = {}) {
14
16
  // Finds chat in cwd first, then chudRoot.
@@ -62,13 +64,130 @@ class ContextResolver {
62
64
  return { raw, paths, over }
63
65
  }
64
66
  buildMessages(parsed, memoryRaw, opts = {}) {
65
- // Prepends memory as a system message if present.
67
+ // Prepends file context + memory as system messages if present.
66
68
  const msgs = (parsed && parsed.messages) || []
69
+ const maxMessages = this._numOr(
70
+ opts.maxMessages,
71
+ this._num(opts.frontMatter && opts.frontMatter.max_messages),
72
+ this.defaultMaxMessages
73
+ )
74
+ const maxTokens = this._numOr(
75
+ opts.maxTokens,
76
+ this._num(opts.frontMatter && opts.frontMatter.max_tokens),
77
+ this.defaultMaxTokens
78
+ )
79
+ const trimmed = this._trimMessages(msgs, { maxMessages, maxTokens })
80
+ const fileContext = this._buildFileContext(opts)
67
81
  const mem = String(memoryRaw || '').trim()
68
- if (!mem)
69
- return msgs
82
+ if (!mem && !fileContext)
83
+ return trimmed
70
84
  const role = opts.memoryRole || 'system'
71
- return [{ role, content: mem }, ...msgs]
85
+ const out = []
86
+ if (fileContext)
87
+ out.push({ role, content: fileContext })
88
+ if (mem)
89
+ out.push({ role, content: mem })
90
+ return [...out, ...trimmed]
91
+ }
92
+ readFiles(opts = {}) {
93
+ const cwd = opts.cwd || process.cwd()
94
+ const cliFiles = Array.isArray(opts.files) ? opts.files : []
95
+ const fmFiles = Array.isArray(opts.frontMatter && opts.frontMatter.files)
96
+ ? opts.frontMatter.files
97
+ : []
98
+ const list = [...cliFiles, ...fmFiles]
99
+ const out = []
100
+ for (const entry of list) {
101
+ const s = String(entry || '').trim()
102
+ if (!s)
103
+ continue
104
+ const filePath = path.resolve(cwd, s)
105
+ const content = this.readText(filePath, { required: true })
106
+ out.push({
107
+ path: filePath,
108
+ displayPath: path.relative(cwd, filePath) || '.',
109
+ content,
110
+ })
111
+ }
112
+ return out
113
+ }
114
+ _buildFileContext(opts = {}) {
115
+ const files = this.readFiles(opts)
116
+ if (!files.length)
117
+ return ''
118
+ const chunks = []
119
+ for (const file of files) {
120
+ chunks.push(
121
+ '-- FILE: ' + file.displayPath + ' ---\n'
122
+ + file.content
123
+ + '\n-- END FILE ---'
124
+ )
125
+ }
126
+ return chunks.join('\n\n')
127
+ }
128
+ _trimMessages(msgs, opts = {}) {
129
+ const list = Array.isArray(msgs) ? msgs : []
130
+ const maxMessages = this._num(opts.maxMessages)
131
+ const maxTokens = this._num(opts.maxTokens)
132
+ const hasMaxMessages = maxMessages > 0
133
+ const hasMaxTokens = maxTokens > 0
134
+ if (!hasMaxMessages && !hasMaxTokens)
135
+ return list
136
+ const pinned = []
137
+ const normal = []
138
+ let seed = null
139
+ for (const msg of list) {
140
+ const role = String(msg && msg.role || '')
141
+ if (role === 'system') {
142
+ pinned.push(msg)
143
+ continue
144
+ }
145
+ if (!seed && role === 'user') {
146
+ seed = msg
147
+ continue
148
+ }
149
+ normal.push(msg)
150
+ }
151
+ let budgetMessages = maxMessages
152
+ if (hasMaxMessages)
153
+ budgetMessages = Math.max(0, budgetMessages - pinned.length - (seed ? 1 : 0))
154
+ let budgetTokens = maxTokens
155
+ if (hasMaxTokens) {
156
+ budgetTokens = Math.max(0, budgetTokens - this._estimateMessagesTokens(pinned))
157
+ if (seed)
158
+ budgetTokens = Math.max(0, budgetTokens - this._estimateTokens(seed))
159
+ }
160
+ const keptTail = []
161
+ let usedTokens = 0
162
+ for (let i = normal.length - 1; i >= 0; i--) {
163
+ if (hasMaxMessages && keptTail.length >= budgetMessages)
164
+ break
165
+ const msg = normal[i]
166
+ const tok = this._estimateTokens(msg)
167
+ if (hasMaxTokens && usedTokens + tok > budgetTokens)
168
+ break
169
+ keptTail.push(msg)
170
+ usedTokens += tok
171
+ }
172
+ keptTail.reverse()
173
+ const out = [...pinned]
174
+ if (seed)
175
+ out.push(seed)
176
+ return [...out, ...keptTail]
177
+ }
178
+ _estimateMessagesTokens(msgs) {
179
+ let sum = 0
180
+ for (const msg of msgs || [])
181
+ sum += this._estimateTokens(msg)
182
+ return sum
183
+ }
184
+ _estimateTokens(msg) {
185
+ const content = String(msg && msg.content || '')
186
+ const words = content.trim() ? content.trim().split(/\s+/).length : 0
187
+ const chars = content.length
188
+ const byWords = words * 1.3
189
+ const byChars = chars / 4
190
+ return Math.max(1, Math.ceil(Math.max(byWords, byChars)))
72
191
  }
73
192
  _pickPath(name, roots, opts = {}) {
74
193
  const s = String(name || '').trim()
@@ -96,6 +215,20 @@ class ContextResolver {
96
215
  return x + '\n\n' + y
97
216
  return x || y || ''
98
217
  }
218
+ _num(v) {
219
+ const n = parseInt(v, 10)
220
+ if (!Number.isFinite(n) || n <= 0)
221
+ return 0
222
+ return n
223
+ }
224
+ _numOr(...vals) {
225
+ for (const v of vals) {
226
+ const n = this._num(v)
227
+ if (n)
228
+ return n
229
+ }
230
+ return 0
231
+ }
99
232
  }
100
233
 
101
234
  module.exports = {
package/lib/runner.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const fs = require('fs')
2
+ const path = require('path')
2
3
 
3
4
  class Runner {
4
5
  constructor(opts = {}) {
@@ -24,7 +25,10 @@ class Runner {
24
25
  cwd: chat.paths.cwd,
25
26
  frontMatter: parsed.frontMatter,
26
27
  })
27
- const messages = this.resolver.buildMessages(parsed, mem.raw, opts)
28
+ const messages = this.resolver.buildMessages(parsed, mem.raw, {
29
+ ...opts,
30
+ frontMatter: parsed.frontMatter,
31
+ })
28
32
  const model = opts.model || (parsed.frontMatter && parsed.frontMatter.model)
29
33
  const assistantText = await this.provider.complete(messages, { model })
30
34
  const rawNext = this.buildAppend(chat.raw, assistantText, opts)
@@ -44,7 +48,25 @@ class Runner {
44
48
  return base + a + u
45
49
  }
46
50
  writeAll(filePath, rawNext, opts = {}) {
47
- fs.writeFileSync(filePath, String(rawNext || ''), 'utf8')
51
+ const dirPath = path.dirname(filePath)
52
+ const baseName = path.basename(filePath)
53
+ const tmpPath = path.join(
54
+ dirPath,
55
+ `.${baseName}.tmp-${process.pid}-${Date.now()}`,
56
+ )
57
+ let didRename = false
58
+ try {
59
+ fs.writeFileSync(tmpPath, String(rawNext || ''), 'utf8')
60
+ fs.renameSync(tmpPath, filePath)
61
+ didRename = true
62
+ } finally {
63
+ if (didRename) return
64
+ try {
65
+ if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath)
66
+ } catch (_err) {
67
+ // best-effort cleanup
68
+ }
69
+ }
48
70
  }
49
71
  }
50
72
 
@@ -0,0 +1,53 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+ const { execSync } = require('child_process')
4
+ const axios = require('axios')
5
+
6
+ class UpdateCheck {
7
+ constructor(opts = {}) {
8
+ this.pkg = opts.pkg || null
9
+ this.timeoutMs = opts.timeoutMs || 5000
10
+ if (!this.pkg)
11
+ throw new Error('UpdateCheck requires opts.pkg')
12
+ }
13
+ localVersion() {
14
+ const pkgPath = path.join(__dirname, '..', 'package.json')
15
+ const raw = fs.readFileSync(pkgPath, 'utf8')
16
+ const pkg = JSON.parse(raw)
17
+ return String(pkg.version || '').trim()
18
+ }
19
+ async remoteVersion() {
20
+ try {
21
+ const url = `https://registry.npmjs.org/-/package/${this.pkg}/dist-tags`
22
+ const res = await axios.get(url, { timeout: this.timeoutMs })
23
+ const v = res && res.data && res.data.latest ? res.data.latest : ''
24
+ return String(v || '').trim()
25
+ } catch (_) {
26
+ const out = execSync(`npm view ${this.pkg} version`, {
27
+ stdio: ['ignore', 'pipe', 'pipe'],
28
+ }).toString('utf8')
29
+ return String(out || '').trim()
30
+ }
31
+ }
32
+ hasUpdate(local, remote) {
33
+ if (!local || !remote)
34
+ return false
35
+ const a = this._parse(local)
36
+ const b = this._parse(remote)
37
+ for (let i = 0; i < 3; i++) {
38
+ if ((b[i] || 0) > (a[i] || 0))
39
+ return true
40
+ if ((b[i] || 0) < (a[i] || 0))
41
+ return false
42
+ }
43
+ return false
44
+ }
45
+ _parse(v) {
46
+ return String(v || '')
47
+ .split('.')
48
+ .slice(0, 3)
49
+ .map((x) => Number(x) || 0)
50
+ }
51
+ }
52
+
53
+ module.exports = UpdateCheck
package/lib/watcher.js CHANGED
@@ -35,7 +35,6 @@ class Watcher {
35
35
  if (evt === 'add' || evt === 'change')
36
36
  this.onChange(p, opts)
37
37
  })
38
- // this.watcher.on('change', p => this.onChange(p, opts))
39
38
  this._emit('watch_start', { chatPath }, opts)
40
39
 
41
40
  return { ok: true, watching: true, chatPath }
@@ -90,8 +89,11 @@ class Watcher {
90
89
  if (this.quiet)
91
90
  return
92
91
 
93
- if (type === 'watch_start')
92
+ if (type === 'watch_start') {
93
+ if (opts && opts.suppressWatchStart)
94
+ return
94
95
  return process.stdout.write('watching: ' + (payload.chatPath || '') + '\n')
96
+ }
95
97
 
96
98
  if (type === 'signal')
97
99
  return process.stdout.write('change detected\n')
@@ -138,7 +140,12 @@ class Watcher {
138
140
  let errMsg = null
139
141
 
140
142
  try {
141
- await this.runner.runOnce(opts)
143
+ const res = await this.runner.runOnce(opts)
144
+ if (res && res.ok === false) {
145
+ ok = false
146
+ const err = res.error
147
+ errMsg = err && err.message ? err.message : String(err || 'error')
148
+ }
142
149
  } catch (err) {
143
150
  ok = false
144
151
  errMsg = err && err.message ? err.message : String(err)
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "chudbot",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "Chudbot is a stupid-simple “chat in a file” bot.",
5
5
  "main": "lib/chudbot.js",
6
6
  "bin": {
7
- "chudbot": "bin/chudbot-cli.js"
7
+ "chudbot": "bin/chudbot-cli.js",
8
+ "chud": "bin/chudbot-cli.js"
8
9
  },
9
10
  "types": "types.d.ts",
10
11
  "files": [
@@ -14,17 +15,32 @@
14
15
  "README.md",
15
16
  "LICENSE"
16
17
  ],
17
- "directories": {
18
- "lib": "lib"
18
+ "author": "Basedwon",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/basedwon/chudbot.git"
22
+ },
23
+ "bugs": {
24
+ "url": "https://github.com/basedwon/chudbot/issues"
25
+ },
26
+ "homepage": "https://github.com/basedwon/chudbot#readme",
27
+ "scripts": {
28
+ "test": "mocha \"test/unit/**/*.spec.js\" \"test/integration/**/*.spec.js\"",
29
+ "test:unit": "mocha \"test/unit/**/*.spec.js\"",
30
+ "test:integration": "mocha \"test/integration/**/*.spec.js\"",
31
+ "test:smoke": "node test/smoke-test.js",
32
+ "dev": "nodemon test/smoke-test.js"
19
33
  },
20
- "scripts": {},
21
34
  "dependencies": {
22
35
  "axios": "^1.13.6",
23
36
  "chokidar": "^3.6.0",
24
- "commander": "^14.0.3",
25
- "dotenv": "^17.3.1"
37
+ "commander": "^12.1.0",
38
+ "dotenv": "^16.4.5"
39
+ },
40
+ "devDependencies": {
41
+ "chai": "^4.5.0",
42
+ "mocha": "^10.8.2"
26
43
  },
27
- "author": "Basedwon",
28
44
  "license": "MIT",
29
45
  "keywords": [
30
46
  "ai",
package/types.d.ts CHANGED
@@ -1,241 +1,115 @@
1
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
- }
2
+ export type FrontMatterValue = string | string[]
3
+ export type FrontMatter = Record<string, FrontMatterValue>
13
4
 
14
5
  export interface ChatParsed {
15
- frontMatter?: Record<string, string>
16
- blocks: ChatBlock[]
17
- messages: ChatMessage[]
6
+ frontMatter: FrontMatter
7
+ blocks: Array<{ role: string; content: string }>
8
+ messages: Array<{ role: string; content: string }>
18
9
  }
19
10
 
20
- export interface ChatParserOptions {
21
- eol?: string
22
- headerRe?: RegExp
23
- allowedRoles?: Record<string, boolean>
11
+ export interface ResolverBaseOptions {
12
+ cwd?: string
13
+ chat?: string
14
+ memory?: string
15
+ frontMatter?: FrontMatter
16
+ maxMessages?: number
17
+ maxTokens?: number
18
+ /**
19
+ * List of file paths to include as context.
20
+ *
21
+ * Expected shape:
22
+ * - Array of string paths
23
+ * - Typically relative to `cwd`, but absolute paths also work
24
+ */
25
+ files?: string[]
24
26
  }
25
27
 
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
- }
28
+ export interface ResolvePathsOptions extends ResolverBaseOptions {}
29
+ export interface LoadMemoryOptions extends ResolverBaseOptions {}
35
30
 
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
31
+ export interface BuildMessagesOptions extends ResolverBaseOptions {
32
+ memoryRole?: string
44
33
  }
45
34
 
46
- export interface ContextResolverOptions {
35
+ export interface ChudbotOptions {
47
36
  userRoot?: string
48
37
  chudRoot?: string
49
38
  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[]
39
+ debounceMs?: number
40
+ maxMessages?: number
41
+ maxTokens?: number
82
42
  }
83
43
 
84
- export interface EnvLoaderOptions {
85
- userRoot?: string
86
- chudRoot?: string
87
- envPath?: string
44
+ export interface InitOptions {
45
+ cwd?: string
46
+ chat?: string
47
+ force?: boolean
48
+ system?: string
88
49
  }
89
50
 
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 }
51
+ export interface RunOptions {
52
+ cwd?: string
53
+ chat?: string
54
+ memory?: string
55
+ model?: string
56
+ maxMessages?: number
57
+ maxTokens?: number
58
+ files?: string[]
97
59
  }
98
60
 
99
- export interface InitializerOptions {
100
- userRoot?: string
101
- chudRoot?: string
102
- envPath?: string
103
- defaultChatName?: string
61
+ export interface WatchOptions {
62
+ cwd?: string
63
+ chat?: string
64
+ memory?: string
65
+ model?: string
66
+ debounceMs?: number
67
+ maxMessages?: number
68
+ maxTokens?: number
69
+ files?: string[]
104
70
  }
105
71
 
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
- }): {
72
+ export class Chudbot {
73
+ constructor(opts?: ChudbotOptions)
74
+ init(opts?: InitOptions): {
127
75
  ok: boolean
128
- chudRoot: string
129
- envPath: string
130
76
  chatPath: string
131
- didWriteEnv: boolean
132
77
  didWriteChat: boolean
133
78
  }
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 }
79
+ run(opts?: RunOptions): Promise<{
80
+ ok: boolean
81
+ didRun: boolean
82
+ chatPath?: string
83
+ error?: any
84
+ }>
85
+ watch(opts?: WatchOptions): {
86
+ ok: boolean
87
+ watching: boolean
88
+ chatPath?: string
89
+ }
207
90
  stop(): Promise<{ ok: boolean; watching: boolean }>
208
- onChange(filePath: string, opts?: any): void
209
91
  }
210
92
 
211
- export interface ChudbotOptions {
212
- userRoot?: string
213
- chudRoot?: string
214
- envPath?: string
215
- debounceMs?: number
216
- }
217
-
218
- export class Chudbot {
93
+ export class ContextResolver {
219
94
  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 }>
95
+ resolvePaths(opts?: ResolvePathsOptions): {
96
+ cwd: string
97
+ userRoot: string
98
+ chudRoot: string
99
+ envPath: string
100
+ chatPath: string
101
+ rootMemoryPath: string
102
+ localMemoryPath: string
103
+ }
104
+ loadMemory(opts?: LoadMemoryOptions): {
105
+ raw: string
106
+ paths: ReturnType<ContextResolver['resolvePaths']>
107
+ over: boolean
108
+ }
109
+ buildMessages(
110
+ parsed: ChatParsed,
111
+ memoryRaw?: string,
112
+ opts?: BuildMessagesOptions
113
+ ): Array<{ role: string; content: string }>
240
114
  }
241
115
  }