@gamaze/hicortex 0.17.1 → 0.17.2
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 +2 -1
- package/assets/dashboard.html +79 -0
- package/dist/cli.d.ts +3 -2
- package/dist/cli.js +10 -3
- package/dist/consolidate.d.ts +67 -1
- package/dist/consolidate.js +198 -16
- package/dist/dashboard.d.ts +71 -2
- package/dist/dashboard.js +36 -1
- package/dist/distiller.js +1 -1
- package/dist/domain-classify.d.ts +8 -2
- package/dist/domain-classify.js +19 -5
- package/dist/init.js +1 -1
- package/dist/llm.d.ts +52 -4
- package/dist/llm.js +65 -5
- package/dist/nightly.d.ts +7 -0
- package/dist/nightly.js +248 -121
- package/dist/state.d.ts +22 -0
- package/dist/telemetry.d.ts +12 -2
- package/dist/types.d.ts +52 -0
- package/hermes-plugin/hicortex/README.md +1 -1
- package/package.json +1 -1
package/dist/dashboard.d.ts
CHANGED
|
@@ -38,6 +38,27 @@ export interface DashboardMetrics {
|
|
|
38
38
|
lessonsGenerated?: number;
|
|
39
39
|
dedup: number;
|
|
40
40
|
supersession: number;
|
|
41
|
+
/** Memories evicted by the capacity stage this run (#245). 0 when under
|
|
42
|
+
* cap; undefined on backfill rows (a stage outcome, not reconstructable). */
|
|
43
|
+
evicted?: number;
|
|
44
|
+
/**
|
|
45
|
+
* Total LLM tokens consumed by this run's consolidation (#246). Undefined
|
|
46
|
+
* in lockstep with `tokens_by_stage` (and on backfill rows, which can't
|
|
47
|
+
* reconstruct a per-run meter).
|
|
48
|
+
*/
|
|
49
|
+
tokens?: number;
|
|
50
|
+
/** Per-stage breakdown of `tokens` (#246). Undefined on backfill rows. */
|
|
51
|
+
tokens_by_stage?: Record<string, {
|
|
52
|
+
prompt: number;
|
|
53
|
+
completion: number;
|
|
54
|
+
total: number;
|
|
55
|
+
}>;
|
|
56
|
+
};
|
|
57
|
+
/** Corpus capacity (#245). `memory_soft_cap` is the configured ceiling (0 =
|
|
58
|
+
* disabled); always present in real snapshots, undefined on backfilled
|
|
59
|
+
* rows (the historical config isn't recoverable from created_at). */
|
|
60
|
+
capacity?: {
|
|
61
|
+
memory_soft_cap: number;
|
|
41
62
|
};
|
|
42
63
|
/** Recall adoption aggregate. Null in backfilled rows. uses_per_showing is
|
|
43
64
|
* null when shown_sum = 0 (divide-by-zero guard). */
|
|
@@ -55,12 +76,28 @@ export interface DashboardSnapshot {
|
|
|
55
76
|
}
|
|
56
77
|
/** The /dashboard/data response — the full payload the page renders. */
|
|
57
78
|
export interface DashboardData {
|
|
58
|
-
range: "7d" | "30d" | "90d" | "all";
|
|
59
79
|
headline: {
|
|
60
80
|
total_memories: number;
|
|
61
81
|
uses_per_showing: number | null;
|
|
62
82
|
cold_count: number;
|
|
83
|
+
/** Corpus vs cap (#245). `memory_soft_cap` is 0 when the cap is disabled
|
|
84
|
+
* (indefinite growth); the page renders no gauge in that case. */
|
|
85
|
+
memory_soft_cap: number;
|
|
86
|
+
/**
|
|
87
|
+
* LLM token usage this billing period (#246). `used` is the running total
|
|
88
|
+
* (state.llmTokensThisPeriod.total after monthly reset); `cap` is the
|
|
89
|
+
* configured `llmTokensPerMonth` (0 = unlimited, page renders no cap).
|
|
90
|
+
* `used` is 0 when no consolidation has metered tokens yet — the page
|
|
91
|
+
* hides the stat in that case. `period_start` is the ISO timestamp of the
|
|
92
|
+
* current accrual period (for the "X this month" label).
|
|
93
|
+
*/
|
|
94
|
+
tokens: {
|
|
95
|
+
used: number;
|
|
96
|
+
cap: number;
|
|
97
|
+
period_start: string | null;
|
|
98
|
+
};
|
|
63
99
|
};
|
|
100
|
+
range: "7d" | "30d" | "90d" | "all";
|
|
64
101
|
series: DashboardSnapshot[];
|
|
65
102
|
composition: {
|
|
66
103
|
by_type: Record<string, number>;
|
|
@@ -85,6 +122,15 @@ export interface DashboardData {
|
|
|
85
122
|
dedup: number;
|
|
86
123
|
supersession: number;
|
|
87
124
|
added: number;
|
|
125
|
+
evicted?: number;
|
|
126
|
+
/** Total tokens consumed that run (#246). Undefined = no metered run. */
|
|
127
|
+
tokens?: number;
|
|
128
|
+
/** Per-stage breakdown of `tokens` (#246). */
|
|
129
|
+
tokens_by_stage?: Record<string, {
|
|
130
|
+
prompt: number;
|
|
131
|
+
completion: number;
|
|
132
|
+
total: number;
|
|
133
|
+
}>;
|
|
88
134
|
};
|
|
89
135
|
dedup_merges: {
|
|
90
136
|
loser_id: string;
|
|
@@ -105,14 +151,37 @@ export interface NightlyDelta {
|
|
|
105
151
|
lessonsGenerated?: number;
|
|
106
152
|
dedup: number;
|
|
107
153
|
supersession: number;
|
|
154
|
+
/** Memories evicted by the capacity stage this run (#245). */
|
|
155
|
+
evicted?: number;
|
|
156
|
+
/**
|
|
157
|
+
* Total LLM tokens consumed by this run's consolidation (#246). Undefined
|
|
158
|
+
* when consolidation didn't run (capture-only / no_llm / throttled / skipped)
|
|
159
|
+
* or made no metered calls. Stamped into the snapshot so the dashboard can
|
|
160
|
+
* show a usage trend.
|
|
161
|
+
*/
|
|
162
|
+
tokensThisRun?: number;
|
|
163
|
+
/**
|
|
164
|
+
* Per-stage breakdown of `tokensThisRun` (#246) — same shape as
|
|
165
|
+
* ConsolidationReport.budget.tokens_by_stage. Undefined in lockstep with
|
|
166
|
+
* `tokensThisRun`.
|
|
167
|
+
*/
|
|
168
|
+
tokensByStage?: Record<string, {
|
|
169
|
+
prompt: number;
|
|
170
|
+
completion: number;
|
|
171
|
+
total: number;
|
|
172
|
+
}>;
|
|
108
173
|
}
|
|
109
174
|
/**
|
|
110
175
|
* Write one snapshot row for `runAt` (an ISO timestamp the caller chooses —
|
|
111
176
|
* nightly.ts passes `now`). OR-replace on the PRIMARY KEY is intentional: a
|
|
112
177
|
* manual re-run for the same instant overwrites, the nightly never produces
|
|
113
178
|
* two rows for the same instant. Returns the row that was written.
|
|
179
|
+
*
|
|
180
|
+
* `memorySoftCap` (#245) is the resolved cap (0 = disabled) from the config;
|
|
181
|
+
* it is stamped into `metrics.capacity` so a historical snapshot records what
|
|
182
|
+
* cap produced its eviction count. Optional for callers that don't track it.
|
|
114
183
|
*/
|
|
115
|
-
export declare function writeSnapshot(db: Database.Database, runAt: string, delta: NightlyDelta): DashboardSnapshot;
|
|
184
|
+
export declare function writeSnapshot(db: Database.Database, runAt: string, delta: NightlyDelta, memorySoftCap?: number): DashboardSnapshot;
|
|
116
185
|
/**
|
|
117
186
|
* When the dashboard_snapshots table is empty, synthesize one row per day from
|
|
118
187
|
* existing memories. Idempotent (only runs when the table is empty — the
|
package/dist/dashboard.js
CHANGED
|
@@ -27,6 +27,8 @@ exports.handleDashboardData = handleDashboardData;
|
|
|
27
27
|
exports.dashboardDataHandler = dashboardDataHandler;
|
|
28
28
|
const recall_index_js_1 = require("./recall-index.js");
|
|
29
29
|
const config_read_js_1 = require("./config-read.js");
|
|
30
|
+
const consolidate_js_1 = require("./consolidate.js");
|
|
31
|
+
const state_js_1 = require("./state.js");
|
|
30
32
|
// ---------------------------------------------------------------------------
|
|
31
33
|
// Metric computation — one SELECT each, prepared inline. Pure: takes a db,
|
|
32
34
|
// returns a value. No side effects, no I/O beyond the open db handle.
|
|
@@ -83,15 +85,28 @@ function computeDashboardMetrics(db) {
|
|
|
83
85
|
* nightly.ts passes `now`). OR-replace on the PRIMARY KEY is intentional: a
|
|
84
86
|
* manual re-run for the same instant overwrites, the nightly never produces
|
|
85
87
|
* two rows for the same instant. Returns the row that was written.
|
|
88
|
+
*
|
|
89
|
+
* `memorySoftCap` (#245) is the resolved cap (0 = disabled) from the config;
|
|
90
|
+
* it is stamped into `metrics.capacity` so a historical snapshot records what
|
|
91
|
+
* cap produced its eviction count. Optional for callers that don't track it.
|
|
86
92
|
*/
|
|
87
|
-
function writeSnapshot(db, runAt, delta) {
|
|
93
|
+
function writeSnapshot(db, runAt, delta, memorySoftCap) {
|
|
88
94
|
const metrics = computeDashboardMetrics(db);
|
|
89
95
|
metrics.new_this_run = {
|
|
90
96
|
added: delta.added,
|
|
91
97
|
lessonsGenerated: delta.lessonsGenerated,
|
|
92
98
|
dedup: delta.dedup,
|
|
93
99
|
supersession: delta.supersession,
|
|
100
|
+
evicted: delta.evicted,
|
|
101
|
+
// #246: forward only when consolidation actually metered tokens this run.
|
|
102
|
+
// Absent on capture-only / throttled / no-LLM / no-metered-call runs — the
|
|
103
|
+
// page treats undefined as "no data for this day", matching adoption.
|
|
104
|
+
...(delta.tokensThisRun !== undefined ? { tokens: delta.tokensThisRun } : {}),
|
|
105
|
+
...(delta.tokensByStage !== undefined ? { tokens_by_stage: delta.tokensByStage } : {}),
|
|
94
106
|
};
|
|
107
|
+
if (memorySoftCap !== undefined) {
|
|
108
|
+
metrics.capacity = { memory_soft_cap: memorySoftCap };
|
|
109
|
+
}
|
|
95
110
|
db.prepare("INSERT OR REPLACE INTO dashboard_snapshots (run_at, metrics) VALUES (?, ?)").run(runAt, JSON.stringify(metrics));
|
|
96
111
|
return { run_at: runAt, metrics };
|
|
97
112
|
}
|
|
@@ -295,10 +310,25 @@ function handleDashboardData(db, query, config) {
|
|
|
295
310
|
// Live composition (so day-one with no snapshots still shows the corpus).
|
|
296
311
|
const live = computeDashboardMetrics(db);
|
|
297
312
|
// Headline = live corpus (the chart shows history; the headline shows now).
|
|
313
|
+
// `memory_soft_cap` (#245): resolve from the live config (the source of
|
|
314
|
+
// truth for "what cap is in force right now"), defaulting to the production
|
|
315
|
+
// default. The page renders no gauge when it's 0 (disabled).
|
|
316
|
+
//
|
|
317
|
+
// `tokens` (#246): period accrual from state.json (the same single source
|
|
318
|
+
// the throttle check reads, so the dashboard always agrees with the runtime
|
|
319
|
+
// decision). The cap is `llmTokensPerMonth` (0 = unlimited). The page hides
|
|
320
|
+
// the stat entirely when `used` is 0 (no metered run yet).
|
|
321
|
+
const tokenState = (0, state_js_1.loadState)().llmTokensThisPeriod;
|
|
298
322
|
const headline = {
|
|
299
323
|
total_memories: live.totals.mem,
|
|
300
324
|
uses_per_showing: live.adoption?.uses_per_showing ?? null,
|
|
301
325
|
cold_count: live.adoption?.cold_count ?? 0,
|
|
326
|
+
memory_soft_cap: (0, config_read_js_1.readNonNegativeConfig)(config ?? {}, "memorySoftCap", consolidate_js_1.DEFAULT_MEMORY_SOFT_CAP),
|
|
327
|
+
tokens: {
|
|
328
|
+
used: tokenState?.total ?? 0,
|
|
329
|
+
cap: (0, config_read_js_1.readNonNegativeConfig)(config ?? {}, "llmTokensPerMonth", 0),
|
|
330
|
+
period_start: tokenState?.periodStart ?? null,
|
|
331
|
+
},
|
|
302
332
|
};
|
|
303
333
|
// Digest: pick the day to summarize. `date` (YYYY-MM-DD) wins; else the most
|
|
304
334
|
// recent snapshot's day (real nightly OR backfill — both carry valid ISO
|
|
@@ -369,6 +399,11 @@ function handleDashboardData(db, query, config) {
|
|
|
369
399
|
dedup: dayMetrics?.new_this_run?.dedup ?? dedupRows.length,
|
|
370
400
|
supersession: dayMetrics?.new_this_run?.supersession ?? supersessionCount,
|
|
371
401
|
added: dayMetrics?.new_this_run?.added ?? sampleRows.length,
|
|
402
|
+
evicted: dayMetrics?.new_this_run?.evicted,
|
|
403
|
+
// #246: only present when the day's nightly metered tokens. Both fields
|
|
404
|
+
// are forwarded together — the page renders either the breakdown or nothing.
|
|
405
|
+
tokens: dayMetrics?.new_this_run?.tokens,
|
|
406
|
+
tokens_by_stage: dayMetrics?.new_this_run?.tokens_by_stage,
|
|
372
407
|
},
|
|
373
408
|
dedup_merges: dedupRows.map((r) => ({
|
|
374
409
|
loser_id: r.loser_id,
|
package/dist/distiller.js
CHANGED
|
@@ -312,7 +312,7 @@ async function distillChunk(llm, transcript, projectName, date) {
|
|
|
312
312
|
// failures, 4xx/5xx, model-not-found, timeouts) propagate up to the caller
|
|
313
313
|
// so the nightly pipeline can treat them as "retry later" instead of
|
|
314
314
|
// "processed successfully with zero extractions".
|
|
315
|
-
const result = await llm.completeDistill(prompt);
|
|
315
|
+
const { text: result } = await llm.completeDistill(prompt);
|
|
316
316
|
if (!result)
|
|
317
317
|
return { entries: [], dropped: [] };
|
|
318
318
|
if (result === "NO_EXTRACT" || result.slice(0, 20).includes("NO_EXTRACT")) {
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
* Work, Ventures, Hardware, Finances, Property, Vehicles, Boating, Health,
|
|
51
51
|
* Family, People, Travel
|
|
52
52
|
*/
|
|
53
|
-
import type { LlmClient } from "./llm.js";
|
|
53
|
+
import type { LlmClient, LlmUsage } from "./llm.js";
|
|
54
54
|
import type { DomainDef } from "./types.js";
|
|
55
55
|
export type { DomainDef };
|
|
56
56
|
/** Max characters of memory content fed to the classifier prompt. */
|
|
@@ -160,5 +160,11 @@ export declare function parseTagReply(reply: string, domains: DomainDef[]): TagR
|
|
|
160
160
|
*
|
|
161
161
|
* @returns {tags} on success (all members of the vocabulary; empty = no-fit),
|
|
162
162
|
* or null on infra error.
|
|
163
|
+
*
|
|
164
|
+
* `onUsage` (#246) is an optional callback fired ONCE with the LlmUsage from
|
|
165
|
+
* the SUCCESSFUL attempt (the one whose reply parsed). Throwing/unparseable
|
|
166
|
+
* attempts don't fire it — only the call that produced the kept result is
|
|
167
|
+
* metered. The consolidation caller wires this to BudgetTracker.recordUsage;
|
|
168
|
+
* the CLI caller (classify-domains) has no tracker and omits it.
|
|
163
169
|
*/
|
|
164
|
-
export declare function classifyMemoryTags(content: string, project: string | null | undefined, domains: DomainDef[], llm: LlmClient): Promise<TagResult | null>;
|
|
170
|
+
export declare function classifyMemoryTags(content: string, project: string | null | undefined, domains: DomainDef[], llm: LlmClient, onUsage?: (usage: LlmUsage) => void): Promise<TagResult | null>;
|
package/dist/domain-classify.js
CHANGED
|
@@ -257,8 +257,14 @@ function parseTagReply(reply, domains) {
|
|
|
257
257
|
*
|
|
258
258
|
* @returns {tags} on success (all members of the vocabulary; empty = no-fit),
|
|
259
259
|
* or null on infra error.
|
|
260
|
+
*
|
|
261
|
+
* `onUsage` (#246) is an optional callback fired ONCE with the LlmUsage from
|
|
262
|
+
* the SUCCESSFUL attempt (the one whose reply parsed). Throwing/unparseable
|
|
263
|
+
* attempts don't fire it — only the call that produced the kept result is
|
|
264
|
+
* metered. The consolidation caller wires this to BudgetTracker.recordUsage;
|
|
265
|
+
* the CLI caller (classify-domains) has no tracker and omits it.
|
|
260
266
|
*/
|
|
261
|
-
async function classifyMemoryTags(content, project, domains, llm) {
|
|
267
|
+
async function classifyMemoryTags(content, project, domains, llm, onUsage) {
|
|
262
268
|
if (domains.length === 0)
|
|
263
269
|
return { tags: [] };
|
|
264
270
|
const prompt = buildClassifyPrompt(content, project, domains);
|
|
@@ -268,8 +274,19 @@ async function classifyMemoryTags(content, project, domains, llm) {
|
|
|
268
274
|
let raw;
|
|
269
275
|
try {
|
|
270
276
|
// ~64 tokens covers a short JSON object with a handful of tags.
|
|
271
|
-
|
|
277
|
+
const r = await llm.completeClassify(prompt, 64);
|
|
278
|
+
raw = r.text;
|
|
272
279
|
threw = false;
|
|
280
|
+
// Surface the usage ONLY when this attempt's reply parses (below). Hold
|
|
281
|
+
// the value across the parse check so the metered call is the one whose
|
|
282
|
+
// result is actually kept.
|
|
283
|
+
const heldUsage = r.usage;
|
|
284
|
+
const parsed = parseTagReply(raw, domains);
|
|
285
|
+
if (parsed) {
|
|
286
|
+
if (heldUsage && onUsage)
|
|
287
|
+
onUsage(heldUsage);
|
|
288
|
+
return parsed;
|
|
289
|
+
}
|
|
273
290
|
}
|
|
274
291
|
catch (err) {
|
|
275
292
|
threw = true;
|
|
@@ -278,9 +295,6 @@ async function classifyMemoryTags(content, project, domains, llm) {
|
|
|
278
295
|
console.warn(`[hicortex] tag classify LLM error: ${err instanceof Error ? err.message : String(err)} — aborting this memory (will retry)`);
|
|
279
296
|
return null; // infra error → abort untouched (never filed, never decayed)
|
|
280
297
|
}
|
|
281
|
-
const parsed = parseTagReply(raw, domains);
|
|
282
|
-
if (parsed)
|
|
283
|
-
return parsed;
|
|
284
298
|
if (attempt === 0) {
|
|
285
299
|
console.warn(`[hicortex] tag classify: unparseable reply "${raw.slice(0, 60)}" — retrying once`);
|
|
286
300
|
}
|
package/dist/init.js
CHANGED
|
@@ -246,7 +246,7 @@ function allowHicortexTools() {
|
|
|
246
246
|
// Only EDIT an existing CC settings file — never invent one. On a host with
|
|
247
247
|
// no CC client (a server, or a Hermes-only box) ~/.claude/settings.json is
|
|
248
248
|
// absent; creating a stub there is wrong, and the earlier mkdir+write was the
|
|
249
|
-
// ENOENT throw on
|
|
249
|
+
// ENOENT throw on a host with no CC client. Without this entry CC just PROMPTS before
|
|
250
250
|
// tool use instead of auto-allowing — the MCP still works either way.
|
|
251
251
|
if (!(0, node_fs_1.existsSync)(CC_SETTINGS)) {
|
|
252
252
|
console.log(` ℹ ${CC_SETTINGS} not found — skipping CC tool permissions (no CC client here; ` +
|
package/dist/llm.d.ts
CHANGED
|
@@ -108,6 +108,33 @@ export declare class RateLimitError extends Error {
|
|
|
108
108
|
retryAfterMs: number;
|
|
109
109
|
constructor(retryAfterMs: number);
|
|
110
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Token-usage triplet reported by every LLM call (#246). All three fields are
|
|
113
|
+
* populated from real API responses — no estimation, no fallback. `usage` is
|
|
114
|
+
* `undefined` ONLY when a backend genuinely returned no usage object (which
|
|
115
|
+
* should never happen on a healthy path: OpenAI-compat and Ollama both echo
|
|
116
|
+
* usage on every successful completion). Callers that record usage must treat
|
|
117
|
+
* `undefined` as "no signal this call" and skip — never as zero (zero would
|
|
118
|
+
* silently undercount a real cost).
|
|
119
|
+
*
|
|
120
|
+
* Field names mirror the OpenAI spec (`prompt_tokens` / `completion_tokens` /
|
|
121
|
+
* `total_tokens`) so the shape is parseable by anything that already speaks
|
|
122
|
+
* that API. Ollama's `prompt_eval_count` / `eval_count` are mapped at the
|
|
123
|
+
* provider boundary in completeOllama.
|
|
124
|
+
*/
|
|
125
|
+
export interface LlmUsage {
|
|
126
|
+
prompt_tokens: number;
|
|
127
|
+
completion_tokens: number;
|
|
128
|
+
total_tokens: number;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* The result of every LLM completion. `text` is the trimmed model output;
|
|
132
|
+
* `usage` is the token accounting from the API response (#246).
|
|
133
|
+
*/
|
|
134
|
+
export interface LlmResult {
|
|
135
|
+
text: string;
|
|
136
|
+
usage?: LlmUsage;
|
|
137
|
+
}
|
|
111
138
|
export declare class LlmClient {
|
|
112
139
|
private config;
|
|
113
140
|
private ollamaCallCount;
|
|
@@ -125,32 +152,43 @@ export declare class LlmClient {
|
|
|
125
152
|
* ollama flush stays (provider-gated) — it is a scoring-call-count cadence and
|
|
126
153
|
* scoring is the highest-frequency call, so this is where the flush belongs.
|
|
127
154
|
*/
|
|
128
|
-
completeFast(prompt: string, maxTokens?: number): Promise<
|
|
155
|
+
completeFast(prompt: string, maxTokens?: number): Promise<LlmResult>;
|
|
129
156
|
/**
|
|
130
157
|
* Reflect-tier completion (nightly reflection). One model serves all phases
|
|
131
158
|
* (#231) — this is a thin wrapper kept for call-site readability.
|
|
132
159
|
*/
|
|
133
|
-
completeReflect(prompt: string, maxTokens?: number): Promise<
|
|
160
|
+
completeReflect(prompt: string, maxTokens?: number): Promise<LlmResult>;
|
|
134
161
|
/**
|
|
135
162
|
* Distillation-tier completion (session knowledge extraction). One model
|
|
136
163
|
* serves all phases (#231) — thin wrapper kept for call-site readability.
|
|
137
164
|
*/
|
|
138
|
-
completeDistill(prompt: string, maxTokens?: number): Promise<
|
|
165
|
+
completeDistill(prompt: string, maxTokens?: number): Promise<LlmResult>;
|
|
139
166
|
/**
|
|
140
167
|
* Classification-tier completion (memory tag classification). One model
|
|
141
168
|
* serves all phases (#231) — thin wrapper kept for call-site readability.
|
|
142
169
|
*/
|
|
143
|
-
completeClassify(prompt: string, maxTokens?: number): Promise<
|
|
170
|
+
completeClassify(prompt: string, maxTokens?: number): Promise<LlmResult>;
|
|
144
171
|
private complete;
|
|
145
172
|
private completeOnce;
|
|
146
173
|
/**
|
|
147
174
|
* Claude CLI: shell out to `claude -p` for subscription users.
|
|
148
175
|
* No API key needed — uses CC's authenticated session.
|
|
176
|
+
*
|
|
177
|
+
* Token usage (#246): the claude CLI JSON output does not carry a token
|
|
178
|
+
* usage field, so this path returns `usage: undefined`. The CLI is billed
|
|
179
|
+
* by Claude subscription, not per-token — there is nothing to meter. The
|
|
180
|
+
* fair-use cap therefore never trips on a claude-cli install, which is the
|
|
181
|
+
* correct outcome (no meterable cost to defend against).
|
|
149
182
|
*/
|
|
150
183
|
private completeClaude;
|
|
151
184
|
/**
|
|
152
185
|
* Ollama: use /api/generate with think:false (important for qwen3.5 models).
|
|
153
186
|
* num_ctx is read from config (one value, all phases — #231; default 8192).
|
|
187
|
+
*
|
|
188
|
+
* Token usage (#246): the FINAL streamed chunk carries the per-request
|
|
189
|
+
* accounting as `prompt_eval_count` (input) + `eval_count` (output). Earlier
|
|
190
|
+
* chunks have null/zero — only the terminal chunk is meaningful, so we keep
|
|
191
|
+
* updating as chunks arrive and the last one wins.
|
|
154
192
|
*/
|
|
155
193
|
private completeOllama;
|
|
156
194
|
/**
|
|
@@ -166,11 +204,21 @@ export declare class LlmClient {
|
|
|
166
204
|
/**
|
|
167
205
|
* Anthropic Messages API (/v1/messages).
|
|
168
206
|
* Auth via x-api-key header.
|
|
207
|
+
*
|
|
208
|
+
* Token usage (#246): Anthropic's response carries `usage.input_tokens` +
|
|
209
|
+
* `usage.output_tokens`. Mapped to the OpenAI-spec field names so downstream
|
|
210
|
+
* accounting is uniform across providers.
|
|
169
211
|
*/
|
|
170
212
|
private completeAnthropic;
|
|
171
213
|
/**
|
|
172
214
|
* OpenAI-compatible /v1/chat/completions (works for OpenAI, OpenRouter, etc).
|
|
173
215
|
* enableThinking is read from config here (one value, all phases — #231).
|
|
216
|
+
*
|
|
217
|
+
* Token usage (#246): the OpenAI spec's `usage` object is always present on
|
|
218
|
+
* a successful completion — `prompt_tokens` / `completion_tokens` /
|
|
219
|
+
* `total_tokens`. The MLX gateway emits the same shape (verified v0.31.3).
|
|
220
|
+
* Parsed verbatim; absent only on a non-conforming endpoint, in which case
|
|
221
|
+
* `usage` stays undefined (no signal, never a fabricated zero).
|
|
174
222
|
*/
|
|
175
223
|
private completeOpenAiCompat;
|
|
176
224
|
}
|
package/dist/llm.js
CHANGED
|
@@ -365,6 +365,12 @@ class LlmClient {
|
|
|
365
365
|
/**
|
|
366
366
|
* Claude CLI: shell out to `claude -p` for subscription users.
|
|
367
367
|
* No API key needed — uses CC's authenticated session.
|
|
368
|
+
*
|
|
369
|
+
* Token usage (#246): the claude CLI JSON output does not carry a token
|
|
370
|
+
* usage field, so this path returns `usage: undefined`. The CLI is billed
|
|
371
|
+
* by Claude subscription, not per-token — there is nothing to meter. The
|
|
372
|
+
* fair-use cap therefore never trips on a claude-cli install, which is the
|
|
373
|
+
* correct outcome (no meterable cost to defend against).
|
|
368
374
|
*/
|
|
369
375
|
async completeClaude(model, prompt, timeoutMs) {
|
|
370
376
|
const { execSync } = require("node:child_process");
|
|
@@ -375,7 +381,7 @@ class LlmClient {
|
|
|
375
381
|
if (data.is_error) {
|
|
376
382
|
throw new Error(`Claude CLI error: ${data.result}`);
|
|
377
383
|
}
|
|
378
|
-
return (data.result ?? "").trim();
|
|
384
|
+
return { text: (data.result ?? "").trim() };
|
|
379
385
|
}
|
|
380
386
|
catch (err) {
|
|
381
387
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -388,6 +394,11 @@ class LlmClient {
|
|
|
388
394
|
/**
|
|
389
395
|
* Ollama: use /api/generate with think:false (important for qwen3.5 models).
|
|
390
396
|
* num_ctx is read from config (one value, all phases — #231; default 8192).
|
|
397
|
+
*
|
|
398
|
+
* Token usage (#246): the FINAL streamed chunk carries the per-request
|
|
399
|
+
* accounting as `prompt_eval_count` (input) + `eval_count` (output). Earlier
|
|
400
|
+
* chunks have null/zero — only the terminal chunk is meaningful, so we keep
|
|
401
|
+
* updating as chunks arrive and the last one wins.
|
|
391
402
|
*/
|
|
392
403
|
async completeOllama(model, prompt, maxTokens, timeoutMs) {
|
|
393
404
|
const url = `${this.config.baseUrl.replace(/\/$/, "")}/api/generate`;
|
|
@@ -418,8 +429,13 @@ class LlmClient {
|
|
|
418
429
|
}
|
|
419
430
|
throw new Error(`Ollama error ${resp.status}: ${text}`);
|
|
420
431
|
}
|
|
421
|
-
// Collect streamed response chunks
|
|
432
|
+
// Collect streamed response chunks. The terminal chunk carries the token
|
|
433
|
+
// accounting (`prompt_eval_count` / `eval_count`); earlier chunks have
|
|
434
|
+
// null. Track the latest values so the final ones win (mirrors the official
|
|
435
|
+
// ollama-js streaming parser).
|
|
422
436
|
let result = "";
|
|
437
|
+
let promptTokens;
|
|
438
|
+
let completionTokens;
|
|
423
439
|
const reader = resp.body?.getReader();
|
|
424
440
|
if (!reader)
|
|
425
441
|
throw new Error("No response body");
|
|
@@ -436,11 +452,26 @@ class LlmClient {
|
|
|
436
452
|
const data = JSON.parse(line);
|
|
437
453
|
if (data.response)
|
|
438
454
|
result += data.response;
|
|
455
|
+
// Token accounting — only present on the terminal chunk. Keep the last
|
|
456
|
+
// non-null value; missing on both → usage stays undefined (no signal).
|
|
457
|
+
if (typeof data.prompt_eval_count === "number") {
|
|
458
|
+
promptTokens = data.prompt_eval_count;
|
|
459
|
+
}
|
|
460
|
+
if (typeof data.eval_count === "number") {
|
|
461
|
+
completionTokens = data.eval_count;
|
|
462
|
+
}
|
|
439
463
|
}
|
|
440
464
|
catch { /* skip malformed lines */ }
|
|
441
465
|
}
|
|
442
466
|
}
|
|
443
|
-
|
|
467
|
+
const usage = promptTokens !== undefined && completionTokens !== undefined
|
|
468
|
+
? {
|
|
469
|
+
prompt_tokens: promptTokens,
|
|
470
|
+
completion_tokens: completionTokens,
|
|
471
|
+
total_tokens: promptTokens + completionTokens,
|
|
472
|
+
}
|
|
473
|
+
: undefined;
|
|
474
|
+
return { text: result.trim(), usage };
|
|
444
475
|
}
|
|
445
476
|
/**
|
|
446
477
|
* Flush ollama's accumulated memory: unload the model (keep_alive:0) so the
|
|
@@ -472,6 +503,10 @@ class LlmClient {
|
|
|
472
503
|
/**
|
|
473
504
|
* Anthropic Messages API (/v1/messages).
|
|
474
505
|
* Auth via x-api-key header.
|
|
506
|
+
*
|
|
507
|
+
* Token usage (#246): Anthropic's response carries `usage.input_tokens` +
|
|
508
|
+
* `usage.output_tokens`. Mapped to the OpenAI-spec field names so downstream
|
|
509
|
+
* accounting is uniform across providers.
|
|
475
510
|
*/
|
|
476
511
|
async completeAnthropic(model, prompt, maxTokens, timeoutMs) {
|
|
477
512
|
const baseUrl = this.config.baseUrl.replace(/\/$/, "");
|
|
@@ -499,11 +534,26 @@ class LlmClient {
|
|
|
499
534
|
}
|
|
500
535
|
const data = (await resp.json());
|
|
501
536
|
const textBlock = data.content?.find((c) => c.type === "text");
|
|
502
|
-
|
|
537
|
+
const inT = data.usage?.input_tokens;
|
|
538
|
+
const outT = data.usage?.output_tokens;
|
|
539
|
+
const usage = typeof inT === "number" && typeof outT === "number"
|
|
540
|
+
? {
|
|
541
|
+
prompt_tokens: inT,
|
|
542
|
+
completion_tokens: outT,
|
|
543
|
+
total_tokens: inT + outT,
|
|
544
|
+
}
|
|
545
|
+
: undefined;
|
|
546
|
+
return { text: (textBlock?.text ?? "").trim(), usage };
|
|
503
547
|
}
|
|
504
548
|
/**
|
|
505
549
|
* OpenAI-compatible /v1/chat/completions (works for OpenAI, OpenRouter, etc).
|
|
506
550
|
* enableThinking is read from config here (one value, all phases — #231).
|
|
551
|
+
*
|
|
552
|
+
* Token usage (#246): the OpenAI spec's `usage` object is always present on
|
|
553
|
+
* a successful completion — `prompt_tokens` / `completion_tokens` /
|
|
554
|
+
* `total_tokens`. The MLX gateway emits the same shape (verified v0.31.3).
|
|
555
|
+
* Parsed verbatim; absent only on a non-conforming endpoint, in which case
|
|
556
|
+
* `usage` stays undefined (no signal, never a fabricated zero).
|
|
507
557
|
*/
|
|
508
558
|
async completeOpenAiCompat(model, prompt, maxTokens, timeoutMs) {
|
|
509
559
|
const baseUrl = this.config.baseUrl.replace(/\/$/, "");
|
|
@@ -547,7 +597,17 @@ class LlmClient {
|
|
|
547
597
|
throw new Error(`LLM API error ${resp.status}: ${text}`);
|
|
548
598
|
}
|
|
549
599
|
const data = (await resp.json());
|
|
550
|
-
|
|
600
|
+
const u = data.usage;
|
|
601
|
+
const usage = typeof u?.prompt_tokens === "number" &&
|
|
602
|
+
typeof u?.completion_tokens === "number" &&
|
|
603
|
+
typeof u?.total_tokens === "number"
|
|
604
|
+
? {
|
|
605
|
+
prompt_tokens: u.prompt_tokens,
|
|
606
|
+
completion_tokens: u.completion_tokens,
|
|
607
|
+
total_tokens: u.total_tokens,
|
|
608
|
+
}
|
|
609
|
+
: undefined;
|
|
610
|
+
return { text: (data.choices?.[0]?.message?.content ?? "").trim(), usage };
|
|
551
611
|
}
|
|
552
612
|
}
|
|
553
613
|
exports.LlmClient = LlmClient;
|
package/dist/nightly.d.ts
CHANGED
|
@@ -25,4 +25,11 @@ export declare function runNightly(options?: {
|
|
|
25
25
|
* capture-only). Uniform across client + server/co-located.
|
|
26
26
|
*/
|
|
27
27
|
watchdog?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Consolidate-only mode (hosted service, #110): skip capture entirely, run
|
|
30
|
+
* consolidation only. The hosted consolidation timer uses this so per-tenant
|
|
31
|
+
* nightly runs don't ingest the operator's local sessions into the tenant's
|
|
32
|
+
* DB — the tenant's agents push via /distill; the server only consolidates.
|
|
33
|
+
*/
|
|
34
|
+
consolidateOnly?: boolean;
|
|
28
35
|
}): Promise<void>;
|