@gamaze/hicortex 0.18.3 → 0.19.1
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 +76 -2
- package/assets/dashboard.html +103 -7
- package/assets/identity.html +48 -1
- package/assets/viz.html +52 -1
- package/dist/backup.d.ts +107 -0
- package/dist/backup.js +343 -0
- package/dist/capture.d.ts +20 -0
- package/dist/capture.js +9 -1
- package/dist/cli.js +30 -0
- package/dist/config-read.d.ts +28 -2
- package/dist/config-read.js +32 -2
- package/dist/consolidate.js +2 -4
- package/dist/dashboard.d.ts +71 -6
- package/dist/dashboard.js +61 -7
- package/dist/index.d.ts +25 -1
- package/dist/index.js +108 -2
- package/dist/mcp-server.d.ts +18 -0
- package/dist/mcp-server.js +60 -2
- package/dist/memory-instructions.js +1 -1
- package/dist/nightly.js +104 -2
- package/dist/prompts.js +19 -4
- package/dist/telemetry.d.ts +14 -0
- package/dist/token-budget.js +10 -2
- package/dist/type-classify.js +7 -4
- package/dist/types.d.ts +40 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +6 -4
package/dist/dashboard.d.ts
CHANGED
|
@@ -42,12 +42,21 @@ export interface DashboardMetrics {
|
|
|
42
42
|
* cap; undefined on backfill rows (a stage outcome, not reconstructable). */
|
|
43
43
|
evicted?: number;
|
|
44
44
|
/**
|
|
45
|
-
* Total LLM tokens consumed by this run
|
|
46
|
-
*
|
|
47
|
-
*
|
|
45
|
+
* Total LLM tokens consumed by this run (#246 consolidation meter; #287
|
|
46
|
+
* widened to the TRUE total — distill + consolidation). Undefined in
|
|
47
|
+
* lockstep with `tokens_by_stage` (and on backfill rows, which can't
|
|
48
|
+
* reconstruct a per-run meter). Older snapshots are consolidation-only:
|
|
49
|
+
* historical rows can't be reconstructed, which is accepted (#287).
|
|
50
|
+
* Inherent under-count, same acceptance: attribution is response-based,
|
|
51
|
+
* so tokens a FAILED distill already spent (500 after spend, response
|
|
52
|
+
* lost after commit) reach the monthly meter but never a run's total —
|
|
53
|
+
* after such a night, the month's bars sum slightly below the headline.
|
|
48
54
|
*/
|
|
49
55
|
tokens?: number;
|
|
50
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* Per-stage breakdown of `tokens` (#246; #287 adds a `distill` entry for
|
|
58
|
+
* capture-time distillation). Undefined on backfill rows.
|
|
59
|
+
*/
|
|
51
60
|
tokens_by_stage?: Record<string, {
|
|
52
61
|
prompt: number;
|
|
53
62
|
completion: number;
|
|
@@ -79,6 +88,19 @@ export interface DashboardMetrics {
|
|
|
79
88
|
* shape is clean on healthy runs.
|
|
80
89
|
*/
|
|
81
90
|
budget_deferred_by_stage?: Record<string, number>;
|
|
91
|
+
/**
|
|
92
|
+
* #6 backup stage outcome (Phase 0B). Present whenever the backup stage
|
|
93
|
+
* ran (full nightly); absent on capture-only / dry-run / backfill rows.
|
|
94
|
+
* `ok` is false when the snapshot OR the operator's offsite hook failed —
|
|
95
|
+
* the page flags a night the offsite copy didn't land. `bytes` is the
|
|
96
|
+
* compressed artifact size; `path` is the on-disk artifact (for "where
|
|
97
|
+
* did the last backup land?" debugging — not a restore button).
|
|
98
|
+
*/
|
|
99
|
+
backup?: {
|
|
100
|
+
ok: boolean;
|
|
101
|
+
bytes: number;
|
|
102
|
+
path?: string;
|
|
103
|
+
};
|
|
82
104
|
};
|
|
83
105
|
/** Corpus capacity (#245). `memory_soft_cap` is the configured ceiling (0 =
|
|
84
106
|
* disabled); always present in real snapshots, undefined on backfilled
|
|
@@ -102,6 +124,17 @@ export interface DashboardSnapshot {
|
|
|
102
124
|
}
|
|
103
125
|
/** The /dashboard/data response — the full payload the page renders. */
|
|
104
126
|
export interface DashboardData {
|
|
127
|
+
/**
|
|
128
|
+
* Account identity (hosted): who the viewer is, so a user holding two
|
|
129
|
+
* tenant tokens can tell whose data the page shows. Each field is null when
|
|
130
|
+
* its config key (displayName/orgName/planLabel) is absent — the page
|
|
131
|
+
* renders nothing when ALL are null (the self-hosted default).
|
|
132
|
+
*/
|
|
133
|
+
account: {
|
|
134
|
+
name: string | null;
|
|
135
|
+
org: string | null;
|
|
136
|
+
plan: string | null;
|
|
137
|
+
};
|
|
105
138
|
headline: {
|
|
106
139
|
total_memories: number;
|
|
107
140
|
uses_per_showing: number | null;
|
|
@@ -149,9 +182,10 @@ export interface DashboardData {
|
|
|
149
182
|
supersession: number;
|
|
150
183
|
added: number;
|
|
151
184
|
evicted?: number;
|
|
152
|
-
/** Total tokens consumed that run (#246
|
|
185
|
+
/** Total tokens consumed that run (#246; #287: distill + consolidation).
|
|
186
|
+
* Undefined = no metered run. */
|
|
153
187
|
tokens?: number;
|
|
154
|
-
/** Per-stage breakdown of `tokens` (#246). */
|
|
188
|
+
/** Per-stage breakdown of `tokens` (#246; #287 adds `distill`). */
|
|
155
189
|
tokens_by_stage?: Record<string, {
|
|
156
190
|
prompt: number;
|
|
157
191
|
completion: number;
|
|
@@ -207,6 +241,19 @@ export interface NightlyDelta {
|
|
|
207
241
|
completion: number;
|
|
208
242
|
total: number;
|
|
209
243
|
}>;
|
|
244
|
+
/**
|
|
245
|
+
* Distill tokens metered by the daemon across this run's capture POSTs
|
|
246
|
+
* (#287) — summed from the /distill responses by the capture loop. Merged
|
|
247
|
+
* into the snapshot so `new_this_run.tokens` is the run's TRUE total
|
|
248
|
+
* (distill + consolidation) and `distill` joins `tokens_by_stage`. Zero
|
|
249
|
+
* (a daemon predating the usage field, or nothing distilled) is a no-op:
|
|
250
|
+
* the row keeps its consolidation-only shape.
|
|
251
|
+
*/
|
|
252
|
+
distillUsage?: {
|
|
253
|
+
prompt: number;
|
|
254
|
+
completion: number;
|
|
255
|
+
total: number;
|
|
256
|
+
};
|
|
210
257
|
/**
|
|
211
258
|
* Always-on consolidation-budget usage (#255 CR). Forwarded whenever
|
|
212
259
|
* consolidation ran so the dashboard renders a continuous used/max bar.
|
|
@@ -227,6 +274,15 @@ export interface NightlyDelta {
|
|
|
227
274
|
* with `budgetExhausted`.
|
|
228
275
|
*/
|
|
229
276
|
budgetDeferredByStage?: Record<string, number>;
|
|
277
|
+
/**
|
|
278
|
+
* #6 backup stage (Phase 0B). Hoisted from the nightly backup block. Present
|
|
279
|
+
* whenever the backup stage ran (full nightly); undefined on capture-only /
|
|
280
|
+
* dry-run. `backupOk` flips to false on snapshot OR hook failure so the
|
|
281
|
+
* digest can flag a night the offsite copy didn't land.
|
|
282
|
+
*/
|
|
283
|
+
backupPath?: string;
|
|
284
|
+
backupBytes?: number;
|
|
285
|
+
backupOk?: boolean;
|
|
230
286
|
}
|
|
231
287
|
/**
|
|
232
288
|
* Write one snapshot row for `runAt` (an ISO timestamp the caller chooses —
|
|
@@ -286,3 +342,12 @@ export declare function handleDashboardData(db: Database.Database, query: {
|
|
|
286
342
|
* Failures surface as a 500 with the usual {error} shape — no silent degrade.
|
|
287
343
|
*/
|
|
288
344
|
export declare function dashboardDataHandler(getDb: () => Database.Database, getConfig: () => Record<string, unknown> | null | undefined): express.RequestHandler;
|
|
345
|
+
/**
|
|
346
|
+
* Express adapter for GET /account — the account identity ONLY (name/org/plan
|
|
347
|
+
* from config), so the /viz and /identity/ui pages can render the nav account
|
|
348
|
+
* element without pulling the full /dashboard/data payload. Same readAccount()
|
|
349
|
+
* construction as the dashboard payload — one shape, two surfaces. Also the
|
|
350
|
+
* natural whoami for the future OAuth session (#292). Failures surface as a
|
|
351
|
+
* 500 with the usual {error} shape (same as dashboardDataHandler).
|
|
352
|
+
*/
|
|
353
|
+
export declare function accountHandler(getConfig: () => Record<string, unknown> | null | undefined): express.RequestHandler;
|
package/dist/dashboard.js
CHANGED
|
@@ -25,6 +25,7 @@ exports.writeSnapshot = writeSnapshot;
|
|
|
25
25
|
exports.backfillSnapshots = backfillSnapshots;
|
|
26
26
|
exports.handleDashboardData = handleDashboardData;
|
|
27
27
|
exports.dashboardDataHandler = dashboardDataHandler;
|
|
28
|
+
exports.accountHandler = accountHandler;
|
|
28
29
|
const recall_index_js_1 = require("./recall-index.js");
|
|
29
30
|
const config_read_js_1 = require("./config-read.js");
|
|
30
31
|
const consolidate_js_1 = require("./consolidate.js");
|
|
@@ -92,17 +93,36 @@ function computeDashboardMetrics(db) {
|
|
|
92
93
|
*/
|
|
93
94
|
function writeSnapshot(db, runAt, delta, memorySoftCap) {
|
|
94
95
|
const metrics = computeDashboardMetrics(db);
|
|
96
|
+
// #287: merge the run's two meters into the customer-facing total. `tokens`
|
|
97
|
+
// = consolidation (tokensThisRun) + distill (distillUsage.total); the distill
|
|
98
|
+
// share joins the stage map under its own key. Both fields stay in lockstep —
|
|
99
|
+
// emitted when EITHER phase metered, omitted when neither did (the page
|
|
100
|
+
// treats undefined as "no data for this day"). A zero/absent distillUsage
|
|
101
|
+
// (old daemon, nothing distilled) changes nothing: tokens/tokens_by_stage
|
|
102
|
+
// come through exactly as the consolidation report produced them.
|
|
103
|
+
const hasDistill = (delta.distillUsage?.total ?? 0) > 0;
|
|
104
|
+
const metered = delta.tokensThisRun !== undefined || hasDistill;
|
|
105
|
+
const mergedTokens = metered
|
|
106
|
+
? (delta.tokensThisRun ?? 0) + (hasDistill ? delta.distillUsage.total : 0)
|
|
107
|
+
: undefined;
|
|
108
|
+
const mergedStages = metered
|
|
109
|
+
? { ...(delta.tokensByStage ?? {}), ...(hasDistill ? { distill: delta.distillUsage } : {}) }
|
|
110
|
+
: undefined;
|
|
111
|
+
// Shape fidelity: `tokens_by_stage` with zero keys never existed pre-#287
|
|
112
|
+
// (the key was simply absent) — keep it that way so consumers that treat
|
|
113
|
+
// "present" as "has a breakdown" stay right.
|
|
114
|
+
const emitStages = mergedStages && Object.keys(mergedStages).length > 0 ? mergedStages : undefined;
|
|
95
115
|
metrics.new_this_run = {
|
|
96
116
|
added: delta.added,
|
|
97
117
|
lessonsGenerated: delta.lessonsGenerated,
|
|
98
118
|
dedup: delta.dedup,
|
|
99
119
|
supersession: delta.supersession,
|
|
100
120
|
evicted: delta.evicted,
|
|
101
|
-
// #246: forward only when
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
...(
|
|
105
|
-
...(
|
|
121
|
+
// #246: forward only when a phase actually metered tokens this run. Absent
|
|
122
|
+
// on capture-only / throttled / no-LLM / no-metered-call runs — the page
|
|
123
|
+
// treats undefined as "no data for this day", matching adoption.
|
|
124
|
+
...(mergedTokens !== undefined ? { tokens: mergedTokens } : {}),
|
|
125
|
+
...(emitStages !== undefined ? { tokens_by_stage: emitStages } : {}),
|
|
106
126
|
// #255 CR: always-on usage metric — forward calls_used + max_calls
|
|
107
127
|
// whenever consolidation ran (regardless of exhaustion) so the page can
|
|
108
128
|
// render a continuous used/max bar. Presence = a run happened; absence =
|
|
@@ -121,6 +141,17 @@ function writeSnapshot(db, runAt, delta, memorySoftCap) {
|
|
|
121
141
|
: {}),
|
|
122
142
|
}
|
|
123
143
|
: {}),
|
|
144
|
+
// #6 backup stage — forwarded as a nested object only when the stage ran
|
|
145
|
+
// (backupOk !== undefined). Absent on capture-only / dry-run / backfill.
|
|
146
|
+
...(delta.backupOk !== undefined
|
|
147
|
+
? {
|
|
148
|
+
backup: {
|
|
149
|
+
ok: delta.backupOk === true,
|
|
150
|
+
bytes: delta.backupBytes ?? 0,
|
|
151
|
+
...(delta.backupPath ? { path: delta.backupPath } : {}),
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
: {}),
|
|
124
155
|
};
|
|
125
156
|
if (memorySoftCap !== undefined) {
|
|
126
157
|
metrics.capacity = { memory_soft_cap: memorySoftCap };
|
|
@@ -418,8 +449,9 @@ function handleDashboardData(db, query, config) {
|
|
|
418
449
|
supersession: dayMetrics?.new_this_run?.supersession ?? supersessionCount,
|
|
419
450
|
added: dayMetrics?.new_this_run?.added ?? sampleRows.length,
|
|
420
451
|
evicted: dayMetrics?.new_this_run?.evicted,
|
|
421
|
-
// #246: only present when the day's nightly metered tokens
|
|
422
|
-
// are forwarded together — the page
|
|
452
|
+
// #246/#287: only present when the day's nightly metered tokens (distill
|
|
453
|
+
// or consolidation). Both fields are forwarded together — the page
|
|
454
|
+
// renders either the breakdown or nothing.
|
|
423
455
|
tokens: dayMetrics?.new_this_run?.tokens,
|
|
424
456
|
tokens_by_stage: dayMetrics?.new_this_run?.tokens_by_stage,
|
|
425
457
|
// #255 CR: always-on usage metric — present whenever consolidation ran
|
|
@@ -441,6 +473,10 @@ function handleDashboardData(db, query, config) {
|
|
|
441
473
|
return {
|
|
442
474
|
status: 200,
|
|
443
475
|
body: {
|
|
476
|
+
// Account identity — read defensively like the numeric knobs above:
|
|
477
|
+
// null when absent/not a string (page renders nothing, never "null").
|
|
478
|
+
// Shared readAccount() so GET /account renders the identical shape.
|
|
479
|
+
account: (0, config_read_js_1.readAccount)(config),
|
|
444
480
|
range: rangeParam,
|
|
445
481
|
headline,
|
|
446
482
|
series,
|
|
@@ -469,3 +505,21 @@ function dashboardDataHandler(getDb, getConfig) {
|
|
|
469
505
|
}
|
|
470
506
|
};
|
|
471
507
|
}
|
|
508
|
+
/**
|
|
509
|
+
* Express adapter for GET /account — the account identity ONLY (name/org/plan
|
|
510
|
+
* from config), so the /viz and /identity/ui pages can render the nav account
|
|
511
|
+
* element without pulling the full /dashboard/data payload. Same readAccount()
|
|
512
|
+
* construction as the dashboard payload — one shape, two surfaces. Also the
|
|
513
|
+
* natural whoami for the future OAuth session (#292). Failures surface as a
|
|
514
|
+
* 500 with the usual {error} shape (same as dashboardDataHandler).
|
|
515
|
+
*/
|
|
516
|
+
function accountHandler(getConfig) {
|
|
517
|
+
return (_req, res) => {
|
|
518
|
+
try {
|
|
519
|
+
res.status(200).json({ account: (0, config_read_js_1.readAccount)(getConfig()) });
|
|
520
|
+
}
|
|
521
|
+
catch (err) {
|
|
522
|
+
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -23,13 +23,37 @@
|
|
|
23
23
|
* machine's Hicortex nightly reads them via oc-transcript-reader.ts —
|
|
24
24
|
* canonical nightly-from-logs, same as CC JSONL and Hermes state.db.
|
|
25
25
|
*/
|
|
26
|
-
import type { MemorySearchResult } from "./types.js";
|
|
26
|
+
import type { HicortexConfig, MemorySearchResult } from "./types.js";
|
|
27
27
|
export declare function formatToolResults(results: MemorySearchResult[]): {
|
|
28
28
|
content: Array<{
|
|
29
29
|
type: string;
|
|
30
30
|
text: string;
|
|
31
31
|
}>;
|
|
32
32
|
};
|
|
33
|
+
/**
|
|
34
|
+
* Resolve the plugin's config from the raw `ctx.config` OC hands the service
|
|
35
|
+
* (the ENTIRE openclaw.json — verified against gateway-cli createServiceContext:
|
|
36
|
+
* config = params.cfg — not a per-plugin section). Stateless: touches no module
|
|
37
|
+
* state, never mutates the input, never throws (console.warn is its only side
|
|
38
|
+
* effect). Three branches, first match wins:
|
|
39
|
+
*
|
|
40
|
+
* 1. `plugins.entries.hicortex.config` — the canonical OC per-plugin
|
|
41
|
+
* section. Only eligible when it is a non-null object with ≥1 OWN key:
|
|
42
|
+
* OC scaffolds `config: {}` for an installed-but-unconfigured plugin,
|
|
43
|
+
* and that empty object must not shadow real config further down.
|
|
44
|
+
* 2. `hicortex` at the top level — but only when `typeof === "object"`:
|
|
45
|
+
* a string/bool/number there (e.g. `"hicortex": true` as a feature
|
|
46
|
+
* toggle) is not a config and is skipped, not cast.
|
|
47
|
+
* 3. the top level itself — bare keys (`serverUrl`, `authToken`, …) at the
|
|
48
|
+
* root of openclaw.json; the pre-0.19 shape, kept for backcompat.
|
|
49
|
+
*
|
|
50
|
+
* `serverUrl` and `authToken` are validated at this boundary: a present but
|
|
51
|
+
* non-string (or empty-string) value warns naming the key and degrades —
|
|
52
|
+
* serverUrl falls back to DEFAULT_SERVER_URL, authToken to undefined. No
|
|
53
|
+
* throw paths: a malformed config degrades to defaults instead of leaving
|
|
54
|
+
* the plugin half-initialized.
|
|
55
|
+
*/
|
|
56
|
+
export declare function resolveOcPluginConfig(raw: unknown): HicortexConfig;
|
|
33
57
|
declare const _default: {
|
|
34
58
|
id: string;
|
|
35
59
|
name: string;
|
package/dist/index.js
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
*/
|
|
27
27
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
28
28
|
exports.formatToolResults = formatToolResults;
|
|
29
|
+
exports.resolveOcPluginConfig = resolveOcPluginConfig;
|
|
29
30
|
const paths_js_1 = require("./paths.js");
|
|
30
31
|
const features_js_1 = require("./features.js");
|
|
31
32
|
const extensions_js_1 = require("./extensions.js");
|
|
@@ -256,6 +257,104 @@ function formatToolResults(results) {
|
|
|
256
257
|
return { content: [{ type: "text", text }] };
|
|
257
258
|
}
|
|
258
259
|
// ---------------------------------------------------------------------------
|
|
260
|
+
// Config resolution
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
/** Object (not null, not array) → itself as a record; anything else → undefined. */
|
|
263
|
+
function isRecord(v) {
|
|
264
|
+
return typeof v === "object" && v !== null && !Array.isArray(v)
|
|
265
|
+
? v
|
|
266
|
+
: undefined;
|
|
267
|
+
}
|
|
268
|
+
function isNonEmptyString(v) {
|
|
269
|
+
return typeof v === "string" && v.length > 0;
|
|
270
|
+
}
|
|
271
|
+
/** Human name for a config value that failed validation. Only ever called
|
|
272
|
+
* with INVALID values (non-strings and empty strings), so the string branch
|
|
273
|
+
* means "empty string". */
|
|
274
|
+
function describeInvalid(v) {
|
|
275
|
+
if (v === null)
|
|
276
|
+
return "null";
|
|
277
|
+
if (Array.isArray(v))
|
|
278
|
+
return "an array";
|
|
279
|
+
if (typeof v === "string")
|
|
280
|
+
return "an empty string";
|
|
281
|
+
return typeof v === "object" ? "an object" : `a ${typeof v}`;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Resolve the plugin's config from the raw `ctx.config` OC hands the service
|
|
285
|
+
* (the ENTIRE openclaw.json — verified against gateway-cli createServiceContext:
|
|
286
|
+
* config = params.cfg — not a per-plugin section). Stateless: touches no module
|
|
287
|
+
* state, never mutates the input, never throws (console.warn is its only side
|
|
288
|
+
* effect). Three branches, first match wins:
|
|
289
|
+
*
|
|
290
|
+
* 1. `plugins.entries.hicortex.config` — the canonical OC per-plugin
|
|
291
|
+
* section. Only eligible when it is a non-null object with ≥1 OWN key:
|
|
292
|
+
* OC scaffolds `config: {}` for an installed-but-unconfigured plugin,
|
|
293
|
+
* and that empty object must not shadow real config further down.
|
|
294
|
+
* 2. `hicortex` at the top level — but only when `typeof === "object"`:
|
|
295
|
+
* a string/bool/number there (e.g. `"hicortex": true` as a feature
|
|
296
|
+
* toggle) is not a config and is skipped, not cast.
|
|
297
|
+
* 3. the top level itself — bare keys (`serverUrl`, `authToken`, …) at the
|
|
298
|
+
* root of openclaw.json; the pre-0.19 shape, kept for backcompat.
|
|
299
|
+
*
|
|
300
|
+
* `serverUrl` and `authToken` are validated at this boundary: a present but
|
|
301
|
+
* non-string (or empty-string) value warns naming the key and degrades —
|
|
302
|
+
* serverUrl falls back to DEFAULT_SERVER_URL, authToken to undefined. No
|
|
303
|
+
* throw paths: a malformed config degrades to defaults instead of leaving
|
|
304
|
+
* the plugin half-initialized.
|
|
305
|
+
*/
|
|
306
|
+
function resolveOcPluginConfig(raw) {
|
|
307
|
+
const warn = (msg) => console.warn(`[hicortex] WARNING: ${msg}`);
|
|
308
|
+
const full = isRecord(raw) ?? {};
|
|
309
|
+
// Branch 1 — isRecord at every level: a missing key, string, array, or
|
|
310
|
+
// null anywhere in the chain just falls through to the next branch.
|
|
311
|
+
const plugins = isRecord(full.plugins);
|
|
312
|
+
const entries = isRecord(plugins?.entries);
|
|
313
|
+
const entry = isRecord(entries?.hicortex);
|
|
314
|
+
const entryConfig = isRecord(entry?.config);
|
|
315
|
+
const nested = entryConfig !== undefined && Object.keys(entryConfig).length > 0
|
|
316
|
+
? entryConfig
|
|
317
|
+
: undefined;
|
|
318
|
+
// Branch 2 — top-level `hicortex`, object-guarded (see doc block).
|
|
319
|
+
const hicortexObj = isRecord(full.hicortex);
|
|
320
|
+
const winner = nested ?? hicortexObj ?? full;
|
|
321
|
+
const winnerPath = nested !== undefined
|
|
322
|
+
? "plugins.entries.hicortex.config"
|
|
323
|
+
: hicortexObj !== undefined ? "hicortex" : "the top level of openclaw.json";
|
|
324
|
+
// Copy, never the caller's object: sanitizing below must not mutate
|
|
325
|
+
// ctx.config, and pluginConfig must not alias OC's config state.
|
|
326
|
+
const resolved = { ...winner };
|
|
327
|
+
const rawUrl = winner.serverUrl;
|
|
328
|
+
if (rawUrl !== undefined && !isNonEmptyString(rawUrl)) {
|
|
329
|
+
warn(`plugin config key "serverUrl" must be a non-empty string (got ${describeInvalid(rawUrl)}) ` +
|
|
330
|
+
`— falling back to ${DEFAULT_SERVER_URL}`);
|
|
331
|
+
resolved.serverUrl = DEFAULT_SERVER_URL;
|
|
332
|
+
}
|
|
333
|
+
const rawToken = winner.authToken;
|
|
334
|
+
if (rawToken !== undefined && !isNonEmptyString(rawToken)) {
|
|
335
|
+
warn(`plugin config key "authToken" must be a non-empty string (got ${describeInvalid(rawToken)}) — ignoring it`);
|
|
336
|
+
resolved.authToken = undefined;
|
|
337
|
+
}
|
|
338
|
+
// Shadow detection (F2) — two configs disagreeing, surfaced instead of
|
|
339
|
+
// silently honoring one of them. Case 1: an OC-scaffolded EMPTY
|
|
340
|
+
// plugins.entries.hicortex.config was skipped while a bare top-level
|
|
341
|
+
// serverUrl exists (the exact shape the ≥1-own-key rule exists for).
|
|
342
|
+
// Gate on the ACTUAL winner (re-CR F1): in the compound shape (empty
|
|
343
|
+
// nested config + a top-level hicortex object + bare serverUrl) the hicortex
|
|
344
|
+
// object wins — the warn must not claim the bare key is being used.
|
|
345
|
+
if (nested === undefined && entryConfig !== undefined && winner === full && isNonEmptyString(full.serverUrl)) {
|
|
346
|
+
warn(`plugins.entries.hicortex.config is empty, so the top-level serverUrl is used instead — ` +
|
|
347
|
+
`remove the empty config section or move serverUrl into it`);
|
|
348
|
+
}
|
|
349
|
+
// Case 2: a real nested/hicortex section won the chain but carries no valid
|
|
350
|
+
// serverUrl, while a bare top-level serverUrl is set and will NOT be read.
|
|
351
|
+
if (winner !== full && !isNonEmptyString(rawUrl) && isNonEmptyString(full.serverUrl)) {
|
|
352
|
+
warn(`top-level serverUrl is set but ${winnerPath} takes precedence and has no valid serverUrl — ` +
|
|
353
|
+
`the plugin will NOT use the top-level value; move serverUrl into ${winnerPath}`);
|
|
354
|
+
}
|
|
355
|
+
return resolved;
|
|
356
|
+
}
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
259
358
|
// Plugin export
|
|
260
359
|
// ---------------------------------------------------------------------------
|
|
261
360
|
exports.default = {
|
|
@@ -269,12 +368,19 @@ exports.default = {
|
|
|
269
368
|
api.registerService({
|
|
270
369
|
id: "hicortex-service",
|
|
271
370
|
async start(ctx) {
|
|
272
|
-
|
|
371
|
+
// OC passes the ENTIRE openclaw.json as ctx.config (verified against
|
|
372
|
+
// gateway-cli createServiceContext: config = params.cfg), not a
|
|
373
|
+
// per-plugin section. resolveOcPluginConfig picks our section out of
|
|
374
|
+
// it (branch order documented on the function) and validates the
|
|
375
|
+
// scalar keys at the boundary. It never throws, so a malformed config
|
|
376
|
+
// can only degrade (warn + default), never half-initialize the plugin.
|
|
377
|
+
const config = resolveOcPluginConfig(ctx.config);
|
|
273
378
|
pluginConfig = config;
|
|
274
379
|
const log = ctx.logger
|
|
275
380
|
? (msg) => ctx.logger.info(msg)
|
|
276
381
|
: console.log;
|
|
277
|
-
// Resolve server URL and auth token from plugin config
|
|
382
|
+
// Resolve server URL and auth token from plugin config (both already
|
|
383
|
+
// validated strings — or absent, hence the ?? default)
|
|
278
384
|
serverUrl = (config.serverUrl ?? DEFAULT_SERVER_URL).replace(/\/+$/, "");
|
|
279
385
|
authToken = config.authToken;
|
|
280
386
|
// Use stateDir from context so tests can redirect state writes
|
package/dist/mcp-server.d.ts
CHANGED
|
@@ -10,7 +10,25 @@
|
|
|
10
10
|
* GET /sse — SSE stream for MCP clients
|
|
11
11
|
* POST /messages — message endpoint for MCP clients
|
|
12
12
|
*/
|
|
13
|
+
import express from "express";
|
|
13
14
|
import type { MemorySearchResult } from "./types.js";
|
|
15
|
+
/**
|
|
16
|
+
* Resolve the request body-size limit in MB (#7). Pure — exported for tests.
|
|
17
|
+
* Precedence: an explicit config value > hosted-mode default (5) > self-hosted
|
|
18
|
+
* default (25, the historical fixed value → no regression). A finite positive
|
|
19
|
+
* config value wins; invalid/absent falls through.
|
|
20
|
+
*/
|
|
21
|
+
export declare function resolveBodyLimitMb(configVal: unknown, hostedMode: boolean): number;
|
|
22
|
+
/**
|
|
23
|
+
* Express error middleware (#7): translate express.json's default HTML 413
|
|
24
|
+
* (entity.too.large) into a consistent JSON response. Catches body-parser
|
|
25
|
+
* errors only — which express.json emits BEFORE any route runs — so by
|
|
26
|
+
* registration order (this sits ahead of the routes) it never intercepts an
|
|
27
|
+
* error thrown inside a route handler; those reach Express's default handler.
|
|
28
|
+
* The `status === 413 || type === "entity.too.large"` check is defense-in-depth
|
|
29
|
+
* on top of that ordering. Exported so tests exercise the real handler.
|
|
30
|
+
*/
|
|
31
|
+
export declare function makeBodyLimitErrorHandler(limitMb: number): express.ErrorRequestHandler;
|
|
14
32
|
export declare function startServer(options?: {
|
|
15
33
|
port?: number;
|
|
16
34
|
host?: string;
|
package/dist/mcp-server.js
CHANGED
|
@@ -48,6 +48,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
48
48
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
49
49
|
};
|
|
50
50
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
51
|
+
exports.resolveBodyLimitMb = resolveBodyLimitMb;
|
|
52
|
+
exports.makeBodyLimitErrorHandler = makeBodyLimitErrorHandler;
|
|
51
53
|
exports.startServer = startServer;
|
|
52
54
|
exports.formatResults = formatResults;
|
|
53
55
|
const express_1 = __importDefault(require("express"));
|
|
@@ -407,6 +409,38 @@ function createMcpServer() {
|
|
|
407
409
|
// ---------------------------------------------------------------------------
|
|
408
410
|
// HTTP server with SSE transport
|
|
409
411
|
// ---------------------------------------------------------------------------
|
|
412
|
+
/**
|
|
413
|
+
* Resolve the request body-size limit in MB (#7). Pure — exported for tests.
|
|
414
|
+
* Precedence: an explicit config value > hosted-mode default (5) > self-hosted
|
|
415
|
+
* default (25, the historical fixed value → no regression). A finite positive
|
|
416
|
+
* config value wins; invalid/absent falls through.
|
|
417
|
+
*/
|
|
418
|
+
function resolveBodyLimitMb(configVal, hostedMode) {
|
|
419
|
+
const cfg = Number(configVal);
|
|
420
|
+
if (Number.isFinite(cfg) && cfg > 0)
|
|
421
|
+
return cfg;
|
|
422
|
+
return hostedMode ? 5 : 25;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Express error middleware (#7): translate express.json's default HTML 413
|
|
426
|
+
* (entity.too.large) into a consistent JSON response. Catches body-parser
|
|
427
|
+
* errors only — which express.json emits BEFORE any route runs — so by
|
|
428
|
+
* registration order (this sits ahead of the routes) it never intercepts an
|
|
429
|
+
* error thrown inside a route handler; those reach Express's default handler.
|
|
430
|
+
* The `status === 413 || type === "entity.too.large"` check is defense-in-depth
|
|
431
|
+
* on top of that ordering. Exported so tests exercise the real handler.
|
|
432
|
+
*/
|
|
433
|
+
function makeBodyLimitErrorHandler(limitMb) {
|
|
434
|
+
return (err, _req, res, next) => {
|
|
435
|
+
const status = err.status;
|
|
436
|
+
const type = err.type;
|
|
437
|
+
if (status === 413 || type === "entity.too.large") {
|
|
438
|
+
res.status(413).json({ error: "request body too large", limit_mb: limitMb });
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
next(err);
|
|
442
|
+
};
|
|
443
|
+
}
|
|
410
444
|
async function startServer(options = {}) {
|
|
411
445
|
const port = options.port ?? 8787;
|
|
412
446
|
const host = options.host ?? "0.0.0.0";
|
|
@@ -493,6 +527,12 @@ async function startServer(options = {}) {
|
|
|
493
527
|
// env (provider-set, tenant-immutable) which takes precedence. Initialised here
|
|
494
528
|
// (after stateDir + savedConfig are known) so the warn-dedup can seed from state.
|
|
495
529
|
(0, token_budget_js_1.initTokenBudget)(stateDir, savedConfig?.llmTokensPerMonth);
|
|
530
|
+
// #7: request body-size limit. Config key wins; else 5 MB hosted / 25 MB
|
|
531
|
+
// self-hosted (the prior fixed value → no regression). Guards the OOM vector
|
|
532
|
+
// (the body is fully parsed into memory before the distiller truncates to 80K
|
|
533
|
+
// chars). Legitimate capture segments are ≤60K chars (~200KB), so this never
|
|
534
|
+
// constrains real flow — it's an abuse/backstop. Oversized → 413.
|
|
535
|
+
const bodyLimitMb = resolveBodyLimitMb(savedConfig?.distillBodyLimitMb, hostedMode);
|
|
496
536
|
if (savedConfig?.llmBackend === "claude-cli") {
|
|
497
537
|
const claudePath = (0, llm_js_1.findClaudeBinary)();
|
|
498
538
|
if (claudePath) {
|
|
@@ -636,7 +676,11 @@ async function startServer(options = {}) {
|
|
|
636
676
|
// Express app
|
|
637
677
|
const app = (0, express_1.default)();
|
|
638
678
|
// Raise the body limit — whole-session denoised transcripts exceed the 100 kB default.
|
|
639
|
-
app.use(express_1.default.json({ limit:
|
|
679
|
+
app.use(express_1.default.json({ limit: `${bodyLimitMb}mb` }));
|
|
680
|
+
// #7: JSON 413 on body-limit exceed (see makeBodyLimitErrorHandler). Server-side
|
|
681
|
+
// only — the client capture loop treats 413 like any non-2xx (holds cursor);
|
|
682
|
+
// it never fires for legitimate capture (segments ≤200KB ≪ the limit).
|
|
683
|
+
app.use(makeBodyLimitErrorHandler(bodyLimitMb));
|
|
640
684
|
// CORS: reflect ONLY explicitly-allowlisted origins (config.corsAllowedOrigins),
|
|
641
685
|
// and never send Access-Control-Allow-Credentials. Reflecting any origin with
|
|
642
686
|
// credentials — combined with the localhost auth bypass and the default 0.0.0.0
|
|
@@ -997,7 +1041,8 @@ async function startServer(options = {}) {
|
|
|
997
1041
|
});
|
|
998
1042
|
// REST /distill — canonical capture endpoint (0.9.0+).
|
|
999
1043
|
// Every machine (including the server itself) POSTs denoised session text here.
|
|
1000
|
-
// The server distills, embeds, stores. Body limit:
|
|
1044
|
+
// The server distills, embeds, stores. Body limit: see `distillBodyLimitMb`
|
|
1045
|
+
// (default 25 MB self-hosted / 5 MB hosted); oversized → 413 (#7).
|
|
1001
1046
|
//
|
|
1002
1047
|
// Accepts text (string, preferred nightly path) OR messages (array, legacy).
|
|
1003
1048
|
// Performs session-level dedup when session_id is present without segment_id.
|
|
@@ -1161,6 +1206,12 @@ async function startServer(options = {}) {
|
|
|
1161
1206
|
ids,
|
|
1162
1207
|
distilled: ids.length,
|
|
1163
1208
|
dropped: dropped.map((d) => (d.length > 120 ? `${d.slice(0, 120)}…` : d)),
|
|
1209
|
+
// #287: this segment's metered usage — the same breakdown
|
|
1210
|
+
// recordDistillUsage accrues below. Lets the capturing nightly
|
|
1211
|
+
// attribute distill tokens in its dashboard snapshot
|
|
1212
|
+
// (new_this_run.tokens_by_stage.distill). Always present (zeros when
|
|
1213
|
+
// no chunk reached an LLM call); pre-#287 clients ignore it.
|
|
1214
|
+
usage: distillUsage,
|
|
1164
1215
|
});
|
|
1165
1216
|
}
|
|
1166
1217
|
catch (err) {
|
|
@@ -1452,6 +1503,13 @@ async function startServer(options = {}) {
|
|
|
1452
1503
|
// express adapter that injects the live db + config. STRICTLY view-only —
|
|
1453
1504
|
// no mutation endpoints on the dashboard surface.
|
|
1454
1505
|
app.get("/dashboard/data", (0, dashboard_js_1.dashboardDataHandler)(() => db, () => readConfigFile(stateDir)));
|
|
1506
|
+
// GET /account — account identity for the console nav (name/org/plan from
|
|
1507
|
+
// config). The LIGHTWEIGHT twin of the account block inside /dashboard/data:
|
|
1508
|
+
// the /viz and /identity/ui pages need only this, not the metric payload;
|
|
1509
|
+
// also the natural whoami for the future OAuth session (#292). Bearer-only
|
|
1510
|
+
// (standard auth middleware, no shell exemption — it carries data); localhost
|
|
1511
|
+
// bypass applies. Handler lives in src/dashboard.ts next to its twin.
|
|
1512
|
+
app.get("/account", (0, dashboard_js_1.accountHandler)(() => readConfigFile(stateDir)));
|
|
1455
1513
|
// SSE endpoint — each connection gets its own McpServer + transport
|
|
1456
1514
|
app.get("/sse", async (req, res) => {
|
|
1457
1515
|
const transport = new sse_js_1.SSEServerTransport("/messages", res);
|
|
@@ -30,7 +30,7 @@ exports.MEMORY_SECTION_NAME = "memory";
|
|
|
30
30
|
* injected once per session into every agent on the fleet. */
|
|
31
31
|
function renderMemoryInstructions() {
|
|
32
32
|
return [
|
|
33
|
-
"
|
|
33
|
+
"Hicortex is your persistent identity and long-term memory: what you learn, decide, and correct survives every session, compaction, and model switch — one memory shared by all your agents.",
|
|
34
34
|
"- A `## Memory recall (auto)` index may arrive with prompts: it is a MENU, not content. Fetch a full memory with `hicortex_get(id)` when the entry could change how you handle the current task.",
|
|
35
35
|
"- Recall before assuming: `hicortex_search` for prior decisions/facts/preferences, `hicortex_recent` to catch up on a project.",
|
|
36
36
|
"- Cite any memory you rely on by id + date, and mark it `FETCHED` (you read the full memory via `hicortex_get`) or `SNIPPET` (the one-line entry only). Don't present a SNIPPET citation as established. On conflicts, newer memories supersede older.",
|