opencode-context-indicator 1.0.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/LICENSE +21 -0
- package/README.md +242 -0
- package/index.js +2367 -0
- package/lib/dedup.js +120 -0
- package/package.json +57 -0
- package/tui.tsx +180 -0
package/lib/dedup.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// lib/dedup.js
|
|
2
|
+
//
|
|
3
|
+
// Shared helpers for the local OpenCode plugins in ../ (V2 + V1 dual format).
|
|
4
|
+
//
|
|
5
|
+
// WHY THIS FILE LIVES IN A SUBDIRECTORY WITHOUT AN INDEX FILE
|
|
6
|
+
// The V2 plugin loader auto-discovers entries under the plugins/ directory.
|
|
7
|
+
// A *directory* entry is treated as a plugin only when it contains an index
|
|
8
|
+
// file (docs: ".opencode/plugins/example/index.ts"); a directory without one
|
|
9
|
+
// is ignored entirely (verified empirically against opencode 2.0.16:
|
|
10
|
+
// plugins/lib/index.js showed up as a plugin, plugins/libprobe2/dedup.js did
|
|
11
|
+
// not). Keeping the shared helpers in ./lib/dedup.js therefore lets other
|
|
12
|
+
// plugin files `import` them with a plain relative specifier — the documented
|
|
13
|
+
// multi-file plugin pattern — WITHOUT registering a phantom no-op plugin.
|
|
14
|
+
// The default export below is still a valid no-op plugin definition as
|
|
15
|
+
// defense-in-depth, so the file stays inert even if a future loader starts
|
|
16
|
+
// discovering non-index modules.
|
|
17
|
+
//
|
|
18
|
+
// EXPORTS
|
|
19
|
+
// claimOnce(key, dedupDir) atomic cross-instance / cross-process claim
|
|
20
|
+
// pruneDedup(dedupDir) delete claim markers older than DEDUP_TTL_MS
|
|
21
|
+
// pruneMap(map, maxSize) LRU-trim an in-process Map
|
|
22
|
+
// DEDUP_TTL_MS marker lifetime (24h)
|
|
23
|
+
//
|
|
24
|
+
// The claim mechanism is the exact one proven in token-usage-logger.js:
|
|
25
|
+
// OpenCode loads one plugin instance per loaded location and mirrors the public
|
|
26
|
+
// event stream to every instance, so without an atomic claim each logical event
|
|
27
|
+
// would be recorded once per location. An exclusive-create file marker ("wx")
|
|
28
|
+
// makes exactly one instance win. Fail-open: if the marker cannot be created
|
|
29
|
+
// for an unexpected reason the event is still recorded (never lose data over
|
|
30
|
+
// bookkeeping).
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
closeSync,
|
|
34
|
+
mkdirSync,
|
|
35
|
+
openSync,
|
|
36
|
+
readdirSync,
|
|
37
|
+
statSync,
|
|
38
|
+
unlinkSync,
|
|
39
|
+
} from "node:fs";
|
|
40
|
+
import { join } from "node:path";
|
|
41
|
+
|
|
42
|
+
export const DEDUP_TTL_MS = 24 * 60 * 60 * 1000; // prune claim markers after a day
|
|
43
|
+
// Time-throttled pruning. A claim directory can hold tens of thousands of
|
|
44
|
+
// markers (mostly legacy per-delta markers), and pruneDedup() does a
|
|
45
|
+
// synchronous readdirSync+statSync over the whole directory — running that in
|
|
46
|
+
// the event-loop hot path stalls the event stream. Pruning therefore runs at
|
|
47
|
+
// most once per PRUNE_MIN_INTERVAL_MS. The timestamp is seeded at module load
|
|
48
|
+
// so the first claim after startup does NOT trigger a scan (keeping startup /
|
|
49
|
+
// first events off the scan path); the first prune happens on the first claim
|
|
50
|
+
// at least PRUNE_MIN_INTERVAL_MS after load.
|
|
51
|
+
const PRUNE_MIN_INTERVAL_MS = 10 * 60 * 1000; // at most one prune / 10 minutes
|
|
52
|
+
let lastPruneAt = Date.now();
|
|
53
|
+
|
|
54
|
+
function logErr(msg, err) {
|
|
55
|
+
try {
|
|
56
|
+
console.error(`[plugin-lib] ${msg}:`, err);
|
|
57
|
+
} catch {
|
|
58
|
+
/* ignore */
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Trim an in-process Map to at most `maxSize` entries (insertion order).
|
|
63
|
+
export function pruneMap(map, maxSize) {
|
|
64
|
+
if (!map || map.size <= maxSize) return;
|
|
65
|
+
const excess = map.size - maxSize;
|
|
66
|
+
let removed = 0;
|
|
67
|
+
for (const key of map.keys()) {
|
|
68
|
+
if (removed >= excess) break;
|
|
69
|
+
map.delete(key);
|
|
70
|
+
removed++;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Atomic, cross-instance, cross-process exactly-once claim.
|
|
75
|
+
// Returns true when this caller won the claim (or on fail-open), false when the
|
|
76
|
+
// marker already existed (another instance already claimed it).
|
|
77
|
+
export function claimOnce(key, dedupDir) {
|
|
78
|
+
try {
|
|
79
|
+
mkdirSync(dedupDir, { recursive: true });
|
|
80
|
+
const safe = String(key).replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 200);
|
|
81
|
+
const fd = openSync(join(dedupDir, `${safe}.seen`), "wx");
|
|
82
|
+
closeSync(fd);
|
|
83
|
+
const now = Date.now();
|
|
84
|
+
if (now - lastPruneAt >= PRUNE_MIN_INTERVAL_MS) {
|
|
85
|
+
lastPruneAt = now;
|
|
86
|
+
pruneDedup(dedupDir);
|
|
87
|
+
}
|
|
88
|
+
return true;
|
|
89
|
+
} catch (err) {
|
|
90
|
+
if (err && err.code === "EEXIST") return false;
|
|
91
|
+
logErr("claimOnce failed (fail-open)", err);
|
|
92
|
+
return true; // fail open
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Delete claim markers older than DEDUP_TTL_MS. Never throws.
|
|
97
|
+
export function pruneDedup(dedupDir) {
|
|
98
|
+
try {
|
|
99
|
+
const now = Date.now();
|
|
100
|
+
for (const f of readdirSync(dedupDir)) {
|
|
101
|
+
try {
|
|
102
|
+
const p = join(dedupDir, f);
|
|
103
|
+
if (now - statSync(p).mtimeMs > DEDUP_TTL_MS) unlinkSync(p);
|
|
104
|
+
} catch {
|
|
105
|
+
/* ignore single file */
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
/* ignore (dir missing / transient) */
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Valid no-op plugin definition (see "WHY THIS FILE LIVES ..." above).
|
|
114
|
+
export default {
|
|
115
|
+
id: "plugin-lib",
|
|
116
|
+
async setup() {},
|
|
117
|
+
async server() {
|
|
118
|
+
return {};
|
|
119
|
+
},
|
|
120
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-context-indicator",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Real-time OpenCode context-window usage indicator: per-category token breakdown written to a log file, plus an optional TUI sidebar. Dual V1 + V2 plugin entry.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./tui": "./tui.tsx"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"index.js",
|
|
13
|
+
"lib/",
|
|
14
|
+
"tui.tsx",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"opencode",
|
|
23
|
+
"opencode-plugin",
|
|
24
|
+
"context",
|
|
25
|
+
"tokens",
|
|
26
|
+
"usage",
|
|
27
|
+
"tui",
|
|
28
|
+
"sidebar"
|
|
29
|
+
],
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"author": "VladSmr",
|
|
34
|
+
"repository": { "type": "git", "url": "git+https://github.com/VladSmr/opencode-context-indicator.git" },
|
|
35
|
+
"homepage": "https://github.com/VladSmr/opencode-context-indicator#readme",
|
|
36
|
+
"bugs": { "url": "https://github.com/VladSmr/opencode-context-indicator/issues" },
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@opencode/plugin": ">=2.0.16",
|
|
39
|
+
"@opentui/core": ">=0.5.12",
|
|
40
|
+
"@opentui/solid": ">=0.5.12",
|
|
41
|
+
"solid-js": ">=1.9.0"
|
|
42
|
+
},
|
|
43
|
+
"peerDependenciesMeta": {
|
|
44
|
+
"@opencode/plugin": {
|
|
45
|
+
"optional": true
|
|
46
|
+
},
|
|
47
|
+
"@opentui/core": {
|
|
48
|
+
"optional": true
|
|
49
|
+
},
|
|
50
|
+
"@opentui/solid": {
|
|
51
|
+
"optional": true
|
|
52
|
+
},
|
|
53
|
+
"solid-js": {
|
|
54
|
+
"optional": true
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
package/tui.tsx
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tui.tsx — optional OpenCode TUI sidebar for `opencode-context-indicator`.
|
|
3
|
+
*
|
|
4
|
+
* Exposed as the `./tui` package entry ("beside the main plugin for automatic
|
|
5
|
+
* loading"). OpenCode loads it only in the terminal TUI; it is NOT loaded in
|
|
6
|
+
* the Desktop app (Desktop exposes no TUI slot API). It renders the
|
|
7
|
+
* per-category context breakdown produced by the server plugin (index.js) into
|
|
8
|
+
* the session sidebar via `append: "sidebar.content"`.
|
|
9
|
+
*
|
|
10
|
+
* Data bridge: the server plugin rewrites
|
|
11
|
+
* %TEMP%/opencode-context-indicator-state.json
|
|
12
|
+
* (see writeStateFile() in index.js) on every breakdown update. This module
|
|
13
|
+
* ONLY reads that file and re-polls it once per second; a missing or corrupt
|
|
14
|
+
* file renders "no data yet". The main plugin never imports this file, so the
|
|
15
|
+
* absence of the OpenTUI peers cannot break the server/V1 path.
|
|
16
|
+
*
|
|
17
|
+
* Peer requirements (declared optional in package.json): @opentui/core,
|
|
18
|
+
* @opentui/solid, solid-js.
|
|
19
|
+
*/
|
|
20
|
+
import {Plugin, usePlugin} from "@opencode/plugin/tui"
|
|
21
|
+
import {createSignal, Show} from "solid-js"
|
|
22
|
+
import {readFileSync} from "node:fs"
|
|
23
|
+
import {tmpdir} from "node:os"
|
|
24
|
+
import {join} from "node:path"
|
|
25
|
+
|
|
26
|
+
const STATE_FILE = join(tmpdir(), "opencode-context-indicator-state.json")
|
|
27
|
+
const POLL_MS = 1000
|
|
28
|
+
const LABEL_WIDTH = 12
|
|
29
|
+
|
|
30
|
+
type Categories = {
|
|
31
|
+
user?: number
|
|
32
|
+
assistant?: number
|
|
33
|
+
reasoning?: number
|
|
34
|
+
toolArgs?: number
|
|
35
|
+
system?: number | null
|
|
36
|
+
toolSchemas?: number | null
|
|
37
|
+
other?: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type StateEntry = {
|
|
41
|
+
sessionID?: string
|
|
42
|
+
model?: string
|
|
43
|
+
providerID?: string
|
|
44
|
+
ctx?: number
|
|
45
|
+
input?: number
|
|
46
|
+
usable?: number | null
|
|
47
|
+
limit?: number | null
|
|
48
|
+
reasoning?: number
|
|
49
|
+
categories?: Categories
|
|
50
|
+
updatedAt?: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function fmt(n: number): string {
|
|
54
|
+
if (!Number.isFinite(n)) return "0"
|
|
55
|
+
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(Math.round(n))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function num(v: unknown): number {
|
|
59
|
+
return typeof v === "number" && Number.isFinite(v) ? v : 0
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Theme tokens are OpenTUI RGBA values (see ResolvedTheme.text.base/.muted);
|
|
63
|
+
// pass them straight to the renderer. Fall back to a named color when a token
|
|
64
|
+
// is absent so an unexpected theme shape can never blank the sidebar.
|
|
65
|
+
type Fg = string | object
|
|
66
|
+
function token(v: unknown, fallback: string): Fg {
|
|
67
|
+
return v && typeof v === "object" ? (v as Fg) : fallback
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Read the snapshot entry for one session. Never throws: a missing, empty or
|
|
71
|
+
// corrupt state file simply yields null ("no data yet").
|
|
72
|
+
function readEntry(sessionID: string | undefined): StateEntry | null {
|
|
73
|
+
if (!sessionID) return null
|
|
74
|
+
try {
|
|
75
|
+
const raw = readFileSync(STATE_FILE, "utf8")
|
|
76
|
+
const all = JSON.parse(raw) as Record<string, StateEntry>
|
|
77
|
+
if (!all || typeof all !== "object" || Array.isArray(all)) return null
|
|
78
|
+
const entry = all[sessionID]
|
|
79
|
+
return entry && typeof entry === "object" ? entry : null
|
|
80
|
+
} catch {
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function updatedAt(iso: string | undefined): string {
|
|
86
|
+
if (!iso) return "?"
|
|
87
|
+
try {
|
|
88
|
+
return new Date(iso).toLocaleTimeString()
|
|
89
|
+
} catch {
|
|
90
|
+
return "?"
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function Sidebar(props: { entry: () => StateEntry | null }) {
|
|
95
|
+
const context = usePlugin()
|
|
96
|
+
const base = () => token(context?.theme?.text?.base, "white")
|
|
97
|
+
const muted = () => token(context?.theme?.text?.muted, base())
|
|
98
|
+
|
|
99
|
+
const e = () => props.entry()
|
|
100
|
+
|
|
101
|
+
const denom = (): number | null => {
|
|
102
|
+
const x = e()
|
|
103
|
+
const d = x?.usable ?? x?.limit
|
|
104
|
+
return typeof d === "number" && d > 0 ? d : null
|
|
105
|
+
}
|
|
106
|
+
const pct = (v: number): string => {
|
|
107
|
+
const d = denom()
|
|
108
|
+
return d ? `${Math.round((v / d) * 100)}%` : ""
|
|
109
|
+
}
|
|
110
|
+
const line = (label: string, v: unknown): string => {
|
|
111
|
+
const n = num(v)
|
|
112
|
+
const l = (label + " ".repeat(LABEL_WIDTH)).slice(0, LABEL_WIDTH)
|
|
113
|
+
const p = pct(n)
|
|
114
|
+
return `${l}${fmt(n).padStart(7)}${p ? ` ${p.padStart(4)}` : ""}`
|
|
115
|
+
}
|
|
116
|
+
const header = (): string => {
|
|
117
|
+
const x = e()
|
|
118
|
+
if (!x) return ""
|
|
119
|
+
const d = denom()
|
|
120
|
+
return `${x.model ?? "?"} · ${fmt(num(x.ctx))}${
|
|
121
|
+
d ? ` / ${fmt(d)}` : ""
|
|
122
|
+
}${d ? ` (${pct(num(x.ctx))})` : ""}`
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<box flexDirection="column">
|
|
127
|
+
<text fg={base()}>{"Context"}</text>
|
|
128
|
+
<Show when={e()} fallback={<text fg={muted()}>{" no data yet"}</text>}>
|
|
129
|
+
<text fg={base()}>{header()}</text>
|
|
130
|
+
<text fg={muted()}>{line("user", e()?.categories?.user)}</text>
|
|
131
|
+
<text fg={muted()}>{line("assistant", e()?.categories?.assistant)}</text>
|
|
132
|
+
<text fg={muted()}>{line("reasoning", e()?.categories?.reasoning)}</text>
|
|
133
|
+
<text fg={muted()}>{line("tool args", e()?.categories?.toolArgs)}</text>
|
|
134
|
+
<text fg={muted()}>
|
|
135
|
+
{e()?.categories?.system == null
|
|
136
|
+
? `${"system".padEnd(LABEL_WIDTH)} n/a`
|
|
137
|
+
: line("system", e()?.categories?.system)}
|
|
138
|
+
</text>
|
|
139
|
+
<text fg={muted()}>
|
|
140
|
+
{e()?.categories?.toolSchemas == null
|
|
141
|
+
? `${"tool schemas".padEnd(LABEL_WIDTH)} n/a`
|
|
142
|
+
: line("tool schemas", e()?.categories?.toolSchemas)}
|
|
143
|
+
</text>
|
|
144
|
+
<text fg={muted()}>{line("other", e()?.categories?.other)}</text>
|
|
145
|
+
<text fg={muted()}>{`updated ${updatedAt(e()?.updatedAt)}`}</text>
|
|
146
|
+
</Show>
|
|
147
|
+
</box>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export default Plugin.define({
|
|
152
|
+
id: "context-indicator.tui",
|
|
153
|
+
setup(context) {
|
|
154
|
+
// Re-read the bridge file once per second and re-render reactively.
|
|
155
|
+
const [tick, setTick] = createSignal(0)
|
|
156
|
+
let currentSession: string | undefined
|
|
157
|
+
const entry = (): StateEntry | null => {
|
|
158
|
+
tick() // track the ticker so reads re-run every poll
|
|
159
|
+
return readEntry(currentSession)
|
|
160
|
+
}
|
|
161
|
+
const timer = setInterval(() => setTick((n) => n + 1), POLL_MS)
|
|
162
|
+
|
|
163
|
+
const unregister = context.ui.slot({
|
|
164
|
+
append: "sidebar.content",
|
|
165
|
+
render: ({ sessionID }) => {
|
|
166
|
+
currentSession = sessionID
|
|
167
|
+
return <Sidebar entry={entry} />
|
|
168
|
+
},
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
return () => {
|
|
172
|
+
clearInterval(timer)
|
|
173
|
+
try {
|
|
174
|
+
unregister?.()
|
|
175
|
+
} catch {
|
|
176
|
+
/* ignore */
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
})
|