@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.
- package/README.md +364 -239
- package/package.json +20 -3
- package/src/cli/{types.ts → command.ts} +1 -4
- package/src/cli/commands/index.ts +27 -3
- package/src/cli/commands/load.ts +44 -0
- package/src/cli/commands/mode.ts +68 -24
- package/src/cli/commands/peek.ts +22 -0
- package/src/cli/commands/replay.ts +194 -0
- package/src/cli/commands/serve.ts +24 -8
- package/src/cli/commands/sessions.ts +70 -0
- package/src/cli/commands/settings.ts +119 -0
- package/src/cli/commands/start.ts +73 -0
- package/src/cli/commands/status.ts +107 -30
- package/src/cli/commands/stop.ts +36 -12
- package/src/cli/commands/uninstall.ts +14 -14
- package/src/cli/commands/watch.ts +16 -0
- package/src/cli/logo/art.ts +99 -0
- package/src/cli/logo/index.ts +12 -18
- package/src/cli/main.ts +3 -3
- package/src/cli/proxy/claude-settings.ts +59 -0
- package/src/cli/proxy/inspect.ts +67 -0
- package/src/cli/proxy/launchd.ts +53 -0
- package/src/cli/proxy/service.ts +53 -0
- package/src/cli/proxy/stats.ts +81 -0
- package/src/cli/proxy/watch.ts +60 -0
- package/src/cli/replay/index.ts +141 -0
- package/src/cli/replay/worker.ts +15 -0
- package/src/cli/sessions/browse.ts +88 -0
- package/src/cli/sessions/index.ts +76 -0
- package/src/cli/sessions/source.ts +74 -0
- package/src/cli/{args.ts → terminal/args.ts} +1 -1
- package/src/cli/{help.ts → terminal/help.ts} +25 -9
- package/src/cli/terminal/prompt.ts +114 -0
- package/src/cli/terminal/ui.ts +137 -0
- package/src/config/index.ts +128 -14
- package/src/config/{constants.ts → paths.ts} +4 -14
- package/src/context/content.ts +51 -3
- package/src/context/fold/evidence.ts +145 -0
- package/src/context/fold/index.ts +65 -0
- package/src/context/fold/strategies.ts +94 -0
- package/src/context/fold/turns.ts +76 -0
- package/src/context/prompts.ts +141 -22
- package/src/context/recall/index.ts +214 -0
- package/src/context/recall/rerank.ts +71 -0
- package/src/proxy/canonical.ts +40 -0
- package/src/proxy/compaction.ts +122 -52
- package/src/proxy/estimate.ts +14 -9
- package/src/proxy/fingerprint.ts +29 -0
- package/src/proxy/handoff.ts +71 -0
- package/src/proxy/log.ts +2 -2
- package/src/proxy/recall.ts +58 -0
- package/src/proxy/rewrite.ts +152 -56
- package/src/proxy/server.ts +122 -47
- package/src/proxy/sessions.ts +64 -0
- package/src/proxy/state.ts +17 -3
- package/src/proxy/usage.ts +50 -34
- package/src/proxy/window.ts +32 -34
- package/src/transcript/format.ts +88 -25
- package/src/transcript/locate.ts +38 -17
- package/src/transcript/messages.ts +18 -0
- package/src/transcript/parse.ts +137 -56
- package/src/transcript/render.ts +44 -17
- package/src/transcript/search.ts +20 -56
- package/src/transcript/subagents.ts +150 -0
- package/src/cli/claude-settings.ts +0 -42
- package/src/cli/commands/install.ts +0 -44
- package/src/cli/constants.ts +0 -29
- package/src/cli/inspect.ts +0 -41
- package/src/cli/launchd.ts +0 -34
- package/src/cli/logo/pixels.ts +0 -34
- package/src/cli/ui.ts +0 -91
- package/src/config/types.ts +0 -21
- package/src/context/constants.ts +0 -7
- package/src/context/strategies.ts +0 -89
- package/src/context/turns.ts +0 -82
- package/src/context/types.ts +0 -24
- package/src/proxy/constants.ts +0 -19
- package/src/proxy/types.ts +0 -58
- package/src/transcript/constants.ts +0 -16
- 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
|
+
}
|