chudbot 0.0.0 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 }
@@ -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 }
@@ -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,57 @@
1
1
  {
2
2
  "name": "chudbot",
3
- "version": "0.0.0",
4
- "description": "",
5
- "main": "index.js",
6
- "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 1"
8
- },
9
- "keywords": [],
10
- "author": "",
11
- "license": "MIT"
3
+ "version": "0.0.2",
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
+ },
9
+ "types": "types.d.ts",
10
+ "files": [
11
+ "bin/",
12
+ "lib/",
13
+ "types.d.ts",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "author": "Basedwon",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/basedwon/chudbot.git"
21
+ },
22
+ "bugs": {
23
+ "url": "https://github.com/basedwon/chudbot/issues"
24
+ },
25
+ "homepage": "https://github.com/basedwon/chudbot#readme",
26
+ "scripts": {},
27
+ "dependencies": {
28
+ "axios": "^1.13.6",
29
+ "chokidar": "^3.6.0",
30
+ "commander": "^12.1.0",
31
+ "dotenv": "^16.4.5"
32
+ },
33
+ "license": "MIT",
34
+ "keywords": [
35
+ "ai",
36
+ "llm",
37
+ "chatgpt",
38
+ "gpt",
39
+ "claude",
40
+ "openrouter",
41
+ "prompt",
42
+ "prompting",
43
+ "chatbot",
44
+ "markdown",
45
+ "markdown-chat",
46
+ "chat-in-a-file",
47
+ "file-based",
48
+ "local-first",
49
+ "cli",
50
+ "command-line",
51
+ "terminal",
52
+ "developer-tools"
53
+ ],
54
+ "engines": {
55
+ "node": ">=16"
56
+ }
12
57
  }