@chenmiao8563/dsh-token-ledger 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/CHANGELOG.md +35 -0
- package/LICENSE +21 -0
- package/README.md +225 -0
- package/README.zh.md +208 -0
- package/bin/dsh-token-ledger.mjs +12 -0
- package/cordis.patch.yml +20 -0
- package/docs/VERIFICATION.md +173 -0
- package/lib/cli.js +443 -0
- package/lib/index.js +253 -0
- package/lib/ledger.js +653 -0
- package/lib/session-log.js +142 -0
- package/lib/store.js +101 -0
- package/package.json +75 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline reader for DSH session logs.
|
|
3
|
+
*
|
|
4
|
+
* A DSH session log is a concatenation of independent Zstandard frames, each
|
|
5
|
+
* frame holding a chunk of newline-delimited JSON. Node's zstd entry points
|
|
6
|
+
* stop after the first frame, so the frames must be delimited explicitly
|
|
7
|
+
* before decompression.
|
|
8
|
+
*
|
|
9
|
+
* This module walks the frame structure described by RFC 8878 (magic number,
|
|
10
|
+
* frame header, block headers, optional content checksum) instead of scanning
|
|
11
|
+
* for the magic byte sequence, because a magic scan can match bytes inside
|
|
12
|
+
* compressed block payloads and silently split a frame in half.
|
|
13
|
+
*
|
|
14
|
+
* Every function here is synchronous, dependency-free, and read-only.
|
|
15
|
+
*
|
|
16
|
+
* @module dsh-token-ledger/session-log
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFileSync } from 'node:fs'
|
|
20
|
+
import { zstdDecompressSync } from 'node:zlib'
|
|
21
|
+
|
|
22
|
+
/** Zstandard frame magic number, stored little-endian: 0xFD2FB528. */
|
|
23
|
+
const ZSTD_MAGIC = 0xfd2fb528
|
|
24
|
+
|
|
25
|
+
/** A Zstandard frame can be at most 128 KiB of header plus blocks; this bounds a walk. */
|
|
26
|
+
const MAX_DICTIONARY_ID_BYTES = [0, 1, 2, 4]
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Read the length of the frame starting at `start`.
|
|
30
|
+
*
|
|
31
|
+
* @param {Buffer} buffer - the whole file.
|
|
32
|
+
* @param {number} start - offset of the frame's magic number.
|
|
33
|
+
* @returns {number} the frame length in bytes.
|
|
34
|
+
* @throws {Error} when the frame is truncated or structurally invalid.
|
|
35
|
+
*/
|
|
36
|
+
export function frameLengthAt(buffer, start) {
|
|
37
|
+
if (start + 4 > buffer.length) throw new Error(`truncated frame magic at offset ${start}`)
|
|
38
|
+
if (buffer.readUInt32LE(start) !== ZSTD_MAGIC) {
|
|
39
|
+
throw new Error(`no zstd magic at offset ${start}`)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let cursor = start + 4
|
|
43
|
+
if (cursor + 1 > buffer.length) throw new Error(`truncated frame header at offset ${start}`)
|
|
44
|
+
const descriptor = buffer.readUInt8(cursor)
|
|
45
|
+
cursor += 1
|
|
46
|
+
|
|
47
|
+
const contentSizeFlag = (descriptor >> 6) & 0x3
|
|
48
|
+
const singleSegment = (descriptor >> 5) & 0x1
|
|
49
|
+
const checksumFlag = (descriptor >> 2) & 0x1
|
|
50
|
+
const dictionaryIdFlag = descriptor & 0x3
|
|
51
|
+
|
|
52
|
+
// Window_Descriptor is present only when Single_Segment_flag is clear.
|
|
53
|
+
if (singleSegment === 0) cursor += 1
|
|
54
|
+
cursor += MAX_DICTIONARY_ID_BYTES[dictionaryIdFlag]
|
|
55
|
+
|
|
56
|
+
// 0 bytes when the flag is 0 and a single segment is declared is not allowed;
|
|
57
|
+
// the spec maps flag 0 to 1 byte in that case.
|
|
58
|
+
const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment === 1 ? 1 : 0) : 1 << contentSizeFlag
|
|
59
|
+
cursor += contentSizeBytes
|
|
60
|
+
|
|
61
|
+
if (cursor > buffer.length) throw new Error(`truncated frame header at offset ${start}`)
|
|
62
|
+
|
|
63
|
+
// Walk the block sequence to find where this frame ends.
|
|
64
|
+
for (;;) {
|
|
65
|
+
if (cursor + 3 > buffer.length) throw new Error(`truncated block header at offset ${cursor}`)
|
|
66
|
+
const header = buffer.readUInt8(cursor) | (buffer.readUInt8(cursor + 1) << 8) | (buffer.readUInt8(cursor + 2) << 16)
|
|
67
|
+
const lastBlock = (header & 0x1) === 1
|
|
68
|
+
const blockType = (header >> 1) & 0x3
|
|
69
|
+
const blockSize = header >> 3
|
|
70
|
+
cursor += 3
|
|
71
|
+
if (blockType === 3) throw new Error(`reserved block type at offset ${cursor - 3}`)
|
|
72
|
+
cursor += blockType === 1 ? 1 : blockSize
|
|
73
|
+
if (cursor > buffer.length) throw new Error(`truncated block payload at offset ${start}`)
|
|
74
|
+
if (lastBlock) break
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (checksumFlag === 1) cursor += 4
|
|
78
|
+
if (cursor > buffer.length) throw new Error(`truncated content checksum at offset ${start}`)
|
|
79
|
+
return cursor - start
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Delimit every Zstandard frame in a buffer.
|
|
84
|
+
*
|
|
85
|
+
* @param {Buffer} buffer - a DSH session log's raw bytes.
|
|
86
|
+
* @returns {{ offset: number, length: number }[]} frames in file order.
|
|
87
|
+
*/
|
|
88
|
+
export function splitZstdFrames(buffer) {
|
|
89
|
+
const frames = []
|
|
90
|
+
let offset = 0
|
|
91
|
+
while (offset < buffer.length) {
|
|
92
|
+
const length = frameLengthAt(buffer, offset)
|
|
93
|
+
frames.push({ offset, length })
|
|
94
|
+
offset += length
|
|
95
|
+
}
|
|
96
|
+
return frames
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Decode a buffer of concatenated Zstandard frames into its raw text and then
|
|
101
|
+
* into JSON events.
|
|
102
|
+
*
|
|
103
|
+
* @param {Buffer} buffer - a DSH session log's raw bytes.
|
|
104
|
+
* @returns {object[]} every parseable JSON record, in order. Unparseable lines
|
|
105
|
+
* are skipped rather than failing the whole log.
|
|
106
|
+
*/
|
|
107
|
+
export function decodeSessionLogBuffer(buffer) {
|
|
108
|
+
const parts = []
|
|
109
|
+
for (const frame of splitZstdFrames(buffer)) {
|
|
110
|
+
parts.push(zstdDecompressSync(buffer.subarray(frame.offset, frame.offset + frame.length)))
|
|
111
|
+
}
|
|
112
|
+
return parseJsonLines(Buffer.concat(parts).toString('utf8'))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Split newline-delimited JSON into records, skipping lines that do not parse.
|
|
117
|
+
*
|
|
118
|
+
* @param {string} text - the decompressed log text.
|
|
119
|
+
* @returns {object[]} the parsed records.
|
|
120
|
+
*/
|
|
121
|
+
export function parseJsonLines(text) {
|
|
122
|
+
const events = []
|
|
123
|
+
for (const line of text.split('\n')) {
|
|
124
|
+
if (line.trim() === '') continue
|
|
125
|
+
try {
|
|
126
|
+
events.push(JSON.parse(line))
|
|
127
|
+
} catch {
|
|
128
|
+
// A torn trailing line is normal for a log whose writer was killed.
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return events
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Read and decode one DSH session log file.
|
|
136
|
+
*
|
|
137
|
+
* @param {string} path - path to a `session.jsonl.zstd` file.
|
|
138
|
+
* @returns {object[]} every JSON event in the log, in order.
|
|
139
|
+
*/
|
|
140
|
+
export function readSessionLog(path) {
|
|
141
|
+
return decodeSessionLogBuffer(readFileSync(path))
|
|
142
|
+
}
|
package/lib/store.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ledger persistence: where the ledger lives and how it is written.
|
|
3
|
+
*
|
|
4
|
+
* Both the host plugin and the CLI use these helpers, so the file the plugin
|
|
5
|
+
* writes is exactly the file the CLI audits. Writes are atomic (temporary file
|
|
6
|
+
* plus rename) because a half-written ledger would silently lose history.
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-token-ledger/store
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
12
|
+
import { homedir } from 'node:os'
|
|
13
|
+
import { dirname, join, resolve } from 'node:path'
|
|
14
|
+
|
|
15
|
+
/** Directory name created inside the DSH home for this plugin's state. */
|
|
16
|
+
export const STATE_DIRNAME = 'token-ledger'
|
|
17
|
+
|
|
18
|
+
/** The ledger file's name inside {@link STATE_DIRNAME}. */
|
|
19
|
+
export const LEDGER_FILENAME = 'ledger.json'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resolve the DSH home directory the way the host process does.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} [explicit] - an explicit home, e.g. a CLI `--home` flag.
|
|
25
|
+
* @param {NodeJS.ProcessEnv} [env] - environment to consult.
|
|
26
|
+
* @returns {string} an absolute path to the DSH home.
|
|
27
|
+
*/
|
|
28
|
+
export function resolveHome(explicit, env = process.env) {
|
|
29
|
+
if (typeof explicit === 'string' && explicit.trim() !== '') return resolve(explicit)
|
|
30
|
+
if (typeof env.DSH_HOME === 'string' && env.DSH_HOME.trim() !== '') return resolve(env.DSH_HOME)
|
|
31
|
+
return join(homedir(), '.dsh')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The paths this plugin reads and writes inside a DSH home.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} [home] - an explicit DSH home.
|
|
38
|
+
* @param {NodeJS.ProcessEnv} [env] - environment to consult.
|
|
39
|
+
* @returns {{ home: string, dir: string, ledger: string, exportsDir: string, sessionsDir: string }} the layout.
|
|
40
|
+
*/
|
|
41
|
+
export function ledgerPaths(home, env = process.env) {
|
|
42
|
+
const resolved = resolveHome(home, env)
|
|
43
|
+
const dir = join(resolved, STATE_DIRNAME)
|
|
44
|
+
return {
|
|
45
|
+
home: resolved,
|
|
46
|
+
dir,
|
|
47
|
+
ledger: join(dir, LEDGER_FILENAME),
|
|
48
|
+
exportsDir: join(dir, 'exports'),
|
|
49
|
+
sessionsDir: join(resolved, 'sessions'),
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Read a stored ledger.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} path - the ledger file path.
|
|
57
|
+
* @returns {object|undefined} the snapshot, or `undefined` when absent or
|
|
58
|
+
* unreadable. A corrupt ledger is treated as "start fresh" rather than fatal.
|
|
59
|
+
*/
|
|
60
|
+
export function loadLedger(path) {
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(readFileSync(path, 'utf8'))
|
|
63
|
+
} catch {
|
|
64
|
+
return undefined
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Write a file atomically, creating parent directories as needed.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} path - the destination path.
|
|
72
|
+
* @param {string} text - the content to write.
|
|
73
|
+
* @returns {string} the destination path.
|
|
74
|
+
*/
|
|
75
|
+
export function writeFileAtomic(path, text) {
|
|
76
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
77
|
+
const temporary = `${path}.tmp-${process.pid}`
|
|
78
|
+
writeFileSync(temporary, text, 'utf8')
|
|
79
|
+
try {
|
|
80
|
+
renameSync(temporary, path)
|
|
81
|
+
} catch (error) {
|
|
82
|
+
try {
|
|
83
|
+
unlinkSync(temporary)
|
|
84
|
+
} catch {
|
|
85
|
+
// The rename failure matters more than the cleanup failure.
|
|
86
|
+
}
|
|
87
|
+
throw error
|
|
88
|
+
}
|
|
89
|
+
return path
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Persist a ledger snapshot atomically.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} path - the ledger file path.
|
|
96
|
+
* @param {object} snapshot - a value from `UsageLedger#snapshot`.
|
|
97
|
+
* @returns {string} the destination path.
|
|
98
|
+
*/
|
|
99
|
+
export function saveLedger(path, snapshot) {
|
|
100
|
+
return writeFileAtomic(path, `${JSON.stringify(snapshot, null, 2)}\n`)
|
|
101
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@chenmiao8563/dsh-token-ledger",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Transparent, auditable token accounting for DeepSeek Harness: a restart-safe ledger over the durable session log, plus a CLI that recomputes it from raw logs and diffs the result.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"dsh",
|
|
7
|
+
"dsh-plugin",
|
|
8
|
+
"deepseek",
|
|
9
|
+
"deepseek-harness",
|
|
10
|
+
"cordis",
|
|
11
|
+
"token-usage",
|
|
12
|
+
"token-accounting",
|
|
13
|
+
"usage-stats",
|
|
14
|
+
"cost-tracking",
|
|
15
|
+
"ledger",
|
|
16
|
+
"audit",
|
|
17
|
+
"llm-observability"
|
|
18
|
+
],
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"author": "chenmiao8563 <1420647373@qq.com>",
|
|
21
|
+
"type": "module",
|
|
22
|
+
"main": "lib/index.js",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": "./lib/index.js",
|
|
25
|
+
"./cli": "./lib/cli.js",
|
|
26
|
+
"./ledger": "./lib/ledger.js",
|
|
27
|
+
"./session-log": "./lib/session-log.js",
|
|
28
|
+
"./store": "./lib/store.js",
|
|
29
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
30
|
+
"./package.json": "./package.json"
|
|
31
|
+
},
|
|
32
|
+
"bin": {
|
|
33
|
+
"dsh-token-ledger": "bin/dsh-token-ledger.mjs"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"lib/",
|
|
37
|
+
"bin/",
|
|
38
|
+
"docs/",
|
|
39
|
+
"cordis.patch.yml",
|
|
40
|
+
"README.md",
|
|
41
|
+
"README.zh.md",
|
|
42
|
+
"CHANGELOG.md",
|
|
43
|
+
"LICENSE"
|
|
44
|
+
],
|
|
45
|
+
"sideEffects": false,
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=22.15.0"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"test": "node --test test/*.test.mjs",
|
|
51
|
+
"test:single-process": "node --test --experimental-test-isolation=none test/*.test.mjs",
|
|
52
|
+
"verify": "node scripts/verify-package.mjs",
|
|
53
|
+
"prepublishOnly": "npm run verify && npm test"
|
|
54
|
+
},
|
|
55
|
+
"dsh": {
|
|
56
|
+
"pluginType": "feature",
|
|
57
|
+
"bundle": {
|
|
58
|
+
"patch": "./cordis.patch.yml"
|
|
59
|
+
},
|
|
60
|
+
"compatibility": {
|
|
61
|
+
"dsh": ">=0.1.2-alpha.1"
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
"repository": {
|
|
65
|
+
"type": "git",
|
|
66
|
+
"url": "git+https://github.com/chenmiao8563/dsh-token-ledger.git"
|
|
67
|
+
},
|
|
68
|
+
"bugs": {
|
|
69
|
+
"url": "https://github.com/chenmiao8563/dsh-token-ledger/issues"
|
|
70
|
+
},
|
|
71
|
+
"homepage": "https://github.com/chenmiao8563/dsh-token-ledger#readme",
|
|
72
|
+
"publishConfig": {
|
|
73
|
+
"access": "public"
|
|
74
|
+
}
|
|
75
|
+
}
|