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/CHANGELOG.md +197 -0
- package/SKILL.md +5 -3
- package/bin/heartcode.js +199 -71
- package/package.json +1 -1
- package/prompts/agent.md +147 -17
- package/prompts/guided.md +6 -3
- package/src/acp.js +2 -2
- package/src/agent.js +418 -34
- package/src/client.js +59 -3
- package/src/config.js +11 -6
- package/src/headless.js +1 -1
- package/src/lsp.js +168 -49
- package/src/mcp.js +20 -2
- package/src/sessions.js +2 -3
- package/src/settings.html +408 -142
- package/src/settings.js +44 -7
- package/src/skills.js +20 -1
- package/src/tabs.js +22 -7
- package/src/tools.js +68 -6
- package/src/ui.js +525 -94
- package/src/voice.js +70 -6
- package/src/web.js +211 -13
package/src/settings.js
CHANGED
|
@@ -21,7 +21,7 @@ import { log, LOGS, REQUESTS, useRequestLog, listRequests, readRequest, pruneLog
|
|
|
21
21
|
|
|
22
22
|
const PAGE = new URL('./settings.html', import.meta.url)
|
|
23
23
|
const EFFORTS = ['default', 'low', 'medium', 'high']
|
|
24
|
-
const TOGGLES = { autoCompact: 'compact the conversation when
|
|
24
|
+
const TOGGLES = { autoCompact: 'compact the conversation when it fills 85% of the model\'s context window', director: 'let the model add skills, MCP servers and change settings when asked', format: "run the project's formatter on every file HeartCode writes", lsp: "show the model a language server's errors after each edit" }
|
|
25
25
|
// The Logging page's: both off unless switched on; how much of each request ("full" or "summary")
|
|
26
26
|
const LOG_SWITCHES = ['log', 'logRequests']
|
|
27
27
|
const DETAILS = ['full', 'summary']
|
|
@@ -102,10 +102,11 @@ const skillsDir = () => path.join(HOME, 'skills')
|
|
|
102
102
|
// what the provider said (names, sizes, prices; not what's known from models.dev or tests, which
|
|
103
103
|
// changes). Asked for again when its list is switched on or off, or the provider is changed.
|
|
104
104
|
const LISTS = path.join(HOME, 'model-lists.json')
|
|
105
|
+
const LIST_V = 2 // how a saved list was read (2: airforce's cents, Cloudflare's prices); another: read again
|
|
105
106
|
// The page's own choices (sorts, folded providers, the provider ranking): kept here, not in the
|
|
106
107
|
// browser, whose storage is per address (a new port each time)
|
|
107
108
|
const PREFS = path.join(HOME, 'settings-page.json')
|
|
108
|
-
const PREF_KEYS = ['catSort', 'folded', 'sort', 'rank']
|
|
109
|
+
const PREF_KEYS = ['catSort', 'freeOnly', 'folded', 'sort', 'rank']
|
|
109
110
|
function forgetList(...names) {
|
|
110
111
|
const all = readJson(LISTS, {})
|
|
111
112
|
if (!names.some((n) => n in all)) return
|
|
@@ -332,7 +333,7 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
|
|
|
332
333
|
const state = () => ({
|
|
333
334
|
providers: Object.entries(byName(cfg.providers))
|
|
334
335
|
.filter(([n]) => n !== cfg.adhoc)
|
|
335
|
-
.map(([name, p]) => ({ name, baseUrl: p.baseUrl, key: mask(p.apiKey), list: p.list !== false, added: p.models ?? [] })),
|
|
336
|
+
.map(([name, p]) => ({ name, baseUrl: p.baseUrl, key: mask(p.apiKey), list: p.list !== false, off: p.off === true, added: p.models ?? [] })),
|
|
336
337
|
provider: cfg.provider ?? null,
|
|
337
338
|
model: cfg.model ?? null,
|
|
338
339
|
order: [...(cfg.providers[cfg.provider] && cfg.model ? [{ provider: cfg.provider, model: cfg.model }] : []), ...rotationOf(cfg).then].map((t) => ({ ...t, tested: testedOf(t), known: knownOf(t), price: priceFor(t) })),
|
|
@@ -414,6 +415,15 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
|
|
|
414
415
|
save('provider')
|
|
415
416
|
return state()
|
|
416
417
|
},
|
|
418
|
+
// A provider switched off: kept (its key, its models in your list), but HeartCode skips its models
|
|
419
|
+
// until it's on again.
|
|
420
|
+
'POST /api/provider/on': ({ name, on }) => {
|
|
421
|
+
if (!cfg.providers[name]) throw new Error(`no provider "${name}"`)
|
|
422
|
+
if (on) delete cfg.providers[name].off
|
|
423
|
+
else cfg.providers[name].off = true
|
|
424
|
+
save('provider')
|
|
425
|
+
return state()
|
|
426
|
+
},
|
|
417
427
|
// A model added by its name (one the provider doesn't list, or its list is off): remembered.
|
|
418
428
|
// all: every model added by name forgotten at once
|
|
419
429
|
'POST /api/provider/added': ({ name, model, remove, all }) => {
|
|
@@ -437,11 +447,11 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
|
|
|
437
447
|
if (!p) throw new Error(`no provider "${name}"`)
|
|
438
448
|
if (p.list === false) return { models: [], details: {}, off: true }
|
|
439
449
|
const lists = readJson(LISTS, {})
|
|
440
|
-
let saved = lists[name]?.baseUrl === p.baseUrl && lists[name].models?.length ? lists[name] : null // an empty one: asked again
|
|
450
|
+
let saved = lists[name]?.baseUrl === p.baseUrl && lists[name].models?.length && lists[name].v === LIST_V ? lists[name] : null // an empty one, or one read the old way: asked again
|
|
441
451
|
if (!saved) {
|
|
442
452
|
const entries = await new Client({ baseUrl: p.baseUrl, apiKey: p.apiKey }).modelEntries()
|
|
443
453
|
const num = (...xs) => xs.map(Number).find((x) => x > 0)
|
|
444
|
-
saved = { baseUrl: p.baseUrl, at: Date.now(), models: entries.map((m) => String(m.id ?? m.name)), details: Object.fromEntries(entries.map((m) => [String(m.id ?? m.name), {
|
|
454
|
+
saved = { v: LIST_V, baseUrl: p.baseUrl, at: Date.now(), models: entries.map((m) => String(m.id ?? m.name)), details: Object.fromEntries(entries.map((m) => [String(m.id ?? m.name), {
|
|
445
455
|
name: m.name && m.name !== String(m.id ?? m.name) ? String(m.name) : undefined,
|
|
446
456
|
context: num(m.context_length, m.context_window, m.max_model_len, m.max_context_length, m.top_provider?.context_length),
|
|
447
457
|
owner: m.owned_by && m.owned_by !== 'system' ? String(m.owned_by) : undefined,
|
|
@@ -505,6 +515,28 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
|
|
|
505
515
|
// itself). In the requests log as a model test. Never throws: { ok, ms, reply } or { ok: false, error }.
|
|
506
516
|
'POST /api/try': ({ provider, model } = {}) => testOne({ provider, model: String(model ?? '') }),
|
|
507
517
|
// the tests' results, and the run going now (with the models it hasn't tested yet)
|
|
518
|
+
// Test, on a model's row: one "Hello" to it, and exactly what was sent and what came back (the
|
|
519
|
+
// key masked), for the page to show. Not streamed, so the answer is the provider's own JSON.
|
|
520
|
+
'POST /api/hello': async ({ provider, model }) => {
|
|
521
|
+
const c = clientFor({ provider, model })
|
|
522
|
+
if (!c || !model) throw new Error(`no provider "${provider}"`)
|
|
523
|
+
const url = `${c.baseUrl}/chat/completions`
|
|
524
|
+
const body = { model, messages: [{ role: 'user', content: 'Hello' }] }
|
|
525
|
+
const sent = { method: 'POST', url, headers: { 'content-type': 'application/json', ...(c.apiKey && { authorization: `Bearer ${mask(c.apiKey)}` }) }, body }
|
|
526
|
+
const t0 = Date.now()
|
|
527
|
+
try {
|
|
528
|
+
const res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', ...(c.apiKey && { authorization: `Bearer ${c.apiKey}` }) }, body: JSON.stringify(body), signal: AbortSignal.timeout(90_000) })
|
|
529
|
+
const text = await res.text()
|
|
530
|
+
let json = null
|
|
531
|
+
try {
|
|
532
|
+
json = JSON.parse(text)
|
|
533
|
+
} catch {}
|
|
534
|
+
const reply = json?.choices?.[0]?.message?.content ?? null
|
|
535
|
+
return { sent, received: { status: res.status, ok: res.ok, ms: Date.now() - t0, type: res.headers.get('content-type'), body: json ?? text.slice(0, 20_000) }, reply }
|
|
536
|
+
} catch (e) {
|
|
537
|
+
return { sent, received: { error: e.name === 'TimeoutError' ? 'no answer within 90 seconds' : `couldn't send it: ${e.cause?.code || e.cause?.message || e.message}`, ms: Date.now() - t0 } }
|
|
538
|
+
}
|
|
539
|
+
},
|
|
508
540
|
'POST /api/tests': () => ({ results: tests, run: run && { ...runState(), pending: [...run.pending] } }),
|
|
509
541
|
// a run: all your models, or the ones given (only); one at a time if one is going already
|
|
510
542
|
'POST /api/tests/start': ({ only } = {}) => {
|
|
@@ -772,7 +804,11 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
|
|
|
772
804
|
// output } (either may be missing), or undefined. Not models.dev's: the same model costs differently
|
|
773
805
|
// at different providers. The forms lists use: pricing.prompt/completion (per token, OpenRouter's);
|
|
774
806
|
// pricing.input/output with a unit (per million when it says so; no unit: per token when it's that
|
|
775
|
-
// small); input_price/output_price (per token); pricepermilliontokens
|
|
807
|
+
// small); input_price/output_price (per token); pricepermilliontokens (in cents: api.airforce's 88
|
|
808
|
+
// is its own 880000 micro-dollars); Cloudflare's properties ("per M input tokens"); only a free
|
|
809
|
+
// tier (is_free, or a tier named free when nothing else is said: a list's "free tier" with a price
|
|
810
|
+
// is a plan that may use it, not a model that costs nothing).
|
|
811
|
+
const cfPrice = (m, unit) => (Array.isArray(m.properties) ? m.properties.find((p) => p?.property_id === 'price')?.value?.find?.((v) => unit.test(v?.unit ?? ''))?.price : undefined)
|
|
776
812
|
export function listedPrice(m) {
|
|
777
813
|
const num = (x) => (x === null || x === undefined || x === '' || !(Number(x) >= 0) ? undefined : Number(x)) // -1: varies (a router's "auto")
|
|
778
814
|
const pr = m.pricing && typeof m.pricing === 'object' && !Array.isArray(m.pricing) ? m.pricing : {}
|
|
@@ -781,7 +817,8 @@ export function listedPrice(m) {
|
|
|
781
817
|
[num(pr.prompt), num(pr.completion), () => 1e6],
|
|
782
818
|
[num(pr.input), num(pr.output), unit],
|
|
783
819
|
[num(m.input_price), num(m.output_price), () => 1e6],
|
|
784
|
-
[num(m.pricepermilliontokens), num(m.output_pricepermilliontokens), () =>
|
|
820
|
+
[num(m.pricepermilliontokens), num(m.output_pricepermilliontokens), () => 0.01],
|
|
821
|
+
[num(cfPrice(m, /^per M input tokens$/i)), num(cfPrice(m, /^per M output tokens$/i)), () => 1],
|
|
785
822
|
]) {
|
|
786
823
|
if (i === undefined && o === undefined) continue
|
|
787
824
|
const per = (x) => (x === undefined ? undefined : Number((x * k(x)).toPrecision(6)))
|
package/src/skills.js
CHANGED
|
@@ -18,7 +18,26 @@ export const USES = ['me', 'model', 'both']
|
|
|
18
18
|
|
|
19
19
|
export function frontMatter(text) {
|
|
20
20
|
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text)
|
|
21
|
-
const
|
|
21
|
+
const lines = (m?.[1] ?? '').replace(/\r/g, '').split('\n')
|
|
22
|
+
const meta = {}
|
|
23
|
+
const indent = (l) => l.length - l.trimStart().length
|
|
24
|
+
for (let i = 0; i < lines.length; i++) {
|
|
25
|
+
const key = /^([\w-]+):\s*(.*)$/.exec(lines[i])
|
|
26
|
+
if (!key) continue
|
|
27
|
+
const [, k, raw] = key
|
|
28
|
+
const base = indent(lines[i])
|
|
29
|
+
const block = /^([|>])([+-]?)\s*$/.exec(raw) // a YAML block scalar: | literal, > folded (Claude Code, others use "description: >-")
|
|
30
|
+
if (block) {
|
|
31
|
+
const kept = []
|
|
32
|
+
while (i + 1 < lines.length && (lines[i + 1].trim() === '' || indent(lines[i + 1]) > base)) kept.push(lines[++i])
|
|
33
|
+
const minIndent = Math.min(...kept.filter((l) => l.trim()).map(indent), Infinity)
|
|
34
|
+
let v = kept.map((l) => l.slice(Number.isFinite(minIndent) ? minIndent : 0)).join(block[1] === '>' ? ' ' : '\n')
|
|
35
|
+
if (block[2] !== '+') v = v.replace(/\n+$/, '') // "-" strips trailing newlines; default keeps one at most
|
|
36
|
+
meta[k] = v.trim()
|
|
37
|
+
} else {
|
|
38
|
+
meta[k] = raw.replace(/^["']|["']$/g, '')
|
|
39
|
+
}
|
|
40
|
+
}
|
|
22
41
|
return { meta, body: (m ? m[2] : text).trim() }
|
|
23
42
|
}
|
|
24
43
|
|
package/src/tabs.js
CHANGED
|
@@ -42,7 +42,7 @@ export class Tabs {
|
|
|
42
42
|
tab.output = Object.assign(new EventEmitter(), { isTTY: true, write: (s) => (on() ? real.output.write(s) : true) })
|
|
43
43
|
for (const k of ['columns', 'rows']) Object.defineProperty(tab.output, k, { get: () => real.output[k] })
|
|
44
44
|
Object.defineProperty(tab.output, 'hidden', { get: () => !on() })
|
|
45
|
-
tab.hooks = { tabBar: (cols) => this.bar(cols), onTab: (what) => this.key(what) }
|
|
45
|
+
tab.hooks = { tabBar: (cols) => this.bar(cols), onTab: (what) => this.key(what), onTabClick: (col) => this.click(col) }
|
|
46
46
|
this.list.push(tab)
|
|
47
47
|
this.at = tab
|
|
48
48
|
tab.id = this.made = (this.made ?? 0) + 1 // for its log file: tab-1.log… (its place in the bar can change)
|
|
@@ -96,6 +96,13 @@ export class Tabs {
|
|
|
96
96
|
if (n) this.show(this.list[Number(n) - 1])
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
// a click on the tab bar (a 0-based column): that tab
|
|
100
|
+
click(col) {
|
|
101
|
+
if (this.locked) return
|
|
102
|
+
const hit = this.spans?.find((s) => col >= s.from && col < s.to)
|
|
103
|
+
if (hit && hit.tab !== this.at) this.show(hit.tab)
|
|
104
|
+
}
|
|
105
|
+
|
|
99
106
|
// working, waiting for an answer, or done since you last looked at it
|
|
100
107
|
state(t) {
|
|
101
108
|
const ui = t.ui
|
|
@@ -108,40 +115,48 @@ export class Tabs {
|
|
|
108
115
|
// The tab bar, three rows: tabs drawn as tabs, the one on screen open onto it (its bottom
|
|
109
116
|
// edge missing), the others closed off. Number, name, and what it's doing.
|
|
110
117
|
// ╭─────────────╮ ╭──────────────╮
|
|
111
|
-
// │ 1 heart_cli │ │ 2 fix tests ●│
|
|
118
|
+
// │ 1 heart_cli │ │ 2 fix tests ●│ Ctrl+T open a new tab
|
|
112
119
|
// ─╯ ╰─┴──────────────┴──────────────────
|
|
113
120
|
bar(cols = 100) {
|
|
114
121
|
const mark = { '?': c.warn('?'), '●': c.brand('●'), '✓': c.ok('✓') }
|
|
115
122
|
const room = Math.max(6, Math.floor((cols - 24) / this.list.length) - 7) // a name's cells, so they all fit
|
|
116
123
|
let [top, mid, bottom] = [' ', ' ', c.ghost('─')]
|
|
124
|
+
let col = 1
|
|
125
|
+
this.spans = [] // the columns each tab takes, for a click (click)
|
|
117
126
|
this.list.forEach((t, i) => {
|
|
118
127
|
const on = t === this.at
|
|
119
128
|
const s = this.state(t)
|
|
120
|
-
const name = cut(t.name || t.app?.chat?.title || pathTail(t.app?.root), room)
|
|
129
|
+
const name = cut(t.name || t.app?.chat?.title || pathTail(t.app?.root), Math.min(NAME_MAX, room))
|
|
121
130
|
const text = ` ${i + 1} ${name}${s ? ' ' + s : ''} `
|
|
122
131
|
const w = width(text)
|
|
123
132
|
const line = on ? c.brand : c.ghost
|
|
124
133
|
const label = ` ${on ? c.bright(c.bold(`${i + 1} ${name}`)) : c.muted(`${i + 1} ${name}`)}${s ? ' ' + mark[s] : ''} `
|
|
134
|
+
this.spans.push({ from: col, to: col + w + 2, tab: t })
|
|
135
|
+
col += w + 3
|
|
125
136
|
top += line(`╭${'─'.repeat(w)}╮`) + ' '
|
|
126
137
|
mid += line('│') + label + line('│') + ' '
|
|
127
138
|
bottom += (on ? c.brand(`╯${' '.repeat(w)}╰`) : c.ghost(`┴${'─'.repeat(w)}┴`)) + c.ghost('─')
|
|
128
139
|
})
|
|
129
|
-
const hint = this.list.length > 1 ? 'Ctrl+T
|
|
140
|
+
const hint = this.list.length > 1 ? 'Ctrl+T open a tab · Ctrl+Q close this one · Ctrl+N/P switch tabs' : 'Ctrl+T open a new tab'
|
|
130
141
|
mid += ` ${c.faint(hint)}`
|
|
131
142
|
bottom += c.ghost('─'.repeat(Math.max(0, cols - width(bottom.replace(/\x1b\[[0-9;]*m/g, '')))))
|
|
132
143
|
return [top, mid, bottom]
|
|
133
144
|
}
|
|
134
145
|
}
|
|
135
146
|
|
|
136
|
-
//
|
|
147
|
+
// A tab's name: at most 20 cells, "..." included (fewer when many tabs share a narrow screen).
|
|
148
|
+
const NAME_MAX = 20
|
|
149
|
+
|
|
150
|
+
// At most n cells, ending in "..." when cut.
|
|
137
151
|
function cut(s, n) {
|
|
152
|
+
s = String(s ?? '').replace(/\s+/g, ' ').trim()
|
|
138
153
|
if (width(s) <= n) return s
|
|
139
154
|
let out = ''
|
|
140
155
|
for (const ch of s) {
|
|
141
|
-
if (width(out + ch) > n -
|
|
156
|
+
if (width(out + ch) > n - 3) break
|
|
142
157
|
out += ch
|
|
143
158
|
}
|
|
144
|
-
return out + '
|
|
159
|
+
return out.trimEnd() + '...'
|
|
145
160
|
}
|
|
146
161
|
|
|
147
162
|
const pathTail = (p = '') => p.split(/[\\/]/).filter(Boolean).pop() ?? p
|
package/src/tools.js
CHANGED
|
@@ -15,7 +15,7 @@ import { webFetch, webSearch } from './web.js'
|
|
|
15
15
|
import { runHooks } from './hooks.js'
|
|
16
16
|
import { parsePatch, applyChunks } from './patch.js'
|
|
17
17
|
import { formatFile } from './format.js'
|
|
18
|
-
import { diagnose } from './lsp.js'
|
|
18
|
+
import { diagnose, lspTool, findCommand } from './lsp.js'
|
|
19
19
|
|
|
20
20
|
const IGNORE = new Set(['.git', 'node_modules', 'dist', 'build', '.next', 'coverage', '.venv', 'venv', '__pycache__', 'target', '.cache', '.DS_Store'])
|
|
21
21
|
|
|
@@ -42,15 +42,28 @@ export function saveOutput(chatId, name, data, ext = 'txt') {
|
|
|
42
42
|
export function resolveIn(root, p) {
|
|
43
43
|
const clean = String(p ?? '').trim().replace(/^["'`]+|["'`]+$/g, '')
|
|
44
44
|
if (!clean) throw new Error('no path given')
|
|
45
|
-
|
|
45
|
+
const bare = root.replace(/^[\\/]+/, '') // the project's own path, its leading "/" left out by a sloppy copy
|
|
46
|
+
let abs = clean === bare || clean.startsWith(bare + '/') ? path.join(root, clean.slice(bare.length)) : path.resolve(root, clean)
|
|
46
47
|
if (abs.startsWith(OUTPUTS + path.sep)) return abs // a saved tool output
|
|
47
48
|
// "num.js:" or "num.js," from a sloppy model: the file without the stray mark, if that one exists
|
|
48
49
|
const trimmed = path.resolve(root, clean.replace(/[:;,.]+$/, ''))
|
|
49
50
|
if (trimmed !== abs && !fs.existsSync(abs) && fs.existsSync(trimmed)) abs = trimmed
|
|
50
|
-
if (abs !== root && !abs.startsWith(root + path.sep))
|
|
51
|
+
if (abs !== root && !abs.startsWith(root + path.sep)) abs = intoProject(root, clean) ?? abs
|
|
52
|
+
if (abs !== root && !abs.startsWith(root + path.sep)) throw new Error(`${clean} is outside the project; use a path relative to the project folder (${root}), like "index.html" or "src/app.js"`)
|
|
51
53
|
return abs
|
|
52
54
|
}
|
|
53
55
|
|
|
56
|
+
// A small model's mangled copy of the project's long path, meant inside it: any path with the
|
|
57
|
+
// project folder's name in it ("/Users-me-…/myapp/index.html"): what comes after that name.
|
|
58
|
+
// null: no such reading.
|
|
59
|
+
function intoProject(root, p) {
|
|
60
|
+
const slash = p.replace(/\\/g, '/')
|
|
61
|
+
const name = path.basename(root)
|
|
62
|
+
const parts = slash.split('/')
|
|
63
|
+
const at = parts.lastIndexOf(name)
|
|
64
|
+
return at >= 0 ? path.resolve(root, parts.slice(at + 1).join('/') || '.') : null
|
|
65
|
+
}
|
|
66
|
+
|
|
54
67
|
// Project files a message names: by its path, or by a file name only one file in the project has
|
|
55
68
|
// ("num.js"; in a project with three index.js, "index.js" names none of them).
|
|
56
69
|
export function namedFiles(text, files) {
|
|
@@ -99,6 +112,33 @@ export function listFiles(root, limit = 2000) {
|
|
|
99
112
|
return files.sort()
|
|
100
113
|
}
|
|
101
114
|
|
|
115
|
+
// One folder's entries (the project root: ''), all of them: folders end in "/", what .gitignore
|
|
116
|
+
// leaves out says "(ignored)". Names only. At most `limit`.
|
|
117
|
+
export function folderView(root, rel = '', limit = 200) {
|
|
118
|
+
const abs = resolveIn(root, rel || '.')
|
|
119
|
+
let entries
|
|
120
|
+
try {
|
|
121
|
+
entries = fs.readdirSync(abs, { withFileTypes: true })
|
|
122
|
+
} catch (e) {
|
|
123
|
+
return { error: e.code === 'ENOENT' ? `no folder ${rel}` : e.code === 'ENOTDIR' ? `${rel} is a file, not a folder` : e.message }
|
|
124
|
+
}
|
|
125
|
+
let ignored = null // the entries .gitignore leaves out ("node_modules/", "dist/", ".env")
|
|
126
|
+
try {
|
|
127
|
+
const out = execFileSync('git', ['ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--directory', '--', abs], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 16 << 20 })
|
|
128
|
+
ignored = new Set(out.split('\0').filter(Boolean).map((f) => f.replace(/\/$/, '')))
|
|
129
|
+
} catch {}
|
|
130
|
+
const here = path.relative(root, abs)
|
|
131
|
+
const names = entries
|
|
132
|
+
.map((e) => ({ name: e.name, dir: e.isDirectory() || (e.isSymbolicLink() && fs.statSync(path.join(abs, e.name), { throwIfNoEntry: false })?.isDirectory()) }))
|
|
133
|
+
.sort((a, b) => b.dir - a.dir || a.name.localeCompare(b.name))
|
|
134
|
+
const shown = names.slice(0, limit).map(({ name, dir }) => {
|
|
135
|
+
const p = path.join(here, name).split(path.sep).join('/')
|
|
136
|
+
const off = ignored ? ignored.has(p) : IGNORE.has(name)
|
|
137
|
+
return `${name}${dir ? '/' : ''}${off && name !== '.git' ? ' (ignored)' : ''}`
|
|
138
|
+
})
|
|
139
|
+
return { entries: shown, more: Math.max(0, names.length - limit) }
|
|
140
|
+
}
|
|
141
|
+
|
|
102
142
|
const isBinary = (buf) => buf.subarray(0, 8000).includes(0)
|
|
103
143
|
export const numberLines = (lines, from = 1) => lines.map((l, i) => `${from + i}| ${l}`).join('\n')
|
|
104
144
|
|
|
@@ -285,6 +325,7 @@ export async function proposeWrite(s, rel, old, next) {
|
|
|
285
325
|
else {
|
|
286
326
|
fs.mkdirSync(path.dirname(abs), { recursive: true })
|
|
287
327
|
fs.writeFileSync(abs, next)
|
|
328
|
+
if (/\.(html?|css)$/i.test(rel)) (s.uiTouched ??= new Set()).add(rel) // looked at in a browser when the model says it's done (agent.js)
|
|
288
329
|
}
|
|
289
330
|
const add = ops.filter(([t]) => t === '+').length
|
|
290
331
|
const del = ops.filter(([t]) => t === '-').length
|
|
@@ -441,7 +482,9 @@ const obj = (properties, required = []) => ({ type: 'object', properties, requir
|
|
|
441
482
|
export const TOOL_DEFS = [
|
|
442
483
|
{ name: 'read_file', description: prompt('agent/tool-read_file'), parameters: obj({ path: str, start_line: int, end_line: int }, ['path']) },
|
|
443
484
|
{ name: 'search', description: prompt('agent/tool-search'), parameters: obj({ query: str, path: str }, ['query']) },
|
|
444
|
-
{ name: 'list_files', description: prompt('agent/tool-list_files'), parameters: obj({ filter: str }) },
|
|
485
|
+
{ name: 'list_files', description: prompt('agent/tool-list_files'), parameters: obj({ path: str, filter: str }) },
|
|
486
|
+
{ name: 'find_command', description: prompt('agent/tool-find_command'), parameters: obj({ names: { type: 'array', items: str } }, ['names']) },
|
|
487
|
+
{ name: 'lsp', description: prompt('agent/tool-lsp'), parameters: obj({ server: str, language: str, action: { type: 'string', enum: ['definition', 'references', 'hover', 'errors'] }, file: str, line: int, column: int, name: str }, ['server', 'language', 'action', 'file']) },
|
|
445
488
|
// One edit per call: a list of edits per call measured 2–6 malformed calls per benchmark run vs 0.
|
|
446
489
|
{ name: 'edit_file', description: prompt('agent/tool-edit_file'), parameters: obj({ path: str, search: str, replace: str }, ['path', 'search', 'replace']) },
|
|
447
490
|
{ name: 'write_file', description: prompt('agent/tool-write_file'), parameters: obj({ path: str, content: str, append: { type: 'boolean' } }, ['path', 'content']) },
|
|
@@ -455,7 +498,15 @@ export const TOOL_DEFS = [
|
|
|
455
498
|
// Plan mode (permission "plan"): looking around is fine, changing things isn't. Edits are refused,
|
|
456
499
|
// and a command runs only when it only reads (else it's refused, not asked about).
|
|
457
500
|
export const PLAN_HIDDEN = new Set(['edit_file', 'write_file', 'apply_patch'])
|
|
458
|
-
const READ_ONLY = /^(ls|cat|head|tail|wc|grep|egrep|rg|find|fd|tree|pwd|file|stat|du|df|which|type|echo|date|printenv|diff|cmp|sort|uniq|cut|jq|nl|basename|dirname|realpath|readlink|uname|whoami|(node|npm|python3?|go|cargo) --version|npm (ls|list|view|outdated)|git (status|log|diff|show|ls-files|blame|grep|describe|shortlog|rev-parse|config --get)|git (branch|tag|remote)( -[avlr]+| --list| -v)*$)\b/
|
|
501
|
+
const READ_ONLY = /^(cd|ls|cat|head|tail|wc|grep|egrep|rg|find|fd|tree|pwd|file|stat|du|df|which|type|echo|date|printenv|diff|cmp|sort|uniq|cut|jq|nl|basename|dirname|realpath|readlink|uname|whoami|(node|npm|python3?|go|cargo) --version|npm (ls|list|view|outdated)|git (status|log|diff|show|ls-files|blame|grep|describe|shortlog|rev-parse|config --get)|git (branch|tag|remote)( -[avlr]+| --list| -v)*$)\b/
|
|
502
|
+
// A command that went into a folder outside the project (a small model copying the project's long
|
|
503
|
+
// path wrong, then lost in what it finds there): told where the project is.
|
|
504
|
+
function outsideNote(root, cmd) {
|
|
505
|
+
const dirs = [...cmd.matchAll(/(?:^|&&|;|\|\|)\s*cd\s+("[^"]+"|'[^']+'|\S+)/g)].map((m) => m[1].replace(/^["']|["']$/g, '').replace(/^~(?=\/|$)/, os.homedir()))
|
|
506
|
+
const out = dirs.map((d) => path.resolve(root, d)).find((d) => d !== root && !d.startsWith(root + path.sep))
|
|
507
|
+
return out ? `\n\n${prompt('agent/outside-project', { dir: out, root })}` : ''
|
|
508
|
+
}
|
|
509
|
+
|
|
459
510
|
export function readOnlyCommand(cmd) {
|
|
460
511
|
const c = String(cmd).trim()
|
|
461
512
|
if (/[>`]|\$\(|\b(sudo|rm|mv|cp|tee|chmod|chown|dd|truncate|mkdir|touch|ln)\b|-delete\b|-exec|-ok\b|sed\s+-i|\bsort\b.*\s-o/.test(c)) return false // writes, or runs something else
|
|
@@ -940,11 +991,22 @@ async function tool(name, args, s, signal, id) {
|
|
|
940
991
|
case 'read_file':
|
|
941
992
|
return IMAGE.test(String(args.path ?? '')) ? readImage(s, args.path) : readFileTool(root, args.path, args.start_line, args.end_line)
|
|
942
993
|
case 'list_files': {
|
|
994
|
+
if (args.path !== undefined && args.path !== '' && !args.filter) {
|
|
995
|
+
const v = folderView(root, String(args.path))
|
|
996
|
+
return v.error ? `error: ${v.error}` : v.entries.join('\n') + (v.more ? `\n… ${v.more} more` : '') || '(empty folder)'
|
|
997
|
+
}
|
|
943
998
|
const files = listFiles(root).filter((f) => !args.filter || f.includes(args.filter))
|
|
944
999
|
return files.slice(0, 500).join('\n') + (files.length > 500 ? `\n… ${files.length - 500} more` : '') || 'no files'
|
|
945
1000
|
}
|
|
946
1001
|
case 'search':
|
|
947
1002
|
return searchFiles(root, String(args.query ?? ''), args.path)
|
|
1003
|
+
case 'find_command': {
|
|
1004
|
+
const names = (Array.isArray(args.names) ? args.names : String(args.names ?? args.name ?? '').split(/[\s,]+/)).map(String).filter(Boolean)
|
|
1005
|
+
if (!names.length) return 'error: find_command needs "names": the commands to look for, like ["rg", "pnpm"]'
|
|
1006
|
+
return names.map((n) => `${n}: ${findCommand(root, n) ?? 'not installed'}`).join('\n')
|
|
1007
|
+
}
|
|
1008
|
+
case 'lsp':
|
|
1009
|
+
return await lspTool(s, args, signal)
|
|
948
1010
|
case 'edit_file':
|
|
949
1011
|
return await editFiles(s, args)
|
|
950
1012
|
case 'write_file':
|
|
@@ -963,7 +1025,7 @@ async function tool(name, args, s, signal, id) {
|
|
|
963
1025
|
}
|
|
964
1026
|
case 'run_command':
|
|
965
1027
|
if (/^\s*apply_patch\b[\s\S]*\*\*\* Begin Patch/.test(String(args.command ?? ''))) return await applyPatch(s, args.command) // apply_patch <<'EOF' … as Codex runs it
|
|
966
|
-
return await runConfirmed(s, String(args.command ?? ''), signal)
|
|
1028
|
+
return (await runConfirmed(s, String(args.command ?? ''), signal)) + outsideNote(root, String(args.command ?? ''))
|
|
967
1029
|
case 'apply_patch':
|
|
968
1030
|
return await applyPatch(s, args.input ?? args.patch ?? args.command ?? '')
|
|
969
1031
|
case 'mcp':
|