@gamaze/hicortex 0.19.1 → 0.19.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -260,11 +260,12 @@ Config at `~/.hicortex/config.json`. Created by `init`. Key options:
260
260
  | `searchLimit` / `recentLimit` | Default result counts for search (8) and recent (12) |
261
261
  | `recentWindowDays` | Candidate window for recent recall (default: 180) |
262
262
  | `coldExposureSlots` | Top-k slots reservable for never-accessed memories so the long tail gets exposure (default: 2) |
263
- | `recallMaxItems` | Max lines in the pushed recall index (default: 6) |
264
- | `recallMinSimilarity` | Relevance floor for index entries (default: 0.55; text-search matches always pass) |
263
+ | `recallMaxItems` | Max lines in the pushed recall index (default: 5) |
264
+ | `noveltyFloorSlots` | Slots of `recallMaxItems` guaranteed to the top passing hit(s) of the pure-prompt (unblended) search — the novelty floor. Keeps a session whose earlier turns set a strong intent from burying a topic-switching prompt's best matches: the floor's picks render first, turn-based re-show suppression still applies, and the total never exceeds `recallMaxItems` (default: 2; set 0 to disable) |
265
+ | `recallMinSimilarity` | Relevance floor for index entries (default: 0.62; text-search matches always pass) |
265
266
  | `recallReshowTurns` | Turns before an already-shown memory may reappear in the same session (default: 30) |
266
267
  | `recallMinPromptChars` | Prompts shorter than this skip the recall index (default: 20) |
267
- | `recallTitleChars` | Chars of each memory's first line shown in an index entry (default: 150, range 40–400). Raised from 100 on 2026-08-02: with topic-first memory titles, 150 chars carries the subject *and* its claim, where 100 cut the claim mid-sentence. Costs roughly +74 tokens per 6-line block |
268
+ | `recallTitleChars` | Chars of each memory's first line shown in an index entry (default: 100, range 40–400). Reverted from 150 on 2026-08-03: a full-corpus relevance eval found 100 and 150 statistically identical while 100 saves ~13% of the block's tokens |
268
269
  | `sessionIntentWeight` | Blend weight of the session-intent rolling centroid in the recall search vector: `query = (1-w)·prompt + w·centroid` (default: 0.33; set 0 to disable — pure-prompt recall, the kill-switch). The first turn of a session searches with pure prompt and seeds the centroid; subsequent turns blend so recall follows the session's intent instead of being query-literal. The EMA rate (0.4) is a shipped constant, not configurable |
269
270
  | `dedupMergeThreshold` | Minimum cosine similarity for `hicortex dedup` to cluster memories as near-duplicates (default: 0.92) |
270
271
  | `supersessionMinSimilarity` | Minimum cosine similarity for a nightly supersession candidate pair (default: 0.80) |
@@ -314,7 +315,10 @@ Optional config (add to plugin entry in `~/.openclaw/openclaw.json`):
314
315
  |-------|---------|-------------|
315
316
  | `serverUrl` | `http://127.0.0.1:8787` | Hicortex server URL. Change for remote servers. |
316
317
  | `authToken` | _(none)_ | Bearer token. Localhost bypasses auth; required for remote servers. Get the token from `hicortex status` on the server. |
317
- | `licenseKey` | _(none)_ | Commercial license key. Optional; only affects the display in `hicortex status`. |
318
+ | `defaultProject` | _(none)_ | Project name sent on recall, search, recent, and ingest whenever the gateway supplies no project (Hermes `default_project` parity). |
319
+ | `recallLimit` | `8` | Max memories per recall on the pre-0.14 `/search` fallback. The pushed recall index is sized by SERVER config (`recallMaxItems`) — the server accepts no client limit. |
320
+
321
+ If `serverUrl`/`authToken` are absent from the config, the `HICORTEX_URL` and `HICORTEX_AUTH_TOKEN` environment variables are used as fallbacks (config always wins).
318
322
 
319
323
  ## LLM Configuration
320
324
 
@@ -291,6 +291,17 @@
291
291
  }
292
292
  })();
293
293
 
294
+ // Deep link (#313): /identity/ui?agent=<id> opens the editor in that
295
+ // agent's scope. The param STAYS in the URL (shareable bookmark; unlike
296
+ // ?token= it is not a secret). It cannot be adopted before the first load —
297
+ // the bare GET is what populates the `agents` map the scope selector needs —
298
+ // so it is stashed here and applied in load()'s success path.
299
+ var pendingScope = null;
300
+ (function initScopeParam() {
301
+ var p = new URLSearchParams(window.location.search).get("agent");
302
+ if (p) pendingScope = p;
303
+ })();
304
+
294
305
  // Shared console nav (#249): append ?token= to internal nav links on click
