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/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)