@gamaze/hicortex 0.19.3 → 0.19.5
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 +13 -2
- package/dist/backup.d.ts +50 -0
- package/dist/backup.js +118 -2
- package/dist/capture.d.ts +27 -2
- package/dist/capture.js +103 -6
- package/dist/distiller.d.ts +46 -1
- package/dist/distiller.js +92 -9
- package/dist/embedder.d.ts +23 -0
- package/dist/embedder.js +29 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +286 -5
- package/dist/mcp-server.d.ts +25 -4
- package/dist/mcp-server.js +89 -10
- package/dist/nightly.d.ts +25 -0
- package/dist/nightly.js +125 -20
- package/dist/prompts.d.ts +18 -0
- package/dist/prompts.js +38 -0
- package/dist/recall-index.d.ts +35 -0
- package/dist/recall-index.js +103 -20
- package/dist/retrieval.d.ts +11 -0
- package/dist/retrieval.js +6 -2
- package/dist/storage.d.ts +30 -0
- package/dist/storage.js +46 -1
- package/dist/type-classify.js +3 -1
- package/dist/types.d.ts +17 -0
- package/dist/uninstall.d.ts +33 -0
- package/dist/uninstall.js +78 -26
- package/dist/viz.d.ts +1 -1
- package/dist/viz.js +28 -1
- package/openclaw.plugin.json +10 -1
- package/package.json +1 -1
package/dist/distiller.js
CHANGED
|
@@ -8,11 +8,28 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
8
8
|
exports.detectChunkSize = detectChunkSize;
|
|
9
9
|
exports.extractConversationText = extractConversationText;
|
|
10
10
|
exports.distillSession = distillSession;
|
|
11
|
+
exports.isNoExtractResponse = isNoExtractResponse;
|
|
11
12
|
exports.hasMinimalSubstance = hasMinimalSubstance;
|
|
13
|
+
exports.typeFromTag = typeFromTag;
|
|
14
|
+
exports.parseDistilledEntries = parseDistilledEntries;
|
|
12
15
|
const prompts_js_1 = require("./prompts.js");
|
|
13
16
|
const redact_js_1 = require("./redact.js");
|
|
14
17
|
const MAX_TRANSCRIPT_CHARS = 80_000;
|
|
15
18
|
const MIN_CONVERSATION_CHARS = 200;
|
|
19
|
+
// #339 (2026-08-24 postmortem): NO_EXTRACT over-firing visibility threshold.
|
|
20
|
+
// Real summary-led segments that the model wrongly abandoned ran 38-64K
|
|
21
|
+
// denoised chars; genuine pure-status noise is ~2.6K. A segment larger than
|
|
22
|
+
// this whose every chunk returns an empty LLM verdict is far more likely the
|
|
23
|
+
// model pattern-matching a bookkeeping-heavy OPENING to the ephemera gate than
|
|
24
|
+
// a legitimately empty segment — so it gets a warning line. Warning ONLY: no
|
|
25
|
+
// auto-retry (cost); the goal is that silent segment loss shows up in the
|
|
26
|
+
// nightly log instead of in a weeks-later eval.
|
|
27
|
+
//
|
|
28
|
+
// The threshold compares the PRE-CHUNKING conversation length, never the chunk
|
|
29
|
+
// length: default ollama chunking (numCtx 8192 → ~19.6K chars) and the
|
|
30
|
+
// small-model speed cap (20K) both keep every chunk at or below this number,
|
|
31
|
+
// so a chunk-level check would be unreachable exactly where the incident lived.
|
|
32
|
+
const NO_EXTRACT_WARN_MIN_CHARS = 20_000;
|
|
16
33
|
// Chunk size limits by model parameter count (for local/CPU inference)
|
|
17
34
|
// Small models are slow on CPU — cap input size to keep inference under ~60s
|
|
18
35
|
const SMALL_MODEL_PARAMS = 8_000_000_000; // 8B — threshold for "small"
|
|
@@ -225,10 +242,16 @@ function extractConversationText(messages, redactionConfig) {
|
|
|
225
242
|
* `droppedOut`, when provided, is filled with every entry the substance gate
|
|
226
243
|
* discarded (full text). Callers use it to build a durable audit trail (#156);
|
|
227
244
|
* omitting it leaves gate behaviour unchanged.
|
|
245
|
+
*
|
|
246
|
+
* `segmentLabel` (optional) identifies the caller's segment in the #339
|
|
247
|
+
* over-firing warning (e.g. the capture pipeline's segment_id). Purely for
|
|
248
|
+
* log correlation — omitting it falls back to "chunk".
|
|
228
249
|
*/
|
|
229
250
|
async function distillSession(llm, conversation, projectName, date, chunkSizeChars, droppedOut,
|
|
230
251
|
/** Called with each chunk's token usage (#5 budget metering). Optional. */
|
|
231
|
-
onUsage
|
|
252
|
+
onUsage,
|
|
253
|
+
/** Segment identifier for the #339 NO_EXTRACT warning. Optional. */
|
|
254
|
+
segmentLabel) {
|
|
232
255
|
if (conversation.length < MIN_CONVERSATION_CHARS) {
|
|
233
256
|
return [];
|
|
234
257
|
}
|
|
@@ -241,9 +264,11 @@ onUsage) {
|
|
|
241
264
|
const chunkSize = chunkSizeChars ?? MAX_TRANSCRIPT_CHARS;
|
|
242
265
|
// If transcript fits in one chunk, distill directly (errors propagate)
|
|
243
266
|
if (transcript.length <= chunkSize) {
|
|
244
|
-
const { entries, dropped } = await distillChunk(llm, transcript, projectName, date, onUsage);
|
|
267
|
+
const { entries, dropped, emptyVerdict } = await distillChunk(llm, transcript, projectName, date, onUsage);
|
|
245
268
|
if (droppedOut)
|
|
246
269
|
droppedOut.push(...dropped);
|
|
270
|
+
if (emptyVerdict)
|
|
271
|
+
warnSuspiciousEmptySegment(segmentLabel, conversation.length);
|
|
247
272
|
return entries;
|
|
248
273
|
}
|
|
249
274
|
// Chunk large transcripts and distill each segment.
|
|
@@ -259,11 +284,14 @@ onUsage) {
|
|
|
259
284
|
const allEntries = [];
|
|
260
285
|
const seen = new Set();
|
|
261
286
|
let chunkFailures = 0;
|
|
287
|
+
let emptyVerdicts = 0;
|
|
262
288
|
let lastError = null;
|
|
263
289
|
for (let i = 0; i < chunks.length; i++) {
|
|
264
290
|
console.log(`[hicortex] Chunk ${i + 1}/${chunks.length} (${chunks[i].length} chars)`);
|
|
265
291
|
try {
|
|
266
|
-
const { entries, dropped } = await distillChunk(llm, chunks[i], projectName, date, onUsage);
|
|
292
|
+
const { entries, dropped, emptyVerdict } = await distillChunk(llm, chunks[i], projectName, date, onUsage);
|
|
293
|
+
if (emptyVerdict)
|
|
294
|
+
emptyVerdicts++;
|
|
267
295
|
if (droppedOut)
|
|
268
296
|
droppedOut.push(...dropped);
|
|
269
297
|
for (const entry of entries) {
|
|
@@ -292,8 +320,38 @@ onUsage) {
|
|
|
292
320
|
if (chunkFailures > 0) {
|
|
293
321
|
console.warn(`[hicortex] Partial distillation: ${chunks.length - chunkFailures}/${chunks.length} chunks succeeded`);
|
|
294
322
|
}
|
|
323
|
+
// #339 segment-level net (CR finding 1): fire when the WHOLE segment produced
|
|
324
|
+
// zero memories and every processed chunk returned an empty LLM verdict. Fires
|
|
325
|
+
// only with zero chunk failures — failed chunks are already loudly visible,
|
|
326
|
+
// and "all failed" throws above. The size gate lives INSIDE
|
|
327
|
+
// warnSuspiciousEmptySegment (strict >, pre-chunking conversation length).
|
|
328
|
+
if (allEntries.length === 0 &&
|
|
329
|
+
chunkFailures === 0 &&
|
|
330
|
+
emptyVerdicts === chunks.length) {
|
|
331
|
+
warnSuspiciousEmptySegment(segmentLabel, conversation.length);
|
|
332
|
+
}
|
|
295
333
|
return allEntries;
|
|
296
334
|
}
|
|
335
|
+
/**
|
|
336
|
+
* #339 over-firing visibility net: an empty result this large is the silent-loss
|
|
337
|
+
* signature (real summary-led segments are 38-64K chars; legitimate pure-status
|
|
338
|
+
* noise is ~2.6K). Warning-only, content-free — segment id + size, nothing from
|
|
339
|
+
* the transcript. The empty SUCCESS semantics are unchanged (no throw, no
|
|
340
|
+
* retry): the cursor advances, but the loss is now VISIBLE in the nightly log
|
|
341
|
+
* instead of surfacing weeks later in an eval.
|
|
342
|
+
*
|
|
343
|
+
* The size check lives here, not at call sites, so no path can skip it. Strict
|
|
344
|
+
* comparison: exactly NO_EXTRACT_WARN_MIN_CHARS chars is a legitimate small
|
|
345
|
+
* segment and stays silent.
|
|
346
|
+
*/
|
|
347
|
+
function warnSuspiciousEmptySegment(segmentLabel, segmentChars) {
|
|
348
|
+
if (segmentChars <= NO_EXTRACT_WARN_MIN_CHARS)
|
|
349
|
+
return;
|
|
350
|
+
console.warn(`[hicortex] Suspicious empty distillation: zero memories for a ${segmentChars}-char ` +
|
|
351
|
+
`${segmentLabel ? `segment ${segmentLabel}` : "segment"} ` +
|
|
352
|
+
`(every chunk returned NO_EXTRACT or nothing parseable) — segments this large almost always ` +
|
|
353
|
+
`contain extractable material; if this repeats, suspect gate over-firing (logged only, not retried)`);
|
|
354
|
+
}
|
|
297
355
|
/**
|
|
298
356
|
* Distill a single chunk of conversation text.
|
|
299
357
|
*
|
|
@@ -309,6 +367,13 @@ onUsage) {
|
|
|
309
367
|
*
|
|
310
368
|
* `dropped` carries entries the substance gate rejected (full text) so the
|
|
311
369
|
* caller can surface them in a durable audit trail (#156).
|
|
370
|
+
*
|
|
371
|
+
* `emptyVerdict` is true when the chunk was processed successfully but the LLM's
|
|
372
|
+
* verdict contained nothing extractable: bare NO_EXTRACT, an empty response, or
|
|
373
|
+
* text that parses to zero bullets (prose — the silent twin of NO_EXTRACT,
|
|
374
|
+
* #339 CR finding 2). The caller aggregates these for the segment-level
|
|
375
|
+
* over-firing warning; entries extracted then dropped by the substance gate do
|
|
376
|
+
* NOT count (their drops are already logged).
|
|
312
377
|
*/
|
|
313
378
|
async function distillChunk(llm, transcript, projectName, date, onUsage) {
|
|
314
379
|
const prompt = (0, prompts_js_1.distillation)(projectName, date, transcript);
|
|
@@ -323,10 +388,8 @@ async function distillChunk(llm, transcript, projectName, date, onUsage) {
|
|
|
323
388
|
// trip a budget.
|
|
324
389
|
if (usage && onUsage)
|
|
325
390
|
onUsage(usage);
|
|
326
|
-
if (!result)
|
|
327
|
-
return { entries: [], dropped: [] };
|
|
328
|
-
if (result === "NO_EXTRACT" || result.slice(0, 20).includes("NO_EXTRACT")) {
|
|
329
|
-
return { entries: [], dropped: [] };
|
|
391
|
+
if (!result || isNoExtractResponse(result)) {
|
|
392
|
+
return { entries: [], dropped: [], emptyVerdict: true };
|
|
330
393
|
}
|
|
331
394
|
const parsed = parseDistilledEntries(result);
|
|
332
395
|
// Smoke alarm (PR #218 review): the prompt enforces topic-first, but models
|
|
@@ -356,7 +419,20 @@ async function distillChunk(llm, transcript, projectName, date, onUsage) {
|
|
|
356
419
|
}
|
|
357
420
|
console.log(`[hicortex] Substance gate: dropped ${dropped.length}/${parsed.length} content-free fragment(s)`);
|
|
358
421
|
}
|
|
359
|
-
|
|
422
|
+
// Parsed-zero bypass (#339 CR finding 2): a non-empty response with no
|
|
423
|
+
// NO_EXTRACT token that still parses to zero bullets is the silent twin of
|
|
424
|
+
// NO_EXTRACT — an empty verdict for the over-firing net.
|
|
425
|
+
return { entries, dropped, emptyVerdict: parsed.length === 0 };
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* The NO_EXTRACT check distillChunk applies to an LLM response. EXPORTED and
|
|
429
|
+
* shared (not copy-pasted) with scripts/distill-ab-check/, whose counts must
|
|
430
|
+
* classify empty verdicts exactly as production does (#339 CR finding 3).
|
|
431
|
+
* Tolerant by design: a literal NO_EXTRACT anywhere in the first 20 chars
|
|
432
|
+
* counts (models prepend stray whitespace or a short phrase).
|
|
433
|
+
*/
|
|
434
|
+
function isNoExtractResponse(result) {
|
|
435
|
+
return result === "NO_EXTRACT" || result.slice(0, 20).includes("NO_EXTRACT");
|
|
360
436
|
}
|
|
361
437
|
/**
|
|
362
438
|
* Split transcript text into chunks at natural boundaries (double newlines).
|
|
@@ -445,6 +521,12 @@ function hasMinimalSubstance(entry) {
|
|
|
445
521
|
* value changed in #264 (episode→experience, fact→knowledge, decision→
|
|
446
522
|
* decisions). The tag letters stay stable so neither the prompt nor the
|
|
447
523
|
* parser needs to change; only this mapping table moves.
|
|
524
|
+
*
|
|
525
|
+
* EXPORTED (with parseDistilledEntries) for scripts/distill-ab-check/ (#339 CR
|
|
526
|
+
* finding 3): the A/B harness computes its counts from each variant build's own
|
|
527
|
+
* parser instead of a copy-pasted mirror, so harness numbers are by construction
|
|
528
|
+
* the numbers that build's production would store. tests/distill-ab-parser-contract.test.ts
|
|
529
|
+
* pins the src and dist parsers against the same corpus.
|
|
448
530
|
*/
|
|
449
531
|
function typeFromTag(letter) {
|
|
450
532
|
switch (letter) {
|
|
@@ -467,7 +549,8 @@ function typeFromTag(letter) {
|
|
|
467
549
|
* tag is extracted (→ memoryType), stripped from the stored content, and
|
|
468
550
|
* passed to `insertMemory` via the `memoryType` option (#216). Bullets with
|
|
469
551
|
* no tag default to "experience" (backward compatible with pre-#216 distiller
|
|
470
|
-
* output that never carried a tag).
|
|
552
|
+
* output that never carried a tag). EXPORTED for the A/B harness — see
|
|
553
|
+
* typeFromTag's comment (#339 CR finding 3).
|
|
471
554
|
*/
|
|
472
555
|
function parseDistilledEntries(markdown) {
|
|
473
556
|
const entries = [];
|
package/dist/embedder.d.ts
CHANGED
|
@@ -25,6 +25,29 @@ export declare function embed(text: string): Promise<Float32Array>;
|
|
|
25
25
|
* Embed multiple texts. Returns an array of Float32Array embeddings.
|
|
26
26
|
*/
|
|
27
27
|
export declare function embedBatch(texts: string[]): Promise<Float32Array[]>;
|
|
28
|
+
/**
|
|
29
|
+
* Fire-and-forget embedder warm-up (#329 item 2), called at the END of server
|
|
30
|
+
* boot. The ONNX pipeline lazy-loads inside the first embed() (~0.5-3s cold),
|
|
31
|
+
* so without this the FIRST /recall-index after every restart paid the model
|
|
32
|
+
* load inside its own latency budget — the 1s client hook budget blows and
|
|
33
|
+
* that turn silently loses recall.
|
|
34
|
+
*
|
|
35
|
+
* Contract (unit-pinned in tests/embedder-warm.test.ts):
|
|
36
|
+
* - fires exactly ONE embed call ("warmup"), NEVER awaited — returns
|
|
37
|
+
* synchronously so boot/listen is never blocked;
|
|
38
|
+
* - a failing warm-up is logged once (console.warn) and swallowed —
|
|
39
|
+
* warm-up is an optimization, never a boot dependency. The next real
|
|
40
|
+
* embed() retries the lazy load on its own terms.
|
|
41
|
+
*
|
|
42
|
+
* `embedFn` is injectable for tests; production passes the module's embed().
|
|
43
|
+
*
|
|
44
|
+
* MEMORY NOTE (accepted trade-off, #329 CR finding 3): warming at boot makes
|
|
45
|
+
* the model (~150-300MB resident) load in every server process from startup —
|
|
46
|
+
* including idle hosted tenant containers, which previously never loaded it.
|
|
47
|
+
* Accepted at current hosted sizing (2g per-tenant caps; active tenants load
|
|
48
|
+
* it on first use anyway). See the warm-site comment in mcp-server.ts.
|
|
49
|
+
*/
|
|
50
|
+
export declare function warmEmbedder(embedFn?: (text: string) => Promise<Float32Array>): void;
|
|
28
51
|
/**
|
|
29
52
|
* Return the embedding dimension count.
|
|
30
53
|
*/
|
package/dist/embedder.js
CHANGED
|
@@ -11,6 +11,7 @@ exports.EMBEDDING_DIMENSIONS = void 0;
|
|
|
11
11
|
exports.resolveModelCacheDir = resolveModelCacheDir;
|
|
12
12
|
exports.embed = embed;
|
|
13
13
|
exports.embedBatch = embedBatch;
|
|
14
|
+
exports.warmEmbedder = warmEmbedder;
|
|
14
15
|
exports.dimensions = dimensions;
|
|
15
16
|
const node_fs_1 = require("node:fs");
|
|
16
17
|
const node_path_1 = require("node:path");
|
|
@@ -104,6 +105,34 @@ async function embedBatch(texts) {
|
|
|
104
105
|
}
|
|
105
106
|
return results;
|
|
106
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Fire-and-forget embedder warm-up (#329 item 2), called at the END of server
|
|
110
|
+
* boot. The ONNX pipeline lazy-loads inside the first embed() (~0.5-3s cold),
|
|
111
|
+
* so without this the FIRST /recall-index after every restart paid the model
|
|
112
|
+
* load inside its own latency budget — the 1s client hook budget blows and
|
|
113
|
+
* that turn silently loses recall.
|
|
114
|
+
*
|
|
115
|
+
* Contract (unit-pinned in tests/embedder-warm.test.ts):
|
|
116
|
+
* - fires exactly ONE embed call ("warmup"), NEVER awaited — returns
|
|
117
|
+
* synchronously so boot/listen is never blocked;
|
|
118
|
+
* - a failing warm-up is logged once (console.warn) and swallowed —
|
|
119
|
+
* warm-up is an optimization, never a boot dependency. The next real
|
|
120
|
+
* embed() retries the lazy load on its own terms.
|
|
121
|
+
*
|
|
122
|
+
* `embedFn` is injectable for tests; production passes the module's embed().
|
|
123
|
+
*
|
|
124
|
+
* MEMORY NOTE (accepted trade-off, #329 CR finding 3): warming at boot makes
|
|
125
|
+
* the model (~150-300MB resident) load in every server process from startup —
|
|
126
|
+
* including idle hosted tenant containers, which previously never loaded it.
|
|
127
|
+
* Accepted at current hosted sizing (2g per-tenant caps; active tenants load
|
|
128
|
+
* it on first use anyway). See the warm-site comment in mcp-server.ts.
|
|
129
|
+
*/
|
|
130
|
+
function warmEmbedder(embedFn = embed) {
|
|
131
|
+
embedFn("warmup").catch((err) => {
|
|
132
|
+
console.warn(`[hicortex] Embedder warm-up failed (first search will lazy-load instead): ` +
|
|
133
|
+
(err instanceof Error ? err.message : String(err)));
|
|
134
|
+
});
|
|
135
|
+
}
|
|
107
136
|
/**
|
|
108
137
|
* Return the embedding dimension count.
|
|
109
138
|
*/
|
package/dist/index.d.ts
CHANGED
|
@@ -69,6 +69,16 @@ export declare function formatToolResults(results: MemorySearchResult[]): {
|
|
|
69
69
|
* the plugin half-initialized.
|
|
70
70
|
*/
|
|
71
71
|
export declare function resolveOcPluginConfig(raw: unknown): HicortexConfig;
|
|
72
|
+
/**
|
|
73
|
+
* Resolve the agent workspace directory from the RAW gateway config (#326):
|
|
74
|
+
* OpenClaw's `agents.defaults.workspace`. Pure — no module state, no fs, no
|
|
75
|
+
* mutation, never throws. Absent/non-string/empty → null (the caller falls
|
|
76
|
+
* back to the OC default workspace). Deliberately does NOT read the plugin's
|
|
77
|
+
* own config section: the workspace is a gateway-level fact, not a plugin
|
|
78
|
+
* knob, so it is resolved from ctx.config directly (the whole openclaw.json,
|
|
79
|
+
* same object resolveOcPluginConfig walks).
|
|
80
|
+
*/
|
|
81
|
+
export declare function resolveOcWorkspaceDir(raw: unknown): string | null;
|
|
72
82
|
declare const _default: {
|
|
73
83
|
id: string;
|
|
74
84
|
name: string;
|
|
@@ -76,3 +86,12 @@ declare const _default: {
|
|
|
76
86
|
register(api: any): void;
|
|
77
87
|
};
|
|
78
88
|
export default _default;
|
|
89
|
+
/**
|
|
90
|
+
* Normalize a gateway-config workspace path for WRITING (#326 CR1): a bare
|
|
91
|
+
* `~` or leading `~/` expands against the real home dir; anything still
|
|
92
|
+
* RELATIVE afterwards is rejected (null). OpenClaw's semantics for relative
|
|
93
|
+
* workspace values are not verifiable from the plugin, and writing under
|
|
94
|
+
* process.cwd() would place the guard where OC never reads it — a silent
|
|
95
|
+
* no-op safety — so the caller skips with a warning instead. Pure; no fs.
|
|
96
|
+
*/
|
|
97
|
+
export declare function normalizeWorkspacePath(ws: string): string | null;
|
package/dist/index.js
CHANGED
|
@@ -42,6 +42,8 @@
|
|
|
42
42
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
43
43
|
exports.formatToolResults = formatToolResults;
|
|
44
44
|
exports.resolveOcPluginConfig = resolveOcPluginConfig;
|
|
45
|
+
exports.resolveOcWorkspaceDir = resolveOcWorkspaceDir;
|
|
46
|
+
exports.normalizeWorkspacePath = normalizeWorkspacePath;
|
|
45
47
|
const paths_js_1 = require("./paths.js");
|
|
46
48
|
const features_js_1 = require("./features.js");
|
|
47
49
|
const extensions_js_1 = require("./extensions.js");
|
|
@@ -128,6 +130,14 @@ const warnedRecallStatuses = new Set();
|
|
|
128
130
|
/** Warn-once flag for /search fallback failures (#316 CR finding 3): fires on
|
|
129
131
|
* the first failure of a streak, re-armed by any successful fallback fetch. */
|
|
130
132
|
let warnedLegacyFallbackFailure = false;
|
|
133
|
+
/** Warn-once-per-PROCESS flag (#326): unpinned gateway plugins.allow. NOT
|
|
134
|
+
* reset at start() — a gateway restart inside one process must not re-warn
|
|
135
|
+
* (a new process starts with clean flags anyway). */
|
|
136
|
+
let warnedUnpinnedPlugins = false;
|
|
137
|
+
/** Warn-once-per-PROCESS bookkeeping (#326) for dead-man scaffold SKIPS and
|
|
138
|
+
* failures — one warning per distinct cause (relative path, missing dir,
|
|
139
|
+
* non-UTF-8 file, fs error), never reset at start(). */
|
|
140
|
+
const warnedScaffoldSkips = new Set();
|
|
131
141
|
/** Plugin logger captured at service start (ctx.logger or console). */
|
|
132
142
|
let pluginLog = console.log;
|
|
133
143
|
/** Sessions whose server-side recall dedup was already reset this process
|
|
@@ -164,6 +174,22 @@ const sessionsReset = makeBoundedSessionTracker(SESSION_TRACKER_CAP);
|
|
|
164
174
|
*/
|
|
165
175
|
const identityInjected = makeBoundedSessionTracker(SESSION_TRACKER_CAP);
|
|
166
176
|
const lessonsInjected = makeBoundedSessionTracker(SESSION_TRACKER_CAP);
|
|
177
|
+
/**
|
|
178
|
+
* #327 banner lifecycle — notices memoized SEPARATELY from identityInjected.
|
|
179
|
+
* appendSystemContext persists on the session (premise above), so a FAILED
|
|
180
|
+
* identity fetch that re-injects the IDENTITY UNAVAILABLE banner every turn
|
|
181
|
+
* accumulates one copy per turn of an outage (dozens over an hour) — and the
|
|
182
|
+
* suspension wording then never retracts. The FETCH still retries every turn
|
|
183
|
+
* (identityInjected is only set on success); the NOTICE (banner or 404
|
|
184
|
+
* version-skew note) is appended once per outage, and the first success that
|
|
185
|
+
* delivers identity content prepends a one-line retraction (see
|
|
186
|
+
* IDENTITY_RESTORED_RETRACTION). Two trackers so the kinds settle
|
|
187
|
+
* independently — a 404 note must not suppress a later genuine-outage banner.
|
|
188
|
+
* Evicted on compaction/reset with the other memos: a rebuilt window may have
|
|
189
|
+
* dropped the notice, so one re-inject after the rebuild is wanted, not lost.
|
|
190
|
+
*/
|
|
191
|
+
const identityBannerShown = makeBoundedSessionTracker(SESSION_TRACKER_CAP);
|
|
192
|
+
const identityNoteShown = makeBoundedSessionTracker(SESSION_TRACKER_CAP);
|
|
167
193
|
/** In-flight {reset:true} POSTs by session key (#316). A compaction hook fires
|
|
168
194
|
* a reset fire-and-forget (F9 — no latency on compaction); the NEXT recall
|
|
169
195
|
* fetch for that session AWAITS the entry here, so a slow reset can never
|
|
@@ -347,6 +373,27 @@ const IDENTITY_UNAVAILABLE_BANNER = [
|
|
|
347
373
|
*/
|
|
348
374
|
const IDENTITY_VERSION_SKEW_NOTE = `[hicortex] Identity layer skipped — ${describeGetFailure(404, "/identity")}. ` +
|
|
349
375
|
`This is version skew, not an outage: no action suspension applies; identity returns once the server is upgraded.`;
|
|
376
|
+
/**
|
|
377
|
+
* Recovery retraction (#327): when identity content arrives for a session that
|
|
378
|
+
* earlier got the dead-man banner, this line rides FIRST in that turn's
|
|
379
|
+
* injection. The banner persists on the session prompt, so without an explicit
|
|
380
|
+
* lift the "public actions suspended" wording outlives the outage for the rest
|
|
381
|
+
* of the session. NOT sent for the 404 note (it carries no suspension) and NOT
|
|
382
|
+
* for a gated/off success — identity content is the one outcome that actually
|
|
383
|
+
* restores what the banner said was missing.
|
|
384
|
+
*/
|
|
385
|
+
const IDENTITY_RESTORED_RETRACTION = `[hicortex] The earlier IDENTITY UNAVAILABLE notice no longer applies — ` +
|
|
386
|
+
`identity restored.`;
|
|
387
|
+
/**
|
|
388
|
+
* The #313 SECONDARY layer, verbatim (#326): the bootstrap-file sentence that
|
|
389
|
+
* still guards the agent when the plugin itself cannot inject anything — the
|
|
390
|
+
* banner above needs a live hook, while this line rides the agent's persisted
|
|
391
|
+
* bootstrap instructions. Installs kept forgetting to add it by hand, so the
|
|
392
|
+
* plugin now scaffolds it itself at service start (scaffoldDeadManGuard).
|
|
393
|
+
*/
|
|
394
|
+
const DEAD_MAN_GUARD_LINE = "If your identity block is missing at session start, something is wrong with your memory — take no public actions until it returns.";
|
|
395
|
+
/** Agent workspace bootstrap file the guard line is scaffolded into (#326). */
|
|
396
|
+
const BOOTSTRAP_FILENAME = "BOOTSTRAP.md";
|
|
350
397
|
/**
|
|
351
398
|
* Fetch /lessons and build the `## Hicortex Learnings` block. `failed: true`
|
|
352
399
|
* ONLY when the fetch itself failed (serverGet null data — unreachable,
|
|
@@ -670,6 +717,13 @@ function resolveOcPluginConfig(raw) {
|
|
|
670
717
|
warn(`plugin config key "defaultProject" must be a non-empty string (got ${describeInvalid(rawProject)}) — ignoring it`);
|
|
671
718
|
resolved.defaultProject = undefined;
|
|
672
719
|
}
|
|
720
|
+
// #326 kill-switch: boolean-only. typeof (not describeInvalid — that helper
|
|
721
|
+
// names INVALID-STRING shapes and would misreport a non-empty string).
|
|
722
|
+
const rawScaffold = winner.scaffoldDeadMan;
|
|
723
|
+
if (rawScaffold !== undefined && typeof rawScaffold !== "boolean") {
|
|
724
|
+
warn(`plugin config key "scaffoldDeadMan" must be a boolean (got ${typeof rawScaffold}) — ignoring it`);
|
|
725
|
+
resolved.scaffoldDeadMan = undefined;
|
|
726
|
+
}
|
|
673
727
|
// Shadow detection (F2) — two configs disagreeing, surfaced instead of
|
|
674
728
|
// silently honoring one of them. Case 1: an OC-scaffolded EMPTY
|
|
675
729
|
// plugins.entries.hicortex.config was skipped while a bare top-level
|
|
@@ -689,6 +743,20 @@ function resolveOcPluginConfig(raw) {
|
|
|
689
743
|
}
|
|
690
744
|
return resolved;
|
|
691
745
|
}
|
|
746
|
+
/**
|
|
747
|
+
* Resolve the agent workspace directory from the RAW gateway config (#326):
|
|
748
|
+
* OpenClaw's `agents.defaults.workspace`. Pure — no module state, no fs, no
|
|
749
|
+
* mutation, never throws. Absent/non-string/empty → null (the caller falls
|
|
750
|
+
* back to the OC default workspace). Deliberately does NOT read the plugin's
|
|
751
|
+
* own config section: the workspace is a gateway-level fact, not a plugin
|
|
752
|
+
* knob, so it is resolved from ctx.config directly (the whole openclaw.json,
|
|
753
|
+
* same object resolveOcPluginConfig walks).
|
|
754
|
+
*/
|
|
755
|
+
function resolveOcWorkspaceDir(raw) {
|
|
756
|
+
const agents = isRecord(isRecord(raw)?.agents);
|
|
757
|
+
const workspace = isRecord(agents?.defaults)?.workspace;
|
|
758
|
+
return isNonEmptyString(workspace) ? workspace : null;
|
|
759
|
+
}
|
|
692
760
|
// ---------------------------------------------------------------------------
|
|
693
761
|
// Plugin export
|
|
694
762
|
// ---------------------------------------------------------------------------
|
|
@@ -740,6 +808,8 @@ exports.default = {
|
|
|
740
808
|
sessionsReset.clear();
|
|
741
809
|
identityInjected.clear();
|
|
742
810
|
lessonsInjected.clear();
|
|
811
|
+
identityBannerShown.clear();
|
|
812
|
+
identityNoteShown.clear();
|
|
743
813
|
pendingResets.clear();
|
|
744
814
|
pluginLog = log;
|
|
745
815
|
log(`[hicortex] Thin-client mode — server: ${serverUrl}`);
|
|
@@ -792,6 +862,17 @@ exports.default = {
|
|
|
792
862
|
`Run \`npx @gamaze/hicortex init\` to start the server. ` +
|
|
793
863
|
`Capture and tool calls will fail until the server is available.`);
|
|
794
864
|
}
|
|
865
|
+
// #326 self-hardening — install hygiene, both fail-soft by design:
|
|
866
|
+
// keep the dead-man guard line (#313 secondary layer) present in the
|
|
867
|
+
// agent workspace bootstrap, and surface an unpinned gateway trust
|
|
868
|
+
// list. The kill-switch (config scaffoldDeadMan, default on) disables
|
|
869
|
+
// the scaffold entirely; the trust warning always runs.
|
|
870
|
+
scaffoldDeadManGuard({
|
|
871
|
+
workspaceDir: resolveOcWorkspaceDir(ctx.config) ?? fallbackOcWorkspace(),
|
|
872
|
+
enabled: config.scaffoldDeadMan !== false,
|
|
873
|
+
log,
|
|
874
|
+
});
|
|
875
|
+
warnIfPluginsUnpinned(ctx.config, log);
|
|
795
876
|
ensureToolsAllowed(log);
|
|
796
877
|
},
|
|
797
878
|
async stop() {
|
|
@@ -869,11 +950,45 @@ exports.default = {
|
|
|
869
950
|
// note, NOT the banner (CR2: a pinned plugin on an old server must
|
|
870
951
|
// not self-suspend every turn). Lessons/recall keep their own
|
|
871
952
|
// independent fail-soft.
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
953
|
+
//
|
|
954
|
+
// #327 lifecycle: the notice is appended ONCE per outage (appends
|
|
955
|
+
// persist on the session — every-turn copies accumulate), and the
|
|
956
|
+
// first success carrying identity content prepends the retraction so
|
|
957
|
+
// the suspension wording cannot linger. Without a sessionId there is
|
|
958
|
+
// no per-session key — inject per turn (pre-#316 shape), matching
|
|
959
|
+
// the standing-block behavior above.
|
|
960
|
+
let identityBlock;
|
|
961
|
+
if (identity !== null && identity.block !== null) {
|
|
962
|
+
identityBlock = identity.block;
|
|
963
|
+
if (sKey !== undefined && identityBannerShown.has(sKey)) {
|
|
964
|
+
identityBlock = `${IDENTITY_RESTORED_RETRACTION}\n\n${identityBlock}`;
|
|
965
|
+
}
|
|
966
|
+
if (sKey !== undefined) {
|
|
967
|
+
identityBannerShown.evict(sKey);
|
|
968
|
+
identityNoteShown.evict(sKey);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
else if (identity !== null && identity.failed) {
|
|
972
|
+
const isSkew = identity.status === 404;
|
|
973
|
+
const shown = isSkew ? identityNoteShown : identityBannerShown;
|
|
974
|
+
if (sKey === undefined || !shown.has(sKey)) {
|
|
975
|
+
if (sKey !== undefined)
|
|
976
|
+
shown.add(sKey);
|
|
977
|
+
identityBlock = isSkew ? IDENTITY_VERSION_SKEW_NOTE : IDENTITY_UNAVAILABLE_BANNER;
|
|
978
|
+
}
|
|
979
|
+
else {
|
|
980
|
+
// Already appended earlier in the outage — it persists on the
|
|
981
|
+
// session; re-sending would duplicate it (see tracker docs).
|
|
982
|
+
identityBlock = null;
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
else {
|
|
986
|
+
// Gated-null success (old server / oc ∉ clients / mode off): a
|
|
987
|
+
// settled outcome with no content. A prior banner stays standing —
|
|
988
|
+
// identity is still effectively missing to the agent, and the
|
|
989
|
+
// bootstrap dead-man guard line keeps advising caution.
|
|
990
|
+
identityBlock = null;
|
|
991
|
+
}
|
|
877
992
|
const lessonsBlock = lessons !== null ? lessons.block : null;
|
|
878
993
|
const blocks = [identityBlock, lessonsBlock, recallBlock].filter((b) => b !== null && b !== "");
|
|
879
994
|
if (blocks.length === 0)
|
|
@@ -911,6 +1026,10 @@ exports.default = {
|
|
|
911
1026
|
void resetRecallDedup(sid, key);
|
|
912
1027
|
identityInjected.evict(key);
|
|
913
1028
|
lessonsInjected.evict(key);
|
|
1029
|
+
// #327: a rebuilt window may have dropped the notice too — evict so one
|
|
1030
|
+
// re-inject (banner while still failing) can happen after the rebuild.
|
|
1031
|
+
identityBannerShown.evict(key);
|
|
1032
|
+
identityNoteShown.evict(key);
|
|
914
1033
|
};
|
|
915
1034
|
api.on("after_compaction", recallResetHook);
|
|
916
1035
|
api.on("before_reset", recallResetHook);
|
|
@@ -1212,6 +1331,168 @@ const HICORTEX_TOOLS = [
|
|
|
1212
1331
|
"hicortex_update",
|
|
1213
1332
|
"hicortex_delete",
|
|
1214
1333
|
];
|
|
1334
|
+
/**
|
|
1335
|
+
* The OpenClaw home this plugin resolves against (#326). `HICORTEX_OC_HOME`
|
|
1336
|
+
* redirects it for tests, mirroring how HICORTEX_HOME redirects the hicortex
|
|
1337
|
+
* home (paths.ts) — one resolution shared by the workspace fallback and the
|
|
1338
|
+
* unpinned-trust warning so they can never disagree.
|
|
1339
|
+
*/
|
|
1340
|
+
function ocHomeDir() {
|
|
1341
|
+
return process.env.HICORTEX_OC_HOME ?? (0, node_path_1.join)((0, node_os_1.homedir)(), ".openclaw");
|
|
1342
|
+
}
|
|
1343
|
+
/**
|
|
1344
|
+
* Normalize a gateway-config workspace path for WRITING (#326 CR1): a bare
|
|
1345
|
+
* `~` or leading `~/` expands against the real home dir; anything still
|
|
1346
|
+
* RELATIVE afterwards is rejected (null). OpenClaw's semantics for relative
|
|
1347
|
+
* workspace values are not verifiable from the plugin, and writing under
|
|
1348
|
+
* process.cwd() would place the guard where OC never reads it — a silent
|
|
1349
|
+
* no-op safety — so the caller skips with a warning instead. Pure; no fs.
|
|
1350
|
+
*/
|
|
1351
|
+
function normalizeWorkspacePath(ws) {
|
|
1352
|
+
let p = ws;
|
|
1353
|
+
if (p === "~")
|
|
1354
|
+
p = (0, node_os_1.homedir)();
|
|
1355
|
+
else if (p.startsWith("~/"))
|
|
1356
|
+
p = (0, node_path_1.join)((0, node_os_1.homedir)(), p.slice(2));
|
|
1357
|
+
return (0, node_path_1.isAbsolute)(p) ? p : null;
|
|
1358
|
+
}
|
|
1359
|
+
/**
|
|
1360
|
+
* Fallback workspace when the gateway config names none (#326): OpenClaw's
|
|
1361
|
+
* default `<ocHome>/workspace`. Returned ONLY when a real OC install is
|
|
1362
|
+
* present (`<ocHome>/openclaw.json` exists — the same touchpoint
|
|
1363
|
+
* ensureToolsAllowed reads): outside a gateway (tests, CI, a bare import) the
|
|
1364
|
+
* plugin must not conjure `~/.openclaw` into existence just to drop a
|
|
1365
|
+
* bootstrap file.
|
|
1366
|
+
*/
|
|
1367
|
+
function fallbackOcWorkspace() {
|
|
1368
|
+
const ocHome = ocHomeDir();
|
|
1369
|
+
return (0, node_fs_1.existsSync)((0, node_path_1.join)(ocHome, "openclaw.json")) ? (0, node_path_1.join)(ocHome, "workspace") : null;
|
|
1370
|
+
}
|
|
1371
|
+
/** Per-cause warn-once for scaffold skips/failures (#326). */
|
|
1372
|
+
function warnScaffoldSkipOnce(cause, log, reason) {
|
|
1373
|
+
if (warnedScaffoldSkips.has(cause))
|
|
1374
|
+
return;
|
|
1375
|
+
warnedScaffoldSkips.add(cause);
|
|
1376
|
+
log(`[hicortex] WARNING: ${reason} — the dead-man guard line was not scaffolded.`);
|
|
1377
|
+
}
|
|
1378
|
+
/**
|
|
1379
|
+
* Scaffold the dead-man guard line into the agent workspace bootstrap (#326 —
|
|
1380
|
+
* the #313 SECONDARY layer; the primary layer is the injected
|
|
1381
|
+
* IDENTITY UNAVAILABLE banner, which needs a live plugin hook). The sentence
|
|
1382
|
+
* used to be a manual install step every public-agent setup could forget, so
|
|
1383
|
+
* the plugin maintains it itself at service start:
|
|
1384
|
+
*
|
|
1385
|
+
* - bootstrap absent → created containing ONLY the guard line
|
|
1386
|
+
* - present without the line → one-time .bak of the operator's original
|
|
1387
|
+
* BYTES, then the line appended exactly once
|
|
1388
|
+
* - present with the line → untouched (idempotent: no write, no backup)
|
|
1389
|
+
*
|
|
1390
|
+
* Deliberately conservative (CR):
|
|
1391
|
+
* - the workspace DIRECTORY is never created — OC may scaffold workspaces
|
|
1392
|
+
* from templates, and a pre-created dir could interfere; absent dir →
|
|
1393
|
+
* warn once + skip (a gateway restart after the first agent run retries)
|
|
1394
|
+
* - a non-absolute workspace path (after ~ expansion) → warn once + skip
|
|
1395
|
+
* (never write somewhere speculative like process.cwd())
|
|
1396
|
+
* - a bootstrap that is not valid UTF-8 → warn once + skip; decoding would
|
|
1397
|
+
* be lossy and rewriting the file would mangle the operator's bytes
|
|
1398
|
+
*
|
|
1399
|
+
* Fail-soft by construction: any filesystem failure (unreadable path,
|
|
1400
|
+
* permissions) warns ONCE per cause and never breaks plugin start. The
|
|
1401
|
+
* kill-switch (config `scaffoldDeadMan: false`, default on) returns before a
|
|
1402
|
+
* single fs call — no write, no file creation.
|
|
1403
|
+
*/
|
|
1404
|
+
function scaffoldDeadManGuard(opts) {
|
|
1405
|
+
if (!opts.enabled)
|
|
1406
|
+
return;
|
|
1407
|
+
const rawWorkspace = opts.workspaceDir;
|
|
1408
|
+
if (!rawWorkspace)
|
|
1409
|
+
return; // no workspace resolvable — not an OC install / no workspace key
|
|
1410
|
+
const log = opts.log;
|
|
1411
|
+
const workspaceDir = normalizeWorkspacePath(rawWorkspace);
|
|
1412
|
+
if (!workspaceDir) {
|
|
1413
|
+
warnScaffoldSkipOnce("relative-workspace", log, `workspace path "${rawWorkspace}" in the gateway config is relative — OpenClaw's ` +
|
|
1414
|
+
"resolution for it is unknown, so the plugin will not write speculatively");
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1417
|
+
const bootstrapPath = (0, node_path_1.join)(workspaceDir, BOOTSTRAP_FILENAME);
|
|
1418
|
+
try {
|
|
1419
|
+
// CR3: never create the workspace dir itself (template interference).
|
|
1420
|
+
if (!(0, node_fs_1.existsSync)(workspaceDir)) {
|
|
1421
|
+
warnScaffoldSkipOnce("missing-workspace-dir", log, `workspace directory ${workspaceDir} does not exist yet (OpenClaw creates it; ` +
|
|
1422
|
+
"restart the gateway after the first agent run to retry)");
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
// Read BYTES (CR2): the .bak must hold the operator's original exactly,
|
|
1426
|
+
// and an append must splice onto the original bytes, not a lossy decode.
|
|
1427
|
+
let original = null;
|
|
1428
|
+
try {
|
|
1429
|
+
original = (0, node_fs_1.readFileSync)(bootstrapPath);
|
|
1430
|
+
}
|
|
1431
|
+
catch (err) {
|
|
1432
|
+
// Only "does not exist" means "create it" — anything else (EACCES,
|
|
1433
|
+
// EISDIR, …) is a genuine failure and must reach the warn below, not
|
|
1434
|
+
// be mistaken for an absent file and overwritten.
|
|
1435
|
+
if (err.code !== "ENOENT")
|
|
1436
|
+
throw err;
|
|
1437
|
+
}
|
|
1438
|
+
if (original !== null) {
|
|
1439
|
+
const decoded = original.toString("utf-8");
|
|
1440
|
+
// Invalid UTF-8 (round-trip compare): appending would rewrite the file
|
|
1441
|
+
// with mangled bytes — leave it untouched and say so once.
|
|
1442
|
+
if (!Buffer.from(decoded, "utf-8").equals(original)) {
|
|
1443
|
+
warnScaffoldSkipOnce("invalid-utf8", log, `${bootstrapPath} is not valid UTF-8 — leaving the file untouched`);
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
if (decoded.includes(DEAD_MAN_GUARD_LINE))
|
|
1447
|
+
return;
|
|
1448
|
+
}
|
|
1449
|
+
if (original === null) {
|
|
1450
|
+
(0, node_fs_1.writeFileSync)(bootstrapPath, `${DEAD_MAN_GUARD_LINE}\n`);
|
|
1451
|
+
}
|
|
1452
|
+
else {
|
|
1453
|
+
// One-time backup of the operator's original BYTES — never churned.
|
|
1454
|
+
const bakPath = `${bootstrapPath}.bak`;
|
|
1455
|
+
if (!(0, node_fs_1.existsSync)(bakPath))
|
|
1456
|
+
(0, node_fs_1.writeFileSync)(bakPath, original);
|
|
1457
|
+
// Separator at the BYTE level (0x0A), so a no-trailing-newline file is
|
|
1458
|
+
// spliced correctly without decoding.
|
|
1459
|
+
const needsSep = original.length > 0 && original[original.length - 1] !== 0x0a;
|
|
1460
|
+
(0, node_fs_1.writeFileSync)(bootstrapPath, Buffer.concat([
|
|
1461
|
+
original,
|
|
1462
|
+
needsSep ? Buffer.from("\n", "utf-8") : Buffer.alloc(0),
|
|
1463
|
+
Buffer.from(`${DEAD_MAN_GUARD_LINE}\n`, "utf-8"),
|
|
1464
|
+
]));
|
|
1465
|
+
}
|
|
1466
|
+
log(`[hicortex] Added the dead-man identity guard line to ${bootstrapPath}`);
|
|
1467
|
+
}
|
|
1468
|
+
catch (err) {
|
|
1469
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1470
|
+
warnScaffoldSkipOnce("fs-error", log, `could not scaffold the dead-man guard line into ${bootstrapPath}: ${msg}`);
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
/**
|
|
1474
|
+
* Warn once per process when the gateway's plugin trust list is unpinned
|
|
1475
|
+
* (#326): while `plugins.allow` is absent or empty, OpenClaw auto-loads ANY
|
|
1476
|
+
* extension dropped into the plugins directory. A plugin must not pin trust
|
|
1477
|
+
* itself — a self-pinned list defeats the point of the list — so this only
|
|
1478
|
+
* WARNS with the fix. It never writes plugins.allow (or any gateway config;
|
|
1479
|
+
* ensureToolsAllowed's tools.allow edit is the one intentional config write).
|
|
1480
|
+
* Any non-empty array counts as pinned → silent.
|
|
1481
|
+
*/
|
|
1482
|
+
function warnIfPluginsUnpinned(raw, log) {
|
|
1483
|
+
if (warnedUnpinnedPlugins)
|
|
1484
|
+
return;
|
|
1485
|
+
const allow = isRecord(isRecord(raw)?.plugins)?.allow;
|
|
1486
|
+
if (Array.isArray(allow) && allow.length > 0)
|
|
1487
|
+
return;
|
|
1488
|
+
warnedUnpinnedPlugins = true;
|
|
1489
|
+
log("[hicortex] WARNING: the OpenClaw plugin trust list (plugins.allow) is not " +
|
|
1490
|
+
"pinned — any extension dropped into the plugins directory loads " +
|
|
1491
|
+
'automatically. Fix: set "plugins": { "allow": ["hicortex"] } in ' +
|
|
1492
|
+
`${(0, node_path_1.join)(ocHomeDir(), "openclaw.json")} (list every plugin you trust). See ` +
|
|
1493
|
+
"https://hicortex.gamaze.com/docs/installation.html — hicortex never " +
|
|
1494
|
+
"edits the trust list itself.");
|
|
1495
|
+
}
|
|
1215
1496
|
/**
|
|
1216
1497
|
* Ensure hicortex tools are in tools.allow so they're visible to agents
|
|
1217
1498
|
* regardless of the tools.profile setting.
|