295
306
  // so navigation between pages doesn't re-authenticate. Reads token from any
296
307
  // page-scoped localStorage key (each page historically used its own).
@@ -374,23 +385,32 @@
374
385
  var active = null; // active section name
375
386
 
376
387
  // Display labels for known sections (the API key stays lowercase; the UI
377
- // shows a friendlier name). Unknown sections display their raw name.
388
+ // shows a friendlier name). Unknown sections display their raw name. The
389
+ // three #313 scope labels mirror SECTION_LABELS in identity-store.ts —
390
+ // keep in sync ("Global rules" marks the section as fleet-wide).
378
391
  var DISPLAY_LABELS = {
392
+ "agent_identity": "Agent identity",
379
393
  "user": "User",
380
- "rules": "Rules",
394
+ "rules": "Global rules",
381
395
  "memory": "Memory instructions",
382
396
  };
383
397
  // Preferred tab order — known sections first in this order, then any custom
384
- // sections alphabetically.
385
- var TAB_PRIORITY = ["user", "rules", "memory"];
398
+ // sections alphabetically. Mirrors the #313 served precedence
399
+ // (SECTION_PRECEDENCE in identity-store.ts): the agent's own identity
400
+ // first, then the principal, then fleet rules.
401
+ var TAB_PRIORITY = ["agent_identity", "user", "rules", "memory"];
386
402
  var afterAuth = null; // action to retry after a successful token prompt
387
403
 
388
404
  // Per-agent scope (0.13). scope = null → the global set; otherwise an agent
389
405
  // id, and edits target PUT /identity?agent=<id>. `agents` is the id -> mode
390
406
  // map from the bare (global) GET; `origins` is the id -> "global"|"agent"
391
407
  // provenance from an agent-scope GET (used to dim inherited tabs).
408
+ // `agents` is PROTOTYPE-LESS (Object.create(null)): a lookup like
409
+ // agents["constructor"] must miss so a bogus ?agent= id cannot slip through
410
+ // the registration check via the Object.prototype chain (CR5). Every
411
+ // assignment from server data re-sanitizes into a fresh prototype-less map.
392
412
  var scope = null;
393
- var agents = {};
413
+ var agents = Object.create(null);
394
414
  var origins = {};
395
415
 
396
416
  // ?agent= query for the active scope. Written so the literal fetch("/identity"
@@ -425,7 +445,12 @@
425
445
  var inherited = scope && origins[name] === "global";
426
446
  b.className = "tab" + (name === active ? " active" : "") + (inherited ? " inherited" : "");
427
447
  b.type = "button";
428
- b.appendChild(document.createTextNode(DISPLAY_LABELS[name] || name));
448
+ // Own-property lookup (CR-A): a section legitimately named "constructor"
449
+ // must display its raw name — DISPLAY_LABELS[name] would resolve through
450
+ // the Object.prototype chain.
451
+ b.appendChild(document.createTextNode(
452
+ Object.prototype.hasOwnProperty.call(DISPLAY_LABELS, name) ? DISPLAY_LABELS[name] : name
453
+ ));
429
454
  if (inherited) {
430
455
  var inh = document.createElement("span");
431
456
  inh.className = "inh";
@@ -505,8 +530,12 @@
505
530
  overlay.classList.remove("open");
506
531
  // The bare (global) response carries the `agents` map; an agent-scope
507
532
  // response carries `origins`. Retain the last-known agents list when the
508
- // current response omits it so the selector stays populated.
509
- if (scope === null && data.agents && typeof data.agents === "object") agents = data.agents;
533
+ // current response omits it so the selector stays populated. Copied
534
+ // into a PROTOTYPE-LESS map (CR5): `agents` is keyed by client- and
535
+ // server-supplied ids, and lookups must never hit Object.prototype.
536
+ if (scope === null && data.agents && typeof data.agents === "object") {
537
+ agents = Object.assign(Object.create(null), data.agents);
538
+ }
510
539
  origins = (data.origins && typeof data.origins === "object") ? data.origins : {};
511
540
  var incoming = data.sections || {};
512
541
  var names = Object.keys(incoming).sort(function (a, b) {
@@ -529,9 +558,27 @@
529
558
  populateScopeOptions();
530
559
  renderTabs();
531
560
  setMsg(order.length ? "" : "", "dim");
561
+ // Deep link (#313): adopt the stashed ?agent= scope now that the
562
+ // agents map is loaded. An unknown-but-valid id registers locally as
563
+ // "override" (same as Add agent — the dir is created on first Save);
564
+ // an INVALID id is dropped, landing on Global rather than erroring.
565
+ if (pendingScope) {
566
+ var want = pendingScope;
567
+ pendingScope = null;
568
+ if (validName(want)) {
569
+ if (!agents[want]) agents[want] = "override";
570
+ switchScope(want);
571
+ }
572
+ }
532
573
  })
533
574
  .catch(function (err) {
575
+ // unauthorized: load() is retried after the token prompt (afterAuth)
576
+ // — the deep link may still adopt on that retry, so keep pendingScope.
534
577
  if (err.message === "unauthorized") return;
578
+ // A FAILED load must not leave the deep link armed: the user may
579
+ // manually switch scope meanwhile, and the next successful load would
580
+ // yank them back to the ?agent= scope (CR5). Cancel it here.
581
+ pendingScope = null;
535
582
  setMsg("Failed to load: " + err.message, "err");
536
583
  });
537
584
  }
