sentinelayer-cli 0.46.4 → 0.48.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 (137) hide show
  1. package/README.md +63 -3
  2. package/package.json +12 -7
  3. package/skills/omar-gate-setup/SKILL.md +103 -0
  4. package/skills/senti-agent-identity/SKILL.md +80 -0
  5. package/skills/senti-judge/SKILL.md +139 -0
  6. package/skills/senti-room-notifications/SKILL.md +73 -0
  7. package/skills/senti-room-notifications/listen-wrapper.cjs +150 -0
  8. package/skills/senti-room-notifications/package.json +1 -0
  9. package/skills/senti-room-notifications/test-classifier.js +109 -0
  10. package/skills/senti-room-notifications/test-receipt.js +54 -0
  11. package/skills/sentinelayer-cli/SKILL.md +222 -0
  12. package/src/agents/devtestbot/runner.js +22 -11
  13. package/src/agents/devtestbot/tool.js +24 -11
  14. package/src/agents/jules/fix-cycle.js +7 -1
  15. package/src/agents/jules/tools/runtime-audit.js +17 -12
  16. package/src/agents/shared-tools/dispatch-core.js +10 -5
  17. package/src/ai/aidenid.js +4 -2
  18. package/src/ai/domain-target-store.js +11 -5
  19. package/src/ai/identity-store.js +11 -5
  20. package/src/ai/site-store.js +8 -4
  21. package/src/audit/orchestrator.js +21 -14
  22. package/src/audit/package.js +6 -4
  23. package/src/audit/persona-loop.js +7 -0
  24. package/src/audit/replay.js +4 -2
  25. package/src/auth/gate.js +46 -8
  26. package/src/auth/service.js +34 -15
  27. package/src/auth/session-store.js +105 -47
  28. package/src/board/evidence-resolvers.js +3 -4
  29. package/src/cli.js +23 -3
  30. package/src/commands/ai/identity-lifecycle.js +30 -26
  31. package/src/commands/ai/provision-governance.js +32 -24
  32. package/src/commands/ai/shared.js +6 -4
  33. package/src/commands/audit.js +14 -12
  34. package/src/commands/auth.js +24 -4
  35. package/src/commands/board.js +2 -2
  36. package/src/commands/chat.js +8 -6
  37. package/src/commands/judge.js +125 -0
  38. package/src/commands/legacy-args.js +1 -0
  39. package/src/commands/mcp.js +45 -52
  40. package/src/commands/omargate.js +2 -1
  41. package/src/commands/plugin.js +11 -3
  42. package/src/commands/review.js +9 -4
  43. package/src/commands/scan.js +7 -7
  44. package/src/commands/session.js +607 -57
  45. package/src/commands/skills.js +45 -0
  46. package/src/commands/watch.js +9 -7
  47. package/src/config/output-filesystem.js +156 -0
  48. package/src/config/schema.js +4 -2
  49. package/src/config/service.js +40 -4
  50. package/src/cost/history.js +15 -9
  51. package/src/daemon/artifact-lineage.js +15 -12
  52. package/src/daemon/assignment-ledger.js +31 -25
  53. package/src/daemon/ast-drift.js +7 -3
  54. package/src/daemon/budget-governor.js +26 -20
  55. package/src/daemon/error-worker.js +28 -22
  56. package/src/daemon/fix-cycle.js +5 -4
  57. package/src/daemon/hybrid-mapper.js +19 -16
  58. package/src/daemon/ingest-refresh.js +4 -0
  59. package/src/daemon/jira-lifecycle.js +21 -17
  60. package/src/daemon/operator-control.js +18 -15
  61. package/src/daemon/reliability-lane.js +18 -15
  62. package/src/daemon/scope-engine.js +12 -8
  63. package/src/daemon/watchdog.js +15 -12
  64. package/src/engram/build-state.js +24 -3
  65. package/src/engram/capability-index.js +367 -0
  66. package/src/engram/compile-graph.js +1063 -0
  67. package/src/engram/contracts/action-receipt.js +82 -0
  68. package/src/engram/contracts/capability-index.js +259 -0
  69. package/src/engram/contracts/capability.js +377 -0
  70. package/src/engram/contracts/content-hash.js +8 -0
  71. package/src/engram/contracts/evidence.js +258 -0
  72. package/src/engram/contracts/graph.js +594 -0
  73. package/src/engram/contracts/identity.js +8 -0
  74. package/src/engram/contracts/index.js +149 -0
  75. package/src/engram/contracts/media-asset.js +85 -0
  76. package/src/engram/contracts/money.js +249 -0
  77. package/src/engram/contracts/source-snapshot.js +57 -0
  78. package/src/engram/contracts/task-context-pack.js +114 -0
  79. package/src/engram/contracts/temporal-segment.js +72 -0
  80. package/src/engram/contracts/versions.js +96 -0
  81. package/src/engram/extract-html.js +692 -0
  82. package/src/engram/graph-store.js +877 -0
  83. package/src/engram/index.js +98 -14
  84. package/src/engram/ingest-site.js +132 -0
  85. package/src/engram/live-state.js +1180 -0
  86. package/src/engram/namespace.js +34 -1
  87. package/src/engram/retrieve-site.js +753 -0
  88. package/src/engram/safe-reader.js +597 -0
  89. package/src/engram/source-intake.js +251 -0
  90. package/src/engram/store.js +227 -9
  91. package/src/ingest/engine.js +21 -28
  92. package/src/judge/verdict.js +305 -0
  93. package/src/legacy-cli.js +96 -19
  94. package/src/mcp/bridge-inputs.js +28 -4
  95. package/src/mcp/cli-command-tools.js +70 -0
  96. package/src/mcp/registry.js +17 -13
  97. package/src/mcp/session-stdio-server.js +297 -19
  98. package/src/memory/blackboard.js +5 -4
  99. package/src/net/gh-api-path.js +35 -9
  100. package/src/net/test-egress-guard.js +16 -4
  101. package/src/plugin/manifest.js +12 -8
  102. package/src/review/ai-review.js +110 -10
  103. package/src/review/investor-dd-devtestbot.js +17 -6
  104. package/src/review/investor-dd-orchestrator.js +21 -13
  105. package/src/review/local-review.js +26 -16
  106. package/src/review/omargate-cache.js +5 -5
  107. package/src/review/omargate-orchestrator.js +89 -17
  108. package/src/review/replay.js +9 -4
  109. package/src/review/report.js +22 -13
  110. package/src/review/typesafe-severity.js +201 -0
  111. package/src/session/admission-auth.js +34 -7
  112. package/src/session/admission.js +322 -71
  113. package/src/session/agent-access.js +137 -0
  114. package/src/session/agent-registry.js +2 -1
  115. package/src/session/attachment-summary.js +77 -0
  116. package/src/session/edit-summary.js +19 -0
  117. package/src/session/invitations.js +65 -1
  118. package/src/session/mentions.js +17 -9
  119. package/src/session/message-edits.js +5 -4
  120. package/src/session/preview.js +10 -2
  121. package/src/session/recap.js +3 -2
  122. package/src/session/room-files.js +519 -0
  123. package/src/session/route-classes.js +22 -8
  124. package/src/session/setup-guides.js +4 -0
  125. package/src/session/standup.js +192 -0
  126. package/src/session/sync.js +229 -10
  127. package/src/session/tickets.js +41 -0
  128. package/src/session/transcript.js +2 -1
  129. package/src/session/wake/resolve-target.js +7 -3
  130. package/src/skills/listener-runtime.cjs +92 -0
  131. package/src/skills/service.js +207 -0
  132. package/src/swarm/factory.js +5 -5
  133. package/src/swarm/pentest.js +9 -9
  134. package/src/swarm/report.js +4 -4
  135. package/src/swarm/runtime.js +15 -12
  136. package/src/swarm/scenario-dsl.js +7 -5
  137. package/src/telemetry/ledger.js +16 -7
