heartcode 0.0.0-stage → 0.1.0

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/memory.js ADDED
@@ -0,0 +1,222 @@
1
+ // Memory: markdown notes, one fact per note, linked Obsidian-style with [[name]], in two places
2
+ // (each folder opens as an Obsidian vault):
3
+ // <project>/.heartcode/memory/ about this project (scope "project", the default)
4
+ // ~/.heart/memory/ the user's preferences that hold in every project (scope "global")
5
+ // Each note:
6
+ // ---
7
+ // name: tabs-not-spaces
8
+ // description: The user wants tabs for indentation in this project
9
+ // type: user | project | feedback | reference
10
+ // tags: style, formatting
11
+ // ---
12
+ // The text. Related: [[code-style]]
13
+ // MEMORY.md, the index for people (and Obsidian), is rebuilt by code on every change. Models get
14
+ // less: the tag list in the system prompt, and with each message only the notes it matches.
15
+ import fs from 'node:fs'
16
+ import path from 'node:path'
17
+ import { HOME } from './config.js'
18
+
19
+ export const TYPES = ['user', 'project', 'feedback', 'reference']
20
+ const LINK = /\[\[([^\]|#]+)(?:[#|][^\]]*)?\]\]/g
21
+ export const slug = (s) =>
22
+ String(s)
23
+ .toLowerCase()
24
+ .replace(/\.md$/, '')
25
+ .replace(/[^a-z0-9]+/g, '-')
26
+ .replace(/^-+|-+$/g, '')
27
+ .slice(0, 60) || 'note'
28
+
29
+ // One folder of notes.
30
+ class Notes {
31
+ constructor(dir, scope) {
32
+ Object.assign(this, { dir, scope })
33
+ }
34
+
35
+ #file(name) {
36
+ return path.join(this.dir, `${slug(name)}.md`)
37
+ }
38
+
39
+ // All notes: { name, description, type, body, links, file }
40
+ list() {
41
+ let files = []
42
+ try {
43
+ files = fs.readdirSync(this.dir).filter((f) => f.endsWith('.md') && f !== 'MEMORY.md')
44
+ } catch {}
45
+ return files
46
+ .map((f) => {
47
+ const text = fs.readFileSync(path.join(this.dir, f), 'utf8')
48
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text)
49
+ const meta = Object.fromEntries((m?.[1] ?? '').split('\n').map((l) => /^(\w+):\s*(.*)$/.exec(l.trim())).filter(Boolean).map(([, k, v]) => [k, v.trim()]))
50
+ const body = (m ? m[2] : text).trim()
51
+ return {
52
+ scope: this.scope,
53
+ name: f.slice(0, -3),
54
+ description: meta.description || body.split('\n')[0].slice(0, 120),
55
+ type: TYPES.includes(meta.type) ? meta.type : 'project',
56
+ tags: tagList(meta.tags).filter((x) => !TYPES.includes(x)),
57
+ body,
58
+ links: [...new Set([...body.matchAll(LINK)].map((x) => slug(x[1])))],
59
+ file: path.join(this.dir, f),
60
+ }
61
+ })
62
+ .sort((a, b) => a.name.localeCompare(b.name))
63
+ }
64
+
65
+ read(name) {
66
+ const note = this.list().find((n) => n.name === slug(name))
67
+ if (!note) return null
68
+ const backlinks = this.list().filter((n) => n.links.includes(note.name)).map((n) => `[[${n.name}]]`)
69
+ return { ...note, backlinks }
70
+ }
71
+
72
+ // Creates or replaces a note; returns its name.
73
+ write({ name, description, type, tags, content }) {
74
+ const n = slug(name || description)
75
+ const old = this.read(n)
76
+ const t = TYPES.includes(type) ? type : (old?.type ?? 'project')
77
+ const d = String(description || old?.description || String(content).split('\n')[0]).replace(/\s+/g, ' ').trim().slice(0, 200)
78
+ const g = (tagList(tags).length ? tagList(tags) : (old?.tags ?? [])).filter((x) => !TYPES.includes(x)) // the type is not a tag
79
+ fs.mkdirSync(this.dir, { recursive: true })
80
+ fs.writeFileSync(this.#file(n), `---\nname: ${n}\ndescription: ${d}\ntype: ${t}\ntags: ${g.join(', ')}\n---\n\n${String(content ?? old?.body ?? '').trim()}\n`)
81
+ this.changed()
82
+ return n
83
+ }
84
+
85
+ delete(name) {
86
+ try {
87
+ fs.rmSync(this.#file(name))
88
+ } catch {
89
+ return false
90
+ }
91
+ this.changed()
92
+ return true
93
+ }
94
+
95
+ // Notes with every word of the query in their name, description, tags or text; "tag:deploy" by tag.
96
+ search(query) {
97
+ const words = String(query).toLowerCase().split(/\s+/).filter(Boolean)
98
+ return this.list().filter((n) =>
99
+ words.every((w) => (w.startsWith('tag:') ? n.tags.includes(w.slice(4)) : `${n.name} ${n.description} ${n.tags.join(' ')} ${n.body}`.toLowerCase().includes(w))),
100
+ )
101
+ }
102
+
103
+ // "tag (count)" for every tag: all the system prompt carries about the memory.
104
+ tagSummary() {
105
+ const count = new Map()
106
+ for (const n of this.list()) for (const t of n.tags) count.set(t, (count.get(t) ?? 0) + 1)
107
+ return [...count].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([t, k]) => `${t} (${k})`).join(', ')
108
+ }
109
+
110
+ relevant(text, max = 6) {
111
+ return relevantOf(this.list(), text, max)
112
+ }
113
+
114
+ // [[links]] to notes that don't exist: found by code, fixed when tidying.
115
+ brokenLinks() {
116
+ const names = new Set(this.list().map((n) => n.name))
117
+ return this.list().flatMap((n) => n.links.filter((l) => !names.has(l)).map((l) => ({ from: n.name, to: l })))
118
+ }
119
+
120
+ // The index for the system prompt: one line per note, grouped by type. '' without notes.
121
+ index(max = 120) {
122
+ const notes = this.list()
123
+ const lines = []
124
+ for (const t of TYPES) {
125
+ const of = notes.filter((n) => n.type === t)
126
+ if (of.length) lines.push(`${t}:`, ...of.map((n) => `- [[${n.name}]] — ${n.description}${n.tags.length ? ` #${n.tags.join(' #')}` : ''}`))
127
+ }
128
+ return lines.length > max ? [...lines.slice(0, max), `… ${lines.length - max} more lines (search the memory)`].join('\n') : lines.join('\n')
129
+ }
130
+
131
+ // Keeps MEMORY.md in step with the notes.
132
+ changed() {
133
+ fs.mkdirSync(this.dir, { recursive: true })
134
+ const what = this.scope === 'global' ? 'The notes HeartCode keeps about you, for every project' : "This project's notes, kept by HeartCode"
135
+ fs.writeFileSync(path.join(this.dir, 'MEMORY.md'), `# Memory\n\n${what}. Each line links to a note.\n\n${this.index(Infinity) || '(no notes yet)'}\n`)
136
+ }
137
+ }
138
+
139
+ // Both memories as one: reading, searching and matching look in both (the project's note wins on
140
+ // the same name); writing goes to the scope asked for, or where the note already is.
141
+ export class Memory {
142
+ constructor(root, globalDir = path.join(HOME, 'memory')) {
143
+ this.project = new Notes(path.join(root, '.heartcode', 'memory'), 'project')
144
+ this.global = new Notes(globalDir, 'global')
145
+ }
146
+
147
+ #in(scope) {
148
+ return scope === 'global' ? this.global : this.project
149
+ }
150
+
151
+ list() {
152
+ return [...this.project.list(), ...this.global.list()]
153
+ }
154
+
155
+ read(name, scope) {
156
+ const n = slug(name)
157
+ const note = this.list().find((x) => x.name === n && (!scope || x.scope === scope))
158
+ if (!note) return null
159
+ return { ...note, backlinks: this.list().filter((x) => x.links.includes(n) && x !== note).map((x) => `[[${x.name}]]`) }
160
+ }
161
+
162
+ write(note) {
163
+ const where = note.scope === 'global' || note.scope === 'project' ? note.scope : (this.read(note.name)?.scope ?? 'project')
164
+ return this.#in(where).write(note)
165
+ }
166
+
167
+ delete(name, scope) {
168
+ const where = scope ?? this.read(name)?.scope
169
+ return where ? this.#in(where).delete(name) : false
170
+ }
171
+
172
+ search(query) {
173
+ return [...this.project.search(query), ...this.global.search(query)]
174
+ }
175
+
176
+ brokenLinks() {
177
+ const names = new Set(this.list().map((n) => n.name))
178
+ return this.list().flatMap((n) => n.links.filter((l) => !names.has(l)).map((l) => ({ from: n.name, to: l })))
179
+ }
180
+
181
+ tagSummary() {
182
+ const count = new Map()
183
+ for (const n of this.list()) for (const t of n.tags) count.set(t, (count.get(t) ?? 0) + 1)
184
+ return [...count].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([t, k]) => `${t} (${k})`).join(', ')
185
+ }
186
+
187
+ relevant(text, max = 6) {
188
+ return relevantOf(this.list(), text, max)
189
+ }
190
+
191
+ index(max) {
192
+ return [this.project.index(max), this.global.index(max) && `global:\n${this.global.index(max)}`].filter(Boolean).join('\n')
193
+ }
194
+ }
195
+
196
+ // The notes worth attaching to a message: the user's ways of working (user/feedback notes, a
197
+ // few), plus notes whose tags, name or description share words with it. Most relevant first.
198
+ function relevantOf(notes, text, max) {
199
+ const words = new Set(keywords(text))
200
+ const scored = notes.map((n) => {
201
+ let score = 0
202
+ for (const t of n.tags) if (keywords(t).some((w) => words.has(w))) score += 3
203
+ for (const w of keywords(n.name)) if (words.has(w)) score += 2
204
+ for (const w of keywords(n.description)) if (words.has(w)) score += 1
205
+ return { n, score }
206
+ })
207
+ const rules = scored.filter((x) => x.n.type === 'user' || x.n.type === 'feedback').sort((a, b) => b.score - a.score).slice(0, 4)
208
+ const matched = scored.filter((x) => x.score >= 2 && !rules.includes(x)).sort((a, b) => b.score - a.score)
209
+ return [...rules, ...matched].slice(0, max).map((x) => x.n)
210
+ }
211
+
212
+ const tagList = (v) => (Array.isArray(v) ? v : String(v ?? '').split(/[,\s#]+/)).map((t) => slug(t)).filter((t) => t && t !== 'note').slice(0, 5)
213
+
214
+ // Words worth matching on: lowercased, without short or very common words, plural/-ing cut off.
215
+ const STOP = new Set('the and for with this that from have what when where which into your you our are was were can will should could would about there their them then than just also only some more most very please make like want need does doing done how why who its it\'s use using'.split(' '))
216
+ function keywords(text) {
217
+ return String(text)
218
+ .toLowerCase()
219
+ .split(/[^a-z0-9]+/)
220
+ .filter((w) => w.length > 2 && !STOP.has(w))
221
+ .map((w) => (w.length > 5 && w.endsWith('ing') ? w.slice(0, -3) : w.length > 4 && w.endsWith('ed') ? w.slice(0, -2) : w.length > 3 && /[^s]s$/.test(w) ? w.slice(0, -1) : w))
222
+ }
package/src/oauth.js ADDED
@@ -0,0 +1,122 @@
1
+ // OAuth sign-in for remote MCP servers, as the MCP authorization spec has it: the server answers
2
+ // 401 → its metadata names the authorization server → HeartCode registers itself there (dynamic
3
+ // client registration, or the clientId from the config) → the browser opens the sign-in page →
4
+ // it comes back to a one-off local page with a code (PKCE) → tokens, kept in ~/.heart/mcp-auth.json
5
+ // (only readable by you) and refreshed when they expire.
6
+ import http from 'node:http'
7
+ import path from 'node:path'
8
+ import { createHash, randomBytes } from 'node:crypto'
9
+ import { HOME, readJson, writeJson } from './config.js'
10
+ import { log } from './log.js'
11
+
12
+ const FILE = path.join(HOME, 'mcp-auth.json')
13
+ const b64url = (buf) => Buffer.from(buf).toString('base64url')
14
+ const store = () => readJson(FILE, {})
15
+ const keep = (url, entry) => writeJson(FILE, { ...store(), [url]: entry })
16
+ export const forget = (url) => {
17
+ const all = store()
18
+ delete all[url]
19
+ writeJson(FILE, all)
20
+ }
21
+
22
+ async function getJson(url, init = {}) {
23
+ const res = await fetch(url, { ...init, headers: { accept: 'application/json', ...init.headers }, signal: init.signal ?? AbortSignal.timeout(20_000) })
24
+ const text = await res.text()
25
+ if (!res.ok) throw Object.assign(new Error(`${res.status} from ${url}: ${text.slice(0, 200)}`), { status: res.status })
26
+ return JSON.parse(text)
27
+ }
28
+
29
+ // The authorization server's endpoints for an MCP server (wwwAuth: its 401's WWW-Authenticate).
30
+ export async function discover(serverUrl, wwwAuth = '') {
31
+ const u = new URL(serverUrl)
32
+ const named = /resource_metadata="([^"]+)"/.exec(wwwAuth)?.[1]
33
+ const tail = u.pathname.replace(/\/$/, '')
34
+ let resource = {}
35
+ for (const url of named ? [named] : [`${u.origin}/.well-known/oauth-protected-resource${tail}`, `${u.origin}/.well-known/oauth-protected-resource`]) {
36
+ try {
37
+ resource = await getJson(url)
38
+ if (resource.authorization_servers?.length) break
39
+ } catch {}
40
+ }
41
+ const issuer = new URL(resource.authorization_servers?.[0] ?? u.origin)
42
+ const p = issuer.pathname.replace(/\/$/, '')
43
+ for (const url of [`${issuer.origin}/.well-known/oauth-authorization-server${p}`, `${issuer.origin}/.well-known/openid-configuration${p}`, ...(p ? [`${issuer.origin}${p}/.well-known/openid-configuration`] : [])]) {
44
+ try {
45
+ const meta = await getJson(url)
46
+ if (meta.authorization_endpoint && meta.token_endpoint) return { ...meta, scopes: resource.scopes_supported }
47
+ } catch {}
48
+ }
49
+ // servers from before the metadata documents: the default endpoints
50
+ return { authorization_endpoint: `${issuer.origin}/authorize`, token_endpoint: `${issuer.origin}/token`, registration_endpoint: `${issuer.origin}/register`, scopes: resource.scopes_supported }
51
+ }
52
+
53
+ async function tokenRequest(endpoint, params) {
54
+ const t = await getJson(endpoint, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(Object.entries(params).filter(([, v]) => v)) })
55
+ if (!t.access_token) throw new Error(`no access token from ${endpoint}`)
56
+ return t
57
+ }
58
+
59
+ const entryFrom = (t, base) => ({ ...base, accessToken: t.access_token, refreshToken: t.refresh_token ?? base.refreshToken, expiresAt: t.expires_in ? Date.now() + t.expires_in * 1000 : null })
60
+
61
+ // The saved token for this server, refreshed first when it has expired; null when there is none
62
+ // (or it can't be refreshed): then it's time to sign in.
63
+ export async function savedToken(url, { force = false } = {}) {
64
+ const e = store()[url]
65
+ if (!e?.accessToken) return null
66
+ if (!force && (!e.expiresAt || e.expiresAt > Date.now() + 60_000)) return e.accessToken
67
+ if (!e.refreshToken) return null
68
+ try {
69
+ const entry = entryFrom(await tokenRequest(e.tokenEndpoint, { grant_type: 'refresh_token', refresh_token: e.refreshToken, client_id: e.clientId, client_secret: e.clientSecret, resource: url }), e)
70
+ keep(url, entry)
71
+ log('oauth', `refreshed the token for ${url}`)
72
+ return entry.accessToken
73
+ } catch (e2) {
74
+ log('oauth', `refresh failed for ${url}: ${e2.message}`)
75
+ return null
76
+ }
77
+ }
78
+
79
+ // Signs in: open(url) shows the user the sign-in page (the browser); resolves to the access token.
80
+ // opts: { clientId, clientSecret, callbackPort, scope } from the server's "oauth" config.
81
+ export async function signIn(url, wwwAuth, open, { signal, oauth = {} } = {}) {
82
+ const meta = await discover(url, wwwAuth)
83
+ const server = http.createServer()
84
+ await new Promise((resolve, reject) => server.once('error', reject).listen(Number(oauth.callbackPort) || 0, '127.0.0.1', resolve))
85
+ try {
86
+ const redirect = `http://127.0.0.1:${server.address().port}/callback`
87
+ let client = { client_id: oauth.clientId, client_secret: oauth.clientSecret }
88
+ if (!client.client_id) {
89
+ if (!meta.registration_endpoint) throw new Error(`${url} has no client registration: give "oauth": { "clientId": … } in its config`)
90
+ client = await getJson(meta.registration_endpoint, {
91
+ method: 'POST',
92
+ headers: { 'content-type': 'application/json' },
93
+ body: JSON.stringify({ client_name: 'HeartCode', redirect_uris: [redirect], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'none' }),
94
+ })
95
+ }
96
+ const verifier = b64url(randomBytes(32))
97
+ const state = b64url(randomBytes(16))
98
+ const auth = new URL(meta.authorization_endpoint)
99
+ const scope = oauth.scope ?? meta.scopes?.join(' ')
100
+ for (const [k, v] of Object.entries({ response_type: 'code', client_id: client.client_id, redirect_uri: redirect, code_challenge: b64url(createHash('sha256').update(verifier).digest()), code_challenge_method: 'S256', state, resource: url, scope })) if (v) auth.searchParams.set(k, v)
101
+ const code = await new Promise((resolve, reject) => {
102
+ const timer = setTimeout(() => reject(new Error('no sign-in within 5 minutes')), 300_000)
103
+ signal?.addEventListener('abort', () => (clearTimeout(timer), reject(Object.assign(new Error('stopped'), { name: 'AbortError' }))), { once: true })
104
+ server.on('request', (req, res) => {
105
+ const q = new URL(req.url, redirect)
106
+ if (q.pathname !== '/callback') return res.writeHead(404).end()
107
+ const ok = q.searchParams.get('state') === state && q.searchParams.get('code')
108
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(`<!doctype html><meta charset="utf-8"><title>HeartCode</title><body style="font:16px system-ui;padding:40px">${ok ? '♥ Signed in. You can close this tab and go back to HeartCode.' : `Sign-in failed: ${String(q.searchParams.get('error_description') ?? q.searchParams.get('error') ?? 'wrong state').replace(/</g, '&lt;')}`}</body>`)
109
+ clearTimeout(timer)
110
+ ok ? resolve(ok) : reject(new Error(`sign-in failed: ${q.searchParams.get('error_description') ?? q.searchParams.get('error') ?? 'the reply did not match'}`))
111
+ })
112
+ open(auth.href)
113
+ })
114
+ const base = { clientId: client.client_id, clientSecret: client.client_secret, tokenEndpoint: meta.token_endpoint }
115
+ const entry = entryFrom(await tokenRequest(meta.token_endpoint, { grant_type: 'authorization_code', code, redirect_uri: redirect, client_id: client.client_id, client_secret: client.client_secret, code_verifier: verifier, resource: url }), base)
116
+ keep(url, entry)
117
+ log('oauth', `signed in to ${url}`)
118
+ return entry.accessToken
119
+ } finally {
120
+ server.close()
121
+ }
122
+ }
package/src/patch.js ADDED
@@ -0,0 +1,96 @@
1
+ // apply_patch: the patch format OpenAI's models (GPT-4.1+, gpt-oss, Codex) are trained to write.
2
+ // Several files in one go: added, deleted, changed, moved.
3
+ // *** Begin Patch
4
+ // *** Add File: hello.txt
5
+ // +Hello
6
+ // *** Update File: src/app.js
7
+ // *** Move to: src/main.js (optional)
8
+ // @@ function main() { (optional: a line just above, to find the right place)
9
+ // context line
10
+ // -removed line
11
+ // +added line
12
+ // *** Delete File: old.js
13
+ // *** End Patch
14
+ // Each changed part is matched like edit_file's search text (applyEdit: exact, else ignoring
15
+ // indentation), so a slightly off context still applies.
16
+ import { applyEdit } from './edit.js'
17
+
18
+ // → [{ type: 'add' | 'delete' | 'update', path, moveTo?, content?, chunks?: [{ header, old, new }] }]
19
+ export function parsePatch(text) {
20
+ const lines = String(text ?? '').replace(/\r\n/g, '\n').split('\n')
21
+ let i = lines.findIndex((l) => l.trim() === '*** Begin Patch')
22
+ if (i === -1) throw new Error('the patch must start with "*** Begin Patch"')
23
+ const ops = []
24
+ let op = null
25
+ let chunk = null
26
+ const newChunk = (header = '') => op.chunks.push((chunk = { header, old: [], new: [] }))
27
+ for (i++; i < lines.length; i++) {
28
+ const l = lines[i]
29
+ let m
30
+ if (l.trim() === '*** End Patch') return ops
31
+ if ((m = /^\*\*\* (Add|Delete|Update) File: (.+)$/.exec(l))) {
32
+ op = { type: m[1].toLowerCase(), path: m[2].trim(), ...(m[1] === 'Add' && { content: [] }), ...(m[1] === 'Update' && { chunks: [] }) }
33
+ chunk = null
34
+ ops.push(op)
35
+ continue
36
+ }
37
+ if (!op) {
38
+ if (l.trim()) throw new Error(`line ${i + 1}: expected "*** Add File:", "*** Update File:" or "*** Delete File:", got "${l}"`)
39
+ continue
40
+ }
41
+ if (op.type === 'add') {
42
+ if (l.startsWith('+')) op.content.push(l.slice(1))
43
+ else if (l.trim()) throw new Error(`line ${i + 1}: in an added file every line starts with "+"`)
44
+ continue
45
+ }
46
+ if (op.type === 'delete') continue
47
+ if ((m = /^\*\*\* Move to: (.+)$/.exec(l))) {
48
+ op.moveTo = m[1].trim()
49
+ continue
50
+ }
51
+ if (l.startsWith('@@')) {
52
+ newChunk(l.replace(/^@@\s?/, '').replace(/\s*@@$/, '').trim())
53
+ continue
54
+ }
55
+ if (l.trim() === '*** End of File') continue
56
+ if (!chunk) newChunk()
57
+ const mark = l[0]
58
+ if (mark === '+') chunk.new.push(l.slice(1))
59
+ else if (mark === '-') chunk.old.push(l.slice(1))
60
+ else {
61
+ const ctx = mark === ' ' ? l.slice(1) : l // models drop the space on blank context lines
62
+ chunk.old.push(ctx)
63
+ chunk.new.push(ctx)
64
+ }
65
+ }
66
+ throw new Error('the patch must end with "*** End Patch"')
67
+ }
68
+
69
+ // One file's chunks applied to its text → { text } or { error }.
70
+ export function applyChunks(text, chunks) {
71
+ let out = text
72
+ for (const [n, ch] of chunks.entries()) {
73
+ const label = chunks.length > 1 ? `part ${n + 1}: ` : ''
74
+ const old = ch.old.join('\n')
75
+ const next = ch.new.join('\n')
76
+ if (!old.trim()) {
77
+ // only added lines: after the @@ line, else at the end
78
+ const at = ch.header ? out.indexOf(ch.header) : -1
79
+ if (ch.header && at === -1) return { error: `${label}the @@ line "${ch.header}" was not found` }
80
+ const end = at === -1 ? out.length : out.indexOf('\n', at) === -1 ? out.length : out.indexOf('\n', at) + 1
81
+ const before = out.slice(0, end)
82
+ out = before + (before && !before.endsWith('\n') ? '\n' : '') + next + '\n' + out.slice(end)
83
+ continue
84
+ }
85
+ let r = applyEdit(out, old, next)
86
+ if (r.error && ch.header && out.includes(ch.header)) {
87
+ // several places match: the one after the @@ line
88
+ const at = out.indexOf(ch.header)
89
+ const tail = applyEdit(out.slice(at), old, next)
90
+ if (!tail.error) r = { text: out.slice(0, at) + tail.text }
91
+ }
92
+ if (r.error) return { error: label + r.error.replace('SEARCH text', 'context and - lines') }
93
+ out = r.text
94
+ }
95
+ return { text: out }
96
+ }