@@ -624,6 +671,10 @@
624
671
  }
625
672
 
626
673
  function switchScope(next) {
674
+ // A manual switch CANCELS a pending ?agent= deep link (CR5): the deep
675
+ // link only ever applies to the FIRST successful load. The adoption path
676
+ // also routes through here, after clearing pendingScope itself.
677
+ pendingScope = null;
627
678
  if (anyDirty() && !window.confirm("Discard unsaved changes and switch scope?")) {
628
679
  scopeSel.value = scope || "";
629
680
  return;
@@ -26,6 +26,12 @@
26
26
  * `context/`), reads ADDITIVELY MERGE: per-section `identity/` wins, and
27
27
  * legacy-only sections are included so nothing is lost — at both the global
28
28
  * and the per-agent (`agents/<id>/`) level.
29
+ *
30
+ * #313 adds the per-agent `agent_identity` section (who THIS agent is + its
31
+ * role conduct; per-agent ONLY, never global) and makes the served section
32
+ * order a defined contract — see SECTION_PRECEDENCE below. `user` remains the
33
+ * PRINCIPAL (global) and `rules` the fleet-wide house rules (never
34
+ * overridden per-agent: section-level replacement would shadow them).
29
35
  */
30
36
  /** A valid section name: lowercase alnum start, then alnum / `_` / `-`. */
31
37
  export declare const SECTION_NAME_RE: RegExp;
@@ -116,6 +122,49 @@ export declare function migrateIdentityDir(home: string): {
116
122
  to: string;
117
123
  reason?: string;
118
124
  };
