crbro-memory 2.8.0 → 2.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +16 -5
  2. package/dist/daemon/endpoint.d.ts +1 -0
  3. package/dist/daemon/endpoint.d.ts.map +1 -1
  4. package/dist/daemon/endpoint.js +15 -13
  5. package/dist/daemon/endpoint.js.map +1 -1
  6. package/dist/engine/brain.d.ts.map +1 -1
  7. package/dist/engine/brain.js +30 -0
  8. package/dist/engine/brain.js.map +1 -1
  9. package/dist/engine/cortex.d.ts +55 -1
  10. package/dist/engine/cortex.d.ts.map +1 -1
  11. package/dist/engine/cortex.js +203 -12
  12. package/dist/engine/cortex.js.map +1 -1
  13. package/dist/engine/maintenance.d.ts +22 -0
  14. package/dist/engine/maintenance.d.ts.map +1 -1
  15. package/dist/engine/maintenance.js +59 -2
  16. package/dist/engine/maintenance.js.map +1 -1
  17. package/dist/engine/secrets.d.ts.map +1 -1
  18. package/dist/engine/secrets.js +12 -1
  19. package/dist/engine/secrets.js.map +1 -1
  20. package/dist/engine/shelf.d.ts +156 -0
  21. package/dist/engine/shelf.d.ts.map +1 -0
  22. package/dist/engine/shelf.js +680 -0
  23. package/dist/engine/shelf.js.map +1 -0
  24. package/dist/engine/source.d.ts +6 -0
  25. package/dist/engine/source.d.ts.map +1 -0
  26. package/dist/engine/source.js +101 -0
  27. package/dist/engine/source.js.map +1 -0
  28. package/dist/search/index.d.ts +6 -0
  29. package/dist/search/index.d.ts.map +1 -1
  30. package/dist/search/index.js +46 -1
  31. package/dist/search/index.js.map +1 -1
  32. package/dist/server.d.ts.map +1 -1
  33. package/dist/server.js +221 -47
  34. package/dist/server.js.map +1 -1
  35. package/dist/sync/materialize.d.ts +4 -0
  36. package/dist/sync/materialize.d.ts.map +1 -1
  37. package/dist/sync/materialize.js +80 -1
  38. package/dist/sync/materialize.js.map +1 -1
  39. package/dist/sync/ops.d.ts +29 -2
  40. package/dist/sync/ops.d.ts.map +1 -1
  41. package/dist/sync/ops.js.map +1 -1
  42. package/dist/sync/space.d.ts.map +1 -1
  43. package/dist/sync/space.js +15 -2
  44. package/dist/sync/space.js.map +1 -1
  45. package/dist/types/index.d.ts +49 -0
  46. package/dist/types/index.d.ts.map +1 -1
  47. package/dist/version.d.ts +26 -0
  48. package/dist/version.d.ts.map +1 -0
  49. package/dist/version.js +65 -0
  50. package/dist/version.js.map +1 -0
  51. package/hooks/crbro-lifecycle.mjs +612 -608
  52. package/package.json +1 -1
package/dist/server.js CHANGED
@@ -23,8 +23,6 @@ exports.createEngines = createEngines;
23
23
  exports.createServer = createServer;
24
24
  const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
25
25
  const zod_1 = require("zod");
26
- const node_fs_1 = require("node:fs");
27
- const node_path_1 = require("node:path");
28
26
  const brain_js_1 = require("./engine/brain.js");
29
27
  const cortex_js_1 = require("./engine/cortex.js");
30
28
  const synapses_js_1 = require("./engine/synapses.js");
@@ -39,6 +37,9 @@ const backup_js_1 = require("./engine/backup.js");
39
37
  const modinstall_js_1 = require("./engine/modinstall.js");
40
38
  const triggers_js_1 = require("./engine/triggers.js");
41
39
  const ids_js_1 = require("./utils/ids.js");
40
+ const shelf_js_1 = require("./engine/shelf.js");
41
+ const source_js_1 = require("./engine/source.js");
42
+ const version_js_1 = require("./version.js");
42
43
  /** A neuron this size with no summary is worth two lines from whoever is closing the session. */
43
44
  const SUMMARY_NUDGE_MIN_ENTRIES = 25;
44
45
  /** Listings carry the day, not the millisecond: "2026-09-07" says what "2026-09-07T14:02:11.483Z" says, in a third of the tokens. Full stamps stay on single-entry reads. */
@@ -89,19 +90,37 @@ const THREE_STAGES = 'A new truth that REPLACES an old one → crbro_learn with
89
90
  '(kept in the file, gone from recall, reversible with status active). Something must not exist ' +
90
91
  'on disk at all — a credential, personal data, a whole neuron → crbro_forget (quarantine copy first).';
91
92
  /**
92
- * The version of CRBRO that is actually running. The manifest carries its own
93
- * version, but that one stamps the brain FORMAT and has not moved since 1.0.0
94
- * — reporting it as "the version" told every user the same thing regardless of
95
- * what they had installed, which is no use to anyone deciding whether to
96
- * update.
93
+ * What to do with a recall's possibly_stale block (shelf life), said once per
94
+ * answer at the START of `hint` (iteration 2, staleness.md §14): the order to
95
+ * check comes first, the follow-up writes after it. Each row carries its own
96
+ * next_step (where to look); this says what to do with the result.
97
97
  */
