Files
sausage/extensions/lineage/catchup.ts
T

446 lines
18 KiB
TypeScript

/** Catch-up combines VFS ref baselines with tally changes and attributed world git commits. */
import type { Context } from '@earendil-works/chord'
import { AgentDoc, defineDoc, ROOT_CONVERSATION_ID } from '@earendil-works/pi-durable'
import type { ConversationId, EntryDraft, EntryId, EntryRecord, Tx } from '@earendil-works/pi-durable'
import { onCommit, estimateTextTokens } from '../messages/compat.ts'
import type { OpenAbstract } from './server.ts'
type ExtensionHost = any
const resolve = (...parts: string[]) => {
const result: string[] = []
for (const path of parts) {
if (path.startsWith('/')) result.length = 0
for (const part of path.split('/')) {
if (part === '..') result.pop()
else if (part && part !== '.') result.push(part)
}
}
return '/' + result.join('/')
}
const isAbsolute = (path: string) => path.startsWith('/')
const relative = (base: string, path: string) => {
const a = base.split('/').filter(Boolean), b = path.split('/').filter(Boolean)
while (a.length && b.length && a[0] === b[0]) { a.shift(); b.shift() }
return [...a.map(() => '..'), ...b].join('/')
}
async function optionalRead(host: any, path: string) {
try { return await host.fs.read(path) }
catch (error) { if ((error as any).code === 'not_found') return undefined; throw error }
}
export const Seen = defineDoc<{main?: string; at?: number; tally?: number}>({kind: 'lineage.seen', version: 1, scope: 'conversation', history: 'latest', fork: 'initial', checkpointWhen: () => false, initial: () => ({})})
type Main = {cwd: string; target: string; sha: string; workspace?: string}
export async function mainOf(host: any, id: ConversationId, ctx: Context, tx?: Tx, repo?: string): Promise<Main | undefined> {
const service = host.want({name: 'workspaces'})
const workspace = service ? tx ? (await tx.doc(service.doc, ROOT_CONVERSATION_ID)).items[String(id)] : await service.get(id, ctx) : undefined
const agent = repo ? undefined : tx ? await tx.doc(AgentDoc, id) : await host.harness().snapshot(AgentDoc, id, ctx)
const cwd = workspace?.source ?? repo ?? agent?.cwd
if (!cwd) return undefined
let gitdir = `${cwd}/.git`
let info
try { info = await host.fs.stat(gitdir) }
catch (error) { if ((error as any).code === 'not_found') return undefined; throw error }
if (info.kind === 'file') gitdir = resolve(cwd, (await host.fs.read(gitdir)).trim().replace(/^gitdir: /, ''))
const head = (await optionalRead(host, `${gitdir}/HEAD`))?.trim()
const target = workspace?.target ?? (head?.startsWith('ref: refs/heads/') ? head.slice(16) : undefined)
if (!target) return undefined
const common = (await optionalRead(host, `${gitdir}/commondir`))?.trim()
if (common) gitdir = resolve(gitdir, common)
const ref = `refs/heads/${target}`
const loose = (await optionalRead(host, `${gitdir}/${ref}`))?.trim()
const packed = loose ? undefined : await optionalRead(host, `${gitdir}/packed-refs`)
const sha = loose ?? packed?.split('\n').find((line: string) => line.endsWith(` ${ref}`))?.split(' ')[0]
return sha ? {cwd, target, sha, ...(workspace?.state === 'ready' ? {workspace: workspace.path} : {})} : undefined
}
export async function seedSeen(tx: Tx, id: ConversationId, main: string, at: number) {
const seen = await tx.doc(Seen, id)
if (!seen.main) {
seen.main = main
seen.at = at
}
}
/** Seed existing conversations on open and new ones on creation; commits never overwrite a saved baseline. */
export function watchSeen(host: ExtensionHost, ctx: Context) {
let unsubscribe: (() => void) | undefined
let pending = Promise.resolve()
const seed = (id: ConversationId) => {
pending = pending.then(async () => {
if ((await host.harness().snapshot(Seen, id, ctx))?.main) return
const main = await mainOf(host, id, ctx)
if (!main) return
await host.harness().commit((tx) => seedSeen(tx, id, main.sha, Date.now()), ctx)
})
return pending
}
host.onOpen(async () => {
const harness = host.harness()
// Not a reactor: it seeds every new top-level conversation, a Pi record, not only lineage's branches and roots.
unsubscribe = onCommit(harness, (publication) => {
for (const change of publication.changes)
if (change.type === 'conversation' && !change.value.owner)
void seed(change.value.id).catch((error: unknown) => {
host.notice(`Could not record main for #${change.value.id}: ${String(error)}`)
})
})
const records = await harness.commit(async (tx) => {
const ids: ConversationId[] = []
let cursor
do {
const page = await tx.scanConversations({}, 100, cursor)
ids.push(...page.items.filter((record) => !record.owner).map((record) => record.id))
cursor = page.next
} while (cursor)
return ids
}, ctx)
for (const id of records) await seed(id)
})
host.onClose(async () => {
unsubscribe?.()
await pending
})
}
/** Files this conversation changed: its workspace diff, or its own edit/write calls in a shared checkout. */
async function changedFiles(host: ExtensionHost, id: ConversationId, main: Main, tx: Tx) {
if (main.workspace) {
const base = await host.git(main.workspace, 'merge-base', 'HEAD', main.sha)
return new Set(
(await host.git(main.workspace, 'diff', '--name-only', base)).split('\n').filter(Boolean),
)
}
const cwd = (await tx.doc(AgentDoc, id)).cwd ?? main.cwd
const files = new Set<string>()
let cursor
do {
const page = await tx.scanEntries({ conversationId: id }, 100, cursor)
for (const entry of page.items) {
if (entry.conversationId !== id) continue
for (const message of entry.model ?? []) {
if (message.role !== 'assistant') continue
for (const part of message.content) {
if (part.type !== 'toolCall' || !['edit', 'write'].includes(part.name)) continue
const path = part.arguments.path
if (typeof path !== 'string') continue
const absolute = isAbsolute(path) ? path : resolve(cwd, path)
files.add(relative(main.cwd, absolute))
}
}
}
cursor = page.next
} while (cursor)
return files
}
async function catchupText(
host: ExtensionHost,
id: ConversationId,
main: Main,
since: { main: string; at?: number },
tx: Tx,
) {
const range = `${since.main}..${main.sha}`
const [count, records, changed] = await Promise.all([
host.git(main.cwd, 'rev-list', '--count', range).then(Number),
host.git(
main.cwd,
'log',
'--name-only',
'--no-renames',
'--diff-merges=first-parent',
'--format=%x1e%H%x00%s%x00%(trailers:key=Sausage-Conversation,valueonly)%x00',
range,
),
changedFiles(host, id, main, tx),
])
if (!count) return undefined
const commits = records
.split('\x1e')
.map((record) => record.trim())
.filter(Boolean)
.map((record) => {
const [sha, subject, trailer, paths] = record.split('\0')
const overlap = paths
.trim()
.split('\n')
.filter((path) => changed.has(path))
const conversation = Number(trailer.trim())
return {
overlap,
row: `- ${overlap.length ? '**Overlap** ' : ''}\`${sha.slice(0, 7)}\` ${subject}${conversation ? ` · #${conversation}` : ''}${overlap.length ? ` — ${overlap.map((path) => `\`${path}\``).join(', ')}` : ''}`,
}
})
.toSorted((a, b) => Number(b.overlap.length > 0) - Number(a.overlap.length > 0))
.slice(0, 30)
const behind = main.workspace
? Number(await host.git(main.workspace, 'rev-list', '--count', `HEAD..${main.sha}`))
: 0
return [
`Since you last worked (${new Date(since.at ?? Date.now()).toISOString()}), ${count} commits landed on ${main.target}:`,
...commits.map((commit) => commit.row),
...(count > commits.length
? [`and ${count - commits.length} more (\`git log ${range}\`).`]
: []),
...(behind
? [`Your workspace is ${behind} commits behind ${main.target}; merge it before continuing.`]
: []),
].join('\n\n')
}
/** The first input `id` placed itself; its own entries all follow the entry it was forked at. */
async function firstInput(tx: Tx, id: ConversationId, forkedAt = 0) {
let cursor
do {
const page = await tx.scanEntries(
{ conversationId: id, minEntryId: (forkedAt + 1) as EntryId, order: 'ascending' },
100,
cursor,
)
const first = page.items.find((entry) => entry.conversationId === id && entry.input)
if (first) return first.id
cursor = page.next
} while (cursor)
return undefined
}
/** A tally a top-level conversation catches up on: its tree root, once that root is a tally. */
export type Tree = { root: ConversationId; since: number; abstracts: Record<string, OpenAbstract> }
/** Tokens the tally part of a catch-up may take. */
export const TALLY_BUDGET = 6000
/** What one conversation of the tree did since the baseline, as model-visible in the tally now. */
type Activity = {
id: number
last: number
title?: string
feature?: string
prompt?: string
abstract?: { at: number; text: string }
landings: {
sha: string
target: string
at: number
divergence: string
state?: { at: number; text: string }
}[]
notes: string[]
}
/** A block's tokens with the blank line that separates it from the next. */
const cost = (text: string) => estimateTextTokens(`${text}\n\n`)
const oneLine = (text: string) => text.replace(/\s*\n\s*/g, ' ').trim()
function full(activity: Activity) {
const { id, title, feature, prompt, abstract, landings, notes } = activity
if (!abstract && !landings.length && !notes.length) return `**#${id}** started: ${prompt}`
// Without a liquid abstract, the state its newest landing froze is its latest.
const frozen = abstract ? undefined : landings.findLast((landing) => landing.state)?.state
const state = abstract ?? frozen
const items = [
...(state
? state.text
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => (/^[-*] /.test(line) ? line : `- ${line}`))
: prompt
? [`- Started: ${prompt}`]
: []),
...landings.map(
(landing) =>
`- Landed \`${landing.sha.slice(0, 7)}\` on ${landing.target} (#${id}/${landing.at}). Diverged: ${oneLine(landing.divergence)}`,
),
...notes.map((note) => `- Note: ${oneLine(note)}`),
]
return [
`**#${id}${title ? ` ${title}` : ''}**${feature ? ` · ${feature}` : ''}${abstract ? ` — abstract as of #${id}/${abstract.at}:` : frozen ? ` — state at landing, as of #${id}/${frozen.at}:` : ':'}`,
...items,
].join('\n')
}
const collapsed = ({ id, title, feature, landings }: Activity) =>
`- #${id}${title ? ` ${title}` : ''}${feature ? ` · ${feature}` : ''} — ${landings.length ? `landed \`${landings.at(-1)!.sha.slice(0, 7)}\`` : 'in progress'}`
/**
* What the tree's tally learned after `baseline`, for conversation `id`: each other conversation's latest abstract (or
* its prompt while it has none), landings and notes, and a distillation of the tally itself. The newest stay in full;
* when they exceed the budget the oldest collapse to one line each. Deterministic: it runs inside input placement.
*/
export async function tallyText(
id: ConversationId,
tree: Tree,
baseline: number,
tx: Tx,
budget = TALLY_BUDGET,
) {
const { root } = tree
const marker = await tx.latestHeadMarker(root)
const entries: EntryRecord[] = []
let cursor
do {
const page = await tx.scanEntries(
{ conversationId: root, minEntryId: (baseline + 1) as EntryId, order: 'ascending' },
100,
cursor,
)
entries.push(...page.items)
cursor = page.next
} while (cursor)
const tip = entries.at(-1)?.id
// The prompts of the conversations it was forked from are in its context already, as their first inputs, when every
// fork on the way up was made at or after that input.
const sources = new Set<number>()
let cut = Number.POSITIVE_INFINITY
for (let record = await tx.conversation(id); record?.parent;) {
cut = Math.min(cut, record.parent.at)
const up = record.parent.conversationId
if (up === root) break
record = await tx.conversation(up)
const first = await firstInput(tx, up, record?.parent?.at)
if (first !== undefined && first <= cut) sources.add(up)
}
const activities = new Map<number, Activity>()
const of = (conversation: number, entry: EntryRecord) => {
let activity = activities.get(conversation)
if (!activity)
activities.set(
conversation,
(activity = { id: conversation, last: 0, landings: [], notes: [] }),
)
activity.last = entry.id
return activity
}
// Entries a compaction or genesis replaced are no longer in the tally; its distillation stands for them.
const visible = entries.filter(
(entry) => (!marker || entry.id >= marker.head) && entry.id !== marker?.id,
)
for (const entry of visible) {
const data = (entry.data ?? {}) as Record<string, unknown>
if (entry.kind === 'lineage.abstract') {
// An open abstract's anchor or a carrier updating it: the abstract as it reads now. A frozen one is the landing's.
const conversation = data.conversation as number
const open = tree.abstracts[String(conversation)] as OpenAbstract | undefined
if (conversation === id || open?.anchor !== ((data.anchor as number | undefined) ?? entry.id))
continue
const activity = of(conversation, entry)
activity.abstract = { at: open.at, text: open.text }
if (open.title) activity.title = open.title
if (open.feature) activity.feature = open.feature
} else if (!entry.model) continue
else if (entry.kind === 'lineage.branched') {
const conversation = data.branch as number
if (conversation === id || sources.has(conversation)) continue
of(conversation, entry).prompt = oneLine((data.intent as string).split('\n')[0])
} else if (entry.kind === 'lineage.note' && typeof data.from === 'number' && data.from !== id) {
const activity = of(data.from, entry)
if (data.source === 'lander') {
const state = data.abstract as Omit<OpenAbstract, 'anchor'> | undefined
activity.landings.push({
sha: data.sha as string,
target: data.target as string,
at: data.at as number,
divergence: (data.divergence as string | undefined) ?? 'not stated',
...(state ? { state: { at: state.at, text: state.text } } : {}),
})
activity.title ??= state?.title ?? (data.title as string | undefined)
activity.feature ??= state?.feature ?? (data.feature as string | undefined)
} else activity.notes.push(data.text as string)
}
}
for (const activity of activities.values()) activity.title ??= activity.prompt
const newest = [...activities.values()].toSorted((a, b) => b.last - a.last)
const blocks = newest.map((activity) => ({ full: full(activity), line: collapsed(activity) }))
const distilled =
marker && marker.id > baseline && marker.model
? marker.model.flatMap((message) =>
message.role !== 'user'
? []
: typeof message.content === 'string'
? [message.content]
: message.content.flatMap((part) => (part.type === 'text' ? [part.text] : [])),
)
: undefined
if (distilled)
blocks.push({
full: `**#${root}'s tally was distilled** (#${root}/${marker!.id}):\n\n${distilled
.join('\n')
.replace(/^<\/?[a-z][^>\n]*>$/gm, '')
.trim()}`,
line: `- #${root}'s tally was distilled at #${root}/${marker!.id}`,
})
if (!blocks.length) return { tip }
const header = `Meanwhile in #${root}'s tree:`
const pointer = `Earlier, collapsed (root #${root} retains the full tally):`
let used = cost(header) + cost(`- and ${blocks.length} more`)
const kept: string[] = []
const lines: string[] = []
let dropped = 0
for (const block of blocks) {
if (!lines.length && !dropped && used + cost(block.full) <= budget) {
kept.unshift(block.full)
used += cost(block.full)
continue
}
if (!lines.length && !dropped) used += cost(pointer)
if (!dropped && used + cost(block.line) <= budget) {
lines.unshift(block.line)
used += cost(block.line)
} else dropped++
}
return {
tip,
text: [
header,
...(lines.length || dropped
? [[pointer, ...(dropped ? [`- and ${dropped} more`] : []), ...lines].join('\n')]
: []),
...kept,
].join('\n\n'),
}
}
// Pi upstream: @earendil-works/pi-durable@1.5.0 — patched transactional beforeInput hook. Drop when Pi ships input-placement hooks.
/** The note, baseline and first input commit together, before any request can see them. */
export async function catchup(
host: ExtensionHost,
_input: EntryDraft,
api: { conversationId: ConversationId; tx: Tx },
ctx: Context,
treeOf: (tx: Tx, id: ConversationId) => Promise<Tree | undefined>,
) {
const { tx, conversationId } = api
const record = await tx.conversation(conversationId)
if (!record || record.owner) return undefined
const tree = await treeOf(tx, conversationId)
const main = await mainOf(host, conversationId, ctx, tx)
const seen = await tx.doc(Seen, conversationId)
// Conversations from before the tally was tracked catch up from its start.
const meanwhile = tree
? await tallyText(conversationId, tree, seen.tally ?? tree.since, tx)
: undefined
const commits =
main && seen.main && seen.main !== main.sha
? await catchupText(host, conversationId, main, { main: seen.main, at: seen.at }, tx)
: undefined
if (meanwhile?.tip !== undefined) seen.tally = meanwhile.tip
if (main) {
seen.main = main.sha
if (!seen.at || commits) seen.at = Date.now()
}
if (!meanwhile?.text && !commits) return undefined
const text = [
...(meanwhile?.text ? [meanwhile.text] : []),
...(commits ? [commits] : []),
'Check whether any of this already covers or changes what you are about to do.',
].join('\n\n')
return {
entries: [
{
kind: 'lineage.note',
// Where this catch-up left the tally: a fork of this conversation inherits that position.
data: { source: 'main', text, ...(seen.tally === undefined ? {} : { tally: seen.tally }) },
model: [{ role: 'user' as const, content: text, timestamp: Date.now() }],
},
],
}
}