crbro-memory 2.8.0 → 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 (43) hide show
  1. package/README.md +15 -4
  2. package/dist/daemon/endpoint.d.ts.map +1 -1
  3. package/dist/daemon/endpoint.js +11 -0
  4. package/dist/daemon/endpoint.js.map +1 -1
  5. package/dist/engine/brain.d.ts.map +1 -1
  6. package/dist/engine/brain.js +30 -0
  7. package/dist/engine/brain.js.map +1 -1
  8. package/dist/engine/cortex.d.ts +55 -1
  9. package/dist/engine/cortex.d.ts.map +1 -1
  10. package/dist/engine/cortex.js +183 -3
  11. package/dist/engine/cortex.js.map +1 -1
  12. package/dist/engine/maintenance.d.ts +22 -0
  13. package/dist/engine/maintenance.d.ts.map +1 -1
  14. package/dist/engine/maintenance.js +59 -2
  15. package/dist/engine/maintenance.js.map +1 -1
  16. package/dist/engine/shelf.d.ts +97 -0
  17. package/dist/engine/shelf.d.ts.map +1 -0
  18. package/dist/engine/shelf.js +343 -0
  19. package/dist/engine/shelf.js.map +1 -0
  20. package/dist/engine/source.d.ts +6 -0
  21. package/dist/engine/source.d.ts.map +1 -0
  22. package/dist/engine/source.js +101 -0
  23. package/dist/engine/source.js.map +1 -0
  24. package/dist/search/index.d.ts +6 -0
  25. package/dist/search/index.d.ts.map +1 -1
  26. package/dist/search/index.js +46 -1
  27. package/dist/search/index.js.map +1 -1
  28. package/dist/server.d.ts.map +1 -1
  29. package/dist/server.js +213 -32
  30. package/dist/server.js.map +1 -1
  31. package/dist/sync/materialize.d.ts +4 -0
  32. package/dist/sync/materialize.d.ts.map +1 -1
  33. package/dist/sync/materialize.js +80 -1
  34. package/dist/sync/materialize.js.map +1 -1
  35. package/dist/sync/ops.d.ts +29 -2
  36. package/dist/sync/ops.d.ts.map +1 -1
  37. package/dist/sync/ops.js.map +1 -1
  38. package/dist/sync/space.d.ts.map +1 -1
  39. package/dist/sync/space.js +15 -2
  40. package/dist/sync/space.js.map +1 -1
  41. package/dist/types/index.d.ts +47 -0
  42. package/dist/types/index.d.ts.map +1 -1
  43. package/package.json +1 -1
package/dist/server.js CHANGED
@@ -39,6 +39,8 @@ const backup_js_1 = require("./engine/backup.js");
39
39
  const modinstall_js_1 = require("./engine/modinstall.js");
40
40
  const triggers_js_1 = require("./engine/triggers.js");
41
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");
42
44
  /** A neuron this size with no summary is worth two lines from whoever is closing the session. */
43
45
  const SUMMARY_NUDGE_MIN_ENTRIES = 25;
44
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. */
@@ -88,6 +90,35 @@ const THREE_STAGES = 'A new truth that REPLACES an old one → crbro_learn with
88
90
  'Something stopped being true, or was never true, and nothing replaces it → crbro_revise ' +
89
91
  '(kept in the file, gone from recall, reversible with status active). Something must not exist ' +
