@chroxify/eunoe 1.0.0 → 1.2.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 +68 -9
- package/package.json +18 -2
- package/src/cli/{types.ts → command.ts} +0 -4
- package/src/cli/commands/index.ts +13 -1
- package/src/cli/commands/load.ts +44 -0
- package/src/cli/commands/mode.ts +41 -33
- package/src/cli/commands/peek.ts +22 -0
- package/src/cli/commands/replay.ts +245 -0
- package/src/cli/commands/serve.ts +22 -7
- package/src/cli/commands/sessions.ts +70 -0
- package/src/cli/commands/settings.ts +79 -60
- package/src/cli/commands/start.ts +56 -21
- package/src/cli/commands/stats.ts +233 -0
- package/src/cli/commands/status.ts +98 -55
- package/src/cli/commands/stop.ts +8 -6
- package/src/cli/commands/uninstall.ts +10 -12
- 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 +69 -0
- package/src/cli/proxy/inspect.ts +67 -0
- package/src/cli/proxy/launchd.ts +53 -0
- package/src/cli/proxy/live.ts +57 -0
- package/src/cli/proxy/service.ts +53 -0
- package/src/cli/{stats.ts → proxy/stats.ts} +29 -16
- package/src/cli/proxy/watch.ts +60 -0
- package/src/cli/replay/index.ts +146 -0
- package/src/cli/replay/worker.ts +20 -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/{help.ts → terminal/help.ts} +24 -8
- package/src/cli/terminal/prompt.ts +114 -0
- package/src/cli/{ui.ts → terminal/ui.ts} +55 -24
- package/src/cli/usage/index.ts +151 -0
- package/src/config/index.ts +99 -34
- package/src/config/paths.ts +17 -0
- package/src/context/content.ts +40 -14
- 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 +17 -9
- package/src/proxy/compaction.ts +106 -53
- package/src/proxy/estimate.ts +14 -9
- package/src/proxy/fingerprint.ts +23 -9
- 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 +141 -119
- package/src/proxy/server.ts +108 -51
- package/src/proxy/sessions.ts +32 -14
- 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 +3 -14
- 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/constants.ts +0 -52
- package/src/cli/inspect.ts +0 -41
- package/src/cli/launchd.ts +0 -39
- package/src/cli/logo/pixels.ts +0 -34
- package/src/cli/prompt.ts +0 -91
- package/src/cli/service.ts +0 -33
- package/src/cli/sessions.ts +0 -25
- package/src/config/constants.ts +0 -39
- package/src/config/types.ts +0 -50
- package/src/context/constants.ts +0 -42
- package/src/context/evidence.ts +0 -92
- package/src/context/recall.ts +0 -155
- package/src/context/rerank.ts +0 -44
- package/src/context/strategies.ts +0 -89
- package/src/context/turns.ts +0 -87
- package/src/context/types.ts +0 -66
- package/src/proxy/constants.ts +0 -29
- package/src/proxy/types.ts +0 -82
- package/src/transcript/constants.ts +0 -15
- package/src/transcript/types.ts +0 -43
- /package/src/cli/{args.ts → terminal/args.ts} +0 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
|
|
5
|
+
export interface LiveSession {
|
|
6
|
+
pid: number
|
|
7
|
+
sessionId: string
|
|
8
|
+
cwd: string
|
|
9
|
+
startedAt: number
|
|
10
|
+
status: string
|
|
11
|
+
entrypoint: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const isLiveSession = (value: Partial<LiveSession>): value is LiveSession => typeof value.pid === "number" && typeof value.sessionId === "string" && typeof value.startedAt === "number" && typeof value.status === "string"
|
|
15
|
+
|
|
16
|
+
function runsClaude(pid: number) {
|
|
17
|
+
const result = spawnSync("ps", ["-o", "command=", "-p", String(pid)], { encoding: "utf8" })
|
|
18
|
+
return result.status === 0 && /claude/i.test(result.stdout)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function listLiveSessions(configDirs: string[]): LiveSession[] {
|
|
22
|
+
const live: LiveSession[] = []
|
|
23
|
+
for (const dir of configDirs) {
|
|
24
|
+
const sessionsDir = path.join(dir, "sessions")
|
|
25
|
+
if (!existsSync(sessionsDir)) continue
|
|
26
|
+
for (const file of readdirSync(sessionsDir).filter((name) => /^\d+\.json$/.test(name))) {
|
|
27
|
+
try {
|
|
28
|
+
const session = JSON.parse(readFileSync(path.join(sessionsDir, file), "utf8"))
|
|
29
|
+
if (isLiveSession(session) && runsClaude(session.pid)) live.push(session)
|
|
30
|
+
} catch {}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return live
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function unroutedSessions(live: LiveSession[], lastSeen: Map<string, string>, managed: (sessionId: string) => boolean, self: string | undefined): LiveSession[] {
|
|
37
|
+
return live.filter((session) => {
|
|
38
|
+
if (session.sessionId === self || !managed(session.sessionId)) return false
|
|
39
|
+
const seen = lastSeen.get(session.sessionId)
|
|
40
|
+
return !seen || Date.parse(seen) < session.startedAt
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const isIdle = (session: LiveSession) => session.status === "idle"
|
|
45
|
+
|
|
46
|
+
export const isTerminal = (session: LiveSession) => session.entrypoint === "cli"
|
|
47
|
+
|
|
48
|
+
export function restartSessions(sessions: LiveSession[]): LiveSession[] {
|
|
49
|
+
return sessions.filter((session) => {
|
|
50
|
+
try {
|
|
51
|
+
process.kill(session.pid, "SIGTERM")
|
|
52
|
+
return true
|
|
53
|
+
} catch {
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { RequestRecord } from "./types"
|
|
1
|
+
import type { RequestRecord } from "./inspect"
|
|
3
2
|
|
|
4
3
|
export interface SessionStats {
|
|
5
4
|
context: number
|
|
5
|
+
fixed: number | null
|
|
6
6
|
plain: number | null
|
|
7
7
|
turns: number | null
|
|
8
8
|
requests: number
|
|
@@ -21,13 +21,22 @@ export interface Totals {
|
|
|
21
21
|
saved: number
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
const
|
|
24
|
+
const sessionOf = (record: RequestRecord): string | null => (typeof record.thread === "string" ? record.thread.replace(/-[0-9a-f]{24}$/, "") : null)
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
const inputOf = (record: RequestRecord) => record.input ?? 0
|
|
27
27
|
|
|
28
|
-
|
|
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 savedTokens(record: RequestRecord): number | null {
|
|
35
|
+
const plain = scaled(record, record.charsIn)
|
|
36
|
+
return plain === null ? null : plain - inputOf(record)
|
|
37
|
+
}
|
|
29
38
|
|
|
30
|
-
export function withinWindow(records: RequestRecord[], now = Date.now(), window =
|
|
39
|
+
export function withinWindow(records: RequestRecord[], now = Date.now(), window = 24 * 3_600_000): RequestRecord[] {
|
|
31
40
|
return records.filter((record) => now - Date.parse(record.ts) <= window)
|
|
32
41
|
}
|
|
33
42
|
|
|
@@ -35,12 +44,13 @@ export function perSession(records: RequestRecord[]): Map<string, SessionStats>
|
|
|
35
44
|
const stats = new Map<string, SessionStats>()
|
|
36
45
|
for (const record of records) {
|
|
37
46
|
const id = sessionOf(record)
|
|
38
|
-
if (!id ||
|
|
47
|
+
if (!id || inputOf(record) <= 0) continue
|
|
39
48
|
const previous = stats.get(id)
|
|
40
49
|
stats.set(id, {
|
|
41
|
-
context: record
|
|
42
|
-
|
|
43
|
-
|
|
50
|
+
context: inputOf(record),
|
|
51
|
+
fixed: scaled(record, record.charsFixed),
|
|
52
|
+
plain: scaled(record, record.charsIn),
|
|
53
|
+
turns: record.turns ?? previous?.turns ?? null,
|
|
44
54
|
requests: (previous?.requests ?? 0) + 1,
|
|
45
55
|
folds: (previous?.folds ?? 0) + (record.compacted ? 1 : 0),
|
|
46
56
|
lastSeen: record.ts,
|
|
@@ -52,7 +62,7 @@ export function perSession(records: RequestRecord[]): Map<string, SessionStats>
|
|
|
52
62
|
export function totals(records: RequestRecord[]): Totals {
|
|
53
63
|
const out: Totals = { requests: 0, cacheRead: 0, cacheWrite: 0, uncached: 0, folds: 0, lastFold: null, sent: 0, saved: 0 }
|
|
54
64
|
for (const record of records) {
|
|
55
|
-
if (
|
|
65
|
+
if (inputOf(record) <= 0) continue
|
|
56
66
|
out.requests += 1
|
|
57
67
|
out.cacheRead += record.cacheRead ?? 0
|
|
58
68
|
out.cacheWrite += record.cacheWrite ?? 0
|
|
@@ -61,13 +71,16 @@ export function totals(records: RequestRecord[]): Totals {
|
|
|
61
71
|
out.folds += 1
|
|
62
72
|
out.lastFold = record.ts
|
|
63
73
|
}
|
|
64
|
-
const
|
|
65
|
-
if (
|
|
66
|
-
out.sent += record
|
|
67
|
-
out.saved +=
|
|
74
|
+
const saved = savedTokens(record)
|
|
75
|
+
if (saved !== null) {
|
|
76
|
+
out.sent += inputOf(record)
|
|
77
|
+
out.saved += saved
|
|
68
78
|
}
|
|
69
79
|
}
|
|
70
80
|
return out
|
|
71
81
|
}
|
|
72
82
|
|
|
73
|
-
export
|
|
83
|
+
export function savedShare(context: number, plain: number | null): number | null {
|
|
84
|
+
if (!plain) return null
|
|
85
|
+
return plain > context ? 1 - context / plain : 0
|
|
86
|
+
}
|
|
@@ -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,146 @@
|
|
|
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 interface ReplayFailure {
|
|
40
|
+
file: string
|
|
41
|
+
error: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const REPLAY_NATIVE_COMPACT_AT = 0.92
|
|
45
|
+
|
|
46
|
+
const NATIVE_SUMMARY_CHARS = 30_000
|
|
47
|
+
const CACHE_READ_WEIGHT = 0.1
|
|
48
|
+
const CACHE_WRITE_WEIGHT = 1.25
|
|
49
|
+
|
|
50
|
+
const emptyChars = sizeOf({ system: "", tools: [], messages: [] })
|
|
51
|
+
const charsOf = (message: Message) => sizeOf({ system: "", tools: [], messages: [message] }) - emptyChars
|
|
52
|
+
const listChars = (sum: number, count: number) => sum + Math.max(0, count - 1)
|
|
53
|
+
const tokens = (chars: number) => Math.round(chars * DEFAULT_RATIO)
|
|
54
|
+
|
|
55
|
+
function commonPrefix(a: Message[], b: Message[]): number {
|
|
56
|
+
let chars = 0
|
|
57
|
+
for (let i = 0; i < Math.min(a.length, b.length); i += 1) {
|
|
58
|
+
if (a[i] !== b[i] && JSON.stringify(a[i]) !== JSON.stringify(b[i])) break
|
|
59
|
+
chars += charsOf(a[i]) + 1
|
|
60
|
+
}
|
|
61
|
+
return chars
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function replaySession(messages: Message[], { config, window, fixedTokens, recallChars: recallPerPrompt = 3_500 }: ReplayOptions): Replay {
|
|
65
|
+
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 }
|
|
66
|
+
const fixedChars = Math.round(fixedTokens / DEFAULT_RATIO)
|
|
67
|
+
const system = "x".repeat(fixedChars)
|
|
68
|
+
const sizes = messages.map(charsOf)
|
|
69
|
+
const { preamble, turns } = splitTurns(messages)
|
|
70
|
+
const turnAt = [...preamble.map(() => -1), ...turns.flatMap((turn, k) => turn.map(() => k))]
|
|
71
|
+
const limit = (config.compactAt * window) / DEFAULT_RATIO
|
|
72
|
+
const cost = (chars: number, cacheable: number) => {
|
|
73
|
+
const cached = Math.max(0, cacheable - fixedChars)
|
|
74
|
+
return CACHE_READ_WEIGHT * tokens(cached) + CACHE_WRITE_WEIGHT * tokens(chars - fixedChars - cached)
|
|
75
|
+
}
|
|
76
|
+
out.turns = turns.length
|
|
77
|
+
|
|
78
|
+
const reduced = new Map<string, Message[]>()
|
|
79
|
+
const reduce = (turn: Message[], options: ReduceOptions) => {
|
|
80
|
+
const key = `${turnFingerprint(turn)}:${turn.length}`
|
|
81
|
+
const hit = reduced.get(key) ?? reduceTurn(turn, options)
|
|
82
|
+
reduced.set(key, hit)
|
|
83
|
+
return hit
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const state: ThreadState = {}
|
|
87
|
+
const eunoe = { sent: [] as Message[], sum: 0, count: 0, upTo: 0, recall: 0, compactedTurn: -2, previous: 0 }
|
|
88
|
+
const plain = { sum: 0, count: 0, since: 0, previous: 0 }
|
|
89
|
+
|
|
90
|
+
const sendEunoe = (i: number) => {
|
|
91
|
+
const turn = turnAt[i - 1]
|
|
92
|
+
const newTurn = turn !== eunoe.compactedTurn
|
|
93
|
+
if (newTurn && state.cut && isPrompt(messages[i - 1])) eunoe.recall += recallPerPrompt
|
|
94
|
+
for (; eunoe.upTo < i; eunoe.upTo += 1) {
|
|
95
|
+
eunoe.sum += sizes[eunoe.upTo]
|
|
96
|
+
eunoe.count += 1
|
|
97
|
+
}
|
|
98
|
+
let cacheable = eunoe.previous
|
|
99
|
+
if (newTurn || fixedChars + listChars(eunoe.sum, eunoe.count) + eunoe.recall > limit) {
|
|
100
|
+
const recall = eunoe.recall
|
|
101
|
+
const decorate = (ms: Message[]) => (recall ? [...ms, { role: "user", content: "r".repeat(recall) }] : ms)
|
|
102
|
+
const result = compact({ body: { system, tools: [], messages: messages.slice(0, i) } as Body, config, state, window, guide: null, mayCompact: true, reduce, decorate })
|
|
103
|
+
if (result.note.compacted) out.folds += 1
|
|
104
|
+
out.dropped = Math.max(out.dropped, Number(result.note.omitted ?? 0))
|
|
105
|
+
if (result.note.compacted || result.note.actions) cacheable = Math.min(eunoe.previous, fixedChars + commonPrefix(eunoe.sent, result.messages))
|
|
106
|
+
eunoe.sent = result.messages
|
|
107
|
+
eunoe.sum = eunoe.sent.reduce((sum, m) => sum + charsOf(m), 0)
|
|
108
|
+
eunoe.count = eunoe.sent.length
|
|
109
|
+
eunoe.compactedTurn = turn
|
|
110
|
+
}
|
|
111
|
+
const chars = fixedChars + listChars(eunoe.sum, eunoe.count) + eunoe.recall
|
|
112
|
+
out.eunoeTokens += tokens(chars - fixedChars)
|
|
113
|
+
out.peakEunoe = Math.max(out.peakEunoe, tokens(chars))
|
|
114
|
+
out.eunoeCost += cost(chars, cacheable)
|
|
115
|
+
eunoe.previous = chars
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const sendPlain = (i: number) => {
|
|
119
|
+
for (; plain.since < i; plain.since += 1) {
|
|
120
|
+
plain.sum += sizes[plain.since]
|
|
121
|
+
plain.count += 1
|
|
122
|
+
}
|
|
123
|
+
let chars = fixedChars + listChars(plain.sum, plain.count)
|
|
124
|
+
let cacheable = plain.previous
|
|
125
|
+
out.peakPlain = Math.max(out.peakPlain, tokens(chars))
|
|
126
|
+
if (tokens(chars) > REPLAY_NATIVE_COMPACT_AT * window) {
|
|
127
|
+
out.nativeCompactions += 1
|
|
128
|
+
out.discarded += tokens(chars - fixedChars)
|
|
129
|
+
plain.sum = NATIVE_SUMMARY_CHARS
|
|
130
|
+
plain.count = 1
|
|
131
|
+
chars = fixedChars + listChars(plain.sum, plain.count)
|
|
132
|
+
cacheable = fixedChars
|
|
133
|
+
}
|
|
134
|
+
out.plainTokens += tokens(chars - fixedChars)
|
|
135
|
+
out.plainCost += cost(chars, cacheable)
|
|
136
|
+
plain.previous = chars
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (let i = 1; i < messages.length; i += 1) {
|
|
140
|
+
if (messages[i].role !== "assistant") continue
|
|
141
|
+
out.requests += 1
|
|
142
|
+
sendEunoe(i)
|
|
143
|
+
sendPlain(i)
|
|
144
|
+
}
|
|
145
|
+
return out
|
|
146
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
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 ReplayFailure, 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
|
+
try {
|
|
13
|
+
const replay = replaySession(sessionMessages(file), options)
|
|
14
|
+
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) }
|
|
15
|
+
postMessage(done)
|
|
16
|
+
} catch (error) {
|
|
17
|
+
const failed: ReplayFailure = { file, error: String(error) }
|
|
18
|
+
postMessage(failed)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
import { claudeConfigDirs, readConfig } from "../../config"
|
|
3
|
+
import { FOLD_CAP_CHARS, foldedView, sourceChunks } from "../../context/fold"
|
|
4
|
+
import { recallFor } from "../../context/recall"
|
|
5
|
+
import { DEFAULT_RATIO } from "../../proxy/estimate"
|
|
6
|
+
import { continueToken } from "../../proxy/handoff"
|
|
7
|
+
import { seenSessions } from "../../proxy/sessions"
|
|
8
|
+
import { isSessionId } from "../../transcript/locate"
|
|
9
|
+
import { subagentChunks } from "../../transcript/subagents"
|
|
10
|
+
import { flagValues } from "../terminal/args"
|
|
11
|
+
import { ago, fail, tokens } from "../terminal/ui"
|
|
12
|
+
import { type SessionCandidate, sessionCandidates } from "./source"
|
|
13
|
+
|
|
14
|
+
const ASK_CHARS = 160
|
|
15
|
+
|
|
16
|
+
export const shortId = (sessionId: string) => sessionId.slice(0, 8)
|
|
17
|
+
|
|
18
|
+
function byPrefix<T>(input: string, items: T[], idOf: (item: T) => string): T | null {
|
|
19
|
+
if (!isSessionId(input)) fail(`Not a session id: ${input}`)
|
|
20
|
+
const exact = items.find((item) => idOf(item) === input)
|
|
21
|
+
if (exact) return exact
|
|
22
|
+
const matches = items.filter((item) => idOf(item).startsWith(input))
|
|
23
|
+
if (matches.length > 1) fail(`${input} matches ${matches.length} sessions`, "Use more of the id.")
|
|
24
|
+
return matches[0] ?? null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function sessionArgs(args: string[]): string[] {
|
|
28
|
+
const seen = seenSessions().map(([id]) => id)
|
|
29
|
+
return [...new Set(flagValues(args, "--session").map((input) => byPrefix(input, seen, (id) => id) ?? input))]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isKnown(sessionId: string) {
|
|
33
|
+
return seenSessions().some(([id]) => id === sessionId)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const candidatesHere = () => sessionCandidates(process.env.CLAUDE_CODE_SESSION_ID ?? "", process.cwd(), claudeConfigDirs(readConfig()))
|
|
37
|
+
|
|
38
|
+
export function candidateOf(input: string | undefined, candidates: SessionCandidate[]): SessionCandidate {
|
|
39
|
+
if (!input) fail("Say which session: eunoe sessions lists them.")
|
|
40
|
+
return byPrefix(input, candidates, (cand) => cand.id) ?? fail(`No earlier session ${input} in this directory.`, "eunoe sessions lists them.")
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const tokensOf = (cand: SessionCandidate) => tokens(Math.round(foldedView(cand.messages, FOLD_CAP_CHARS).length * DEFAULT_RATIO))
|
|
44
|
+
|
|
45
|
+
export const titleOf = (cand: SessionCandidate) => cand.label || shortId(cand.id)
|
|
46
|
+
|
|
47
|
+
export const metaOf = (cand: SessionCandidate) => `${cand.asks.length} turns · ~${tokensOf(cand)} tokens · ${ago(new Date(cand.mtime).toISOString())}`
|
|
48
|
+
|
|
49
|
+
export function askOf(text: string | undefined): string {
|
|
50
|
+
const flat = (text ?? "").replace(/\s+/g, " ").trim()
|
|
51
|
+
if (flat.length <= ASK_CHARS) return flat
|
|
52
|
+
const cut = flat.slice(0, ASK_CHARS)
|
|
53
|
+
return `${cut.slice(0, Math.max(cut.lastIndexOf(" "), ASK_CHARS / 2))}…`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const continuePrompt = (id: string) => continueToken(shortId(id))
|
|
57
|
+
|
|
58
|
+
export function copy(text: string): boolean {
|
|
59
|
+
const clipboards: Array<[string, string[]]> = [
|
|
60
|
+
["pbcopy", []],
|
|
61
|
+
["wl-copy", []],
|
|
62
|
+
["xclip", ["-selection", "clipboard"]],
|
|
63
|
+
]
|
|
64
|
+
return clipboards.some(([cmd, args]) => {
|
|
65
|
+
const result = spawnSync(cmd, args, { input: text })
|
|
66
|
+
return !result.error && result.status === 0
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function peekText(cand: SessionCandidate, words: string): string | null {
|
|
71
|
+
const chunks = [...sourceChunks(cand.messages, titleOf(cand), true), ...subagentChunks(cand.file)]
|
|
72
|
+
const found = recallFor(words, chunks, { maxChars: 12_000, maxCharsMulti: 12_000, perQuery: 6, minTerms: 1 })
|
|
73
|
+
if (!found) return null
|
|
74
|
+
const excerpts = found.replace(/^<recalled-context>\n[^\n]*\n\n|\n<\/recalled-context>$/g, "")
|
|
75
|
+
return `Excerpts of session ${shortId(cand.id)} "${titleOf(cand)}" that match "${words}", quoted exactly, oldest first:\n\n${excerpts}`
|
|
76
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { statSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { type Message, textOf } from "../../context/content"
|
|
4
|
+
import { isPrompt, splitTurns } from "../../context/fold/turns"
|
|
5
|
+
import { type RecallChunk, rank, terms } from "../../context/recall"
|
|
6
|
+
import { cwdOf, seenSessions, titleOf } from "../../proxy/sessions"
|
|
7
|
+
import { findSessionFile, listEntries, projectFolderName } from "../../transcript/locate"
|
|
8
|
+
import { sessionMessages } from "../../transcript/messages"
|
|
9
|
+
|
|
10
|
+
export interface SessionCandidate {
|
|
11
|
+
id: string
|
|
12
|
+
file: string
|
|
13
|
+
label: string
|
|
14
|
+
mtime: number
|
|
15
|
+
asks: string[]
|
|
16
|
+
outline: string
|
|
17
|
+
messages: Message[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const SESSION_SOURCE_MIN_TURNS = 2
|
|
21
|
+
export const SESSION_SOURCE_CANDIDATES = 12
|
|
22
|
+
export const SESSION_MIN_TERMS = 2
|
|
23
|
+
|
|
24
|
+
const promptText = (message: Message) =>
|
|
25
|
+
textOf(message)
|
|
26
|
+
.replace(/<[a-z-]+(?:\s[^>]*)?>[\s\S]*?<\/[a-z-]+>/g, "")
|
|
27
|
+
.trim()
|
|
28
|
+
|
|
29
|
+
export function outlineOf(messages: Message[]): string[] {
|
|
30
|
+
return splitTurns(messages)
|
|
31
|
+
.turns.filter((turn) => isPrompt(turn[0]))
|
|
32
|
+
.map((turn) => promptText(turn[0]))
|
|
33
|
+
.filter(Boolean)
|
|
34
|
+
.map((text) => text.slice(0, 240))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sessionFiles(sessionId: string, cwd: string, configDirs: string[]): Array<{ id: string; file: string; mtime: number }> {
|
|
38
|
+
const files = new Map<string, { file: string; mtime: number }>()
|
|
39
|
+
const add = (id: string, file: string) => {
|
|
40
|
+
if (id === sessionId || files.has(id)) return
|
|
41
|
+
try {
|
|
42
|
+
files.set(id, { file, mtime: statSync(file).mtimeMs })
|
|
43
|
+
} catch {}
|
|
44
|
+
}
|
|
45
|
+
for (const [id, seen] of seenSessions()) {
|
|
46
|
+
if (seen.cwd !== cwd) continue
|
|
47
|
+
const file = findSessionFile(id, configDirs)
|
|
48
|
+
if (file) add(id, file)
|
|
49
|
+
}
|
|
50
|
+
for (const dir of configDirs) {
|
|
51
|
+
const folder = path.join(dir, "projects", projectFolderName(cwd))
|
|
52
|
+
for (const name of listEntries(folder)) if (name.endsWith(".jsonl")) add(name.slice(0, -".jsonl".length), path.join(folder, name))
|
|
53
|
+
}
|
|
54
|
+
return [...files].map(([id, entry]) => ({ id, ...entry })).sort((a, b) => b.mtime - a.mtime)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function sessionCandidates(sessionId: string, cwd: string, configDirs: string[]): SessionCandidate[] {
|
|
58
|
+
const out: SessionCandidate[] = []
|
|
59
|
+
for (const { id, file, mtime } of sessionFiles(sessionId, cwd, configDirs)) {
|
|
60
|
+
const recorded = cwdOf(file)
|
|
61
|
+
if (recorded && recorded !== cwd) continue
|
|
62
|
+
const messages = sessionMessages(file)
|
|
63
|
+
const asks = outlineOf(messages)
|
|
64
|
+
if (asks.length < SESSION_SOURCE_MIN_TURNS) continue
|
|
65
|
+
out.push({ id, file, label: titleOf(file) ?? "", mtime, asks, outline: asks.map((ask, i) => `${i + 1}. ${ask}`).join("\n"), messages })
|
|
66
|
+
if (out.length >= SESSION_SOURCE_CANDIDATES) break
|
|
67
|
+
}
|
|
68
|
+
return out
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function rankCandidates(prompt: string, candidates: SessionCandidate[]): SessionCandidate[] {
|
|
72
|
+
const chunks: RecallChunk[] = candidates.map((cand, i) => ({ turn: i, call: cand.id, lines: [cand.outline], terms: terms(cand.outline) }))
|
|
73
|
+
return rank(terms(prompt), chunks, SESSION_MIN_TERMS).map((r) => candidates[r.index])
|
|
74
|
+
}
|