crbro-memory 2.7.2 → 2.9.0

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 (54) hide show
  1. package/README.md +66 -7
  2. package/SECURITY.md +36 -0
  3. package/bin/crbro.mjs +1437 -1366
  4. package/dist/daemon/endpoint.d.ts.map +1 -1
  5. package/dist/daemon/endpoint.js +18 -0
  6. package/dist/daemon/endpoint.js.map +1 -1
  7. package/dist/engine/brain.d.ts.map +1 -1
  8. package/dist/engine/brain.js +30 -0
  9. package/dist/engine/brain.js.map +1 -1
  10. package/dist/engine/cortex.d.ts +55 -1
  11. package/dist/engine/cortex.d.ts.map +1 -1
  12. package/dist/engine/cortex.js +183 -3
  13. package/dist/engine/cortex.js.map +1 -1
  14. package/dist/engine/maintenance.d.ts +22 -0
  15. package/dist/engine/maintenance.d.ts.map +1 -1
  16. package/dist/engine/maintenance.js +59 -2
  17. package/dist/engine/maintenance.js.map +1 -1
  18. package/dist/engine/modinstall.d.ts +215 -0
  19. package/dist/engine/modinstall.d.ts.map +1 -0
  20. package/dist/engine/modinstall.js +1342 -0
  21. package/dist/engine/modinstall.js.map +1 -0
  22. package/dist/engine/shelf.d.ts +97 -0
  23. package/dist/engine/shelf.d.ts.map +1 -0
  24. package/dist/engine/shelf.js +343 -0
  25. package/dist/engine/shelf.js.map +1 -0
  26. package/dist/engine/source.d.ts +6 -0
  27. package/dist/engine/source.d.ts.map +1 -0
  28. package/dist/engine/source.js +101 -0
  29. package/dist/engine/source.js.map +1 -0
  30. package/dist/search/index.d.ts +6 -0
  31. package/dist/search/index.d.ts.map +1 -1
  32. package/dist/search/index.js +46 -1
  33. package/dist/search/index.js.map +1 -1
  34. package/dist/server.d.ts.map +1 -1
  35. package/dist/server.js +226 -33
  36. package/dist/server.js.map +1 -1
  37. package/dist/sync/materialize.d.ts +4 -0
  38. package/dist/sync/materialize.d.ts.map +1 -1
  39. package/dist/sync/materialize.js +80 -1
  40. package/dist/sync/materialize.js.map +1 -1
  41. package/dist/sync/ops.d.ts +29 -2
  42. package/dist/sync/ops.d.ts.map +1 -1
  43. package/dist/sync/ops.js.map +1 -1
  44. package/dist/sync/space.d.ts.map +1 -1
  45. package/dist/sync/space.js +15 -2
  46. package/dist/sync/space.js.map +1 -1
  47. package/dist/types/index.d.ts +47 -0
  48. package/dist/types/index.d.ts.map +1 -1
  49. package/mods/crbro-pending/.claude-plugin/plugin.json +21 -0
  50. package/mods/crbro-pending/hooks/hooks.json +1 -0
  51. package/mods/crbro-pending/hooks/register.tsx +576 -0
  52. package/mods/crbro-pending/hooks/strings.ts +200 -0
  53. package/mods/crbro-pending/types/index.d.ts +27 -0
  54. package/package.json +2 -1
package/dist/server.js CHANGED
@@ -36,8 +36,11 @@ const semantic_js_1 = require("./search/semantic.js");
36
36
  const budget_js_1 = require("./utils/budget.js");
37
37
  const secrets_js_1 = require("./engine/secrets.js");
38
38
  const backup_js_1 = require("./engine/backup.js");
39
+ const modinstall_js_1 = require("./engine/modinstall.js");
39
40
  const triggers_js_1 = require("./engine/triggers.js");
40
41
  const ids_js_1 = require("./utils/ids.js");
