@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/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
- return { entries, dropped };
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 = [];
@@ -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
- const identityBlock = identity !== null && identity.block !== null
873
- ? identity.block
874
- : identity !== null && identity.failed
875
- ? (identity.status === 404 ? IDENTITY_VERSION_SKEW_NOTE : IDENTITY_UNAVAILABLE_BANNER)
876
- : null;
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.