vibestreak 0.0.0-stage → 0.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/LICENSE +21 -0
- package/README.md +41 -2
- package/bin/vibestreak.mjs +194 -0
- package/package.json +13 -4
- package/src/autosync.mjs +231 -0
- package/src/claude.mjs +83 -0
- package/src/codex.mjs +83 -0
- package/src/collect.mjs +53 -0
- package/src/config.mjs +34 -0
- package/src/copilot.mjs +160 -0
- package/src/cursor.mjs +79 -0
- package/src/days.mjs +29 -0
- package/src/files.mjs +45 -0
- package/src/git.mjs +145 -0
- package/src/google.mjs +115 -0
- package/src/nudge.mjs +64 -0
- package/src/opencode.mjs +91 -0
- package/src/sqlite.mjs +107 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kingsbridge Consultancy LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,42 @@
|
|
|
1
|
-
#
|
|
1
|
+
# vibestreak
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Your AI coding streak, from your own machine: a heatmap of the days you coded
|
|
4
|
+
with AI agents and the commits you shipped with them, ranked on consistency,
|
|
5
|
+
not spend. Profiles and the leaderboard: https://vibestreak.kingsbridge-consultancy.com
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx vibestreak login --token PASTE_YOUR_TOKEN # token from Settings → Machines
|
|
9
|
+
npx vibestreak emails scan # add every address you commit as
|
|
10
|
+
npx vibestreak sync
|
|
11
|
+
npx vibestreak autosync on # background syncs + evening streak check
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
All commands: https://vibestreak.kingsbridge-consultancy.com/cli
|
|
15
|
+
|
|
16
|
+
## What it reads
|
|
17
|
+
|
|
18
|
+
| Tool | Where |
|
|
19
|
+
| --- | --- |
|
|
20
|
+
| Claude Code | `~/.claude/projects` |
|
|
21
|
+
| Codex | `~/.codex/sessions`, `~/.codex/archived_sessions` |
|
|
22
|
+
| Cursor | its editor database and AI code-tracking database |
|
|
23
|
+
| GitHub Copilot | Copilot CLI's `~/.copilot/session-state`; Copilot Chat sessions in VS Code |
|
|
24
|
+
| Gemini CLI | `~/.gemini/tmp/*/chats` |
|
|
25
|
+
| Antigravity | `~/.gemini/antigravity/conversations` |
|
|
26
|
+
| OpenCode | `~/.local/share/opencode` |
|
|
27
|
+
| Commits | `git log` in the repos under your usual folders, for the addresses you list |
|
|
28
|
+
|
|
29
|
+
## What it sends
|
|
30
|
+
|
|
31
|
+
Per day and tool: message, tool-call, session and token counts, and model names.
|
|
32
|
+
Per commit you authored: a keyed hash of its id, its day, and the AI agent named
|
|
33
|
+
in its co-author trailer. Never prompts, responses, code, file paths, repo names,
|
|
34
|
+
commit messages or email addresses. `npx vibestreak collect --json` prints the
|
|
35
|
+
exact upload without sending it.
|
|
36
|
+
|
|
37
|
+
## Requirements
|
|
38
|
+
|
|
39
|
+
Node.js 22.13 or newer and git, on macOS, Linux or Windows. Background syncs use
|
|
40
|
+
the system's own scheduler: launchd (macOS), systemd user timers or crontab
|
|
41
|
+
(Linux), Task Scheduler (Windows). The evening streak check notifies through
|
|
42
|
+
Notification Center, `notify-send`, or a Windows toast.
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { parseArgs } from 'node:util'
|
|
4
|
+
|
|
5
|
+
import { installAutosync, removeAutosync } from '../src/autosync.mjs'
|
|
6
|
+
import { notify, nudgeText, shouldNudge } from '../src/nudge.mjs'
|
|
7
|
+
import { collect, sinceDate, summarize } from '../src/collect.mjs'
|
|
8
|
+
import { CONFIG_PATH, loadConfig, saveConfig } from '../src/config.mjs'
|
|
9
|
+
import { DEFAULT_ROOTS, globalEmail, scanEmails } from '../src/git.mjs'
|
|
10
|
+
|
|
11
|
+
const BRAND = process.env.VIBESTREAK_BRAND ?? 'vibestreak'
|
|
12
|
+
const DEFAULT_SERVER = process.env.VIBESTREAK_SERVER ?? 'https://vibestreak.kingsbridge-consultancy.com'
|
|
13
|
+
|
|
14
|
+
const HELP = `${BRAND} — share your AI coding heatmap: what you shipped, and how steadily.
|
|
15
|
+
|
|
16
|
+
${BRAND} collect [--json] preview what would be sent (nothing leaves the machine)
|
|
17
|
+
${BRAND} login --token <t> [--server <url>]
|
|
18
|
+
link this machine (token from your settings page)
|
|
19
|
+
${BRAND} sync [--quiet] collect and upload daily totals
|
|
20
|
+
${BRAND} emails [scan|add <e>|remove <e>]
|
|
21
|
+
the addresses you commit as (all of your accounts)
|
|
22
|
+
${BRAND} roots [add <dir>|remove <dir>]
|
|
23
|
+
where to look for git repos
|
|
24
|
+
${BRAND} autosync [on|off] sync in the background through the day
|
|
25
|
+
${BRAND} nudge [on|off|test] an evening notification when your streak is
|
|
26
|
+
about to end (on with autosync)
|
|
27
|
+
${BRAND} whoami the profile this machine syncs to
|
|
28
|
+
|
|
29
|
+
Reads: Claude Code, Codex, Cursor, Copilot (CLI and VS Code), Gemini CLI,
|
|
30
|
+
Antigravity and OpenCode history, and git.
|
|
31
|
+
Sent: per day and tool, message, tool-call, session and token counts and model
|
|
32
|
+
names; per commit you authored, a keyed hash, its day and the AI agent named in
|
|
33
|
+
its co-author trailer. Never prompts, code, file paths, repo names or messages.
|
|
34
|
+
Config: ${CONFIG_PATH}
|
|
35
|
+
All commands, with examples: ${DEFAULT_SERVER}/cli`
|
|
36
|
+
|
|
37
|
+
const fmt = n => n.toLocaleString('en-US')
|
|
38
|
+
|
|
39
|
+
async function api(config, path, init = {}) {
|
|
40
|
+
if (!config.server || !config.token) throw new Error(`Not linked yet: run \`${BRAND} login --token <token>\`.`)
|
|
41
|
+
const response = await fetch(new URL(path, config.server), {
|
|
42
|
+
...init,
|
|
43
|
+
headers: { authorization: `Bearer ${config.token}`, 'content-type': 'application/json', ...init.headers },
|
|
44
|
+
})
|
|
45
|
+
const body = await response.json().catch(() => ({}))
|
|
46
|
+
if (!response.ok) throw new Error(body.error ?? `${response.status} ${response.statusText}`)
|
|
47
|
+
return body
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function main() {
|
|
51
|
+
const { positionals, values } = parseArgs({
|
|
52
|
+
allowPositionals: true,
|
|
53
|
+
options: {
|
|
54
|
+
json: { type: 'boolean' },
|
|
55
|
+
quiet: { type: 'boolean' },
|
|
56
|
+
token: { type: 'string' },
|
|
57
|
+
server: { type: 'string' },
|
|
58
|
+
since: { type: 'string' },
|
|
59
|
+
help: { type: 'boolean', short: 'h' },
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
const [command = 'help', ...rest] = positionals
|
|
63
|
+
const config = await loadConfig()
|
|
64
|
+
const log = (...args) => values.quiet || console.log(...args)
|
|
65
|
+
|
|
66
|
+
if (command === 'help' || values.help) return console.log(HELP)
|
|
67
|
+
|
|
68
|
+
if (command === 'emails') {
|
|
69
|
+
const [action, email] = rest
|
|
70
|
+
if (!config.emails.length) {
|
|
71
|
+
const first = await globalEmail()
|
|
72
|
+
if (first) config.emails = [first]
|
|
73
|
+
}
|
|
74
|
+
if (action === 'add' && email) config.emails = [...new Set([...config.emails, email])]
|
|
75
|
+
if (action === 'remove' && email) config.emails = config.emails.filter(e => e !== email)
|
|
76
|
+
if (action === 'scan') {
|
|
77
|
+
console.log('Author addresses in your repos (last 400 days); add each one that is you:\n')
|
|
78
|
+
for (const [address, count] of await scanEmails({ roots: config.roots ?? DEFAULT_ROOTS, since: sinceDate() })) {
|
|
79
|
+
console.log(` ${config.emails.includes(address) ? '✓' : ' '} ${String(count).padStart(6)} ${address}`)
|
|
80
|
+
}
|
|
81
|
+
console.log(`\n ${BRAND} emails add <address>`)
|
|
82
|
+
}
|
|
83
|
+
await saveConfig(config)
|
|
84
|
+
if (action !== 'scan') console.log(config.emails.length ? config.emails.join('\n') : 'No addresses yet.')
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (command === 'roots') {
|
|
89
|
+
const [action, dir] = rest
|
|
90
|
+
const roots = config.roots ?? DEFAULT_ROOTS
|
|
91
|
+
if (action === 'add' && dir) config.roots = [...new Set([...roots, dir])]
|
|
92
|
+
if (action === 'remove' && dir) config.roots = roots.filter(r => r !== dir)
|
|
93
|
+
await saveConfig(config)
|
|
94
|
+
return console.log((config.roots ?? DEFAULT_ROOTS).join('\n'))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (command === 'login') {
|
|
98
|
+
if (!values.token) return console.log(`Get a token on ${values.server ?? config.server ?? DEFAULT_SERVER}/settings, then:\n ${BRAND} login --token <token>`)
|
|
99
|
+
config.server = values.server ?? config.server ?? DEFAULT_SERVER
|
|
100
|
+
config.token = values.token
|
|
101
|
+
const me = await api(config, '/api/v1/me')
|
|
102
|
+
if (!config.emails.length) {
|
|
103
|
+
const first = await globalEmail()
|
|
104
|
+
if (first) config.emails = [first]
|
|
105
|
+
}
|
|
106
|
+
await saveConfig(config)
|
|
107
|
+
console.log(`Linked ${config.machineName} to ${me.handle} (${me.profileUrl}).`)
|
|
108
|
+
console.log(`Commit addresses: ${config.emails.join(', ') || 'none'}; run \`${BRAND} emails scan\` to add your other accounts.`)
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (command === 'whoami') {
|
|
113
|
+
const me = await api(config, '/api/v1/me')
|
|
114
|
+
return console.log(`${me.handle} · ${me.profileUrl}\nGitHub: ${me.github.join(', ')}\nMachine: ${config.machineName}`)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (command === 'collect' || command === 'sync') return sync(config, values, log, command === 'sync')
|
|
118
|
+
|
|
119
|
+
if (command === 'nudge') {
|
|
120
|
+
const bin = fileURLToPath(import.meta.url)
|
|
121
|
+
if (rest[0] === 'on' || rest[0] === 'off') {
|
|
122
|
+
config.nudge = rest[0] === 'on'
|
|
123
|
+
await saveConfig(config)
|
|
124
|
+
if (config.autosync) await installAutosync(bin, { nudge: config.nudge })
|
|
125
|
+
return console.log(`Streak nudge ${config.nudge ? 'on: 20:30 each evening when your streak is at risk' : 'off'}.`)
|
|
126
|
+
}
|
|
127
|
+
if (rest[0] === 'test') {
|
|
128
|
+
const { streak } = await api(config, '/api/v1/me')
|
|
129
|
+
await notify(nudgeText({ ...streak, current: Math.max(1, streak.current) }))
|
|
130
|
+
return console.log(`Sent a test notification (your streak: ${streak.current} days; ${streak.activeToday ? 'active today' : 'not active yet today'}).`)
|
|
131
|
+
}
|
|
132
|
+
// The evening run: sync, then nudge if the streak would end at midnight.
|
|
133
|
+
const result = await sync(config, values, log, true)
|
|
134
|
+
if (shouldNudge(result.streak, config)) {
|
|
135
|
+
await notify(nudgeText(result.streak))
|
|
136
|
+
config.lastNudge = result.streak.today
|
|
137
|
+
await saveConfig(config)
|
|
138
|
+
console.log(`${new Date().toISOString()} nudged: ${result.streak.current}-day streak at risk`)
|
|
139
|
+
}
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (command === 'autosync') {
|
|
144
|
+
if (rest[0] === 'off') {
|
|
145
|
+
config.autosync = false
|
|
146
|
+
await saveConfig(config)
|
|
147
|
+
return console.log(await removeAutosync())
|
|
148
|
+
}
|
|
149
|
+
const result = await installAutosync(fileURLToPath(import.meta.url), { nudge: config.nudge !== false })
|
|
150
|
+
config.autosync = result.installed
|
|
151
|
+
await saveConfig(config)
|
|
152
|
+
return console.log(
|
|
153
|
+
result.installed
|
|
154
|
+
? `Syncing at ${result.times.join(', ')} via ${result.using}${result.nudgeAt ? `; streak check at ${result.nudgeAt}` : ''}. Log: ${result.log}`
|
|
155
|
+
: result.hint,
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
console.log(HELP)
|
|
160
|
+
process.exitCode = 1
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Collects this machine's totals and, for a sync, uploads them; the server
|
|
164
|
+
// answers with where the streak stands (the nudge's input).
|
|
165
|
+
async function sync(config, values, log, isSync) {
|
|
166
|
+
const me = isSync ? await api(config, '/api/v1/me') : undefined
|
|
167
|
+
const emails = [...new Set([...config.emails, ...(me?.emails ?? [])])]
|
|
168
|
+
if (!emails.length) {
|
|
169
|
+
const first = await globalEmail()
|
|
170
|
+
if (first) emails.push(first)
|
|
171
|
+
}
|
|
172
|
+
const started = Date.now()
|
|
173
|
+
const payload = await collect({ config: { ...config, emails }, salt: me?.salt ?? config.localSalt, since: values.since })
|
|
174
|
+
if (values.json) return console.log(JSON.stringify(payload, null, 2))
|
|
175
|
+
const s = summarize(payload)
|
|
176
|
+
log(`${s.activeDays} active days since ${payload.since} (${((Date.now() - started) / 1000).toFixed(1)}s)`)
|
|
177
|
+
for (const [tool, t] of Object.entries(s.tools)) {
|
|
178
|
+
log(` ${tool.padEnd(11)} ${String(t.days).padStart(4)} days ${fmt(t.messages).padStart(10)} messages ${fmt(t.toolCalls).padStart(9)} tool calls ${fmt(t.tokens).padStart(16)} tokens`)
|
|
179
|
+
}
|
|
180
|
+
log(` ${'commits'.padEnd(11)} ${fmt(s.commits)} by you in ${s.repos} repos, ${fmt(s.aiCommits)} with an AI co-author (${emails.join(', ') || 'no addresses'})`)
|
|
181
|
+
if (!isSync) return log(`\nNothing was sent. \`${BRAND} sync\` uploads these totals.`)
|
|
182
|
+
const result = await api(config, '/api/v1/upload', { method: 'POST', body: JSON.stringify(payload) })
|
|
183
|
+
config.lastSync = new Date().toISOString()
|
|
184
|
+
await saveConfig(config)
|
|
185
|
+
// Always one line, quiet or not: the background sync's log is how a person sees it ran.
|
|
186
|
+
const streak = result.streak ? ` · streak ${result.streak.current}d${result.streak.activeToday ? '' : ' (not yet today)'}` : ''
|
|
187
|
+
console.log(`${config.lastSync} uploaded ${result.days} days, ${result.commits} commits from ${s.repos} repos${streak} → ${result.profileUrl}`)
|
|
188
|
+
return result
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
main().catch(error => {
|
|
192
|
+
console.error(`${BRAND}: ${error.message}`)
|
|
193
|
+
process.exitCode = 1
|
|
194
|
+
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vibestreak",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Your AI coding streak: a heatmap of the days you coded with Claude Code, Codex, Cursor, Copilot, Gemini and OpenCode, and the commits you shipped with them. Sends daily totals only.",
|
|
5
|
+
"keywords": ["claude-code", "codex", "cursor", "copilot", "gemini", "opencode", "ai-coding", "heatmap", "streak", "leaderboard", "cli"],
|
|
6
|
+
"homepage": "https://vibestreak.kingsbridge-consultancy.com/cli",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": "Kingsbridge Consultancy LLC <support@kingsbridge-consultancy.com>",
|
|
9
|
+
"type": "module",
|
|
10
|
+
"bin": { "vibestreak": "bin/vibestreak.mjs" },
|
|
11
|
+
"files": ["bin", "src", "README.md", "LICENSE"],
|
|
12
|
+
"engines": { "node": ">=22.13" },
|
|
13
|
+
"os": ["darwin", "linux", "win32"],
|
|
14
|
+
"scripts": { "test": "node --test test/*.test.mjs" }
|
|
15
|
+
}
|
package/src/autosync.mjs
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { cp, mkdir, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { homedir, platform } from 'node:os'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
import { promisify } from 'node:util'
|
|
6
|
+
|
|
7
|
+
import { CONFIG_PATH } from './config.mjs'
|
|
8
|
+
|
|
9
|
+
const run = promisify(execFile)
|
|
10
|
+
|
|
11
|
+
// Syncs through the day keep the Today board current; the evening run also
|
|
12
|
+
// checks whether the streak is about to end (`vibestreak nudge`).
|
|
13
|
+
export const SYNC_TIMES = [
|
|
14
|
+
[9, 17],
|
|
15
|
+
[13, 17],
|
|
16
|
+
[17, 17],
|
|
17
|
+
]
|
|
18
|
+
export const NUDGE_TIME = [20, 30]
|
|
19
|
+
|
|
20
|
+
export const clock = ([h, m]) => `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
|
|
21
|
+
const configDir = () => dirname(CONFIG_PATH)
|
|
22
|
+
const logPath = () => join(configDir(), 'sync.log')
|
|
23
|
+
|
|
24
|
+
// The jobs to schedule: a sync at each sync time, and the evening nudge.
|
|
25
|
+
export const jobs = nudge => [
|
|
26
|
+
{ name: 'sync', args: ['sync', '--quiet'], times: SYNC_TIMES },
|
|
27
|
+
...(nudge ? [{ name: 'nudge', args: ['nudge', '--quiet'], times: [NUDGE_TIME] }] : []),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
// A CLI run through npx lives in npm's cache, which can be cleared; a
|
|
31
|
+
// scheduled job needs a copy that stays put, so autosync keeps one in the
|
|
32
|
+
// config folder (refreshed by every `autosync on`).
|
|
33
|
+
export async function stableBin(bin) {
|
|
34
|
+
if (!/[/\\](_npx|npm-cache)[/\\]/.test(bin)) return bin
|
|
35
|
+
const pkg = dirname(dirname(bin))
|
|
36
|
+
const home = join(configDir(), 'cli')
|
|
37
|
+
await rm(home, { recursive: true, force: true })
|
|
38
|
+
await cp(pkg, home, { recursive: true, filter: src => !/[/\\](node_modules|test)([/\\]|$)/.test(src.slice(pkg.length)) })
|
|
39
|
+
return join(home, 'bin', 'vibestreak.mjs')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// --- macOS: launchd agents ------------------------------------------------
|
|
43
|
+
|
|
44
|
+
const LAUNCHD = { sync: 'dev.vibestreak.sync', nudge: 'dev.vibestreak.nudge' }
|
|
45
|
+
const agents = () => join(homedir(), 'Library', 'LaunchAgents')
|
|
46
|
+
|
|
47
|
+
export function launchdPlist(label, node, bin, args, times, log) {
|
|
48
|
+
const intervals = times.map(([h, m]) => `<dict><key>Hour</key><integer>${h}</integer><key>Minute</key><integer>${m}</integer></dict>`).join('')
|
|
49
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
50
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
51
|
+
<plist version="1.0">
|
|
52
|
+
<dict>
|
|
53
|
+
<key>Label</key><string>${label}</string>
|
|
54
|
+
<key>ProgramArguments</key>
|
|
55
|
+
<array><string>${node}</string><string>${bin}</string>${args.map(a => `<string>${a}</string>`).join('')}</array>
|
|
56
|
+
<key>StartCalendarInterval</key>
|
|
57
|
+
<array>${intervals}</array>
|
|
58
|
+
<key>RunAtLoad</key><false/>
|
|
59
|
+
<key>StandardOutPath</key><string>${log}</string>
|
|
60
|
+
<key>StandardErrorPath</key><string>${log}</string>
|
|
61
|
+
</dict>
|
|
62
|
+
</plist>
|
|
63
|
+
`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const launchd = {
|
|
67
|
+
async install(node, bin, nudge) {
|
|
68
|
+
await mkdir(agents(), { recursive: true })
|
|
69
|
+
for (const job of jobs(true)) {
|
|
70
|
+
const plist = join(agents(), `${LAUNCHD[job.name]}.plist`)
|
|
71
|
+
await run('launchctl', ['unload', plist]).catch(() => {})
|
|
72
|
+
if (job.name === 'nudge' && !nudge) {
|
|
73
|
+
await rm(plist, { force: true })
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
await writeFile(plist, launchdPlist(LAUNCHD[job.name], node, bin, job.args, job.times, logPath()))
|
|
77
|
+
await run('launchctl', ['load', plist])
|
|
78
|
+
}
|
|
79
|
+
return 'launchd'
|
|
80
|
+
},
|
|
81
|
+
async remove() {
|
|
82
|
+
for (const label of Object.values(LAUNCHD)) {
|
|
83
|
+
const plist = join(agents(), `${label}.plist`)
|
|
84
|
+
await run('launchctl', ['unload', plist]).catch(() => {})
|
|
85
|
+
await rm(plist, { force: true })
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// --- Linux: systemd user timers, else crontab -------------------------------
|
|
91
|
+
|
|
92
|
+
const units = () => join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'systemd', 'user')
|
|
93
|
+
// systemd's own quoting: backslash-escaped quotes and backslashes, and $ and %
|
|
94
|
+
// (its variables and specifiers) doubled.
|
|
95
|
+
const quoteArg = a => `"${a.replace(/(["\\])/g, '\\$1').replace(/\$/g, '$$$$').replace(/%/g, '%%')}"`
|
|
96
|
+
|
|
97
|
+
export function systemdUnits(node, bin, job, log) {
|
|
98
|
+
const calendar = job.times.map(t => `OnCalendar=*-*-* ${clock(t)}:00`).join('\n')
|
|
99
|
+
return {
|
|
100
|
+
service: `[Unit]
|
|
101
|
+
Description=vibestreak ${job.name}
|
|
102
|
+
|
|
103
|
+
[Service]
|
|
104
|
+
Type=oneshot
|
|
105
|
+
ExecStart=${[node, bin, ...job.args].map(quoteArg).join(' ')}
|
|
106
|
+
StandardOutput=append:${log}
|
|
107
|
+
StandardError=append:${log}
|
|
108
|
+
`,
|
|
109
|
+
// Persistent: a run missed while the machine slept happens when it wakes.
|
|
110
|
+
timer: `[Unit]
|
|
111
|
+
Description=vibestreak ${job.name} schedule
|
|
112
|
+
|
|
113
|
+
[Timer]
|
|
114
|
+
${calendar}
|
|
115
|
+
Persistent=true
|
|
116
|
+
|
|
117
|
+
[Install]
|
|
118
|
+
WantedBy=timers.target
|
|
119
|
+
`,
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const CRON_BEGIN = '# vibestreak:begin'
|
|
124
|
+
const CRON_END = '# vibestreak:end'
|
|
125
|
+
const shellQuote = a => `'${a.replace(/'/g, `'\\''`)}'`
|
|
126
|
+
|
|
127
|
+
export function cronBlock(node, bin, nudge, log) {
|
|
128
|
+
const lines = jobs(nudge).flatMap(job =>
|
|
129
|
+
job.times.map(([h, m]) => `${m} ${h} * * * ${[node, bin, ...job.args].map(shellQuote).join(' ')} >> ${shellQuote(log)} 2>&1`),
|
|
130
|
+
)
|
|
131
|
+
return [CRON_BEGIN, ...lines, CRON_END].join('\n')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// The crontab with our block replaced (or removed, given no block).
|
|
135
|
+
export const withCronBlock = (current, block) => {
|
|
136
|
+
const kept = current.replace(new RegExp(`\\n?${CRON_BEGIN}[\\s\\S]*?${CRON_END}\\n?`, 'g'), '\n').trim()
|
|
137
|
+
return `${[kept, block].filter(Boolean).join('\n')}\n`
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function writeCrontab(block) {
|
|
141
|
+
const current = await run('crontab', ['-l']).then(r => r.stdout, () => '')
|
|
142
|
+
await new Promise((resolve, reject) => {
|
|
143
|
+
const child = execFile('crontab', ['-'], error => (error ? reject(error) : resolve()))
|
|
144
|
+
child.stdin.end(withCronBlock(current, block))
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const hasUserSystemd = () => run('systemctl', ['--user', 'show-environment']).then(() => true, () => false)
|
|
149
|
+
|
|
150
|
+
const linux = {
|
|
151
|
+
async install(node, bin, nudge) {
|
|
152
|
+
if (await hasUserSystemd()) {
|
|
153
|
+
await mkdir(units(), { recursive: true })
|
|
154
|
+
for (const job of jobs(true)) {
|
|
155
|
+
const name = `vibestreak-${job.name}`
|
|
156
|
+
if (job.name === 'nudge' && !nudge) {
|
|
157
|
+
await run('systemctl', ['--user', 'disable', '--now', `${name}.timer`]).catch(() => {})
|
|
158
|
+
await rm(join(units(), `${name}.timer`), { force: true })
|
|
159
|
+
await rm(join(units(), `${name}.service`), { force: true })
|
|
160
|
+
continue
|
|
161
|
+
}
|
|
162
|
+
const { service, timer } = systemdUnits(node, bin, job, logPath())
|
|
163
|
+
await writeFile(join(units(), `${name}.service`), service)
|
|
164
|
+
await writeFile(join(units(), `${name}.timer`), timer)
|
|
165
|
+
}
|
|
166
|
+
await run('systemctl', ['--user', 'daemon-reload'])
|
|
167
|
+
for (const job of jobs(nudge)) await run('systemctl', ['--user', 'enable', '--now', `vibestreak-${job.name}.timer`])
|
|
168
|
+
return 'systemd'
|
|
169
|
+
}
|
|
170
|
+
await writeCrontab(cronBlock(node, bin, nudge, logPath()))
|
|
171
|
+
return 'crontab'
|
|
172
|
+
},
|
|
173
|
+
async remove() {
|
|
174
|
+
for (const name of ['vibestreak-sync', 'vibestreak-nudge']) {
|
|
175
|
+
await run('systemctl', ['--user', 'disable', '--now', `${name}.timer`]).catch(() => {})
|
|
176
|
+
await rm(join(units(), `${name}.timer`), { force: true })
|
|
177
|
+
await rm(join(units(), `${name}.service`), { force: true })
|
|
178
|
+
}
|
|
179
|
+
await run('systemctl', ['--user', 'daemon-reload']).catch(() => {})
|
|
180
|
+
await writeCrontab('').catch(() => {})
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// --- Windows: Task Scheduler ------------------------------------------------
|
|
185
|
+
|
|
186
|
+
// Each task runs a small .cmd in the config folder, so the command (and its
|
|
187
|
+
// log redirect) has no length limit and no quoting inside schtasks.
|
|
188
|
+
export const windowsTaskName = (job, [h, m]) => `vibestreak\\${job.name}-${String(h).padStart(2, '0')}${String(m).padStart(2, '0')}`
|
|
189
|
+
const cmdPath = p => p.replace(/%/g, '%%')
|
|
190
|
+
export const windowsScript = (node, bin, job, log) => `@echo off\r\n"${cmdPath(node)}" "${cmdPath(bin)}" ${job.args.join(' ')} >> "${cmdPath(log)}" 2>&1\r\n`
|
|
191
|
+
|
|
192
|
+
const ALL_WINDOWS_TASKS = jobs(true).flatMap(job => job.times.map(t => windowsTaskName(job, t)))
|
|
193
|
+
|
|
194
|
+
const windows = {
|
|
195
|
+
async install(node, bin, nudge) {
|
|
196
|
+
await windows.remove()
|
|
197
|
+
await mkdir(configDir(), { recursive: true })
|
|
198
|
+
for (const job of jobs(nudge)) {
|
|
199
|
+
const script = join(configDir(), `${job.name}.cmd`)
|
|
200
|
+
await writeFile(script, windowsScript(node, bin, job, logPath()))
|
|
201
|
+
for (const time of job.times) {
|
|
202
|
+
await run('schtasks', ['/Create', '/F', '/SC', 'DAILY', '/ST', clock(time), '/TN', windowsTaskName(job, time), '/TR', `"${script}"`])
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return 'Task Scheduler'
|
|
206
|
+
},
|
|
207
|
+
async remove() {
|
|
208
|
+
for (const name of ALL_WINDOWS_TASKS) await run('schtasks', ['/Delete', '/F', '/TN', name]).catch(() => {})
|
|
209
|
+
},
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const SCHEDULERS = { darwin: launchd, linux, win32: windows }
|
|
213
|
+
|
|
214
|
+
// Schedules the background syncs (and the evening nudge) with the system's
|
|
215
|
+
// own scheduler.
|
|
216
|
+
export async function installAutosync(bin, { nudge = true } = {}) {
|
|
217
|
+
const scheduler = SCHEDULERS[platform()]
|
|
218
|
+
const times = SYNC_TIMES.map(clock)
|
|
219
|
+
const nudgeAt = nudge ? clock(NUDGE_TIME) : undefined
|
|
220
|
+
if (!scheduler) {
|
|
221
|
+
return { installed: false, hint: `No scheduler support on ${platform()} yet. Run \`${bin} sync --quiet\` at ${times.join(', ')}${nudge ? ` and \`nudge --quiet\` at ${nudgeAt}` : ''}.` }
|
|
222
|
+
}
|
|
223
|
+
await mkdir(configDir(), { recursive: true })
|
|
224
|
+
const using = await scheduler.install(process.execPath, await stableBin(bin), nudge)
|
|
225
|
+
return { installed: true, using, times, nudgeAt, log: logPath() }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function removeAutosync() {
|
|
229
|
+
await SCHEDULERS[platform()]?.remove()
|
|
230
|
+
return { removed: true }
|
|
231
|
+
}
|
package/src/claude.mjs
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { dayOfIso, emptyDay } from './days.mjs'
|
|
5
|
+
import { lines, walk } from './files.mjs'
|
|
6
|
+
|
|
7
|
+
// Claude Code keeps every session as JSONL under ~/.claude/projects (subagents
|
|
8
|
+
// included). Lines are matched with regular expressions rather than parsed:
|
|
9
|
+
// a heavy user has gigabytes of transcripts, and the fields needed sit at the
|
|
10
|
+
// top level of each row.
|
|
11
|
+
//
|
|
12
|
+
// - A message is a user or assistant row; a row copied into another file (a
|
|
13
|
+
// fork, a resume) keeps its uuid and is counted once.
|
|
14
|
+
// - One API request is written as several assistant rows repeating its usage,
|
|
15
|
+
// so tokens count once per requestId, on the day of its first row, each
|
|
16
|
+
// field at its largest.
|
|
17
|
+
const ROLE = /"type":"(user|assistant)"/
|
|
18
|
+
const TIMESTAMP = /"timestamp":"([^"]+)"/
|
|
19
|
+
const UUID = /"uuid":"([0-9a-f-]{36})"/
|
|
20
|
+
const SESSION = /"sessionId":"([^"]+)"/
|
|
21
|
+
const REQUEST = /"requestId":"([^"]+)"/
|
|
22
|
+
const MODEL = /"model":"((?:claude|gpt|gemini)[^"]*)"/
|
|
23
|
+
const TOOL_USE = /"type":"tool_use"/g
|
|
24
|
+
const USAGE = {
|
|
25
|
+
input: /"input_tokens":(\d+)/,
|
|
26
|
+
output: /"output_tokens":(\d+)/,
|
|
27
|
+
cacheRead: /"cache_read_input_tokens":(\d+)/,
|
|
28
|
+
cacheWrite: /"cache_creation_input_tokens":(\d+)/,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function collectClaude({ root = join(homedir(), '.claude', 'projects'), since } = {}) {
|
|
32
|
+
const days = new Map()
|
|
33
|
+
const seen = new Set()
|
|
34
|
+
const sessions = new Map()
|
|
35
|
+
const requests = new Map()
|
|
36
|
+
const day = date => {
|
|
37
|
+
if (!days.has(date)) days.set(date, { ...emptyDay(date, 'claude'), models: new Set() })
|
|
38
|
+
return days.get(date)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
for await (const path of walk(root, '.jsonl')) {
|
|
42
|
+
for await (const line of lines(path)) {
|
|
43
|
+
const role = ROLE.exec(line)?.[1]
|
|
44
|
+
if (!role) continue
|
|
45
|
+
const date = dayOfIso(TIMESTAMP.exec(line)?.[1] ?? '')
|
|
46
|
+
if (!date || (since && date < since)) continue
|
|
47
|
+
const uuid = UUID.exec(line)?.[1]
|
|
48
|
+
if (uuid) {
|
|
49
|
+
if (seen.has(uuid)) continue
|
|
50
|
+
seen.add(uuid)
|
|
51
|
+
}
|
|
52
|
+
const counts = day(date)
|
|
53
|
+
counts.messages++
|
|
54
|
+
counts.toolCalls += line.match(TOOL_USE)?.length ?? 0
|
|
55
|
+
const session = SESSION.exec(line)?.[1]
|
|
56
|
+
if (session) {
|
|
57
|
+
if (!sessions.has(date)) sessions.set(date, new Set())
|
|
58
|
+
sessions.get(date).add(session)
|
|
59
|
+
}
|
|
60
|
+
if (role !== 'assistant') continue
|
|
61
|
+
const model = MODEL.exec(line)?.[1]
|
|
62
|
+
if (model && model !== '<synthetic>') counts.models.add(model)
|
|
63
|
+
const request = REQUEST.exec(line)?.[1]
|
|
64
|
+
if (!request) continue
|
|
65
|
+
const usage = requests.get(request) ?? { date, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
|
|
66
|
+
for (const [field, pattern] of Object.entries(USAGE)) {
|
|
67
|
+
const value = Number(pattern.exec(line)?.[1] ?? 0)
|
|
68
|
+
if (value > usage[field]) usage[field] = value
|
|
69
|
+
}
|
|
70
|
+
requests.set(request, usage)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const { date, ...usage } of requests.values()) {
|
|
75
|
+
const counts = day(date)
|
|
76
|
+
for (const field of Object.keys(USAGE)) counts[field] += usage[field]
|
|
77
|
+
}
|
|
78
|
+
for (const [date, ids] of sessions) day(date).sessions = ids.size
|
|
79
|
+
|
|
80
|
+
return [...days.values()]
|
|
81
|
+
.map(d => ({ ...d, models: [...d.models].sort() }))
|
|
82
|
+
.sort((a, b) => a.date.localeCompare(b.date))
|
|
83
|
+
}
|