@toddzheng024/dscode-bundle 0.7.6 → 0.7.8
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/THIRD_PARTY_NOTICES.md +3 -3
- package/cordis.patch.yml +26 -5
- package/package.json +4 -4
- package/plugins/auto-review/index.mjs +6 -1
- package/plugins/code-review/index.mjs +109 -62
- package/plugins/compaction/tetris.mjs +65 -0
- package/plugins/compaction/threshold.mjs +46 -0
- package/plugins/credentials/index.mjs +2 -2
- package/plugins/i18n/messages.mjs +18 -0
- package/plugins/memory/index.mjs +1 -1
- package/plugins/memory/pipeline.mjs +2 -1
- package/plugins/openrouter/adapter.mjs +157 -0
- package/plugins/openrouter/index.mjs +112 -0
- package/plugins/openrouter/models.mjs +157 -0
- package/plugins/openrouter/search.mjs +109 -0
- package/plugins/openrouter/wire.mjs +416 -0
- package/plugins/providers/catalog.mjs +18 -68
- package/plugins/providers/openrouter-account.mjs +171 -0
- package/plugins/session-bridge/communication.mjs +11 -0
- package/plugins/session-bridge/mailbox.mjs +58 -3
- package/plugins/session-bridge/server.mjs +3 -1
- package/plugins/session-cards/manager.mjs +13 -4
- package/plugins/session-metrics/balance.mjs +29 -19
- package/plugins/session-metrics/index.mjs +19 -9
- package/plugins/session-metrics/pricing.mjs +26 -6
- package/plugins/session-metrics/rate.mjs +3 -2
- package/plugins/session-metrics/store.mjs +59 -8
- package/plugins/session-metrics/view.mjs +13 -3
- package/plugins/tui-tools/index.mjs +2 -1
- package/plugins/ultra/policy.mjs +0 -16
- package/presets/dscode/agent.cordis.yml +12 -1
- package/vendor/compaction-basic/index.js +983 -0
- package/vendor/compaction-basic/types/config.d.ts +37 -0
- package/vendor/compaction-basic/types/index.d.ts +84 -0
- package/vendor/compaction-basic/types/region.d.ts +65 -0
- package/vendor/compaction-basic/types/summarizer.d.ts +64 -0
- package/vendor/compaction-basic/types/types.d.ts +73 -0
- package/vendor/tui/dscode-providers/catalog.mjs +18 -68
- package/vendor/tui/dscode-providers/openrouter-account.mjs +171 -0
- package/vendor/tui/index.mjs +390 -165
- package/plugins/session-metrics/openrouter-prices.mjs +0 -96
- package/vendor/pi-ai/index.js +0 -2701
- package/vendor/pi-ai/types/adapter.d.ts +0 -105
- package/vendor/pi-ai/types/auth.d.ts +0 -60
- package/vendor/pi-ai/types/catalog.d.ts +0 -355
- package/vendor/pi-ai/types/config.d.ts +0 -208
- package/vendor/pi-ai/types/context.d.ts +0 -42
- package/vendor/pi-ai/types/discovery.d.ts +0 -43
- package/vendor/pi-ai/types/index.d.ts +0 -69
- package/vendor/pi-ai/types/login.d.ts +0 -21
- package/vendor/pi-ai/types/provider.d.ts +0 -59
- package/vendor/pi-ai/types/replay.d.ts +0 -63
- package/vendor/pi-ai/types/stream.d.ts +0 -43
- /package/vendor/{pi-ai → compaction-basic}/LICENSE +0 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// OpenRouter account facts for /openrouter and the footer balance. The inference key
|
|
2
|
+
// reads the account credits and its own limit and usage; an optional management key,
|
|
3
|
+
// which cannot call models, adds every key's usage and the last 30 days of spend.
|
|
4
|
+
// This directory also ships beside the TUI, so the module imports nothing.
|
|
5
|
+
export const OPENROUTER_API = 'https://openrouter.ai/api/v1';
|
|
6
|
+
export const MANAGEMENT_REF = 'OPENROUTER_MANAGEMENT_KEY';
|
|
7
|
+
|
|
8
|
+
const finite = value => {
|
|
9
|
+
const number = Number(value);
|
|
10
|
+
return value !== null && value !== undefined && value !== '' && Number.isFinite(number) ? number : undefined;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export class OpenRouterAccountError extends Error {
|
|
14
|
+
constructor(message, status) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = 'OpenRouterAccountError';
|
|
17
|
+
this.status = status;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function get(path, key, { fetch: fetchImpl = globalThis.fetch, signal } = {}) {
|
|
22
|
+
let response;
|
|
23
|
+
try {
|
|
24
|
+
response = await fetchImpl(`${OPENROUTER_API}${path}`, { headers: { authorization: `Bearer ${key}`, accept: 'application/json' }, signal });
|
|
25
|
+
} catch (error) {
|
|
26
|
+
throw new OpenRouterAccountError(`OpenRouter is unreachable: ${error instanceof Error ? error.message : String(error)}`);
|
|
27
|
+
}
|
|
28
|
+
let body;
|
|
29
|
+
try { body = await response.json(); } catch { body = undefined; }
|
|
30
|
+
if (!response.ok) throw new OpenRouterAccountError(typeof body?.error?.message === 'string' ? body.error.message : `HTTP ${response.status}`, response.status);
|
|
31
|
+
return body;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Account credits from a `/credits` body, or undefined when it carries none. */
|
|
35
|
+
export function creditsOf(body) {
|
|
36
|
+
const total = finite(body?.data?.total_credits), used = finite(body?.data?.total_usage);
|
|
37
|
+
if (total === undefined || used === undefined || total < 0 || used < 0) return undefined;
|
|
38
|
+
return { total, used, remaining: Math.max(0, total - used) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Remaining USD account credits from a `/credits` body: purchased minus used. */
|
|
42
|
+
export function parseOpenRouterCredits(body) {
|
|
43
|
+
return creditsOf(body)?.remaining ?? null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Remaining USD credit limit from a `/key` body; null when the key has no limit. */
|
|
47
|
+
export function parseOpenRouterKeyRemaining(body) {
|
|
48
|
+
const remaining = body?.data?.limit_remaining;
|
|
49
|
+
if (remaining == null) return null;
|
|
50
|
+
const value = Number(remaining);
|
|
51
|
+
return Number.isFinite(value) ? Math.max(0, value) : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function keyOf(raw) {
|
|
55
|
+
return {
|
|
56
|
+
label: typeof raw?.label === 'string' ? raw.label : undefined,
|
|
57
|
+
name: typeof raw?.name === 'string' && raw.name.length > 0 ? raw.name : undefined,
|
|
58
|
+
disabled: raw?.disabled === true,
|
|
59
|
+
limit: finite(raw?.limit),
|
|
60
|
+
limitRemaining: finite(raw?.limit_remaining),
|
|
61
|
+
usage: finite(raw?.usage),
|
|
62
|
+
usageDaily: finite(raw?.usage_daily),
|
|
63
|
+
usageWeekly: finite(raw?.usage_weekly),
|
|
64
|
+
usageMonthly: finite(raw?.usage_monthly),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Verify a management key before it is stored. Account activity is the reading OpenRouter
|
|
70
|
+
* refuses an inference key; `/credits`, though documented as management-only, serves both.
|
|
71
|
+
* @throws OpenRouterAccountError with a message fit for the key prompt.
|
|
72
|
+
*/
|
|
73
|
+
export async function verifyManagementKey(key, options) {
|
|
74
|
+
try {
|
|
75
|
+
await get('/activity', key, options);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (error instanceof OpenRouterAccountError && (error.status === 401 || error.status === 403)) {
|
|
78
|
+
throw new OpenRouterAccountError('This is not a management key: OpenRouter refused it for account data. Create one under Settings → Management keys.', error.status);
|
|
79
|
+
}
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The `/activity` rows (last 30 completed UTC days) as totals and the top models with the providers that served them. */
|
|
85
|
+
export function summarizeActivity(rows, top = 5) {
|
|
86
|
+
const models = new Map(), days = new Set();
|
|
87
|
+
let usage = 0, requests = 0;
|
|
88
|
+
for (const row of Array.isArray(rows) ? rows : []) {
|
|
89
|
+
if (typeof row?.model !== 'string') continue;
|
|
90
|
+
const cost = finite(row.usage) ?? 0, count = finite(row.requests) ?? 0;
|
|
91
|
+
usage += cost;
|
|
92
|
+
requests += count;
|
|
93
|
+
if (typeof row.date === 'string') days.add(row.date);
|
|
94
|
+
const entry = models.get(row.model) ?? { model: row.model, usage: 0, requests: 0, providers: new Map() };
|
|
95
|
+
entry.usage += cost;
|
|
96
|
+
entry.requests += count;
|
|
97
|
+
const name = typeof row.provider_name === 'string' && row.provider_name.length > 0 ? row.provider_name : 'unknown';
|
|
98
|
+
const served = entry.providers.get(name) ?? { name, usage: 0, requests: 0 };
|
|
99
|
+
served.usage += cost;
|
|
100
|
+
served.requests += count;
|
|
101
|
+
entry.providers.set(name, served);
|
|
102
|
+
models.set(row.model, entry);
|
|
103
|
+
}
|
|
104
|
+
const ranked = [...models.values()].sort((left, right) => right.usage - left.usage || right.requests - left.requests).slice(0, top)
|
|
105
|
+
.map(entry => ({ ...entry, providers: [...entry.providers.values()].sort((left, right) => right.usage - left.usage) }));
|
|
106
|
+
return { usage, requests, days: days.size, modelCount: models.size, models: ranked };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Everything /openrouter shows. Each section settles on its own: `{ value }`, `{ error }`,
|
|
111
|
+
* or undefined when the key it needs is missing.
|
|
112
|
+
*/
|
|
113
|
+
export async function loadOpenRouterAccount({ apiKey, managementKey, fetch, signal } = {}) {
|
|
114
|
+
const options = { fetch, signal };
|
|
115
|
+
const section = (key, run) => key ? run().then(value => ({ value }), error => ({ error: error instanceof Error ? error.message : String(error) })) : Promise.resolve(undefined);
|
|
116
|
+
const [key, credits, keys, activity] = await Promise.all([
|
|
117
|
+
section(apiKey, async () => keyOf((await get('/key', apiKey, options))?.data)),
|
|
118
|
+
section(managementKey ?? apiKey, async () => {
|
|
119
|
+
const credits = creditsOf(await get('/credits', managementKey ?? apiKey, options));
|
|
120
|
+
if (!credits) throw new Error('OpenRouter returned no account credits');
|
|
121
|
+
return credits;
|
|
122
|
+
}),
|
|
123
|
+
section(managementKey, async () => {
|
|
124
|
+
const body = await get('/keys', managementKey, options);
|
|
125
|
+
return (Array.isArray(body?.data) ? body.data : []).map(keyOf);
|
|
126
|
+
}),
|
|
127
|
+
section(managementKey, async () => summarizeActivity((await get('/activity', managementKey, options))?.data)),
|
|
128
|
+
]);
|
|
129
|
+
return { hasApiKey: Boolean(apiKey), hasManagementKey: Boolean(managementKey), key, credits, keys, activity };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const money = value => Number.isFinite(value) ? `$${value.toFixed(2)}` : '$--';
|
|
133
|
+
const limitText = key => key.limit === undefined ? 'no limit' : `limit ${money(key.limit)}, ${money(key.limitRemaining)} left`;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The panel's lines for a loaded account.
|
|
137
|
+
* @returns `{ text, tone }` rows; tone is `title`, `value`, `dim` or `error`.
|
|
138
|
+
*/
|
|
139
|
+
export function openRouterAccountLines(account, { maxKeys = 5 } = {}) {
|
|
140
|
+
const lines = [];
|
|
141
|
+
const push = (text, tone = 'value') => lines.push({ text, tone });
|
|
142
|
+
if (!account.hasApiKey) push('No OpenRouter API key: run /login openrouter.', 'error');
|
|
143
|
+
if (account.credits?.value) {
|
|
144
|
+
const { remaining, total, used } = account.credits.value;
|
|
145
|
+
push(`Account balance ${money(remaining)} · credits ${money(total)} · used ${money(used)}`, 'title');
|
|
146
|
+
} else if (account.credits?.error) push(`Account balance unavailable: ${account.credits.error}`, 'error');
|
|
147
|
+
else push('Account balance $--', 'dim');
|
|
148
|
+
if (account.key?.value) {
|
|
149
|
+
const key = account.key.value;
|
|
150
|
+
push(`This key ${key.label ?? 'unnamed'} · ${limitText(key)}`, 'title');
|
|
151
|
+
push(` today ${money(key.usageDaily)} · week ${money(key.usageWeekly)} · month ${money(key.usageMonthly)}`, 'dim');
|
|
152
|
+
} else if (account.key?.error) push(`This key unavailable: ${account.key.error}`, 'error');
|
|
153
|
+
if (!account.hasManagementKey) push('API keys and 30-day spend press m to add a management key', 'dim');
|
|
154
|
+
if (account.keys?.value) {
|
|
155
|
+
const keys = [...account.keys.value].sort((left, right) => (right.usageMonthly ?? 0) - (left.usageMonthly ?? 0));
|
|
156
|
+
push(`API keys (${keys.length})`, 'title');
|
|
157
|
+
for (const key of keys.slice(0, maxKeys)) {
|
|
158
|
+
const current = account.key?.value?.label !== undefined && key.label === account.key.value.label;
|
|
159
|
+
push(` ${key.name ?? key.label ?? 'unnamed'}${current ? ' (this key)' : ''}${key.disabled ? ' · disabled' : ''} · today ${money(key.usageDaily)} · month ${money(key.usageMonthly)} · ${limitText(key)}`, key.disabled ? 'dim' : 'value');
|
|
160
|
+
}
|
|
161
|
+
if (keys.length > maxKeys) push(` +${keys.length - maxKeys} more`, 'dim');
|
|
162
|
+
} else if (account.keys?.error) push(`API keys unavailable: ${account.keys.error}`, 'error');
|
|
163
|
+
if (account.activity?.value) {
|
|
164
|
+
const activity = account.activity.value;
|
|
165
|
+
push(`Last 30 days ${money(activity.usage)} · ${activity.requests} requests · ${activity.modelCount} models`, 'title');
|
|
166
|
+
for (const model of activity.models) {
|
|
167
|
+
push(` ${model.model} · ${money(model.usage)} · ${model.requests} req · ${model.providers.map(provider => `${provider.name} ${money(provider.usage)}`).join(', ')}`);
|
|
168
|
+
}
|
|
169
|
+
} else if (account.activity?.error) push(`Activity unavailable: ${account.activity.error}`, 'error');
|
|
170
|
+
return lines;
|
|
171
|
+
}
|
|
@@ -27,6 +27,17 @@ export class CommunicationService {
|
|
|
27
27
|
ctx.on('agent/disposed', ({ agent }) => this.remove(agent)),
|
|
28
28
|
];
|
|
29
29
|
for (const agent of ctx.agents.list()) this.start(agent);
|
|
30
|
+
// The mailbox keeps every settled message for a week; a daily prune stops the sqlite file
|
|
31
|
+
// from growing for the process lifetime (prune itself is throttled and cheap).
|
|
32
|
+
const pruneTimer = setInterval(() => { try { this.store.prune(); } catch (error) { ctx.logger?.warn?.(`Mailbox prune failed: ${error.message}`); } }, 24 * 3600000);
|
|
33
|
+
pruneTimer.unref?.();
|
|
34
|
+
// Through the same disposer list as the event handlers: a context without `effect` still clears it.
|
|
35
|
+
this.disposers.push(() => clearInterval(pruneTimer));
|
|
36
|
+
// A TUI restarts far more often than daily, and the throttle is per process, so the first
|
|
37
|
+
// sweep is forced once per start and deferred off the startup path.
|
|
38
|
+
const firstPrune = setTimeout(() => { try { this.store.prune({ force: true }); } catch (error) { ctx.logger?.warn?.(`Mailbox prune failed: ${error.message}`); } }, 15000);
|
|
39
|
+
firstPrune.unref?.();
|
|
40
|
+
this.disposers.push(() => clearTimeout(firstPrune));
|
|
30
41
|
}
|
|
31
42
|
background(promise) {
|
|
32
43
|
this.pending.add(promise);
|
|
@@ -3,7 +3,11 @@ import { mkdirSync, chmodSync } from 'node:fs';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { randomUUID, createHash, timingSafeEqual } from 'node:crypto';
|
|
5
5
|
|
|
6
|
-
export const limits = Object.freeze({ depth: 3, sends: 8, ttlMs: 3600000, pending: 100, bytes: 1048576, contexts: 32
|
|
6
|
+
export const limits = Object.freeze({ depth: 3, sends: 8, ttlMs: 3600000, pending: 100, bytes: 1048576, contexts: 32,
|
|
7
|
+
// The retention window is the real policy: settled rows are deleted only once they are past it.
|
|
8
|
+
// The row caps are a backstop against runaway growth from an idle or hostile sender; they are
|
|
9
|
+
// deliberately far above any realistic mailbox so they never cut a live late-reply window short.
|
|
10
|
+
retentionMs: 7 * 24 * 3600000, retainedMessages: 50000, retainedEvents: 5000, retainedChains: 20000, retainedRefusals: 5000 });
|
|
7
11
|
export class CommunicationError extends Error {
|
|
8
12
|
constructor(code, message) { super(message); this.code = code; }
|
|
9
13
|
}
|
|
@@ -19,9 +23,14 @@ export function mergeContexts(...sets) {
|
|
|
19
23
|
|
|
20
24
|
// Shared metadata only. Native Harness owns the session writer and model driver.
|
|
21
25
|
export class Mailbox {
|
|
22
|
-
constructor(home, now = Date.now) {
|
|
26
|
+
constructor(home, now = Date.now, retention = {}) {
|
|
27
|
+
this.retention = Object.freeze(Object.fromEntries(['retentionMs', 'retainedMessages', 'retainedEvents', 'retainedChains', 'retainedRefusals'].map(key => {
|
|
28
|
+
const value = retention[key] ?? limits[key];
|
|
29
|
+
if (!Number.isSafeInteger(value) || value < 1) throw new Error(`Invalid mailbox retention: ${key}`);
|
|
30
|
+
return [key, value];
|
|
31
|
+
})));
|
|
23
32
|
const root = join(home, 'session-communication'); mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
24
|
-
this.db = new DatabaseSync(join(root, 'mailbox.sqlite')); this.now = now;
|
|
33
|
+
this.db = new DatabaseSync(join(root, 'mailbox.sqlite')); this.now = now; this.prunedAt = 0; this.eventsPrunedAt = new Map();
|
|
25
34
|
chmodSync(join(root, 'mailbox.sqlite'), 0o600);
|
|
26
35
|
this.db.exec(`PRAGMA busy_timeout=3000; PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;
|
|
27
36
|
CREATE TABLE IF NOT EXISTS owners(id TEXT PRIMARY KEY, generation TEXT NOT NULL, secret TEXT NOT NULL, socket TEXT NOT NULL);
|
|
@@ -34,6 +43,8 @@ export class Mailbox {
|
|
|
34
43
|
CREATE TABLE IF NOT EXISTS refusals(key TEXT PRIMARY KEY);
|
|
35
44
|
CREATE TABLE IF NOT EXISTS events(seq INTEGER PRIMARY KEY AUTOINCREMENT, recipient TEXT NOT NULL, message_id TEXT, type TEXT NOT NULL, time INTEGER NOT NULL, data TEXT NOT NULL);`);
|
|
36
45
|
}
|
|
46
|
+
/** Cheap size check for the prune test surface. */
|
|
47
|
+
tableCounts() { return Object.fromEntries(['messages', 'events', 'refusals', 'chains', 'contexts'].map(name => [name, this.one(`SELECT COUNT(*) AS n FROM ${name}`).n])); }
|
|
37
48
|
all(sql, ...args) { return this.db.prepare(sql).all(...args); }
|
|
38
49
|
one(sql, ...args) { return this.db.prepare(sql).get(...args); }
|
|
39
50
|
run(sql, ...args) { return this.db.prepare(sql).run(...args); }
|
|
@@ -91,6 +102,50 @@ export class Mailbox {
|
|
|
91
102
|
this.run("UPDATE messages SET delivery='expired' WHERE id=?", row.id); this.event(id, 'expired', row.id);
|
|
92
103
|
}
|
|
93
104
|
}
|
|
105
|
+
/** Drop dead rows so the sqlite file stays bounded: settled messages past the retention
|
|
106
|
+
* window (or beyond the cap), their events, expired chains and old refusals. A request whose
|
|
107
|
+
* reply window may still be used is kept for the full retention window, because late replies
|
|
108
|
+
* are legal. Cheap, idempotent and throttled to once an hour. */
|
|
109
|
+
prune({ force = false } = {}) {
|
|
110
|
+
const now = this.now();
|
|
111
|
+
// The throttle is per process, but the table outlives it: the caller forces the first sweep.
|
|
112
|
+
if (!force && now - this.prunedAt < 3600000) return;
|
|
113
|
+
// Stamp only after the transaction commits, so a failed sweep retries instead of waiting an hour.
|
|
114
|
+
this.transaction(() => {
|
|
115
|
+
const cutoff = now - this.retention.retentionMs;
|
|
116
|
+
// Settle every request whose reply window has closed first: the in-process expiry sweep only
|
|
117
|
+
// runs on mailbox reads, so an idle session would otherwise never settle one.
|
|
118
|
+
for (const { recipient } of this.all('SELECT DISTINCT recipient FROM messages')) this.expire(recipient);
|
|
119
|
+
this.run("DELETE FROM messages WHERE delivery IN ('consumed','cancelled','expired','late') AND expires<=?", cutoff);
|
|
120
|
+
// Inside the retention window neither still-deliverable mail (accepted/admitted) nor a
|
|
121
|
+
// request whose reply window is still open is ever evicted: late replies are legal.
|
|
122
|
+
this.run("DELETE FROM messages WHERE delivery NOT IN ('accepted','admitted') AND expires<=? AND seq <= COALESCE((SELECT seq FROM messages WHERE delivery NOT IN ('accepted','admitted') AND expires<=? ORDER BY seq DESC LIMIT 1 OFFSET ?), -1)", now, now, this.retention.retainedMessages);
|
|
123
|
+
this.run('DELETE FROM events WHERE time<?', cutoff);
|
|
124
|
+
// Events are per-recipient cursors: cap each recipient's own stream so a busy session
|
|
125
|
+
// cannot age out another session's unread notifications.
|
|
126
|
+
for (const { recipient } of this.all('SELECT DISTINCT recipient FROM events')) {
|
|
127
|
+
this.run('DELETE FROM events WHERE recipient=? AND seq <= COALESCE((SELECT seq FROM events WHERE recipient=? ORDER BY seq DESC LIMIT 1 OFFSET ?), -1)', recipient, recipient, this.retention.retainedEvents);
|
|
128
|
+
}
|
|
129
|
+
this.run('DELETE FROM chains WHERE expires<?', cutoff);
|
|
130
|
+
this.run('DELETE FROM chains WHERE expires<? AND created < COALESCE((SELECT created FROM chains ORDER BY created DESC LIMIT 1 OFFSET ?), 0)', now, this.retention.retainedChains);
|
|
131
|
+
this.run('DELETE FROM refusals WHERE rowid NOT IN (SELECT rowid FROM refusals ORDER BY rowid DESC LIMIT ?)', this.retention.retainedRefusals);
|
|
132
|
+
});
|
|
133
|
+
this.prunedAt = now;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Event-only pruning for the watch loop: events are cheap rows and can be dropped more often. */
|
|
137
|
+
pruneEvents(recipient) {
|
|
138
|
+
const now = this.now();
|
|
139
|
+
// Per recipient: one session's poller must not starve another window.
|
|
140
|
+
if (now - (this.eventsPrunedAt.get(recipient) ?? 0) < 60000) return;
|
|
141
|
+
this.transaction(() => {
|
|
142
|
+
this.run('DELETE FROM events WHERE recipient=? AND time<?', recipient, now - this.retention.retentionMs);
|
|
143
|
+
// Scoped to one recipient: a busy session must not age out another session's notifications.
|
|
144
|
+
this.run('DELETE FROM events WHERE recipient=? AND seq <= COALESCE((SELECT seq FROM events WHERE recipient=? ORDER BY seq DESC LIMIT 1 OFFSET ?), -1)', recipient, recipient, this.retention.retainedEvents);
|
|
145
|
+
});
|
|
146
|
+
this.eventsPrunedAt.set(recipient, now);
|
|
147
|
+
}
|
|
148
|
+
|
|
94
149
|
admit(recipient, request, auth) {
|
|
95
150
|
const { text, mode = 'queue', kind = 'request', requestId, source = 'cli', inReplyTo, title } = request;
|
|
96
151
|
if (typeof text !== 'string' || !text.trim() || Buffer.byteLength(text) > 64000) fail('invalid_text', 'Text must contain 1..64000 bytes');
|
|
@@ -135,7 +135,9 @@ export class SessionBridge {
|
|
|
135
135
|
const poll = () => {
|
|
136
136
|
if (!this.ctx.agents.get(agent.id)) { send({ type: 'closed', sessionId: agent.id, cursor }); socket.end(); return; }
|
|
137
137
|
try { for (const event of this.communication.store.events(agent.id, cursor)) { if (!send({ type: 'event', event })) return; cursor = event.seq; } }
|
|
138
|
-
catch { socket.end(); }
|
|
138
|
+
catch { socket.end(); return; }
|
|
139
|
+
// Maintenance stays outside the read's failure domain: a busy or failing prune never closes a live watch.
|
|
140
|
+
try { this.communication.store.pruneEvents(agent.id); } catch (error) { this.ctx.logger?.warn?.(`Mailbox prune failed: ${error.message}`); }
|
|
139
141
|
};
|
|
140
142
|
poll(); const interval = setInterval(poll, 250); interval.unref(); cleanup = () => clearInterval(interval);
|
|
141
143
|
} else if (req.method === 'watch') {
|
|
@@ -26,13 +26,21 @@ export class SessionCards {
|
|
|
26
26
|
}
|
|
27
27
|
path(session) { return join(this.root, fingerprint(session.id) + '.json'); }
|
|
28
28
|
input(state) { return selectRequests(state.requests, this.config.maxMessages, this.config.maxInputChars); }
|
|
29
|
-
|
|
29
|
+
/** A cheap key over everything `input()` reads: the memo can only be stale if this repeats. */
|
|
30
|
+
hashKey(state) { return `${state.requests.length}:${state.requests.at(-1)?.seq ?? ''}:${this.config.topicCount}:${this.config.maxMessages}:${this.config.maxInputChars}`; }
|
|
31
|
+
/** The digest hashes up to 16k characters, and arm()/updateStatus() ask for it
|
|
32
|
+
* several times per event across every tracked session; memoize until that key changes. */
|
|
33
|
+
hash(state) {
|
|
34
|
+
const key = this.hashKey(state);
|
|
35
|
+
if (state.digestKey !== key) { state.digest = fingerprint({ topicCount: this.config.topicCount, messages: this.input(state) }); state.digestKey = key; }
|
|
36
|
+
return state.digest;
|
|
37
|
+
}
|
|
30
38
|
track(session) {
|
|
31
39
|
if (this.closed || session.header.agentPreset !== 'dscode' || session.header.origin === 'subagent') return null;
|
|
32
40
|
if (this.states.has(session.id)) return this.states.get(session.id);
|
|
33
41
|
const requests = session.snapshotEvents().map(userRequest).filter(Boolean);
|
|
34
42
|
const state = { session, requests: requests.slice(-this.config.maxMessages).map(m => ({ ...m, text: m.text.slice(0, 4000) })),
|
|
35
|
-
project: null, topics: [], hash: '', updatedAt: null, coveredUserSeq: null, status: 'empty', failures: 0,
|
|
43
|
+
project: null, topics: [], hash: '', digest: null, digestKey: null, updatedAt: null, coveredUserSeq: null, status: 'empty', failures: 0,
|
|
36
44
|
nextAt: Date.now() + this.config.debounceMs, lastAttempt: 0, route: session.requestHeader()?.config, usage: { calls: 0, inputTokens: 0, outputTokens: 0, unknown: 0 } };
|
|
37
45
|
try {
|
|
38
46
|
const raw = readFileSync(this.path(session), 'utf8');
|
|
@@ -52,7 +60,8 @@ export class SessionCards {
|
|
|
52
60
|
this.updateStatus(state); this.arm(); return state;
|
|
53
61
|
}
|
|
54
62
|
updateStatus(state) {
|
|
55
|
-
|
|
63
|
+
const digest = !this.config.enabled || !state.requests.length ? null : this.hash(state);
|
|
64
|
+
state.status = !this.config.enabled ? 'disabled' : !state.requests.length ? 'empty' : state.hash === digest ? 'ready' : state.requests.length < this.config.minMessages ? 'insufficient' : 'pending';
|
|
56
65
|
}
|
|
57
66
|
observe(session, event) {
|
|
58
67
|
const state = this.track(session); if (!state) return;
|
|
@@ -110,7 +119,7 @@ export class SessionCards {
|
|
|
110
119
|
signal.throwIfAborted();
|
|
111
120
|
if (this.states.get(state.session.id) !== state || hash !== this.hash(state)) return;
|
|
112
121
|
const topics = validateTopics(result.value, messages, this.config.topicCount);
|
|
113
|
-
const updated = { topics, hash, updatedAt: Date.now(), coveredUserSeq: messages.at(-1)?.seq ?? null };
|
|
122
|
+
const updated = { topics, hash, digest: hash, digestKey: this.hashKey(state), updatedAt: Date.now(), coveredUserSeq: messages.at(-1)?.seq ?? null };
|
|
114
123
|
this.save({ ...state, ...updated });
|
|
115
124
|
Object.assign(state, updated); state.failures = 0; state.status = 'ready';
|
|
116
125
|
} catch {
|
|
@@ -1,10 +1,18 @@
|
|
|
1
|
+
import { OPENROUTER_API, parseOpenRouterCredits, parseOpenRouterKeyRemaining } from '../providers/openrouter-account.mjs';
|
|
2
|
+
|
|
3
|
+
export { parseOpenRouterCredits, parseOpenRouterKeyRemaining };
|
|
4
|
+
|
|
1
5
|
// Remaining provider balance, refreshed on a long cache. DeepSeek's response
|
|
2
6
|
// also carries the trusted clock: its `Date` header anchors peak/off-peak pricing
|
|
3
|
-
// without a second network call. OpenRouter has no peak window
|
|
4
|
-
//
|
|
7
|
+
// without a second network call. OpenRouter has no peak window. Its account
|
|
8
|
+
// credits (`/credits`) are documented as management-only but are served to
|
|
9
|
+
// inference keys too; a key refused them falls back to its own remaining limit (`/key`).
|
|
5
10
|
const SOURCES = {
|
|
6
|
-
'deepseek-official': { url: 'https://api.deepseek.com/user/balance',
|
|
7
|
-
openrouter: {
|
|
11
|
+
'deepseek-official': { env: 'DEEPSEEK_API_KEY', clock: true, requests: ({ key }) => [{ url: 'https://api.deepseek.com/user/balance', key, parse: body => parseBalance(body) }] },
|
|
12
|
+
openrouter: { env: 'OPENROUTER_API_KEY', managementEnv: 'OPENROUTER_MANAGEMENT_KEY', clock: false, requests: ({ key, managementKey }) => [
|
|
13
|
+
{ url: `${OPENROUTER_API}/credits`, key: managementKey ?? key, parse: parseOpenRouterCredits },
|
|
14
|
+
{ url: `${OPENROUTER_API}/key`, key, parse: parseOpenRouterKeyRemaining },
|
|
15
|
+
] },
|
|
8
16
|
};
|
|
9
17
|
export const BALANCE_PROVIDERS = Object.freeze(Object.keys(SOURCES));
|
|
10
18
|
const CACHE_MS = 5 * 60 * 1000;
|
|
@@ -22,14 +30,6 @@ export function parseBalance(body) {
|
|
|
22
30
|
return body?.is_available === false ? null : total;
|
|
23
31
|
}
|
|
24
32
|
|
|
25
|
-
/** Remaining USD credits from an OpenRouter `/credits` body: purchased minus used. */
|
|
26
|
-
export function parseOpenRouterCredits(body) {
|
|
27
|
-
const credits = Number(body?.data?.total_credits), used = Number(body?.data?.total_usage);
|
|
28
|
-
if (body?.data?.total_credits == null || body?.data?.total_usage == null) return null;
|
|
29
|
-
if (!Number.isFinite(credits) || !Number.isFinite(used) || credits < 0 || used < 0) return null;
|
|
30
|
-
return Math.max(0, credits - used);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
33
|
export function balanceNow(provider = 'deepseek-official') { return snapshotOf(provider).balance; }
|
|
34
34
|
/** Clock anchored to the last DeepSeek balance response's `Date` header, else the local one. */
|
|
35
35
|
export function trustedNow() {
|
|
@@ -43,29 +43,39 @@ export async function refreshBalance(options = {}) {
|
|
|
43
43
|
const source = SOURCES[provider];
|
|
44
44
|
if (!source) return null;
|
|
45
45
|
const snapshot = snapshotOf(provider);
|
|
46
|
+
const key = options.key ?? process.env[source.env];
|
|
47
|
+
const managementKey = options.managementKey ?? (source.managementEnv ? process.env[source.managementEnv] : undefined);
|
|
48
|
+
const requests = source.requests({ key, managementKey }).filter(request => request.key);
|
|
49
|
+
const mode = managementKey ? 'management' : 'key';
|
|
46
50
|
const now = Date.now();
|
|
47
|
-
|
|
51
|
+
// A newly added (or removed) management key changes the source; it is read at once.
|
|
52
|
+
if (snapshot.pending || snapshot.mode === mode && now - snapshot.fetchedAt < CACHE_MS) return snapshot.balance;
|
|
48
53
|
const retrySoon = () => Date.now() - (CACHE_MS - RETRY_MS);
|
|
49
|
-
|
|
50
|
-
if (!key) { snapshots.set(provider, { ...snapshot, fetchedAt: retrySoon(), pending: false }); return snapshot.balance; }
|
|
54
|
+
if (requests.length === 0) { snapshots.set(provider, { ...snapshot, mode, fetchedAt: retrySoon(), pending: false }); return snapshot.balance; }
|
|
51
55
|
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
52
56
|
if (typeof fetchImpl !== 'function') return snapshot.balance;
|
|
53
57
|
snapshots.set(provider, { ...snapshot, pending: true });
|
|
54
58
|
try {
|
|
55
|
-
|
|
59
|
+
let response, body, request;
|
|
60
|
+
for (request of requests) {
|
|
61
|
+
response = await fetchImpl(request.url, { headers: { Authorization: `Bearer ${request.key}`, Accept: 'application/json' } });
|
|
62
|
+
body = await response.json();
|
|
63
|
+
// A key refused this reading falls through to the next source.
|
|
64
|
+
if (response.ok || (response.status !== 401 && response.status !== 403)) break;
|
|
65
|
+
}
|
|
56
66
|
const header = source.clock ? response.headers?.get?.('date') : null;
|
|
57
67
|
const anchor = header ? Date.parse(header) : NaN;
|
|
58
|
-
const
|
|
59
|
-
const parsed = response.ok ? source.parse(body) : null;
|
|
68
|
+
const parsed = response.ok ? request.parse(body) : null;
|
|
60
69
|
if (Number.isFinite(anchor)) clock = { anchor, skewMs: anchor - Date.now() };
|
|
61
70
|
snapshots.set(provider, {
|
|
71
|
+
mode,
|
|
62
72
|
balance: parsed === null && response.ok ? null : parsed ?? snapshot.balance,
|
|
63
73
|
fetchedAt: parsed === null ? retrySoon() : Date.now(),
|
|
64
74
|
pending: false,
|
|
65
75
|
});
|
|
66
76
|
} catch {
|
|
67
77
|
// A transient failure keeps the last known balance and retries sooner.
|
|
68
|
-
snapshots.set(provider, { ...snapshot, fetchedAt: retrySoon(), pending: false });
|
|
78
|
+
snapshots.set(provider, { ...snapshot, mode, fetchedAt: retrySoon(), pending: false });
|
|
69
79
|
}
|
|
70
80
|
return snapshotOf(provider).balance;
|
|
71
81
|
}
|
|
@@ -3,7 +3,8 @@ import { appendMetric } from './store.mjs';
|
|
|
3
3
|
import { estimateCost, priceVersionFor } from './pricing.mjs';
|
|
4
4
|
import { setMetricSource } from './view.mjs';
|
|
5
5
|
import { BALANCE_PROVIDERS, refreshBalance } from './balance.mjs';
|
|
6
|
-
import {
|
|
6
|
+
import { refreshOpenRouterModels } from '../openrouter/models.mjs';
|
|
7
|
+
import { REPLAY_KIND } from '../openrouter/wire.mjs';
|
|
7
8
|
import { providerSpec } from '../providers/catalog.mjs';
|
|
8
9
|
import { createWindowRate } from './rate.mjs';
|
|
9
10
|
import { currentCharge } from './attribution.mjs';
|
|
@@ -34,12 +35,17 @@ export function apply(ctx) {
|
|
|
34
35
|
const credentials = ctx.get?.('credentials');
|
|
35
36
|
const refresh = () => Promise.all(BALANCE_PROVIDERS.map(async provider => {
|
|
36
37
|
try {
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
const spec = providerSpec(provider);
|
|
39
|
+
const secret = async ref => {
|
|
40
|
+
if (!ref) return undefined;
|
|
41
|
+
const resolved = await credentials?.resolve?.(ref);
|
|
42
|
+
return (typeof resolved === 'string' ? resolved : resolved?.value) || process.env[ref] || undefined;
|
|
43
|
+
};
|
|
44
|
+
// An OpenRouter management key, when stored, turns the balance into the account's credits.
|
|
45
|
+
const [key, managementKey] = await Promise.all([secret(spec.credentialRef), secret(spec.managementRef)]);
|
|
46
|
+
await refreshBalance({ provider, key, managementKey });
|
|
47
|
+
// The listing needs no key, but only a user with an OpenRouter key uses it; the adapter waits for it before its first call.
|
|
48
|
+
if (provider === 'openrouter' && key) await refreshOpenRouterModels({ home });
|
|
43
49
|
} catch {
|
|
44
50
|
/* balance stays unknown */
|
|
45
51
|
}
|
|
@@ -64,19 +70,23 @@ export function apply(ctx) {
|
|
|
64
70
|
}
|
|
65
71
|
const save = entry => { for (const recipient of recipients) record(recipient, { ...entry, sessionId }); };
|
|
66
72
|
save({ kind: 'start', id, time, provider: options.provider, model: options.model, purpose });
|
|
67
|
-
let usage, firstTokenTime;
|
|
73
|
+
let usage, firstTokenTime, billed;
|
|
68
74
|
const liveSession = purpose === 'agent' ? ctx.agents.get(sessionId)?.session : undefined;
|
|
69
75
|
try {
|
|
70
76
|
for await (const chunk of next()) {
|
|
71
77
|
if (chunk.type === 'usage') usage = chunk.usage;
|
|
72
78
|
else if (firstTokenTime === undefined && OUTPUT_CHUNKS.has(chunk.type)) firstTokenTime = Date.now();
|
|
79
|
+
// OpenRouter reports what it charged; the finish of its response carries it.
|
|
80
|
+
if (chunk.type === 'finish' && chunk.replayState?.response?.kind === REPLAY_KIND && Number.isFinite(chunk.replayState.response.cost)) billed = chunk.replayState.response.cost;
|
|
73
81
|
if (liveSession) liveRate.add(liveSession, chunk);
|
|
74
82
|
yield chunk;
|
|
75
83
|
}
|
|
76
84
|
} finally {
|
|
77
85
|
if (liveSession) liveRate.calibrate(liveSession, usage?.outputTokens);
|
|
78
86
|
// `time` stays the start (it prices the call); `endTime` and `firstTokenTime` time it.
|
|
79
|
-
save({ kind: 'end', id, time, endTime: Date.now(), ...(firstTokenTime === undefined ? {} : { firstTokenTime }), usage: usage ?? null,
|
|
87
|
+
save({ kind: 'end', id, time, endTime: Date.now(), ...(firstTokenTime === undefined ? {} : { firstTokenTime }), usage: usage ?? null, ...(billed === undefined
|
|
88
|
+
? { cost: estimateCost(options.provider, options.model, usage, time), priceVersion: priceVersionFor(options.provider, options.model) }
|
|
89
|
+
: { cost: billed, priceVersion: 'openrouter-billed' }) });
|
|
80
90
|
}
|
|
81
91
|
});
|
|
82
92
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { openRouterPriceVersion, openRouterRates } from '
|
|
1
|
+
import { openRouterPriceVersion, openRouterRates } from '../openrouter/models.mjs';
|
|
2
2
|
|
|
3
3
|
// USD per million tokens. Snapshot of the official page opened 2026-09-11.
|
|
4
4
|
// https://api-docs.deepseek.com/quick_start/pricing/
|
|
5
5
|
export const PRICE_SOURCE = 'https://api-docs.deepseek.com/quick_start/pricing/';
|
|
6
6
|
export const PRICE_VERSION = 'deepseek-2026-09-11';
|
|
7
|
-
// OpenRouter calls
|
|
7
|
+
// OpenRouter calls carry their billed cost; an unbilled one is estimated from the live model listing (plugins/openrouter/models.mjs).
|
|
8
8
|
// Until that table loads, the DeepSeek models keep these list prices from the pinned
|
|
9
9
|
// pi-ai 0.85.1 catalog. OpenRouter bills no peak window. [cache read, input, output].
|
|
10
10
|
export const OPENROUTER_PRICE_VERSION = 'openrouter-pi-ai-0.85.1';
|
|
@@ -31,13 +31,33 @@ export function estimateCost(provider, model, usage, time) {
|
|
|
31
31
|
return OPENROUTER_PRICES[model] ? charge(usage, OPENROUTER_PRICES[model]) : null;
|
|
32
32
|
}
|
|
33
33
|
if (provider !== 'deepseek-official') return null;
|
|
34
|
+
const rates = deepSeekRates(model, time);
|
|
35
|
+
if (!rates) return null;
|
|
36
|
+
const cost = charge(usage, rates);
|
|
37
|
+
return cost === null ? null : cost * (isPeak(time) ? 2 : 1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** DeepSeek list prices [cache read, input, output] at `time`, before the peak multiplier. */
|
|
41
|
+
function deepSeekRates(model, time) {
|
|
34
42
|
// Earlier requests require an older price table; never back-price them at today's rate.
|
|
35
|
-
if (time < Date.UTC(2026, 8, 11)) return
|
|
43
|
+
if (time < Date.UTC(2026, 8, 11)) return undefined;
|
|
36
44
|
const flash = ['deepseek-flash', 'deepseek-v4-flash', 'deepseek-v4-flash-vision-exp'].includes(model)
|
|
37
45
|
|| model === 'deepseek-v4-pro' && time >= Date.UTC(2026, 8, 14, 4);
|
|
38
|
-
if (!flash && model !== 'deepseek-v4-pro') return
|
|
39
|
-
|
|
40
|
-
|
|
46
|
+
if (!flash && model !== 'deepseek-v4-pro') return undefined;
|
|
47
|
+
return flash ? [0.003, 0.15, 0.6] : [0.022, 0.66, 1.98];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Cache-read price over input price for a route, or undefined when the route is unpriced.
|
|
52
|
+
* A model that lists no cache-read price bills cached input at the input rate (1).
|
|
53
|
+
*/
|
|
54
|
+
export function cacheReadRatio(provider, model, time = Date.now()) {
|
|
55
|
+
let rates;
|
|
56
|
+
if (provider === 'openrouter') {
|
|
57
|
+
const live = openRouterRates(model);
|
|
58
|
+
rates = live ? [live.cacheRead ?? live.input, live.input] : OPENROUTER_PRICES[model];
|
|
59
|
+
} else if (provider === 'deepseek-official') rates = deepSeekRates(model, time);
|
|
60
|
+
return rates && rates[1] > 0 ? rates[0] / rates[1] : undefined;
|
|
41
61
|
}
|
|
42
62
|
|
|
43
63
|
/** Cost in USD; cache writes need a write price, or the call stays unpriced. */
|
|
@@ -77,9 +77,10 @@ export function sessionAverageTps(events) {
|
|
|
77
77
|
const starts = new Map();
|
|
78
78
|
let callMs = 0, outputTokens = 0, known = 0, unknown = false;
|
|
79
79
|
for (const event of events) {
|
|
80
|
-
|
|
81
|
-
if (event.type === 'step/start') starts.set(
|
|
80
|
+
// Build the key only for the two event types that use it: the array carries every event.
|
|
81
|
+
if (event.type === 'step/start') starts.set(`${event.data?.turn}:${event.data?.step}`, event.time);
|
|
82
82
|
else if (event.type === 'assistant/message') {
|
|
83
|
+
const key = `${event.data?.turn}:${event.data?.step}`;
|
|
83
84
|
const start = starts.get(key);
|
|
84
85
|
starts.delete(key);
|
|
85
86
|
const output = event.data?.usage?.outputTokens;
|
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
import { appendFileSync, mkdirSync, readFileSync, statSync } from 'node:fs';
|
|
1
|
+
import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync, readSync, statSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
|
+
// The render path reads this once a second per session; keep the last few ledgers and
|
|
5
|
+
// append to the parsed rows instead of re-reading and re-parsing the whole jsonl.
|
|
6
|
+
const MAX_SESSIONS = 16;
|
|
4
7
|
const cache = new Map();
|
|
8
|
+
const parse = (line, onCorrupt) => { try { return [JSON.parse(line)]; } catch { onCorrupt(); return []; } };
|
|
9
|
+
const readsAsJson = text => { try { JSON.parse(text); return true; } catch { return false; } };
|
|
5
10
|
export const ledgerPath = (home, id) => join(home, 'session-metrics', createHash('sha256').update(id).digest('hex') + '.jsonl');
|
|
6
11
|
export function appendMetric(home, id, entry) {
|
|
7
12
|
const path = ledgerPath(home, id);
|
|
@@ -12,13 +17,59 @@ export function readMetrics(home, id) {
|
|
|
12
17
|
const path = ledgerPath(home, id);
|
|
13
18
|
try {
|
|
14
19
|
const st = statSync(path), key = `${st.mtimeMs}:${st.size}`;
|
|
15
|
-
|
|
20
|
+
const entry = cache.get(path);
|
|
21
|
+
if (entry?.key === key) return entry.value;
|
|
22
|
+
// Touching a key refreshes its recency; the oldest ledger is dropped past the cap.
|
|
23
|
+
if (entry) cache.delete(path);
|
|
16
24
|
let corrupt = false;
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
25
|
+
const onCorrupt = () => { corrupt = true; };
|
|
26
|
+
let value, offset = 0, pending = false;
|
|
27
|
+
if (entry && st.ino === entry.ino && st.dev === entry.dev && st.size > entry.size) {
|
|
28
|
+
// Append-only ledger: read just the bytes written since the last read (offset is a newline boundary).
|
|
29
|
+
const fd = openSync(path, 'r');
|
|
30
|
+
try {
|
|
31
|
+
const buffer = Buffer.allocUnsafe(st.size - entry.offset);
|
|
32
|
+
let filled = 0;
|
|
33
|
+
while (filled < buffer.length) {
|
|
34
|
+
const read = readSync(fd, buffer, filled, buffer.length - filled, entry.offset + filled);
|
|
35
|
+
if (read <= 0) break;
|
|
36
|
+
filled += read;
|
|
37
|
+
}
|
|
38
|
+
// A short read must never read as "the writer stopped here": fall back to the full
|
|
39
|
+
// parse below rather than caching an offset that would drop the unread bytes forever.
|
|
40
|
+
if (filled < buffer.length) throw Object.assign(new Error('short ledger read'), { code: 'ESHORTREAD' });
|
|
41
|
+
const tail = buffer.toString('utf8');
|
|
42
|
+
const lastNewline = tail.lastIndexOf('\n') + 1;
|
|
43
|
+
let complete = tail.slice(0, lastNewline);
|
|
44
|
+
const pendingLine = tail.slice(lastNewline);
|
|
45
|
+
// A fragment without a newline is either a torn write or a complete row this reader is
|
|
46
|
+
// simply early for; keep it only when it parses, and re-read it next time either way.
|
|
47
|
+
pending = pendingLine !== '' && readsAsJson(pendingLine);
|
|
48
|
+
if (pending) complete += pendingLine + '\n';
|
|
49
|
+
offset = entry.offset + Buffer.byteLength(tail.slice(0, lastNewline));
|
|
50
|
+
value = { rows: [...(entry.pending ? entry.value.rows.slice(0, -1) : entry.value.rows), ...complete.split('\n').filter(Boolean).flatMap(line => parse(line, onCorrupt))], corrupt: entry.value.corrupt || corrupt };
|
|
51
|
+
} finally { closeSync(fd); }
|
|
52
|
+
} else {
|
|
53
|
+
const raw = readFileSync(path);
|
|
54
|
+
const lastNewline = raw.lastIndexOf(0x0a) + 1;
|
|
55
|
+
let complete = raw.subarray(0, lastNewline);
|
|
56
|
+
const pendingLine = raw.subarray(lastNewline).toString('utf8');
|
|
57
|
+
pending = pendingLine !== '' && readsAsJson(pendingLine);
|
|
58
|
+
if (pending) complete = Buffer.concat([complete, Buffer.from(pendingLine + '\n')]);
|
|
59
|
+
offset = lastNewline;
|
|
60
|
+
value = { rows: complete.toString('utf8').split('\n').filter(Boolean).flatMap(line => parse(line, onCorrupt)), corrupt };
|
|
61
|
+
}
|
|
62
|
+
cache.set(path, { key, offset, value, pending, size: st.size, ino: st.ino, dev: st.dev });
|
|
63
|
+
while (cache.size > MAX_SESSIONS) cache.delete(cache.keys().next().value);
|
|
22
64
|
return value;
|
|
23
|
-
} catch (e) {
|
|
65
|
+
} catch (e) {
|
|
66
|
+
cache.delete(path);
|
|
67
|
+
if (e.code === 'ESHORTREAD') {
|
|
68
|
+
// Read it the simple way once; the next call starts from a fresh offset.
|
|
69
|
+
let corrupt = false;
|
|
70
|
+
const rows = readFileSync(path, 'utf8').split('\n').filter(Boolean).flatMap(line => parse(line, () => { corrupt = true; }));
|
|
71
|
+
return { rows, corrupt };
|
|
72
|
+
}
|
|
73
|
+
return { rows: [], corrupt: e.code !== 'ENOENT' };
|
|
74
|
+
}
|
|
24
75
|
}
|