@chroxify/eunoe 0.1.0 → 1.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.
Files changed (80) hide show
  1. package/README.md +364 -239
  2. package/package.json +20 -3
  3. package/src/cli/{types.ts → command.ts} +1 -4
  4. package/src/cli/commands/index.ts +27 -3
  5. package/src/cli/commands/load.ts +44 -0
  6. package/src/cli/commands/mode.ts +68 -24
  7. package/src/cli/commands/peek.ts +22 -0
  8. package/src/cli/commands/replay.ts +194 -0
  9. package/src/cli/commands/serve.ts +24 -8
  10. package/src/cli/commands/sessions.ts +70 -0
  11. package/src/cli/commands/settings.ts +119 -0
  12. package/src/cli/commands/start.ts +73 -0
  13. package/src/cli/commands/status.ts +107 -30
  14. package/src/cli/commands/stop.ts +36 -12
  15. package/src/cli/commands/uninstall.ts +14 -14
  16. package/src/cli/commands/watch.ts +16 -0
  17. package/src/cli/logo/art.ts +99 -0
  18. package/src/cli/logo/index.ts +12 -18
  19. package/src/cli/main.ts +3 -3
  20. package/src/cli/proxy/claude-settings.ts +59 -0
  21. package/src/cli/proxy/inspect.ts +67 -0
  22. package/src/cli/proxy/launchd.ts +53 -0
  23. package/src/cli/proxy/service.ts +53 -0
  24. package/src/cli/proxy/stats.ts +81 -0
  25. package/src/cli/proxy/watch.ts +60 -0
  26. package/src/cli/replay/index.ts +141 -0
  27. package/src/cli/replay/worker.ts +15 -0
  28. package/src/cli/sessions/browse.ts +88 -0
  29. package/src/cli/sessions/index.ts +76 -0
  30. package/src/cli/sessions/source.ts +74 -0
  31. package/src/cli/{args.ts → terminal/args.ts} +1 -1
  32. package/src/cli/{help.ts → terminal/help.ts} +25 -9
  33. package/src/cli/terminal/prompt.ts +114 -0
  34. package/src/cli/terminal/ui.ts +137 -0
  35. package/src/config/index.ts +128 -14
  36. package/src/config/{constants.ts → paths.ts} +4 -14
  37. package/src/context/content.ts +51 -3
  38. package/src/context/fold/evidence.ts +145 -0
  39. package/src/context/fold/index.ts +65 -0
  40. package/src/context/fold/strategies.ts +94 -0
  41. package/src/context/fold/turns.ts +76 -0
  42. package/src/context/prompts.ts +141 -22
  43. package/src/context/recall/index.ts +214 -0
  44. package/src/context/recall/rerank.ts +71 -0
  45. package/src/proxy/canonical.ts +40 -0
  46. package/src/proxy/compaction.ts +122 -52
  47. package/src/proxy/estimate.ts +14 -9
  48. package/src/proxy/fingerprint.ts +29 -0
  49. package/src/proxy/handoff.ts +71 -0
  50. package/src/proxy/log.ts +2 -2
  51. package/src/proxy/recall.ts +58 -0
  52. package/src/proxy/rewrite.ts +152 -56
  53. package/src/proxy/server.ts +122 -47
  54. package/src/proxy/sessions.ts +64 -0
  55. package/src/proxy/state.ts +17 -3
  56. package/src/proxy/usage.ts +50 -34
  57. package/src/proxy/window.ts +32 -34
  58. package/src/transcript/format.ts +88 -25
  59. package/src/transcript/locate.ts +38 -17
  60. package/src/transcript/messages.ts +18 -0
  61. package/src/transcript/parse.ts +137 -56
  62. package/src/transcript/render.ts +44 -17
  63. package/src/transcript/search.ts +20 -56
  64. package/src/transcript/subagents.ts +150 -0
  65. package/src/cli/claude-settings.ts +0 -42
  66. package/src/cli/commands/install.ts +0 -44
  67. package/src/cli/constants.ts +0 -29
  68. package/src/cli/inspect.ts +0 -41
  69. package/src/cli/launchd.ts +0 -34
  70. package/src/cli/logo/pixels.ts +0 -34
  71. package/src/cli/ui.ts +0 -91
  72. package/src/config/types.ts +0 -21
  73. package/src/context/constants.ts +0 -7
  74. package/src/context/strategies.ts +0 -89
  75. package/src/context/turns.ts +0 -82
  76. package/src/context/types.ts +0 -24
  77. package/src/proxy/constants.ts +0 -19
  78. package/src/proxy/types.ts +0 -58
  79. package/src/transcript/constants.ts +0 -16
  80. package/src/transcript/types.ts +0 -43
