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/LICENSE +21 -0
- package/README.md +129 -2
- package/SKILL.md +170 -0
- package/bin/heart.js +77 -0
- package/bin/heartcode.js +1573 -0
- package/package.json +15 -4
- package/prompts/README.md +30 -0
- package/prompts/agent.md +320 -0
- package/prompts/compact.md +95 -0
- package/prompts/guided.md +193 -0
- package/prompts/probe.md +67 -0
- package/src/acp.js +248 -0
- package/src/agent.js +642 -0
- package/src/client.js +545 -0
- package/src/config.js +105 -0
- package/src/context.js +91 -0
- package/src/director.js +143 -0
- package/src/edit.js +127 -0
- package/src/format.js +74 -0
- package/src/guided.js +609 -0
- package/src/headless.js +217 -0
- package/src/hooks.js +80 -0
- package/src/importer.js +202 -0
- package/src/log.js +139 -0
- package/src/lsp.js +207 -0
- package/src/mcp.js +330 -0
- package/src/memory.js +222 -0
- package/src/oauth.js +122 -0
- package/src/patch.js +96 -0
- package/src/probe.js +343 -0
- package/src/prompts.js +103 -0
- package/src/reset.js +49 -0
- package/src/sessions.js +135 -0
- package/src/settings.html +1540 -0
- package/src/settings.js +526 -0
- package/src/skills.js +71 -0
- package/src/tabs.js +147 -0
- package/src/tools.js +774 -0
- package/src/tree.js +162 -0
- package/src/ui.js +1278 -0
- package/src/usage.js +134 -0
- package/src/voice.js +97 -0
- package/src/web.js +123 -0
- package/src/worktree.js +56 -0
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, '<')}`}</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
|
+
}
|