125
+ /**
126
+ * The order sections are SERVED in (#313): `agent_identity` → `user` →
127
+ * `rules`, then every other section alphabetically. Semantics behind the
128
+ * order:
129
+ * - `agent_identity` — who THIS agent is (name, origin, character, voice,
130
+ * genuine interests) plus its role conduct; per-agent only, at
131
+ * `identity/agents/<id>/agent_identity.md`. It leads so the agent reads
132
+ * its own self before anything else.
133
+ * - `user` — the PRINCIPAL the whole fleet serves (global, never the agent).
134
+ * - `rules` — fleet-wide house rules. Global only: section-level replacement
135
+ * means a per-agent rules.md would SHADOW house rules, so role-specific
136
+ * conduct lives INSIDE agent_identity.md (additive by construction).
137
+ * Before #313 this ordering rode on file-read order (readdirSync, FS-dependent)
138
+ * where it existed at all — now it is a defined, tested contract. The SAME
139
+ * list is shared by the client renderers (orderSectionNames in
140
+ * learnings-identity.ts imports it; the Hermes plugin mirrors it) so server
141
+ * and clients can never drift: the precedence is defined ONCE, here.
142
+ */
143
+ export declare const SECTION_PRECEDENCE: readonly ["agent_identity", "user", "rules"];
144
+ /**
145
+ * Scope display labels (#313): ONE map, consumed by the injected-block
146
+ * renderer (renderIdentityBlock — CC hook + OC plugin + MCP tool), the UI
147
+ * editor (DISPLAY_LABELS mirrors it), and the Hermes plugin (mirror in
148
+ * provider.py). "Global rules" says what the section IS (fleet-wide house
149
+ * rules), distinguishing it from per-agent content. Unknown section names
150
+ * fall back to title-case (titleCaseSection) — no file renames, the API keys
151
+ * stay the section names on disk.
152
+ */
153
+ export declare const SECTION_LABELS: Readonly<Record<string, string>>;
154
+ /**
155
+ * Rebuild a sections record with its keys inserted in SECTION_PRECEDENCE
156
+ * order, then the rest alphabetically. Pure: same values, same (string) keys,
157
+ * deterministic order — JSON serialisation preserves insertion order, so this
158
+ * IS the wire contract. Idempotent; a map with no known primaries is just
159
+ * alphabetised.
160
+ *
161
+ * DOCUMENTED EXCEPTION: a canonically-numeric section name (e.g. "42") is an
162
+ * integer-index key in JS and serialises BEFORE every string key regardless
163
+ * of insertion order — it escapes the contract by engine semantics, not by
164
+ * our choice. That is why writeSections rejects purely-numeric names on new
165
+ * writes (reads keep serving existing numeric files).
166
+ */
167
+ export declare function orderSections(sections: Record<string, string>): Record<string, string>;
119
168
  export interface ReadResult {
120
169
  sections: Record<string, string>;
121
170
  /** ISO timestamp of the latest included file's mtime, or null when none. */
@@ -143,7 +192,10 @@ export declare function readSectionsWithFallback(identityDir: string): ReadResul
143
192
  /**
144
193
  * Partial upsert of the named sections. Contract (spec §1):
145
194
  * - Validate ALL names first; any invalid → throw InvalidSectionNameError and
146
- * write NOTHING (atomic request semantics).
195
+ * write NOTHING (atomic request semantics). Purely-numeric names are
196
+ * rejected here too (NUMERIC_SECTION_RE): they serialize ahead of the
197
+ * precedence contract (see orderSections), so no new ones may be created —
198
+ * existing numeric files remain readable, just no longer writable.
147
199
  * - Only the named sections are touched; omitted sections stay untouched
148
200
  * (omission is not deletion — deletion is filesystem-only).
149
201
  * - Each write goes temp-file-then-rename in the same dir (no half-applied
@@ -281,6 +333,18 @@ export interface HandlerResult {
281
333
  * paramless legacy caller is covered by the migration docs, not this guard.)
282
334
  */
283
335
  export declare function handleIdentityGet(identityDir: string, clients: string[], query: Record<string, unknown>, identityAgents?: Record<string, AgentMode>): HandlerResult;
336
+ /**
337
+ * Compose the served GET /identity (and /context alias) body — the ONE
338
+ * composition site (#313 CR3): take a handleIdentityGet HandlerResult, inject
339
+ * the synthetic product-owned `memory` section (#192, when enabled and mode
340
+ * ≠ off), then apply the SECTION_PRECEDENCE order (injectMemorySection
341
+ * APPENDS the section last, so the order must be re-applied after it). Used
342
+ * by both REST adapters and buildIdentityToolResult so the final wire key
343
+ * order — including memory's alphabetical slot among "rest" — can never drift
344
+ * between them. Non-200 results pass through untouched. Mutates and returns
345
+ * the SAME HandlerResult (the body object is shared with the caller).
346
+ */
347
+ export declare function serveIdentityBody(handlerResult: HandlerResult, memoryEnabled: boolean): HandlerResult;
284
348
  /**
285
349
  * PUT /identity. Validates the body shape and section content types, then
286
350
  * delegates to writeSections (which owns the name allowlist + atomicity +
@@ -27,14 +27,21 @@
27
27
  * `context/`), reads ADDITIVELY MERGE: per-section `identity/` wins, and
28
28
  * legacy-only sections are included so nothing is lost — at both the global
29
29
  * and the per-agent (`agents/<id>/`) level.
30
+ *
31
+ * #313 adds the per-agent `agent_identity` section (who THIS agent is + its
32
+ * role conduct; per-agent ONLY, never global) and makes the served section
33
+ * order a defined contract — see SECTION_PRECEDENCE below. `user` remains the
34
+ * PRINCIPAL (global) and `rules` the fleet-wide house rules (never
35
+ * overridden per-agent: section-level replacement would shadow them).
30
36
  */
31
37
  Object.defineProperty(exports, "__esModule", { value: true });
32
- exports.KNOWN_CONTEXT_CLIENTS = exports.KNOWN_IDENTITY_CLIENTS = exports.CONTEXT_SIZE_WARN_BYTES = exports.IDENTITY_SIZE_WARN_BYTES = exports.isValidAgentId = exports.AGENTS_DIR = exports.InvalidSectionNameError = exports.SECTION_NAME_MAX = exports.SECTION_NAME_RE = void 0;
38
+ exports.KNOWN_CONTEXT_CLIENTS = exports.KNOWN_IDENTITY_CLIENTS = exports.CONTEXT_SIZE_WARN_BYTES = exports.IDENTITY_SIZE_WARN_BYTES = exports.SECTION_LABELS = exports.SECTION_PRECEDENCE = exports.isValidAgentId = exports.AGENTS_DIR = exports.InvalidSectionNameError = exports.SECTION_NAME_MAX = exports.SECTION_NAME_RE = void 0;
33
39
  exports.isValidSectionName = isValidSectionName;
34
40
  exports.sanitizeAgentId = sanitizeAgentId;
35
41
  exports.resolveAgentIdentity = resolveAgentIdentity;
36
42
  exports.resolveIdentityAgents = resolveIdentityAgents;
37
43
  exports.migrateIdentityDir = migrateIdentityDir;
44
+ exports.orderSections = orderSections;
38
45
  exports.readSections = readSections;
39
46
  exports.readSectionsWithFallback = readSectionsWithFallback;
40
47
  exports.writeSections = writeSections;
@@ -47,10 +54,12 @@ exports.resolveAgentMode = resolveAgentMode;
47
54
  exports.listAgents = listAgents;
48
55
  exports.readResolvedSections = readResolvedSections;
49
56
  exports.handleIdentityGet = handleIdentityGet;
57
+ exports.serveIdentityBody = serveIdentityBody;
50
58
  exports.handleIdentityPut = handleIdentityPut;
51
59
  const node_fs_1 = require("node:fs");
52
60
  const node_path_1 = require("node:path");
53
61
  const node_crypto_1 = require("node:crypto");
62
+ const memory_instructions_js_1 = require("./memory-instructions.js");
54
63
  // ---------------------------------------------------------------------------
55
64
  // Section-name allowlist (security contract)
56
65
  // ---------------------------------------------------------------------------
@@ -77,6 +86,15 @@ class InvalidSectionNameError extends Error {
77
86
  }
78
87
  }
