@vimoxshah/tokenflow 1.1.1 → 1.2.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/CHANGELOG.md +228 -0
- package/Dockerfile.team +20 -0
- package/README.md +30 -11
- package/bin/tokenflow.js +147 -12
- package/design/tokens.yaml +330 -0
- package/docs/architecture.md +5 -4
- package/docs/cli.md +204 -0
- package/docs/configuration.md +117 -2
- package/docs/design-system.md +187 -0
- package/docs/exports-and-budgets.md +85 -0
- package/docs/guard-codex.md +132 -0
- package/docs/ledger.md +144 -0
- package/docs/live-mode.md +40 -0
- package/docs/media/overview-aurora-dark.png +0 -0
- package/docs/media/receipts-aurora-dark.png +0 -0
- package/docs/providers-otel.md +179 -0
- package/docs/providers.md +54 -1
- package/docs/receipt-schema.md +74 -0
- package/docs/roadmap.md +182 -0
- package/docs/team-server.md +170 -0
- package/docs/ui-views.md +322 -0
- package/package.json +7 -2
- package/schemas/receipt.v0.json +160 -0
- package/scripts/build-dmg.sh +11 -2
- package/scripts/build-menubar-app.sh +58 -7
- package/scripts/design-build.js +475 -0
- package/src/analytics/anatomy.js +467 -0
- package/src/analytics/branch-compare.js +159 -0
- package/src/analytics/cache-health.js +141 -0
- package/src/analytics/live-view.js +266 -0
- package/src/analytics/receipt-schema.js +214 -0
- package/src/analytics/receipt.js +709 -0
- package/src/analytics/rhythm.js +184 -0
- package/src/analytics/whatif.js +263 -0
- package/src/commands/budget-scopes.js +133 -0
- package/src/commands/doctor-checks.js +400 -0
- package/src/commands/guard.js +531 -0
- package/src/commands/hooks.js +238 -0
- package/src/commands/pricing-diff.js +316 -0
- package/src/commands/receipt.js +226 -0
- package/src/commands/team-serve.js +407 -0
- package/src/commands/week.js +86 -0
- package/src/core/annotations.js +97 -0
- package/src/core/budget.js +33 -0
- package/src/core/bundle.js +45 -2
- package/src/core/ingest.js +33 -0
- package/src/core/live-status.js +227 -2
- package/src/core/policy.js +103 -0
- package/src/core/receipt-note.js +123 -0
- package/src/core/repo.js +64 -0
- package/src/core/sync.js +163 -26
- package/src/core/team.js +0 -0
- package/src/export/html-snapshot.js +28 -1
- package/src/export/menubar.js +21 -0
- package/src/export/receipt-card.js +210 -0
- package/src/export/week-card.js +185 -0
- package/src/providers/mock/index.js +383 -52
- package/src/providers/openai/index.js +31 -1
- package/src/providers/otel/index.js +656 -0
- package/src/server/routes/annotations.js +42 -0
- package/src/server/routes/cache-health.js +95 -0
- package/src/server/routes/index.js +54 -0
- package/src/server/routes/session.js +157 -0
- package/src/server/server.js +47 -1
- package/src/ui/app.js +541 -308
- package/src/ui/charts.js +95 -0
- package/src/ui/first-run.js +144 -0
- package/src/ui/index.html +4 -1
- package/src/ui/palette.js +335 -0
- package/src/ui/styles/anatomy.css +117 -0
- package/src/ui/styles/annotations.css +40 -0
- package/src/ui/styles/branches.css +99 -0
- package/src/ui/styles/cache.css +6 -0
- package/src/ui/styles/first-run.css +31 -0
- package/src/ui/styles/live.css +100 -0
- package/src/ui/styles/palette.css +85 -0
- package/src/ui/styles/rhythm.css +8 -0
- package/src/ui/styles/whatif.css +55 -0
- package/src/ui/styles.css +303 -196
- package/src/ui/views/anatomy.js +567 -0
- package/src/ui/views/annotations.js +121 -0
- package/src/ui/views/branches.js +304 -0
- package/src/ui/views/cache.js +232 -0
- package/src/ui/views/index.js +85 -0
- package/src/ui/views/live.js +683 -0
- package/src/ui/views/rhythm.js +206 -0
- package/src/ui/views/whatif.js +196 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-repository guard policy — `.tokenflow/policy.yaml` at a repository root.
|
|
3
|
+
*
|
|
4
|
+
* A cap declared with `tokenflow guard --set` lives in ~/.tokenflow/config.yaml:
|
|
5
|
+
* one machine, one person. A repository-level cap travels with the repo itself
|
|
6
|
+
* (checked in, reviewed in a PR, the same for everyone who clones it) and wins
|
|
7
|
+
* over the personal default — a data-heavy repo's sessions legitimately carry a
|
|
8
|
+
* bigger prompt than a docs repo's, and that is a fact about the repo, not
|
|
9
|
+
* about who happens to be sitting at the keyboard.
|
|
10
|
+
*
|
|
11
|
+
* Nothing here throws on a malformed file: a broken policy.yaml degrades to
|
|
12
|
+
* "reported, not applied" — the same posture the rest of TokenFlow takes
|
|
13
|
+
* toward bad input — rather than breaking the hook it is meant to configure.
|
|
14
|
+
*/
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { parseYaml } from './yaml.js';
|
|
18
|
+
import { repoRootOf } from './repo.js';
|
|
19
|
+
import { loadConfig } from './config.js';
|
|
20
|
+
|
|
21
|
+
/** The five thresholds `evaluateGuard` understands. Canonical list — guard.js re-exports this. */
|
|
22
|
+
export const GUARD_KEYS = ['warnCostUsd', 'maxCostUsd', 'warnContextTokens', 'maxContextTokens', 'warnMarginalUsd'];
|
|
23
|
+
|
|
24
|
+
const POLICY_RELATIVE_PATH = path.join('.tokenflow', 'policy.yaml');
|
|
25
|
+
|
|
26
|
+
function isPositiveNumber(v) {
|
|
27
|
+
return typeof v === 'number' && Number.isFinite(v) && v > 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Read `<repo root>/.tokenflow/policy.yaml`, if this cwd sits inside a
|
|
32
|
+
* repository and that file exists. Invalid values are reported in `errors`
|
|
33
|
+
* and left out of `guard`, never thrown — a typo in a checked-in policy file
|
|
34
|
+
* must not take the hook down for everyone who clones the repo.
|
|
35
|
+
* @param {string|null|undefined} cwd
|
|
36
|
+
* @returns {{repoRoot:string|null, path:string|null, found:boolean, guard:object, note:string|null, errors:string[]}}
|
|
37
|
+
*/
|
|
38
|
+
export function loadRepoPolicy(cwd) {
|
|
39
|
+
const errors = [];
|
|
40
|
+
const repoRoot = cwd ? repoRootOf(cwd) : null;
|
|
41
|
+
if (!repoRoot) return { repoRoot: null, path: null, found: false, guard: {}, note: null, errors };
|
|
42
|
+
|
|
43
|
+
const file = path.join(repoRoot, POLICY_RELATIVE_PATH);
|
|
44
|
+
if (!fs.existsSync(file)) return { repoRoot, path: file, found: false, guard: {}, note: null, errors };
|
|
45
|
+
|
|
46
|
+
let doc;
|
|
47
|
+
try {
|
|
48
|
+
doc = parseYaml(fs.readFileSync(file, 'utf8')) || {};
|
|
49
|
+
} catch (err) {
|
|
50
|
+
errors.push(`${file}: ${err.message}`);
|
|
51
|
+
return { repoRoot, path: file, found: true, guard: {}, note: null, errors };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const rawGuard = doc.guard && typeof doc.guard === 'object' && !Array.isArray(doc.guard) ? doc.guard : {};
|
|
55
|
+
const guard = {};
|
|
56
|
+
for (const [k, v] of Object.entries(rawGuard)) {
|
|
57
|
+
if (k === 'note') continue; // accepted alongside the thresholds; handled below
|
|
58
|
+
if (!GUARD_KEYS.includes(k)) { errors.push(`${file}: unknown guard key "${k}"`); continue; }
|
|
59
|
+
if (v === null || v === undefined) continue; // not declared
|
|
60
|
+
if (!isPositiveNumber(v)) { errors.push(`${file}: guard.${k} must be a positive number, got ${JSON.stringify(v)}`); continue; }
|
|
61
|
+
guard[k] = v;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// `note:` can sit at the top level or beside the guard block — accept both
|
|
65
|
+
// rather than guess which one a hand-written file used.
|
|
66
|
+
const noteRaw = doc.note !== undefined ? doc.note : rawGuard.note;
|
|
67
|
+
let note = null;
|
|
68
|
+
if (noteRaw !== undefined && noteRaw !== null) {
|
|
69
|
+
if (typeof noteRaw === 'string') note = noteRaw;
|
|
70
|
+
else errors.push(`${file}: note must be a string`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { repoRoot, path: file, found: true, guard, note, errors };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Merge a repository's declared policy over `config.guard`: the repo wins
|
|
78
|
+
* key-by-key, the personal config is the fallback, and an undeclared key is
|
|
79
|
+
* `null` — informational only, the same contract `evaluateGuard` already has.
|
|
80
|
+
* @param {{cwd?:string|null, config?:object}} [opt]
|
|
81
|
+
* @returns {{policy:object, sources:Record<string,'repo'|'config'|'default'>, repoRoot:string|null, note:string|null, errors:string[]}}
|
|
82
|
+
*/
|
|
83
|
+
export function effectiveGuardPolicy({ cwd = null, config } = {}) {
|
|
84
|
+
const cfg = config || loadConfig();
|
|
85
|
+
const repo = loadRepoPolicy(cwd);
|
|
86
|
+
const cfgGuard = cfg.guard || {};
|
|
87
|
+
const policy = {};
|
|
88
|
+
/** @type {Record<string, 'repo'|'config'|'default'>} */
|
|
89
|
+
const sources = {};
|
|
90
|
+
for (const k of GUARD_KEYS) {
|
|
91
|
+
if (isPositiveNumber(repo.guard[k])) {
|
|
92
|
+
policy[k] = repo.guard[k];
|
|
93
|
+
sources[k] = 'repo';
|
|
94
|
+
} else if (isPositiveNumber(cfgGuard[k])) {
|
|
95
|
+
policy[k] = cfgGuard[k];
|
|
96
|
+
sources[k] = 'config';
|
|
97
|
+
} else {
|
|
98
|
+
policy[k] = null;
|
|
99
|
+
sources[k] = 'default';
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { policy, sources, repoRoot: repo.repoRoot, note: repo.note, errors: repo.errors };
|
|
103
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Receipts as git notes — a branch's TokenFlow receipt attached to the commit
|
|
3
|
+
* being pushed, under `refs/notes/tokenflow`, so it travels with the code
|
|
4
|
+
* with no server involved. `src/commands/hooks.js` is the only caller in this
|
|
5
|
+
* package (the pre-push hook body); everything here also works standalone.
|
|
6
|
+
*
|
|
7
|
+
* Every `git` invocation uses `execFileSync` with an argument array — never a
|
|
8
|
+
* shell string — so a branch name or path can never be interpreted as shell
|
|
9
|
+
* syntax.
|
|
10
|
+
*/
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { execFileSync } from 'node:child_process';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { paths } from './config.js';
|
|
17
|
+
import { readJson } from './store.js';
|
|
18
|
+
import { buildPriceBook } from './pricing.js';
|
|
19
|
+
import { repoRootOf, makeRepoResolver } from './repo.js';
|
|
20
|
+
import { buildReceipts } from '../analytics/receipt.js';
|
|
21
|
+
import { toReceiptV0 } from '../analytics/receipt-schema.js';
|
|
22
|
+
import { loadPrimaryRecords } from '../commands/receipt.js';
|
|
23
|
+
|
|
24
|
+
/** The git notes ref every receipt note in this package lives under. */
|
|
25
|
+
export const NOTES_REF = 'tokenflow';
|
|
26
|
+
|
|
27
|
+
/** This package's own version, read from its package.json (not the target repo's). */
|
|
28
|
+
function toolVersion() {
|
|
29
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
const pkgPath = path.resolve(here, '..', '..', 'package.json');
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version || '0.0.0';
|
|
33
|
+
} catch {
|
|
34
|
+
return '0.0.0'; // package.json missing or unreadable: still return a usable receipt
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function resolveSha(repoPath, branch) {
|
|
39
|
+
return execFileSync('git', ['rev-parse', `refs/heads/${branch}`], {
|
|
40
|
+
cwd: repoPath, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
41
|
+
}).trim();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Compute the receipt.v0 object for one branch of one repository checkout.
|
|
46
|
+
* Scans every primary record this machine has recorded, then narrows to the
|
|
47
|
+
* repository identified from `repoPath` (the same worktree-aware resolution
|
|
48
|
+
* `tokenflow receipt` uses) and to the named branch.
|
|
49
|
+
*
|
|
50
|
+
* No pull request is looked up here — attaching a note happens at push time,
|
|
51
|
+
* before any PR necessarily exists — so the returned receipt always carries
|
|
52
|
+
* `pr: null` and `changedLines: null`.
|
|
53
|
+
*
|
|
54
|
+
* @param {{repoPath:string, branch:string, store?:import('./store.js').Store,
|
|
55
|
+
* config?:object, sha?:string}} opt
|
|
56
|
+
* `config` is the pricing-overrides object (the shape of pricing.json /
|
|
57
|
+
* buildPriceBook's argument); defaults to what's on disk. `sha` defaults to
|
|
58
|
+
* `git rev-parse refs/heads/<branch>` in repoPath.
|
|
59
|
+
* @returns {object|null} a receipt.v0 object, or null when this branch has no local sessions
|
|
60
|
+
*/
|
|
61
|
+
export function buildBranchReceipt({ repoPath, branch, store, config, sha }) {
|
|
62
|
+
const pricingConfig = config || readJson(paths().pricing, {});
|
|
63
|
+
const book = buildPriceBook(pricingConfig);
|
|
64
|
+
const repoName = path.basename(repoRootOf(repoPath) || repoPath);
|
|
65
|
+
|
|
66
|
+
const records = loadPrimaryRecords({ store });
|
|
67
|
+
const result = buildReceipts(records, { book, repoOf: makeRepoResolver(), minTurns: 1 });
|
|
68
|
+
const R = result.repos.find((r) => r.repo === repoName);
|
|
69
|
+
const b = R ? R.branches.find((x) => x.key === branch) : null;
|
|
70
|
+
if (!b) return null;
|
|
71
|
+
|
|
72
|
+
const headSha = sha || resolveSha(repoPath, branch);
|
|
73
|
+
return toReceiptV0(b, { repo: repoName, headSha, toolVersion: toolVersion() });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Attach `receipt` to `sha` as a git note under refs/notes/tokenflow,
|
|
78
|
+
* overwriting any note already there (a re-push of the same sha updates it).
|
|
79
|
+
* @param {{repoPath:string, sha:string, receipt:object}} opt
|
|
80
|
+
*/
|
|
81
|
+
export function writeNote({ repoPath, sha, receipt }) {
|
|
82
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tokenflow-note-'));
|
|
83
|
+
const file = path.join(dir, 'receipt.json');
|
|
84
|
+
try {
|
|
85
|
+
fs.writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`);
|
|
86
|
+
execFileSync('git', ['notes', `--ref=${NOTES_REF}`, 'add', '-f', '-F', file, sha], {
|
|
87
|
+
cwd: repoPath, stdio: ['ignore', 'ignore', 'pipe'],
|
|
88
|
+
});
|
|
89
|
+
} finally {
|
|
90
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Read back the receipt attached to `sha`, or null when there isn't one.
|
|
96
|
+
* @param {{repoPath:string, sha:string}} opt
|
|
97
|
+
* @returns {object|null}
|
|
98
|
+
*/
|
|
99
|
+
export function readNote({ repoPath, sha }) {
|
|
100
|
+
try {
|
|
101
|
+
const out = execFileSync('git', ['notes', `--ref=${NOTES_REF}`, 'show', sha], {
|
|
102
|
+
cwd: repoPath, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
103
|
+
});
|
|
104
|
+
return JSON.parse(out);
|
|
105
|
+
} catch {
|
|
106
|
+
return null; // no note on this sha (or a git/JSON error) — nothing to read back
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Push refs/notes/tokenflow to `remote`. Runs with `TOKENFLOW_HOOK_NESTED=1`
|
|
112
|
+
* in the child's environment so a pre-push hook this triggers on the same
|
|
113
|
+
* repo can recognize this as the nested notes push and return immediately
|
|
114
|
+
* instead of recursing.
|
|
115
|
+
* @param {{repoPath:string, remote:string}} opt
|
|
116
|
+
*/
|
|
117
|
+
export function pushNotes({ repoPath, remote }) {
|
|
118
|
+
execFileSync('git', ['push', remote, `refs/notes/${NOTES_REF}`], {
|
|
119
|
+
cwd: repoPath,
|
|
120
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
121
|
+
env: { ...process.env, TOKENFLOW_HOOK_NESTED: '1' },
|
|
122
|
+
});
|
|
123
|
+
}
|
package/src/core/repo.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repository identity from a working directory, seeing through git worktrees.
|
|
3
|
+
*
|
|
4
|
+
* The adapters record `project` as the basename of the working directory, so a
|
|
5
|
+
* session in `<repo>/.worktrees/<x>` is filed under `x` and one repository's
|
|
6
|
+
* spend is split across every worktree it ever had. Walking up from the cwd
|
|
7
|
+
* to `.git` and following a worktree's `gitdir:` pointer back to the main
|
|
8
|
+
* checkout puts them back together. No subprocess, no network; results are
|
|
9
|
+
* cached per cwd because a store holds thousands of distinct ones.
|
|
10
|
+
*
|
|
11
|
+
* `src/core/ingest.js` applies `repoRootOf` at ingest time too, not only when
|
|
12
|
+
* a receipt is built later: `project`/`repository` are corrected to the
|
|
13
|
+
* resolved repo name as each record is normalized, and `metadata.repoResolved`
|
|
14
|
+
* records whether a root was found, so a record's stored fields already carry
|
|
15
|
+
* the correction rather than needing every reader to redo this walk.
|
|
16
|
+
*/
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {string} cwd
|
|
22
|
+
* @param {Map<string,string|null>} [cache]
|
|
23
|
+
* @returns {string|null} the main checkout's root, or null when no `.git` is found above `cwd`
|
|
24
|
+
*/
|
|
25
|
+
export function repoRootOf(cwd, cache = new Map()) {
|
|
26
|
+
if (!cwd) return null;
|
|
27
|
+
if (cache.has(cwd)) return cache.get(cwd);
|
|
28
|
+
let d = cwd;
|
|
29
|
+
let out = null;
|
|
30
|
+
for (let i = 0; i < 12 && d && d !== path.dirname(d); i++) {
|
|
31
|
+
const g = path.join(d, '.git');
|
|
32
|
+
let st = null;
|
|
33
|
+
try { st = fs.statSync(g); } catch { /* not here; keep walking up */ }
|
|
34
|
+
if (st) {
|
|
35
|
+
if (st.isDirectory()) { out = d; break; }
|
|
36
|
+
// A worktree: `.git` is a file "gitdir: /main/checkout/.git/worktrees/<name>"
|
|
37
|
+
let txt = '';
|
|
38
|
+
try { txt = fs.readFileSync(g, 'utf8'); } catch { /* unreadable; fall back to this dir */ }
|
|
39
|
+
const m = /gitdir:\s*(.+)\s*$/m.exec(txt);
|
|
40
|
+
if (m) {
|
|
41
|
+
const gitdir = path.resolve(d, m[1].trim());
|
|
42
|
+
const wt = gitdir.indexOf(`${path.sep}.git${path.sep}worktrees${path.sep}`);
|
|
43
|
+
out = wt > -1 ? gitdir.slice(0, wt) : d;
|
|
44
|
+
} else {
|
|
45
|
+
out = d;
|
|
46
|
+
}
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
d = path.dirname(d);
|
|
50
|
+
}
|
|
51
|
+
cache.set(cwd, out);
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A resolver for the receipt builder: repository name from the recorded cwd, else what the adapter said. */
|
|
56
|
+
export function makeRepoResolver() {
|
|
57
|
+
const cache = new Map();
|
|
58
|
+
return (rec) => {
|
|
59
|
+
const cwd = rec.metadata && rec.metadata.cwd;
|
|
60
|
+
const root = cwd ? repoRootOf(cwd, cache) : null;
|
|
61
|
+
if (root) return path.basename(root);
|
|
62
|
+
return rec.repository || rec.project || null;
|
|
63
|
+
};
|
|
64
|
+
}
|
package/src/core/sync.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Multi-machine aggregation — optional, OFF by default, file-based
|
|
2
|
+
* Multi-machine aggregation — optional, OFF by default, file-based (or a
|
|
3
|
+
* server the user names).
|
|
3
4
|
*
|
|
4
5
|
* Philosophy: instead of a cloud SaaS endpoint, TokenFlow syncs through a
|
|
5
6
|
* folder the user already trusts (iCloud Drive, Dropbox, Syncthing mount,
|
|
@@ -14,23 +15,48 @@
|
|
|
14
15
|
* machineName: MacBook Pro # friendly label shown in aggregated views
|
|
15
16
|
* developerName: Vimox # OPTIONAL — only when the team explicitly
|
|
16
17
|
* # opts into per-developer visibility (P4-B)
|
|
18
|
+
* receipts: true # ← default; set false to skip the ledger file
|
|
19
|
+
* to: https://… # OPTIONAL — POST both files to a server
|
|
20
|
+
* # instead of writing them into `dir`
|
|
21
|
+
* token: … # bearer token for `to` (or env
|
|
22
|
+
* # TOKENFLOW_SYNC_TOKEN)
|
|
17
23
|
*
|
|
18
|
-
* What is transmitted (per day, per provider/model):
|
|
24
|
+
* What is transmitted, in the daily rollup (per day, per provider/model):
|
|
19
25
|
* date, tokens in/out/cache, requests, estimated cost, machineId
|
|
20
26
|
* + developerName ONLY if you set it yourself (team mode, opt-in)
|
|
21
|
-
* What is NEVER transmitted: prompts, code, file paths beyond the machine
|
|
22
|
-
* label you chose, credentials.
|
|
23
27
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
+
* A SECOND file, `<machineId>.receipts.json`, carries the team's cost-per-
|
|
29
|
+
* branch-and-PR ledger (see src/analytics/receipt.js), whole-state and
|
|
30
|
+
* last-write-wins, written on every push unless `sync.receipts: false`. Per
|
|
31
|
+
* branch it transmits only:
|
|
32
|
+
* repo (basename only — never a path or URL), branch, costUsd, turns,
|
|
33
|
+
* sessions, subagentTurns, contextShare, first/last (ISO timestamps),
|
|
34
|
+
* longLived (bool), and pr — null, or {number, mergedAt} for a PR this
|
|
35
|
+
* branch shipped in. (`pr` comes from buildReceiptsForStore(), which has
|
|
36
|
+
* no PR source wired in yet — it is null on every real push today; the
|
|
37
|
+
* shape exists so a future cached PR list needs no format change.)
|
|
38
|
+
* It NEVER transmits: file paths, commit hashes, PR titles, diffs or line
|
|
39
|
+
* counts, prompts, or any model/code text — nothing beyond the aggregate
|
|
40
|
+
* numbers above.
|
|
41
|
+
*
|
|
42
|
+
* Conflict resolution: each machine writes ONLY its own files
|
|
43
|
+
* (<machineId>.jsonl append-only last-write-wins per line;
|
|
44
|
+
* <machineId>.receipts.json whole-state last-write-wins). Reads merge all
|
|
45
|
+
* sibling files. Offline is the natural state: files just sync whenever the
|
|
46
|
+
* folder does.
|
|
47
|
+
*
|
|
48
|
+
* `sync.to` (+ `sync.token` / env TOKENFLOW_SYNC_TOKEN) sends the exact same
|
|
49
|
+
* two files' contents to a server the user names instead of writing them
|
|
50
|
+
* into the shared folder — see push() below. Nothing else about the data
|
|
51
|
+
* changes; only the destination does.
|
|
28
52
|
*/
|
|
29
53
|
import fs from 'node:fs';
|
|
30
54
|
import path from 'node:path';
|
|
31
55
|
import os from 'node:os';
|
|
32
56
|
import crypto from 'node:crypto';
|
|
33
57
|
import { loadConfig, paths } from './config.js';
|
|
58
|
+
import { Store, readJson } from './store.js';
|
|
59
|
+
import { buildReceiptsForStore } from './bundle.js';
|
|
34
60
|
|
|
35
61
|
/** Stable, anonymous machine id: random UUID persisted locally on first use. */
|
|
36
62
|
export function machineId(cfgHome = null) {
|
|
@@ -54,32 +80,25 @@ export function syncDir(cfg) {
|
|
|
54
80
|
}
|
|
55
81
|
|
|
56
82
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
83
|
+
* Read the local daily cube rollup written by the dashboard/watch pipeline
|
|
84
|
+
* and fold it into one JSONL line per date. Provider/model detail stays
|
|
85
|
+
* LOCAL; the synced line is deliberately coarse so the shared folder (or
|
|
86
|
+
* server) leaks minimum information.
|
|
87
|
+
* @param {object} cfg loaded config
|
|
88
|
+
* @param {string} id this machine's id
|
|
89
|
+
* @param {string} cubeFile path to data/cube.json, already known to exist
|
|
90
|
+
* @returns {string[]} one JSON-encoded line per date, oldest first
|
|
60
91
|
*/
|
|
61
|
-
|
|
62
|
-
const cfg = opt.config || loadConfig();
|
|
63
|
-
if (!isEnabled(cfg)) throw new Error('sync is disabled (sync.enabled: false)');
|
|
64
|
-
const dir = ensureDir(cfg);
|
|
65
|
-
|
|
66
|
-
// Read the local daily cube rollup written by the dashboard/watch pipeline.
|
|
67
|
-
const cubeFile = `${paths().data}/cube.json`;
|
|
68
|
-
if (!fs.existsSync(cubeFile)) return { file: null, days: 0 };
|
|
69
|
-
|
|
92
|
+
function computeDailyLines(cfg, id, cubeFile) {
|
|
70
93
|
const cube = JSON.parse(fs.readFileSync(cubeFile, 'utf8'));
|
|
71
94
|
const dims = cube.dims;
|
|
72
95
|
const di = dims.indexOf('d'); // date
|
|
73
|
-
const pi = dims.indexOf('p'); // provider
|
|
74
96
|
const off = dims.length;
|
|
75
97
|
const mIn = off + cube.measures.indexOf('in');
|
|
76
98
|
const mOut = off + cube.measures.indexOf('out');
|
|
77
99
|
const mReq = off + cube.measures.indexOf('req');
|
|
78
100
|
const mCost = off + cube.measures.indexOf('cost');
|
|
79
101
|
|
|
80
|
-
// Aggregate rows → one record per (date): totals across providers/models.
|
|
81
|
-
// Provider/model detail stays LOCAL; the synced file is deliberately coarse
|
|
82
|
-
// so the shared folder leaks minimum information.
|
|
83
102
|
const byDay = new Map();
|
|
84
103
|
for (const r of cube.rows) {
|
|
85
104
|
const day = r[di];
|
|
@@ -91,12 +110,11 @@ export function push(opt = {}) {
|
|
|
91
110
|
acc.estCost += r[mCost] || 0;
|
|
92
111
|
}
|
|
93
112
|
|
|
94
|
-
const id = machineId();
|
|
95
113
|
const name = sanitizeName(cfg.sync.machineName || os.hostname().split('.')[0]);
|
|
96
114
|
// Developer identity is included ONLY when the user explicitly set
|
|
97
115
|
// sync.developerName in their own config. Absent field = anonymous machine.
|
|
98
116
|
const dev = cfg.sync.developerName ? sanitizeName(cfg.sync.developerName) : null;
|
|
99
|
-
|
|
117
|
+
return [...byDay.values()]
|
|
100
118
|
.sort((a, b) => a.date.localeCompare(b.date))
|
|
101
119
|
.map((d) => JSON.stringify({
|
|
102
120
|
machineId: id, machineName: name,
|
|
@@ -106,12 +124,131 @@ export function push(opt = {}) {
|
|
|
106
124
|
requests: d.requests, estCostUsd: Math.round(d.estCost * 10000) / 10000,
|
|
107
125
|
exportedAt: new Date().toISOString(),
|
|
108
126
|
}));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** `sync.receipts: false` is the only way to skip the ledger file; absent = on. */
|
|
130
|
+
function receiptsAllowed(cfg) {
|
|
131
|
+
return cfg?.sync?.receipts !== false;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The whole-state receipts ledger for this machine: cost per (repo, branch),
|
|
136
|
+
* joined to a PR when one is known. Sourced entirely from
|
|
137
|
+
* buildReceiptsForStore() — this module never re-derives cost or reads a
|
|
138
|
+
* session transcript itself.
|
|
139
|
+
* @param {object} cfg loaded config
|
|
140
|
+
* @param {string} id this machine's id
|
|
141
|
+
* @returns {object} the JSON-serializable payload, allowlisted fields only
|
|
142
|
+
*/
|
|
143
|
+
function buildReceiptsPayload(cfg, id) {
|
|
144
|
+
const pricing = readJson(paths().pricing, {});
|
|
145
|
+
const store = new Store();
|
|
146
|
+
const built = buildReceiptsForStore(store, pricing);
|
|
147
|
+
const receipts = [];
|
|
148
|
+
for (const R of built.repos) {
|
|
149
|
+
const repo = path.basename(R.repo || 'unknown');
|
|
150
|
+
for (const b of R.branches) {
|
|
151
|
+
receipts.push({
|
|
152
|
+
repo,
|
|
153
|
+
branch: b.key,
|
|
154
|
+
costUsd: b.cost,
|
|
155
|
+
turns: b.turns,
|
|
156
|
+
sessions: b.sessions,
|
|
157
|
+
subagentTurns: b.subagentTurns,
|
|
158
|
+
contextShare: b.contextShare,
|
|
159
|
+
first: b.first,
|
|
160
|
+
last: b.last,
|
|
161
|
+
longLived: b.longLived,
|
|
162
|
+
pr: b.pr ? { number: b.pr.number, mergedAt: b.pr.mergedAt ?? null } : null,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// Unlike the jsonl rollup (which always carries a hostname fallback), the
|
|
167
|
+
// machine label here is opt-in only — this file joins branch/PR identity,
|
|
168
|
+
// so it stays anonymous unless the user chose a label themselves.
|
|
169
|
+
const name = cfg?.sync?.machineName ? sanitizeName(cfg.sync.machineName) : null;
|
|
170
|
+
return {
|
|
171
|
+
schema: 1,
|
|
172
|
+
machineId: id,
|
|
173
|
+
...(name ? { machineName: name } : {}),
|
|
174
|
+
generatedAt: new Date().toISOString(),
|
|
175
|
+
receipts,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Export this machine's daily rollups (and, unless disabled, its receipts
|
|
181
|
+
* ledger) to the shared folder, or POST both to a server when `to`/
|
|
182
|
+
* `sync.to` is set.
|
|
183
|
+
*
|
|
184
|
+
* Stays synchronous for the folder path (existing callers rely on getting
|
|
185
|
+
* `{file, days}` back immediately, not a Promise). The moment a destination
|
|
186
|
+
* server is configured, a network call is unavoidable, so that branch alone
|
|
187
|
+
* returns a Promise — `await push(...)` works either way.
|
|
188
|
+
* @param {{config?: object, to?: string|null, token?: string|null}} opt
|
|
189
|
+
* @returns {{file:string|null, days:number}|Promise<{file:null, days:number, pushedTo:string}>}
|
|
190
|
+
*/
|
|
191
|
+
export function push(opt = {}) {
|
|
192
|
+
const cfg = opt.config || loadConfig();
|
|
193
|
+
const id = machineId();
|
|
194
|
+
const to = opt.to || cfg?.sync?.to || null;
|
|
195
|
+
const token = opt.token || cfg?.sync?.token || process.env.TOKENFLOW_SYNC_TOKEN || null;
|
|
196
|
+
|
|
197
|
+
if (to) return pushToServer({ cfg, id, to, token });
|
|
198
|
+
|
|
199
|
+
if (!isEnabled(cfg)) throw new Error('sync is disabled (sync.enabled: false)');
|
|
200
|
+
const dir = ensureDir(cfg);
|
|
201
|
+
|
|
202
|
+
const cubeFile = `${paths().data}/cube.json`;
|
|
203
|
+
if (!fs.existsSync(cubeFile)) return { file: null, days: 0 };
|
|
109
204
|
|
|
205
|
+
const lines = computeDailyLines(cfg, id, cubeFile);
|
|
110
206
|
const file = path.join(dir, `${id}.jsonl`);
|
|
111
207
|
fs.writeFileSync(file, lines.join('\n') + (lines.length ? '\n' : ''));
|
|
208
|
+
|
|
209
|
+
if (receiptsAllowed(cfg)) {
|
|
210
|
+
const receiptsFile = path.join(dir, `${id}.receipts.json`);
|
|
211
|
+
fs.writeFileSync(receiptsFile, JSON.stringify(buildReceiptsPayload(cfg, id), null, 2));
|
|
212
|
+
}
|
|
213
|
+
|
|
112
214
|
return { file, days: lines.length };
|
|
113
215
|
}
|
|
114
216
|
|
|
217
|
+
/**
|
|
218
|
+
* The remote-destination branch of push(): same two files' contents, POSTed
|
|
219
|
+
* instead of written to a folder.
|
|
220
|
+
* @param {{cfg:object, id:string, to:string, token:string|null}} args
|
|
221
|
+
* @returns {Promise<{file:null, days:number, pushedTo:string}>}
|
|
222
|
+
*/
|
|
223
|
+
async function pushToServer({ cfg, id, to, token }) {
|
|
224
|
+
if (cfg?.sync?.enabled !== true) throw new Error('sync is disabled (sync.enabled: false)');
|
|
225
|
+
if (!/^https?:\/\//i.test(to)) throw new Error(`sync.to must be an http(s) URL, got: ${to}`);
|
|
226
|
+
|
|
227
|
+
const cubeFile = `${paths().data}/cube.json`;
|
|
228
|
+
const lines = fs.existsSync(cubeFile) ? computeDailyLines(cfg, id, cubeFile) : [];
|
|
229
|
+
const files = { [`${id}.jsonl`]: lines.join('\n') + (lines.length ? '\n' : '') };
|
|
230
|
+
if (receiptsAllowed(cfg)) {
|
|
231
|
+
files[`${id}.receipts.json`] = JSON.stringify(buildReceiptsPayload(cfg, id), null, 2);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const url = `${to.replace(/\/+$/, '')}/api/rollup`;
|
|
235
|
+
const res = await fetch(url, {
|
|
236
|
+
method: 'POST',
|
|
237
|
+
headers: {
|
|
238
|
+
'content-type': 'application/json',
|
|
239
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
240
|
+
},
|
|
241
|
+
body: JSON.stringify({ machineId: id, files }),
|
|
242
|
+
});
|
|
243
|
+
if (!res.ok) {
|
|
244
|
+
// Drain the body so the connection can be reused, but never surface it —
|
|
245
|
+
// a server's error page is not ours to print.
|
|
246
|
+
try { await res.text(); } catch { /* ignore */ }
|
|
247
|
+
throw new Error(`sync push to ${to} failed: ${res.status} ${res.statusText}`);
|
|
248
|
+
}
|
|
249
|
+
return { file: null, days: lines.length, pushedTo: to };
|
|
250
|
+
}
|
|
251
|
+
|
|
115
252
|
/**
|
|
116
253
|
* Merge every sibling machine's file into combined daily totals.
|
|
117
254
|
* @returns {{machines: string[], days: Array}}
|
package/src/core/team.js
CHANGED
|
Binary file
|
|
@@ -24,9 +24,36 @@ function candidatePorts() {
|
|
|
24
24
|
return [...new Set([configured, 7799, 7800, 8799].filter(Boolean))];
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* The base stylesheet followed by every registered view's stylesheet, in name
|
|
29
|
+
* order, concatenated in the same sequence the live page links them.
|
|
30
|
+
*
|
|
31
|
+
* The dev page gets these as one `<link>` per view, injected by app.js. A
|
|
32
|
+
* snapshot has no server to fetch them from, so they are inlined here instead —
|
|
33
|
+
* miss this and every registered tab loses its styles the moment the file is
|
|
34
|
+
* saved. Order matters: view rules must be able to override the base.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} root repository root
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
function collectCss(root) {
|
|
40
|
+
const uiDir = path.join(root, 'src', 'ui');
|
|
41
|
+
const parts = [fs.readFileSync(path.join(uiDir, 'styles.css'), 'utf8')];
|
|
42
|
+
const viewCssDir = path.join(uiDir, 'styles');
|
|
43
|
+
let files = [];
|
|
44
|
+
try {
|
|
45
|
+
files = fs.readdirSync(viewCssDir).filter((f) => f.endsWith('.css')).sort();
|
|
46
|
+
} catch { /* no per-view stylesheets yet: the base sheet is the whole answer */ }
|
|
47
|
+
for (const f of files) {
|
|
48
|
+
parts.push(`/* --- ${path.posix.join('src/ui/styles', f)} --- */`);
|
|
49
|
+
parts.push(fs.readFileSync(path.join(viewCssDir, f), 'utf8'));
|
|
50
|
+
}
|
|
51
|
+
return parts.join('\n');
|
|
52
|
+
}
|
|
53
|
+
|
|
27
54
|
export function buildSnapshot({ maxRecords = 20000, title = 'Tokenflow' } = {}) {
|
|
28
55
|
const ROOT = rootDir();
|
|
29
|
-
const css =
|
|
56
|
+
const css = collectCss(ROOT);
|
|
30
57
|
const html = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'index.html'), 'utf8');
|
|
31
58
|
const js = bundle(path.join(ROOT, 'src', 'ui', 'app.js'), { root: ROOT });
|
|
32
59
|
// A snapshot that does not parse is worse than a failed export, so check the
|
package/src/export/menubar.js
CHANGED
|
@@ -103,6 +103,27 @@ export function renderXbar(status, opt = {}) {
|
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
// ---- live sessions --------------------------------------------------------
|
|
107
|
+
// Older status.json files (written before this field existed) simply lack
|
|
108
|
+
// it, so this whole block is null-guarded rather than assumed present.
|
|
109
|
+
const live = status.liveSessions?.sessions || [];
|
|
110
|
+
if (live.length) {
|
|
111
|
+
lines.push('---');
|
|
112
|
+
lines.push(`Live now (${live.length}) | font-size=11`);
|
|
113
|
+
for (const s of live.slice(0, 3)) {
|
|
114
|
+
const glyph = GLYPH[s.guard?.level] || '';
|
|
115
|
+
const where = s.project || s.repository || s.branch || '';
|
|
116
|
+
lines.push(`${glyph}${s.model || 'session'}${where ? ` · ${where}` : ''}${s.costUsd != null ? ` · ${money(s.costUsd)}` : ''} | font-size=12`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---- guard ------------------------------------------------------------
|
|
121
|
+
const guard = status.guard?.lastVerdict;
|
|
122
|
+
if (guard && guard.level !== 'ok') {
|
|
123
|
+
lines.push('---');
|
|
124
|
+
lines.push(`${GLYPH[guard.level] || ''}Guard: ${guard.reasons?.[0] || guard.level} | font-size=12`);
|
|
125
|
+
}
|
|
126
|
+
|
|
106
127
|
// ---- freshness + actions ------------------------------------------------
|
|
107
128
|
lines.push('---');
|
|
108
129
|
const fr = status.freshness || {};
|