@@ -0,0 +1,59 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ export interface Settings {
5
+ env?: Record<string, string>
6
+ autoCompactEnabled?: boolean
7
+ [key: string]: unknown
8
+ }
9
+
10
+ export const BLOCKING_LIMIT_ENV = "CLAUDE_CODE_BLOCKING_LIMIT_OVERRIDE"
11
+ export const BLOCKING_LIMIT_OFF = "1000000000"
12
+
13
+ export const proxyUrl = (port: number) => `http://127.0.0.1:${port}`
14
+
15
+ export const pointsAtEunoe = (settings: Settings, port: number) => settings.env?.ANTHROPIC_BASE_URL === proxyUrl(port)
16
+
17
+ function withoutEnv(settings: Settings, name: string): Settings {
18
+ if (!settings.env || !(name in settings.env)) return settings
19
+ const { [name]: _, ...env } = settings.env
20
+ const next: Settings = { ...settings, env }
21
+ if (Object.keys(env).length === 0) delete next.env
22
+ return next
23
+ }
24
+
25
+ export function withCompactionOwner(settings: Settings, mode: string): Settings {
26
+ if (mode !== "off") return { ...settings, autoCompactEnabled: false, env: { ...settings.env, [BLOCKING_LIMIT_ENV]: BLOCKING_LIMIT_OFF } }
27
+ const { autoCompactEnabled: _, ...rest } = settings
28
+ return rest.env?.[BLOCKING_LIMIT_ENV] === BLOCKING_LIMIT_OFF ? withoutEnv(rest, BLOCKING_LIMIT_ENV) : rest
29
+ }
30
+
31
+ export function withEunoe(settings: Settings, port: number, mode: string): Settings {
32
+ return withCompactionOwner({ ...settings, env: { ...settings.env, ANTHROPIC_BASE_URL: proxyUrl(port) } }, mode)
33
+ }
34
+
35
+ export function withoutEunoe(settings: Settings, port: number): Settings {
36
+ return withCompactionOwner(pointsAtEunoe(settings, port) ? withoutEnv(settings, "ANTHROPIC_BASE_URL") : settings, "off")
37
+ }
38
+
39
+ export function readSettings(dir: string): Settings {
40
+ try {
41
+ return JSON.parse(readFileSync(path.join(dir, "settings.json"), "utf8"))
42
+ } catch {
43
+ return {}
44
+ }
45
+ }
46
+
47
+ export function editSettings(dirs: string[], edit: (settings: Settings) => Settings): string[] {
48
+ const touched: string[] = []
49
+ for (const dir of dirs) {
50
+ if (!existsSync(dir)) continue
51
+ const file = path.join(dir, "settings.json")
52
+ const current: Settings = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {}
53
+ const next = edit(current)
54
+ if (JSON.stringify(next) === JSON.stringify(current)) continue
55
+ writeFileSync(file, `${JSON.stringify(next, null, 2)}\n`)
56
+ touched.push(file)
57
+ }
58
+ return touched
59
+ }
@@ -0,0 +1,67 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
2
+ import path from "node:path"
3
+ import { type Config, claudeConfigDirs } from "../../config"
4
+ import { paths } from "../../config/paths"
5
+ import { HEALTH_PATH, type Health } from "../../proxy/server"
6
+ import { parseJsonl } from "../../transcript/parse"
7
+ import { pointsAtEunoe, proxyUrl, readSettings } from "./claude-settings"
8
+
9
+ export interface RequestRecord {
10
+ ts: string
11
+ action?: string
12
+ thread?: string | null
13
+ input?: number
14
+ charsIn?: number
15
+ charsOut?: number
16
+ charsFixed?: number
17
+ cacheRead?: number
18
+ cacheWrite?: number
19
+ uncached?: number
20
+ compacted?: boolean
21
+ turns?: number
22
+ reranked?: string
23
+ rerankError?: string
24
+ overflow?: number
25
+ fit?: string
26
+ [key: string]: unknown
27
+ }
28
+
29
+ export const RECENT_REQUESTS = 5_000
30
+
31
+ export async function probe(port: number): Promise<Health | null> {
32
+ try {
33
+ const response = await fetch(`${proxyUrl(port)}${HEALTH_PATH}`, { signal: AbortSignal.timeout(800) })
34
+ if (!response.ok) return null
35
+ return (await response.json()) as Health
36
+ } catch {
37
+ return null
38
+ }
39
+ }
40
+
41
+ function newestSource(dir: string): number {
42
+ let newest = 0
43
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
44
+ const file = path.join(dir, entry.name)
45
+ if (entry.isDirectory()) newest = Math.max(newest, newestSource(file))
46
+ else if (entry.name.endsWith(".ts")) newest = Math.max(newest, statSync(file).mtimeMs)
47
+ }
48
+ return newest
49
+ }
50
+
51
+ export function runsStaleCode(health: Health | null): boolean {
52
+ if (!health?.startedAt) return health !== null
53
+ try {
54
+ return newestSource(path.join(import.meta.dir, "..")) > health.startedAt
55
+ } catch {
56
+ return false
57
+ }
58
+ }
59
+
60
+ export function routedDirs(config: Config) {
61
+ return claudeConfigDirs(config).filter((dir) => pointsAtEunoe(readSettings(dir), config.port))
62
+ }
63
+
64
+ export function recentRequests(limit = RECENT_REQUESTS): RequestRecord[] {
65
+ if (!existsSync(paths.requests)) return []
66
+ return parseJsonl<RequestRecord>(readFileSync(paths.requests, "utf8")).slice(-limit)
67
+ }
@@ -0,0 +1,53 @@
1
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ export interface Agent {
5
+ label: string
6
+ plist: string
7
+ interval?: number
8
+ }
9
+
10
+ export const supported = process.platform === "darwin"
11
+
12
+ export function isInstalled(agent: Agent) {
13
+ return existsSync(agent.plist)
14
+ }
15
+
16
+ function plistFor(agent: Agent, program: string[], logFile: string) {
17
+ const args = program.map((arg) => `<string>${arg}</string>`).join("")
18
+ const schedule = agent.interval ? `<key>StartInterval</key><integer>${agent.interval}</integer>` : `<key>KeepAlive</key><true/>\n <key>ThrottleInterval</key><integer>1</integer>`
19
+ return `<?xml version="1.0" encoding="UTF-8"?>
20
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
21
+ <plist version="1.0"><dict>
22
+ <key>Label</key><string>${agent.label}</string>
23
+ <key>ProgramArguments</key><array>${args}</array>
24
+ <key>RunAtLoad</key><true/>
25
+ ${schedule}
26
+ <key>StandardOutPath</key><string>${logFile}</string>
27
+ <key>StandardErrorPath</key><string>${logFile}</string>
28
+ </dict></plist>
29
+ `
30
+ }
31
+
32
+ export function installAgent(agent: Agent, program: string[], logFile: string) {
33
+ mkdirSync(path.dirname(agent.plist), { recursive: true })
34
+ const plist = plistFor(agent, program, logFile)
35
+ if (existsSync(agent.plist) && readFileSync(agent.plist, "utf8") === plist) {
36
+ Bun.spawnSync(["launchctl", "load", agent.plist])
37
+ return
38
+ }
39
+ writeFileSync(agent.plist, plist)
40
+ Bun.spawnSync(["launchctl", "unload", agent.plist])
41
+ Bun.spawnSync(["launchctl", "load", agent.plist])
42
+ }
43
+
44
+ export function restartAgent(agent: Agent) {
45
+ Bun.spawnSync(["launchctl", "kickstart", "-k", `gui/${process.getuid?.() ?? ""}/${agent.label}`])
46
+ }
47
+
48
+ export function removeAgent(agent: Agent) {
49
+ if (!existsSync(agent.plist)) return false
50
+ Bun.spawnSync(["launchctl", "unload", agent.plist])
51
+ unlinkSync(agent.plist)
52
+ return true
53
+ }
@@ -0,0 +1,53 @@
1
+ import { homedir } from "node:os"
2
+ import path from "node:path"
3
+ import type { Config } from "../../config"
4
+ import { paths } from "../../config/paths"
5
+ import type { Health } from "../../proxy/server"
6
+ import { c, line, ok, warn } from "../terminal/ui"
7
+ import { proxyUrl } from "./claude-settings"
8
+ import { probe, runsStaleCode } from "./inspect"
9
+ import * as launchd from "./launchd"
10
+
11
+ const agentFor = (label: string, interval?: number): launchd.Agent => ({ label, plist: path.join(homedir(), "Library", "LaunchAgents", `${label}.plist`), interval })
12
+
13
+ export const PROXY_AGENT = agentFor("com.chroxify.eunoe")
14
+ const WATCH_AGENT = agentFor("com.chroxify.eunoe.watch", 10)
15
+
16
+ const cli = () => path.resolve(process.argv[1] ?? "")
17
+
18
+ async function waitFor(port: number, ready: (health: Health | null) => boolean) {
19
+ const deadline = Date.now() + 3_000
20
+ while (Date.now() < deadline) {
21
+ if (ready(await probe(port))) return true
22
+ await Bun.sleep(150)
23
+ }
24
+ return false
25
+ }
26
+
27
+ export async function startService(config: Config) {
28
+ if (!launchd.supported) {
29
+ if (await probe(config.port)) return true
30
+ warn("No launchd here, so keep the proxy running yourself:")
31
+ line(` ${c.cyan("eunoe serve")}`)
32
+ return false
33
+ }
34
+ launchd.installAgent(PROXY_AGENT, [process.execPath, cli(), "serve"], paths.proxyLog)
35
+ launchd.installAgent(WATCH_AGENT, [process.execPath, cli(), "watch"], paths.proxyLog)
36
+ const running = await probe(config.port)
37
+ if (running && runsStaleCode(running)) {
38
+ launchd.restartAgent(PROXY_AGENT)
39
+ await waitFor(config.port, (health) => health !== null && health.pid !== running.pid)
40
+ }
41
+ if (await waitFor(config.port, (health) => health !== null)) {
42
+ ok(`Proxy running in the background on ${c.cyan(proxyUrl(config.port))}`)
43
+ return true
44
+ }
45
+ warn(`The proxy didn't answer on ${proxyUrl(config.port)}. Check ${paths.proxyLog}.`)
46
+ return false
47
+ }
48
+
49
+ export function removeService() {
50
+ const removed = launchd.removeAgent(PROXY_AGENT)
51
+ launchd.removeAgent(WATCH_AGENT)
52
+ return removed
53
+ }
@@ -0,0 +1,81 @@
1
+ import type { RequestRecord } from "./inspect"
2
+
3
+ export interface SessionStats {
4
+ context: number
5
+ fixed: number | null
6
+ plain: number | null
7
+ turns: number | null
8
+ requests: number
9
+ folds: number
10
+ lastSeen: string
11
+ }
12
+
13
+ export interface Totals {
14
+ requests: number
15
+ cacheRead: number
16
+ cacheWrite: number
17
+ uncached: number
18
+ folds: number
19
+ lastFold: string | null
20
+ sent: number
21
+ saved: number
22
+ }
23
+
24
+ const sessionOf = (record: RequestRecord): string | null => (typeof record.thread === "string" ? record.thread.replace(/-[0-9a-f]{24}$/, "") : null)
25
+
26
+ const inputOf = (record: RequestRecord) => record.input ?? 0
27
+
28
+ function scaled(record: RequestRecord, chars: number | undefined): number | null {
29
+ const input = inputOf(record)
30
+ const charsOut = record.charsOut ?? 0
31
+ return input > 0 && charsOut > 0 && chars && chars > 0 ? Math.round((input * chars) / charsOut) : null
32
+ }
33
+
34
+ export function withinWindow(records: RequestRecord[], now = Date.now(), window = 24 * 3_600_000): RequestRecord[] {
35
+ return records.filter((record) => now - Date.parse(record.ts) <= window)
36
+ }
37
+
38
+ export function perSession(records: RequestRecord[]): Map<string, SessionStats> {
39
+ const stats = new Map<string, SessionStats>()
40
+ for (const record of records) {
41
+ const id = sessionOf(record)
42
+ if (!id || inputOf(record) <= 0) continue
43
+ const previous = stats.get(id)
44
+ stats.set(id, {
45
+ context: inputOf(record),
46
+ fixed: scaled(record, record.charsFixed),
47
+ plain: scaled(record, record.charsIn),
48
+ turns: record.turns ?? previous?.turns ?? null,
49
+ requests: (previous?.requests ?? 0) + 1,
50
+ folds: (previous?.folds ?? 0) + (record.compacted ? 1 : 0),
51
+ lastSeen: record.ts,
52
+ })
53
+ }
54
+ return stats
55
+ }
56
+
57
+ export function totals(records: RequestRecord[]): Totals {
58
+ const out: Totals = { requests: 0, cacheRead: 0, cacheWrite: 0, uncached: 0, folds: 0, lastFold: null, sent: 0, saved: 0 }
59
+ for (const record of records) {
60
+ if (inputOf(record) <= 0) continue
61
+ out.requests += 1
62
+ out.cacheRead += record.cacheRead ?? 0
63
+ out.cacheWrite += record.cacheWrite ?? 0
64
+ out.uncached += record.uncached ?? 0
65
+ if (record.compacted) {
66
+ out.folds += 1
67
+ out.lastFold = record.ts
68
+ }
69
+ const plain = scaled(record, record.charsIn)
70
+ if (plain !== null) {
71
+ out.sent += inputOf(record)
72
+ out.saved += plain - inputOf(record)
73
+ }
74
+ }
75
+ return out
76
+ }
77
+
78
+ export function savedShare(context: number, plain: number | null): number | null {
79
+ if (!plain) return null
80
+ return plain > context ? 1 - context / plain : 0
81
+ }
@@ -0,0 +1,60 @@
1
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
2
+ import { type Config, claudeConfigDirs, compactionOwner } from "../../config"
3
+ import { paths } from "../../config/paths"
4
+ import { editSettings, withEunoe, withoutEunoe } from "./claude-settings"
5
+ import { probe, routedDirs } from "./inspect"
6
+
7
+ interface Watch {
8
+ misses: number
9
+ paused: string[]
10
+ pausedAt?: string
11
+ }
12
+
13
+ interface WatchStep {
14
+ watch: Watch
15
+ pause: string[]
16
+ resume: string[]
17
+ }
18
+
19
+ export const WATCH_MISSES = 2
20
+
21
+ const idle = (): Watch => ({ misses: 0, paused: [] })
22
+
23
+ export function readWatch(): Watch {
24
+ try {
25
+ return { ...idle(), ...JSON.parse(readFileSync(paths.watch, "utf8")) }
26
+ } catch {
27
+ return idle()
28
+ }
29
+ }
30
+
31
+ function saveWatch(watch: Watch) {
32
+ if (watch.misses === 0 && watch.paused.length === 0) {
33
+ if (existsSync(paths.watch)) unlinkSync(paths.watch)
34
+ return
35
+ }
36
+ writeFileSync(paths.watch, `${JSON.stringify(watch, null, 2)}\n`)
37
+ }
38
+
39
+ export function clearWatch() {
40
+ saveWatch(idle())
41
+ }
42
+
43
+ export function nextWatch(watch: Watch, alive: boolean, routed: string[], now: string): WatchStep {
44
+ if (alive) return { watch: idle(), pause: [], resume: watch.paused }
45
+ if (routed.length === 0) return { watch: { ...watch, misses: 0 }, pause: [], resume: [] }
46
+ const misses = watch.misses + 1
47
+ if (misses < WATCH_MISSES) return { watch: { ...watch, misses }, pause: [], resume: [] }
48
+ return { watch: { misses: 0, paused: [...new Set([...watch.paused, ...routed])], pausedAt: now }, pause: routed, resume: [] }
49
+ }
50
+
51
+ export async function runWatch(config: Config): Promise<WatchStep> {
52
+ const alive = (await probe(config.port)) !== null
53
+ const step = nextWatch(readWatch(), alive, routedDirs(config), new Date().toISOString())
54
+ if (step.pause.length > 0) editSettings(step.pause, (settings) => withoutEunoe(settings, config.port))
55
+ const managed = claudeConfigDirs(config)
56
+ const resumed = step.resume.filter((dir) => managed.includes(dir))
57
+ if (resumed.length > 0) editSettings(resumed, (settings) => withEunoe(settings, config.port, compactionOwner(config)))
58
+ saveWatch(step.watch)
59
+ return step
60
+ }
@@ -0,0 +1,141 @@
1
+ import type { Config } from "../../config"
2
+ import type { Body, Message } from "../../context/content"
3
+ import { isPrompt, type ReduceOptions, reduceTurn, splitTurns, turnFingerprint } from "../../context/fold/turns"
4
+ import { compact } from "../../proxy/compaction"
5
+ import { DEFAULT_RATIO, sizeOf } from "../../proxy/estimate"
6
+ import type { ThreadState } from "../../proxy/state"
7
+
8
+ export interface ReplayOptions {
9
+ config: Config
10
+ window: number
11
+ fixedTokens: number
12
+ recallChars?: number
13
+ }
14
+
15
+ export interface Replay {
16
+ requests: number
17
+ turns: number
18
+ folds: number
19
+ dropped: number
20
+ nativeCompactions: number
21
+ discarded: number
22
+ plainTokens: number
23
+ eunoeTokens: number
24
+ plainCost: number
25
+ eunoeCost: number
26
+ peakPlain: number
27
+ peakEunoe: number
28
+ }
29
+
30
+ export interface ReplayedSession extends Replay {
31
+ id: string
32
+ file: string
33
+ bytes: number
34
+ cwd: string | null
35
+ title: string | null
36
+ ms: number
37
+ }
38
+
39
+ export const REPLAY_NATIVE_COMPACT_AT = 0.92
40
+
41
+ const NATIVE_SUMMARY_CHARS = 30_000
42
+ const CACHE_READ_WEIGHT = 0.1
43
+ const CACHE_WRITE_WEIGHT = 1.25
44
+
45
+ const emptyChars = sizeOf({ system: "", tools: [], messages: [] })
46
+ const charsOf = (message: Message) => sizeOf({ system: "", tools: [], messages: [message] }) - emptyChars
47
+ const listChars = (sum: number, count: number) => sum + Math.max(0, count - 1)
48
+ const tokens = (chars: number) => Math.round(chars * DEFAULT_RATIO)
49
+
50
+ function commonPrefix(a: Message[], b: Message[]): number {
51
+ let chars = 0
52
+ for (let i = 0; i < Math.min(a.length, b.length); i += 1) {
53
+ if (a[i] !== b[i] && JSON.stringify(a[i]) !== JSON.stringify(b[i])) break
54
+ chars += charsOf(a[i]) + 1
55
+ }
56
+ return chars
57
+ }
58
+
59
+ export function replaySession(messages: Message[], { config, window, fixedTokens, recallChars: recallPerPrompt = 3_500 }: ReplayOptions): Replay {
60
+ const out: Replay = { requests: 0, turns: 0, folds: 0, dropped: 0, nativeCompactions: 0, discarded: 0, plainTokens: 0, eunoeTokens: 0, plainCost: 0, eunoeCost: 0, peakPlain: 0, peakEunoe: 0 }
61
+ const fixedChars = Math.round(fixedTokens / DEFAULT_RATIO)
62
+ const system = "x".repeat(fixedChars)
63
+ const sizes = messages.map(charsOf)
64
+ const { preamble, turns } = splitTurns(messages)
65
+ const turnAt = [...preamble.map(() => -1), ...turns.flatMap((turn, k) => turn.map(() => k))]
66
+ const limit = (config.compactAt * window) / DEFAULT_RATIO
67
+ const cost = (chars: number, cacheable: number) => {
68
+ const cached = Math.max(0, cacheable - fixedChars)
69
+ return CACHE_READ_WEIGHT * tokens(cached) + CACHE_WRITE_WEIGHT * tokens(chars - fixedChars - cached)
70
+ }
71
+ out.turns = turns.length
72
+
73
+ const reduced = new Map<string, Message[]>()
74
+ const reduce = (turn: Message[], options: ReduceOptions) => {
75
+ const key = `${turnFingerprint(turn)}:${turn.length}`
76
+ const hit = reduced.get(key) ?? reduceTurn(turn, options)
77
+ reduced.set(key, hit)
78
+ return hit
79
+ }
80
+
81
+ const state: ThreadState = {}
82
+ const eunoe = { sent: [] as Message[], sum: 0, count: 0, upTo: 0, recall: 0, compactedTurn: -2, previous: 0 }
83
+ const plain = { sum: 0, count: 0, since: 0, previous: 0 }
84
+
85
+ const sendEunoe = (i: number) => {
86
+ const turn = turnAt[i - 1]
87
+ const newTurn = turn !== eunoe.compactedTurn
88
+ if (newTurn && state.cut && isPrompt(messages[i - 1])) eunoe.recall += recallPerPrompt
89
+ for (; eunoe.upTo < i; eunoe.upTo += 1) {
90
+ eunoe.sum += sizes[eunoe.upTo]
91
+ eunoe.count += 1
92
+ }
93
+ let cacheable = eunoe.previous
94
+ if (newTurn || fixedChars + listChars(eunoe.sum, eunoe.count) + eunoe.recall > limit) {
95
+ const recall = eunoe.recall
96
+ const decorate = (ms: Message[]) => (recall ? [...ms, { role: "user", content: "r".repeat(recall) }] : ms)
97
+ const result = compact({ body: { system, tools: [], messages: messages.slice(0, i) } as Body, config, state, window, guide: null, mayCompact: true, reduce, decorate })
98
+ if (result.note.compacted) out.folds += 1
99
+ out.dropped = Math.max(out.dropped, Number(result.note.omitted ?? 0))
100
+ if (result.note.compacted || result.note.actions) cacheable = Math.min(eunoe.previous, fixedChars + commonPrefix(eunoe.sent, result.messages))
101
+ eunoe.sent = result.messages
102
+ eunoe.sum = eunoe.sent.reduce((sum, m) => sum + charsOf(m), 0)
103
+ eunoe.count = eunoe.sent.length
104
+ eunoe.compactedTurn = turn
105
+ }
106
+ const chars = fixedChars + listChars(eunoe.sum, eunoe.count) + eunoe.recall
107
+ out.eunoeTokens += tokens(chars - fixedChars)
108
+ out.peakEunoe = Math.max(out.peakEunoe, tokens(chars))
109
+ out.eunoeCost += cost(chars, cacheable)
110
+ eunoe.previous = chars
111
+ }
112
+
113
+ const sendPlain = (i: number) => {
114
+ for (; plain.since < i; plain.since += 1) {
115
+ plain.sum += sizes[plain.since]
116
+ plain.count += 1
117
+ }
118
+ let chars = fixedChars + listChars(plain.sum, plain.count)
119
+ let cacheable = plain.previous
120
+ out.peakPlain = Math.max(out.peakPlain, tokens(chars))
121
+ if (tokens(chars) > REPLAY_NATIVE_COMPACT_AT * window) {
122
+ out.nativeCompactions += 1
123
+ out.discarded += tokens(chars - fixedChars)
124
+ plain.sum = NATIVE_SUMMARY_CHARS
125
+ plain.count = 1
126
+ chars = fixedChars + listChars(plain.sum, plain.count)
127
+ cacheable = fixedChars
128
+ }
129
+ out.plainTokens += tokens(chars - fixedChars)
130
+ out.plainCost += cost(chars, cacheable)
131
+ plain.previous = chars
132
+ }
133
+
134
+ for (let i = 1; i < messages.length; i += 1) {
135
+ if (messages[i].role !== "assistant") continue
136
+ out.requests += 1
137
+ sendEunoe(i)
138
+ sendPlain(i)
139
+ }
140
+ return out
141
+ }
@@ -0,0 +1,15 @@
1
+ import { statSync } from "node:fs"
2
+ import { basename } from "node:path"
3
+ import { cwdOf, titleOf } from "../../proxy/sessions"
4
+ import { sessionMessages } from "../../transcript/messages"
5
+ import { type ReplayedSession, type ReplayOptions, replaySession } from "."
6
+
7
+ declare var self: Worker
8
+
9
+ self.onmessage = (event: MessageEvent<{ file: string; options: ReplayOptions }>) => {
10
+ const { file, options } = event.data
11
+ const started = performance.now()
12
+ const replay = replaySession(sessionMessages(file), options)
13
+ const done: ReplayedSession = { ...replay, id: basename(file, ".jsonl"), file, bytes: statSync(file).size, cwd: cwdOf(file) ?? null, title: titleOf(file) ?? null, ms: Math.round(performance.now() - started) }
14
+ postMessage(done)
15
+ }
@@ -0,0 +1,88 @@
1
+ import { clip } from "../../context/content"
2
+ import { interact, keys, printable } from "../terminal/prompt"
3
+ import { c, wrap } from "../terminal/ui"
4
+ import { askOf, metaOf, shortId } from "."
5
+ import { rankCandidates, type SessionCandidate } from "./source"
6
+
7
+ export const UNTITLED = "untitled"
8
+
9
+ const ROWS = 7
10
+
11
+ const metas = new Map<string, string>()
12
+
13
+ const columns = () => Math.max(60, process.stdout.columns ?? 100)
14
+
15
+ function cachedMeta(cand: SessionCandidate) {
16
+ const meta = metas.get(cand.id) ?? metaOf(cand)
17
+ metas.set(cand.id, meta)
18
+ return meta
19
+ }
20
+
21
+ export function filterCandidates(query: string, candidates: SessionCandidate[]): SessionCandidate[] {
22
+ const words = query.toLowerCase().split(/\s+/).filter(Boolean)
23
+ if (words.length === 0) return candidates
24
+ const ranked = rankCandidates(query, candidates)
25
+ const haystack = (cand: SessionCandidate) => `${cand.label} ${cand.asks.join(" ")} ${cand.id}`.toLowerCase()
26
+ const loose = candidates.filter((cand) => !ranked.includes(cand) && words.every((word) => haystack(cand).includes(word)))
27
+ return [...ranked, ...loose]
28
+ }
29
+
30
+ function row(cand: SessionCandidate, chosen: boolean, width: number): string[] {
31
+ const marker = chosen ? c.green("●") : c.dim("○")
32
+ const info = cachedMeta(cand)
33
+ const title = cand.label ? clip(cand.label, width - info.length - 16) : UNTITLED
34
+ const head = ` ${marker} ${c.cyan(shortId(cand.id))} ${chosen ? c.bold(title) : cand.label ? title : c.dim(title)} ${c.dim(info)}`
35
+ if (!chosen) return [head]
36
+ const lines = [head]
37
+ for (const [label, text] of [
38
+ ["first", cand.asks[0]],
39
+ ["last ", cand.asks.at(-1)],
40
+ ] as const) {
41
+ const [first = "", ...rest] = wrap(askOf(text), width - 14)
42
+ lines.push(` ${c.dim(label)} ${first}`, ...rest.map((more) => ` ${more}`))
43
+ }
44
+ return lines
45
+ }
46
+
47
+ function render(title: string, query: string, shown: SessionCandidate[], total: number, index: number, help: string): string[] {
48
+ const width = columns()
49
+ const lines = [` ${c.bold(title)} ${c.dim(`${shown.length} of ${total}`)}`, ` ${c.cyan("›")} ${query || c.dim("type to filter")}`, ""]
50
+ if (shown.length === 0) lines.push(` ${c.dim("nothing matches")}`)
51
+ const start = Math.max(0, Math.min(index - Math.floor(ROWS / 2), shown.length - ROWS))
52
+ if (start > 0) lines.push(` ${c.dim(`↑ ${start} more`)}`)
53
+ for (const [i, cand] of shown.slice(start, start + ROWS).entries()) lines.push(...row(cand, start + i === index, width))
54
+ if (start + ROWS < shown.length) lines.push(` ${c.dim(`↓ ${shown.length - start - ROWS} more`)}`)
55
+ lines.push("", ` ${c.dim(help)}`)
56
+ return lines
57
+ }
58
+
59
+ export async function browse(title: string, candidates: SessionCandidate[], initial = "", help = "↑↓ move · type to filter · enter choose · esc quit"): Promise<SessionCandidate | null> {
60
+ let query = initial
61
+ let shown = filterCandidates(query, candidates)
62
+ let index = 0
63
+ let chosen: SessionCandidate | null = null
64
+ const step = (by: number) => {
65
+ index = shown.length > 0 ? (index + shown.length + by) % shown.length : 0
66
+ }
67
+ await interact(
68
+ () => render(title, query, shown, candidates.length, index, help),
69
+ (key) => {
70
+ if (key === keys.up || key === keys.down) {
71
+ step(key === keys.up ? -1 : 1)
72
+ return
73
+ }
74
+ if (keys.enter.includes(key)) {
75
+ chosen = shown[index] ?? null
76
+ return true
77
+ }
78
+ if (keys.escape.includes(key)) return true
79
+ if (keys.backspace.includes(key)) query = query.slice(0, -1)
80
+ else if (key === keys.clear) query = ""
81
+ else if (printable(key)) query += key
82
+ else return
83
+ shown = filterCandidates(query, candidates)
84
+ index = 0
85
+ },
86
+ )
87
+ return chosen
88
+ }