dsh-cost-meter 1.7.21 → 1.7.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +6 -6
- package/README.md +6 -6
- package/lib/backfill.js +37 -41
- package/lib/native-search-billing.js +5 -10
- package/lib/native-search-events.js +11 -0
- package/lib/native-search-history.js +79 -0
- package/lib/repair-sessions-cli.js +51 -0
- package/lib/session-log-files.js +30 -0
- package/lib/session-log-repair.js +175 -0
- package/package.json +2 -1
package/README.en.md
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
Per-conversation cost · daily totals · OpenCode Go subscription quota display · budget with usage percentage · official account balance · custom provider balance · balance progress bar · history · peak/off-peak pricing hours display (peak hours UTC 01:00–04:00, 06:00–10:00; from Aug 23, 2026 weekends are billed at off-peak prices all day, shown as “Weekend — all off-peak”) · pre-switch popup & system-notification alerts for peak/off-peak changes (position / lead time / alert type configurable) · one-click price sync from the official docs · Codex-style token usage heat grid · multi-vendor model pricing (built-in 90+ model price catalog with auto-matching) · mainstream Coding Plan quota queries & display (Anthropic / Z.ai / MiniMax / Kimi / OpenRouter / SiliconFlow / CommandCode / SCNet) plan/API dual-track billing (subscription quota vs pay-as-you-go money separated, per-1% & full-window token/equivalent-cost estimates with daily/weekly/monthly curves) · · quota strip above the input box (budget / Go / coding-plan usage in one row, toggleable)
|
|
8
8
|
|
|
9
|
-
[](https://github.com/Han-1413141/dsh-cost-meter)
|
|
10
10
|
|
|
11
|
-
**v1.7.
|
|
11
|
+
**v1.7.22** fixes session reload failures after native search and missed history in versioned session logs. Search usage is persisted separately. See the [recovery guide](docs/session-history-recovery.md#english) for existing affected sessions and historical imports.
|
|
12
12
|
|
|
13
13
|
[](https://www.npmjs.com/package/dsh-cost-meter)
|
|
14
14
|
[](LICENSE)
|
|
@@ -261,22 +261,22 @@ Real captures from an actual DSH sidebar of the period strip and collapsed verti
|
|
|
261
261
|
dsh plugin --profile web add dsh-cost-meter
|
|
262
262
|
```
|
|
263
263
|
|
|
264
|
-
**PowerShell one-click script** (copy the whole line, paste, press Enter; pnpm is provisioned automatically, git is auto-detected — no clone needed; the install chain is **pinned to the release tag `v1.7.
|
|
264
|
+
**PowerShell one-click script** (copy the whole line, paste, press Enter; pnpm is provisioned automatically, git is auto-detected — no clone needed; the install chain is **pinned to the release tag `v1.7.22`** — review the script before running):
|
|
265
265
|
|
|
266
266
|
```powershell
|
|
267
|
-
irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.7.
|
|
267
|
+
irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.7.22/install.ps1 | iex
|
|
268
268
|
```
|
|
269
269
|
|
|
270
270
|
**Or a plain command line** (the machine must already have pnpm and git; also pinned to the tag):
|
|
271
271
|
|
|
272
272
|
```sh
|
|
273
|
-
dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.7.
|
|
273
|
+
dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.7.22
|
|
274
274
|
```
|
|
275
275
|
|
|
276
276
|
Without git, use the GitHub tag archive:
|
|
277
277
|
|
|
278
278
|
```sh
|
|
279
|
-
dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.7.
|
|
279
|
+
dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.7.22.tar.gz
|
|
280
280
|
```
|
|
281
281
|
|
|
282
282
|
After installing, **restart** `dsh web` (plugin rows, the Typert manifest and the client bundle are all scanned at startup):
|
package/README.md
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
本会话费用 · 当日费用 · OpenCode Go 订阅额度显示 · 预算与已用百分比 · 官方账户余额 · 自定义 Provider 余额查询(可配任意 HTTP 端点) · 余额三段进度条 · 历史记录 · 峰谷计价时段显示(UTC 01:00–04:00、06:00–10:00 为峰时段;2026-08-23 起周末全天按谷价,显示「周末时段——全谷价」) · 峰/谷切换前弹窗与系统通知提醒(位置/提前量/提醒类型可配) · 官方价格一键同步 · 类 Codex Token 用量热图 · 多厂商多模型价格计费(内置 90+ 模型价格目录与自动匹配) · 主流 Coding Plan 额度查询与显示(Anthropic / Z.ai / MiniMax / Kimi / OpenRouter / SiliconFlow / CommandCode / SCNet / 火山方舟 九家,含 Volcano Ark AK/SK 签名) · Plan/API 双轨计费(订阅额度与按量金额分离统计,每 1% 额度与满窗的 token/等值金额估算及日/周/月曲线) · 输入框上方额度横条(预算/Go/Coding Plan 用量一条横排显示,可开关)
|
|
8
8
|
|
|
9
|
-
[](https://github.com/Han-1413141/dsh-cost-meter)
|
|
10
10
|
|
|
11
|
-
**v1.7.
|
|
11
|
+
**v1.7.22**:修复原生搜索后会话重启无法加载,以及带版本号日志的历史导入遗漏。搜索明细改为独立存储;已受影响的会话可按 [恢复说明](docs/session-history-recovery.md) 备份修复。
|
|
12
12
|
|
|
13
13
|
[](https://www.npmjs.com/package/dsh-cost-meter)
|
|
14
14
|
[](LICENSE)
|
|
@@ -263,22 +263,22 @@
|
|
|
263
263
|
dsh plugin --profile web add dsh-cost-meter
|
|
264
264
|
```
|
|
265
265
|
|
|
266
|
-
**PowerShell 一键脚本**(复制整行粘贴回车;自动补齐 pnpm、自动探测 git,无需克隆仓库;安装链**固定到发布 tag `v1.7.
|
|
266
|
+
**PowerShell 一键脚本**(复制整行粘贴回车;自动补齐 pnpm、自动探测 git,无需克隆仓库;安装链**固定到发布 tag `v1.7.22`**,建议先下载审阅再运行):
|
|
267
267
|
|
|
268
268
|
```powershell
|
|
269
|
-
irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.7.
|
|
269
|
+
irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.7.22/install.ps1 | iex
|
|
270
270
|
```
|
|
271
271
|
|
|
272
272
|
**或直接命令行**(机器上需已有 pnpm 与 git;同样固定到 tag):
|
|
273
273
|
|
|
274
274
|
```sh
|
|
275
|
-
dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.7.
|
|
275
|
+
dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.7.22
|
|
276
276
|
```
|
|
277
277
|
|
|
278
278
|
没有 git 时可用 GitHub tag 打包直链:
|
|
279
279
|
|
|
280
280
|
```sh
|
|
281
|
-
dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.7.
|
|
281
|
+
dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.7.22.tar.gz
|
|
282
282
|
```
|
|
283
283
|
|
|
284
284
|
安装后**重启** `dsh web`(插件行、Typert 清单与客户端 bundle 均在启动时扫描):
|
package/lib/backfill.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 历史账本按模型回填:按模型统计(byProviderModel)上线之前的账本只有
|
|
3
3
|
* 每日/会话合计,没有 provider:model 拆分。本模块回放宿主会话日志
|
|
4
|
-
* ($DSH_HOME/sessions/<项目>/<会话>/session.jsonl[.zstd]),按与 costUsage
|
|
4
|
+
* ($DSH_HOME/sessions/<项目>/<会话>/session[.vN].jsonl[.zstd]),按与 costUsage
|
|
5
5
|
* 投影一致的逻辑逐次重建用量,并按事件时刻的档位(峰谷时代前按
|
|
6
6
|
* legacyBase 历史价)计算费用,回填到账本中 byProviderModel 为空的
|
|
7
7
|
* 日期与会话条目。
|
|
@@ -10,15 +10,15 @@
|
|
|
10
10
|
* 避免与实时计费重复计数。会话日志是宿主的只读数据,本模块从不写入。
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { readdirSync, statSync } from 'node:fs'
|
|
14
13
|
import { open } from 'node:fs/promises'
|
|
15
|
-
import { join } from 'node:path'
|
|
16
14
|
import * as zlib from 'node:zlib'
|
|
17
15
|
import { costOf, providerPriceEntryFor, usdFromCost, isWrapperProviderId, wrapperUpstreamProvider } from './pricing.js'
|
|
18
16
|
import { localDayKey, zeroDay, splitLedgerApiCost } from './store.js'
|
|
19
17
|
import { billingClassOf, enabledPlanSetOf } from './plan-billing.js'
|
|
20
18
|
import { USAGE_DEDUP_WINDOW_MS, usageFingerprint } from './usage-dedup.js'
|
|
21
|
-
import { isNativeSearchUsageEvent } from './native-search-
|
|
19
|
+
import { isNativeSearchUsageEvent } from './native-search-events.js'
|
|
20
|
+
import { listSessionLogFiles } from './session-log-files.js'
|
|
21
|
+
import { listNativeSearchHistory, readNativeSearchHistory, nativeSearchRecordsFor } from './native-search-history.js'
|
|
22
22
|
|
|
23
23
|
const ZSTD_MAGIC = 4247762216
|
|
24
24
|
/** 打包行(文本/推理/工具调用增量游程)不含 header 与 usage,回放时跳过。 */
|
|
@@ -245,45 +245,38 @@ export async function readSessionRecordsAsync(path, options = {}) {
|
|
|
245
245
|
}
|
|
246
246
|
|
|
247
247
|
/**
|
|
248
|
-
*
|
|
248
|
+
* 枚举每个会话最高数字代际的日志路径(session[.vN].jsonl[.zstd])。
|
|
249
249
|
* @param root - 会话根目录。
|
|
250
250
|
* @param onlySessionIds - 可选:仅枚举这些会话目录(Set<会话 id>)。纯标题/时间戳
|
|
251
251
|
* 补齐时按缺失会话定向,避免为补齐一两个标题全量读取全部会话日志(其中可能
|
|
252
252
|
* 含解压后数百 MB 的大文件)。null = 全部。
|
|
253
253
|
*/
|
|
254
254
|
export function listSessionLogs(root, onlySessionIds = null) {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
continue
|
|
269
|
-
}
|
|
270
|
-
for (const session of sessions) {
|
|
271
|
-
if (!session.isDirectory()) continue
|
|
272
|
-
if (onlySessionIds !== null && !onlySessionIds.has(session.name)) continue
|
|
273
|
-
for (const name of ['session.jsonl.zstd', 'session.jsonl']) {
|
|
274
|
-
const path = join(root, project.name, session.name, name)
|
|
275
|
-
try {
|
|
276
|
-
if (statSync(path).isFile()) {
|
|
277
|
-
paths.push(path)
|
|
278
|
-
break // 同一会话两种编码互斥,取先命中者
|
|
279
|
-
}
|
|
280
|
-
} catch {
|
|
281
|
-
// 不存在:继续尝试另一后缀。
|
|
282
|
-
}
|
|
283
|
-
}
|
|
255
|
+
return listSessionLogFiles(root, onlySessionIds)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Merge the plugin journal before replay, so UUID dedup also covers repaired v1.7.21 logs. */
|
|
259
|
+
async function* sessionBillingRecords(ledger, sessionsRoot, onlySessionIds = null) {
|
|
260
|
+
const seen = new Set()
|
|
261
|
+
for (const path of listSessionLogs(sessionsRoot, onlySessionIds)) {
|
|
262
|
+
let records
|
|
263
|
+
try { records = await readSessionRecordsAsync(path) } catch { yield null; continue }
|
|
264
|
+
const id = records.find(record => record.type === 'session')?.id
|
|
265
|
+
if (typeof id === 'string') {
|
|
266
|
+
seen.add(id)
|
|
267
|
+
try { for (const record of await nativeSearchRecordsFor(ledger.path, id)) records.push(record) } catch { /* Keep the readable host prefix. */ }
|
|
284
268
|
}
|
|
269
|
+
yield records
|
|
270
|
+
}
|
|
271
|
+
// A deleted/missing host log must not discard the plugin's surviving search history.
|
|
272
|
+
for (const path of listNativeSearchHistory(ledger.path, seen, onlySessionIds)) {
|
|
273
|
+
let history
|
|
274
|
+
try { history = await readNativeSearchHistory(path) } catch { continue }
|
|
275
|
+
const id = history.sessionId
|
|
276
|
+
if (!id || seen.has(id) || (onlySessionIds !== null && !onlySessionIds.has(id))) continue
|
|
277
|
+
seen.add(id)
|
|
278
|
+
yield [{ type: 'session', id, createdAt: 0 }, ...history.records]
|
|
285
279
|
}
|
|
286
|
-
return paths
|
|
287
280
|
}
|
|
288
281
|
|
|
289
282
|
/**
|
|
@@ -570,14 +563,15 @@ export async function backfillLegacyLedger(ledger, sessionsRoot) {
|
|
|
570
563
|
}
|
|
571
564
|
}
|
|
572
565
|
}
|
|
573
|
-
for (const
|
|
566
|
+
for await (const records of sessionBillingRecords(ledger, sessionsRoot, onlySessionIds)) {
|
|
574
567
|
// 会话日志多时逐份解压会长时间占住事件循环:每 8 份让出一次,不卡宿主 UI;
|
|
575
568
|
// 单文件内部亦为流式读取(v1.7.7,issue #88:解压期间周期性让出,大日志不再卡死宿主)。
|
|
576
569
|
if ((scannedCount += 1) % 8 === 0) await new Promise(resolve => setImmediate(resolve))
|
|
577
570
|
result.scanned += 1
|
|
578
571
|
let replayed
|
|
579
572
|
try {
|
|
580
|
-
|
|
573
|
+
if (records === null) continue
|
|
574
|
+
replayed = replaySessionRecords(records, ledger.config, needDates.size > 0 ? needDates : new Set(['-']))
|
|
581
575
|
} catch {
|
|
582
576
|
continue // 单文件损坏(含解压预算超限)不阻断整体回填。
|
|
583
577
|
}
|
|
@@ -811,12 +805,13 @@ export async function importLegacyHistory(ledger, sessionsRoot) {
|
|
|
811
805
|
const result = { days: 0, sessions: 0, scanned: 0 }
|
|
812
806
|
const bySession = new Map()
|
|
813
807
|
let scannedCount = 0
|
|
814
|
-
for (const
|
|
808
|
+
for await (const records of sessionBillingRecords(ledger, sessionsRoot)) {
|
|
815
809
|
if ((scannedCount += 1) % 8 === 0) await new Promise(resolve => setImmediate(resolve))
|
|
816
810
|
result.scanned += 1
|
|
817
811
|
let replayed
|
|
818
812
|
try {
|
|
819
|
-
|
|
813
|
+
if (records === null) continue
|
|
814
|
+
replayed = replaySessionRecords(records, ledger.config, null)
|
|
820
815
|
} catch {
|
|
821
816
|
continue // 单文件损坏(含解压预算超限)不阻断整体导入。
|
|
822
817
|
}
|
|
@@ -906,11 +901,12 @@ export async function importLegacyHistory(ledger, sessionsRoot) {
|
|
|
906
901
|
export async function recomputeLedgerPricingBasis(ledger, sessionsRoot, includeBucket) {
|
|
907
902
|
const result = { scanned: 0, recostedSessions: 0, skippedSessions: 0, recostedDays: 0 }
|
|
908
903
|
const bySession = new Map()
|
|
909
|
-
for (const
|
|
904
|
+
for await (const records of sessionBillingRecords(ledger, sessionsRoot)) {
|
|
910
905
|
if ((result.scanned += 1) % 8 === 0) await new Promise(resolve => setImmediate(resolve))
|
|
911
906
|
let replayed
|
|
912
907
|
try {
|
|
913
|
-
|
|
908
|
+
if (records === null) continue
|
|
909
|
+
replayed = replaySessionRecords(records, ledger.config, null)
|
|
914
910
|
} catch {
|
|
915
911
|
continue // 单文件损坏(含解压预算超限)不阻断整体重算。
|
|
916
912
|
}
|
|
@@ -9,16 +9,9 @@ import { randomUUID } from 'node:crypto'
|
|
|
9
9
|
import { readFileSync, writeFileSync, renameSync, mkdirSync, statSync } from 'node:fs'
|
|
10
10
|
import { dirname } from 'node:path'
|
|
11
11
|
import { gunzipSync, inflateSync, brotliDecompressSync } from 'node:zlib'
|
|
12
|
+
import { recordNativeSearchUsage } from './native-search-history.js'
|
|
13
|
+
export { NATIVE_SEARCH_USAGE_EVENT, isNativeSearchUsageEvent } from './native-search-events.js'
|
|
12
14
|
|
|
13
|
-
export const NATIVE_SEARCH_USAGE_EVENT = 'cost-meter/native-search-usage'
|
|
14
|
-
export function isNativeSearchUsageEvent(event) {
|
|
15
|
-
const data = event?.data
|
|
16
|
-
return event?.type === NATIVE_SEARCH_USAGE_EVENT && data?.provider === 'deepseek-official'
|
|
17
|
-
&& typeof data.model === 'string' && /^deepseek-[a-z\d._:-]{1,120}$/i.test(data.model)
|
|
18
|
-
&& typeof data.requestId === 'string' && /^[a-f\d]{8}(?:-[a-f\d]{4}){3}-[a-f\d]{12}$/i.test(data.requestId)
|
|
19
|
-
&& Number.isFinite(data.startedAtMs) && data.startedAtMs > 0
|
|
20
|
-
&& ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens'].every(key => Number.isSafeInteger(data.usage?.[key]) && data.usage[key] >= 0)
|
|
21
|
-
}
|
|
22
15
|
const MAX_BODY_BYTES = 4 * 1024 * 1024
|
|
23
16
|
const monitors = new WeakMap()
|
|
24
17
|
|
|
@@ -253,7 +246,9 @@ export function installNativeSearchBilling(ctx, ledger) {
|
|
|
253
246
|
const usage = event.usage
|
|
254
247
|
ledger.account({ input: usage.inputTokens, output: usage.outputTokens, cacheRead: usage.cacheReadTokens, cacheWrite: usage.cacheWriteTokens, reasoning: usage.reasoningTokens }, event.model, session?.id, event.startedAtMs, event.provider)
|
|
255
248
|
},
|
|
256
|
-
|
|
249
|
+
// Never infer an append contract from function source. Host logs remain read-only;
|
|
250
|
+
// the plugin journal retains exact usage for imports and currency repricing.
|
|
251
|
+
record(session, event) { recordNativeSearchUsage(ledger.path, session.id, event) },
|
|
257
252
|
uncovered: (event) => coverage.add(event),
|
|
258
253
|
})
|
|
259
254
|
monitors.set(ledger, { coverage })
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Shared, host-independent validation for the plugin's own metering records. */
|
|
2
|
+
export const NATIVE_SEARCH_USAGE_EVENT = 'cost-meter/native-search-usage'
|
|
3
|
+
|
|
4
|
+
export function isNativeSearchUsageEvent(event) {
|
|
5
|
+
const data = event?.data
|
|
6
|
+
return event?.type === NATIVE_SEARCH_USAGE_EVENT && data?.provider === 'deepseek-official'
|
|
7
|
+
&& typeof data.model === 'string' && /^deepseek-[a-z\d._:-]{1,120}$/i.test(data.model)
|
|
8
|
+
&& typeof data.requestId === 'string' && /^[a-f\d]{8}(?:-[a-f\d]{4}){3}-[a-f\d]{12}$/i.test(data.requestId)
|
|
9
|
+
&& Number.isFinite(data.startedAtMs) && data.startedAtMs > 0
|
|
10
|
+
&& ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens'].every(key => Number.isSafeInteger(data.usage?.[key]) && data.usage[key] >= 0)
|
|
11
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** Native-search usage belongs to the plugin, never to the host's event vocabulary. */
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { mkdirSync, openSync, writeSync, fsyncSync, closeSync, lstatSync, readdirSync, createReadStream } from 'node:fs'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { StringDecoder } from 'node:string_decoder'
|
|
6
|
+
import { NATIVE_SEARCH_USAGE_EVENT, isNativeSearchUsageEvent } from './native-search-events.js'
|
|
7
|
+
|
|
8
|
+
const validId = id => typeof id === 'string' && id.length > 0 && id.length <= 512
|
|
9
|
+
export const nativeSearchHistoryRoot = ledgerPath => ledgerPath ? `${ledgerPath}.native-search` : ''
|
|
10
|
+
const filename = id => `${createHash('sha256').update(id).digest('hex')}.jsonl`
|
|
11
|
+
|
|
12
|
+
export function recordNativeSearchUsage(ledgerPath, sessionId, data) {
|
|
13
|
+
if (!ledgerPath || !validId(sessionId) || !isNativeSearchUsageEvent({ type: NATIVE_SEARCH_USAGE_EVENT, data })) throw new Error('Invalid native-search history record')
|
|
14
|
+
const root = nativeSearchHistoryRoot(ledgerPath)
|
|
15
|
+
mkdirSync(root, { recursive: true, mode: 0o700 })
|
|
16
|
+
if (!lstatSync(root).isDirectory() || lstatSync(root).isSymbolicLink()) throw new Error('Unsafe native-search history directory')
|
|
17
|
+
const path = join(root, filename(sessionId))
|
|
18
|
+
try { if (!lstatSync(path).isFile() || lstatSync(path).isSymbolicLink()) throw new Error('Unsafe native-search history file') } catch (error) { if (error.code !== 'ENOENT') throw error }
|
|
19
|
+
// Whitelist fields: no prompt, response body, credential, or Session object is persisted.
|
|
20
|
+
const event = { type: NATIVE_SEARCH_USAGE_EVENT, sessionId, time: data.startedAtMs, data: {
|
|
21
|
+
model: data.model, provider: data.provider, startedAtMs: data.startedAtMs, requestId: data.requestId,
|
|
22
|
+
usage: Object.fromEntries(['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens'].map(key => [key, data.usage[key]])),
|
|
23
|
+
} }
|
|
24
|
+
const fd = openSync(path, 'a', 0o600)
|
|
25
|
+
try {
|
|
26
|
+
// A leading newline isolates any torn final row from the next successful append.
|
|
27
|
+
const bytes = Buffer.from(`\n${JSON.stringify(event)}\n`)
|
|
28
|
+
let offset = 0
|
|
29
|
+
while (offset < bytes.length) offset += writeSync(fd, bytes, offset, bytes.length - offset)
|
|
30
|
+
fsyncSync(fd)
|
|
31
|
+
} finally { closeSync(fd) }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function listNativeSearchHistory(ledgerPath, excludeIds = new Set(), onlyIds = null) {
|
|
35
|
+
const root = nativeSearchHistoryRoot(ledgerPath)
|
|
36
|
+
if (!root) return []
|
|
37
|
+
try {
|
|
38
|
+
if (lstatSync(root).isSymbolicLink()) return []
|
|
39
|
+
const excluded = new Set([...excludeIds].map(filename))
|
|
40
|
+
const included = onlyIds === null ? null : new Set([...onlyIds].map(filename))
|
|
41
|
+
return readdirSync(root, { withFileTypes: true }).filter(e => e.isFile() && /^[a-f\d]{64}\.jsonl$/.test(e.name) && !excluded.has(e.name) && (included === null || included.has(e.name))).map(e => join(root, e.name))
|
|
42
|
+
} catch { return [] }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Small, bounded lines; invalid/torn rows are ignored without joining adjacent calls. */
|
|
46
|
+
export async function readNativeSearchHistory(path, expectedId) {
|
|
47
|
+
const records = [], ids = new Set()
|
|
48
|
+
const info = lstatSync(path)
|
|
49
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > 256 * 1024 * 1024) throw new Error('Invalid native-search history file')
|
|
50
|
+
const decoder = new StringDecoder('utf8')
|
|
51
|
+
let pending = '', dropping = false, sessionId = expectedId
|
|
52
|
+
const consume = text => {
|
|
53
|
+
const lines = text.split('\n')
|
|
54
|
+
lines[0] = pending + lines[0]
|
|
55
|
+
pending = lines.pop()
|
|
56
|
+
for (const line of lines) {
|
|
57
|
+
if (dropping) { dropping = false; continue }
|
|
58
|
+
if (line.length > 4096) continue
|
|
59
|
+
let row
|
|
60
|
+
try { row = JSON.parse(line) } catch { continue }
|
|
61
|
+
if (!validId(row?.sessionId) || !isNativeSearchUsageEvent(row) || !Number.isFinite(row.time) || row.time <= 0 || !path.endsWith(filename(row.sessionId))) continue
|
|
62
|
+
if (sessionId === undefined) sessionId = row.sessionId
|
|
63
|
+
if (row.sessionId !== sessionId || ids.has(row.data.requestId)) continue
|
|
64
|
+
ids.add(row.data.requestId)
|
|
65
|
+
records.push(row)
|
|
66
|
+
}
|
|
67
|
+
if (pending.length > 4096) { pending = ''; dropping = true }
|
|
68
|
+
}
|
|
69
|
+
for await (const bytes of createReadStream(path, { highWaterMark: 64 * 1024 })) consume(decoder.write(bytes))
|
|
70
|
+
consume(decoder.end())
|
|
71
|
+
// No newline means the append may have been interrupted: never infer its completion.
|
|
72
|
+
return { sessionId, records }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function nativeSearchRecordsFor(ledgerPath, sessionId) {
|
|
76
|
+
if (!ledgerPath || !validId(sessionId)) return []
|
|
77
|
+
try { return (await readNativeSearchHistory(join(nativeSearchHistoryRoot(ledgerPath), filename(sessionId)), sessionId)).records }
|
|
78
|
+
catch (error) { if (error.code === 'ENOENT') return []; throw error }
|
|
79
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from 'node:util'
|
|
3
|
+
import { createRequire } from 'node:module'
|
|
4
|
+
import { pathToFileURL } from 'node:url'
|
|
5
|
+
import { join, resolve } from 'node:path'
|
|
6
|
+
import { lstat } from 'node:fs/promises'
|
|
7
|
+
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
|
|
8
|
+
import { listSessionLogFiles } from './session-log-files.js'
|
|
9
|
+
import { repairSessionLog } from './session-log-repair.js'
|
|
10
|
+
|
|
11
|
+
async function main() {
|
|
12
|
+
const { values } = parseArgs({ options: {
|
|
13
|
+
write: { type: 'boolean', default: false },
|
|
14
|
+
'sessions-root': { type: 'string' }, 'host-modules': { type: 'string' },
|
|
15
|
+
help: { type: 'boolean', short: 'h' },
|
|
16
|
+
} })
|
|
17
|
+
if (values.help) {
|
|
18
|
+
console.log('dsh-cost-meter-repair-sessions [--sessions-root <目录>] [--write] [--host-modules <node_modules>]\n默认只检查。退出所有 DSH 后加 --write 修复;每份受影响日志先保留原样备份。\n写入需要同次 npx 安装 @deepseek-ai/dsh,或用 --host-modules 指向现有宿主的 node_modules。')
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
const root = resolve(values['sessions-root'] ?? join(resolveDshHome(), 'sessions'))
|
|
22
|
+
if (!(await lstat(root)).isDirectory()) throw new Error('会话根目录不存在或不是目录')
|
|
23
|
+
let acquireLease
|
|
24
|
+
if (values.write) {
|
|
25
|
+
const require = createRequire(values['host-modules'] ? join(resolve(values['host-modules']), '__cost_meter_repair__.cjs') : import.meta.url)
|
|
26
|
+
let Host
|
|
27
|
+
try { Host = (await import(pathToFileURL(require.resolve('@deepseek-ai/dsh-session-persistence-jsonl')).href)).default }
|
|
28
|
+
catch { throw new Error('未找到可用宿主:请在 npx 命令中同时指定 --package=@deepseek-ai/dsh@0.1.5-rc.2,或传 --host-modules') }
|
|
29
|
+
if (typeof Host?.prototype?.acquireLease !== 'function') throw new Error('该宿主没有兼容的会话锁接口,未写入任何日志')
|
|
30
|
+
acquireLease = (directory, id) => Host.prototype.acquireLease.call(null, id, undefined, directory)
|
|
31
|
+
}
|
|
32
|
+
const totals = { scanned: 0, affected: 0, events: 0, repaired: 0, failed: 0 }
|
|
33
|
+
console.log(`${values.write ? '修复' : '只读检查'}:${root}`)
|
|
34
|
+
for (const path of listSessionLogFiles(root, null, true)) {
|
|
35
|
+
totals.scanned++
|
|
36
|
+
try {
|
|
37
|
+
const result = await repairSessionLog(path, { write: values.write, acquireLease })
|
|
38
|
+
if (!result.changedEvents) continue
|
|
39
|
+
totals.affected++; totals.events += result.changedEvents
|
|
40
|
+
if (result.written) totals.repaired++
|
|
41
|
+
console.log(JSON.stringify({ path, events: result.changedEvents, written: result.written, backup: result.backup }))
|
|
42
|
+
} catch (error) {
|
|
43
|
+
totals.failed++
|
|
44
|
+
console.error(JSON.stringify({ path, error: error.message }))
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
console.log(JSON.stringify(totals))
|
|
48
|
+
if (totals.failed) process.exitCode = 1
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
main().catch(error => { console.error(error.message); process.exitCode = 1 })
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/** Canonical committed artifacts only: exclude backups, temporary files and aliases. */
|
|
5
|
+
export function sessionLogGeneration(name) {
|
|
6
|
+
const match = /^session(?:\.v([1-9]\d*))?\.jsonl(\.zstd)?$/.exec(name)
|
|
7
|
+
if (!match) return null
|
|
8
|
+
const version = Number(match[1] ?? 0)
|
|
9
|
+
return Number.isSafeInteger(version) ? { version, compressed: Boolean(match[2]) } : null
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function listSessionLogFiles(root, onlySessionIds = null, allGenerations = false) {
|
|
13
|
+
const entries = path => { try { return readdirSync(path, { withFileTypes: true }) } catch { return [] } }
|
|
14
|
+
const paths = []
|
|
15
|
+
for (const project of entries(root)) {
|
|
16
|
+
if (!project.isDirectory()) continue
|
|
17
|
+
for (const session of entries(join(root, project.name))) {
|
|
18
|
+
if (!session.isDirectory() || (onlySessionIds !== null && !onlySessionIds.has(session.name))) continue
|
|
19
|
+
const directory = join(root, project.name, session.name)
|
|
20
|
+
const candidates = entries(directory).filter(entry => entry.isFile()).flatMap(entry => {
|
|
21
|
+
const generation = sessionLogGeneration(entry.name)
|
|
22
|
+
return generation ? [{ name: entry.name, ...generation }] : []
|
|
23
|
+
}).sort((a, b) => b.version - a.version || Number(b.compressed) - Number(a.compressed))
|
|
24
|
+
// DSH keeps older generations after migration; they share the same event prefix.
|
|
25
|
+
// Billing uses exactly one generation per session, with numeric v10 > v3 ordering.
|
|
26
|
+
for (const entry of allGenerations ? candidates : candidates.slice(0, 1)) paths.push(join(directory, entry.name))
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return paths
|
|
30
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/** Offline repair for v1.7.21's unknown native-search events. Never called at startup. */
|
|
2
|
+
import { open, lstat, realpath, copyFile, rename, unlink } from 'node:fs/promises'
|
|
3
|
+
import { createReadStream, constants } from 'node:fs'
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
5
|
+
import { dirname, basename, resolve } from 'node:path'
|
|
6
|
+
import * as zlib from 'node:zlib'
|
|
7
|
+
import { scanZstdFrames } from './backfill.js'
|
|
8
|
+
import { NATIVE_SEARCH_USAGE_EVENT, isNativeSearchUsageEvent } from './native-search-events.js'
|
|
9
|
+
import { sessionLogGeneration } from './session-log-files.js'
|
|
10
|
+
|
|
11
|
+
const MAX_FRAME = 64 * 1024 * 1024
|
|
12
|
+
const MAX_TOTAL = 4 * 1024 * 1024 * 1024
|
|
13
|
+
const identity = info => [info.dev, info.ino, info.size, info.mtimeNs, info.ctimeNs].join(':')
|
|
14
|
+
|
|
15
|
+
async function canonicalLogPath(path) {
|
|
16
|
+
// realpath also expands Windows 8.3 names (e.g. RUNNER~1), which are ordinary
|
|
17
|
+
// aliases, not symbolic links. Check links explicitly, then use one physical
|
|
18
|
+
// identity for validation. The host lock can still depend on the supplied spelling.
|
|
19
|
+
for (let current = path; ; current = dirname(current)) {
|
|
20
|
+
if ((await lstat(current)).isSymbolicLink()) throw new Error('不修复符号链接或目录链接中的日志')
|
|
21
|
+
if (dirname(current) === current) break
|
|
22
|
+
}
|
|
23
|
+
return realpath(path)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function hashFile(path) {
|
|
27
|
+
const hash = createHash('sha256')
|
|
28
|
+
for await (const bytes of createReadStream(path)) hash.update(bytes)
|
|
29
|
+
return hash.digest('hex')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Copy untouched compressed frames byte for byte; join only frames that split a row. */
|
|
33
|
+
async function* logGroups(path, compressed) {
|
|
34
|
+
let tail = Buffer.alloc(0), rawParts = [], plainParts = [], groupSize = 0, rawSize = 0, total = 0
|
|
35
|
+
const group = (raw, plain) => {
|
|
36
|
+
total += plain.length
|
|
37
|
+
groupSize += plain.length
|
|
38
|
+
rawSize += raw.length
|
|
39
|
+
if (groupSize > MAX_FRAME || rawSize > MAX_FRAME || total > MAX_TOTAL) throw new Error('会话日志超过修复内存预算,原文件未改动')
|
|
40
|
+
rawParts.push(raw); plainParts.push(plain)
|
|
41
|
+
if (plain.length === 0 || plain.at(-1) !== 10) return null
|
|
42
|
+
const out = { raw: Buffer.concat(rawParts, rawSize), plain: Buffer.concat(plainParts, groupSize) }
|
|
43
|
+
rawParts = []; plainParts = []; groupSize = 0; rawSize = 0
|
|
44
|
+
return out
|
|
45
|
+
}
|
|
46
|
+
for await (const bytes of createReadStream(path, { highWaterMark: 1024 * 1024 })) {
|
|
47
|
+
const data = tail.length ? Buffer.concat([tail, bytes]) : bytes
|
|
48
|
+
if (!compressed) {
|
|
49
|
+
const end = data.lastIndexOf(10) + 1
|
|
50
|
+
if (end > MAX_FRAME) throw new Error('会话日志行过大,原文件未改动')
|
|
51
|
+
if (end) yield { raw: data.subarray(0, end), plain: data.subarray(0, end) }
|
|
52
|
+
tail = Buffer.from(data.subarray(end))
|
|
53
|
+
total += end
|
|
54
|
+
if (total > MAX_TOTAL || tail.length > MAX_FRAME) throw new Error('会话日志超过修复内存预算,原文件未改动')
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
const frames = scanZstdFrames(data)
|
|
58
|
+
for (const frame of frames) {
|
|
59
|
+
const raw = data.subarray(frame.start, frame.end)
|
|
60
|
+
const plain = zlib.zstdDecompressSync(raw, { maxOutputLength: MAX_FRAME })
|
|
61
|
+
const out = group(raw, plain)
|
|
62
|
+
if (out) yield out
|
|
63
|
+
}
|
|
64
|
+
tail = Buffer.from(data.subarray(frames.at(-1)?.end ?? 0))
|
|
65
|
+
if (tail.length > MAX_FRAME) throw new Error('Zstandard 帧损坏或过大,原文件未改动')
|
|
66
|
+
}
|
|
67
|
+
if (compressed && tail.length) throw new Error('Zstandard 尾帧不完整或损坏,原文件未改动')
|
|
68
|
+
if (plainParts.length || tail.length) throw new Error('会话日志末行未完整落盘,原文件未改动')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function repairGroup(bytes, state) {
|
|
72
|
+
const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
|
|
73
|
+
const lines = text.split('\n')
|
|
74
|
+
let changes = 0
|
|
75
|
+
for (let i = 0; i < lines.length - 1; i++) {
|
|
76
|
+
const line = lines[i]
|
|
77
|
+
if (state.rows++ === 0) {
|
|
78
|
+
const header = JSON.parse(line)
|
|
79
|
+
if (header?.type !== 'session' || typeof header.id !== 'string' || !Number.isSafeInteger(header.createdAt)) throw new Error('会话日志缺少合法头记录,原文件未改动')
|
|
80
|
+
state.sessionId = header.id
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
if (!line.includes(NATIVE_SEARCH_USAGE_EVENT)) continue
|
|
84
|
+
const row = JSON.parse(line)
|
|
85
|
+
if (row?.type !== NATIVE_SEARCH_USAGE_EVENT || row.ignorable === true) continue
|
|
86
|
+
if (!isNativeSearchUsageEvent(row) || !Number.isSafeInteger(row.seq) || row.seq < 0 || !Number.isSafeInteger(row.time) || row.time <= 0) throw new Error('原生搜索记录形态异常,未自动修改')
|
|
87
|
+
if (Object.hasOwn(row, 'ignorable')) lines[i] = JSON.stringify({ ...row, ignorable: true })
|
|
88
|
+
else {
|
|
89
|
+
// Preserve every existing byte in the affected row when adding the missing marker.
|
|
90
|
+
const end = line.lastIndexOf('}')
|
|
91
|
+
lines[i] = `${line.slice(0, end)},"ignorable":true${line.slice(end)}`
|
|
92
|
+
}
|
|
93
|
+
changes++
|
|
94
|
+
}
|
|
95
|
+
state.changedEvents += changes
|
|
96
|
+
return changes ? Buffer.from(lines.join('\n')) : null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function writeAll(handle, bytes) {
|
|
100
|
+
let offset = 0
|
|
101
|
+
while (offset < bytes.length) offset += (await handle.write(bytes, offset, bytes.length - offset)).bytesWritten
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Default is read-only. Writes require the actual host's kernel Session lease.
|
|
106
|
+
* Each replacement keeps an exclusive byte-identical backup and verifies source identity.
|
|
107
|
+
*/
|
|
108
|
+
export async function repairSessionLog(path, { write = false, acquireLease } = {}) {
|
|
109
|
+
path = resolve(path)
|
|
110
|
+
const generation = sessionLogGeneration(basename(path))
|
|
111
|
+
if (!generation) throw new Error('只接受规范的 session[.vN].jsonl[.zstd] 文件')
|
|
112
|
+
if (generation.compressed && (typeof zlib.zstdDecompressSync !== 'function' || typeof zlib.zstdCompressSync !== 'function')) throw new Error('修复 Zstandard 日志需要 Node 22.15+ 或 Node 24')
|
|
113
|
+
const supplied = await lstat(path, { bigint: true })
|
|
114
|
+
if (!supplied.isFile() || supplied.isSymbolicLink()) throw new Error('不修复符号链接或非普通日志文件')
|
|
115
|
+
const physicalPath = await canonicalLogPath(path)
|
|
116
|
+
const initial = await lstat(physicalPath, { bigint: true })
|
|
117
|
+
if (identity(initial) !== identity(supplied)) throw new Error('日志在路径解析期间已变化,未自动修改')
|
|
118
|
+
const state = { path, rows: 0, sessionId: '', changedEvents: 0, written: false, backup: null }
|
|
119
|
+
const leases = []
|
|
120
|
+
let output, temporary
|
|
121
|
+
try {
|
|
122
|
+
if (write) {
|
|
123
|
+
if (typeof acquireLease !== 'function') throw new Error('写入修复需要宿主会话锁,请使用带 @deepseek-ai/dsh 的修复命令')
|
|
124
|
+
// Windows host semaphores hash resolve(path), without expanding 8.3 aliases.
|
|
125
|
+
// Hold both supplied and physical names so either host spelling is protected.
|
|
126
|
+
const directories = [dirname(path)]
|
|
127
|
+
if (process.platform === 'win32' && dirname(path).toLowerCase() !== dirname(physicalPath).toLowerCase()) directories.push(dirname(physicalPath))
|
|
128
|
+
for (const directory of directories) {
|
|
129
|
+
const lease = await acquireLease(directory, basename(dirname(path)))
|
|
130
|
+
leases.push(lease)
|
|
131
|
+
if (typeof lease?.release !== 'function') throw new Error('宿主未提供可释放的会话锁,停止修复')
|
|
132
|
+
}
|
|
133
|
+
temporary = `${path}.cost-meter-repair-${randomUUID()}.tmp`
|
|
134
|
+
output = await open(temporary, 'wx', Number(initial.mode & 0o777n))
|
|
135
|
+
}
|
|
136
|
+
const before = await lstat(path, { bigint: true })
|
|
137
|
+
if (identity(before) !== identity(initial)) throw new Error('日志在取得锁之前已变化,请退出宿主后重试')
|
|
138
|
+
const sourceHash = createHash('sha256')
|
|
139
|
+
for await (const group of logGroups(path, generation.compressed)) {
|
|
140
|
+
sourceHash.update(group.raw)
|
|
141
|
+
const updated = repairGroup(group.plain, state)
|
|
142
|
+
if (output) {
|
|
143
|
+
const bytes = !updated ? group.raw : generation.compressed ? zlib.zstdCompressSync(updated, {
|
|
144
|
+
params: { [zlib.constants.ZSTD_c_checksumFlag]: 1 },
|
|
145
|
+
}) : updated
|
|
146
|
+
await writeAll(output, bytes)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (state.rows === 0) throw new Error('空日志,未自动修改')
|
|
150
|
+
const expectedHash = sourceHash.digest('hex')
|
|
151
|
+
if (identity(await lstat(path, { bigint: true })) !== identity(before)) throw new Error('日志在扫描期间已变化,请退出宿主后重试')
|
|
152
|
+
if (output && state.changedEvents) {
|
|
153
|
+
await output.sync()
|
|
154
|
+
await output.close(); output = null
|
|
155
|
+
const backup = `${path}.cost-meter-backup-${Date.now()}-${randomUUID()}`
|
|
156
|
+
await copyFile(path, backup, constants.COPYFILE_EXCL)
|
|
157
|
+
state.backup = backup
|
|
158
|
+
const backupHandle = await open(backup, 'r+')
|
|
159
|
+
try { await backupHandle.sync() } finally { await backupHandle.close() }
|
|
160
|
+
if (await hashFile(backup) !== expectedHash || await hashFile(path) !== expectedHash || identity(await lstat(path, { bigint: true })) !== identity(before)) throw new Error('日志或备份在写入前发生变化,原文件未替换')
|
|
161
|
+
await rename(temporary, path)
|
|
162
|
+
temporary = null
|
|
163
|
+
state.written = true
|
|
164
|
+
if (process.platform !== 'win32') {
|
|
165
|
+
const directory = await open(dirname(path), 'r')
|
|
166
|
+
try { await directory.sync() } finally { await directory.close() }
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return state
|
|
170
|
+
} finally {
|
|
171
|
+
if (output) await output.close()
|
|
172
|
+
if (temporary) await unlink(temporary).catch(() => {})
|
|
173
|
+
for (const lease of leases.reverse()) await lease?.release?.()
|
|
174
|
+
}
|
|
175
|
+
}
|
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-cost-meter",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.22",
|
|
4
4
|
"description": "DeepSeek Harness 会话费用统计插件:本会话成本、当日费用、历史记录与官方价格同步,支持多厂商多模型价格计费(内置 90+ 模型价格目录与自动匹配)、主流 Coding Plan 订阅额度查询与显示(9 家,含 SCNet Token Plan 本地 Credits 计量与火山方舟 Volcano Ark Coding Plan AK/SK 签名查询)、自定义 Provider 余额查询(可配任意 HTTP 端点)与余额进度条、峰谷计价时段显示与切换前弹窗/系统通知提醒(位置/提前量/类型可配),界面中英双语。Session cost tracking plugin for DeepSeek Harness: per-conversation cost, daily totals, history and official price sync, with multi-vendor model pricing (built-in 90+ model catalog with auto-matching), Coding Plan quota queries & display for 9 vendors (incl. local credits metering for the SCNet Token Plan and Volcano Ark Coding Plan via AK/SK signature), plan/API dual-track billing with per-1% & full-window Token Plan usage estimates, custom provider balance lookup (configurable HTTP endpoint) and balance progress bar, plus peak/off-peak pricing display with pre-switch popup & system-notification alerts (position / lead time / type configurable), in a bilingual (Chinese/English) UI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
|
+
"bin": { "dsh-cost-meter-repair-sessions": "./lib/repair-sessions-cli.js" },
|
|
7
8
|
"exports": {
|
|
8
9
|
".": {
|
|
9
10
|
"default": "./lib/index.js"
|