42
+ const shelf_js_1 = require("./engine/shelf.js");
43
+ const source_js_1 = require("./engine/source.js");
41
44
  /** A neuron this size with no summary is worth two lines from whoever is closing the session. */
42
45
  const SUMMARY_NUDGE_MIN_ENTRIES = 25;
43
46
  /** 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. */
@@ -87,6 +90,35 @@ const THREE_STAGES = 'A new truth that REPLACES an old one → crbro_learn with
87
90
  'Something stopped being true, or was never true, and nothing replaces it → crbro_revise ' +
88
91
  '(kept in the file, gone from recall, reversible with status active). Something must not exist ' +
89
92
  'on disk at all — a credential, personal data, a whole neuron → crbro_forget (quarantine copy first).';
93
+ /**
94
+ * What to do with a recall's possibly_stale block (shelf life), said once per
95
+ * answer at the START of `hint` (iteration 2, staleness.md §14): the order to
96
+ * check comes first, the follow-up writes after it. Each row carries its own
97
+ * next_step (where to look); this says what to do with the result.
98
+ */
99
+ const STALE_HINT = 'possibly_stale holds last-known values that may have changed: do not answer with one as current. Check it first ' +
100
+ '(its next_step says where), then: still true → crbro_revise neuron=<neuron_id> status=verified facts=[entry_id] ' +
101
+ '(entries=[entry_id] for a decision or pattern); changed → crbro_learn the new value with supersedes=[entry_id]. ' +
102
+ 'If you cannot check, say it may be out of date.';
103
+ /**
104
+ * The per-row framing of a possibly_stale row (iteration 2). The stored line
105
+ * comes back as `last_known`, not as `matching_content`, behind a warning and
106
+ * a concrete next step: open what the line itself names, when it names a
107
+ * file, path or URL; otherwise look where that kind of value lives, and if
108
+ * that is not possible, say the value may be out of date.
109
+ */
110
+ function staleFraming(text, s) {
111
+ const since = s.last_verified;
112
+ const warning = s.age_from
113
+ ? `last known value, not verified since ${since}: past its shelf life, may have changed`
114
+ : `last known value, unverified for ${s.age_days} days (since ${since}): may have changed`;
115
+ const named = (0, source_js_1.namedSources)(text);
116
+ const fallback = `If you cannot check, say this value is from ${since} and may be out of date; do not state it as current.`;
117
+ const next_step = named.length
118
+ ? `Before answering, open ${named.join(' / ')} (named in this entry) and answer with what it says now. ${fallback}`
119
+ : `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}`;
120
+ return { warning, next_step };
121
+ }
90
122
  /**
91
123
  * The version of CRBRO that is actually running. The manifest carries its own
92
124
  * version, but that one stamps the brain FORMAT and has not moved since 1.0.0
@@ -154,6 +186,7 @@ function createServer(shared) {
154
186
  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. ' +
155
187
  '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. ' +
156
188
  '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. ' +
189
+ '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. ' +
157
190
  '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. ' +
158
191
  '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. ' +
159
192
  'Save with crbro_learn as you go, and close with crbro_consolidate before the conversation ends.',
@@ -212,6 +245,12 @@ function createServer(shared) {
212
245
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
213
246
  }, async (args) => {
214
247
  try {
248
+ // The Claude Code mod (open items above the prompt): installed or
249
+ // refreshed on its own, once per process, on a short budget that
250
+ // never fails or holds up the boot. Started first so it runs beside
251
+ // the brain's own work; its notice is taken only once this boot has
252
+ // an answer to carry it, so a boot that fails does not lose it.
253
+ const modNotice = (0, modinstall_js_1.startModOnBoot)();
215
254
  const result = await brain.boot();
216
255
  // Initialize search engine
217
256
  await searchEngine.init();
@@ -333,6 +372,11 @@ function createServer(shared) {
333
372
  'Semantic recall is not installed on this machine: run once npx crbro-memory init (about 500 MB). ' +
334
373
  'Until then recall is keyword-only; keywords at save time still work.';
335
374
  }
375
+ // Said once, the boot after the mod was installed or updated: what
376
+ // changed on the user's machine and how to undo it.
377
+ const aviso = await modNotice();
378
+ if (aviso)
379
+ response.mod_notice = aviso;
336
380
  response.memory_discipline =
337
381
  'Before crbro_learn, crbro_recall: what you are about to save may already exist — then pass ' +
338
382
  'supersedes instead of adding a sibling (two versions of one fact compete on recall as equals). ' +
@@ -348,7 +392,10 @@ function createServer(shared) {
348
392
  'deliberate deferral with its ceiling and revisit trigger. Credentials never go in the brain: ' +
349
393
  'crbro_secret, then record only the NAME. Recall results carry confidence — "weak" means the match ' +
350
394
  'covers little of the question, verify before relying on it — and when two facts disagree, prefer ' +
351
- 'the more recent. Lifecycle: supersedes replaces, crbro_revise retires, crbro_forget removes what ' +
395
+ 'the more recent. possibly_stale in a recall = a last-known value that may have changed: check its ' +
396
+ 'source before answering with it, or say it may be out of date (then crbro_revise status=verified if it ' +
397
+ 'holds, crbro_learn supersedes if it changed). A value that can change (version, price, port, host, ' +
398
+ '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 ' +
352
399
  'must not exist on disk — each tool describes its own stage. ' +
353
400
  'Call crbro_consolidate before the conversation ends; it logs the session too.';
354
401
  // A mature brain outgrew the boot payload: on a 1,145-neuron brain it
@@ -358,7 +405,7 @@ function createServer(shared) {
358
405
  // start correctly are kept whole; what grows without bound is shortened
359
406
  // and says so, with the call that reads it in full.
360
407
  return jsonResult((0, budget_js_1.fitToBudget)(response, {
361
- keep: ['protocol_enforcement', 'memory_discipline', 'retired_tools', 'pending_guidance', 'semantic_hint'],
408
+ keep: ['protocol_enforcement', 'memory_discipline', 'retired_tools', 'pending_guidance', 'semantic_hint', 'mod_notice'],
362
409
  howToGetMore: 'Session summaries were shortened. Read one in full with crbro_inspect view=sessions.',
363
410
  }));
364
411
  }
@@ -460,6 +507,13 @@ function createServer(shared) {
460
507
  last_consolidation: manifest.last_consolidation,
461
508
  semantic: (0, semantic_js_1.semanticStatus)(),
462
509
  hot_topics_recalculated: hot?.last_recalculated ?? null,
510
+ // Shelf life: whether recall splits off possibly_stale, the windows
511
+ // in days, and the day this brain started counting (legacy grace).
512
+ staleness: {
513
+ enabled: (0, shelf_js_1.stalenessEnabled)(),
514
+ windows: (0, shelf_js_1.shelfWindows)(),
515
+ since: manifest.staleness_since ? dia(manifest.staleness_since) : null,
516
+ },
463
517
  });
464
518
  }
465
519
  if (args.view === 'neuron') {
@@ -491,21 +545,38 @@ function createServer(shared) {
491
545
  const offset = Math.max(args.offset ?? 0, 0);
492
546
  const connections = await synapses.getConnections(neuron.id, args.min_strength);
493
547
  const retired = (id) => neuron.entry_status?.[id]?.status;
548
+ // Shelf life: the same three facts recall shows, so the index of a
549
+ // neuron and a recall never disagree about what is old.
550
+ const vida = (0, shelf_js_1.stalenessContext)((await brain.getManifest()).staleness_since);
551
+ const rancio = (s) => (s?.stale ? { stale_days: s.age_days } : {});
494
552
  const rows = [];
495
553
  for (const f of neuron.facts || []) {
496
554
  rows.push({ id: f.id || (0, hash_js_1.factId)(f.text), kind: 'fact', text: f.text, added: f.added || '',
497
555
  status: f.status, confidence: f.confidence, keys: f.keys, revision_note: f.revision_note, revised: f.revised,
498
556
  // Only when it says something: 1 is every fact's default (2.7).
499
- ...((f.confirmations ?? 1) > 1 ? { confirmations: f.confirmations } : {}) });
557
+ ...((f.confirmations ?? 1) > 1 ? { confirmations: f.confirmations } : {}),
558
+ ...(f.verified ? { verified: f.verified } : {}),
559
+ ...(f.shelf_life ? { shelf_life: f.shelf_life } : {}),
560
+ ...(vida && neuron.type !== 'protocol' ? rancio((0, shelf_js_1.factStaleness)(f, vida)) : {}) });
500
561
  }
562
+ const entrada = (kind, text, id) => {
563
+ const v = neuron.entry_verified?.[id];
564
+ const retirada = !!neuron.entry_status?.[id];
565
+ return {
566
+ ...(v ? { verified: v } : {}),
567
+ ...(vida && !retirada && neuron.type !== 'protocol' ? rancio((0, shelf_js_1.entryStaleness)(neuron, kind, text, vida)) : {}),
568
+ };
569
+ };
501
570
  for (const d of neuron.decisions || []) {
502
571
  const id = d.id || (0, ops_js_1.entryId)(d.text);
503
- 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 });
572
+ 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,
573
+ ...entrada('decision', d.text, (0, ops_js_1.entryId)(d.text)) });
504
574
  }
505
575
  const sidecar = (kind, list) => {
506
576
  for (const t of list || []) {
507
577
  const id = (0, ops_js_1.entryId)(t);
508
- 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 });
578
+ 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,
579
+ ...entrada(kind, t, id) });
509
580
  }
510
581
  };
511
582
  sidecar('pattern', neuron.patterns);
@@ -585,6 +656,9 @@ function createServer(shared) {
585
656
  preview: r.text.length > PREVIEW ? `${r.text.slice(0, PREVIEW).trimEnd()}…` : r.text,
586
657
  chars: r.text.length,
587
658
  ...(r.confirmations ? { confirmations: r.confirmations } : {}),
659
+ ...(r.verified ? { verified: dia(r.verified) } : {}),
660
+ ...(r.shelf_life ? { shelf_life: r.shelf_life } : {}),
661
+ ...(r.stale_days !== undefined ? { stale_days: r.stale_days } : {}),
588
662
  ...(isRetired(r.status) ? { status: r.status, ...(r.revised ? { revised: dia(r.revised) } : {}), ...(r.revision_note ? { retired_note: r.revision_note } : {}) } : {}),
589
663
  })),
590
664
  entries_pagination: {
@@ -665,7 +739,7 @@ function createServer(shared) {
665
739
  // ═══════════════════════════════════════════════════════════════
666
740
  server.registerTool('crbro_learn', {
667
741
  title: 'Learn something',
668
- 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.',
742
+ 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).',
669
743
  inputSchema: {
670
744
  // Optional since 2.0.3, and the reason is measured: the description
671
745
  // told callers that neuron_id "skips name matching entirely", the
@@ -676,7 +750,7 @@ function createServer(shared) {
676
750
  // message that says what to pass.
677
751
  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.'),
678
752
  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."'),
679
- content: zod_1.z.string().describe('The knowledge itself. Dense and self-contained: it is recalled without this conversation as context.'),
753
+ 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.'),
680
754
  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).'),
681
755
  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).'),
682
756
  rationale: zod_1.z.string().optional().describe('Why the decision was taken. Stored and indexed with it; ignored for other types.'),
@@ -684,6 +758,7 @@ function createServer(shared) {
684
758
  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.'),
685
759
  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.'),
686
760
  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.'),
761
+ 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.'),
687
762
  },
688
763
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
689
764
  }, async (args) => {
@@ -711,6 +786,7 @@ function createServer(shared) {
711
786
  supersedes: args.supersedes,
712
787
  keys: args.keywords,
713
788
  keysReplace: args.keywords_replace,
789
+ shelfLife: args.type === 'fact' ? args.shelf_life : undefined,
714
790
  });
715
791
  // Indexing happens inside cortex.learn, through the indexer hook.
716
792
  if (result.action === 'skipped_retired' && result.skipped_retired) {
@@ -729,6 +805,13 @@ function createServer(shared) {
729
805
  if (!result.neuron) {
730
806
  return textResult(`No neuron matched "${topic}" and none was created.`);
731
807
  }
808
+ // Lengthening an explicit shelf life does not hold on a shared neuron:
809
+ // the team merge keeps the most volatile value, and the older op is
810
+ // still in the append-only log, so the next sync brings it back.
811
+ const sharedIn = result.shelf_lengthened ? (await (0, space_js_1.sharedMap)(brain))[result.neuron.id] : undefined;
812
+ const shelfSharedWarning = sharedIn
813
+ ? `"${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.`
814
+ : undefined;
732
815
  const keywordsMissing = args.type === 'fact' && result.action !== 'skipped' &&
733
816
  !(args.keywords && args.keywords.some(k => typeof k === 'string' && k.trim().length > 0));
734
817
  return jsonResult({
@@ -772,6 +855,15 @@ function createServer(shared) {
772
855
  'question may use that the text lacks (synonyms, the other language, the generic name of the product): ' +
773
856
  'they merge into this fact instead of adding a new one.'
774
857
  : undefined,
858
+ // Shelf life, facts only: the class that applies, and when it was not
859
+ // given, that it was inferred and by which rule, so a model that
860
+ // disagrees can say otherwise in the next call.
861
+ shelf_life: result.shelf?.shelf_life,
862
+ shelf_inferred: result.shelf?.inferred ? true : undefined,
863
+ shelf_reason: result.shelf?.inferred ? result.shelf.reason : undefined,
864
+ // The same line learned again, and nothing else: its shelf-life clock restarts now.
865
+ reconfirmed: result.reconfirmed || undefined,
866
+ shared_warning: shelfSharedWarning,
775
867
  total_facts: result.neuron.facts.length,
776
868
  total_decisions: result.neuron.decisions.length,
777
869
  total_patterns: result.neuron.patterns.length,
@@ -794,13 +886,13 @@ function createServer(shared) {
794
886
  // ═══════════════════════════════════════════════════════════════
795
887
  server.registerTool('crbro_recall', {
796
888
  title: 'Recall',
797
- 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.',
889
+ 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.',
798
890
  inputSchema: {
799
891
  query: zod_1.z.string().describe('What to look for, e.g. "Firebase authentication setup". Fewer, distinctive terms beat full sentences.'),
800
892
  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.'),
801
893
  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.'),
802
894
  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).'),
803
- 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.'),
895
+ 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.'),
804
896
  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.'),
805
897
  },
806
898
  outputSchema: {
@@ -814,11 +906,18 @@ function createServer(shared) {
814
906
  matched_terms: zod_1.z.number().optional(), query_terms: zod_1.z.number().optional(),
815
907
  confidence: zod_1.z.enum(['strong', 'weak']).optional(),
816
908
  entry_id: zod_1.z.string().optional(),
909
+ rank: zod_1.z.number().optional().describe('Position in the ranking, set when some row moved to possibly_stale'),
817
910
  origin: zod_1.z.string().optional(), by: zod_1.z.string().optional(),
818
911
  content_truncated: zod_1.z.boolean().optional(), content_chars: zod_1.z.number().optional(),
819
- 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(),
912
+ 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(),
820
913
  }).loose()),
821
- returned: zod_1.z.number().optional(),
914
+ stale_warning: zod_1.z.string().optional().describe('Set, first, when some row moved to possibly_stale: do not answer with those as current'),
915
+ possibly_stale: zod_1.z.array(zod_1.z.object({
916
+ warning: zod_1.z.string(), next_step: zod_1.z.string(), last_known: zod_1.z.string(), neuron_id: zod_1.z.string(),
917
+ age_days: zod_1.z.number(), last_verified: zod_1.z.string(), shelf_life: zod_1.z.string(), shelf_inferred: zod_1.z.boolean(),
918
+ }).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'),
919
+ possibly_stale_count: zod_1.z.number().optional(),
920
+ returned: zod_1.z.number().optional().describe('Rows that came back: results plus possibly_stale'),
822
921
  matched_neurons: zod_1.z.number().optional().describe('Neurons with any hit before limit; total_results is what came back'),
823
922
  has_more: zod_1.z.boolean().optional(),
824
923
  sessions_matched: zod_1.z.array(zod_1.z.object({}).loose()).optional(),
@@ -862,36 +961,94 @@ function createServer(shared) {
862
961
  ...(r.also_matched ? { also_matched: r.also_matched.map(a => ({ ...a, added: dia(a.added) })) } : {}),
863
962
  };
864
963
  });
865
- const sobran = matched_neurons - results.length;
964
+ // Shelf life: partition, not penalty. The ranking above already chose
965
+ // these rows exactly as before; a row whose WINNING entry is past its
966
+ // shelf life since last verified moves whole to possibly_stale, in
967
+ // rank order. No backfill (the cost stays bounded by limit, and "what
968
+ // does memory say about X" never silently becomes a weaker line about
969
+ // something else), and never re-headed with an also_matched line.
970
+ // The internal `staleness` field leaves every row here.
971
+ const actuales = [];
972
+ const rancios = [];
973
+ // When anything moves, every row keeps its rank: results[0] is then
974
+ // not necessarily the best match, and the agent has to be able to see it.
975
+ const algunoRancio = rows.some(r => r.staleness?.stale);
976
+ let mejorMovido = false;
977
+ for (const [i, row] of rows.entries()) {
978
+ const { staleness, ...resto } = row;
979
+ if (algunoRancio)
980
+ resto.rank = i + 1;
981
+ if (staleness?.stale) {
982
+ if (i === 0)
983
+ mejorMovido = true;
984
+ // Iteration 2: the old line is not served as ordinary content. Its
985
+ // warning and next step come first, then the line as last_known.
986
+ const { matching_content, neuron_id, name, entry_id, rank, ...demas } = resto;
987
+ rancios.push({
988
+ ...staleFraming(matching_content, staleness),
989
+ last_known: matching_content,
990
+ neuron_id, name,
991
+ ...(entry_id !== undefined ? { entry_id } : {}),
992
+ ...(rank !== undefined ? { rank } : {}),
993
+ ...demas,
994
+ age_days: staleness.age_days,
995
+ last_verified: staleness.last_verified,
996
+ shelf_life: staleness.shelf_life,
997
+ shelf_inferred: staleness.shelf_inferred,
998
+ ...(staleness.age_from ? { age_counted_from: staleness.age_from } : {}),
999
+ });
1000
+ }
1001
+ else {
1002
+ actuales.push(resto);
1003
+ }
1004
+ }
1005
+ const servidos = actuales.length + rancios.length;
1006
+ const sobran = matched_neurons - servidos;
1007
+ // Iteration 2: when anything moved, the answer OPENS with a short
1008
+ // warning, before query and results, so it is read before the rows.
1009
+ const menorEdad = rancios.length ? Math.min(...rancios.map(r => r.age_days)) : 0;
1010
+ const uno = rancios.length === 1;
1011
+ const avisoRancio = rancios.length
1012
+ ? (uno
1013
+ ? `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)`
1014
+ : `${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)`) +
1015
+ '; if you cannot, say it may be out of date, even in a short answer.'
1016
+ : '';
866
1017
  const payload = {
1018
+ ...(avisoRancio ? { stale_warning: avisoRancio } : {}),
867
1019
  query: args.query,
868
- // total_results is what came back (capped by limit); matched_neurons is
869
- // how many neurons had a hit at all, so five never reads as "only five".
870
- total_results: results.length,
871
- returned: results.length,
1020
+ // total_results is what came back as current (capped by limit);
1021
+ // returned adds possibly_stale; matched_neurons is how many neurons
1022
+ // had a hit at all, so five never reads as "only five".
1023
+ total_results: actuales.length,
1024
+ returned: servidos,
872
1025
  matched_neurons,
873
1026
  has_more: sobran > 0,
874
1027
  // The filter as it was understood ("30d" becomes a day), so a narrowed
875
1028
  // answer never reads as the whole brain.
876
1029
  ...(filtrado ? { filters: { ...(since ? { since } : {}), ...(kinds ? { kind: kinds } : {}) } } : {}),
877
1030
  ...(since ? { undated_skipped: sinFecha } : {}),
878
- results: rows,
1031
+ results: actuales,
1032
+ ...(rancios.length ? { possibly_stale: rancios, possibly_stale_count: rancios.length } : {}),
879
1033
  // The diary, searched since 2.2: day logs whose summary mentions the
880
1034
  // question, in a list of their own so narrative never outranks a fact.
881
1035
  // sessions_total: how many days had a hit; three shown never reads as three.
882
1036
  ...(sessions.length ? { sessions_matched: sessions.map(s => ({ ...s, date: dia(s.date) })), sessions_total } : {}),
883
- hint: (results.length === 0
884
- ? (sessions.length
885
- // A day mentions it and no fact does: point at the day, not at rephrasing.
886
- ? `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.`
887
- : (filtrado
888
- ? 'Nothing matched inside the filter. Drop since/kind and ask again before concluding it is not stored.'
889
- : 'Nothing matched. Try fewer, more distinctive words - names, ids, filenames - rather than a full sentence.'))
890
- : 'weak: verify. Newer wins on conflict. entry_id → crbro_inspect view=neuron entries=[id]. has_map → crbro_map first.'
891
- + (sobran > 0 ? ` ${sobran} more neuron${sobran === 1 ? '' : 's'} matched: raise limit or narrow the query.` : '')
892
- + (cortados > 0 ? ' content_truncated → read the entry by entry_id.' : '')
893
- + (sessions.length ? ' sessions_matched: day logs that mention it; read one whole with crbro_inspect view=sessions session=<session_id>.' : ''))
894
- + (filtrado && results.length > 0 ? ' Filtered: entries outside since/kind were not searched.' : '')
1037
+ hint: (rancios.length && !actuales.length ? 'Nothing current matched; the rows that did are in possibly_stale. '
1038
+ : mejorMovido ? 'The best match (rank 1) moved to possibly_stale; results holds lower-ranked rows that may be about something else. ' : '') +
1039
+ (rancios.length ? `${STALE_HINT} ` : '') +
1040
+ (servidos === 0
1041
+ ? (sessions.length
1042
+ // A day mentions it and no fact does: point at the day, not at rephrasing.
1043
+ ? `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.`
1044
+ : (filtrado
1045
+ ? 'Nothing matched inside the filter. Drop since/kind and ask again before concluding it is not stored.'
1046
+ : 'Nothing matched. Try fewer, more distinctive words - names, ids, filenames - rather than a full sentence.'))
1047
+ : 'weak: verify. Newer wins on conflict. entry_id → crbro_inspect view=neuron entries=[id]. has_map → crbro_map first.'
1048
+ + (sobran > 0 ? ` ${sobran} more neuron${sobran === 1 ? '' : 's'} matched: raise limit or narrow the query.` : '')
1049
+ + (cortados > 0 ? ' content_truncated → read the entry by entry_id.' : '')
1050
+ + (sessions.length ? ' sessions_matched: day logs that mention it; read one whole with crbro_inspect view=sessions session=<session_id>.' : ''))
1051
+ + (filtrado && servidos > 0 ? ' Filtered: entries outside since/kind were not searched.' : '')
895
1052
  + (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).` : '')
896
1053
  + (sessions_total > sessions.length ? ` ${sessions_total - sessions.length} more day${sessions_total - sessions.length === 1 ? '' : 's'} mention it: narrow the query.` : ''),
897
1054
  };
@@ -916,9 +1073,9 @@ function createServer(shared) {
916
1073
  const reviseSchema = zod_1.z.object({
917
1074
  neuron: zod_1.z.string().describe('Neuron id or name holding what to revise, e.g. "project_octochat".'),
918
1075
  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.'),
919
- 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.'),
920
- 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).'),
921
- 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.'),
1076
+ 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.'),
1077
+ 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.'),
1078
+ 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.'),
922
1079
  summary: zod_1.z.string().optional().describe('Replace the neuron summary. Credentials are redacted and listed in redacted.'),
923
1080
  domain: zod_1.z.string().optional().describe('Replace the neuron domain unconditionally, e.g. "proyectos-web".'),
924
1081
  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.'),
@@ -938,7 +1095,7 @@ function createServer(shared) {
938
1095
  });
939
1096
  server.registerTool('crbro_revise', {
940
1097
  title: 'Revise a neuron',
941
- 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.',
1098
+ 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.',
942
1099
  inputSchema: reviseSchema,
943
1100
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
944
1101
  }, async (args) => {
@@ -977,6 +1134,42 @@ function createServer(shared) {
977
1134
  : 'Nothing matched, nothing moved. Pass entry ids from crbro_inspect view=neuron, or the exact text.',
978
1135
  });
979
1136
  }
1137
+ // ── status verified: reconfirm, do not retire (shelf life) ──
1138
+ if (status === 'verified') {
1139
+ const v = (args.facts?.length || args.entries?.length)
1140
+ ? await cortex.verify(target.id, { facts: args.facts, entries: args.entries })
1141
+ : { verified: [], unmatched: [], retired: [] };
1142
+ let cambiados = [];
1143
+ let tachados = [];
1144
+ const metaV = { summary: args.summary, domain: args.domain, tags: args.tags, name: args.name };
1145
+ if (Object.values(metaV).some(x => x !== undefined)) {
1146
+ const r = await cortex.setMeta(target.id, metaV);
1147
+ cambiados = r.changed;
1148
+ tachados = r.redacted;
1149
+ }
1150
+ const n = v.verified.length;
1151
+ const partes = [];
1152
+ if (n > 0)
1153
+ partes.push(`${n} entr${n === 1 ? 'y' : 'ies'} verified: last checked now, shelf-life clock restarted`);
1154
+ if (cambiados.length > 0)
1155
+ partes.push(`${cambiados.join(', ')} updated`);
1156
+ return jsonResult({
1157
+ neuron_id: target.id,
1158
+ status,
1159
+ verified: v.verified,
1160
+ unmatched: v.unmatched.length > 0 ? v.unmatched : undefined,
1161
+ retired_targets: v.retired.length > 0 ? v.retired : undefined,
1162
+ changed: cambiados,
1163
+ redacted: tachados.length > 0 ? tachados : undefined,
1164
+ message: partes.length > 0
1165
+ ? `${partes.join('; ')} in "${target.name}".` +
1166
+ (v.unmatched.length > 0 ? ` WARNING: ${v.unmatched.length} target(s) matched no active fact or live decision/pattern and were not verified.` : '') +
1167
+ (v.retired.length > 0 ? ' A retired line is not verifiable: if it holds again, reactivate it with status active first.' : '')
1168
+ : (v.retired.length > 0
1169
+ ? 'Nothing verified: the targets are retired. If one holds again, reactivate it with status active first, then verify.'
1170
+ : '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.'),
1171
+ });
1172
+ }
980
1173
  let revisedFacts = 0;
981
1174
  let revisedEntries = 0;
982
1175
  const unmatched = [];