79
88
  exports.InvalidSectionNameError = InvalidSectionNameError;
89
+ /**
90
+ * A purely-numeric section name (`^\d+$`). Allowlist-VALID (reads must keep
91
+ * serving existing numeric files) but rejected on writes: integer-index
92
+ * object keys serialise before every string key, so a numeric section escapes
93
+ * the SECTION_PRECEDENCE wire contract entirely (see orderSections' documented
94
+ * exception). Enforced in writeSections only — never in isValidSectionName,
95
+ * which gates READS as well.
96
+ */
97
+ const NUMERIC_SECTION_RE = /^\d+$/;
80
98
  /** Reserved subdir under <identity>/ holding per-agent sections. NOT a section. */
81
99
  exports.AGENTS_DIR = "agents";
82
100
  /**
@@ -185,6 +203,64 @@ function migrateIdentityDir(home) {
185
203
  return { renamed: false, from, to, reason: `rename failed: ${err instanceof Error ? err.message : String(err)}` };
186
204
  }
187
205
  }
206
+ // ---------------------------------------------------------------------------
207
+ // Section precedence — the served ordering contract (#313)
208
+ // ---------------------------------------------------------------------------
209
+ /**
210
+ * The order sections are SERVED in (#313): `agent_identity` → `user` →
211
+ * `rules`, then every other section alphabetically. Semantics behind the
212
+ * order:
213
+ * - `agent_identity` — who THIS agent is (name, origin, character, voice,
214
+ * genuine interests) plus its role conduct; per-agent only, at
215
+ * `identity/agents/<id>/agent_identity.md`. It leads so the agent reads
216
+ * its own self before anything else.
217
+ * - `user` — the PRINCIPAL the whole fleet serves (global, never the agent).
218
+ * - `rules` — fleet-wide house rules. Global only: section-level replacement
219
+ * means a per-agent rules.md would SHADOW house rules, so role-specific
220
+ * conduct lives INSIDE agent_identity.md (additive by construction).
221
+ * Before #313 this ordering rode on file-read order (readdirSync, FS-dependent)
222
+ * where it existed at all — now it is a defined, tested contract. The SAME
223
+ * list is shared by the client renderers (orderSectionNames in
224
+ * learnings-identity.ts imports it; the Hermes plugin mirrors it) so server
225
+ * and clients can never drift: the precedence is defined ONCE, here.
226
+ */
227
+ exports.SECTION_PRECEDENCE = ["agent_identity", "user", "rules"];
228
+ /**
229
+ * Scope display labels (#313): ONE map, consumed by the injected-block
230
+ * renderer (renderIdentityBlock — CC hook + OC plugin + MCP tool), the UI
231
+ * editor (DISPLAY_LABELS mirrors it), and the Hermes plugin (mirror in
232
+ * provider.py). "Global rules" says what the section IS (fleet-wide house
233
+ * rules), distinguishing it from per-agent content. Unknown section names
234
+ * fall back to title-case (titleCaseSection) — no file renames, the API keys
235
+ * stay the section names on disk.
236
+ */
237
+ exports.SECTION_LABELS = {
238
+ agent_identity: "Agent identity",
239
+ user: "User",
240
+ rules: "Global rules",
241
+ };
242
+ /**
243
+ * Rebuild a sections record with its keys inserted in SECTION_PRECEDENCE
244
+ * order, then the rest alphabetically. Pure: same values, same (string) keys,
245
+ * deterministic order — JSON serialisation preserves insertion order, so this
246
+ * IS the wire contract. Idempotent; a map with no known primaries is just
247
+ * alphabetised.
248
+ *
249
+ * DOCUMENTED EXCEPTION: a canonically-numeric section name (e.g. "42") is an
250
+ * integer-index key in JS and serialises BEFORE every string key regardless
251
+ * of insertion order — it escapes the contract by engine semantics, not by
252
+ * our choice. That is why writeSections rejects purely-numeric names on new
253
+ * writes (reads keep serving existing numeric files).
254
+ */
255
+ function orderSections(sections) {
256
+ const names = Object.keys(sections);
257
+ const primaries = exports.SECTION_PRECEDENCE.filter((p) => names.includes(p));
258
+ const rest = names.filter((n) => !exports.SECTION_PRECEDENCE.includes(n)).sort();
259
+ const ordered = {};
260
+ for (const name of [...primaries, ...rest])
261
+ ordered[name] = sections[name];
262
+ return ordered;
263
+ }
188
264
  /**
189
265
  * Enumerate the identity dir and return the served sections. Only regular files
190
266
  * whose basename (sans `.md`) passes the allowlist are included; symlinks are
@@ -285,7 +361,10 @@ function readSectionsWithFallback(identityDir) {
285
361
  /**
286
362
  * Partial upsert of the named sections. Contract (spec §1):
287
363
  * - Validate ALL names first; any invalid → throw InvalidSectionNameError and
288
- * write NOTHING (atomic request semantics).
364
+ * write NOTHING (atomic request semantics). Purely-numeric names are
365
+ * rejected here too (NUMERIC_SECTION_RE): they serialize ahead of the
366
+ * precedence contract (see orderSections), so no new ones may be created —
367
+ * existing numeric files remain readable, just no longer writable.
289
368
  * - Only the named sections are touched; omitted sections stay untouched
290
369
  * (omission is not deletion — deletion is filesystem-only).
291
370
  * - Each write goes temp-file-then-rename in the same dir (no half-applied
@@ -310,7 +389,7 @@ function readSectionsWithFallback(identityDir) {
310
389
  * written THROUGH (never-follow-symlinks-on-write).
311
390
  */