@@ -0,0 +1,692 @@
1
+ /**
2
+ * ENGRAM §EW04 — HTML/JSON-LD extraction with byte offsets.
3
+ *
4
+ * Pure extractor: takes bytes of an HTML page and returns structured facts
5
+ * (title, description, canonical, headings, links, JSON-LD) with byte-level
6
+ * evidence pointing back into the source. Unparseable JSON-LD becomes an
7
+ * `unverified` item, never a guess. All extracted items are labelled
8
+ * `epistemicType: "extracted"`, distinct from `"api"`.
9
+ *
10
+ * Works entirely in UTF-8 byte offsets — never uses JavaScript string indices.
11
+ *
12
+ * Library only. Node builtins only. No new dependency. No DOM, no JS execution.
13
+ */
14
+
15
+
16
+ /** Unicode-whitespace test — same as /\s/ — for whitespace collapse. */
17
+ const WS_RE = /\s/;
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Buffer search helpers (byte-level, not string-level)
21
+ // ---------------------------------------------------------------------------
22
+
23
+ function indexOf(buf, needle, start = 0) {
24
+ return buf.indexOf(needle, start);
25
+ }
26
+
27
+ function indexOfStr(buf, str, start = 0) {
28
+ return buf.indexOf(Buffer.from(str, "utf-8"), start);
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Lightweight HTML tag-aware scanner (byte-level)
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /**
36
+ * Find the first '>' outside of single- and double-quoted attribute values.
37
+ * Returns byte position of '>', or -1 if not found.
38
+ */
39
+ function findGtOutsideQuotes(bytes, start, limit) {
40
+ const bufLen = bytes.length;
41
+ const end = limit !== undefined && limit < bufLen ? limit : bufLen;
42
+ for (let i = start; i < end; i++) {
43
+ const b = bytes[i];
44
+ if (b === 0x22) { // double quote
45
+ for (i++; i < end; i++) {
46
+ if (bytes[i] === 0x22) break;
47
+ }
48
+ continue;
49
+ }
50
+ if (b === 0x27) { // single quote
51
+ for (i++; i < end; i++) {
52
+ if (bytes[i] === 0x27) break;
53
+ }
54
+ continue;
55
+ }
56
+ if (b === 0x3e) return i; // '>'
57
+ }
58
+ return -1;
59
+ }
60
+
61
+ /**
62
+ * Parse attributes from a tag's inner text into a Map of lowercased name -> value.
63
+ * Handles unquoted, single-quoted, and double-quoted values.
64
+ */
65
+ function parseAttrs(inner) {
66
+ const attrs = new Map();
67
+ let i = 0;
68
+ const len = inner.length;
69
+
70
+ while (i < len) {
71
+ // Skip whitespace
72
+ while (i < len && /\s/.test(inner[i])) i++;
73
+ if (i >= len) break;
74
+
75
+ // Find end of attribute name — scan until whitespace, '=', or end
76
+ let nameEnd = i;
77
+ while (nameEnd < len && !/\s/.test(inner[nameEnd]) && inner[nameEnd] !== '=') nameEnd++;
78
+ const name = inner.slice(i, nameEnd).toLowerCase();
79
+ i = nameEnd;
80
+
81
+ // Skip ASCII whitespace between name and possible '='
82
+ while (i < len && /\s/.test(inner[i])) i++;
83
+
84
+ if (i < len && inner[i] === '=') {
85
+ // '=' found: read the value
86
+ i++; // skip '='
87
+ // Skip whitespace after =
88
+ while (i < len && /\s/.test(inner[i])) i++;
89
+
90
+ if (i >= len) {
91
+ if (name.length > 0) { if (!attrs.has(name)) attrs.set(name, ''); }
92
+ break;
93
+ }
94
+
95
+ if (inner[i] === '"' || inner[i] === "'") {
96
+ const quote = inner[i];
97
+ i++;
98
+ let valStart = i;
99
+ while (i < len && inner[i] !== quote) {
100
+ i++;
101
+ }
102
+ const value = inner.slice(valStart, i);
103
+ if (name.length > 0) { if (!attrs.has(name)) attrs.set(name, value); }
104
+ if (i < len) i++; // skip closing quote
105
+ } else {
106
+ // Unquoted value
107
+ let valStart = i;
108
+ while (i < len && !/\s/.test(inner[i])) i++;
109
+ const value = inner.slice(valStart, i);
110
+ if (name.length > 0) { if (!attrs.has(name)) attrs.set(name, value); }
111
+ }
112
+ } else {
113
+ // No '=': valueless attribute
114
+ if (name.length > 0) { if (!attrs.has(name)) attrs.set(name, ''); }
115
+ }
116
+ }
117
+
118
+ return attrs;
119
+ }
120
+
121
+ /**
122
+ * Find the next HTML tag starting at or after `pos` (byte position).
123
+ * Returns { tagName, start, end, selfClosing, raw, attrs, comment, unclosed? } or null.
124
+ */
125
+ function nextTag(bytes, pos) {
126
+ const lt = indexOfStr(bytes, "<", pos);
127
+ if (lt === -1) return null;
128
+
129
+ // Check for <!DOCTYPE, <!--, <![CDATA[, <!> etc before scanning for >
130
+ if (lt + 1 < bytes.length) {
131
+ const afterLt = bytes[lt + 1];
132
+
133
+ // Comment: <!-- ... --> or <!--> (empty comment)
134
+ if (afterLt === 0x21 && lt + 4 <= bytes.length &&
135
+ bytes[lt + 2] === 0x2d && bytes[lt + 3] === 0x2d) { // !--
136
+ // Check for <!--> (empty comment)
137
+ if (lt + 5 <= bytes.length && bytes[lt + 4] === 0x3e) {
138
+ return { tagName: null, start: lt, end: lt + 5, selfClosing: false,
139
+ raw: "<!-->", attrs: new Map(), comment: true };
140
+ }
141
+ // Find -->
142
+ const commentEnd = indexOfStr(bytes, "-->", lt + 4);
143
+ if (commentEnd !== -1) {
144
+ const raw = bytes.subarray(lt, commentEnd + 3).toString("utf-8");
145
+ return { tagName: null, start: lt, end: commentEnd + 3, selfClosing: false,
146
+ raw, attrs: new Map(), comment: true };
147
+ }
148
+ // Unclosed comment: runs to end of input
149
+ const raw = bytes.subarray(lt).toString("utf-8");
150
+ return { tagName: null, start: lt, end: bytes.length, selfClosing: false,
151
+ raw, attrs: new Map(), comment: true, unclosed: true };
152
+ }
153
+
154
+ // <!> (empty comment per spec)
155
+ if (afterLt === 0x21 && lt + 2 < bytes.length && bytes[lt + 2] === 0x3e) {
156
+ return { tagName: null, start: lt, end: lt + 3, selfClosing: false,
157
+ raw: "<!>", attrs: new Map(), comment: true };
158
+ }
159
+
160
+ // DOCTYPE or other <!...>
161
+ if (afterLt === 0x21 || afterLt === 0x3f) { // ! or ?
162
+ const gt = indexOfStr(bytes, ">", lt + 2);
163
+ if (gt === -1) {
164
+ const raw = bytes.subarray(lt).toString("utf-8");
165
+ return { tagName: null, start: lt, end: bytes.length, selfClosing: false,
166
+ raw, attrs: new Map(), comment: true, unclosed: true };
167
+ }
168
+ const raw = bytes.subarray(lt, gt + 1).toString("utf-8");
169
+ return { tagName: null, start: lt, end: gt + 1, selfClosing: false,
170
+ raw, attrs: new Map(), comment: true };
171
+ }
172
+ }
173
+
174
+ // Regular tag: find '>' outside quotes
175
+ const gt = findGtOutsideQuotes(bytes, lt + 1);
176
+ if (gt === -1) return null;
177
+
178
+ const raw = bytes.subarray(lt, gt + 1).toString("utf-8");
179
+ const inner = raw.slice(1, -1).trim();
180
+
181
+ // Check for closing tag </name>
182
+ // Keep '/' prefix for closing tags to maintain compatibility
183
+ let tagName = null;
184
+ if (inner.startsWith("/")) {
185
+ const afterSlash = inner.slice(1).trim();
186
+ const spaceIdx = afterSlash.search(/\s/);
187
+ const name = (spaceIdx === -1 ? afterSlash : afterSlash.slice(0, spaceIdx)).toLowerCase();
188
+ tagName = "/" + name;
189
+ } else {
190
+ const spaceIdx = inner.search(/\s/);
191
+ if (spaceIdx === -1) {
192
+ tagName = inner.replace(/\/$/, "").toLowerCase();
193
+ } else {
194
+ tagName = inner.slice(0, spaceIdx).toLowerCase();
195
+ }
196
+ }
197
+
198
+ const attrs = parseAttrs(inner);
199
+
200
+ const selfClosing = raw.endsWith("/>") || ["br", "hr", "img", "input", "meta", "link", "area", "base", "col", "embed", "source", "track", "wbr"].includes(tagName);
201
+
202
+ return {
203
+ tagName,
204
+ start: lt,
205
+ end: gt + 1,
206
+ selfClosing,
207
+ raw,
208
+ attrs,
209
+ comment: false,
210
+ };
211
+ }
212
+
213
+ /**
214
+ * Extract an attribute value from a tag's parsed attributes Map.
215
+ * Exact lowercased key match only.
216
+ */
217
+ function attrValue(attrs, attrName) {
218
+ return attrs.get(attrName.toLowerCase()) ?? null;
219
+ }
220
+
221
+ /**
222
+ * Find closing tag case-insensitively at byte level.
223
+ * Handles </TAGNAME>, </tagname>, </Tagname>, etc.
224
+ * Returns byte position AFTER the closing tag, or -1 if not found.
225
+ */
226
+ function findCloseTagCi(bytes, tagName, startPos, missCache) {
227
+ const lowerTag = tagName.toLowerCase();
228
+ const bufLen = bytes.length;
229
+
230
+ // Memoised miss: if we already know this tag has no close from >= startPos, skip
231
+ if (missCache) {
232
+ const missFrom = missCache.get(lowerTag);
233
+ if (missFrom !== undefined && startPos >= missFrom) {
234
+ return -1;
235
+ }
236
+ }
237
+
238
+ for (let i = startPos; i <= bufLen - 3; i++) {
239
+ if (bytes[i] !== 0x3c) continue; // <
240
+ const next = bytes[i + 1];
241
+ if (next !== 0x2f && next !== 0x2F) continue; // /
242
+
243
+ // Try to match the tag name case-insensitively
244
+ const tagStart = i + 2;
245
+ if (tagStart + lowerTag.length > bufLen) continue;
246
+
247
+ let matched = true;
248
+ for (let j = 0; j < lowerTag.length; j++) {
249
+ const c = bytes[tagStart + j];
250
+ const expected = lowerTag.charCodeAt(j);
251
+ // Case-insensitive ASCII match
252
+ if ((c | 0x20) !== (expected | 0x20)) { matched = false; break; }
253
+ }
254
+ if (!matched) continue;
255
+
256
+ // Check for > after tag name (optionally with whitespace)
257
+ let after = tagStart + lowerTag.length;
258
+ while (after < bufLen && (bytes[after] === 0x20 || bytes[after] === 0x09 || bytes[after] === 0x0a || bytes[after] === 0x0d)) {
259
+ after++;
260
+ }
261
+ if (after < bufLen && bytes[after] === 0x3e) {
262
+ return after + 1; // past the >
263
+ }
264
+ }
265
+
266
+ // Record miss: any search from >= startPos for this tag will also miss
267
+ if (missCache) {
268
+ const prev = missCache.get(lowerTag);
269
+ if (prev === undefined || startPos < prev) {
270
+ missCache.set(lowerTag, startPos);
271
+ }
272
+ }
273
+ return -1;
274
+ }
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // Entity decoding — single left-to-right pass
278
+ // ---------------------------------------------------------------------------
279
+
280
+ const NAMED_ENTITIES = Object.assign(Object.create(null), {
281
+ amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: "\u00A0",
282
+ iexcl: "\u00A1", cent: "\u00A2", pound: "\u00A3", curren: "\u00A4",
283
+ yen: "\u00A5", brvbar: "\u00A6", sect: "\u00A7", uml: "\u00A8",
284
+ copy: "\u00A9", ordf: "\u00AA", laquo: "\u00AB", not: "\u00AC",
285
+ shy: "\u00AD", reg: "\u00AE", macr: "\u00AF", deg: "\u00B0",
286
+ plusmn: "\u00B1", sup2: "\u00B2", sup3: "\u00B3", acute: "\u00B4",
287
+ micro: "\u00B5", para: "\u00B6", middot: "\u00B7", cedil: "\u00B8",
288
+ sup1: "\u00B9", ordm: "\u00BA", raquo: "\u00BB", frac14: "\u00BC",
289
+ frac12: "\u00BD", frac34: "\u00BE", iquest: "\u00BF",
290
+ times: "\u00D7", divide: "\u00F7",
291
+ Agrave: "\u00C0", Aacute: "\u00C1", Acirc: "\u00C2", Atilde: "\u00C3",
292
+ Auml: "\u00C4", Aring: "\u00C5", AElig: "\u00C6", Ccedil: "\u00C7",
293
+ Egrave: "\u00C8", Eacute: "\u00C9", Ecirc: "\u00CA", Euml: "\u00CB",
294
+ Igrave: "\u00CC", Iacute: "\u00CD", Icirc: "\u00CE", Iuml: "\u00CF",
295
+ ETH: "\u00D0", Ntilde: "\u00D1", Ograve: "\u00D2", Oacute: "\u00D3",
296
+ Ocirc: "\u00D4", Otilde: "\u00D5", Ouml: "\u00D6", Oslash: "\u00D8",
297
+ Ugrave: "\u00D9", Uacute: "\u00DA", Ucirc: "\u00DB", Uuml: "\u00DC",
298
+ Yacute: "\u00DD", THORN: "\u00DE", szlig: "\u00DF",
299
+ agrave: "\u00E0", aacute: "\u00E1", acirc: "\u00E2", atilde: "\u00E3",
300
+ auml: "\u00E4", aring: "\u00E5", aelig: "\u00E6", ccedil: "\u00E7",
301
+ egrave: "\u00E8", eacute: "\u00E9", ecirc: "\u00EA", euml: "\u00EB",
302
+ igrave: "\u00EC", iacute: "\u00ED", icirc: "\u00EE", iuml: "\u00EF",
303
+ eth: "\u00F0", ntilde: "\u00F1", ograve: "\u00F2", oacute: "\u00F3",
304
+ ocirc: "\u00F4", otilde: "\u00F5", ouml: "\u00F6", oslash: "\u00F8",
305
+ ugrave: "\u00F9", uacute: "\u00FA", ucirc: "\u00FB", uuml: "\u00FC",
306
+ yacute: "\u00FD", thorn: "\u00FE", yuml: "\u00FF",
307
+ OElig: "\u0152", oelig: "\u0153", Scaron: "\u0160", scaron: "\u0161",
308
+ Yuml: "\u0178", fnof: "\u0192", circ: "\u02C6", tilde: "\u02DC",
309
+ Alpha: "\u0391", Beta: "\u0392", Gamma: "\u0393", Delta: "\u0394",
310
+ Epsilon: "\u0395", Zeta: "\u0396", Eta: "\u0397", Theta: "\u0398",
311
+ Iota: "\u0399", Kappa: "\u039A", Lambda: "\u039B", Mu: "\u039C",
312
+ Nu: "\u039D", Xi: "\u039E", Omicron: "\u039F", Pi: "\u03A0",
313
+ Rho: "\u03A1", Sigma: "\u03A3", Tau: "\u03A4", Upsilon: "\u03A5",
314
+ Phi: "\u03A6", Chi: "\u03A7", Psi: "\u03A8", Omega: "\u03A9",
315
+ alpha: "\u03B1", beta: "\u03B2", gamma: "\u03B3", delta: "\u03B4",
316
+ epsilon: "\u03B5", zeta: "\u03B6", eta: "\u03B7", theta: "\u03B8",
317
+ iota: "\u03B9", kappa: "\u03BA", lambda: "\u03BB", mu: "\u03BC",
318
+ nu: "\u03BD", xi: "\u03BE", omicron: "\u03BF", pi: "\u03C0",
319
+ rho: "\u03C1", sigmaf: "\u03C2", sigma: "\u03C3", tau: "\u03C4",
320
+ upsilon: "\u03C5", phi: "\u03C6", chi: "\u03C7", psi: "\u03C8",
321
+ omega: "\u03C9", thetasym: "\u03D1", upsih: "\u03D2", piv: "\u03D6",
322
+ ensp: "\u2002", emsp: "\u2003", thinsp: "\u2009", zwnj: "\u200C",
323
+ zwj: "\u200D", lrm: "\u200E", rlm: "\u200F", ndash: "\u2013",
324
+ mdash: "\u2014", lsquo: "\u2018", rsquo: "\u2019", sbquo: "\u201A",
325
+ ldquo: "\u201C", rdquo: "\u201D", bdquo: "\u201E", dagger: "\u2020",
326
+ Dagger: "\u2021", bull: "\u2022", hellip: "\u2026", permil: "\u2030",
327
+ prime: "\u2032", Prime: "\u2033", lsaquo: "\u2039", rsaquo: "\u203A",
328
+ oline: "\u203E", frasl: "\u2044", euro: "\u20AC", image: "\u2111",
329
+ weierp: "\u2118", real: "\u211C", trade: "\u2122", alefsym: "\u2135",
330
+ larr: "\u2190", uarr: "\u2191", rarr: "\u2192", darr: "\u2193",
331
+ harr: "\u2194", crarr: "\u21B5", lArr: "\u21D0", uArr: "\u21D1",
332
+ rArr: "\u21D2", dArr: "\u21D3", hArr: "\u21D4", forall: "\u2200",
333
+ part: "\u2202", exist: "\u2203", empty: "\u2205", nabla: "\u2207",
334
+ isin: "\u2208", notin: "\u2209", ni: "\u220B", prod: "\u220F",
335
+ sum: "\u2211", minus: "\u2212", lowast: "\u2217", radic: "\u221A",
336
+ prop: "\u221D", infin: "\u221E", ang: "\u2220", and: "\u2227",
337
+ or: "\u2228", cap: "\u2229", cup: "\u222A", int: "\u222B",
338
+ there4: "\u2234", sim: "\u223C", cong: "\u2245", asymp: "\u2248",
339
+ ne: "\u2260", equiv: "\u2261", le: "\u2264", ge: "\u2265",
340
+ sub: "\u2282", sup: "\u2283", nsub: "\u2284", sube: "\u2286",
341
+ supe: "\u2287", oplus: "\u2295", otimes: "\u2297", perp: "\u22A5",
342
+ sdot: "\u22C5", lceil: "\u2308", rceil: "\u2309", lfloor: "\u230A",
343
+ rfloor: "\u230B", lang: "\u2329", rang: "\u232A", loz: "\u25CA",
344
+ spades: "\u2660", clubs: "\u2663", hearts: "\u2665", diams: "\u2666",
345
+ });
346
+
347
+ /**
348
+ * Decode HTML entities in a single left-to-right pass.
349
+ * - Named entities: decode if known, stay literal if unknown.
350
+ * - Numeric entities: decode if valid (1..0x10FFFF, non-surrogate), else U+FFFD.
351
+ * - Never throws. One pass only (no double-decoding).
352
+ */
353
+ function decodeEntities(str) {
354
+ let out = '';
355
+ let i = 0;
356
+ const len = str.length;
357
+
358
+ while (i < len) {
359
+ if (str[i] === '&') {
360
+ // Look for ';'
361
+ let semiIdx = -1;
362
+ for (let j = i + 1; j < len && j < i + 32; j++) {
363
+ if (str[j] === ';') { semiIdx = j; break; }
364
+ }
365
+
366
+ if (semiIdx === -1) {
367
+ // No semicolon: not an entity, emit & and continue
368
+ out += '&';
369
+ i++;
370
+ continue;
371
+ }
372
+
373
+ const entity = str.slice(i + 1, semiIdx);
374
+
375
+ if (entity.length === 0) {
376
+ out += '&;';
377
+ i = semiIdx + 1;
378
+ continue;
379
+ }
380
+
381
+ // Numeric entity
382
+ if (entity[0] === '#') {
383
+ const rest = entity.slice(1);
384
+ let codepoint;
385
+ if (rest.length > 0 && (rest[0] === 'x' || rest[0] === 'X')) {
386
+ codepoint = parseInt(rest.slice(1), 16);
387
+ } else {
388
+ codepoint = parseInt(rest, 10);
389
+ }
390
+
391
+ if (Number.isNaN(codepoint) || codepoint < 1 || codepoint > 0x10FFFF ||
392
+ (codepoint >= 0xD800 && codepoint <= 0xDFFF)) {
393
+ out += '\uFFFD';
394
+ } else {
395
+ out += String.fromCodePoint(codepoint);
396
+ }
397
+ i = semiIdx + 1;
398
+ continue;
399
+ }
400
+
401
+ // Named entity
402
+ const lowerEntity = entity.toLowerCase();
403
+ const decoded = NAMED_ENTITIES[entity] ?? NAMED_ENTITIES[lowerEntity];
404
+ if (decoded !== undefined) {
405
+ out += decoded;
406
+ } else {
407
+ // Unknown named entity: stay literal
408
+ out += '&' + entity + ';';
409
+ }
410
+ i = semiIdx + 1;
411
+ continue;
412
+ }
413
+
414
+ out += str[i];
415
+ i++;
416
+ }
417
+
418
+ return out;
419
+ }
420
+
421
+ // ---------------------------------------------------------------------------
422
+ // Element categories per HTML spec
423
+ // ---------------------------------------------------------------------------
424
+
425
+ const RAW_TEXT_ELEMENTS = new Set([
426
+ "script", "style", "xmp", "iframe", "noembed", "noframes",
427
+ ]);
428
+
429
+ const RCDATA_ELEMENTS = new Set([
430
+ "title", "textarea",
431
+ ]);
432
+
433
+ // ---------------------------------------------------------------------------
434
+ // Text extraction between two byte positions
435
+ // ---------------------------------------------------------------------------
436
+
437
+ function textBetween(bytes, start, end) {
438
+ // Walk bytes directly with one forward cursor p from start to end.
439
+ // On 0x3C ('<'): skip the tag (findGtOutsideQuotes bounded by end).
440
+ // Each text run between tags is decoded ONCE via bytes.toString('utf-8', runStart, runEnd).
441
+ // 0x3C never occurs inside a UTF-8 multi-byte sequence, so scanning bytes is safe.
442
+ let content = "";
443
+ let lastWasSpace = false;
444
+ let p = start;
445
+
446
+ while (p < end) {
447
+ const b = bytes[p];
448
+ if (b === 0x3C) { // '<' - start of a tag
449
+ const gt = findGtOutsideQuotes(bytes, p + 1, end);
450
+ if (gt === -1) break; // unclosed tag: drop rest of span
451
+ p = gt + 1; // skip past '>'
452
+ continue;
453
+ }
454
+ // Text byte — accumulate until next '<' or end
455
+ const runStart = p;
456
+ while (p < end && bytes[p] !== 0x3C) p++;
457
+ // Decode this text run once
458
+ const runStr = bytes.toString('utf-8', runStart, p);
459
+ for (let i = 0; i < runStr.length; i++) {
460
+ const ch = runStr[i];
461
+ if (WS_RE.test(ch)) {
462
+ if (!lastWasSpace && content.length > 0) {
463
+ content += " ";
464
+ lastWasSpace = true;
465
+ }
466
+ continue;
467
+ }
468
+ content += ch;
469
+ lastWasSpace = false;
470
+ }
471
+ }
472
+
473
+ return decodeEntities(content.trim());
474
+ }
475
+ // ---------------------------------------------------------------------------
476
+ // Main extractor
477
+ // ---------------------------------------------------------------------------
478
+
479
+ /**
480
+ * extractFromHtml(bytes: Buffer) → extracted page facts with byte-level evidence.
481
+ *
482
+ * Never throws on any input. Returns an object with:
483
+ * title, description, canonical, headings[], links[], jsonLd[]
484
+ */
485
+ export function extractFromHtml(bytes) {
486
+ try {
487
+ return extractFromHtmlImpl(bytes);
488
+ } catch {
489
+ return emptyResult();
490
+ }
491
+ }
492
+
493
+ function extractFromHtmlImpl(bytes) {
494
+ const missCache = new Map();
495
+ const result = {
496
+ title: null,
497
+ description: null,
498
+ canonical: null,
499
+ headings: [],
500
+ links: [],
501
+ jsonLd: [],
502
+ };
503
+
504
+ let headTitle = null;
505
+ let fallbackTitle = null;
506
+ let inHead = false;
507
+ let inSvg = false;
508
+ let inMath = false;
509
+ let pos = 0;
510
+
511
+ while (pos < bytes.length) {
512
+ const tag = nextTag(bytes, pos);
513
+ if (!tag) break;
514
+
515
+ // Skip comments
516
+ if (tag.comment) {
517
+ pos = tag.end;
518
+ continue;
519
+ }
520
+
521
+ const tagName = tag.tagName;
522
+ if (!tagName) { pos = tag.end; continue; }
523
+
524
+ // Track head / svg / math context
525
+ if (tagName === "head") { inHead = true; pos = tag.end; continue; }
526
+ if (tagName === "/head") { inHead = false; pos = tag.end; continue; }
527
+ if (tagName === "svg") { inSvg = true; pos = tag.end; continue; }
528
+ if (tagName === "/svg") { inSvg = false; pos = tag.end; continue; }
529
+ if (tagName === "math") { inMath = true; pos = tag.end; continue; }
530
+ if (tagName === "/math") { inMath = false; pos = tag.end; continue; }
531
+
532
+ // --- Structural skip for raw-text and RCDATA elements ---
533
+ if (RAW_TEXT_ELEMENTS.has(tagName)) {
534
+ const closeIdx = findCloseTagCi(bytes, tagName, tag.end, missCache);
535
+
536
+ if (tagName === "script") {
537
+ const typeAttr = attrValue(tag.attrs, "type");
538
+ if (typeAttr === "application/ld+json") {
539
+ // JSON-LD script: extract content between tags
540
+ let contentEnd = closeIdx;
541
+ if (contentEnd === -1) {
542
+ contentEnd = bytes.length;
543
+ } else {
544
+ for (let ci = contentEnd - 1; ci >= tag.end; ci--) {
545
+ if (bytes[ci] === 0x3c) { contentEnd = ci; break; }
546
+ }
547
+ }
548
+ const jsonText = bytes.toString("utf-8", tag.end, contentEnd).trim();
549
+ const evidenceEnd = closeIdx === -1 ? bytes.length : closeIdx;
550
+ const evidence = { start: tag.start, end: evidenceEnd };
551
+ let data = null;
552
+ let verified = false;
553
+ try {
554
+ data = JSON.parse(jsonText);
555
+ verified = true;
556
+ } catch {
557
+ // unparseable: store as unverified
558
+ }
559
+ result.jsonLd.push({
560
+ data,
561
+ raw: jsonText,
562
+ verified,
563
+ evidence,
564
+ });
565
+ }
566
+ }
567
+
568
+ if (closeIdx !== -1) {
569
+ pos = closeIdx;
570
+ } else {
571
+ pos = bytes.length;
572
+ }
573
+ continue;
574
+ }
575
+
576
+ if (RCDATA_ELEMENTS.has(tagName)) {
577
+ const closeIdx = findCloseTagCi(bytes, tagName, tag.end, missCache);
578
+ let contentEnd = closeIdx;
579
+ if (contentEnd === -1) {
580
+ contentEnd = bytes.length;
581
+ } else {
582
+ for (let ci = contentEnd - 1; ci >= tag.end; ci--) {
583
+ if (bytes[ci] === 0x3c) { contentEnd = ci; break; }
584
+ }
585
+ }
586
+
587
+ if (tagName === "title") {
588
+ if (!inSvg && !inMath) {
589
+ const rawTitleBytes = bytes.subarray(tag.end, contentEnd);
590
+ const titleText = decodeEntities(rawTitleBytes.toString("utf-8").trim());
591
+ if (inHead) {
592
+ headTitle = titleText;
593
+ } else if (fallbackTitle === null) {
594
+ fallbackTitle = titleText;
595
+ }
596
+ }
597
+ }
598
+
599
+ if (closeIdx !== -1) {
600
+ pos = closeIdx;
601
+ } else {
602
+ pos = bytes.length;
603
+ }
604
+ continue;
605
+ }
606
+ // --- End structural skip ---
607
+
608
+ if (tagName === "meta") {
609
+ const name = attrValue(tag.attrs, "name") || attrValue(tag.attrs, "property");
610
+ const content = attrValue(tag.attrs, "content");
611
+ if (name === "description" && content) {
612
+ result.description = decodeEntities(content);
613
+ }
614
+ pos = tag.end;
615
+ continue;
616
+ }
617
+
618
+ if (tagName === "link") {
619
+ const rel = attrValue(tag.attrs, "rel");
620
+ if (rel === "canonical") {
621
+ const href = attrValue(tag.attrs, "href");
622
+ if (href) result.canonical = href;
623
+ }
624
+ pos = tag.end;
625
+ continue;
626
+ }
627
+
628
+ if (/^h[1-6]$/.test(tagName)) {
629
+ const level = parseInt(tagName[1], 10);
630
+ const closeEnd = findCloseTagCi(bytes, tagName, tag.end, missCache);
631
+ if (closeEnd !== -1) {
632
+ let contentEnd = closeEnd;
633
+ for (let ci = closeEnd - 1; ci >= tag.end; ci--) {
634
+ if (bytes[ci] === 0x3c) { contentEnd = ci; break; }
635
+ }
636
+ const headingText = textBetween(bytes, tag.end, contentEnd);
637
+ result.headings.push({
638
+ level,
639
+ text: headingText,
640
+ evidence: { start: tag.start, end: closeEnd },
641
+ });
642
+ pos = closeEnd;
643
+ } else {
644
+ pos = tag.end;
645
+ }
646
+ continue;
647
+ }
648
+
649
+ if (tagName === "a") {
650
+ const href = attrValue(tag.attrs, "href");
651
+ const rel = attrValue(tag.attrs, "rel");
652
+ if (!href) {
653
+ pos = tag.end;
654
+ continue;
655
+ }
656
+ const closeEnd = findCloseTagCi(bytes, "a", tag.end, missCache);
657
+ if (closeEnd === -1) {
658
+ pos = tag.end;
659
+ continue;
660
+ }
661
+ let contentEnd = closeEnd;
662
+ for (let ci = closeEnd - 1; ci >= tag.end; ci--) {
663
+ if (bytes[ci] === 0x3c) { contentEnd = ci; break; }
664
+ }
665
+ const linkText = textBetween(bytes, tag.end, contentEnd);
666
+ result.links.push({
667
+ href,
668
+ rel,
669
+ text: linkText,
670
+ evidence: { start: tag.start, end: closeEnd },
671
+ });
672
+ pos = closeEnd;
673
+ continue;
674
+ }
675
+
676
+ pos = tag.end;
677
+ }
678
+
679
+ result.title = headTitle ?? fallbackTitle;
680
+ return result;
681
+ }
682
+
683
+ function emptyResult() {
684
+ return {
685
+ title: null,
686
+ description: null,
687
+ canonical: null,
688
+ headings: [],
689
+ links: [],
690
+ jsonLd: [],
691
+ };
692
+ }