syndes 0.1.0 → 0.3.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/README.md CHANGED
@@ -29,6 +29,13 @@ surface at most one nudge, at a moment that does not interrupt you, always with
29
29
  the evidence attached: *"you edited `src/app.ts` six times in twelve minutes
30
30
  without running tests."*
31
31
 
32
+ **Splits one shared account between the people on it.** Two people on one Claude
33
+ plan look like one user to Anthropic — there is no per-person breakdown to ask
34
+ for. SynDes measures on each machine and pools the results through a private git
35
+ repo or a synced folder, so you get the actual split: who used what, when, and
36
+ which hours you spent competing for the same rate-limit window. Counts and
37
+ tokens are shared; prompts, paths and commands never leave the machine.
38
+
32
39
  **Shows you the whole picture.** `syndes dashboard` opens a local
33
40
  dashboard: your efficiency score and its five pillars, token and cost
34
41
  breakdowns, cache hit rate, active time versus wall-clock, permission friction,
@@ -50,6 +57,8 @@ machine already has — `syndes lock system` asks Touch ID — or set a PIN.
50
57
  | `syndes sessions` | list and drill down |
51
58
  | `syndes export --format=csv` | all of it, back out |
52
59
  | `syndes lock system` | require Touch ID to open the dashboard |
60
+ | `syndes team join <repo>` | share one account, see the split |
61
+ | `syndes team status` | who used how much |
53
62
  | `syndes sources` | which coding agents SynDes can see |
54
63
  | `syndes watch` | follow agents that have no hook API |
55
64
  | `syndes doctor` | what is wired and what actually works |
@@ -66,6 +75,50 @@ request whose `Host` is not a literal loopback address — without that, any web
66
75
  page you have open could read it. The lock is the second layer, for machines
67
76
  other people also use.
68
77
 
78
+ ## Sharing an account
79
+
80
+ `syndes install` asks. Or set it up later:
81
+
82
+ ```bash
83
+ syndes team join git@github.com:you/usage.git # a private repo you both push to
84
+ syndes team join ~/Dropbox/usage --folder # or no server at all
85
+ ```
86
+
87
+ **Adding someone is one line.** `syndes team invite` — or the Invite button on
88
+ the Team page — prints a command you send them:
89
+
90
+ ```
91
+ npm install -g syndes && syndes join syndes1_eyJ2Ijox…
92
+ ```
93
+
94
+ That installs SynDes, works out why git cannot reach the repo, offers to sign
95
+ them in, joins them and starts publishing. The invite carries an address, never
96
+ a credential: who may join is the repository's collaborator list, and only that.
97
+
98
+ | | |
99
+ | --- | --- |
100
+ | `syndes team invite` | a line to send someone |
101
+ | `syndes team status` | who used how much |
102
+ | `syndes team remove <name>` | take a machine out of the pool |
103
+ | `syndes team doctor` | what is stopping git reaching the repo |
104
+
105
+ Each machine writes only its own folder and reads everyone's, so there is nothing
106
+ to merge and nothing to run. Open the dashboard and the **Team** page is live
107
+ while you watch it.
108
+
109
+ What leaves your machine: daily counts, tokens, and the hours you worked. What
110
+ never does: your prompts, your file paths, your commands — at any setting.
111
+
112
+ **A pool is one room.** Everyone in it sees everyone else — names, hours and
113
+ volumes — and can read the raw files on GitHub without SynDes. Add only people
114
+ who should all see each other. Removing someone takes them out of the view;
115
+ git keeps the old commits, so it is not an erasure.
116
+
117
+ This is cooperative measurement, not enforcement. It sees Claude Code on machines
118
+ where SynDes is installed, and anyone can stop publishing. Note that consumer
119
+ plans are licensed to one person; per-person logins are both the supported path
120
+ and the only way a split can be enforced rather than agreed.
121
+
69
122
  ## Honest limits
70
123
 
71
124
  The ledger is **tamper-evident, not tamper-proof**. Anything running as your