90
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
+ }
91
122
  /**
92
123
  * The version of CRBRO that is actually running. The manifest carries its own
93
124
  * version, but that one stamps the brain FORMAT and has not moved since 1.0.0
@@ -155,6 +186,7 @@ function createServer(shared) {
155
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. ' +
156
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. ' +
157
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. ' +
158
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. ' +
159
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. ' +
160
192
  'Save with crbro_learn as you go, and close with crbro_consolidate before the conversation ends.',
@@ -360,7 +392,10 @@ function createServer(shared) {
360
392
  'deliberate deferral with its ceiling and revisit trigger. Credentials never go in the brain: ' +
361
393
  'crbro_secret, then record only the NAME. Recall results carry confidence — "weak" means the match ' +
362
394
  '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 ' +
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 ' +
364
399
  'must not exist on disk — each tool describes its own stage. ' +
365
400
  'Call crbro_consolidate before the conversation ends; it logs the session too.';
366
401
  // A mature brain outgrew the boot payload: on a 1,145-neuron brain it
@@ -472,6 +507,13 @@ function createServer(shared) {
472
507
  last_consolidation: manifest.last_consolidation,
473
508
  semantic: (0, semantic_js_1.semanticStatus)(),
474
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
+ },
475
517
  });
476
518
  }
477
519
  if (args.view === 'neuron') {
@@ -503,21 +545,38 @@ function createServer(shared) {
503
545
  const offset = Math.max(args.offset ?? 0, 0);
504
546
  const connections = await synapses.getConnections(neuron.id, args.min_strength);
505
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 } : {});
506
552
  const rows = [];
507
553
  for (const f of neuron.facts || []) {
508
554
  rows.push({ id: f.id || (0, hash_js_1.factId)(f.text), kind: 'fact', text: f.text, added: f.added || '',
509
555
  status: f.status, confidence: f.confidence, keys: f.keys, revision_note: f.revision_note, revised: f.revised,
510
556
  // Only when it says something: 1 is every fact's default (2.7).
511
- ...((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)) : {}) });
512
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
+ };
513
570
  for (const d of neuron.decisions || []) {
514
571
  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 });
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)) });
516
574
  }
517
575
  const sidecar = (kind, list) => {
518
576
  for (const t of list || []) {
519
577
  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 });
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) });
521
580
  }
522
581
  };
523
582
  sidecar('pattern', neuron.patterns);
@@ -597,6 +656,9 @@ function createServer(shared) {
597
656
  preview: r.text.length > PREVIEW ? `${r.text.slice(0, PREVIEW).trimEnd()}…` : r.text,
598
657
  chars: r.text.length,
599
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 } : {}),
600
662
  ...(isRetired(r.status) ? { status: r.status, ...(r.revised ? { revised: dia(r.revised) } : {}), ...(r.revision_note ? { retired_note: r.revision_note } : {}) } : {}),
601
663
  })),
602
664
  entries_pagination: {
@@ -677,7 +739,7 @@ function createServer(shared) {
677
739
  // ═══════════════════════════════════════════════════════════════
678
740
  server.registerTool('crbro_learn', {
679
741
  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.',
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).',
681
743
  inputSchema: {
682
744
  // Optional since 2.0.3, and the reason is measured: the description
683
745
  // told callers that neuron_id "skips name matching entirely", the
@@ -688,7 +750,7 @@ function createServer(shared) {
688
750
  // message that says what to pass.
689
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.'),
690
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."'),
691
- 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.'),
692
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).'),
693
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).'),
694
756
  rationale: zod_1.z.string().optional().describe('Why the decision was taken. Stored and indexed with it; ignored for other types.'),
@@ -696,6 +758,7 @@ function createServer(shared) {
696
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.'),
697
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.'),
698
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.'),
699
762
  },
700
763
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
701
764
  }, async (args) => {
@@ -723,6 +786,7 @@ function createServer(shared) {
723
786
  supersedes: args.supersedes,
724
787
  keys: args.keywords,
725
788
  keysReplace: args.keywords_replace,
789
+ shelfLife: args.type === 'fact' ? args.shelf_life : undefined,
726
790
  });
727
791
  // Indexing happens inside cortex.learn, through the indexer hook.
728
792
  if (result.action === 'skipped_retired' && result.skipped_retired) {
@@ -741,6 +805,13 @@ function createServer(shared) {
741
805
  if (!result.neuron) {
742
806
  return textResult(`No neuron matched "${topic}" and none was created.`);
743
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;
744
815
  const keywordsMissing = args.type === 'fact' && result.action !== 'skipped' &&
745
816
  !(args.keywords && args.keywords.some(k => typeof k === 'string' && k.trim().length > 0));
746
817
  return jsonResult({
@@ -784,6 +855,15 @@ function createServer(shared) {
784
855
  'question may use that the text lacks (synonyms, the other language, the generic name of the product): ' +
785
856
  'they merge into this fact instead of adding a new one.'
786
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,
787
867
  total_facts: result.neuron.facts.length,
788
868
  total_decisions: result.neuron.decisions.length,
789
869
  total_patterns: result.neuron.patterns.length,
@@ -806,13 +886,13 @@ function createServer(shared) {
806
886
  // ═══════════════════════════════════════════════════════════════
807
887
  server.registerTool('crbro_recall', {
808
888
  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.',
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.',
810
890
  inputSchema: {
811
891
  query: zod_1.z.string().describe('What to look for, e.g. "Firebase authentication setup". Fewer, distinctive terms beat full sentences.'),
812
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.'),
813
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.'),
814
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).'),
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.'),
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.'),
816
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.'),
817
897
  },
818
898
  outputSchema: {
@@ -826,11 +906,18 @@ function createServer(shared) {
826
906
  matched_terms: zod_1.z.number().optional(), query_terms: zod_1.z.number().optional(),
827
907
  confidence: zod_1.z.enum(['strong', 'weak']).optional(),
828
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'),
829
910
  origin: zod_1.z.string().optional(), by: zod_1.z.string().optional(),
830
911
  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(),
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(),
832
913
  }).loose()),
833
- 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'),
834
921
  matched_neurons: zod_1.z.number().optional().describe('Neurons with any hit before limit; total_results is what came back'),
835
922
  has_more: zod_1.z.boolean().optional(),
836
923
  sessions_matched: zod_1.z.array(zod_1.z.object({}).loose()).optional(),
@@ -874,36 +961,94 @@ function createServer(shared) {
874
961
  ...(r.also_matched ? { also_matched: r.also_matched.map(a => ({ ...a, added: dia(a.added) })) } : {}),
875
962
  };
876
963
  });
877
- 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
+ : '';
878
1017
  const payload = {
1018
+ ...(avisoRancio ? { stale_warning: avisoRancio } : {}),
879
1019
  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,
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,
884
1025
  matched_neurons,
885
1026
  has_more: sobran > 0,
886
1027
  // The filter as it was understood ("30d" becomes a day), so a narrowed
887
1028
  // answer never reads as the whole brain.
888
1029
  ...(filtrado ? { filters: { ...(since ? { since } : {}), ...(kinds ? { kind: kinds } : {}) } } : {}),
889
1030
  ...(since ? { undated_skipped: sinFecha } : {}),
890
- results: rows,
1031
+ results: actuales,
1032
+ ...(rancios.length ? { possibly_stale: rancios, possibly_stale_count: rancios.length } : {}),
891
1033
  // The diary, searched since 2.2: day logs whose summary mentions the
892
1034
  // question, in a list of their own so narrative never outranks a fact.
893
1035
  // sessions_total: how many days had a hit; three shown never reads as three.
894
1036
  ...(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.' : '')
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.' : '')
907
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).` : '')
908
1053
  + (sessions_total > sessions.length ? ` ${sessions_total - sessions.length} more day${sessions_total - sessions.length === 1 ? '' : 's'} mention it: narrow the query.` : ''),
909
1054
  };
@@ -928,9 +1073,9 @@ function createServer(shared) {
928
1073
  const reviseSchema = zod_1.z.object({
929
1074
  neuron: zod_1.z.string().describe('Neuron id or name holding what to revise, e.g. "project_octochat".'),
930
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.'),
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.'),
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.'),
934
1079
  summary: zod_1.z.string().optional().describe('Replace the neuron summary. Credentials are redacted and listed in redacted.'),
935
1080
  domain: zod_1.z.string().optional().describe('Replace the neuron domain unconditionally, e.g. "proyectos-web".'),
936
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.'),
@@ -950,7 +1095,7 @@ function createServer(shared) {
950
1095
  });
951
1096
  server.registerTool('crbro_revise', {
952
1097
  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.',
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.',
954
1099
  inputSchema: reviseSchema,
955
1100
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
956
1101
  }, async (args) => {
@@ -989,6 +1134,42 @@ function createServer(shared) {
989
1134
  : 'Nothing matched, nothing moved. Pass entry ids from crbro_inspect view=neuron, or the exact text.',
990
1135
  });
991
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
+ }
992
1173
  let revisedFacts = 0;
993
1174
  let revisedEntries = 0;
994
1175
  const unmatched = [];