98
+ const STALE_HINT = 'possibly_stale holds last-known values that may have changed: do not answer with one as current. Check it first ' +
99
+ '(its next_step says where), then: still true → crbro_revise neuron=<neuron_id> status=verified facts=[entry_id] ' +
100
+ '(entries=[entry_id] for a decision or pattern); changed → crbro_learn the new value with supersedes=[entry_id]. ' +
101
+ 'If you cannot check, say it may be out of date.';
102
+ /**
103
+ * The per-row framing of a possibly_stale row (iteration 2). The stored line
104
+ * comes back as `last_known`, not as `matching_content`, behind a warning and
105
+ * a concrete next step: open what the line itself names, when it names a
106
+ * file, path or URL; otherwise look where that kind of value lives, and if
107
+ * that is not possible, say the value may be out of date.
108
+ */
109
+ function staleFraming(text, s) {
110
+ const since = s.last_verified;
111
+ const warning = s.age_from
112
+ ? `last known value, not verified since ${since}: past its shelf life, may have changed`
113
+ : `last known value, unverified for ${s.age_days} days (since ${since}): may have changed`;
114
+ const named = (0, source_js_1.namedSources)(text);
115
+ const fallback = `If you cannot check, say this value is from ${since} and may be out of date; do not state it as current.`;
116
+ const next_step = named.length
117
+ ? `Before answering, open ${named.join(' / ')} (named in this entry) and answer with what it says now. ${fallback}`
118
+ : `Before answering, look for the current value where it lives: the project's files or config if you can read them, or the user. ${fallback}`;
119
+ return { warning, next_step };
120
+ }
121
+ /** The version this process runs (read once at load: src/version.ts). */
98
122
  function runningVersion() {
99
- try {
100
- return JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(__dirname, '..', 'package.json'), 'utf8')).version;
101
- }
102
- catch {
103
- return 'unknown';
104
- }
123
+ return version_js_1.RUNNING_VERSION;
105
124
  }
