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.
- package/README.md +16 -5
- package/dist/daemon/endpoint.d.ts +1 -0
- package/dist/daemon/endpoint.d.ts.map +1 -1
- package/dist/daemon/endpoint.js +15 -13
- package/dist/daemon/endpoint.js.map +1 -1
- package/dist/engine/brain.d.ts.map +1 -1
- package/dist/engine/brain.js +30 -0
- package/dist/engine/brain.js.map +1 -1
- package/dist/engine/cortex.d.ts +55 -1
- package/dist/engine/cortex.d.ts.map +1 -1
- package/dist/engine/cortex.js +203 -12
- package/dist/engine/cortex.js.map +1 -1
- package/dist/engine/maintenance.d.ts +22 -0
- package/dist/engine/maintenance.d.ts.map +1 -1
- package/dist/engine/maintenance.js +59 -2
- package/dist/engine/maintenance.js.map +1 -1
- package/dist/engine/secrets.d.ts.map +1 -1
- package/dist/engine/secrets.js +12 -1
- package/dist/engine/secrets.js.map +1 -1
- package/dist/engine/shelf.d.ts +156 -0
- package/dist/engine/shelf.d.ts.map +1 -0
- package/dist/engine/shelf.js +680 -0
- package/dist/engine/shelf.js.map +1 -0
- package/dist/engine/source.d.ts +6 -0
- package/dist/engine/source.d.ts.map +1 -0
- package/dist/engine/source.js +101 -0
- package/dist/engine/source.js.map +1 -0
- package/dist/search/index.d.ts +6 -0
- package/dist/search/index.d.ts.map +1 -1
- package/dist/search/index.js +46 -1
- package/dist/search/index.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +221 -47
- package/dist/server.js.map +1 -1
- package/dist/sync/materialize.d.ts +4 -0
- package/dist/sync/materialize.d.ts.map +1 -1
- package/dist/sync/materialize.js +80 -1
- package/dist/sync/materialize.js.map +1 -1
- package/dist/sync/ops.d.ts +29 -2
- package/dist/sync/ops.d.ts.map +1 -1
- package/dist/sync/ops.js.map +1 -1
- package/dist/sync/space.d.ts.map +1 -1
- package/dist/sync/space.js +15 -2
- package/dist/sync/space.js.map +1 -1
- package/dist/types/index.d.ts +49 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/version.d.ts +26 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +65 -0
- package/dist/version.js.map +1 -0
- package/hooks/crbro-lifecycle.mjs +612 -608
- package/package.json +1 -1
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ─── CRBRO Shelf life ────────────────────────────────────────────
|
|
3
|
+
//
|
|
4
|
+
// How fast a stored value goes stale, and whether it already has. Pure: no
|
|
5
|
+
// disk, no clock of its own (the caller passes `nowMs`), so every rule here is
|
|
6
|
+
// unit-tested with fixed dates. Design: docs/design/staleness.md.
|
|
7
|
+
//
|
|
8
|
+
// Four classes, chosen to be easy for a model to pick from the text alone:
|
|
9
|
+
// volatile — versions, prices, ports and hosts, paths and URLs, config
|
|
10
|
+
// values, who holds a role (default window: 90 days)
|
|
11
|
+
// normal — any other fact (365 days)
|
|
12
|
+
// durable — decisions, procedures (patterns) (730 days)
|
|
13
|
+
// permanent — history: preferences, errors, debts, a fact marked so, and
|
|
14
|
+
// (2.9.1) a dated record of something done or a line the
|
|
15
|
+
// miner imported (never)
|
|
16
|
+
//
|
|
17
|
+
// The windows are a policy choice, not a measurement: nothing in this
|
|
18
|
+
// repository measures how long a port or a price stays true. They are set so
|
|
19
|
+
// that volatile warns within a quarter and normal within a year, and can be
|
|
20
|
+
// changed per machine with CRBRO_SHELF_DAYS="volatile=90,normal=365,durable=730".
|
|
21
|
+
// CRBRO_STALENESS=0 switches the whole feature off.
|
|
22
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
exports.FUTURE_SLACK_MS = exports.DEFAULT_SHELF_DAYS = exports.SHELF_LIVES = void 0;
|
|
24
|
+
exports.stalenessEnabled = stalenessEnabled;
|
|
25
|
+
exports.shelfWindows = shelfWindows;
|
|
26
|
+
exports.isShelfLife = isShelfLife;
|
|
27
|
+
exports.mostVolatile = mostVolatile;
|
|
28
|
+
exports.isDatedRecord = isDatedRecord;
|
|
29
|
+
exports.recordVerdict = recordVerdict;
|
|
30
|
+
exports.detectShelf = detectShelf;
|
|
31
|
+
exports.shelfOfFact = shelfOfFact;
|
|
32
|
+
exports.shelfOfKind = shelfOfKind;
|
|
33
|
+
exports.isLegacyBrain = isLegacyBrain;
|
|
34
|
+
exports.stalenessContext = stalenessContext;
|
|
35
|
+
exports.stalenessContextOf = stalenessContextOf;
|
|
36
|
+
exports.plausibleCheck = plausibleCheck;
|
|
37
|
+
exports.spreadOf = spreadOf;
|
|
38
|
+
exports.factStaleness = factStaleness;
|
|
39
|
+
exports.entryStaleness = entryStaleness;
|
|
40
|
+
exports.latestOf = latestOf;
|
|
41
|
+
const ops_js_1 = require("../sync/ops.js");
|
|
42
|
+
exports.SHELF_LIVES = ['volatile', 'normal', 'durable', 'permanent'];
|
|
43
|
+
exports.DEFAULT_SHELF_DAYS = { volatile: 90, normal: 365, durable: 730 };
|
|
44
|
+
const DAY_MS = 86_400_000;
|
|
45
|
+
/** Off only when someone said so: CRBRO_STALENESS=0 (or false/off/no). */
|
|
46
|
+
function stalenessEnabled(env = process.env) {
|
|
47
|
+
const v = String(env.CRBRO_STALENESS ?? '').trim().toLowerCase();
|
|
48
|
+
return !(v === '0' || v === 'false' || v === 'off' || v === 'no');
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The windows in force: the defaults, with whatever CRBRO_SHELF_DAYS overrides.
|
|
52
|
+
* A malformed or non-positive value is ignored for that class, never an error:
|
|
53
|
+
* a typo in an environment variable must not switch the warnings off.
|
|
54
|
+
*/
|
|
55
|
+
function shelfWindows(env = process.env) {
|
|
56
|
+
const out = { ...exports.DEFAULT_SHELF_DAYS };
|
|
57
|
+
const raw = String(env.CRBRO_SHELF_DAYS ?? '').trim();
|
|
58
|
+
if (!raw)
|
|
59
|
+
return out;
|
|
60
|
+
for (const part of raw.split(/[,;]/)) {
|
|
61
|
+
const m = /^\s*(volatile|normal|durable)\s*[=:]\s*(\d{1,5})\s*$/i.exec(part);
|
|
62
|
+
if (!m)
|
|
63
|
+
continue;
|
|
64
|
+
const days = Number(m[2]);
|
|
65
|
+
if (Number.isInteger(days) && days > 0)
|
|
66
|
+
out[m[1].toLowerCase()] = days;
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
/** volatile < normal < durable < permanent. */
|
|
71
|
+
const ORDER = { volatile: 0, normal: 1, durable: 2, permanent: 3 };
|
|
72
|
+
function isShelfLife(v) {
|
|
73
|
+
return typeof v === 'string' && exports.SHELF_LIVES.includes(v);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The more volatile of two explicit values (absent loses to any value). The
|
|
77
|
+
* merge rule for team spaces: a needless warning costs one check, a missing
|
|
78
|
+
* one costs a wrong answer.
|
|
79
|
+
*/
|
|
80
|
+
function mostVolatile(a, b) {
|
|
81
|
+
const va = isShelfLife(a) ? a : undefined;
|
|
82
|
+
const vb = isShelfLife(b) ? b : undefined;
|
|
83
|
+
if (!va)
|
|
84
|
+
return vb;
|
|
85
|
+
if (!vb)
|
|
86
|
+
return va;
|
|
87
|
+
return ORDER[va] <= ORDER[vb] ? va : vb;
|
|
88
|
+
}
|
|
89
|
+
/** Lower-cased, accents folded: "versión" and "VERSION" read alike. Length-preserving for Latin text. */
|
|
90
|
+
function fold(text) {
|
|
91
|
+
return text.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
|
|
92
|
+
}
|
|
93
|
+
/** Products whose name followed by a number is a version: "PostgreSQL 14", "Node 22", "Python 3.12". */
|
|
94
|
+
const VERSIONED = [
|
|
95
|
+
'node', 'nodejs', 'node.js', 'postgres', 'postgresql', 'mysql', 'mariadb', 'mongodb', 'mongo', 'redis', 'sqlite',
|
|
96
|
+
'python', 'php', 'java', 'jdk', 'ruby', 'golang', 'rust', 'typescript', 'ecmascript',
|
|
97
|
+
'react', 'vue', 'angular', 'next', 'next.js', 'nextjs', 'nuxt', 'svelte', 'django', 'laravel', 'rails', 'symfony',
|
|
98
|
+
'spring', 'kotlin', 'swift', 'dart', 'flutter', 'dotnet', '.net', 'deno', 'bun', 'vite', 'webpack', 'electron',
|
|
99
|
+
'ubuntu', 'debian', 'alpine', 'centos', 'fedora', 'rhel', 'windows', 'macos', 'ios', 'android',
|
|
100
|
+
'docker', 'kubernetes', 'k8s', 'nginx', 'apache', 'elasticsearch', 'wordpress', 'woocommerce', 'drupal',
|
|
101
|
+
'gradle', 'maven', 'npm', 'yarn', 'pnpm', 'terraform', 'ansible', 'openssl', 'tls', 'tailwind', 'bootstrap',
|
|
102
|
+
'gpt', 'claude', 'gemini', 'llama', 'mistral', 'opus', 'sonnet', 'haiku',
|
|
103
|
+
];
|
|
104
|
+
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
105
|
+
const VERSIONED_RE = new RegExp(`(?:^|[^a-z0-9.])(?:${VERSIONED.map(escape).join('|')})[\\s-]?v?\\d+(?:\\.\\d+)*(?![\\d.]*\\d)`, 'i');
|
|
106
|
+
const RULES = [
|
|
107
|
+
{
|
|
108
|
+
reason: 'version',
|
|
109
|
+
test: (_raw, t) => /\bv\d+(?:\.\d+)+\b/.test(t) // v2.3
|
|
110
|
+
|| /\b(?:version|release)\s*:?\s*v?\d+(?:\.\d+)*/.test(t) // version 3.6, versión 5
|
|
111
|
+
|| /(?:^|[^\d.])\d{1,3}\.\d{1,3}\.\d{1,3}(?:-[0-9a-z.]+)?(?![\d.]*\d)/.test(t) // 2.8.0, 1.4.2-beta (not a dd.mm.yyyy day)
|
|
112
|
+
|| VERSIONED_RE.test(t), // PostgreSQL 14, Node 22
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
reason: 'price',
|
|
116
|
+
test: (_raw, t) => /[€$£¥]\s?\d|\d(?:[.,]\d+)?\s?[€$£¥]/.test(t) // $49, 29 €
|
|
117
|
+
|| /\b\d+(?:[.,]\d+)?\s*(?:euros?|eur|usd|dollars?|dolares|libras|pounds?|gbp|mxn|pesos)\b/.test(t)
|
|
118
|
+
|| /\d[^.\n]{0,40}(?:\bal mes\b|\bpor mes\b|\/mes\b|\bper month\b|\ba month\b|\/month\b|\/mo\b|\bal ano\b|\bpor ano\b|\bper year\b|\/year\b|\/yr\b|\bmensuales?\b|\banuales?\b)/.test(t)
|
|
119
|
+
|| /\b(?:price|pricing|precio|precios|tarifa|tarifas|cuesta|cuestan|cost|costs|fee|cuota)\b[^.\n]{0,30}\d/.test(t),
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
reason: 'port',
|
|
123
|
+
test: (_raw, t) => /\b(?:port|puerto|porta)s?\b[^.\n;]{0,20}?\b\d{2,5}\b/.test(t) // port 8443, el puerto se movió al 2299
|
|
124
|
+
|| /(?:localhost|127\.0\.0\.1|\]|[a-z0-9-]+\.[a-z]{2,})\:\d{2,5}\b/.test(t) // host:8443
|
|
125
|
+
|| /(?:^|[\s(])\:\d{4,5}\b/.test(t), // ":5432" alone (not a time)
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
reason: 'url',
|
|
129
|
+
test: (_raw, t) => /\b(?:https?|ftp|ssh|postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|wss?):\/\/\S+/.test(t),
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
reason: 'host',
|
|
133
|
+
test: (_raw, t) => /\b(?:\d{1,3}\.){3}\d{1,3}\b/.test(t) // an IPv4
|
|
134
|
+
|| /\b[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9-]+)*\.(?:com|net|org|io|dev|app|es|co|ai|cloud|info|biz|eu|uk|de|fr|it|mx|ar|cl|local|internal|lan|xyz|me|tech|site|online|store)\b/.test(t),
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
reason: 'path',
|
|
138
|
+
test: (raw) => /(?:^|[\s"'(`])\/[\w.-]+\/[\w.\/-]+/.test(raw) // /etc/nginx/…
|
|
139
|
+
|| /(?:^|[\s"'(`])[A-Za-z]:[\\/]/.test(raw) // C:\…
|
|
140
|
+
|| /(?:^|[\s"'(`])~[\\/]\S*/.test(raw) // ~/…
|
|
141
|
+
|| /(?:^|\s)\.\.?\/[\w.-]+/.test(raw), // ./src, ../lib
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
reason: 'config',
|
|
145
|
+
test: (raw, t) => /\b[A-Z][A-Z0-9]*_[A-Z0-9_]+\s*=\s*\S+/.test(raw) // KEY_NAME=value
|
|
146
|
+
|| /(?:^|\s)--[a-z][\w-]*=\S+/.test(raw) // --max-old-space-size=4096
|
|
147
|
+
|| /\b[a-z][a-z0-9]*_[a-z0-9_]+\b\s*(?:=|:|de|a|en|to|at|is|es)?\s*\d/.test(t) // memory_limit de 512M
|
|
148
|
+
|| /\b(?:set to|configured (?:to|as|at)|configurad[oa]s? (?:a|en|como)|fijad[oa]s? (?:a|en)|establecid[oa]s? (?:a|en)|timeout|time-out|ttl|rate limit|limit|limite|max|maximo|maximum|minimo|minimum|retries|reintentos|threshold|umbral|batch size|pool size|flag)\b[^.\n]{0,25}?\d/.test(t),
|
|
149
|
+
},
|
|
150
|
+
];
|
|
151
|
+
/** Role words, as written (first letter either case, rest lower) or as acronyms. */
|
|
152
|
+
const ROLE_WORDS = [
|
|
153
|
+
'contact', 'contacto', 'lead', 'manager', 'owner', 'responsable', 'encargado', 'encargada', 'jefe', 'jefa',
|
|
154
|
+
'director', 'directora', 'maintainer', 'mantenedor', 'gerente', 'coordinador', 'coordinadora', 'interlocutor',
|
|
155
|
+
'interlocutora', 'representante', 'administrador', 'administradora', 'referente', 'head', 'boss', 'supervisor',
|
|
156
|
+
'supervisora', 'propietario', 'propietaria', 'titular',
|
|
157
|
+
];
|
|
158
|
+
const ROLE_ACRONYMS = ['CEO', 'CTO', 'CFO', 'COO', 'CMO', 'CPO', 'PM', 'PO'];
|
|
159
|
+
const roleAlt = [
|
|
160
|
+
...ROLE_WORDS.map(w => `[${w[0].toUpperCase()}${w[0]}]${escape(w.slice(1))}`),
|
|
161
|
+
...ROLE_ACRONYMS,
|
|
162
|
+
].join('|');
|
|
163
|
+
const NAME = "[A-ZÁÉÍÓÚÑÜ][\\p{L}'’-]+(?:\\s+[A-ZÁÉÍÓÚÑÜ][\\p{L}'’-]+)?";
|
|
164
|
+
/** "<role> … is/es <Name>": "La responsable del soporte es Irene Zubiaurre", "Our contact is Ana". */
|
|
165
|
+
const ROLE_THEN_NAME = new RegExp(`\\b(?:${roleAlt})\\b[^.\\n]{0,60}?(?:\\b(?:is|es|será|sera|is now|ahora es)\\b|:)\\s+${NAME}`, 'u');
|
|
166
|
+
/**
|
|
167
|
+
* "<Name> is the <role>": "Marta es la jefa de proyecto", "Ana is our CTO".
|
|
168
|
+
* The article is required: "Docker es responsable de aislar…" is not a person.
|
|
169
|
+
*/
|
|
170
|
+
const NAME_THEN_ROLE = new RegExp(`${NAME}\\s+(?:is|es)\\s+(?:(?:now|ahora)\\s+)?(?:the|el|la|our|nuestr[oa])\\s+(?:(?:new|nuev[oa])\\s+)?(?:${roleAlt})\\b`, 'u');
|
|
171
|
+
// ─── History: a dated record of something done (2.9.1) ───────────
|
|
172
|
+
//
|
|
173
|
+
// "FASE 2 completada (ago 2026)", "2026-08-11: VERIFICADO que …", "RECHAZO
|
|
174
|
+
// de la tienda v3.4.0 (9 jul 2026)", "Deployed v2.3.1 on 2026-06-18":
|
|
175
|
+
// a line that says what was done and when does not go stale — it tells what
|
|
176
|
+
// happened, and it carries its own date, so whoever reads it sees how old it
|
|
177
|
+
// is. Before 2.9.1 the version, URL or path inside such a record made the
|
|
178
|
+
// whole line volatile, and on a real personal brain records were a large
|
|
179
|
+
// share of what the detector flagged (docs/design/staleness.md §15).
|
|
180
|
+
//
|
|
181
|
+
// The rule, all on the line's HEAD (its text up to the first sentence end —
|
|
182
|
+
// ". ", "! ", "? " — or line break; details after it are not judged):
|
|
183
|
+
// 1. the head names a date: 2026-06-18, 18/06/2026, 18-jun-2026, 18 de
|
|
184
|
+
// junio (de 2026), jun 2026, June 18, 2026;
|
|
185
|
+
// 2. the head has a word of finished action: a Spanish participle
|
|
186
|
+
// (completado, implementada, resueltos, desplegado, publicado, verificado,
|
|
187
|
+
// corregido, migrado, creado, añadido, rechazado, medido…), an
|
|
188
|
+
// unambiguous Spanish preterite (desplegó, migró, corrigió, resolvió, or
|
|
189
|
+
// any of them after "se": "se publicó"), an English past form (fixed,
|
|
190
|
+
// deployed, released, completed, verified, migrated, checked…) or an
|
|
191
|
+
// event noun (fix, hotfix, rechazo, incidente, outage, release,
|
|
192
|
+
// ejecución…);
|
|
193
|
+
// 3. and none of these turns it back into a statement of state:
|
|
194
|
+
// - a date that opens a period: "desde el 18-sep", "since", "a partir
|
|
195
|
+
// de", "as of", "from", "hasta", "until", "a 4-oct", "al 4-oct";
|
|
196
|
+
// - a word of the future or of a deadline: will, planned, previsto,
|
|
197
|
+
// programado, pendiente, caduca, expires, vence, renews, next,
|
|
198
|
+
// próximo, "para el <date>", "by <date>", "antes del <date>" ("tarea
|
|
199
|
+
// programada" and "scheduled task" name a kind of job and do not count);
|
|
200
|
+
// - a word of the present: actualmente, currently, current, actual,
|
|
201
|
+
// ahora, now, todavía, still, vigente, último, last, latest;
|
|
202
|
+
// - a verb of state BEFORE the first finished-action word: "la API
|
|
203
|
+
// corre en el puerto 8443, desplegada el 2026-06-18" is a port that
|
|
204
|
+
// also says when it went up, so it stays volatile;
|
|
205
|
+
// 4. and, when the head carries a changeable value (a volatile rule fires
|
|
206
|
+
// on it with its dates blanked), none of these says the value holds:
|
|
207
|
+
// - a verb of state anywhere in it, or pasa a, queda, sigue, devuelve,
|
|
208
|
+
// responde, abierto, becomes, returns ("Fix (4-oct-2026): el webhook
|
|
209
|
+
// apunta a https://…", "La release 2.3 usa el puerto 8443 (jun 2026)");
|
|
210
|
+
// - a check — verificado, comprobado, confirmado, probado, medido,
|
|
211
|
+
// detectado, verified, checked… — with the value before it ("Puerto
|
|
212
|
+
// 9443 verificado el 2026-09-18", "Plan: $499/año (confirmado …)"): a
|
|
213
|
+
// dated check of a value is that value, with the day it was seen;
|
|
214
|
+
// - a move to a place: migrado, desplegado, instalado, migrated,
|
|
215
|
+
// deployed… followed within four words by a/al/en/to/into/on/at and a
|
|
216
|
+
// port, host, URL or path ("se migró el panel al puerto 9443",
|
|
217
|
+
// "Deployed to https://app.example.com on 2026-06-18");
|
|
218
|
+
// - a schedule: a cycle word with a time of day ("ejecución diaria 03:00
|
|
219
|
+
// en /var/backups") or cada/every with a unit ("cada lunes");
|
|
220
|
+
// - a word that also describes a state — cerrado, aprobado, completo,
|
|
221
|
+
// medida, closed, approved, complete — when it is the only
|
|
222
|
+
// finished-action word ("Presupuesto aprobado (…): 1.200 € al mes").
|
|
223
|
+
// Quoted titles and asides in parentheses are not read for the verbs.
|
|
224
|
+
// Words that introduce a new current value without telling an event —
|
|
225
|
+
// actualizado, cambiado, configurado, updated, changed, set, renovado — are
|
|
226
|
+
// deliberately not finished-action words. The adjective "completa" counts
|
|
227
|
+
// fully only after a kind of work ("FASE 4 completa"). A line with no date
|
|
228
|
+
// is never history, however past its verbs are: it cannot show its age.
|
|
229
|
+
//
|
|
230
|
+
// Limits, said once: the head decides, so "Migrado a Hetzner (3-oct-2026).
|
|
231
|
+
// El host es 10.0.0.5." is history whole; the line's own date is what tells
|
|
232
|
+
// the reader how old that host is. Only Spanish and English. A record without
|
|
233
|
+
// a date ("Publicado el artículo en https://…") is not history. A version is
|
|
234
|
+
// not a place: "Instalado Node 20.11.0 en el servidor (3-oct-2026)" and
|
|
235
|
+
// "Migrado a PostgreSQL 16 el 3-oct-2026" are records of an upgrade.
|
|
236
|
+
/** Month names and the abbreviations people write, es/en, longest first. */
|
|
237
|
+
const MONTH = '(?:enero|febrero|marzo|abril|mayo|junio|julio|agosto|septiembre|setiembre|octubre|noviembre|diciembre|'
|
|
238
|
+
+ 'january|february|march|april|june|july|august|september|october|november|december|'
|
|
239
|
+
+ 'ene|feb|mar|abr|may|jun|jul|ago|sept|sep|oct|nov|dic|jan|apr|aug|dec)\\.?(?![a-z])';
|
|
240
|
+
/** A calendar date as people write it, on folded text. Year optional only after a day and a month name. */
|
|
241
|
+
const DATE = '(?:'
|
|
242
|
+
// A numeric date is not glued to a word, a version or a path: in "/api/v1/12/24" there is no day.
|
|
243
|
+
+ '(?<![\\w./-])20\\d{2}[-/]\\d{1,2}[-/]\\d{1,2}(?![\\d])' // 2026-06-18
|
|
244
|
+
+ '|(?<![\\w./-])\\d{1,2}/\\d{1,2}/(?:20)?\\d{2}(?![\\d./])' // 18/06/2026
|
|
245
|
+
+ '|(?<![\\w./-])\\d{1,2}-\\d{1,2}-(?:20)?\\d{2}(?![\\d./-])' // 18-06-2026
|
|
246
|
+
+ '|(?<![\\w./-])\\d{1,2}\\.\\d{1,2}\\.20\\d{2}(?![\\d.])' // 18.06.2026 (not a version)
|
|
247
|
+
// Day + month. A bare "may" followed by another word is the English modal ("Node 18 may be removed"), not May.
|
|
248
|
+
+ `|(?<!\\d)\\d{1,2}º?(?:\\s+de\\s+|[\\s-]+)(?!may\\s+(?!de\\s+20)[a-z])${MONTH}(?:(?:\\s+de\\s+|,?\\s+|-)20\\d{2}(?!\\d))?` // 18-jun(-2026), 18 de junio de 2026
|
|
249
|
+
+ `|(?<![a-z])${MONTH}(?:\\s+de\\s+|\\s+|-)20\\d{2}(?!\\d)` // jun 2026, junio de 2026
|
|
250
|
+
+ `|(?<![a-z])${MONTH}\\s+\\d{1,2}(?:st|nd|rd|th)?,?\\s+20\\d{2}(?!\\d)` // June 18, 2026
|
|
251
|
+
+ ')';
|
|
252
|
+
const DATE_RE = new RegExp(DATE);
|
|
253
|
+
const DATE_ALL = new RegExp(DATE, 'g');
|
|
254
|
+
/** Not part of a path, a branch name or an identifier: "fix-newsletter/", "hotfix/login" are not events. */
|
|
255
|
+
const W0 = '(?<![a-z0-9_/.\\-])';
|
|
256
|
+
const W1 = '(?![a-z0-9_/\\-])';
|
|
257
|
+
/** Kinds of work that can be "complete": "FASE 4 COMPLETA", "Auditoría SEO completa". Not a list or a configuration. */
|
|
258
|
+
const WORK = '(?:fase|fases|etapa|auditoria|migracion|tarea|tareas|revision|repaso|implementacion|instalacion|ejecucion'
|
|
259
|
+
+ '|integracion|prueba|pruebas|limpieza|sesion|sprint|refactor|refactorizacion|traduccion|importacion|indexacion|copia|backup)';
|
|
260
|
+
const DONE = new RegExp(W0 + '(?:'
|
|
261
|
+
// Spanish participles, any gender and number. Cerrado and aprobado are in STATIVE below: they also
|
|
262
|
+
// describe a state ("puerto 22 cerrado", "presupuesto aprobado: 1.200 €/mes").
|
|
263
|
+
+ '(?:completad|implementad|resuelt|desplegad|publicad|verificad|comprobad|confirmad|corregid|migrad|cread'
|
|
264
|
+
+ '|anadid|rechazad|denegad|arreglad|terminad|finalizad|lanzad|instalad|eliminad|borrad|enviad|entregad'
|
|
265
|
+
+ '|auditad|integrad|solucionad|probad|testead|validad|detectad|reparad|restaurad|revertid|fusionad|renombrad|retirad'
|
|
266
|
+
+ '|descartad|construid|realizad|ejecutad|aplicad|cancelad|abortad|reescrit|rehech)(?:o|a|os|as)'
|
|
267
|
+
// The adjective "completa" only after a kind of work: "Lista completa de precios" is not an event.
|
|
268
|
+
+ `|(?<=(?:^|[^a-z])${WORK}(?:\\s+\\S+){0,3}\\s+)complet(?:o|a|os|as)`
|
|
269
|
+
// "medido"; "medida" only when it is not the noun ("a medida", "medida de seguridad").
|
|
270
|
+
+ '|medid(?:o|os)|(?<!(?:^|[^a-z])(?:a|la|las|una|unas|de|del|sus?) )medidas?(?! de(?![a-z]))'
|
|
271
|
+
// Spanish preterites that are not also a common noun, adjective or present tense.
|
|
272
|
+
+ '|desplego|desplegue|migro|migre|corrigio|corregi|resolvio|resolvi|rechace|arregle|verifique|verifico|finalizo'
|
|
273
|
+
+ '|finalice|elimino|elimine|aprobo|publique|implemente|implemento|hizo|hice|hicimos'
|
|
274
|
+
// "se" + preterite is unambiguous even where the bare form is not ("se publicó" vs "público").
|
|
275
|
+
+ '|se (?:publico|creo|anadio|lanzo|instalo|elimino|borro|desplego|corrigio|resolvio|migro|cerro|aprobo|envio|entrego'
|
|
276
|
+
+ '|termino|completo|implemento|aplico|ejecuto|cancelo|midio|arreglo|reparo|verifico|comprobo|rechazo)'
|
|
277
|
+
// Event nouns.
|
|
278
|
+
+ '|fix|hotfix|bugfix|rechazo|incidente|incidencia|incident|outage|caida|post-?mortem|release|lanzamiento|ejecucion'
|
|
279
|
+
// English past forms.
|
|
280
|
+
+ '|fixed|deployed|released|completed|implemented|resolved|published|verified|confirmed|migrated|created|added'
|
|
281
|
+
+ '|rejected|shipped|merged|launched|installed|removed|deleted|done|finished|sent|audited|tested'
|
|
282
|
+
+ '|validated|detected|repaired|restored|reverted|solved|delivered|submitted|renamed|built|rolled back|refactored|applied'
|
|
283
|
+
+ '|checked|measured|executed|uploaded|posted|cancell?ed|aborted'
|
|
284
|
+
+ ')' + W1);
|
|
285
|
+
/**
|
|
286
|
+
* Words that tell an event or describe a state: "FASE 0 APROBADA el 2-sep",
|
|
287
|
+
* "Tanda CERRADA el 21-sep", "DIAGNÓSTICO COMPLETO (21-08-2026)" are records,
|
|
288
|
+
* "Puerto 22 cerrado (…): SSH en el 65002", "Presupuesto aprobado (…): 1.200 €
|
|
289
|
+
* al mes", "Lista completa de precios (…)" are states. They count as a
|
|
290
|
+
* finished action only in a head that carries no changeable value.
|
|
291
|
+
*/
|
|
292
|
+
const STATIVE = new RegExp(W0 + '(?:(?:cerrad|aprobad|complet)(?:o|a|os|as)|(?<!(?:^|[^a-z])(?:a|la|las|una|unas|de|del|sus?) )medidas?|closed|approved|complete)' + W1);
|
|
293
|
+
/** A date that opens a period, or a moment that does: the line states what holds from then on. */
|
|
294
|
+
const OPENS_PERIOD = new RegExp(`(?<![a-z])(?:desde|since|a partir del?|as of|as from|from|effective(?: from)?|con efecto(?: desde)?|a fecha de|hasta|until|till|a|al)\\s+`
|
|
295
|
+
+ `(?:(?:el|la|los|the|dia|day)\\s+)*(?:${DATE}|entonces|then|hoy|today|ahora|now|ese dia|that day)`);
|
|
296
|
+
/** The future, a deadline or a renewal: something still to happen is not a record. */
|
|
297
|
+
const FUTURE = new RegExp(
|
|
298
|
+
// "Tarea programada" and "scheduled task" name a kind of job, not a future: they stay out.
|
|
299
|
+
'(?<![a-z])(?:will|shall|going to|planned|planificad[oa]s?|previst[oa]s?|(?<!(?:tarea|rutina)s? )programad[oa]s?'
|
|
300
|
+
+ '|scheduled(?! (?:tasks?|jobs?|routines?))|pendientes?|pending'
|
|
301
|
+
+ '|por hacer|to-do|deadline|fecha limite|plazo|vencen?|vencimiento|caducan?|caducidad|expiran?|expires?|expiry'
|
|
302
|
+
+ '|renuevan?|renews?|renewal|next|proxim[oa]s?|siguientes?)(?![a-z])'
|
|
303
|
+
+ `|(?<![a-z])(?:para el|para|by|antes del?|before|no later than)\\s+(?:(?:el|la|the)\\s+)?${DATE}`);
|
|
304
|
+
/** The present: the line says what holds now, whatever else it records. */
|
|
305
|
+
const PRESENT = /(?<![a-z])(?:actualmente|currently|current|actual|actuales|ahora|now|todavia|aun|still|hoy en dia|a dia de hoy|vigente|en vigor|in force|ultim[oa]s?|last|latest)(?![a-z])/;
|
|
306
|
+
/**
|
|
307
|
+
* Verbs that state how something is. Before the first finished-action word
|
|
308
|
+
* they make the line a statement of state. Not inside a domain or a path:
|
|
309
|
+
* the "es" of "garza.es" is not a verb.
|
|
310
|
+
*/
|
|
311
|
+
const STATE_WORDS = 'es|son|is|are|corre|corren|runs?|usa|usan|uses?|tiene|tienen|cuesta|cuestan|costs?|vale|valen|apunta|apuntan'
|
|
312
|
+
+ '|points?|escucha|escuchan|listens?|requiere|requieren|requires?|sirve|sirven|serves?|vive|viven|lives?|funciona|funcionan'
|
|
313
|
+
+ '|works?|contiene|contienen|contains?|ocupa|ocupan';
|
|
314
|
+
const STATE_VERB = new RegExp(`(?<![a-z0-9_./-])(?:${STATE_WORDS}|esta|estan)(?![a-z])`);
|
|
315
|
+
/**
|
|
316
|
+
* The same verbs and a few more that say what holds (pasa a, queda, sigue,
|
|
317
|
+
* devuelve, responde, abierto, becomes, returns…), looked for anywhere in a
|
|
318
|
+
* head that carries a changeable value: "Desplegado el 18-jun-2026: la API
|
|
319
|
+
* corre en el puerto 8443" is a port, whatever came first. "Esta" counts only
|
|
320
|
+
* as the verb ("está en", "está caído"), not as "this" ("esta web").
|
|
321
|
+
*/
|
|
322
|
+
const STATE_AFTER = new RegExp('(?<![a-z0-9_./-])(?:'
|
|
323
|
+
+ STATE_WORDS
|
|
324
|
+
+ '|estan?(?= (?:en|a|al|ahora|caid|activ|disponible|abiert|operativ|online|offline|rot|vaci|llen|list|apuntando|corriendo|usando|sirviendo))'
|
|
325
|
+
+ '|pasan? a|quedan?|siguen?|devuelven?|returns?|responden?|responds?|becomes?|abiert[oa]s?|open'
|
|
326
|
+
+ ')(?![a-z])');
|
|
327
|
+
/** A check of something: dated, it tells what held that day, and what held is the value. */
|
|
328
|
+
const CHECK = /^(?:verificad|comprobad|confirmad|probad|testead|validad|detectad|medid|verifique|verifico|se verifico|se comprobo|se midio|verified|confirmed|checked|tested|validated|measured|detected)/;
|
|
329
|
+
/** A move to a place: "migrado al puerto 9443", "deployed to https://…" says where the thing lives now. */
|
|
330
|
+
const MOVE = /^(?:migrad|desplegad|instalad|trasladad|movid|migro|migre|desplego|desplegue|se migro|se desplego|se instalo|migrated|deployed|installed|moved)/;
|
|
331
|
+
/** What follows a move, up to four words later, when it names the destination. */
|
|
332
|
+
const TO_PLACE = /^[^\s]*(?:\s+[^\s]+){0,4}?\s+(?:a|al|en|hacia|to|into|on|at)\s+(.*)$/;
|
|
333
|
+
/**
|
|
334
|
+
* Something done on a cycle is a schedule, not an event: "ejecución diaria
|
|
335
|
+
* 03:00 en /var/backups". A cycle word alone is not enough — "Ejecución
|
|
336
|
+
* diaria del 31-ago-2026: publicados…" is one run of a daily job, a record —
|
|
337
|
+
* so it takes a time of day right after it, or cada/every with a unit.
|
|
338
|
+
*/
|
|
339
|
+
const RECURRING = new RegExp('(?<![a-z])(?:'
|
|
340
|
+
+ '(?:diari[oa]s?|diariamente|semanal(?:es|mente)?|mensual(?:es|mente)?|daily|nightly|weekly|monthly|hourly)'
|
|
341
|
+
+ '(?:\\s+(?:a las|at))?\\s+\\d{1,2}[:h]\\d{2}'
|
|
342
|
+
+ '|(?:cada|every)\\s+(?:\\d+\\s+)?(?:dia|dias|hora|horas|semana|semanas|mes|meses|minutos?|lunes|martes|miercoles|jueves|viernes|sabado|domingo'
|
|
343
|
+
+ '|day|days|hour|hours|week|weeks|month|months|minutes?|monday|tuesday|wednesday|thursday|friday|saturday|sunday|night|noche)'
|
|
344
|
+
+ ')(?![a-z0-9])');
|
|
345
|
+
/** Month abbreviations a period may follow without ending the sentence: "jun. 2026", "Sept. 18". */
|
|
346
|
+
const MONTH_ABBR_DOT = /(?<![a-z])(?:ene|feb|mar|abr|may|jun|jul|ago|sept|sep|oct|nov|dic|jan|apr|aug|dec)$/;
|
|
347
|
+
/** The head of a line: up to the first sentence end or line break. */
|
|
348
|
+
function headOf(folded) {
|
|
349
|
+
const re = /[.!?](?=\s|$)|\n/g;
|
|
350
|
+
let m;
|
|
351
|
+
while ((m = re.exec(folded))) {
|
|
352
|
+
if (m[0] === '.' && MONTH_ABBR_DOT.test(folded.slice(0, m.index)))
|
|
353
|
+
continue;
|
|
354
|
+
return folded.slice(0, m.index);
|
|
355
|
+
}
|
|
356
|
+
return folded;
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Which volatile rule fires on a piece of a line, with its dates blanked out
|
|
360
|
+
* (a date is not a value). `raw` and `folded` are the same piece; folding is
|
|
361
|
+
* length-preserving for Latin text, so a date found in one is blanked in both.
|
|
362
|
+
*/
|
|
363
|
+
function valueIn(raw, folded) {
|
|
364
|
+
let r0 = raw, f0 = folded;
|
|
365
|
+
if (r0.length === f0.length) {
|
|
366
|
+
for (const m of folded.matchAll(DATE_ALL)) {
|
|
367
|
+
const a = m.index ?? 0, b = a + m[0].length, gap = ' '.repeat(b - a);
|
|
368
|
+
r0 = r0.slice(0, a) + gap + r0.slice(b);
|
|
369
|
+
f0 = f0.slice(0, a) + gap + f0.slice(b);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
r0 = f0 = folded.replace(DATE_ALL, ' ');
|
|
374
|
+
}
|
|
375
|
+
for (const r of RULES)
|
|
376
|
+
if (r.test(r0, f0))
|
|
377
|
+
return r.reason;
|
|
378
|
+
return undefined;
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* True when the text reads as a dated record of something done (see the
|
|
382
|
+
* rule above). Exported for the tests and the design doc's examples.
|
|
383
|
+
*/
|
|
384
|
+
function isDatedRecord(text) {
|
|
385
|
+
return recordVerdict(text).record;
|
|
386
|
+
}
|
|
387
|
+
/** Quoted titles say nothing about state: «… que siguen trabajando …» is a headline. Blanked, length kept. */
|
|
388
|
+
const QUOTED = /«[^»\n]*»|"[^"\n]*"|“[^”\n]*”/g;
|
|
389
|
+
/** Nor does an aside in parentheses: "publicado en npm (no es repo git)" is a record with a remark. */
|
|
390
|
+
const ASIDE = /\([^()\n]*\)/g;
|
|
391
|
+
const blankAsides = (s) => s.replace(QUOTED, m => ' '.repeat(m.length)).replace(ASIDE, m => ' '.repeat(m.length));
|
|
392
|
+
/**
|
|
393
|
+
* The same decision with the rule that made it, for the tests and for
|
|
394
|
+
* diagnosing a real brain: 'record', or the first rule that said no.
|
|
395
|
+
*/
|
|
396
|
+
function recordVerdict(text) {
|
|
397
|
+
const no = (why) => ({ record: false, why });
|
|
398
|
+
const raw = String(text || '');
|
|
399
|
+
if (!raw.trim())
|
|
400
|
+
return no('empty');
|
|
401
|
+
const folded = fold(raw);
|
|
402
|
+
const head = headOf(folded);
|
|
403
|
+
if (!DATE_RE.test(head))
|
|
404
|
+
return no('no-date');
|
|
405
|
+
const strict = DONE.exec(head);
|
|
406
|
+
const done = strict ?? STATIVE.exec(head);
|
|
407
|
+
if (!done)
|
|
408
|
+
return no('no-done');
|
|
409
|
+
if (OPENS_PERIOD.test(head))
|
|
410
|
+
return no('opens-period');
|
|
411
|
+
if (FUTURE.test(head))
|
|
412
|
+
return no('future');
|
|
413
|
+
if (PRESENT.test(head))
|
|
414
|
+
return no('present');
|
|
415
|
+
if (STATE_VERB.test(head.slice(0, done.index)))
|
|
416
|
+
return no('state-before');
|
|
417
|
+
// Rule 4: only a head that carries a changeable value can state it as current.
|
|
418
|
+
const rawHead = raw.length === folded.length ? raw.slice(0, head.length) : head;
|
|
419
|
+
if (!valueIn(rawHead, head))
|
|
420
|
+
return { record: true, why: 'record' };
|
|
421
|
+
if (!strict)
|
|
422
|
+
return no(`stative-with-value:${done[0]}`);
|
|
423
|
+
const plain = blankAsides(head);
|
|
424
|
+
const after = STATE_AFTER.exec(plain);
|
|
425
|
+
if (after)
|
|
426
|
+
return no(`state-after:${after[0]}`);
|
|
427
|
+
if (RECURRING.test(plain))
|
|
428
|
+
return no('recurring');
|
|
429
|
+
const word = done[0];
|
|
430
|
+
if (CHECK.test(word)) {
|
|
431
|
+
const before = valueIn(rawHead.slice(0, done.index), head.slice(0, done.index));
|
|
432
|
+
// A bare domain before a check is usually the site being checked, not the value.
|
|
433
|
+
if (before && before !== 'host')
|
|
434
|
+
return no('check-of-value');
|
|
435
|
+
}
|
|
436
|
+
if (MOVE.test(word)) {
|
|
437
|
+
const end = done.index + word.length;
|
|
438
|
+
const to = TO_PLACE.exec(head.slice(end));
|
|
439
|
+
if (to) {
|
|
440
|
+
const start = head.length - to[1].length;
|
|
441
|
+
// The destination is the few words after the preposition, not the rest of the line.
|
|
442
|
+
const object = /^\S+(?:\s+\S+){0,2}/.exec(to[1])?.[0] ?? '';
|
|
443
|
+
const where = valueIn(rawHead.slice(start, start + object.length), object);
|
|
444
|
+
if (where === 'port' || where === 'host' || where === 'url' || where === 'path')
|
|
445
|
+
return no('move-to-place');
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return { record: true, why: 'record' };
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* The class an unmarked fact gets from its text: permanent when it is a dated
|
|
452
|
+
* record of something done (2.9.1), else volatile when a rule fires (and
|
|
453
|
+
* which one), normal otherwise.
|
|
454
|
+
*/
|
|
455
|
+
function detectShelf(text) {
|
|
456
|
+
const raw = String(text || '');
|
|
457
|
+
if (!raw.trim())
|
|
458
|
+
return { shelf: 'normal' };
|
|
459
|
+
if (isDatedRecord(raw))
|
|
460
|
+
return { shelf: 'permanent', reason: 'history' };
|
|
461
|
+
const t = fold(raw);
|
|
462
|
+
for (const r of RULES) {
|
|
463
|
+
if (r.test(raw, t))
|
|
464
|
+
return { shelf: 'volatile', reason: r.reason };
|
|
465
|
+
}
|
|
466
|
+
if (ROLE_THEN_NAME.test(raw) || NAME_THEN_ROLE.test(raw))
|
|
467
|
+
return { shelf: 'volatile', reason: 'role' };
|
|
468
|
+
return { shelf: 'normal' };
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* The class that applies to a fact: its explicit shelf_life; else permanent
|
|
472
|
+
* when the miner imported it (2.9.1: an imported note is a copy of something
|
|
473
|
+
* written elsewhere, at some other time, and was never a claim this memory
|
|
474
|
+
* made about the present — warning on it is noise); else the one its text
|
|
475
|
+
* implies.
|
|
476
|
+
*
|
|
477
|
+
* Once a session has said the same line too, it is no longer only an
|
|
478
|
+
* imported note: the duplicate branch of learn keeps `source: "miner"` but
|
|
479
|
+
* sets `verified` or adds a confirmation, and from then on the line is judged
|
|
480
|
+
* by its text like any other claim about the present.
|
|
481
|
+
*/
|
|
482
|
+
function shelfOfFact(f) {
|
|
483
|
+
if (isShelfLife(f.shelf_life))
|
|
484
|
+
return { shelf: f.shelf_life, inferred: false };
|
|
485
|
+
if (f.source === 'miner' && !f.verified && (f.confirmations ?? 1) <= 1)
|
|
486
|
+
return { shelf: 'permanent', inferred: true, reason: 'miner' };
|
|
487
|
+
const d = detectShelf(f.text);
|
|
488
|
+
return { shelf: d.shelf, inferred: true, ...(d.reason ? { reason: d.reason } : {}) };
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* The class of a non-fact entry, by kind alone (not settable). null for what
|
|
492
|
+
* is out of scope (the map, the header).
|
|
493
|
+
*/
|
|
494
|
+
function shelfOfKind(kind) {
|
|
495
|
+
switch (kind) {
|
|
496
|
+
case 'decision':
|
|
497
|
+
case 'pattern':
|
|
498
|
+
return 'durable';
|
|
499
|
+
case 'preference':
|
|
500
|
+
case 'error':
|
|
501
|
+
case 'debt':
|
|
502
|
+
return 'permanent';
|
|
503
|
+
default:
|
|
504
|
+
return null;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* How long after `created` a stamp may come and still belong to a brain born
|
|
509
|
+
* stamped: initialize() writes both in the same call, an upgrade writes the
|
|
510
|
+
* stamp at the first boot of a new version, days or months later.
|
|
511
|
+
*/
|
|
512
|
+
const LEGACY_MARGIN_MS = 60_000;
|
|
513
|
+
/**
|
|
514
|
+
* Whether a brain predates shelf life, from its manifest: not stamped yet, a
|
|
515
|
+
* creation date that cannot be read, or a stamp later than its creation by
|
|
516
|
+
* more than LEGACY_MARGIN_MS.
|
|
517
|
+
*/
|
|
518
|
+
function isLegacyBrain(created, since) {
|
|
519
|
+
const s = parseMs(since ?? undefined);
|
|
520
|
+
if (s === null)
|
|
521
|
+
return true;
|
|
522
|
+
const c = parseMs(created ?? undefined);
|
|
523
|
+
if (c === null)
|
|
524
|
+
return true;
|
|
525
|
+
return s - c > LEGACY_MARGIN_MS;
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* `created` is the manifest's: pass it (see stalenessContextOf) and the
|
|
529
|
+
* context knows whether the brain is legacy. Left out, it is not legacy —
|
|
530
|
+
* the 2.9.0 behaviour, where a volatile fact never got the grace.
|
|
531
|
+
*/
|
|
532
|
+
function stalenessContext(since, env = process.env, nowMs = Date.now(), created) {
|
|
533
|
+
if (!stalenessEnabled(env))
|
|
534
|
+
return null;
|
|
535
|
+
const legacy = created !== undefined && isLegacyBrain(created, since);
|
|
536
|
+
return { nowMs, windows: shelfWindows(env), ...(since ? { since } : {}), ...(legacy ? { legacy: true } : {}) };
|
|
537
|
+
}
|
|
538
|
+
/** The context for a brain, from its manifest (or none, when it could not be read: no legacy, full grace). */
|
|
539
|
+
function stalenessContextOf(manifest, env = process.env, nowMs = Date.now()) {
|
|
540
|
+
if (!manifest)
|
|
541
|
+
return stalenessContext(undefined, env, nowMs);
|
|
542
|
+
return stalenessContext(manifest.staleness_since, env, nowMs, manifest.created ?? null);
|
|
543
|
+
}
|
|
544
|
+
const parseMs = (iso) => {
|
|
545
|
+
if (!iso || typeof iso !== 'string')
|
|
546
|
+
return null;
|
|
547
|
+
const t = Date.parse(iso);
|
|
548
|
+
return Number.isFinite(t) ? t : null;
|
|
549
|
+
};
|
|
550
|
+
/**
|
|
551
|
+
* How far ahead of this machine's clock a check may be dated and still count:
|
|
552
|
+
* a day, for time zones and ordinary drift. Anything later is a clock that is
|
|
553
|
+
* wrong (a teammate's, or a corrupt `at`), and a check from the future would
|
|
554
|
+
* otherwise win every "latest" merge and keep the line fresh until that day.
|
|
555
|
+
*/
|
|
556
|
+
exports.FUTURE_SLACK_MS = DAY_MS;
|
|
557
|
+
/** A verification instant, or undefined when it is malformed or too far in the future to be real. */
|
|
558
|
+
function plausibleCheck(iso, nowMs = Date.now()) {
|
|
559
|
+
const t = parseMs(iso);
|
|
560
|
+
if (t === null || t > nowMs + exports.FUTURE_SLACK_MS)
|
|
561
|
+
return undefined;
|
|
562
|
+
return iso;
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* A fixed share in [0, 1) for a line, from its text hash (FNV-1a): the same
|
|
566
|
+
* line gets the same share on every machine and every call, so the staggered
|
|
567
|
+
* grace is deterministic and needs nothing stored.
|
|
568
|
+
*/
|
|
569
|
+
function spreadOf(seed) {
|
|
570
|
+
let h = 0x811c9dc5;
|
|
571
|
+
for (let i = 0; i < seed.length; i++) {
|
|
572
|
+
h ^= seed.charCodeAt(i);
|
|
573
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
574
|
+
}
|
|
575
|
+
return h / 0x1_0000_0000;
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Judge one entry. `clock` is verified ?? recorded date; `graceable` says
|
|
579
|
+
* whether the legacy grace may move the clock forward to `ctx.since`.
|
|
580
|
+
* Returns null when there is nothing to judge: no date (a line with no date
|
|
581
|
+
* cannot prove it is old any more than it can prove it is recent).
|
|
582
|
+
*/
|
|
583
|
+
function judge(shelf, inferred, reason, clockIso, graceable, ctx, seed) {
|
|
584
|
+
const t = parseMs(clockIso);
|
|
585
|
+
if (t === null)
|
|
586
|
+
return null;
|
|
587
|
+
let desde = t;
|
|
588
|
+
let ageFrom;
|
|
589
|
+
const window = shelf === 'permanent' ? null : ctx.windows[shelf];
|
|
590
|
+
if (graceable) {
|
|
591
|
+
// No stamp yet (a recall before the first boot of this version): the
|
|
592
|
+
// grace runs from now, so an old brain is never flagged before it is stamped.
|
|
593
|
+
const s = ctx.since ? parseMs(ctx.since) : ctx.nowMs;
|
|
594
|
+
if (s !== null && s > t) {
|
|
595
|
+
// Staggered, not one shared start: if every old line ran from the
|
|
596
|
+
// stamp, they would all cross their window on the same day and an old
|
|
597
|
+
// brain would go from no warnings to all of them at once. Each line's
|
|
598
|
+
// clock starts up to half a window before the stamp, by a fixed share
|
|
599
|
+
// drawn from a hash of its text, and never before its real date. On the
|
|
600
|
+
// stamp day nothing is past its window (at most half of it has
|
|
601
|
+
// elapsed), and an old brain's lines come due evenly over the second
|
|
602
|
+
// half of the first window instead of on one day.
|
|
603
|
+
const half = window === null ? 0 : (window * DAY_MS) / 2;
|
|
604
|
+
const atras = Math.min(s - t, half * spreadOf(seed));
|
|
605
|
+
desde = s - atras;
|
|
606
|
+
ageFrom = new Date(desde).toISOString().slice(0, 10);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
const age = Math.max(0, Math.floor((ctx.nowMs - desde) / DAY_MS));
|
|
610
|
+
return {
|
|
611
|
+
stale: window !== null && age > window,
|
|
612
|
+
age_days: age,
|
|
613
|
+
last_verified: new Date(t).toISOString().slice(0, 10),
|
|
614
|
+
shelf_life: shelf,
|
|
615
|
+
shelf_inferred: inferred,
|
|
616
|
+
...(reason ? { shelf_reason: reason } : {}),
|
|
617
|
+
...(ageFrom ? { age_from: ageFrom } : {}),
|
|
618
|
+
window,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
/** A fact's standing. Retired facts are not judged (they never surface). */
|
|
622
|
+
function factStaleness(f, ctx) {
|
|
623
|
+
if (!f || (f.status && f.status !== 'active'))
|
|
624
|
+
return null;
|
|
625
|
+
const s = shelfOfFact(f);
|
|
626
|
+
// A check dated in the future is treated as no check: the clock runs from
|
|
627
|
+
// the recorded date, never from a day that has not come.
|
|
628
|
+
const verified = plausibleCheck(f.verified, ctx.nowMs);
|
|
629
|
+
const clock = verified ?? f.added;
|
|
630
|
+
// Legacy grace: never re-checked, nobody chose its class. A volatile fact
|
|
631
|
+
// gets it only in a brain that predates shelf life (2.9.1): there the
|
|
632
|
+
// detector's volatile lines are hundreds, written before anyone could mark
|
|
633
|
+
// them, and flagging them all on upgrade day buries the few that matter. A
|
|
634
|
+
// fact marked volatile by hand gets none (not inferred), and in a brain
|
|
635
|
+
// born with shelf life neither does an inferred one: a port saved months
|
|
636
|
+
// ago deserves the warning on the first recall that serves it.
|
|
637
|
+
const graceable = !verified && s.inferred && (s.shelf !== 'volatile' || ctx.legacy === true);
|
|
638
|
+
return judge(s.shelf, s.inferred, s.reason, clock, graceable, ctx, (0, ops_js_1.entryId)(f.text));
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* A decision, pattern, preference, error or debt, by kind and text. Its clock
|
|
642
|
+
* is entry_verified, else its recorded date (a decision's `date`, the
|
|
643
|
+
* entry_dates sidecar for the rest). Permanent kinds come back with
|
|
644
|
+
* stale:false so callers can still show their age if they want to.
|
|
645
|
+
*/
|
|
646
|
+
function entryStaleness(neuron, kind, text, ctx) {
|
|
647
|
+
const shelf = shelfOfKind(kind);
|
|
648
|
+
if (!shelf || !text)
|
|
649
|
+
return null;
|
|
650
|
+
let base = text;
|
|
651
|
+
let fecha;
|
|
652
|
+
if (kind === 'decision') {
|
|
653
|
+
// A decision chunk in the index is "text — rationale"; the entry is the text.
|
|
654
|
+
const d = (neuron.decisions || []).find(x => x.text === text || (x.rationale ? `${x.text} — ${x.rationale}` : x.text) === text);
|
|
655
|
+
if (!d)
|
|
656
|
+
return null;
|
|
657
|
+
base = d.text;
|
|
658
|
+
fecha = d.date;
|
|
659
|
+
}
|
|
660
|
+
else {
|
|
661
|
+
fecha = neuron.entry_dates?.[(0, ops_js_1.entryId)(base)];
|
|
662
|
+
}
|
|
663
|
+
const id = (0, ops_js_1.entryId)(base);
|
|
664
|
+
const verified = plausibleCheck(neuron.entry_verified?.[id], ctx.nowMs);
|
|
665
|
+
return judge(shelf, true, undefined, verified ?? fecha, !verified && shelf !== 'permanent', ctx, id);
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* The latest of two ISO instants. Absent, unparseable or implausibly future
|
|
669
|
+
* (past now + FUTURE_SLACK_MS) loses: a check from a clock that is ahead must
|
|
670
|
+
* not win every later merge and pin the line as fresh until that day.
|
|
671
|
+
*/
|
|
672
|
+
function latestOf(a, b, nowMs = Date.now()) {
|
|
673
|
+
const ta = parseMs(plausibleCheck(a, nowMs)), tb = parseMs(plausibleCheck(b, nowMs));
|
|
674
|
+
if (ta === null)
|
|
675
|
+
return tb === null ? undefined : b;
|
|
676
|
+
if (tb === null)
|
|
677
|
+
return a;
|
|
678
|
+
return tb > ta ? b : a;
|
|
679
|
+
}
|
|
680
|
+
//# sourceMappingURL=shelf.js.map
|