dsh-tacit 0.2.3 → 0.3.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/README.md +3 -3
- package/client/client.js +1739 -91
- package/docs/README.zh.md +3 -3
- package/lib/analyze.js +114 -30
- package/lib/index.js +3 -1
- package/lib/pricing-source.js +133 -0
- package/lib/pricing.js +311 -0
- package/lib/routes.js +14 -1
- package/lib/schema.js +170 -0
- package/lib/service.js +349 -60
- package/lib/store.js +163 -3
- package/lib/usage.js +708 -0
- package/package.json +1 -1
package/lib/routes.js
CHANGED
|
@@ -10,7 +10,14 @@
|
|
|
10
10
|
* into one ctx.effect so a fiber unload removes every route.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Run `fn(service)` as soon as `serviceName` exists — immediately when it is
|
|
15
|
+
* already registered, otherwise once on the next `internal/service` event for
|
|
16
|
+
* it (the listener removes itself). Exported so the service layer can wait on
|
|
17
|
+
* optional siblings (e.g. `costMeter`) the same way the routes wait on
|
|
18
|
+
* `webServer`.
|
|
19
|
+
*/
|
|
20
|
+
export function withService(ctx, serviceName, fn) {
|
|
14
21
|
const existing = ctx.get !== undefined && typeof ctx.get === 'function' ? ctx.get(serviceName) : undefined
|
|
15
22
|
if (existing !== undefined && existing !== null) {
|
|
16
23
|
fn(existing)
|
|
@@ -130,14 +137,20 @@ export function registerWebRoutes(ctx, service) {
|
|
|
130
137
|
route('POST', '/api/tacit/reports', (body) => service.getReports(body))
|
|
131
138
|
route('POST', '/api/tacit/history', (body) => service.listHistory(body))
|
|
132
139
|
route('POST', '/api/tacit/analyze', (body) => service.analyzeTurn(body))
|
|
140
|
+
route('POST', '/api/tacit/analyze-batch', (body) => service.analyzeBatch(body))
|
|
133
141
|
route('POST', '/api/tacit/improve', (body) => service.improveDraft(body))
|
|
134
142
|
route('POST', '/api/tacit/feedback', (body) => service.feedback(body))
|
|
135
143
|
route('POST', '/api/tacit/applied', (body) => service.applied(body))
|
|
136
144
|
route('POST', '/api/tacit/directives', (body) => service.directives(body))
|
|
137
145
|
route('POST', '/api/tacit/stats', (body) => service.stats(body))
|
|
138
146
|
route('POST', '/api/tacit/bootstrap', (body) => service.bootstrap(body))
|
|
147
|
+
route('POST', '/api/tacit/bootstrap-preview', (body) => service.bootstrapPreview(body))
|
|
139
148
|
route('POST', '/api/tacit/config', (body) => service.updateConfig(body))
|
|
140
149
|
route('POST', '/api/tacit/clear', () => service.clearReports())
|
|
150
|
+
route('POST', '/api/tacit/usage', (body) => service.usageReport(body))
|
|
151
|
+
route('POST', '/api/tacit/usage-run', (body) => service.usageRun(body))
|
|
152
|
+
route('POST', '/api/tacit/usage-clear', () => service.usageClear())
|
|
153
|
+
route('POST', '/api/tacit/pricing-refresh', () => service.pricingRefresh())
|
|
141
154
|
|
|
142
155
|
ctx.effect(() => () => {
|
|
143
156
|
for (const dispose of disposers.splice(0).reverse()) {
|
package/lib/schema.js
CHANGED
|
@@ -21,6 +21,16 @@ export const COACH_MODELS = ['deepseek-v4-flash', 'deepseek-v4-pro']
|
|
|
21
21
|
*/
|
|
22
22
|
export const COACH_PROVIDER = 'deepseek-official'
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* The model-call failure codes Tacit is allowed to put on the wire. The client
|
|
26
|
+
* renders `err.<code>` for whatever the envelope carries, so a raw provider
|
|
27
|
+
* code (`RATE_LIMIT`, `ERROR`, `ABORTED`, …) would surface as a literal
|
|
28
|
+
* `err.ABORTED` banner — `coachErrorCode` (`lib/service.js`) maps anything
|
|
29
|
+
* outside this list onto one of these. Every entry has an `err.*` key in both
|
|
30
|
+
* dictionaries (`client/src/10-i18n.js`, test-enforced).
|
|
31
|
+
*/
|
|
32
|
+
export const COACH_ERROR_CODES = ['no-llm', 'no-api-key', 'rate-limited', 'timeout', 'empty-response', 'call-failed']
|
|
33
|
+
|
|
24
34
|
/**
|
|
25
35
|
* The loader-facing plugin config. Wrapped in `z.preprocess` so a patch row
|
|
26
36
|
* without a `config:` block (`undefined`) resolves to all defaults — a bare
|
|
@@ -63,6 +73,12 @@ export const Config = z.preprocess((v) => v ?? {}, z.object({
|
|
|
63
73
|
bootstrapConcurrency: z.number().default(1),
|
|
64
74
|
/** Also learn from a clean turn that follows a messy one (what the user included the second time). Automatic, capped. */
|
|
65
75
|
learnFromGood: z.boolean().default(true),
|
|
76
|
+
/** Days of detailed usage-ledger day files kept before they expire (7-365, clamped in mergeConfig). */
|
|
77
|
+
costHistoryDays: z.number().default(30),
|
|
78
|
+
/** Daily USD spend that triggers the warn/exceeded cost UI; 0 disables (clamped in mergeConfig). */
|
|
79
|
+
costWarnDailyUsd: z.number().default(0),
|
|
80
|
+
/** Same as `costWarnDailyUsd`, over a calendar month; 0 disables (clamped in mergeConfig). */
|
|
81
|
+
costWarnMonthlyUsd: z.number().default(0),
|
|
66
82
|
}))
|
|
67
83
|
|
|
68
84
|
/**
|
|
@@ -88,6 +104,9 @@ const configPatchSchema = z.object({
|
|
|
88
104
|
directiveWorseBy: z.number().optional(),
|
|
89
105
|
bootstrapConcurrency: z.number().optional(),
|
|
90
106
|
learnFromGood: z.boolean().optional(),
|
|
107
|
+
costHistoryDays: z.number().optional(),
|
|
108
|
+
costWarnDailyUsd: z.number().optional(),
|
|
109
|
+
costWarnMonthlyUsd: z.number().optional(),
|
|
91
110
|
})
|
|
92
111
|
|
|
93
112
|
// ── Trajectory projection ──────────────────────────────────────────────────
|
|
@@ -288,6 +307,12 @@ export const analyzeArgSchema = z.object({
|
|
|
288
307
|
turn: z.number().int().min(1),
|
|
289
308
|
})
|
|
290
309
|
|
|
310
|
+
/** `/api/tacit/analyze-batch`: one session, up to 50 turns analyzed under a single run. */
|
|
311
|
+
export const analyzeBatchArgSchema = z.object({
|
|
312
|
+
sessionId: z.string().min(1).max(200),
|
|
313
|
+
turns: z.array(z.number().int().min(1)).min(1).max(50),
|
|
314
|
+
})
|
|
315
|
+
|
|
291
316
|
export const improveArgSchema = z.object({
|
|
292
317
|
sessionId: z.string().min(1).max(200),
|
|
293
318
|
draft: z.string().min(1).max(100000),
|
|
@@ -308,3 +333,148 @@ export const appliedArgSchema = z.object({
|
|
|
308
333
|
export const configArgSchema = z.object({
|
|
309
334
|
patch: configPatchSchema,
|
|
310
335
|
})
|
|
336
|
+
|
|
337
|
+
// ── Usage ledger (content-free: no prompts, no responses, no tool args) ────
|
|
338
|
+
|
|
339
|
+
/** Every op a metered model call can be tagged with (Task 1's sink + the distillation/enrichment calls). */
|
|
340
|
+
export const USAGE_OPS = [
|
|
341
|
+
'analysis',
|
|
342
|
+
'analysis-repair',
|
|
343
|
+
'directive-distillation',
|
|
344
|
+
'style-distillation',
|
|
345
|
+
'improve',
|
|
346
|
+
'improve-repair',
|
|
347
|
+
'enrichment',
|
|
348
|
+
]
|
|
349
|
+
|
|
350
|
+
/** Every kind of run the tracker groups attempts into. */
|
|
351
|
+
export const USAGE_RUN_TYPES = [
|
|
352
|
+
'bootstrap',
|
|
353
|
+
'analysis',
|
|
354
|
+
'analysis-batch',
|
|
355
|
+
'improve',
|
|
356
|
+
'directive-distillation',
|
|
357
|
+
'style-distillation',
|
|
358
|
+
'prompt-enrichment',
|
|
359
|
+
]
|
|
360
|
+
|
|
361
|
+
/** Raw token counts, zero-filled so totals can be summed without null checks. */
|
|
362
|
+
export const tokenBucketsSchema = z.object({
|
|
363
|
+
inputTokens: z.number().default(0),
|
|
364
|
+
outputTokens: z.number().default(0),
|
|
365
|
+
cacheReadTokens: z.number().default(0),
|
|
366
|
+
cacheWriteTokens: z.number().default(0),
|
|
367
|
+
reasoningTokens: z.number().default(0),
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* One metered model call. Mirrors the sink record `callCoachModel` hands the
|
|
372
|
+
* tracker (`startedAt`..`usage`) plus the identity fields the tracker itself
|
|
373
|
+
* assigns (`id`, `op`, `sessionId`, `turn`) and the priced result. Never
|
|
374
|
+
* carries prompt/response text, tool args, or API keys.
|
|
375
|
+
*/
|
|
376
|
+
export const usageAttemptSchema = z.object({
|
|
377
|
+
id: z.string(),
|
|
378
|
+
op: z.enum(USAGE_OPS),
|
|
379
|
+
startedAt: z.number(),
|
|
380
|
+
durationMs: z.number().default(0),
|
|
381
|
+
model: z.string().default(''),
|
|
382
|
+
provider: z.string().default(''),
|
|
383
|
+
reasoningEffort: z.string().nullable().default(null),
|
|
384
|
+
finish: z.string().default(''),
|
|
385
|
+
status: z.enum(['ok', 'failed', 'unmetered']),
|
|
386
|
+
code: z.string().default(''),
|
|
387
|
+
sessionId: z.string().default(''),
|
|
388
|
+
turn: z.number().nullable().default(null),
|
|
389
|
+
usage: tokenBucketsSchema.nullable().default(null),
|
|
390
|
+
/** null when no price table matched the route/model (e.g. a proxy provider). */
|
|
391
|
+
priced: z.object({
|
|
392
|
+
source: z.enum(['bundled', 'costMeter']),
|
|
393
|
+
tier: z.string(),
|
|
394
|
+
rates: z.object({ cacheHit: z.number(), cacheMiss: z.number(), output: z.number() }),
|
|
395
|
+
asOf: z.string(),
|
|
396
|
+
usd: z.number(),
|
|
397
|
+
}).nullable().default(null),
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* A precomputed, already-defaulted instance of a nested object schema.
|
|
402
|
+
* zod's `.default(value)` injects `value` verbatim when a field is absent —
|
|
403
|
+
* it does NOT re-run `value` through the schema — so a literal `{}` default
|
|
404
|
+
* on a nested object would skip that object's own field defaults. Passing
|
|
405
|
+
* `schema.parse({})` instead gives the same "all defaults" shape correctly.
|
|
406
|
+
*/
|
|
407
|
+
const emptyTokenBuckets = tokenBucketsSchema.parse({})
|
|
408
|
+
|
|
409
|
+
/** Aggregate counters shared by a run's totals, the lifetime summary, and every summary bucket. */
|
|
410
|
+
export const usageTotalsSchema = z.object({
|
|
411
|
+
attempts: z.number().default(0),
|
|
412
|
+
billedCalls: z.number().default(0),
|
|
413
|
+
unmeteredCalls: z.number().default(0),
|
|
414
|
+
unpricedCalls: z.number().default(0),
|
|
415
|
+
tokens: tokenBucketsSchema.default(emptyTokenBuckets),
|
|
416
|
+
usdKnown: z.number().default(0),
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
const emptyUsageTotals = usageTotalsSchema.parse({})
|
|
420
|
+
|
|
421
|
+
/** One tracker run: a group of attempts sharing a trigger (a single call, an auto-analysis, a bootstrap batch, ...). */
|
|
422
|
+
export const usageRunSchema = z.object({
|
|
423
|
+
runId: z.string(),
|
|
424
|
+
type: z.enum(USAGE_RUN_TYPES),
|
|
425
|
+
trigger: z.string().default(''),
|
|
426
|
+
startedAt: z.number(),
|
|
427
|
+
endedAt: z.number().default(0),
|
|
428
|
+
status: z.enum(['running', 'success', 'partial', 'failed']).default('running'),
|
|
429
|
+
sessionId: z.string().default(''),
|
|
430
|
+
turn: z.number().nullable().default(null),
|
|
431
|
+
workspace: z.string().default(''),
|
|
432
|
+
model: z.string().default(''),
|
|
433
|
+
provider: z.string().default(''),
|
|
434
|
+
results: z.record(z.number()).default({}),
|
|
435
|
+
attempts: z.array(usageAttemptSchema).default([]),
|
|
436
|
+
totals: usageTotalsSchema.default(emptyUsageTotals),
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
/** One day's `usage/YYYY-MM-DD.json` file. */
|
|
440
|
+
export const usageDayFileSchema = z.object({
|
|
441
|
+
version: z.literal(1),
|
|
442
|
+
day: z.string(),
|
|
443
|
+
runs: z.array(usageRunSchema).default([]),
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
const usageDayTotalsSchema = usageTotalsSchema.extend({
|
|
447
|
+
byType: z.record(usageTotalsSchema).default({}),
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
/** `usage/summary.json`: rolling totals kept alongside the day files so reports never have to re-scan every day. */
|
|
451
|
+
export const usageSummarySchema = z.object({
|
|
452
|
+
version: z.literal(1),
|
|
453
|
+
trackingSince: z.number(),
|
|
454
|
+
lifetime: usageTotalsSchema.default(emptyUsageTotals),
|
|
455
|
+
byType: z.record(usageTotalsSchema).default({}),
|
|
456
|
+
byModel: z.record(usageTotalsSchema).default({}),
|
|
457
|
+
days: z.record(usageDayTotalsSchema).default({}),
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Arguments for `/api/tacit/usage`. Declared here (not next to
|
|
462
|
+
* `bootstrapArgSchema`) because `z.enum(USAGE_RUN_TYPES)` needs the run-type
|
|
463
|
+
* list above it. Every field is optional on the wire; `tracker.report()`
|
|
464
|
+
* applies the defaults (`range: '30d'`, `page: 1`, `pageSize: 20`).
|
|
465
|
+
*/
|
|
466
|
+
export const usageArgSchema = z.object({
|
|
467
|
+
range: z.enum(['today', '7d', '30d', 'month', 'all']).optional(),
|
|
468
|
+
type: z.enum(USAGE_RUN_TYPES).optional(),
|
|
469
|
+
status: z.enum(['success', 'partial', 'failed']).optional(),
|
|
470
|
+
model: z.string().max(64).optional(),
|
|
471
|
+
workspace: z.string().max(200).optional(),
|
|
472
|
+
sessionId: z.string().max(200).optional(),
|
|
473
|
+
page: z.number().int().min(1).max(1000).optional(),
|
|
474
|
+
pageSize: z.number().int().min(1).max(100).optional(),
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
/** Arguments for `/api/tacit/usage-run`: one run id, as minted by `beginRun`. */
|
|
478
|
+
export const usageRunArgSchema = z.object({
|
|
479
|
+
runId: z.string().min(1).max(64),
|
|
480
|
+
})
|