@chroxify/eunoe 1.0.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 +55 -9
- package/package.json +18 -2
- package/src/cli/{types.ts → command.ts} +0 -4
- package/src/cli/commands/index.ts +11 -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 +194 -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 +27 -18
- 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 +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/{stats.ts → proxy/stats.ts} +23 -15
- 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/{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/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,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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -1,20 +1,36 @@
|
|
|
1
|
-
import { VERSION } from "
|
|
2
|
-
import {
|
|
3
|
-
import { withLogo } from "
|
|
4
|
-
import type { Command } from "./types"
|
|
1
|
+
import { VERSION } from "../../version"
|
|
2
|
+
import type { Command, OptionRow } from "../command"
|
|
3
|
+
import { withLogo } from "../logo"
|
|
5
4
|
import { c, line } from "./ui"
|
|
6
5
|
|
|
6
|
+
const GROUPS: Array<[title: string, names: string[]]> = [
|
|
7
|
+
["Proxy", ["start", "stop", "status", "replay", "uninstall"]],
|
|
8
|
+
["Context", ["mode", "settings"]],
|
|
9
|
+
["Sessions", ["sessions"]],
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
const GLOBAL_OPTIONS: OptionRow[] = [
|
|
13
|
+
["-h, --help", "Show help for a command"],
|
|
14
|
+
["-v, --version", "Show the version"],
|
|
15
|
+
]
|
|
16
|
+
|
|
7
17
|
export function printHelp(commands: Record<string, Command>) {
|
|
8
18
|
const width = Math.max(...Object.keys(commands).map((name) => name.length), ...GLOBAL_OPTIONS.map(([flag]) => flag.length))
|
|
9
19
|
line()
|
|
10
|
-
for (const row of withLogo([`${c.bold("eunoe")} ${c.dim(VERSION)}`, c.italic(
|
|
20
|
+
for (const row of withLogo([`${c.bold("eunoe")} ${c.dim(VERSION)}`, c.italic("Context management for Claude Code that never forgets what matters.")])) line(row)
|
|
11
21
|
line()
|
|
12
22
|
line(` ${c.dim("Usage")}`)
|
|
13
23
|
line(` eunoe ${c.cyan("<command>")} ${c.dim("[options]")}`)
|
|
14
24
|
line()
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
25
|
+
const visible = Object.keys(commands).filter((name) => !commands[name].hidden)
|
|
26
|
+
const grouped = new Set(GROUPS.flatMap(([, names]) => names))
|
|
27
|
+
for (const [title, names] of [...GROUPS, ["Other", visible.filter((name) => !grouped.has(name))] as const]) {
|
|
28
|
+
const shown = names.filter((name) => visible.includes(name))
|
|
29
|
+
if (shown.length === 0) continue
|
|
30
|
+
line(` ${c.dim(title)}`)
|
|
31
|
+
for (const name of shown) line(` ${c.cyan(name.padEnd(width))} ${commands[name].summary}`)
|
|
32
|
+
line()
|
|
33
|
+
}
|
|
18
34
|
line(` ${c.dim("Options")}`)
|
|
19
35
|
for (const [flag, description] of GLOBAL_OPTIONS) line(` ${flag.padEnd(width)} ${description}`)
|
|
20
36
|
line()
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { c, useColor } from "./ui"
|
|
2
|
+
|
|
3
|
+
const ESC = "\x1b"
|
|
4
|
+
const KEY_SEQUENCE = new RegExp(`${ESC}\\[[0-9;]*[A-Za-z~]|${ESC}.|[\\s\\S]`, "g")
|
|
5
|
+
|
|
6
|
+
interface Choice<T extends string = string> {
|
|
7
|
+
value: T
|
|
8
|
+
label?: string
|
|
9
|
+
hint?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const keys = {
|
|
13
|
+
up: "\x1b[A",
|
|
14
|
+
down: "\x1b[B",
|
|
15
|
+
enter: ["\r", "\n"],
|
|
16
|
+
escape: ["\x1b", "\x03"],
|
|
17
|
+
backspace: ["\x7f", "\b"],
|
|
18
|
+
clear: "\x15",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY)
|
|
22
|
+
|
|
23
|
+
export const printable = (key: string) => key >= " " && !key.startsWith("\x1b")
|
|
24
|
+
|
|
25
|
+
function readKeys(onKey: (key: string) => boolean | undefined): Promise<void> {
|
|
26
|
+
return new Promise((resolve) => {
|
|
27
|
+
const stdin = process.stdin
|
|
28
|
+
stdin.setRawMode(true)
|
|
29
|
+
stdin.resume()
|
|
30
|
+
stdin.setEncoding("utf8")
|
|
31
|
+
const handler = (chunk: string) => {
|
|
32
|
+
for (const key of chunk.match(KEY_SEQUENCE) ?? []) {
|
|
33
|
+
if (onKey(key) !== true) continue
|
|
34
|
+
stdin.off("data", handler)
|
|
35
|
+
stdin.setRawMode(false)
|
|
36
|
+
stdin.pause()
|
|
37
|
+
resolve()
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
stdin.on("data", handler)
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function interact(render: () => string[], onKey: (key: string) => boolean | undefined, hideCursor = true): Promise<void> {
|
|
46
|
+
let drawn = 0
|
|
47
|
+
const draw = () => {
|
|
48
|
+
const lines = render()
|
|
49
|
+
if (drawn) process.stdout.write(`\x1b[${drawn}A\x1b[J`)
|
|
50
|
+
process.stdout.write(`${lines.join("\n")}\n`)
|
|
51
|
+
drawn = lines.length
|
|
52
|
+
}
|
|
53
|
+
const toggleCursor = hideCursor && useColor
|
|
54
|
+
if (toggleCursor) process.stdout.write("\x1b[?25l")
|
|
55
|
+
draw()
|
|
56
|
+
await readKeys((key) => {
|
|
57
|
+
if (onKey(key) === true) return true
|
|
58
|
+
draw()
|
|
59
|
+
})
|
|
60
|
+
process.stdout.write(`\x1b[${drawn}A\x1b[J`)
|
|
61
|
+
if (toggleCursor) process.stdout.write("\x1b[?25h")
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function renderChoices(title: string, choices: Choice[], index: number, width: number) {
|
|
65
|
+
const lines = [` ${c.bold(title)}`, ""]
|
|
66
|
+
for (const [i, choice] of choices.entries()) {
|
|
67
|
+
const label = (choice.label ?? choice.value).padEnd(width)
|
|
68
|
+
const marker = i === index ? c.green("●") : c.dim("○")
|
|
69
|
+
lines.push(` ${marker} ${i === index ? c.bold(label) : label}${choice.hint ? ` ${c.dim(choice.hint)}` : ""}`)
|
|
70
|
+
}
|
|
71
|
+
lines.push("", ` ${c.dim("↑↓ move · enter choose · esc cancel")}`)
|
|
72
|
+
return lines
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function select<T extends string>(title: string, choices: Choice<T>[], current?: T): Promise<T | null> {
|
|
76
|
+
let index = Math.max(
|
|
77
|
+
0,
|
|
78
|
+
choices.findIndex((choice) => choice.value === current),
|
|
79
|
+
)
|
|
80
|
+
const width = Math.max(...choices.map((choice) => (choice.label ?? choice.value).length))
|
|
81
|
+
let chosen: T | null = null
|
|
82
|
+
await interact(
|
|
83
|
+
() => renderChoices(title, choices, index, width),
|
|
84
|
+
(key) => {
|
|
85
|
+
if (key === keys.up || key === "k") index = (index + choices.length - 1) % choices.length
|
|
86
|
+
else if (key === keys.down || key === "j") index = (index + 1) % choices.length
|
|
87
|
+
else if (keys.enter.includes(key)) {
|
|
88
|
+
chosen = choices[index].value
|
|
89
|
+
return true
|
|
90
|
+
} else if (keys.escape.includes(key) || key === "q") return true
|
|
91
|
+
},
|
|
92
|
+
)
|
|
93
|
+
return chosen
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function input(title: string, placeholder: string, initial = ""): Promise<string | null> {
|
|
97
|
+
let value = initial
|
|
98
|
+
let result: string | null = null
|
|
99
|
+
const render = () => [` ${c.bold(title)}`, ` ${c.cyan("›")} ${value || c.dim(placeholder)}`, "", ` ${c.dim("enter save · esc cancel")}`]
|
|
100
|
+
await interact(
|
|
101
|
+
render,
|
|
102
|
+
(key) => {
|
|
103
|
+
if (keys.enter.includes(key)) {
|
|
104
|
+
result = value
|
|
105
|
+
return true
|
|
106
|
+
}
|
|
107
|
+
if (keys.escape.includes(key)) return true
|
|
108
|
+
if (keys.backspace.includes(key)) value = value.slice(0, -1)
|
|
109
|
+
else if (printable(key)) value += key
|
|
110
|
+
},
|
|
111
|
+
false,
|
|
112
|
+
)
|
|
113
|
+
return result
|
|
114
|
+
}
|