crbro-memory 2.8.0 → 2.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +16 -5
  2. package/dist/daemon/endpoint.d.ts +1 -0
  3. package/dist/daemon/endpoint.d.ts.map +1 -1
  4. package/dist/daemon/endpoint.js +15 -13
  5. package/dist/daemon/endpoint.js.map +1 -1
  6. package/dist/engine/brain.d.ts.map +1 -1
  7. package/dist/engine/brain.js +30 -0
  8. package/dist/engine/brain.js.map +1 -1
  9. package/dist/engine/cortex.d.ts +55 -1
  10. package/dist/engine/cortex.d.ts.map +1 -1
  11. package/dist/engine/cortex.js +203 -12
  12. package/dist/engine/cortex.js.map +1 -1
  13. package/dist/engine/maintenance.d.ts +22 -0
  14. package/dist/engine/maintenance.d.ts.map +1 -1
  15. package/dist/engine/maintenance.js +59 -2
  16. package/dist/engine/maintenance.js.map +1 -1
  17. package/dist/engine/secrets.d.ts.map +1 -1
  18. package/dist/engine/secrets.js +12 -1
  19. package/dist/engine/secrets.js.map +1 -1
  20. package/dist/engine/shelf.d.ts +156 -0
  21. package/dist/engine/shelf.d.ts.map +1 -0
  22. package/dist/engine/shelf.js +680 -0
  23. package/dist/engine/shelf.js.map +1 -0
  24. package/dist/engine/source.d.ts +6 -0
  25. package/dist/engine/source.d.ts.map +1 -0
  26. package/dist/engine/source.js +101 -0
  27. package/dist/engine/source.js.map +1 -0
  28. package/dist/search/index.d.ts +6 -0
  29. package/dist/search/index.d.ts.map +1 -1
  30. package/dist/search/index.js +46 -1
  31. package/dist/search/index.js.map +1 -1
  32. package/dist/server.d.ts.map +1 -1
  33. package/dist/server.js +221 -47
  34. package/dist/server.js.map +1 -1
  35. package/dist/sync/materialize.d.ts +4 -0
  36. package/dist/sync/materialize.d.ts.map +1 -1
  37. package/dist/sync/materialize.js +80 -1
  38. package/dist/sync/materialize.js.map +1 -1
  39. package/dist/sync/ops.d.ts +29 -2
  40. package/dist/sync/ops.d.ts.map +1 -1
  41. package/dist/sync/ops.js.map +1 -1
  42. package/dist/sync/space.d.ts.map +1 -1
  43. package/dist/sync/space.js +15 -2
  44. package/dist/sync/space.js.map +1 -1
  45. package/dist/types/index.d.ts +49 -0
  46. package/dist/types/index.d.ts.map +1 -1
  47. package/dist/version.d.ts +26 -0
  48. package/dist/version.d.ts.map +1 -0
  49. package/dist/version.js +65 -0
  50. package/dist/version.js.map +1 -0
  51. package/hooks/crbro-lifecycle.mjs +612 -608
  52. package/package.json +1 -1
@@ -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