heartcode 0.0.0-stage → 0.1.1
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 +117 -2
- package/SKILL.md +179 -0
- package/bin/heart.js +78 -0
- package/bin/heartcode.js +1572 -0
- package/package.json +15 -4
- package/prompts/README.md +30 -0
- package/prompts/agent.md +348 -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 +697 -0
- package/src/client.js +629 -0
- package/src/config.js +109 -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 +218 -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 +376 -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 +104 -0
- package/src/reset.js +49 -0
- package/src/sessions.js +135 -0
- package/src/settings.html +1618 -0
- package/src/settings.js +554 -0
- package/src/skills.js +71 -0
- package/src/tabs.js +147 -0
- package/src/tools.js +865 -0
- package/src/tree.js +162 -0
- package/src/ui.js +1278 -0
- package/src/usage.js +149 -0
- package/src/voice.js +97 -0
- package/src/web.js +123 -0
- package/src/worktree.js +56 -0
package/src/tree.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// A session as a tree: going back to an earlier message never loses what came after it. That part
|
|
2
|
+
// is kept as a branch, and /tree goes to any message on any branch, with the code following
|
|
3
|
+
// (each branch keeps the files as it left them). Leaving a branch can bring a summary of it into
|
|
4
|
+
// the one you go to, so what was tried there isn't lost (as pi does).
|
|
5
|
+
//
|
|
6
|
+
// The chat's own fields are the branch you're on. Every other branch is in chat.branches, whole:
|
|
7
|
+
// { id, left (when you left it), state: { messages, history, turns, … }, tip: { file: text | null } }
|
|
8
|
+
// (tip: the files that branch changed, as they were when you left it). Branches share their first
|
|
9
|
+
// messages; the tree is worked out from the turns they have in common.
|
|
10
|
+
// ponytail: each branch is stored whole (shared messages repeated); fine for chats of a few MB.
|
|
11
|
+
import fs from 'node:fs'
|
|
12
|
+
import path from 'node:path'
|
|
13
|
+
|
|
14
|
+
const STATE = ['messages', 'history', 'turns', 'epoch', 'snapshots', 'sentNotes', 'todos', 'log']
|
|
15
|
+
export const turnKey = (t) => t.id ?? `${t.at}\0${t.task}`
|
|
16
|
+
|
|
17
|
+
const stateOf = (chat) => Object.fromEntries(STATE.map((k) => [k, chat[k]]))
|
|
18
|
+
const keysOf = (turns) => turns.map(turnKey)
|
|
19
|
+
const isPrefix = (a, b) => a.length <= b.length && a.every((k, i) => k === b[i])
|
|
20
|
+
const common = (a, b) => {
|
|
21
|
+
let i = 0
|
|
22
|
+
while (i < a.length && i < b.length && a[i] === b[i]) i++
|
|
23
|
+
return i
|
|
24
|
+
}
|
|
25
|
+
const read = (root, rel) => {
|
|
26
|
+
try {
|
|
27
|
+
return fs.readFileSync(path.resolve(root, rel), 'utf8')
|
|
28
|
+
} catch {
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const changed = (turns) => [...new Set(turns.flatMap((t) => Object.keys(t.files ?? {})))]
|
|
33
|
+
|
|
34
|
+
// The files a branch changed, as they are on disk now (the branch you're on owns the disk).
|
|
35
|
+
const tipNow = (chat) => Object.fromEntries(changed(chat.turns).map((rel) => [rel, read(chat.root, rel)]))
|
|
36
|
+
|
|
37
|
+
// A file as it was on a branch after its first `keep` messages: as it was before the next message
|
|
38
|
+
// that changed it, else as the branch left it. undefined: not known.
|
|
39
|
+
function fileAt(turns, tip, keep, rel) {
|
|
40
|
+
for (const t of turns.slice(keep)) if (rel in (t.files ?? {})) return t.files[rel]
|
|
41
|
+
return tip?.[rel]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The state after the first `keep` messages (as /rewind did it: back to where each stood).
|
|
45
|
+
function cut(state, keep) {
|
|
46
|
+
const t = state.turns[keep]
|
|
47
|
+
if (!t) return state
|
|
48
|
+
let { messages, history, snapshots, epoch } = state
|
|
49
|
+
if ((t.epoch ?? 0) < (epoch ?? 0)) {
|
|
50
|
+
;({ messages, history } = snapshots[t.epoch ?? 0]) // from before a compaction
|
|
51
|
+
snapshots = snapshots.slice(0, t.epoch ?? 0)
|
|
52
|
+
epoch = t.epoch ?? 0
|
|
53
|
+
}
|
|
54
|
+
return { ...state, messages: t.msgIndex ? (messages?.slice(0, t.msgIndex) ?? null) : null, history: history.slice(0, t.histIndex), turns: state.turns.slice(0, keep), log: (state.log ?? []).slice(0, t.logIndex), sentNotes: [], epoch, snapshots, todos: [] }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Every branch as { turns, branch (null: the one you're on) }.
|
|
58
|
+
const lines = (chat) => [{ turns: chat.turns, branch: null }, ...(chat.branches ?? []).map((b) => ({ turns: b.state.turns, branch: b }))]
|
|
59
|
+
|
|
60
|
+
// The tree, for a picker: one item per message, depth-first, the branch you're on first.
|
|
61
|
+
// Item: { label, desc, depth, task, branch, keep (messages before it on that branch), here, end }
|
|
62
|
+
export function treeItems(chat, ago = (x) => x) {
|
|
63
|
+
const root = { kids: [] }
|
|
64
|
+
for (const line of lines(chat)) {
|
|
65
|
+
let node = root
|
|
66
|
+
line.turns.forEach((t, k) => {
|
|
67
|
+
let next = node.kids.find((n) => n.key === turnKey(t))
|
|
68
|
+
if (!next) node.kids.push((next = { key: turnKey(t), turn: t, kids: [], on: [] }))
|
|
69
|
+
next.on.push({ branch: line.branch, k })
|
|
70
|
+
node = next
|
|
71
|
+
})
|
|
72
|
+
if (line.turns.length) (node.ends ??= []).push(line.branch)
|
|
73
|
+
}
|
|
74
|
+
const items = []
|
|
75
|
+
const walk = (node, depth, lead) => {
|
|
76
|
+
const kids = [...node.kids].sort((a, b) => Number(b.on.some((o) => !o.branch)) - Number(a.on.some((o) => !o.branch)))
|
|
77
|
+
kids.forEach((n, i) => {
|
|
78
|
+
const fork = kids.length > 1
|
|
79
|
+
const d = depth + (fork ? 1 : 0)
|
|
80
|
+
const mine = n.on.find((o) => !o.branch) ?? n.on[0] // on the branch you're on, if it is
|
|
81
|
+
const here = Boolean(n.ends?.includes(null))
|
|
82
|
+
const files = Object.keys(n.turn.files ?? {}).length
|
|
83
|
+
items.push({
|
|
84
|
+
label: `${lead}${fork ? (i === kids.length - 1 ? '└─ ' : '├─ ') : ''}${n.turn.task.split('\n')[0].slice(0, 70)}`,
|
|
85
|
+
desc: [ago(n.turn.at), files && `${files} file${files > 1 ? 's' : ''}`, here && '● you are here', !here && n.ends && '◆ end of a branch'].filter(Boolean).join(' · '),
|
|
86
|
+
depth: d,
|
|
87
|
+
task: n.turn.task,
|
|
88
|
+
branch: mine.branch,
|
|
89
|
+
keep: mine.k,
|
|
90
|
+
here,
|
|
91
|
+
end: Boolean(n.ends),
|
|
92
|
+
})
|
|
93
|
+
walk(n, d, lead + (fork ? (i === kids.length - 1 ? ' ' : '│ ') : ''))
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
walk(root, 0, '')
|
|
97
|
+
return items
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Keeps the branch you're on (unless it's already all in a kept branch), with its files as now.
|
|
101
|
+
export function stash(chat) {
|
|
102
|
+
if (!chat.turns.length) return
|
|
103
|
+
const keys = keysOf(chat.turns)
|
|
104
|
+
const kept = chat.branches ?? []
|
|
105
|
+
if (kept.some((b) => isPrefix(keys, keysOf(b.state.turns)))) return
|
|
106
|
+
chat.branches = kept.filter((b) => !isPrefix(keysOf(b.state.turns), keys)) // ones it grew from
|
|
107
|
+
chat.branches.push({ id: Math.random().toString(36).slice(2, 8), left: new Date().toISOString(), state: structuredClone(stateOf(chat)), tip: tipNow(chat) })
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The files going to (branch, keep) changes, with what each becomes: [[rel, text | null]], and
|
|
111
|
+
// the ones whose content isn't known (a branch saved without them).
|
|
112
|
+
export function codePlan(chat, branch, keep) {
|
|
113
|
+
const turns = branch ? branch.state.turns : chat.turns
|
|
114
|
+
const tip = branch ? branch.tip : tipNow(chat)
|
|
115
|
+
const c = common(keysOf(chat.turns), keysOf(turns.slice(0, keep)))
|
|
116
|
+
const plan = []
|
|
117
|
+
const unknown = []
|
|
118
|
+
for (const rel of new Set([...changed(chat.turns.slice(c)), ...changed(turns.slice(c, keep))])) {
|
|
119
|
+
let want = fileAt(turns, tip, keep, rel)
|
|
120
|
+
if (want === undefined) want = fileAt(chat.turns, {}, c, rel) // never changed on that branch: as it was where they split
|
|
121
|
+
if (want === undefined) unknown.push(rel)
|
|
122
|
+
else if (want !== read(chat.root, rel)) plan.push([rel, want])
|
|
123
|
+
}
|
|
124
|
+
return { plan, unknown }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// How many messages of the branch you're on the point after `keep` messages of `branch` lacks.
|
|
128
|
+
export const leaving = (chat, branch, keep) => chat.turns.length - common(keysOf(chat.turns), keysOf((branch ? branch.state.turns : chat.turns).slice(0, keep)))
|
|
129
|
+
|
|
130
|
+
// Goes to the point after the first `keep` messages of `branch` (null: the one you're on). The
|
|
131
|
+
// branch you leave is kept. code: the files follow. Returns { left: the messages only the branch
|
|
132
|
+
// you left had (for a summary) with its state, files: the files written }.
|
|
133
|
+
export function move(chat, branch, keep, { code = true } = {}) {
|
|
134
|
+
const target = branch ? branch.state : stateOf(chat)
|
|
135
|
+
const theirs = keysOf(target.turns.slice(0, keep))
|
|
136
|
+
const c = common(keysOf(chat.turns), theirs)
|
|
137
|
+
const left = { state: structuredClone(stateOf(chat)), from: c, turns: chat.turns.slice(c) }
|
|
138
|
+
const { plan } = code ? codePlan(chat, branch, keep) : { plan: [] }
|
|
139
|
+
stash(chat) // before anything is written: it keeps the files as they are now
|
|
140
|
+
if (branch && keep === target.turns.length) chat.branches = chat.branches.filter((b) => b !== branch) // you're on it now
|
|
141
|
+
Object.assign(chat, structuredClone(cut(target, keep)))
|
|
142
|
+
for (const [rel, text] of plan) {
|
|
143
|
+
const abs = path.resolve(chat.root, rel)
|
|
144
|
+
if (text === null) fs.rmSync(abs, { force: true })
|
|
145
|
+
else {
|
|
146
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true })
|
|
147
|
+
fs.writeFileSync(abs, text)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return { left, files: plan.map(([rel]) => rel) }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// The part of the branch you left that the new one doesn't have, as text to summarize.
|
|
154
|
+
export function leftText(left, transcript) {
|
|
155
|
+
const { state, from } = left
|
|
156
|
+
const t = state.turns[from]
|
|
157
|
+
if (!t) return ''
|
|
158
|
+
if (state.messages?.length && (t.epoch ?? 0) === (state.epoch ?? 0)) return transcript(state.messages.slice(t.msgIndex))
|
|
159
|
+
return state.history.slice(t.histIndex).map((h) => `User: ${h.task}\nAssistant: ${h.answer}`).join('\n\n')
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export const leftFiles = (left) => changed(left.turns)
|