312
391
  function writeSections(dir, sections) {
313
- const invalid = Object.keys(sections).filter((n) => !isValidSectionName(n));
392
+ const invalid = Object.keys(sections).filter((n) => !isValidSectionName(n) || NUMERIC_SECTION_RE.test(n));
314
393
  if (invalid.length > 0)
315
394
  throw new InvalidSectionNameError(invalid);
316
395
  (0, node_fs_1.mkdirSync)(dir, { recursive: true });
@@ -596,7 +675,9 @@ function listAgents(identityDir, identityAgents) {
596
675
  function readResolvedSections(identityDir, agentId, identityAgents) {
597
676
  if (agentId === null) {
598
677
  const g = readSectionsWithFallback(identityDir);
599
- return { sections: g.sections, updatedAt: g.updatedAt };
678
+ // #313: every served read carries the precedence contract — the bare
679
+ // global set is ordered exactly like a resolved per-agent set.
680
+ return { sections: orderSections(g.sections), updatedAt: g.updatedAt };
600
681
  }
601
682
  const mode = resolveAgentMode(identityDir, agentId, identityAgents);
602
683
  if (mode === "off") {
@@ -604,22 +685,26 @@ function readResolvedSections(identityDir, agentId, identityAgents) {
604
685
  }
605
686
  const global = readSectionsWithFallback(identityDir);
606
687
  if (mode === "global") {
607
- return { sections: global.sections, updatedAt: global.updatedAt, agent: agentId, mode };
688
+ return { sections: orderSections(global.sections), updatedAt: global.updatedAt, agent: agentId, mode };
608
689
  }
609
690
  // override — read the agent dir (identity + legacy fallback merged so the
610
691
  // both-dirs edge case doesn't lose legacy-only agent sections).
611
692
  const agentRead = readAgentSectionsWithFallback(identityDir, agentId);
612
- const sections = { ...global.sections, ...agentRead.sections };
693
+ // #313: build the merged map, then serve it in precedence order
694
+ // (agent_identity → user → rules → rest alphabetical) — one ordering for
695
+ // every read, so what the client renders is defined server-side.
696
+ const merged = { ...global.sections, ...agentRead.sections };
697
+ const sections = orderSections(merged);
613
698
  const origins = {};
614
699
  let latestMtimeMs = 0;
615
- for (const name of Object.keys(sections)) {
700
+ for (const name of Object.keys(merged)) {
616
701
  const fromAgent = name in agentRead.sections;
617
702
  origins[name] = fromAgent ? "agent" : "global";
618
703
  const mt = fromAgent ? agentRead.mtimes[name] : global.mtimes[name];
619
704
  if (mt && mt > latestMtimeMs)
620
705
  latestMtimeMs = mt;
621
706
  }
622
- const updatedAt = Object.keys(sections).length > 0 ? new Date(latestMtimeMs).toISOString() : null;
707
+ const updatedAt = Object.keys(merged).length > 0 ? new Date(latestMtimeMs).toISOString() : null;
623
708
  return { sections, updatedAt, agent: agentId, mode, origins };
624
709
  }
625
710
  // ---------------------------------------------------------------------------
@@ -667,7 +752,10 @@ function handleIdentityGet(identityDir, clients, query, identityAgents = {}) {
667
752
  // No agent → the plain global read plus the additive `agents` map the UI
668
753
  // selector needs (backward compatible: existing callers ignore unknown keys).
669
754
  if (agentId === null) {
670
- const { sections, updatedAt } = readSectionsWithFallback(identityDir);
755
+ // #313: route through readResolvedSections so the bare read serves the
756
+ // SAME precedence-ordered sections as a per-agent read (it also drops the
757
+ // duplicated readSectionsWithFallback call this handler used to carry).
758
+ const { sections, updatedAt } = readResolvedSections(identityDir, null, identityAgents);
671
759
  return {
672
760
  status: 200,
673
761
  body: { sections, updated_at: updatedAt, clients, agents: listAgents(identityDir, identityAgents) },
@@ -685,6 +773,26 @@ function handleIdentityGet(identityDir, clients, query, identityAgents = {}) {
685
773
  body.origins = resolved.origins;
686
774
  return { status: 200, body };
687
775
  }
776
+ /**
777
+ * Compose the served GET /identity (and /context alias) body — the ONE
778
+ * composition site (#313 CR3): take a handleIdentityGet HandlerResult, inject
779
+ * the synthetic product-owned `memory` section (#192, when enabled and mode
780
+ * ≠ off), then apply the SECTION_PRECEDENCE order (injectMemorySection
781
+ * APPENDS the section last, so the order must be re-applied after it). Used
782
+ * by both REST adapters and buildIdentityToolResult so the final wire key
783
+ * order — including memory's alphabetical slot among "rest" — can never drift
784
+ * between them. Non-200 results pass through untouched. Mutates and returns
785
+ * the SAME HandlerResult (the body object is shared with the caller).
786
+ */
787
+ function serveIdentityBody(handlerResult, memoryEnabled) {
788
+ if (handlerResult.status !== 200)
789
+ return handlerResult;
790
+ const body = handlerResult.body;
791
+ (0, memory_instructions_js_1.injectMemorySection)(body, memoryEnabled);
792
+ if (body.sections)
793
+ body.sections = orderSections(body.sections);
794
+ return handlerResult;
795
+ }
688
796
  /**
689
797
  * PUT /identity. Validates the body shape and section content types, then
690
798
  * delegates to writeSections (which owns the name allowlist + atomicity +
@@ -703,6 +811,21 @@ function handleIdentityPut(identityDir, body, query = {}, identityAgents = {}) {
703
811
  return { status: 400, body: { error: `Section '${name}' content must be a string` } };
704
812
  }
705
813
  }
814
+ // CR3: numeric names get their OWN 400 message, not the generic
815
+ // invalid-name one. The name passes every other check (it even GETs fine —
816
+ // reads keep serving existing numeric files), so "invalid section name"
817
+ // would read as a bug rather than policy. writeSections enforces the same
818
+ // rejection for direct callers (defense in depth).
819
+ const numeric = Object.keys(sections).filter((n) => NUMERIC_SECTION_RE.test(n));
820
+ if (numeric.length > 0) {
821
+ return {
822
+ status: 400,
823
+ body: {
824
+ error: `Section name(s) ${numeric.map((n) => `'${n}'`).join(", ")}: purely-numeric names are read-only via the API ` +
825
+ `(integer-index keys break the served section ordering); existing numeric files stay readable — write a renamed section instead`,
826
+ },
827
+ };
828
+ }
706
829
  const targetDir = agentId === null ? identityDir : (0, node_path_1.join)(identityDir, exports.AGENTS_DIR, agentId);
707
830
  if (agentId !== null) {
708
831
  // A1 write-path guard: never write through a symlinked/non-dir root or leaf
package/dist/index.d.ts CHANGED
@@ -8,14 +8,29 @@
8
8
  * Install once: `openclaw plugins install @gamaze/hicortex`
9
9
  * Run server: `npx @gamaze/hicortex init`
10
10
  *
11
- * Responsibilities (recall-only adapter, like the Hermes plugin):
12
- * - before_agent_start → GET /identity + GET /lessons + POST /recall-index
13
- * (fail-soft, 3s timeout each, concurrent) inject identity + lessons. In
14
- * OpenClaw every inbound message spawns an embedded run, so this hook fires
15
- * PER TURN with the current prompt and session id it is the per-turn
16
- * /recall-index surface, not just session start.
11
+ * Responsibilities (recall-only adapter, behaviorally aligned with the Hermes
12
+ * reference plugin hermes-plugin/hicortex/provider.py since #316):
13
+ * - before_agent_start GET /identity + GET /lessons once per SESSION
14
+ * (#316: standing blocks are not re-sent every turn; a FAILED identity
15
+ * fetch is retried next turn only success memoizes, so the #313 dead-man
16
+ * banner keeps firing until identity returns) + POST /recall-index EVERY
17
+ * turn (fail-soft, concurrent; recall hot path capped at 1.5 s like
18
+ * Hermes). The FIRST recall fetch of a session is preceded by an AWAITED
19
+ * {reset:true} so a gateway restart resuming a session cannot inherit a
20
+ * stale server-side shown-set, and no reset can land after a fetch and
21
+ * wipe what it built. In OpenClaw every inbound message spawns an embedded
22
+ * run, so this hook fires PER TURN — it is the per-turn /recall-index
23
+ * surface, not just session start.
24
+ * - 404 on /recall-index → 600 s TTL latch + GET /search fallback that
25
+ * renders CONTENT (Hermes's legacy `_format_hits` shape — the pushed
26
+ * index's `hicortex_get(id)` menu is useless against the pre-0.14 servers
27
+ * the fallback exists for) — old servers keep full-content recall instead
28
+ * of degrading to nothing. Auth/5xx errors do NOT latch-fallback (they
29
+ * are errors, not version skew): fail soft per turn + warn ONCE per HTTP
30
+ * status.
17
31
  * - after_compaction / before_reset → POST /recall-index {reset:true}
18
- * (context window rebuilt → the server's per-session shown-set is stale)
32
+ * (context window rebuilt → the server's per-session shown-set is stale
33
+ * AND the standing blocks may have been dropped → re-injected next turn)
19
34
  * - Tools → HTTP proxies to /search, /memory, /recent, /ingest, /lessons
20
35
  *
21
36
  * CAPTURE IS NOT THIS PLUGIN'S JOB. OpenClaw persists sessions at