tktop-tui 0.3.0__py3-none-any.whl
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.
- tktop_tui/__init__.py +72 -0
- tktop_tui/tui/LICENSE +21 -0
- tktop_tui/tui/bin/tktop +16 -0
- tktop_tui/tui/package.json +47 -0
- tktop_tui/tui/src/adapters/claude-code.ts +143 -0
- tktop_tui/tui/src/adapters/codex.ts +131 -0
- tktop_tui/tui/src/adapters/generic-jsonl.ts +170 -0
- tktop_tui/tui/src/adapters/jsonl-tail.ts +114 -0
- tktop_tui/tui/src/adapters/opencode.ts +124 -0
- tktop_tui/tui/src/adapters/registry.ts +26 -0
- tktop_tui/tui/src/dump.ts +78 -0
- tktop_tui/tui/src/format.ts +86 -0
- tktop_tui/tui/src/index.ts +80 -0
- tktop_tui/tui/src/store.ts +327 -0
- tktop_tui/tui/src/types.ts +67 -0
- tktop_tui/tui/src/ui.ts +914 -0
- tktop_tui-0.3.0.dist-info/METADATA +70 -0
- tktop_tui-0.3.0.dist-info/RECORD +22 -0
- tktop_tui-0.3.0.dist-info/WHEEL +5 -0
- tktop_tui-0.3.0.dist-info/entry_points.txt +2 -0
- tktop_tui-0.3.0.dist-info/licenses/LICENSE +21 -0
- tktop_tui-0.3.0.dist-info/top_level.txt +1 -0
tktop_tui/__init__.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""tktop-tui: Python launcher for the tktop TUI.
|
|
2
|
+
|
|
3
|
+
`pip install tktop-tui` gives you the `tktop` command. The wheel bundles the
|
|
4
|
+
TypeScript TUI (src/ + bin/); on first run it is staged into a user cache dir
|
|
5
|
+
(~/.local/share/tktop/<version>) where `bun install` fetches its JavaScript
|
|
6
|
+
dependencies, then the TUI is exec'd under Bun. Read-only site-packages are
|
|
7
|
+
never touched.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from importlib.resources import as_file, files
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
VERSION = "0.3.0"
|
|
19
|
+
HOMEPAGE = "https://github.com/AlexKay28/tktop"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def find_bun() -> str | None:
|
|
23
|
+
"""Locate bun on PATH or in its default install location."""
|
|
24
|
+
found = shutil.which("bun")
|
|
25
|
+
if found:
|
|
26
|
+
return found
|
|
27
|
+
default = Path.home() / ".bun" / "bin" / "bun"
|
|
28
|
+
return str(default) if default.is_file() and os.access(default, os.X_OK) else None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def stage_tui() -> Path:
|
|
32
|
+
"""Copy the bundled TUI into a per-version cache dir (once per version)."""
|
|
33
|
+
base = Path.home() / ".local" / "share" / "tktop" / VERSION
|
|
34
|
+
marker = base / ".installed"
|
|
35
|
+
if marker.is_file():
|
|
36
|
+
return base
|
|
37
|
+
if base.exists():
|
|
38
|
+
shutil.rmtree(base)
|
|
39
|
+
base.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
src = files("tktop_tui").joinpath("tui")
|
|
41
|
+
with as_file(src) as src_path:
|
|
42
|
+
shutil.copytree(src_path, base)
|
|
43
|
+
marker.write_text("staged\n")
|
|
44
|
+
return base
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def main() -> None:
|
|
48
|
+
bun = find_bun()
|
|
49
|
+
if not bun:
|
|
50
|
+
print("tktop: bun is required but not installed.", file=sys.stderr)
|
|
51
|
+
print(" install it with: curl -fsSL https://bun.sh/install | bash", file=sys.stderr)
|
|
52
|
+
print(f" project: {HOMEPAGE}", file=sys.stderr)
|
|
53
|
+
raise SystemExit(1)
|
|
54
|
+
|
|
55
|
+
tui = stage_tui()
|
|
56
|
+
|
|
57
|
+
# fetch JS dependencies once per staged version (needs network on first run)
|
|
58
|
+
if not (tui / "node_modules").is_dir():
|
|
59
|
+
pkg = json.loads((tui / "package.json").read_text())
|
|
60
|
+
pkg.pop("devDependencies", None) # @types/bun is not needed at runtime
|
|
61
|
+
pkg.get("scripts", {}).pop("prepublishOnly", None)
|
|
62
|
+
(tui / "package.json").write_text(json.dumps(pkg))
|
|
63
|
+
r = subprocess.run([bun, "install", "--production"], cwd=tui)
|
|
64
|
+
if r.returncode != 0:
|
|
65
|
+
print("tktop: bun install failed (network reachable?).", file=sys.stderr)
|
|
66
|
+
raise SystemExit(1)
|
|
67
|
+
|
|
68
|
+
os.execv(bun, [bun, "run", str(tui / "src" / "index.ts"), *sys.argv[1:]])
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__":
|
|
72
|
+
main()
|
tktop_tui/tui/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AlexKay28
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
tktop_tui/tui/bin/tktop
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# tktop launcher — runs the TUI via Bun.
|
|
3
|
+
# Resolves the repo root from this script's own location, so the shim works
|
|
4
|
+
# from ~/.local/bin (symlinked anywhere) and survives repo moves.
|
|
5
|
+
set -eu
|
|
6
|
+
|
|
7
|
+
REPO_ROOT="$(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)"
|
|
8
|
+
export PATH="$HOME/.bun/bin:$PATH"
|
|
9
|
+
|
|
10
|
+
if ! command -v bun >/dev/null 2>&1; then
|
|
11
|
+
echo "tktop: bun is required but not installed." >&2
|
|
12
|
+
echo " install it with: curl -fsSL https://bun.sh/install | bash" >&2
|
|
13
|
+
exit 1
|
|
14
|
+
fi
|
|
15
|
+
|
|
16
|
+
exec bun run "$REPO_ROOT/src/index.ts" "$@"
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tktop",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Live htop-style TUI dashboard for AI token consumption across harnesses (OpenCode, Claude Code, Codex, generic JSONL)",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"cli",
|
|
8
|
+
"tui",
|
|
9
|
+
"tokens",
|
|
10
|
+
"llm",
|
|
11
|
+
"ai",
|
|
12
|
+
"monitoring",
|
|
13
|
+
"opentui",
|
|
14
|
+
"htop"
|
|
15
|
+
],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+ssh://git@github.com/AlexKay28/tktop.git"
|
|
20
|
+
},
|
|
21
|
+
"bin": {
|
|
22
|
+
"tktop": "bin/tktop"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"bin/",
|
|
26
|
+
"src/",
|
|
27
|
+
"docs/",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"engines": {
|
|
32
|
+
"bun": ">=1.1.0"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"start": "bun run src/index.ts",
|
|
36
|
+
"tktop": "bun run src/index.ts",
|
|
37
|
+
"dump": "bun run src/dump.ts",
|
|
38
|
+
"typecheck": "bun check",
|
|
39
|
+
"prepublishOnly": "bun run typecheck"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@opentui/core": "^0.8.1"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/bun": "latest"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code adapter — additive parsing of JSONL files under ~/.claude/projects.
|
|
3
|
+
* Auto-activates when ~/.claude exists. See docs/ADR-003 for caveats.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync } from "node:fs"
|
|
6
|
+
import { homedir } from "node:os"
|
|
7
|
+
import type { Adapter, SessionInfo, TokenCounts } from "../types"
|
|
8
|
+
import { JsonlTail } from "./jsonl-tail"
|
|
9
|
+
|
|
10
|
+
interface Acc {
|
|
11
|
+
id: string
|
|
12
|
+
title: string
|
|
13
|
+
directory: string
|
|
14
|
+
model: string
|
|
15
|
+
tokens: TokenCounts
|
|
16
|
+
cost: number
|
|
17
|
+
timeCreated: number
|
|
18
|
+
timeUpdated: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function newAcc(id: string): Acc {
|
|
22
|
+
return {
|
|
23
|
+
id,
|
|
24
|
+
title: "",
|
|
25
|
+
directory: "",
|
|
26
|
+
model: "-",
|
|
27
|
+
tokens: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 },
|
|
28
|
+
cost: 0,
|
|
29
|
+
timeCreated: 0,
|
|
30
|
+
timeUpdated: 0,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class ClaudeCodeAdapter implements Adapter {
|
|
35
|
+
readonly harness = "claude-code"
|
|
36
|
+
lastError: string | null = null
|
|
37
|
+
|
|
38
|
+
private root: string
|
|
39
|
+
private tail: JsonlTail
|
|
40
|
+
private accs = new Map<string, Acc>()
|
|
41
|
+
private started = false
|
|
42
|
+
|
|
43
|
+
constructor(root?: string) {
|
|
44
|
+
this.root = root ?? `${homedir()}/.claude/projects`
|
|
45
|
+
this.tail = new JsonlTail(`${this.root}/**/*.jsonl`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
available(): boolean {
|
|
49
|
+
return existsSync(this.root)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private sid(obj: any, path: string): string {
|
|
53
|
+
if (obj.sessionId) return String(obj.sessionId)
|
|
54
|
+
const m = path.match(/([0-9a-f-]{36})(?:\.jsonl)?$/i)
|
|
55
|
+
if (m?.[1]) return m[1]
|
|
56
|
+
return path
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
poll(): SessionInfo[] {
|
|
60
|
+
try {
|
|
61
|
+
if (!this.started) {
|
|
62
|
+
this.tail.refreshFiles()
|
|
63
|
+
this.started = true
|
|
64
|
+
} else if (this.accs.size === 0 || Math.random() < 0.05) {
|
|
65
|
+
// occasionally re-scan for new project files
|
|
66
|
+
this.tail.refreshFiles()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
this.tail.read((obj, path) => this.onLine(obj, path))
|
|
70
|
+
|
|
71
|
+
const out: SessionInfo[] = []
|
|
72
|
+
for (const [sid, a] of this.accs) {
|
|
73
|
+
out.push({
|
|
74
|
+
key: `${this.harness}:${sid}`,
|
|
75
|
+
harness: this.harness,
|
|
76
|
+
id: sid,
|
|
77
|
+
title: a.title || (a.directory ? a.directory.split("/").filter(Boolean).pop() || sid.slice(0, 12) : sid.slice(0, 12)),
|
|
78
|
+
directory: a.directory,
|
|
79
|
+
model: a.model || "-",
|
|
80
|
+
archived: false,
|
|
81
|
+
tokens: a.tokens,
|
|
82
|
+
cost: a.cost,
|
|
83
|
+
timeCreated: a.timeCreated || a.timeUpdated,
|
|
84
|
+
timeUpdated: a.timeUpdated,
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
this.lastError = null
|
|
88
|
+
return out
|
|
89
|
+
} catch (e) {
|
|
90
|
+
this.lastError = (e as Error).message
|
|
91
|
+
return []
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private onLine(obj: any, path: string): void {
|
|
96
|
+
if (!obj || typeof obj !== "object") return
|
|
97
|
+
const sid = this.sid(obj, path)
|
|
98
|
+
let acc = this.accs.get(sid)
|
|
99
|
+
if (!acc) {
|
|
100
|
+
acc = newAcc(sid)
|
|
101
|
+
this.accs.set(sid, acc)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const ts = obj.timestamp ? (typeof obj.timestamp === "number" ? obj.timestamp : Date.parse(obj.timestamp)) : 0
|
|
105
|
+
if (ts > acc.timeUpdated) acc.timeUpdated = ts
|
|
106
|
+
if (acc.timeCreated === 0 || (ts > 0 && ts < acc.timeCreated)) acc.timeCreated = ts
|
|
107
|
+
if (obj.cwd) acc.directory = String(obj.cwd)
|
|
108
|
+
|
|
109
|
+
if (obj.type === "summary" && obj.summary) {
|
|
110
|
+
if (!acc.title) acc.title = String(obj.summary)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const msg = obj.message
|
|
115
|
+
if (!msg || typeof msg !== "object") return
|
|
116
|
+
|
|
117
|
+
if (obj.type === "user" && !acc.title) {
|
|
118
|
+
const c = msg.content
|
|
119
|
+
const text =
|
|
120
|
+
typeof c === "string"
|
|
121
|
+
? c
|
|
122
|
+
: Array.isArray(c)
|
|
123
|
+
? (c.find((p: any) => p?.type === "text")?.text ?? "")
|
|
124
|
+
: ""
|
|
125
|
+
if (text) acc.title = text.replace(/\s+/g, " ").slice(0, 80)
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (obj.type === "assistant") {
|
|
130
|
+
if (msg.model) acc.model = String(msg.model)
|
|
131
|
+
const u = msg.usage
|
|
132
|
+
if (u && typeof u === "object") {
|
|
133
|
+
acc.tokens.input += u.input_tokens ?? 0
|
|
134
|
+
acc.tokens.output += u.output_tokens ?? 0
|
|
135
|
+
acc.tokens.cacheRead += u.cache_read_input_tokens ?? 0
|
|
136
|
+
acc.tokens.cacheWrite += u.cache_creation_input_tokens ?? 0
|
|
137
|
+
// some history formats fold reasoning into output; record if present
|
|
138
|
+
acc.tokens.reasoning += u.reasoning_tokens ?? 0
|
|
139
|
+
}
|
|
140
|
+
if (typeof obj.costUSD === "number") acc.cost += obj.costUSD
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex CLI adapter — cumulative token_count events in
|
|
3
|
+
* ~/.codex/sessions JSONL files ("latest" semantics). See docs/ADR-003.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync } from "node:fs"
|
|
6
|
+
import { homedir } from "node:os"
|
|
7
|
+
import type { Adapter, SessionInfo, TokenCounts } from "../types"
|
|
8
|
+
import { JsonlTail } from "./jsonl-tail"
|
|
9
|
+
|
|
10
|
+
interface Acc {
|
|
11
|
+
id: string
|
|
12
|
+
title: string
|
|
13
|
+
directory: string
|
|
14
|
+
model: string
|
|
15
|
+
tokens: TokenCounts
|
|
16
|
+
cost: number
|
|
17
|
+
timeCreated: number
|
|
18
|
+
timeUpdated: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function newAcc(id: string): Acc {
|
|
22
|
+
return {
|
|
23
|
+
id,
|
|
24
|
+
title: "",
|
|
25
|
+
directory: "",
|
|
26
|
+
model: "-",
|
|
27
|
+
tokens: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 },
|
|
28
|
+
cost: 0,
|
|
29
|
+
timeCreated: 0,
|
|
30
|
+
timeUpdated: 0,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class CodexAdapter implements Adapter {
|
|
35
|
+
readonly harness = "codex"
|
|
36
|
+
lastError: string | null = null
|
|
37
|
+
|
|
38
|
+
private root: string
|
|
39
|
+
private tail: JsonlTail
|
|
40
|
+
private accs = new Map<string, Acc>()
|
|
41
|
+
private started = false
|
|
42
|
+
|
|
43
|
+
constructor(root?: string) {
|
|
44
|
+
this.root = root ?? `${homedir()}/.codex/sessions`
|
|
45
|
+
this.tail = new JsonlTail(`${this.root}/**/*.jsonl`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
available(): boolean {
|
|
49
|
+
return existsSync(this.root)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
poll(): SessionInfo[] {
|
|
53
|
+
try {
|
|
54
|
+
if (!this.started) {
|
|
55
|
+
this.tail.refreshFiles()
|
|
56
|
+
this.started = true
|
|
57
|
+
} else if (Math.random() < 0.05) {
|
|
58
|
+
this.tail.refreshFiles()
|
|
59
|
+
}
|
|
60
|
+
this.tail.read((obj, path) => this.onLine(obj, path))
|
|
61
|
+
|
|
62
|
+
const out: SessionInfo[] = []
|
|
63
|
+
for (const [sid, a] of this.accs) {
|
|
64
|
+
out.push({
|
|
65
|
+
key: `${this.harness}:${sid}`,
|
|
66
|
+
harness: this.harness,
|
|
67
|
+
id: sid,
|
|
68
|
+
title: a.title || sid.slice(0, 12),
|
|
69
|
+
directory: a.directory,
|
|
70
|
+
model: a.model || "-",
|
|
71
|
+
archived: false,
|
|
72
|
+
tokens: { ...a.tokens },
|
|
73
|
+
cost: a.cost,
|
|
74
|
+
timeCreated: a.timeCreated || a.timeUpdated,
|
|
75
|
+
timeUpdated: a.timeUpdated,
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
this.lastError = null
|
|
79
|
+
return out
|
|
80
|
+
} catch (e) {
|
|
81
|
+
this.lastError = (e as Error).message
|
|
82
|
+
return []
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private onLine(obj: any, path: string): void {
|
|
87
|
+
if (!obj || typeof obj !== "object") return
|
|
88
|
+
const payload = obj.payload
|
|
89
|
+
|
|
90
|
+
// session id: prefer payload.session_meta.id, else file stem
|
|
91
|
+
let sid: string | null = null
|
|
92
|
+
if (payload?.type === "session_meta" && payload.id) sid = String(payload.id)
|
|
93
|
+
if (!sid) {
|
|
94
|
+
const m = path.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i)
|
|
95
|
+
if (m?.[1]) sid = m[1]
|
|
96
|
+
}
|
|
97
|
+
if (!sid) return
|
|
98
|
+
|
|
99
|
+
let acc = this.accs.get(sid)
|
|
100
|
+
if (!acc) {
|
|
101
|
+
acc = newAcc(sid)
|
|
102
|
+
this.accs.set(sid, acc)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const ts = obj.timestamp ? (typeof obj.timestamp === "number" ? obj.timestamp : Date.parse(obj.timestamp)) : 0
|
|
106
|
+
if (ts > acc.timeUpdated) acc.timeUpdated = ts
|
|
107
|
+
if (acc.timeCreated === 0 || (ts > 0 && ts < acc.timeCreated)) acc.timeCreated = ts
|
|
108
|
+
|
|
109
|
+
const p = payload ?? {}
|
|
110
|
+
if (p.cwd) acc.directory = String(p.cwd)
|
|
111
|
+
if (p.model) acc.model = String(p.model)
|
|
112
|
+
|
|
113
|
+
if (p.type === "token_count") {
|
|
114
|
+
const info = p.info ?? p
|
|
115
|
+
// cumulative totals: overwrite, never sum
|
|
116
|
+
const total = info.total_token_usage ?? info.totalTokenUsage ?? info.usage
|
|
117
|
+
if (total && typeof total === "object") {
|
|
118
|
+
const pick = (...names: string[]): number => {
|
|
119
|
+
for (const n of names) if (typeof total[n] === "number") return total[n]
|
|
120
|
+
return 0
|
|
121
|
+
}
|
|
122
|
+
acc.tokens.input = pick("input_tokens", "inputTokens", "input")
|
|
123
|
+
acc.tokens.cacheRead = pick("cached_input_tokens", "cachedInputTokens", "cache_read", "cached")
|
|
124
|
+
acc.tokens.output = pick("output_tokens", "outputTokens", "output")
|
|
125
|
+
acc.tokens.reasoning = pick("reasoning_output_tokens", "reasoning_tokens", "reasoningOutputTokens", "reasoning")
|
|
126
|
+
}
|
|
127
|
+
} else if (p.type === "turn_context" && p.model) {
|
|
128
|
+
acc.model = String(p.model)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic JSONL adapter — operator-declared sources via
|
|
3
|
+
* ~/.config/tktop/adapters.json. See docs/ADR-003 for the config schema.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
6
|
+
import { homedir } from "node:os"
|
|
7
|
+
import type { Adapter, SessionInfo, TokenCounts } from "../types"
|
|
8
|
+
import { JsonlTail } from "./jsonl-tail"
|
|
9
|
+
|
|
10
|
+
/** Resolve dotted path "a.b.c" against an object; undefined when missing. */
|
|
11
|
+
function dig(obj: any, path: string | undefined): any {
|
|
12
|
+
if (!path) return undefined
|
|
13
|
+
let cur = obj
|
|
14
|
+
for (const seg of path.split(".")) {
|
|
15
|
+
if (cur == null || typeof cur !== "object") return undefined
|
|
16
|
+
cur = cur[seg]
|
|
17
|
+
}
|
|
18
|
+
return cur
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function toTime(v: any): number {
|
|
22
|
+
if (v == null) return 0
|
|
23
|
+
if (typeof v === "number") return v < 1e12 ? v * 1000 : v
|
|
24
|
+
const t = Date.parse(String(v))
|
|
25
|
+
return Number.isNaN(t) ? 0 : t
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface GenericSourceConfig {
|
|
29
|
+
harness: string
|
|
30
|
+
glob: string
|
|
31
|
+
sessionId?: string
|
|
32
|
+
model?: string
|
|
33
|
+
title?: string
|
|
34
|
+
directory?: string
|
|
35
|
+
timeUpdated?: string
|
|
36
|
+
usage?: string
|
|
37
|
+
tokens?: Partial<Record<keyof TokenCounts, string>>
|
|
38
|
+
cost?: string
|
|
39
|
+
mode?: "additive" | "latest"
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface Acc {
|
|
43
|
+
id: string
|
|
44
|
+
model: string
|
|
45
|
+
title: string
|
|
46
|
+
directory: string
|
|
47
|
+
tokens: TokenCounts
|
|
48
|
+
cost: number
|
|
49
|
+
timeUpdated: number
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function loadGenericConfig(path?: string): GenericSourceConfig[] {
|
|
53
|
+
const p = path ?? `${homedir()}/.config/tktop/adapters.json`
|
|
54
|
+
if (!existsSync(p)) return []
|
|
55
|
+
try {
|
|
56
|
+
const raw = JSON.parse(readFileSync(p, "utf8"))
|
|
57
|
+
return Array.isArray(raw?.sources) ? (raw.sources as GenericSourceConfig[]) : []
|
|
58
|
+
} catch {
|
|
59
|
+
return []
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class GenericJsonlAdapter implements Adapter {
|
|
64
|
+
lastError: string | null = null
|
|
65
|
+
|
|
66
|
+
readonly harness: string
|
|
67
|
+
private tail: JsonlTail
|
|
68
|
+
private accs = new Map<string, Acc>()
|
|
69
|
+
private additive: boolean
|
|
70
|
+
private started = false
|
|
71
|
+
|
|
72
|
+
constructor(private cfg: GenericSourceConfig) {
|
|
73
|
+
this.harness = cfg.harness || "generic"
|
|
74
|
+
this.tail = new JsonlTail(cfg.glob)
|
|
75
|
+
this.additive = (cfg.mode ?? "additive") === "additive"
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
available(): boolean {
|
|
79
|
+
this.tail.refreshFiles()
|
|
80
|
+
return this.tail.filePaths.length > 0
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
poll(): SessionInfo[] {
|
|
84
|
+
try {
|
|
85
|
+
if (!this.started) {
|
|
86
|
+
this.tail.refreshFiles()
|
|
87
|
+
this.started = true
|
|
88
|
+
} else {
|
|
89
|
+
this.tail.refreshFiles() // cheap enough for operator-defined source counts
|
|
90
|
+
}
|
|
91
|
+
this.tail.read((obj, path) => this.onLine(obj, path))
|
|
92
|
+
|
|
93
|
+
const out: SessionInfo[] = []
|
|
94
|
+
for (const [sid, a] of this.accs) {
|
|
95
|
+
out.push({
|
|
96
|
+
key: `${this.harness}:${sid}`,
|
|
97
|
+
harness: this.harness,
|
|
98
|
+
id: sid,
|
|
99
|
+
title: a.title || sid.slice(0, 12),
|
|
100
|
+
directory: a.directory,
|
|
101
|
+
model: a.model || "-",
|
|
102
|
+
archived: false,
|
|
103
|
+
tokens: { ...a.tokens },
|
|
104
|
+
cost: a.cost,
|
|
105
|
+
timeCreated: a.timeUpdated,
|
|
106
|
+
timeUpdated: a.timeUpdated,
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
this.lastError = null
|
|
110
|
+
return out
|
|
111
|
+
} catch (e) {
|
|
112
|
+
this.lastError = (e as Error).message
|
|
113
|
+
return []
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private onLine(obj: any, path: string): void {
|
|
118
|
+
if (!obj || typeof obj !== "object") return
|
|
119
|
+
const sid = String(dig(obj, this.cfg.sessionId) ?? path)
|
|
120
|
+
let acc = this.accs.get(sid)
|
|
121
|
+
if (!acc) {
|
|
122
|
+
acc = {
|
|
123
|
+
id: sid,
|
|
124
|
+
model: "",
|
|
125
|
+
title: "",
|
|
126
|
+
directory: "",
|
|
127
|
+
tokens: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 },
|
|
128
|
+
cost: 0,
|
|
129
|
+
timeUpdated: 0,
|
|
130
|
+
}
|
|
131
|
+
this.accs.set(sid, acc)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (this.cfg.model) {
|
|
135
|
+
const m = dig(obj, this.cfg.model)
|
|
136
|
+
if (m != null) acc.model = String(m)
|
|
137
|
+
}
|
|
138
|
+
if (this.cfg.title && !acc.title) {
|
|
139
|
+
const t = dig(obj, this.cfg.title)
|
|
140
|
+
if (t != null) acc.title = String(t).replace(/\s+/g, " ").slice(0, 80)
|
|
141
|
+
}
|
|
142
|
+
if (this.cfg.directory) {
|
|
143
|
+
const d = dig(obj, this.cfg.directory)
|
|
144
|
+
if (d != null) acc.directory = String(d)
|
|
145
|
+
}
|
|
146
|
+
const ts = toTime(dig(obj, this.cfg.timeUpdated))
|
|
147
|
+
if (ts > acc.timeUpdated) acc.timeUpdated = ts
|
|
148
|
+
|
|
149
|
+
const usage = this.cfg.usage ? dig(obj, this.cfg.usage) : obj
|
|
150
|
+
if (usage && typeof usage === "object") {
|
|
151
|
+
const map = this.cfg.tokens ?? {}
|
|
152
|
+
const keys: (keyof TokenCounts)[] = ["input", "output", "reasoning", "cacheRead", "cacheWrite"]
|
|
153
|
+
for (const k of keys) {
|
|
154
|
+
const field = map[k] ?? k
|
|
155
|
+
const v = dig(usage, field as string)
|
|
156
|
+
if (typeof v === "number") {
|
|
157
|
+
if (this.additive) acc.tokens[k] += v
|
|
158
|
+
else acc.tokens[k] = v
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (this.cfg.cost) {
|
|
163
|
+
const c = dig(obj, this.cfg.cost)
|
|
164
|
+
if (typeof c === "number") {
|
|
165
|
+
if (this.additive) acc.cost += c
|
|
166
|
+
else acc.cost = c
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|