@vimoxshah/tokenflow 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +84 -0
- package/LICENSE +21 -0
- package/README.md +250 -0
- package/Refresh & Open Dashboard.command +22 -0
- package/SECURITY.md +42 -0
- package/bin/tokenflow.js +1342 -0
- package/docs/architecture.md +193 -0
- package/docs/cli.md +390 -0
- package/docs/configuration.md +281 -0
- package/docs/creating-provider.md +262 -0
- package/docs/data-model.md +213 -0
- package/docs/getting-started.md +266 -0
- package/docs/live-mode.md +199 -0
- package/docs/media/architecture-hero.svg +86 -0
- package/docs/media/cost-editorial-dark.png +0 -0
- package/docs/media/health-terminal-light.png +0 -0
- package/docs/media/menubar-dark.png +0 -0
- package/docs/media/menubar-light.png +0 -0
- package/docs/media/models-terminal-dark.png +0 -0
- package/docs/media/overview-aurora-dark.png +0 -0
- package/docs/media/time-aurora-light.png +0 -0
- package/docs/providers.md +309 -0
- package/docs/skill.md +64 -0
- package/docs/troubleshooting.md +207 -0
- package/examples/config.example.yaml +92 -0
- package/examples/demo-data/README.md +38 -0
- package/examples/demo-data/sample-usage.csv +11 -0
- package/package.json +74 -0
- package/scripts/build-dmg.sh +33 -0
- package/scripts/build-menubar-app.sh +67 -0
- package/scripts/lint.js +111 -0
- package/scripts/validate-install.js +140 -0
- package/skills/tokenflow/SKILL.md +392 -0
- package/skills/tokenflow/examples/config.yaml +92 -0
- package/skills/tokenflow/examples/generic-mapping.json +26 -0
- package/skills/tokenflow/examples/session-transcript.md +191 -0
- package/skills/tokenflow/providers/adapter-template.js +135 -0
- package/skills/tokenflow/providers/detection-matrix.md +142 -0
- package/skills/tokenflow/schemas/config.schema.json +107 -0
- package/skills/tokenflow/schemas/normalized-record.json +63 -0
- package/src/analytics/aggregate.js +247 -0
- package/src/analytics/anomalies.js +222 -0
- package/src/analytics/capacity.js +278 -0
- package/src/analytics/comparison.js +96 -0
- package/src/analytics/dimensions.js +230 -0
- package/src/analytics/efficiency.js +138 -0
- package/src/analytics/forecast.js +202 -0
- package/src/analytics/index.js +327 -0
- package/src/analytics/insights.js +283 -0
- package/src/analytics/milestones.js +91 -0
- package/src/analytics/peak.js +106 -0
- package/src/analytics/productivity.js +166 -0
- package/src/analytics/token-usage.js +267 -0
- package/src/commands/diagnostics.js +88 -0
- package/src/commands/digest.js +155 -0
- package/src/commands/models-compare.js +96 -0
- package/src/core/budget.js +142 -0
- package/src/core/bundle.js +191 -0
- package/src/core/config.js +202 -0
- package/src/core/delivery.js +109 -0
- package/src/core/geo.js +99 -0
- package/src/core/ingest.js +457 -0
- package/src/core/interface-map.js +55 -0
- package/src/core/jsonl.js +124 -0
- package/src/core/live-status.js +417 -0
- package/src/core/model-map.js +157 -0
- package/src/core/notify.js +83 -0
- package/src/core/pricing.js +288 -0
- package/src/core/prompt-analytics.js +127 -0
- package/src/core/registry.js +107 -0
- package/src/core/restore.js +261 -0
- package/src/core/schedule.js +120 -0
- package/src/core/schema.js +316 -0
- package/src/core/sqlite.js +96 -0
- package/src/core/store.js +493 -0
- package/src/core/sync.js +151 -0
- package/src/core/units.js +147 -0
- package/src/core/validate.js +123 -0
- package/src/core/watch.js +287 -0
- package/src/core/yaml.js +209 -0
- package/src/export/bundler.js +107 -0
- package/src/export/csv.js +100 -0
- package/src/export/html-snapshot.js +101 -0
- package/src/export/menubar.js +158 -0
- package/src/index.js +18 -0
- package/src/providers/anthropic/index.js +294 -0
- package/src/providers/cline/index.js +120 -0
- package/src/providers/cursor/index.js +143 -0
- package/src/providers/generic/index.js +268 -0
- package/src/providers/git/index.js +188 -0
- package/src/providers/headroom/index.js +114 -0
- package/src/providers/hermes/index.js +299 -0
- package/src/providers/mock/index.js +117 -0
- package/src/providers/openai/index.js +370 -0
- package/src/providers/opencode/index.js +245 -0
- package/src/sdk.js +46 -0
- package/src/server/server.js +264 -0
- package/src/ui/app.js +2473 -0
- package/src/ui/charts.js +925 -0
- package/src/ui/index.html +42 -0
- package/src/ui/styles.css +644 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider registry + SDK.
|
|
3
|
+
*
|
|
4
|
+
* A provider is a plain object satisfying the contract in
|
|
5
|
+
* docs/creating-provider.md. `createProvider` validates it up front so a
|
|
6
|
+
* broken adapter fails at load with a readable message instead of halfway
|
|
7
|
+
* through a 1.5 GB ingest.
|
|
8
|
+
*
|
|
9
|
+
* Discovery order (later wins on id collision, so a user can shadow a built-in):
|
|
10
|
+
* 1. built-ins in src/providers/<id>/index.js
|
|
11
|
+
* 2. user adapters in $TOKENFLOW_HOME/providers/*.js
|
|
12
|
+
* 3. anything registered programmatically via registerProvider()
|
|
13
|
+
*/
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import url from 'node:url';
|
|
17
|
+
import { validateProvider } from './validate.js';
|
|
18
|
+
import { homeDir } from './config.js';
|
|
19
|
+
|
|
20
|
+
const HERE = path.dirname(url.fileURLToPath(import.meta.url));
|
|
21
|
+
export const BUILTIN_DIR = path.join(HERE, '..', 'providers');
|
|
22
|
+
|
|
23
|
+
const registry = new Map();
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Wrap a provider definition with defaults + validation.
|
|
27
|
+
* @template {{id:string, name:string, ingestFile?:Function}} T
|
|
28
|
+
* @param {T} def
|
|
29
|
+
* @returns {T}
|
|
30
|
+
*/
|
|
31
|
+
export function createProvider(def) {
|
|
32
|
+
const v = validateProvider(def);
|
|
33
|
+
if (!v.ok) {
|
|
34
|
+
throw new Error(`Invalid provider "${def?.id ?? '(no id)'}":\n - ${v.errors.join('\n - ')}`);
|
|
35
|
+
}
|
|
36
|
+
const p = {
|
|
37
|
+
description: '',
|
|
38
|
+
measurement: 'primary',
|
|
39
|
+
/** File extensions / prefilter substrings the engine uses to skip work. */
|
|
40
|
+
prefilter: null,
|
|
41
|
+
/** Human notes surfaced in `tokenflow providers`. */
|
|
42
|
+
requires: [],
|
|
43
|
+
getMetadata() {
|
|
44
|
+
return {
|
|
45
|
+
id: p.id, name: p.name, description: p.description,
|
|
46
|
+
measurement: p.measurement, requires: p.requires,
|
|
47
|
+
kind: typeof p.ingestFile === 'function' ? 'file' : 'fetch',
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
...def,
|
|
51
|
+
};
|
|
52
|
+
return Object.freeze(p);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function registerProvider(p) {
|
|
56
|
+
registry.set(p.id, p);
|
|
57
|
+
return p;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function getProvider(id) {
|
|
61
|
+
return registry.get(id);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function listProviders() {
|
|
65
|
+
return [...registry.values()];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function clearRegistry() {
|
|
69
|
+
registry.clear();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Load built-in adapters, then any user adapters. Idempotent. */
|
|
73
|
+
export async function loadProviders({ includeUser = true, dir = BUILTIN_DIR } = {}) {
|
|
74
|
+
let entries = [];
|
|
75
|
+
try {
|
|
76
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
77
|
+
} catch {
|
|
78
|
+
entries = [];
|
|
79
|
+
}
|
|
80
|
+
for (const e of entries) {
|
|
81
|
+
if (!e.isDirectory()) continue;
|
|
82
|
+
const f = path.join(dir, e.name, 'index.js');
|
|
83
|
+
if (!fs.existsSync(f)) continue;
|
|
84
|
+
const mod = await import(url.pathToFileURL(f).href);
|
|
85
|
+
const p = mod.default ?? mod.provider;
|
|
86
|
+
if (p) registerProvider(p);
|
|
87
|
+
}
|
|
88
|
+
if (includeUser) {
|
|
89
|
+
const udir = path.join(homeDir(), 'providers');
|
|
90
|
+
let ufiles = [];
|
|
91
|
+
try {
|
|
92
|
+
ufiles = fs.readdirSync(udir).filter((f) => f.endsWith('.js') || f.endsWith('.mjs'));
|
|
93
|
+
} catch {
|
|
94
|
+
ufiles = [];
|
|
95
|
+
}
|
|
96
|
+
for (const f of ufiles) {
|
|
97
|
+
try {
|
|
98
|
+
const mod = await import(url.pathToFileURL(path.join(udir, f)).href);
|
|
99
|
+
const p = mod.default ?? mod.provider;
|
|
100
|
+
if (p) registerProvider(p);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
console.error(`! could not load user provider ${f}: ${err.message}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return listProviders();
|
|
107
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rebuild the store from a full CSV export.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: `tokenflow export --all` writes every normalized record at
|
|
5
|
+
* request granularity, so that file is a complete, portable, human-readable
|
|
6
|
+
* snapshot of the dataset. Restore turns it back into a store — which makes it
|
|
7
|
+
* three useful things at once:
|
|
8
|
+
*
|
|
9
|
+
* - portability move a dataset to another machine, or into a team roll-up,
|
|
10
|
+
* without shipping the vendors' raw session logs (which
|
|
11
|
+
* contain prompts and source code; the CSV does not)
|
|
12
|
+
* - recovery rebuild after the source logs have been rotated, pruned,
|
|
13
|
+
* or moved out of reach
|
|
14
|
+
* - re-pricing the cost of every *estimated* record is recomputed with
|
|
15
|
+
* the current price table, so a pricing update can be
|
|
16
|
+
* applied to history without re-reading gigabytes of logs
|
|
17
|
+
*
|
|
18
|
+
* What a restore honestly cannot recover: per-record `metadata` (working
|
|
19
|
+
* directory, streaming audit trail, price provenance) is not part of the CSV
|
|
20
|
+
* contract, so restored records carry `metadata.restored_from` and nothing
|
|
21
|
+
* else. Measured costs are preserved verbatim and never re-estimated — a
|
|
22
|
+
* gateway's own billing number is evidence, not an estimate.
|
|
23
|
+
*
|
|
24
|
+
* A restored slice is provisional: `refresh()` marks it stale as soon as the
|
|
25
|
+
* real logs for that source are read again, so the two are never double
|
|
26
|
+
* counted.
|
|
27
|
+
*/
|
|
28
|
+
import fs from 'node:fs';
|
|
29
|
+
import { Store, encodeRecord, fileId, writeJson, truncateFile, readJson } from './store.js';
|
|
30
|
+
import { paths, loadConfig } from './config.js';
|
|
31
|
+
import { computeTotal, hashId, MEASUREMENT } from './schema.js';
|
|
32
|
+
import { buildPriceBook, estimateCost } from './pricing.js';
|
|
33
|
+
import { RECORD_COLUMNS } from '../export/csv.js';
|
|
34
|
+
|
|
35
|
+
const NUM = new Set([
|
|
36
|
+
'hour', 'input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_write_tokens',
|
|
37
|
+
'cache_refresh_tokens', 'reasoning_tokens', 'total_tokens', 'estimated_cost', 'duration_ms',
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Stream a CSV file row-by-row without materialising it.
|
|
42
|
+
* Handles quoted cells, escaped quotes and newlines inside quotes.
|
|
43
|
+
* @param {string} file
|
|
44
|
+
* @param {(row:object, i:number)=>void} onRow
|
|
45
|
+
* @returns {{rows:number, malformed:number, columns:string[]}}
|
|
46
|
+
*/
|
|
47
|
+
export function streamCsv(file, onRow) {
|
|
48
|
+
const fd = fs.openSync(file, 'r');
|
|
49
|
+
const buf = Buffer.alloc(1 << 20);
|
|
50
|
+
let rest = '';
|
|
51
|
+
let cell = '';
|
|
52
|
+
let row = [];
|
|
53
|
+
let quoted = false;
|
|
54
|
+
let pendingQuote = false; // a '"' at a chunk boundary, escape or close unknown
|
|
55
|
+
let columns = null;
|
|
56
|
+
let rows = 0;
|
|
57
|
+
let malformed = 0;
|
|
58
|
+
|
|
59
|
+
const endRow = () => {
|
|
60
|
+
row.push(cell);
|
|
61
|
+
cell = '';
|
|
62
|
+
const r = row;
|
|
63
|
+
row = [];
|
|
64
|
+
if (r.length === 1 && r[0] === '') return;
|
|
65
|
+
if (!columns) { columns = r.map((h) => h.trim().replace(/^/, '')); return; }
|
|
66
|
+
if (r.length !== columns.length) { malformed++; return; }
|
|
67
|
+
const o = {};
|
|
68
|
+
for (let i = 0; i < columns.length; i++) o[columns[i]] = r[i];
|
|
69
|
+
onRow(o, rows++);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
for (;;) {
|
|
74
|
+
const n = fs.readSync(fd, buf, 0, buf.length, null);
|
|
75
|
+
if (!n) break;
|
|
76
|
+
const text = rest + buf.toString('utf8', 0, n);
|
|
77
|
+
// Keep a trailing partial UTF-8 sequence out of the parse.
|
|
78
|
+
let end = text.length;
|
|
79
|
+
while (end > 0 && (text.charCodeAt(end - 1) & 0xfc00) === 0xd800) end--;
|
|
80
|
+
rest = text.slice(end);
|
|
81
|
+
const chunk = text.slice(0, end);
|
|
82
|
+
for (let i = 0; i < chunk.length; i++) {
|
|
83
|
+
const c = chunk[i];
|
|
84
|
+
if (pendingQuote) {
|
|
85
|
+
pendingQuote = false;
|
|
86
|
+
if (c === '"') { cell += '"'; continue; }
|
|
87
|
+
quoted = false;
|
|
88
|
+
// fall through and handle c as an unquoted character
|
|
89
|
+
}
|
|
90
|
+
if (quoted) {
|
|
91
|
+
if (c === '"') {
|
|
92
|
+
if (i + 1 < chunk.length) {
|
|
93
|
+
if (chunk[i + 1] === '"') { cell += '"'; i++; } else quoted = false;
|
|
94
|
+
} else pendingQuote = true;
|
|
95
|
+
} else cell += c;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (c === '"') { quoted = true; continue; }
|
|
99
|
+
if (c === ',') { row.push(cell); cell = ''; continue; }
|
|
100
|
+
if (c === '\n') { endRow(); continue; }
|
|
101
|
+
if (c === '\r') continue;
|
|
102
|
+
cell += c;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (cell !== '' || row.length) endRow();
|
|
106
|
+
} finally {
|
|
107
|
+
fs.closeSync(fd);
|
|
108
|
+
}
|
|
109
|
+
return { rows, malformed, columns: columns || [] };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** CSV cell -> record field, honouring the null contract ('' is not 0). */
|
|
113
|
+
function cellToField(name, raw) {
|
|
114
|
+
if (raw === undefined || raw === '') return null;
|
|
115
|
+
if (name === 'total_is_partial') return raw === 'true';
|
|
116
|
+
if (NUM.has(name)) {
|
|
117
|
+
const n = Number(raw);
|
|
118
|
+
return Number.isFinite(n) ? n : null;
|
|
119
|
+
}
|
|
120
|
+
return raw;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* @param {string} file path to a full CSV export
|
|
125
|
+
* @param {object} [opt]
|
|
126
|
+
* @param {boolean} [opt.reprice=true] recompute estimated costs with the current table
|
|
127
|
+
* @param {(e:object)=>void} [opt.onProgress]
|
|
128
|
+
* @param {object} [opt.config] pre-loaded config; defaults to loadConfig()
|
|
129
|
+
*/
|
|
130
|
+
export function restoreFromCsv(file, opt = {}) {
|
|
131
|
+
if (!fs.existsSync(file)) throw new Error(`no such file: ${file}`);
|
|
132
|
+
const t0 = Date.now();
|
|
133
|
+
const progress = opt.onProgress || (() => {});
|
|
134
|
+
const config = opt.config || loadConfig();
|
|
135
|
+
const reprice = opt.reprice !== false;
|
|
136
|
+
const priceBook = buildPriceBook(readJson(paths().pricing, {}));
|
|
137
|
+
const store = new Store();
|
|
138
|
+
|
|
139
|
+
const required = ['timestamp', 'date', 'provider', 'model'];
|
|
140
|
+
const store_p = paths();
|
|
141
|
+
|
|
142
|
+
// A restore replaces the contents of the store: it is the whole dataset, not
|
|
143
|
+
// an increment, and mixing it with per-file offsets from a different machine
|
|
144
|
+
// would be meaningless.
|
|
145
|
+
store.state.sources = {};
|
|
146
|
+
store.state.stale = [];
|
|
147
|
+
store.state.counters = { records: 0, malformed: 0 };
|
|
148
|
+
store.resetCube();
|
|
149
|
+
store.resetSessions();
|
|
150
|
+
store.resetActivity();
|
|
151
|
+
for (const s of store.listShards()) truncateFile(`${store_p.records}/${s}`);
|
|
152
|
+
|
|
153
|
+
const gen = (store.state.restored?.gen || 0) + 1;
|
|
154
|
+
const bySource = {};
|
|
155
|
+
const dropped = { noTimestamp: 0, noDate: 0 };
|
|
156
|
+
let repriced = 0;
|
|
157
|
+
let measuredKept = 0;
|
|
158
|
+
let unpriced = 0;
|
|
159
|
+
let checkedHeader = false;
|
|
160
|
+
|
|
161
|
+
const res = streamCsv(file, (o, i) => {
|
|
162
|
+
if (!checkedHeader) {
|
|
163
|
+
checkedHeader = true;
|
|
164
|
+
const missing = required.filter((c) => !(c in o));
|
|
165
|
+
if (missing.length) throw new Error(`not a full tokenflow export — missing column(s): ${missing.join(', ')}`);
|
|
166
|
+
}
|
|
167
|
+
/** Built column-by-column from the CSV, so its shape is dynamic. */
|
|
168
|
+
/** @type {Record<string, any>} */
|
|
169
|
+
const rec = {};
|
|
170
|
+
for (const [name] of RECORD_COLUMNS) rec[name] = cellToField(name, o[name]);
|
|
171
|
+
if (!rec.timestamp) { dropped.noTimestamp++; return; }
|
|
172
|
+
if (!rec.date) { dropped.noDate++; return; }
|
|
173
|
+
|
|
174
|
+
rec.provider_label = rec.provider;
|
|
175
|
+
rec.application = null;
|
|
176
|
+
rec.tz_offset = null;
|
|
177
|
+
rec.dow = dowOf(rec.date);
|
|
178
|
+
rec.measurement = rec.measurement || MEASUREMENT.PRIMARY;
|
|
179
|
+
rec.total_is_partial = !!rec.total_is_partial;
|
|
180
|
+
rec.metadata = { restored_from: file.replace(/^.*\//, '') };
|
|
181
|
+
|
|
182
|
+
// computeTotal returns {total, partial} — destructure it. Assigning the
|
|
183
|
+
// whole object here stored total_tokens as an object on every restored
|
|
184
|
+
// record, which re-exported as "[object Object]".
|
|
185
|
+
const t = computeTotal(rec);
|
|
186
|
+
if (t.total !== null) rec.total_tokens = t.total;
|
|
187
|
+
|
|
188
|
+
if (rec.cost_basis === 'measured') {
|
|
189
|
+
measuredKept++;
|
|
190
|
+
} else if (reprice) {
|
|
191
|
+
const c = estimateCost(rec, rec.model, rec.provider, priceBook, { tier: rec.service_tier });
|
|
192
|
+
rec.estimated_cost = c.cost;
|
|
193
|
+
rec.cost_basis = c.cost === null ? null : c.basis;
|
|
194
|
+
if (c.cost === null) unpriced++;
|
|
195
|
+
else {
|
|
196
|
+
repriced++;
|
|
197
|
+
if (c.partial) rec.metadata.cost_partial = true;
|
|
198
|
+
if (c.tierMult !== 1) rec.metadata.cost_tier_multiplier = c.tierMult;
|
|
199
|
+
if (c.src) rec.metadata.price_source = c.src;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const src = rec.source || 'restore';
|
|
204
|
+
rec.id = rec.id || hashId(`restore|${src}|${rec.timestamp}|${rec.request_id || rec.session_id || ''}|${i}`);
|
|
205
|
+
rec._fileId = fileId('restore', src);
|
|
206
|
+
rec._gen = gen;
|
|
207
|
+
bySource[src] = (bySource[src] || 0) + 1;
|
|
208
|
+
|
|
209
|
+
if (config.store?.keepRaw !== false) store.writer(rec.date).write(encodeRecord(rec));
|
|
210
|
+
store.addToCube(rec);
|
|
211
|
+
store.upsertSession(rec);
|
|
212
|
+
store.addToActivity(rec);
|
|
213
|
+
|
|
214
|
+
if ((i + 1) % 20000 === 0) progress({ type: 'progress', records: i + 1 });
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
store.closeWriters();
|
|
218
|
+
const records = Object.values(bySource).reduce((a, b) => a + b, 0);
|
|
219
|
+
store.state.counters = { records, malformed: res.malformed };
|
|
220
|
+
store.state.restored = {
|
|
221
|
+
file, at: new Date().toISOString(), gen, records, bySource, repriced,
|
|
222
|
+
pricingVersion: priceBook.version,
|
|
223
|
+
};
|
|
224
|
+
store.state.lastRefresh = new Date().toISOString();
|
|
225
|
+
// Every restored source gets a state entry so `status` can explain where the
|
|
226
|
+
// data came from, with no file offsets — an incremental refresh must not
|
|
227
|
+
// believe it has already read logs it has never seen.
|
|
228
|
+
for (const [id, n] of Object.entries(bySource)) {
|
|
229
|
+
const st = store.sourceState(id);
|
|
230
|
+
st.files = {};
|
|
231
|
+
st.cursor = null;
|
|
232
|
+
st.records = n;
|
|
233
|
+
st.restored = true;
|
|
234
|
+
st.lastRefresh = store.state.restored.at;
|
|
235
|
+
}
|
|
236
|
+
store.saveCube({ tz: config.timezone || null, pricingVersion: priceBook.version });
|
|
237
|
+
store.saveSessions();
|
|
238
|
+
store.saveActivity();
|
|
239
|
+
store.saveState();
|
|
240
|
+
writeJson(`${store_p.data}/project-paths.json`, readJson(`${store_p.data}/project-paths.json`, { paths: [] }));
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
file,
|
|
244
|
+
records,
|
|
245
|
+
bySource,
|
|
246
|
+
malformed: res.malformed,
|
|
247
|
+
dropped,
|
|
248
|
+
repriced,
|
|
249
|
+
measuredKept,
|
|
250
|
+
unpriced,
|
|
251
|
+
pricingVersion: priceBook.version,
|
|
252
|
+
durationMs: Date.now() - t0,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** 0 = Monday, matching schema.dateParts(). */
|
|
257
|
+
function dowOf(date) {
|
|
258
|
+
const d = new Date(`${date}T00:00:00Z`);
|
|
259
|
+
if (Number.isNaN(+d)) return 0;
|
|
260
|
+
return (d.getUTCDay() + 6) % 7;
|
|
261
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Digest scheduling — launchd-based, no resident Node process.
|
|
3
|
+
*
|
|
4
|
+
* Installs a per-user LaunchAgent that runs `tokenflow digest` on a schedule
|
|
5
|
+
* (default: every Monday at 09:00) and optionally delivers it via a
|
|
6
|
+
* configured channel (email/telegram/webhook — see delivery.js). The digest
|
|
7
|
+
* is also always written to $TOKENFLOW_HOME/digests/<date>.md so there is a
|
|
8
|
+
* local record even if delivery fails.
|
|
9
|
+
*
|
|
10
|
+
* tokenflow schedule --install # weekly, Monday 09:00
|
|
11
|
+
* tokenflow schedule --install --at "Sunday 18:00"
|
|
12
|
+
* tokenflow schedule --uninstall
|
|
13
|
+
* tokenflow schedule --status
|
|
14
|
+
*
|
|
15
|
+
* Design notes:
|
|
16
|
+
* - launchd StartCalendarInterval = native macOS scheduling; the agent only
|
|
17
|
+
* wakes Node for the seconds the digest takes to build.
|
|
18
|
+
* - Delivery adapters live in delivery.js and are entirely opt-in. With no
|
|
19
|
+
* delivery configured, the digest is still written locally.
|
|
20
|
+
* - The plist embeds an absolute node path captured at install time.
|
|
21
|
+
*/
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import os from 'node:os';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { execFileSync } from 'node:child_process';
|
|
26
|
+
import { paths } from './config.js';
|
|
27
|
+
|
|
28
|
+
const LABEL = 'app.tokenflow.digest';
|
|
29
|
+
|
|
30
|
+
export function plistPath() {
|
|
31
|
+
return path.join(os.homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Parse "Sunday 18:00" / "Mon 09:15" → { Weekday, Hour, Minute } */
|
|
35
|
+
export function parseWhen(when) {
|
|
36
|
+
const DAYS = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 };
|
|
37
|
+
const m = /^(sun|mon|tue|wed|thu|fri|sat)[a-z]*\s+(\d{1,2}):(\d{2})$/i.exec((when || '').trim());
|
|
38
|
+
if (!m) return null;
|
|
39
|
+
const wd = DAYS[m[1].toLowerCase()];
|
|
40
|
+
const hour = Number(m[2]);
|
|
41
|
+
const minute = Number(m[3]);
|
|
42
|
+
if (hour > 23 || minute > 59) return null;
|
|
43
|
+
return { Weekday: wd, Hour: hour, Minute: minute };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function renderPlist({ nodeBin, cliPath, when, home }) {
|
|
47
|
+
const cal = when || { Weekday: 1, Hour: 9, Minute: 0 };
|
|
48
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
49
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
50
|
+
<plist version="1.0">
|
|
51
|
+
<dict>
|
|
52
|
+
<key>Label</key><string>${LABEL}</string>
|
|
53
|
+
<key>ProgramArguments</key>
|
|
54
|
+
<array>
|
|
55
|
+
<string>${nodeBin}</string>
|
|
56
|
+
<string>${cliPath}</string>
|
|
57
|
+
<string>digest</string>
|
|
58
|
+
<string>--deliver</string>
|
|
59
|
+
</array>
|
|
60
|
+
<key>StartCalendarInterval</key>
|
|
61
|
+
<dict>
|
|
62
|
+
<key>Weekday</key><integer>${cal.Weekday}</integer>
|
|
63
|
+
<key>Hour</key><integer>${cal.Hour}</integer>
|
|
64
|
+
<key>Minute</key><integer>${cal.Minute}</integer>
|
|
65
|
+
</dict>
|
|
66
|
+
<key>EnvironmentVariables</key>
|
|
67
|
+
<dict>
|
|
68
|
+
<key>TOKENFLOW_HOME</key><string>${home}</string>
|
|
69
|
+
</dict>
|
|
70
|
+
<key>StandardErrorPath</key><string>${home}/digests/schedule.err.log</string>
|
|
71
|
+
</dict>
|
|
72
|
+
</plist>
|
|
73
|
+
`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function install({ when }) {
|
|
77
|
+
const cal = parseWhen(when);
|
|
78
|
+
if (when && !cal) {
|
|
79
|
+
throw new Error(`could not parse --at "${when}" — expected e.g. "Monday 09:00"`);
|
|
80
|
+
}
|
|
81
|
+
const nodeBin = process.execPath;
|
|
82
|
+
// CLI lives next to this module: src/core/…/schedule.js → ../../bin
|
|
83
|
+
const here = path.dirname(new URL(import.meta.url).pathname);
|
|
84
|
+
const cliPath = path.resolve(here, '..', '..', 'bin', 'tokenflow.js');
|
|
85
|
+
const home = process.env.TOKENFLOW_HOME || path.join(os.homedir(), '.tokenflow');
|
|
86
|
+
ensureDigestDir(home);
|
|
87
|
+
|
|
88
|
+
fs.writeFileSync(plistPath(), renderPlist({ nodeBin, cliPath, when: cal, home }));
|
|
89
|
+
execFileSync('launchctl', ['unload', plistPath()], { stdio: 'ignore' });
|
|
90
|
+
execFileSync('launchctl', ['load', plistPath()]);
|
|
91
|
+
const day = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][(cal || { Weekday: 1 }).Weekday];
|
|
92
|
+
const hh = String((cal || { Hour: 9 }).Hour).padStart(2, '0');
|
|
93
|
+
const mm = String((cal || { Minute: 0 }).Minute).padStart(2, '0');
|
|
94
|
+
return `installed — runs every ${day} at ${hh}:${mm}; digests saved to ${home}/digests/`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function uninstall() {
|
|
98
|
+
try { execFileSync('launchctl', ['unload', plistPath()], { stdio: 'ignore' }); } catch { /* not loaded */ }
|
|
99
|
+
if (fs.existsSync(plistPath())) fs.unlinkSync(plistPath());
|
|
100
|
+
return 'uninstalled';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function status() {
|
|
104
|
+
const installed = fs.existsSync(plistPath());
|
|
105
|
+
let loaded = false;
|
|
106
|
+
try {
|
|
107
|
+
const out = execFileSync('launchctl', ['list'], { encoding: 'utf8' });
|
|
108
|
+
loaded = out.split('\n').some((l) => l.includes(LABEL));
|
|
109
|
+
} catch { /* launchctl unavailable */ }
|
|
110
|
+
const dir = path.join(process.env.TOKENFLOW_HOME || path.join(os.homedir(), '.tokenflow'), 'digests');
|
|
111
|
+
const latest = fs.existsSync(dir)
|
|
112
|
+
? fs.readdirSync(dir).filter((f) => f.endsWith('.md')).sort().pop() || null
|
|
113
|
+
: null;
|
|
114
|
+
return { installed, loaded, latestDigest: latest ? path.join(dir, latest) : null };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function ensureDigestDir(home) {
|
|
118
|
+
const dir = path.join(home, 'digests');
|
|
119
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
120
|
+
}
|