dsh-vorssaint 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rúben Garrido
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,150 @@
1
- # Temporary Holding Version
1
+ # dsh-vorssaint
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm](https://img.shields.io/npm/v/dsh-vorssaint)](https://www.npmjs.com/package/dsh-vorssaint)
4
+ [![ci](https://github.com/RGarrido03/dsh-vorssaint/actions/workflows/ci.yml/badge.svg)](https://github.com/RGarrido03/dsh-vorssaint/actions/workflows/ci.yml)
5
+
6
+ Report [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)
7
+ usage to [Vorssaint](https://vorssaint.com): token spend, live runs, models and
8
+ projects, and — with your model prices — the API value that feeds the daily
9
+ budget.
10
+
11
+ Vorssaint already follows Claude, Codex, OpenCode and GitHub Copilot by reading
12
+ their local session logs. DSH keeps its logs compressed and rotates them, so
13
+ this plugin writes the small, stable JSONL feed that Vorssaint's DSH reader
14
+ tails instead:
15
+
16
+ ```
17
+ ~/.vorssaint/agents/dsh/<session-id>.jsonl
18
+ ```
19
+
20
+ The plugin starts reporting from the moment it loads; usage from recent
21
+ sessions is imported from DSH's own logs on first run, so the current day and
22
+ month are not empty after installing it.
23
+
24
+ ## Requirements
25
+
26
+ - DeepSeek Harness `0.2.2-rc.1` or newer in the `0.2.x` line.
27
+ - A Vorssaint build with DeepSeek Harness support (the `dsh` provider).
28
+ - Node.js `22.15` or newer for backfill; without it the live feed still works.
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ dsh plugin --profile web add dsh-vorssaint
34
+ ```
35
+
36
+ Use `--profile desktop` when your app runs the desktop profile. From a local
37
+ checkout, before the npm release:
38
+
39
+ ```bash
40
+ dsh plugin --profile web add link:$PWD
41
+ ```
42
+
43
+ Enable the bundle if the Plugin Manager did not (add `"dsh-vorssaint"` to
44
+ `dsh.profile.bundles` in `~/.dsh/profiles/<profile>/package.json`), then
45
+ **restart DSH**. The installer marks a new bundle as requiring a restart because
46
+ the running host cannot pick up a plugin that is not in its live list.
47
+
48
+ Finally, open Vorssaint → Settings → the AI page and switch **DeepSeek Harness**
49
+ on. It is enabled by default like the other agents once the feature is
50
+ installed.
51
+
52
+ ## Prices and the daily budget
53
+
54
+ DSH reports tokens per response, not a price, and a gateway can report any
55
+ model id it likes. The plugin therefore ships no prices: configure the rates
56
+ you are actually billed, in USD per million tokens, keyed by the exact model id
57
+ Vorssaint will show (the id DSH reports in `assistant/message`). Add a `*`
58
+ entry as the fallback for ids you did not list.
59
+
60
+ Add this to `~/.dsh/profiles/<profile>/cordis.patch.yml` (the file's existing
61
+ entries stay):
62
+
63
+ ```yaml
64
+ - id: vorssaint
65
+ config:
66
+ pricing:
67
+ "deepseek/deepseek-v4.1-flash":
68
+ input: 0.28
69
+ output: 0.42
70
+ cacheRead: 0.028
71
+ cacheWrite: 0.28
72
+ "*":
73
+ input: 1
74
+ output: 2
75
+ ```
76
+
77
+ `cacheRead` and `cacheWrite` default to the input rate. Reasoning tokens are
78
+ already part of output and are not billed twice. Without a price the feed still
79
+ carries tokens, model, project and runs; only API value and the daily budget
80
+ stay empty for those responses.
81
+
82
+ Change the values to the rates on your provider's pricing page — the numbers
83
+ above are a shape example, not a quote.
84
+
85
+ ## How it works
86
+
87
+ 1. The plugin listens to DSH's committed `session/event` stream.
88
+ 2. Every response that reports usage becomes one `usage` line with input,
89
+ cache-write, cache-read, output and reasoning tokens, the model id and the
90
+ provider route, plus `cost` when the model has a rate.
91
+ 3. Turn boundaries become `turn` lines, which Vorssaint shows as a live run.
92
+ 4. One file per session, append-only and serialized; the writer skips sequences
93
+ already on disk, so restarts and re-reads never double count. Vorssaint also
94
+ dedupes by the response id.
95
+ 5. On start, before buffered live events are written, the plugin imports the
96
+ usage already in DSH's session logs from the last `backfillDays` days. A
97
+ session whose feed is at least as new as its log is skipped, so a normal
98
+ restart costs nothing. A fork's inherited prefix is not billed again.
99
+
100
+ The exact line schema, tolerances and golden lines are in
101
+ [`docs/FEED.md`](docs/FEED.md).
102
+
103
+ ## Configuration
104
+
105
+ | field | default | meaning |
106
+ | --- | --- | --- |
107
+ | `feedDir` | `~/.vorssaint/agents/dsh` | Where the feed is written. Vorssaint only reads the default path; change it only together with the app side. |
108
+ | `pricing` | `{}` | USD per million tokens by exact model id; `*` is the fallback. |
109
+ | `backfill` | `true` | Import usage already in recent DSH session logs on start. |
110
+ | `backfillDays` | `30` | How far back to import. `0` disables the scan. |
111
+ | `sessionsDir` | `$DSH_HOME/sessions`, else `~/.dsh/sessions` | DSH's session-log directory, for backfill. |
112
+
113
+ Configuration is validated by the loader; invalid rates are dropped rather than
114
+ guessed. The feed directory and files are created with `0700`/`0600`.
115
+
116
+ ## Privacy
117
+
118
+ Only the fields in [`docs/FEED.md`](docs/FEED.md) are written: session id,
119
+ working directory, timestamps, turn numbers, model/provider ids, token counts
120
+ and the optional cost. No prompt text, tool arguments, model output, file
121
+ contents or credentials ever enter the feed. Writes are fire-and-forget: a full
122
+ disk or a closed permissions dialog is logged once and never blocks or fails an
123
+ agent turn.
124
+
125
+ ## Troubleshooting
126
+
127
+ - **No DeepSeek Harness entry in Vorssaint.** Update Vorssaint to a build with
128
+ the DSH provider, then enable it on the AI settings page.
129
+ - **Plugin not loaded.** `dsh --profile web --dump-config | grep vorssaint`
130
+ should show the `vorssaint` row. If it does not, enable the bundle and restart
131
+ DSH.
132
+ - **No data.** Check that `~/.vorssaint/agents/dsh` exists after a DSH turn. A
133
+ turn with no model response (for example, cancelled before the first step)
134
+ writes no usage line.
135
+ - **Tokens but no budget.** Configure `pricing` (above).
136
+
137
+ ## Development
138
+
139
+ ```bash
140
+ pnpm install
141
+ pnpm check # typecheck, build, unit tests
142
+ ```
143
+
144
+ The tests cover the line mapping, cost rules, backfill parsing (including fork
145
+ cuts and subagents), the append-only writer's restart behavior, and the package
146
+ metadata the loader reads.
147
+
148
+ ## License
149
+
150
+ MIT © Rúben Garrido
@@ -0,0 +1,17 @@
1
+ # dsh-vorssaint bundle layer.
2
+ # One plugin row: turns the DSH session event stream into the JSONL usage feed
3
+ # Vorssaint reads at ~/.vorssaint/agents/dsh.
4
+ - insert:
5
+ - id: vorssaint
6
+ name: "dsh-vorssaint"
7
+ config:
8
+ # Vorssaint only reads the default feed directory; override it only
9
+ # together with the app side. Prices are USD per million tokens, keyed
10
+ # by exact DSH model id (`*` is the fallback):
11
+ #
12
+ # pricing:
13
+ # "deepseek/deepseek-v4.1-flash": { input: 0.28, output: 0.42 }
14
+ #
15
+ # Without a price the feed still reports tokens, model and runs; only
16
+ # the API-value and daily-budget figures stay empty.
17
+ backfill: true
package/docs/FEED.md ADDED
@@ -0,0 +1,148 @@
1
+ # DSH usage feed — contract v1
2
+
3
+ The `dsh-vorssaint` DSH plugin writes one JSONL file per DeepSeek Harness
4
+ session. Vorssaint's `DSHReader` tails those files to report usage, API value
5
+ and live runs for the DeepSeek Harness provider.
6
+
7
+ The plugin is the only writer; Vorssaint is the only reader. The contract is
8
+ versioned with the `v` field on every line and only changes with a new major
9
+ version of the plugin.
10
+
11
+ ## Location
12
+
13
+ ```
14
+ ~/.vorssaint/agents/dsh/<session-id>.jsonl
15
+ ```
16
+
17
+ * The directory is created on demand, mode `0700`; files are mode `0600`.
18
+ Existing paths are tightened to the same modes on first use.
19
+ * `<session-id>` is the DSH session id, with every character outside
20
+ `[A-Za-z0-9._-]` replaced by `_`.
21
+ * Files are UTF-8, one JSON object per line, `\n` terminated. A torn final
22
+ line (no trailing newline) is ignored by the reader and removed by the
23
+ writer the next time it opens the file.
24
+ * The directory can be overridden in the plugin config (`feedDir`), but
25
+ Vorssaint only reads this default path.
26
+
27
+ ## Lines
28
+
29
+ Every line is an object with `v` (always `1`) and `type`. Times are epoch
30
+ **milliseconds**. Unknown fields and unknown line types are ignored by the
31
+ reader. The writer adds `seq` (the DSH event sequence) to every line it can;
32
+ it uses it to skip replayed events and Vorssaint ignores it.
33
+
34
+ ### `session`
35
+
36
+ Written first in a file. A restart appends to the existing file instead of
37
+ rewriting it, so the header normally appears once.
38
+
39
+ ```json
40
+ {"v":1,"type":"session","session":"session-7fe88e4c-4773-4582-ab38-d5b56f942811","ts":1793850692328,"cwd":"/Users/ruben/dsh-plugins","preset":"standard"}
41
+ ```
42
+
43
+ | field | type | meaning |
44
+ |---|---|---|
45
+ | `session` | string | DSH session id |
46
+ | `ts` | number | `SessionHeader.createdAt` |
47
+ | `cwd` | string, optional | session working directory; the reader derives the project name from its last path component |
48
+ | `parent` | string, optional | the session this one was created from, informational. DSH also records it for ordinary forks, so it does **not** mean "subagent". |
49
+ | `subagent` | `true`, optional | present only when DSH's `SessionHeader.origin` is `subagent`; the reader then counts usage but shows no live run |
50
+ | `preset` | string, optional | DSH agent preset at creation |
51
+
52
+ ### `turn`
53
+
54
+ ```json
55
+ {"v":1,"type":"turn","ts":1793850830395,"turn":1,"phase":"start","seq":5}
56
+ {"v":1,"type":"turn","ts":1793850866520,"turn":1,"phase":"end","completed":true,"seq":12}
57
+ ```
58
+
59
+ | field | type | meaning |
60
+ |---|---|---|
61
+ | `ts` | number | DSH event time |
62
+ | `turn` | integer | DSH turn number |
63
+ | `phase` | `"start"` or `"end"` | |
64
+ | `completed` | boolean, on `end` | `true` when the turn ended normally: reason `completed` or `max-tokens`; `false` for `aborted`, `interrupted`, `blocked`, `error`, `forked` |
65
+ | `seq` | integer, optional | the DSH event sequence, present on every turn line the writer emits |
66
+
67
+ Turn lines in a subagent session (`subagent: true`) are ignored by the reader:
68
+ the subagent's work is part of the run it serves. Its usage is still counted,
69
+ but as its own session — it is not folded into the parent's live run, so a
70
+ subagent working while the parent waits does not grow the parent's run. A
71
+ session that merely has a `parent` (an ordinary fork) keeps its turn lines.
72
+
73
+ ### `usage`
74
+
75
+ One line per assembled model response that reported usage. This is the billing
76
+ record.
77
+
78
+ ```json
79
+ {"v":1,"type":"usage","ts":1793850842900,"turn":1,"step":2,"seq":20,"request":"eb7abe7f-9bfe-4df1-b072-4c365a8112a5","model":"deepseek/deepseek-v4.1-flash","provider":"commandcode","tokens":{"input":4819,"cacheWrite":0,"cacheRead":77568,"output":339,"reasoning":78},"cost":0.0004}
80
+ ```
81
+
82
+ | field | type | meaning |
83
+ |---|---|---|
84
+ | `ts` | number | DSH event time |
85
+ | `turn`, `step` | integer | position in the session (informational) |
86
+ | `seq` | integer, optional | DSH event sequence; used by the writer, ignored by the reader |
87
+ | `request` | string, optional | assistant message id; it forms the reader's dedupe key (`dsh:` + id) |
88
+ | `model` | string | model id exactly as the provider route reported it |
89
+ | `provider` | string, optional | DSH provider route (informational) |
90
+ | `tokens.input` | integer | prompt tokens that missed the cache |
91
+ | `tokens.cacheWrite` | integer | prompt tokens written to the cache |
92
+ | `tokens.cacheRead` | integer | prompt tokens served from the cache |
93
+ | `tokens.output` | integer | completion tokens; includes reasoning tokens |
94
+ | `tokens.reasoning` | integer | reasoning tokens included in `output` |
95
+ | `cost` | number, optional | API list value of this response in US dollars, when the plugin has a price for the model; omitted when unknown |
96
+
97
+ ## Reader semantics (Vorssaint `DSHReader`)
98
+
99
+ * `session` → adopt `session`, compute `project` with the existing
100
+ `AgentLogParser.projectName(cwd)`, remember `subagent: true` (never infer it
101
+ from `parent`).
102
+ * `turn` `start` (root session) → `.turnBegan(ts)`.
103
+ * `turn` `end` (root session) → `.turnEnded(ts, completed: completed, duration: nil)`.
104
+ * `usage` → `.usage(key: "dsh:" + (request, or "<session>:<turn>:<step>:<unix-seconds>"
105
+ when the line has no request id),
106
+ record: AgentUsageRecord(provider: .dsh, date: ts, model: model,
107
+ project: state.project, session: state.session, tokens: tokens,
108
+ cost: cost, savings: 0, reportedCost: cost != nil), billable: AgentBillable(tokens: tokens))`.
109
+ * Missing `tokens` fields default to 0; negative or non-finite values are
110
+ clamped to 0. A usage line with an empty `model` is ignored.
111
+ * The reader tolerates a file that starts mid-session: absent metadata leaves
112
+ the session/project empty and only usage is counted.
113
+ * Vorssaint's store dedupes `usage` records by `key`, so the same response
114
+ written twice never double counts. `turn` lines have no key: a re-read can
115
+ replay a turn boundary, which the store's idempotence rules absorb.
116
+ * `DSHReader.readsFullHistoryFirst` is `false`.
117
+
118
+ ## Golden lines
119
+
120
+ ```json
121
+ {"v":1,"type":"session","session":"s-dsh","ts":1790088000000,"cwd":"/Users/me/code/app","preset":"standard"}
122
+ {"v":1,"type":"turn","ts":1790088000000,"turn":1,"phase":"start","seq":0}
123
+ {"v":1,"type":"usage","ts":1790088010000,"turn":1,"step":1,"request":"m1","model":"deepseek/deepseek-v4.1-flash","provider":"commandcode","tokens":{"input":100,"cacheWrite":20,"cacheRead":300,"output":40,"reasoning":10},"cost":0.001}
124
+ {"v":1,"type":"turn","ts":1790088020000,"turn":1,"phase":"end","completed":true,"seq":3}
125
+ ```
126
+
127
+ ```json
128
+ {"v":1,"type":"session","session":"s-sub","ts":1790088000000,"cwd":"/Users/me/code/app","parent":"s-dsh","subagent":true}
129
+ {"v":1,"type":"turn","ts":1790088000000,"turn":1,"phase":"start","seq":0}
130
+ {"v":1,"type":"usage","ts":1790088010000,"turn":1,"step":1,"request":"m2","model":"deepseek/deepseek-v4.1-flash","tokens":{"input":10,"output":5}}
131
+ ```
132
+
133
+ The subagent file's usage is counted; its turn lines produce no live runs.
134
+
135
+ ## Writer-side behavior worth knowing
136
+
137
+ * **Backfill.** On start the plugin reads `session*.jsonl.zstd` under the DSH
138
+ sessions directory for the last `backfillDays` days (default 30) and writes
139
+ the usage/turn lines already present. For a seeded (forked) session it skips
140
+ every event up to and including the `session/end-seed` marker whose
141
+ `inherited` is `true`; pre-v1 logs, which have no such marker, fall back to
142
+ the header's `seedLength`. Forked history is therefore not billed twice.
143
+ * **Live.** Lines are appended as the session commits them. Writes are queued
144
+ per file, so order inside a file is preserved.
145
+ * **Restarts.** Existing files are appended to, not rewritten; a missing file
146
+ for a recent session is rebuilt from that session's log.
147
+ * **Privacy.** Only the fields above are written. No prompt text, tool
148
+ arguments, model output or file paths beyond `cwd` ever enter the feed.
package/icon.svg ADDED
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
2
+ <rect x="2" y="2" width="60" height="60" rx="14" fill="#F4F7FF"/>
3
+ <g transform="translate(8 8) scale(2)" fill="#4D6BFE" fill-rule="evenodd"><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z"></path></g>
4
+ </svg>
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Import the usage already in DSH's own session logs, so a fresh install
3
+ * reports the current day (or month) instead of starting from zero.
4
+ *
5
+ * DSH stores one zstd-compressed JSONL file per session below `sessions/`.
6
+ * The plugin reads the recent ones once at start, maps the same events the
7
+ * live listener sees, and hands them to the feed writer. A log's inherited
8
+ * fork prefix ends at its `session/end-seed` marker with `inherited: true`;
9
+ * counting those events again would bill the parent's work twice, so they
10
+ * are skipped.
11
+ *
12
+ * Decompression is optional: Node gained `zstdDecompressSync` in 22.15, and
13
+ * a host that predates it simply gets no backfill.
14
+ *
15
+ * @module dsh-vorssaint/backfill
16
+ */
17
+ import { readdir, readFile, stat } from 'node:fs/promises';
18
+ import { basename, dirname, join } from 'node:path';
19
+ import * as zlib from 'node:zlib';
20
+ import { feedEntriesForEvent, feedFileName } from './feed.js';
21
+ /** DSH session logs, whatever the format generation in their name. */
22
+ const SESSION_LOG = /^session.*\.jsonl\.zstd$/;
23
+ /** A session log larger than this is skipped rather than held in memory. */
24
+ const MAX_LOG_BYTES = 64 * 1024 * 1024;
25
+ const INTERESTING = ['"turn/start"', '"turn/end"', '"assistant/message"'];
26
+ const END_SEED = '"session/end-seed"';
27
+ const zstdDecompressSync = zlib.zstdDecompressSync;
28
+ /**
29
+ * Session logs under `sessionsDir` touched since `sinceMs`, newest first.
30
+ * @param sessionsDir - DSH's sessions directory.
31
+ * @param sinceMs - only files modified at or after now minus this.
32
+ * @returns absolute log paths; empty when the directory is missing.
33
+ */
34
+ export async function discoverSessionLogs(sessionsDir, sinceMs) {
35
+ let entries;
36
+ try {
37
+ entries = await readdir(sessionsDir, { recursive: true, withFileTypes: true });
38
+ }
39
+ catch {
40
+ return [];
41
+ }
42
+ const cutoff = Date.now() - sinceMs;
43
+ const files = [];
44
+ for (const entry of entries) {
45
+ if (!entry.isFile() || !SESSION_LOG.test(entry.name))
46
+ continue;
47
+ const path = join(entry.parentPath, entry.name);
48
+ try {
49
+ const info = await stat(path);
50
+ if (info.size === 0 || info.size > MAX_LOG_BYTES || info.mtimeMs < cutoff)
51
+ continue;
52
+ files.push({ path, modified: info.mtimeMs });
53
+ }
54
+ catch {
55
+ // A log removed between the listing and the stat is simply gone.
56
+ }
57
+ }
58
+ files.sort((left, right) => right.modified - left.modified);
59
+ return files.map((file) => file.path);
60
+ }
61
+ /**
62
+ * Every recent session's feed entries, one session at a time.
63
+ * @param sessionsDir - DSH's sessions directory.
64
+ * @param options - window, rates, and the feed directory used to skip
65
+ * sessions already captured live.
66
+ */
67
+ export async function* backfillSessions(sessionsDir, options) {
68
+ const decompress = options.decompress ?? zstdDecompressSync;
69
+ if (decompress === undefined)
70
+ return;
71
+ for (const path of await discoverSessionLogs(sessionsDir, options.sinceMs)) {
72
+ try {
73
+ if (options.feedDir !== undefined && (await feedCoversLog(path, options.feedDir)))
74
+ continue;
75
+ const text = decompress(await readFile(path)).toString('utf8');
76
+ const parsed = parseSessionLog(text, options.pricing);
77
+ if (parsed !== undefined && parsed.entries.length > 0)
78
+ yield parsed;
79
+ }
80
+ catch {
81
+ // A log being written, or one in a format this reader does not know,
82
+ // is not worth failing the plugin over: live usage still gets through.
83
+ }
84
+ }
85
+ }
86
+ /**
87
+ * Map one session log's text to its identity and feed entries.
88
+ * @param text - decompressed JSONL.
89
+ * @param pricing - configured model rates.
90
+ * @returns the session, or undefined when the log does not start with a
91
+ * session header this reader understands.
92
+ */
93
+ export function parseSessionLog(text, pricing) {
94
+ const rows = text.split('\n');
95
+ const header = firstHeader(rows);
96
+ if (header === undefined)
97
+ return undefined;
98
+ const identity = {
99
+ session: header.id,
100
+ ts: header.createdAt,
101
+ ...(header.cwd === undefined ? {} : { cwd: header.cwd }),
102
+ ...(header.parentSession === undefined ? {} : { parent: header.parentSession }),
103
+ ...(header.origin === 'subagent' ? { subagent: true } : {}),
104
+ ...(header.agentPreset === undefined ? {} : { preset: header.agentPreset }),
105
+ };
106
+ // Older formats (v0) predate the tagged fork marker and only carry the
107
+ // inherited prefix length in the header. Without this cut, a fork written
108
+ // by an old DSH would bill its parent's history again.
109
+ const inherited = Math.max(inheritedCut(rows), seedCut(header));
110
+ const entries = [];
111
+ let lastSeq = inherited;
112
+ for (const row of rows) {
113
+ if (!INTERESTING.some((needle) => row.includes(needle)))
114
+ continue;
115
+ const event = eventOf(row);
116
+ if (event === undefined || event.seq <= lastSeq)
117
+ continue;
118
+ lastSeq = event.seq;
119
+ entries.push(...feedEntriesForEvent(event, pricing));
120
+ }
121
+ return { identity, entries };
122
+ }
123
+ /**
124
+ * Whether a session's feed was written at or after its log's last change.
125
+ * When it was, live capture already holds the latest event and decompressing
126
+ * the whole log again would only replay it.
127
+ */
128
+ async function feedCoversLog(logPath, feedDir) {
129
+ try {
130
+ const [feed, log] = await Promise.all([
131
+ stat(join(feedDir, feedFileName(basename(dirname(logPath))))),
132
+ stat(logPath),
133
+ ]);
134
+ // A log flushed after its feed append is re-read rather than skipped;
135
+ // the writer's sequence filter makes that cheap for the events it holds.
136
+ return feed.mtimeMs >= log.mtimeMs;
137
+ }
138
+ catch {
139
+ return false;
140
+ }
141
+ }
142
+ function firstHeader(rows) {
143
+ const first = rows.find((row) => row.trim() !== '');
144
+ if (first === undefined)
145
+ return undefined;
146
+ const parsed = json(first);
147
+ if (parsed === undefined || parsed.type !== 'session')
148
+ return undefined;
149
+ const id = parsed.id;
150
+ const createdAt = parsed.createdAt;
151
+ if (typeof id !== 'string' || id === '' || typeof createdAt !== 'number' || !Number.isFinite(createdAt)) {
152
+ return undefined;
153
+ }
154
+ return {
155
+ id,
156
+ createdAt,
157
+ ...(typeof parsed.cwd === 'string' && parsed.cwd !== '' ? { cwd: parsed.cwd } : {}),
158
+ ...(typeof parsed.parentSession === 'string' && parsed.parentSession !== ''
159
+ ? { parentSession: parsed.parentSession }
160
+ : {}),
161
+ ...(typeof parsed.origin === 'string' && parsed.origin !== '' ? { origin: parsed.origin } : {}),
162
+ ...(typeof parsed.agentPreset === 'string' && parsed.agentPreset !== ''
163
+ ? { agentPreset: parsed.agentPreset }
164
+ : {}),
165
+ ...(typeof parsed.seedLength === 'number' && Number.isSafeInteger(parsed.seedLength) && parsed.seedLength > 0
166
+ ? { seedLength: parsed.seedLength }
167
+ : {}),
168
+ };
169
+ }
170
+ /**
171
+ * The last inherited sequence of a pre-v1 seeded log, which wrote the prefix
172
+ * length (`seedLength`) instead of an end-seed marker. The prefix occupies
173
+ * sequences 0..seedLength-1.
174
+ */
175
+ function seedCut(header) {
176
+ return header.seedLength === undefined ? -1 : header.seedLength - 1;
177
+ }
178
+ /** The sequence through which a log is a copy of another session's history. */
179
+ function inheritedCut(rows) {
180
+ let cut = -1;
181
+ for (const row of rows) {
182
+ if (!row.includes(END_SEED))
183
+ continue;
184
+ const parsed = json(row);
185
+ if (parsed === undefined || parsed.type !== 'session/end-seed')
186
+ continue;
187
+ const data = record(parsed.data);
188
+ if (data?.inherited !== true)
189
+ continue;
190
+ const seq = parsed.seq;
191
+ if (typeof seq === 'number' && Number.isSafeInteger(seq) && seq > cut)
192
+ cut = seq;
193
+ }
194
+ return cut;
195
+ }
196
+ function eventOf(row) {
197
+ const parsed = json(row);
198
+ if (parsed === undefined)
199
+ return undefined;
200
+ const type = parsed.type;
201
+ const seq = parsed.seq;
202
+ const time = parsed.time;
203
+ if (typeof type !== 'string' || typeof seq !== 'number' || typeof time !== 'number')
204
+ return undefined;
205
+ if (!Number.isSafeInteger(seq) || seq < 0 || !Number.isFinite(time))
206
+ return undefined;
207
+ return { type, seq, time, data: parsed.data };
208
+ }
209
+ function json(row) {
210
+ try {
211
+ const parsed = JSON.parse(row);
212
+ return record(parsed);
213
+ }
214
+ catch {
215
+ return undefined;
216
+ }
217
+ }
218
+ function record(value) {
219
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
220
+ ? value
221
+ : undefined;
222
+ }
package/lib/feed.js ADDED
@@ -0,0 +1,329 @@
1
+ /**
2
+ * The Vorssaint usage feed: line builders and an append-only writer.
3
+ *
4
+ * One file per DSH session under the feed directory. The contract lives in
5
+ * `docs/FEED.md`; this module is the only writer of it.
6
+ *
7
+ * @module dsh-vorssaint/feed
8
+ */
9
+ import { appendFile, chmod, mkdir, open, stat, truncate, writeFile } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+ import { costOf, resolveRate } from './pricing.js';
12
+ /** Contract version written on every line. */
13
+ export const FEED_VERSION = 1;
14
+ /**
15
+ * The feed identity of a live session.
16
+ * @param session - the DSH session.
17
+ * @returns its id and creation metadata.
18
+ */
19
+ export function identityOf(session) {
20
+ const header = session.header;
21
+ return {
22
+ session: String(header.id),
23
+ ts: header.createdAt,
24
+ ...(header.cwd === undefined ? {} : { cwd: header.cwd }),
25
+ ...(header.parentSession === undefined ? {} : { parent: header.parentSession }),
26
+ ...(header.origin === 'subagent' ? { subagent: true } : {}),
27
+ ...(header.agentPreset === undefined ? {} : { preset: header.agentPreset }),
28
+ };
29
+ }
30
+ /**
31
+ * The `session` line that opens a feed file.
32
+ * @param identity - the session it describes.
33
+ * @returns serialized line.
34
+ */
35
+ export function sessionLine(identity) {
36
+ const line = {
37
+ v: FEED_VERSION,
38
+ type: 'session',
39
+ session: identity.session,
40
+ ts: identity.ts,
41
+ };
42
+ if (identity.cwd !== undefined && identity.cwd !== '')
43
+ line.cwd = identity.cwd;
44
+ if (typeof identity.parent === 'string' && identity.parent !== '')
45
+ line.parent = identity.parent;
46
+ if (identity.subagent === true)
47
+ line.subagent = true;
48
+ if (identity.preset !== undefined && identity.preset !== '')
49
+ line.preset = identity.preset;
50
+ return JSON.stringify(line);
51
+ }
52
+ /**
53
+ * The feed lines one committed session event produces.
54
+ * @param event - a committed DSH session event.
55
+ * @param pricing - configured model rates.
56
+ * @returns zero or more entries; empty for events Vorssaint does not read.
57
+ */
58
+ export function feedEntriesForEvent(event, pricing) {
59
+ if (!Number.isSafeInteger(event.seq) || event.seq < 0 || !Number.isFinite(event.time))
60
+ return [];
61
+ switch (event.type) {
62
+ case 'turn/start': {
63
+ const turn = integer(record(event.data)?.turn);
64
+ if (turn === undefined)
65
+ return [];
66
+ return [{ seq: event.seq, line: turnLine(event.time, turn, 'start', event.seq) }];
67
+ }
68
+ case 'turn/end': {
69
+ const turn = integer(record(event.data)?.turn);
70
+ if (turn === undefined)
71
+ return [];
72
+ const reason = record(record(event.data)?.reason);
73
+ const kind = typeof reason?.kind === 'string' ? reason.kind : '';
74
+ // A max-tokens stop is how the model ends a turn normally when it runs
75
+ // out of room; only aborts, errors and interruptions are unfinished.
76
+ const completed = kind === 'completed' || kind === 'max-tokens';
77
+ return [{ seq: event.seq, line: turnLine(event.time, turn, 'end', event.seq, completed) }];
78
+ }
79
+ case 'assistant/message': {
80
+ const data = record(event.data);
81
+ if (data === undefined)
82
+ return [];
83
+ const usage = record(data.usage);
84
+ const message = record(data.message);
85
+ const source = record(message?.source);
86
+ const model = typeof source?.model === 'string' ? source.model : '';
87
+ // With no usage the provider billed nothing reportable; with no model
88
+ // the record cannot be attributed, and Vorssaint skips those as well.
89
+ if (usage === undefined || model === '')
90
+ return [];
91
+ const tokens = {
92
+ input: count(usage.inputTokens),
93
+ cacheWrite: count(usage.cacheWriteTokens),
94
+ cacheRead: count(usage.cacheReadTokens),
95
+ output: count(usage.outputTokens),
96
+ reasoning: count(usage.reasoningTokens),
97
+ };
98
+ const row = {
99
+ v: FEED_VERSION,
100
+ type: 'usage',
101
+ ts: event.time,
102
+ seq: event.seq,
103
+ };
104
+ const turn = integer(data.turn);
105
+ const step = integer(data.step);
106
+ if (turn !== undefined)
107
+ row.turn = turn;
108
+ if (step !== undefined)
109
+ row.step = step;
110
+ if (typeof message?.id === 'string' && message.id !== '')
111
+ row.request = message.id;
112
+ row.model = model;
113
+ if (typeof source?.provider === 'string' && source.provider !== '')
114
+ row.provider = source.provider;
115
+ row.tokens = tokens;
116
+ const rate = resolveRate(pricing, model);
117
+ const cost = costOf(rate, tokens);
118
+ if (cost !== undefined)
119
+ row.cost = cost;
120
+ return [{ seq: event.seq, line: JSON.stringify(row) }];
121
+ }
122
+ default:
123
+ return [];
124
+ }
125
+ }
126
+ /**
127
+ * An append-only feed writer, one chain per session file.
128
+ *
129
+ * Appends are serialized so lines keep DSH's event order, and the writer skips
130
+ * sequences already on disk, which keeps a restart or a re-read from writing
131
+ * the same response twice. Vorssaint also dedupes usage by the response id, so
132
+ * a torn last line is at worst replayed, never double counted.
133
+ *
134
+ * Failures are swallowed after one warning: a read-only home directory or a
135
+ * full disk must never fail, block or delay an agent turn.
136
+ */
137
+ export class FeedWriter {
138
+ dir;
139
+ pricing;
140
+ log;
141
+ files = new Map();
142
+ opening = new Map();
143
+ directory;
144
+ warned = false;
145
+ constructor(dir, pricing, log) {
146
+ this.dir = dir;
147
+ this.pricing = pricing;
148
+ this.log = log;
149
+ }
150
+ /**
151
+ * Append the entries not already written for their session.
152
+ * @param identity - the session the entries belong to.
153
+ * @param entries - ordered entries; filtered by their sequence.
154
+ * @returns a promise that never rejects.
155
+ */
156
+ ingest(identity, entries) {
157
+ if (entries.length === 0)
158
+ return Promise.resolve();
159
+ return this.ensure(identity)
160
+ .then((state) => {
161
+ const fresh = entries.filter((entry) => entry.seq > state.lastSeq);
162
+ if (fresh.length === 0)
163
+ return;
164
+ // Advancing before the append resolves keeps a burst of events from
165
+ // queueing the same sequence twice; a failed append loses that line
166
+ // rather than replaying older ones on every later event.
167
+ state.lastSeq = fresh[fresh.length - 1].seq;
168
+ const text = fresh.map((entry) => entry.line).join('\n') + '\n';
169
+ state.chain = state.chain
170
+ .then(() => appendFile(state.path, text, { mode: 0o600 }))
171
+ .catch((error) => {
172
+ this.warn(`could not append usage to ${state.path}: ${describe(error)}`);
173
+ });
174
+ return state.chain;
175
+ })
176
+ .catch((error) => {
177
+ // The directory promise is cached; clear it so a fixed home directory
178
+ // is retried by the next event instead of replaying this failure.
179
+ this.directory = undefined;
180
+ this.warn(`could not open the usage feed at ${this.dir}: ${describe(error)}`);
181
+ });
182
+ }
183
+ /** Wait for every in-flight open and queued append; called on unload. */
184
+ async close() {
185
+ await Promise.allSettled([...this.opening.values()]);
186
+ await Promise.allSettled([...this.files.values()].map((state) => state.chain));
187
+ }
188
+ ensure(identity) {
189
+ const existing = this.files.get(identity.session);
190
+ if (existing !== undefined)
191
+ return Promise.resolve(existing);
192
+ const opening = this.opening.get(identity.session);
193
+ if (opening !== undefined)
194
+ return opening;
195
+ const created = this.open(identity).finally(() => this.opening.delete(identity.session));
196
+ this.opening.set(identity.session, created);
197
+ return created;
198
+ }
199
+ async open(identity) {
200
+ await this.ensureDirectory();
201
+ const path = join(this.dir, feedFileName(identity.session));
202
+ let size = 0;
203
+ try {
204
+ size = (await stat(path)).size;
205
+ }
206
+ catch {
207
+ size = 0;
208
+ }
209
+ // A torn last line from an earlier crash would otherwise merge with the
210
+ // next record and make both unreadable. Cut it back to the last newline.
211
+ if (size > 0) {
212
+ const complete = await completeLength(path, size);
213
+ if (complete < size) {
214
+ try {
215
+ await truncate(path, complete);
216
+ size = complete;
217
+ }
218
+ catch {
219
+ // Left in place: the next append starts a new line anyway.
220
+ }
221
+ }
222
+ }
223
+ const lastSeq = size > 0 ? await lastWrittenSeq(path) : -1;
224
+ const state = { path, lastSeq, chain: Promise.resolve() };
225
+ if (size === 0) {
226
+ state.chain = writeFile(path, sessionLine(identity) + '\n', { mode: 0o600 }).catch((error) => {
227
+ this.warn(`could not open the usage feed at ${path}: ${describe(error)}`);
228
+ });
229
+ }
230
+ state.chain = state.chain.then(() => chmod(path, 0o600).catch(() => undefined));
231
+ this.files.set(identity.session, state);
232
+ return state;
233
+ }
234
+ ensureDirectory() {
235
+ this.directory ??= mkdir(this.dir, { recursive: true, mode: 0o700 })
236
+ .then(() => chmod(this.dir, 0o700))
237
+ .catch((error) => {
238
+ // The caller clears this cached failure; report it once here.
239
+ this.warn(`could not create the usage feed directory ${this.dir}: ${describe(error)}`);
240
+ throw error;
241
+ });
242
+ return this.directory;
243
+ }
244
+ warn(message) {
245
+ if (this.warned)
246
+ return;
247
+ this.warned = true;
248
+ this.log?.warn(`vorssaint: ${message}`);
249
+ }
250
+ }
251
+ /**
252
+ * One file name for a session id. DSH ids are already safe; sanitizing keeps
253
+ * a hand-made or future id from escaping the feed directory.
254
+ */
255
+ export function feedFileName(session) {
256
+ const safe = session.replace(/[^A-Za-z0-9._-]/g, '_').replace(/^\.+/, '_');
257
+ return `${safe === '' ? 'session' : safe}.jsonl`;
258
+ }
259
+ /** The length of a file up to and including its last newline. */
260
+ async function completeLength(path, size) {
261
+ const handle = await open(path, 'r');
262
+ try {
263
+ let start = Math.max(0, size - 64 * 1024);
264
+ for (;;) {
265
+ const length = size - start;
266
+ const buffer = Buffer.alloc(length);
267
+ await handle.read(buffer, 0, length, start);
268
+ const newline = buffer.lastIndexOf(0x0a);
269
+ if (newline !== -1)
270
+ return start + newline + 1;
271
+ if (start === 0)
272
+ return 0;
273
+ start = Math.max(0, start - 64 * 1024);
274
+ }
275
+ }
276
+ finally {
277
+ await handle.close();
278
+ }
279
+ }
280
+ /**
281
+ * The highest sequence already in a feed file, read from its last complete
282
+ * line. -1 when the file has none (new file, or only the session header).
283
+ */
284
+ async function lastWrittenSeq(path) {
285
+ let handle;
286
+ try {
287
+ handle = await open(path, 'r');
288
+ const size = (await handle.stat()).size;
289
+ const length = Math.min(size, 64 * 1024);
290
+ const buffer = Buffer.alloc(length);
291
+ await handle.read(buffer, 0, length, size - length);
292
+ let body = buffer.toString('utf8');
293
+ // A torn final line is not trusted: read the line before it.
294
+ body = body.endsWith('\n') ? body.slice(0, -1) : body.slice(0, body.lastIndexOf('\n'));
295
+ if (body === '')
296
+ return -1;
297
+ const start = body.lastIndexOf('\n');
298
+ const line = start === -1 ? body : body.slice(start + 1);
299
+ const parsed = record(JSON.parse(line));
300
+ const seq = integer(parsed?.seq);
301
+ return seq ?? -1;
302
+ }
303
+ catch {
304
+ return -1;
305
+ }
306
+ finally {
307
+ await handle?.close().catch(() => undefined);
308
+ }
309
+ }
310
+ function turnLine(ts, turn, phase, seq, completed) {
311
+ const line = { v: FEED_VERSION, type: 'turn', ts, turn, phase, seq };
312
+ if (phase === 'end')
313
+ line.completed = completed === true;
314
+ return JSON.stringify(line);
315
+ }
316
+ function record(value) {
317
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
318
+ ? value
319
+ : undefined;
320
+ }
321
+ function integer(value) {
322
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
323
+ }
324
+ function count(value) {
325
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
326
+ }
327
+ function describe(error) {
328
+ return error instanceof Error ? error.message : String(error);
329
+ }
package/lib/index.js ADDED
@@ -0,0 +1,205 @@
1
+ /**
2
+ * dsh-vorssaint — report DeepSeek Harness usage, API value and live runs to
3
+ * Vorssaint.
4
+ *
5
+ * Vorssaint follows coding agents by reading their local session logs. DSH
6
+ * compresses and rotates its own logs, so this plugin writes a small,
7
+ * stable JSONL feed that Vorssaint's DSH reader tails instead:
8
+ *
9
+ * ~/.vorssaint/agents/dsh/<session-id>.jsonl
10
+ *
11
+ * The feed carries token counts per model response, the turn boundaries the
12
+ * island shows as a live run, and — when the user configured model rates —
13
+ * the response's API list value. It never carries prompt text, tool
14
+ * arguments or model output.
15
+ *
16
+ * Usage produced while the plugin was not loaded is recovered once at start
17
+ * from DSH's recent session logs (`backfill`), so budgets do not reset on a
18
+ * plugin install or a DSH upgrade.
19
+ *
20
+ * @module dsh-vorssaint
21
+ */
22
+ import { homedir } from 'node:os';
23
+ import { join } from 'node:path';
24
+ import z from '@deepseek-ai/schemastery';
25
+ import { backfillSessions } from './backfill.js';
26
+ import { FeedWriter, feedEntriesForEvent, identityOf } from './feed.js';
27
+ /** Plugin name; also the loader row id and logger name. */
28
+ export const name = 'vorssaint';
29
+ const DEFAULT_FEED_DIR = '~/.vorssaint/agents/dsh';
30
+ const DEFAULT_BACKFILL_DAYS = 30;
31
+ const MAX_BACKFILL_DAYS = 3650;
32
+ const DAY_MS = 24 * 60 * 60 * 1000;
33
+ /** One model's rates, in USD per million tokens. */
34
+ const Rate = z.object({
35
+ input: z.number().min(0),
36
+ output: z.number().min(0),
37
+ cacheRead: z.number().min(0),
38
+ cacheWrite: z.number().min(0),
39
+ });
40
+ export const Config = z.object({
41
+ feedDir: z
42
+ .string()
43
+ .default(DEFAULT_FEED_DIR)
44
+ .description('Directory Vorssaint reads the usage feed from. Change only together with the app side.'),
45
+ pricing: z
46
+ .dict(Rate)
47
+ .default({})
48
+ .description('USD per million tokens, keyed by exact DSH model id; "*" is the fallback. ' +
49
+ 'Without a price the feed reports tokens and runs but no API value.'),
50
+ backfill: z
51
+ .boolean()
52
+ .default(true)
53
+ .description('Import usage already in recent DSH session logs when the plugin starts.'),
54
+ backfillDays: z
55
+ .number()
56
+ .min(0)
57
+ .max(MAX_BACKFILL_DAYS)
58
+ .default(DEFAULT_BACKFILL_DAYS)
59
+ .description('How far back to import DSH session logs. 0 disables the scan.'),
60
+ sessionsDir: z
61
+ .string()
62
+ .default('')
63
+ .description('DSH sessions directory. Empty uses $DSH_HOME/sessions, or ~/.dsh/sessions.'),
64
+ });
65
+ /**
66
+ * Normalize the config the loader (or a direct mount in a test) supplies.
67
+ * @param raw - user configuration, possibly empty or partial.
68
+ * @returns usable settings with every default applied.
69
+ */
70
+ export function resolveConfig(raw) {
71
+ const values = record(raw);
72
+ const feedDir = text(values?.feedDir) ?? DEFAULT_FEED_DIR;
73
+ const sessionsDir = text(values?.sessionsDir);
74
+ const days = values?.backfillDays;
75
+ return {
76
+ feedDir: expandHome(feedDir),
77
+ pricing: pricingOf(values?.pricing),
78
+ backfill: values?.backfill === undefined ? true : values.backfill === true,
79
+ backfillDays: typeof days === 'number' && Number.isFinite(days) && days >= 0
80
+ ? Math.min(Math.trunc(days), MAX_BACKFILL_DAYS)
81
+ : DEFAULT_BACKFILL_DAYS,
82
+ sessionsDir: expandHome(sessionsDir ?? defaultSessionsDir()),
83
+ };
84
+ }
85
+ /**
86
+ * Subscribe to committed session events and write the feed. The loader owns
87
+ * the fiber, so unloading the plugin stops every listener.
88
+ */
89
+ export function apply(ctx, config) {
90
+ const settings = resolveConfig(config);
91
+ const logger = ctx.logger(name);
92
+ const writer = new FeedWriter(settings.feedDir, settings.pricing, logger);
93
+ // Backfill runs before any buffered live event is written, so a session's
94
+ // history lands in the file before its ongoing turns do.
95
+ let ready = !settings.backfill || settings.backfillDays === 0;
96
+ let disposed = false;
97
+ const pending = [];
98
+ const flush = async () => {
99
+ const items = pending.splice(0);
100
+ await Promise.all(items.map((item) => writer.ingest(item.identity, item.entries)));
101
+ };
102
+ ctx.on('session/event', (session, event) => {
103
+ if (disposed)
104
+ return;
105
+ try {
106
+ const entries = feedEntriesForEvent(event, settings.pricing);
107
+ if (entries.length === 0)
108
+ return;
109
+ const identity = identityOf(session);
110
+ if (ready)
111
+ void writer.ingest(identity, entries);
112
+ else
113
+ pending.push({ identity, entries });
114
+ }
115
+ catch (error) {
116
+ logger.warn(`could not record session usage: ${describe(error)}`);
117
+ }
118
+ });
119
+ if (!ready) {
120
+ void (async () => {
121
+ try {
122
+ for await (const item of backfillSessions(settings.sessionsDir, {
123
+ sinceMs: settings.backfillDays * DAY_MS,
124
+ pricing: settings.pricing,
125
+ feedDir: settings.feedDir,
126
+ })) {
127
+ if (disposed)
128
+ return;
129
+ await writer.ingest(item.identity, item.entries);
130
+ }
131
+ }
132
+ catch (error) {
133
+ logger.warn(`could not backfill usage from session logs: ${describe(error)}`);
134
+ }
135
+ finally {
136
+ if (!disposed) {
137
+ ready = true;
138
+ void flush();
139
+ }
140
+ }
141
+ })();
142
+ }
143
+ ctx.effect(() => async () => {
144
+ disposed = true;
145
+ ready = true;
146
+ await flush();
147
+ await writer.close();
148
+ });
149
+ }
150
+ function defaultSessionsDir() {
151
+ const home = process.env.DSH_HOME?.trim();
152
+ return join(home !== undefined && home !== '' ? expandHome(home) : join(homedir(), '.dsh'), 'sessions');
153
+ }
154
+ function expandHome(path) {
155
+ if (path === '~')
156
+ return homedir();
157
+ if (path.startsWith('~/') || path.startsWith('~\\'))
158
+ return join(homedir(), path.slice(2));
159
+ return path;
160
+ }
161
+ function pricingOf(value) {
162
+ const pricing = {};
163
+ const entries = record(value);
164
+ if (entries === undefined)
165
+ return pricing;
166
+ for (const [model, raw] of Object.entries(entries)) {
167
+ if (model === '')
168
+ continue;
169
+ const rate = rateOf(raw);
170
+ if (rate !== undefined)
171
+ pricing[model] = rate;
172
+ }
173
+ return pricing;
174
+ }
175
+ function rateOf(value) {
176
+ const raw = record(value);
177
+ if (raw === undefined)
178
+ return undefined;
179
+ const input = number(raw.input);
180
+ const output = number(raw.output);
181
+ if (input === undefined || output === undefined)
182
+ return undefined;
183
+ const rate = { input, output };
184
+ const cacheRead = number(raw.cacheRead);
185
+ const cacheWrite = number(raw.cacheWrite);
186
+ if (cacheRead !== undefined)
187
+ rate.cacheRead = cacheRead;
188
+ if (cacheWrite !== undefined)
189
+ rate.cacheWrite = cacheWrite;
190
+ return rate;
191
+ }
192
+ function number(value) {
193
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
194
+ }
195
+ function text(value) {
196
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
197
+ }
198
+ function record(value) {
199
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
200
+ ? value
201
+ : undefined;
202
+ }
203
+ function describe(error) {
204
+ return error instanceof Error ? error.message : String(error);
205
+ }
package/lib/pricing.js ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * What one response billed, and what the plugin charges for it.
3
+ *
4
+ * Vorssaint values a DSH session from the `cost` the plugin writes, because
5
+ * it has no DeepSeek price list of its own and DSH reports no price per call.
6
+ * The plugin therefore ships no rates: a user opts in with the `pricing`
7
+ * config, keyed by the exact model id the provider route reports (with `*`
8
+ * as a fallback). Without a rate the feed still carries tokens; cost stays
9
+ * absent rather than guessed.
10
+ *
11
+ * @module dsh-vorssaint/pricing
12
+ */
13
+ const PER_MILLION = 1_000_000;
14
+ /**
15
+ * The rate that prices one model.
16
+ * @param pricing - configured rates.
17
+ * @param model - exact model id from the assistant message.
18
+ * @returns the model's rate, the wildcard rate, or undefined.
19
+ */
20
+ export function resolveRate(pricing, model) {
21
+ if (pricing === undefined || model === '')
22
+ return undefined;
23
+ return pricing[model] ?? pricing['*'];
24
+ }
25
+ /**
26
+ * The API list value of one response.
27
+ * @param rate - its model's rate, or undefined when unknown.
28
+ * @param tokens - the response's token counts.
29
+ * @returns US dollars, or undefined when the model has no rate.
30
+ */
31
+ export function costOf(rate, tokens) {
32
+ if (rate === undefined)
33
+ return undefined;
34
+ const cacheRead = rate.cacheRead ?? rate.input;
35
+ const cacheWrite = rate.cacheWrite ?? rate.input;
36
+ const value = (tokens.input * rate.input +
37
+ tokens.cacheWrite * cacheWrite +
38
+ tokens.cacheRead * cacheRead +
39
+ tokens.output * rate.output) /
40
+ PER_MILLION;
41
+ return Number.isFinite(value) && value >= 0 ? value : undefined;
42
+ }
package/package.json CHANGED
@@ -1,6 +1,79 @@
1
1
  {
2
2
  "name": "dsh-vorssaint",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Report DeepSeek Harness usage, API value and live runs to Vorssaint.",
5
+ "icon": "./icon.svg",
6
+ "type": "module",
7
+ "main": "./lib/index.js",
8
+ "exports": {
9
+ ".": {
10
+ "default": "./lib/index.js"
11
+ },
12
+ "./package.json": "./package.json"
13
+ },
14
+ "files": [
15
+ "lib",
16
+ "docs",
17
+ "cordis.patch.yml",
18
+ "icon.svg",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "scripts": {
23
+ "build": "tsc -p tsconfig.json",
24
+ "typecheck": "tsc -p tsconfig.json --noEmit",
25
+ "pretest": "pnpm build",
26
+ "test": "node --test test/*.test.mjs",
27
+ "check": "pnpm typecheck && pnpm build && pnpm test",
28
+ "prepare": "tsc -p tsconfig.json",
29
+ "prepack": "tsc -p tsconfig.json"
30
+ },
31
+ "engines": {
32
+ "node": ">=22.15"
33
+ },
34
+ "keywords": [
35
+ "deepseek-harness",
36
+ "dsh",
37
+ "dsh-plugin",
38
+ "vorssaint",
39
+ "usage",
40
+ "tokens",
41
+ "budget",
42
+ "menubar"
43
+ ],
44
+ "license": "MIT",
45
+ "author": "Rúben Garrido",
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "git+https://github.com/RGarrido03/dsh-vorssaint.git"
49
+ },
50
+ "homepage": "https://github.com/RGarrido03/dsh-vorssaint#readme",
51
+ "bugs": {
52
+ "url": "https://github.com/RGarrido03/dsh-vorssaint/issues"
53
+ },
54
+ "publishConfig": {
55
+ "access": "public"
56
+ },
57
+ "dsh": {
58
+ "bundle": {
59
+ "patch": "./cordis.patch.yml"
60
+ },
61
+ "compatibility": {
62
+ "dshReleases": {
63
+ "0.2.2-rc.1": "compatible"
64
+ },
65
+ "dsh": "^0.2.2-rc.1"
66
+ }
67
+ },
68
+ "peerDependencies": {
69
+ "@deepseek-ai/cordis": "^4.0.0",
70
+ "@deepseek-ai/schemastery": "^3.18.0"
71
+ },
72
+ "devDependencies": {
73
+ "@deepseek-ai/cordis": "4.1.0",
74
+ "@deepseek-ai/dsh-session": "0.2.2-rc.1",
75
+ "@deepseek-ai/schemastery": "3.19.0",
76
+ "@types/node": "^26.6.5",
77
+ "typescript": "^7.0.2"
78
+ }
79
+ }