@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.
Files changed (88) hide show
  1. package/README.md +68 -9
  2. package/package.json +18 -2
  3. package/src/cli/{types.ts → command.ts} +0 -4
  4. package/src/cli/commands/index.ts +13 -1
  5. package/src/cli/commands/load.ts +44 -0
  6. package/src/cli/commands/mode.ts +41 -33
  7. package/src/cli/commands/peek.ts +22 -0
  8. package/src/cli/commands/replay.ts +245 -0
  9. package/src/cli/commands/serve.ts +22 -7
  10. package/src/cli/commands/sessions.ts +70 -0
  11. package/src/cli/commands/settings.ts +79 -60
  12. package/src/cli/commands/start.ts +56 -21
  13. package/src/cli/commands/stats.ts +233 -0
  14. package/src/cli/commands/status.ts +98 -55
  15. package/src/cli/commands/stop.ts +8 -6
  16. package/src/cli/commands/uninstall.ts +10 -12
  17. package/src/cli/commands/watch.ts +16 -0
  18. package/src/cli/logo/art.ts +99 -0
  19. package/src/cli/logo/index.ts +12 -18
  20. package/src/cli/main.ts +3 -3
  21. package/src/cli/proxy/claude-settings.ts +69 -0
  22. package/src/cli/proxy/inspect.ts +67 -0
  23. package/src/cli/proxy/launchd.ts +53 -0
  24. package/src/cli/proxy/live.ts +57 -0
  25. package/src/cli/proxy/service.ts +53 -0
  26. package/src/cli/{stats.ts → proxy/stats.ts} +29 -16
  27. package/src/cli/proxy/watch.ts +60 -0
  28. package/src/cli/replay/index.ts +146 -0
  29. package/src/cli/replay/worker.ts +20 -0
  30. package/src/cli/sessions/browse.ts +88 -0
  31. package/src/cli/sessions/index.ts +76 -0
  32. package/src/cli/sessions/source.ts +74 -0
  33. package/src/cli/{help.ts → terminal/help.ts} +24 -8
  34. package/src/cli/terminal/prompt.ts +114 -0
  35. package/src/cli/{ui.ts → terminal/ui.ts} +55 -24
  36. package/src/cli/usage/index.ts +151 -0
  37. package/src/config/index.ts +99 -34
  38. package/src/config/paths.ts +17 -0
  39. package/src/context/content.ts +40 -14
  40. package/src/context/fold/evidence.ts +145 -0
  41. package/src/context/fold/index.ts +65 -0
  42. package/src/context/fold/strategies.ts +94 -0
  43. package/src/context/fold/turns.ts +76 -0
  44. package/src/context/prompts.ts +141 -22
  45. package/src/context/recall/index.ts +214 -0
  46. package/src/context/recall/rerank.ts +71 -0
  47. package/src/proxy/canonical.ts +17 -9
  48. package/src/proxy/compaction.ts +106 -53
  49. package/src/proxy/estimate.ts +14 -9
  50. package/src/proxy/fingerprint.ts +23 -9
  51. package/src/proxy/handoff.ts +71 -0
  52. package/src/proxy/log.ts +2 -2
  53. package/src/proxy/recall.ts +58 -0
  54. package/src/proxy/rewrite.ts +141 -119
  55. package/src/proxy/server.ts +108 -51
  56. package/src/proxy/sessions.ts +32 -14
  57. package/src/proxy/state.ts +17 -3
  58. package/src/proxy/usage.ts +50 -34
  59. package/src/proxy/window.ts +32 -34
  60. package/src/transcript/format.ts +88 -25
  61. package/src/transcript/locate.ts +38 -17
  62. package/src/transcript/messages.ts +3 -14
  63. package/src/transcript/parse.ts +137 -56
  64. package/src/transcript/render.ts +44 -17
  65. package/src/transcript/search.ts +20 -56
  66. package/src/transcript/subagents.ts +150 -0
  67. package/src/cli/claude-settings.ts +0 -42
  68. package/src/cli/constants.ts +0 -52
  69. package/src/cli/inspect.ts +0 -41
  70. package/src/cli/launchd.ts +0 -39
  71. package/src/cli/logo/pixels.ts +0 -34
  72. package/src/cli/prompt.ts +0 -91
  73. package/src/cli/service.ts +0 -33
  74. package/src/cli/sessions.ts +0 -25
  75. package/src/config/constants.ts +0 -39
  76. package/src/config/types.ts +0 -50
  77. package/src/context/constants.ts +0 -42
  78. package/src/context/evidence.ts +0 -92
  79. package/src/context/recall.ts +0 -155
  80. package/src/context/rerank.ts +0 -44
  81. package/src/context/strategies.ts +0 -89
  82. package/src/context/turns.ts +0 -87
  83. package/src/context/types.ts +0 -66
  84. package/src/proxy/constants.ts +0 -29
  85. package/src/proxy/types.ts +0 -82
  86. package/src/transcript/constants.ts +0 -15
  87. package/src/transcript/types.ts +0 -43
  88. /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 { STATUS_WINDOW_MS } from "./constants"
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 THREAD_SUFFIX = /-[0-9a-f]{24}$/
24
+ const sessionOf = (record: RequestRecord): string | null => (typeof record.thread === "string" ? record.thread.replace(/-[0-9a-f]{24}$/, "") : null)
25
25
 
26
- export const sessionOf = (record: RequestRecord): string | null => (typeof record.thread === "string" ? record.thread.replace(THREAD_SUFFIX, "") : null)
26
+ const inputOf = (record: RequestRecord) => record.input ?? 0
27
27
 
28
- const plainTokens = (record: RequestRecord): number | null => (record.charsIn > 0 && record.charsOut > 0 && record.input > 0 ? Math.round(record.input * record.charsIn / record.charsOut) : null)
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 = STATUS_WINDOW_MS): RequestRecord[] {
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 || !(record.input > 0)) continue
47
+ if (!id || inputOf(record) <= 0) continue
39
48
  const previous = stats.get(id)
40
49
  stats.set(id, {
41
- context: record.input,
42
- plain: plainTokens(record),
43
- turns: typeof record.turns === "number" ? record.turns : previous?.turns ?? null,
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 (!(record.input > 0)) continue
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 plain = plainTokens(record)
65
- if (plain !== null) {
66
- out.sent += record.input
67
- out.saved += plain - record.input
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 const savedShare = (context: number, plain: number | null): number | null => (plain && plain > context ? 1 - context / plain : plain ? 0 : null)
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
+ }