@subvalue/cli 0.1.0 → 0.1.1

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/PRE_RELEASE.md CHANGED
@@ -6,13 +6,13 @@ The normal installation command is `npx @subvalue/cli`. These instructions valid
6
6
 
7
7
  Install Node.js **24.13 or newer** on Windows, macOS or Linux. Check `node --version`.
8
8
 
9
- With access to the private repository, open the latest successful [Validate run](https://github.com/axelmtn/subvalue/actions/workflows/ci.yml). Download its `subvalue-cli-…` artifact and unzip it. Only `subvalue-cli-0.1.0.tgz` should be inside. The artifact expires after 14 days; a new Validate run regenerates it.
9
+ With access to the private repository, open the latest successful [Validate run](https://github.com/axelmtn/subvalue/actions/workflows/ci.yml). Download its `subvalue-cli-…` artifact and unzip it. Only `subvalue-cli-0.1.1.tgz` should be inside. The artifact expires after 14 days; a new Validate run regenerates it.
10
10
 
11
11
  Put the `.tgz` in a new folder outside any repository. Open a terminal in that folder.
12
12
 
13
13
  ```sh
14
- npx --offline --yes --package ./subvalue-cli-0.1.0.tgz subvalue --dry-run
15
- npx --offline --yes --package ./subvalue-cli-0.1.0.tgz subvalue
14
+ npx --offline --yes --package ./subvalue-cli-0.1.1.tgz subvalue --dry-run
15
+ npx --offline --yes --package ./subvalue-cli-0.1.1.tgz subvalue
16
16
  ```
17
17
 
18
18
  The first command checks detection without reading session contents. The second scans real local usage, opens the browser, and serves only `127.0.0.1`. A different loopback port is selected if 4731 is occupied. No demo data is injected.
@@ -29,7 +29,7 @@ The first command checks detection without reading session contents. The second
29
29
  For scan timings and cache counts:
30
30
 
31
31
  ```sh
32
- npx --offline --yes --package ./subvalue-cli-0.1.0.tgz subvalue --verbose
32
+ npx --offline --yes --package ./subvalue-cli-0.1.1.tgz subvalue --verbose
33
33
  ```
34
34
 
35
35
  Default metadata storage:
package/PRIVACY.md CHANGED
@@ -12,7 +12,7 @@ Only these provider sources are opened for usage analysis:
12
12
 
13
13
  The CLI also reads its own bundled assets and its own local application database. It checks source-directory/file metadata to detect providers and changes. It does not open credentials, `.env` files, shell history, browser profiles, repositories or project paths mentioned by a session. Symlinks and junctions are rejected. `--dry-run` detects directories without opening session contents.
14
14
 
15
- Session files colocate counters and conversation text. Complete JSONL lines can therefore pass through memory during a scan; only approved metadata is projected into storage. Prompts, responses, attachments and source code are never persisted or displayed.
15
+ Session files colocate counters and conversation text. Their bytes must therefore pass through memory during a scan. A metadata-only JSON extractor decodes whitelisted token counters, model identifiers, timestamps and required deduplication fields. It skips conversation, instruction, attachment and tool-content values without constructing their text strings or objects. Skipped JSON is checked for valid syntax; raw line buffers are discarded after processing. Prompts, responses, attachments and source code are never persisted or displayed.
16
16
 
17
17
  ## Local metadata retained
18
18
 
package/README.md CHANGED
@@ -40,6 +40,8 @@ npm run demo
40
40
  - **Receipt:** local canvas preview and PNG export of the exact same pixels. No projects, paths, requests or sessions in exports.
41
41
  - **Settings:** Sources, Billing, USD, Privacy / data. Inactive providers stay here, with optional manual inclusion.
42
42
 
43
+ First run shows detected history, then asks how each provider was used before rendering results. Subscription users can select a plan or enter a custom USD amount. API, mixed usage and an explicit “Set up later” choice never imply a subscription.
44
+
43
45
  Month compares the full monthly subscription against usage imported so far. Other ranges use calendar-day proration, including multi-month ranges. Amounts and API prices are USD; no invented exchange rates. Billing declarations do not overwrite historical records or prove their original billing mode. Settings offers bundled, verified ChatGPT/Claude plan presets and a custom amount. Annual presets retain the exact annual price divided by 12, with per-seat plans labeled.
44
46
 
45
47
  ## Source access
@@ -52,9 +54,9 @@ Only these session inputs are opened by the scanner:
52
54
  ~/.claude/projects/**/*.jsonl
53
55
  ```
54
56
 
55
- No credentials, auth files, repositories, attachments, clipboard caches, shell history or browser data. The optional Claude stats cache is deliberately not imported in V1 because its history cannot be allocated safely by request/date. Directories referenced by session metadata are never opened. Project identifiers are hashed locally.
57
+ No credentials, auth files, repositories, attachments, clipboard caches, shell history or browser data. The optional Claude stats cache is deliberately not imported in V1 because its history cannot be allocated safely by request/date. Directories referenced by session metadata are never opened. Project-path values are skipped by the metadata extractor.
56
58
 
57
- Transcripts contain conversation text alongside metadata. They must be streamed to extract usage. Conversation text is transient and is never persisted, logged, sent to the browser, or exported. Codex response records are skipped before JSON deserialization; Claude objects are immediately projected onto the metadata allowlist.
59
+ Transcripts contain conversation text alongside metadata, so their bytes must be streamed to locate usage fields. A metadata-only JSON extractor decodes approved counters, dates, models and necessary deduplication fields for both providers. Prompt, response, instruction and tool-content values are skipped without constructing their strings or objects. Raw buffers are discarded after each line. No conversation content is persisted, logged, sent to the browser, or exported.
58
60
 
59
61
  ## Accuracy and pricing
60
62
 
@@ -8,8 +8,11 @@ import {hash} from './metadata.js';
8
8
  import {codexState,parseCodex, } from './providers/codex/index.js';
9
9
  import {claudeState,parseClaude, } from './providers/claude/index.js';
10
10
  import {emptyDiagnostics, } from './types.js';
11
+ import {usageMetadata} from './usage-json.js';
11
12
  export const PARSER_VERSION='2';
12
13
  const MAX_LINE=16*1024*1024;
14
+ const READ_BLOCK=1024*1024;
15
+ const CODEX_MARKERS=['"session_meta"','"turn_context"','"token_count"'].map(value=>Buffer.from(value));
13
16
  function fingerprint(fd ,start ,len ) {const b=Buffer.alloc(Math.max(0,len));const n=fs.readSync(fd,b,0,b.length,start);return createHash('sha256').update(b.subarray(0,n)).digest('hex');}
14
17
  function combine(to ,from ){for(const k of ['malformed','inherited','repeated','resets','oversize','unsupported','records'] )to[k]+=from[k];to.partialTail ||= from.partialTail;if(from.first)to.first=!to.first||from.first<to.first?from.first:to.first;if(from.last)to.last=!to.last||from.last>to.last?from.last:to.last;}
15
18
 
@@ -17,29 +20,38 @@ export async function scan(store ,options
17
20
  const home=options.home??os.homedir(),detected=detectedProviders(home);const statuses =(['codex','claude'] ).map(provider=>({provider,detected:detected[provider],files:0,scanned:0,cached:0,errors:0,skippedLinks:0,missingFiles:0,diagnostics:emptyDiagnostics()}));
18
21
  const seen=new Set ();let bytes=0;
19
22
  for(const root of sourceRoots(home)){const status=statuses.find(s=>s.provider===root.provider) ;const found=enumerate(root);status.files+=found.files.length;status.errors+=found.errors;status.skippedLinks+=found.skippedLinks;
20
- for(const file of found.files){const ref=hash(root.safeLabel,path.relative(root.directory,file));seen.add(ref);let fd ;let transaction=false;
23
+ for(const file of found.files){const ref=hash(root.safeLabel,path.relative(root.directory,file));seen.add(ref);let fd ;let stream ;let transaction=false;
21
24
  try{fd=openAllowedFile(root,file);const stat=fs.fstatSync(fd);let cp=store.checkpoint(ref);const identity=`${stat.dev}:${stat.ino}:${PARSER_VERSION}`;
22
25
  if(cp&&cp.size===stat.size&&cp.mtime===stat.mtimeMs&&cp.identity===identity&&fingerprint(fd,0,Math.min(4096,cp.offset))===cp.head&&fingerprint(fd,Math.max(0,cp.offset-4096),Math.min(4096,cp.offset))===cp.tail){status.cached++;combine(status.diagnostics,cp.state.diagnostics);continue;}
23
26
  if(cp){const headLen=Math.min(4096,cp.offset);const tailStart=Math.max(0,cp.offset-4096);const appendSafe=stat.size>cp.size&&identity===cp.identity&&fingerprint(fd,0,headLen)===cp.head&&fingerprint(fd,tailStart,cp.offset-tailStart)===cp.tail;if(!appendSafe)cp=null;}
24
27
  const state =cp?cp.state:root.provider==='codex'?codexState():claudeState();state.diagnostics.partialTail=false;
25
- let offset=cp?.offset??0,lineNumber=cp?.line??0;let carry =Buffer.alloc(0),dropping=false,consumed=0;const start=offset;store.db.exec('BEGIN IMMEDIATE');transaction=true;if(!cp){store.clearSource(ref);store.collectOrphans();}
28
+ let offset=cp?.offset??0,lineNumber=cp?.line??0;let parts =[],length=0,dropping=false,consumed=0;const start=offset;store.db.exec('BEGIN IMMEDIATE');transaction=true;if(!cp)store.clearSource(ref);
26
29
  // The stream owns only the already validated read-only file descriptor.
27
- if(stat.size>start)for await(const chunk of fs.createReadStream(file,{fd,autoClose:false,start,end:stat.size-1,highWaterMark:256*1024})){bytes+=chunk.length;let cursor=0;while(cursor<chunk.length){const newline=chunk.indexOf(10,cursor);const end=newline<0?chunk.length:newline;const part=chunk.subarray(cursor,end);if(!dropping){if(carry.length+part.length>MAX_LINE){carry=Buffer.alloc(0);dropping=true;state.diagnostics.oversize++;}else carry=Buffer.concat([carry,part]);}
30
+ if(stat.size>start){stream=fs.createReadStream(file,{fd,autoClose:false,start,end:stat.size-1,highWaterMark:READ_BLOCK});for await(const chunk of stream.iterator({destroyOnReturn:false})){bytes+=chunk.length;let cursor=0;while(cursor<chunk.length){const newline=chunk.indexOf(10,cursor);const end=newline<0?chunk.length:newline;const part=chunk.subarray(cursor,end);if(!dropping){if(length+part.length>MAX_LINE){parts=[];length=0;dropping=true;state.diagnostics.oversize++;}else if(part.length){parts.push(part);length+=part.length;}}
28
31
  if(newline<0)break;lineNumber++;
29
32
  const absoluteEnd=start+consumed+newline+1;offset=absoluteEnd;
30
- if(!dropping&&carry.length){const line=carry.toString('utf8').replace(/\r$/,'');let o =null;
31
- // Codex conversation records are never deserialized. Claude records are
32
- // projected immediately; no raw object is allowed into storage/state.
33
- const relevant=root.provider==='claude'||/"type"\s*:\s*"(?:session_meta|turn_context)"/.test(line)||line.includes('"token_count"');
34
- if(relevant){try{o=JSON.parse(line);}catch{state.diagnostics.malformed++;}
35
- if(o){const r=root.provider==='codex'?parseCodex(o,state ,ref,lineNumber):parseClaude(o,state ,ref,lineNumber);if(r){const result=store.put(r,ref);if(result!=='new')state.diagnostics.repeated++;}}}
36
- }carry=Buffer.alloc(0);dropping=false;cursor=newline+1;
33
+ if(!dropping&&length){const buffer=parts.length===1?parts[0]:Buffer.concat(parts,length);
34
+ // Native byte searches avoid decoding conversation-only Codex lines.
35
+ // This is a conservative prefilter; the existing schema check still
36
+ // decides relevance, including malformed metadata and quoted markers.
37
+ const candidate=root.provider==='claude'||CODEX_MARKERS.some(marker=>buffer.includes(marker));
38
+ if(candidate){let o =null;
39
+ try{o=usageMetadata(buffer,root.provider);}catch{state.diagnostics.malformed++;}
40
+ if(o){const r=root.provider==='codex'?parseCodex(o,state ,ref,lineNumber):parseClaude(o,state ,ref,lineNumber);if(r){const result=store.put(r,ref);if(result!=='new')state.diagnostics.repeated++;}}
41
+ }
42
+ }parts=[];length=0;dropping=false;cursor=newline+1;
37
43
  }consumed+=chunk.length;options.onProgress?.({provider:root.provider,files:status.scanned+status.cached,total:status.files,bytes,phase:'scanning'});
38
- }
39
- if(carry.length||dropping)state.diagnostics.partialTail=true;
44
+ }}
45
+ if(length||dropping)state.diagnostics.partialTail=true;
40
46
  const checkpoint ={ref,size:stat.size,mtime:stat.mtimeMs,offset,line:lineNumber,head:fingerprint(fd,0,Math.min(4096,offset)),tail:fingerprint(fd,Math.max(0,offset-4096),Math.min(4096,offset)),state,identity};
41
- store.saveCheckpoint(checkpoint);store.collectOrphans();store.db.exec('COMMIT');transaction=false;status.scanned++;combine(status.diagnostics,state.diagnostics);
42
- }catch{if(transaction)store.db.exec('ROLLBACK');status.errors++;const previous=store.checkpoint(ref);if(previous)combine(status.diagnostics,previous.state.diagnostics);}finally{if(fd!==undefined)fs.closeSync(fd);}
47
+ // New records always receive a source association in put(); neither a
48
+ // fresh import nor an append creates orphans needing a global sweep.
49
+ store.saveCheckpoint(checkpoint);store.db.exec('COMMIT');transaction=false;status.scanned++;combine(status.diagnostics,state.diagnostics);
50
+ }catch{if(transaction)store.db.exec('ROLLBACK');status.errors++;const previous=store.checkpoint(ref);if(previous)combine(status.diagnostics,previous.state.diagnostics);}finally{
51
+ // A failed loop must not implicitly close the descriptor and then close
52
+ // it again here. Await the stream's single close, including pending reads.
53
+ if(stream)await new Promise (resolve=>stream .close(()=>resolve()));else if(fd!==undefined)fs.closeSync(fd);
54
+ }
43
55
  }
44
56
  }
45
57
  // Missing sources are retained as cached history, never silently treated as zero.
@@ -1,4 +1,4 @@
1
- import {DatabaseSync} from 'node:sqlite';
1
+ import {DatabaseSync, } from 'node:sqlite';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import {applicationDirectory} from './security.js';
@@ -23,41 +23,51 @@ function privateCheckpoint(checkpoint ) {
23
23
  }
24
24
  export class Store {
25
25
  db ;
26
+ statements=new Map ();
27
+ statement(sql ) {let statement=this.statements.get(sql);if(!statement){statement=this.db.prepare(sql);this.statements.set(sql,statement);}return statement;}
26
28
  constructor(directory ,home ){const safe=applicationDirectory(directory,home);fs.mkdirSync(safe,{recursive:true,mode:0o700});const target=path.join(safe,'subvalue.sqlite');for(const file of [target,target+'-wal',target+'-shm',target+'-journal']){try{if(fs.lstatSync(file).isSymbolicLink())throw new Error('Database links are not allowed');}catch(e){if((e ).code!=='ENOENT')throw e;}}this.db=new DatabaseSync(target);this.db.exec(`
27
29
  PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=2000;
28
30
  CREATE TABLE IF NOT EXISTS records(id TEXT PRIMARY KEY,provider TEXT NOT NULL,timestamp TEXT,data TEXT NOT NULL);
29
31
  CREATE INDEX IF NOT EXISTS records_period ON records(timestamp,provider);
30
32
  CREATE TABLE IF NOT EXISTS sources(ref TEXT PRIMARY KEY,checkpoint TEXT NOT NULL);
31
33
  CREATE TABLE IF NOT EXISTS source_records(ref TEXT NOT NULL,id TEXT NOT NULL,PRIMARY KEY(ref,id));
34
+ CREATE INDEX IF NOT EXISTS source_records_event ON source_records(id,ref);
32
35
  CREATE TABLE IF NOT EXISTS app(key TEXT PRIMARY KEY,data TEXT NOT NULL);
33
36
  PRAGMA user_version=1;
34
37
  `);try{fs.chmodSync(target,0o600);}catch{/* Windows ACLs inherit from the user directory. */}
35
38
  if(this.get('privacyMetadataVersion')!==1){this.db.exec('BEGIN IMMEDIATE');try{
36
- for(const row of this.db.prepare('SELECT id,data FROM records').iterate())this.db.prepare('UPDATE records SET data=? WHERE id=?').run(JSON.stringify(privateRecord(JSON.parse(String(row.data)))),row.id );
39
+ for(const row of this.statement('SELECT id,data FROM records').iterate())this.statement('UPDATE records SET data=? WHERE id=?').run(JSON.stringify(privateRecord(JSON.parse(String(row.data)))),row.id );
37
40
  for(const checkpoint of this.allCheckpoints())this.saveCheckpoint(checkpoint);
38
41
  this.save('privacyMetadataVersion',1);this.db.exec('COMMIT');
39
42
  }catch(error){this.db.exec('ROLLBACK');this.db.close();throw error;}}
40
43
  }
41
- checkpoint(ref ) {const r=this.db.prepare('SELECT checkpoint FROM sources WHERE ref=?').get(ref);return r?JSON.parse(String(r.checkpoint)):null;}
42
- saveCheckpoint(c ){this.db.prepare('INSERT OR REPLACE INTO sources VALUES (?,?)').run(c.ref,JSON.stringify(privateCheckpoint(c)));}
43
- clearSource(ref ){this.db.prepare('DELETE FROM source_records WHERE ref=?').run(ref);this.db.prepare('DELETE FROM sources WHERE ref=?').run(ref);}
44
+ checkpoint(ref ) {const r=this.statement('SELECT checkpoint FROM sources WHERE ref=?').get(ref);return r?JSON.parse(String(r.checkpoint)):null;}
45
+ saveCheckpoint(c ){this.statement('INSERT OR REPLACE INTO sources VALUES (?,?)').run(c.ref,JSON.stringify(privateCheckpoint(c)));}
46
+ // The scanner calls this inside its import transaction. Only this source's
47
+ // exclusive records are removed; shared events keep their deduplication state.
48
+ // Delete before reimport so a rewritten event cannot retain stale counters.
49
+ clearSource(ref ){
50
+ this.statement(`DELETE FROM records WHERE id IN (SELECT id FROM source_records WHERE ref=?)
51
+ AND NOT EXISTS (SELECT 1 FROM source_records WHERE id=records.id AND ref<>?)`).run(ref,ref);
52
+ this.statement('DELETE FROM source_records WHERE ref=?').run(ref);
53
+ this.statement('DELETE FROM sources WHERE ref=?').run(ref);
54
+ }
44
55
  put(r ,ref ) {
45
56
  r=privateRecord(r);
46
- const old=this.db.prepare('SELECT data FROM records WHERE id=?').get(r.id);let result ='new';
57
+ const old=this.statement('SELECT data FROM records WHERE id=?').get(r.id);let result ='new';
47
58
  if(old){const prev=JSON.parse(String(old.data)) ;const usageKeys=['input_tokens','cached_input_tokens','cache_creation_tokens','cache_creation_5m_tokens','cache_creation_1h_tokens','output_tokens','reasoning_tokens','model_raw','request_id','total_tokens'] ;
48
59
  const different=usageKeys.some(k=>prev[k]!==r[k]);result=different?'conflict':'duplicate';
49
- if(different){prev.quality='INCOMPLETE';if(!prev.notes.includes('duplicate-usage-disagreement'))prev.notes.push('duplicate-usage-disagreement');this.db.prepare('UPDATE records SET data=? WHERE id=?').run(JSON.stringify(prev),r.id);}
50
- }else this.db.prepare('INSERT INTO records VALUES (?,?,?,?)').run(r.id,r.provider,r.timestamp,JSON.stringify(r));
51
- this.db.prepare('INSERT OR IGNORE INTO source_records VALUES (?,?)').run(ref,r.id);return result;
60
+ if(different){prev.quality='INCOMPLETE';if(!prev.notes.includes('duplicate-usage-disagreement'))prev.notes.push('duplicate-usage-disagreement');this.statement('UPDATE records SET data=? WHERE id=?').run(JSON.stringify(prev),r.id);}
61
+ }else this.statement('INSERT INTO records VALUES (?,?,?,?)').run(r.id,r.provider,r.timestamp,JSON.stringify(r));
62
+ this.statement('INSERT OR IGNORE INTO source_records VALUES (?,?)').run(ref,r.id);return result;
52
63
  }
53
- collectOrphans(){this.db.exec('DELETE FROM records WHERE id NOT IN (SELECT id FROM source_records)');}
54
- records(from ,until ) {const rows=from&&until?this.db.prepare('SELECT data FROM records WHERE timestamp>=? AND timestamp<? ORDER BY timestamp,id').all(from,until):this.db.prepare('SELECT data FROM records ORDER BY timestamp,id').all();return rows.map(r=>JSON.parse(String(r.data)));}
55
- revision() {return `${this.db.prepare('PRAGMA data_version').get() .data_version}:${this.db.prepare('SELECT total_changes() AS changes').get() .changes}`;}
56
- allCheckpoints() {return this.db.prepare('SELECT checkpoint FROM sources').all().map(r=>JSON.parse(String(r.checkpoint)));}
57
- settings() {const r=this.db.prepare("SELECT data FROM app WHERE key='settings'").get();return r?JSON.parse(String(r.data)):defaultSettings();}
64
+ records(from ,until ) {const rows=from&&until?this.statement('SELECT data FROM records WHERE timestamp>=? AND timestamp<? ORDER BY timestamp,id').all(from,until):this.statement('SELECT data FROM records ORDER BY timestamp,id').all();return rows.map(r=>JSON.parse(String(r.data)));}
65
+ revision() {return `${this.statement('PRAGMA data_version').get() .data_version}:${this.statement('SELECT total_changes() AS changes').get() .changes}`;}
66
+ allCheckpoints() {return this.statement('SELECT checkpoint FROM sources').all().map(r=>JSON.parse(String(r.checkpoint)));}
67
+ settings() {const r=this.statement("SELECT data FROM app WHERE key='settings'").get();return r?JSON.parse(String(r.data)):defaultSettings();}
58
68
  saveSettings(s ){this.save('settings',s);}
59
- save(key ,value ){this.db.prepare('INSERT OR REPLACE INTO app VALUES (?,?)').run(key,JSON.stringify(value));}
60
- get (key ) {const r=this.db.prepare('SELECT data FROM app WHERE key=?').get(key);return r?JSON.parse(String(r.data)):null;}
69
+ save(key ,value ){this.statement('INSERT OR REPLACE INTO app VALUES (?,?)').run(key,JSON.stringify(value));}
70
+ get (key ) {const r=this.statement('SELECT data FROM app WHERE key=?').get(key);return r?JSON.parse(String(r.data)):null;}
61
71
  statuses() {return this.get ('sources')??[];}
62
72
  close(){this.db.close();}
63
73
  }
@@ -0,0 +1,73 @@
1
+
2
+
3
+ // These are the fields consumed by the provider adapters. Conversation text,
4
+ // instructions, working directories and tool content have no selected path.
5
+
6
+
7
+ const counters ={input_tokens:'number',cached_input_tokens:'number',cache_write_input_tokens:'number',output_tokens:'number',reasoning_output_tokens:'number',total_tokens:'number'};
8
+ const fields ={
9
+ codex:{type:'text',timestamp:'text',ordinal:'number',payload:{type:'text',id:'text',model:'text',subagent_history_start_ordinal:'number',info:{total_token_usage:counters,last_token_usage:counters}}},
10
+ claude:{type:'text',timestamp:'text',sessionId:'text',requestId:'text',message:{id:'text',model:'text',usage:{input_tokens:'number',cache_read_input_tokens:'number',cache_creation_input_tokens:'number',output_tokens:'number',cache_creation:{ephemeral_5m_input_tokens:'number',ephemeral_1h_input_tokens:'number'},service_tier:'text',speed:'text',server_tool_use:{web_search_requests:'number',web_fetch_requests:'number'}}}}
11
+ };
12
+
13
+ /** Validate one JSONL line and decode only whitelisted metadata scalars.
14
+ * Skipped strings remain bytes: no prompt/response strings or objects are built.
15
+ * The line's byte buffer is discarded by the scanner after processing.
16
+ */
17
+ export function usageMetadata(bytes ,provider ) {
18
+ let at=0;
19
+ const fail=() =>{throw new SyntaxError('Invalid usage JSON');};
20
+ const space=()=>{while(at<bytes.length&&(bytes[at]===32||bytes[at]===9||bytes[at]===10||bytes[at]===13))at++;};
21
+ const string=() =>{
22
+ const buffer=bytes;let i=at+1;
23
+ for(;i<buffer.length;i++){
24
+ const byte=buffer[i];if(byte===34){at=i+1;return at;}
25
+ if(byte<32)fail();
26
+ if(byte===92){const escaped=buffer[++i];
27
+ if(escaped===117){for(let j=0;j<4;j++){const hex=buffer[++i];if(!((hex>=48&&hex<=57)||(hex>=65&&hex<=70)||(hex>=97&&hex<=102)))fail();}}
28
+ else if(![34,92,47,98,102,110,114,116].includes(escaped))fail();
29
+ }
30
+ }
31
+ return fail();
32
+ };
33
+ const number=()=>{
34
+ if(bytes[at]===45)at++;
35
+ if(bytes[at]===48)at++;
36
+ else {if(!(bytes[at]>=49&&bytes[at]<=57))fail();while(bytes[at]>=48&&bytes[at]<=57)at++;}
37
+ if(bytes[at]===46){at++;if(!(bytes[at]>=48&&bytes[at]<=57))fail();while(bytes[at]>=48&&bytes[at]<=57)at++;}
38
+ if(bytes[at]===101||bytes[at]===69){at++;if(bytes[at]===43||bytes[at]===45)at++;if(!(bytes[at]>=48&&bytes[at]<=57))fail();while(bytes[at]>=48&&bytes[at]<=57)at++;}
39
+ };
40
+ const value=(selected ,depth ) =>{
41
+ if(depth>512)fail();space();const start=at,byte=bytes[at];
42
+ if(byte===123){
43
+ at++;space();const object =selected&&typeof selected==='object'?{}:undefined;
44
+ if(bytes[at]===125){at++;return object??(selected?null:undefined);}
45
+ while(at<bytes.length){
46
+ if(bytes[at]!==34)fail();const keyStart=at;string();let child =undefined,key='';
47
+ // All selected keys are short ASCII names; escaped spellings are allowed.
48
+ if(object&&at-keyStart<=192){key=JSON.parse(bytes.toString('utf8',keyStart,at));if(selected&&typeof selected==='object'&&Object.hasOwn(selected,key))child=selected[key];}
49
+ space();if(bytes[at++]!==58)fail();const result=value(child,depth+1);if(child)object [key]=result;
50
+ space();const next=bytes[at++];if(next===125)return object??(selected?null:undefined);if(next!==44)fail();space();
51
+ }
52
+ return fail();
53
+ }
54
+ if(byte===91){
55
+ at++;space();if(bytes[at]===93){at++;return selected&&typeof selected==='object'?[]:selected?null:undefined;}
56
+ while(at<bytes.length){value(undefined,depth+1);space();const next=bytes[at++];if(next===93)return selected&&typeof selected==='object'?[]:selected?null:undefined;if(next!==44)fail();}
57
+ return fail();
58
+ }
59
+ if(byte===34)string();
60
+ else if(byte===45||(byte>=48&&byte<=57))number();
61
+ else {
62
+ const literal=byte===116?'true':byte===102?'false':byte===110?'null':fail();
63
+ for(let i=0;i<literal.length;i++)if(bytes[at++]!==literal.charCodeAt(i))fail();
64
+ }
65
+ if(!selected)return undefined;
66
+ if(!((selected==='text'&&byte===34)||(selected==='number'&&(byte===45||(byte>=48&&byte<=57)))))return null;
67
+ // Oversized metadata scalars cannot be valid identifiers/counters. Never
68
+ // materialize a conversation disguised as an allowed metadata value.
69
+ if(at-start>1024)return null;
70
+ return JSON.parse(bytes.toString('utf8',start,at));
71
+ };
72
+ const result=value(fields[provider],0);space();if(at!==bytes.length)fail();return result;
73
+ }
@@ -63,3 +63,4 @@ dialog{background:#131a1e;border-color:#34453c}dialog::backdrop{background:#0306
63
63
  @media(max-width:760px){.billing-row{grid-template-columns:1fr 155px}.subscription-fields{grid-column:1/-1}.subscription-fields .amount-field{grid-column:auto;justify-content:end}}
64
64
  .refresh-button{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:44px;padding:9px 13px;background:#151a1e;border:1px solid #343d43;border-radius:4px;color:var(--text);font-size:11px;white-space:nowrap}.refresh-button svg{width:15px;height:15px;color:var(--green)}.refresh-button:hover:enabled{background:#1d2328;border-color:#59656c}
65
65
  dialog .subscription-fields{grid-column:1/-1}
66
+ dialog{max-height:calc(100dvh - 32px);overflow-y:auto}
@@ -27,21 +27,21 @@ function refreshButton(){return `<button type="button" class="refresh-button" id
27
27
  function updateRefreshButton(){const button=document.querySelector ('#rescan');if(button){button.disabled=busy;button.setAttribute('aria-busy',String(busy));button.innerHTML=icon('refresh')+'<span>'+(busy?'Scanning…':'Refresh usage')+'</span>';}}
28
28
  function overview(){const t=summary.total,c=comparisonForDisplay(summary);const active=summary.providers.filter(p=>p.records);const showSubs=active.length&&active.every(p=>p.mode==='SUBSCRIPTION');return `<div class="page-heading"><div><h1>Overview</h1></div>${refreshButton()}</div>${coverage()}<div class="overview-layout"><div class="overview-main"><div class="metrics ${showSubs?'':'single'}">${metric('API equivalent',t.apiEquivalent===null?(t.knownSubtotal!==null?money(t.knownSubtotal):t.records?'—':'No data'):money(t.apiEquivalent),summary.range.label,'api-metric')}${showSubs?metric('Subscription',c.subscription===null?'Not set':money(c.subscription),period==='month'?'Per month':'Prorated'):''}${showSubs?metric(summary.historyPartial?'Observed value':'Value',c.value===null?'—':money(c.value,true),c.value===null?'Comparison unavailable':summary.historyPartial?'Partial history':'',c.value!==null?(c.value>=0?'positive':'negative'):''):''}${showSubs?metric('Value multiple',c.roi===null?'—':`${c.roi.toFixed(2)}<span class="multiply">×</span>`,c.breakEven?`Break-even ${new Date(c.breakEven+'T12:00:00').toLocaleDateString('en-US',{month:'short',day:'numeric'})}`:c.roi===null?'Unavailable':t.apiEquivalent===null?'Priced portion':''):''}</div>${providerCards()}${chart()}<div class="overview-bottom"><span>${icon('shield')}Local analysis. Read-only sources.</span><button class="text-button" data-page="settings">Manage sources ${icon('arrow')}</button></div>${!t.records?'<section class="panel empty-panel"><h2>No data for this period</h2><button class="text-button" data-page="settings">View sources →</button></section>':''}</div>${receiptPanel(true)}</div>`;}
29
29
  function receiptPage(){return `<div class="page-heading"><div><h1>Receipt</h1></div></div><div class="receipt-page-layout">${receiptPanel()}<aside class="receipt-aside"><span class="eyebrow">API EQUIVALENT</span><strong>${summary.total.knownSubtotal!==null?money(summary.total.apiEquivalent??summary.total.knownSubtotal):summary.total.records?'No priced usage':'No data'}</strong><span>${summary.range.label}</span>${coverage()}<details class="privacy-note"><summary>Privacy</summary><p>Exports contain provider totals and the selected period. No projects, sessions, paths, prompts, or responses.</p></details></aside></div>`;}
30
- function billingFields(p ){const b=settings.billing[p],plan=subscriptionPlan(p,b.planId);return `<div class="billing-row"><label class="provider-setting-name" for="mode-${p}">${name(p)}</label><select id="mode-${p}" name="mode-${p}" data-billing-mode="${p}"><option value="UNKNOWN" ${b.mode==='UNKNOWN'?'selected':''}>Not set</option><option value="SUBSCRIPTION" ${b.mode==='SUBSCRIPTION'?'selected':''}>Subscription</option><option value="API" ${b.mode==='API'?'selected':''}>API</option><option value="MIXED" ${b.mode==='MIXED'?'selected':''}>Mixed / not sure</option></select><div class="subscription-fields ${b.mode==='SUBSCRIPTION'?'':'hidden'}" id="amount-${p}"><label class="sr-only" for="plan-${p}">${name(p)} subscription plan</label><select id="plan-${p}" name="plan-${p}" data-subscription-plan="${p}"><option value="custom" ${!plan?'selected':''}>Custom amount</option>${subscriptionPlans.filter(plan=>plan.provider===p).map(option=>`<option value="${option.id}" ${plan?.id===option.id?'selected':''}>${esc(subscriptionPlanLabel(option))}</option>`).join('')}</select><label class="amount-field"><span class="sr-only">${name(p)} monthly subscription in USD</span><span>$</span><input name="monthly-${p}" type="number" min="0" max="1000000" step="0.01" value="${plan?plan.monthly.toFixed(2):b.monthly??''}" ${plan?'readonly':''} placeholder="Amount"><span>/ month</span></label></div></div>`;}
31
- function settingsPage(){return `<div class="page-heading"><div><h1>Settings</h1></div></div><div class="settings-layout"><form id="settings-form"><section class="panel settings-section"><h2>Sources</h2>${summary.providers.map(p=>{const s=sources.find(s=>s.provider===p.provider);return `<div class="source-row"><div><strong>${name(p.provider)}</strong><span class="source-state">${p.status==='USAGE_FOUND'?'Usage found':p.detected?'No usage in selected period':'Not detected'}</span></div><label class="include-check"><input type="checkbox" name="include-${p.provider}" ${settings.include[p.provider]?'checked':''}>Always show</label><details><summary>Details</summary><dl><div><dt>Detection</dt><dd>${p.detected?'Detected':'Not detected'}</dd></div><div><dt>Imported events</dt><dd>${p.importedRecords.toLocaleString()}</dd></div><div><dt>Last scan</dt><dd>${lastScan?new Date(lastScan).toLocaleString():'Not scanned'}</dd></div><div><dt>Source files</dt><dd>${s?.files??0}</dd></div><div><dt>Available history</dt><dd>${p.first?p.first.slice(0,10)+' → '+p.last?.slice(0,10):'No data'}</dd></div><div><dt>Read errors</dt><dd>${s?.errors??0}</dd></div><div><dt>Malformed / skipped</dt><dd>${(s?.diagnostics.malformed??0)+(s?.diagnostics.oversize??0)}</dd></div><div><dt>Incomplete final line</dt><dd>${s?.diagnostics.partialTail?'Yes':'No'}</dd></div></dl></details></div>`;}).join('')}${refreshButton()}</section><section class="panel settings-section"><h2>Billing</h2>${(['codex','claude'] ).filter(p=>sources.find(s=>s.provider===p)?.detected).map(billingFields).join('')}<details class="compact-details"><summary>Details</summary><p>Plans verified ${subscriptionVerified}. ${subscriptionPlans.filter((p,i,list)=>list.findIndex(q=>q.source===p.source)===i).map(p=>`<a href="${esc(p.source)}" target="_blank" rel="noreferrer">${p.provider==='codex'?'ChatGPT':'Claude'} ↗</a>`).join(' · ')}. Annual plans use the exact annual price divided by 12; team plans are per seat. Enterprise / Edu: custom amount.</p><p>These are your declarations, not inferred billing history. Amounts are in USD and prorated by calendar day across the selected period. API and mixed usage have no subscription comparison.</p></details></section><section class="panel settings-section"><h2>Currency</h2><div class="setting-line"><span>API prices & subscription</span><strong>USD</strong></div></section><section class="panel settings-section"><h2>Privacy / data</h2><div class="privacy-tags"><span>${icon('check')}Local only</span><span>${icon('check')}Read-only sources</span><span>${icon('check')}No telemetry</span></div><details><summary>Data access</summary><p>Only approved Codex and Claude JSONL session files are streamed. Only normalized usage metadata is stored. Credentials, project contents and conversation content are not stored.</p><code>~/.codex/sessions/**/*.jsonl<br>~/.codex/archived_sessions/*.jsonl<br>~/.claude/projects/**/*.jsonl</code></details></section><div class="form-actions"><button class="button" type="submit">Save changes</button></div></form></div>`;}
32
- function onboarding(){if(settings.onboarded)return '';const found=summary.providers.filter(p=>p.first),billable=summary.providers.filter(p=>p.records>0);return `<dialog id="onboarding" aria-labelledby="onboarding-title"><span class="brand-mark">${productMark}</span><span class="eyebrow">${esc(product.name.toUpperCase())} / FIRST RUN</span><h2 id="onboarding-title">${onboardingStage==='found'?(found.length?'Local usage found':'No local usage found'):'How did you use it?'}</h2>${onboardingStage==='found'?`<div class="found-sources">${summary.providers.map(p=>`<div><strong>${name(p.provider)}</strong><span>${p.first?`${Math.max(1,Math.ceil((Date.parse(p.last )-Date.parse(p.first))/864e5))} days ${icon('check')}`:p.detected?'No data found':'Not detected'}</span></div>`).join('')}</div><button class="button" id="onboarding-continue">Continue ${icon('arrow')}</button>`:`<form id="onboarding-billing">${billable.map(p=>`<h3>How did you use ${name(p.provider)}?</h3>${billingFields(p.provider)}`).join('')}${!billable.length?'<p>No usage metadata found.</p>':''}<button class="button" type="submit">Open overview ${icon('arrow')}</button></form>`}<button class="text-button onboarding-skip" id="onboarding-skip">${onboardingStage==='found'?'Skip billing':'Set up later'}</button></dialog>`;}
33
- function render(){app.innerHTML=`${demo?'<div class="demo-banner">DEMO / FIXTURE DATA — no local sources accessed</div>':''}<div class="app-shell">${navigation()}<main class="app-main">${page==='overview'?overview():page==='receipt'?receiptPage():settingsPage()}</main><footer class="app-footer"><span>${esc(product.name)} <span class="version">0.1</span></span><span>LOCAL / READ-ONLY</span></footer></div>${onboarding()}`;
34
- const canvas=document.querySelector ('#receipt-canvas');if(canvas)drawReceipt(canvas,receiptModel(summary));const dialog=document.querySelector ('#onboarding');if(dialog)dialog.showModal();bind();}
30
+ function billingFields(p ,firstRun=false){const b=settings.billing[p],plan=subscriptionPlan(p,b.planId);return `<div class="billing-row"><label class="provider-setting-name" for="mode-${p}">${name(p)}</label><select id="mode-${p}" name="mode-${p}" data-billing-mode="${p}" ${firstRun?'required data-first-run="true"':''}><option value="${firstRun?'':'UNKNOWN'}" ${b.mode==='UNKNOWN'?'selected':''}>${firstRun?'Choose usage':'Not set'}</option><option value="SUBSCRIPTION" ${b.mode==='SUBSCRIPTION'?'selected':''}>Subscription</option><option value="API" ${b.mode==='API'?'selected':''}>API</option><option value="MIXED" ${b.mode==='MIXED'?'selected':''}>Mixed / not sure</option></select><div class="subscription-fields ${b.mode==='SUBSCRIPTION'?'':'hidden'}" id="amount-${p}"><label class="sr-only" for="plan-${p}">${name(p)} subscription plan</label><select id="plan-${p}" name="plan-${p}" data-subscription-plan="${p}"><option value="custom" ${!plan?'selected':''}>Custom amount</option>${subscriptionPlans.filter(plan=>plan.provider===p).map(option=>`<option value="${option.id}" ${plan?.id===option.id?'selected':''}>${esc(subscriptionPlanLabel(option))}</option>`).join('')}</select><label class="amount-field"><span class="sr-only">${name(p)} monthly subscription in USD</span><span>$</span><input name="monthly-${p}" type="number" min="0" max="1000000" step="0.01" value="${plan?plan.monthly.toFixed(2):b.monthly??''}" ${plan?'readonly':''} ${firstRun&&b.mode==='SUBSCRIPTION'?'required':''} placeholder="Amount"><span>/ month</span></label></div></div>`;}
31
+ function settingsPage(){return `<div class="page-heading"><div><h1>Settings</h1></div></div><div class="settings-layout"><form id="settings-form"><section class="panel settings-section"><h2>Sources</h2>${summary.providers.map(p=>{const s=sources.find(s=>s.provider===p.provider);return `<div class="source-row"><div><strong>${name(p.provider)}</strong><span class="source-state">${p.status==='USAGE_FOUND'?'Usage found':p.detected?'No usage in selected period':'Not detected'}</span></div><label class="include-check"><input type="checkbox" name="include-${p.provider}" ${settings.include[p.provider]?'checked':''}>Always show</label><details><summary>Details</summary><dl><div><dt>Detection</dt><dd>${p.detected?'Detected':'Not detected'}</dd></div><div><dt>Imported events</dt><dd>${p.importedRecords.toLocaleString()}</dd></div><div><dt>Last scan</dt><dd>${lastScan?new Date(lastScan).toLocaleString():'Not scanned'}</dd></div><div><dt>Source files</dt><dd>${s?.files??0}</dd></div><div><dt>Available history</dt><dd>${p.first?p.first.slice(0,10)+' → '+p.last?.slice(0,10):'No data'}</dd></div><div><dt>Read errors</dt><dd>${s?.errors??0}</dd></div><div><dt>Malformed / skipped</dt><dd>${(s?.diagnostics.malformed??0)+(s?.diagnostics.oversize??0)}</dd></div><div><dt>Incomplete final line</dt><dd>${s?.diagnostics.partialTail?'Yes':'No'}</dd></div></dl></details></div>`;}).join('')}${refreshButton()}</section><section class="panel settings-section"><h2>Billing</h2>${(['codex','claude'] ).filter(p=>sources.find(s=>s.provider===p)?.detected).map(p=>billingFields(p)).join('')}<details class="compact-details"><summary>Details</summary><p>Plans verified ${subscriptionVerified}. ${subscriptionPlans.filter((p,i,list)=>list.findIndex(q=>q.source===p.source)===i).map(p=>`<a href="${esc(p.source)}" target="_blank" rel="noreferrer">${p.provider==='codex'?'ChatGPT':'Claude'} ↗</a>`).join(' · ')}. Annual plans use the exact annual price divided by 12; team plans are per seat. Enterprise / Edu: custom amount.</p><p>These are your declarations, not inferred billing history. Amounts are in USD and prorated by calendar day across the selected period. API and mixed usage have no subscription comparison.</p></details></section><section class="panel settings-section"><h2>Currency</h2><div class="setting-line"><span>API prices & subscription</span><strong>USD</strong></div></section><section class="panel settings-section"><h2>Privacy / data</h2><div class="privacy-tags"><span>${icon('check')}Local only</span><span>${icon('check')}Read-only sources</span><span>${icon('check')}No telemetry</span></div><details><summary>Data access</summary><p>Only approved Codex and Claude JSONL session files are streamed. Only normalized usage metadata is stored. Credentials, project contents and conversation content are not stored.</p><code>~/.codex/sessions/**/*.jsonl<br>~/.codex/archived_sessions/*.jsonl<br>~/.claude/projects/**/*.jsonl</code></details></section><div class="form-actions"><button class="button" type="submit">Save changes</button></div></form></div>`;}
32
+ function onboarding(){const found=summary.providers.filter(p=>p.first),billable=summary.providers.filter(p=>p.importedRecords>0);return `<dialog id="onboarding" aria-labelledby="onboarding-title"><span class="brand-mark">${productMark}</span><span class="eyebrow">${esc(product.name.toUpperCase())} / FIRST RUN</span><h2 id="onboarding-title">${onboardingStage==='found'?(found.length?'Local usage found':'No local usage found'):'How did you use it?'}</h2>${onboardingStage==='found'?`<div class="found-sources">${summary.providers.map(p=>`<div><strong>${name(p.provider)}</strong><span>${p.first?`${Math.max(1,Math.ceil((Date.parse(p.last )-Date.parse(p.first))/864e5))} days ${icon('check')}`:p.detected?'No data found':'Not detected'}</span></div>`).join('')}</div><button class="button" id="onboarding-continue">Continue ${icon('arrow')}</button>`:`<form id="onboarding-billing">${billable.map(p=>`<h3>How did you use ${name(p.provider)}?</h3>${billingFields(p.provider,true)}`).join('')}<button class="button" type="submit">Open overview ${icon('arrow')}</button></form><button class="text-button onboarding-skip" id="onboarding-skip">Set up later</button>`}</dialog>`;}
33
+ function render(){app.innerHTML=`${demo?'<div class="demo-banner">DEMO / FIXTURE DATA — no local sources accessed</div>':''}${!settings.onboarded?onboarding():`<div class="app-shell">${navigation()}<main class="app-main">${page==='overview'?overview():page==='receipt'?receiptPage():settingsPage()}</main><footer class="app-footer"><span>${esc(product.name)} <span class="version">0.1</span></span><span>LOCAL / READ-ONLY</span></footer></div>`}`;
34
+ const canvas=document.querySelector ('#receipt-canvas');if(canvas)drawReceipt(canvas,receiptModel(summary));const dialog=document.querySelector ('#onboarding');if(dialog){dialog.addEventListener('cancel',event=>event.preventDefault());dialog.showModal();}bind();}
35
35
  function readBilling(form ){const data=new FormData(form);for(const p of ['codex','claude'] ){const mode=data.get(`mode-${p}`);if(mode){const amount=data.get(`monthly-${p}`),plan=subscriptionPlan(p,String(data.get(`plan-${p}`)));settings.billing[p]={mode:mode ,monthly:mode==='SUBSCRIPTION'?(plan?.monthly??(amount!==''&&amount!==null?Number(amount):null)):null,...(mode==='SUBSCRIPTION'?{planId:plan?.id??null}:{})};}}}
36
36
  async function save(){await api('/api/settings','POST',settings);await load();render();}
37
37
  function bind(){document.querySelectorAll ('[data-page]').forEach(b=>b.addEventListener('click',()=>{page=b.dataset.page ;history.replaceState(null,'',page==='overview'?'/':`/${page}`);render();window.scrollTo(0,0);}));document.querySelectorAll ('[data-period]').forEach(b=>b.addEventListener('click',async()=>{if(busy)return;period=b.dataset.period ;if(period==='custom'&&(!from||!to)){from=new Date(summary.range.from).toLocaleDateString('en-CA');to=new Date(Date.parse(summary.range.until)-864e5).toLocaleDateString('en-CA');render();return;}try{if(await load())render();}catch{toast('Choose a valid date range');}}));
38
38
  document.querySelector ('#custom-range')?.addEventListener('submit',async e=>{e.preventDefault();const data=new FormData(e.currentTarget );from=String(data.get('from'));to=String(data.get('to'));try{if(await load())render();}catch{toast('Choose a valid date range');}});
39
- document.querySelectorAll ('[data-billing-mode]').forEach(select=>select.addEventListener('change',()=>document.querySelector(`#amount-${select.dataset.billingMode}`)?.classList.toggle('hidden',select.value!=='SUBSCRIPTION')));
39
+ document.querySelectorAll ('[data-billing-mode]').forEach(select=>select.addEventListener('change',()=>{document.querySelector(`#amount-${select.dataset.billingMode}`)?.classList.toggle('hidden',select.value!=='SUBSCRIPTION');const amount=document.querySelector (`[name="monthly-${select.dataset.billingMode}"]`);if(amount)amount.required=select.dataset.firstRun==='true'&&select.value==='SUBSCRIPTION';}));
40
40
  document.querySelectorAll ('[data-subscription-plan]').forEach(select=>select.addEventListener('change',()=>{const p=select.dataset.subscriptionPlan ,plan=subscriptionPlan(p,select.value),input=document.querySelector (`[name="monthly-${p}"]`) ;input.readOnly=!!plan;if(plan)input.value=plan.monthly.toFixed(2);}));
41
41
  document.querySelector ('#settings-form')?.addEventListener('submit',async e=>{e.preventDefault();const form=e.currentTarget ;readBilling(form);const data=new FormData(form);for(const p of ['codex','claude'] )settings.include[p]=data.has(`include-${p}`);try{await save();toast('Saved locally');}catch{toast('Unable to save settings');}});
42
42
  document.querySelector('#export-png')?.addEventListener('click',async()=>{try{await exportReceipt(document.querySelector ('#receipt-canvas') );toast('Receipt exported');}catch{toast('Unable to export PNG');}});
43
43
  document.querySelector('#rescan')?.addEventListener('click',async()=>{if(busy)return;busy=true;updateRefreshButton();try{await api('/api/rescan','POST');const poll=async()=>{try{const b=await api ('/api/bootstrap');if(b.scanning){setTimeout(()=>void poll(),1000);return;}sources=b.sources;lastScan=b.lastScan;busy=false;await load();render();toast('Sources updated');}catch{busy=false;updateRefreshButton();toast('Unable to scan');}};setTimeout(()=>void poll(),1000);}catch{busy=false;updateRefreshButton();toast('Unable to scan');}});
44
- document.querySelector('#onboarding-continue')?.addEventListener('click',async()=>{if(!summary.providers.some(p=>p.records>0)){settings.onboarded=true;await save();return;}onboardingStage='billing';render();});document.querySelector('#onboarding-skip')?.addEventListener('click',async()=>{settings.onboarded=true;await save();});document.querySelector ('#onboarding-billing')?.addEventListener('submit',async e=>{e.preventDefault();readBilling(e.currentTarget );settings.onboarded=true;try{await save();}catch{toast('Unable to save');}});
44
+ document.querySelector('#onboarding-continue')?.addEventListener('click',async()=>{if(!summary.providers.some(p=>p.importedRecords>0)){settings.onboarded=true;try{await save();}catch{settings.onboarded=false;toast('Unable to save');}return;}onboardingStage='billing';render();});document.querySelector('#onboarding-skip')?.addEventListener('click',async()=>{settings.onboarded=true;try{await save();}catch{settings.onboarded=false;toast('Unable to save');}});document.querySelector ('#onboarding-billing')?.addEventListener('submit',async e=>{e.preventDefault();readBilling(e.currentTarget );settings.onboarded=true;try{await save();}catch{settings.onboarded=false;toast('Unable to save');}});
45
45
  }
46
46
  async function boot(){try{const b=await api ('/api/bootstrap');settings=b.settings;sources=b.sources;token=b.token;demo=b.demo;lastScan=b.lastScan;await Promise.all([load(),receiptFonts(),document.fonts.load('14px Inter')]);render();}catch{app.innerHTML=`<main class="loading"><h1>${esc(product.name)}</h1><p>Unable to load local usage.</p><button class="button" id="retry">Retry</button></main>`;document.querySelector('#retry')?.addEventListener('click',()=>void boot());}}
47
47
  void boot();
@@ -63,6 +63,7 @@ dialog{background:#131a1e;border-color:#34453c}dialog::backdrop{background:#0306
63
63
  @media(max-width:760px){.billing-row{grid-template-columns:1fr 155px}.subscription-fields{grid-column:1/-1}.subscription-fields .amount-field{grid-column:auto;justify-content:end}}
64
64
  .refresh-button{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:44px;padding:9px 13px;background:#151a1e;border:1px solid #343d43;border-radius:4px;color:var(--text);font-size:11px;white-space:nowrap}.refresh-button svg{width:15px;height:15px;color:var(--green)}.refresh-button:hover:enabled{background:#1d2328;border-color:#59656c}
65
65
  dialog .subscription-fields{grid-column:1/-1}
66
+ dialog{max-height:calc(100dvh - 32px);overflow-y:auto}
66
67
 
67
68
  .landing-nav{max-width:1120px;padding:30px 32px;margin:auto;display:flex;justify-content:space-between;align-items:center}.github-link{font-size:12px;color:var(--muted);display:flex;gap:8px}.github-link span{color:var(--green)}.hero{text-align:center;padding:57px 24px 43px;display:flex;flex-direction:column;align-items:center}.hero>.eyebrow{color:#819989;margin-bottom:24px;font-size:9px}.hero h1{font-size:61px;letter-spacing:-3px;font-weight:550;margin-bottom:17px;line-height:1.05}.hero h2{font-size:37px;line-height:1.22;letter-spacing:-1.25px;font-weight:450}.hero p{font-size:13px;max-width:450px;margin:20px 0 27px;line-height:1.65}.install-command{display:flex;align-items:center;justify-content:space-between;gap:32px;background:#151c17;border:1px solid #354339;border-radius:5px;padding:14px 14px 14px 20px;min-width:340px;max-width:100%}.install-command code{font:14px var(--mono);white-space:nowrap;color:#edf3ef}.install-command code>span{color:var(--green);margin-right:5px}.copy-command{border:0;background:none;color:var(--muted);display:flex;padding:4px;min-height:28px;align-items:center}.copy-command:hover{color:var(--green)}.copy-command svg{width:16px;height:16px}.trust-row{display:flex;justify-content:center;gap:25px;margin-top:22px;color:#a3b0a6;font-size:10px}.trust-row span{white-space:nowrap}.release-note{font:9px var(--mono);color:var(--muted);margin-top:16px}.product-preview{max-width:1120px;margin:13px auto 0;padding:0 32px}.preview-label{display:flex;justify-content:space-between;font:8px var(--mono);letter-spacing:1.2px;color:var(--muted);margin-bottom:12px}.preview-window{border:1px solid #35473b;border-radius:9px;background:#0d130f;overflow:hidden;box-shadow:0 24px 64px #0006}.preview-topbar{display:flex;align-items:center;gap:29px;padding:20px 24px;border-bottom:1px solid var(--border);font-size:10px;color:var(--muted)}.preview-topbar .brand{font-size:16px;margin-right:20px}.preview-topbar .brand-mark{font-size:29px}.preview-tab{color:var(--text);position:relative}.preview-tab:after{content:'';height:2px;background:var(--green);position:absolute;left:0;right:0;bottom:-23px}.preview-date{margin-left:auto;font:8px var(--mono)}.preview-body{display:grid;grid-template-columns:1fr 245px;gap:22px;padding:25px}.preview-main{min-width:0}.preview-metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-bottom:16px}.preview-metrics>div{display:flex;flex-direction:column;background:var(--panel);border:1px solid var(--border);border-radius:4px;padding:16px 12px;gap:14px;min-width:0}.preview-metrics>div:first-child{border-top:2px solid var(--green);padding-top:15px}.preview-metrics span{font-size:8px;color:var(--muted)}.preview-metrics strong{font-size:24px;letter-spacing:-1px;font-weight:500;font-variant-numeric:tabular-nums}.green-text{color:var(--green)}.preview-provider{background:var(--panel);border:1px solid var(--border);border-radius:4px;padding:19px;display:flex;align-items:center;gap:14px;margin-bottom:16px}.preview-provider-mark{font-size:32px}.preview-provider>div{display:flex;flex-direction:column;gap:6px}.preview-provider strong{font-size:12px;font-weight:500}.preview-provider>div span{font-size:9px;color:var(--muted)}.preview-provider b{margin-left:auto;font-size:20px;font-weight:450;letter-spacing:-.5px}.preview-active{font:8px var(--mono);background:#153020;border:1px solid #284f35;color:var(--green);padding:4px 6px;border-radius:3px}.preview-chart{padding:20px;background:var(--panel);border:1px solid var(--border);border-radius:4px}.preview-chart>div:first-child{display:flex;justify-content:space-between;gap:12px;font-size:9px;margin-bottom:19px}.preview-chart strong{font-size:11px;font-weight:450}.preview-chart>div:first-child span{color:var(--green);font-size:8px}.preview-bars{height:143px;display:flex;align-items:end;gap:6px;border-bottom:1px solid #3a443d;background:repeating-linear-gradient(to top,transparent,transparent 35px,#263029 36px,transparent 37px)}.preview-bars i{background:var(--green);opacity:.87;flex:1;border-radius:1px 1px 0 0;min-width:0}.preview-chart-axis{display:flex;justify-content:space-between;font:8px var(--mono);color:var(--muted);margin-top:12px}.preview-bottom{display:flex;justify-content:space-between;gap:10px;margin-top:20px;font:7px var(--mono);letter-spacing:.6px;color:var(--muted)}.preview-receipt{display:flex;flex-direction:column;justify-content:start;gap:13px}.preview-receipt canvas{width:100%;display:block}.preview-receipt>span{background:var(--green);color:#12241a;border-radius:3px;text-align:center;padding:11px;font-size:10px;font-weight:600}.landing-privacy{max-width:1056px;margin:82px auto 0;padding:47px 0 49px;border-top:1px solid var(--border);border-bottom:1px solid var(--border);display:grid;grid-template-columns:1fr 1fr;gap:20px}.landing-privacy .eyebrow{grid-column:1/-1}.landing-privacy h2{font-size:31px;line-height:1.25;letter-spacing:-1px;font-weight:450}.landing-privacy>div{display:grid;grid-template-columns:1fr 1fr;align-content:center;gap:20px;font-size:12px;color:var(--muted)}.landing-privacy>div span:before{content:'✓';color:var(--green);margin-right:10px}.install-section{display:flex;flex-direction:column;align-items:center;padding:54px 24px 64px}.install-section .eyebrow{margin-bottom:22px}.landing-footer{max-width:1056px;margin:auto;padding:23px 0 27px;border-top:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;gap:20px}.landing-footer .brand{font-size:15px}.landing-footer .brand-mark{font-size:30px}.landing-footer>span:last-child{font:8px var(--mono);letter-spacing:1px;color:var(--muted)}.copy-status{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);font:12px var(--mono);background:#223c2b;border-radius:4px;color:var(--green)}.copy-status:not(:empty){padding:10px 18px}
68
69
  @media(max-width:1100px){.landing-privacy,.landing-footer{margin-left:32px;margin-right:32px}.preview-metrics strong{font-size:20px}.preview-body{grid-template-columns:1fr 215px;gap:17px;padding:20px}.preview-topbar{gap:23px}.preview-active{display:none}.preview-metrics{gap:8px}.preview-metrics>div{padding:14px 10px}.preview-metrics span{font-size:7px}}
@@ -12,7 +12,7 @@ Only these provider sources are opened for usage analysis:
12
12
 
13
13
  The CLI also reads its own bundled assets and its own local application database. It checks source-directory/file metadata to detect providers and changes. It does not open credentials, `.env` files, shell history, browser profiles, repositories or project paths mentioned by a session. Symlinks and junctions are rejected. `--dry-run` detects directories without opening session contents.
14
14
 
15
- Session files colocate counters and conversation text. Complete JSONL lines can therefore pass through memory during a scan; only approved metadata is projected into storage. Prompts, responses, attachments and source code are never persisted or displayed.
15
+ Session files colocate counters and conversation text. Their bytes must therefore pass through memory during a scan. A metadata-only JSON extractor decodes whitelisted token counters, model identifiers, timestamps and required deduplication fields. It skips conversation, instruction, attachment and tool-content values without constructing their text strings or objects. Skipped JSON is checked for valid syntax; raw line buffers are discarded after processing. Prompts, responses, attachments and source code are never persisted or displayed.
16
16
 
17
17
  ## Local metadata retained
18
18
 
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "name": "@subvalue/cli", "version": "0.1.0", "description": "Local, read-only Codex and Claude Code API-equivalent usage dashboard",
2
+ "name": "@subvalue/cli", "version": "0.1.1", "description": "Local, read-only Codex and Claude Code API-equivalent usage dashboard",
3
3
  "type": "module", "license": "MIT", "engines": {"node": ">=24.13.0"},
4
4
  "repository": {"type": "git", "url": "git+https://github.com/axelmtn/subvalue.git"},
5
5
  "bin": {"subvalue": "dist/cli.js"}, "files": ["dist/", "README.md", "PRE_RELEASE.md", "PRIVACY.md", "LICENSE"],