heartcode 0.1.14 → 0.1.24

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/src/voice.js CHANGED
@@ -1,5 +1,5 @@
1
- // Voice input: Ctrl+R (or /voice) records from the microphone; Enter or Ctrl+R again stops, Esc
2
- // cancels; the words go into the input box, to read over and send.
1
+ // Voice input: /voice, then Space to talk (and again to pause), Enter to finish, Esc to
2
+ // cancel. What's said shows in the input box as it's said, and stays there to read over and send.
3
3
  // Recording: ffmpeg (macOS: the default microphone; Linux: PulseAudio, else ALSA). Speech to text:
4
4
  // - on this machine with whisper.cpp's whisper-cli and a model already on disk (~/.whisper-models,
5
5
  // ~/.heart/whisper, or "voice": { "model": "/path/ggml-….bin" } in config.json); nothing is
@@ -18,8 +18,9 @@ import { log } from './log.js'
18
18
  const onPath = (cmd) => (process.env.PATH ?? '').split(path.delimiter).find((d) => d && fs.existsSync(path.join(d, cmd)))
19
19
 
20
20
  // A whisper.cpp model already on disk: the configured one, else the best found (small > base > tiny).
21
- export function localModel(cfg = {}) {
22
- if (cfg.model && fs.existsSync(cfg.model)) return cfg.model
21
+ // fast: the quickest found instead (tiny > base > small), for the words shown while you speak.
22
+ export function localModel(cfg = {}, { fast = false } = {}) {
23
+ if (cfg.model && fs.existsSync(cfg.model) && !fast) return cfg.model
23
24
  const found = [path.join(os.homedir(), '.whisper-models'), path.join(HOME, 'whisper')].flatMap((d) => {
24
25
  try {
25
26
  return fs.readdirSync(d).filter((f) => /^ggml-.*\.bin$/.test(f)).map((f) => path.join(d, f))
@@ -27,8 +28,9 @@ export function localModel(cfg = {}) {
27
28
  return []
28
29
  }
29
30
  })
30
- const rank = (f) => ['large', 'medium', 'small', 'base', 'tiny'].findIndex((s) => path.basename(f).includes(s))
31
- return found.sort((a, b) => (rank(a) === -1 ? 9 : rank(a)) - (rank(b) === -1 ? 9 : rank(b)))[0] ?? null
31
+ const order = fast ? ['tiny', 'base', 'small', 'medium', 'large'] : ['large', 'medium', 'small', 'base', 'tiny']
32
+ const rank = (f) => order.findIndex((s) => path.basename(f).includes(s))
33
+ return found.sort((a, b) => (rank(a) === -1 ? 9 : rank(a)) - (rank(b) === -1 ? 9 : rank(b)))[0] ?? (fast ? localModel(cfg) : null)
32
34
  }
33
35
 
34
36
  // Why voice can't work here, or null when it can.
@@ -39,6 +41,68 @@ export function voiceProblem(cfg = {}, providers = {}) {
39
41
  return localModel(cfg) ? null : 'voice input needs a whisper model: put a ggml-*.bin in ~/.whisper-models, or set "voice": { "model": "/path/to/ggml-….bin" }'
40
42
  }
41
43
 
44
+ const RATE = 16000 // 16 kHz mono, 16-bit: what whisper wants
45
+
46
+ // Starts listening: the sound comes into memory as it's recorded. Returns { pcm() → what's been
47
+ // said so far (raw), seconds(), stop() → all of it, cancel() }.
48
+ export function listen() {
49
+ // HEART_VOICE_FILE: a sound file played in at its own speed instead of the microphone (tests)
50
+ const input = process.env.HEART_VOICE_FILE ? ['-re', '-i', process.env.HEART_VOICE_FILE] : process.platform === 'darwin' ? ['-f', 'avfoundation', '-i', ':default'] : ['-f', 'pulse', '-i', 'default']
51
+ const child = spawn('ffmpeg', ['-hide_banner', '-loglevel', 'error', ...input, '-ac', '1', '-ar', String(RATE), '-f', 's16le', 'pipe:1'], { stdio: ['pipe', 'pipe', 'pipe'] })
52
+ const chunks = []
53
+ let size = 0
54
+ let err = ''
55
+ child.stdout.on('data', (d) => (chunks.push(d), (size += d.length)))
56
+ child.stderr.on('data', (d) => (err += d))
57
+ child.stdin.on('error', () => {})
58
+ // what's been said so far, in one piece (the pieces joined once, then kept joined)
59
+ const pcm = () => {
60
+ if (chunks.length > 1) chunks.splice(0, chunks.length, Buffer.concat(chunks))
61
+ return chunks[0] ?? Buffer.alloc(0)
62
+ }
63
+ const ended = new Promise((resolve) => child.once('exit', resolve))
64
+ return {
65
+ pcm,
66
+ seconds: () => size / (RATE * 2),
67
+ async stop() {
68
+ child.stdin.end('q') // ffmpeg stops on "q"
69
+ const t = setTimeout(() => child.kill('SIGKILL'), 2000)
70
+ await ended
71
+ clearTimeout(t)
72
+ if (size < RATE * 2 * 0.3) throw new Error(`nothing was recorded${err.trim() ? `: ${err.trim().split('\n').pop()}` : ' (does the terminal have microphone access? System Settings › Privacy & Security › Microphone)'}`)
73
+ return pcm()
74
+ },
75
+ cancel() {
76
+ child.kill('SIGKILL')
77
+ },
78
+ }
79
+ }
80
+
81
+ // Raw 16 kHz mono 16-bit sound as a WAV file's bytes.
82
+ export function wavOf(pcm) {
83
+ const head = Buffer.alloc(44)
84
+ head.write('RIFF', 0)
85
+ head.writeUInt32LE(36 + pcm.length, 4)
86
+ head.write('WAVEfmt ', 8)
87
+ head.writeUInt32LE(16, 16)
88
+ head.writeUInt16LE(1, 20) // PCM
89
+ head.writeUInt16LE(1, 22) // mono
90
+ head.writeUInt32LE(RATE, 24)
91
+ head.writeUInt32LE(RATE * 2, 28)
92
+ head.writeUInt16LE(2, 32)
93
+ head.writeUInt16LE(16, 34)
94
+ head.write('data', 36)
95
+ head.writeUInt32LE(pcm.length, 40)
96
+ return Buffer.concat([head, pcm])
97
+ }
98
+
99
+ // The words in some raw sound (fast: the quickest model, for what's shown while you speak).
100
+ export async function transcribePcm(pcm, cfg = {}, providers = {}, { fast = false, signal } = {}) {
101
+ const file = path.join(os.tmpdir(), `heartcode-voice-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}.wav`)
102
+ fs.writeFileSync(file, wavOf(pcm))
103
+ return transcribe(file, fast ? { ...cfg, model: localModel(cfg, { fast: true }) } : cfg, providers, signal)
104
+ }
105
+
42
106
  // Starts recording to a 16 kHz mono WAV. Returns { stop() → the file, cancel() }.
43
107
  export function record() {
44
108
  const file = path.join(os.tmpdir(), `heartcode-voice-${process.pid}-${Date.now()}.wav`)
package/src/web.js CHANGED
@@ -1,6 +1,11 @@
1
1
  // web_fetch and web_search: a page as text, and search results, with no API key.
2
- // Search scrapes DuckDuckGo's HTML page (asked twice when it answers with its bot check). Bing,
3
- // Startpage and Mojeek were tried too: blocked, or junk results for scripts.
2
+ // Search asks several engines at once and puts their results together (webSearch): public SearXNG
3
+ // instances (the list from searx.space), Mwmbl, DuckDuckGo's HTML page, and for coding questions
4
+ // Stack Overflow, npm and GitHub. Bing, Startpage, Mojeek, Google and Brave were tried too: blocked
5
+ // (Brave refuses Node's connections), or junk results for scripts. When none can be reached, the
6
+ // model is told "search is unreachable", so it stops searching instead of trying other words.
7
+ import path from 'node:path'
8
+ import { HOME, readJson, writeJson } from './config.js'
4
9
  // ponytail: scraping, no API; add a keyed backend (Brave, Tavily) if DuckDuckGo blocks for good.
5
10
 
6
11
  const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36'
@@ -102,22 +107,215 @@ async function duckduckgo(query, signal) {
102
107
  return out
103
108
  }
104
109
 
105
- export const ENGINES = { duckduckgo }
110
+ // SearXNG: the healthy public instances from searx.space (kept a day in ~/.heart/searx.json),
111
+ // fastest first; the ones that gave good results go first (kept there too), the ones that failed
112
+ // or answered empty sit out ten minutes. Five are asked at a time (5 s each), the next five when
113
+ // none answers well, through the whole list if need be; fewer than ten good results: pages 2 and 3
114
+ // too. Everything they find is put together, best match first. Their JSON output is off on almost all of them, and many
115
+ // answer a GET empty: the results page is asked for the way its form does (POST).
116
+ const SEARX_FILE = path.join(HOME, 'searx.json')
117
+ const answered = readJson(SEARX_FILE, {}).good ?? [] // instances that gave good results, latest first (kept)
118
+ const resting = new Map() // instance → until when it's left out
119
+ async function searxInstances(signal) {
120
+ const saved = readJson(SEARX_FILE, null)
121
+ if (saved?.list?.length && Date.now() - saved.at < 86_400_000) return saved.list
122
+ try {
123
+ const d = await (await fetch('https://searx.space/data/instances.json', { headers: HEADERS, signal })).json()
124
+ const list = Object.entries(d.instances ?? {})
125
+ .filter(([, i]) => i.network_type === 'normal' && i.http?.status_code === 200 && (i.uptime?.uptimeDay ?? 0) >= 90 && i.timing?.search?.all?.median)
126
+ .sort((a, b) => a[1].timing.search.all.median - b[1].timing.search.all.median)
127
+ .map(([u]) => u.replace(/\/?$/, '/'))
128
+ if (list.length) writeJson(SEARX_FILE, { ...readJson(SEARX_FILE, {}), at: Date.now(), list })
129
+ return list.length ? list : (saved?.list ?? [])
130
+ } catch (e) {
131
+ if (saved?.list?.length) return saved.list // searx.space is down: the older list
132
+ throw new Error(`searx.space: ${e.message}`)
133
+ }
134
+ }
135
+ async function searxAt(base, query, signal, page = 1) {
136
+ // as its own search form sends it: a GET is refused or answered empty by many instances' limiter
137
+ const res = await fetch(`${base}search`, { method: 'POST', headers: { ...HEADERS, 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ q: query, category_general: '1', language: 'en', ...(page > 1 && { pageno: String(page) }) }), signal })
138
+ if (!res.ok) throw new Error(`${base} answered ${res.status}`)
139
+ const html = await res.text()
140
+ const out = []
141
+ for (const block of html.split('<article class="result').slice(1)) {
142
+ const a = /<h3>\s*<a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/.exec(block)
143
+ if (!a || !/^https?:\/\//.test(decode(a[1]))) continue
144
+ out.push({ title: strip(a[2]), url: decode(a[1]), snippet: strip(/<p class="content">([\s\S]*?)<\/p>/.exec(block)?.[1] ?? '') })
145
+ }
146
+ return out
147
+ }
148
+ // How well a result matches the query: the share of its words (3+ letters) in its title, snippet
149
+ // and address. Junk (an instance passing on an engine that answers scripts with whatever matches one
150
+ // word) scores low.
151
+ function relevance(words, h) {
152
+ if (!words.length) return 1
153
+ const text = `${h.title} ${h.snippet} ${h.url}`.toLowerCase()
154
+ return words.filter((w) => text.includes(w)).length / words.length
155
+ }
156
+ async function searx(query, signal) {
157
+ const all = await searxInstances(signal)
158
+ const order = [...answered.filter((u) => all.includes(u)), ...all.filter((u) => !answered.includes(u) && !((resting.get(u) ?? 0) > Date.now()))]
159
+ const words = [...new Set(query.toLowerCase().match(/[\p{L}\p{N}][\p{L}\p{N}.+#-]{2,}/gu) ?? [])]
160
+ const errors = []
161
+ const merged = new Map() // address → { hit, score, from: how many instances had it }
162
+ const worked = [] // instances that gave good results for this query
163
+ const good = () => [...merged.values()].filter((x) => x.score >= 0.5).length
164
+ const add = (hits) => {
165
+ for (const h of hits) {
166
+ const key = h.url.replace(/^https?:\/\/(www\.)?/, '').replace(/[/#?]+$/, '')
167
+ const had = merged.get(key)
168
+ merged.set(key, had ? { ...had, from: had.from + 1 } : { hit: h, score: relevance(words, h), from: 1 })
169
+ }
170
+ }
171
+ // Asks these instances at once (page `page`); once one has answered, the others get 1.5 s more.
172
+ const round = (batch, page) =>
173
+ new Promise((resolve) => {
174
+ const stop = new AbortController()
175
+ const each = AbortSignal.any([signal, stop.signal, AbortSignal.timeout(5000)].filter(Boolean))
176
+ let left = batch.length
177
+ let grace = null
178
+ const done = () => (clearTimeout(grace), stop.abort(), resolve())
179
+ for (const u of batch) {
180
+ searxAt(u, query, each, page)
181
+ .then((hits) => {
182
+ const mean = hits.slice(0, 5).reduce((t, h) => t + relevance(words, h), 0) / Math.max(1, Math.min(5, hits.length))
183
+ if (page === 1) {
184
+ if (!hits.length || mean < 0.5) resting.set(u, Date.now() + 600_000) // empty or junk: others first for a while
185
+ else worked.push(u)
186
+ }
187
+ add(hits)
188
+ if (hits.length) grace ??= setTimeout(done, 1500)
189
+ })
190
+ .catch((e) => {
191
+ errors.push(e.message)
192
+ if (page === 1) resting.set(u, Date.now() + 600_000)
193
+ })
194
+ .finally(() => --left === 0 && done())
195
+ }
196
+ })
197
+ // five at a time until some answer well; none of the five: the next five
198
+ for (let i = 0; i < order.length && !worked.length; i += 5) {
199
+ await round(order.slice(i, i + 5), 1)
200
+ if (signal?.aborted) break
201
+ }
202
+ // not many good ones on the first page: pages 2 and 3 from the ones that answered, all at once
203
+ if (worked.length && good() < 10 && !signal?.aborted) await Promise.all([2, 3].map((page) => round(worked.slice(0, 3), page)))
204
+ if (worked.length) {
205
+ answered.splice(0, answered.length, ...worked, ...answered.filter((x) => !worked.includes(x)).slice(0, 20 - worked.length))
206
+ writeJson(SEARX_FILE, { ...readJson(SEARX_FILE, {}), good: answered }) // the next HeartCode starts with them
207
+ }
208
+ // best match first; found by more instances breaks a tie
209
+ if (merged.size) return [...merged.values()].sort((a, b) => b.score - a.score || b.from - a.from).map((x) => x.hit)
210
+ if (errors.length && errors.length >= order.length) throw new Error(`no instance answered (${errors[0]}…)`)
211
+ return []
212
+ }
213
+
214
+ // Mwmbl: an open-source, non-profit engine with its own index; a JSON API, no key. Fast, and
215
+ // always there; smaller than the big engines.
216
+ async function mwmbl(query, signal) {
217
+ const res = await fetch(`https://api.mwmbl.org/api/v1/search/?s=${encodeURIComponent(query)}`, { headers: { ...HEADERS, accept: 'application/json' }, signal })
218
+ if (!res.ok) throw new Error(`Mwmbl answered ${res.status}`)
219
+ const text = (parts) => (Array.isArray(parts) ? parts.map((p) => p.value ?? '').join('') : String(parts ?? ''))
220
+ return (await res.json()).map((r) => ({ title: decode(text(r.title)), url: r.url, snippet: decode(text(r.extract)) }))
221
+ }
222
+
223
+ // Stack Overflow's questions (the Stack Exchange API, no key): for errors and how-to questions.
224
+ async function stackoverflow(query, signal) {
225
+ const res = await fetch(`https://api.stackexchange.com/2.3/search/advanced?order=desc&sort=relevance&pagesize=5&site=stackoverflow&q=${encodeURIComponent(query)}`, { headers: { ...HEADERS, accept: 'application/json' }, signal })
226
+ if (!res.ok) throw new Error(`Stack Exchange answered ${res.status}`)
227
+ return ((await res.json()).items ?? []).map((i) => ({ title: decode(i.title), url: i.link, snippet: `${i.is_answered ? 'answered' : 'no accepted answer'} · score ${i.score} · ${(i.tags ?? []).join(', ')}` }))
228
+ }
229
+
230
+ // npm's package search: for libraries and tools.
231
+ async function npm(query, signal) {
232
+ const res = await fetch(`https://registry.npmjs.org/-/v1/search?size=5&text=${encodeURIComponent(query)}`, { headers: { ...HEADERS, accept: 'application/json' }, signal })
233
+ if (!res.ok) throw new Error(`npm answered ${res.status}`)
234
+ return ((await res.json()).objects ?? []).map(({ package: p }) => ({ title: `${p.name} (npm package, ${p.version})`, url: p.links?.npm ?? `https://www.npmjs.com/package/${p.name}`, snippet: p.description ?? '' }))
235
+ }
236
+
237
+ // GitHub's repository search (no key: a few requests a minute): for projects and examples.
238
+ async function github(query, signal) {
239
+ const res = await fetch(`https://api.github.com/search/repositories?per_page=5&q=${encodeURIComponent(query)}`, { headers: { ...HEADERS, accept: 'application/vnd.github+json' }, signal })
240
+ if (!res.ok) throw new Error(`GitHub answered ${res.status}`)
241
+ return ((await res.json()).items ?? []).map((r) => ({ title: `${r.full_name} (GitHub, ${r.stargazers_count}★)`, url: r.html_url, snippet: r.description ?? '' }))
242
+ }
106
243
 
107
- // Results as text: title, URL and snippet, numbered.
108
- export async function webSearch(query, { signal, limit = 8, engines = ENGINES } = {}) {
244
+ // All asked at once; a search fails only when every one does. Public instances of the open-source
245
+ // engines come and go (on 2026-10-31 most LibreY, Whoogle and 4get ones were down or blocked, so
246
+ // they aren't here), and DuckDuckGo is blocked on some networks.
247
+ export const ENGINES = { searx, mwmbl, duckduckgo, stackoverflow, npm, github }
248
+ const SIDE = new Set(['stackoverflow', 'npm', 'github']) // for coding questions: a few each, when they match
249
+ const GENERAL = new Set(['searx', 'duckduckgo']) // the broadest: waited for a while even when the fast ones have answered
250
+
251
+ // Results as text: title, URL, where they came from and snippet, numbered. The engines are asked
252
+ // together; once enough good results are in, the slower ones get 2 s more. Each result is scored on
253
+ // its own (the query's words in it), one that several engines found ranks higher.
254
+ let unreachableUntil = 0 // search couldn't be reached: said at once for a while, without waiting again
255
+ let unreachableWhy = ''
256
+ export async function webSearch(query, { signal, limit = 10, engines = ENGINES } = {}) {
109
257
  const q = String(query ?? '').trim()
110
258
  if (!q) return 'error: no query given'
259
+ if (Date.now() < unreachableUntil && engines === ENGINES) return unreachableWhy
260
+ const words = [...new Set(q.toLowerCase().match(/[\p{L}\p{N}][\p{L}\p{N}.+#-]{2,}/gu) ?? [])]
111
261
  const failed = []
112
- for (const [name, engine] of Object.entries(engines)) {
113
- try {
114
- const hits = (await engine(q, signal ? AbortSignal.any([signal, AbortSignal.timeout(20_000)]) : AbortSignal.timeout(20_000))).filter((h) => h.url && h.title)
115
- if (hits.length) return hits.slice(0, limit).map((h, i) => `${i + 1}. ${h.title}\n ${h.url}${h.snippet ? `\n ${h.snippet}` : ''}`).join('\n\n')
116
- failed.push(`${name}: no results`)
117
- } catch (e) {
118
- if (signal?.aborted) throw e
119
- failed.push(`${name}: ${e.message}`)
262
+ const merged = new Map()
263
+ const stop = new AbortController()
264
+ const each = AbortSignal.any([signal, stop.signal, AbortSignal.timeout(25_000)].filter(Boolean))
265
+ const good = () => [...merged.values()].filter((x) => x.score >= 0.5).length
266
+ await new Promise((resolve) => {
267
+ const names = Object.keys(engines)
268
+ let left = names.length
269
+ let answered = 0
270
+ let general = 0
271
+ let grace = null
272
+ const done = () => (clearTimeout(grace), resolve())
273
+ for (const name of names) {
274
+ engines[name](q, each)
275
+ .then((hits) => {
276
+ hits = hits.filter((h) => h.url && h.title)
277
+ if (SIDE.has(name)) hits = hits.slice(0, 3)
278
+ if (!hits.length) return failed.push(`${name}: no results`)
279
+ answered++
280
+ if (GENERAL.has(name)) general++
281
+ for (const h of hits) {
282
+ const key = h.url.replace(/^https?:\/\/(www\.)?/, '').replace(/[/#?]+$/, '')
283
+ const had = merged.get(key)
284
+ if (!had) merged.set(key, { hit: h, score: relevance(words, h), from: [name] })
285
+ else if (!had.from.includes(name)) had.from.push(name)
286
+ }
287
+ // enough: the slower ones get a little more (a general engine, the best results, up to 12 s)
288
+ if (answered >= 2 && good() >= limit) {
289
+ clearTimeout(grace)
290
+ grace = setTimeout(done, general ? 2000 : 12_000)
291
+ }
292
+ })
293
+ .catch((e) => failed.push(`${name}: ${e.name === 'AbortError' || /aborted/.test(e.message) ? (signal?.aborted ? 'stopped' : 'too slow') : e.message}`))
294
+ .finally(() => --left === 0 && done())
120
295
  }
296
+ })
297
+ stop.abort() // the slow ones: not waited for
298
+ if (signal?.aborted) throw Object.assign(new Error('stopped'), { name: 'AbortError' })
299
+ // ranked by the query's words in each, a word that few results have weighing more ("tailwind"
300
+ // over "latest"), and by how many engines found it
301
+ const all = [...merged.values()]
302
+ const textOf = (h) => `${h.title} ${h.snippet} ${h.url}`.toLowerCase()
303
+ const weight = Object.fromEntries(words.map((w) => [w, Math.log(1 + all.length / (1 + all.filter((x) => textOf(x.hit).includes(w)).length))]))
304
+ const total = words.reduce((t, w) => t + weight[w], 0) || 1
305
+ const ranked = all.map((x) => ({ ...x, rank: words.reduce((t, w) => t + (textOf(x.hit).includes(w) ? weight[w] : 0), 0) / total + 0.15 * (x.from.length - 1) })).sort((a, b) => b.rank - a.rank)
306
+ if (ranked.length) {
307
+ unreachableUntil = 0
308
+ return ranked
309
+ .slice(0, limit)
310
+ .map(({ hit: h, from }, i) => `${i + 1}. ${h.title}\n ${h.url} (${from.join(', ')})${h.snippet ? `\n ${h.snippet}` : ''}`)
311
+ .join('\n\n')
312
+ }
313
+ // can't be reached: every engine failed on the network itself (not public instances being busy)
314
+ const unreachable = failed.length && failed.every((f) => !/no instance answered|no results/.test(f) && /timeout|too slow|aborted|fetch failed|ENOTFOUND|ECONN|EAI_AGAIN|network/i.test(f))
315
+ if (unreachable) {
316
+ unreachableWhy = `error: web search can't be reached from here right now (${failed.join('; ')}). Don't search again in this task: web_fetch pages you know (official docs, sites you can name), or go on with what you know.`
317
+ if (engines === ENGINES) unreachableUntil = Date.now() + 5 * 60_000
318
+ return unreachableWhy
121
319
  }
122
320
  return `no results for "${q}" (${failed.join('; ')}). Try other words, or web_fetch a site you know.`
123
321
  }