@@ -85,11 +85,17 @@ export function merge(parts) {
85
85
  let verifiedRuns = 0;
86
86
  let unverifiedRuns = 0;
87
87
 
88
+ // A storm's key names the command that failed, so it is never shared — but
89
+ // the COUNT is what the score reads, and without it a shared rollup scored a
90
+ // free hundred on the one part meant to catch a loop that is not converging.
91
+ let stormsCounted = 0;
92
+
88
93
  for (const part of parts) {
89
94
  for (const [path, count] of Object.entries(part.churn ?? {})) {
90
95
  churn[path] = Math.max(churn[path] ?? 0, count);
91
96
  }
92
- storms.push(...(part.storms ?? []));
97
+ if (Array.isArray(part.storms)) storms.push(...part.storms);
98
+ else stormsCounted += part.stormCount ?? 0;
93
99
  errors += part.errors ?? 0;
94
100
  calls += part.calls ?? 0;
95
101
  verifiedRuns += part.verifiedRuns ?? 0;
@@ -102,7 +108,7 @@ export function merge(parts) {
102
108
  churn: Object.fromEntries(ranked.slice(0, 25)),
103
109
  worstChurn: ranked[0] ? { path: ranked[0][0], edits: ranked[0][1] } : null,
104
110
  storms: storms.slice(0, 25),
105
- stormCount: storms.length,
111
+ stormCount: storms.length + stormsCounted,
106
112
  errors,
107
113
  calls,
108
114
  errorRate: calls ? errors / calls : 0,
@@ -68,11 +68,24 @@ export function merge(parts) {
68
68
  const files = new Set();
69
69
  const totals = { prompts: 0, toolCalls: 0, writes: 0, commits: 0, insertions: 0, deletions: 0, filesChanged: 0 };
70
70
 
71
+ // A shared rollup has had its ids removed and carries only a count (see
72
+ // sync/share.mjs). Deduplication is impossible without the ids, so those
73
+ // counts are summed instead — which over-counts a session spanning midnight
74
+ // and is far better than the alternative, which was reporting zero sessions
75
+ // and silently dropping the largest part of the context score.
76
+ const counted = { sessions: 0, projects: 0, files: 0 };
77
+
71
78
  const bySource = {};
72
79
  for (const part of parts) {
73
- for (const id of part.sessions ?? []) sessions.add(id);
74
- for (const id of part.projects ?? []) projects.add(id);
75
- for (const file of part.files ?? []) files.add(file);
80
+ if (Array.isArray(part.sessions)) for (const id of part.sessions) sessions.add(id);
81
+ else counted.sessions += part.sessionCount ?? 0;
82
+
83
+ if (Array.isArray(part.projects)) for (const id of part.projects) projects.add(id);
84
+ else counted.projects += part.projectCount ?? 0;
85
+
86
+ if (Array.isArray(part.files)) for (const file of part.files) files.add(file);
87
+ else counted.files += part.fileCount ?? 0;
88
+
76
89
  for (const key of Object.keys(totals)) totals[key] += part[key] ?? 0;
77
90
 
78
91
  for (const [id, bucket] of Object.entries(part.bySource ?? {})) {
@@ -86,9 +99,9 @@ export function merge(parts) {
86
99
 
87
100
  return {
88
101
  ...totals,
89
- sessionCount: sessions.size,
90
- projectCount: projects.size,
91
- fileCount: files.size,
102
+ sessionCount: sessions.size + counted.sessions,
103
+ projectCount: projects.size + counted.projects,
104
+ fileCount: files.size + counted.files,
92
105
  sessions: [...sessions],
93
106
  projects: [...projects],
94
107
  bySource: Object.fromEntries(Object.entries(bySource).map(([id, b]) => [id, {
@@ -0,0 +1,245 @@
1
+ /**
2
+ * The shared-account view: who used how much, and when you were in each
3
+ * other's way.
4
+ *
5
+ * The headline number here is deliberately NOT dollars. On a shared plan there
6
+ * is no per-token bill to split — the scarce thing is the usage window, and two
7
+ * people working the same hour throttle each other in a way neither of them can
8
+ * see from their own machine. So this module computes share, and it computes
9
+ * contention, and it treats cost as the secondary figure it actually is.
10
+ *
11
+ * Everything is derived from sanitised peer rollups (sync/share.mjs), which
12
+ * carry counts and no content. Nothing here reads a peer's records, because
13
+ * nothing ever sends them.
14
+ */
15
+
16
+ import { rangeFor, previousRange } from './ranges.mjs';
17
+ import { mergeRollups, METRICS } from './rollup.mjs';
18
+ import { costOf } from './metrics/cost.mjs';
19
+ import { score } from './score.mjs';
20
+ import { readPool, loadState, teamConfig, isEnabled, poolRoot } from '../sync/index.mjs';
21
+ import { identity, initialsOf } from '../runtime/identity.mjs';
22
+ import { loadConfig } from '../runtime/config.mjs';
23
+
24
+ /** Three-hour bands: eight rows is a grid you can read, twenty-four is a wall. */
25
+ const BAND_HOURS = 3;
26
+ const BANDS = 24 / BAND_HOURS;
27
+ /** Beyond this the grid stops being a picture and starts being a spreadsheet. */
28
+ const MAX_GRID_DAYS = 14;
29
+
30
+ /**
31
+ * @param {string} spec a range spec, as analytics/ranges.mjs understands it
32
+ * @returns {Promise<object>} everything the Team page renders
33
+ */
34
+ export async function team(spec = '7d') {
35
+ const range = rangeFor(spec);
36
+ const before = previousRange(range);
37
+ const prices = loadConfig().prices ?? {};
38
+ const me = identity();
39
+
40
+ const enabled = isEnabled();
41
+ const pool = enabled ? readPool() : [];
42
+
43
+ const peers = pool.map((peer) => {
44
+ const current = rollupsIn(peer, range.days);
45
+ const previous = rollupsIn(peer, before.days);
46
+
47
+ const merged = mergeRollups(current);
48
+ const mergedBefore = mergeRollups(previous);
49
+ const tokens = totalTokens(merged.tokens);
50
+
51
+ return {
52
+ deviceId: peer.deviceId,
53
+ name: peer.name,
54
+ initials: initialsOf(peer.name),
55
+ host: peer.host,
56
+ os: peer.os,
57
+ scope: peer.scope,
58
+ isMe: peer.isMe,
59
+ lastSeen: peer.lastSeen,
60
+ // A shelf nobody has updated in a day is stale, not quiet. The UI must be
61
+ // able to tell those apart or it will report someone as idle who has
62
+ // simply stopped syncing.
63
+ stale: peer.lastSeen ? Date.now() - peer.lastSeen > 6 * 3_600_000 : true,
64
+ days: current.length,
65
+ totals: {
66
+ tokens,
67
+ input: merged.tokens.input ?? 0,
68
+ output: merged.tokens.output ?? 0,
69
+ cacheRead: merged.tokens.cacheRead ?? 0,
70
+ cacheWrite: merged.tokens.cacheWrite ?? 0,
71
+ cacheHitRate: merged.tokens.cacheHitRate ?? 0,
72
+ prompts: merged.prompts.count ?? 0,
73
+ toolCalls: merged.tools.calls ?? 0,
74
+ sessions: sessionCountOf(merged, current),
75
+ activeMs: merged.time.activeMs ?? 0,
76
+ wallMs: merged.time.wallMs ?? 0,
77
+ compacts: merged.context.compacts ?? 0,
78
+ blocks: merged.friction.blocks ?? 0,
79
+ usd: costOf(merged.tokens, prices).usd,
80
+ },
81
+ previousTokens: totalTokens(mergedBefore.tokens),
82
+ // A peer sharing at `summary` still has every pillar the score reads,
83
+ // because the score never needed a path or a session id.
84
+ score: score(merged).total,
85
+ hours: merged.time.hours ?? new Array(24).fill(0),
86
+ series: range.days.map((day) => {
87
+ const rollup = peer.rollups.get(day);
88
+ return {
89
+ day,
90
+ tokens: rollup ? totalTokens(mergeRollups([rollup]).tokens) : 0,
91
+ activeMs: rollup?.metrics?.time?.activeMs ?? 0,
92
+ prompts: rollup?.metrics?.prompts?.count ?? 0,
93
+ toolCalls: rollup?.metrics?.tools?.calls ?? 0,
94
+ hours: rollup?.metrics?.time?.hours ?? new Array(24).fill(0),
95
+ };
96
+ }),
97
+ };
98
+ });
99
+
100
+ const poolTokens = peers.reduce((sum, peer) => sum + peer.totals.tokens, 0);
101
+ const poolBefore = peers.reduce((sum, peer) => sum + peer.previousTokens, 0);
102
+
103
+ for (const peer of peers) {
104
+ peer.share = poolTokens ? peer.totals.tokens / poolTokens : 0;
105
+ const shareBefore = poolBefore ? peer.previousTokens / poolBefore : null;
106
+ peer.shareDelta = shareBefore === null ? null : peer.share - shareBefore;
107
+ }
108
+ peers.sort((a, b) => b.totals.tokens - a.totals.tokens);
109
+
110
+ return {
111
+ enabled,
112
+ config: {
113
+ transport: teamConfig().transport ?? null,
114
+ scope: teamConfig().scope ?? 'summary',
115
+ pollSeconds: teamConfig().pollSeconds ?? 30,
116
+ pool: enabled ? poolRoot() : null,
117
+ },
118
+ sync: loadState(),
119
+ me: { deviceId: me.deviceId, name: me.name, initials: initialsOf(me.name) },
120
+ range: { ...range, label: range.label },
121
+ peers,
122
+ totals: {
123
+ tokens: poolTokens,
124
+ usd: peers.reduce((sum, peer) => sum + peer.totals.usd, 0),
125
+ prompts: peers.reduce((sum, peer) => sum + peer.totals.prompts, 0),
126
+ toolCalls: peers.reduce((sum, peer) => sum + peer.totals.toolCalls, 0),
127
+ activeMs: peers.reduce((sum, peer) => sum + peer.totals.activeMs, 0),
128
+ sessions: peers.reduce((sum, peer) => sum + peer.totals.sessions, 0),
129
+ people: peers.length,
130
+ deltaTokens: poolBefore ? poolTokens - poolBefore : null,
131
+ },
132
+ grid: contentionGrid(peers, range.days),
133
+ lanes: laneRows(peers, range.days),
134
+ };
135
+ }
136
+
137
+ /**
138
+ * The contention grid — the one picture that answers "were we in each other's
139
+ * way".
140
+ *
141
+ * Each cell is a three-hour band of one day. Size carries volume; colour carries
142
+ * how many people were working in it, which on a shared plan is the thing that
143
+ * actually costs you: one person alone gets the whole window, two people split
144
+ * it and both get throttled. Colour is therefore load-bearing here in exactly
145
+ * the way the palette intends — orange means look at this, and it means it
146
+ * because that band is where your limits were being spent twice.
147
+ */
148
+ function contentionGrid(peers, days) {
149
+ const window = days.slice(-MAX_GRID_DAYS);
150
+ const cells = [];
151
+ let peak = 0;
152
+
153
+ for (const day of window) {
154
+ for (let band = 0; band < BANDS; band += 1) {
155
+ const byDevice = {};
156
+ let total = 0;
157
+
158
+ for (const peer of peers) {
159
+ const entry = peer.series.find((point) => point.day === day);
160
+ if (!entry) continue;
161
+ let count = 0;
162
+ for (let hour = band * BAND_HOURS; hour < (band + 1) * BAND_HOURS; hour += 1) {
163
+ count += entry.hours[hour] ?? 0;
164
+ }
165
+ if (count > 0) byDevice[peer.deviceId] = count;
166
+ total += count;
167
+ }
168
+
169
+ peak = Math.max(peak, total);
170
+ cells.push({
171
+ day,
172
+ band,
173
+ hour: band * BAND_HOURS,
174
+ total,
175
+ people: Object.keys(byDevice).length,
176
+ byDevice,
177
+ });
178
+ }
179
+ }
180
+
181
+ const overlapping = cells.filter((cell) => cell.people > 1);
182
+ return {
183
+ days: window,
184
+ bands: BANDS,
185
+ bandHours: BAND_HOURS,
186
+ cells,
187
+ peak,
188
+ overlapBands: overlapping.length,
189
+ // Reported as a share of the bands that had ANY work in them. A share of all
190
+ // bands would be dominated by everyone being asleep, which flatters nobody.
191
+ overlapRate: cells.filter((cell) => cell.total > 0).length
192
+ ? overlapping.length / cells.filter((cell) => cell.total > 0).length
193
+ : 0,
194
+ };
195
+ }
196
+
197
+ /** One row per day, split into each person's segment — the split, day by day. */
198
+ function laneRows(peers, days) {
199
+ return [...days].reverse().slice(0, 12).map((day) => {
200
+ const segments = peers
201
+ .map((peer) => {
202
+ const point = peer.series.find((entry) => entry.day === day);
203
+ return {
204
+ deviceId: peer.deviceId,
205
+ name: peer.name,
206
+ initials: peer.initials,
207
+ isMe: peer.isMe,
208
+ tokens: point?.tokens ?? 0,
209
+ activeMs: point?.activeMs ?? 0,
210
+ };
211
+ })
212
+ .filter((segment) => segment.tokens > 0)
213
+ .sort((a, b) => b.tokens - a.tokens);
214
+
215
+ return { day, total: segments.reduce((sum, s) => sum + s.tokens, 0), segments };
216
+ });
217
+ }
218
+
219
+ function rollupsIn(peer, days) {
220
+ const out = [];
221
+ for (const day of days) {
222
+ const rollup = peer.rollups.get(day);
223
+ if (rollup) out.push(rollup);
224
+ }
225
+ return out;
226
+ }
227
+
228
+ function totalTokens(summary) {
229
+ if (!summary) return 0;
230
+ return (summary.input ?? 0) + (summary.output ?? 0) + (summary.cacheRead ?? 0) + (summary.cacheWrite ?? 0);
231
+ }
232
+
233
+ /**
234
+ * Sessions, from whichever field the peer's scope left us.
235
+ *
236
+ * At `summary` the ids are gone and only a count survives, so summing the
237
+ * per-day counts is the honest answer — it over-counts a session that spans
238
+ * midnight, which is a smaller error than reporting zero.
239
+ */
240
+ function sessionCountOf(merged, rollups) {
241
+ if (merged.volume?.sessionCount) return merged.volume.sessionCount;
242
+ return rollups.reduce((sum, rollup) => sum + (rollup.metrics?.volume?.sessionCount ?? 0), 0);
243
+ }
244
+
245
+ export { contentionGrid, BAND_HOURS, BANDS };