@gamaze/hicortex 0.17.1 → 0.17.3
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/context.html +44 -0
- package/assets/dashboard.html +129 -0
- package/assets/viz.html +35 -4
- 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/mcp-server.js +5 -0
- 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/consolidate.js
CHANGED
|
@@ -38,8 +38,9 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
38
38
|
};
|
|
39
39
|
})();
|
|
40
40
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
41
|
-
exports.DEFAULT_SUPERSESSION_MAX_CALLS = exports.DEFAULT_SUPERSESSION_MIN_SIMILARITY = exports.BudgetTracker = exports.REFLECTION_CONTRADICTION_MIN_COSINE = exports.l2ToCosine = exports.CROSS_PROJECT_LINK_THRESHOLD = exports.CONSOLIDATE_LINK_TOP_K = exports.CONSOLIDATE_LINK_THRESHOLD = exports.CONSOLIDATE_MAX_LLM_CALLS = void 0;
|
|
41
|
+
exports.DEFAULT_MEMORY_SOFT_CAP = exports.DEFAULT_SUPERSESSION_MAX_CALLS = exports.DEFAULT_SUPERSESSION_MIN_SIMILARITY = exports.BudgetTracker = exports.REFLECTION_CONTRADICTION_MIN_COSINE = exports.l2ToCosine = exports.CROSS_PROJECT_LINK_THRESHOLD = exports.CONSOLIDATE_LINK_TOP_K = exports.CONSOLIDATE_LINK_THRESHOLD = exports.CONSOLIDATE_MAX_LLM_CALLS = void 0;
|
|
42
42
|
exports.isContradictionCandidate = isContradictionCandidate;
|
|
43
|
+
exports.shouldThrottleTokens = shouldThrottleTokens;
|
|
43
44
|
exports.parseJsonLenient = parseJsonLenient;
|
|
44
45
|
exports.rebuildContentModuleIndex = rebuildContentModuleIndex;
|
|
45
46
|
exports.discoverLinkCandidates = discoverLinkCandidates;
|
|
@@ -49,6 +50,7 @@ exports.buildSupersessionPrompt = buildSupersessionPrompt;
|
|
|
49
50
|
exports.parseSupersessionReply = parseSupersessionReply;
|
|
50
51
|
exports.stageSupersession = stageSupersession;
|
|
51
52
|
exports.stageDecayPrune = stageDecayPrune;
|
|
53
|
+
exports.stageMemoryCapEviction = stageMemoryCapEviction;
|
|
52
54
|
exports.runConsolidation = runConsolidation;
|
|
53
55
|
exports.msUntilHour = msUntilHour;
|
|
54
56
|
exports.scheduleConsolidation = scheduleConsolidation;
|
|
@@ -125,6 +127,19 @@ class BudgetTracker {
|
|
|
125
127
|
maxCalls;
|
|
126
128
|
callsUsed = 0;
|
|
127
129
|
callsByStage = {};
|
|
130
|
+
/**
|
|
131
|
+
* Token usage per stage (#246). Keys are the same stage labels passed to
|
|
132
|
+
* `use()`. A stage that made no metered calls (no usage returned — never the
|
|
133
|
+
* path on a healthy openai/ollama endpoint) is absent, NOT zero, so the
|
|
134
|
+
* dashboard can distinguish "nothing spent" from "no signal".
|
|
135
|
+
*/
|
|
136
|
+
tokensByStage = {};
|
|
137
|
+
/** Run-wide totals — the sum of every recordUsage() call this run. */
|
|
138
|
+
totalTokens = {
|
|
139
|
+
prompt: 0,
|
|
140
|
+
completion: 0,
|
|
141
|
+
total: 0,
|
|
142
|
+
};
|
|
128
143
|
constructor(maxCalls) {
|
|
129
144
|
this.maxCalls = maxCalls;
|
|
130
145
|
}
|
|
@@ -144,17 +159,73 @@ class BudgetTracker {
|
|
|
144
159
|
this.callsByStage[stage] = (this.callsByStage[stage] ?? 0) + count;
|
|
145
160
|
return true;
|
|
146
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* Record token usage from one LLM call (#246). Called by the consolidation
|
|
164
|
+
* stages after each metered completion. `undefined` usage (claude-cli path,
|
|
165
|
+
* or a non-conforming endpoint that returned no usage object) is a no-op —
|
|
166
|
+
* never recorded as zero, which would silently undercount real spend.
|
|
167
|
+
*/
|
|
168
|
+
recordUsage(stage, usage) {
|
|
169
|
+
if (!usage)
|
|
170
|
+
return;
|
|
171
|
+
const cur = this.tokensByStage[stage] ?? { prompt: 0, completion: 0, total: 0 };
|
|
172
|
+
cur.prompt += usage.prompt_tokens;
|
|
173
|
+
cur.completion += usage.completion_tokens;
|
|
174
|
+
cur.total += usage.total_tokens;
|
|
175
|
+
this.tokensByStage[stage] = cur;
|
|
176
|
+
this.totalTokens.prompt += usage.prompt_tokens;
|
|
177
|
+
this.totalTokens.completion += usage.completion_tokens;
|
|
178
|
+
this.totalTokens.total += usage.total_tokens;
|
|
179
|
+
}
|
|
147
180
|
summary() {
|
|
148
181
|
return {
|
|
149
182
|
max_calls: this.maxCalls,
|
|
150
183
|
calls_used: this.callsUsed,
|
|
151
184
|
calls_remaining: this.remaining,
|
|
152
185
|
calls_by_stage: { ...this.callsByStage },
|
|
186
|
+
tokens_by_stage: Object.fromEntries(Object.entries(this.tokensByStage).map(([k, v]) => [k, { ...v }])),
|
|
187
|
+
tokens_total: { ...this.totalTokens },
|
|
153
188
|
};
|
|
154
189
|
}
|
|
155
190
|
}
|
|
156
191
|
exports.BudgetTracker = BudgetTracker;
|
|
157
192
|
// ---------------------------------------------------------------------------
|
|
193
|
+
// Token fair-use throttle decision (#246)
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
/**
|
|
196
|
+
* Decide whether consolidation should be throttled this run based on the
|
|
197
|
+
* `llmTokensPerMonth` fair-use cap. Pure (no I/O) so it can be unit-tested
|
|
198
|
+
* independently of the nightly wiring.
|
|
199
|
+
*
|
|
200
|
+
* Returns `{ throttle: true, used, cap }` when the projected post-run total
|
|
201
|
+
* would exceed the cap; `{ throttle: false }` otherwise. The estimate is the
|
|
202
|
+
* previous run's actual usage (`llmTokensLastRun`, 0/absent on the first
|
|
203
|
+
* metered run = never throttle the first run — no baseline yet).
|
|
204
|
+
*
|
|
205
|
+
* `cap = 0` (the self-hosted default) → never throttle (unlimited).
|
|
206
|
+
* `periodStart` in a previous calendar month → period resets to 0 first
|
|
207
|
+
* (mirrors the reset logic in nightly.ts; both sides agree because both read
|
|
208
|
+
* the same state + clock).
|
|
209
|
+
*/
|
|
210
|
+
function shouldThrottleTokens(cap, period, lastRunTokens, now = new Date()) {
|
|
211
|
+
if (cap <= 0)
|
|
212
|
+
return { throttle: false };
|
|
213
|
+
let periodTotal = period?.total ?? 0;
|
|
214
|
+
const periodStart = period?.periodStart;
|
|
215
|
+
if (periodStart) {
|
|
216
|
+
const start = new Date(periodStart);
|
|
217
|
+
if (start.getUTCFullYear() !== now.getUTCFullYear() ||
|
|
218
|
+
start.getUTCMonth() !== now.getUTCMonth()) {
|
|
219
|
+
// Stale period → reset accrual to 0 before the check.
|
|
220
|
+
periodTotal = 0;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (periodTotal + lastRunTokens > cap) {
|
|
224
|
+
return { throttle: true, used: periodTotal, cap };
|
|
225
|
+
}
|
|
226
|
+
return { throttle: false };
|
|
227
|
+
}
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
158
229
|
// JSON parsing helper
|
|
159
230
|
// ---------------------------------------------------------------------------
|
|
160
231
|
/**
|
|
@@ -239,8 +310,9 @@ async function stageImportance(db, memories, llm, budget, dryRun) {
|
|
|
239
310
|
break;
|
|
240
311
|
}
|
|
241
312
|
try {
|
|
242
|
-
const
|
|
243
|
-
|
|
313
|
+
const r = await llm.completeFast(prompt, 256);
|
|
314
|
+
budget.recordUsage("importance", r.usage);
|
|
315
|
+
let scores = parseJsonLenient(r.text, null);
|
|
244
316
|
if (!Array.isArray(scores)) {
|
|
245
317
|
scores = new Array(batch.length).fill(0.5);
|
|
246
318
|
}
|
|
@@ -300,8 +372,9 @@ async function stageReflection(db, memories, llm, budget, embedFn, dryRun) {
|
|
|
300
372
|
return { lessons_generated: 0, skipped: true, reason: "budget_exhausted" };
|
|
301
373
|
}
|
|
302
374
|
try {
|
|
303
|
-
const
|
|
304
|
-
|
|
375
|
+
const r = await llm.completeReflect(prompt, 2048);
|
|
376
|
+
budget.recordUsage("reflection", r.usage);
|
|
377
|
+
const lessons = parseJsonLenient(r.text, []);
|
|
305
378
|
if (!Array.isArray(lessons)) {
|
|
306
379
|
return { lessons_generated: 0, failed: true };
|
|
307
380
|
}
|
|
@@ -349,9 +422,14 @@ async function stageReflection(db, memories, llm, budget, embedFn, dryRun) {
|
|
|
349
422
|
const existingText = similarLessons[0].content.slice(0, 300);
|
|
350
423
|
const newText = content.slice(0, 300);
|
|
351
424
|
try {
|
|
352
|
-
const
|
|
425
|
+
const verdictR = await llm.completeFast(`Two lessons from an AI memory system. Do they CONTRADICT each other (opposite advice on the same topic)?\n\n` +
|
|
353
426
|
`EXISTING: ${existingText}\n\nNEW: ${newText}\n\n` +
|
|
354
427
|
`Answer ONLY "yes" or "no". If the new lesson updates/refines the existing one (not contradicts), answer "no".`, 16);
|
|
428
|
+
// Stage label "contradiction_check" matches the budget.use() call
|
|
429
|
+
// above (separate counter from the reflection call proper). Token
|
|
430
|
+
// accounting follows the same stage partition as the call counter.
|
|
431
|
+
budget.recordUsage("contradiction_check", verdictR.usage);
|
|
432
|
+
const verdict = verdictR.text;
|
|
355
433
|
if (verdict.toLowerCase().trim().startsWith("yes")) {
|
|
356
434
|
contradicted = true;
|
|
357
435
|
console.log(`[hicortex] Lesson suppressed (contradicts existing): "${lessonText.slice(0, 80)}"`);
|
|
@@ -462,7 +540,10 @@ async function stageContentDomains(db, domains, llm, budget, embedFn, dryRun, st
|
|
|
462
540
|
// classifyMemoryTags returns null ONLY on infra error (throws after retry) —
|
|
463
541
|
// skip that memory, leaving domain/tags/strength untouched so a later
|
|
464
542
|
// run retries it (issue #150: never file or decay on infra errors).
|
|
465
|
-
|
|
543
|
+
// The onUsage callback (#246) wires the metered call's token accounting
|
|
544
|
+
// into this stage's BudgetTracker slot — same stage label the budget.use
|
|
545
|
+
// call above uses, so call count + tokens stay aligned.
|
|
546
|
+
const result = await (0, domain_classify_js_1.classifyMemoryTags)(row.content, row.project, domains, llm, (u) => budget.recordUsage("content_domain", u));
|
|
466
547
|
if (result === null) {
|
|
467
548
|
console.warn(`[hicortex] content-domain: infra error classifying ${row.id} — skipped (will retry)`);
|
|
468
549
|
continue;
|
|
@@ -599,8 +680,9 @@ async function stageDomainCuration(db, llm, budget, dryRun, stateDir) {
|
|
|
599
680
|
.map((r) => `${r.project}: ${r.cnt} / ${lessonsByProject.get(r.project) ?? 0}`)
|
|
600
681
|
.join("\n");
|
|
601
682
|
try {
|
|
602
|
-
const
|
|
603
|
-
|
|
683
|
+
const r = await llm.completeFast((0, prompts_js_1.domainCuration)(projectLines), 1024);
|
|
684
|
+
budget.recordUsage("domain_curation", r.usage);
|
|
685
|
+
const parsed = parseJsonLenient(r.text, []);
|
|
604
686
|
if (!Array.isArray(parsed) || parsed.length === 0) {
|
|
605
687
|
console.warn("[hicortex] Domain curation: LLM returned empty/invalid response, using fallback");
|
|
606
688
|
domains = projectRows.map((r) => ({
|
|
@@ -934,16 +1016,19 @@ function parseSupersessionReply(reply) {
|
|
|
934
1016
|
}
|
|
935
1017
|
/**
|
|
936
1018
|
* ONE classify-tier LLM call judging whether `newContent` supersedes
|
|
937
|
-
* `oldContent`. Returns null on any infra error
|
|
938
|
-
* caller treats null as "skip this pair"
|
|
1019
|
+
* `oldContent`. Returns `{verdict, usage}` — verdict is null on any infra error
|
|
1020
|
+
* or unparseable reply (the caller treats null as "skip this pair", never
|
|
1021
|
+
* mis-links on ambiguity). `usage` is the call's token accounting (#246),
|
|
1022
|
+
* surfaced even on a null verdict so the BudgetTracker still meters a
|
|
1023
|
+
* network-round-tripped attempt (the cost is real even if the parse failed).
|
|
939
1024
|
*/
|
|
940
1025
|
async function classifySupersession(llm, oldContent, newContent) {
|
|
941
1026
|
try {
|
|
942
|
-
const
|
|
943
|
-
return parseSupersessionReply(
|
|
1027
|
+
const r = await llm.completeClassify(buildSupersessionPrompt(oldContent, newContent), 32);
|
|
1028
|
+
return { verdict: parseSupersessionReply(r.text), usage: r.usage };
|
|
944
1029
|
}
|
|
945
1030
|
catch {
|
|
946
|
-
return null;
|
|
1031
|
+
return { verdict: null, usage: undefined };
|
|
947
1032
|
}
|
|
948
1033
|
}
|
|
949
1034
|
/**
|
|
@@ -1033,7 +1118,10 @@ async function stageSupersession(db, llm, budget, embedFn, dryRun, stateDir, opt
|
|
|
1033
1118
|
if (callsUsed >= maxCalls || !budget.use("supersession"))
|
|
1034
1119
|
break;
|
|
1035
1120
|
callsUsed++;
|
|
1036
|
-
const verdict = await classifySupersession(llm, neighbor.content, candidate.content);
|
|
1121
|
+
const { verdict, usage } = await classifySupersession(llm, neighbor.content, candidate.content);
|
|
1122
|
+
// Meter every round-tripped attempt (#246) — even a null verdict spent
|
|
1123
|
+
// real tokens. The stage label matches the budget.use() above.
|
|
1124
|
+
budget.recordUsage("supersession", usage);
|
|
1037
1125
|
evaluated++;
|
|
1038
1126
|
if (verdict === null) {
|
|
1039
1127
|
skippedInfra++;
|
|
@@ -1111,11 +1199,100 @@ function stageDecayPrune(db, dryRun) {
|
|
|
1111
1199
|
}
|
|
1112
1200
|
return { candidates, pruned, failed };
|
|
1113
1201
|
}
|
|
1202
|
+
// ---------------------------------------------------------------------------
|
|
1203
|
+
// Stage 4.5: Memory cap eviction (#245)
|
|
1204
|
+
// ---------------------------------------------------------------------------
|
|
1205
|
+
//
|
|
1206
|
+
// The active forgetting mechanism. The pre-#245 prune (stageDecayPrune above)
|
|
1207
|
+
// is inert by design — the strength floor (~0.3162) + the `< 0.01` threshold +
|
|
1208
|
+
// the 365-day decay half-life means a never-accessed memory takes ~3 years to
|
|
1209
|
+
// become eligible, so the corpus grew without bound. This stage bounds it:
|
|
1210
|
+
// when the count exceeds `memorySoftCap`, the lowest-effectiveStrength
|
|
1211
|
+
// memories are evicted until under the cap.
|
|
1212
|
+
//
|
|
1213
|
+
// Eviction reuses the SAME effectiveStrength() the recall ranker uses — no
|
|
1214
|
+
// formula duplication, so the eviction criterion cannot drift from what
|
|
1215
|
+
// surfaces in the top-k. The evicted tail is, by construction, the tail that
|
|
1216
|
+
// was not surfacing anyway (cold, decayed). Ties are broken by oldest
|
|
1217
|
+
// COALESCE(last_accessed, created_at) — i.e. the memories that have gone
|
|
1218
|
+
// longest without anyone looking at them.
|
|
1219
|
+
//
|
|
1220
|
+
// `cap = 0` disables the stage (indefinite growth — the pre-#245 default is
|
|
1221
|
+
// preserved opt-out). The JS-side sort is O(n log n); at 10K memories the
|
|
1222
|
+
// load + compute is <100 ms, a rounding error against the LLM-bound phases.
|
|
1223
|
+
/**
|
|
1224
|
+
* Default soft cap on the memory corpus (#245). Above this the lowest-value
|
|
1225
|
+
* memories are evicted each nightly. 10000 balances headroom for a busy
|
|
1226
|
+
* self-hosted install against the noise cost of a bloated vector index
|
|
1227
|
+
* (recall top-k competes against the long tail). Override via `memorySoftCap`.
|
|
1228
|
+
*/
|
|
1229
|
+
exports.DEFAULT_MEMORY_SOFT_CAP = 10000;
|
|
1230
|
+
function stageMemoryCapEviction(db, dryRun, cap) {
|
|
1231
|
+
// `0` = explicitly disabled (current/legacy behaviour). The guard is on `<=`
|
|
1232
|
+
// not `===` to also absorb a stray negative (readNonNegativeConfig already
|
|
1233
|
+
// rejects negatives at the boundary, but this stage is callable directly).
|
|
1234
|
+
if (cap <= 0)
|
|
1235
|
+
return { cap, evicted: 0 };
|
|
1236
|
+
const count = storage.countMemories(db);
|
|
1237
|
+
if (count <= cap)
|
|
1238
|
+
return { cap, evicted: 0 };
|
|
1239
|
+
const surplus = count - cap;
|
|
1240
|
+
// Load the fields effectiveStrength needs + the tiebreak. base_strength is
|
|
1241
|
+
// NOT NULL after scoring; the `?? 0.5` mirrors stageDecayPrune's defensive
|
|
1242
|
+
// default for unscored rows (inserts at 0.5). last_accessed is NULL until
|
|
1243
|
+
// first /recall-index exposure — COALESCE to created_at for the tiebreak so
|
|
1244
|
+
// never-shown memories sort by when they entered the corpus.
|
|
1245
|
+
const rows = db
|
|
1246
|
+
.prepare(`SELECT id, base_strength, last_accessed, access_count, created_at
|
|
1247
|
+
FROM memories`)
|
|
1248
|
+
.all();
|
|
1249
|
+
const linkCounts = storage.getAllLinkCounts(db);
|
|
1250
|
+
const now = new Date();
|
|
1251
|
+
// Decorate + sort: lowest effectiveStrength first; ties broken by oldest
|
|
1252
|
+
// COALESCE(last_accessed, created_at). The victims are the first `surplus`.
|
|
1253
|
+
const decorated = rows.map((r) => {
|
|
1254
|
+
const eff = (0, retrieval_js_1.effectiveStrength)(r.base_strength ?? 0.5, r.last_accessed, now, {
|
|
1255
|
+
accessCount: r.access_count ?? 0,
|
|
1256
|
+
linkCount: linkCounts.get(r.id) ?? 0,
|
|
1257
|
+
});
|
|
1258
|
+
return {
|
|
1259
|
+
id: r.id,
|
|
1260
|
+
eff,
|
|
1261
|
+
lastTouch: r.last_accessed ?? r.created_at,
|
|
1262
|
+
};
|
|
1263
|
+
});
|
|
1264
|
+
decorated.sort((a, b) =>
|
|
1265
|
+
// ASC by effectiveStrength, then ASC by lastTouch (oldest first = evict).
|
|
1266
|
+
a.eff !== b.eff ? a.eff - b.eff
|
|
1267
|
+
: a.lastTouch < b.lastTouch ? -1 : a.lastTouch > b.lastTouch ? 1 : 0);
|
|
1268
|
+
const victims = decorated.slice(0, surplus);
|
|
1269
|
+
if (dryRun) {
|
|
1270
|
+
console.log(`[hicortex] Memory cap eviction (dry-run): would remove ${victims.length} ` +
|
|
1271
|
+
`lowest-value memories (corpus ${count}, cap ${cap}).`);
|
|
1272
|
+
return { cap, evicted: victims.length };
|
|
1273
|
+
}
|
|
1274
|
+
// deleteMemory cascades: memory_links (both directions), memory_tags,
|
|
1275
|
+
// memory_vectors, and the FTS index (via the AFTER DELETE trigger on
|
|
1276
|
+
// memories, db.ts — no manual FTS cleanup needed). Wrap the batch in a
|
|
1277
|
+
// transaction so a failure leaves the corpus consistent (all-or-nothing).
|
|
1278
|
+
const tx = db.transaction(() => {
|
|
1279
|
+
for (const v of victims)
|
|
1280
|
+
storage.deleteMemory(db, v.id);
|
|
1281
|
+
});
|
|
1282
|
+
tx();
|
|
1283
|
+
console.log(`[hicortex] Memory cap eviction: removed ${victims.length} lowest-value ` +
|
|
1284
|
+
`memories (corpus was ${count}, cap ${cap}).`);
|
|
1285
|
+
return { cap, evicted: victims.length };
|
|
1286
|
+
}
|
|
1114
1287
|
async function runConsolidation(db, llm, embedFn, dryRun = false, skipReflection = false, stateDir, domainOptions, supersessionOptions,
|
|
1115
1288
|
/** Total LLM-call ceiling across classify-tier stages (#241). The caller
|
|
1116
1289
|
* reads `consolidateMaxLlmCalls` from config and passes it; unset → the
|
|
1117
1290
|
* exported `CONSOLIDATE_MAX_LLM_CALLS` default (5000). */
|
|
1118
|
-
budgetMaxCalls
|
|
1291
|
+
budgetMaxCalls,
|
|
1292
|
+
/** Soft cap on the corpus (#245). Nightly.ts reads `memorySoftCap` from
|
|
1293
|
+
* config and passes it; unset → `DEFAULT_MEMORY_SOFT_CAP` (10000). `0`
|
|
1294
|
+
* disables eviction (indefinite growth). */
|
|
1295
|
+
memorySoftCap) {
|
|
1119
1296
|
const start = new Date();
|
|
1120
1297
|
const report = {
|
|
1121
1298
|
started_at: start.toISOString(),
|
|
@@ -1141,6 +1318,10 @@ budgetMaxCalls) {
|
|
|
1141
1318
|
new_memory_count: precheck.newMemories.length,
|
|
1142
1319
|
unscored_count: scoreMemories.length - precheck.newMemories.length,
|
|
1143
1320
|
};
|
|
1321
|
+
// Memory cap eviction (#245) — runs BEFORE the precheck skip so the corpus
|
|
1322
|
+
// is bounded even on quiet nights (no new memories → precheck would skip,
|
|
1323
|
+
// but the cap stage is pure DB: cheap, idempotent when under cap).
|
|
1324
|
+
report.stages.memory_cap = stageMemoryCapEviction(db, dryRun, memorySoftCap ?? exports.DEFAULT_MEMORY_SOFT_CAP);
|
|
1144
1325
|
if (skip) {
|
|
1145
1326
|
report.status = "skipped";
|
|
1146
1327
|
report.completed_at = new Date().toISOString();
|
|
@@ -1192,6 +1373,7 @@ budgetMaxCalls) {
|
|
|
1192
1373
|
report.stages.supersession = await stageSupersession(db, llm, budget, embedFn, dryRun, stateDir, supersessionOptions);
|
|
1193
1374
|
// Stage 4: Decay & Prune
|
|
1194
1375
|
report.stages.decay_prune = stageDecayPrune(db, dryRun);
|
|
1376
|
+
// (Memory cap eviction moved before the precheck skip — see above.)
|
|
1195
1377
|
}
|
|
1196
1378
|
catch (err) {
|
|
1197
1379
|
report.status = "failed";
|
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; ` +
|