@yunazgr/pi-companion 0.2.5 → 0.2.6
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 +30 -4
- package/package.json +1 -1
- package/src/bridge.ts +75 -12
- package/src/index.ts +15 -0
- package/src/protocol.ts +18 -3
- package/src/telemetry.ts +145 -0
package/README.md
CHANGED
|
@@ -153,7 +153,7 @@ Dialogs from other extensions (`ctx.ui.select`, `ctx.ui.confirm`, `ctx.ui.input`
|
|
|
153
153
|
|
|
154
154
|
Settings → **Notifications and camera** (on every device, not just the console) asks the browser for both permissions from a tap, as browsers require, and shows whether each is allowed, blocked or unavailable. Overview also offers a one-time "Turn on notifications" banner.
|
|
155
155
|
|
|
156
|
-
With notifications on, the device gets a system notification (through the service worker, so it works for installed apps and Android) when Pi asks a question, finishes a turn or a session ends. Tapping it opens that session. Nothing is sent while you are already looking at that session. iPhone and iPad only allow web notifications from an app added to the Home Screen. The camera is used only for the pairing QR scanner.
|
|
156
|
+
With notifications on, the device gets a system notification (through the service worker, so it works for installed apps and Android) when Pi asks a question, finishes a turn or a session ends. Tapping it opens that session. Nothing is sent while you are already looking at that session. iPhone and iPad only allow web notifications from an app added to the Home Screen. The camera is used only for the pairing QR scanner. Camera access requires HTTPS or localhost; plain HTTP LAN/phone links cannot prompt. After upgrading, restart the daemon and reload the app to refresh cached camera policies. Settings distinguishes browser denial, page/proxy policy blocks and transient camera errors, with retry or manual-code pairing guidance.
|
|
157
157
|
|
|
158
158
|
Activity no longer lives only in the open tab. The daemon keeps a bounded, in-memory log of each session's recent feed (streamed text is merged, up to 800 entries) at `GET /api/sessions/{id}/activity` on both surfaces (paired devices: shared sessions only). The UI rebuilds the feed from it when a session opens and after every reconnect or resync, so a phone that slept, dropped its connection or was closed catches up without sending a new message. Prompts, steers and answers from any device are added to that log too. The log is cleared when the daemon restarts or the session is archived.
|
|
159
159
|
|
|
@@ -178,11 +178,37 @@ Paired devices (name, browser user agent, pairing and last-seen times, credentia
|
|
|
178
178
|
|
|
179
179
|
A phone pairs once with the daemon. Individual Pi sessions still opt in using /companion.
|
|
180
180
|
|
|
181
|
-
Network loss and daemon restarts reconnect automatically with bounded backoff. Heartbeats detect silent connections, and returning to the foreground or restoring network access retries immediately. Pairing credentials and unsent drafts survive outages; session snapshots, pending questions, and previously loaded upload lists refresh on reconnect.
|
|
181
|
+
Network loss and daemon restarts reconnect automatically with bounded backoff. Heartbeats detect silent connections, and returning to the foreground or restoring network access retries immediately. Pairing credentials and unsent drafts survive outages; session snapshots, pending questions, and previously loaded upload lists refresh on reconnect. Recent activity is replayed from the daemon’s bounded log, but prompts/answers are never automatically resent. A deliberate **Disconnect** waits for the device’s explicit retry; **Revoke** removes access and requires pairing again.
|
|
182
|
+
|
|
183
|
+
## Session metadata and usage
|
|
184
|
+
|
|
185
|
+
Shared session title, model, effort and working directory refresh on Pi events and once per second while sharing, including while idle. Session detail shows context-window usage and estimated session cost. Native Pi context and recorded usage costs take precedence; independent extension reports fill unavailable fields. Missing usage is shown as unavailable, not zero.
|
|
186
|
+
|
|
187
|
+
**Settings → Usage** is available on the console and paired devices. It displays the latest snapshot per provider: weekly usage, 5-hour usage when reported, reset times, source and snapshot age. Quota snapshots are not added across sessions or accounts. These are extension-reported limits, not billing totals or locally inferred quotas; providers without a quota report remain unavailable.
|
|
188
|
+
|
|
189
|
+
Extensions can publish a provider-neutral event without importing Companion:
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
pi.events.emit("companion:telemetry", {
|
|
193
|
+
source: "my-usage-extension", // distinguish independent producers
|
|
194
|
+
// sessionId: ctx.sessionManager.getSessionId(), // optional session guard
|
|
195
|
+
context: { tokens: 24000, window: 200000, percent: 12 },
|
|
196
|
+
cost: { amount: 0.25, currency: "USD" },
|
|
197
|
+
providers: [{
|
|
198
|
+
provider: "openai-codex", updatedAt: new Date().toISOString(),
|
|
199
|
+
weekly: { usedPercent: 35, resetsAt: "2026-10-12T00:00:00Z" },
|
|
200
|
+
fiveHour: { usedPercent: 20 } // omit windows the provider doesn't supply
|
|
201
|
+
}]
|
|
202
|
+
});
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Publish complete snapshots per provider; session context and cost may be supplied independently by different extensions. Companion also accepts `usage:update`, `session:usage` and `provider:usage` with this shape. On opt-in and every 30 seconds it emits `companion:telemetry:request` with the Pi `sessionId` and `companionSessionId`, allowing adapters to respond with their cached snapshots. It never reads provider credentials or calls quota APIs itself. Extensions with private, non-exported quota data need an adapter to publish this contract.
|
|
206
|
+
|
|
207
|
+
As a legacy fallback, `ctx.ui.setStatus` reports with explicit `ctx 12%` / `context 12%` and `cost: $0.25` (or cost/usage/footer keys containing `$0.25`) are recognized. Quota status lines must identify `provider=…` and label `7d`/`weekly` and `5h`/`5-hour` percentages (`used`, `left`, or `remaining`). Unlabelled percentages mean used. Status rendering is preserved. Invalid reports are ignored; extension context/cost expires after five minutes without a fresh reading. Context estimates are also invalidated on model changes, compaction and tree navigation; quota snapshots retain their original timestamps and visibly age.
|
|
182
208
|
|
|
183
209
|
## Settings
|
|
184
210
|
|
|
185
|
-
The **Settings** page
|
|
211
|
+
The **Settings → General** page provides appearance and device permissions on every device; daemon configuration is local-console-only:
|
|
186
212
|
|
|
187
213
|
| Setting | Default | Notes |
|
|
188
214
|
|---|---|---|
|
|
@@ -326,7 +352,7 @@ Clone the repository, then:
|
|
|
326
352
|
|
|
327
353
|
`npm run serve` builds the embedded Svelte UI and starts the Rust daemon in the foreground. It prints:
|
|
328
354
|
|
|
329
|
-
Pi Companion v0.2.
|
|
355
|
+
Pi Companion v0.2.6
|
|
330
356
|
|
|
331
357
|
Console http://127.0.0.1:43721
|
|
332
358
|
Paired devices http://127.0.0.1:43722
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yunazgr/pi-companion",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"description": "Local-first remote control for Pi sessions: live activity feed, steering, git diff, file drop and phone pairing from a single Rust daemon.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
package/src/bridge.ts
CHANGED
|
@@ -5,6 +5,7 @@ import WebSocket from "ws";
|
|
|
5
5
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { relayDialogs, ToolDialogRelay, type AskChannel, type AskInput } from "./ask.js";
|
|
7
7
|
import { ensureDaemon } from "./daemon.js";
|
|
8
|
+
import { TelemetryRelay } from "./telemetry.js";
|
|
8
9
|
import type { AskAnswers, AskRequest, BridgeMessage, ServerMessage, SessionSnapshot, TempFile } from "./protocol.js";
|
|
9
10
|
|
|
10
11
|
const execFileAsync = promisify(execFile);
|
|
@@ -17,6 +18,10 @@ export class CompanionBridge implements AskChannel {
|
|
|
17
18
|
private ctx?: ExtensionContext;
|
|
18
19
|
private reconnect?: NodeJS.Timeout;
|
|
19
20
|
private heartbeat?: NodeJS.Timeout;
|
|
21
|
+
private metadataTimer?: NodeJS.Timeout;
|
|
22
|
+
private telemetryRequestedAt = 0;
|
|
23
|
+
private restoreStatus?: () => void;
|
|
24
|
+
private telemetry = new TelemetryRelay();
|
|
20
25
|
private closed = false;
|
|
21
26
|
private activated = false;
|
|
22
27
|
private restoreDialogs?: () => void;
|
|
@@ -44,22 +49,55 @@ export class CompanionBridge implements AskChannel {
|
|
|
44
49
|
setContext(ctx: ExtensionContext) {
|
|
45
50
|
this.ctx = ctx;
|
|
46
51
|
if (this.activated && this.snapshot.remoteEnabled && ctx.hasUI && !this.restoreDialogs) this.restoreDialogs = relayDialogs(ctx.ui, this, this.toolDialogs);
|
|
47
|
-
|
|
48
|
-
this.snapshot = {
|
|
49
|
-
...this.snapshot,
|
|
50
|
-
name,
|
|
51
|
-
cwd: ctx.cwd,
|
|
52
|
-
shortTitle: name?.trim() || ctx.cwd.split(/[\\/]/).filter(Boolean).pop() || "Pi",
|
|
53
|
-
mainModel: ctx.model?.id,
|
|
54
|
-
effort: ctx.thinkingLevel,
|
|
55
|
-
status: ctx.isIdle() ? "idle" : "active"
|
|
56
|
-
};
|
|
52
|
+
this.refreshMetadata();
|
|
57
53
|
}
|
|
58
54
|
|
|
59
55
|
setName(name?: string) {
|
|
60
|
-
this.snapshot.name = name;
|
|
56
|
+
this.snapshot.name = name ?? null;
|
|
61
57
|
this.snapshot.shortTitle = name?.trim() || this.snapshot.cwd.split(/[\\/]/).filter(Boolean).pop() || "Pi";
|
|
62
|
-
this.send({ type: "session.update", session: { name, shortTitle: this.snapshot.shortTitle } });
|
|
58
|
+
this.send({ type: "session.update", session: { name: name ?? null, shortTitle: this.snapshot.shortTitle } });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Context getters remain live while idle; send only changed metadata, including explicit clears. */
|
|
62
|
+
refreshMetadata() {
|
|
63
|
+
const ctx = this.ctx;
|
|
64
|
+
if (!ctx || this.closed) return;
|
|
65
|
+
const cwd = ctx.cwd || ctx.sessionManager?.getCwd?.() || process.cwd();
|
|
66
|
+
const name = this.pi.getSessionName() ?? null;
|
|
67
|
+
if ((this.snapshot.mainModel ?? null) !== (ctx.model?.id ?? null)) this.telemetry.invalidateContext();
|
|
68
|
+
const next = {
|
|
69
|
+
name, cwd,
|
|
70
|
+
shortTitle: name?.trim() || cwd.split(/[\\/]/).filter(Boolean).pop() || "Pi",
|
|
71
|
+
mainModel: ctx.model?.id ?? null,
|
|
72
|
+
effort: ctx.thinkingLevel ?? this.pi.getThinkingLevel?.() ?? null,
|
|
73
|
+
telemetry: this.telemetry.snapshot(ctx)
|
|
74
|
+
};
|
|
75
|
+
const patch: Partial<SessionSnapshot> = {};
|
|
76
|
+
for (const key of Object.keys(next) as Array<keyof typeof next>) {
|
|
77
|
+
if (JSON.stringify(this.snapshot[key]) !== JSON.stringify(next[key])) Object.assign(patch, { [key]: next[key] });
|
|
78
|
+
}
|
|
79
|
+
Object.assign(this.snapshot, patch);
|
|
80
|
+
if (Object.keys(patch).length) this.send({ type: "session.update", session: patch });
|
|
81
|
+
if (this.activated && this.snapshot.remoteEnabled && Date.now() - this.telemetryRequestedAt >= 30_000) this.requestTelemetry();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
invalidateContext() {
|
|
85
|
+
this.telemetry.invalidateContext();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
ingestTelemetry(value: unknown, source: string) {
|
|
89
|
+
if (!this.isActivated() || !this.snapshot.remoteEnabled) return;
|
|
90
|
+
const id = value && typeof value === "object" ? (value as { sessionId?: unknown }).sessionId : undefined;
|
|
91
|
+
if (id !== undefined && id !== this.sessionId && id !== this.ctx?.sessionManager?.getSessionId?.()) return;
|
|
92
|
+
this.telemetry.ingest(value, source);
|
|
93
|
+
this.refreshMetadata();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private requestTelemetry() {
|
|
97
|
+
this.telemetryRequestedAt = Date.now();
|
|
98
|
+
try {
|
|
99
|
+
this.pi.events?.emit("companion:telemetry:request", { sessionId: this.ctx?.sessionManager?.getSessionId?.(), companionSessionId: this.sessionId });
|
|
100
|
+
} catch { /* A failing third-party responder must never interrupt sharing. */ }
|
|
63
101
|
}
|
|
64
102
|
|
|
65
103
|
setRemoteEnabled(remoteEnabled: boolean) {
|
|
@@ -73,6 +111,10 @@ export class CompanionBridge implements AskChannel {
|
|
|
73
111
|
// End only Companion sharing: Pi itself and its local history keep running.
|
|
74
112
|
this.restoreDialogs?.();
|
|
75
113
|
this.restoreDialogs = undefined;
|
|
114
|
+
this.restoreStatus?.();
|
|
115
|
+
this.restoreStatus = undefined;
|
|
116
|
+
if (this.metadataTimer) clearInterval(this.metadataTimer);
|
|
117
|
+
this.metadataTimer = undefined;
|
|
76
118
|
for (const entry of [...this.asks.values()]) entry.settle(null);
|
|
77
119
|
for (const pending of this.pendingDeletes.values()) {
|
|
78
120
|
clearTimeout(pending.timer);
|
|
@@ -99,6 +141,23 @@ export class CompanionBridge implements AskChannel {
|
|
|
99
141
|
activate() {
|
|
100
142
|
this.activated = true;
|
|
101
143
|
if (this.snapshot.remoteEnabled && this.ctx?.hasUI && !this.restoreDialogs) this.restoreDialogs = relayDialogs(this.ctx.ui, this, this.toolDialogs);
|
|
144
|
+
if (!this.snapshot.remoteEnabled) return;
|
|
145
|
+
const ui = this.ctx?.ui;
|
|
146
|
+
if (ui && typeof ui.setStatus === "function" && !this.restoreStatus) {
|
|
147
|
+
const original = ui.setStatus;
|
|
148
|
+
const relay = this.telemetry;
|
|
149
|
+
const wrapped: typeof original = function(key, value) {
|
|
150
|
+
original.call(ui, key, value);
|
|
151
|
+
relay.status(key, value);
|
|
152
|
+
};
|
|
153
|
+
ui.setStatus = wrapped;
|
|
154
|
+
this.restoreStatus = () => { if (ui.setStatus === wrapped) ui.setStatus = original; };
|
|
155
|
+
}
|
|
156
|
+
if (!this.metadataTimer) {
|
|
157
|
+
this.metadataTimer = setInterval(() => this.refreshMetadata(), 1000);
|
|
158
|
+
this.metadataTimer.unref();
|
|
159
|
+
}
|
|
160
|
+
this.refreshMetadata();
|
|
102
161
|
}
|
|
103
162
|
|
|
104
163
|
isActivated() {
|
|
@@ -170,6 +229,10 @@ export class CompanionBridge implements AskChannel {
|
|
|
170
229
|
this.closed = true;
|
|
171
230
|
this.restoreDialogs?.();
|
|
172
231
|
this.restoreDialogs = undefined;
|
|
232
|
+
this.restoreStatus?.();
|
|
233
|
+
this.restoreStatus = undefined;
|
|
234
|
+
if (this.metadataTimer) clearInterval(this.metadataTimer);
|
|
235
|
+
this.metadataTimer = undefined;
|
|
173
236
|
for (const entry of [...this.asks.values()]) entry.settle(null);
|
|
174
237
|
if (this.reconnect) clearTimeout(this.reconnect);
|
|
175
238
|
if (this.heartbeat) clearTimeout(this.heartbeat);
|
package/src/index.ts
CHANGED
|
@@ -71,6 +71,11 @@ export default function companionExtension(pi: ExtensionAPI) {
|
|
|
71
71
|
let bridge = new CompanionBridge(pi);
|
|
72
72
|
let sharingGeneration = 0;
|
|
73
73
|
|
|
74
|
+
// Any extension can publish the neutral contract. No dependency on a particular footer/provider.
|
|
75
|
+
for (const channel of ["companion:telemetry", "usage:update", "session:usage", "provider:usage"]) {
|
|
76
|
+
pi.events?.on(channel, value => bridge.ingestTelemetry(value, channel));
|
|
77
|
+
}
|
|
78
|
+
|
|
74
79
|
pi.on("session_start", (_event, ctx) => {
|
|
75
80
|
// Switching or starting a Pi session must not inherit the previous opt-in.
|
|
76
81
|
sharingGeneration += 1;
|
|
@@ -84,6 +89,7 @@ export default function companionExtension(pi: ExtensionAPI) {
|
|
|
84
89
|
bridge.setName(event.name);
|
|
85
90
|
});
|
|
86
91
|
pi.on("model_select", (_event, ctx) => {
|
|
92
|
+
bridge.invalidateContext();
|
|
87
93
|
bridge.setContext(ctx);
|
|
88
94
|
});
|
|
89
95
|
pi.on("thinking_level_select", (_event, ctx) => {
|
|
@@ -99,6 +105,15 @@ export default function companionExtension(pi: ExtensionAPI) {
|
|
|
99
105
|
bridge.updateStatus("idle");
|
|
100
106
|
bridge.emit("agent.end", { messages: Array.isArray((event as { messages?: unknown[] }).messages) ? (event as { messages: unknown[] }).messages.length : 0 });
|
|
101
107
|
});
|
|
108
|
+
pi.on("message_end", (_event, ctx) => bridge.setContext(ctx));
|
|
109
|
+
pi.on("session_compact", (_event, ctx) => {
|
|
110
|
+
bridge.invalidateContext();
|
|
111
|
+
bridge.setContext(ctx);
|
|
112
|
+
});
|
|
113
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
114
|
+
bridge.invalidateContext();
|
|
115
|
+
bridge.setContext(ctx);
|
|
116
|
+
});
|
|
102
117
|
// Forward compact deltas instead of the full partial message on every token.
|
|
103
118
|
pi.on("message_update", event => {
|
|
104
119
|
const update = event.assistantMessageEvent;
|
package/src/protocol.ts
CHANGED
|
@@ -36,15 +36,30 @@ export type AskRequest = {
|
|
|
36
36
|
/** Answers keyed by question id; each value lists chosen option labels and/or custom text. */
|
|
37
37
|
export type AskAnswers = Record<string, string[]>;
|
|
38
38
|
|
|
39
|
+
export type UsageWindow = { usedPercent: number; resetsAt?: string };
|
|
40
|
+
export type ProviderUsage = {
|
|
41
|
+
provider: string;
|
|
42
|
+
source?: string;
|
|
43
|
+
updatedAt: string;
|
|
44
|
+
weekly?: UsageWindow;
|
|
45
|
+
fiveHour?: UsageWindow;
|
|
46
|
+
};
|
|
47
|
+
export type SessionTelemetry = {
|
|
48
|
+
context?: { tokens?: number; window?: number; percent?: number; source?: string };
|
|
49
|
+
cost?: { amount: number; currency?: string; source?: string };
|
|
50
|
+
providers?: ProviderUsage[];
|
|
51
|
+
};
|
|
52
|
+
|
|
39
53
|
export type SessionSnapshot = {
|
|
40
54
|
id: string;
|
|
41
|
-
name?: string;
|
|
55
|
+
name?: string | null;
|
|
42
56
|
cwd: string;
|
|
43
57
|
pid: number;
|
|
44
58
|
shortTitle: string;
|
|
45
59
|
status: "active" | "idle" | "stopped";
|
|
46
|
-
mainModel?: string;
|
|
47
|
-
effort?: string;
|
|
60
|
+
mainModel?: string | null;
|
|
61
|
+
effort?: string | null;
|
|
62
|
+
telemetry?: SessionTelemetry;
|
|
48
63
|
remoteEnabled: boolean;
|
|
49
64
|
connectedAt: string;
|
|
50
65
|
/** Questions waiting for an answer. Kept in the snapshot so late-joining browsers see them. */
|
package/src/telemetry.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ProviderUsage, SessionTelemetry, UsageWindow } from "./protocol.js";
|
|
3
|
+
|
|
4
|
+
const record = (value: unknown): Record<string, unknown> | undefined =>
|
|
5
|
+
value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
6
|
+
const number = (value: unknown): number | undefined => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
7
|
+
const text = (value: unknown): string | undefined => typeof value === "string" && value.trim() ? value.slice(0, 160) : undefined;
|
|
8
|
+
function timestamp(value: unknown): string | undefined {
|
|
9
|
+
const millis = typeof value === "number" ? (value < 1e12 ? value * 1000 : value) : typeof value === "string" ? Date.parse(value) : NaN;
|
|
10
|
+
return Number.isFinite(millis) && Math.abs(millis) <= 8.64e15 ? new Date(millis).toISOString() : undefined;
|
|
11
|
+
}
|
|
12
|
+
function usageWindow(value: unknown): UsageWindow | undefined {
|
|
13
|
+
const raw = record(value);
|
|
14
|
+
if (!raw) return;
|
|
15
|
+
const remaining = number(raw.remainingPercent);
|
|
16
|
+
const usedPercent = number(raw.usedPercent ?? raw.used_percent) ?? (remaining !== undefined && remaining <= 100 ? 100 - remaining : undefined);
|
|
17
|
+
if (usedPercent === undefined || usedPercent > 100) return;
|
|
18
|
+
return { usedPercent, resetsAt: timestamp(raw.resetsAt ?? raw.resetAt ?? raw.reset_at) };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Allowlisted, provider-neutral input; never relay arbitrary extension data or credentials. */
|
|
22
|
+
export function normalizeTelemetry(value: unknown, source = "extension", now = Date.now()): SessionTelemetry {
|
|
23
|
+
const raw = record(value);
|
|
24
|
+
if (!raw) return {};
|
|
25
|
+
const result: SessionTelemetry = {};
|
|
26
|
+
const context = record(raw.context ?? raw.contextUsage);
|
|
27
|
+
if (context) {
|
|
28
|
+
const tokens = number(context.tokens);
|
|
29
|
+
const window = number(context.window ?? context.contextWindow);
|
|
30
|
+
const percent = number(context.percent) ?? (tokens !== undefined && window ? tokens / window * 100 : undefined);
|
|
31
|
+
if (tokens !== undefined || percent !== undefined) result.context = { tokens, window, percent, source };
|
|
32
|
+
}
|
|
33
|
+
const cost = record(raw.cost);
|
|
34
|
+
const amount = number(cost?.amount ?? cost?.total ?? raw.cost);
|
|
35
|
+
if (amount !== undefined) result.cost = { amount, currency: text(cost?.currency) ?? "USD", source };
|
|
36
|
+
const providers = Array.isArray(raw.providers) ? raw.providers.slice(0, 100) : raw.provider ? [raw] : [];
|
|
37
|
+
const normalized: ProviderUsage[] = [];
|
|
38
|
+
for (const item of providers) {
|
|
39
|
+
const provider = record(item);
|
|
40
|
+
const name = text(provider?.provider);
|
|
41
|
+
if (!provider || !name) continue;
|
|
42
|
+
const weekly = usageWindow(provider.weekly ?? provider.sevenDay ?? provider.seven_day);
|
|
43
|
+
const fiveHour = usageWindow(provider.fiveHour ?? provider.five_hour);
|
|
44
|
+
if (!weekly && !fiveHour) continue;
|
|
45
|
+
normalized.push({ provider: name, source, updatedAt: timestamp(provider.updatedAt) ?? new Date(now).toISOString(), weekly, fiveHour });
|
|
46
|
+
}
|
|
47
|
+
if (normalized.length) result.providers = normalized;
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Independent sources may supply different fields; one broken/missing adapter cannot erase another. */
|
|
52
|
+
export class TelemetryRelay {
|
|
53
|
+
private sources = new Map<string, { telemetry: SessionTelemetry; contextAt?: number; costAt?: number }>();
|
|
54
|
+
|
|
55
|
+
ingest(value: unknown, fallbackSource = "extension", now = Date.now()) {
|
|
56
|
+
const raw = record(value);
|
|
57
|
+
const source = text(raw?.source) ?? fallbackSource;
|
|
58
|
+
const telemetry = normalizeTelemetry(raw?.telemetry ?? value, source, now);
|
|
59
|
+
if (!Object.keys(telemetry).length) return;
|
|
60
|
+
const previousEntry = this.sources.get(source);
|
|
61
|
+
const previous = previousEntry?.telemetry;
|
|
62
|
+
const providers = new Map(previous?.providers?.map(provider => [provider.provider, provider]));
|
|
63
|
+
for (const provider of telemetry.providers ?? []) {
|
|
64
|
+
const old = providers.get(provider.provider);
|
|
65
|
+
// A delayed adapter response must not roll a newer provider snapshot backward.
|
|
66
|
+
if (!old || Date.parse(provider.updatedAt) >= Date.parse(old.updatedAt)) providers.set(provider.provider, provider);
|
|
67
|
+
}
|
|
68
|
+
this.sources.delete(source);
|
|
69
|
+
this.sources.set(source, {
|
|
70
|
+
telemetry: { ...previous, ...telemetry, providers: [...providers.values()] },
|
|
71
|
+
contextAt: telemetry.context ? now : previousEntry?.contextAt,
|
|
72
|
+
costAt: telemetry.cost ? now : previousEntry?.costAt
|
|
73
|
+
});
|
|
74
|
+
if (this.sources.size > 100) this.sources.delete(this.sources.keys().next().value!);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Token estimates from the old projection/model are not valid after compaction or a switch. */
|
|
78
|
+
invalidateContext() {
|
|
79
|
+
for (const entry of this.sources.values()) {
|
|
80
|
+
delete entry.telemetry.context;
|
|
81
|
+
delete entry.contextAt;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Legacy status producers need no Companion dependency. Explicit labels avoid ambiguous numbers. */
|
|
86
|
+
status(key: string, value: string | undefined) {
|
|
87
|
+
if (!value) { this.sources.delete("status:" + key); return; }
|
|
88
|
+
const plain = value.replace(/\x1b\[[0-9;]*m/g, "");
|
|
89
|
+
const telemetry: Record<string, unknown> = { source: "status:" + key };
|
|
90
|
+
const context = plain.match(/(?:context|ctx)\s*[:=]?\s*([\d.]+)%/i);
|
|
91
|
+
const labelledCost = plain.match(/cost\s*[:=]?\s*\$([\d.]+)/i);
|
|
92
|
+
const cost = labelledCost ?? (/cost|usage|token|footer|context/i.test(key) ? plain.match(/\$([\d.]+)/) : null);
|
|
93
|
+
if (context) telemetry.context = { percent: Number(context[1]) };
|
|
94
|
+
if (cost) telemetry.cost = { amount: Number(cost[1]) };
|
|
95
|
+
const weekly = plain.match(/(?:weekly|7d|7-day)\s*[:=]?\s*([\d.]+)%\s*(left|remaining|used)?/i);
|
|
96
|
+
const fiveHour = plain.match(/(?:5h|5-hour)\s*[:=]?\s*([\d.]+)%\s*(left|remaining|used)?/i);
|
|
97
|
+
const window = (match: RegExpMatchArray | null) => match ? { usedPercent: /left|remaining/i.test(match[2] ?? "") ? 100 - Number(match[1]) : Number(match[1]) } : undefined;
|
|
98
|
+
const provider = plain.match(/provider\s*[:=]\s*([\w.-]+)/i)?.[1];
|
|
99
|
+
if (provider && (weekly || fiveHour)) telemetry.providers = [{ provider, weekly: window(weekly), fiveHour: window(fiveHour) }];
|
|
100
|
+
this.ingest(telemetry);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
snapshot(ctx: ExtensionContext | undefined, now = Date.now()): SessionTelemetry {
|
|
104
|
+
const result: SessionTelemetry = {};
|
|
105
|
+
const providers = new Map<string, ProviderUsage>();
|
|
106
|
+
let latestContext = -Infinity;
|
|
107
|
+
let latestCost = -Infinity;
|
|
108
|
+
for (const { telemetry, contextAt, costAt } of this.sources.values()) {
|
|
109
|
+
if (telemetry.context && contextAt !== undefined && now - contextAt <= 300_000 && contextAt >= latestContext) {
|
|
110
|
+
result.context = telemetry.context;
|
|
111
|
+
latestContext = contextAt;
|
|
112
|
+
}
|
|
113
|
+
if (telemetry.cost && costAt !== undefined && now - costAt <= 300_000 && costAt >= latestCost) {
|
|
114
|
+
result.cost = telemetry.cost;
|
|
115
|
+
latestCost = costAt;
|
|
116
|
+
}
|
|
117
|
+
for (const provider of telemetry.providers ?? []) {
|
|
118
|
+
const old = providers.get(provider.provider);
|
|
119
|
+
if (!old || Date.parse(provider.updatedAt) >= Date.parse(old.updatedAt)) providers.set(provider.provider, provider);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (providers.size) result.providers = [...providers.values()];
|
|
123
|
+
try {
|
|
124
|
+
const native = ctx?.getContextUsage?.();
|
|
125
|
+
if (native) {
|
|
126
|
+
// Native unknown after compaction is authoritative: do not resurrect stale extension tokens.
|
|
127
|
+
result.context = { tokens: number(native.tokens), window: number(native.contextWindow), percent: number(native.percent), source: "native" };
|
|
128
|
+
}
|
|
129
|
+
} catch { /* Older runtimes/other extensions may not implement this getter. */ }
|
|
130
|
+
try {
|
|
131
|
+
const entries = ctx?.sessionManager?.getEntries?.() ?? [];
|
|
132
|
+
let total = 0;
|
|
133
|
+
let known = false;
|
|
134
|
+
for (const entry of entries) {
|
|
135
|
+
const raw = record(entry);
|
|
136
|
+
const message = record(raw?.message);
|
|
137
|
+
const usage = record(raw?.type === "message" ? message?.usage : ["usage", "compaction", "branch_summary"].includes(String(raw?.type)) ? raw?.usage : undefined);
|
|
138
|
+
const cost = number(record(usage?.cost)?.total);
|
|
139
|
+
if (cost !== undefined) { known = true; total += cost; }
|
|
140
|
+
}
|
|
141
|
+
if (known && Number.isFinite(total)) result.cost = { amount: total, currency: "USD", source: "native" };
|
|
142
|
+
} catch { /* Preserve extension estimate when native usage is unavailable. */ }
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
}
|