106
125
  function textResult(text, isError = false) {
107
126
  return { content: [{ type: 'text', text }], ...(isError ? { isError: true } : {}) };
@@ -155,6 +174,7 @@ function createServer(shared) {
155
174
  instructions: 'CRBRO is this user\'s persistent memory, kept on their own machine. Start every conversation with crbro_boot: it loads what earlier sessions left — protocols to follow, open items, hot topics. ' +
156
175
  'Before answering OR ACTING ON anything about the user, their projects, preferences, decisions or past work, call crbro_recall: the answer is usually stored, and making them repeat it is the failure this memory exists to prevent. ' +
157
176
  'Recall even when you think you know. Only when the current message itself states the answer does it outrank memory: then use it — a recall that finds nothing does not make it unknown, and a stored value older than what the user just said is the one to update, not to repeat. ' +
177
+ 'A row in a recall\'s possibly_stale is a last-known value that may have changed: before answering with it, check it where it lives (the file it names, the project\'s files or config, or the user) and answer from that; if you cannot check, say it may be out of date — never state it as current. Then crbro_revise status=verified or crbro_learn with supersedes. ' +
158
178
  'Acting includes touching one of their systems: before the first command that explores or changes a project of theirs, recall what is already known about it — a stored pattern or map usually holds the very procedure you were about to reconstruct by reading files, and reconstructing it is how you end up doing the steps in the wrong order. ' +
159
179
  'Questions about CRBRO itself (version, counts, whether semantic recall is on) are crbro_inspect view=status. Read one entry, not a whole neuron: view=neuron gives an index, entries=[ids] the text. ' +
160
180
  'Save with crbro_learn as you go, and close with crbro_consolidate before the conversation ends.',
@@ -360,7 +380,10 @@ function createServer(shared) {
360
380
  'deliberate deferral with its ceiling and revisit trigger. Credentials never go in the brain: ' +
361
381
  'crbro_secret, then record only the NAME. Recall results carry confidence — "weak" means the match ' +
362
382
  'covers little of the question, verify before relying on it — and when two facts disagree, prefer ' +
363
- 'the more recent. Lifecycle: supersedes replaces, crbro_revise retires, crbro_forget removes what ' +
383
+ 'the more recent. possibly_stale in a recall = a last-known value that may have changed: check its ' +
384
+ 'source before answering with it, or say it may be out of date (then crbro_revise status=verified if it ' +
385
+ 'holds, crbro_learn supersedes if it changed). A value that can change (version, price, port, host, ' +
386
+ 'setting, who holds a role) should say where it came from — the file, key, URL or person. Lifecycle: supersedes replaces, crbro_revise retires, crbro_forget removes what ' +
364
387
  'must not exist on disk — each tool describes its own stage. ' +
365
388
  'Call crbro_consolidate before the conversation ends; it logs the session too.';
366
389
  // A mature brain outgrew the boot payload: on a 1,145-neuron brain it
@@ -402,6 +425,8 @@ function createServer(shared) {
402
425
  view: zod_1.z.enum(INSPECT_VIEWS),
403
426
  status: zod_1.z.object({
404
427
  crbro_version: zod_1.z.string(),
428
+ installed_version: zod_1.z.string().optional(),
429
+ version_note: zod_1.z.string().optional(),
405
430
  brain_format: zod_1.z.string().optional(),
406
431
  total_neurons: zod_1.z.number(),
407
432
  total_synapses: zod_1.z.number(),
@@ -462,7 +487,10 @@ function createServer(shared) {
462
487
  const manifest = await brain.getManifest();
463
488
  const hot = await (0, fs_js_1.readJSON)(brain.paths.hotTopics());
464
489
  return done({
465
- crbro_version: runningVersion(),
490
+ // The version this process RUNS, read once at start; when the
491
+ // package on disk says another one (npx replaced the files under
492
+ // a running process), it is added apart, with a note (2.9.1).
493
+ ...(0, version_js_1.versionStatus)(),
466
494
  brain_format: manifest.version,
467
495
  total_neurons: manifest.total_neurons,
468
496
  total_synapses: manifest.total_synapses,
@@ -472,6 +500,13 @@ function createServer(shared) {
472
500
  last_consolidation: manifest.last_consolidation,
473
501
  semantic: (0, semantic_js_1.semanticStatus)(),
474
502
  hot_topics_recalculated: hot?.last_recalculated ?? null,
503
+ // Shelf life: whether recall splits off possibly_stale, the windows
504
+ // in days, and the day this brain started counting (legacy grace).
505
+ staleness: {
506
+ enabled: (0, shelf_js_1.stalenessEnabled)(),
507
+ windows: (0, shelf_js_1.shelfWindows)(),
508
+ since: manifest.staleness_since ? dia(manifest.staleness_since) : null,
509
+ },
475
510
  });
476
511
  }
477
512
  if (args.view === 'neuron') {
@@ -503,21 +538,38 @@ function createServer(shared) {
503
538
  const offset = Math.max(args.offset ?? 0, 0);
504
539
  const connections = await synapses.getConnections(neuron.id, args.min_strength);
505
540
  const retired = (id) => neuron.entry_status?.[id]?.status;
541
+ // Shelf life: the same three facts recall shows, so the index of a
542
+ // neuron and a recall never disagree about what is old.
543
+ const vida = (0, shelf_js_1.stalenessContextOf)(await brain.getManifest());
544
+ const rancio = (s) => (s?.stale ? { stale_days: s.age_days } : {});
506
545
  const rows = [];
507
546
  for (const f of neuron.facts || []) {
508
547
  rows.push({ id: f.id || (0, hash_js_1.factId)(f.text), kind: 'fact', text: f.text, added: f.added || '',
509
548
  status: f.status, confidence: f.confidence, keys: f.keys, revision_note: f.revision_note, revised: f.revised,
510
549
  // Only when it says something: 1 is every fact's default (2.7).
511
- ...((f.confirmations ?? 1) > 1 ? { confirmations: f.confirmations } : {}) });
550
+ ...((f.confirmations ?? 1) > 1 ? { confirmations: f.confirmations } : {}),
551
+ ...(f.verified ? { verified: f.verified } : {}),
552
+ ...(f.shelf_life ? { shelf_life: f.shelf_life } : {}),
553
+ ...(vida && neuron.type !== 'protocol' ? rancio((0, shelf_js_1.factStaleness)(f, vida)) : {}) });
512
554
  }
555
+ const entrada = (kind, text, id) => {
556
+ const v = neuron.entry_verified?.[id];
557
+ const retirada = !!neuron.entry_status?.[id];
558
+ return {
559
+ ...(v ? { verified: v } : {}),
560
+ ...(vida && !retirada && neuron.type !== 'protocol' ? rancio((0, shelf_js_1.entryStaleness)(neuron, kind, text, vida)) : {}),
561
+ };
562
+ };
513
563
  for (const d of neuron.decisions || []) {
514
564
  const id = d.id || (0, ops_js_1.entryId)(d.text);
515
- rows.push({ id, kind: 'decision', text: d.text, added: d.date || '', rationale: d.rationale, status: retired(id), revised: neuron.entry_status?.[id]?.revised, revision_note: neuron.entry_status?.[id]?.note });
565
+ rows.push({ id, kind: 'decision', text: d.text, added: d.date || '', rationale: d.rationale, status: retired(id), revised: neuron.entry_status?.[id]?.revised, revision_note: neuron.entry_status?.[id]?.note,
566
+ ...entrada('decision', d.text, (0, ops_js_1.entryId)(d.text)) });
516
567
  }
517
568
  const sidecar = (kind, list) => {
518
569
  for (const t of list || []) {
519
570
  const id = (0, ops_js_1.entryId)(t);
520
- rows.push({ id, kind, text: t, added: neuron.entry_dates?.[id] || '', status: retired(id), revised: neuron.entry_status?.[id]?.revised, revision_note: neuron.entry_status?.[id]?.note });
571
+ rows.push({ id, kind, text: t, added: neuron.entry_dates?.[id] || '', status: retired(id), revised: neuron.entry_status?.[id]?.revised, revision_note: neuron.entry_status?.[id]?.note,
572
+ ...entrada(kind, t, id) });
521
573
  }
522
574
  };
523
575
  sidecar('pattern', neuron.patterns);
@@ -597,6 +649,9 @@ function createServer(shared) {
597
649
  preview: r.text.length > PREVIEW ? `${r.text.slice(0, PREVIEW).trimEnd()}…` : r.text,
598
650
  chars: r.text.length,
599
651
  ...(r.confirmations ? { confirmations: r.confirmations } : {}),
652
+ ...(r.verified ? { verified: dia(r.verified) } : {}),
653
+ ...(r.shelf_life ? { shelf_life: r.shelf_life } : {}),
654
+ ...(r.stale_days !== undefined ? { stale_days: r.stale_days } : {}),
600
655
  ...(isRetired(r.status) ? { status: r.status, ...(r.revised ? { revised: dia(r.revised) } : {}), ...(r.revision_note ? { retired_note: r.revision_note } : {}) } : {}),
601
656
  })),
602
657
  entries_pagination: {
@@ -677,7 +732,7 @@ function createServer(shared) {
677
732
  // ═══════════════════════════════════════════════════════════════
678
733
  server.registerTool('crbro_learn', {
679
734
  title: 'Learn something',
680
- description: 'Write: store a fact, decision, pattern, preference, error or debt on a topic; the neuron is created if missing (or pass neuron_id). Stage 1 of the lifecycle: a new truth that REPLACES an old one → crbro_learn with supersedes (one call does both); to retire with no replacement use crbro_revise; to delete from disk use crbro_forget. crbro_recall first — it may already exist. The same fact text again is not duplicated: keywords merge (or keywords_replace), a changed confidence applies (updated_in_place) and another session repeating it raises confirmations, returned; text matching a retired fact or entry is refused with skipped_retired. Decisions always append; preferences never leave this machine. Credentials are replaced with a marker and listed in redacted — crbro_secret them, record only the name. Returns neuron_id, action, superseded count, near_duplicates (stored anyway; retire the old telling), supersedes_unmatched (still live) and totals.',
735
+ description: 'Write: store a fact, decision, pattern, preference, error or debt on a topic; the neuron is created if missing (or pass neuron_id). A value that can change names its source (file, key, URL, person). Stage 1 of the lifecycle: a new truth that REPLACES an old one → crbro_learn with supersedes; retire with no replacement → crbro_revise; delete from disk → crbro_forget. crbro_recall first — it may already exist. The same fact text again is not duplicated: keywords merge, a changed confidence or shelf_life applies (updated_in_place), a bare repeat counts as re-verified and another session repeating it raises confirmations; text matching a retired entry is refused with skipped_retired. Decisions always append; preferences never leave this machine. Credentials become a marker listed in redacted: crbro_secret them, keep only the name. Returns neuron_id, action, the fact\'s shelf_life, near_duplicates (stored anyway; retire the old telling) and supersedes_unmatched (still live).',
681
736
  inputSchema: {
682
737
  // Optional since 2.0.3, and the reason is measured: the description
683
738
  // told callers that neuron_id "skips name matching entirely", the
@@ -688,7 +743,7 @@ function createServer(shared) {
688
743
  // message that says what to pass.
689
744
  topic: zod_1.z.string().optional().describe('Topic name, e.g. "OctoChat", "Firebase", "SEO Strategy". Required UNLESS you pass neuron_id, in which case the topic is taken from that neuron.'),
690
745
  type: zod_1.z.enum(['fact', 'decision', 'pattern', 'preference', 'error', 'debt']).describe('error = a mistake plus its correction, in one entry. debt = a deliberate deferral: what was NOT done on purpose, its ceiling, and the revisit condition, e.g. "DEFERRED: protecting the PDFs. CEILING: anyone can download them without signing up. REVISIT WHEN: the signup flow works."'),
691
- content: zod_1.z.string().describe('The knowledge itself. Dense and self-contained: it is recalled without this conversation as context.'),
746
+ content: zod_1.z.string().describe('The knowledge itself. Dense and self-contained: it is recalled without this conversation as context. For a value that can change (a version, price, port, host, setting, who holds a role), say where it came from — the file, config key, URL or person — so a later check knows where to look.'),
692
747
  confidence: zod_1.z.number().min(0).max(1).optional().describe('0.0-1.0, default 1.0. Facts only. On an exact-duplicate active fact the stored confidence is updated to this value (updated_in_place:true).'),
693
748
  domain: zod_1.z.string().optional().describe('Domain, e.g. "proyectos-web". Applied when the neuron is created; on an existing neuron it only replaces the default "general" (crbro_revise domain replaces it unconditionally).'),
694
749
  rationale: zod_1.z.string().optional().describe('Why the decision was taken. Stored and indexed with it; ignored for other types.'),
@@ -696,6 +751,7 @@ function createServer(shared) {
696
751
  supersedes: zod_1.z.array(zod_1.z.string()).optional().describe('Facts this one replaces: their ids or exact text. They leave recall but stay in the file. Unmatched targets are reported and stay live.'),
697
752
  keywords: zod_1.z.array(zod_1.z.string()).optional().describe('Facts only, and expected on every fact: 2-5 words a future question may use that the text does not contain — synonyms, the other language, the generic name of the product named. Indexed with the fact, never shown; the largest measured lever on recall. Without them the fact is stored and the answer carries keywords_missing. The same text again with new keywords merges them.'),
698
753
  keywords_replace: zod_1.z.boolean().optional().describe('When the exact fact text already exists, replace its stored keywords with `keywords` instead of merging (default false). Teammates in a shared space only ever receive the union.'),
754
+ shelf_life: zod_1.z.enum(shelf_js_1.SHELF_LIVES).optional().describe('Facts only: how fast this value goes stale. volatile = versions, prices, ports, hosts, paths, config, who holds a role; durable = rarely moves; permanent = history that cannot change; normal otherwise. Omitted: inferred from the text, and returned.'),
699
755
  },
700
756
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
701
757
  }, async (args) => {
@@ -723,6 +779,7 @@ function createServer(shared) {
723
779
  supersedes: args.supersedes,
724
780
  keys: args.keywords,
725
781
  keysReplace: args.keywords_replace,
782
+ shelfLife: args.type === 'fact' ? args.shelf_life : undefined,
726
783
  });
727
784
  // Indexing happens inside cortex.learn, through the indexer hook.
728
785
  if (result.action === 'skipped_retired' && result.skipped_retired) {
@@ -741,6 +798,13 @@ function createServer(shared) {
741
798
  if (!result.neuron) {
742
799
  return textResult(`No neuron matched "${topic}" and none was created.`);
743
800
  }
801
+ // Lengthening an explicit shelf life does not hold on a shared neuron:
802
+ // the team merge keeps the most volatile value, and the older op is
803
+ // still in the append-only log, so the next sync brings it back.
804
+ const sharedIn = result.shelf_lengthened ? (await (0, space_js_1.sharedMap)(brain))[result.neuron.id] : undefined;
805
+ const shelfSharedWarning = sharedIn
806
+ ? `"${result.neuron.id}" is shared in space "${sharedIn}": a longer shelf_life is local only — the next sync restores the more volatile value from the shared log.`
807
+ : undefined;
744
808
  const keywordsMissing = args.type === 'fact' && result.action !== 'skipped' &&
745
809
  !(args.keywords && args.keywords.some(k => typeof k === 'string' && k.trim().length > 0));
746
810
  return jsonResult({
@@ -784,6 +848,15 @@ function createServer(shared) {
784
848
  'question may use that the text lacks (synonyms, the other language, the generic name of the product): ' +
785
849
  'they merge into this fact instead of adding a new one.'
786
850
  : undefined,
851
+ // Shelf life, facts only: the class that applies, and when it was not
852
+ // given, that it was inferred and by which rule, so a model that
853
+ // disagrees can say otherwise in the next call.
854
+ shelf_life: result.shelf?.shelf_life,
855
+ shelf_inferred: result.shelf?.inferred ? true : undefined,
856
+ shelf_reason: result.shelf?.inferred ? result.shelf.reason : undefined,
857
+ // The same line learned again, and nothing else: its shelf-life clock restarts now.
858
+ reconfirmed: result.reconfirmed || undefined,
859
+ shared_warning: shelfSharedWarning,
787
860
  total_facts: result.neuron.facts.length,
788
861
  total_decisions: result.neuron.decisions.length,
789
862
  total_patterns: result.neuron.patterns.length,
@@ -806,13 +879,13 @@ function createServer(shared) {
806
879
  // ═══════════════════════════════════════════════════════════════
807
880
  server.registerTool('crbro_recall', {
808
881
  title: 'Recall',
809
- description: 'Read-only search of everything saved in earlier sessions — facts, decisions, patterns, preferences, errors, debts and maps. Call it BEFORE answering anything about the user, their projects, preferences, decisions or past work: the answer is usually stored, and making them repeat it is the failure this memory exists to prevent. Also before crbro_learn, to supersede rather than duplicate. One result per neuron: the best matching entry with entry_id (read it whole: crbro_inspect view=neuron entries=[id]), matched_kind, matched_added, a confidence label (weak = little of the question covered; verify) and also_matched previews. Retired entries never surface. Lines not the user\'s own carry origin, also_matched too: team:<space> (team if unshared) with self-declared by, or miner. Five results by default; matched_neurons counts every hit. If nothing matches, retry with 2-4 phrasings in queries or fewer, rarer words. has_map:true: read the system map with crbro_map before touching that system.',
882
+ description: 'Read-only search of everything saved in earlier sessions — facts, decisions, patterns, preferences, errors, debts and maps. Call it BEFORE answering anything about the user, their projects, preferences, decisions or past work: the answer is usually stored, and making them repeat it is the failure this memory exists to prevent. One result per neuron: the best matching entry with entry_id (read it whole: crbro_inspect view=neuron entries=[id]), matched_kind, matched_added, a confidence label (weak = little of the question covered; verify) and also_matched previews. Lines not the user\'s own carry origin, also_matched too: team:<space> (team if unshared) with self-declared by, or miner. Rows past their shelf life move to possibly_stale as last_known with a next_step: check before answering with one, or say it may be out of date. If nothing matches, retry with 2-4 phrasings in queries or fewer, rarer words. has_map:true: read the system map with crbro_map before touching that system.',
810
883
  inputSchema: {
811
884
  query: zod_1.z.string().describe('What to look for, e.g. "Firebase authentication setup". Fewer, distinctive terms beat full sentences.'),
812
885
  queries: zod_1.z.array(zod_1.z.string()).optional().describe('Alternative phrasings of the same question, searched together with query and fused by rank. Use synonyms, the other language and the concrete product name; 2-4 is plenty.'),
813
886
  domain: zod_1.z.string().optional().describe('Only neurons in this domain (exact match, e.g. "proyectos-web"). Day logs have no domain: sessions_matched is listed regardless.'),
814
887
  limit: zod_1.z.number().int().positive().optional().describe('Max neurons returned (default 5, ranked; ask for more only when the top five did not answer).'),
815
- since: zod_1.z.string().optional().describe('Only entries dated on or after this: a day ("2026-09-01") or a span back from today ("7d", "2w", "3m"). For "what changed lately" and to keep an old telling out. Undated entries cannot prove they are recent: they are left out and counted in undated_skipped.'),
888
+ since: zod_1.z.string().optional().describe('Only entries recorded on or after this: a day ("2026-09-01") or a span back from today ("7d", "2w", "3m"). For "what changed lately" and to keep an old telling out. A later verification does not make an old entry new. Undated entries cannot prove they are recent: they are left out and counted in undated_skipped.'),
816
889
  kind: zod_1.z.array(zod_1.z.enum(index_js_1.RECALL_KINDS)).optional().describe('Only these entry kinds, e.g. ["error"] for past mistakes before repeating one, ["decision"] for what was agreed and why, ["debt"] for what was deferred. Day logs are left out when set.'),
817
890
  },
818
891
  outputSchema: {
@@ -826,11 +899,18 @@ function createServer(shared) {
826
899
  matched_terms: zod_1.z.number().optional(), query_terms: zod_1.z.number().optional(),
827
900
  confidence: zod_1.z.enum(['strong', 'weak']).optional(),
828
901
  entry_id: zod_1.z.string().optional(),
902
+ rank: zod_1.z.number().optional().describe('Position in the ranking, set when some row moved to possibly_stale'),
829
903
  origin: zod_1.z.string().optional(), by: zod_1.z.string().optional(),
830
904
  content_truncated: zod_1.z.boolean().optional(), content_chars: zod_1.z.number().optional(),
831
- also_matched: zod_1.z.array(zod_1.z.object({ entry_id: zod_1.z.string().optional(), kind: zod_1.z.string(), added: zod_1.z.string(), preview: zod_1.z.string(), chars: zod_1.z.number(), origin: zod_1.z.string().optional(), by: zod_1.z.string().optional() }).loose()).optional(),
905
+ also_matched: zod_1.z.array(zod_1.z.object({ entry_id: zod_1.z.string().optional(), kind: zod_1.z.string(), added: zod_1.z.string(), preview: zod_1.z.string(), chars: zod_1.z.number(), origin: zod_1.z.string().optional(), by: zod_1.z.string().optional(), stale_days: zod_1.z.number().optional() }).loose()).optional(),
832
906
  }).loose()),
833
- returned: zod_1.z.number().optional(),
907
+ stale_warning: zod_1.z.string().optional().describe('Set, first, when some row moved to possibly_stale: do not answer with those as current'),
908
+ possibly_stale: zod_1.z.array(zod_1.z.object({
909
+ warning: zod_1.z.string(), next_step: zod_1.z.string(), last_known: zod_1.z.string(), neuron_id: zod_1.z.string(),
910
+ age_days: zod_1.z.number(), last_verified: zod_1.z.string(), shelf_life: zod_1.z.string(), shelf_inferred: zod_1.z.boolean(),
911
+ }).loose()).optional().describe('Rows whose best entry is past its shelf life since last verified: warning, next_step (what to check before answering), the entry as last_known instead of matching_content, then the rest of a results row plus age_days, last_verified, shelf_life, shelf_inferred'),
912
+ possibly_stale_count: zod_1.z.number().optional(),
913
+ returned: zod_1.z.number().optional().describe('Rows that came back: results plus possibly_stale'),
834
914
  matched_neurons: zod_1.z.number().optional().describe('Neurons with any hit before limit; total_results is what came back'),
835
915
  has_more: zod_1.z.boolean().optional(),
836
916
  sessions_matched: zod_1.z.array(zod_1.z.object({}).loose()).optional(),
@@ -874,36 +954,94 @@ function createServer(shared) {
874
954
  ...(r.also_matched ? { also_matched: r.also_matched.map(a => ({ ...a, added: dia(a.added) })) } : {}),
875
955
  };
876
956
  });
877
- const sobran = matched_neurons - results.length;
957
+ // Shelf life: partition, not penalty. The ranking above already chose
958
+ // these rows exactly as before; a row whose WINNING entry is past its
959
+ // shelf life since last verified moves whole to possibly_stale, in
960
+ // rank order. No backfill (the cost stays bounded by limit, and "what
961
+ // does memory say about X" never silently becomes a weaker line about
962
+ // something else), and never re-headed with an also_matched line.
963
+ // The internal `staleness` field leaves every row here.
964
+ const actuales = [];
965
+ const rancios = [];
966
+ // When anything moves, every row keeps its rank: results[0] is then
967
+ // not necessarily the best match, and the agent has to be able to see it.
968
+ const algunoRancio = rows.some(r => r.staleness?.stale);
969
+ let mejorMovido = false;
970
+ for (const [i, row] of rows.entries()) {
971
+ const { staleness, ...resto } = row;
972
+ if (algunoRancio)
973
+ resto.rank = i + 1;
974
+ if (staleness?.stale) {
975
+ if (i === 0)
976
+ mejorMovido = true;
977
+ // Iteration 2: the old line is not served as ordinary content. Its
978
+ // warning and next step come first, then the line as last_known.
979
+ const { matching_content, neuron_id, name, entry_id, rank, ...demas } = resto;
980
+ rancios.push({
981
+ ...staleFraming(matching_content, staleness),
982
+ last_known: matching_content,
983
+ neuron_id, name,
984
+ ...(entry_id !== undefined ? { entry_id } : {}),
985
+ ...(rank !== undefined ? { rank } : {}),
986
+ ...demas,
987
+ age_days: staleness.age_days,
988
+ last_verified: staleness.last_verified,
989
+ shelf_life: staleness.shelf_life,
990
+ shelf_inferred: staleness.shelf_inferred,
991
+ ...(staleness.age_from ? { age_counted_from: staleness.age_from } : {}),
992
+ });
993
+ }
994
+ else {
995
+ actuales.push(resto);
996
+ }
997
+ }
998
+ const servidos = actuales.length + rancios.length;
999
+ const sobran = matched_neurons - servidos;
1000
+ // Iteration 2: when anything moved, the answer OPENS with a short
1001
+ // warning, before query and results, so it is read before the rows.
1002
+ const menorEdad = rancios.length ? Math.min(...rancios.map(r => r.age_days)) : 0;
1003
+ const uno = rancios.length === 1;
1004
+ const avisoRancio = rancios.length
1005
+ ? (uno
1006
+ ? `1 matching row is in possibly_stale${mejorMovido ? ', the best match' : ''}: a last-known value, unchecked for ${menorEdad} days, that may have changed. Do not answer with it as current. Check it first (its next_step says where)`
1007
+ : `${rancios.length} matching rows are in possibly_stale${mejorMovido ? ', the best match among them' : ''}: last-known values, unchecked for ${menorEdad}+ days, that may have changed. Do not answer with one as current. Check it first (each row's next_step says where)`) +
1008
+ '; if you cannot, say it may be out of date, even in a short answer.'
1009
+ : '';
878
1010
  const payload = {
1011
+ ...(avisoRancio ? { stale_warning: avisoRancio } : {}),
879
1012
  query: args.query,
880
- // total_results is what came back (capped by limit); matched_neurons is
881
- // how many neurons had a hit at all, so five never reads as "only five".
882
- total_results: results.length,
883
- returned: results.length,
1013
+ // total_results is what came back as current (capped by limit);
1014
+ // returned adds possibly_stale; matched_neurons is how many neurons
1015
+ // had a hit at all, so five never reads as "only five".
1016
+ total_results: actuales.length,
1017
+ returned: servidos,
884
1018
  matched_neurons,
885
1019
  has_more: sobran > 0,
886
1020
  // The filter as it was understood ("30d" becomes a day), so a narrowed
887
1021
  // answer never reads as the whole brain.
888
1022
  ...(filtrado ? { filters: { ...(since ? { since } : {}), ...(kinds ? { kind: kinds } : {}) } } : {}),
889
1023
  ...(since ? { undated_skipped: sinFecha } : {}),
890
- results: rows,
1024
+ results: actuales,
1025
+ ...(rancios.length ? { possibly_stale: rancios, possibly_stale_count: rancios.length } : {}),
891
1026
  // The diary, searched since 2.2: day logs whose summary mentions the
892
1027
  // question, in a list of their own so narrative never outranks a fact.
893
1028
  // sessions_total: how many days had a hit; three shown never reads as three.
894
1029
  ...(sessions.length ? { sessions_matched: sessions.map(s => ({ ...s, date: dia(s.date) })), sessions_total } : {}),
895
- hint: (results.length === 0
896
- ? (sessions.length
897
- // A day mentions it and no fact does: point at the day, not at rephrasing.
898
- ? `No stored fact matched; ${sessions_total} day log${sessions_total === 1 ? '' : 's'} mention it (sessions_matched): read one whole with crbro_inspect view=sessions session=<session_id>. If it is a fact worth keeping, save it with crbro_learn.`
899
- : (filtrado
900
- ? 'Nothing matched inside the filter. Drop since/kind and ask again before concluding it is not stored.'
901
- : 'Nothing matched. Try fewer, more distinctive words - names, ids, filenames - rather than a full sentence.'))
902
- : 'weak: verify. Newer wins on conflict. entry_id → crbro_inspect view=neuron entries=[id]. has_map → crbro_map first.'
903
- + (sobran > 0 ? ` ${sobran} more neuron${sobran === 1 ? '' : 's'} matched: raise limit or narrow the query.` : '')
904
- + (cortados > 0 ? ' content_truncated → read the entry by entry_id.' : '')
905
- + (sessions.length ? ' sessions_matched: day logs that mention it; read one whole with crbro_inspect view=sessions session=<session_id>.' : ''))
906
- + (filtrado && results.length > 0 ? ' Filtered: entries outside since/kind were not searched.' : '')
1030
+ hint: (rancios.length && !actuales.length ? 'Nothing current matched; the rows that did are in possibly_stale. '
1031
+ : mejorMovido ? 'The best match (rank 1) moved to possibly_stale; results holds lower-ranked rows that may be about something else. ' : '') +
1032
+ (rancios.length ? `${STALE_HINT} ` : '') +
1033
+ (servidos === 0
1034
+ ? (sessions.length
1035
+ // A day mentions it and no fact does: point at the day, not at rephrasing.
1036
+ ? `No stored fact matched; ${sessions_total} day log${sessions_total === 1 ? '' : 's'} mention it (sessions_matched): read one whole with crbro_inspect view=sessions session=<session_id>. If it is a fact worth keeping, save it with crbro_learn.`
1037
+ : (filtrado
1038
+ ? 'Nothing matched inside the filter. Drop since/kind and ask again before concluding it is not stored.'
1039
+ : 'Nothing matched. Try fewer, more distinctive words - names, ids, filenames - rather than a full sentence.'))
1040
+ : 'weak: verify. Newer wins on conflict. entry_id → crbro_inspect view=neuron entries=[id]. has_map → crbro_map first.'
1041
+ + (sobran > 0 ? ` ${sobran} more neuron${sobran === 1 ? '' : 's'} matched: raise limit or narrow the query.` : '')
1042
+ + (cortados > 0 ? ' content_truncated → read the entry by entry_id.' : '')
1043
+ + (sessions.length ? ' sessions_matched: day logs that mention it; read one whole with crbro_inspect view=sessions session=<session_id>.' : ''))
1044
+ + (filtrado && servidos > 0 ? ' Filtered: entries outside since/kind were not searched.' : '')
907
1045
  + (sinFecha > 0 ? ` ${sinFecha} matching entr${sinFecha === 1 ? 'y has' : 'ies have'} no date and were left out by since (crbro_maintenance backfill_dates dates the ones that state a day).` : '')
908
1046
  + (sessions_total > sessions.length ? ` ${sessions_total - sessions.length} more day${sessions_total - sessions.length === 1 ? '' : 's'} mention it: narrow the query.` : ''),
909
1047
  };
@@ -928,9 +1066,9 @@ function createServer(shared) {
928
1066
  const reviseSchema = zod_1.z.object({
929
1067
  neuron: zod_1.z.string().describe('Neuron id or name holding what to revise, e.g. "project_octochat".'),
930
1068
  facts: zod_1.z.array(zod_1.z.string()).optional().describe('Facts to move to `status`: their ids (from crbro_recall) or exact text (trimmed, case-insensitive). For superseded/retracted only active facts match; for active only retired ones do.'),
931
- entries: zod_1.z.array(zod_1.z.string()).optional().describe('Exact texts of decisions, patterns, errors or debts to move to `status`. Retired entries stay in the file (entry_status) but leave recall like a superseded fact.'),
932
- status: zod_1.z.enum(['superseded', 'retracted', 'active']).optional().describe('superseded = a newer truth exists (default); retracted = it was never true; active = reactivate a retired fact or entry. Reactivation is local: on a shared neuron the next sync re-applies the retirement (the response carries shared_warning).'),
933
- note: zod_1.z.string().optional().describe('Why. Stored as revision_note on facts and entry_status.note on entries. The next reader will wonder.'),
1069
+ entries: zod_1.z.array(zod_1.z.string()).optional().describe('Exact texts of decisions, patterns, errors or debts to move to `status`. Retired entries stay in the file (entry_status) but leave recall like a superseded fact. With status verified: decisions or patterns, by exact text or entry id.'),
1070
+ status: zod_1.z.enum(['superseded', 'retracted', 'active', 'verified']).optional().describe('superseded = a newer truth exists (default); retracted = it was never true; active = reactivate a retired fact or entry (local only on a shared neuron: the next sync re-applies the retirement; shared_warning says so). verified = you checked these active facts, or live decisions or patterns, against their source and they still hold: their last verification becomes now and they leave possibly_stale. A retired target is not verifiable: it comes back in unmatched and retired_targets.'),
1071
+ note: zod_1.z.string().optional().describe('Why. Stored as revision_note on facts and entry_status.note on entries. The next reader will wonder. Ignored with status verified.'),
934
1072
  summary: zod_1.z.string().optional().describe('Replace the neuron summary. Credentials are redacted and listed in redacted.'),
935
1073
  domain: zod_1.z.string().optional().describe('Replace the neuron domain unconditionally, e.g. "proyectos-web".'),
936
1074
  tags: zod_1.z.array(zod_1.z.string()).optional().describe('Replace the WHOLE tag list (trimmed, deduplicated). On protocol neurons re-send the priority: and source: tags or they are gone.'),
@@ -950,7 +1088,7 @@ function createServer(shared) {
950
1088
  });
951
1089
  server.registerTool('crbro_revise', {
952
1090
  title: 'Revise a neuron',
953
- description: 'Write: change what a neuron says without deleting anything. Stage 2 of the lifecycle: something stopped being true, or was never true, and nothing replaces it → crbro_revise (kept in the file, gone from recall, reversible with status active). If a replacement exists, crbro_learn with supersedes does both; for what must not exist on disk use crbro_forget. facts retires facts by id or exact text; entries retires decisions, patterns, errors and debts by exact text; status active reactivates either (local only on a shared neuron: the next sync re-applies the retirement, shared_warning says so). summary, domain, tags and name edit metadata in the same call (tags replaces the whole list; the id never changes). move_to splits: the listed entries go to another neuron with their dates. Anything in unmatched is STILL LIVE — fix and re-run.',
1091
+ description: 'Write: change what a neuron says without deleting anything. Stage 2 of the lifecycle: something stopped being true, or was never true, and nothing replaces it → crbro_revise (kept in the file, gone from recall, reversible with status active). If a replacement exists, crbro_learn with supersedes does both; for what must not exist on disk use crbro_forget. facts retires facts by id or exact text; entries retires decisions, patterns, errors and debts by exact text; status active reactivates either (local only on a shared neuron: the next sync re-applies the retirement, shared_warning says so); status verified records that a fact, decision or pattern was checked against its source and still holds. summary, domain, tags and name edit metadata in the same call (tags replaces the whole list; the id never changes). move_to splits: the listed entries go to another neuron with their dates. Anything in unmatched is STILL LIVE — fix and re-run.',
954
1092
  inputSchema: reviseSchema,
955
1093
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
956
1094
  }, async (args) => {
@@ -989,6 +1127,42 @@ function createServer(shared) {
989
1127
  : 'Nothing matched, nothing moved. Pass entry ids from crbro_inspect view=neuron, or the exact text.',
990
1128
  });
991
1129
  }
1130
+ // ── status verified: reconfirm, do not retire (shelf life) ──
1131
+ if (status === 'verified') {
1132
+ const v = (args.facts?.length || args.entries?.length)
1133
+ ? await cortex.verify(target.id, { facts: args.facts, entries: args.entries })
1134
+ : { verified: [], unmatched: [], retired: [] };
1135
+ let cambiados = [];
1136
+ let tachados = [];
1137
+ const metaV = { summary: args.summary, domain: args.domain, tags: args.tags, name: args.name };
1138
+ if (Object.values(metaV).some(x => x !== undefined)) {
1139
+ const r = await cortex.setMeta(target.id, metaV);
1140
+ cambiados = r.changed;
1141
+ tachados = r.redacted;
1142
+ }
1143
+ const n = v.verified.length;
1144
+ const partes = [];
1145
+ if (n > 0)
1146
+ partes.push(`${n} entr${n === 1 ? 'y' : 'ies'} verified: last checked now, shelf-life clock restarted`);
1147
+ if (cambiados.length > 0)
1148
+ partes.push(`${cambiados.join(', ')} updated`);
1149
+ return jsonResult({
1150
+ neuron_id: target.id,
1151
+ status,
1152
+ verified: v.verified,
1153
+ unmatched: v.unmatched.length > 0 ? v.unmatched : undefined,
1154
+ retired_targets: v.retired.length > 0 ? v.retired : undefined,
1155
+ changed: cambiados,
1156
+ redacted: tachados.length > 0 ? tachados : undefined,
1157
+ message: partes.length > 0
1158
+ ? `${partes.join('; ')} in "${target.name}".` +
1159
+ (v.unmatched.length > 0 ? ` WARNING: ${v.unmatched.length} target(s) matched no active fact or live decision/pattern and were not verified.` : '') +
1160
+ (v.retired.length > 0 ? ' A retired line is not verifiable: if it holds again, reactivate it with status active first.' : '')
1161
+ : (v.retired.length > 0
1162
+ ? 'Nothing verified: the targets are retired. If one holds again, reactivate it with status active first, then verify.'
1163
+ : 'Nothing verified. Pass the fact id (entry_id from crbro_recall) or exact text in facts; decisions and patterns go in entries. Preferences, errors and debts never go stale.'),
1164
+ });
1165
+ }
992
1166
  let revisedFacts = 0;
993
1167
  let revisedEntries = 0;
994
1168
  const unmatched = [];
@@ -1049,7 +1223,7 @@ function createServer(shared) {
1049
1223
  // ═══════════════════════════════════════════════════════════════
1050
1224
  const forgetSchema = zod_1.z.object({
1051
1225
  neuron: zod_1.z.string().optional().describe('Neuron id or name the mode acts on. Required for every mode except session. restore needs the exact neuron id.'),
1052
- facts: zod_1.z.array(zod_1.z.string()).optional().describe('Mode facts: fact ids, or the exact text of a fact, decision, pattern, preference, error or debt; the exact full text of the map removes the map. Deleted for good after a quarantine copy; decision/pattern removals travel to shared spaces like errors and debts.'),
1226
+ facts: zod_1.z.array(zod_1.z.string()).optional().describe('Mode facts: the ids crbro_inspect and crbro_recall show, or the exact text, of facts, decisions, patterns, preferences, errors or debts; the exact full text of the map removes the map. Deleted for good after a quarantine copy; decision/pattern removals travel to shared spaces like errors and debts.'),
1053
1227
  entire: zod_1.z.boolean().optional().describe('Mode entire: delete the whole neuron and its synapses. Without confirm_token it is a dry run — { neuron_id, dry_run:true, counts, shared_in, confirm_token }. Refused (no token) while the neuron is shared: crbro_share unshare first.'),
1054
1228
  confirm_token: zod_1.z.string().optional().describe('Only with entire:true — the token from the dry run. Derived from the neuron\'s counts, so it goes stale (and is refused) when the neuron changed in between.'),
1055
1229
  restore: zod_1.z.boolean().optional().describe('Mode restore: bring back the newest quarantine copy of `neuron` (exact id). If the neuron exists again, the copy is merged into it (merged_into_existing:true, moved counts). The quarantine file stays, so restore is repeatable.'),